diff --git a/Makefile b/Makefile index 1abda79a..2c98b156 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build package clean +.PHONY: build package clean check-doc DIST_DIR := bin/MissionDMX-Editor.dist @@ -14,4 +14,7 @@ package: $(wildcard build_files/*) submodules/resources/logo.png bin/MissionDMX- python3 build_files/build_deb.py clean: - rm -rf ./bin \ No newline at end of file + rm -rf ./bin + +check-doc: + ruff check --select D --ignore D203 --ignore D213 --ignore D401 --ignore D415 \ No newline at end of file diff --git a/src/model/board_configuration.py b/src/model/board_configuration.py index f39b9536..40a229c0 100644 --- a/src/model/board_configuration.py +++ b/src/model/board_configuration.py @@ -231,7 +231,7 @@ def _distribute_filter_update_message(self, param: proto.FilterMode_pb2.update_p for c in candidate_list: c(param) - def register_filter_update_callback(self, target_scene: int, target_filter_id: str, c: Callable) -> None: + def register_filter_update_callback(self, target_scene: int | Scene, target_filter_id: str, c: Callable) -> None: """Register a new callback for filter update messages. If filter update messages are received, they need to be routed to their intended destination. This is done using @@ -243,6 +243,8 @@ def register_filter_update_callback(self, target_scene: int, target_filter_id: s c: The callable to register. """ + if isinstance(target_scene, Scene): + target_scene = target_scene.scene_id callable_list = self._filter_update_msg_register.get((target_scene, target_filter_id)) if callable_list is None: callable_list = set() diff --git a/src/view/show_mode/editor/editor_tab_widgets/ui_widget_editor/scene_ui_page_editor_widget.py b/src/view/show_mode/editor/editor_tab_widgets/ui_widget_editor/scene_ui_page_editor_widget.py index 5af2b28d..70b9d06f 100644 --- a/src/view/show_mode/editor/editor_tab_widgets/ui_widget_editor/scene_ui_page_editor_widget.py +++ b/src/view/show_mode/editor/editor_tab_widgets/ui_widget_editor/scene_ui_page_editor_widget.py @@ -1,4 +1,4 @@ -"""A scene can have multiple pages""" +"""A scene can have multiple pages.""" from typing import override from PySide6.QtCore import QPoint, Qt @@ -13,9 +13,10 @@ class SceneUIPageEditorWidget(QWidget): - """This class represents a part of a scene""" + """Class represents a part of a scene.""" def __init__(self, page: UIPage, parent: QWidget) -> None: + """Initialize editing widget.""" super().__init__(parent) self._ui_page: UIPage = page self.setLayout(QGridLayout(self)) @@ -35,38 +36,29 @@ def mousePressEvent(self, event: QMouseEvent) -> None: def _widget_selection_menu(self, pos: QPoint) -> None: menu = QMenu(self) - """ - added_filters = 0 - for filter_ in self.ui_page.scene.filters: - if len(filter_.gui_update_keys.keys()) < 1: - continue - action = QAction(filter_.filter_id, self) - menu.addAction(action) - action.triggered.connect(lambda checked=False, filter__=filter_: self._add_filter_widget(filter__, pos)) - added_filters += 1 - if added_filters == 0: - action = QAction("There are no suitable filters in the scene", menu) - action.setEnabled(False) - menu.addAction(action) - menu.addSeparator() - auto_track_action = QAction("Auto Tracker", self) - auto_track_action.triggered.connect(lambda checked=False, filter__=None: self._add_generic_widget( - AutoTrackerUIWidget("", self._ui_page), pos) - ) - menu.addAction(auto_track_action) - """ + categories: dict[str, QMenu] = {} for widget_def in WIDGET_LIBRARY.values(): - action = QAction(widget_def[0], menu) + category = widget_def[3] + if category is None: + parent = menu + else: + if category not in categories: + submenu = QMenu(category, menu) + menu.addMenu(submenu) + categories[category] = submenu + parent = categories[category] + action = QAction(widget_def[0], parent) action.triggered.connect(lambda _, widget=widget_def: self._inst_generic_widget(widget, pos)) - menu.addAction(action) + parent.addAction(action) menu.popup(self.mapToGlobal(pos)) def _add_filter_widget(self, filter_: Filter, pos: QPoint) -> None: """Adds the filter widget to the page at the specified position. Args: - ui_widget: A widget to manage a filter + filter_: The filter to use for widget linking pos: The position at which the widget should be placed + """ # TODO replace with filter.gui_update_keys to ui widget / Change function to construct one from the keys # FIXME we should use this method to provide a context menu to nodes, enabling them to place widgets without @@ -74,7 +66,7 @@ def _add_filter_widget(self, filter_: Filter, pos: QPoint) -> None: config_widget = filter_to_ui_widget(filter_, self._ui_page) self._add_generic_widget(config_widget, pos) - def _inst_generic_widget(self, widget_def: tuple[str, type[UIWidget], list[list[FilterTypeEnumeration]]], + def _inst_generic_widget(self, widget_def: tuple[str, type[UIWidget], list[list[FilterTypeEnumeration]], str|None], pos: QPoint) -> None: config_widget = widget_def[1](self._ui_page, {}) key_filters = widget_def[2] @@ -94,10 +86,11 @@ def _add_generic_widget(self, config_widget: UIWidget, pos: QPoint) -> None: self._ui_page.display_update_required = True def _remove_widget_holder(self, wh: UIWidgetHolder) -> None: - """ - This method should be invoked once a widget should be removed and handles the destruction of the container. + """Method should be invoked once a widget should be removed and handles the destruction of the container. + Args: wh: The widget that should be removed + """ self._widgets.remove(wh) self._ui_page.remove_widget(wh.widget) @@ -105,5 +98,5 @@ def _remove_widget_holder(self, wh: UIWidgetHolder) -> None: @property def ui_page(self) -> UIPage: - """The scene the page represents""" + """The scene the page represents.""" return self._ui_page diff --git a/src/view/show_mode/show_ui_widgets/__init__.py b/src/view/show_mode/show_ui_widgets/__init__.py index 6293c96a..de22f4a9 100644 --- a/src/view/show_mode/show_ui_widgets/__init__.py +++ b/src/view/show_mode/show_ui_widgets/__init__.py @@ -23,17 +23,19 @@ from view.show_mode.show_ui_widgets.pan_tilt_constant_show_ui import PanTiltConstantControlUIWidget from view.show_mode.show_ui_widgets.sequencer_control import SequencerControlUIWidget from view.show_mode.show_ui_widgets.show_label import ShowLabelUIWidget +from view.show_mode.show_ui_widgets.slider_constant_ctrl_uiwidget import SliderConstantUIWidget """ The widget library contains information about widgets, provided by their slug. The infomration that is stored consists out of the human readable name, the type required to instantiate a requested widget, the supported filter types (that should be selected for construction) and a number indicating how many filters should be selected. """ -WIDGET_LIBRARY: dict[str, tuple[str, type[UIWidget], list[list[FilterTypeEnumeration]]]] = { +WIDGET_LIBRARY: dict[str, tuple[str, type[UIWidget], list[list[FilterTypeEnumeration]], str | None]] = { "autotracker": ( "Auto Tracker", AutoTrackerUIWidget, [[FilterTypeEnumeration.VFILTER_POSITION_CONSTANT, FilterTypeEnumeration.VFILTER_AUTOTRACKER]], + None, ), "buttonarray": ( "Button Array", @@ -43,8 +45,12 @@ FilterTypeEnumeration.FILTER_CONSTANT_8BIT, FilterTypeEnumeration.FILTER_CONSTANT_16_BIT, FilterTypeEnumeration.FILTER_CONSTANT_FLOAT, + FilterTypeEnumeration.FILTER_RESPONDING_CONSTANT_8BIT, + FilterTypeEnumeration.FILTER_RESPONDING_CONSTANT_16BIT, + FilterTypeEnumeration.FILTER_RESPONDING_CONSTANT_FLOAT, ] ], + "Constants", ), "buttonarray_submit": ( "Button Array w/ Submit", @@ -54,41 +60,75 @@ FilterTypeEnumeration.FILTER_CONSTANT_8BIT, FilterTypeEnumeration.FILTER_CONSTANT_16_BIT, FilterTypeEnumeration.FILTER_CONSTANT_FLOAT, + FilterTypeEnumeration.FILTER_RESPONDING_CONSTANT_8BIT, + FilterTypeEnumeration.FILTER_RESPONDING_CONSTANT_16BIT, + FilterTypeEnumeration.FILTER_RESPONDING_CONSTANT_FLOAT, ] ], + "Constants", + ), + "slider": ( + "Slider", + SliderConstantUIWidget, + [ + [ + FilterTypeEnumeration.FILTER_CONSTANT_8BIT, + FilterTypeEnumeration.FILTER_CONSTANT_16_BIT, + FilterTypeEnumeration.FILTER_CONSTANT_FLOAT, + FilterTypeEnumeration.FILTER_RESPONDING_CONSTANT_8BIT, + FilterTypeEnumeration.FILTER_RESPONDING_CONSTANT_16BIT, + FilterTypeEnumeration.FILTER_RESPONDING_CONSTANT_FLOAT, + ] + ], + "Constants", + ), + "colorpicker": ( + "Color Picker", + ColorSelectionUIWidget, + [[FilterTypeEnumeration.FILTER_CONSTANT_COLOR]], + "Constants", ), - "colorpicker": ("Color Picker", ColorSelectionUIWidget, [[FilterTypeEnumeration.FILTER_CONSTANT_COLOR]]), "cuecontrol": ( "Cue Control", CueControlUIWidget, [[FilterTypeEnumeration.FILTER_TYPE_CUES, FilterTypeEnumeration.VFILTER_CUES]], + None, ), "pantiltconstant": ( "Pan/Tilt Control", PanTiltConstantControlUIWidget, [[FilterTypeEnumeration.VFILTER_POSITION_CONSTANT]], + "Constants", ), "sequencercontrol": ( "Sequence Listing", SequencerControlUIWidget, [[FilterTypeEnumeration.VFILTER_SEQUENCER, FilterTypeEnumeration.FILTER_SEQUENCER]], + None, ), "chaser-preset-selector": ( "Chaser Preset Selector", ChaserApplyPresetUIWidget, - [[FilterTypeEnumeration.FILTER_COLOR_CHASER]] + [[FilterTypeEnumeration.FILTER_COLOR_CHASER]], + None, ), "chaser-live-config": ( "Chaser Live Config Tool", ChaserCreateConfigUIWidget, - [[FilterTypeEnumeration.FILTER_COLOR_CHASER]] + [[FilterTypeEnumeration.FILTER_COLOR_CHASER]], + None, ), - "label": ("Text Label", ShowLabelUIWidget, []), - "clock": ("BF Clock", ClockUIWidget, []), - "macrobuttons": ("Macro Buttons", MacroButtonUIWidget, []), + "label": ("Text Label", ShowLabelUIWidget, [], "Utility"), + "clock": ("BF Clock", ClockUIWidget, [], "Utility"), + "macrobuttons": ("Macro Buttons", MacroButtonUIWidget, [], None), # TODO add direct inputs # TODO add fader update widgets - "debug_color": ("Color Visualizer", ColorDebugVizWidget, [[FilterTypeEnumeration.FILTER_REMOTE_DEBUG_PIXEL]]), + "debug_color": ( + "Color Visualizer", + ColorDebugVizWidget, + [[FilterTypeEnumeration.FILTER_REMOTE_DEBUG_PIXEL]], + "Output", + ), "debug_number": ( "Number Output", NumberDebugVizWidget, @@ -99,6 +139,7 @@ FilterTypeEnumeration.FILTER_REMOTE_DEBUG_8BIT, ] ], + "Output", ), } diff --git a/src/view/show_mode/show_ui_widgets/slider_constant_ctrl_uiwidget.py b/src/view/show_mode/show_ui_widgets/slider_constant_ctrl_uiwidget.py new file mode 100644 index 00000000..8cf697da --- /dev/null +++ b/src/view/show_mode/show_ui_widgets/slider_constant_ctrl_uiwidget.py @@ -0,0 +1,281 @@ +"""Show UI widget to update constant filter values using a slider.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, override + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QDialog, + QHBoxLayout, + QLabel, + QRadioButton, + QSlider, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from model import Filter, UIPage, UIWidget +from model.filter import FilterTypeEnumeration + +if TYPE_CHECKING: + import proto.FilterMode_pb2 + + +class SliderConstantUIWidget(UIWidget): + """Show UI widget to provide the user with a slider that alters the content of a constant filter.""" + + def __init__(self, parent: UIPage, configuration: dict[str, str]) -> None: + """Slider UI widget. + + Args: + parent: The parent widget page. + configuration: The configuration of this widget. + + """ + super().__init__(parent, configuration) + self._player_widget: QWidget | None = None + self._configuration_widget: QWidget | None = None + self._model = None + self._value = 0 + self._minimum = 0 + self._maximum = 255 + self._orientation = Qt.Orientation.Horizontal + self._ui_update_callback_initialized = False + self._player_slider: QSlider | None = None + self._value_label: QLabel | None = None + + # Load configuration if available + if "orientation" in self._configuration: + self._orientation = ( + Qt.Orientation.Vertical + if self._configuration["orientation"] == "vertical" + else Qt.Orientation.Horizontal + ) + + def __del__(self) -> None: + """Unregister callbacks.""" + if self._ui_update_callback_initialized and self._model is not None: + self._model.scene.board_configuration.remove_filter_update_callback( + self._model.scene.scene_id, + self._model.filter_id, + self._update_from_fish + ) + + def set_filter(self, f: Filter, i: int) -> None: + """Set the filter associated with this UI widget. + + Args: + f: The new filter to set + i: The index to update (unused for slider, always 0). + + """ + if f is None: + return + super().set_filter(f, i) + self._model = f + self.associated_filters["constant"] = f.filter_id + + # Set value range based on filter type + if f.filter_type in [FilterTypeEnumeration.FILTER_CONSTANT_8BIT, + FilterTypeEnumeration.FILTER_RESPONDING_CONSTANT_8BIT]: + self._minimum = 0 + self._maximum = 255 + self._value = int(f.initial_parameters.get("value", "0")) + elif f.filter_type in [FilterTypeEnumeration.FILTER_CONSTANT_16_BIT, + FilterTypeEnumeration.FILTER_RESPONDING_CONSTANT_16BIT]: + self._minimum = 0 + self._maximum = (2 ** 16) - 1 + self._value = int(f.initial_parameters.get("value", "0")) + else: # FILTER_CONSTANT_FLOAT + self._minimum = 0 + self._maximum = 1000 # Default range for float, can be configured + self._value = float(f.initial_parameters.get("value", "0.0")) + + # Update slider if it exists + if self._player_slider is not None: + self._player_slider.setMinimum(self._minimum) + self._player_slider.setMaximum(self._maximum) + self._player_slider.setValue(int(self._value)) + if self._value_label is not None: + self._value_label.setText(str(self._value)) + + if not self._ui_update_callback_initialized: + f.scene.board_configuration.register_filter_update_callback( + f.scene, f.filter_id, self._update_from_fish + ) + self._ui_update_callback_initialized = True + + def _set_value(self, new_value: int) -> None: + """Update the value and push to filter.""" + self._value = new_value + if self._value_label is not None: + self._value_label.setText(str(new_value)) + self.push_update() + + @override + def generate_update_content(self) -> list[tuple[str, str]]: + """Generate update content for the filter.""" + return [("value", str(self._value))] + + @override + def get_player_widget(self, parent: QWidget | None) -> QWidget: + """Get the player widget with the slider.""" + w = QWidget(parent) + self._construct_player_widget(w) + + if self._orientation == Qt.Orientation.Vertical: + layout = QVBoxLayout() + layout.addWidget(self._value_label) + layout.addWidget(self._player_widget) + else: + layout = QHBoxLayout() + layout.addWidget(self._player_widget) + layout.addWidget(self._value_label) + + w.setLayout(layout) + return w + + @override + def get_configuration_widget(self, parent: QWidget | None) -> QWidget: + """Get the configuration widget for the editor.""" + w = QWidget(parent) + self._construct_configuration_widget(w) + layout = QVBoxLayout() + layout.addWidget(self._configuration_widget) + w.setLayout(layout) + return w + + @override + def copy(self, new_parent: UIPage) -> UIWidget: + """Create a deep copy of this widget.""" + w = SliderConstantUIWidget(new_parent, self.configuration.copy()) + super().copy_base(w) + return w + + def _construct_player_widget(self, parent: QWidget | None) -> None: + """Construct the player widget with slider.""" + self._player_widget = QWidget(parent) + + # Create slider + self._player_slider = QSlider(self._orientation, self._player_widget) + self._player_slider.setMinimum(self._minimum) + self._player_slider.setMaximum(self._maximum) + self._player_slider.setValue(int(self._value)) + self._player_slider.setTickPosition(QSlider.TickPosition.TicksBelow) + self._player_slider.setTickInterval(max(1, (self._maximum - self._minimum) // 10)) + + # Connect slider value changed signal + self._player_slider.valueChanged.connect(self._set_value) + + # Create value label + self._value_label = QLabel(str(self._value), self._player_widget) + self._value_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Set layout + if self._orientation == Qt.Orientation.Vertical: + layout = QVBoxLayout() + self._player_widget.setMinimumHeight(200) + self._player_widget.setMinimumWidth(80) + else: + layout = QHBoxLayout() + self._player_widget.setMinimumHeight(80) + self._player_widget.setMinimumWidth(200) + + layout.addWidget(self._player_slider) + self._player_widget.setLayout(layout) + + def _construct_configuration_widget(self, parent: QWidget | None) -> None: + """Construct the configuration widget for the editor.""" + if self._player_widget is None: + self._construct_player_widget(None) + self._configuration_widget = QWidget(parent) + layout = QVBoxLayout() + + # Orientation selection + orientation_group = QWidget(self._configuration_widget) + orientation_layout = QVBoxLayout() + orientation_layout.addWidget(QLabel("Orientation:", orientation_group)) + + horizontal_radio = QRadioButton("Horizontal", orientation_group) + vertical_radio = QRadioButton("Vertical", orientation_group) + + # Set current orientation + if self._orientation == Qt.Orientation.Horizontal: + horizontal_radio.setChecked(True) + else: + vertical_radio.setChecked(True) + + orientation_layout.addWidget(horizontal_radio) + orientation_layout.addWidget(vertical_radio) + orientation_group.setLayout(orientation_layout) + layout.addWidget(orientation_group) + + # Slider size configuration + size_group = QWidget(self._configuration_widget) + size_layout = QHBoxLayout() + size_layout.addWidget(QLabel("Slider Size:", size_group)) + + size_spinbox = QSpinBox(size_group) + size_spinbox.setMinimum(50) + size_spinbox.setMaximum(500) + size_spinbox.setValue( + self._player_widget.minimumHeight() if self._orientation == Qt.Orientation.Vertical + else self._player_widget.minimumWidth() + ) + size_layout.addWidget(size_spinbox) + size_group.setLayout(size_layout) + layout.addWidget(size_group) + + # Connect signals + def update_orientation() -> None: + if horizontal_radio.isChecked(): + self._orientation = Qt.Orientation.Horizontal + self._configuration["orientation"] = "horizontal" + else: + self._orientation = Qt.Orientation.Vertical + self._configuration["orientation"] = "vertical" + + horizontal_radio.toggled.connect(update_orientation) + vertical_radio.toggled.connect(update_orientation) + + def update_size(new_size: int) -> None: + if self._orientation == Qt.Orientation.Vertical: + self._player_widget.setMinimumHeight(new_size) + else: + self._player_widget.setMinimumWidth(new_size) + + size_spinbox.valueChanged.connect(update_size) + + self._configuration_widget.setLayout(layout) + + def __str__(self) -> str: + """Get the filter id string or an error message.""" + return str(self._model.filter_id if self._model else "Error: No Filter configured.") + + def _update_from_fish(self, param: proto.FilterMode_pb2.update_parameter) -> None: + """Update slider position based on filter updates from fish.""" + if param.parameter_key != "value": + return + + try: + new_value = int(float(param.parameter_value)) + if self._player_slider is not None: + # Block signals to prevent recursive updates + self._player_slider.blockSignals(True) + self._player_slider.setValue(new_value) + self._player_slider.blockSignals(False) + + if self._value_label is not None: + self._value_label.setText(str(new_value)) + + self._value = new_value + except (ValueError, TypeError): + pass # Ignore invalid values + + @override + def get_config_dialog_widget(self, parent: QDialog) -> QWidget: + """Get the configuration dialog widget.""" + # Reuse the configuration widget for the dialog + return self.get_configuration_widget(parent)