#!/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 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)