diff --git a/src/simudator/gui/dialogs/module_state_dialog.py b/src/simudator/gui/dialogs/module_state_dialog.py index 622112d17d1d88ac6ec09483cd4b13e0d4a95c00..fc768d7ade04490453e641d1f77fc3e2d5a76266 100644 --- a/src/simudator/gui/dialogs/module_state_dialog.py +++ b/src/simudator/gui/dialogs/module_state_dialog.py @@ -1,7 +1,6 @@ -from typing import Optional - from qtpy.QtCore import Qt from qtpy.QtCore import Signal as pyqtSignal +from qtpy.QtCore import Slot from qtpy.QtWidgets import ( QComboBox, QDialog, @@ -12,65 +11,97 @@ from qtpy.QtWidgets import ( ) from simudator.core import Module +from simudator.gui.formatting import format_to_str, parse_str +from simudator.gui.module_graphics_item.module_widget import ModuleWidget class ModuleStateDialog(QDialog): - accepted = pyqtSignal(str, str, str, name='okSignal') + """ + A dialog for letting the user edit the state of a processor module. + + Parameters + ---------- + module : Module + The module to edit the state of. + module_widget : ModuleWidget + The corresponding module widget for the module. Used mainly for + formatting and parsing the values of the state variables of the module. + parent : QWidget | None + Optional parent widget of this dialog. + flags : Qt.WindowFlags | Qt.WindowType + Optional window flags for the window of the dialog. + """ + + state_edited = pyqtSignal() def __init__( self, module: Module, - parent: Optional['QWidget'] = None, + module_widget: ModuleWidget, + parent: QWidget | None = None, flags: Qt.WindowFlags | Qt.WindowType = Qt.WindowFlags(), ) -> None: super().__init__(parent, flags) - self.module = module - states = module.get_gui_state() - - # Remove the 'name' state of the module from the drop down menu so - # that the user cannot e.g. edit or add a breakpoint of a module - states.pop('name') + self._module = module + self._state_vars = module_widget.get_state_vars() - # Set up the drop down menu for selecting a state of the module - # to perform an action to - self.stateSelectWidget = QComboBox() - self.stateSelectWidget.addItems(states.keys()) - self.stateSelectWidget.activated.connect(self.updateState) + # Set up the drop down menu for selecting a state variable of the module + self._state_var_widget = QComboBox() + self._state_var_widget.addItems(self._state_vars.keys()) - # Set up a text field for entering the value used for the e.g. editing - # the selected state - self.valueWidget = QLineEdit() + # Set up a text field for displaying and editing the value of the + # selected state variable + self._value_widget = QLineEdit() # Set up buttons - self.okButton = QPushButton('OK') - self.okButton.clicked.connect(self.signalOK) - self.cancelButton = QPushButton('Cancel') - self.cancelButton.clicked.connect(self.close) + self._accept_button = QPushButton('OK') + self._accept_button.clicked.connect(self.edit_state) + self._cancel_button = QPushButton('Cancel') + self._cancel_button.clicked.connect(self.close) # Set up the layout of the widget - self.HBoxLayout = QHBoxLayout() - self.HBoxLayout.addWidget(self.stateSelectWidget, 0) - self.HBoxLayout.addWidget(self.valueWidget, 1) - self.HBoxLayout.addWidget(self.okButton, 2) - self.HBoxLayout.addWidget(self.cancelButton, 3) - self.setLayout(self.HBoxLayout) + self._layout = QHBoxLayout() + self._layout.addWidget(self._state_var_widget, 0) + self._layout.addWidget(self._value_widget, 1) + self._layout.addWidget(self._accept_button, 2) + self._layout.addWidget(self._cancel_button, 3) + self.setLayout(self._layout) # Show the widget - self.updateState() - self.show() - - def updateState(self) -> None: - selectedState = self.stateSelectWidget.currentText() - value = self.module.get_gui_state()[selectedState] - self.valueWidget.setText(str(value)) - - def signalOK(self) -> None: - module_name = self.module.get_state()['name'] - selectedState = self.stateSelectWidget.currentText() - enteredValue = self.valueWidget.text() - self.accepted.emit(module_name, selectedState, enteredValue) - self.close() - - def close(self) -> bool: - return super().close() + self.select_state_var() + self.exec() + + @Slot() + def select_state_var(self) -> None: + """ + Select a state variable of the module to edit. + """ + selected_state = self._state_var_widget.currentText() + format_info = self._state_vars[selected_state] + value = format_to_str(format_info, self._module.get_state()[selected_state]) + self._value_widget.setText(value) + + @Slot() + def edit_state(self) -> None: + """ + Parse the user input and edit the state of the module associated with + this dialog. + + This triggers the `state_edited` signal if the parsing is + successful. Else, the user is given an error message. + """ + state = self._module.get_state() + selected_state_var = self._state_var_widget.currentText() + try: + value = parse_str( + self._state_vars[selected_state_var], self._value_widget.text() + ) + state[selected_state_var] = value + self._module.set_state(state) + self.state_edited.emit() + self.close() + except ValueError as e: + # TODO: error handling + pass + diff --git a/src/simudator/gui/formatting.py b/src/simudator/gui/formatting.py new file mode 100644 index 0000000000000000000000000000000000000000..ed57c8f4db9c84547663a82516e3af8da8a5167d --- /dev/null +++ b/src/simudator/gui/formatting.py @@ -0,0 +1,118 @@ +import math +from enum import Enum, auto +from typing import Any + + +class Format(Enum): + """ + An Enum class containing the different valid formats for formatting and + parsing values of different data types. + """ + + Str = (auto(),) + Int = (auto(),) + Bin = (auto(),) + Hex = (auto(),) + Decimal = (auto(),) + DecimalSigned = (auto(),) + + +class FormatInfo: + """ + A class for neatly keeping data about the supported formats and selected + format for correctly displaying and parsing values of different data types. + + Parameters + ---------- + supported_format : list[Format] + List of formats that are supported for some variable. + selected_format : Format + The format that should be used on initialization. + **kwargs + Arguments passed to `format_to_str` and `parse_str`. Required by some + formats. + + Attributes + ---------- + supported_formats : list[Format] + List of format that are supported. + selected_format : Format + The selected format to use when formatting/parsing. Should be one of + the supported formats. + """ + + NUMERICAL_FORMATS = ( + Format.Int, + Format.Bin, + Format.Hex, + Format.Decimal, + Format.DecimalSigned, + ) + """ + List of standard numerical formats for handling numbers. It is possible to + switch between these formats easily as they all require the same + keyword arguments. + """ + + __slots__ = ("selected_format", "supported_formats", "kwargs") + + def __init__( + self, supported_formats: list[Format], selected_format: Format, **kwargs + ) -> None: + self.selected_format = selected_format + self.supported_formats = supported_formats + self.kwargs = kwargs + + +def format_to_str(format_info: FormatInfo, value: Any) -> str: + """ + Create a formatted string from a given value. + + Parameters + ---------- + format_info : FormatInfo + A class containing information about what format to use. + value : Any + An arbitrary value that is to be formatted to a string. + + Returns + ------- + The input value formatted to a string according to the input variable + format. + """ + match format_info.selected_format: + case Format.Int: + pass + case Format.Str: + pass + case Format.Bin: + return f"{value:0{format_info.kwargs['bit_length']}b}" + case Format.Hex: + return f"{value:0{math.ceil(format_info.kwargs['bit_length']/4)}x}" + case Format.Decimal: + pass + case Format.DecimalSigned: + pass + + +def parse_str(format_info: FormatInfo, value: str) -> Any: + """ + Parse an input string formatted using the given format. + + Parameters + ---------- + format_info : FormatInfo + An instance containing information about the format used on the string. + value : str + A formatted string to parse into a value. + + Returns + ------- + The parsed value from the formatted string. The data type of the value + depends on the format. + """ + match format_info.selected_format: + case Format.Int: + return int(value) + case Format.Hex: + return int(value, base=16) diff --git a/src/simudator/gui/gui.py b/src/simudator/gui/gui.py index 9b3fdc9cb05e293a70869cc1c078057c23668475..63c39edcea6018b47973b3e2fa9edb5997e1d0e6 100644 --- a/src/simudator/gui/gui.py +++ b/src/simudator/gui/gui.py @@ -2,7 +2,6 @@ import ast import sys from qtpy import QtCore, QtWidgets -from qtpy.QtCore import Signal as pyqtSignal from qtpy.QtCore import Slot from qtpy.QtWidgets import ( QAction, @@ -19,6 +18,7 @@ from simudator.gui.cpu_graphics_scene import CpuGraphicsScene from simudator.gui.dialogs.lambda_breakpoint_dialog import LambdaBreakpointDialog from simudator.gui.menu_bar import MainMenuBar from simudator.gui.module_graphics_item.module_graphics_item import ModuleGraphicsItem +from simudator.gui.module_graphics_item.module_widget import ModuleWidget from simudator.gui.pipeline import PipeLine from simudator.gui.processor_handler import ProcessorHandler from simudator.gui.signal_viewer import SignalViewer @@ -182,12 +182,6 @@ class GUI(QMainWindow): if self._breakpoint_window is not None: self._breakpoint_window.update() - """ - @Slot is used to explicitly mark a python method as a Qt slot - and specify a C++ signature for it, which is used most commonly - in order to select a particular overload. - """ - @Slot(str, str, str) def editModuleState(self, module_name, state, value) -> None: """ @@ -297,6 +291,20 @@ class GUI(QMainWindow): self.connectModuleActions(item.getActionSignals()) self._processor_handler.changed.connect(item.update) + def add_module_widget(self, widget: ModuleWidget) -> None: + """ + Add a module widget to the processor scene and connect its QT signals + and slots. + + Parameters + ---------- + widget : ModuleWidget + The module widget to add to the processor scene. + """ + self._processor_handler.changed.connect(widget.update) + self._graphics_scene.addItem(widget) + widget.state_changed.connect(self._processor_handler.handle_module_change) + def add_all_signals(self) -> None: """ Add visual representations of all processor signals between all modules diff --git a/src/simudator/gui/module_graphics_item/actions.py b/src/simudator/gui/module_graphics_item/actions.py new file mode 100644 index 0000000000000000000000000000000000000000..c1abb1f5f37d1f974e2816e03fe39a8af5e1a6c8 --- /dev/null +++ b/src/simudator/gui/module_graphics_item/actions.py @@ -0,0 +1,67 @@ +from enum import Enum, auto + +from PyQt5.QtWidgets import QAction + +from simudator.core.module import Module +from simudator.gui.dialogs.module_state_dialog import ModuleStateDialog +from simudator.gui.module_graphics_item.module_widget import ModuleWidget + + +class ActionType(Enum): + """ + An Enum class containing the different valid actions for interacting with + module widgets. + """ + + EditState = (auto(),) + + +def edit_state_action(module: Module, module_widget: ModuleWidget) -> QAction: + """ + Create a QAction for opening a state-edit dialog for a module widget. + The dialog triggers the state_changed signal of the module widget if the + user accepts the dialog. + + Parameters + ---------- + module : Module + The module that should be edited when triggering the created edit action. + module_widget : ModuleWidget + The module widget for the module that should be edited. Used for + displaying and parsing information correctly. + + Returns + ------- + QAction + A Qt action for opening a dialog for editing the state of a module. + Intended to be added as an action to the given module widget. + """ + action = QAction("Edit state", module_widget) + + def action_triggered(): + dialog = ModuleStateDialog(module, module_widget) + dialog.state_edited.connect(module_widget.state_changed) + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..53bfaf40c443c01069d313908f2fc630c89b69df --- /dev/null +++ b/src/simudator/gui/module_graphics_item/module_widget.py @@ -0,0 +1,337 @@ +from qtpy.QtCore import Property, 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 ( + QErrorMessage, + QGraphicsItem, + QGraphicsSceneContextMenuEvent, + QGraphicsWidget, + QMenu, + QStyleOptionGraphicsItem, + QWidget, +) + +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): + """ + A general class for displaying an arbitrary processor module. The + appearance of a module can be customized by changing its properties and + by choosing which state variables to display and in what formats. + + The default appearance is a 150x150 rectangle with an outline of width 1 + and inner padding set to 5. Text id by default displayed in Sans Serif 9pt. + The default brushes for outline, text and background are the ones provided + by the palette of the widget. + + Parameters + ---------- + module : Module + The processor module to display. + 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. + """ + + # Used for controlling the default appearance. Make sure these are + # consistent with the class docstring + _DEFAULT_WIDTH = 150 + _DEFAULT_HEIGHT = 150 + _DEFAULT_OUTLINE_WIDTH = 1 + _DEFAULT_TEXT_FONT = QFont("Sans Serif", 9) + _DEFAULT_PADDING = 5 + + state_changed = pyqtSignal() + + def __init__( + self, + module: Module, + state_vars: dict[str, FormatInfo], + ports: list[str], + parent: QGraphicsItem | None = None, + flags: Qt.WindowFlags | Qt.WindowType = Qt.WindowFlags(), + ) -> None: + super().__init__(parent, flags) + super().__init__(parent, flags) + self.setFlag(QGraphicsItem.ItemIsMovable) + + self._module = module + self._state_vars = state_vars + self._port_widgets: list[PortWidget] = [] + + # Default values for properties for appearance + self._outline_width = self._DEFAULT_OUTLINE_WIDTH + self._outline = self.palette().windowText() + self._background = self.palette().window() + self._text_color = self.palette().windowText().color() + self._text_font = self._DEFAULT_TEXT_FONT + self._padding = self._DEFAULT_PADDING + + # 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: + height = (self._padding + QFontMetrics(self._text_font).height()) * ( + len(state_vars) + 1 + ) + self._padding + self.resize(width, height) + + self._create_ports(ports) + + def paint( + self, + painter: QPainter | None = None, + option: QStyleOptionGraphicsItem | None = None, + widget: QWidget | None = None, + ) -> None: + painter.save() + + width = self.size().width() + height = self.size().height() + + # Draw the base shape + pen = QPen(self.outline, self.outline_width) + painter.setPen(pen) + painter.setBrush(self.background) + painter.drawPath(self.shape()) + + # Set text specific painter settings + painter.setPen(QPen(self.text_color)) + painter.setBrush(QBrush(self.text_color)) + painter.setFont(self.text_font) + text_flags = Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextWordWrap + + text_rect = self.shape().boundingRect() + + # Draw only name of the module if no state variables are to be shown + if len(self._state_vars.keys()) == 0: + painter.drawText(text_rect, text_flags, self._module.get_state()["name"]) + + # Special case: only 1 state variable to show + elif len(self._state_vars.keys()) == 1: + state_var = list(self._state_vars.keys())[0] + format_info = self._state_vars[state_var] + value = format_to_str(format_info, self._module.get_state()[state_var]) + painter.drawText( + text_rect, text_flags, f"{self._module.get_state()['name']}: {value}" + ) + + # Draw the name of the module and each state variable with its value on one row each + else: + text_rect = QRectF(0, 0, width, height / (len(self._state_vars) + 1)) + painter.drawText(text_rect, text_flags, self._module.get_state()["name"]) + + for i, state_var in enumerate(self._state_vars): + y_start = height / (len(self._state_vars) + 1) * (i + 1) + y_end = height / (len(self._state_vars) + 1) + text_rect = QRectF(0, y_start, width, y_end) + + format_info = self._state_vars[state_var] + value = format_to_str(format_info, self._module.get_state()[state_var]) + painter.drawText(text_rect, text_flags, f"{state_var}: {value}") + + painter.restore() + + @Slot() + def update(self, rect: QRectF | None = None): + # This "override" is needed in order to decorate update() as a + # pyqt slot + if rect is None: + rect = self.boundingRect() + super().update(rect) + + def shape(self) -> QPainterPath: + path = QPainterPath() + path.addRect(0, 0, self.size().width(), self.size().height()) + return path + + def boundingRect(self) -> QRectF: + width = self.size().width() + height = self.size().height() + margin = self.outline_width / 2 + return QRectF(0 - margin, 0 - margin, width + 2 * margin, height + 2 * margin) + + def _create_ports(self, ports: list[str]) -> None: + """ + Instantiate and add port widgets to this module widget. + + Parameters + ---------- + ports : list[str] + List of names for the ports to create. One widget is created per + each name. + """ + 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. + """ + for port in self._port_widgets: + port.setVisible(not port.isVisible()) + + def contextMenuEvent(self, event: 'QGraphicsSceneContextMenuEvent') -> None: + """ + Show a context menu for this module, allowing the user to perform any + of the actions available to this module. + """ + menu = QMenu() + menu.addActions(self.actions()) + menu.exec_(event.screenPos()) + + def get_state_vars(self) -> dict[str, FormatInfo]: + """ + Return a mapping from displayed state variables of the displayed + module to their respective format information. + + Useful for viewing the supported formats and the selected format for a + state variable. + + Returns + ------- + dict[str, FormatInfo] + Mapping from state variable to FormatInfo instance. + """ + return self._state_vars + + @Slot() + def set_format(self, state_var: str, format: Format) -> None: + """ + Set the format to be used when displaying and parsing a given state + variable of the displayed module. + + Parameters + ---------- + state_va : str + The state variable to set the formats of. + format : Format + The format to use for the value of the state variable. Should be + one of the supported formats in the FormatInfo instance for the + state variable. + """ + self._state_vars[state_var].selected_format = format + self.update() + + def outline_width(self) -> float: + """Return the outline pen width. + + Returns + ------- + float + Outline pen width. + """ + return self._outline_width + + def set_outline_width(self, width: float) -> None: + """Set the outline pen width. + + Parameters + ---------- + width : float + Outline pen width. + + """ + self._outline_width = width + + def outline(self) -> QBrush: + """Return the outline brush used to create the outline pen. + + Returns + ------- + QBrush + Outline brush. + """ + return self._outline + + def set_outline(self, brush: QBrush) -> None: + """Set the outline brush used to create the outline pen. + + Parameters + ---------- + brush : QBrush + Outline brush. + """ + self._outline = brush + + def background(self) -> QBrush: + """Return the bursh used for filling the background. + + Returns + ------- + QBrush + Background brush. + """ + return self._background + + def set_background(self, brush: QBrush) -> None: + """Set the bursh used for filling the background. + + Parameters + ---------- + brush : QBrush + Background brush. + """ + self._background = brush + + def text_color(self) -> QColor: + """Return the color used for text. + + Returns + ------- + QColor + Text color. + """ + return self._text_color + + def set_text_color(self, color: QColor) -> None: + """Set the color used for text. + + Parameters + ---------- + color : QColor + Text color. + """ + self._text_color = color + + def text_font(self) -> QFont: + """Return the font used for text. + + Returns + ------- + QFont + Text font. + """ + return self._text_font + + def set_text_font(self, font: QFont) -> None: + """Set the font used for text. + + Parameters + ---------- + font : QFont + Text font. + """ + self._text_font = font + + outline_width = Property(float, outline_width, set_outline_width) + outline = Property(QBrush, outline, set_outline) + 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..b35555f3a0de8563b985d8eff0dde463d2c36740 --- /dev/null +++ b/src/simudator/gui/port_widget.py @@ -0,0 +1,399 @@ +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. + + Returns + ------- + float + Port line width. + """ + 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) diff --git a/src/simudator/gui/processor_handler.py b/src/simudator/gui/processor_handler.py index 9a9e9b3321f5fcb82c00be6bd77dbd362751ab53..e0ccb9ac0f5fe249540a5304ea10ad4cb149dff3 100644 --- a/src/simudator/gui/processor_handler.py +++ b/src/simudator/gui/processor_handler.py @@ -1,4 +1,3 @@ -from qtpy import QtCore from qtpy.QtCore import QObject, QThreadPool from qtpy.QtCore import Signal as pyqtSignal from qtpy.QtCore import Slot @@ -21,12 +20,12 @@ class ProcessorHandler(QObject): running = pyqtSignal(bool) """ - PyQT signal emitted when the processor has started or finished running. + PyQT signal emitted when the processor has started or finished running. Emits ``True`` when it has started and ``False`` when it has finished. """ cycle_changed = pyqtSignal(int) """ - PyQT signal emitted when the current clock cycle count of the processor + PyQT signal emitted when the current clock cycle count of the processor has changed. Emits the current clock cycle count as an ``int``. """ changed = pyqtSignal() @@ -105,7 +104,7 @@ class ProcessorHandler(QObject): @Slot(int) def step_asm_instructions(self, instructions: int): """ - Run some numer of asm instructions. + Run some number of asm instructions. Parameters ---------- @@ -333,3 +332,12 @@ class ProcessorHandler(QObject): ) if ok: self._update_delay = delay + + @Slot() + def handle_module_change(self): + """ + Handle propagating changes when the state of a module has been edited + by the user. + """ + pass + diff --git a/src/simudator/gui/signal_viewer.py b/src/simudator/gui/signal_viewer.py index 34aeb20e847fa34689781a710d0ccb6a242fabd1..abd12edbc0be47971e06e19eaba391bceb013d2a 100644 --- a/src/simudator/gui/signal_viewer.py +++ b/src/simudator/gui/signal_viewer.py @@ -96,7 +96,7 @@ class SignalViewer(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) def outline_width(self) -> float: """Return the outline pen width.