class TreeView(AbstractView, QtGui.QTreeView): """The TreeView view shows a list of model item rows, which can be nested under each other.""" AUTO_EXPAND_DELAY = 250 # milliseconds itemDoubleClicked = QtCore.Signal(list) allExpanded = QtCore.Signal() allCollapsed = QtCore.Signal() # MENU_HANDLER_CLASS = TreeMenuHandler expandOnLoad = property(lambda self: self.__expandOnLoad) def __init__(self, parent=None): """ """ QtGui.QTreeView.__init__(self, parent) AbstractView.__init__(self) self.addMenuHandler(TreeMenuHandler(self, parent=self)) self.addMenuHandler(StandardMenuHandler(self, parent=self)) # use custom delegate self.setItemDelegate(TreeDelegate(self)) # properties self.__refreshOnNextCtrlRelease = False self.__showGrid = False # timer for auto-expanding branches on drag/drop self.__openTimer = QtCore.QTimer(self) self.__openTimer.timeout.connect(self.__doAutoExpand) # set default values for properties self.setAutoExpandDelay(self.AUTO_EXPAND_DELAY) # persistent expansion state using model's uniqueId self.__expandOnLoad = 0 self.__expandAllIsExpanded = False # cache of the number of things in model, -1 means we haven't # set this yet, see setModel self.modelrows = -1 def setModel(self, model): """ """ # disconnect any existing model oldModel = self.model() if oldModel: oldModel.modelReset.disconnect(self.applyExpandOnLoad) QtGui.QTreeView.setModel(self, model) AbstractView.setModel(self, model) # tree-specific model setup newModel = self.model() # update the number of rows we think the model has because the # model changed, the can be updated in expandAll() if the model # has changed when that is called self.modelrows = newModel.num_items() newModel.modelReset.connect(self.applyExpandOnLoad) # set custom selection model self.setSelectionModel(TreeSelectionModel(newModel, self)) # load the model data self.doModelRefresh() def selectAll(self): """ """ if not isinstance(self.selectionModel(), TreeSelectionModel): return QtGui.QTreeView.selectAll(self) # store parameters selectionModel = self.selectionModel() model = self.model() getIndex = model.index columnCount = model.columnCount(QtCore.QModelIndex()) selection = QtGui.QItemSelection() propagate = selectionModel.propagateSelection() oldSelection = selectionModel.selection() def selectChildRange(parentIndex): rowCount = model.rowCount(parentIndex) firstIndex = getIndex(0, 0, parentIndex) lastIndex = getIndex(rowCount - 1, columnCount - 1, parentIndex) selection.select(firstIndex, lastIndex) def recursiveSelect(parentIndex): selectChildRange(parentIndex) if propagate is True: # if we skip this check it will always select child rows. for row in range(model.rowCount(parentIndex)): index = getIndex(row, 0, parentIndex) if index.isValid(): recursiveSelect(index) # prepare block = selectionModel.blockSignals(True) self.setUpdatesEnabled(False) if propagate is True: selectionModel.setPropagateSelection(False) # do selection if self.selectionMode() == QtGui.QAbstractItemView.SingleSelection: selection.select( model.index(0, 0, QtCore.QModelIndex()), model.index(0, columnCount - 1, QtCore.QModelIndex())) else: recursiveSelect(QtCore.QModelIndex()) selectionModel.select(selection, QtGui.QItemSelectionModel.Select) # restore previous settings self.setUpdatesEnabled(True) selectionModel.setPropagateSelection(propagate) selectionModel.blockSignals(block) # refresh view QtGui.QApplication.processEvents() selectionModel.selectionChanged.emit(selection, oldSelection) ########################################################################### # PERSISTENT SETTINGS ########################################################################### def getState(self): """ """ state = AbstractView.getState(self) if state is None: return None state['expandOnLoad'] = self.__expandOnLoad if not self.__expandOnLoad: # expanded indexes expandedIds = [] # the expanded index list must be in order so that when we restore the expanded state, # parents are expanded before their children indexQueue = [self.rootIndex()] while indexQueue: parentIndex = indexQueue.pop(0) # iterate over all children of this index numChildren = self.model().rowCount(parentIndex) for i in xrange(numChildren): childIndex = self.model().index(i, 0, parentIndex) if childIndex.isValid() and self.isExpanded(childIndex): uniqueId = self.model().uniqueIdFromIndex(childIndex) if uniqueId is not None: expandedIds.append(uniqueId) indexQueue.append(childIndex) state["expanded"] = expandedIds return state def restoreState(self): blockSignals = self.selectionModel().blockSignals block = blockSignals(True) AbstractView.restoreState(self) blockSignals(block) self.applyExpandOnLoad() def applyState(self, state): """ """ # expand indexes before calling superclass to ensure selected items are properly scrolled to expandOnLoad = state.get('expandOnLoad', 0) if type(expandOnLoad) == bool: expandOnLoad = -1 if expandOnLoad else 0 self.setExpandOnLoad(expandOnLoad) self.applyExpandOnLoad() state = state or {} if "expanded" in state and isinstance(state["expanded"], list): indexFromUniqueId = self.model().indexFromUniqueId blocked = self.blockSignals(True) for uniqueId in state["expanded"]: index = indexFromUniqueId(uniqueId) if index.isValid() and not self.isExpanded(index): # IMPORTANT: must layout the view before calling setExpanded() for lazy-loaded # trees, otherwise expand() will not call fetchMore() and child indexes will not be loaded self.executeDelayedItemsLayout() self.setExpanded(index, True) self.blockSignals(blocked) AbstractView.applyState(self, state) if self.selectionModel() and hasattr(self.selectionModel(), "requestRefresh"): self.selectionModel().requestRefresh() ########################################################################### # GRID METHODS ########################################################################### def showGrid(self): """ """ return self.__showGrid def setShowGrid(self, show): """ """ if show != self.__showGrid: self.__showGrid = show self.viewport().update() def visualRect(self, index): """ """ rect = QtGui.QTreeView.visualRect(self, index) if self.__showGrid and rect.isValid(): # remove 1 pixel from right and bottom edge of rect, to account for grid lines rect.setRight(rect.right() - 1) rect.setBottom(rect.bottom() - 1) return rect def drawRow(self, painter, option, index): """ """ # draw the partially selected rows a lighter colour selectionState = index.model().itemFromIndex(index).data( role=common.ROLE_SELECTION_STATE) if selectionState == 1: palette = self.palette() selectionColor = palette.color(palette.Highlight) selectionColor.setAlpha(127) painter.save() painter.fillRect(option.rect, selectionColor) painter.restore() QtGui.QTreeView.drawRow(self, painter, option, index) # draw the grid line if self.__showGrid: painter.save() gridHint = self.style().styleHint( QtGui.QStyle.SH_Table_GridLineColor, self.viewOptions(), self, None) # must ensure that the value is positive before constructing a QColor from it # http://www.riverbankcomputing.com/pipermail/pyqt/2010-February/025893.html gridColor = QtGui.QColor.fromRgb(gridHint & 0xffffffff) painter.setPen(QtGui.QPen(gridColor, 0, QtCore.Qt.SolidLine)) # paint the horizontal line painter.drawLine(option.rect.left(), option.rect.bottom(), option.rect.right(), option.rect.bottom()) painter.restore() def drawBranches(self, painter, rect, index): """ """ QtGui.QTreeView.drawBranches(self, painter, rect, index) # draw the grid line if self.__showGrid: painter.save() gridHint = QtGui.QApplication.style().styleHint( QtGui.QStyle.SH_Table_GridLineColor, self.viewOptions(), self, None) # must ensure that the value is positive before # constructing a QColor from it # http://www.riverbankcomputing.com/pipermail/pyqt/2010-February/025893.html gridColor = QtGui.QColor.fromRgb(gridHint & 0xffffffff) painter.setPen(QtGui.QPen(gridColor, 0, QtCore.Qt.SolidLine)) # paint the horizontal line painter.drawLine(rect.left(), rect.bottom(), rect.right(), rect.bottom()) painter.restore() def sizeHintForColumn(self, column): """ """ return QtGui.QTreeView.sizeHintForColumn( self, column) + (1 if self.__showGrid else 0) def scrollContentsBy(self, dx, dy): """ """ QtGui.QTreeView.scrollContentsBy(self, dx, dy) self._updateImageCycler() ########################################################################### # EVENTS ########################################################################### def paintEvent(self, event): """ """ QtGui.QTreeView.paintEvent(self, event) AbstractView.paintEvent(self, event) def viewportEvent(self, event): """ """ AbstractView.viewportEvent(self, event) return QtGui.QTreeView.viewportEvent(self, event) def keyReleaseEvent(self, event): # if we've postponed the refresh during a multiselect, and ctrl has now been released, # emit the selection changed signal. if event.key( ) == QtCore.Qt.Key_Control and self.__refreshOnNextCtrlRelease: self.selectionModel().requestRefresh() self.__refreshOnNextCtrlRelease = False QtGui.QTreeView.keyReleaseEvent(self, event) def keyPressEvent(self, event): """ """ def getHashableShortcut(action): # QKeySequence not hashable in pyside shortcut = action.shortcut() if isinstance(shortcut, QtGui.QKeySequence): return shortcut.toString() return shortcut # handle shortcut keys of actions if event.key(): QtGui.QTreeView.keyPressEvent(self, event) if event.key() in (QtCore.Qt.Key_Up, QtCore.Qt.Key_Down): if self.selectionModel() and hasattr(self.selectionModel(), "requestRefresh"): self.selectionModel().requestRefresh() self.setFocus(QtCore.Qt.OtherFocusReason) else: QtGui.QTreeView.keyPressEvent(self, event) def mouseReleaseEvent(self, event): """ Reimplemented for parent-only selection on alt+click when selection is propagated to children. Ctrl+alt+click adds the single item to the selection when selection is propagated to children. :parameters: event : QMouseEvent """ modifiers = event.modifiers() button = event.button() if button == QtCore.Qt.LeftButton: selectionModel = self.selectionModel() if modifiers & (QtCore.Qt.AltModifier | QtCore.Qt.ControlModifier): # only ctrl is pressed - default behaviour if modifiers == QtCore.Qt.ControlModifier: if self.selectionModel() and hasattr( self.selectionModel(), "requestRefresh"): self.__refreshOnNextCtrlRelease = True QtGui.QTreeView.mouseReleaseEvent(self, event) return else: # store current propagation state propagationState = selectionModel.propagateSelection() if propagationState: # set propagation to false so that only the item clicked is selected selectionModel.setPropagateSelection(False) # both alt and ctrl are pressed - add single item to selection if modifiers == (QtCore.Qt.AltModifier | QtCore.Qt.ControlModifier): selectionModel.setCurrentIndex( self.indexAt(event.pos()), QtGui.QItemSelectionModel.Select) # only alt is pressed - clear selection and select single item elif modifiers == QtCore.Qt.AltModifier: selectionModel.setCurrentIndex( self.indexAt(event.pos()), QtGui.QItemSelectionModel.ClearAndSelect) # restore propagation state selectionModel.setPropagateSelection(propagationState) QtGui.QTreeView.mouseReleaseEvent(self, event) if self.selectionModel() and hasattr(self.selectionModel(), "requestRefresh"): self.selectionModel().requestRefresh() def mousePressEvent(self, event): """ Reimplement QAbstractItemView.mousePressEvent """ # Note: Uncomment this and the section at the end of this # function if you want profile the selection in # twig/stalk etc widgets. Do Not leave this uncomented # in production. # # import time # import cProfile # prof = cProfile.Profile() # prof.enable() # statsfile = 'selection.pstats' # start = time.time() QtGui.QTreeView.mousePressEvent(self, event) # Note: Uncomment this and the section at the start of this # function if you want profile the selection in # twig/stalk etc widgets. Do Not leave this uncomented # in production. # # print( 'wzq::m::tv::mousePressEvent: {0:11.8f} ({1})'.format( time.time()-start, statsfile ) ) # prof.disable() # prof.dump_stats(statsfile) def mouseDoubleClickEvent(self, event): """ Mouse double click event """ if self.model(): indexes = self.currentSelection(minimal=False) items = [self.model().itemFromIndex(i) for i in indexes] self.itemDoubleClicked.emit(items) super(TreeView, self).mouseDoubleClickEvent(event) # def _canExpand( self ): # """ # """ # # determine if the selection can be expanded # indexes = self.selectionModel().selectedIndexes() # if not indexes: # return False # for idx in indexes: # if self.model().hasChildren( idx ) and not self.isExpanded( idx ): # return True # return False # # def _canCollapse( self ): # """ # """ # # determine if the selection can be collapsed # indexes = self.selectionModel().selectedIndexes() # if not indexes: # return False # for idx in indexes: # if self.model().hasChildren( idx ) and self.isExpanded( idx ): # return True # return False ########################################################################### # EXPAND/COLLAPSE METHODS ########################################################################### def _toggleExpanded(self): selection = self.currentSelection() if not selection: return # make them all do the same thing, not some expand and others collapse expanded = not self.isExpanded(selection[0]) for index in selection: self.setExpanded(index, expanded) if (not expanded): self.__expandAllIsExpanded = False def _setSelectedExpanded(self, expanded): """ """ for index in self.currentSelection(): self.setExpanded(index, expanded) if (not expanded): self.__expandAllIsExpanded = False def _expandOneBranch(self, index, depth): """ """ model = self.model() # use depth < 0 for infinite depth if depth != 0 and self.model().hasChildren(index): if not self.isExpanded(index): model.modelReset.disconnect(self.applyExpandOnLoad) self.expand(index) model.modelReset.connect(self.applyExpandOnLoad) for row in xrange(model.rowCount(index)): childIndex = model.index(row, index.column(), parentIndex=index) if childIndex != index: self._expandOneBranch(childIndex, depth - 1) def expandAll(self, always_expand=False): # Only bother expanding all, if either: # * we have not already expanded all the things, or # * the number of things has changed since we expanded all # The modelrows check was added to cope with the model contents # changing underneath the tree_view. There's probably a better # way to fix it, but this works for now. if (not self.__expandAllIsExpanded or (self.modelrows != self.model().num_items()) or always_expand): self.modelrows = self.model().num_items() self.__expandAllIsExpanded = True super(TreeView, self).expandAll() self.allExpanded.emit() def collapseAll(self): self.__expandAllIsExpanded = False super(TreeView, self).collapseAll() self.allCollapsed.emit() def applyExpandOnLoad(self): """ """ if self.__expandOnLoad: model = self.model() root = QtCore.QModelIndex() getIndex = lambda row: model.index(row, 0, root) rowCount = model.rowCount(root) for row in xrange(0, rowCount + 1): self._expandOneBranch(getIndex(row), self.__expandOnLoad) def setExpandOnLoad(self, value): """ """ if type(value) is not int: value = -1 if value else 0 enabled = value != 0 # self._expandOnLoadAction.setChecked( enabled ) self.__expandOnLoad = value if enabled: self.applyExpandOnLoad() def expand(self, index): model = self.model() if model.canFetchMore(index): model.fetchMore(index) super(TreeView, self).expand(index) self.__expandAllIsExpanded = False def expandToSelected(self): """ """ model = self.model() propagateSelection = self.selectionModel().propagateSelection() # remove duplicates def removeIndexDuplicates(indexList): return filter( QtCore.QModelIndex.isValid, map(model.indexFromItem, set(map(model.itemFromIndex, indexList)))) # index has no parent def isTopLevel(index): return not index.parent().isValid() # get selected indexes indexes = removeIndexDuplicates( filter(QtCore.QModelIndex.isValid, self.selectionModel().selectedIndexes())) # if no selection, expand one level if not indexes: self._expandOneBranch(model.index(0, 0, QtCore.QModelIndex()), 1) return block = self.blockSignals(True) # if the top level is selected, it's quicker to just expand all if propagateSelection is True and filter(isTopLevel, indexes): self.__expandAllIsExpanded = False self.expandAll() self.blockSignals(block) return # expand back up the hierarchy to reveal the selected index for index in indexes: while index.isValid() and not self.isExpanded(index): self.setExpanded(index, True) index = index.parent() self.blockSignals(block) ########################################################################### # DRAG/DROP METHODS ########################################################################### def dragMoveEvent(self, event): """ """ # re-implemented so we can customise the autoExpandDelay behaviour if self.autoExpandDelay() >= 0: self.__openTimer.start(self.autoExpandDelay()) AbstractView.dragMoveEvent(self, event) def __doAutoExpand(self): """ """ pos = self.viewport().mapFromGlobal(QtGui.QCursor.pos()) if self.state( ) == QtGui.QAbstractItemView.DraggingState and self.viewport().rect( ).contains(pos): index = self.indexAt(pos) # only expand branches, never collapse them if not self.isExpanded(index): self.setExpanded(index, True) self.__openTimer.stop() def loadSettings(self, settings): AbstractView.loadSettings(self, settings) # allow 'expand on load' to be passed from SITE cfg file. # 'state' is usually stored and read from the local cfg, which will override a SITE default being set self.setExpandOnLoad(settings['expandOnLoad'] or 0)
class TreeSelectionModel(QtGui.QItemSelectionModel): """A custom selection model for :class:`TreeView`, which inherits :class:`QtGui.QItemSelectionModel`, and adds selection propagation, i.e. when propagation is turned on, by calling ``setPropagateSelection( True )`` it will select all items below the item that is selected by the mouse. """ STATE_UNSELECTED = 0 STATE_PARTIALLY_SELECTED = 1 STATE_SELECTED = 2 refreshRequested = QtCore.Signal() def __init__(self, model, parent=None): """ """ super(TreeSelectionModel, self).__init__(model, parent) self.__propagateSelection = False self.__showPartiallySelected = True self.__lastselectionflags = 0 def __propagateSelectionDown(self, selection): """ """ childSelection = QtGui.QItemSelection() indexQueue = selection.indexes() while indexQueue: index = indexQueue.pop(0) if index.isValid(): numChildren = self.model().rowCount(index) childIndexes = [ self.model().index(row, 0, index) for row in xrange(numChildren) ] if childIndexes: # add child indexes to the selection childSelection.append( QtGui.QItemSelectionRange(childIndexes[0], childIndexes[-1])) indexQueue.extend(childIndexes) return childSelection def _propagateSelectionUp(self, selection, command): """ """ parentSelection = QtGui.QItemSelection() # filter out duplicates by unique id because pyside QModelIndexes are not hashable, and cannot be added to a set parentIndexes = map(QtCore.QModelIndex.parent, selection.indexes()) parentIndexes = dict( zip(map(self.model().uniqueIdFromIndex, parentIndexes), parentIndexes)).values() for index in parentIndexes: while index.isValid(): if not (selection.contains(index) or parentSelection.contains(index)): if command & QtGui.QItemSelectionModel.Deselect: # children are being deselected, deselect parents too parentSelection.select(index, index) elif command & QtGui.QItemSelectionModel.Select: # children are being selected, select parent if all children are now selected numChildren = self.model().rowCount(index) if numChildren: numSelected = 0 for row in xrange(numChildren): childIndex = self.model().index(row, 0, index) if selection.contains(childIndex) or \ parentSelection.contains(childIndex) or \ (not (command & QtGui.QItemSelectionModel.Clear) and self.isSelected( childIndex)): numSelected += 1 else: break if numSelected == numChildren: # all children are selected, select parent too parentSelection.select(index, index) index = index.parent() return parentSelection def setPropagateSelection(self, enabled): """ """ self.__propagateSelection = bool(enabled) def propagateSelection(self): """ """ return self.__propagateSelection def setShowPartiallySelected(self, state): self.__showPartiallySelected = bool(state) def showPartiallySelected(self): return self.__showPartiallySelected def setSelectionStates(self, indexes, previous=None): """ go from child up through the parents, setting the ancestors to partially selected """ model = self.model() role = common.ROLE_SELECTION_STATE def selectUpstream(item, state=self.STATE_PARTIALLY_SELECTED): parent = item.parent() while parent is not None: parent.setData(state, role=role) parent = parent.parent() itemFromIndex = model.itemFromIndex # if there is a previous selection, unselect it if previous is not None: for index in previous: item = itemFromIndex(index) # always deselect upstream, in case self.__showPartiallySelected changes between selections selectUpstream(item, state=self.STATE_UNSELECTED) item.setData(self.STATE_UNSELECTED, role=role) # select new for item in set(map(itemFromIndex, indexes)): # if self.__showPartiallySelected is True, partial-select parents until we hit the root node if self.__showPartiallySelected is True: selectUpstream(item, state=self.STATE_PARTIALLY_SELECTED) item.setData(self.STATE_SELECTED, role=role) # emit model data changed signal to trigger view repaint model.dataChanged.emit( model.index(0, 0), model.index(model.rowCount(), model.columnCount())) def getSelectionFlags(self): return self.__lastselectionflags def select(self, selection, command): """ Select items :parameters: selection : QtGui.QItemSelection selected model items command : QtGui.QItemSelectionModel.SelectionFlags NoUpdate: No selection will be made. Clear: The complete selection will be cleared. Select: All specified indexes will be selected. Deselect: All specified indexes will be deselected. Toggle: All specified indexes will be selected or deselected depending on their current state. Current: The current selection will be updated. Rows: All indexes will be expanded to span rows. Columns: All indexes will be expanded to span columns. SelectCurrent:A combination of Select and Current, provided for convenience. ToggleCurrent: A combination of Toggle and Current, provided for convenience. ClearAndSelect: A combination of Clear and Select, provided for convenience. """ if self.__propagateSelection: # propagate selection to children/parents of selected indexes if isinstance(selection, QtCore.QModelIndex): selection = QtGui.QItemSelection(selection, selection) # propagate selection down to children childSelection = self.__propagateSelectionDown(selection) # propagate selection up to parents parentSelection = self._propagateSelectionUp(selection, command) selection.merge(childSelection, QtGui.QItemSelectionModel.SelectCurrent) selection.merge(parentSelection, QtGui.QItemSelectionModel.SelectCurrent) # NOTE: the 'command' parameter is really the 'selectionFlags' self.__lastselectionflags = command if (command & QtGui.QItemSelectionModel.Columns): # NOTE: I'm not sure anyone ever has this set but just in # case for compatibility. In future we should apptrack # this and if no one uses column seleciton then this # option should be removed. previousSelection = self.selectedIndexes() else: # This saves on many many duplicates in the selection previousSelection = self.selectedRows() QtGui.QItemSelectionModel.select(self, selection, command) # NOTE: the 'command' parameter is really 'selectionFlags' if (command & QtGui.QItemSelectionModel.Columns): # NOTE: I'm not sure anyone ever has this set but just in # case for compatibility. In future we should apptrack # this and if no one uses column seleciton then this # option should be removed. selected_now = self.selectedIndexes() else: # This saves on many many duplicates in the selection selected_now = self.selectedRows() self.setSelectionStates(selected_now, previous=previousSelection) self.requestRefresh() def requestRefresh(self): """ """ self.refreshRequested.emit()
class ModelItemSorter(QtCore.QObject): # constants DEFAULT_SORT_DIRECTION = QtCore.Qt.AscendingOrder _OPPOSITE_SORT_DIRECTION = int(not DEFAULT_SORT_DIRECTION) _SORT_DIRECTION_MAP = { wizqt.TYPE_STRING_SINGLELINE: DEFAULT_SORT_DIRECTION, wizqt.TYPE_STRING_MULTILINE: DEFAULT_SORT_DIRECTION, wizqt.TYPE_INTEGER: _OPPOSITE_SORT_DIRECTION, wizqt.TYPE_UNSIGNED_INTEGER: _OPPOSITE_SORT_DIRECTION, wizqt.TYPE_FLOAT: _OPPOSITE_SORT_DIRECTION, wizqt.TYPE_BOOLEAN: DEFAULT_SORT_DIRECTION, wizqt.TYPE_DATE: _OPPOSITE_SORT_DIRECTION, wizqt.TYPE_DATE_TIME: _OPPOSITE_SORT_DIRECTION, wizqt.TYPE_FILE_PATH: DEFAULT_SORT_DIRECTION, wizqt.TYPE_DN_PATH: DEFAULT_SORT_DIRECTION, wizqt.TYPE_DN_RANGE: DEFAULT_SORT_DIRECTION, wizqt.TYPE_RESOLUTION: _OPPOSITE_SORT_DIRECTION, wizqt.TYPE_IMAGE: DEFAULT_SORT_DIRECTION, wizqt.TYPE_ENUM: DEFAULT_SORT_DIRECTION } MAX_SORT_COLUMNS = 3 # signals sortingChanged = QtCore.Signal() # properties sortColumns = property(lambda self: self._sortColumns[:]) sortDirections = property(lambda self: self._sortDirections[:]) def __init__(self, model): super(ModelItemSorter, self).__init__(model) self._model = model self._sortColumns = [] self._sortDirections = [] def setSortColumns(self, columns, directions): """ Update the list of columns/directions for multi-sorting """ # copy the current column/directions for comparison oldColumns = self._sortColumns[:] oldDirections = self._sortDirections[:] self._sortColumns = [] self._sortDirections = [] for (column, direction) in zip(columns, directions): self.__addSortColumn(column, direction) if self._sortColumns != oldColumns or self._sortDirections != oldDirections: self.sortingChanged.emit() def addSortColumn(self, column, direction=None): """ Add one more column/direction to the multi-sort list """ # copy the current column/directions for comparison oldColumns = self._sortColumns[:] oldDirections = self._sortDirections[:] self.__addSortColumn(column, direction) if self._sortColumns != oldColumns or self._sortDirections != oldDirections: self.sortingChanged.emit() def __addSortColumn(self, column, direction): # skip invalid and unsortable columns if column < 0 or not self._model.headerData( column, QtCore.Qt.Horizontal, wizqt.ROLE_IS_SORTABLE): return # check if this column is already in the list try: idx = self._sortColumns.index(column) except ValueError: pass else: # remove the column/direction from the list del self._sortColumns[idx] del self._sortDirections[idx] # add the column/direction to the list self._sortColumns.append(column) self._sortDirections.append(direction) # limit the number of columns that can be sorted on if len(self._sortColumns) > self.MAX_SORT_COLUMNS: del self._sortColumns[:-self.MAX_SORT_COLUMNS] del self._sortDirections[:-self.MAX_SORT_COLUMNS] def defaultSortDirection(self, column): dataType = self._model.headerData(column, QtCore.Qt.Horizontal, wizqt.ROLE_TYPE) if dataType not in wizqt.TYPES: dataType = wizqt.TYPE_DEFAULT return self._SORT_DIRECTION_MAP.get(dataType, self.DEFAULT_SORT_DIRECTION) def sortItems(self, itemList): """ Sort a list of ModelItems in-place. All child items are recursively sorted as well. The itemList argument is re-ordered based on the current multisort column/direction settings in the sorter. When sorting, ModelItems are compared based on their dataType. """ if not (self._sortColumns and self._sortDirections): return name = (self._model.dataSource or self._model).__class__.__name__ _LOGGER.log( dnlogging.VERBOSE, "Sorting %s items on columns %s..." % (name, zip(self._sortColumns, self._sortDirections))) start = time.time() # sort the list itemList.sort(cmp=self.__compareItems) # recursively sort each child for item in itemList: item.sortTree(cmp=self.__compareItems) end = time.time() _LOGGER.debug("%f seconds" % (end - start)) def __compareItems(self, item1, item2): """ Compare the value of 2 ModelItems based on their dataType. """ comparison = 0 for (column, direction) in zip(self._sortColumns, self._sortDirections): # first compare values by sort role sortVal1 = item1.data(column, wizqt.ROLE_SORT) sortVal2 = item2.data(column, wizqt.ROLE_SORT) if sortVal1 or sortVal2: # do case insensitive comparison of strings if isinstance(sortVal1, basestring): sortVal1 = sortVal1.lower() if isinstance(sortVal2, basestring): sortVal2 = sortVal2.lower() comparison = cmp(sortVal1, sortVal2) if comparison != 0: return comparison if direction == QtCore.Qt.AscendingOrder else -comparison # next check the display role displayVal1 = item1.data(column) displayVal2 = item2.data(column) # compare items using the typehandler for the data type set on this column dataType = self._model.headerData(column, QtCore.Qt.Horizontal, wizqt.ROLE_TYPE) if dataType not in wizqt.TYPES: dataType = wizqt.TYPE_DEFAULT comparison = getTypeHandler(dataType).compare( displayVal1, displayVal2) if comparison != 0: return comparison if direction == QtCore.Qt.AscendingOrder else -comparison return comparison
class Action(QtGui.QAction): """ """ completed = QtCore.Signal(bool, list) def __init__(self, text=None, icon=None, tip=None, shortcut=None, shortcutContext=QtCore.Qt.WidgetShortcut, menu=None, checkable=False, separator=False, selectionBased=False, signal="triggered()", enabled=True, isValidFn=None, runFn=None, parent=None): super(Action, self).__init__(parent) if text: self.setText(text) if icon: self.setIcon(icon) if tip: self.setToolTip(tip) self.setStatusTip(tip) if shortcut: if isinstance(shortcut, list): shortcuts = [QtGui.QKeySequence(key) for key in shortcut] self.setShortcuts(shortcuts) else: self.setShortcut(QtGui.QKeySequence(shortcut)) self.setShortcutContext(shortcutContext) if menu: self.setMenu(menu) self.setCheckable(checkable) self.setEnabled(enabled) self.setSeparator(separator) self.__selectionBased = selectionBased self.__isValidFn = isValidFn self.__runFn = runFn self.connect(self, QtCore.SIGNAL(signal), self.run) self.__subActions = [] ########################################################################### # isSelectionBased ########################################################################### def isSelectionBased(self): return self.__selectionBased ########################################################################### # isValid ########################################################################### def isValid(self): if self.__isValidFn: return self.__isValidFn() return True ########################################################################### # run ########################################################################### def run(self, *args): status = False info = [] if self.isValid() and self.__runFn: if metrics.METRICS_ARE_ON: # Record metrics for the action we're going to run try: # This'll work for a normal function or lambda action_name = self.__runFn.__name__ except AttributeError: # This'll work for a functools.partial, which will # have thrown an AttributeError. action_name = self.__runFn.func.__name__ if action_name == '<lambda>': action_name = self.text().replace(' ', '_') metrics.incr('menuactions.{0}'.format(action_name)) # Run the action function info = self.__runFn(*args) if info: if not isinstance(info, list): info = [info] else: info = [] status = True self.completed.emit(status, info) ########################################################################### # custom event system for using with custom wizqt toolbar ########################################################################### def event(self, event): ret = QtGui.QAction.event(self, event) return ret def eventFilter(self, obj, event): return QtCore.QObject.eventFilter(self, obj, event) def addSubActions(self, actions): self.__subActions.extend(actions) def addSubAction(self, action): self.addSubActions([action]) def getSubActions(self): return self.__subActions
class AbstractDataSource(QtCore.QObject): """ This is an abstract base class representing the datasource for a model. The datasource is what supplies a model with its items. The model requests items via the :meth:`fetchItems` and :meth:`fetchMore` methods. The datasource could receive its data from an SQL database, a file, etc. Subclass AbstractDataSource to provide your own datasource and connect it to a model using :meth:`~wizqt.modelview.model.Model.setDataSource` """ # load methods LOAD_METHOD_ALL = "all" LOAD_METHOD_PAGINATED = "paginated" LOAD_METHOD_INCREMENTAL = "incremental" DEFAULT_LOAD_METHOD = LOAD_METHOD_ALL DEFAULT_BATCH_SIZE = 500 # signals dataNeedsRefresh = QtCore.Signal() totalCountChanged = QtCore.Signal(int) pageChanged = QtCore.Signal(int) # properties @property def model(self): return self._model @property def headerItem(self): return self._headerItem @property def loadMethod(self): return self._loadMethod @property def lazyLoadChildren(self): return self._lazyLoadChildren @property def batchSize(self): return self._batchSize @property def needToRefresh(self): return self._needToRefresh @property def isValid(self): return self._isValid def __init__(self, columnNames=[], parent=None): """ Initialiser :keywords: columnNames : list list of column header names parent : QtCore.QObject parent qt object """ super(AbstractDataSource, self).__init__(parent) self._model = None # link to the model this datasource is populating self._loadMethod = self.DEFAULT_LOAD_METHOD self._needToRefresh = True # indicate that data needs to be refreshed to trigger initial population self._isValid = True self._itemsCheckable = False # lazy-load children for tree models # children are not populated until parent is expanded in tree self._lazyLoadChildren = False # lazy-load in batches self._pageNum = 0 self._batchSize = self.DEFAULT_BATCH_SIZE self._totalCount = -1 # initialize the header item self._headerItem = self._createHeaderItem(columnNames) def _setTotalCount(self, totalCount): """ Store the number of results :parameters: totalCount : int number of results """ if self._totalCount != totalCount: self._totalCount = totalCount self.totalCountChanged.emit(self._totalCount) def _createHeaderItem(self, columnNames): """ Create a modelitem to represent header data :parameters: columnNames : list Create a modelitem with columns <columnNames> :return: the created model item :rtype: wizqt.modelview.model_item.ModelItem """ headerItem = ModelItem(len(columnNames)) for i, columnName in enumerate(columnNames): self._populateHeaderColumn(headerItem, i, columnName) return headerItem def _populateHeaderColumn(self, headerItem, col, columnName): """ Add the text <columnName> to the modelitem <headerItem> at column index <col> :parameters: headerItem : wizqt.modelview.model_item.ModelItem the header's model item col : int index of a column columnName : str column name """ headerItem.setData(columnName, col, QtCore.Qt.DisplayRole) headerItem.setData(columnName, col, common.ROLE_NAME) def setModel(self, model): """ Set the model that this datasource is populating :parameters: model : wizqt.modelview.model_item.Model the model """ self._model = model def setLoadMethod(self, loadMethod): """ Set the way that data is loaded :parameters: loadMethod : str the load method self.LOAD_METHOD_ALL load all data in one go self.LOAD_METHOD_PAGINATED load data in groups i.e. pages. The number of items per page is self.DEFAULT_BATCH_SIZE :see: self.setBatchSize self.LOAD_METHOD_INCREMENTAL load data incrementally, as long as self.canFetchMore() returns True """ self._loadMethod = loadMethod def setLazyLoadChildren(self, enabled): """ lazy load children (children are not populated until parent is expanded in tree) :parameters: enabled : bool enabled state """ self._lazyLoadChildren = bool(enabled) def setNeedToRefresh(self, needToRefresh): """ Either emit a signal that indicates that the data is no longer up to date, or stop the signal from being emitted, i.e. the data is now up to date :parameters: needToRefresh : bool the data needs to be updated """ if self._needToRefresh != needToRefresh: self._needToRefresh = needToRefresh if self._needToRefresh: self.dataNeedsRefresh.emit() def setBatchSize(self, batchSize): """ Set the maximum number of items that can be loaded in one go. :parameters: batchSize : int the result count limit """ if self._batchSize != batchSize: self._batchSize = batchSize self.setNeedToRefresh(True) def setPage(self, pageNum): """ If we're loading data in pages, set the current page that we're on :parameters: pageNum : int page number """ if self._loadMethod == self.LOAD_METHOD_PAGINATED and self._pageNum != pageNum: self._pageNum = pageNum self.pageChanged.emit(pageNum) self.setNeedToRefresh(True) def canFetchMore(self, parentIndex): """ Check whether the parentIndex needs to be populated. This applies only to incremental loading or lazy population of children :parameters: parentIndex : QtCore.QModelIndex model index to be queried :return: True if item needs to be populated :rtype: bool """ if self._isValid and (self._loadMethod == self.LOAD_METHOD_INCREMENTAL or self._lazyLoadChildren): """ if not parentIndex.isValid(): # top-level item if self._totalCount < 0: # total count is uninitialized, so item is unpopulated return True elif self._loadMethod == self.LOAD_METHOD_INCREMENTAL and self._model.rowCount( parentIndex ) < self._totalCount: return True else: """ if True: # must be a tree model item = self._model.itemFromIndex(parentIndex) if self._lazyLoadChildren and item.totalChildCount() < 0: # totalChildCount is uninitialized, so item is unpopulated return True elif self._loadMethod == self.LOAD_METHOD_INCREMENTAL and item.childCount() < item.totalChildCount(): return True return False def fetchMore(self, parentIndex): """ This method only gets called when canFetchMore() returns True should only be for incremental loading or lazy population of children :parameters: parentIndex : QtCore.QModelIndex model index to be queried """ assert self._isValid and (self._loadMethod == self.LOAD_METHOD_INCREMENTAL or self._lazyLoadChildren) offset = self._model.rowCount(parentIndex) limit = self._batchSize if self._loadMethod == self.LOAD_METHOD_INCREMENTAL else 0 itemList = self._fetchBatch(parentIndex, offset, limit, False) # FIXME: reload? # sort the items using the model's sorter self._model.sorter.sortItems(itemList) # mark parent as populated if self._lazyLoadChildren: # and parentIndex.isValid(): parentItem = self._model.itemFromIndex(parentIndex) parentItem.setTotalChildCount(len(itemList)) # insert the new items in the model self._model.appendItems(itemList, parentIndex) def fetchItems(self, parentIndex, reload): """ Get modelitems for this index. This method should only be called by requestRefresh() in Model, and only for top-level items. :parameters: parentIndex : QtCore.QModelIndex model index to be populated reload : bool the index's data should be replaced :return: The result of self._fetchBatch :rtype: ?, self._fetchBatch is abstract in this class """ assert not parentIndex.isValid() if not self._isValid: self._setTotalCount(-1) return [] limit = 0 offset = 0 if self._loadMethod == self.LOAD_METHOD_PAGINATED and not parentIndex.isValid(): limit = self._batchSize offset = self._pageNum * self._batchSize elif self._loadMethod == self.LOAD_METHOD_INCREMENTAL: limit = self._batchSize item = self._model.itemFromIndex(parentIndex) if item.totalChildCount() < 0: offset = 0 else: offset = self._model.rowCount(parentIndex) itemList = self._fetchBatch(parentIndex, offset, limit, reload) # sort the items using the model's sorter self._model.sorter.sortItems(itemList) return itemList def setItemsCheckable(self, checkable): """ Set model items to be checkable. This setting should be taken into account when model items are constructed in _fetchBatch. :parameters: checkable : bool """ if self._itemsCheckable != checkable: self._itemsCheckable = checkable self.setNeedToRefresh(True) ############################################################################################## # ABSTRACT METHODS # these are the only methods you should need to implement in datasource sub-classes ############################################################################################## def _fetchBatch(self, parentIndex, offset, limit, reload): """ ABSTRACT: Return a list of ModelItem entities to the model :parameters: parentIndex : QtCore.QModelIndex model index to be populated offset : int page offset limit : int limit of results per page reload : bool reload existing results """ raise NotImplementedError def sortByColumns(self, columns, directions, refresh=True): """ ABSTRACT: multi sort columns :parameters: columns : list list of int directions : list list of QtCore.Qt.SortOrder """ raise NotImplementedError @property def stateId(self): """ ABSTRACT: A string uniquely identifying the current state of the data source contents For example, a string representation of the active filter """ return None