diff --git a/src/simudator/gui/module_graphics_item/actions.py b/src/simudator/gui/module_graphics_item/actions.py index 1747bec59fd1cf99501b600788e452d18bd9797c..c1abb1f5f37d1f974e2816e03fe39a8af5e1a6c8 100644 --- a/src/simudator/gui/module_graphics_item/actions.py +++ b/src/simudator/gui/module_graphics_item/actions.py @@ -45,3 +45,23 @@ def edit_state_action(module: Module, module_widget: ModuleWidget) -> QAction: action.triggered.connect(action_triggered) return action + +def toggle_ports_action(module_widget: ModuleWidget) -> QAction: + """ + Create a QAction for toggling the visibility of port widgets of a module + widget. + + Parameters + ---------- + module_widget : ModuleWidget + The module for which its ports should be toggled to visible or invisible + using a QAction. + + Returns + ------- + QAction for toggling port visibility of a module widget. + """ + action = QAction("Toggle ports", module_widget) + action.triggered.connect(module_widget.toggle_ports) + return action + diff --git a/src/simudator/gui/module_graphics_item/module_widget.py b/src/simudator/gui/module_graphics_item/module_widget.py index b1a6a8b286ca0d317d9e03c5f6c47cb3d1d004d5..98716c22ff72295e7eb8b2a0cf365a48bd62dac8 100644 --- a/src/simudator/gui/module_graphics_item/module_widget.py +++ b/src/simudator/gui/module_graphics_item/module_widget.py @@ -1,5 +1,3 @@ -from typing import Any - from qtpy.QtCore import Property, QRectF, Qt from qtpy.QtCore import Signal as pyqtSignal from qtpy.QtCore import Slot @@ -16,6 +14,7 @@ from qtpy.QtWidgets import ( from simudator.core.module import Module from simudator.gui.formatting import Format, FormatInfo, format_to_str +from simudator.gui.port_widget import PortWidget class ModuleWidget(QGraphicsWidget): @@ -31,14 +30,17 @@ class ModuleWidget(QGraphicsWidget): state_vars : dict[str, FormatInfo] The state variables of the module to display and in what formats to display them. + ports: list[str] + List of names for signal port widgets to create and display for this + module widget. parent : QGraphicsItem | None Optional parent item of this module widget. flags : Qt.WindowFlags | Qt.WindowType Optional window flags for the window of this widget. """ - DEFAULT_WIDTH = 50 * 3 - DEFAULT_HEIGHT = 50 * 3 + _DEFAULT_WIDTH = 50 * 3 + _DEFAULT_HEIGHT = 50 * 3 state_changed = pyqtSignal() @@ -46,6 +48,7 @@ class ModuleWidget(QGraphicsWidget): self, module: Module, state_vars: dict[str, FormatInfo], + ports: list[str], parent: QGraphicsItem | None = None, flags: Qt.WindowFlags | Qt.WindowType = Qt.WindowFlags(), ) -> None: @@ -55,6 +58,7 @@ class ModuleWidget(QGraphicsWidget): self._module = module self._state_vars = state_vars + self._port_widgets: list[PortWidget] = [] # Default values for properties for appearance # TODO: Put these values in constants? @@ -65,8 +69,9 @@ class ModuleWidget(QGraphicsWidget): self._text_font = QFont("Sans Serif", 9) self._padding = 5 - width = self.DEFAULT_WIDTH - height = self.DEFAULT_HEIGHT + # Set the size of the module depending on the number of state variables + width = self._DEFAULT_WIDTH + height = self._DEFAULT_HEIGHT if len(self._state_vars) <= 1: height = QFontMetrics(self._text_font).height() + 2 * self._padding else: @@ -75,6 +80,8 @@ class ModuleWidget(QGraphicsWidget): ) + self._padding self.resize(width, height) + self._create_ports(ports) + def paint( self, painter: QPainter | None = None, @@ -146,19 +153,32 @@ class ModuleWidget(QGraphicsWidget): width = self.size().width() height = self.size().height() margin = self.outline_width / 2 - return QRectF(0 - margin, 0 - margin, width + margin, height + margin) + return QRectF(0 - margin, 0 - margin, width + 2 * margin, height + 2 * margin) - @Slot(bool) - def show_ports(self, value: bool) -> None: + def _create_ports(self, ports: list[str]) -> None: """ - Toggle the visibility of the signal ports of the displayed module. + Instantiate and add port widgets to this module widget. Parameters ---------- - value : bool - `True` to show all ports, `False` to hide them. + ports : list[str] + List of names for the ports to create. One widget is created per + each name. """ - pass + for port_name in ports: + port = PortWidget(port_name, parent=self) + port.setPos(0, 0) + self._port_widgets.append(port) + + @Slot() + def toggle_ports(self) -> None: + """ + Toggle the visibility of the signal ports of the displayed module. + """ + # TODO: Set the visibility to the same value for all ports rather than + # switching the visibility for each port + for port in self._port_widgets: + port.setVisible(not port.isVisible()) def contextMenuEvent(self, event: 'QGraphicsSceneContextMenuEvent') -> None: """ @@ -308,4 +328,3 @@ class ModuleWidget(QGraphicsWidget): background = Property(QBrush, background, set_background) text_color = Property(QColor, text_color, set_text_color) text_font = Property(QFont, text_font, set_text_font) - diff --git a/src/simudator/gui/orientation.py b/src/simudator/gui/orientation.py index 6d3adfe80bf67dbc4b230128b90677109d7bd017..f9d49487870b74746956e9da662304f415700c52 100644 --- a/src/simudator/gui/orientation.py +++ b/src/simudator/gui/orientation.py @@ -1,6 +1,7 @@ from enum import IntEnum +# TODO: Remove this file class Orientation(IntEnum): """ Used to give and track ports orientations. diff --git a/src/simudator/gui/port_widget.py b/src/simudator/gui/port_widget.py new file mode 100644 index 0000000000000000000000000000000000000000..e78ed6c60fd0fb51636ec48a1638556e5d4cd135 --- /dev/null +++ b/src/simudator/gui/port_widget.py @@ -0,0 +1,394 @@ +from enum import IntEnum + +from qtpy.QtCore import Property, QPointF, QRectF, Qt +from qtpy.QtCore import Signal as pyqtSignal +from qtpy.QtCore import Slot +from qtpy.QtGui import QBrush, QColor, QFont, QFontMetrics, QPainter, QPainterPath, QPen +from qtpy.QtWidgets import ( + QAction, + QGraphicsItem, + QGraphicsSceneContextMenuEvent, + QGraphicsSceneMouseEvent, + QGraphicsWidget, + QMenu, + QStyleOptionGraphicsItem, + QWidget, +) + + +class Orientation(IntEnum): + """ + Used to give and track ports orientations. + """ + + UP = 0 + LEFT = 1 + DOWN = 2 + RIGHT = 3 + + +class PortWidget(QGraphicsWidget): + """ + A graphics widget for displaying a signal port of a module widget. + + The port is drawn as a line at the edge of the shape of a module widget. + A name of the port can also be displayed at the end of the port. + + The default appearance of the port has line length 12, line width 1 and + uses the font Sans Serif 9pt. + + Parameters + ---------- + name : str + The name to display on the label for the port widget. + parent : QGraphicsItem | None + Optional parent item of this module widget. + flags : Qt.WindowFlags | Qt.WindowType + Optional window flags for the window of this widget. + """ + + _DEFAULT_LINE_LENGTH = 12 + """ + Default length of the line that represents this signal port. + """ + _DEFAULT_LINE_WIDTH = 1 + """ + Default width of the line that represents this signal port. + """ + _DEFAULT_TEXT_FONT = QFont("Sans Serif", 10) + """ + Default text font used to display the name label of this signal port. + """ + + signal_toggled = pyqtSignal() + + def __init__( + self, + name: str, + parent: QGraphicsItem | None = None, + flags: Qt.WindowFlags | Qt.WindowType = Qt.WindowFlags(), + ) -> None: + super().__init__(parent, flags) + self._name = name + # TODO: Change this? Use shape() instead? + self._attached_shape = parent.boundingRect() + + self.set_orientation(Orientation.LEFT) + self.set_line_length(self._DEFAULT_LINE_LENGTH) + self.set_line_width(self._DEFAULT_LINE_WIDTH) + self.set_line_brush(self.palette().text()) + self.set_label_visible(True) + self.set_text_font(self._DEFAULT_TEXT_FONT) + + self.setFlag(QGraphicsItem.ItemIsMovable) + + def paint( + self, + painter: QPainter | None, + option: QStyleOptionGraphicsItem | None = None, + widget: QWidget | None = None, + ) -> None: + painter.save() + + # Set style and draw a line to represent the port + painter.setPen(QPen(self.line_brush, self.line_width)) + painter.drawLine(QPointF(0, 0), self.get_end_point()) + + # Draw the port label if it should be visible + if self.label_visible: + # Set text style + painter.setFont(self.text_font) + + # Draw text + text_rect = self._get_label_rect() + painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, self._name) + + painter.restore() + + def get_name(self) -> str: + """ + Return the displayed name of this port. + + Returns + ------- + str + Name of this port. + """ + return self._name + + def get_end_point(self) -> QPointF: + """ + Return the point at the end of the line of this port. The end is the one + that is not attached to the shape of the module widget that the port + is attached to. + + Returns + ------- + QPointF + The end point of the port line. + """ + match self.orientation: + case Orientation.UP: + return QPointF(0, -self.line_length) + case Orientation.DOWN: + return QPointF(0, self.line_length) + case Orientation.LEFT: + return QPointF(-self.line_length, 0) + case Orientation.RIGHT: + return QPointF(self.line_length, 0) + + def _get_label_rect(self) -> QRectF: + """ + Calculate and return a rectangle in which the name label of this port + should be placed. + + Returns + ------- + QRectF + Rectangle to put the port name label in. + """ + end_point = self.get_end_point() + font_metrics = QFontMetrics(self.text_font) + text_width = font_metrics.width(self._name) + text_height = font_metrics.height() + + match self.orientation: + case Orientation.UP: + x = end_point.x() - text_width / 2 + y = end_point.y() - text_height + case Orientation.DOWN: + x = end_point.x() - text_width / 2 + y = end_point.y() + case Orientation.LEFT: + x = end_point.x() - text_width + y = end_point.y() - text_height + case Orientation.RIGHT: + x = end_point.x() + y = end_point.y() - text_height + + return QRectF(x, y, text_width, text_height) + + def shape(self) -> QPainterPath: + path = QPainterPath() + path.addRect(self.boundingRect()) + return path + + def boundingRect(self) -> QRectF: + label_rect = self._get_label_rect() + match self.orientation: + case Orientation.UP: + top_left = label_rect.topLeft() + width = label_rect.width() + height = label_rect.height() + self.line_length + + case Orientation.DOWN: + top_left = label_rect.topLeft() - QPointF(0, self.line_length) + width = label_rect.width() + height = label_rect.height() + self.line_length + + case Orientation.LEFT: + top_left = label_rect.topLeft() + width = label_rect.width() + self.line_length + height = label_rect.height() + + case Orientation.RIGHT: + top_left = QPointF(0, label_rect.top()) + width = label_rect.width() + self.line_length + height = label_rect.height() + + # Add small margin since the line is drawn on the edge of the bounding box. + # This removes any possibilities of artifacts appearing. + margin = self.line_width / 2 + return QRectF( + top_left.x() - margin, + top_left.y() - margin, + width + 2 * margin, + height + 2 * margin, + ) + + def contextMenuEvent( + self, event: QGraphicsSceneContextMenuEvent | None = None + ) -> None: + menu = QMenu() + hide_signal_action = QAction("Toggle signal visibility", menu) + hide_signal_action.triggered.connect(self.signal_toggled) + # hide_signal_action.setEnabled(not self.isEnabled) + + hide_port_action = QAction("Toggle port visibility", menu) + hide_port_action.triggered.connect( + lambda: self.setVisible(not self.isVisible()) + ) + # hide_port_action.setEnabled(not self.isLocked) + + menu.addAction(hide_signal_action) + menu.addAction(hide_port_action) + menu.exec_(event.screenPos()) + event.accept() + + def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: + if event.buttons() != Qt.MouseButton.LeftButton: + return + + rel_x = event.scenePos().x() - self.parentItem().x() + rel_y = event.scenePos().y() - self.parentItem().y() + + min_x = self._attached_shape.topLeft().x() + min_y = self._attached_shape.topLeft().y() + max_x = self._attached_shape.bottomRight().x() + max_y = self._attached_shape.bottomRight().y() + + # clamp position between the min and max allowed values + x_pos = min(max(rel_x, min_x), max_x) + y_pos = min(max(rel_y, min_y), max_y) + + # Make sure the port cannot be placed inside of the module that it + # is attached to + if not (min_x < x_pos < max_x) or not (min_y < y_pos < max_y): + self.setX(x_pos) + self.setY(y_pos) + + if x_pos == max_x: + self.set_orientation(Orientation.RIGHT) + elif x_pos == min_x: + self.set_orientation(Orientation.LEFT) + elif y_pos == max_y: + self.set_orientation(Orientation.DOWN) + elif y_pos == min_y: + self.set_orientation(Orientation.UP) + self.update(self.boundingRect()) + + def line_length(self) -> float: + """ + Return the length of the port line. + + Returns + ------- + float + The length of the port line as a float. + """ + return self._line_length + + def set_line_length(self, length: float) -> None: + """ + Set the length of the port line. + + Parameters + ---------- + length: float + The desired port length. + """ + self._line_length = length + + def line_width(self) -> float: + """ + Return the width of the port line length. + """ + return self._line_width + + def set_line_width(self, width: float) -> None: + """ + Set the width of the port line. + + Parameters + ---------- + length: float + The desired port line width. + """ + self._line_width = width + + def line_brush(self) -> QBrush: + """ + Return the brush used to draw the port widget. + + Returns + ------- + QBrush + The brush used to draw the port widget. + """ + return self._line_brush + + def set_line_brush(self, brush: QBrush) -> None: + """ + Set the brush used to draw the port widget. + + Parameters + ---------- + brush: QBrush + The brush to use when drawing the port widget. + """ + self._line_brush = brush + + def label_visible(self) -> bool: + """ + Return a bool representing the visible state of the ports label. + + Returns + ------- + bool + `True` if the label is visible, `False` otherwise. + """ + return self._label_visible + + @Slot(bool) + def set_label_visible(self, value: bool) -> None: + """ + Set the visibility of the label. + + Parameter + --------- + value: bool + Sets the label as visible if value is `True`, `False` otherwise. + """ + self._label_visible = value + + def orientation(self) -> Orientation: + """ + Return the orientation of the port widget. + + Returns + ------- + Orientation + The current orientation of the port widget. + """ + return self._orientation + + def set_orientation(self, orientation: Orientation) -> None: + """ + Set the orientation of the port widget. + + Parameters + ---------- + orientation: Orientation + Sets the orientation of the port widget to one of the alternatives of + UP, DOWN, LEFT, RIGHT. + """ + self._orientation = orientation + + def text_font(self) -> QFont: + """ + Return the font of the port widgets label. + + Returns + ------- + QFont + The font used for the text in the port widgets label. + """ + return self._text_font + + def set_text_font(self, font: QFont) -> None: + """ + Set the font for the port widgets label. + + Parameters + ---------- + font: QFont + Sets the font used in the port widgets label. + """ + self._text_font = font + + line_length = Property(float, line_length, set_line_length) + line_width = Property(float, line_width, set_line_width) + line_brush = Property(QBrush, line_brush, set_line_brush) + label_visible = Property(bool, label_visible, set_label_visible) + orientation = Property(Orientation, orientation, set_orientation) + text_font = Property(QFont, text_font, set_text_font)