Source code for qtypy.widgets.view

#!/usr/bin/env python3
#-*- coding: ISO-8859-1 -*-

import logging
import gettext
import functools

from PyQt5 import QtCore, QtWidgets

from qtypy.model import _ColumnedTreeModel, PythonTreeModel
from qtypy.layout import LayoutBuilder

try:
    _ = gettext.translation('qtypy').gettext
except FileNotFoundError:
    _ = lambda msgid: msgid


class LayoutWrapper(QtWidgets.QVBoxLayout):
    def setGeometry(self, rc):
        super().setGeometry(rc)
        self.parentWidget().maybeUpdate()


class WidgetWrapper(QtWidgets.QWidget):
    def __init__(self, widget, parent, delegate, index):
        super().__init__(parent)

        layout = LayoutWrapper(self)
        layout.addWidget(widget)
        layout.setContentsMargins(0, 0, 0, 0)
        self.setLayout(layout)

        self._widget = widget
        self._delegate = delegate
        self._prevSize = QtCore.QSize()
        self._index = index

    def setGeometry(self, index, rect):
        self._index = index
        super().setGeometry(rect)
        self.maybeUpdate()

    def maybeUpdate(self):
        size = super().sizeHint()
        size.setWidth(self.width())
        if self._widget.hasHeightForWidth():
            size.setHeight(self._widget.heightForWidth(size.width()))
        if size.height() != self._prevSize.height():
            self._prevSize = size
            self._delegate.sizeHintChanged.emit(self._index)

    def sizeHint(self):
        return self._prevSize


class WidgetColumnDelegate(QtWidgets.QStyledItemDelegate):
    def __init__(self, column, parent):
        self.column = column
        super().__init__(parent)

    def createEditor(self, parent, options, index):
        item = index.data(QtCore.Qt.UserRole)
        widget = self.column.widgetFactory(item, parent)
        widget.setFocusPolicy(QtCore.Qt.StrongFocus)
        return WidgetWrapper(widget, parent, self, index)

    def updateEditorGeometry(self, editor, options, index):
        editor.setGeometry(index, options.rect)


[docs]class ColumnedView(QtWidgets.QWidget): """ A multi-column view for a :class:`PythonTreeModel` or :class:`PythonListModel`. """ logger = logging.getLogger('qtypy.ColumnedView') NoEditTriggers = QtWidgets.QTreeView.NoEditTriggers CurrentChanged = QtWidgets.QTreeView.CurrentChanged DoubleClicked = QtWidgets.QTreeView.DoubleClicked SelectedClicked = QtWidgets.QTreeView.SelectedClicked EditKeyPressed = QtWidgets.QTreeView.EditKeyPressed AnyKeyPressed = QtWidgets.QTreeView.AnyKeyPressed AllEditTriggers = QtWidgets.QTreeView.AllEditTriggers BTN_ADD = 1 #: Standard Add button BTN_DEL = 2 #: Standard Remove button selectionChanged = QtCore.pyqtSignal() #: Signal emitted when the selection changes addButtonClicked = QtCore.pyqtSignal(set) #: Signal emitted when the "Add" button is clicked; current selection is passed as argument. delButtonClicked = QtCore.pyqtSignal(set) #: Signal emitted when the "Remove" button is clicked; current selection is passed as argument. def __init__(self, model, parent=None): super().__init__(parent) builder = LayoutBuilder(self) with builder.vbox() as layout: layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(4) self._view = QtWidgets.QTreeView(self) self._model = model cmodel = _ColumnedTreeModel(model) if isinstance(model, PythonTreeModel) else _ColumnedTreeModel(PythonTreeModel(model=model)) cmodel.resizeModeChanged.connect(self._setResizeMode) cmodel.visibilityChanged.connect(self._setVisibility) cmodel.headerDataChanged.connect(self._checkColumnNames) self._view.setModel(cmodel) layout.addWidget(self._view) with builder.hbox() as self._buttons_layout: self._buttons = {} self._view.header().setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self._view.header().customContextMenuRequested.connect(self._contextMenu) self._view.selectionModel().selectionChanged.connect(self._checkButtons) self._view.selectionModel().selectionChanged.connect(lambda s, d: self.selectionChanged.emit()) cmodel.rowsInserted.connect(self._rowsInserted)
[docs] def selection(self): """ Returns the set of selected model objects """ return self._selection()
[docs] def model(self): """ Returns the PythonListModel of root elements """ return self._view.model().sourceModel().root()
[docs] def itemContainer(self, item): """ Returns the PythonListModel that contains `item` """ return self._view.model().sourceModel().itemContainer(item)
[docs] def expandAll(self, root=None): """ Expands all items """ if root is None: root = QtCore.QModelIndex() for i in range(self._view.model().rowCount(root)): index = self._view.model().index(i, 0, root) self._view.setExpanded(index, True) self.expandAll(index)
def setAlternatingRowColors(self, alt): self._view.setAlternatingRowColors(alt)
[docs] def setStandardButtons(self, buttons): """ Show standard buttons. The argument is an ORed combination of BTN_* values. The order and position of buttons will depend on the current platform's HIG. """ for idx in range(self._buttons_layout.count()): self._buttons_layout.takeAt(idx) self._buttons = {} # XXXTODO follow platform HIG if buttons & self.BTN_ADD: btn = QtWidgets.QPushButton(_('Add'), self) self._buttons_layout.addWidget(btn) self._buttons[self.BTN_ADD] = btn btn.clicked.connect(lambda: self.addButtonClicked.emit(self._selection())) if buttons & self.BTN_DEL: btn = QtWidgets.QPushButton(_('Remove'), self) self._buttons_layout.addWidget(btn) self._buttons[self.BTN_DEL] = btn btn.clicked.connect(lambda: self.delButtonClicked.emit(self._selection())) self._buttons_layout.addStretch(1) self._checkButtons()
[docs] def standardButton(self, type_): """ Returns the specified standard button """ return self._buttons[type_]
[docs] def addButtonEnabled(self, selection): # pylint: disable=W0613,R0201 """ Called to check if the 'Add' button should be enabled; return a boolean. The argument is a set of currently selected items (model objects). The default returns True. """ return True
[docs] def delButtonEnabled(self, selection): # pylint: disable=R0201 """ Called to check if the 'Remove' button should be enabled; return a boolean. The argument is a set of currently selected items (model objects). The default returns True if the selection is not empty. """ return len(selection) != 0
[docs] def saveState(self): """ Returns a bytes object encapsulating the current state (column visibility, order, etc). You can save this in your settings and use it later to restore the state using `restoreState`. """ return self._view.header().saveState()
[docs] def restoreState(self, state): """ Restore the state saved through `saveState`. If the versions do not match, nothing is done. Returns True if the state was restored. """ if self._view.header().restoreState(state): for idx, column in enumerate(self._view.model().columns()): self._view.model().setColumnVisible(column, not self._view.header().isSectionHidden(idx)) return True return False
[docs] def addColumn(self, column, visible=True): """ Append a column (instance of a :class:`Column` subclass). Returns the column instance, for chaining. """ index = self._view.model().addColumn(column, visible=visible) if isinstance(column, WidgetColumn): self._view.setItemDelegateForColumn(index, WidgetColumnDelegate(column, self)) for col, column in enumerate(self._view.model().columns()): if isinstance(column, WidgetColumn): self._visitTree(self._view.rootIndex(), functools.partial(self._openEditors, col, column)) self._checkColumnNames() return column
[docs] def createContextMenu(self): """ This is called when then user right-clicks the view header. Return None for no context menu. You can use `populateContextMenu` to add actions to show/hide columns. """
[docs] def populateContextMenu(self, menu): """ This appends to the menu as many checkable actions as there are columns, to show or hide them. """ # Populate in visual order columns = list(self._view.model().columns()) for idx in range(self._view.header().count()): column = columns[self._view.header().logicalIndex(idx)] action = menu.addAction(column.name()) action.setCheckable(True) action.setChecked(self._view.model().isColumnVisible(column)) action.triggered.connect(column.setVisible)
[docs] def setEditTriggers(self, triggers): """ Sets edit triggers for the underlying tree view """ self._view.setEditTriggers(triggers)
def _contextMenu(self, pos): menu = self.createContextMenu() if menu is not None: menu.popup(self.mapToGlobal(pos)) def _setResizeMode(self, index, mode): self._view.header().setStretchLastSection(False) if mode == -1: self._view.header().setSectionResizeMode(index, QtWidgets.QHeaderView.Interactive) elif mode == -2: self._view.header().setSectionResizeMode(index, QtWidgets.QHeaderView.Stretch) elif mode == -3: self._view.header().setSectionResizeMode(index, QtWidgets.QHeaderView.ResizeToContents) elif mode > 0: self._view.header().setSectionResizeMode(index, QtWidgets.QHeaderView.Fixed) self._view.header().resizeSection(index, mode) def _setVisibility(self, index, visible): if visible: self._view.showColumn(index) else: self._view.hideColumn(index) self._checkColumnNames() def _rowsInserted(self, parentIndex, first, last): for col, column in enumerate(self._view.model().columns()): if isinstance(column, WidgetColumn): self._visitTree(parentIndex, functools.partial(self._openEditors, col, column), first=first, last=last) def _openEditors(self, col, column, parentIndex, row): if column.appliesToChildrenOf(parentIndex.data(QtCore.Qt.UserRole)): index = self._view.model().index(row, col, parentIndex) self._view.openPersistentEditor(index) def _visitTree(self, parentIndex, callback, first=None, last=None): for row in range(self._view.model().rowCount(parentIndex)): if first is not None and row < first: continue if last is not None and row > last: break callback(parentIndex, row) index = self._view.model().index(row, 0, parentIndex) self._visitTree(index, callback) def _checkColumnNames(self, *args): # pylint: disable=W0613 for column in self._view.model().visibleColumns(): if column.name() is not None: self._view.setHeaderHidden(False) return self._view.setHeaderHidden(True) def _selection(self): selection = set() for index in self._view.selectionModel().selectedIndexes(): if index.column() == 0: item = self._view.model().data(index, QtCore.Qt.UserRole) selection.add(item) return selection def _checkButtons(self, *args, **kwargs): # pylint: disable=W0613 for btn_type, btn in self._buttons.items(): if btn_type == self.BTN_ADD: btn.setEnabled(self.addButtonEnabled(self._selection())) if btn_type == self.BTN_DEL: btn.setEnabled(self.delButtonEnabled(self._selection()))
[docs]class Column(QtCore.QObject): """ Represents a column in a tree view. When you want to display data from a :class:`PythonTreeModel`, use a :class:`ColumnedView` and add instances of subclasses of this class to define columns. Various mixins are available for common behavior (checkable, editable, etc). Constant values for `setResizeMode`: :cvar Interactive: Column is user-resizable :cvar Stretch: Column takes all available space :cvar Contents: Column is resized according to contents """ #: This signal should be emitted if the column name changes nameChanged = QtCore.pyqtSignal() Interactive = -1 Stretch = -2 Contents = -3 def __init__(self): self.model = None super().__init__()
[docs] def id(self): """ Return a persistent string identifier for this column; this is used by saveState/restoreState in ColumnedView. """ raise NotImplementedError
[docs] def name(self): """Return the column's name, or None."""
[docs] def appliesToChildrenOf(self, parent): # pylint: disable=W0613,R0201 """ Return True if this column applies to children of `parent` """ return True
[docs] def setVisible(self, visible): """ Sets the current column visibility. This must be called *after* the column has been added to a view. """ self.model.setColumnVisible(self, visible)
[docs] def setResizeMode(self, mode): """ Sets the resize mode. Possible values are either class attributes Interactive, Stretch or Contents, or an integer for a fixed size. .. note:: by default all columns are user-resizable, except for the last one which is stretched. """ self.model.setColumnResizeMode(self, mode)
[docs] def show(self): # pragma: no cover """Short for setVisible(True)""" self.setVisible(True)
[docs] def hide(self): # pragma: no cover """Short for setVisible(False)""" self.setVisible(False)
[docs] def labelForItem(self, item): # pylint: disable=R0201 """ Returns the text for this column for the given item. The default is to cast the item to `str`. """ return str(item) # pragma: no cover
[docs] def flags(self): # pylint: disable=R0201 """ Called when the model needs to know flags associated with this column. """ return QtCore.Qt.NoItemFlags
[docs] def data(self, item, role): """ Called when the model needs the item's data for this column. Mixins override this and provide specific methods (like `checkState()` in :class:`CheckableColumnMixin`). """ if role == QtCore.Qt.DisplayRole: return self.labelForItem(item) return None # pragma: no cover
[docs] def setData(self, item, role, value): # pylint: disable=W0613,R0201 """ Called when the item's data for this column has been changed by the user and must be updated. Mixins override this and provide specific methods (like `setCheckState()` in :class:`CheckableColumnMixin`). """ return False
[docs]class CheckableColumnMixin: """ Mixin to make a column checkable. """ def flags(self): return super().flags() | QtCore.Qt.ItemIsUserCheckable def data(self, item, role): if role == QtCore.Qt.CheckStateRole: return self.checkState(item) return super().data(item, role) def setData(self, item, role, value): if role == QtCore.Qt.CheckStateRole: self.setCheckState(item, value) return True return super().setData(item, role, value)
[docs] def checkState(self, item): """Override to return the check state for the item""" raise NotImplementedError
[docs] def setCheckState(self, item, state): """Override to update the item state""" raise NotImplementedError
[docs]class EditableColumnMixin: """Mixin to make a column editable with the default editor.""" def labelForItem(self, item): return self.value(item) def flags(self): return super().flags() | QtCore.Qt.ItemIsEditable def data(self, item, role): if role == QtCore.Qt.EditRole: return self.value(item) return super().data(item, role) def setData(self, item, role, value): if role == QtCore.Qt.EditRole: self.setValue(item, value) return True return super().setData(item, role, value)
[docs] def value(self, item): """Override to return the item's edit value. The default is the item's label.""" raise NotImplementedError
[docs] def setValue(self, item, value): """Override to update the item's edit value.""" raise NotImplementedError
[docs]class EditableTextColumn(EditableColumnMixin, Column): # pylint: disable=W0223 """ Concrete column class to display an editable text attribute """ def __init__(self, column_name, attr_name): """ :param str column_name: The column name :param str attr_name: The attribute to lookup for as object text value """ self._column_name = column_name self._attr_name = attr_name super().__init__()
[docs] def name(self): return self._column_name
[docs] def value(self, item): return str(getattr(item, self._attr_name))
[docs] def setValue(self, item, value): setattr(item, self._attr_name, value)
[docs]class CheckColumn(CheckableColumnMixin, Column): # pylint: disable=W0223 """ Concrete column class to display a checkbox """ def __init__(self, column_name, attr_name): """ :param str column_name: The column name :param str attr_name: The attribute to lookup for as object boolean value """ self._column_name = column_name self._attr_name = attr_name super().__init__()
[docs] def name(self): return self._column_name
[docs] def labelForItem(self, item): return None
[docs] def checkState(self, item): return QtCore.Qt.Checked if getattr(item, self._attr_name) else QtCore.Qt.Unchecked
[docs] def setCheckState(self, item, state): setattr(item, self._attr_name, state == QtCore.Qt.Checked)
[docs]class WidgetColumn(Column): """ A column that display a widget for each row. """ def __init__(self, column_name): self._column_name = column_name super().__init__()
[docs] def name(self): return self._column_name
[docs] def labelForItem(self, item): return None
[docs] def widgetFactory(self, item, parent): """ Widget factory. This is what you should overload. :param item: The item :param parent: Parent for the new widget """ raise NotImplementedError