class TabButton(QToolButton): def __init__(self, parent=None, **kwargs): QToolButton.__init__(self, parent, **kwargs) self.setToolButtonStyle(Qt.ToolButtonIconOnly) self.setCheckable(True) self.__flat = True self.__showMenuIndicator = False def setFlat(self, flat): if self.__flat != flat: self.__flat = flat self.update() def flat(self): return self.__flat flat_ = Property(bool, fget=flat, fset=setFlat, designable=True) def setShownMenuIndicator(self, show): if self.__showMenuIndicator != show: self.__showMenuIndicator = show self.update() def showMenuIndicator(self): return self.__showMenuIndicator showMenuIndicator_ = Property(bool, fget=showMenuIndicator, fset=setShownMenuIndicator, designable=True) def paintEvent(self, event): opt = QStyleOptionToolButton() self.initStyleOption(opt) if self.__showMenuIndicator and self.isChecked(): opt.features |= QStyleOptionToolButton.HasMenu if self.__flat: # Use default widget background/border styling. StyledWidget_paintEvent(self, event) p = QStylePainter(self) p.drawControl(QStyle.CE_ToolButtonLabel, opt) else: p = QStylePainter(self) p.drawComplexControl(QStyle.CC_ToolButton, opt) def sizeHint(self): opt = QStyleOptionToolButton() self.initStyleOption(opt) if self.__showMenuIndicator and self.isChecked(): opt.features |= QStyleOptionToolButton.HasMenu style = self.style() hint = style.sizeFromContents(QStyle.CT_ToolButton, opt, opt.iconSize, self) return hint
class LineEditButton(QToolButton): """ A button in the :class:`LineEdit`. """ def __init__(self, parent=None, flat=True, **kwargs): # type: (Optional[QWidget], bool, Any) -> None super().__init__(parent, **kwargs) self.__flat = flat def setFlat(self, flat): # type: (bool) -> None if self.__flat != flat: self.__flat = flat self.update() def flat(self): # type: () -> bool return self.__flat flat_ = Property(bool, fget=flat, fset=setFlat, designable=True) def paintEvent(self, event): # type: (QPaintEvent) -> None if self.__flat: opt = QStyleOptionToolButton() self.initStyleOption(opt) p = QStylePainter(self) p.drawControl(QStyle.CE_ToolButtonLabel, opt) else: super().paintEvent(event)
class Obj(QObject): _f = None def _set(self, val): self._f = val def _get(self): return self._f prop = Property(type_, _get, _set)
class GraphicsTextEdit(GraphicsTextEdit): """ QGraphicsTextItem subclass defining an additional placeholderText property (text displayed when no text is set). """ def __init__(self, *args, placeholderText="", **kwargs): # type: (Any, str, Any) -> None kwargs.setdefault( "editTriggers", GraphicsTextEdit.DoubleClicked | GraphicsTextEdit.EditKeyPressed) super().__init__(*args, **kwargs) self.setAcceptHoverEvents(True) self.__placeholderText = placeholderText def setPlaceholderText(self, text): # type: (str) -> None """ Set the placeholder text. This is shown when the item has no text, i.e when `toPlainText()` returns an empty string. """ if self.__placeholderText != text: self.__placeholderText = text if not self.toPlainText(): self.update() def placeholderText(self): # type: () -> str """ Return the placeholder text. """ return self.__placeholderText placeholderText_ = Property(str, placeholderText, setPlaceholderText, doc="Placeholder text") def paint(self, painter, option, widget=None): # type: (QPainter, QStyleOptionGraphicsItem, Optional[QWidget]) -> None super().paint(painter, option, widget) # Draw placeholder text if necessary if not (self.toPlainText() and self.toHtml()) and \ self.__placeholderText and \ not (self.hasFocus() and \ self.textInteractionFlags() & Qt.TextEditable): brect = self.boundingRect() painter.setFont(self.font()) metrics = painter.fontMetrics() text = metrics.elidedText(self.__placeholderText, Qt.ElideRight, brect.width()) color = self.defaultTextColor() color.setAlpha(min(color.alpha(), 150)) painter.setPen(QPen(color)) painter.drawText(brect, Qt.AlignTop | Qt.AlignLeft, text)
class GraphicsTextEdit(QGraphicsTextItem): """ QGraphicsTextItem subclass defining an additional placeholderText property (text displayed when no text is set). """ def __init__(self, *args, **kwargs): QGraphicsTextItem.__init__(self, *args, **kwargs) self.__placeholderText = "" def setPlaceholderText(self, text): """ Set the placeholder text. This is shown when the item has no text, i.e when `toPlainText()` returns an empty string. """ if self.__placeholderText != text: self.__placeholderText = text if not self.toPlainText(): self.update() def placeholderText(self): """ Return the placeholder text. """ return str(self.__placeholderText) placeholderText_ = Property(str, placeholderText, setPlaceholderText, doc="Placeholder text") def paint(self, painter, option, widget=None): QGraphicsTextItem.paint(self, painter, option, widget) # Draw placeholder text if necessary if not (self.toPlainText() and self.toHtml()) and \ self.__placeholderText and \ not (self.hasFocus() and \ self.textInteractionFlags() & Qt.TextEditable): brect = self.boundingRect() painter.setFont(self.font()) metrics = painter.fontMetrics() text = metrics.elidedText(self.__placeholderText, Qt.ElideRight, brect.width()) color = self.defaultTextColor() color.setAlpha(min(color.alpha(), 150)) painter.setPen(QPen(color)) painter.drawText(brect, Qt.AlignTop | Qt.AlignLeft, text)
class StyleConfigWidget(QWidget): DisplayNames = { "windowsvista": "Windows (default)", "macintosh": "macOS (default)", "windows": "MS Windows 9x", } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._current_palette = "" form = FormLayout() styles = QStyleFactory.keys() styles = sorted( styles, key=cmp_to_key(lambda a, b: 1 if a.lower() == "windows" and b. lower() == "fusion" else (-1 if a.lower( ) == "fusion" and b.lower() == "windows" else 0))) styles = [(self.DisplayNames.get(st.lower(), st.capitalize()), st) for st in styles] # Default style with empty userData key so it cleared in # persistent settings, allowing for default style resolution # on application star. styles = [("Default", "")] + styles self.style_cb = style_cb = QComboBox(objectName="style-cb") for name, key in styles: self.style_cb.addItem(name, userData=key) style_cb.currentIndexChanged.connect(self._style_changed) self.colors_cb = colors_cb = QComboBox(objectName="palette-cb") colors_cb.addItem("Default", userData="") colors_cb.addItem("Breeze Light", userData="breeze-light") colors_cb.addItem("Breeze Dark", userData="breeze-dark") colors_cb.addItem("Zion Reversed", userData="zion-reversed") colors_cb.addItem("Dark", userData="dark") form.addRow("Style", style_cb) form.addRow("Color theme", colors_cb) label = QLabel( "<small>Changes will be applied on next application startup.</small>", enabled=False, ) label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) form.addRow(label) self.setLayout(form) self._update_colors_enabled_state() style_cb.currentIndexChanged.connect(self.selectedStyleChanged) colors_cb.currentIndexChanged.connect(self.selectedPaletteChanged) def _style_changed(self): self._update_colors_enabled_state() def _update_colors_enabled_state(self): current = self.style_cb.currentData(Qt.UserRole) enable = current is not None and current.lower() in ("fusion", "windows") self._set_palette_enabled(enable) def _set_palette_enabled(self, state: bool): cb = self.colors_cb if cb.isEnabled() != state: cb.setEnabled(state) if not state: current = cb.currentData(Qt.UserRole) self._current_palette = current cb.setCurrentIndex(-1) else: index = cb.findData(self._current_palette, Qt.UserRole) if index == -1: index = 0 cb.setCurrentIndex(index) def selectedStyle(self) -> str: """Return the current selected style key.""" key = self.style_cb.currentData() return key if key is not None else "" def setSelectedStyle(self, style: str) -> None: """Set the current selected style key.""" idx = self.style_cb.findData(style, Qt.DisplayRole, Qt.MatchFixedString) if idx == -1: idx = 0 # select the default style self.style_cb.setCurrentIndex(idx) selectedStyleChanged = Signal() selectedStyle_ = Property(str, selectedStyle, setSelectedStyle, notify=selectedStyleChanged) def selectedPalette(self) -> str: """The current selected palette key.""" key = self.colors_cb.currentData(Qt.UserRole) return key if key is not None else "" def setSelectedPalette(self, key: str) -> None: """Set the current selected palette key.""" if not self.colors_cb.isEnabled(): self._current_palette = key return idx = self.colors_cb.findData(key, Qt.UserRole, Qt.MatchFixedString) if idx == -1: idx = 0 # select the default color theme self.colors_cb.setCurrentIndex(idx) selectedPaletteChanged = Signal() selectedPalette_ = Property(str, selectedPalette, setSelectedPalette, notify=selectedPaletteChanged)
class NodeItem(QGraphicsWidget): """ An widget node item in the canvas. """ #: Signal emitted when the scene position of the node has changed. positionChanged = Signal() #: Signal emitted when the geometry of the channel anchors changes. anchorGeometryChanged = Signal() #: Signal emitted when the item has been activated (by a mouse double #: click or a keyboard) activated = Signal() #: The item is under the mouse. hovered = Signal() #: Span of the anchor in degrees ANCHOR_SPAN_ANGLE = 90 #: Z value of the item Z_VALUE = 100 def __init__(self, widget_description=None, parent=None, **kwargs): self.__boundingRect = None super().__init__(parent, **kwargs) self.setFocusPolicy(Qt.ClickFocus) self.setFlag(QGraphicsItem.ItemSendsGeometryChanges, True) self.setFlag(QGraphicsItem.ItemHasNoContents, True) self.setFlag(QGraphicsItem.ItemIsSelectable, True) self.setFlag(QGraphicsItem.ItemIsMovable, True) self.setFlag(QGraphicsItem.ItemIsFocusable, True) # central body shape item self.shapeItem = None # in/output anchor items self.inputAnchorItem = None self.outputAnchorItem = None # title text item self.captionTextItem = None # error, warning, info items self.errorItem = None self.warningItem = None self.infoItem = None self.__title = "" self.__processingState = 0 self.__progress = -1 self.__statusMessage = "" self.__error = None self.__warning = None self.__info = None self.__anchorLayout = None self.__animationEnabled = False self.setZValue(self.Z_VALUE) self.setupGraphics() self.setWidgetDescription(widget_description) @classmethod def from_node(cls, node): """ Create an :class:`NodeItem` instance and initialize it from a :class:`SchemeNode` instance. """ self = cls() self.setWidgetDescription(node.description) # self.setCategoryDescription(node.category) return self @classmethod def from_node_meta(cls, meta_description): """ Create an `NodeItem` instance from a node meta description. """ self = cls() self.setWidgetDescription(meta_description) return self def setupGraphics(self): """ Set up the graphics. """ shape_rect = QRectF(-24, -24, 48, 48) self.shapeItem = NodeBodyItem(self) self.shapeItem.setShapeRect(shape_rect) self.shapeItem.setAnimationEnabled(self.__animationEnabled) # Rect for widget's 'ears'. anchor_rect = QRectF(-31, -31, 62, 62) self.inputAnchorItem = SinkAnchorItem(self) input_path = QPainterPath() start_angle = 180 - self.ANCHOR_SPAN_ANGLE / 2 input_path.arcMoveTo(anchor_rect, start_angle) input_path.arcTo(anchor_rect, start_angle, self.ANCHOR_SPAN_ANGLE) self.inputAnchorItem.setAnchorPath(input_path) self.outputAnchorItem = SourceAnchorItem(self) output_path = QPainterPath() start_angle = self.ANCHOR_SPAN_ANGLE / 2 output_path.arcMoveTo(anchor_rect, start_angle) output_path.arcTo(anchor_rect, start_angle, -self.ANCHOR_SPAN_ANGLE) self.outputAnchorItem.setAnchorPath(output_path) self.inputAnchorItem.hide() self.outputAnchorItem.hide() # Title caption item self.captionTextItem = NameTextItem(self) self.captionTextItem.setPlainText("") self.captionTextItem.setPos(0, 33) def iconItem(standard_pixmap): item = GraphicsIconItem(self, icon=standard_icon(standard_pixmap), iconSize=QSize(16, 16)) item.hide() return item self.errorItem = iconItem(QStyle.SP_MessageBoxCritical) self.warningItem = iconItem(QStyle.SP_MessageBoxWarning) self.infoItem = iconItem(QStyle.SP_MessageBoxInformation) self.prepareGeometryChange() self.__boundingRect = None # TODO: Remove the set[Widget|Category]Description. The user should # handle setting of icons, title, ... def setWidgetDescription(self, desc): """ Set widget description. """ self.widget_description = desc if desc is None: return icon = icon_loader.from_description(desc).get(desc.icon) if icon: self.setIcon(icon) if not self.title(): self.setTitle(desc.name) if desc.inputs: self.inputAnchorItem.show() if desc.outputs: self.outputAnchorItem.show() tooltip = NodeItem_toolTipHelper(self) self.setToolTip(tooltip) def setWidgetCategory(self, desc): """ Set the widget category. """ self.category_description = desc if desc and desc.background: background = NAMED_COLORS.get(desc.background, desc.background) color = QColor(background) if color.isValid(): self.setColor(color) def setIcon(self, icon): """ Set the node item's icon (:class:`QIcon`). """ if isinstance(icon, QIcon): self.icon_item = GraphicsIconItem(self.shapeItem, icon=icon, iconSize=QSize(36, 36)) self.icon_item.setPos(-18, -18) else: raise TypeError def setColor(self, color, selectedColor=None): """ Set the widget color. """ if selectedColor is None: selectedColor = saturated(color, 150) palette = create_palette(color, selectedColor) self.shapeItem.setPalette(palette) def setTitle(self, title): """ Set the node title. The title text is displayed at the bottom of the node. """ self.__title = title self.__updateTitleText() def title(self): """ Return the node title. """ return self.__title title_ = Property(str, fget=title, fset=setTitle, doc="Node title text.") def setFont(self, font): """ Set the title text font (:class:`QFont`). """ if font != self.font(): self.prepareGeometryChange() self.captionTextItem.setFont(font) self.__updateTitleText() def font(self): """ Return the title text font. """ return self.captionTextItem.font() def setAnimationEnabled(self, enabled): """ Set the node animation enabled state. """ if self.__animationEnabled != enabled: self.__animationEnabled = enabled self.shapeItem.setAnimationEnabled(enabled) def animationEnabled(self): """ Are node animations enabled. """ return self.__animationEnabled def setProcessingState(self, state): """ Set the node processing state i.e. the node is processing (is busy) or is idle. """ if self.__processingState != state: self.__processingState = state self.shapeItem.setProcessingState(state) if not state: # Clear the progress meter. self.setProgress(-1) if self.__animationEnabled: self.shapeItem.ping() def processingState(self): """ The node processing state. """ return self.__processingState processingState_ = Property(int, fget=processingState, fset=setProcessingState) def setProgress(self, progress): """ Set the node work progress state (number between 0 and 100). """ if progress is None or progress < 0 or not self.__processingState: progress = -1 progress = max(min(progress, 100), -1) if self.__progress != progress: self.__progress = progress self.shapeItem.setProgress(progress) self.__updateTitleText() def progress(self): """ Return the node work progress state. """ return self.__progress progress_ = Property(float, fget=progress, fset=setProgress, doc="Node progress state.") def setStatusMessage(self, message): """ Set the node status message text. This text is displayed below the node's title. """ if self.__statusMessage != message: self.__statusMessage = message self.__updateTitleText() def statusMessage(self): return self.__statusMessage def setStateMessage(self, message): """ Set a state message to display over the item. Parameters ---------- message : UserMessage Message to display. `message.severity` is used to determine the icon and `message.contents` is used as a tool tip. """ # TODO: Group messages by message_id not by severity # and deprecate set[Error|Warning|Error]Message if message.severity == UserMessage.Info: self.setInfoMessage(message.contents) elif message.severity == UserMessage.Warning: self.setWarningMessage(message.contents) elif message.severity == UserMessage.Error: self.setErrorMessage(message.contents) def setErrorMessage(self, message): if self.__error != message: self.__error = message self.__updateMessages() def setWarningMessage(self, message): if self.__warning != message: self.__warning = message self.__updateMessages() def setInfoMessage(self, message): if self.__info != message: self.__info = message self.__updateMessages() def newInputAnchor(self): """ Create and return a new input :class:`AnchorPoint`. """ if not (self.widget_description and self.widget_description.inputs): raise ValueError("Widget has no inputs.") anchor = AnchorPoint() self.inputAnchorItem.addAnchor(anchor, position=1.0) positions = self.inputAnchorItem.anchorPositions() positions = uniform_linear_layout(positions) self.inputAnchorItem.setAnchorPositions(positions) return anchor def removeInputAnchor(self, anchor): """ Remove input anchor. """ self.inputAnchorItem.removeAnchor(anchor) positions = self.inputAnchorItem.anchorPositions() positions = uniform_linear_layout(positions) self.inputAnchorItem.setAnchorPositions(positions) def newOutputAnchor(self): """ Create and return a new output :class:`AnchorPoint`. """ if not (self.widget_description and self.widget_description.outputs): raise ValueError("Widget has no outputs.") anchor = AnchorPoint(self) self.outputAnchorItem.addAnchor(anchor, position=1.0) positions = self.outputAnchorItem.anchorPositions() positions = uniform_linear_layout(positions) self.outputAnchorItem.setAnchorPositions(positions) return anchor def removeOutputAnchor(self, anchor): """ Remove output anchor. """ self.outputAnchorItem.removeAnchor(anchor) positions = self.outputAnchorItem.anchorPositions() positions = uniform_linear_layout(positions) self.outputAnchorItem.setAnchorPositions(positions) def inputAnchors(self): """ Return a list of all input anchor points. """ return self.inputAnchorItem.anchorPoints() def outputAnchors(self): """ Return a list of all output anchor points. """ return self.outputAnchorItem.anchorPoints() def setAnchorRotation(self, angle): """ Set the anchor rotation. """ self.inputAnchorItem.setRotation(angle) self.outputAnchorItem.setRotation(angle) self.anchorGeometryChanged.emit() def anchorRotation(self): """ Return the anchor rotation. """ return self.inputAnchorItem.rotation() def boundingRect(self): # TODO: Important because of this any time the child # items change geometry the self.prepareGeometryChange() # needs to be called. if self.__boundingRect is None: self.__boundingRect = self.childrenBoundingRect() return self.__boundingRect def shape(self): # Shape for mouse hit detection. # TODO: Should this return the union of all child items? return self.shapeItem.shape() def __updateTitleText(self): """ Update the title text item. """ text = ['<div align="center">%s' % escape(self.title())] status_text = [] progress_included = False if self.__statusMessage: msg = escape(self.__statusMessage) format_fields = dict(parse_format_fields(msg)) if "progress" in format_fields and len(format_fields) == 1: # Insert progress into the status text format string. spec, _ = format_fields["progress"] if spec != None: progress_included = True progress_str = "{0:.0f}%".format(self.progress()) status_text.append(msg.format(progress=progress_str)) else: status_text.append(msg) if self.progress() >= 0 and not progress_included: status_text.append("%i%%" % int(self.progress())) if status_text: text += [ "<br/>", '<span style="font-style: italic">', "<br/>".join(status_text), "</span>" ] text += ["</div>"] text = "".join(text) # The NodeItems boundingRect could change. self.prepareGeometryChange() self.__boundingRect = None self.captionTextItem.setHtml(text) self.captionTextItem.document().adjustSize() width = self.captionTextItem.textWidth() self.captionTextItem.setPos(-width / 2.0, 33) def __updateMessages(self): """ Update message items (position, visibility and tool tips). """ items = [self.errorItem, self.warningItem, self.infoItem] messages = [self.__error, self.__warning, self.__info] for message, item in zip(messages, items): item.setVisible(bool(message)) item.setToolTip(message or "") shown = [item for item in items if item.isVisible()] count = len(shown) if count: spacing = 3 rects = [item.boundingRect() for item in shown] width = sum(rect.width() for rect in rects) width += spacing * max(0, count - 1) height = max(rect.height() for rect in rects) origin = self.shapeItem.boundingRect().top() - spacing - height origin = QPointF(-width / 2, origin) for item, rect in zip(shown, rects): item.setPos(origin) origin = origin + QPointF(rect.width() + spacing, 0) def mousePressEvent(self, event): if self.shapeItem.path().contains(event.pos()): return super().mousePressEvent(event) else: event.ignore() def mouseDoubleClickEvent(self, event): if self.shapeItem.path().contains(event.pos()): super().mouseDoubleClickEvent(event) QTimer.singleShot(0, self.activated.emit) else: event.ignore() def contextMenuEvent(self, event): if self.shapeItem.path().contains(event.pos()): return super().contextMenuEvent(event) else: event.ignore() def focusInEvent(self, event): self.shapeItem.setHasFocus(True) return super().focusInEvent(event) def focusOutEvent(self, event): self.shapeItem.setHasFocus(False) return super().focusOutEvent(event) def changeEvent(self, event): if event.type() == QEvent.PaletteChange: self.__updatePalette() elif event.type() == QEvent.FontChange: self.__updateFont() super().changeEvent(event) def itemChange(self, change, value): if change == QGraphicsItem.ItemSelectedChange: self.shapeItem.setSelected(value) self.captionTextItem.setSelectionState(value) elif change == QGraphicsItem.ItemPositionHasChanged: self.positionChanged.emit() return super().itemChange(change, value) def __updatePalette(self): self.captionTextItem.setPalette(self.palette()) def __updateFont(self): self.prepareGeometryChange() self.captionTextItem.setFont(self.font()) self.__updateTitleText()
class CanvasView(QGraphicsView): """Canvas View handles the zooming. """ def __init__(self, *args): super().__init__(*args) self.setAlignment(Qt.AlignTop | Qt.AlignLeft) self.__backgroundIcon = QIcon() self.__autoScroll = False self.__autoScrollMargin = 16 self.__autoScrollTimer = QTimer(self) self.__autoScrollTimer.timeout.connect(self.__autoScrollAdvance) # scale factor accumulating partial increments from wheel events self.__zoomLevel = 100 # effective scale level(rounded to whole integers) self.__effectiveZoomLevel = 100 self.__zoomInAction = QAction( self.tr("Zoom in"), self, objectName="action-zoom-in", shortcut=QKeySequence.ZoomIn, triggered=self.zoomIn, ) self.__zoomOutAction = QAction(self.tr("Zoom out"), self, objectName="action-zoom-out", shortcut=QKeySequence.ZoomOut, triggered=self.zoomOut) self.__zoomResetAction = QAction( self.tr("Reset Zoom"), self, objectName="action-zoom-reset", triggered=self.zoomReset, shortcut=QKeySequence(Qt.ControlModifier | Qt.Key_0)) def setScene(self, scene): super().setScene(scene) self._ensureSceneRect(scene) def _ensureSceneRect(self, scene): r = scene.addRect(QRectF(0, 0, 400, 400)) scene.sceneRect() scene.removeItem(r) def setAutoScrollMargin(self, margin): self.__autoScrollMargin = margin def autoScrollMargin(self): return self.__autoScrollMargin def setAutoScroll(self, enable): self.__autoScroll = enable def autoScroll(self): return self.__autoScroll def mousePressEvent(self, event): super().mousePressEvent(event) def mouseMoveEvent(self, event): if event.buttons() & Qt.LeftButton: if not self.__autoScrollTimer.isActive() and \ self.__shouldAutoScroll(event.pos()): self.__startAutoScroll() super().mouseMoveEvent(event) def mouseReleaseEvent(self, event): if event.button() & Qt.LeftButton: self.__stopAutoScroll() return super().mouseReleaseEvent(event) def wheelEvent(self, event: QWheelEvent): if event.modifiers() & Qt.ControlModifier \ and event.buttons() == Qt.NoButton: delta = event.angleDelta().y() # use mouse position as anchor while zooming anchor = self.transformationAnchor() self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) self.__setZoomLevel(self.__zoomLevel + 10 * delta / 120) self.setTransformationAnchor(anchor) event.accept() else: super().wheelEvent(event) def zoomIn(self): self.__setZoomLevel(self.__zoomLevel + 10) def zoomOut(self): self.__setZoomLevel(self.__zoomLevel - 10) def zoomReset(self): """ Reset the zoom level. """ self.__setZoomLevel(100) def zoomLevel(self): # type: () -> float """ Return the current zoom level. Level is expressed in percentages; 100 is unscaled, 50 is half size, ... """ return self.__effectiveZoomLevel def setZoomLevel(self, level): self.__setZoomLevel(level) def __setZoomLevel(self, scale): # type: (float) -> None self.__zoomLevel = max(30, min(scale, 300)) scale = round(self.__zoomLevel) self.__zoomOutAction.setEnabled(scale != 30) self.__zoomInAction.setEnabled(scale != 300) if self.__effectiveZoomLevel != scale: self.__effectiveZoomLevel = scale transform = QTransform() transform.scale(scale / 100, scale / 100) self.setTransform(transform) self.zoomLevelChanged.emit(scale) zoomLevelChanged = Signal(float) zoomLevel_ = Property(float, zoomLevel, setZoomLevel, notify=zoomLevelChanged) def __shouldAutoScroll(self, pos): if self.__autoScroll: margin = self.__autoScrollMargin viewrect = self.contentsRect() rect = viewrect.adjusted(margin, margin, -margin, -margin) # only do auto scroll when on the viewport's margins return not rect.contains(pos) and viewrect.contains(pos) else: return False def __startAutoScroll(self): self.__autoScrollTimer.start(10) log.debug("Auto scroll timer started") def __stopAutoScroll(self): if self.__autoScrollTimer.isActive(): self.__autoScrollTimer.stop() log.debug("Auto scroll timer stopped") def __autoScrollAdvance(self): """Advance the auto scroll """ pos = QCursor.pos() pos = self.mapFromGlobal(pos) margin = self.__autoScrollMargin vvalue = self.verticalScrollBar().value() hvalue = self.horizontalScrollBar().value() vrect = QRect(0, 0, self.width(), self.height()) # What should be the speed advance = 10 # We only do auto scroll if the mouse is inside the view. if vrect.contains(pos): if pos.x() < vrect.left() + margin: self.horizontalScrollBar().setValue(hvalue - advance) if pos.y() < vrect.top() + margin: self.verticalScrollBar().setValue(vvalue - advance) if pos.x() > vrect.right() - margin: self.horizontalScrollBar().setValue(hvalue + advance) if pos.y() > vrect.bottom() - margin: self.verticalScrollBar().setValue(vvalue + advance) if self.verticalScrollBar().value() == vvalue and \ self.horizontalScrollBar().value() == hvalue: self.__stopAutoScroll() else: self.__stopAutoScroll() log.debug("Auto scroll advance") def setBackgroundIcon(self, icon): if not isinstance(icon, QIcon): raise TypeError("A QIcon expected.") if self.__backgroundIcon != icon: self.__backgroundIcon = icon self.viewport().update() def backgroundIcon(self): return QIcon(self.__backgroundIcon) def drawBackground(self, painter, rect): super().drawBackground(painter, rect) if not self.__backgroundIcon.isNull(): painter.setClipRect(rect) vrect = QRect(QPoint(0, 0), self.viewport().size()) vrect = self.mapToScene(vrect).boundingRect() pm = self.__backgroundIcon.pixmap(vrect.size().toSize().boundedTo( QSize(200, 200))) pmrect = QRect(QPoint(0, 0), pm.size()) pmrect.moveCenter(vrect.center().toPoint()) if rect.toRect().intersects(pmrect): painter.drawPixmap(pmrect, pm)
class CanvasView(QGraphicsView): """Canvas View handles the zooming. """ def __init__(self, *args): super().__init__(*args) self.setAlignment(Qt.AlignTop | Qt.AlignLeft) self.grabGesture(Qt.PinchGesture) self.__backgroundIcon = QIcon() self.__autoScroll = False self.__autoScrollMargin = 16 self.__autoScrollTimer = QTimer(self) self.__autoScrollTimer.timeout.connect(self.__autoScrollAdvance) # scale factor accumulating partial increments from wheel events self.__zoomLevel = 100 # effective scale level(rounded to whole integers) self.__effectiveZoomLevel = 100 self.__zoomInAction = QAction( self.tr("Zoom in"), self, objectName="action-zoom-in", shortcut=QKeySequence.ZoomIn, triggered=self.zoomIn, ) self.__zoomOutAction = QAction(self.tr("Zoom out"), self, objectName="action-zoom-out", shortcut=QKeySequence.ZoomOut, triggered=self.zoomOut) self.__zoomResetAction = QAction( self.tr("Reset Zoom"), self, objectName="action-zoom-reset", triggered=self.zoomReset, shortcut=QKeySequence(Qt.ControlModifier | Qt.Key_0)) def setScene(self, scene): super().setScene(scene) self._ensureSceneRect(scene) def _ensureSceneRect(self, scene): r = scene.addRect(QRectF(0, 0, 400, 400)) scene.sceneRect() scene.removeItem(r) def setAutoScrollMargin(self, margin): self.__autoScrollMargin = margin def autoScrollMargin(self): return self.__autoScrollMargin def setAutoScroll(self, enable): self.__autoScroll = enable def autoScroll(self): return self.__autoScroll def mousePressEvent(self, event): super().mousePressEvent(event) def mouseMoveEvent(self, event): if event.buttons() & Qt.LeftButton: if not self.__autoScrollTimer.isActive() and \ self.__shouldAutoScroll(event.pos()): self.__startAutoScroll() super().mouseMoveEvent(event) def mouseReleaseEvent(self, event): if event.button() & Qt.LeftButton: self.__stopAutoScroll() return super().mouseReleaseEvent(event) def __should_scroll_horizontally(self, event: QWheelEvent): if event.source() != Qt.MouseEventNotSynthesized: return False if (event.modifiers() & Qt.ShiftModifier and sys.platform == 'darwin' or event.modifiers() & Qt.AltModifier and sys.platform != 'darwin'): return True if event.angleDelta().x() == 0: vBar = self.verticalScrollBar() yDelta = event.angleDelta().y() direction = yDelta >= 0 edgeVBarValue = vBar.minimum() if direction else vBar.maximum() return vBar.value() == edgeVBarValue return False def wheelEvent(self, event: QWheelEvent): # Zoom if event.modifiers() & Qt.ControlModifier \ and event.buttons() == Qt.NoButton: delta = event.angleDelta().y() # use mouse position as anchor while zooming anchor = self.transformationAnchor() self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) self.__setZoomLevel(self.__zoomLevel + 10 * delta / 120) self.setTransformationAnchor(anchor) event.accept() # Scroll horizontally elif self.__should_scroll_horizontally(event): x, y = event.angleDelta().x(), event.angleDelta().y() sign_value = x if x != 0 else y sign = 1 if sign_value >= 0 else -1 new_angle_delta = QPoint(sign * max(abs(x), abs(y), sign_value), 0) new_pixel_delta = QPoint(0, 0) new_modifiers = event.modifiers() & ~(Qt.ShiftModifier | Qt.AltModifier) new_event = QWheelEvent(event.pos(), event.globalPos(), new_pixel_delta, new_angle_delta, event.buttons(), new_modifiers, event.phase(), event.inverted(), event.source()) event.accept() super().wheelEvent(new_event) else: super().wheelEvent(event) def gestureEvent(self, event: QGestureEvent): gesture = event.gesture(Qt.PinchGesture) if gesture is None: return if gesture.state() == Qt.GestureStarted: event.accept(gesture) elif gesture.changeFlags() & QPinchGesture.ScaleFactorChanged: anchor = gesture.centerPoint().toPoint() anchor = self.mapToScene(anchor) self.__setZoomLevel(self.__zoomLevel * gesture.scaleFactor(), anchor=anchor) event.accept() elif gesture.state() == Qt.GestureFinished: event.accept() def event(self, event: QEvent) -> bool: if event.type() == QEvent.Gesture: self.gestureEvent(cast(QGestureEvent, event)) return super().event(event) def zoomIn(self): self.__setZoomLevel(self.__zoomLevel + 10) def zoomOut(self): self.__setZoomLevel(self.__zoomLevel - 10) def zoomReset(self): """ Reset the zoom level. """ self.__setZoomLevel(100) def zoomLevel(self): # type: () -> float """ Return the current zoom level. Level is expressed in percentages; 100 is unscaled, 50 is half size, ... """ return self.__effectiveZoomLevel def setZoomLevel(self, level): self.__setZoomLevel(level) def __setZoomLevel(self, scale, anchor=None): # type: (float, Optional[QPointF]) -> None self.__zoomLevel = max(30, min(scale, 300)) scale = round(self.__zoomLevel) self.__zoomOutAction.setEnabled(scale != 30) self.__zoomInAction.setEnabled(scale != 300) if self.__effectiveZoomLevel != scale: self.__effectiveZoomLevel = scale transform = QTransform() transform.scale(scale / 100, scale / 100) if anchor is not None: anchor = self.mapFromScene(anchor) self.setTransform(transform) if anchor is not None: center = self.viewport().rect().center() diff = self.mapToScene(center) - self.mapToScene(anchor) self.centerOn(anchor + diff) self.zoomLevelChanged.emit(scale) zoomLevelChanged = Signal(float) zoomLevel_ = Property(float, zoomLevel, setZoomLevel, notify=zoomLevelChanged) def __shouldAutoScroll(self, pos): if self.__autoScroll: margin = self.__autoScrollMargin viewrect = self.contentsRect() rect = viewrect.adjusted(margin, margin, -margin, -margin) # only do auto scroll when on the viewport's margins return not rect.contains(pos) and viewrect.contains(pos) else: return False def __startAutoScroll(self): self.__autoScrollTimer.start(10) log.debug("Auto scroll timer started") def __stopAutoScroll(self): if self.__autoScrollTimer.isActive(): self.__autoScrollTimer.stop() log.debug("Auto scroll timer stopped") def __autoScrollAdvance(self): """Advance the auto scroll """ pos = QCursor.pos() pos = self.mapFromGlobal(pos) margin = self.__autoScrollMargin vvalue = self.verticalScrollBar().value() hvalue = self.horizontalScrollBar().value() vrect = QRect(0, 0, self.width(), self.height()) # What should be the speed advance = 10 # We only do auto scroll if the mouse is inside the view. if vrect.contains(pos): if pos.x() < vrect.left() + margin: self.horizontalScrollBar().setValue(hvalue - advance) if pos.y() < vrect.top() + margin: self.verticalScrollBar().setValue(vvalue - advance) if pos.x() > vrect.right() - margin: self.horizontalScrollBar().setValue(hvalue + advance) if pos.y() > vrect.bottom() - margin: self.verticalScrollBar().setValue(vvalue + advance) if self.verticalScrollBar().value() == vvalue and \ self.horizontalScrollBar().value() == hvalue: self.__stopAutoScroll() else: self.__stopAutoScroll() log.debug("Auto scroll advance") def setBackgroundIcon(self, icon): if not isinstance(icon, QIcon): raise TypeError("A QIcon expected.") if self.__backgroundIcon != icon: self.__backgroundIcon = icon self.viewport().update() def backgroundIcon(self): return QIcon(self.__backgroundIcon) def drawBackground(self, painter, rect): super().drawBackground(painter, rect) if not self.__backgroundIcon.isNull(): painter.setClipRect(rect) vrect = QRect(QPoint(0, 0), self.viewport().size()) vrect = self.mapToScene(vrect).boundingRect() pm = self.__backgroundIcon.pixmap(vrect.size().toSize().boundedTo( QSize(200, 200))) pmrect = QRect(QPoint(0, 0), pm.size()) pmrect.moveCenter(vrect.center().toPoint()) if rect.toRect().intersects(pmrect): painter.drawPixmap(pmrect, pm)
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 FeatureEditor(QFrame): FUNCTIONS = dict(chain([(key, val) for key, val in math.__dict__.items() if not key.startswith("_")], [("str", str)])) featureChanged = Signal() featureEdited = Signal() modifiedChanged = Signal(bool) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) layout = QFormLayout( fieldGrowthPolicy=QFormLayout.ExpandingFieldsGrow ) layout.setContentsMargins(0, 0, 0, 0) self.nameedit = QLineEdit( placeholderText="Name...", sizePolicy=QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed) ) self.expressionedit = QLineEdit( placeholderText="Expression..." ) self.attrs_model = itemmodels.VariableListModel( ["Select Feature"], parent=self) self.attributescb = QComboBox( minimumContentsLength=16, sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLengthWithIcon, sizePolicy=QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) ) self.attributescb.setModel(self.attrs_model) sorted_funcs = sorted(self.FUNCTIONS) self.funcs_model = itemmodels.PyListModelTooltip() self.funcs_model.setParent(self) self.funcs_model[:] = chain(["Select Function"], sorted_funcs) self.funcs_model.tooltips[:] = chain( [''], [self.FUNCTIONS[func].__doc__ for func in sorted_funcs]) self.functionscb = QComboBox( minimumContentsLength=16, sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLengthWithIcon, sizePolicy=QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)) self.functionscb.setModel(self.funcs_model) hbox = QHBoxLayout() hbox.addWidget(self.attributescb) hbox.addWidget(self.functionscb) layout.addRow(self.nameedit, self.expressionedit) layout.addRow(self.tr(""), hbox) self.setLayout(layout) self.nameedit.editingFinished.connect(self._invalidate) self.expressionedit.textChanged.connect(self._invalidate) self.attributescb.currentIndexChanged.connect(self.on_attrs_changed) self.functionscb.currentIndexChanged.connect(self.on_funcs_changed) self._modified = False def setModified(self, modified): if not type(modified) is bool: raise TypeError if self._modified != modified: self._modified = modified self.modifiedChanged.emit(modified) def modified(self): return self._modified modified = Property(bool, modified, setModified, notify=modifiedChanged) def setEditorData(self, data, domain): self.nameedit.setText(data.name) self.expressionedit.setText(data.expression) self.setModified(False) self.featureChanged.emit() self.attrs_model[:] = ["Select Feature"] if domain is not None and (domain or domain.metas): self.attrs_model[:] += chain(domain.attributes, domain.class_vars, domain.metas) def editorData(self): return FeatureDescriptor(name=self.nameedit.text(), expression=self.nameedit.text()) def _invalidate(self): self.setModified(True) self.featureEdited.emit() self.featureChanged.emit() def on_attrs_changed(self): index = self.attributescb.currentIndex() if index > 0: attr = sanitized_name(self.attrs_model[index].name) self.insert_into_expression(attr) self.attributescb.setCurrentIndex(0) def on_funcs_changed(self): index = self.functionscb.currentIndex() if index > 0: func = self.funcs_model[index] if func in ["atan2", "fmod", "ldexp", "log", "pow", "copysign", "hypot"]: self.insert_into_expression(func + "(,)") self.expressionedit.cursorBackward(False, 2) elif func in ["e", "pi"]: self.insert_into_expression(func) else: self.insert_into_expression(func + "()") self.expressionedit.cursorBackward(False) self.functionscb.setCurrentIndex(0) def insert_into_expression(self, what): cp = self.expressionedit.cursorPosition() ct = self.expressionedit.text() text = ct[:cp] + what + ct[cp:] self.expressionedit.setText(text) self.expressionedit.setFocus()
class Scheme(QObject): """ An :class:`QObject` subclass representing the scheme widget workflow with annotations. Parameters ---------- parent : :class:`QObject` A parent QObject item (default `None`). title : str The scheme title. description : str A longer description of the scheme. Attributes ---------- nodes : list of :class:`.SchemeNode` A list of all the nodes in the scheme. links : list of :class:`.SchemeLink` A list of all links in the scheme. annotations : list of :class:`BaseSchemeAnnotation` A list of all the annotations in the scheme. """ # Signal emitted when a `node` is added to the scheme. node_added = Signal(SchemeNode) # Signal emitted when a `node` is removed from the scheme. node_removed = Signal(SchemeNode) # Signal emitted when a `link` is added to the scheme. link_added = Signal(SchemeLink) # Signal emitted when a `link` is removed from the scheme. link_removed = Signal(SchemeLink) # Signal emitted when a `annotation` is added to the scheme. annotation_added = Signal(BaseSchemeAnnotation) # Signal emitted when a `annotation` is removed from the scheme. annotation_removed = Signal(BaseSchemeAnnotation) # Signal emitted when the title of scheme changes. title_changed = Signal(str) # Signal emitted when the description of scheme changes. description_changed = Signal(str) node_state_changed = Signal() channel_state_changed = Signal() topology_changed = Signal() #: Emitted when the associated runtime environment changes #: runtime_env_changed(key: str, newvalue: Option[str], #: oldvalue: Option[str]) runtime_env_changed = Signal(str, object, object) def __init__(self, parent=None, title=None, description=None, env={}): QObject.__init__(self, parent) self.__title = title or "" "Workflow title (empty string by default)." self.__description = description or "" "Workflow description (empty string by default)." self.__annotations = [] self.__nodes = [] self.__links = [] self.__env = dict(env) @property def nodes(self): """ A list of all nodes (:class:`.SchemeNode`) currently in the scheme. """ return list(self.__nodes) @property def links(self): """ A list of all links (:class:`.SchemeLink`) currently in the scheme. """ return list(self.__links) @property def annotations(self): """ A list of all annotations (:class:`.BaseSchemeAnnotation`) in the scheme. """ return list(self.__annotations) def set_title(self, title): """ Set the scheme title text. """ if self.__title != title: self.__title = title self.title_changed.emit(title) def title(self): """ The title (human readable string) of the scheme. """ return self.__title title = Property(str, fget=title, fset=set_title) def set_description(self, description): """ Set the scheme description text. """ if self.__description != description: self.__description = description self.description_changed.emit(description) def description(self): """ Scheme description text. """ return self.__description description = Property(str, fget=description, fset=set_description) def add_node(self, node): """ Add a node to the scheme. An error is raised if the node is already in the scheme. Parameters ---------- node : :class:`.SchemeNode` Node instance to add to the scheme. """ check_arg(node not in self.__nodes, "Node already in scheme.") check_type(node, SchemeNode) self.__nodes.append(node) log.info("Added node %r to scheme %r." % (node.title, self.title)) self.node_added.emit(node) def new_node(self, description, title=None, position=None, properties=None): """ Create a new :class:`.SchemeNode` and add it to the scheme. Same as:: scheme.add_node(SchemeNode(description, title, position, properties)) Parameters ---------- description : :class:`WidgetDescription` The new node's description. title : str, optional Optional new nodes title. By default `description.name` is used. position : `(x, y)` tuple of floats, optional Optional position in a 2D space. properties : dict, optional A dictionary of optional extra properties. See also -------- .SchemeNode, Scheme.add_node """ if isinstance(description, WidgetDescription): node = SchemeNode(description, title=title, position=position, properties=properties) else: raise TypeError("Expected %r, got %r." % \ (WidgetDescription, type(description))) self.add_node(node) return node def remove_node(self, node): """ Remove a `node` from the scheme. All links into and out of the `node` are also removed. If the node in not in the scheme an error is raised. Parameters ---------- node : :class:`.SchemeNode` Node instance to remove. """ check_arg(node in self.__nodes, "Node is not in the scheme.") self.__remove_node_links(node) self.__nodes.remove(node) log.info("Removed node %r from scheme %r." % (node.title, self.title)) self.node_removed.emit(node) return node def __remove_node_links(self, node): """ Remove all links for node. """ links_in, links_out = [], [] for link in self.__links: if link.source_node is node: links_out.append(link) elif link.sink_node is node: links_in.append(link) for link in links_out + links_in: self.remove_link(link) def add_link(self, link): """ Add a `link` to the scheme. Parameters ---------- link : :class:`.SchemeLink` An initialized link instance to add to the scheme. """ check_type(link, SchemeLink) self.check_connect(link) self.__links.append(link) log.info("Added link %r (%r) -> %r (%r) to scheme %r." % \ (link.source_node.title, link.source_channel.name, link.sink_node.title, link.sink_channel.name, self.title) ) self.link_added.emit(link) def new_link(self, source_node, source_channel, sink_node, sink_channel): """ Create a new :class:`.SchemeLink` from arguments and add it to the scheme. The new link is returned. Parameters ---------- source_node : :class:`.SchemeNode` Source node of the new link. source_channel : :class:`.OutputSignal` Source channel of the new node. The instance must be from ``source_node.output_channels()`` sink_node : :class:`.SchemeNode` Sink node of the new link. sink_channel : :class:`.InputSignal` Sink channel of the new node. The instance must be from ``sink_node.input_channels()`` See also -------- .SchemeLink, Scheme.add_link """ link = SchemeLink(source_node, source_channel, sink_node, sink_channel) self.add_link(link) return link def remove_link(self, link): """ Remove a link from the scheme. Parameters ---------- link : :class:`.SchemeLink` Link instance to remove. """ check_arg(link in self.__links, "Link is not in the scheme.") self.__links.remove(link) log.info("Removed link %r (%r) -> %r (%r) from scheme %r." % \ (link.source_node.title, link.source_channel.name, link.sink_node.title, link.sink_channel.name, self.title) ) self.link_removed.emit(link) def check_connect(self, link): """ Check if the `link` can be added to the scheme and raise an appropriate exception. Can raise: - :class:`TypeError` if `link` is not an instance of :class:`.SchemeLink` - :class:`.SchemeCycleError` if the `link` would introduce a cycle - :class:`.IncompatibleChannelTypeError` if the channel types are not compatible - :class:`.SinkChannelError` if a sink channel has a `Single` flag specification and the channel is already connected. - :class:`.DuplicatedLinkError` if a `link` duplicates an already present link. """ check_type(link, SchemeLink) if self.creates_cycle(link): raise SchemeCycleError("Cannot create cycles in the scheme") if not self.compatible_channels(link): raise IncompatibleChannelTypeError( "Cannot connect %r to %r." \ % (link.source_channel.type, link.sink_channel.type) ) links = self.find_links(source_node=link.source_node, source_channel=link.source_channel, sink_node=link.sink_node, sink_channel=link.sink_channel) if links: raise DuplicatedLinkError( "A link from %r (%r) -> %r (%r) already exists" \ % (link.source_node.title, link.source_channel.name, link.sink_node.title, link.sink_channel.name) ) if link.sink_channel.single: links = self.find_links(sink_node=link.sink_node, sink_channel=link.sink_channel) if links: raise SinkChannelError( "%r is already connected." % link.sink_channel.name ) def creates_cycle(self, link): """ Return `True` if `link` would introduce a cycle in the scheme. Parameters ---------- link : :class:`.SchemeLink` """ check_type(link, SchemeLink) source_node, sink_node = link.source_node, link.sink_node upstream = self.upstream_nodes(source_node) upstream.add(source_node) return sink_node in upstream def compatible_channels(self, link): """ Return `True` if the channels in `link` have compatible types. Parameters ---------- link : :class:`.SchemeLink` """ check_type(link, SchemeLink) return compatible_channels(link.source_channel, link.sink_channel) def can_connect(self, link): """ Return `True` if `link` can be added to the scheme. See also -------- Scheme.check_connect """ check_type(link, SchemeLink) try: self.check_connect(link) return True except (SchemeCycleError, IncompatibleChannelTypeError, SinkChannelError, DuplicatedLinkError): return False def upstream_nodes(self, start_node): """ Return a set of all nodes upstream from `start_node` (i.e. all ancestor nodes). Parameters ---------- start_node : :class:`.SchemeNode` """ visited = set() queue = deque([start_node]) while queue: node = queue.popleft() snodes = [link.source_node for link in self.input_links(node)] for source_node in snodes: if source_node not in visited: queue.append(source_node) visited.add(node) visited.remove(start_node) return visited def downstream_nodes(self, start_node): """ Return a set of all nodes downstream from `start_node`. Parameters ---------- start_node : :class:`.SchemeNode` """ visited = set() queue = deque([start_node]) while queue: node = queue.popleft() snodes = [link.sink_node for link in self.output_links(node)] for source_node in snodes: if source_node not in visited: queue.append(source_node) visited.add(node) visited.remove(start_node) return visited def is_ancestor(self, node, child): """ Return True if `node` is an ancestor node of `child` (is upstream of the child in the workflow). Both nodes must be in the scheme. Parameters ---------- node : :class:`.SchemeNode` child : :class:`.SchemeNode` """ return child in self.downstream_nodes(node) def children(self, node): """ Return a set of all children of `node`. """ return set(link.sink_node for link in self.output_links(node)) def parents(self, node): """ Return a set of all parents of `node`. """ return set(link.source_node for link in self.input_links(node)) def input_links(self, node): """ Return a list of all input links (:class:`.SchemeLink`) connected to the `node` instance. """ return self.find_links(sink_node=node) def output_links(self, node): """ Return a list of all output links (:class:`.SchemeLink`) connected to the `node` instance. """ return self.find_links(source_node=node) def find_links(self, source_node=None, source_channel=None, sink_node=None, sink_channel=None): # TODO: Speedup - keep index of links by nodes and channels result = [] match = lambda query, value: (query is None or value == query) for link in self.__links: if match(source_node, link.source_node) and \ match(sink_node, link.sink_node) and \ match(source_channel, link.source_channel) and \ match(sink_channel, link.sink_channel): result.append(link) return result def propose_links(self, source_node, sink_node): """ Return a list of ordered (:class:`OutputSignal`, :class:`InputSignal`, weight) tuples that could be added to the scheme between `source_node` and `sink_node`. .. note:: This can depend on the links already in the scheme. """ if source_node is sink_node or \ self.is_ancestor(sink_node, source_node): # Cyclic connections are not possible. return [] outputs = source_node.output_channels() inputs = sink_node.input_channels() # Get existing links to sink channels that are Single. links = self.find_links(None, None, sink_node) already_connected_sinks = [link.sink_channel for link in links \ if link.sink_channel.single] def weight(out_c, in_c): # type: (OutputSignal, InputSignal) -> int if out_c.explicit or in_c.explicit: # Zero weight for explicit links weight = 0 else: # Does the connection type check (can only ever be False for # dynamic signals) type_checks = issubclass(name_lookup(out_c.type), name_lookup(in_c.type)) assert type_checks or out_c.dynamic # Dynamic signals that require runtime instance type check # are considered last. check = [type_checks, in_c not in already_connected_sinks, bool(in_c.default), bool(out_c.default) ] weights = [2 ** i for i in range(len(check), 0, -1)] weight = sum([w for w, c in zip(weights, check) if c]) return weight proposed_links = [] for out_c in outputs: for in_c in inputs: if compatible_channels(out_c, in_c): proposed_links.append((out_c, in_c, weight(out_c, in_c))) return sorted(proposed_links, key=itemgetter(-1), reverse=True) def add_annotation(self, annotation): """ Add an annotation (:class:`BaseSchemeAnnotation` subclass) instance to the scheme. """ check_arg(annotation not in self.__annotations, "Cannot add the same annotation multiple times.") check_type(annotation, BaseSchemeAnnotation) self.__annotations.append(annotation) self.annotation_added.emit(annotation) def remove_annotation(self, annotation): """ Remove the `annotation` instance from the scheme. """ check_arg(annotation in self.__annotations, "Annotation is not in the scheme.") self.__annotations.remove(annotation) self.annotation_removed.emit(annotation) def clear(self): """ Remove all nodes, links, and annotation items from the scheme. """ def is_terminal(node): return not bool(self.find_links(source_node=node)) while self.nodes: terminal_nodes = list(filter(is_terminal, self.nodes)) for node in terminal_nodes: self.remove_node(node) for annotation in self.annotations: self.remove_annotation(annotation) assert(not (self.nodes or self.links or self.annotations)) def sync_node_properties(self): """ Called before saving, allowing a subclass to update/sync. The default implementation does nothing. """ pass def save_to(self, stream, pretty=True, pickle_fallback=False): """ Save the scheme as an xml formated file to `stream` See also -------- .scheme_to_ows_stream """ if isinstance(stream, str): stream = open(stream, "wb") self.sync_node_properties() readwrite.scheme_to_ows_stream(self, stream, pretty, pickle_fallback=pickle_fallback) def load_from(self, stream): """ Load the scheme from xml formated stream. """ if self.__nodes or self.__links or self.__annotations: # TODO: should we clear the scheme and load it. raise ValueError("Scheme is not empty.") if isinstance(stream, str): stream = open(stream, "rb") readwrite.scheme_load(self, stream) # parse_scheme(self, stream) def set_runtime_env(self, key, value): """ Set a runtime environment variable `key` to `value` """ oldvalue = self.__env.get(key, None) if value != oldvalue: self.__env[key] = value self.runtime_env_changed.emit(key, value, oldvalue) def get_runtime_env(self, key, default=None): """ Return a runtime environment variable for `key`. """ return self.__env.get(key, default) def runtime_env(self): """ Return (a view to) the full runtime environment. The return value is a types.MappingProxyType of the underlying environment dictionary. Changes to the env. will be reflected in it. """ return types.MappingProxyType(self.__env) def dump_settings(self, node: SchemeNode): """Dump current settings of the `node` to the standard output""" print(node.properties)
class SchemeArrowAnnotation(BaseSchemeAnnotation): """ An arrow annotation in the scheme. """ color_changed = Signal(str) def __init__(self, start_pos, end_pos, color="red", anchor=None, parent=None): # type: (Pos, Pos, str, Any, Optional[QObject]) -> None super().__init__(parent) self.__start_pos = start_pos self.__end_pos = end_pos self.__color = color self.__anchor = anchor def set_line(self, start_pos, end_pos): # type: (Pos, Pos) -> None """ Set arrow lines start and end position (``(x, y)`` tuples). """ if self.__start_pos != start_pos or self.__end_pos != end_pos: self.__start_pos = start_pos self.__end_pos = end_pos self.geometry_changed.emit() def start_pos(self): # type: () -> Pos """ Start position of the arrow (base point). """ return self.__start_pos start_pos = Property(tuple, fget=start_pos) # type: ignore def end_pos(self): """ End position of the arrow (arrow head points toward the end). """ return self.__end_pos end_pos = Property(tuple, fget=end_pos) # type: ignore def set_geometry(self, geometry): # type: (Tuple[Pos, Pos]) -> None """ Set the geometry of the arrow as a start and end position tuples (e.g. ``set_geometry(((0, 0), (100, 0))``). """ (start_pos, end_pos) = geometry self.set_line(start_pos, end_pos) def geometry(self): # type: () -> Tuple[Pos, Pos] """ Return the start and end positions of the arrow. """ return (self.start_pos, self.end_pos) geometry = Property(tuple, fget=geometry, fset=set_geometry) # type: ignore def set_color(self, color): # type: (str) -> None """ Set the fill color for the arrow as a string (`#RGB`, `#RRGGBB`, `#RRRGGGBBB`, `#RRRRGGGGBBBB` format or one of SVG color keyword names). """ if self.__color != color: self.__color = color self.color_changed.emit(color) def color(self): # type: () -> str """ The arrow's fill color. """ return self.__color color = Property(str, fget=color, fset=set_color) # type: ignore
class CollapsibleDockWidget(QDockWidget): """ This :class:`QDockWidget` subclass overrides the `close` header button to instead collapse to a smaller size. The contents contents to show when in each state can be set using the ``setExpandedWidget`` and ``setCollapsedWidget``. .. note:: Do not use the base class ``QDockWidget.setWidget`` method to set the docks contents. Use set[Expanded|Collapsed]Widget instead. """ #: Emitted when the dock widget's expanded state changes. expandedChanged = Signal(bool) def __init__(self, *args, **kwargs): QDockWidget.__init__(self, *args, **kwargs) self.__expandedWidget = None self.__collapsedWidget = None self.__expanded = True self.__trueMinimumWidth = -1 self.setFeatures(QDockWidget.DockWidgetClosable | \ QDockWidget.DockWidgetMovable) self.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea) self.featuresChanged.connect(self.__onFeaturesChanged) self.dockLocationChanged.connect(self.__onDockLocationChanged) # Use the toolbar horizontal extension button icon as the default # for the expand/collapse button icon = self.style().standardIcon( QStyle.SP_ToolBarHorizontalExtensionButton) # Mirror the icon transform = QTransform() transform = transform.scale(-1.0, 1.0) icon_rev = QIcon() for s in (8, 12, 14, 16, 18, 24, 32, 48, 64): pm = icon.pixmap(s, s) icon_rev.addPixmap(pm.transformed(transform)) self.__iconRight = QIcon(icon) self.__iconLeft = QIcon(icon_rev) close = self.findChild(QAbstractButton, name="qt_dockwidget_closebutton") close.installEventFilter(self) self.__closeButton = close self.__stack = AnimatedStackedWidget() self.__stack.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) self.__stack.transitionStarted.connect(self.__onTransitionStarted) self.__stack.transitionFinished.connect(self.__onTransitionFinished) QDockWidget.setWidget(self, self.__stack) self.__closeButton.setIcon(self.__iconLeft) def setExpanded(self, state): """ Set the widgets `expanded` state. """ if self.__expanded != state: self.__expanded = state if state and self.__expandedWidget is not None: log.debug("Dock expanding.") self.__stack.setCurrentWidget(self.__expandedWidget) elif not state and self.__collapsedWidget is not None: log.debug("Dock collapsing.") self.__stack.setCurrentWidget(self.__collapsedWidget) self.__fixIcon() self.expandedChanged.emit(state) def expanded(self): """ Is the dock widget in expanded state. If `True` the ``expandedWidget`` will be shown, and ``collapsedWidget`` otherwise. """ return self.__expanded expanded_ = Property(bool, fset=setExpanded, fget=expanded) def setWidget(self, w): raise NotImplementedError( "Use the 'setExpandedWidget'/'setCollapsedWidget' " "methods to set the contents of the dock widget.") def setExpandedWidget(self, widget): """ Set the widget with contents to show while expanded. """ if widget is self.__expandedWidget: return if self.__expandedWidget is not None: self.__stack.removeWidget(self.__expandedWidget) self.__stack.insertWidget(0, widget) self.__expandedWidget = widget if self.__expanded: self.__stack.setCurrentWidget(widget) self.updateGeometry() def expandedWidget(self): """ Return the widget previously set with ``setExpandedWidget``, or ``None`` if no widget has been set. """ return self.__expandedWidget def setCollapsedWidget(self, widget): """ Set the widget with contents to show while collapsed. """ if widget is self.__collapsedWidget: return if self.__collapsedWidget is not None: self.__stack.removeWidget(self.__collapsedWidget) self.__stack.insertWidget(1, widget) self.__collapsedWidget = widget if not self.__expanded: self.__stack.setCurrentWidget(widget) self.updateGeometry() def collapsedWidget(self): """ Return the widget previously set with ``setCollapsedWidget``, or ``None`` if no widget has been set. """ return self.__collapsedWidget def setAnimationEnabled(self, animationEnabled): """ Enable/disable the transition animation. """ self.__stack.setAnimationEnabled(animationEnabled) def animationEnabled(self): """ Is transition animation enabled. """ return self.__stack.animationEnabled() def currentWidget(self): """ Return the current shown widget depending on the `expanded` state. """ if self.__expanded: return self.__expandedWidget else: return self.__collapsedWidget def expand(self): """ Expand the dock (same as ``setExpanded(True)``) """ self.setExpanded(True) def collapse(self): """ Collapse the dock (same as ``setExpanded(False)``) """ self.setExpanded(False) def eventFilter(self, obj, event): if obj is self.__closeButton: etype = event.type() if etype == QEvent.MouseButtonPress: self.setExpanded(not self.__expanded) return True elif etype == QEvent.MouseButtonDblClick or \ etype == QEvent.MouseButtonRelease: return True # TODO: which other events can trigger the button (is the button # focusable). return QDockWidget.eventFilter(self, obj, event) def event(self, event): if event.type() == QEvent.LayoutRequest: self.__fixMinimumWidth() return QDockWidget.event(self, event) def __onFeaturesChanged(self, features): pass def __onDockLocationChanged(self, area): if area == Qt.LeftDockWidgetArea: self.setLayoutDirection(Qt.LeftToRight) else: self.setLayoutDirection(Qt.RightToLeft) self.__stack.setLayoutDirection(self.parentWidget().layoutDirection()) self.__fixIcon() def __onTransitionStarted(self): log.debug("Dock transition started.") def __onTransitionFinished(self): log.debug("Dock transition finished (new width %i)", self.size().width()) def __fixMinimumWidth(self): # A workaround for forcing the QDockWidget layout to disregard the # default minimumSize which can be to wide for us (overriding the # minimumSizeHint or setting the minimum size directly does not # seem to have an effect (Qt 4.8.3). size = self.__stack.sizeHint() if size.isValid() and not size.isEmpty(): left, _, right, _ = self.getContentsMargins() width = size.width() + left + right if width < self.minimumSizeHint().width(): if not self.__hasFixedWidth(): log.debug( "Overriding default minimum size " "(setFixedWidth(%i))", width) self.__trueMinimumWidth = self.minimumSizeHint().width() self.setFixedWidth(width) else: if self.__hasFixedWidth(): if width >= self.__trueMinimumWidth: # Unset the fixed size. log.debug( "Restoring default minimum size " "(setFixedWidth(%i))", QWIDGETSIZE_MAX) self.__trueMinimumWidth = -1 self.setFixedWidth(QWIDGETSIZE_MAX) self.updateGeometry() else: self.setFixedWidth(width) def __hasFixedWidth(self): return self.__trueMinimumWidth >= 0 def __fixIcon(self): """Fix the dock close icon. """ direction = self.layoutDirection() if direction == Qt.LeftToRight: if self.__expanded: icon = self.__iconLeft else: icon = self.__iconRight else: if self.__expanded: icon = self.__iconRight else: icon = self.__iconLeft self.__closeButton.setIcon(icon)
class SchemeNode(QObject): """ A node in a :class:`.Scheme`. Parameters ---------- description : :class:`WidgetDescription` Node description instance. title : str, optional Node title string (if None `description.name` is used). position : tuple (x, y) tuple of floats for node position in a visual display. properties : dict Additional extra instance properties (settings, widget geometry, ...) parent : :class:`QObject` Parent object. """ def __init__(self, description, title=None, position=None, properties=None, parent=None): QObject.__init__(self, parent) self.description = description if title is None: title = description.name self.__title = title self.__position = position or (0, 0) self.__progress = -1 self.__processing_state = 0 self.__status_message = "" self.__state_messages = {} self.properties = properties or {} def input_channels(self): """ Return a list of input channels (:class:`InputSignal`) for the node. """ return list(self.description.inputs) def output_channels(self): """ Return a list of output channels (:class:`OutputSignal`) for the node. """ return list(self.description.outputs) def input_channel(self, name): """ Return the input channel matching `name`. Raise a `ValueError` if not found. """ for channel in self.input_channels(): if channel.name == name: return channel raise ValueError("%r is not a valid input channel name for %r." % (name, self.description.name)) def output_channel(self, name): """ Return the output channel matching `name`. Raise an `ValueError` if not found. """ for channel in self.output_channels(): if channel.name == name: return channel raise ValueError("%r is not a valid output channel name for %r." % (name, self.description.name)) #: The title of the node has changed title_changed = Signal(str) def set_title(self, title): """ Set the node title. """ if self.__title != title: self.__title = str(title) self.title_changed.emit(self.__title) def title(self): """ The node title. """ return self.__title title = Property(str, fset=set_title, fget=title) #: Position of the node in the scheme has changed position_changed = Signal(tuple) def set_position(self, pos): """ Set the position (``(x, y)`` tuple) of the node. """ if self.__position != pos: self.__position = pos self.position_changed.emit(pos) def position(self): """ ``(x, y)`` tuple containing the position of the node in the scheme. """ return self.__position position = Property(tuple, fset=set_position, fget=position) #: Node's progress value has changed. progress_changed = Signal(float) def set_progress(self, value): """ Set the progress value. """ if self.__progress != value: self.__progress = value self.progress_changed.emit(value) def progress(self): """ The current progress value. -1 if progress is not set. """ return self.__progress progress = Property(float, fset=set_progress, fget=progress) #: Node's processing state has changed. processing_state_changed = Signal(int) def set_processing_state(self, state): """ Set the node processing state. """ if self.__processing_state != state: self.__processing_state = state self.processing_state_changed.emit(state) def processing_state(self): """ The node processing state, 0 for not processing, 1 the node is busy. """ return self.__processing_state processing_state = Property(int, fset=set_processing_state, fget=processing_state) def set_tool_tip(self, tool_tip): if self.__tool_tip != tool_tip: self.__tool_tip = tool_tip def tool_tip(self): return self.__tool_tip tool_tip = Property(str, fset=set_tool_tip, fget=tool_tip) #: The node's status tip has changes status_message_changed = Signal(str) def set_status_message(self, text): if self.__status_message != text: self.__status_message = text self.status_message_changed.emit(text) def status_message(self): return self.__status_message #: The node's state message has changed state_message_changed = Signal(UserMessage) def set_state_message(self, message): """ Set a message to be displayed by a scheme view for this node. """ if message.message_id in self.__state_messages and not message.contents: del self.__state_messages[message.message_id] self.__state_messages[message.message_id] = message self.state_message_changed.emit(message) def state_messages(self): """ Return a list of all state messages. """ return self.__state_messages.values() def __str__(self): return "SchemeNode(description_id=%s, title=%r, ...)" % ( str(self.description.id), self.title, ) def __repr__(self): return str(self)
class FramelessWindow(QWidget): """ A basic frameless window widget with rounded corners (if supported by the windowing system). """ def __init__(self, parent=None, radius=6, **kwargs): # type: (Optional[QWidget], int, Any) -> None super().__init__(parent, **kwargs) self.setWindowFlags(self.windowFlags() | Qt.FramelessWindowHint) self.__radius = radius self.__isTransparencySupported = is_transparency_supported() self.setAttribute(Qt.WA_TranslucentBackground, self.__isTransparencySupported) def setRadius(self, radius): # type: (int) -> None """ Set the window rounded border radius. """ if self.__radius != radius: self.__radius = radius if not self.__isTransparencySupported: self.__updateMask() self.update() def radius(self): # type: () -> int """ Return the border radius. """ return self.__radius radius_ = Property(int, fget=radius, fset=setRadius, designable=True, doc="Window border radius") def resizeEvent(self, event): super().resizeEvent(event) if not self.__isTransparencySupported: self.__updateMask() def __updateMask(self): # type: () -> None opt = QStyleOption() opt.initFrom(self) rect = opt.rect size = rect.size() mask = QBitmap(size) p = QPainter(mask) p.setRenderHint(QPainter.Antialiasing) p.setBrush(Qt.black) p.setPen(Qt.NoPen) p.drawRoundedRect(rect, self.__radius, self.__radius) p.end() self.setMask(mask) def paintEvent(self, event): # type: (QPaintEvent) -> None if self.__isTransparencySupported: opt = QStyleOption() opt.initFrom(self) rect = opt.rect p = QPainter(self) p.setRenderHint(QPainter.Antialiasing, True) p.setBrush(opt.palette.brush(QPalette.Window)) p.setPen(Qt.NoPen) p.drawRoundedRect(rect, self.__radius, self.__radius) p.end() else: StyledWidget_paintEvent(self, event)
class StartItem(QWidget): """ An active item in the bottom row of the welcome screen. """ def __init__(self, *args, text="", icon=QIcon(), iconSize=QSize(), iconActive=QIcon(), **kwargs): self.__iconSize = QSize() self.__icon = QIcon() self.__icon_active = QIcon() self.__text = "" self.__active = False super().__init__(*args, **kwargs) self.setAutoFillBackground(True) font = self.font() font.setPointSize(18) self.setFont(font) self.setAttribute(Qt.WA_SetFont, False) self.setText(text) self.setIcon(icon) self.setIconSize(iconSize) self.setIconActive(iconActive) self.installEventFilter(self) def iconSize(self): if not self.__iconSize.isValid(): size = self.style().pixelMetric(QStyle.PM_LargeIconSize, None, self) * 2 return QSize(size, size) else: return QSize(self.__iconSize) def setIconSize(self, size): if size != self.__iconSize: self.__iconSize = QSize(size) self.updateGeometry() iconSize_ = Property(QSize, iconSize, setIconSize, designable=True) def icon(self): if self.__active: return QIcon(self.__icon_active) else: return QIcon(self.__icon) def setIcon(self, icon): self.__icon = QIcon(icon) self.update() icon_ = Property(QIcon, icon, setIcon, designable=True) def iconActive(self): return QIcon(self.__icon_active) def setIconActive(self, icon): self.__icon_active = QIcon(icon) self.update() icon_active_ = Property(QIcon, iconActive, setIconActive, designable=True) def sizeHint(self): return QSize(200, 150) def setText(self, text): if self.__text != text: self.__text = text self.updateGeometry() self.update() def text(self): return self.__text text_ = Property(str, text, setText, designable=True) def initStyleOption(self, option): # type: (QStyleOptionViewItem) -> None option.initFrom(self) option.backgroundBrush = option.palette.brush( self.backgroundRole()) option.font = self.font() option.text = self.text() option.icon = self.icon() option.decorationPosition = QStyleOptionViewItem.Top option.decorationAlignment = Qt.AlignCenter option.decorationSize = self.iconSize() option.displayAlignment = Qt.AlignCenter option.features = (QStyleOptionViewItem.WrapText | QStyleOptionViewItem.HasDecoration | QStyleOptionViewItem.HasDisplay) option.showDecorationSelected = True option.widget = self def paintEvent(self, event): style = self.style() # type: QStyle painter = QPainter(self) option = QStyleOption() option.initFrom(self) style.drawPrimitive(QStyle.PE_Widget, option, painter, self) option = QStyleOptionViewItem() self.initStyleOption(option) style.drawControl(QStyle.CE_ItemViewItem, option, painter, self) def eventFilter(self, obj, event): try: if event.type() == QEvent.Enter: self.__active = True self.setCursor(Qt.PointingHandCursor) self.update() return True elif event.type() == QEvent.Leave: self.__active = False self.unsetCursor() self.update() return True except Exception as ex: pass return False
class IconWidget(QWidget): """ A widget displaying an `QIcon` """ def __init__(self, parent=None, icon=QIcon(), iconSize=QSize(), **kwargs): sizePolicy = kwargs.pop( "sizePolicy", QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)) super().__init__(parent, **kwargs) self._opacity = 1 self.__icon = QIcon(icon) self.__iconSize = QSize(iconSize) self.setSizePolicy(sizePolicy) def setIcon(self, icon): # type: (QIcon) -> None if self.__icon != icon: self.__icon = QIcon(icon) self.updateGeometry() self.update() def getOpacity(self): return self._opacity def setOpacity(self, o): self._opacity = o self.update() opacity = Property(float, fget=getOpacity, fset=setOpacity) def icon(self): # type: () -> QIcon return QIcon(self.__icon) def iconSize(self): # type: () -> QSize if not self.__iconSize.isValid(): size = self.style().pixelMetric(QStyle.PM_ButtonIconSize) return QSize(size, size) else: return QSize(self.__iconSize) def setIconSize(self, iconSize): # type: (QSize) -> None if self.__iconSize != iconSize: self.__iconSize = QSize(iconSize) self.updateGeometry() self.update() def sizeHint(self): sh = self.iconSize() m = self.contentsMargins() return QSize(sh.width() + m.left() + m.right(), sh.height() + m.top() + m.bottom()) def paintEvent(self, event): painter = QStylePainter(self) painter.setOpacity(self._opacity) opt = QStyleOption() opt.initFrom(self) painter.drawPrimitive(QStyle.PE_Widget, opt) if not self.__icon.isNull(): rect = self.contentsRect() if opt.state & QStyle.State_Active: mode = QIcon.Active else: mode = QIcon.Disabled self.__icon.paint(painter, rect, Qt.AlignCenter, mode, QIcon.Off) painter.end()
class GraphicsTextEdit(QGraphicsTextItem): """ QGraphicsTextItem subclass defining an additional placeholderText property (text displayed when no text is set). """ #: Signal emitted when editing operation starts (the item receives edit #: focus) editingStarted = Signal() #: Signal emitted when editing operation ends (the item loses edit focus) editingFinished = Signal() def __init__(self, *args, placeholderText="", **kwargs): # type: (Any, str, Any) -> None super().__init__(*args, **kwargs) self.setAcceptHoverEvents(True) self.__placeholderText = placeholderText self.__editing = False # text editing in progress def setPlaceholderText(self, text): # type: (str) -> None """ Set the placeholder text. This is shown when the item has no text, i.e when `toPlainText()` returns an empty string. """ if self.__placeholderText != text: self.__placeholderText = text if not self.toPlainText(): self.update() def placeholderText(self): # type: () -> str """ Return the placeholder text. """ return self.__placeholderText placeholderText_ = Property(str, placeholderText, setPlaceholderText, doc="Placeholder text") def paint(self, painter, option, widget=None): # type: (QPainter, QStyleOptionGraphicsItem, Optional[QWidget]) -> None super().paint(painter, option, widget) # Draw placeholder text if necessary if not (self.toPlainText() and self.toHtml()) and \ self.__placeholderText and \ not (self.hasFocus() and \ self.textInteractionFlags() & Qt.TextEditable): brect = self.boundingRect() painter.setFont(self.font()) metrics = painter.fontMetrics() text = metrics.elidedText(self.__placeholderText, Qt.ElideRight, brect.width()) color = self.defaultTextColor() color.setAlpha(min(color.alpha(), 150)) painter.setPen(QPen(color)) painter.drawText(brect, Qt.AlignTop | Qt.AlignLeft, text) def hoverMoveEvent(self, event): # type: (QGraphicsSceneHoverEvent) -> None layout = self.document().documentLayout() if layout.anchorAt(event.pos()): self.setCursor(Qt.PointingHandCursor) else: self.unsetCursor() super().hoverMoveEvent(event) def mousePressEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> None flags = self.textInteractionFlags() if flags & Qt.LinksAccessibleByMouse \ and not flags & Qt.TextSelectableByMouse \ and self.document().documentLayout().anchorAt(event.pos()): # QGraphicsTextItem ignores the press event without # Qt.TextSelectableByMouse flag set. This causes the # corresponding mouse release to never get to this item # and therefore no linkActivated/openUrl ... super().mousePressEvent(event) if not event.isAccepted(): event.accept() else: super().mousePressEvent(event) def setTextInteractionFlags(self, flags): # type: (Union[Qt.TextInteractionFlag, Qt.TextInteractionFlags]) -> None super().setTextInteractionFlags(flags) if self.hasFocus() and flags & Qt.TextEditable and not self.__editing: self.__editing = True self.editingStarted.emit() def focusInEvent(self, event): # type: (QFocusEvent) -> None super().focusInEvent(event) if self.textInteractionFlags() & Qt.TextEditable and \ not self.__editing: self.__editing = True self.editingStarted.emit() def focusOutEvent(self, event): # type: (QFocusEvent) -> None super().focusOutEvent(event) if self.__editing and \ event.reason() not in {Qt.ActiveWindowFocusReason, Qt.PopupFocusReason}: self.__editing = False self.editingFinished.emit()
class DropShadowFrame(QWidget): """ A widget drawing a drop shadow effect around the geometry of another widget (works similar to :class:`QFocusFrame`). Parameters ---------- parent : :class:`QObject` Parent object. color : :class:`QColor` The color of the drop shadow. radius : float Shadow radius. """ def __init__(self, parent=None, color=QColor(), radius=5, **kwargs): QWidget.__init__(self, parent, **kwargs) self.setAttribute(Qt.WA_TransparentForMouseEvents, True) self.setAttribute(Qt.WA_NoChildEventsForParent, True) self.setFocusPolicy(Qt.NoFocus) self.__color = QColor(color) self.__radius = radius self.__widget = None self.__widgetParent = None self.__updatePixmap() def setColor(self, color): """ Set the color of the shadow. """ if not isinstance(color, QColor): color = QColor(color) if self.__color != color: self.__color = QColor(color) self.__updatePixmap() def color(self): """ Return the color of the drop shadow. By default this is a color from the `palette` (for `self.foregroundRole()`) """ if self.__color.isValid(): return QColor(self.__color) else: return self.palette().color(self.foregroundRole()) color_ = Property(QColor, fget=color, fset=setColor, designable=True, doc="Drop shadow color") def setRadius(self, radius): """ Set the drop shadow's blur radius. """ if self.__radius != radius: self.__radius = radius self.__updateGeometry() self.__updatePixmap() def radius(self): """ Return the shadow blur radius. """ return self.__radius radius_ = Property( int, fget=radius, fset=setRadius, designable=True, doc="Drop shadow blur radius.", ) def setWidget(self, widget): """ Set the widget around which to show the shadow. """ if self.__widget: self.__widget.removeEventFilter(self) self.__widget = widget if self.__widget: self.__widget.installEventFilter(self) # Find the parent for the frame # This is the top level window a toolbar or a viewport # of a scroll area parent = widget.parentWidget() while not (isinstance(parent, (QAbstractScrollArea, QToolBar)) or parent.isWindow()): parent = parent.parentWidget() if isinstance(parent, QAbstractScrollArea): parent = parent.viewport() self.__widgetParent = parent self.setParent(parent) self.stackUnder(widget) self.__updateGeometry() self.setVisible(widget.isVisible()) def widget(self): """ Return the widget that was set by `setWidget`. """ return self.__widget def paintEvent(self, event): # TODO: Use QPainter.drawPixmapFragments on Qt 4.7 opt = QStyleOption() opt.initFrom(self) pixmap = self.__shadowPixmap shadow_rect = QRectF(opt.rect) widget_rect = QRectF(self.widget().geometry()) widget_rect.moveTo(self.radius_, self.radius_) left = top = right = bottom = self.radius_ pixmap_rect = QRectF(QPointF(0, 0), QSizeF(pixmap.size())) # Shadow casting rectangle in the source pixmap. pixmap_shadow_rect = pixmap_rect.adjusted(left, top, -right, -bottom) source_rects = self.__shadowPixmapFragments(pixmap_rect, pixmap_shadow_rect) target_rects = self.__shadowPixmapFragments(shadow_rect, widget_rect) painter = QPainter(self) for source, target in zip(source_rects, target_rects): painter.drawPixmap(target, pixmap, source) painter.end() def eventFilter(self, obj, event): etype = event.type() if etype == QEvent.Move or etype == QEvent.Resize: self.__updateGeometry() elif etype == QEvent.Show: self.__updateGeometry() self.show() elif etype == QEvent.Hide: self.hide() return QWidget.eventFilter(self, obj, event) def __updateGeometry(self): """ Update the shadow geometry to fit the widget's changed geometry. """ widget = self.__widget parent = self.__widgetParent radius = self.radius_ pos = widget.pos() if parent != widget.parentWidget(): pos = widget.parentWidget().mapTo(parent, pos) geom = QRect(pos, widget.size()) geom.adjust(-radius, -radius, radius, radius) if geom != self.geometry(): self.setGeometry(geom) # Set the widget mask (punch a hole through to the `widget` instance. rect = self.rect() mask = QRegion(rect) transparent = QRegion(rect.adjusted(radius, radius, -radius, -radius)) mask = mask.subtracted(transparent) self.setMask(mask) def __updatePixmap(self): """ Update the cached shadow pixmap. """ rect_size = QSize(50, 50) left = top = right = bottom = self.radius_ # Size of the pixmap. pixmap_size = QSize(rect_size.width() + left + right, rect_size.height() + top + bottom) shadow_rect = QRect(QPoint(left, top), rect_size) pixmap = QPixmap(pixmap_size) pixmap.fill(QColor(0, 0, 0, 0)) rect_fill_color = self.palette().color(QPalette.Window) pixmap = render_drop_shadow_frame( pixmap, QRectF(shadow_rect), shadow_color=self.color_, offset=QPointF(0, 0), radius=self.radius_, rect_fill_color=rect_fill_color, ) self.__shadowPixmap = pixmap self.update() def __shadowPixmapFragments(self, pixmap_rect, shadow_rect): """ Return a list of 8 QRectF fragments for drawing a shadow. """ s_left, s_top, s_right, s_bottom = ( shadow_rect.left(), shadow_rect.top(), shadow_rect.right(), shadow_rect.bottom(), ) s_width, s_height = shadow_rect.width(), shadow_rect.height() p_width, p_height = pixmap_rect.width(), pixmap_rect.height() top_left = QRectF(0.0, 0.0, s_left, s_top) top = QRectF(s_left, 0.0, s_width, s_top) top_right = QRectF(s_right, 0.0, p_width - s_width, s_top) right = QRectF(s_right, s_top, p_width - s_right, s_height) right_bottom = QRectF(shadow_rect.bottomRight(), pixmap_rect.bottomRight()) bottom = QRectF( shadow_rect.bottomLeft(), pixmap_rect.bottomRight() - QPointF(p_width - s_right, 0.0), ) bottom_left = QRectF( shadow_rect.bottomLeft() - QPointF(s_left, 0.0), pixmap_rect.bottomLeft() + QPointF(s_left, 0.0), ) left = QRectF(pixmap_rect.topLeft() + QPointF(0.0, s_top), shadow_rect.bottomLeft()) return [ top_left, top, top_right, right, right_bottom, bottom, bottom_left, left, ]
class SchemeTextAnnotation(BaseSchemeAnnotation): """ Text annotation in the scheme. """ # Signal emitted when the annotation content change. content_changed = Signal(str, str) # Signal emitted when the annotation text changes. text_changed = Signal(str) # Signal emitted when the annotation text font changes. font_changed = Signal(dict) def __init__(self, rect, text="", content_type="text/plain", font=None, anchor=None, parent=None): # type: (Rect, str, str, Optional[dict], Any, Optional[QObject]) -> None super().__init__(parent) self.__rect = rect # type: Rect self.__content = text self.__content_type = content_type self.__font = {} if font is None else font self.__anchor = anchor def set_rect(self, rect): # type: (Rect) -> None """ Set the text geometry bounding rectangle (``(x, y, width, height)`` tuple). """ if self.__rect != rect: self.__rect = rect self.geometry_changed.emit() def rect(self): # type: () -> Rect """ Text bounding rectangle """ return self.__rect rect = Property(tuple, fget=rect, fset=set_rect) # type: ignore def set_geometry(self, rect): # type: (Rect) -> None """ Set the text geometry (same as ``set_rect``) """ self.set_rect(rect) def geometry(self): # type: () -> Rect """ Text annotation geometry (same as ``rect``) """ return self.__rect geometry = Property(tuple, fget=geometry, fset=set_geometry) # type: ignore def set_text(self, text): # type: (str) -> None """ Set the annotation text. Same as `set_content(text, "text/plain")` """ self.set_content(text, "text/plain") def text(self): # type: () -> str """ Annotation text. .. deprecated:: Use `content` instead. """ return self.__content text = Property(str, fget=text, fset=set_text) # type: ignore @property def content_type(self): # type: () -> str """ Return the annotations' content type. Currently this will be 'text/plain', 'text/html' or 'text/rst'. """ return self.__content_type @property def content(self): # type: () -> str """ The annotation content. How the content is interpreted/displayed depends on `content_type`. """ return self.__content def set_content(self, content, content_type="text/plain"): # type: (str, str) -> None """ Set the annotation content. Parameters ---------- content : str The content. content_type : str Content type. Currently supported are 'text/plain' 'text/html' (subset supported by `QTextDocument`) and `text/rst`. """ if self.__content != content or self.__content_type != content_type: text_changed = self.__content != content self.__content = content self.__content_type = content_type self.content_changed.emit(content, content_type) if text_changed: self.text_changed.emit(content) def set_font(self, font): # type: (dict) -> None """ Set the annotation's default font as a dictionary of font properties (at the moment only family and size are used). >>> annotation.set_font({"family": "Helvetica", "size": 16}) """ check_type(font, dict) font = dict(font) if self.__font != font: self.__font = font self.font_changed.emit(font) def font(self): # type: () -> dict """ Annotation's font property dictionary. """ return dict(self.__font) font = Property(str, fget=font, fset=set_font) # type: ignore
class WidgetToolBox(ToolBox): """ `WidgetToolBox` widget shows a tool box containing button grids of actions for a :class:`QtWidgetRegistry` item model. """ triggered = Signal(QAction) hovered = Signal(QAction) def __init__(self, parent=None): ToolBox.__init__(self, parent) self.__model = None self.__iconSize = QSize(25, 25) self.__buttonSize = QSize(50, 50) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) def setIconSize(self, size): """ Set the widget icon size (icons in the button grid). """ self.__iconSize = size for widget in map(self.widget, range(self.count())): widget.setIconSize(size) def iconSize(self): """ Return the widget buttons icon size. """ return self.__iconSize iconSize_ = Property(QSize, fget=iconSize, fset=setIconSize, designable=True) def setButtonSize(self, size): """ Set fixed widget button size. """ self.__buttonSize = size for widget in map(self.widget, range(self.count())): widget.setButtonSize(size) def buttonSize(self): """Return the widget button size """ return self.__buttonSize buttonSize_ = Property(QSize, fget=buttonSize, fset=setButtonSize, designable=True) def saveState(self): """ Return the toolbox state (as a `QByteArray`). .. note:: Individual tabs are stored by their action's text. """ version = 2 actions = map(self.tabAction, range(self.count())) expanded = [action for action in actions if action.isChecked()] expanded = [action.text() for action in expanded] byte_array = QByteArray() stream = QDataStream(byte_array, QIODevice.WriteOnly) stream.writeInt(version) stream.writeQStringList(expanded) return byte_array def restoreState(self, state): """ Restore the toolbox from a :class:`QByteArray` `state`. .. note:: The toolbox should already be populated for the state changes to take effect. """ # In version 1 of saved state the state was saved in # a simple dict repr string. if isinstance(state, QByteArray): stream = QDataStream(state, QIODevice.ReadOnly) version = stream.readInt() if version == 2: expanded = stream.readQStringList() for action in map(self.tabAction, range(self.count())): if (action.text() in expanded) != action.isChecked(): action.trigger() return True return False def setModel(self, model): """ Set the widget registry model (:class:`QStandardItemModel`) for this toolbox. """ if self.__model is not None: self.__model.itemChanged.disconnect(self.__on_itemChanged) self.__model.rowsInserted.disconnect(self.__on_rowsInserted) self.__model.rowsRemoved.disconnect(self.__on_rowsRemoved) self.__model = model if self.__model is not None: self.__model.itemChanged.connect(self.__on_itemChanged) self.__model.rowsInserted.connect(self.__on_rowsInserted) self.__model.rowsRemoved.connect(self.__on_rowsRemoved) self.__initFromModel(self.__model) def __initFromModel(self, model): for cat_item in iter_item(model.invisibleRootItem()): self.__insertItem(cat_item, self.count()) def __insertItem(self, item, index): """ Insert category item at index. """ grid = WidgetToolGrid() grid.setModel(item.model(), item.index()) grid.actionTriggered.connect(self.triggered) grid.actionHovered.connect(self.hovered) grid.setIconSize(self.__iconSize) grid.setButtonSize(self.__buttonSize) text = item.text() icon = item.icon() tooltip = item.toolTip() # Set the 'tab-title' property to text. grid.setProperty("tab-title", text) grid.setObjectName("widgets-toolbox-grid") self.insertItem(index, grid, text, icon, tooltip) button = self.tabButton(index) # Set the 'highlight' color if item.data(Qt.BackgroundRole) is not None: brush = item.background() elif item.data(QtWidgetRegistry.BACKGROUND_ROLE) is not None: brush = item.data(QtWidgetRegistry.BACKGROUND_ROLE) else: brush = self.palette().brush(QPalette.Button) if not brush.gradient(): gradient = create_gradient(brush.color()) brush = QBrush(gradient) palette = button.palette() palette.setBrush(QPalette.Highlight, brush) button.setPalette(palette) def __on_itemChanged(self, item): """ Item contents have changed. """ parent = item.parent() if parent is self.__model.invisibleRootItem(): button = self.tabButton(item.row()) button.setIcon(item.icon()) button.setText(item.text()) button.setToolTip(item.toolTip()) def __on_rowsInserted(self, parent, start, end): """ Items have been inserted in the model. """ # Only the top level items (categories) are handled here. if not parent is not None: root = self.__model.invisibleRootItem() for i in range(start, end + 1): item = root.child(i) self.__insertItem(item, i) def __on_rowsRemoved(self, parent, start, end): """ Rows have been removed from the model. """ # Only the top level items (categories) are handled here. if not parent is not None: for i in range(end, start - 1, -1): self.removeItem(i)
class WidgetToolBox(ToolBox): """ `WidgetToolBox` widget shows a tool box containing button grids of actions for a :class:`QtWidgetRegistry` item model. """ triggered = Signal(QAction) hovered = Signal(QAction) def __init__(self, parent=None): # type: (Optional[QWidget]) -> None super().__init__(parent) self.__model = None # type: Optional[QAbstractItemModel] self.__iconSize = QSize(25, 25) self.__buttonSize = QSize(50, 50) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) def setIconSize(self, size): # type: (QSize) -> None """ Set the widget icon size (icons in the button grid). """ if self.__iconSize != size: self.__iconSize = QSize(size) for widget in map(self.widget, range(self.count())): widget.setIconSize(size) def iconSize(self): # type: () -> QSize """ Return the widget buttons icon size. """ return QSize(self.__iconSize) iconSize_ = Property(QSize, fget=iconSize, fset=setIconSize, designable=True) def setButtonSize(self, size): # type: (QSize) -> None """ Set fixed widget button size. """ if self.__buttonSize != size: self.__buttonSize = QSize(size) for widget in map(self.widget, range(self.count())): widget.setButtonSize(size) def buttonSize(self): # type: () -> QSize """Return the widget button size """ return QSize(self.__buttonSize) buttonSize_ = Property(QSize, fget=buttonSize, fset=setButtonSize, designable=True) def saveState(self): # type: () -> QByteArray """ Return the toolbox state (as a `QByteArray`). .. note:: Individual tabs are stored by their action's text. """ version = 2 actions = map(self.tabAction, range(self.count())) expanded = [action for action in actions if action.isChecked()] expanded = [action.text() for action in expanded] byte_array = QByteArray() stream = QDataStream(byte_array, QIODevice.WriteOnly) stream.writeInt(version) stream.writeQStringList(expanded) return byte_array def restoreState(self, state): # type: (QByteArray) -> bool """ Restore the toolbox from a :class:`QByteArray` `state`. .. note:: The toolbox should already be populated for the state changes to take effect. """ stream = QDataStream(state, QIODevice.ReadOnly) version = stream.readInt() if version == 2: expanded = stream.readQStringList() for action in map(self.tabAction, range(self.count())): if (action.text() in expanded) != action.isChecked(): action.trigger() return True return False def setModel(self, model): # type: (QAbstractItemModel) -> None """ Set the widget registry model (:class:`QAbstractItemModel`) for this toolbox. """ if self.__model is not None: self.__model.dataChanged.disconnect(self.__on_dataChanged) self.__model.rowsInserted.disconnect(self.__on_rowsInserted) self.__model.rowsRemoved.disconnect(self.__on_rowsRemoved) self.__model = model if self.__model is not None: self.__model.dataChanged.connect(self.__on_dataChanged) self.__model.rowsInserted.connect(self.__on_rowsInserted) self.__model.rowsRemoved.connect(self.__on_rowsRemoved) self.__initFromModel(self.__model) def __initFromModel(self, model): # type: (QAbstractItemModel) -> None for row in range(model.rowCount()): self.__insertItem(model.index(row, 0), self.count()) def __insertItem(self, item, index): # type: (QModelIndex, int) -> None """ Insert category item (`QModelIndex`) at index. """ grid = WidgetToolGrid() grid.setModel(item.model(), item) grid.actionTriggered.connect(self.triggered) grid.actionHovered.connect(self.hovered) grid.setIconSize(self.__iconSize) grid.setButtonSize(self.__buttonSize) grid.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) text = item_text(item) icon = item_icon(item) tooltip = item_tooltip(item) # Set the 'tab-title' property to text. grid.setProperty("tab-title", text) grid.setObjectName("widgets-toolbox-grid") self.insertItem(index, grid, text, icon, tooltip) button = self.tabButton(index) # Set the 'highlight' color if applicable highlight_foreground = None highlight = item_background(item) if highlight is None \ and item.data(QtWidgetRegistry.BACKGROUND_ROLE) is not None: highlight = item.data(QtWidgetRegistry.BACKGROUND_ROLE) if isinstance(highlight, QBrush) and highlight.style() != Qt.NoBrush: if not highlight.gradient(): value = highlight.color().value() gradient = create_gradient(highlight.color()) highlight = QBrush(gradient) highlight_foreground = Qt.black if value > 128 else Qt.white palette = button.palette() if highlight is not None: palette.setBrush(QPalette.Highlight, highlight) if highlight_foreground is not None: palette.setBrush(QPalette.HighlightedText, highlight_foreground) button.setPalette(palette) def __on_dataChanged(self, topLeft, bottomRight): # type: (QModelIndex, QModelIndex) -> None parent = topLeft.parent() if not parent.isValid(): for row in range(topLeft.row(), bottomRight.row() + 1): item = topLeft.sibling(row, topLeft.column()) button = self.tabButton(row) button.setIcon(item_icon(item)) button.setText(item_text(item)) button.setToolTip(item_tooltip(item)) def __on_rowsInserted(self, parent, start, end): # type: (QModelIndex, int, int) -> None """ Items have been inserted in the model. """ # Only the top level items (categories) are handled here. assert self.__model is not None if not parent.isValid(): for i in range(start, end + 1): item = self.__model.index(i, 0) self.__insertItem(item, i) def __on_rowsRemoved(self, parent, start, end): # type: (QModelIndex, int, int) -> None """ Rows have been removed from the model. """ # Only the top level items (categories) are handled here. if not parent.isValid(): for i in range(end, start - 1, -1): self.removeItem(i)
class SchemeNode(QObject): """ A node in a :class:`.Scheme`. Parameters ---------- description : :class:`WidgetDescription` Node description instance. title : str, optional Node title string (if None `description.name` is used). position : tuple (x, y) tuple of floats for node position in a visual display. properties : dict Additional extra instance properties (settings, widget geometry, ...) parent : :class:`QObject` Parent object. """ class State(enum.IntEnum): """ A workflow node's runtime state flags """ #: The node has no state. NoState = 0 #: The node is running (i.e. executing a task). Running = 1 #: The node has invalidated inputs. This flag is set when: #: #: * An input link is added or removed #: * An input link is marked as pending #: #: It is set/cleared by the execution manager when the inputs are #: propagated to the node. Pending = 2 #: The node has invalidated outputs. Execution manager should not #: propagate this node's existing outputs to dependent nodes until #: this flag is cleared. Invalidated = 4 #: The node is in a state where it does not accept new signals. #: The execution manager should not propagate inputs to this node #: until this flag is cleared. NotReady = 8 NoState = State.NoState Running = State.Running Pending = State.Pending Invalidated = State.Invalidated NotReady = State.NotReady def __init__(self, description, title=None, position=None, properties=None, parent=None): # type: (WidgetDescription, str, Tuple[float, float], dict, QObject) -> None super().__init__(parent) self.description = description if title is None: title = description.name self.__title = title self.__position = position or (0, 0) self.__progress = -1 self.__processing_state = 0 self.__status_message = "" self.__state_messages = {} # type: Dict[str, UserMessage] self.__state = SchemeNode.NoState # type: Union[SchemeNode.State, int] self.properties = properties or {} def input_channels(self): # type: () -> List[InputSignal] """ Return a list of input channels (:class:`InputSignal`) for the node. """ return list(self.description.inputs) def output_channels(self): # type: () -> List[OutputSignal] """ Return a list of output channels (:class:`OutputSignal`) for the node. """ return list(self.description.outputs) def input_channel(self, name): # type: (str) -> InputSignal """ Return the input channel matching `name`. Raise a `ValueError` if not found. """ for channel in self.input_channels(): if channel.name == name: return channel raise ValueError("%r is not a valid input channel name for %r." % \ (name, self.description.name)) def output_channel(self, name): # type: (str) -> OutputSignal """ Return the output channel matching `name`. Raise an `ValueError` if not found. """ for channel in self.output_channels(): if channel.name == name: return channel raise ValueError("%r is not a valid output channel name for %r." % \ (name, self.description.name)) #: The title of the node has changed title_changed = Signal(str) def set_title(self, title): """ Set the node title. """ if self.__title != title: self.__title = title self.title_changed.emit(self.__title) def title(self): """ The node title. """ return self.__title title = Property(str, fset=set_title, fget=title) # type: ignore #: Position of the node in the scheme has changed position_changed = Signal(tuple) def set_position(self, pos): """ Set the position (``(x, y)`` tuple) of the node. """ if self.__position != pos: self.__position = pos self.position_changed.emit(pos) def position(self): """ ``(x, y)`` tuple containing the position of the node in the scheme. """ return self.__position position = Property(tuple, fset=set_position, fget=position) # type: ignore #: Node's progress value has changed. progress_changed = Signal(float) def set_progress(self, value): """ Set the progress value. """ if self.__progress != value: self.__progress = value self.progress_changed.emit(value) def progress(self): """ The current progress value. -1 if progress is not set. """ return self.__progress progress = Property(float, fset=set_progress, fget=progress) # type: ignore #: Node's processing state has changed. processing_state_changed = Signal(int) def set_processing_state(self, state): """ Set the node processing state. """ self.set_state_flags(SchemeNode.Running, bool(state)) def processing_state(self): """ The node processing state, 0 for not processing, 1 the node is busy. """ return int(bool(self.state() & SchemeNode.Running)) processing_state = Property( int, fset=set_processing_state, # type: ignore fget=processing_state) def set_tool_tip(self, tool_tip): if self.__tool_tip != tool_tip: self.__tool_tip = tool_tip def tool_tip(self): return self.__tool_tip tool_tip = Property( str, fset=set_tool_tip, # type: ignore fget=tool_tip) #: The node's status tip has changes status_message_changed = Signal(str) def set_status_message(self, text): # type: (str) -> None """Set a short status message.""" if self.__status_message != text: self.__status_message = text self.status_message_changed.emit(text) def status_message(self): # type: () -> str """A short status message summarizing the current node state.""" return self.__status_message #: The node's state message has changed state_message_changed = Signal(UserMessage) def set_state_message(self, message): # type: (UserMessage) -> None """ Set a message to be displayed by a scheme view for this node. """ if message.message_id is not None: self.__state_messages[message.message_id] = message self.state_message_changed.emit(message) else: warnings.warn( "'message' with no id was ignored. " "This will raise an error in the future.", FutureWarning, stacklevel=2) def clear_state_message(self, message_id): # type: (str) -> None """ Clear (remove) a message with `message_id`. :attr:`state_message_changed` signal will be emitted with a empty message for the `message_id`. """ if message_id in self.__state_messages: # emit an empty message m = self.__state_messages[message_id] m = UserMessage("", m.severity, m.message_id) self.__state_messages[message_id] = m self.state_message_changed.emit(m) del self.__state_messages[message_id] def state_message(self, message_id): # type: (str) -> Optional[UserMessage] """ Return a message with `message_id` or None if a message with that id does not exist. """ return self.__state_messages.get(message_id, None) def state_messages(self): # type: () -> Iterable[UserMessage] """ Return a list of all state messages. """ return self.__state_messages.values() state_changed = Signal(int) def set_state(self, state): # type: (Union[State, int]) -> None """ Set the node runtime state flags Parameters ---------- state: SchemeNode.State """ if self.__state != state: curr = self.__state self.__state = state QCoreApplication.sendEvent( self, NodeEvent(NodeEvent.NodeStateChange, self)) self.state_changed.emit(state) if curr & SchemeNode.Running != state & SchemeNode.Running: self.processing_state_changed.emit( int(bool(state & SchemeNode.Running))) def state(self): # type: () -> Union[State, int] """ Return the node runtime state flags. """ return self.__state def set_state_flags(self, flags, on): # type: (Union[State, int], bool) -> None """ Set the specified state flags on/off. Parameters ---------- flags: SchemeNode.State Flag to modify on: bool Turn the flag on or off """ if on: state = self.__state | flags else: state = self.__state & ~flags self.set_state(state) def test_state_flags(self, flag): # type: (State) -> bool """ Return True/False if the runtime state flag is set. Parameters ---------- flag: SchemeNode.State Returns ------- val: bool """ return bool(self.__state & flag) def __str__(self): return "SchemeNode(description_id=%r, title=%r, ...)" % \ (str(self.description.id), self.title) def __repr__(self): return str(self)
class ToolBoxTabButton(QToolButton): """ A tab button for an item in a :class:`ToolBox`. """ def setNativeStyling(self, state): """ Render tab buttons as native (or css styled) :class:`QToolButtons`. If set to `False` (default) the button is pained using a custom paint routine. """ self.__nativeStyling = state self.update() def nativeStyling(self): """ Use :class:`QStyle`'s to paint the class:`QToolButton` look. """ return self.__nativeStyling nativeStyling_ = Property(bool, fget=nativeStyling, fset=setNativeStyling, designable=True) def __init__(self, *args, **kwargs): self.__nativeStyling = False self.position = QStyleOptionToolBox.OnlyOneTab self.selected = QStyleOptionToolBox.NotAdjacent QToolButton.__init__(self, *args, **kwargs) def paintEvent(self, event): if self.__nativeStyling: QToolButton.paintEvent(self, event) else: self.__paintEventNoStyle() def __paintEventNoStyle(self): p = QPainter(self) opt = QStyleOptionToolButton() self.initStyleOption(opt) fm = QFontMetrics(opt.font) palette = opt.palette # highlight brush is used as the background for the icon and background # when the tab is expanded and as mouse hover color (lighter). brush_highlight = palette.highlight() if opt.state & QStyle.State_Sunken: # State 'down' pressed during a mouse press (slightly darker). background_brush = brush_darker(brush_highlight, 110) elif opt.state & QStyle.State_MouseOver: background_brush = brush_darker(brush_highlight, 95) elif opt.state & QStyle.State_On: background_brush = brush_highlight else: # The default button brush. background_brush = palette.button() rect = opt.rect icon_area_rect = QRect(rect) icon_area_rect.setRight(int(icon_area_rect.height() * 1.26)) text_rect = QRect(rect) text_rect.setLeft(icon_area_rect.right() + 10) # Background (TODO: Should the tab button have native # toolbutton shape, drawn using PE_PanelButtonTool or even # QToolBox tab shape) # Default outline pen pen = QPen(palette.color(QPalette.Mid)) p.save() p.setPen(Qt.NoPen) p.setBrush(QBrush(background_brush)) p.drawRect(rect) # Draw the background behind the icon if the background_brush # is different. if not opt.state & QStyle.State_On: p.setBrush(brush_highlight) p.drawRect(icon_area_rect) # Line between the icon and text p.setPen(pen) p.drawLine(icon_area_rect.topRight(), icon_area_rect.bottomRight()) if opt.state & QStyle.State_HasFocus: # Set the focus frame pen and draw the border pen = QPen(QColor(FOCUS_OUTLINE_COLOR)) p.setPen(pen) p.setBrush(Qt.NoBrush) # Adjust for pen rect = rect.adjusted(0, 0, -1, -1) p.drawRect(rect) else: p.setPen(pen) # Draw the top/bottom border if self.position == QStyleOptionToolBox.OnlyOneTab or \ self.position == QStyleOptionToolBox.Beginning or \ self.selected & \ QStyleOptionToolBox.PreviousIsSelected: p.drawLine(rect.topLeft(), rect.topRight()) p.drawLine(rect.bottomLeft(), rect.bottomRight()) p.restore() p.save() text = fm.elidedText(opt.text, Qt.ElideRight, text_rect.width()) p.setPen(QPen(palette.color(QPalette.ButtonText))) p.setFont(opt.font) p.drawText(text_rect, int(Qt.AlignVCenter | Qt.AlignLeft) | \ int(Qt.TextSingleLine), text) if not opt.icon.isNull(): if opt.state & QStyle.State_Enabled: mode = QIcon.Normal else: mode = QIcon.Disabled if opt.state & QStyle.State_On: state = QIcon.On else: state = QIcon.Off icon_area_rect = icon_area_rect icon_rect = QRect(QPoint(0, 0), opt.iconSize) icon_rect.moveCenter(icon_area_rect.center()) opt.icon.paint(p, icon_rect, Qt.AlignCenter, mode, state) p.restore()
class SchemeLink(QObject): """ A instantiation of a link between two :class:`.SchemeNode` instances in a :class:`.Scheme`. Parameters ---------- source_node : :class:`.SchemeNode` Source node. source_channel : :class:`OutputSignal` The source widget's signal. sink_node : :class:`.SchemeNode` The sink node. sink_channel : :class:`InputSignal` The sink widget's input signal. properties : `dict` Additional link properties. """ #: The link enabled state has changed enabled_changed = Signal(bool) #: The link dynamic enabled state has changed. dynamic_enabled_changed = Signal(bool) #: Runtime link state has changed state_changed = Signal(int) class State(enum.IntEnum): """ Flags indicating the runtime state of a link """ #: The link has no associated state (e.g. is not associated with any #: execution contex) NoState = 0 #: A link is empty when it has no value on it. Empty = 1 #: A link is active when the source node provides a value on output. Active = 2 #: A link is pending when it's sink node has not yet been notified #: of a change (note that Empty|Pending is a valid state) Pending = 4 #: The link's source node has invalidated the source channel. #: The execution manager should not propagate this links source value #: until this flag is cleared. #: #: .. versionadded:: 0.1.8 Invalidated = 8 NoState = State.NoState Empty = State.Empty Active = State.Active Pending = State.Pending Invalidated = State.Invalidated def __init__(self, source_node, source_channel, sink_node, sink_channel, enabled=True, properties=None, parent=None): # type: (Node, Output, Node, Input, bool, dict, QObject) -> None super().__init__(parent) self.source_node = source_node if isinstance(source_channel, str): source_channel = source_node.output_channel(source_channel) elif source_channel not in source_node.output_channels(): raise ValueError("%r not in in nodes output channels." \ % source_channel) self.source_channel = source_channel self.sink_node = sink_node if isinstance(sink_channel, str): sink_channel = sink_node.input_channel(sink_channel) elif sink_channel not in sink_node.input_channels(): raise ValueError("%r not in in nodes input channels." \ % source_channel) self.sink_channel = sink_channel if not compatible_channels(source_channel, sink_channel): raise IncompatibleChannelTypeError( "Cannot connect %r to %r" % (source_channel.type, sink_channel.type)) self.__enabled = enabled self.__dynamic_enabled = False self.__state = SchemeLink.NoState # type: Union[SchemeLink.State, int] self.__tool_tip = "" self.properties = properties or {} def source_type(self): # type: () -> type """ Return the type of the source channel. .. deprecated:: 0.1.5 Use :func:`source_types` instead. """ warnings.warn("`source_type()` is deprecated. Use `source_types()`.", DeprecationWarning, stacklevel=2) return _get_first_type(self.source_channel.type, "source_types") def source_types(self): # type: () -> Tuple[type, ...] """ Return the type(s) of the source channel. """ return resolved_valid_types(self.source_channel.types) def sink_type(self): # type: () -> type """ Return the type of the sink channel. .. deprecated:: 0.1.5 Use :func:`sink_types` instead. """ warnings.warn("`sink_type()` is deprecated. Use `sink_types()`.", DeprecationWarning, stacklevel=2) return _get_first_type(self.sink_channel.types, "sink_types") def sink_types(self): # type: () -> Tuple[type, ...] """ Return the type(s) of the sink channel. """ return resolved_valid_types(self.sink_channel.types) def is_dynamic(self): # type: () -> bool """ Is this link dynamic. """ sink_types = self.sink_types() source_types = self.source_types() if self.source_channel.dynamic: strict, dynamic = _classify_connection(self.source_channel, self.sink_channel) # If the connection type checks (strict) then supress the dynamic # state. return not strict and dynamic else: return False def set_enabled(self, enabled): # type: (bool) -> None """ Enable/disable the link. """ if self.__enabled != enabled: self.__enabled = enabled self.enabled_changed.emit(enabled) def is_enabled(self): # type: () -> bool """ Is this link enabled. """ return self.__enabled enabled = Property(bool, fget=is_enabled, fset=set_enabled) def set_dynamic_enabled(self, enabled): # type: (bool) -> None """ Enable/disable the dynamic link. Has no effect if the link is not dynamic. """ if self.is_dynamic() and self.__dynamic_enabled != enabled: self.__dynamic_enabled = enabled self.dynamic_enabled_changed.emit(enabled) def is_dynamic_enabled(self): # type: () -> bool """ Is this a dynamic link and is `dynamic_enabled` set to `True` """ return self.is_dynamic() and self.__dynamic_enabled dynamic_enabled = Property(bool, fget=is_dynamic_enabled, fset=set_dynamic_enabled) def set_runtime_state(self, state): # type: (Union[State, int]) -> None """ Set the link's runtime state. Parameters ---------- state : SchemeLink.State """ if self.__state != state: self.__state = state ev = LinkEvent(LinkEvent.InputLinkStateChange, self) QCoreApplication.sendEvent(self.sink_node, ev) ev = LinkEvent(LinkEvent.OutputLinkStateChange, self) QCoreApplication.sendEvent(self.source_node, ev) self.state_changed.emit(state) def runtime_state(self): # type: () -> Union[State, int] """ Returns ------- state : SchemeLink.State """ return self.__state def set_runtime_state_flag(self, flag, on): # type: (State, bool) -> None """ Set/unset runtime state flag. Parameters ---------- flag: SchemeLink.State on: bool """ if on: state = self.__state | flag else: state = self.__state & ~flag self.set_runtime_state(state) def test_runtime_state(self, flag): # type: (State) -> bool """ Test if runtime state flag is on/off Parameters ---------- flag: SchemeLink.State State flag to test Returns ------- on: bool True if `flag` is set; False otherwise. """ return bool(self.__state & flag) def set_tool_tip(self, tool_tip): # type: (str) -> None """ Set the link tool tip. """ if self.__tool_tip != tool_tip: self.__tool_tip = tool_tip def tool_tip(self): # type: () -> str """ Link tool tip. """ return self.__tool_tip tool_tip = Property( str, fget=tool_tip, # type: ignore fset=set_tool_tip) def __str__(self): return "{0}(({1}, {2}) -> ({3}, {4}))".format( type(self).__name__, self.source_node.title, self.source_channel.name, self.sink_node.title, self.sink_channel.name)
class SplitterResizer(QObject): """ An object able to control the size of a widget in a QSplitter instance. """ def __init__(self, parent=None, **kwargs): # type: (Optional[QObject], Any) -> None super().__init__(parent, **kwargs) self.__splitter = None # type: Optional[QSplitter] self.__widget = None # type: Optional[QWidget] self.__updateOnShow = True # Need __update on next show event self.__animationEnabled = True self.__size = -1 self.__expanded = False self.__animation = QPropertyAnimation( self, b"size_", self, duration=200 ) self.__action = QAction("toggle-expanded", self, checkable=True) self.__action.triggered[bool].connect(self.setExpanded) def setSize(self, size): # type: (int) -> None """ Set the size of the controlled widget (either width or height depending on the orientation). .. note:: The controlled widget's size is only updated when it it is shown. """ if self.__size != size: self.__size = size self.__update() def size(self): # type: () -> int """ Return the size of the widget in the splitter (either height of width) depending on the splitter orientation. """ if self.__splitter and self.__widget: index = self.__splitter.indexOf(self.__widget) sizes = self.__splitter.sizes() return sizes[index] else: return -1 size_ = Property(int, fget=size, fset=setSize) def setAnimationEnabled(self, enable): # type: (bool) -> None """Enable/disable animation.""" self.__animation.setDuration(0 if enable else 200) def animationEnabled(self): # type: () -> bool return self.__animation.duration() == 0 def setSplitterAndWidget(self, splitter, widget): # type: (QSplitter, QWidget) -> None """Set the QSplitter and QWidget instance the resizer should control. .. note:: the widget must be in the splitter. """ if splitter and widget and not splitter.indexOf(widget) > 0: raise ValueError("Widget must be in a splitter.") if self.__widget is not None: self.__widget.removeEventFilter(self) if self.__splitter is not None: self.__splitter.removeEventFilter(self) self.__splitter = splitter self.__widget = widget if widget is not None: widget.installEventFilter(self) if splitter is not None: splitter.installEventFilter(self) self.__update() size = self.size() if self.__expanded and size == 0: self.open() elif not self.__expanded and size > 0: self.close() def toggleExpandedAction(self): # type: () -> QAction """Return a QAction that can be used to toggle expanded state. """ return self.__action def toogleExpandedAction(self): warnings.warn( "'toogleExpandedAction is deprecated, use 'toggleExpandedAction' " "instead.", DeprecationWarning, stacklevel=2 ) return self.toggleExpandedAction() def open(self): # type: () -> None """Open the controlled widget (expand it to sizeHint). """ self.__expanded = True self.__action.setChecked(True) if self.__splitter is None or self.__widget is None: return hint = self.__widget.sizeHint() if self.__splitter.orientation() == Qt.Vertical: end = hint.height() else: end = hint.width() self.__animation.setStartValue(0) self.__animation.setEndValue(end) self.__animation.start() def close(self): # type: () -> None """Close the controlled widget (shrink to size 0). """ self.__expanded = False self.__action.setChecked(False) if self.__splitter is None or self.__widget is None: return self.__animation.setStartValue(self.size()) self.__animation.setEndValue(0) self.__animation.start() def setExpanded(self, expanded): # type: (bool) -> None """Set the expanded state.""" if self.__expanded != expanded: if expanded: self.open() else: self.close() def expanded(self): # type: () -> bool """Return the expanded state.""" return self.__expanded def __update(self): # type: () -> None """Update the splitter sizes.""" if self.__splitter and self.__widget: if sum(self.__splitter.sizes()) == 0: # schedule update on next show event self.__updateOnShow = True return splitter = self.__splitter index = splitter.indexOf(self.__widget) sizes = splitter.sizes() current = sizes[index] diff = current - self.__size sizes[index] = self.__size sizes[index - 1] = sizes[index - 1] + diff self.__splitter.setSizes(sizes) def eventFilter(self, obj, event): # type: (QObject, QEvent) -> bool if event.type() == QEvent.Resize and obj is self.__widget and \ self.__animation.state() == QPropertyAnimation.Stopped: # Update the expanded state when the user opens/closes the widget # by dragging the splitter handle. assert self.__splitter is not None assert isinstance(event, QResizeEvent) if self.__splitter.orientation() == Qt.Vertical: size = event.size().height() else: size = event.size().width() if self.__expanded and size == 0: self.__action.setChecked(False) self.__expanded = False elif not self.__expanded and size > 0: self.__action.setChecked(True) self.__expanded = True if event.type() == QEvent.Show and obj is self.__splitter and \ self.__updateOnShow: # Update the splitter state after receiving valid geometry self.__updateOnShow = False self.__update() return super().eventFilter(obj, event)
class MenuPage(ToolTree): """ A menu page in a :class:`QuickMenu` widget, showing a list of actions. Shown actions can be disabled by setting a filtering function using the :func:`setFilterFunc`. """ def __init__(self, parent=None, title="", icon=QIcon(), **kwargs): # type: (Optional[QWidget], str, QIcon, Any) -> None super().__init__(parent, **kwargs) self.__title = title self.__icon = QIcon(icon) self.__sizeHint = None # type: Optional[QSize] self.view().setItemDelegate(_MenuItemDelegate(self.view())) self.view().entered.connect(self.__onEntered) self.view().viewport().setMouseTracking(True) # Make sure the initial model is wrapped in a ItemDisableFilter. self.setModel(self.model()) def setTitle(self, title): # type: (str) -> None """ Set the title of the page. """ if self.__title != title: self.__title = title self.update() def title(self): # type: () -> str """ Return the title of this page. """ return self.__title title_ = Property(str, fget=title, fset=setTitle, doc="Title of the page.") def setIcon(self, icon): # type: (QIcon) -> None """ Set icon for this menu page. """ if self.__icon != icon: self.__icon = icon self.update() def icon(self): # type: () -> QIcon """ Return the icon of this menu page. """ return QIcon(self.__icon) icon_ = Property(QIcon, fget=icon, fset=setIcon, doc="Page icon") def setFilterFunc(self, func): # type: (Optional[Callable[[QModelIndex], bool]]) -> None """ Set the filtering function. `func` should a function taking a single :class:`QModelIndex` argument and returning True if the item at index should be disabled and False otherwise. To disable filtering `func` can be set to ``None``. """ proxyModel = self.view().model() proxyModel.setFilterFunc(func) def setModel(self, model): # type: (QAbstractItemModel) -> None """ Reimplemented from :func:`ToolTree.setModel`. """ proxyModel = ItemDisableFilter(self) proxyModel.setSourceModel(model) super().setModel(proxyModel) self.__invalidateSizeHint() def setRootIndex(self, index): # type: (QModelIndex) -> None """ Reimplemented from :func:`ToolTree.setRootIndex` """ proxyModel = self.view().model() mappedIndex = proxyModel.mapFromSource(index) super().setRootIndex(mappedIndex) self.__invalidateSizeHint() def rootIndex(self): # type: () -> QModelIndex """ Reimplemented from :func:`ToolTree.rootIndex` """ proxyModel = self.view().model() return proxyModel.mapToSource(super().rootIndex()) def sizeHint(self): # type: () -> QSize """ Reimplemented from :func:`QWidget.sizeHint`. """ if self.__sizeHint is None: view = self.view() model = view.model() # This will not work for nested items (tree). count = model.rowCount(view.rootIndex()) # 'sizeHintForColumn' is the reason for size hint caching # since it must traverse all items in the column. width = view.sizeHintForColumn(0) if count: height = view.sizeHintForRow(0) height = height * count else: height = 0 self.__sizeHint = QSize(width, height) return self.__sizeHint def __invalidateSizeHint(self): # type: () -> None self.__sizeHint = None self.updateGeometry() def __onEntered(self, index): # type: (QModelIndex) -> None if not index.isValid(): return if self.view().state() != QTreeView.NoState: # The item view can emit an 'entered' signal while the model/view # is being changed (rows removed). When this happens, setting the # current item can segfault (in QTreeView::scrollTo). return if index.flags() & Qt.ItemIsEnabled: self.view().selectionModel().setCurrentIndex( index, QItemSelectionModel.ClearAndSelect)
class CrossFadePixmapWidget(QWidget): """ A widget for cross fading between two pixmaps. """ def __init__(self, parent=None, pixmap1=None, pixmap2=None): QWidget.__init__(self, parent) self.setPixmap(pixmap1) self.setPixmap2(pixmap2) self.blendingFactor_ = 0.0 self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) def setPixmap(self, pixmap): """ Set pixmap 1 """ self.pixmap1 = pixmap self.updateGeometry() def setPixmap2(self, pixmap): """ Set pixmap 2 """ self.pixmap2 = pixmap self.updateGeometry() def setBlendingFactor(self, factor): """ Set the blending factor between the two pixmaps. """ self.__blendingFactor = factor self.updateGeometry() def blendingFactor(self): """ Pixmap blending factor between 0.0 and 1.0 """ return self.__blendingFactor blendingFactor_ = Property(float, fget=blendingFactor, fset=setBlendingFactor) def sizeHint(self): """ Return an interpolated size between pixmap1.size() and pixmap2.size() """ if self.pixmap1 and self.pixmap2: size1 = self.pixmap1.size() size2 = self.pixmap2.size() return size1 + self.blendingFactor_ * (size2 - size1) else: return QWidget.sizeHint(self) def paintEvent(self, event): """ Paint the interpolated pixmap image. """ p = QPainter(self) p.setClipRect(event.rect()) factor = self.blendingFactor_**2 if self.pixmap1 and 1. - factor: p.setOpacity(1. - factor) p.drawPixmap(QPoint(0, 0), self.pixmap1) if self.pixmap2 and factor: p.setOpacity(factor) p.drawPixmap(QPoint(0, 0), self.pixmap2)
class SchemeNode(QObject): """ A node in a :class:`.Scheme`. Parameters ---------- description : :class:`WidgetDescription` Node description instance. title : str, optional Node title string (if None `description.name` is used). position : tuple (x, y) tuple of floats for node position in a visual display. properties : dict Additional extra instance properties (settings, widget geometry, ...) parent : :class:`QObject` Parent object. """ def __init__(self, description, title=None, position=None, properties=None, parent=None): # type: (WidgetDescription, str, Tuple[float, float], dict, QObject) -> None super().__init__(parent) self.description = description if title is None: title = description.name self.__title = title self.__position = position or (0, 0) self.__progress = -1 self.__processing_state = 0 self.__status_message = "" self.__state_messages = {} # type: Dict[str, UserMessage] self.properties = properties or {} def input_channels(self): # type: () -> List[InputSignal] """ Return a list of input channels (:class:`InputSignal`) for the node. """ return list(self.description.inputs) def output_channels(self): # type: () -> List[OutputSignal] """ Return a list of output channels (:class:`OutputSignal`) for the node. """ return list(self.description.outputs) def input_channel(self, name): # type: (str) -> InputSignal """ Return the input channel matching `name`. Raise a `ValueError` if not found. """ for channel in self.input_channels(): if channel.name == name: return channel raise ValueError("%r is not a valid input channel name for %r." % \ (name, self.description.name)) def output_channel(self, name): # type: (str) -> OutputSignal """ Return the output channel matching `name`. Raise an `ValueError` if not found. """ for channel in self.output_channels(): if channel.name == name: return channel raise ValueError("%r is not a valid output channel name for %r." % \ (name, self.description.name)) #: The title of the node has changed title_changed = Signal(str) def set_title(self, title): """ Set the node title. """ if self.__title != title: self.__title = title self.title_changed.emit(self.__title) def title(self): """ The node title. """ return self.__title title = Property(str, fset=set_title, fget=title) # type: ignore #: Position of the node in the scheme has changed position_changed = Signal(tuple) def set_position(self, pos): """ Set the position (``(x, y)`` tuple) of the node. """ if self.__position != pos: self.__position = pos self.position_changed.emit(pos) def position(self): """ ``(x, y)`` tuple containing the position of the node in the scheme. """ return self.__position position = Property(tuple, fset=set_position, fget=position) # type: ignore #: Node's progress value has changed. progress_changed = Signal(float) def set_progress(self, value): """ Set the progress value. """ if self.__progress != value: self.__progress = value self.progress_changed.emit(value) def progress(self): """ The current progress value. -1 if progress is not set. """ return self.__progress progress = Property(float, fset=set_progress, fget=progress) # type: ignore #: Node's processing state has changed. processing_state_changed = Signal(int) def set_processing_state(self, state): """ Set the node processing state. """ if self.__processing_state != state: self.__processing_state = state self.processing_state_changed.emit(state) def processing_state(self): """ The node processing state, 0 for not processing, 1 the node is busy. """ return self.__processing_state processing_state = Property( int, fset=set_processing_state, # type: ignore fget=processing_state) def set_tool_tip(self, tool_tip): if self.__tool_tip != tool_tip: self.__tool_tip = tool_tip def tool_tip(self): return self.__tool_tip tool_tip = Property( str, fset=set_tool_tip, # type: ignore fget=tool_tip) #: The node's status tip has changes status_message_changed = Signal(str) def set_status_message(self, text): # type: (str) -> None """Set a short status message.""" if self.__status_message != text: self.__status_message = text self.status_message_changed.emit(text) def status_message(self): # type: () -> str """A short status message summarizing the current node state.""" return self.__status_message #: The node's state message has changed state_message_changed = Signal(UserMessage) def set_state_message(self, message): # type: (UserMessage) -> None """ Set a message to be displayed by a scheme view for this node. """ if message.message_id is not None: self.__state_messages[message.message_id] = message self.state_message_changed.emit(message) else: warnings.warn( "'message' with no id was ignored. " "This will raise an error in the future.", FutureWarning, stacklevel=2) def clear_state_message(self, message_id): # type: (str) -> None """ Clear (remove) a message with `message_id`. :attr:`state_message_changed` signal will be emitted with a empty message for the `message_id`. """ if message_id in self.__state_messages: # emit an empty message m = self.__state_messages[message_id] m = UserMessage("", m.severity, m.message_id) self.__state_messages[message_id] = m self.state_message_changed.emit(m) del self.__state_messages[message_id] def state_message(self, message_id): # type: (str) -> Optional[UserMessage] """ Return a message with `message_id` or None if a message with that id does not exist. """ return self.__state_messages.get(message_id, None) def state_messages(self): # type: () -> Iterable[UserMessage] """ Return a list of all state messages. """ return self.__state_messages.values() def __str__(self): return "SchemeNode(description_id=%r, title=%r, ...)" % \ (str(self.description.id), self.title) def __repr__(self): return str(self)