diff --git a/src/controller/file/read.py b/src/controller/file/read.py index 339d1d62..6c7cc56b 100644 --- a/src/controller/file/read.py +++ b/src/controller/file/read.py @@ -6,6 +6,7 @@ import xmlschema from defusedxml.ElementTree import parse +from PySide6.QtWidgets import QMessageBox import proto.Console_pb2 import proto.UniverseControl_pb2 @@ -22,6 +23,7 @@ from model.media_assets.factory_hint import AssetFactoryObjectHint from model.media_assets.registry import clear as clear_media_registry from model.ofl.fixture import load_fixture, make_used_fixture +from model.ofl.fixture_not_found_exception import FixtureDefNotFoundError from model.scene import FilterPage from model.virtual_filters.vfilter_factory import construct_virtual_filter_instance from utility import resource_path @@ -265,6 +267,14 @@ def _parse_filter_page(element: ET.Element, parent_scene: Scene, instantiated_pa return True +def _parse_dmx_default_value(scene: Scene, child: ET.Element) -> None: + scene.insert_dmx_default_value( + int(child.attrib["universe"]), + int(child.attrib["channel"]), + int(child.attrib["value"]) + ) + + def _parse_scene( scene_element: ET.Element, board_configuration: BoardConfiguration, loaded_banksets: dict[str, BankSet] ) -> None: @@ -301,6 +311,8 @@ def _parse_scene( filter_pages.append(child) case "uipage": ui_page_elements.append(child) + case "dmxdefaultvalue": + _parse_dmx_default_value(scene, child) case _: logger.warning("Scene %s contains unknown element: %s", human_readable_name, child.tag) @@ -652,13 +664,21 @@ def _parse_patching(board_configuration: BoardConfiguration, location_element: E fixtures_path = "/var/cache/missionDMX/fixtures" # TODO config file for child in location_element: - make_used_fixture( - board_configuration, - load_fixture(os.path.join(fixtures_path, child.attrib["fixture_file"])), - int(child.attrib["mode"]), - universe_id, - int(child.attrib["start"]), - ) + try: + make_used_fixture( + board_configuration, + load_fixture(os.path.join(fixtures_path, child.attrib["fixture_file"])), + int(child.attrib["mode"]), + universe_id, + int(child.attrib["start"]), + ) + except FixtureDefNotFoundError as e: + # Calling Dialog exec is not an issue here as we're in the process of loading the show file anyway + mb = QMessageBox(QMessageBox.Icon.Critical, "Failed to load fixture", str(e) + + "\n\nDo not continue until this error is fixed as the show is now corrupted.\nMaybe try " + "updating the fixture database.") + mb.exec_() + continue # TODO load fixture name from file diff --git a/src/controller/file/serializing/scene_serialization.py b/src/controller/file/serializing/scene_serialization.py index 10c2ab21..7e5d850d 100644 --- a/src/controller/file/serializing/scene_serialization.py +++ b/src/controller/file/serializing/scene_serialization.py @@ -107,6 +107,13 @@ def generate_scene_xml_description( for ui_page in scene.ui_pages: _add_ui_page_to_element(scene_element, ui_page) + scene.sort_dmx_default_values() + for default_value in scene.dmx_default_values: + ET.SubElement(scene_element, "dmxdefaultvalue", attrib={ + "universe": str(default_value.universe_id), + "channel": str(default_value.channel), + "value": str(default_value.value), + }) def _create_scene_element(scene: Scene, parent: ET.Element) -> ET.Element: diff --git a/src/model/board_configuration.py b/src/model/board_configuration.py index bfd4d2e9..f39b9536 100644 --- a/src/model/board_configuration.py +++ b/src/model/board_configuration.py @@ -330,13 +330,14 @@ def get_fixture_by_address(self, fixture_univ: int, fixture_chan: int) -> UsedFi Args: fixture_univ: The universe of the fixture. - fixture_chan: The first channel of the fixture. + fixture_chan: A channel of the fixture. Returns: The fixture or None if no fixture was found. """ for fixture in self._fixtures.values(): - if fixture.universe_id == fixture_univ and fixture.start_index == fixture_chan: + if fixture.universe_id == fixture_univ and \ + fixture.start_index <= fixture_chan < fixture.start_index + fixture.channel_length: return fixture return None diff --git a/src/model/broadcaster.py b/src/model/broadcaster.py index 0f8fd07c..7d8d54e7 100644 --- a/src/model/broadcaster.py +++ b/src/model/broadcaster.py @@ -57,6 +57,7 @@ class Broadcaster(QtCore.QObject, metaclass=QObjectSingletonMeta): scene_open_in_editor_requested: QtCore.Signal = QtCore.Signal(object) # FilterPage bankset_open_in_editor_requested: QtCore.Signal = QtCore.Signal(dict) uipage_opened_in_editor_requested: QtCore.Signal = QtCore.Signal(dict) + default_dmx_value_editor_opening_requested: QtCore.Signal = QtCore.Signal(object) delete_scene: QtCore.Signal = QtCore.Signal(object) delete_universe: QtCore.Signal = QtCore.Signal(object) device_created: QtCore.Signal = QtCore.Signal(object) # device diff --git a/src/model/channel.py b/src/model/channel.py index 365a7ebd..f24e7a51 100644 --- a/src/model/channel.py +++ b/src/model/channel.py @@ -1,15 +1,23 @@ -"""Basic dmx channel with 256 values""" +"""Basic dmx channel with 256 values.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + from PySide6 import QtCore +if TYPE_CHECKING: + from model import Universe class Channel(QtCore.QObject): - """Basic dmx channel with 256 values""" + """Basic dmx channel with 256 values.""" updated: QtCore.Signal = QtCore.Signal(int) - def __init__(self, channel_address: int) -> None: + def __init__(self, parent_universe: Universe, channel_address: int) -> None: """Constructs a channel.""" super().__init__(None) + self.parent_universe = parent_universe if not (0 <= channel_address <= 511): raise ValueError(f"Tried to create a channel with address {channel_address}") self._address: int = channel_address @@ -17,21 +25,23 @@ def __init__(self, channel_address: int) -> None: @property def address(self) -> int: - """Address of the channel. 0-indexed""" + """Address of the channel. 0-indexed.""" return self._address @property def value(self) -> int: - """The current value of the channel""" + """The current value of the channel.""" return self._value @value.setter def value(self, value: int) -> None: """Updates the value of the channel. + Must be between 0 and 255. Raises: ValueError: The value is below 0 or above 255. + """ if not (0 <= value <= 511): raise ValueError(f"Tried to set channel {self._address} to {value}.") diff --git a/src/model/ofl/fixture.py b/src/model/ofl/fixture.py index bf53681d..a90c60ee 100644 --- a/src/model/ofl/fixture.py +++ b/src/model/ofl/fixture.py @@ -15,6 +15,7 @@ import numpy as np from PySide6 import QtCore +from model.ofl.fixture_not_found_exception import FixtureDefNotFoundError from model.ofl.ofl_fixture import CapabilityType, FixtureMode, MatrixChannelInsert, OflFixture from model.patching.fixture_channel import FixtureChannel, FixtureChannelType @@ -56,11 +57,11 @@ def __str__(self) -> str: return "+".join(s) -def load_fixture(file: str) -> OflFixture | None: +def load_fixture(file: str) -> OflFixture: """Load fixture from OFL JSON.""" if not os.path.isfile(file): logger.error("Fixture definition %s not found.", file) - return None + raise FixtureDefNotFoundError(file, "Path is no file. Does it exist?") with open(file, "r", encoding="UTF-8") as f: try: ob: dict = json.load(f) @@ -198,7 +199,17 @@ def comment(self) -> str: @property def mode(self) -> FixtureMode: - """Mode of theFixture.""" + """Mode of theFixture. + + Raises: + FixtureDefNotFoundError if the fixture mode does not exist. + + """ + if len(self._fixture.modes) <= self._mode_index: + raise FixtureDefNotFoundError( + self._fixture.fileName, + "Fixture does not have requested mode. Are the fixture defintions up to date?" + ) return self._fixture.modes[self._mode_index] @property @@ -325,4 +336,8 @@ def make_used_fixture( board_configuration: BoardConfiguration, fixture: OflFixture, mode_index: int, universe_id: int, start_index: int ) -> UsedFixture: """Generate a new Used Fixture from a oflFixture.""" - return UsedFixture(board_configuration, fixture, mode_index, universe_id, start_index) + try: + return UsedFixture(board_configuration, fixture, mode_index, universe_id, start_index) + except ValueError as e: + logger.error(e) + raise FixtureDefNotFoundError(fixture.fileName, str(e)) from e diff --git a/src/model/ofl/fixture_not_found_exception.py b/src/model/ofl/fixture_not_found_exception.py new file mode 100644 index 00000000..0fb7ef46 --- /dev/null +++ b/src/model/ofl/fixture_not_found_exception.py @@ -0,0 +1,20 @@ +"""Provides FixtureDefNotFoundError.""" + +from __future__ import annotations + +import os + + +class FixtureDefNotFoundError(Exception): + """Exception raised when fixture definition could not be found on disk.""" + + def __init__(self, fixture_path: str, further_info: str) -> None: + """Initialize with default message and provided fixture info.""" + super().__init__("Fixture Definition Not Found") + self.fixture_path = fixture_path + self.further_info = further_info + + def __str__(self) -> str: + """Generate reasonable error message for observing human.""" + return f"Failed to load fixture {self.fixture_path}.\n{"File not Found.\n" if not + os.path.exists(self.fixture_path) else ""}Further info: {self.further_info}" diff --git a/src/model/scene.py b/src/model/scene.py index b49ce750..93ebeee2 100644 --- a/src/model/scene.py +++ b/src/model/scene.py @@ -2,7 +2,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, override +from typing import TYPE_CHECKING, NamedTuple, override + +from PySide6.QtCore import QObject, Signal + +from .universe import Universe if TYPE_CHECKING: from .board_configuration import BoardConfiguration @@ -62,11 +66,22 @@ def copy(self, new_scene: Scene = None) -> FilterPage: return new_fp -class Scene: +class DmxDefaultValue(NamedTuple): + """Contains a single default entry to be applied on scene activation by fish.""" + + universe_id: int + channel: int + value: int + + +class Scene(QObject): """Scene for a show file.""" + default_values_changed = Signal() + def __init__(self, scene_id: int, human_readable_name: str, board_configuration: BoardConfiguration) -> None: """Scene for a show file.""" + super().__init__() self._scene_id: int = scene_id self._human_readable_name: str = human_readable_name self._board_configuration: BoardConfiguration = board_configuration @@ -75,6 +90,7 @@ def __init__(self, scene_id: int, human_readable_name: str, board_configuration: self._filter_pages: list[FilterPage] = [] self._associated_bankset: BankSet | None = None self._ui_pages: list[UIPage] = [] + self._dmx_default_values: list[DmxDefaultValue] = [] @property def scene_id(self) -> int: @@ -116,6 +132,58 @@ def pages(self) -> list[FilterPage]: self._filter_pages.append(default_page) return self._filter_pages + @property + def dmx_default_values(self) -> list[DmxDefaultValue]: + """Get the list of default values to be applied on scene switch.""" + return self._dmx_default_values.copy() + + def insert_dmx_default_value(self, universe: Universe | int, channel: int, value: int, + supress_emission: bool = False) -> bool: + """Add a new default value to the scene. + + Existing values will be updated. + + Args: + universe: target universe or its ID. + channel: target channel. + value: value to set on scene entry. + supress_emission: If this is enabled to change signal will be enabled. Only use this if you're certain + you're taking care of all updates yourself. + + Returns: + True if a value was updated and false if it was added. + + """ + universe_id = universe.id if isinstance(universe, Universe) else universe + value_to_remove = None + for existing_value in self._dmx_default_values: + if existing_value.universe_id == universe_id and existing_value.channel == channel: + value_to_remove = existing_value + if value_to_remove is not None: + self._dmx_default_values.remove(value_to_remove) + self._dmx_default_values.append(DmxDefaultValue(universe_id, channel, value)) + if not supress_emission: + self.default_values_changed.emit() + return value_to_remove is not None + + def remove_dmx_default_value(self, universe: Universe | int, channel: int, supress_emission: bool = False) -> None: + """Remove a default DMX value from the scene. + + Args: + universe: target universe or its ID. + channel: target channel. + supress_emission: If this is enabled to change signal will be enabled. Only use this if you're certain + you're taking care of all updates yourself. + + """ + universe_id = universe.id if isinstance(universe, Universe) else universe + values_to_remove = [val for val in self._dmx_default_values if + val.universe_id == universe_id and val.channel == channel] + for item in values_to_remove: + self._dmx_default_values.remove(item) + if len(values_to_remove) > 0 and not supress_emission: + self.default_values_changed.emit() + def insert_filterpage(self, fp: FilterPage) -> None: """Add a filterpage to the scene.""" self._filter_pages.append(fp) @@ -184,6 +252,8 @@ def copy(self, existing_scenes: list[Scene]) -> Scene: scene.linked_bankset = self._associated_bankset.copy() for page in self._ui_pages: scene._ui_pages.append(page.copy(scene)) + for ddv in self._dmx_default_values: + scene._dmx_default_values.append(ddv) return scene def get_filter_by_id(self, fid: str) -> Filter | None: @@ -266,3 +336,12 @@ def notify_about_filter_rename_action(self, sender: Filter, old_id: str) -> None for page in self._ui_pages: for widget in page.widgets: widget.notify_id_rename(old_id, sender.filter_id) + + def sort_dmx_default_values(self) -> None: + """Sorts the dmx defaults by their universe and channel. + + This improves human interaction and performance of fish. + + """ + self._dmx_default_values.sort(key=lambda x: (x.universe_id * 512) + x.channel) + self.default_values_changed.emit() diff --git a/src/model/universe.py b/src/model/universe.py index 7bd70ff1..55bc72ef 100644 --- a/src/model/universe.py +++ b/src/model/universe.py @@ -1,4 +1,4 @@ -"""DMX Universe""" +"""DMX Universe.""" from typing import Final import proto.UniverseControl_pb2 @@ -9,12 +9,18 @@ class Universe: - """DMX universe with 512 channels""" + """DMX universe with 512 channels.""" def __init__(self, universe_proto: proto.UniverseControl_pb2.Universe) -> None: + """Initialize universe model using provided protobuf definition. + + Args: + universe_proto: The protobuf universe declaration to use. + + """ self._broadcaster = Broadcaster() self._universe_proto: proto.UniverseControl_pb2 = universe_proto - self._channels: Final[list[Channel]] = [Channel(channel_address) for channel_address in + self._channels: Final[list[Channel]] = [Channel(self, channel_address) for channel_address in range(NUMBER_OF_CHANNELS)] self._name = f"Universe {self.universe_proto.id + 1}" @@ -23,7 +29,7 @@ def __init__(self, universe_proto: proto.UniverseControl_pb2.Universe) -> None: @property def universe_proto(self) -> proto.UniverseControl_pb2.Universe: - """the UniverseProto of the Universe""" + """UniverseProto definition of the Universe.""" return self._universe_proto @universe_proto.setter @@ -32,12 +38,12 @@ def universe_proto(self, proto_: proto.UniverseControl_pb2.Universe) -> None: @property def channels(self) -> list[Channel]: - """List of all 512 dmx channels belonging to the Universe""" + """List of all 512 dmx channels belonging to the Universe.""" return self._channels @property def id(self) -> int: - """id of the universe""" + """ID of the universe.""" return self._universe_proto.id @property @@ -61,7 +67,7 @@ def description(self, description: str) -> None: @property def location( self) -> int | proto.UniverseControl_pb2.Universe.ArtNet | proto.UniverseControl_pb2.Universe.USBConfig: - """network location""" + """Network location.""" if self._universe_proto.remote_location.ip_address != "": return self._universe_proto.remote_location if self._universe_proto.ftdi_dongle.vendor_id != "": diff --git a/src/view/console_mode/console_channel_widget.py b/src/view/console_mode/console_channel_widget.py index 3dcc0a33..fe0d0550 100644 --- a/src/view/console_mode/console_channel_widget.py +++ b/src/view/console_mode/console_channel_widget.py @@ -1,6 +1,7 @@ """Widget to edit a channel.""" +from logging import getLogger -from PySide6 import QtCore, QtWidgets +from PySide6 import QtCore, QtGui, QtWidgets from PySide6.QtWidgets import QWidget import style @@ -9,6 +10,8 @@ from model.patching.fixture_channel import FixtureChannel from view.console_mode.console_fader_bank_selector import ConsoleFaderBankSelectorWidget +logger = getLogger(__name__) + class ChannelWidget(QtWidgets.QWidget): """Widget to edit a channel. @@ -67,10 +70,11 @@ def __init__( self._value_editor.textChanged.connect(self.update_value) self._value_editor.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) - self._fixture_channel = QtWidgets.QLabel(fixture_channel.name) - self._fixture_channel.setWordWrap(True) - self._fixture_channel.setFixedSize(element_size, element_size * 2) - self._fixture_channel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self._fixture_channel = fixture_channel + self._fixture_channel_label = QtWidgets.QLabel(fixture_channel.name) + self._fixture_channel_label.setWordWrap(True) + self._fixture_channel_label.setFixedSize(element_size, element_size * 2) + self._fixture_channel_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) self._channel.updated.connect(self.update_value) @@ -108,7 +112,7 @@ def __init__( layout = QtWidgets.QVBoxLayout() layout.addWidget(address_label) - layout.addWidget(self._fixture_channel) + layout.addWidget(self._fixture_channel_label) layout.addWidget(self._bank_selector) layout.addWidget(self._value_editor) layout.addWidget(self._max_button) @@ -118,6 +122,28 @@ def __init__( self.setLayout(layout) self.setContentsMargins(0, 0, 0, 0) + self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self._open_context_menu) + self._menu: QtWidgets.QMenu | None = None + + def _open_context_menu(self, point: QtCore.QPoint) -> None: + self._menu = QtWidgets.QMenu(self) + self._menu.move(self.mapToGlobal(point)) + save_default_value_action = QtGui.QAction("Save Channel Value as default", self._menu) + save_default_value_action.triggered.connect(self._save_default_triggered) + self._menu.addAction(save_default_value_action) + self._menu.show() + + def _save_default_triggered(self) -> None: + p = self.parent() + from view.console_mode.console_universe_selector import UniverseSelector + while not isinstance(p, UniverseSelector): + if p is None: + logger.critical("Expected to have a UniverseSelector parent.") + return + p = p.parent() + p.save_to_scene_default_clicked(self._channel) + def _update(self, value: int) -> None: """Update the slider and value label.""" self._slider.setValue(value) diff --git a/src/view/console_mode/console_universe_selector.py b/src/view/console_mode/console_universe_selector.py index d29cbcb4..b48c79e2 100644 --- a/src/view/console_mode/console_universe_selector.py +++ b/src/view/console_mode/console_universe_selector.py @@ -1,14 +1,21 @@ """Contains select Universe widget.""" +from __future__ import annotations + +from typing import TYPE_CHECKING from PySide6 import QtWidgets from PySide6.QtGui import QAction, Qt -from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QSizePolicy, QWidget +from PySide6.QtWidgets import QHBoxLayout, QLabel, QMessageBox, QPushButton, QSizePolicy, QWidget -from model import BoardConfiguration -from model.universe import Universe from view.console_mode.console_universe_widget import DirectUniverseWidget +from view.dialogs.selection_dialog import SelectionDialog from view.show_mode.editor.node_editor_widgets.cue_editor.yes_no_dialog import YesNoDialog +if TYPE_CHECKING: + from model import BoardConfiguration + from model.channel import Channel + from model.universe import Universe + class UniverseSelector(QtWidgets.QTabWidget): """select Universe from Tab Widget.""" @@ -32,6 +39,7 @@ def __init__(self, board_configuration: BoardConfiguration, parent: QWidget) -> initial_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.addTab(initial_label, "") self._initial_tab_present: bool = True + self._dialog: SelectionDialog | None = None def add_universe(self, universe: Universe) -> None: """Add a new Universe to universe Selector. @@ -54,6 +62,12 @@ def add_universe(self, universe: Universe) -> None: automap_button.setToolTip("Would you like to automatically map all channels to bank sets?") automap_button.clicked.connect(self._automap) row_layout.addWidget(automap_button) + row_layout.addSpacing(25) + save_as_scene_default_button = QPushButton("Save as Scene default") + save_as_scene_default_button.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) + save_as_scene_default_button.setToolTip("Save the current setup as a default for a scene.") + save_as_scene_default_button.clicked.connect(self.save_to_scene_default_clicked) + row_layout.addWidget(save_as_scene_default_button) row_layout.addStretch() layout.addLayout(row_layout) @@ -79,3 +93,32 @@ def notify_activate(self) -> None: def _automap(self) -> None: for uw in self._universe_widgets: uw.automap() + + def save_to_scene_default_clicked(self, channel: Channel | None = None) -> None: + """Save a single channel or all values to scenes.""" + if len(self._board_configuration.scenes) == 0: + self._dialog = QMessageBox(QMessageBox.Icon.Information, "No Scenes Created", + "You need to create at least one scene.") + self._dialog.show() + return + scene_list = [f"{scene.scene_id}: {scene.human_readable_name}" for scene in self._board_configuration.scenes] + self._dialog = SelectionDialog("Select Scene", + f"Please select the scene to apply the value{'s' if channel is None else ''} to", + scene_list, self, True, + lambda d,c=channel: self._scene_selected_for_default_value_add(d, c)) + self._dialog.show() + + def _scene_selected_for_default_value_add(self, dialog: SelectionDialog, channel: Channel | None) -> None: + for selected_item in dialog.selected_items: + scene = self._board_configuration.get_scene_by_id(int(selected_item.split(": ", 1)[0])) + if scene is None: + return + if channel is None: + for univ_widget in self._universe_widgets: + univ_widget.add_settings_to_scenes_default_values(scene) + else: + scene.insert_dmx_default_value(channel.parent_universe, channel.address, channel.value, + supress_emission=True) + scene.sort_dmx_default_values() + self._dialog.deleteLater() + self._dialog = None diff --git a/src/view/console_mode/console_universe_widget.py b/src/view/console_mode/console_universe_widget.py index 8f8c6c20..8483f220 100644 --- a/src/view/console_mode/console_universe_widget.py +++ b/src/view/console_mode/console_universe_widget.py @@ -7,6 +7,7 @@ from model.broadcaster import Broadcaster from model.control_desk import BankSet from model.ofl.fixture import UsedFixture +from model.scene import Scene from model.universe import Universe from view.console_mode.console_channel_widget import ChannelWidget @@ -112,6 +113,11 @@ def automap(self) -> None: index += 1 fixtures_per_bank = 0 + def add_settings_to_scenes_default_values(self, scene: Scene) -> None: + """Add the current universes configuration to the scenes default values.""" + for channel in self._universe.channels: + scene.insert_dmx_default_value(self._universe, channel.address, channel.value, supress_emission=True) + def _add_fixture(self, fixture: UsedFixture) -> None: if fixture.universe_id != self._universe.id: return diff --git a/src/view/show_mode/editor/editor_tab_widgets/dmx_default_value_editor.py b/src/view/show_mode/editor/editor_tab_widgets/dmx_default_value_editor.py new file mode 100644 index 00000000..66839a5c --- /dev/null +++ b/src/view/show_mode/editor/editor_tab_widgets/dmx_default_value_editor.py @@ -0,0 +1,150 @@ +"""Contains DMX default value editor tab widget.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PySide6.QtWidgets import ( + QComboBox, + QDialog, + QFormLayout, + QHBoxLayout, + QLabel, + QListWidget, + QPushButton, + QSizePolicy, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from view.show_mode.editor.show_browser.annotated_item import AnnotatedListWidgetItem + +if TYPE_CHECKING: + from model.scene import Scene + + +class _ValueQueryDialog(QDialog): + def __init__(self, parent: DMXDefaultValueEditorWidget) -> None: + super().__init__(parent) + self._editor: DMXDefaultValueEditorWidget = parent + + self._universe_id_tb = QComboBox() + for univ in parent.scene.board_configuration.universes: + self._universe_id_tb.addItem(str(univ.name), univ.id) + self._channel_tb = QSpinBox() + self._channel_tb.setRange(0, 511) + self._value_tb = QSpinBox() + self._value_tb.setRange(0, 255) + + layout = QFormLayout() + layout.addRow("Universe ID", self._universe_id_tb) + layout.addRow("Channel", self._channel_tb) + layout.addRow("Value", self._value_tb) + + layout_exit = QHBoxLayout() + self._ok = QPushButton() + self._ok.setText("Enter") + _cancel = QPushButton() + _cancel.setText("Cancel") + layout_exit.addWidget(_cancel) + layout_exit.addWidget(self._ok) + _cancel.setAutoDefault(False) + self._ok.setAutoDefault(True) + self._ok.clicked.connect(self._accept) + _cancel.clicked.connect(self.close) + layout.addRow(layout_exit) + + self.setLayout(layout) + self.setWindowTitle("Add Entry") + self.setModal(True) + + def _accept(self) -> None: + univ_id = self._universe_id_tb.currentData() + channel = self._channel_tb.value() + value = self._value_tb.value() + refresh_required = self._editor.scene.insert_dmx_default_value(univ_id, channel, value, supress_emission=True) + if refresh_required: + self._editor.refresh() + else: + self._editor.add_value_entry(univ_id, channel, value) + self.close() + + +class DMXDefaultValueEditorWidget(QWidget): + """Widget to edit default DMX value mappings.""" + + def __init__(self, scene: Scene, parent: QWidget | None = None) -> None: + """Initializes the widget. + + Args: + scene: The scene which default DMX values should be edited. + parent: The parent widget. + + """ + super().__init__(parent) + self._scene: Scene = scene + self._scene.default_values_changed.connect(self.refresh) + + layout = QVBoxLayout() + + self._value_list_widget = QListWidget() + self._value_list_widget.setSizePolicy(QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)) + self._value_list_widget.setSelectionMode(QListWidget.SelectionMode.MultiSelection) + + self._remove_value_button = QPushButton("Remove Default Value") + self._remove_value_button.clicked.connect(self._remove_entry) + + self.refresh() + + self._add_value_button = QPushButton("Add Default Value") + self._add_value_button.clicked.connect(self._add_value_clicked) + + layout.addWidget(QLabel(f"DMX Default Values of Scene '{scene.human_readable_name}' [{scene.scene_id}]")) + layout.addWidget(self._value_list_widget) + buttons_layout = QHBoxLayout() + buttons_layout.addStretch() + buttons_layout.addWidget(self._remove_value_button) + buttons_layout.addSpacing(25) + buttons_layout.addWidget(self._add_value_button) + layout.addLayout(buttons_layout) + + self.setLayout(layout) + + def refresh(self) -> None: + """Refreshes the list view.""" + self._value_list_widget.clear() + empty = True + for univ_id, channel, value in self._scene.dmx_default_values: + self.add_value_entry(univ_id, channel, value) + empty = False + self._remove_value_button.setEnabled(not empty) + + @property + def scene(self) -> Scene: + """Get the scene in use.""" + return self._scene + + def _add_value_clicked(self) -> None: + self._dialog = _ValueQueryDialog(self) + self._dialog.show() + + def add_value_entry(self, universe_id: int, channel: int, value: int) -> None: + """Add an entry to the list view.""" + item = AnnotatedListWidgetItem(self._value_list_widget) + item.annotated_data = (universe_id, channel, value) + capability = "" + associated_fixture = self._scene.board_configuration.get_fixture_by_address(universe_id, channel) + if associated_fixture is not None: + capability = associated_fixture.fixture_channels[channel - associated_fixture.start_index].name + item.setText(f"{universe_id}:{channel} ({associated_fixture.name if + associated_fixture is not None else ""}.{capability}) -> {value}") + self._remove_value_button.setEnabled(True) + + def _remove_entry(self) -> None: + for entry_item in self._value_list_widget.selectedItems(): + if not isinstance(entry_item, AnnotatedListWidgetItem): + continue + univ, channel, _ = entry_item.annotated_data + self.scene.remove_dmx_default_value(univ, channel, supress_emission=True) + self.refresh() diff --git a/src/view/show_mode/editor/nodes/impl/adapters.py b/src/view/show_mode/editor/nodes/impl/adapters.py index d8b70cf2..49a15dd2 100644 --- a/src/view/show_mode/editor/nodes/impl/adapters.py +++ b/src/view/show_mode/editor/nodes/impl/adapters.py @@ -47,7 +47,7 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Adapter16bitToFloat(FilterNode): - """Node for 16bit to float adapter filter.""" + """Filter node to convert a 16 bit value to a float.""" nodeName = "16bit to Float converter" # noqa: N815 @@ -65,7 +65,7 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Adapter8bitToFloat(FilterNode): - """Node for 8bit to float adapter filter.""" + """Filter node to convert a 8bit value to a float.""" nodeName = "8bit to Float converter" # noqa: N815 @@ -338,8 +338,9 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class ColorBrightnessMixinNode(FilterNode): - """Node for color brightness mixin v-filter. + """Filter node to mix brightness values conveniently. + It supports the brightness mixin v-filter. It provides convenience features for easier mix selection. """ diff --git a/src/view/show_mode/editor/show_browser/show_browser.py b/src/view/show_mode/editor/show_browser/show_browser.py index 3462f304..e0d473ca 100644 --- a/src/view/show_mode/editor/show_browser/show_browser.py +++ b/src/view/show_mode/editor/show_browser/show_browser.py @@ -41,6 +41,7 @@ class ShowBrowser: _filter_browser_tab_icon = QIcon(resource_path(os.path.join("resources", "icons", "showbrowser-filterpages.svg"))) _fader_icon = QIcon(resource_path(os.path.join("resources", "icons", "faders.svg"))) _uipage_icon = QIcon(resource_path(os.path.join("resources", "icons", "uipage.svg"))) + _dmx_default_value_tab_icon = QIcon(resource_path(os.path.join("resources", "icons", "dmx-values-default.svg"))) def __init__(self, parent: QWidget, show: BoardConfiguration, editor_tab_browser: QTabWidget) -> None: """Initialize a ShowBrowser. @@ -178,6 +179,12 @@ def add_filter_page(parent_item: AnnotatedTreeWidgetItem, fp: FilterPage) -> Non bankset_item.setIcon(0, ShowBrowser._fader_icon) bankset_item.setText(1, s.linked_bankset.description) bankset_item.annotated_data = s.linked_bankset + default_value_item = AnnotatedTreeWidgetItem(item) + default_value_item.setText(0, "Default DMX values") + default_value_item.setIcon(0, ShowBrowser._dmx_default_value_tab_icon) + default_value_item.setData(1, Qt.ItemDataRole.WhatsThisRole, "DMXDEFAULTDATA") + default_value_item.annotated_data = s + if len(s.ui_pages) < 1: s.ui_pages.append(UIPage(s)) @@ -282,7 +289,8 @@ def rename(c: ShowBrowser, scene: Scene | FilterPage, text: str) -> None: for si in items: if isinstance(si, AnnotatedTreeWidgetItem): - if isinstance(si.annotated_data, Scene): + if (isinstance(si.annotated_data, Scene) and + si.data(1, Qt.ItemDataRole.WhatsThisRole) != "DMXDEFAULTDATA"): scene_to_rename = si.annotated_data self._input_dialog = QInputDialog(self.widget) self._input_dialog.setInputMode(QInputDialog.TextInput) @@ -316,11 +324,13 @@ def rename(c: ShowBrowser, scene: Scene | FilterPage, text: str) -> None: def _scene_item_double_clicked(self, item: AnnotatedTreeWidgetItem) -> None: if isinstance(item, AnnotatedTreeWidgetItem): data = item.annotated_data - if isinstance(data, Scene): + if isinstance(data, Scene) and item.data(1, Qt.ItemDataRole.WhatsThisRole) != "DMXDEFAULTDATA": self._show.broadcaster.scene_open_in_editor_requested.emit(data.pages[0]) if self._selected_scene != data: self._selected_scene = data self._refresh_filter_browser() + elif isinstance(data, Scene) and item.data(1, Qt.ItemDataRole.WhatsThisRole) == "DMXDEFAULTDATA": + self._show.broadcaster.default_dmx_value_editor_opening_requested.emit(data) elif isinstance(data, FilterPage): # TODO exchange for correct loading of page self._show.broadcaster.scene_open_in_editor_requested.emit(data) diff --git a/src/view/show_mode/editor/showmanager.py b/src/view/show_mode/editor/showmanager.py index 30732810..4fc6c13f 100644 --- a/src/view/show_mode/editor/showmanager.py +++ b/src/view/show_mode/editor/showmanager.py @@ -3,6 +3,7 @@ Usage (where self is a QWidget and board_configuration is a BoardConfiguration): node_editor = NodeEditor(self, board_configuration) self.addWidget(node_editor) + """ from PySide6.QtGui import QAction from PySide6.QtWidgets import QInputDialog, QSplitter, QTabBar, QTabWidget, QWidget @@ -17,6 +18,7 @@ from .editing_utils import add_scene_to_show from .editor_tab_widgets.bankset_tab import BankSetTabWidget +from .editor_tab_widgets.dmx_default_value_editor import DMXDefaultValueEditorWidget from .show_browser.show_browser import ShowBrowser @@ -24,12 +26,14 @@ class ShowEditorWidget(QSplitter): """Node Editor to create and manage filters.""" def __init__(self, board_configuration: BoardConfiguration, bcaster: Broadcaster, parent: QWidget) -> None: + """Initialize the widget.""" super().__init__(parent) self._broadcaster = bcaster self._board_configuration = board_configuration self._opened_pages = set() self._opened_banksets = set() self._opened_uieditors = set() + self._open_dmx_value_editors = set() # Buttons to add or remove scenes from show self._open_page_tab_widget = QTabWidget(self) @@ -67,6 +71,9 @@ def __init__(self, board_configuration: BoardConfiguration, bcaster: Broadcaster board_configuration.broadcaster.bankset_open_in_editor_requested.connect(self._add_bankset_tab) board_configuration.broadcaster.uipage_opened_in_editor_requested.connect(self._add_uipage_tab) board_configuration.broadcaster.delete_scene.connect(self._remove_tab) + board_configuration.broadcaster.default_dmx_value_editor_opening_requested.connect( + self._open_dmx_default_value_editor + ) def _select_scene_to_be_removed(self) -> None: scene_index, ok_button_pressed = QInputDialog.getInt(self, "Remove a scene", "Scene index (0-index)") @@ -75,13 +82,15 @@ def _select_scene_to_be_removed(self) -> None: @property def toolbar(self) -> list[QAction]: - """toolbar for node_mode""" + """Toolbar for node_mode.""" return self._toolbar def _tab_bar_clicked(self, index: int) -> None: """Handles adding/deleting button action. + Args: index: Index of the clicked tab + """ # Left to right, first "+" button, second "-" button if index == self._open_page_tab_widget.tabBar().count() - 1: @@ -91,9 +100,11 @@ def _add_button_clicked(self) -> None: add_scene_to_show(self, self._board_configuration) def _add_scene_tab(self, page: Scene | FilterPage) -> SceneTabWidget | None: - """Creates a tab for a scene + """Creates a tab for a scene. + Args: page: The scene to be added + """ if isinstance(page, Scene): page = page.pages[0] @@ -163,6 +174,7 @@ def _remove_tab(self, scene_or_index: Scene | int) -> None: Args: scene_or_index: The that is being removed. + """ if isinstance(scene_or_index, Scene): for index in range(self._open_page_tab_widget.count() - 1): @@ -184,8 +196,26 @@ def _remove_tab(self, scene_or_index: Scene | int) -> None: self._opened_banksets.remove(widget.bankset) elif isinstance(widget, SceneUIPageEditorWidget): self._opened_uieditors.remove(widget.ui_page) + elif isinstance(widget, DMXDefaultValueEditorWidget): + self._open_dmx_value_editors.remove(widget.scene) self._open_page_tab_widget.removeTab(scene_or_index) def _send_show_file(self) -> None: - """Send the current board configuration as a xml file to fish""" + """Send the current board configuration as a xml file to fish.""" transmit_to_fish(self._board_configuration) + + def _open_dmx_default_value_editor(self, s: Scene) -> None: + if s in self._open_dmx_value_editors: + for tab_index in range(self._open_page_tab_widget.count()): + tab = self._open_page_tab_widget.widget(tab_index) + if isinstance(tab, DMXDefaultValueEditorWidget) and tab.scene == s: + self._open_page_tab_widget.setCurrentIndex(tab_index) + return + self._open_dmx_value_editors.add(s) + tab = DMXDefaultValueEditorWidget(s, self._open_page_tab_widget) + self._open_page_tab_widget.insertTab( + self._open_page_tab_widget.tabBar().count() - 1, + tab, + s.human_readable_name + "/Defaults", + ) + self._open_page_tab_widget.setCurrentWidget(tab) diff --git a/submodules/docs b/submodules/docs index 9726b4c9..9fe92bdf 160000 --- a/submodules/docs +++ b/submodules/docs @@ -1 +1 @@ -Subproject commit 9726b4c9196c1ebca838622dc8ec8eb4709674bd +Subproject commit 9fe92bdfcd76a45545b70d11c1aef9733d5e8e7a diff --git a/submodules/resources b/submodules/resources index 5ac5f5f8..b3cc0a5c 160000 --- a/submodules/resources +++ b/submodules/resources @@ -1 +1 @@ -Subproject commit 5ac5f5f8d6b9a667fa727da00441b09453c01d2d +Subproject commit b3cc0a5c89abec62939e7d4c44ebb650fb833018