class CanvasScene(QGraphicsScene): """ A Graphics Scene for displaying an :class:`~.scheme.Scheme` instance. """ #: Signal emitted when a :class:`NodeItem` has been added to the scene. node_item_added = Signal(object) #: Signal emitted when a :class:`NodeItem` has been removed from the #: scene. node_item_removed = Signal(object) #: Signal emitted when a new :class:`LinkItem` has been added to the #: scene. link_item_added = Signal(object) #: Signal emitted when a :class:`LinkItem` has been removed. link_item_removed = Signal(object) #: Signal emitted when a :class:`Annotation` item has been added. annotation_added = Signal(object) #: Signal emitted when a :class:`Annotation` item has been removed. annotation_removed = Signal(object) #: Signal emitted when the position of a :class:`NodeItem` has changed. node_item_position_changed = Signal(object, QPointF) #: Signal emitted when an :class:`NodeItem` has been double clicked. node_item_double_clicked = Signal(object) #: An node item has been activated (clicked) node_item_activated = Signal(object) #: An node item has been hovered node_item_hovered = Signal(object) #: Link item has been hovered link_item_hovered = Signal(object) def __init__(self, *args, **kwargs): QGraphicsScene.__init__(self, *args, **kwargs) self.scheme = None self.registry = None # All node items self.__node_items = [] # Mapping from SchemeNodes to canvas items self.__item_for_node = {} # All link items self.__link_items = [] # Mapping from SchemeLinks to canvas items. self.__item_for_link = {} # All annotation items self.__annotation_items = [] # Mapping from SchemeAnnotations to canvas items. self.__item_for_annotation = {} # Is the scene editable self.editable = True # Anchor Layout self.__anchor_layout = AnchorLayout() self.addItem(self.__anchor_layout) self.__channel_names_visible = True self.__node_animation_enabled = True self.user_interaction_handler = None self.activated_mapper = QSignalMapper(self) self.activated_mapper.mapped[QObject].connect( lambda node: self.node_item_activated.emit(node)) self.hovered_mapper = QSignalMapper(self) self.hovered_mapper.mapped[QObject].connect( lambda node: self.node_item_hovered.emit(node)) self.position_change_mapper = QSignalMapper(self) self.position_change_mapper.mapped[QObject].connect( self._on_position_change) log.info("'%s' intitialized." % self) def clear_scene(self): """ Clear (reset) the scene. """ if self.scheme is not None: self.scheme.node_added.disconnect(self.add_node) self.scheme.node_removed.disconnect(self.remove_node) self.scheme.link_added.disconnect(self.add_link) self.scheme.link_removed.disconnect(self.remove_link) self.scheme.annotation_added.disconnect(self.add_annotation) self.scheme.annotation_removed.disconnect(self.remove_annotation) self.scheme.node_state_changed.disconnect( self.on_widget_state_change) self.scheme.channel_state_changed.disconnect( self.on_link_state_change) # Remove all items to make sure all signals from scheme items # to canvas items are disconnected. for annot in self.scheme.annotations: if annot in self.__item_for_annotation: self.remove_annotation(annot) for link in self.scheme.links: if link in self.__item_for_link: self.remove_link(link) for node in self.scheme.nodes: if node in self.__item_for_node: self.remove_node(node) self.scheme = None self.__node_items = [] self.__item_for_node = {} self.__link_items = [] self.__item_for_link = {} self.__annotation_items = [] self.__item_for_annotation = {} self.__anchor_layout.deleteLater() self.user_interaction_handler = None self.clear() log.info("'%s' cleared." % self) def set_scheme(self, scheme): """ Set the scheme to display. Populates the scene with nodes and links already in the scheme. Any further change to the scheme will be reflected in the scene. Parameters ---------- scheme : :class:`~.scheme.Scheme` """ if self.scheme is not None: # Clear the old scheme self.clear_scene() log.info("Setting scheme '%s' on '%s'" % (scheme, self)) self.scheme = scheme if self.scheme is not None: self.scheme.node_added.connect(self.add_node) self.scheme.node_removed.connect(self.remove_node) self.scheme.link_added.connect(self.add_link) self.scheme.link_removed.connect(self.remove_link) self.scheme.annotation_added.connect(self.add_annotation) self.scheme.annotation_removed.connect(self.remove_annotation) self.scheme.node_state_changed.connect(self.on_widget_state_change) self.scheme.channel_state_changed.connect( self.on_link_state_change) self.scheme.topology_changed.connect(self.on_scheme_change) for node in scheme.nodes: self.add_node(node) for link in scheme.links: self.add_link(link) for annot in scheme.annotations: self.add_annotation(annot) def set_registry(self, registry): """ Set the widget registry. """ # TODO: Remove/Deprecate. Is used only to get the category/background # color. That should be part of the SchemeNode/WidgetDescription. log.info("Setting registry '%s on '%s'." % (registry, self)) self.registry = registry def set_anchor_layout(self, layout): """ Set an :class:`~.layout.AnchorLayout` """ if self.__anchor_layout != layout: if self.__anchor_layout: self.__anchor_layout.deleteLater() self.__anchor_layout = None self.__anchor_layout = layout def anchor_layout(self): """ Return the anchor layout instance. """ return self.__anchor_layout def set_channel_names_visible(self, visible): """ Set the channel names visibility. """ self.__channel_names_visible = visible for link in self.__link_items: link.setChannelNamesVisible(visible) def channel_names_visible(self): """ Return the channel names visibility state. """ return self.__channel_names_visible def set_node_animation_enabled(self, enabled): """ Set node animation enabled state. """ if self.__node_animation_enabled != enabled: self.__node_animation_enabled = enabled for node in self.__node_items: node.setAnimationEnabled(enabled) def add_node_item(self, item): """ Add a :class:`.NodeItem` instance to the scene. """ if item in self.__node_items: raise ValueError("%r is already in the scene." % item) if item.pos().isNull(): if self.__node_items: pos = self.__node_items[-1].pos() + QPointF(150, 0) else: pos = QPointF(150, 150) item.setPos(pos) item.setFont(self.font()) # Set signal mappings self.activated_mapper.setMapping(item, item) item.activated.connect(self.activated_mapper.map) self.hovered_mapper.setMapping(item, item) item.hovered.connect(self.hovered_mapper.map) self.position_change_mapper.setMapping(item, item) item.positionChanged.connect(self.position_change_mapper.map) self.addItem(item) self.__node_items.append(item) self.node_item_added.emit(item) log.info("Added item '%s' to '%s'" % (item, self)) return item def add_node(self, node): """ Add and return a default constructed :class:`.NodeItem` for a :class:`SchemeNode` instance `node`. If the `node` is already in the scene do nothing and just return its item. """ if node in self.__item_for_node: # Already added return self.__item_for_node[node] item = self.new_node_item(node.description) if node.position: pos = QPointF(*node.position) item.setPos(pos) item.setTitle(node.title) item.setProcessingState(node.processing_state) item.setProgress(node.progress) for message in node.state_messages(): item.setStateMessage(message) item.setStatusMessage(node.status_message()) self.__item_for_node[node] = item node.position_changed.connect(self.__on_node_pos_changed) node.title_changed.connect(item.setTitle) node.progress_changed.connect(item.setProgress) node.processing_state_changed.connect(item.setProcessingState) node.state_message_changed.connect(item.setStateMessage) node.status_message_changed.connect(item.setStatusMessage) return self.add_node_item(item) def new_node_item(self, widget_desc, category_desc=None): """ Construct an new :class:`.NodeItem` from a `WidgetDescription`. Optionally also set `CategoryDescription`. """ item = items.NodeItem() item.setWidgetDescription(widget_desc) if category_desc is None and self.registry and widget_desc.category: category_desc = self.registry.category(widget_desc.category) if category_desc is None and self.registry is not None: try: category_desc = self.registry.category(widget_desc.category) except KeyError: pass if category_desc is not None: item.setWidgetCategory(category_desc) item.setAnimationEnabled(self.__node_animation_enabled) return item def remove_node_item(self, item): """ Remove `item` (:class:`.NodeItem`) from the scene. """ self.activated_mapper.removeMappings(item) self.hovered_mapper.removeMappings(item) self.position_change_mapper.removeMappings(item) item.hide() self.removeItem(item) self.__node_items.remove(item) self.node_item_removed.emit(item) log.info("Removed item '%s' from '%s'" % (item, self)) def remove_node(self, node): """ Remove the :class:`.NodeItem` instance that was previously constructed for a :class:`SchemeNode` `node` using the `add_node` method. """ item = self.__item_for_node.pop(node) node.position_changed.disconnect(self.__on_node_pos_changed) node.title_changed.disconnect(item.setTitle) node.progress_changed.disconnect(item.setProgress) node.processing_state_changed.disconnect(item.setProcessingState) node.state_message_changed.disconnect(item.setStateMessage) self.remove_node_item(item) def node_items(self): """ Return all :class:`.NodeItem` instances in the scene. """ return list(self.__node_items) def add_link_item(self, item): """ Add a link (:class:`.LinkItem`) to the scene. """ if item.scene() is not self: self.addItem(item) item.setFont(self.font()) self.__link_items.append(item) self.link_item_added.emit(item) log.info("Added link %r -> %r to '%s'" % \ (item.sourceItem.title(), item.sinkItem.title(), self)) self.__anchor_layout.invalidateLink(item) return item def add_link(self, scheme_link): """ Create and add a :class:`.LinkItem` instance for a :class:`SchemeLink` instance. If the link is already in the scene do nothing and just return its :class:`.LinkItem`. """ if scheme_link in self.__item_for_link: return self.__item_for_link[scheme_link] source = self.__item_for_node[scheme_link.source_node] sink = self.__item_for_node[scheme_link.sink_node] item = self.new_link_item(source, scheme_link.source_channel, sink, scheme_link.sink_channel) item.setEnabled(scheme_link.enabled) scheme_link.enabled_changed.connect(item.setEnabled) if scheme_link.is_dynamic(): item.setDynamic(True) item.setDynamicEnabled(scheme_link.dynamic_enabled) scheme_link.dynamic_enabled_changed.connect(item.setDynamicEnabled) item.setRuntimeState(scheme_link.runtime_state()) scheme_link.state_changed.connect(item.setRuntimeState) self.add_link_item(item) self.__item_for_link[scheme_link] = item return item def new_link_item(self, source_item, source_channel, sink_item, sink_channel): """ Construct and return a new :class:`.LinkItem` """ item = items.LinkItem() item.setSourceItem(source_item) item.setSinkItem(sink_item) def channel_name(channel): if isinstance(channel, str): return channel else: return channel.name source_name = channel_name(source_channel) sink_name = channel_name(sink_channel) fmt = "<b>{0}</b> \u2192 <b>{1}</b>" item.setToolTip(fmt.format(escape(source_name), escape(sink_name))) item.setSourceName(source_name) item.setSinkName(sink_name) item.setChannelNamesVisible(self.__channel_names_visible) return item def remove_link_item(self, item): """ Remove a link (:class:`.LinkItem`) from the scene. """ # Invalidate the anchor layout. self.__anchor_layout.invalidateAnchorItem( item.sourceItem.outputAnchorItem) self.__anchor_layout.invalidateAnchorItem( item.sinkItem.inputAnchorItem) self.__link_items.remove(item) # Remove the anchor points. item.removeLink() self.removeItem(item) self.link_item_removed.emit(item) log.info("Removed link '%s' from '%s'" % (item, self)) return item def remove_link(self, scheme_link): """ Remove a :class:`.LinkItem` instance that was previously constructed for a :class:`SchemeLink` instance `link` using the `add_link` method. """ item = self.__item_for_link.pop(scheme_link) scheme_link.enabled_changed.disconnect(item.setEnabled) if scheme_link.is_dynamic(): scheme_link.dynamic_enabled_changed.disconnect( item.setDynamicEnabled) scheme_link.state_changed.disconnect(item.setRuntimeState) self.remove_link_item(item) def link_items(self): """ Return all :class:`.LinkItem`\s in the scene. """ return list(self.__link_items) def add_annotation_item(self, annotation): """ Add an :class:`.Annotation` item to the scene. """ self.__annotation_items.append(annotation) self.addItem(annotation) self.annotation_added.emit(annotation) return annotation def add_annotation(self, scheme_annot): """ Create a new item for :class:`SchemeAnnotation` and add it to the scene. If the `scheme_annot` is already in the scene do nothing and just return its item. """ if scheme_annot in self.__item_for_annotation: # Already added return self.__item_for_annotation[scheme_annot] if isinstance(scheme_annot, scheme.SchemeTextAnnotation): item = items.TextAnnotation() x, y, w, h = scheme_annot.rect item.setPos(x, y) item.resize(w, h) item.setTextInteractionFlags(Qt.TextEditorInteraction) font = font_from_dict(scheme_annot.font, item.font()) item.setFont(font) item.setContent(scheme_annot.content, scheme_annot.content_type) scheme_annot.content_changed.connect(item.setContent) elif isinstance(scheme_annot, scheme.SchemeArrowAnnotation): item = items.ArrowAnnotation() start, end = scheme_annot.start_pos, scheme_annot.end_pos item.setLine(QLineF(QPointF(*start), QPointF(*end))) item.setColor(QColor(scheme_annot.color)) scheme_annot.geometry_changed.connect( self.__on_scheme_annot_geometry_change) self.add_annotation_item(item) self.__item_for_annotation[scheme_annot] = item return item def remove_annotation_item(self, annotation): """ Remove an :class:`.Annotation` instance from the scene. """ self.__annotation_items.remove(annotation) self.removeItem(annotation) self.annotation_removed.emit(annotation) def remove_annotation(self, scheme_annotation): """ Remove an :class:`.Annotation` instance that was previously added using :func:`add_anotation`. """ item = self.__item_for_annotation.pop(scheme_annotation) scheme_annotation.geometry_changed.disconnect( self.__on_scheme_annot_geometry_change) if isinstance(scheme_annotation, scheme.SchemeTextAnnotation): scheme_annotation.content_changed.disconnect(item.setContent) self.remove_annotation_item(item) def annotation_items(self): """ Return all :class:`.Annotation` items in the scene. """ return self.__annotation_items def item_for_annotation(self, scheme_annotation): return self.__item_for_annotation[scheme_annotation] def annotation_for_item(self, item): rev = dict(reversed(item) \ for item in self.__item_for_annotation.items()) return rev[item] def commit_scheme_node(self, node): """ Commit the `node` into the scheme. """ if not self.editable: raise Exception("Scheme not editable.") if node not in self.__item_for_node: raise ValueError("No 'NodeItem' for node.") item = self.__item_for_node[node] try: self.scheme.add_node(node) except Exception: log.error("An error occurred while committing node '%s'", node, exc_info=True) # Cleanup (remove the node item) self.remove_node_item(item) raise log.info("Commited node '%s' from '%s' to '%s'" % \ (node, self, self.scheme)) def commit_scheme_link(self, link): """ Commit a scheme link. """ if not self.editable: raise Exception("Scheme not editable") if link not in self.__item_for_link: raise ValueError("No 'LinkItem' for link.") self.scheme.add_link(link) log.info("Commited link '%s' from '%s' to '%s'" % \ (link, self, self.scheme)) def node_for_item(self, item): """ Return the `SchemeNode` for the `item`. """ rev = dict([(v, k) for k, v in self.__item_for_node.items()]) return rev[item] def item_for_node(self, node): """ Return the :class:`NodeItem` instance for a :class:`SchemeNode`. """ return self.__item_for_node[node] def link_for_item(self, item): """ Return the `SchemeLink for `item` (:class:`LinkItem`). """ rev = dict([(v, k) for k, v in self.__item_for_link.items()]) return rev[item] def item_for_link(self, link): """ Return the :class:`LinkItem` for a :class:`SchemeLink` """ return self.__item_for_link[link] def selected_node_items(self): """ Return the selected :class:`NodeItem`'s. """ return [item for item in self.__node_items if item.isSelected()] def selected_annotation_items(self): """ Return the selected :class:`Annotation`'s """ return [item for item in self.__annotation_items if item.isSelected()] def node_links(self, node_item): """ Return all links from the `node_item` (:class:`NodeItem`). """ return self.node_output_links(node_item) + \ self.node_input_links(node_item) def node_output_links(self, node_item): """ Return a list of all output links from `node_item`. """ return [ link for link in self.__link_items if link.sourceItem == node_item ] def node_input_links(self, node_item): """ Return a list of all input links for `node_item`. """ return [ link for link in self.__link_items if link.sinkItem == node_item ] def neighbor_nodes(self, node_item): """ Return a list of `node_item`'s (class:`NodeItem`) neighbor nodes. """ neighbors = list( map(attrgetter("sourceItem"), self.node_input_links(node_item))) neighbors.extend( map(attrgetter("sinkItem"), self.node_output_links(node_item))) return neighbors def on_widget_state_change(self, widget, state): pass def on_link_state_change(self, link, state): pass def on_scheme_change(self, ): pass def _on_position_change(self, item): # Invalidate the anchor point layout and schedule a layout. self.__anchor_layout.invalidateNode(item) self.node_item_position_changed.emit(item, item.pos()) def __on_node_pos_changed(self, pos): node = self.sender() item = self.__item_for_node[node] item.setPos(*pos) def __on_scheme_annot_geometry_change(self): annot = self.sender() item = self.__item_for_annotation[annot] if isinstance(annot, scheme.SchemeTextAnnotation): item.setGeometry(QRectF(*annot.rect)) elif isinstance(annot, scheme.SchemeArrowAnnotation): p1 = item.mapFromScene(QPointF(*annot.start_pos)) p2 = item.mapFromScene(QPointF(*annot.end_pos)) item.setLine(QLineF(p1, p2)) else: pass def item_at(self, pos, type_or_tuple=None, buttons=0): """Return the item at `pos` that is an instance of the specified type (`type_or_tuple`). If `buttons` (`Qt.MouseButtons`) is given only return the item if it is the top level item that would accept any of the buttons (`QGraphicsItem.acceptedMouseButtons`). """ rect = QRectF(pos, QSizeF(1, 1)) items = self.items(rect) if buttons: items = itertools.dropwhile( lambda item: not item.acceptedMouseButtons() & buttons, items) items = list(items)[:1] if type_or_tuple: items = [i for i in items if isinstance(i, type_or_tuple)] return items[0] if items else None if USE_PYQT and PYQT_VERSION < 0x40900: # For QGraphicsObject subclasses items, itemAt ... return a # QGraphicsItem wrapper instance and not the actual class instance. def itemAt(self, *args, **kwargs): item = QGraphicsScene.itemAt(self, *args, **kwargs) return toGraphicsObjectIfPossible(item) def items(self, *args, **kwargs): items = QGraphicsScene.items(self, *args, **kwargs) return list(map(toGraphicsObjectIfPossible, items)) def selectedItems(self, *args, **kwargs): return list( map(toGraphicsObjectIfPossible, QGraphicsScene.selectedItems(self, *args, **kwargs))) def collidingItems(self, *args, **kwargs): return list( map(toGraphicsObjectIfPossible, QGraphicsScene.collidingItems(self, *args, **kwargs))) def focusItem(self, *args, **kwargs): item = QGraphicsScene.focusItem(self, *args, **kwargs) return toGraphicsObjectIfPossible(item) def mouseGrabberItem(self, *args, **kwargs): item = QGraphicsScene.mouseGrabberItem(self, *args, **kwargs) return toGraphicsObjectIfPossible(item) def mousePressEvent(self, event): if self.user_interaction_handler and \ self.user_interaction_handler.mousePressEvent(event): return # Right (context) click on the node item. If the widget is not # in the current selection then select the widget (only the widget). # Else simply return and let customContextMenuRequested signal # handle it shape_item = self.item_at(event.scenePos(), items.NodeItem) if shape_item and event.button() == Qt.RightButton and \ shape_item.flags() & QGraphicsItem.ItemIsSelectable: if not shape_item.isSelected(): self.clearSelection() shape_item.setSelected(True) return QGraphicsScene.mousePressEvent(self, event) def mouseMoveEvent(self, event): if self.user_interaction_handler and \ self.user_interaction_handler.mouseMoveEvent(event): return return QGraphicsScene.mouseMoveEvent(self, event) def mouseReleaseEvent(self, event): if self.user_interaction_handler and \ self.user_interaction_handler.mouseReleaseEvent(event): return return QGraphicsScene.mouseReleaseEvent(self, event) def mouseDoubleClickEvent(self, event): if self.user_interaction_handler and \ self.user_interaction_handler.mouseDoubleClickEvent(event): return return QGraphicsScene.mouseDoubleClickEvent(self, event) def keyPressEvent(self, event): if self.user_interaction_handler and \ self.user_interaction_handler.keyPressEvent(event): return return QGraphicsScene.keyPressEvent(self, event) def keyReleaseEvent(self, event): if self.user_interaction_handler and \ self.user_interaction_handler.keyReleaseEvent(event): return return QGraphicsScene.keyReleaseEvent(self, event) def contextMenuEvent(self, event): if self.user_interaction_handler and \ self.user_interaction_handler.contextMenuEvent(event): return super().contextMenuEvent(event) def set_user_interaction_handler(self, handler): if self.user_interaction_handler and \ not self.user_interaction_handler.isFinished(): self.user_interaction_handler.cancel() log.info("Setting interaction '%s' to '%s'" % (handler, self)) self.user_interaction_handler = handler if handler: handler.start() def event(self, event): # TODO: change the base class of Node/LinkItem to QGraphicsWidget. # It already handles font changes. if event.type() == QEvent.FontChange: self.__update_font() return QGraphicsScene.event(self, event) def __update_font(self): font = self.font() for item in self.__node_items + self.__link_items: item.setFont(font) def __str__(self): return "%s(objectName=%r, ...)" % \ (type(self).__name__, str(self.objectName()))
class ToolBox(QFrame): """ A tool box widget. """ # Emitted when a tab is toggled. tabToogled = Signal(int, bool) def setExclusive(self, exclusive): """ Set exclusive tabs (only one tab can be open at a time). """ if self.__exclusive != exclusive: self.__exclusive = exclusive self.__tabActionGroup.setExclusive(exclusive) checked = self.__tabActionGroup.checkedAction() if checked is None: # The action group can be out of sync with the actions state # when switching between exclusive states. actions_checked = [page.action for page in self.__pages if page.action.isChecked()] if actions_checked: checked = actions_checked[0] # Trigger/toggle remaining open pages if exclusive and checked is not None: for page in self.__pages: if checked != page.action and page.action.isChecked(): page.action.trigger() def exclusive(self): """ Are the tabs in the toolbox exclusive. """ return self.__exclusive exclusive_ = Property(bool, fget=exclusive, fset=setExclusive, designable=True, doc="Exclusive tabs") def __init__(self, parent=None, **kwargs): QFrame.__init__(self, parent, **kwargs) self.__pages = [] self.__tabButtonHeight = -1 self.__tabIconSize = QSize() self.__exclusive = False self.__setupUi() def __setupUi(self): layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) # Scroll area for the contents. self.__scrollArea = \ _ToolBoxScrollArea(self, objectName="toolbox-scroll-area") self.__scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) self.__scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.__scrollArea.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) self.__scrollArea.setFrameStyle(QScrollArea.NoFrame) self.__scrollArea.setWidgetResizable(True) # A widget with all of the contents. # The tabs/contents are placed in the layout inside this widget self.__contents = QWidget(self.__scrollArea, objectName="toolbox-contents") self.__contentsLayout = _ToolBoxLayout( sizeConstraint=_ToolBoxLayout.SetMinAndMaxSize, spacing=0 ) self.__contentsLayout.setContentsMargins(0, 0, 0, 0) self.__contents.setLayout(self.__contentsLayout) self.__scrollArea.setWidget(self.__contents) layout.addWidget(self.__scrollArea) self.setLayout(layout) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding) self.__tabActionGroup = \ QActionGroup(self, objectName="toolbox-tab-action-group") self.__tabActionGroup.setExclusive(self.__exclusive) self.__actionMapper = QSignalMapper(self) self.__actionMapper.mapped[QObject].connect(self.__onTabActionToogled) def setTabButtonHeight(self, height): """ Set the tab button height. """ if self.__tabButtonHeight != height: self.__tabButtonHeight = height for page in self.__pages: page.button.setFixedHeight(height) def tabButtonHeight(self): """ Return the tab button height. """ return self.__tabButtonHeight def setTabIconSize(self, size): """ Set the tab button icon size. """ if self.__tabIconSize != size: self.__tabIconSize = size for page in self.__pages: page.button.setIconSize(size) def tabIconSize(self): """ Return the tab icon size. """ return self.__tabIconSize def tabButton(self, index): """ Return the tab button at `index` """ return self.__pages[index].button def tabAction(self, index): """ Return open/close action for the tab at `index`. """ return self.__pages[index].action def addItem(self, widget, text, icon=None, toolTip=None): """ Append the `widget` in a new tab and return its index. Parameters ---------- widget : :class:`QWidget` A widget to be inserted. The toolbox takes ownership of the widget. text : str Name/title of the new tab. icon : :class:`QIcon`, optional An icon for the tab button. toolTip : str, optional Tool tip for the tab button. """ return self.insertItem(self.count(), widget, text, icon, toolTip) def insertItem(self, index, widget, text, icon=None, toolTip=None): """ Insert the `widget` in a new tab at position `index`. See also -------- ToolBox.addItem """ button = self.createTabButton(widget, text, icon, toolTip) self.__contentsLayout.insertWidget(index * 2, button) self.__contentsLayout.insertWidget(index * 2 + 1, widget) widget.hide() page = _ToolBoxPage(index, widget, button.defaultAction(), button) self.__pages.insert(index, page) for i in range(index + 1, self.count()): self.__pages[i] = self.__pages[i]._replace(index=i) self.__updatePositions() # Show (open) the first tab. if self.count() == 1 and index == 0: page.action.trigger() self.__updateSelected() self.updateGeometry() return index def removeItem(self, index): """ Remove the widget at `index`. .. note:: The widget hidden but is is not deleted. """ self.__contentsLayout.takeAt(2 * index + 1) self.__contentsLayout.takeAt(2 * index) page = self.__pages.pop(index) # Update the page indexes for i in range(index, self.count()): self.__pages[i] = self.__pages[i]._replace(index=i) page.button.deleteLater() # Hide the widget and reparent to self # This follows QToolBox.removeItem page.widget.hide() page.widget.setParent(self) self.__updatePositions() self.__updateSelected() self.updateGeometry() def count(self): """ Return the number of widgets inserted in the toolbox. """ return len(self.__pages) def widget(self, index): """ Return the widget at `index`. """ return self.__pages[index].widget def createTabButton(self, widget, text, icon=None, toolTip=None): """ Create the tab button for `widget`. """ action = QAction(text, self) action.setCheckable(True) if icon: action.setIcon(icon) if toolTip: action.setToolTip(toolTip) self.__tabActionGroup.addAction(action) self.__actionMapper.setMapping(action, action) action.toggled.connect(self.__actionMapper.map) button = ToolBoxTabButton(self, objectName="toolbox-tab-button") button.setDefaultAction(action) button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) button.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) if self.__tabIconSize.isValid(): button.setIconSize(self.__tabIconSize) if self.__tabButtonHeight > 0: button.setFixedHeight(self.__tabButtonHeight) return button def ensureWidgetVisible(self, child, xmargin=50, ymargin=50): """ Scroll the contents so child widget instance is visible inside the viewport. """ self.__scrollArea.ensureWidgetVisible(child, xmargin, ymargin) def sizeHint(self): hint = self.__contentsLayout.sizeHint() if self.count(): # Compute max width of hidden widgets also. scroll = self.__scrollArea scroll_w = scroll.verticalScrollBar().sizeHint().width() frame_w = self.frameWidth() * 2 + scroll.frameWidth() * 2 max_w = max([p.widget.sizeHint().width() for p in self.__pages]) hint = QSize(max(max_w, hint.width()) + scroll_w + frame_w, hint.height()) return QSize(200, 200).expandedTo(hint) def __onTabActionToogled(self, action): page = find(self.__pages, action, key=attrgetter("action")) on = action.isChecked() page.widget.setVisible(on) index = page.index if index > 0: # Update the `previous` tab buttons style hints previous = self.__pages[index - 1].button flag = QStyleOptionToolBox.NextIsSelected if on: previous.selected |= flag else: previous.selected &= ~flag previous.update() if index < self.count() - 1: next = self.__pages[index + 1].button flag = QStyleOptionToolBox.PreviousIsSelected if on: next.selected |= flag else: next.selected &= ~flag next.update() self.tabToogled.emit(index, on) self.__contentsLayout.invalidate() def __updateSelected(self): """Update the tab buttons selected style flags. """ if self.count() == 0: return opt = QStyleOptionToolBox def update(button, next_sel, prev_sel): if next_sel: button.selected |= opt.NextIsSelected else: button.selected &= ~opt.NextIsSelected if prev_sel: button.selected |= opt.PreviousIsSelected else: button.selected &= ~ opt.PreviousIsSelected button.update() if self.count() == 1: update(self.__pages[0].button, False, False) elif self.count() >= 2: pages = self.__pages for i in range(1, self.count() - 1): update(pages[i].button, pages[i + 1].action.isChecked(), pages[i - 1].action.isChecked()) def __updatePositions(self): """Update the tab buttons position style flags. """ if self.count() == 0: return elif self.count() == 1: self.__pages[0].button.position = QStyleOptionToolBox.OnlyOneTab else: self.__pages[0].button.position = QStyleOptionToolBox.Beginning self.__pages[-1].button.position = QStyleOptionToolBox.End for p in self.__pages[1:-1]: p.button.position = QStyleOptionToolBox.Middle for p in self.__pages: p.button.update()
class ToolGrid(QFrame): """ A widget containing a grid of actions/buttons. Actions can be added using standard :func:`QWidget.addAction(QAction)` and :func:`QWidget.insertAction(int, QAction)` methods. Parameters ---------- parent : :class:`QWidget` Parent widget. columns : int Number of columns in the grid layout. buttonSize : QSize Size of tool buttons in the grid. iconSize : QSize Size of icons in the buttons. toolButtonStyle : :class:`Qt.ToolButtonStyle` Tool button style. """ #: Signal emitted when an action is triggered actionTriggered = Signal(QAction) #: Signal emitted when an action is hovered actionHovered = Signal(QAction) def __init__(self, parent=None, columns=4, buttonSize=QSize(), iconSize=QSize(), toolButtonStyle=Qt.ToolButtonTextUnderIcon, **kwargs): # type: (Optional[QWidget], int, QSize, QSize, Qt.ToolButtonStyle, Any) -> None sizePolicy = kwargs.pop("sizePolicy", None) # type: Optional[QSizePolicy] super().__init__(parent, **kwargs) if buttonSize is None: buttonSize = QSize() if iconSize is None: iconSize = QSize() self.__columns = columns self.__buttonSize = QSize(buttonSize) self.__iconSize = QSize(iconSize) self.__toolButtonStyle = toolButtonStyle self.__gridSlots = [] # type: List[_ToolGridSlot] self.__mapper = QSignalMapper() self.__mapper.mapped[QObject].connect(self.__onClicked) layout = QGridLayout() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) self.setLayout(layout) if sizePolicy is None: self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding) self.setAttribute(Qt.WA_WState_OwnSizePolicy, True) else: self.setSizePolicy(sizePolicy) def setButtonSize(self, size): # type: (QSize) -> None """ Set the button size. """ if self.__buttonSize != size: self.__buttonSize = QSize(size) for slot in self.__gridSlots: slot.button.setFixedSize(size) def buttonSize(self): # type: () -> QSize """ Return the button size. """ return QSize(self.__buttonSize) def setIconSize(self, size): # type: (QSize) -> None """ Set the button icon size. The default icon size is style defined. """ if self.__iconSize != size: self.__iconSize = QSize(size) size = self.__effectiveIconSize() for slot in self.__gridSlots: slot.button.setIconSize(size) def iconSize(self): # type: () -> QSize """ Return the icon size. If no size is set a default style defined size is returned. """ return self.__effectiveIconSize() def __effectiveIconSize(self): # type: () -> QSize if not self.__iconSize.isValid(): opt = QStyleOptionToolButton() opt.initFrom(self) s = self.style().pixelMetric(QStyle.PM_LargeIconSize, opt, None) return QSize(s, s) else: return QSize(self.__iconSize) def changeEvent(self, event): # type: (QEvent) -> None if event.type() == QEvent.StyleChange: size = self.__effectiveIconSize() for item in self.__gridSlots: item.button.setIconSize(size) super().changeEvent(event) def setToolButtonStyle(self, style): # type: (Qt.ToolButtonStyle) -> None """ Set the tool button style. """ if self.__toolButtonStyle != style: self.__toolButtonStyle = style for slot in self.__gridSlots: slot.button.setToolButtonStyle(style) def toolButtonStyle(self): # type: () -> Qt.ToolButtonStyle """ Return the tool button style. """ return self.__toolButtonStyle def setColumnCount(self, columns): # type: (int) -> None """ Set the number of button/action columns. """ if self.__columns != columns: self.__columns = columns self.__relayout() def columns(self): # type: () -> int """ Return the number of columns in the grid. """ return self.__columns def clear(self): # type: () -> None """ Clear all actions/buttons. """ for slot in reversed(list(self.__gridSlots)): self.removeAction(slot.action) self.__gridSlots = [] def insertAction(self, before, action): # type: (Union[QAction, int], QAction) -> None """ Insert a new action at the position currently occupied by `before` (can also be an index). Parameters ---------- before : :class:`QAction` or int Position where the `action` should be inserted. action : :class:`QAction` Action to insert """ if isinstance(before, int): actions = list(self.actions()) if len(actions) == 0 or before >= len(actions): # Insert as the first action or the last action. return self.addAction(action) before = actions[before] return super().insertAction(before, action) def setActions(self, actions): # type: (Iterable[QAction]) -> None """ Clear the grid and add `actions`. """ self.clear() for action in actions: self.addAction(action) def buttonForAction(self, action): # type: (QAction) -> QToolButton """ Return the :class:`QToolButton` instance button for `action`. """ actions = [slot.action for slot in self.__gridSlots] index = actions.index(action) return self.__gridSlots[index].button def createButtonForAction(self, action): # type: (QAction) -> QToolButton """ Create and return a :class:`QToolButton` for action. """ button = ToolGridButton(self) button.setDefaultAction(action) if self.__buttonSize.isValid(): button.setFixedSize(self.__buttonSize) button.setIconSize(self.__effectiveIconSize()) button.setToolButtonStyle(self.__toolButtonStyle) button.setProperty("tool-grid-button", True) return button def count(self): # type: () -> int """ Return the number of buttons/actions in the grid. """ return len(self.__gridSlots) def actionEvent(self, event): # type: (QActionEvent) -> None super().actionEvent(event) if event.type() == QEvent.ActionAdded: # Note: the action is already in the self.actions() list. actions = list(self.actions()) index = actions.index(event.action()) self.__insertActionButton(index, event.action()) elif event.type() == QEvent.ActionRemoved: self.__removeActionButton(event.action()) def __insertActionButton(self, index, action): # type: (int, QAction) -> None """Create a button for the action and add it to the layout at index. """ self.__shiftGrid(index, 1) button = self.createButtonForAction(action) row = index // self.__columns column = index % self.__columns layout = self.layout() assert isinstance(layout, QGridLayout) layout.addWidget(button, row, column) self.__gridSlots.insert( index, _ToolGridSlot(button, action, row, column) ) self.__mapper.setMapping(button, action) button.clicked.connect(self.__mapper.map) button.installEventFilter(self) def __removeActionButton(self, action): # type: (QAction) -> None """Remove the button for the action from the layout and delete it. """ actions = [slot.action for slot in self.__gridSlots] index = actions.index(action) slot = self.__gridSlots.pop(index) slot.button.removeEventFilter(self) self.__mapper.removeMappings(slot.button) self.layout().removeWidget(slot.button) self.__shiftGrid(index + 1, -1) slot.button.deleteLater() def __shiftGrid(self, start, count=1): # type: (int, int) -> None """Shift all buttons starting at index `start` by `count` cells. """ layout = self.layout() assert isinstance(layout, QGridLayout) button_count = layout.count() columns = self.__columns direction = 1 if count >= 0 else -1 if direction == 1: start, end = button_count - 1, start - 1 else: start, end = start, button_count for index in range(start, end, -direction): item = layout.itemAtPosition( index // columns, index % columns ) if item: button = item.widget() new_index = index + count layout.addWidget( button, new_index // columns, new_index % columns, ) def __relayout(self): # type: () -> None """Relayout the buttons. """ layout = self.layout() assert isinstance(layout, QGridLayout) for i in reversed(range(layout.count())): layout.takeAt(i) self.__gridSlots = [ _ToolGridSlot(slot.button, slot.action, i // self.__columns, i % self.__columns) for i, slot in enumerate(self.__gridSlots) ] for slot in self.__gridSlots: layout.addWidget(slot.button, slot.row, slot.column) def __indexOf(self, button): # type: (QWidget) -> int """Return the index of button widget. """ buttons = [slot.button for slot in self.__gridSlots] return buttons.index(button) def __onButtonEnter(self, button): # type: (QToolButton) -> None action = button.defaultAction() self.actionHovered.emit(action) @Slot(QObject) def __onClicked(self, action): # type: (QAction) -> None assert isinstance(action, QAction) self.actionTriggered.emit(action) def eventFilter(self, obj, event): # type: (QObject, QEvent) -> bool etype = event.type() if etype == QEvent.KeyPress and obj.hasFocus(): key = event.key() if key in [Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right]: if self.__focusMove(obj, key): event.accept() return True elif etype == QEvent.HoverEnter and obj.parent() is self: self.__onButtonEnter(obj) return super().eventFilter(obj, event) def __focusMove(self, focus, key): # type: (QWidget, Qt.Key) -> bool assert focus is self.focusWidget() try: index = self.__indexOf(focus) except IndexError: return False if key == Qt.Key_Down: index += self.__columns elif key == Qt.Key_Up: index -= self.__columns elif key == Qt.Key_Left: index -= 1 elif key == Qt.Key_Right: index += 1 if 0 <= index < self.count(): button = self.__gridSlots[index].button button.setFocus(Qt.TabFocusReason) return True else: return False
class CanvasScene(QGraphicsScene): """ A Graphics Scene for displaying an :class:`~.scheme.Scheme` instance. """ #: Signal emitted when a :class:`NodeItem` has been added to the scene. node_item_added = Signal(object) #: Signal emitted when a :class:`NodeItem` has been removed from the #: scene. node_item_removed = Signal(object) #: Signal emitted when a new :class:`LinkItem` has been added to the #: scene. link_item_added = Signal(object) #: Signal emitted when a :class:`LinkItem` has been removed. link_item_removed = Signal(object) #: Signal emitted when a :class:`Annotation` item has been added. annotation_added = Signal(object) #: Signal emitted when a :class:`Annotation` item has been removed. annotation_removed = Signal(object) #: Signal emitted when the position of a :class:`NodeItem` has changed. node_item_position_changed = Signal(object, QPointF) #: Signal emitted when an :class:`NodeItem` has been double clicked. node_item_double_clicked = Signal(object) #: An node item has been activated (clicked) node_item_activated = Signal(object) #: An node item has been hovered node_item_hovered = Signal(object) #: Link item has been hovered link_item_hovered = Signal(object) def __init__(self, *args, **kwargs): QGraphicsScene.__init__(self, *args, **kwargs) self.scheme = None self.registry = None # All node items self.__node_items = [] # Mapping from SchemeNodes to canvas items self.__item_for_node = {} # All link items self.__link_items = [] # Mapping from SchemeLinks to canvas items. self.__item_for_link = {} # All annotation items self.__annotation_items = [] # Mapping from SchemeAnnotations to canvas items. self.__item_for_annotation = {} # Is the scene editable self.editable = True # Anchor Layout self.__anchor_layout = AnchorLayout() self.addItem(self.__anchor_layout) self.__channel_names_visible = True self.__node_animation_enabled = True self.user_interaction_handler = None self.activated_mapper = QSignalMapper(self) self.activated_mapper.mapped[QObject].connect( lambda node: self.node_item_activated.emit(node) ) self.hovered_mapper = QSignalMapper(self) self.hovered_mapper.mapped[QObject].connect( lambda node: self.node_item_hovered.emit(node) ) self.position_change_mapper = QSignalMapper(self) self.position_change_mapper.mapped[QObject].connect( self._on_position_change ) log.info("'%s' intitialized." % self) def clear_scene(self): """ Clear (reset) the scene. """ if self.scheme is not None: self.scheme.node_added.disconnect(self.add_node) self.scheme.node_removed.disconnect(self.remove_node) self.scheme.link_added.disconnect(self.add_link) self.scheme.link_removed.disconnect(self.remove_link) self.scheme.annotation_added.disconnect(self.add_annotation) self.scheme.annotation_removed.disconnect(self.remove_annotation) self.scheme.node_state_changed.disconnect( self.on_widget_state_change ) self.scheme.channel_state_changed.disconnect( self.on_link_state_change ) # Remove all items to make sure all signals from scheme items # to canvas items are disconnected. for annot in self.scheme.annotations: if annot in self.__item_for_annotation: self.remove_annotation(annot) for link in self.scheme.links: if link in self.__item_for_link: self.remove_link(link) for node in self.scheme.nodes: if node in self.__item_for_node: self.remove_node(node) self.scheme = None self.__node_items = [] self.__item_for_node = {} self.__link_items = [] self.__item_for_link = {} self.__annotation_items = [] self.__item_for_annotation = {} self.__anchor_layout.deleteLater() self.user_interaction_handler = None self.clear() log.info("'%s' cleared." % self) def set_scheme(self, scheme): """ Set the scheme to display. Populates the scene with nodes and links already in the scheme. Any further change to the scheme will be reflected in the scene. Parameters ---------- scheme : :class:`~.scheme.Scheme` """ if self.scheme is not None: # Clear the old scheme self.clear_scene() log.info("Setting scheme '%s' on '%s'" % (scheme, self)) self.scheme = scheme if self.scheme is not None: self.scheme.node_added.connect(self.add_node) self.scheme.node_removed.connect(self.remove_node) self.scheme.link_added.connect(self.add_link) self.scheme.link_removed.connect(self.remove_link) self.scheme.annotation_added.connect(self.add_annotation) self.scheme.annotation_removed.connect(self.remove_annotation) self.scheme.node_state_changed.connect( self.on_widget_state_change ) self.scheme.channel_state_changed.connect( self.on_link_state_change ) self.scheme.topology_changed.connect(self.on_scheme_change) for node in scheme.nodes: self.add_node(node) for link in scheme.links: self.add_link(link) for annot in scheme.annotations: self.add_annotation(annot) def set_registry(self, registry): """ Set the widget registry. """ # TODO: Remove/Deprecate. Is used only to get the category/background # color. That should be part of the SchemeNode/WidgetDescription. log.info("Setting registry '%s on '%s'." % (registry, self)) self.registry = registry def set_anchor_layout(self, layout): """ Set an :class:`~.layout.AnchorLayout` """ if self.__anchor_layout != layout: if self.__anchor_layout: self.__anchor_layout.deleteLater() self.__anchor_layout = None self.__anchor_layout = layout def anchor_layout(self): """ Return the anchor layout instance. """ return self.__anchor_layout def set_channel_names_visible(self, visible): """ Set the channel names visibility. """ self.__channel_names_visible = visible for link in self.__link_items: link.setChannelNamesVisible(visible) def channel_names_visible(self): """ Return the channel names visibility state. """ return self.__channel_names_visible def set_node_animation_enabled(self, enabled): """ Set node animation enabled state. """ if self.__node_animation_enabled != enabled: self.__node_animation_enabled = enabled for node in self.__node_items: node.setAnimationEnabled(enabled) def add_node_item(self, item): """ Add a :class:`.NodeItem` instance to the scene. """ if item in self.__node_items: raise ValueError("%r is already in the scene." % item) if item.pos().isNull(): if self.__node_items: pos = self.__node_items[-1].pos() + QPointF(150, 0) else: pos = QPointF(150, 150) item.setPos(pos) item.setFont(self.font()) # Set signal mappings self.activated_mapper.setMapping(item, item) item.activated.connect(self.activated_mapper.map) self.hovered_mapper.setMapping(item, item) item.hovered.connect(self.hovered_mapper.map) self.position_change_mapper.setMapping(item, item) item.positionChanged.connect(self.position_change_mapper.map) self.addItem(item) self.__node_items.append(item) self.node_item_added.emit(item) log.info("Added item '%s' to '%s'" % (item, self)) return item def add_node(self, node): """ Add and return a default constructed :class:`.NodeItem` for a :class:`SchemeNode` instance `node`. If the `node` is already in the scene do nothing and just return its item. """ if node in self.__item_for_node: # Already added return self.__item_for_node[node] item = self.new_node_item(node.description) if node.position: pos = QPointF(*node.position) item.setPos(pos) item.setTitle(node.title) item.setProcessingState(node.processing_state) item.setProgress(node.progress) for message in node.state_messages(): item.setStateMessage(message) item.setStatusMessage(node.status_message()) self.__item_for_node[node] = item node.position_changed.connect(self.__on_node_pos_changed) node.title_changed.connect(item.setTitle) node.progress_changed.connect(item.setProgress) node.processing_state_changed.connect(item.setProcessingState) node.state_message_changed.connect(item.setStateMessage) node.status_message_changed.connect(item.setStatusMessage) return self.add_node_item(item) def new_node_item(self, widget_desc, category_desc=None): """ Construct an new :class:`.NodeItem` from a `WidgetDescription`. Optionally also set `CategoryDescription`. """ item = items.NodeItem() item.setWidgetDescription(widget_desc) if category_desc is None and self.registry and widget_desc.category: category_desc = self.registry.category(widget_desc.category) if category_desc is None and self.registry is not None: try: category_desc = self.registry.category(widget_desc.category) except KeyError: pass if category_desc is not None: item.setWidgetCategory(category_desc) item.setAnimationEnabled(self.__node_animation_enabled) return item def remove_node_item(self, item): """ Remove `item` (:class:`.NodeItem`) from the scene. """ self.activated_mapper.removeMappings(item) self.hovered_mapper.removeMappings(item) self.position_change_mapper.removeMappings(item) item.hide() self.removeItem(item) self.__node_items.remove(item) self.node_item_removed.emit(item) log.info("Removed item '%s' from '%s'" % (item, self)) def remove_node(self, node): """ Remove the :class:`.NodeItem` instance that was previously constructed for a :class:`SchemeNode` `node` using the `add_node` method. """ item = self.__item_for_node.pop(node) node.position_changed.disconnect(self.__on_node_pos_changed) node.title_changed.disconnect(item.setTitle) node.progress_changed.disconnect(item.setProgress) node.processing_state_changed.disconnect(item.setProcessingState) node.state_message_changed.disconnect(item.setStateMessage) self.remove_node_item(item) def node_items(self): """ Return all :class:`.NodeItem` instances in the scene. """ return list(self.__node_items) def add_link_item(self, item): """ Add a link (:class:`.LinkItem`) to the scene. """ if item.scene() is not self: self.addItem(item) item.setFont(self.font()) self.__link_items.append(item) self.link_item_added.emit(item) log.info("Added link %r -> %r to '%s'" % \ (item.sourceItem.title(), item.sinkItem.title(), self)) self.__anchor_layout.invalidateLink(item) return item def add_link(self, scheme_link): """ Create and add a :class:`.LinkItem` instance for a :class:`SchemeLink` instance. If the link is already in the scene do nothing and just return its :class:`.LinkItem`. """ if scheme_link in self.__item_for_link: return self.__item_for_link[scheme_link] source = self.__item_for_node[scheme_link.source_node] sink = self.__item_for_node[scheme_link.sink_node] item = self.new_link_item(source, scheme_link.source_channel, sink, scheme_link.sink_channel) item.setEnabled(scheme_link.enabled) scheme_link.enabled_changed.connect(item.setEnabled) if scheme_link.is_dynamic(): item.setDynamic(True) item.setDynamicEnabled(scheme_link.dynamic_enabled) scheme_link.dynamic_enabled_changed.connect(item.setDynamicEnabled) item.setRuntimeState(scheme_link.runtime_state()) scheme_link.state_changed.connect(item.setRuntimeState) self.add_link_item(item) self.__item_for_link[scheme_link] = item return item def new_link_item(self, source_item, source_channel, sink_item, sink_channel): """ Construct and return a new :class:`.LinkItem` """ item = items.LinkItem() item.setSourceItem(source_item) item.setSinkItem(sink_item) def channel_name(channel): if isinstance(channel, str): return channel else: return channel.name source_name = channel_name(source_channel) sink_name = channel_name(sink_channel) fmt = "<b>{0}</b> \u2192 <b>{1}</b>" item.setToolTip( fmt.format(escape(source_name), escape(sink_name)) ) item.setSourceName(source_name) item.setSinkName(sink_name) item.setChannelNamesVisible(self.__channel_names_visible) return item def remove_link_item(self, item): """ Remove a link (:class:`.LinkItem`) from the scene. """ # Invalidate the anchor layout. self.__anchor_layout.invalidateAnchorItem( item.sourceItem.outputAnchorItem ) self.__anchor_layout.invalidateAnchorItem( item.sinkItem.inputAnchorItem ) self.__link_items.remove(item) # Remove the anchor points. item.removeLink() self.removeItem(item) self.link_item_removed.emit(item) log.info("Removed link '%s' from '%s'" % (item, self)) return item def remove_link(self, scheme_link): """ Remove a :class:`.LinkItem` instance that was previously constructed for a :class:`SchemeLink` instance `link` using the `add_link` method. """ item = self.__item_for_link.pop(scheme_link) scheme_link.enabled_changed.disconnect(item.setEnabled) if scheme_link.is_dynamic(): scheme_link.dynamic_enabled_changed.disconnect( item.setDynamicEnabled ) scheme_link.state_changed.disconnect(item.setRuntimeState) self.remove_link_item(item) def link_items(self): """ Return all :class:`.LinkItem`\s in the scene. """ return list(self.__link_items) def add_annotation_item(self, annotation): """ Add an :class:`.Annotation` item to the scene. """ self.__annotation_items.append(annotation) self.addItem(annotation) self.annotation_added.emit(annotation) return annotation def add_annotation(self, scheme_annot): """ Create a new item for :class:`SchemeAnnotation` and add it to the scene. If the `scheme_annot` is already in the scene do nothing and just return its item. """ if scheme_annot in self.__item_for_annotation: # Already added return self.__item_for_annotation[scheme_annot] if isinstance(scheme_annot, scheme.SchemeTextAnnotation): item = items.TextAnnotation() x, y, w, h = scheme_annot.rect item.setPos(x, y) item.resize(w, h) item.setTextInteractionFlags(Qt.TextEditorInteraction) font = font_from_dict(scheme_annot.font, item.font()) item.setFont(font) item.setContent(scheme_annot.content, scheme_annot.content_type) scheme_annot.content_changed.connect(item.setContent) elif isinstance(scheme_annot, scheme.SchemeArrowAnnotation): item = items.ArrowAnnotation() start, end = scheme_annot.start_pos, scheme_annot.end_pos item.setLine(QLineF(QPointF(*start), QPointF(*end))) item.setColor(QColor(scheme_annot.color)) scheme_annot.geometry_changed.connect( self.__on_scheme_annot_geometry_change ) self.add_annotation_item(item) self.__item_for_annotation[scheme_annot] = item return item def remove_annotation_item(self, annotation): """ Remove an :class:`.Annotation` instance from the scene. """ self.__annotation_items.remove(annotation) self.removeItem(annotation) self.annotation_removed.emit(annotation) def remove_annotation(self, scheme_annotation): """ Remove an :class:`.Annotation` instance that was previously added using :func:`add_anotation`. """ item = self.__item_for_annotation.pop(scheme_annotation) scheme_annotation.geometry_changed.disconnect( self.__on_scheme_annot_geometry_change ) if isinstance(scheme_annotation, scheme.SchemeTextAnnotation): scheme_annotation.content_changed.disconnect(item.setContent) self.remove_annotation_item(item) def annotation_items(self): """ Return all :class:`.Annotation` items in the scene. """ return self.__annotation_items def item_for_annotation(self, scheme_annotation): return self.__item_for_annotation[scheme_annotation] def annotation_for_item(self, item): rev = dict(reversed(item) \ for item in self.__item_for_annotation.items()) return rev[item] def commit_scheme_node(self, node): """ Commit the `node` into the scheme. """ if not self.editable: raise Exception("Scheme not editable.") if node not in self.__item_for_node: raise ValueError("No 'NodeItem' for node.") item = self.__item_for_node[node] try: self.scheme.add_node(node) except Exception: log.error("An error occurred while committing node '%s'", node, exc_info=True) # Cleanup (remove the node item) self.remove_node_item(item) raise log.info("Commited node '%s' from '%s' to '%s'" % \ (node, self, self.scheme)) def commit_scheme_link(self, link): """ Commit a scheme link. """ if not self.editable: raise Exception("Scheme not editable") if link not in self.__item_for_link: raise ValueError("No 'LinkItem' for link.") self.scheme.add_link(link) log.info("Commited link '%s' from '%s' to '%s'" % \ (link, self, self.scheme)) def node_for_item(self, item): """ Return the `SchemeNode` for the `item`. """ rev = dict([(v, k) for k, v in self.__item_for_node.items()]) return rev[item] def item_for_node(self, node): """ Return the :class:`NodeItem` instance for a :class:`SchemeNode`. """ return self.__item_for_node[node] def link_for_item(self, item): """ Return the `SchemeLink for `item` (:class:`LinkItem`). """ rev = dict([(v, k) for k, v in self.__item_for_link.items()]) return rev[item] def item_for_link(self, link): """ Return the :class:`LinkItem` for a :class:`SchemeLink` """ return self.__item_for_link[link] def selected_node_items(self): """ Return the selected :class:`NodeItem`'s. """ return [item for item in self.__node_items if item.isSelected()] def selected_annotation_items(self): """ Return the selected :class:`Annotation`'s """ return [item for item in self.__annotation_items if item.isSelected()] def node_links(self, node_item): """ Return all links from the `node_item` (:class:`NodeItem`). """ return self.node_output_links(node_item) + \ self.node_input_links(node_item) def node_output_links(self, node_item): """ Return a list of all output links from `node_item`. """ return [link for link in self.__link_items if link.sourceItem == node_item] def node_input_links(self, node_item): """ Return a list of all input links for `node_item`. """ return [link for link in self.__link_items if link.sinkItem == node_item] def neighbor_nodes(self, node_item): """ Return a list of `node_item`'s (class:`NodeItem`) neighbor nodes. """ neighbors = list(map(attrgetter("sourceItem"), self.node_input_links(node_item))) neighbors.extend(map(attrgetter("sinkItem"), self.node_output_links(node_item))) return neighbors def on_widget_state_change(self, widget, state): pass def on_link_state_change(self, link, state): pass def on_scheme_change(self, ): pass def _on_position_change(self, item): # Invalidate the anchor point layout and schedule a layout. self.__anchor_layout.invalidateNode(item) self.node_item_position_changed.emit(item, item.pos()) def __on_node_pos_changed(self, pos): node = self.sender() item = self.__item_for_node[node] item.setPos(*pos) def __on_scheme_annot_geometry_change(self): annot = self.sender() item = self.__item_for_annotation[annot] if isinstance(annot, scheme.SchemeTextAnnotation): item.setGeometry(QRectF(*annot.rect)) elif isinstance(annot, scheme.SchemeArrowAnnotation): p1 = item.mapFromScene(QPointF(*annot.start_pos)) p2 = item.mapFromScene(QPointF(*annot.end_pos)) item.setLine(QLineF(p1, p2)) else: pass def item_at(self, pos, type_or_tuple=None, buttons=0): """Return the item at `pos` that is an instance of the specified type (`type_or_tuple`). If `buttons` (`Qt.MouseButtons`) is given only return the item if it is the top level item that would accept any of the buttons (`QGraphicsItem.acceptedMouseButtons`). """ rect = QRectF(pos, QSizeF(1, 1)) items = self.items(rect) if buttons: items = itertools.dropwhile( lambda item: not item.acceptedMouseButtons() & buttons, items ) items = list(items)[:1] if type_or_tuple: items = [i for i in items if isinstance(i, type_or_tuple)] return items[0] if items else None if USE_PYQT and PYQT_VERSION < 0x40900: # For QGraphicsObject subclasses items, itemAt ... return a # QGraphicsItem wrapper instance and not the actual class instance. def itemAt(self, *args, **kwargs): item = QGraphicsScene.itemAt(self, *args, **kwargs) return toGraphicsObjectIfPossible(item) def items(self, *args, **kwargs): items = QGraphicsScene.items(self, *args, **kwargs) return list(map(toGraphicsObjectIfPossible, items)) def selectedItems(self, *args, **kwargs): return [toGraphicsObjectIfPossible(obj) for obj in QGraphicsScene.selectedItems(self, *args, **kwargs)] def collidingItems(self, *args, **kwargs): return [toGraphicsObjectIfPossible(obj) for obj in QGraphicsScene.collidingItems(self, *args, **kwargs)] def focusItem(self, *args, **kwargs): item = QGraphicsScene.focusItem(self, *args, **kwargs) return toGraphicsObjectIfPossible(item) def mouseGrabberItem(self, *args, **kwargs): item = QGraphicsScene.mouseGrabberItem(self, *args, **kwargs) return toGraphicsObjectIfPossible(item) def mousePressEvent(self, event): if self.user_interaction_handler and \ self.user_interaction_handler.mousePressEvent(event): return # Right (context) click on the node item. If the widget is not # in the current selection then select the widget (only the widget). # Else simply return and let customContextMenuRequested signal # handle it shape_item = self.item_at(event.scenePos(), items.NodeItem) if shape_item and event.button() == Qt.RightButton and \ shape_item.flags() & QGraphicsItem.ItemIsSelectable: if not shape_item.isSelected(): self.clearSelection() shape_item.setSelected(True) return QGraphicsScene.mousePressEvent(self, event) def mouseMoveEvent(self, event): if self.user_interaction_handler and \ self.user_interaction_handler.mouseMoveEvent(event): return return QGraphicsScene.mouseMoveEvent(self, event) def mouseReleaseEvent(self, event): if self.user_interaction_handler and \ self.user_interaction_handler.mouseReleaseEvent(event): return return QGraphicsScene.mouseReleaseEvent(self, event) def mouseDoubleClickEvent(self, event): if self.user_interaction_handler and \ self.user_interaction_handler.mouseDoubleClickEvent(event): return return QGraphicsScene.mouseDoubleClickEvent(self, event) def keyPressEvent(self, event): if self.user_interaction_handler and \ self.user_interaction_handler.keyPressEvent(event): return return QGraphicsScene.keyPressEvent(self, event) def keyReleaseEvent(self, event): if self.user_interaction_handler and \ self.user_interaction_handler.keyReleaseEvent(event): return return QGraphicsScene.keyReleaseEvent(self, event) def contextMenuEvent(self, event): if self.user_interaction_handler and \ self.user_interaction_handler.contextMenuEvent(event): return super().contextMenuEvent(event) def set_user_interaction_handler(self, handler): if self.user_interaction_handler and \ not self.user_interaction_handler.isFinished(): self.user_interaction_handler.cancel() log.info("Setting interaction '%s' to '%s'" % (handler, self)) self.user_interaction_handler = handler if handler: handler.start() def __str__(self): return "%s(objectName=%r, ...)" % \ (type(self).__name__, str(self.objectName()))
class ToolGrid(QFrame): """ A widget containing a grid of actions/buttons. Actions can be added using standard :func:`QWidget.addAction(QAction)` and :func:`QWidget.insertAction(int, QAction)` methods. Parameters ---------- parent : :class:`QWidget` Parent widget. columns : int Number of columns in the grid layout. buttonSize : :class:`QSize`, optional Size of tool buttons in the grid. iconSize : :class:`QSize`, optional Size of icons in the buttons. toolButtonStyle : :class:`Qt.ToolButtonStyle` Tool button style. """ actionTriggered = Signal(QAction) actionHovered = Signal(QAction) def __init__(self, parent=None, columns=4, buttonSize=None, iconSize=None, toolButtonStyle=Qt.ToolButtonTextUnderIcon): QFrame.__init__(self, parent) if buttonSize is not None: buttonSize = QSize(buttonSize) if iconSize is not None: iconSize = QSize(iconSize) self.__columns = columns self.__buttonSize = buttonSize or QSize(50, 50) self.__iconSize = iconSize or QSize(26, 26) self.__toolButtonStyle = toolButtonStyle self.__gridSlots = [] self.__mapper = QSignalMapper() self.__mapper.mapped[QObject].connect(self.__onClicked) self.__setupUi() def __setupUi(self): layout = QGridLayout() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.setSizeConstraint(QGridLayout.SetFixedSize) self.setLayout(layout) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding) def setButtonSize(self, size): """ Set the button size. """ if self.__buttonSize != size: self.__buttonSize = size for slot in self.__gridSlots: slot.button.setFixedSize(size) def buttonSize(self): """ Return the button size. """ return QSize(self.__buttonSize) def setIconSize(self, size): """ Set the button icon size. """ if self.__iconSize != size: self.__iconSize = size for slot in self.__gridSlots: slot.button.setIconSize(size) def iconSize(self): """ Return the icon size """ return QSize(self.__iconSize) def setToolButtonStyle(self, style): """ Set the tool button style. """ if self.__toolButtonStyle != style: self.__toolButtonStyle = style for slot in self.__gridSlots: slot.button.setToolButtonStyle(style) def toolButtonStyle(self): """ Return the tool button style. """ return self.__toolButtonStyle def setColumnCount(self, columns): """ Set the number of button/action columns. """ if self.__columns != columns: self.__columns = columns self.__relayout() def columns(self): """ Return the number of columns in the grid. """ return self.__columns def clear(self): """ Clear all actions/buttons. """ for slot in reversed(list(self.__gridSlots)): self.removeAction(slot.action) self.__gridSlots = [] def insertAction(self, before, action): """ Insert a new action at the position currently occupied by `before` (can also be an index). Parameters ---------- before : :class:`QAction` or int Position where the `action` should be inserted. action : :class:`QAction` Action to insert """ if isinstance(before, int): actions = list(self.actions()) if len(actions) == 0 or before >= len(actions): # Insert as the first action or the last action. return self.addAction(action) before = actions[before] return QFrame.insertAction(self, before, action) def setActions(self, actions): """ Clear the grid and add `actions`. """ self.clear() for action in actions: self.addAction(action) def buttonForAction(self, action): """ Return the :class:`QToolButton` instance button for `action`. """ actions = [slot.action for slot in self.__gridSlots] index = actions.index(action) return self.__gridSlots[index].button def createButtonForAction(self, action): """ Create and return a :class:`QToolButton` for action. """ button = _ToolGridButton(self) button.setDefaultAction(action) if self.__buttonSize.isValid(): button.setFixedSize(self.__buttonSize) if self.__iconSize.isValid(): button.setIconSize(self.__iconSize) button.setToolButtonStyle(self.__toolButtonStyle) button.setProperty("tool-grid-button", qtcompat.qwrap(True)) return button def count(self): """ Return the number of buttons/actions in the grid. """ return len(self.__gridSlots) def actionEvent(self, event): QFrame.actionEvent(self, event) if event.type() == QEvent.ActionAdded: # Note: the action is already in the self.actions() list. actions = list(self.actions()) index = actions.index(event.action()) self.__insertActionButton(index, event.action()) elif event.type() == QEvent.ActionRemoved: self.__removeActionButton(event.action()) def __insertActionButton(self, index, action): """Create a button for the action and add it to the layout at index. """ self.__shiftGrid(index, 1) button = self.createButtonForAction(action) row = index // self.__columns column = index % self.__columns self.layout().addWidget( button, row, column, Qt.AlignLeft | Qt.AlignTop ) self.__gridSlots.insert( index, _ToolGridSlot(button, action, row, column) ) self.__mapper.setMapping(button, action) button.clicked.connect(self.__mapper.map) button.installEventFilter(self) def __removeActionButton(self, action): """Remove the button for the action from the layout and delete it. """ actions = [slot.action for slot in self.__gridSlots] index = actions.index(action) slot = self.__gridSlots.pop(index) slot.button.removeEventFilter(self) self.__mapper.removeMappings(slot.button) self.layout().removeWidget(slot.button) self.__shiftGrid(index + 1, -1) slot.button.deleteLater() def __shiftGrid(self, start, count=1): """Shift all buttons starting at index `start` by `count` cells. """ button_count = self.layout().count() direction = 1 if count >= 0 else -1 if direction == 1: start, end = button_count - 1, start - 1 else: start, end = start, button_count for index in range(start, end, -direction): item = self.layout().itemAtPosition(index / self.__columns, index % self.__columns) if item: button = item.widget() new_index = index + count self.layout().addWidget(button, new_index / self.__columns, new_index % self.__columns, Qt.AlignLeft | Qt.AlignTop) def __relayout(self): """Relayout the buttons. """ for i in reversed(range(self.layout().count())): self.layout().takeAt(i) self.__gridSlots = [_ToolGridSlot(slot.button, slot.action, i / self.__columns, i % self.__columns) for i, slot in enumerate(self.__gridSlots)] for slot in self.__gridSlots: self.layout().addWidget(slot.button, slot.row, slot.column, Qt.AlignLeft | Qt.AlignTop) def __indexOf(self, button): """Return the index of button widget. """ buttons = [slot.button for slot in self.__gridSlots] return buttons.index(button) def __onButtonEnter(self, button): action = button.defaultAction() self.actionHovered.emit(action) def __onClicked(self, action): self.actionTriggered.emit(action) def paintEvent(self, event): return utils.StyledWidget_paintEvent(self, event) def eventFilter(self, obj, event): etype = event.type() if etype == QEvent.KeyPress and obj.hasFocus(): key = event.key() if key in [Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right]: if self.__focusMove(obj, key): event.accept() return True elif etype == QEvent.HoverEnter and obj.parent() is self: self.__onButtonEnter(obj) return QFrame.eventFilter(self, obj, event) def __focusMove(self, focus, key): assert(focus is self.focusWidget()) try: index = self.__indexOf(focus) except IndexError: return False if key == Qt.Key_Down: index += self.__columns elif key == Qt.Key_Up: index -= self.__columns elif key == Qt.Key_Left: index -= 1 elif key == Qt.Key_Right: index += 1 if index >= 0 and index < self.count(): button = self.__gridSlots[index].button button.setFocus(Qt.TabFocusReason) return True else: return False
class ToolGrid(QFrame): """ A widget containing a grid of actions/buttons. Actions can be added using standard :func:`QWidget.addAction(QAction)` and :func:`QWidget.insertAction(int, QAction)` methods. Parameters ---------- parent : :class:`QWidget` Parent widget. columns : int Number of columns in the grid layout. buttonSize : :class:`QSize`, optional Size of tool buttons in the grid. iconSize : :class:`QSize`, optional Size of icons in the buttons. toolButtonStyle : :class:`Qt.ToolButtonStyle` Tool button style. """ actionTriggered = Signal(QAction) actionHovered = Signal(QAction) def __init__(self, parent=None, columns=4, buttonSize=None, iconSize=None, toolButtonStyle=Qt.ToolButtonTextUnderIcon): super().__init__(parent) if buttonSize is not None: buttonSize = QSize(buttonSize) if iconSize is not None: iconSize = QSize(iconSize) self.__columns = columns self.__buttonSize = buttonSize or QSize(50, 50) self.__iconSize = iconSize or QSize(26, 26) self.__toolButtonStyle = toolButtonStyle self.__gridSlots = [] self.__mapper = QSignalMapper() self.__mapper.mapped[QObject].connect(self.__onClicked) self.__setupUi() def __setupUi(self): layout = QGridLayout() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.setSizeConstraint(QGridLayout.SetFixedSize) self.setLayout(layout) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding) def setButtonSize(self, size): """ Set the button size. """ if self.__buttonSize != size: self.__buttonSize = size for slot in self.__gridSlots: slot.button.setFixedSize(size) def buttonSize(self): """ Return the button size. """ return QSize(self.__buttonSize) def setIconSize(self, size): """ Set the button icon size. """ if self.__iconSize != size: self.__iconSize = size for slot in self.__gridSlots: slot.button.setIconSize(size) def iconSize(self): """ Return the icon size """ return QSize(self.__iconSize) def setToolButtonStyle(self, style): """ Set the tool button style. """ if self.__toolButtonStyle != style: self.__toolButtonStyle = style for slot in self.__gridSlots: slot.button.setToolButtonStyle(style) def toolButtonStyle(self): """ Return the tool button style. """ return self.__toolButtonStyle def setColumnCount(self, columns): """ Set the number of button/action columns. """ if self.__columns != columns: self.__columns = columns self.__relayout() def columns(self): """ Return the number of columns in the grid. """ return self.__columns def clear(self): """ Clear all actions/buttons. """ for slot in reversed(list(self.__gridSlots)): self.removeAction(slot.action) self.__gridSlots = [] def insertAction(self, before, action): """ Insert a new action at the position currently occupied by `before` (can also be an index). Parameters ---------- before : :class:`QAction` or int Position where the `action` should be inserted. action : :class:`QAction` Action to insert """ if isinstance(before, int): actions = list(self.actions()) if len(actions) == 0 or before >= len(actions): # Insert as the first action or the last action. return self.addAction(action) before = actions[before] return super().insertAction(before, action) def setActions(self, actions): """ Clear the grid and add `actions`. """ self.clear() for action in actions: self.addAction(action) def buttonForAction(self, action): """ Return the :class:`QToolButton` instance button for `action`. """ actions = [slot.action for slot in self.__gridSlots] index = actions.index(action) return self.__gridSlots[index].button def createButtonForAction(self, action): """ Create and return a :class:`QToolButton` for action. """ button = _ToolGridButton(self) button.setDefaultAction(action) if self.__buttonSize.isValid(): button.setFixedSize(self.__buttonSize) if self.__iconSize.isValid(): button.setIconSize(self.__iconSize) button.setToolButtonStyle(self.__toolButtonStyle) button.setProperty("tool-grid-button", True) return button def count(self): """ Return the number of buttons/actions in the grid. """ return len(self.__gridSlots) def actionEvent(self, event): super().actionEvent(event) if event.type() == QEvent.ActionAdded: # Note: the action is already in the self.actions() list. actions = list(self.actions()) index = actions.index(event.action()) self.__insertActionButton(index, event.action()) elif event.type() == QEvent.ActionRemoved: self.__removeActionButton(event.action()) def __insertActionButton(self, index, action): """Create a button for the action and add it to the layout at index. """ self.__shiftGrid(index, 1) button = self.createButtonForAction(action) row = index // self.__columns column = index % self.__columns self.layout().addWidget( button, row, column, Qt.AlignLeft | Qt.AlignTop ) self.__gridSlots.insert( index, _ToolGridSlot(button, action, row, column) ) self.__mapper.setMapping(button, action) button.clicked.connect(self.__mapper.map) button.installEventFilter(self) def __removeActionButton(self, action): """Remove the button for the action from the layout and delete it. """ actions = [slot.action for slot in self.__gridSlots] index = actions.index(action) slot = self.__gridSlots.pop(index) slot.button.removeEventFilter(self) self.__mapper.removeMappings(slot.button) self.layout().removeWidget(slot.button) self.__shiftGrid(index + 1, -1) slot.button.deleteLater() def __shiftGrid(self, start, count=1): """Shift all buttons starting at index `start` by `count` cells. """ button_count = self.layout().count() direction = 1 if count >= 0 else -1 if direction == 1: start, end = button_count - 1, start - 1 else: start, end = start, button_count for index in range(start, end, -direction): item = self.layout().itemAtPosition(index / self.__columns, index % self.__columns) if item: button = item.widget() new_index = index + count self.layout().addWidget(button, new_index / self.__columns, new_index % self.__columns, Qt.AlignLeft | Qt.AlignTop) def __relayout(self): """Relayout the buttons. """ for i in reversed(range(self.layout().count())): self.layout().takeAt(i) self.__gridSlots = [_ToolGridSlot(slot.button, slot.action, i / self.__columns, i % self.__columns) for i, slot in enumerate(self.__gridSlots)] for slot in self.__gridSlots: self.layout().addWidget(slot.button, slot.row, slot.column, Qt.AlignLeft | Qt.AlignTop) def __indexOf(self, button): """Return the index of button widget. """ buttons = [slot.button for slot in self.__gridSlots] return buttons.index(button) def __onButtonEnter(self, button): action = button.defaultAction() self.actionHovered.emit(action) def __onClicked(self, action): self.actionTriggered.emit(action) def paintEvent(self, event): return utils.StyledWidget_paintEvent(self, event) def eventFilter(self, obj, event): etype = event.type() if etype == QEvent.KeyPress and obj.hasFocus(): key = event.key() if key in [Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right]: if self.__focusMove(obj, key): event.accept() return True elif etype == QEvent.HoverEnter and obj.parent() is self: self.__onButtonEnter(obj) return super().eventFilter(obj, event) def __focusMove(self, focus, key): assert(focus is self.focusWidget()) try: index = self.__indexOf(focus) except IndexError: return False if key == Qt.Key_Down: index += self.__columns elif key == Qt.Key_Up: index -= self.__columns elif key == Qt.Key_Left: index -= 1 elif key == Qt.Key_Right: index += 1 if index >= 0 and index < self.count(): button = self.__gridSlots[index].button button.setFocus(Qt.TabFocusReason) return True else: return False
class ToolBox(QFrame): """ A tool box widget. """ # Emitted when a tab is toggled. tabToogled = Signal(int, bool) def setExclusive(self, exclusive): """ Set exclusive tabs (only one tab can be open at a time). """ if self.__exclusive != exclusive: self.__exclusive = exclusive self.__tabActionGroup.setExclusive(exclusive) checked = self.__tabActionGroup.checkedAction() if checked is None: # The action group can be out of sync with the actions state # when switching between exclusive states. actions_checked = [page.action for page in self.__pages if page.action.isChecked()] if actions_checked: checked = actions_checked[0] # Trigger/toggle remaining open pages if exclusive and checked is not None: for page in self.__pages: if checked != page.action and page.action.isChecked(): page.action.trigger() def exclusive(self): """ Are the tabs in the toolbox exclusive. """ return self.__exclusive exclusive_ = Property(bool, fget=exclusive, fset=setExclusive, designable=True, doc="Exclusive tabs") def __init__(self, parent=None, **kwargs): super().__init__(parent, **kwargs) self.__pages = [] self.__tabButtonHeight = -1 self.__tabIconSize = QSize() self.__exclusive = False self.__setupUi() def __setupUi(self): layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) # Scroll area for the contents. self.__scrollArea = \ _ToolBoxScrollArea(self, objectName="toolbox-scroll-area") self.__scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) self.__scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.__scrollArea.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) self.__scrollArea.setFrameStyle(QScrollArea.NoFrame) self.__scrollArea.setWidgetResizable(True) # A widget with all of the contents. # The tabs/contents are placed in the layout inside this widget self.__contents = QWidget(self.__scrollArea, objectName="toolbox-contents") self.__contentsLayout = _ToolBoxLayout( sizeConstraint=_ToolBoxLayout.SetMinAndMaxSize, spacing=0 ) self.__contentsLayout.setContentsMargins(0, 0, 0, 0) self.__contents.setLayout(self.__contentsLayout) self.__scrollArea.setWidget(self.__contents) layout.addWidget(self.__scrollArea) self.setLayout(layout) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding) self.__tabActionGroup = \ QActionGroup(self, objectName="toolbox-tab-action-group") self.__tabActionGroup.setExclusive(self.__exclusive) self.__actionMapper = QSignalMapper(self) self.__actionMapper.mapped[QObject].connect(self.__onTabActionToogled) def setTabButtonHeight(self, height): """ Set the tab button height. """ if self.__tabButtonHeight != height: self.__tabButtonHeight = height for page in self.__pages: page.button.setFixedHeight(height) def tabButtonHeight(self): """ Return the tab button height. """ return self.__tabButtonHeight def setTabIconSize(self, size): """ Set the tab button icon size. """ if self.__tabIconSize != size: self.__tabIconSize = size for page in self.__pages: page.button.setIconSize(size) def tabIconSize(self): """ Return the tab icon size. """ return self.__tabIconSize def tabButton(self, index): """ Return the tab button at `index` """ return self.__pages[index].button def tabAction(self, index): """ Return open/close action for the tab at `index`. """ return self.__pages[index].action def addItem(self, widget, text, icon=None, toolTip=None): """ Append the `widget` in a new tab and return its index. Parameters ---------- widget : :class:`QWidget` A widget to be inserted. The toolbox takes ownership of the widget. text : str Name/title of the new tab. icon : :class:`QIcon`, optional An icon for the tab button. toolTip : str, optional Tool tip for the tab button. """ return self.insertItem(self.count(), widget, text, icon, toolTip) def insertItem(self, index, widget, text, icon=None, toolTip=None): """ Insert the `widget` in a new tab at position `index`. See also -------- ToolBox.addItem """ button = self.createTabButton(widget, text, icon, toolTip) self.__contentsLayout.insertWidget(index * 2, button) self.__contentsLayout.insertWidget(index * 2 + 1, widget) widget.hide() page = _ToolBoxPage(index, widget, button.defaultAction(), button) self.__pages.insert(index, page) for i in range(index + 1, self.count()): self.__pages[i] = self.__pages[i]._replace(index=i) self.__updatePositions() # Show (open) the first tab. if self.count() == 1 and index == 0: page.action.trigger() self.__updateSelected() self.updateGeometry() return index def removeItem(self, index): """ Remove the widget at `index`. .. note:: The widget hidden but is is not deleted. """ self.__contentsLayout.takeAt(2 * index + 1) self.__contentsLayout.takeAt(2 * index) page = self.__pages.pop(index) # Update the page indexes for i in range(index, self.count()): self.__pages[i] = self.__pages[i]._replace(index=i) page.button.deleteLater() # Hide the widget and reparent to self # This follows QToolBox.removeItem page.widget.hide() page.widget.setParent(self) self.__updatePositions() self.__updateSelected() self.updateGeometry() def count(self): """ Return the number of widgets inserted in the toolbox. """ return len(self.__pages) def widget(self, index): """ Return the widget at `index`. """ return self.__pages[index].widget def createTabButton(self, widget, text, icon=None, toolTip=None): """ Create the tab button for `widget`. """ action = QAction(text, self) action.setCheckable(True) if icon: action.setIcon(icon) if toolTip: action.setToolTip(toolTip) self.__tabActionGroup.addAction(action) self.__actionMapper.setMapping(action, action) action.toggled.connect(self.__actionMapper.map) button = ToolBoxTabButton(self, objectName="toolbox-tab-button") button.setDefaultAction(action) button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) button.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) if self.__tabIconSize.isValid(): button.setIconSize(self.__tabIconSize) if self.__tabButtonHeight > 0: button.setFixedHeight(self.__tabButtonHeight) return button def ensureWidgetVisible(self, child, xmargin=50, ymargin=50): """ Scroll the contents so child widget instance is visible inside the viewport. """ self.__scrollArea.ensureWidgetVisible(child, xmargin, ymargin) def sizeHint(self): hint = self.__contentsLayout.sizeHint() if self.count(): # Compute max width of hidden widgets also. scroll = self.__scrollArea scroll_w = scroll.verticalScrollBar().sizeHint().width() frame_w = self.frameWidth() * 2 + scroll.frameWidth() * 2 max_w = max([p.widget.sizeHint().width() for p in self.__pages]) hint = QSize(max(max_w, hint.width()) + scroll_w + frame_w, hint.height()) return QSize(200, 200).expandedTo(hint) def __onTabActionToogled(self, action): page = find(self.__pages, action, key=attrgetter("action")) on = action.isChecked() page.widget.setVisible(on) index = page.index if index > 0: # Update the `previous` tab buttons style hints previous = self.__pages[index - 1].button flag = QStyleOptionToolBox.NextIsSelected if on: previous.selected |= flag else: previous.selected &= ~flag previous.update() if index < self.count() - 1: next = self.__pages[index + 1].button flag = QStyleOptionToolBox.PreviousIsSelected if on: next.selected |= flag else: next.selected &= ~flag next.update() self.tabToogled.emit(index, on) self.__contentsLayout.invalidate() def __updateSelected(self): """Update the tab buttons selected style flags. """ if self.count() == 0: return opt = QStyleOptionToolBox def update(button, next_sel, prev_sel): if next_sel: button.selected |= opt.NextIsSelected else: button.selected &= ~opt.NextIsSelected if prev_sel: button.selected |= opt.PreviousIsSelected else: button.selected &= ~ opt.PreviousIsSelected button.update() if self.count() == 1: update(self.__pages[0].button, False, False) elif self.count() >= 2: pages = self.__pages for i in range(1, self.count() - 1): update(pages[i].button, pages[i + 1].action.isChecked(), pages[i - 1].action.isChecked()) def __updatePositions(self): """Update the tab buttons position style flags. """ if self.count() == 0: return elif self.count() == 1: self.__pages[0].button.position = QStyleOptionToolBox.OnlyOneTab else: self.__pages[0].button.position = QStyleOptionToolBox.Beginning self.__pages[-1].button.position = QStyleOptionToolBox.End for p in self.__pages[1:-1]: p.button.position = QStyleOptionToolBox.Middle for p in self.__pages: p.button.update()