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..3b164161b0aeb39f674430ccf29ca53243af477a --- /dev/null +++ b/src/simudator/gui/formatting.py @@ -0,0 +1,119 @@ +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..1747bec59fd1cf99501b600788e452d18bd9797c --- /dev/null +++ b/src/simudator/gui/module_graphics_item/actions.py @@ -0,0 +1,47 @@ +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 + diff --git a/src/simudator/gui/module_graphics_item/module_widget.py b/src/simudator/gui/module_graphics_item/module_widget.py index b9cc6d81384e902cd7ed68cf9ee5f817530dcafe..b1a6a8b286ca0d317d9e03c5f6c47cb3d1d004d5 100644 --- a/src/simudator/gui/module_graphics_item/module_widget.py +++ b/src/simudator/gui/module_graphics_item/module_widget.py @@ -1,33 +1,60 @@ from typing import Any -from qtpy.QtCore import Property, QPointF, QRectF, Qt, Slot -from qtpy.QtGui import QBrush, QColor, QFont, QFontMetrics, QPainter, QPainterPath, QPen + +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, - QAction, ) from simudator.core.module import Module +from simudator.gui.formatting import Format, FormatInfo, format_to_str -class ModuleWidget(QGraphicsWidget): - DEFAULT_WIDTH = 50*3 - DEFAULT_HEIGHT = 50*3 - - update = pyqtSignal() - def __init__(self, module: Module, state_vars: list[str], actions: list[QAction], formatters: dict[str, Any], parent: QGraphicsItem | None = None, flags: Qt.WindowFlags | Qt.WindowType = Qt.WindowFlags()) -> None: +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. + + 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. + 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 + + state_changed = pyqtSignal() + + def __init__( + self, + module: Module, + state_vars: dict[str, FormatInfo], + 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._actions = actions - self._formatters = formatters # Default values for properties for appearance # TODO: Put these values in constants? @@ -41,9 +68,11 @@ class ModuleWidget(QGraphicsWidget): width = self.DEFAULT_WIDTH height = self.DEFAULT_HEIGHT if len(self._state_vars) <= 1: - height = QFontMetrics(self._text_font).height() + 2*self._padding + height = QFontMetrics(self._text_font).height() + 2 * self._padding else: - height = (self._padding + QFontMetrics(self._text_font).height()) * (len(state_vars) + 1) + self._padding + height = (self._padding + QFontMetrics(self._text_font).height()) * ( + len(state_vars) + 1 + ) + self._padding self.resize(width, height) def paint( @@ -72,24 +101,30 @@ class ModuleWidget(QGraphicsWidget): text_rect = self.shape().boundingRect() # Draw only name of the module if no state variables are to be shown - if len(self._state_vars) == 0: + 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) == 1: - value = self._module.get_state()[self._state_vars[0]] - painter.drawText(text_rect, text_flags, f"{self._module.get_state()["name"]}: {value}") + 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)) + 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 ) + 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) - value = self._module.get_state()[state_var] + + 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() @@ -98,7 +133,9 @@ class ModuleWidget(QGraphicsWidget): def update(self, rect: QRectF | None = None): # This "override" is needed in order to decorate update() as a # pyqt slot - super().update() + if rect is None: + rect = self.boundingRect() + super().update(rect) def shape(self) -> QPainterPath: path = QPainterPath() @@ -111,6 +148,60 @@ class ModuleWidget(QGraphicsWidget): margin = self.outline_width / 2 return QRectF(0 - margin, 0 - margin, width + margin, height + margin) + @Slot(bool) + def show_ports(self, value: bool) -> None: + """ + Toggle the visibility of the signal ports of the displayed module. + + Parameters + ---------- + value : bool + `True` to show all ports, `False` to hide them. + """ + pass + + 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. @@ -216,4 +307,5 @@ class ModuleWidget(QGraphicsWidget): 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) \ No newline at end of file + 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 +