From c5bc30cef6e25bed9a54b4ea2e1bdc741fec23d8 Mon Sep 17 00:00:00 2001 From: Doralitze Date: Sun, 14 Dec 2025 19:18:43 +0100 Subject: [PATCH 01/53] add: towards dimmer brightness mixin --- src/model/filter.py | 1 + src/model/virtual_filters/range_adapters.py | 87 +++++++++++++++++++ src/model/virtual_filters/vfilter_factory.py | 5 +- .../editor/show_browser/fixture_to_filter.py | 1 + 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/src/model/filter.py b/src/model/filter.py index 696f6874..93bd3533 100644 --- a/src/model/filter.py +++ b/src/model/filter.py @@ -82,6 +82,7 @@ class FilterTypeEnumeration(IntFlag): Negative values indicate virtual filters. """ + VFILTER_DIMMER_BRIGHTNESS_MIXIN = -13 VFILTER_SEQUENCER = -12 VFILTER_COLOR_MIXER = -11 VFILTER_IMPORT = -10 diff --git a/src/model/virtual_filters/range_adapters.py b/src/model/virtual_filters/range_adapters.py index e92793ea..f86d696f 100644 --- a/src/model/virtual_filters/range_adapters.py +++ b/src/model/virtual_filters/range_adapters.py @@ -115,6 +115,93 @@ def instantiate_filters(self, filter_list: list[Filter]) -> None: filter_list.append(filter_) +class DimmerGlobalBrightnessMixinVFilter(VirtualFilter): + """V-Filter that allows brightness mixin for 8bit and 16bit values.""" + + def __init__(self, scene: Scene, filter_id: str, pos: tuple[int, int] | None = None) -> None: + """Instantiate a new dimmer brightness mixin vfilter.""" + super().__init__(scene, filter_id, FilterTypeEnumeration.VFILTER_DIMMER_BRIGHTNESS_MIXIN, pos=pos) + self._configuration_supported = True + self._out_data_types["dimmer_out8b"] = DataType.DT_8_BIT + self._out_data_types["dimmer_out16b"] = DataType.DT_16_BIT + self._in_data_types["input"] = DataType.DT_16_BIT # TODO make this configurable + self._in_data_types["mixin"] = DataType.DT_16_BIT + + @override + def resolve_output_port_id(self, virtual_port_id: str) -> str | None: + out_16b = self.filter_configurations.get("has_16bit_output") == "true" + out_8b = self.filter_configurations.get("has_8bit_output") == "true" + if virtual_port_id == "dimmer_out8b": + if out_8b and out_16b: + return f"{self.filter_id}_16b_downsampler:upper" + if out_8b: + return f"{self._filter_id}_8b_range:value" + raise ValueError("Requested 8bit output port but 8bit output is disabled.") + if virtual_port_id == "dimmer_out16b": + if out_16b: + return f"{self._filter_id}_16b_range:value" + raise ValueError("Requested 8bit output port but 16bit output is disabled.") + raise ValueError("Unknown output port") + + @override + def instantiate_filters(self, filter_list: list[Filter]) -> None: + out_16b = self.filter_configurations.get("has_16bit_output") == "true" + out_8b = self.filter_configurations.get("has_8bit_output") == "true" + needs_8bit_input = self.filter_configurations["input_method"] == "8bit" + needs_global_brightness_input = self.channel_links.get("mixin") is None + # TODO place const float 1.0 if mixin port not connected + # TODO add input global brightness dimmer if input port not connected + + # TODO add 8bit to float range adapter if required + # TODO add 16bit to float range adapter if required + + # TODO inst mac filter with offset = 0 + + if out_16b: + range16b_out_filter = Filter( + self.scene, + f"{self._filter_id}_16b_range", + FilterTypeEnumeration.FILTER_ADAPTER_FLOAT_TO_16BIT_RANGE, + pos=self.pos, + filter_configurations={ + "lower_bound_in": "0.0", + "upper_bound_in": "1.0", + "lower_bound_out": "0", + "upper_bound_out": "65565", + "limit_range": "1" + } + ) + # TODO configure input of range16b_out_filter + filter_list.append(range16b_out_filter) + if out_8b: + pass # TODO inst 16bit to 8bit adapter as f"{self.filter_id}_16b_downsampler" + else: + range8b_out_filter = Filter( + self.scene, + f"{self._filter_id}_8b_range", + FilterTypeEnumeration.FILTER_ADAPTER_FLOAT_TO_8BIT_RANGE, + pos=self.pos, + filter_configurations={ + "lower_bound_in": "0.0", + "upper_bound_in": "1.0", + "lower_bound_out": "0", + "upper_bound_out": "255", + "limit_range": "1" + } + ) + # TODO configure input of range8b_out_filter + filter_list.append(range8b_out_filter) + + @override + def deserialize(self) -> None: + if self.filter_configurations.get("has_8bit_output") is None: + self.filter_configurations["has_8bit_output"] = "true" + if self.filter_configurations.get("has_16bit_output") is None: + self.filter_configurations["has_16bit_output"] = "false" + if self.filter_configurations.get("input_method") is None: + self.filter_configurations["input_method"] = "16bit" + + class ColorGlobalBrightnessMixinVFilter(VirtualFilter): """V-Filter that provides the global brightness property.""" diff --git a/src/model/virtual_filters/vfilter_factory.py b/src/model/virtual_filters/vfilter_factory.py index b8494eb0..af7da8eb 100644 --- a/src/model/virtual_filters/vfilter_factory.py +++ b/src/model/virtual_filters/vfilter_factory.py @@ -17,6 +17,7 @@ from model.virtual_filters.pan_tilt_constant import PanTiltConstantFilter from model.virtual_filters.range_adapters import ( ColorGlobalBrightnessMixinVFilter, + DimmerGlobalBrightnessMixinVFilter, EightBitToFloatRange, SixteenBitToFloatRange, ) @@ -51,11 +52,11 @@ def construct_virtual_filter_instance( return None case FilterTypeEnumeration.VFILTER_POSITION_CONSTANT: return PanTiltConstantFilter(scene, filter_id, pos=pos) - + case FilterTypeEnumeration.VFILTER_DIMMER_BRIGHTNESS_MIXIN: + return DimmerGlobalBrightnessMixinVFilter(scene, filter_id, pos=pos) case FilterTypeEnumeration.VFILTER_CUES: return CueFilter(scene, filter_id, pos=pos) case FilterTypeEnumeration.VFILTER_EFFECTSSTACK: - # TODO implement effects stack virtual filter (as described in issue #87) return EffectsStack(scene, filter_id, pos=pos) case FilterTypeEnumeration.VFILTER_AUTOTRACKER: return AutoTrackerFilter(scene, filter_id, pos=pos) diff --git a/src/view/show_mode/editor/show_browser/fixture_to_filter.py b/src/view/show_mode/editor/show_browser/fixture_to_filter.py index e2564b97..a78cdcf9 100644 --- a/src/view/show_mode/editor/show_browser/fixture_to_filter.py +++ b/src/view/show_mode/editor/show_browser/fixture_to_filter.py @@ -165,6 +165,7 @@ def compute_filter_height(channel_count_: int, filter_index: int, filter_chan_co # output_map[c[c_i]] = adapter_name + ":value" #TODO i += 1 elif channel.name == "Dimmer": + # TODO replace with brightness mixin vfilter dimmer_name = _sanitize_name(f"dimmer_{i}_{name}") global_dimmer_filter = Filter(scene=fp.parent_scene, filter_id=dimmer_name, From ce652a8a9e6ebd2b97608618b17380bc090219d6 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Mon, 15 Dec 2025 19:02:48 +0100 Subject: [PATCH 02/53] add: finished implementation of filter inst --- src/model/virtual_filters/range_adapters.py | 147 ++++++++++++++++++-- 1 file changed, 135 insertions(+), 12 deletions(-) diff --git a/src/model/virtual_filters/range_adapters.py b/src/model/virtual_filters/range_adapters.py index f86d696f..1ae788c6 100644 --- a/src/model/virtual_filters/range_adapters.py +++ b/src/model/virtual_filters/range_adapters.py @@ -116,16 +116,28 @@ def instantiate_filters(self, filter_list: list[Filter]) -> None: class DimmerGlobalBrightnessMixinVFilter(VirtualFilter): - """V-Filter that allows brightness mixin for 8bit and 16bit values.""" + """V-Filter that allows brightness mixin for 8bit and 16bit values. + + The filter allows the configuration of an input and a mixin input channel. + Their defaults are the global brightness and a constant 1. + If they're connected their input data typed can both be configured as either 8bit or 16bit. + The optional offset input channel needs to be a float. Reasonable values range from (-1, 1). + + The outputs can be configured as 8bit or 16bit. + """ def __init__(self, scene: Scene, filter_id: str, pos: tuple[int, int] | None = None) -> None: """Instantiate a new dimmer brightness mixin vfilter.""" super().__init__(scene, filter_id, FilterTypeEnumeration.VFILTER_DIMMER_BRIGHTNESS_MIXIN, pos=pos) self._configuration_supported = True + self.filter_configurations.setdefault("has_16bit_output", "true") + self.filter_configurations.setdefault("has_8bit_output", "true") + self.filter_configurations.setdefault("input_method", "8bit") + self.filter_configurations.setdefault("input_method_mixin", "8bit") self._out_data_types["dimmer_out8b"] = DataType.DT_8_BIT self._out_data_types["dimmer_out16b"] = DataType.DT_16_BIT - self._in_data_types["input"] = DataType.DT_16_BIT # TODO make this configurable - self._in_data_types["mixin"] = DataType.DT_16_BIT + self._in_data_types["offset"] = DataType.DT_DOUBLE + self.deserialize() @override def resolve_output_port_id(self, virtual_port_id: str) -> str | None: @@ -148,14 +160,76 @@ def instantiate_filters(self, filter_list: list[Filter]) -> None: out_16b = self.filter_configurations.get("has_16bit_output") == "true" out_8b = self.filter_configurations.get("has_8bit_output") == "true" needs_8bit_input = self.filter_configurations["input_method"] == "8bit" - needs_global_brightness_input = self.channel_links.get("mixin") is None - # TODO place const float 1.0 if mixin port not connected - # TODO add input global brightness dimmer if input port not connected + required_mixin_input_method = 1 if self.filter_configurations["input_method_mixin"] == "8bit" else 2 + needs_global_brightness_input = self.channel_links.get("input") is None + needs_const_mixin = self.channel_links.get("mixin") is None + needs_offset = self.channel_links.get("offset") is None - # TODO add 8bit to float range adapter if required - # TODO add 16bit to float range adapter if required + if needs_const_mixin and (not needs_global_brightness_input and not needs_offset): + const_mixin_filter = Filter( + self.scene, + f"{self.filter_id}_const_mixin", + FilterTypeEnumeration.FILTER_CONSTANT_FLOAT, + pos=self.pos + ) + const_mixin_filter.initial_parameters["value"] = "1.0" + filter_list.append(const_mixin_filter) + mixin_port_name = f"{self.filter_id}_const_mixin:value" + required_mixin_input_method = 0 + else: + mixin_port_name = self.channel_links.get("mixin") - # TODO inst mac filter with offset = 0 + if needs_global_brightness_input: + global_brightness_filter = Filter( + self.scene, + f"{self.filter_id}_global_brightness_input", + FilterTypeEnumeration.FILTER_TYPE_MAIN_BRIGHTNESS, + pos=self.pos + ) + filter_list.append(global_brightness_filter) + input_port_name = f"{self.filter_id}_global_brightness_input:brightness" + needs_8bit_input = False + else: + input_port_name = self.channel_links.get("input") + + if needs_8bit_input: + range_8b_to_float_filter = self._generate_8b_to_float_range(filter_list, input_port_name) + input_port_name = range_8b_to_float_filter.resolve_output_port_id("value") + else: + range_16b_to_float_filter = self._generate_16b_to_float_range(filter_list, input_port_name) + input_port_name = range_16b_to_float_filter.resolve_output_port_id("value") + + if required_mixin_input_method == 1: + range_8b_to_float_filter = self._generate_8b_to_float_range(filter_list, mixin_port_name) + mixin_port_name = range_8b_to_float_filter.resolve_output_port_id("value") + elif required_mixin_input_method == 2: + range_16b_to_float_filter = self._generate_16b_to_float_range(filter_list, mixin_port_name) + mixin_port_name = range_16b_to_float_filter.resolve_output_port_id("value") + + if not (needs_global_brightness_input and needs_const_mixin and needs_offset): + if needs_offset: + offset_filter = Filter( + self.scene, + f"{self.filter_id}_offset", + FilterTypeEnumeration.FILTER_CONSTANT_FLOAT, + pos=self.pos + ) + offset_filter.initial_parameters["value"] = "0.0" + filter_list.append(offset_filter) + offset_input_port = offset_filter.filter_id + ":value" + else: + offset_input_port = self.channel_links.get("offset") + mac_filter = Filter( + self.scene, + f"{self.filter_id}_mac", + FilterTypeEnumeration.FILTER_ARITHMETICS_MAC, + pos=self.pos + ) + mac_filter.channel_links["factor1"] = input_port_name + mac_filter.channel_links["factor2"] = mixin_port_name + mac_filter.channel_links["summand"] = offset_input_port + filter_list.append(mac_filter) + input_port_name = mac_filter.filter_id + ":value" if out_16b: range16b_out_filter = Filter( @@ -171,10 +245,17 @@ def instantiate_filters(self, filter_list: list[Filter]) -> None: "limit_range": "1" } ) - # TODO configure input of range16b_out_filter + range16b_out_filter.channel_links["value_in"] = input_port_name filter_list.append(range16b_out_filter) if out_8b: - pass # TODO inst 16bit to 8bit adapter as f"{self.filter_id}_16b_downsampler" + downsampling_filter = Filter( + self.scene, + f"{self._filter_id}_16b_downsampler", + FilterTypeEnumeration.FILTER_ADAPTER_16BIT_TO_DUAL_8BIT, + pos=self.pos + ) + downsampling_filter.channel_links["value"] = f"{range16b_out_filter.filter_id}:value" + filter_list.append(downsampling_filter) else: range8b_out_filter = Filter( self.scene, @@ -189,9 +270,43 @@ def instantiate_filters(self, filter_list: list[Filter]) -> None: "limit_range": "1" } ) - # TODO configure input of range8b_out_filter + range8b_out_filter.channel_links["value_in"] = input_port_name filter_list.append(range8b_out_filter) + def _generate_16b_to_float_range(self, filter_list: list[Filter], input_port_name: str) -> SixteenBitToFloatRange: + range_16b_to_float_filter = SixteenBitToFloatRange( + self.scene, + f"{self.filter_id}_16bit_to_float", + pos=self.pos + ) + range_16b_to_float_filter.filter_configurations.update({ + "lower_bound_in": "0", + "upper_bound_in": "65565", + "lower_bound_out": "0.0", + "upper_bound_out": "1.0", + "limit_range": "1" + }) + range_16b_to_float_filter.channel_links["value_in"] = input_port_name + range_16b_to_float_filter.instantiate_filters(filter_list) + return range_16b_to_float_filter + + def _generate_8b_to_float_range(self, filter_list: list[Filter], input_port_name: str) -> EightBitToFloatRange: + range_8b_to_float_filter = EightBitToFloatRange( + self.scene, + f"{self.filter_id}_8bit_to_float", + pos=self.pos + ) + range_8b_to_float_filter.filter_configurations.update({ + "lower_bound_in": "0", + "upper_bound_in": "255", + "lower_bound_out": "0.0", + "upper_bound_out": "1.0", + "limit_range": "1" + }) + range_8b_to_float_filter.channel_links["value_in"] = input_port_name + range_8b_to_float_filter.instantiate_filters(filter_list) + return range_8b_to_float_filter + @override def deserialize(self) -> None: if self.filter_configurations.get("has_8bit_output") is None: @@ -200,6 +315,14 @@ def deserialize(self) -> None: self.filter_configurations["has_16bit_output"] = "false" if self.filter_configurations.get("input_method") is None: self.filter_configurations["input_method"] = "16bit" + if self.filter_configurations.get("input_method") == "8bit": + self._in_data_types["input"] = DataType.DT_8_BIT + else: + self._in_data_types["input"] = DataType.DT_16_BIT + if self.filter_configurations.get("input_method_mixin") == "8bit": + self._in_data_types["mixin"] = DataType.DT_8_BIT + else: + self._in_data_types["mixin"] = DataType.DT_16_BIT class ColorGlobalBrightnessMixinVFilter(VirtualFilter): From 6d5f57e78403728c4a63d627cb6ac27a66d6cb39 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Tue, 16 Dec 2025 11:33:11 +0100 Subject: [PATCH 03/53] add: node implementation of dimmer brightness mixin --- .../editor/nodes/filter_node_library.py | 2 ++ .../show_mode/editor/nodes/impl/adapters.py | 33 ++++++++++++++++++- .../editor/nodes/type_to_node_dict.py | 2 ++ .../editor/show_browser/fixture_to_filter.py | 20 +++++++++-- 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/view/show_mode/editor/nodes/filter_node_library.py b/src/view/show_mode/editor/nodes/filter_node_library.py index a9f97cec..1b8e711d 100644 --- a/src/view/show_mode/editor/nodes/filter_node_library.py +++ b/src/view/show_mode/editor/nodes/filter_node_library.py @@ -19,6 +19,7 @@ AdapterFloatToRange, ColorBrightnessMixinNode, CombineTwo8BitToSingle16Bit, + DimmerBrightnessMixinNode, Map8BitTo16Bit, ) from view.show_mode.editor.nodes.impl.arithmetics import ( @@ -159,6 +160,7 @@ def _register_adapters_nodes(self) -> None: self.addNodeType(CombineTwo8BitToSingle16Bit, [("Adapters",)]) self.addNodeType(Map8BitTo16Bit, [("Adapters",)]) self.addNodeType(ColorBrightnessMixinNode, [("Adapters",)]) + self.addNodeType(DimmerBrightnessMixinNode, [("Adapters",)]) def _register_arithmetic_nodes(self) -> None: """Register all the arithmetics nodes.""" diff --git a/src/view/show_mode/editor/nodes/impl/adapters.py b/src/view/show_mode/editor/nodes/impl/adapters.py index 4d693530..958410ee 100644 --- a/src/view/show_mode/editor/nodes/impl/adapters.py +++ b/src/view/show_mode/editor/nodes/impl/adapters.py @@ -1,6 +1,8 @@ """Adapters and converters filter nodes""" +from typing import override + from model import DataType, Scene -from model.filter import Filter, FilterTypeEnumeration +from model.filter import Filter, FilterTypeEnumeration, VirtualFilter from view.show_mode.editor.nodes.base.filternode import FilterNode @@ -316,3 +318,32 @@ def __init__(self, model: Filter | Scene, name: str) -> None: self.filter.in_data_types["brightness"] = DataType.DT_8_BIT self.channel_hints["brightness"] = "[0-255, optional]" self.filter._configuration_supported = False + + +class DimmerBrightnessMixinNode(FilterNode): + nodeName = "Dimmer Brightness Mixin" # noqa: N815 + + def __init__(self, model: Filter | Scene, name: str) -> None: + super().__init__(model=model, filter_type=FilterTypeEnumeration.VFILTER_DIMMER_BRIGHTNESS_MIXIN, name=name, + terminals={"input": {"io": "in"}, "mixin": {"io": "in"}, "offset": {"io": "in"}}) + self.channel_hints["offset"] = "[(-1, 1), optional]" + self.channel_hints["input"] = "[default: global brightness]" + self.channel_hints["mixin"] = "[optional]" + self._update_output_terminals() + + def _update_output_terminals(self) -> None: + for setting, term_name in [("has_8bit_output", "dimmer_out8b"), ("has_16bit_output", "dimmer_out16b")]: + if self.filter.filter_configurations.get(setting) == "true": + if self.outputs().get(term_name) is None: + self.addOutput(term_name) + else: + if self.outputs().get(term_name) is not None: + self.removeTerminal(term_name) + + @override + def update_node_after_settings_changed(self) -> None: + if isinstance(self.filter, VirtualFilter): + self.filter.deserialize() + else: + raise ValueError("Expected filter instance to be a DimmerGlobalBrightnessMixinVFilter, implying a vFilter.") + self._update_output_terminals() diff --git a/src/view/show_mode/editor/nodes/type_to_node_dict.py b/src/view/show_mode/editor/nodes/type_to_node_dict.py index 5114ac21..4a83fc4b 100644 --- a/src/view/show_mode/editor/nodes/type_to_node_dict.py +++ b/src/view/show_mode/editor/nodes/type_to_node_dict.py @@ -18,6 +18,7 @@ AdapterFloatToRange, ColorBrightnessMixinNode, CombineTwo8BitToSingle16Bit, + DimmerBrightnessMixinNode, Map8BitTo16Bit, ) from view.show_mode.editor.nodes.impl.arithmetics import ( @@ -101,6 +102,7 @@ FilterTypeEnumeration.VFILTER_SEQUENCER: SequencerNode.nodeName, FilterTypeEnumeration.VFILTER_COLOR_MIXER: ColorMixerVFilterNode.nodeName, FilterTypeEnumeration.VFILTER_IMPORT: ImportNode.nodeName, + FilterTypeEnumeration.VFILTER_DIMMER_BRIGHTNESS_MIXIN: DimmerBrightnessMixinNode.nodeName, FilterTypeEnumeration.VFILTER_COLOR_GLOBAL_BRIGHTNESS_MIXIN: ColorBrightnessMixinNode.nodeName, FilterTypeEnumeration.VFILTER_POSITION_CONSTANT: PanTiltConstant.nodeName, FilterTypeEnumeration.VFILTER_CUES: CueListNode.nodeName, diff --git a/src/view/show_mode/editor/show_browser/fixture_to_filter.py b/src/view/show_mode/editor/show_browser/fixture_to_filter.py index a78cdcf9..901dafdb 100644 --- a/src/view/show_mode/editor/show_browser/fixture_to_filter.py +++ b/src/view/show_mode/editor/show_browser/fixture_to_filter.py @@ -1,4 +1,4 @@ -"""write fixture to file""" +"""write fixture to file.""" from logging import getLogger from typing import Union @@ -26,6 +26,20 @@ def _sanitize_name(input_: str | dict) -> str: def place_fixture_filters_in_scene(fixture: UsedFixture | tuple[UsedFixture, ColorSupport], filter_page: FilterPage, output_map: dict[Union[ ColorSupport, str], str] | None = None) -> bool: + """Generate fixture control filters from a given fixture. + + Purpose of the output map: A fixture has certain features (such as color segments). These features receive special + filters to drive them. Their input ports are placed in this map in order to enable higher level automated tools + (such as the effects stack system) to find them and connect to them. + + Args: + fixture: The fixture to generate filters from. + filter_page: The page to place the new filters in. + output_map: A map that should receive the mapped fixture features. + + Returns: + True if the operation was successful. + """ # TODO output_map do nothing if isinstance(fixture, tuple): fixture = fixture[0] @@ -116,7 +130,7 @@ def compute_filter_height(channel_count_: int, filter_index: int, filter_chan_co _sanitize_name(channel.name)] = adapter_name + ":value_upper" fp.filters.append(split_filter) # if output_map is not None: - # output_map[c[c_i]] = split_filter.filter_id + ":value" #TODO + # output_map[c[c_i]] = split_filter.filter_id + ":value" #FIXME already_added_filters.append(split_filter) i += 1 elif channel.name.startswith("Red"): @@ -162,7 +176,7 @@ def compute_filter_height(channel_count_: int, filter_index: int, filter_chan_co fp.filters.append(rgb_filter) already_added_filters.append(rgb_filter) # if output_map is not None: - # output_map[c[c_i]] = adapter_name + ":value" #TODO + # output_map[c[c_i]] = adapter_name + ":value" # FIXME i += 1 elif channel.name == "Dimmer": # TODO replace with brightness mixin vfilter From b5db3afb470b46c4b8816eebccdb4289c6dd94fd Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Tue, 16 Dec 2025 13:40:12 +0100 Subject: [PATCH 04/53] fix: documentation in adapter nodes file --- .../show_mode/editor/nodes/impl/adapters.py | 51 ++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/src/view/show_mode/editor/nodes/impl/adapters.py b/src/view/show_mode/editor/nodes/impl/adapters.py index 958410ee..740e43df 100644 --- a/src/view/show_mode/editor/nodes/impl/adapters.py +++ b/src/view/show_mode/editor/nodes/impl/adapters.py @@ -1,4 +1,4 @@ -"""Adapters and converters filter nodes""" +"""Adapters and converters filter nodes.""" from typing import override from model import DataType, Scene @@ -8,9 +8,11 @@ class Adapter16BitTo8BitNode(FilterNode): """Filter to convert a 16 bit value to two 8 bit values.""" + nodeName = "16 bit to 8 bit converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize 16bit to 8bit splitter adapter node.""" super().__init__(model=model, filter_type=int(FilterTypeEnumeration.FILTER_ADAPTER_16BIT_TO_DUAL_8BIT), name=name, terminals={ "value": {"io": "in"}, @@ -27,9 +29,11 @@ class Adapter16BitToBoolNode(FilterNode): """Filter to convert a 16 bit value to a boolean. If input is 0, output is 0, else 1. """ + nodeName = "16 bit to bool converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize 16bit to boolean adapter node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_16BIT_TO_BOOL, name=name, terminals={ "value_in": {"io": "in"}, @@ -41,9 +45,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Adapter16bitToFloat(FilterNode): + """Node for 16bit to float adapter filter.""" + nodeName = "16bit to Float converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize 16bit to float adapter node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_TYPE_ADAPTER_16BIT_TO_FLOAT, name=name, terminals={ "value_in": {"io": "in"}, @@ -56,9 +63,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Adapter8bitToFloat(FilterNode): + """Node for 8bit to float adapter filter.""" + nodeName = "8bit to Float converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize 8bit to float adapter node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_TYPE_ADAPTER_8BIT_TO_FLOAT, name=name, terminals={ "value_in": {"io": "in"}, @@ -72,9 +82,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterColorToRGBNode(FilterNode): """Filter to convert a color value to a rgb value.""" + nodeName = "Color to rgb converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Color to rgb adapter node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_COLOR_TO_RGB, name=name, terminals={ "value": {"io": "in"}, @@ -91,9 +103,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterColorToRGBWNode(FilterNode): """Filter to convert a color value to a rgbw value.""" + nodeName = "Color to rgb-w converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Color to rgb-w adapter node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_COLOR_TO_RGBW, name=name, terminals={ "value": {"io": "in"}, @@ -112,9 +126,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterColorToRGBWANode(FilterNode): """Filter to convert a color value to a RGBWA value.""" + nodeName = "Color to rgb-wa converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Color to rgb-wa adapter node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_COLOR_TO_RGBWA, name=name, terminals={ "value": {"io": "in"}, @@ -135,9 +151,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterFloatToColorNode(FilterNode): """Filter to convert a float/double value to a color value.""" + nodeName = "Float to color converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Float to color combining converter node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_FLOAT_TO_COLOR, name=name, terminals={ "h": {"io": "in"}, @@ -155,9 +173,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterColorToFloatsNode(FilterNode): """Filter that splits the HSI values into three individual float channels.""" + nodeName = "Color to Float converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Color to Float converter node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_COLOR_TO_FLOAT, name=name, terminals={ "input": {"io": "in"}, @@ -173,13 +193,14 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterFloatToRange(FilterNode): - """Filter maps a range of floats to another range of specific type (template)""" + """Filter maps a range of floats to another range of specific type (template).""" nodeName = "float range to float range" # noqa: N815 def __init__(self, model: Filter | Scene, name: str, filter_type: FilterTypeEnumeration = FilterTypeEnumeration.FILTER_ADAPTER_FLOAT_TO_FLOAT_RANGE) -> None: + """Initialize float range to float range template node.""" super().__init__(model, int(filter_type), name, terminals={ "value_in": {"io": "in"}, "value": {"io": "out"}, @@ -215,10 +236,12 @@ def __init__(self, model: Filter | Scene, name: str, class AdapterFloatTo8BitRange(AdapterFloatToRange): - """Filter maps a range of float to a range of 8bit""" + """Filter maps a range of float to a range of 8bit.""" + nodeName = "Float range to 8bit" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize float range to 8bit range node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_FLOAT_TO_8BIT_RANGE, name=name) try: self.filter.initial_parameters["upper_bound_out"] = model.initial_parameters["upper_bound_out"] @@ -228,10 +251,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterFloatTo16BitRange(AdapterFloatToRange): - """Filter maps a range of float to a range of 16bit""" + """Filter maps a range of float to a range of 16bit.""" + nodeName = "Float range to 16bit" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize float range to 16bit range node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_FLOAT_TO_16BIT_RANGE, name=name) try: self.filter.initial_parameters["upper_bound_out"] = model.initial_parameters["upper_bound_out"] @@ -241,10 +266,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Adapter16BitToRangeFloat(AdapterFloatToRange): - """Filter maps a range of 16bit to a range of float""" + """Filter maps a range of 16bit to a range of float.""" + nodeName = "16bit range to Float" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize 16bit to float range node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.VFILTER_FILTER_ADAPTER_16BIT_TO_FLOAT_RANGE, name=name) try: @@ -256,10 +283,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Adapter8BitToRangeFloat(AdapterFloatToRange): - """Filter maps a range of 8bit to a range of floats""" + """Filter maps a range of 8bit to a range of floats.""" + nodeName = "8bit range to Float" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize 8bit to float range node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.VFILTER_FILTER_ADAPTER_8BIT_TO_FLOAT_RANGE, name=name) try: @@ -272,9 +301,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class CombineTwo8BitToSingle16Bit(FilterNode): """Filter that combines two 8bit values to a 16bit one.""" + nodeName = "Dual 8bit to single 16bit" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Dual 8bit to single 16bit combiner node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_DUAL_BYTE_TO_16BIT, name=name, terminals={ "lower": {"io": "in"}, @@ -289,9 +320,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Map8BitTo16Bit(FilterNode): """Filter that maps an 8-Bit value to a 16-Bit one.""" + nodeName = "Map 8bit to 16bit" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Map 8bit to 16bit map node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_8BIT_TO_16BIT, name=name, terminals={ "value_in": {"io": "in"}, @@ -303,9 +336,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class ColorBrightnessMixinNode(FilterNode): + """Node for color brightness mixin v-filter.""" + nodeName = "Color Brightness Mixin" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Color Brightness Mixin node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.VFILTER_COLOR_GLOBAL_BRIGHTNESS_MIXIN, name=name, terminals={ "out": {"io": "out"}, @@ -321,9 +357,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class DimmerBrightnessMixinNode(FilterNode): + """Node for dimmer brightness mixin v-filter.""" + nodeName = "Dimmer Brightness Mixin" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Dimmer Brightness Mixin node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.VFILTER_DIMMER_BRIGHTNESS_MIXIN, name=name, terminals={"input": {"io": "in"}, "mixin": {"io": "in"}, "offset": {"io": "in"}}) self.channel_hints["offset"] = "[(-1, 1), optional]" From 8e1bc1700a60047fb53b0fb605c95c5ebe6e5883 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Tue, 16 Dec 2025 14:21:55 +0100 Subject: [PATCH 05/53] add: config widget --- .../show_mode/editor/filter_settings_item.py | 4 +- .../dimmer_brightness_mixin_config_widget.py | 88 +++++++++++++++++++ .../show_mode/editor/nodes/impl/adapters.py | 1 + .../editor/show_browser/fixture_to_filter.py | 1 + 4 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 src/view/show_mode/editor/node_editor_widgets/dimmer_brightness_mixin_config_widget.py diff --git a/src/view/show_mode/editor/filter_settings_item.py b/src/view/show_mode/editor/filter_settings_item.py index 916bb305..e4d7f02f 100644 --- a/src/view/show_mode/editor/filter_settings_item.py +++ b/src/view/show_mode/editor/filter_settings_item.py @@ -29,6 +29,7 @@ from .node_editor_widgets.autotracker_settings import AutotrackerSettingsWidget from .node_editor_widgets.color_mixing_setup_widget import ColorMixingSetupWidget from .node_editor_widgets.column_select import ColumnSelect +from .node_editor_widgets.dimmer_brightness_mixin_config_widget import DimmerBrightnessMixinConfigWidget from .node_editor_widgets.import_vfilter_settings_widget import ImportVFilterSettingsWidget from .node_editor_widgets.lua_widget import LuaScriptConfigWidget from .node_editor_widgets.sequencer_editor.widget import SequencerEditor @@ -133,7 +134,8 @@ def check_if_filter_has_special_widget(filter_: Filter) -> NodeEditorFilterConfi return ColorMixingSetupWidget() if filter_.filter_type == FilterTypeEnumeration.VFILTER_SEQUENCER: return SequencerEditor(f=filter_) - + if filter_.filter_type == FilterTypeEnumeration.VFILTER_DIMMER_BRIGHTNESS_MIXIN: + return DimmerBrightnessMixinConfigWidget() return None diff --git a/src/view/show_mode/editor/node_editor_widgets/dimmer_brightness_mixin_config_widget.py b/src/view/show_mode/editor/node_editor_widgets/dimmer_brightness_mixin_config_widget.py new file mode 100644 index 00000000..00ca8228 --- /dev/null +++ b/src/view/show_mode/editor/node_editor_widgets/dimmer_brightness_mixin_config_widget.py @@ -0,0 +1,88 @@ +from typing import override + +from PySide6.QtWidgets import QWidget, QFormLayout, QCheckBox, QButtonGroup, QRadioButton, QHBoxLayout, QLabel + +from view.show_mode.editor.node_editor_widgets import NodeEditorFilterConfigWidget + + +class DimmerBrightnessMixinConfigWidget(NodeEditorFilterConfigWidget): + """Configuration widget for dimmer brightness mixin node.""" + + def __init__(self, parent: QWidget | None = None) -> None: + """Load the filter and prepare a widget.""" + super().__init__() + self._widget = QWidget(parent=parent) + layout = QFormLayout() + self._cb_has_16bit = QCheckBox(self._widget) + self._cb_has_16bit.setText("Enable 16bit output") + self._cb_has_16bit.checkStateChanged.connect(self._update_warning_visibility) + layout.addWidget(self._cb_has_16bit) + self._cb_has_8bit = QCheckBox(self._widget) + self._cb_has_8bit.setText("Enable 8bit output") + self._cb_has_8bit.checkStateChanged.connect(self._update_warning_visibility) + layout.addWidget(self._cb_has_8bit) + self._output_warning_label = QLabel("At least one output should be configured.", self._widget) + self._output_warning_label.setVisible(False) + self._output_warning_label.setStyleSheet("color: red;") + layout.addWidget(self._output_warning_label) + + self._input_method_rb_group = QButtonGroup(self._widget) + self._input_8bit = QRadioButton("8bit", self._widget) + self._input_method_rb_group.addButton(self._input_8bit) + self._input_16bit = QRadioButton("16bit", self._widget) + self._input_method_rb_group.addButton(self._input_16bit) + button_layout = QHBoxLayout() + button_layout.addWidget(self._input_8bit) + button_layout.addWidget(self._input_16bit) + layout.addRow("Input Port Data Type:", button_layout) + + self._mixin_method_rb_group = QButtonGroup(self._widget) + self._mixin_8bit = QRadioButton("8bit", self._widget) + self._mixin_method_rb_group.addButton(self._mixin_8bit) + self._mixin_16bit = QRadioButton("16bit", self._widget) + self._mixin_method_rb_group.addButton(self._mixin_16bit) + button_layout = QHBoxLayout() + button_layout.addWidget(self._mixin_8bit) + button_layout.addWidget(self._mixin_16bit) + layout.addRow("Mixin Port Data Type:", button_layout) + + self._widget.setLayout(layout) + + def _update_warning_visibility(self): + self._output_warning_label.setVisible(not self._cb_has_8bit.isChecked() and not self._cb_has_16bit.isChecked()) + + @override + def _get_configuration(self) -> dict[str, str]: + return { + "has_16bit_output": "true" if self._cb_has_16bit.isChecked() else "false", + "has_8bit_output": "true" if self._cb_has_8bit.isChecked() else "false", + "input_method": "8bit" if self._input_8bit.isChecked() else "16bit", + "input_method_mixin": "8bit" if self._mixin_8bit.isChecked() else "16bit", + } + + @override + def _load_configuration(self, conf: dict[str, str]) -> None: + self._cb_has_16bit.setChecked(conf.get("has_16bit_output", "false") == "true") + self._cb_has_8bit.setChecked(conf.get("has_8bit_output", "false") == "true") + self._input_8bit.setChecked(conf.get("input_method", "8bit") == "8bit") + self._input_16bit.setChecked(conf.get("input_method", "8bit") == "16bit") + self._mixin_8bit.setChecked(conf.get("input_method_mixin", "8bit") == "8bit") + self._mixin_16bit.setChecked(conf.get("input_method_mixin", "8bit") == "16bit") + + @override + def get_widget(self) -> QWidget: + return self._widget + + @override + def _load_parameters(self, parameters: dict[str, str]) -> dict: + # Nothing to do here + pass + + @override + def _get_parameters(self) -> dict[str, str]: + return {} + + @override + def parent_opened(self) -> None: + # Nothing to do here + pass \ No newline at end of file diff --git a/src/view/show_mode/editor/nodes/impl/adapters.py b/src/view/show_mode/editor/nodes/impl/adapters.py index 740e43df..e7ec8fab 100644 --- a/src/view/show_mode/editor/nodes/impl/adapters.py +++ b/src/view/show_mode/editor/nodes/impl/adapters.py @@ -27,6 +27,7 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Adapter16BitToBoolNode(FilterNode): """Filter to convert a 16 bit value to a boolean. + If input is 0, output is 0, else 1. """ diff --git a/src/view/show_mode/editor/show_browser/fixture_to_filter.py b/src/view/show_mode/editor/show_browser/fixture_to_filter.py index 901dafdb..524fffb6 100644 --- a/src/view/show_mode/editor/show_browser/fixture_to_filter.py +++ b/src/view/show_mode/editor/show_browser/fixture_to_filter.py @@ -39,6 +39,7 @@ def place_fixture_filters_in_scene(fixture: UsedFixture | tuple[UsedFixture, Col Returns: True if the operation was successful. + """ # TODO output_map do nothing if isinstance(fixture, tuple): From f79752ba34fb2894cad0386d6d6660c0cb05f0d8 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Tue, 16 Dec 2025 14:26:27 +0100 Subject: [PATCH 06/53] fix: missing typo annotation --- .../dimmer_brightness_mixin_config_widget.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/view/show_mode/editor/node_editor_widgets/dimmer_brightness_mixin_config_widget.py b/src/view/show_mode/editor/node_editor_widgets/dimmer_brightness_mixin_config_widget.py index 00ca8228..0d100326 100644 --- a/src/view/show_mode/editor/node_editor_widgets/dimmer_brightness_mixin_config_widget.py +++ b/src/view/show_mode/editor/node_editor_widgets/dimmer_brightness_mixin_config_widget.py @@ -1,6 +1,6 @@ from typing import override -from PySide6.QtWidgets import QWidget, QFormLayout, QCheckBox, QButtonGroup, QRadioButton, QHBoxLayout, QLabel +from PySide6.QtWidgets import QButtonGroup, QCheckBox, QFormLayout, QHBoxLayout, QLabel, QRadioButton, QWidget from view.show_mode.editor.node_editor_widgets import NodeEditorFilterConfigWidget @@ -48,7 +48,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self._widget.setLayout(layout) - def _update_warning_visibility(self): + def _update_warning_visibility(self) -> None: self._output_warning_label.setVisible(not self._cb_has_8bit.isChecked() and not self._cb_has_16bit.isChecked()) @override @@ -85,4 +85,4 @@ def _get_parameters(self) -> dict[str, str]: @override def parent_opened(self) -> None: # Nothing to do here - pass \ No newline at end of file + pass From 3907036b4fbb4069323688dbe4f59e878a8f3a66 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Tue, 16 Dec 2025 18:55:28 +0100 Subject: [PATCH 07/53] chg: global dimmer in fixture inst to vfilter --- .../editor/show_browser/fixture_to_filter.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/view/show_mode/editor/show_browser/fixture_to_filter.py b/src/view/show_mode/editor/show_browser/fixture_to_filter.py index 524fffb6..5750ff24 100644 --- a/src/view/show_mode/editor/show_browser/fixture_to_filter.py +++ b/src/view/show_mode/editor/show_browser/fixture_to_filter.py @@ -6,6 +6,7 @@ from model.filter import FilterTypeEnumeration from model.ofl.fixture import ColorSupport, UsedFixture from model.scene import FilterPage +from model.virtual_filters.range_adapters import DimmerGlobalBrightnessMixinVFilter from model.virtual_filters.vfilter_factory import construct_virtual_filter_instance logger = getLogger(__name__) @@ -179,14 +180,15 @@ def compute_filter_height(channel_count_: int, filter_index: int, filter_chan_co # if output_map is not None: # output_map[c[c_i]] = adapter_name + ":value" # FIXME i += 1 - elif channel.name == "Dimmer": - # TODO replace with brightness mixin vfilter + elif channel.name == "Dimmer" or channel.name == "Intensity": dimmer_name = _sanitize_name(f"dimmer_{i}_{name}") - global_dimmer_filter = Filter(scene=fp.parent_scene, + global_dimmer_filter = DimmerGlobalBrightnessMixinVFilter(scene=fp.parent_scene, filter_id=dimmer_name, - filter_type=49, - pos=(x - 2 * _additional_filter_depth, - compute_filter_height(channel_count, i))) + pos=(int(x - 2 * _additional_filter_depth), + int(compute_filter_height(channel_count, i)))) + global_dimmer_filter.filter_configurations["has_16bit_output"] = "true" + global_dimmer_filter.filter_configurations["has_8bit_output"] = "false" + global_dimmer_filter.deserialize() added_depth = max(added_depth, 2 * _additional_filter_depth) global_dimmer_found = True fp.filters.append(global_dimmer_filter) @@ -194,6 +196,9 @@ def compute_filter_height(channel_count_: int, filter_index: int, filter_chan_co already_added_filters.append(global_dimmer_filter) dimmer_name = global_dimmer_filter.filter_id x += 10 + + # TODO if we only have a single dimmer port we can output 8bit directly, reducing overhead; this + # requires having a dimmer feature in the fixture definition adapter_name = _sanitize_name(f"dimmer2byte_{i}_{name}") dimmer_to_byte_filter = Filter(scene=fp.parent_scene, filter_id=adapter_name, @@ -203,7 +208,7 @@ def compute_filter_height(channel_count_: int, filter_index: int, filter_chan_co fp.parent_scene.append_filter(dimmer_to_byte_filter) already_added_filters.append(dimmer_to_byte_filter) adapter_name = dimmer_to_byte_filter.filter_id - dimmer_to_byte_filter.channel_links["value"] = dimmer_name + ":brightness" + dimmer_to_byte_filter.channel_links["value"] = dimmer_name + ":dimmer_out16b" universe_filter.channel_links[_sanitize_name(channel.name)] = adapter_name + ":value_upper" fp.filters.append(dimmer_to_byte_filter) i += 1 From 7650ba3de957c15cce4ea3b9ef252b5d8c806dee Mon Sep 17 00:00:00 2001 From: Doralitze Date: Tue, 16 Dec 2025 20:08:07 +0100 Subject: [PATCH 08/53] add: support for fine dimmer channel --- .../dimmer_brightness_mixin_config_widget.py | 2 + .../editor/show_browser/fixture_to_filter.py | 49 ++++++++++++------- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/src/view/show_mode/editor/node_editor_widgets/dimmer_brightness_mixin_config_widget.py b/src/view/show_mode/editor/node_editor_widgets/dimmer_brightness_mixin_config_widget.py index 0d100326..8ef61aaf 100644 --- a/src/view/show_mode/editor/node_editor_widgets/dimmer_brightness_mixin_config_widget.py +++ b/src/view/show_mode/editor/node_editor_widgets/dimmer_brightness_mixin_config_widget.py @@ -1,3 +1,5 @@ +"""Module contains dimmer brightness mixin node config widget.""" + from typing import override from PySide6.QtWidgets import QButtonGroup, QCheckBox, QFormLayout, QHBoxLayout, QLabel, QRadioButton, QWidget diff --git a/src/view/show_mode/editor/show_browser/fixture_to_filter.py b/src/view/show_mode/editor/show_browser/fixture_to_filter.py index 5750ff24..5a2601b8 100644 --- a/src/view/show_mode/editor/show_browser/fixture_to_filter.py +++ b/src/view/show_mode/editor/show_browser/fixture_to_filter.py @@ -180,14 +180,21 @@ def compute_filter_height(channel_count_: int, filter_index: int, filter_chan_co # if output_map is not None: # output_map[c[c_i]] = adapter_name + ":value" # FIXME i += 1 - elif channel.name == "Dimmer" or channel.name == "Intensity": + elif channel.name.lower() == "dimmer" or channel.name.lower() == "intensity": dimmer_name = _sanitize_name(f"dimmer_{i}_{name}") + double_channel_dimmer_required = any([ + ("dimmer" in fc.name.lower() or "intensity" in fc.name.lower()) and "fine" in fc.name.lower() + for fc in fixture.fixture_channels]) global_dimmer_filter = DimmerGlobalBrightnessMixinVFilter(scene=fp.parent_scene, filter_id=dimmer_name, pos=(int(x - 2 * _additional_filter_depth), int(compute_filter_height(channel_count, i)))) - global_dimmer_filter.filter_configurations["has_16bit_output"] = "true" - global_dimmer_filter.filter_configurations["has_8bit_output"] = "false" + if double_channel_dimmer_required: + global_dimmer_filter.filter_configurations["has_16bit_output"] = "true" + global_dimmer_filter.filter_configurations["has_8bit_output"] = "false" + else: + global_dimmer_filter.filter_configurations["has_16bit_output"] = "false" + global_dimmer_filter.filter_configurations["has_8bit_output"] = "true" global_dimmer_filter.deserialize() added_depth = max(added_depth, 2 * _additional_filter_depth) global_dimmer_found = True @@ -197,20 +204,28 @@ def compute_filter_height(channel_count_: int, filter_index: int, filter_chan_co dimmer_name = global_dimmer_filter.filter_id x += 10 - # TODO if we only have a single dimmer port we can output 8bit directly, reducing overhead; this - # requires having a dimmer feature in the fixture definition - adapter_name = _sanitize_name(f"dimmer2byte_{i}_{name}") - dimmer_to_byte_filter = Filter(scene=fp.parent_scene, - filter_id=adapter_name, - filter_type=8, - pos=(x - _additional_filter_depth, - compute_filter_height(channel_count, i))) - fp.parent_scene.append_filter(dimmer_to_byte_filter) - already_added_filters.append(dimmer_to_byte_filter) - adapter_name = dimmer_to_byte_filter.filter_id - dimmer_to_byte_filter.channel_links["value"] = dimmer_name + ":dimmer_out16b" - universe_filter.channel_links[_sanitize_name(channel.name)] = adapter_name + ":value_upper" - fp.filters.append(dimmer_to_byte_filter) + if double_channel_dimmer_required: + adapter_name = _sanitize_name(f"dimmer2byte_{i}_{name}") + dimmer_to_byte_filter = Filter(scene=fp.parent_scene, + filter_id=adapter_name, + filter_type=FilterTypeEnumeration.FILTER_ADAPTER_16BIT_TO_DUAL_8BIT, + pos=(x - _additional_filter_depth, + compute_filter_height(channel_count, i))) + fp.parent_scene.append_filter(dimmer_to_byte_filter) + already_added_filters.append(dimmer_to_byte_filter) + adapter_name = dimmer_to_byte_filter.filter_id + dimmer_to_byte_filter.channel_links["value"] = dimmer_name + ":dimmer_out16b" + fp.filters.append(dimmer_to_byte_filter) + + if double_channel_dimmer_required: + universe_filter.channel_links[_sanitize_name(channel.name)] = adapter_name + ":value_upper" + for fc in fixture.fixture_channels: + if (("dimmer" in fc.name.lower() or "intensity" in fc.name.lower()) + and "fine" in fc.name.lower()): + universe_filter.channel_links[_sanitize_name(fc.name)] = adapter_name + ":value_lower" + else: + universe_filter.channel_links[_sanitize_name(channel.name)] = dimmer_name + ":dimmer_out8b" + i += 1 except IndexError: continue From 756635b2345ee6b906e92cfb4c04eaf8ee02c2ec Mon Sep 17 00:00:00 2001 From: Doralitze Date: Tue, 16 Dec 2025 20:10:39 +0100 Subject: [PATCH 09/53] fix: redundant array copy --- src/view/show_mode/editor/show_browser/fixture_to_filter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/view/show_mode/editor/show_browser/fixture_to_filter.py b/src/view/show_mode/editor/show_browser/fixture_to_filter.py index 5a2601b8..eb5c042e 100644 --- a/src/view/show_mode/editor/show_browser/fixture_to_filter.py +++ b/src/view/show_mode/editor/show_browser/fixture_to_filter.py @@ -182,9 +182,9 @@ def compute_filter_height(channel_count_: int, filter_index: int, filter_chan_co i += 1 elif channel.name.lower() == "dimmer" or channel.name.lower() == "intensity": dimmer_name = _sanitize_name(f"dimmer_{i}_{name}") - double_channel_dimmer_required = any([ + double_channel_dimmer_required = any( ("dimmer" in fc.name.lower() or "intensity" in fc.name.lower()) and "fine" in fc.name.lower() - for fc in fixture.fixture_channels]) + for fc in fixture.fixture_channels) global_dimmer_filter = DimmerGlobalBrightnessMixinVFilter(scene=fp.parent_scene, filter_id=dimmer_name, pos=(int(x - 2 * _additional_filter_depth), From 113e9dff8c5b50ce7a745909b26b2a7444fe91e1 Mon Sep 17 00:00:00 2001 From: Doralitze Date: Thu, 18 Dec 2025 18:18:45 +0100 Subject: [PATCH 10/53] add: towards unit test --- test/unittests/.gitignore | 1 + test/unittests/__init__.py | 0 .../test_dimmer_brightness_mixin_vfilter.py | 81 +++++++++++++++++++ test/unittests/utilities.py | 51 ++++++++++++ 4 files changed, 133 insertions(+) create mode 100644 test/unittests/.gitignore create mode 100644 test/unittests/__init__.py create mode 100644 test/unittests/test_dimmer_brightness_mixin_vfilter.py create mode 100644 test/unittests/utilities.py diff --git a/test/unittests/.gitignore b/test/unittests/.gitignore new file mode 100644 index 00000000..f85c6b18 --- /dev/null +++ b/test/unittests/.gitignore @@ -0,0 +1 @@ +config.py \ No newline at end of file diff --git a/test/unittests/__init__.py b/test/unittests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/unittests/test_dimmer_brightness_mixin_vfilter.py b/test/unittests/test_dimmer_brightness_mixin_vfilter.py new file mode 100644 index 00000000..197b7762 --- /dev/null +++ b/test/unittests/test_dimmer_brightness_mixin_vfilter.py @@ -0,0 +1,81 @@ +import unittest + +from model import BoardConfiguration, Scene, Filter +from model.filter import FilterTypeEnumeration +from model.virtual_filters.range_adapters import DimmerGlobalBrightnessMixinVFilter +from test.unittests.utilities import execute_board_configuratiuon + + +class DimmerBrightnessMixinTest(unittest.TestCase): + def _prepare_show_config(self) -> tuple[BoardConfiguration, list[tuple[str, float]]]: + show = BoardConfiguration() + scene = Scene(0, "Test Scene for dimmer brightness mixin", show) + show._add_scene(scene) + output_list = [] + row: int = 0 + + def create_input(method: str, mf: DimmerGlobalBrightnessMixinVFilter, is_mixin: bool) -> str: + mf.filter_configurations["input_method_mixin" if is_mixin else "input_method"] = method.replace("-", "") + if "-" not in method: + input_filter = Filter( + scene, + f"input_filter_{row}_{method}_{"mixin" if is_mixin else "input"}", + FilterTypeEnumeration.FILTER_CONSTANT_8BIT if method == "8bit" else FilterTypeEnumeration.FILTER_CONSTANT_16_BIT, + pos=(-10, row * 15 + (5 if is_mixin else -5)) + ) + input_filter.initial_parameters["value"] = str(255 / 2) if method == "8bit" else str(65565 / 2) + scene.append_filter(input_filter) + mf.channel_links["mixin" if is_mixin else "input"] = f"{input_filter.filter_id}:value" + + def create_output(is_8b: bool): + output_filter = Filter( + scene, f"output_8b_{row}", FilterTypeEnumeration.FILTER_REMOTE_DEBUG_8BIT if is_8b else FilterTypeEnumeration.FILTER_REMOTE_DEBUG_16BIT, + pos=(5, row * 15 + (-5 if is_8b else 5)) + ) + scene.append_filter(output_filter) + output_filter.channel_links["value"] = mixin_filter.filter_id + ":dimmer_out8b" + output_list.append( + (output_filter.filter_id, (255 if is_8b else 65565) * calculate_dimmer_val(input_method, mixin_method, has_offset))) + + def calculate_dimmer_val(in_m: str, mixin_m: str, has_offset: bool) -> float: + in_stream = 1.0 if "-" in in_m else 0.5 + mixin_stream = 1.0 + offset_stream = 0.25 if has_offset else 0.0 + return max(0.0, min(1.0, in_stream * mixin_stream + offset_stream)) + + for input_method in ["-8bit", "-16bit", "8bit", "16bit"]: + for mixin_method in ["-8bit", "-16bit", "8bit", "16bit"]: + for output_8b_enabled in [True, False]: + for output_16b_enabled in [True, False]: + for has_offset in [True, False]: + mixin_filter = DimmerGlobalBrightnessMixinVFilter(scene, f"mixin_filter_r{row}", (0, row * 15)) + create_input(input_method, mixin_filter, False) + create_input(mixin_method, mixin_filter, True) + + if has_offset: + offset_filter = Filter( + scene, f"offset_filter_{row}", FilterTypeEnumeration.FILTER_CONSTANT_FLOAT, + pos=(-5, row * 15) + ) + offset_filter.initial_parameters["value"] = "0.25" + scene.append_filter(offset_filter) + mixin_filter.channel_links["offset"] = offset_filter.filter_id + ":value" + + if output_8b_enabled: + create_output(True) + if output_16b_enabled: + create_output(False) + row += 1 + return show, output_list + + def test_inst(self): + show, expected_output_list = self._prepare_show_config() + recorded_output_list = [] + expected_output_dict = {} + for key, expected_val in expected_output_list: + expected_output_dict[key] = expected_val + self.assertTrue(execute_board_configuratiuon(show, recorded_gui_updates=recorded_output_list)) + for scene_id, filter_id, value_str in recorded_output_list: + self.assertEqual(scene_id, 0, "Expected scene ID to be 0.") + self.assertTrue(expected_output_dict[filter_id] - 5 < int(value_str) < expected_output_dict[filter_id] + 5, + f"Expected configuration of {filter_id} to be {expected_output_dict[filter_id]} but got {value_str} instead.") diff --git a/test/unittests/utilities.py b/test/unittests/utilities.py new file mode 100644 index 00000000..388b7ace --- /dev/null +++ b/test/unittests/utilities.py @@ -0,0 +1,51 @@ +"""Unit test utils. + +This module requires a config.py to be in the same package. This config file needs to contain a variable called +FISH_EXEC_PATH pointing to a local fish binary. +""" +import subprocess + +from model import BoardConfiguration + +from .config import FISH_EXEC_PATH + +def _stall_until_stdout_reached_target(process: subprocess.Popen, target: str): + while True: + output = process.stdout.read(1024) + if not output and process.poll() is not None: + return False + if target in output.decode(): + return True + + +def execute_board_configuratiuon(bc: BoardConfiguration, cycles: int = 25, recorded_gui_updates: list[tuple[int, str, str]] | None = None, main_brightness: int = 65565) -> bool: + """Execute a board configuration. + + This method starts a fish instance, connects to it, uploads a board configuration and enables filter execution. + If after the specified amount of iterations, no error occurred, the method stops, returning true. Otherwise false. + + Args: + bc: The board configuration to apply + cycles: The amount of cycles to wait + recorded_gui_updates: If A list is provided, any GUI updates received during execution are stored in there. + main_brightness: The Main brightness value used for the test. + + Returns: + True if no error occurred during execution. Otherwise, false. + + """ + process = subprocess.Popen( + [FISH_EXEC_PATH], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True + ) + _stall_until_stdout_reached_target(process, "[debug] Entering ev defloop") + # TODO create network manager instance + # TODO call send to fish method + # TODO wait for response and abort if error received + # TODO set main_brightness + # TODO wait for cycles * 40 ms and record gui messages + # TODO disconnect client and close QT Application + # TODO stop fish by sending enter to stdin + # TODO wait for fish to stop and return result From 61e0e8251368dc5b02ff9cf95ba6e5e7a7c23ea1 Mon Sep 17 00:00:00 2001 From: Doralitze Date: Thu, 18 Dec 2025 22:57:31 +0100 Subject: [PATCH 11/53] add: execute_board_configuration method --- src/main.py | 4 +- .../test_dimmer_brightness_mixin_vfilter.py | 21 +++-- test/unittests/utilities.py | 90 ++++++++++++++++--- 3 files changed, 94 insertions(+), 21 deletions(-) diff --git a/src/main.py b/src/main.py index 2dc7556d..11bf6fbe 100644 --- a/src/main.py +++ b/src/main.py @@ -2,12 +2,13 @@ if __name__ == "__main__": import os + import sys from PySide6.QtCore import Qt from PySide6.QtWidgets import QApplication, QSplashScreen QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseDesktopOpenGL) - app = QApplication([]) + app = QApplication(sys.argv) from PySide6.QtGui import QPixmap from utility import resource_path @@ -44,7 +45,6 @@ import logging.config import logging.handlers import pathlib - import sys from PySide6.QtCore import QEventLoop diff --git a/test/unittests/test_dimmer_brightness_mixin_vfilter.py b/test/unittests/test_dimmer_brightness_mixin_vfilter.py index 197b7762..13931ca6 100644 --- a/test/unittests/test_dimmer_brightness_mixin_vfilter.py +++ b/test/unittests/test_dimmer_brightness_mixin_vfilter.py @@ -1,9 +1,14 @@ +import logging import unittest +from logging import getLogger, basicConfig from model import BoardConfiguration, Scene, Filter from model.filter import FilterTypeEnumeration from model.virtual_filters.range_adapters import DimmerGlobalBrightnessMixinVFilter -from test.unittests.utilities import execute_board_configuratiuon +from test.unittests.utilities import execute_board_configuration + + +logger = getLogger(__name__) class DimmerBrightnessMixinTest(unittest.TestCase): @@ -29,13 +34,15 @@ def create_input(method: str, mf: DimmerGlobalBrightnessMixinVFilter, is_mixin: def create_output(is_8b: bool): output_filter = Filter( - scene, f"output_8b_{row}", FilterTypeEnumeration.FILTER_REMOTE_DEBUG_8BIT if is_8b else FilterTypeEnumeration.FILTER_REMOTE_DEBUG_16BIT, + scene, f"output_{"8b" if is_8b else "16b"}_{row}", FilterTypeEnumeration.FILTER_REMOTE_DEBUG_8BIT if is_8b else FilterTypeEnumeration.FILTER_REMOTE_DEBUG_16BIT, pos=(5, row * 15 + (-5 if is_8b else 5)) ) scene.append_filter(output_filter) - output_filter.channel_links["value"] = mixin_filter.filter_id + ":dimmer_out8b" + output_filter.channel_links["value"] = mixin_filter.filter_id + (":dimmer_out8b" if is_8b else ":dimmer_out16b") + expected_output = (255 if is_8b else 65565) * calculate_dimmer_val(input_method, mixin_method, has_offset) + logger.info("Registering output %s => %s.", output_filter.filter_id, expected_output) output_list.append( - (output_filter.filter_id, (255 if is_8b else 65565) * calculate_dimmer_val(input_method, mixin_method, has_offset))) + (output_filter.filter_id, expected_output)) def calculate_dimmer_val(in_m: str, mixin_m: str, has_offset: bool) -> float: in_stream = 1.0 if "-" in in_m else 0.5 @@ -48,6 +55,7 @@ def calculate_dimmer_val(in_m: str, mixin_m: str, has_offset: bool) -> float: for output_8b_enabled in [True, False]: for output_16b_enabled in [True, False]: for has_offset in [True, False]: + logger.info("Creating Config Row: %s, Input: %s, Mixin: %s, Out bit: %s, Out 16bit: %s, has Offset: %s", row, input_method, mixin_method, output_8b_enabled, output_16b_enabled, has_offset) mixin_filter = DimmerGlobalBrightnessMixinVFilter(scene, f"mixin_filter_r{row}", (0, row * 15)) create_input(input_method, mixin_filter, False) create_input(mixin_method, mixin_filter, True) @@ -69,13 +77,14 @@ def calculate_dimmer_val(in_m: str, mixin_m: str, has_offset: bool) -> float: return show, output_list def test_inst(self): + basicConfig(level=logging.DEBUG) show, expected_output_list = self._prepare_show_config() recorded_output_list = [] expected_output_dict = {} for key, expected_val in expected_output_list: expected_output_dict[key] = expected_val - self.assertTrue(execute_board_configuratiuon(show, recorded_gui_updates=recorded_output_list)) - for scene_id, filter_id, value_str in recorded_output_list: + self.assertTrue(execute_board_configuration(show, recorded_gui_updates=recorded_output_list)) + for scene_id, filter_id, key, value_str in recorded_output_list: self.assertEqual(scene_id, 0, "Expected scene ID to be 0.") self.assertTrue(expected_output_dict[filter_id] - 5 < int(value_str) < expected_output_dict[filter_id] + 5, f"Expected configuration of {filter_id} to be {expected_output_dict[filter_id]} but got {value_str} instead.") diff --git a/test/unittests/utilities.py b/test/unittests/utilities.py index 388b7ace..539fa372 100644 --- a/test/unittests/utilities.py +++ b/test/unittests/utilities.py @@ -3,26 +3,40 @@ This module requires a config.py to be in the same package. This config file needs to contain a variable called FISH_EXEC_PATH pointing to a local fish binary. """ +import os.path import subprocess +from logging import getLogger +from time import sleep -from model import BoardConfiguration +from PySide6.QtWidgets import QApplication + +import proto.FilterMode_pb2 +from controller.file.serializing.general_serialization import create_xml +from controller.network import NetworkManager +from controller.utils.process_notifications import get_process_notifier +from model import BoardConfiguration, Broadcaster from .config import FISH_EXEC_PATH +logger = getLogger(__name__) + def _stall_until_stdout_reached_target(process: subprocess.Popen, target: str): while True: - output = process.stdout.read(1024) + output = process.stdout.readline() if not output and process.poll() is not None: return False - if target in output.decode(): + if target in output: return True -def execute_board_configuratiuon(bc: BoardConfiguration, cycles: int = 25, recorded_gui_updates: list[tuple[int, str, str]] | None = None, main_brightness: int = 65565) -> bool: +_last_error_message = "" +_GOOD_MESSAGES = ["No Error occured", "Showfile Applied"] + +def execute_board_configuration(bc: BoardConfiguration, cycles: int = 25, recorded_gui_updates: list[tuple[int, str, str, str]] | None = None, main_brightness: int = 65565) -> bool: """Execute a board configuration. This method starts a fish instance, connects to it, uploads a board configuration and enables filter execution. - If after the specified amount of iterations, no error occurred, the method stops, returning true. Otherwise false. + If after the specified amount of iterations, no error occurred, the method stops, returning true. Otherwise, false. Args: bc: The board configuration to apply @@ -34,18 +48,68 @@ def execute_board_configuratiuon(bc: BoardConfiguration, cycles: int = 25, recor True if no error occurred during execution. Otherwise, false. """ + global _last_error_message + _last_error_message = _GOOD_MESSAGES[0] + logger.debug("Starting fish...") + if os.path.exists("/tmp/fish.sock"): + logger.warning("Removing fish socket.") + os.remove("/tmp/fish.sock") process = subprocess.Popen( [FISH_EXEC_PATH], stdout=subprocess.PIPE, + stdin=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True ) _stall_until_stdout_reached_target(process, "[debug] Entering ev defloop") - # TODO create network manager instance - # TODO call send to fish method - # TODO wait for response and abort if error received - # TODO set main_brightness - # TODO wait for cycles * 40 ms and record gui messages - # TODO disconnect client and close QT Application - # TODO stop fish by sending enter to stdin - # TODO wait for fish to stop and return result + logger.debug("Starting QApplication...") + application = QApplication([]) + logger.debug("Starting network manager.") + nm = NetworkManager() + def fish_error_received(msg: str): + global _last_error_message + if msg != _last_error_message: + if msg not in _GOOD_MESSAGES: + logger.error("Received error from fish: '%s'", msg) + _last_error_message = msg + nm.status_updated.connect(fish_error_received) + broadcaster = Broadcaster() + application.processEvents() + logger.debug("Connecting to fish...") + nm.start() + application.processEvents() + error_occurred: bool = False + + def receive_update_from_fish(msg: proto.FilterMode_pb2.update_parameter): + if recorded_gui_updates is not None: + recorded_gui_updates.append((msg.scene_id, msg.filter_id, msg.parameter_key, msg.parameter_value)) + + broadcaster.update_filter_parameter.connect(receive_update_from_fish) + for i in range(10): + application.processEvents() + sleep(0.01) + pn = get_process_notifier("test upload notifier", 300) + logger.info("Creating Show XML...") + xml = create_xml(bc, pn, assemble_for_fish_loading=True) + logger.info("Sending it to fish...") + nm.transmit_show_file(xml, True) + while _last_error_message == _GOOD_MESSAGES[0] and nm.connection_state(): + application.processEvents() + sleep(0.01) + if _last_error_message != _GOOD_MESSAGES[1]: + error_occurred = True + nm.set_main_brightness_fader_position(int((main_brightness / 65565) * 255)) + logger.info("Evaluating execution...") + if not error_occurred: + for i in range(cycles * 4): + application.processEvents() + sleep(0.01) + logger.info("Ending execution...") + nm.disconnect() + sleep(1) + del broadcaster + del nm + del application + process.stdin.write("k\n") + process.communicate() + return process.returncode == 0 and not error_occurred From b1e80e5020a0d875c195bad2631737332290abea Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Fri, 19 Dec 2025 16:23:20 +0100 Subject: [PATCH 12/53] fix: documentation in new unit tests --- test/unittests/__init__.py | 1 + test/unittests/test_dimmer_brightness_mixin_vfilter.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/test/unittests/__init__.py b/test/unittests/__init__.py index e69de29b..dc2612aa 100644 --- a/test/unittests/__init__.py +++ b/test/unittests/__init__.py @@ -0,0 +1 @@ +"""Unit test collection.""" \ No newline at end of file diff --git a/test/unittests/test_dimmer_brightness_mixin_vfilter.py b/test/unittests/test_dimmer_brightness_mixin_vfilter.py index 13931ca6..d5f64f86 100644 --- a/test/unittests/test_dimmer_brightness_mixin_vfilter.py +++ b/test/unittests/test_dimmer_brightness_mixin_vfilter.py @@ -1,3 +1,4 @@ +"""Unit test for dimmer brightness mixin.""" import logging import unittest from logging import getLogger, basicConfig @@ -12,6 +13,8 @@ class DimmerBrightnessMixinTest(unittest.TestCase): + """Unit test for dimmer brightness mixin.""" + def _prepare_show_config(self) -> tuple[BoardConfiguration, list[tuple[str, float]]]: show = BoardConfiguration() scene = Scene(0, "Test Scene for dimmer brightness mixin", show) @@ -77,6 +80,7 @@ def calculate_dimmer_val(in_m: str, mixin_m: str, has_offset: bool) -> float: return show, output_list def test_inst(self): + """Test instanciation and results of v filter.""" basicConfig(level=logging.DEBUG) show, expected_output_list = self._prepare_show_config() recorded_output_list = [] From eebcbb79226b23328df6a7866fd88a20944aac64 Mon Sep 17 00:00:00 2001 From: Doralitze Date: Sat, 20 Dec 2025 15:41:25 +0100 Subject: [PATCH 13/53] fix: bug causing show file loading to not work --- src/model/virtual_filters/range_adapters.py | 7 +++++-- test/unittests/test_dimmer_brightness_mixin_vfilter.py | 4 +++- test/unittests/utilities.py | 7 +++++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/model/virtual_filters/range_adapters.py b/src/model/virtual_filters/range_adapters.py index 1ae788c6..511f500b 100644 --- a/src/model/virtual_filters/range_adapters.py +++ b/src/model/virtual_filters/range_adapters.py @@ -145,7 +145,7 @@ def resolve_output_port_id(self, virtual_port_id: str) -> str | None: out_8b = self.filter_configurations.get("has_8bit_output") == "true" if virtual_port_id == "dimmer_out8b": if out_8b and out_16b: - return f"{self.filter_id}_16b_downsampler:upper" + return f"{self.filter_id}_16b_downsampler:value_upper" if out_8b: return f"{self._filter_id}_8b_range:value" raise ValueError("Requested 8bit output port but 8bit output is disabled.") @@ -165,7 +165,7 @@ def instantiate_filters(self, filter_list: list[Filter]) -> None: needs_const_mixin = self.channel_links.get("mixin") is None needs_offset = self.channel_links.get("offset") is None - if needs_const_mixin and (not needs_global_brightness_input and not needs_offset): + if needs_const_mixin and (not needs_global_brightness_input or not needs_offset): const_mixin_filter = Filter( self.scene, f"{self.filter_id}_const_mixin", @@ -176,6 +176,9 @@ def instantiate_filters(self, filter_list: list[Filter]) -> None: filter_list.append(const_mixin_filter) mixin_port_name = f"{self.filter_id}_const_mixin:value" required_mixin_input_method = 0 + elif needs_const_mixin: + required_mixin_input_method = 0 + mixin_port_name = None else: mixin_port_name = self.channel_links.get("mixin") diff --git a/test/unittests/test_dimmer_brightness_mixin_vfilter.py b/test/unittests/test_dimmer_brightness_mixin_vfilter.py index d5f64f86..42480a73 100644 --- a/test/unittests/test_dimmer_brightness_mixin_vfilter.py +++ b/test/unittests/test_dimmer_brightness_mixin_vfilter.py @@ -58,7 +58,7 @@ def calculate_dimmer_val(in_m: str, mixin_m: str, has_offset: bool) -> float: for output_8b_enabled in [True, False]: for output_16b_enabled in [True, False]: for has_offset in [True, False]: - logger.info("Creating Config Row: %s, Input: %s, Mixin: %s, Out bit: %s, Out 16bit: %s, has Offset: %s", row, input_method, mixin_method, output_8b_enabled, output_16b_enabled, has_offset) + logger.info("Creating Config Row: %s, Input: %s, Mixin: %s, Out 8bit: %s, Out 16bit: %s, has Offset: %s", row, input_method, mixin_method, output_8b_enabled, output_16b_enabled, has_offset) mixin_filter = DimmerGlobalBrightnessMixinVFilter(scene, f"mixin_filter_r{row}", (0, row * 15)) create_input(input_method, mixin_filter, False) create_input(mixin_method, mixin_filter, True) @@ -72,6 +72,8 @@ def calculate_dimmer_val(in_m: str, mixin_m: str, has_offset: bool) -> float: scene.append_filter(offset_filter) mixin_filter.channel_links["offset"] = offset_filter.filter_id + ":value" + scene.append_filter(mixin_filter) + if output_8b_enabled: create_output(True) if output_16b_enabled: diff --git a/test/unittests/utilities.py b/test/unittests/utilities.py index 539fa372..0a6d6a5d 100644 --- a/test/unittests/utilities.py +++ b/test/unittests/utilities.py @@ -30,7 +30,7 @@ def _stall_until_stdout_reached_target(process: subprocess.Popen, target: str): _last_error_message = "" -_GOOD_MESSAGES = ["No Error occured", "Showfile Applied"] +_GOOD_MESSAGES = ["No Error occured", "Showfile Applied."] def execute_board_configuration(bc: BoardConfiguration, cycles: int = 25, recorded_gui_updates: list[tuple[int, str, str, str]] | None = None, main_brightness: int = 65565) -> bool: """Execute a board configuration. @@ -97,6 +97,7 @@ def receive_update_from_fish(msg: proto.FilterMode_pb2.update_parameter): application.processEvents() sleep(0.01) if _last_error_message != _GOOD_MESSAGES[1]: + logger.error("Final error state from fish was: %s. Failing execution.", _last_error_message) error_occurred = True nm.set_main_brightness_fader_position(int((main_brightness / 65565) * 255)) logger.info("Evaluating execution...") @@ -112,4 +113,6 @@ def receive_update_from_fish(msg: proto.FilterMode_pb2.update_parameter): del application process.stdin.write("k\n") process.communicate() - return process.returncode == 0 and not error_occurred + if process.returncode != 0: + logger.error("Fish terminated with code %s", process.returncode) + return not error_occurred From 6c6213147aa733b8cd61ee1f4f268c92c99b4439 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Wed, 7 Jan 2026 15:47:34 +0100 Subject: [PATCH 14/53] add: improved show file upload error handling to editor --- src/model/ofl/fixture.py | 5 ++++- src/model/virtual_filters/range_adapters.py | 4 ++-- src/view/show_mode/editor/show_browser/show_browser.py | 9 ++++++++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/model/ofl/fixture.py b/src/model/ofl/fixture.py index 129b197e..035d5141 100644 --- a/src/model/ofl/fixture.py +++ b/src/model/ofl/fixture.py @@ -61,7 +61,10 @@ def load_fixture(file: str) -> OflFixture | None: logger.error("Fixture definition %s not found.", file) return None with open(file, "r", encoding="UTF-8") as f: - ob: dict = json.load(f) + try: + ob: dict = json.load(f) + except json.decoder.JSONDecodeError as e: + logger.error("Fixture definition (%s) JSON error: %s", file, e) ob.update({"fileName": file.split("/fixtures/")[1]}) return OflFixture.model_validate(ob) diff --git a/src/model/virtual_filters/range_adapters.py b/src/model/virtual_filters/range_adapters.py index 511f500b..1575c340 100644 --- a/src/model/virtual_filters/range_adapters.py +++ b/src/model/virtual_filters/range_adapters.py @@ -148,11 +148,11 @@ def resolve_output_port_id(self, virtual_port_id: str) -> str | None: return f"{self.filter_id}_16b_downsampler:value_upper" if out_8b: return f"{self._filter_id}_8b_range:value" - raise ValueError("Requested 8bit output port but 8bit output is disabled.") + raise ValueError(f"Requested 8bit output port but 8bit output is disabled. Filter ID: {self.filter_id}") if virtual_port_id == "dimmer_out16b": if out_16b: return f"{self._filter_id}_16b_range:value" - raise ValueError("Requested 8bit output port but 16bit output is disabled.") + raise ValueError(f"Requested 16bit output port but 16bit output is disabled. Filter ID: {self.filter_id}") raise ValueError("Unknown output port") @override 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 3de3ddf0..a7a50891 100644 --- a/src/view/show_mode/editor/show_browser/show_browser.py +++ b/src/view/show_mode/editor/show_browser/show_browser.py @@ -343,7 +343,14 @@ def _universe_item_double_clicked(self, item: QTreeWidgetItem) -> None: current_widget.refresh() def _upload_showfile(self) -> None: - transmit_to_fish(self._show, False) + try: + transmit_to_fish(self._show, False) + except ValueError as e: + self._input_dialog = QMessageBox(QMessageBox.Icon.Critical, "Uploading Showfile Failed", + "An error occurred while uploading the show file to fish:", + parent=self.widget, detailedText=str(e)) + self._input_dialog.setModal(True) + self._input_dialog.show() def _duplicate_scene(self, selected_items: list[QTreeWidgetItem]) -> None: i: int = 1 From a5f7ebbb092e01912a5e8339b531c596a7d7352e Mon Sep 17 00:00:00 2001 From: Doralitze Date: Sat, 10 Jan 2026 17:44:28 +0100 Subject: [PATCH 15/53] add: option to remove assets --- src/model/media_assets/asset.py | 6 +++++- src/model/media_assets/registry.py | 19 +++++++++++++++++++ src/view/dialogs/asset_mgmt_dialog.py | 17 ++++++++++++++++- .../utility_widgets/asset_selection_widget.py | 5 ++++- 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/model/media_assets/asset.py b/src/model/media_assets/asset.py index 02dbc984..3c08ebc1 100644 --- a/src/model/media_assets/asset.py +++ b/src/model/media_assets/asset.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING from uuid import uuid4 -from model.media_assets.registry import register +from model.media_assets.registry import register, unregister if TYPE_CHECKING: from PySide6.QtGui import QPixmap @@ -102,3 +102,7 @@ def is_local_resource(self) -> bool: Non-local resources need to be provided and copied together with the show file. """ return False + + def unregister(self) -> None: + """Unregister this asset.""" + unregister(self) diff --git a/src/model/media_assets/registry.py b/src/model/media_assets/registry.py index dacc2cc5..4abaf857 100644 --- a/src/model/media_assets/registry.py +++ b/src/model/media_assets/registry.py @@ -30,6 +30,25 @@ def register(asset: MediaAsset, uuid: str) -> bool: d[uuid] = asset return True +def unregister(asset: MediaAsset) -> bool: + """Method unregisters a media asset. + + Args: + asset: the media asset to unregister + + Returns: + True if the asset was successfully unregistered, False otherwise. + + """ + reg = _asset_library.get(asset.get_type()) + if reg is None: + return False + try: + reg.pop(asset.id) + return True + except KeyError: + return False + def get_asset_by_uuid(uuid: str) -> MediaAsset | None: """Get a media asset by its UUID. diff --git a/src/view/dialogs/asset_mgmt_dialog.py b/src/view/dialogs/asset_mgmt_dialog.py index 5c288d02..68aba5ae 100644 --- a/src/view/dialogs/asset_mgmt_dialog.py +++ b/src/view/dialogs/asset_mgmt_dialog.py @@ -36,10 +36,16 @@ def __init__(self, parent: QWidget | None = None, show_file_path: str | None = N self._load_asset_file.setText("Load asset from file") self._load_asset_file.triggered.connect(self._open_file) self._action_button_group.addAction(self._load_asset_file) + self._delete_selected_asset_action = QAction() + self._delete_selected_asset_action.setIcon(QIcon.fromTheme("edit-delete")) + self._delete_selected_asset_action.setText("Delete selected asset") + self._delete_selected_asset_action.triggered.connect(self._delete_selected_asset) + self._delete_selected_asset_action.setEnabled(False) + self._action_button_group.addAction(self._delete_selected_asset_action) layout.addWidget(self._action_button_group) - self._asset_display = AssetSelectionWidget(self) + self._asset_display = AssetSelectionWidget(self, multiselection_allowed=True) layout.addWidget(self._asset_display) self._close_button_group = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) @@ -51,6 +57,7 @@ def __init__(self, parent: QWidget | None = None, show_file_path: str | None = N self._dialog: QFileDialog | None = None self._show_file_path = show_file_path if show_file_path is not None else "" self.setMinimumWidth(800) + self._asset_display.asset_selection_changed.connect(self._selected_asset_changed) def _open_file(self) -> None: self._dialog = QFileDialog(self, "Open file") @@ -90,3 +97,11 @@ def _process_loading_files(self, list_of_files: list[str]) -> None: detailedText=accumulated_errors) msg_box.show() self._dialog = msg_box + + def _selected_asset_changed(self) -> None: + self._delete_selected_asset_action.setEnabled(len(self._asset_display.selected_asset) > 0) + + def _delete_selected_asset(self) -> None: + for asset in self._asset_display.selected_asset: + asset.unregister() + self._asset_display.reload_model() diff --git a/src/view/utility_widgets/asset_selection_widget.py b/src/view/utility_widgets/asset_selection_widget.py index fd157ab7..2d267e11 100644 --- a/src/view/utility_widgets/asset_selection_widget.py +++ b/src/view/utility_widgets/asset_selection_widget.py @@ -5,7 +5,7 @@ import os from typing import TYPE_CHECKING, Any, override -from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt +from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal from PySide6.QtGui import QAction, QIcon from PySide6.QtWidgets import QAbstractItemView, QLineEdit, QTableView, QToolBar, QVBoxLayout, QWidget @@ -148,6 +148,8 @@ def get_row_indicies(self, assets: list[MediaAsset]) -> list[int]: class AssetSelectionWidget(QWidget): """Provide a sortable and searchable selection widget for assets.""" + asset_selection_changed: Signal = Signal() + def __init__(self, parent: QWidget | None = None, allowed_types: list[MediaType] | None = None, multiselection_allowed: bool = True) -> None: """Initialize the asset selection widget. @@ -199,6 +201,7 @@ def __init__(self, parent: QWidget | None = None, allowed_types: list[MediaType] self.setLayout(layout) self._update_filter() + self._asset_view.selectionModel().selectionChanged.connect(lambda: self.asset_selection_changed.emit()) def _update_filter(self, force: bool = False) -> None: selected_types: set[MediaType] = set() From 63f36dd7ef53a1777c49ca8ead11b25533d3d3c9 Mon Sep 17 00:00:00 2001 From: Doralitze Date: Sat, 10 Jan 2026 18:32:05 +0100 Subject: [PATCH 16/53] add: option to edit icons of macro buttons --- src/view/dialogs/asset_selection_dialog.py | 51 +++++++++++++++++++ .../macro_buttons_ui_widget.py | 42 ++++++++++++--- 2 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 src/view/dialogs/asset_selection_dialog.py diff --git a/src/view/dialogs/asset_selection_dialog.py b/src/view/dialogs/asset_selection_dialog.py new file mode 100644 index 00000000..1b722f0b --- /dev/null +++ b/src/view/dialogs/asset_selection_dialog.py @@ -0,0 +1,51 @@ +"""Provide a dialog for selecting assets.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, override + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import QDialog, QWidget, QVBoxLayout, QDialogButtonBox +from qasync import QApplication + +from model.media_assets.asset import MediaAsset +from view.utility_widgets.asset_selection_widget import AssetSelectionWidget + +if TYPE_CHECKING: + from model.media_assets.media_type import MediaType + + +class AssetSelectionDialog(QDialog): + """A dialog for selecting assets.""" + + asset_selected: Signal = Signal(MediaAsset) + + def __init__(self, parent: QWidget | None = None, allowed_types: list[MediaType] | None = None, multiselection_allowed: bool = False) -> None: + """Initialize the dialog.""" + super().__init__(parent) + layout = QVBoxLayout() + + self._asset_view = AssetSelectionWidget(self, allowed_types, multiselection_allowed) + layout.addWidget(self._asset_view) + + button_box = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, Qt.Orientation.Horizontal, self + ) + layout.addWidget(button_box) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + self.setMinimumWidth(800) + self.setMinimumHeight(600) + self.setLayout(layout) + + @override + def accept(self) -> None: + self.asset_selected.emit(self._asset_view.selected_asset) + QApplication.processEvents() + super().accept() + self.close() + + @override + def reject(self) -> None: + super().reject() + self.close() diff --git a/src/view/show_mode/show_ui_widgets/macro_buttons_ui_widget.py b/src/view/show_mode/show_ui_widgets/macro_buttons_ui_widget.py index cae5ab09..d926117e 100644 --- a/src/view/show_mode/show_ui_widgets/macro_buttons_ui_widget.py +++ b/src/view/show_mode/show_ui_widgets/macro_buttons_ui_widget.py @@ -28,6 +28,7 @@ from model.media_assets.registry import get_asset_by_uuid from utility import resource_path from view.action_setup_view._command_insertion_dialog import escape_argument +from view.dialogs.asset_selection_dialog import AssetSelectionDialog from view.show_mode.editor.editor_tab_widgets.ui_widget_editor._widget_holder import UIWidgetHolder from view.show_mode.editor.show_browser.annotated_item import AnnotatedListWidgetItem from view.utility_widgets.asset_selection_widget import AssetSelectionWidget @@ -35,6 +36,7 @@ if TYPE_CHECKING: from model import UIPage + from model.media_assets.asset import MediaAsset class _AddMacroActionDialog(QDialog): @@ -108,12 +110,9 @@ def __init__(self, parent: QListWidget, item_def: dict[str, str], index: int, up self._icon_bt.setIcon(self._NO_ICON) layout.addWidget(self._icon_bt) layout.addStretch() - icon_label = QLabel(self) - if self._item_def.get("icon", "") != "": - asset = get_asset_by_uuid(self._item_def["icon"]) - if asset is not None: - icon_label.setPixmap(asset.get_thumbnail()) - layout.addWidget(icon_label) + self.icon_label = QLabel(self) + self._update_displayed_icon() + layout.addWidget(self.icon_label) layout.addStretch() self._text_tb = QLineEdit(self) self._text_tb.setText(item_def["text"]) @@ -125,7 +124,18 @@ def __init__(self, parent: QListWidget, item_def: dict[str, str], index: int, up self._command_tb.textChanged.connect(self._command_changed) layout.addWidget(self._command_tb) self.setLayout(layout) - # TODO implement icon changing functionality + self._dialog: QDialog | None = None + self._icon_bt.pressed.connect(self._change_icon_of_button) + + def _update_displayed_icon(self): + found_icon = False + if self._item_def.get("icon", "") != "": + asset = get_asset_by_uuid(self._item_def["icon"]) + if asset is not None: + self.icon_label.setPixmap(asset.get_thumbnail()) + found_icon = True + if not found_icon: + self.icon_label.setPixmap(self._NO_ICON.pixmap(64, 64)) def _text_changed(self, text: str) -> None: self._item_def["text"] = text @@ -135,6 +145,24 @@ def _command_changed(self, text: str) -> None: self._item_def["command"] = text self._update_button.setEnabled(True) + def _change_icon_of_button(self) -> None: + self._dialog = AssetSelectionDialog(self, allowed_types=[MediaType.IMAGE], multiselection_allowed=False) + self._dialog.setModal(True) + self._dialog.asset_selected.connect(self._icon_changed) + self._dialog.open() + + def _icon_changed(self, asset: list[MediaAsset]) -> None: + self._dialog = None + if len(asset) == 0: + asset_id = "" + else: + asset = asset[-1] + asset_id = asset.id if asset is not None else "" + if asset_id != self._item_def.get("icon", ""): + self._update_button.setEnabled(True) + self._item_def["icon"] = asset_id + self._update_displayed_icon() + @property def item_def(self) -> dict[str, str]: return self._item_def From 66caaf51d5fc418eda8c8017196f4e93b6ac81d4 Mon Sep 17 00:00:00 2001 From: Doralitze Date: Sat, 10 Jan 2026 21:44:40 +0100 Subject: [PATCH 17/53] add: dialog to ask for exit --- src/view/dialogs/asset_selection_dialog.py | 3 ++- src/view/main_window.py | 16 ++++++++++++++++ .../show_ui_widgets/macro_buttons_ui_widget.py | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/view/dialogs/asset_selection_dialog.py b/src/view/dialogs/asset_selection_dialog.py index 1b722f0b..f529b62b 100644 --- a/src/view/dialogs/asset_selection_dialog.py +++ b/src/view/dialogs/asset_selection_dialog.py @@ -20,7 +20,8 @@ class AssetSelectionDialog(QDialog): asset_selected: Signal = Signal(MediaAsset) - def __init__(self, parent: QWidget | None = None, allowed_types: list[MediaType] | None = None, multiselection_allowed: bool = False) -> None: + def __init__(self, parent: QWidget | None = None, + allowed_types: list[MediaType] | None = None, multiselection_allowed: bool = False) -> None: """Initialize the dialog.""" super().__init__(parent) layout = QVBoxLayout() diff --git a/src/view/main_window.py b/src/view/main_window.py index 62c32acd..f4c2a79c 100644 --- a/src/view/main_window.py +++ b/src/view/main_window.py @@ -28,6 +28,7 @@ from view.misc.console_dock_widget import ConsoleDockWidget from view.misc.settings.settings_dialog import SettingsDialog from view.patch_view.patch_mode import PatchMode +from view.show_mode.editor.node_editor_widgets.cue_editor.yes_no_dialog import YesNoDialog from view.show_mode.editor.showmanager import ShowEditorWidget from view.show_mode.player.showplayer import ShowPlayerWidget from view.utility_widgets.wizzards.patch_plan_export import PatchPlanExportWizard @@ -136,6 +137,7 @@ def __init__(self, parent: QWidget = None) -> None: self._terminal_widget: ConsoleDockWidget | None = None self.setWindowIcon(QPixmap(resource_path(os.path.join("resources", "logo.png")))) + self._close_now = False @property def fish_connector(self) -> NetworkManager: @@ -405,3 +407,17 @@ def _toggle_terminal(self) -> None: def _open_asset_mgmt_dialog(self) -> None: self._settings_dialog = AssetManagementDialog(self, self._board_configuration.file_path) self._settings_dialog.show() + + @override + def closeEvent(self, event: QCloseEvent) -> None: + if self._close_now: + super().closeEvent(event) + else: + event.ignore() + self._settings_dialog = YesNoDialog(self, "Close Editor", "Do you really want to close this window? Any unsaved changes will be lost.", self.close_callback) + self._settings_dialog.setModal(True) + self._settings_dialog.show() + + def close_callback(self) -> None: + self._close_now = True + self.close() diff --git a/src/view/show_mode/show_ui_widgets/macro_buttons_ui_widget.py b/src/view/show_mode/show_ui_widgets/macro_buttons_ui_widget.py index d926117e..33bcbe79 100644 --- a/src/view/show_mode/show_ui_widgets/macro_buttons_ui_widget.py +++ b/src/view/show_mode/show_ui_widgets/macro_buttons_ui_widget.py @@ -127,7 +127,7 @@ def __init__(self, parent: QListWidget, item_def: dict[str, str], index: int, up self._dialog: QDialog | None = None self._icon_bt.pressed.connect(self._change_icon_of_button) - def _update_displayed_icon(self): + def _update_displayed_icon(self) -> None: found_icon = False if self._item_def.get("icon", "") != "": asset = get_asset_by_uuid(self._item_def["icon"]) From 07ce689811764198e7b81df8fd73a0575b7ac4b3 Mon Sep 17 00:00:00 2001 From: Doralitze Date: Sun, 11 Jan 2026 18:06:29 +0100 Subject: [PATCH 18/53] add: basic template generation for C2CW vFilter --- src/model/filter.py | 1 + .../virtual_filters/color_to_colorwheel.py | 138 ++++++++++++++++++ .../data/color-to-colorwheel-template.lua.j2 | 71 +++++++++ 3 files changed, 210 insertions(+) create mode 100644 src/model/virtual_filters/color_to_colorwheel.py create mode 100644 src/resources/data/color-to-colorwheel-template.lua.j2 diff --git a/src/model/filter.py b/src/model/filter.py index 696f6874..8ebd6392 100644 --- a/src/model/filter.py +++ b/src/model/filter.py @@ -82,6 +82,7 @@ class FilterTypeEnumeration(IntFlag): Negative values indicate virtual filters. """ + VFILTER_COLOR_TO_COLORWHEEL = -13 VFILTER_SEQUENCER = -12 VFILTER_COLOR_MIXER = -11 VFILTER_IMPORT = -10 diff --git a/src/model/virtual_filters/color_to_colorwheel.py b/src/model/virtual_filters/color_to_colorwheel.py new file mode 100644 index 00000000..9bed7178 --- /dev/null +++ b/src/model/virtual_filters/color_to_colorwheel.py @@ -0,0 +1,138 @@ +"""Contains ColorToColorwheel vFilter.""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, override + +from jinja2 import Environment + +from model.filter import Filter, VirtualFilter, FilterTypeEnumeration +from utility import resource_path + +if TYPE_CHECKING: + from jinja2.environment import Template + + from model import Scene + +with open(resource_path(os.path.join("resources", "data", "color-to-colorwheel-template.lua.j2")), "r") as f: + _FILTER_CONTENT_TEMPLATE: Template = Environment().from_string(f.read()) + + +class ColorToColorWheel(VirtualFilter): + """A vFilter that takes a color channel as an input and maps it to a color wheel channel.""" + + def __init__(self, scene: Scene, filter_id: str, pos: tuple[int] | None = None) -> None: + """Initialize the vFilter. + + The following filter configuration properties are available: + - mode: Either automatic (the filter loads its color points from the fixture) or manual. + - fixture-uuid: The fixture UUID to use in automatic mode. + - color-mappings: The color mappings to use in manual mode. Format: h:s:cw;h:s:cw;... + - dimmer-input: Should a dimmer input be used? Can either be "8bit", "16bit", "float" or "". + - dimmer-output: The data type of the dimmer output channel. Can either be "8bit", "16bit", "float" or "". + - colorwheel-datatype: The data type of the color wheel slot. Can either be "8bit" or "16bit". + - wheel_speed: Time in ms to switch between two adjacent wheel slots in manual mode. + - dim_when_off: If true, the brightness will be suspended as long as the wrong color wheel slot is dialed in. + + The following ports are provided: + - input: The color input channel (input, color) + - in_dimmer: Optional, Input dimmer mixin (input, 8bit or 16bit or float) + - dimmer: Output dimmer channel (output, 8bit or 16bit or float) + - colorwheel: The color wheel slot to use (output, 8bit or 16bit) + + Args: + scene: The scene to use for this filter. + filter_id: The id of the filter to use. + pos: Optional, The position of the filter to use. + + """ + super().__init__(scene, filter_id, FilterTypeEnumeration.VFILTER_COLOR_TO_COLORWHEEL, pos=pos) + if "mode" not in self._filter_configurations: + self.filter_configurations["mode"] = "automatic" + if "fixture-uuid" not in self._filter_configurations: + self.filter_configurations["fixture-uuid"] = "" + if "color-mappings" not in self._filter_configurations: + self.filter_configurations["color-mappings"] = "" + if "dimmer-input" not in self._filter_configurations: + self.filter_configurations["dimmer-input"] = "8bit" + if "dimmer-output" not in self._filter_configurations: + self.filter_configurations["dimmer-output"] = "" + if "colorwheel-datatype" not in self._filter_configurations: + self.filter_configurations["colorwheel-datatype"] = "8bit" + if not "wheel_speed" in self.filter_configurations: + self.filter_configurations["wheel_speed"] = "300" + if not "dim_when_off" in self.filter_configurations: + self.filter_configurations["dim_when_off"] = "true" + + @override + def resolve_output_port_id(self, virtual_port_id: str) -> str | None: + if virtual_port_id not in ["dimmer", "colorwheel"]: + raise ValueError(f"Invalid virtual port ID. Filter ID: {self.filter_id}, Requested Port: {virtual_port_id}") + return f"{self.filter_id}:{virtual_port_id}" + + @override + def instantiate_filters(self, filter_list: list[Filter]) -> None: + if self.filter_configurations["mode"] == "automatic": + # TODO + raise ValueError(f"Automatic mode is currently not implemented. Filter ID: {self.filter_id}") + input_dimmer_channel = self.channel_links.get("in_dimmer", "") + hue_values: list[float] = [] + saturation_values: list[float] = [] + slots: list[int] = [] + required_dimmer_output_data_type = self.filter_configurations["dimmer-output"] + dimmer_input_data_type = self.filter_configurations["dimmer-input"] + + colorwheel_is_16bit = self.filter_configurations["colorwheel-datatype"] == "16bit" + + input_dimmer_configured = input_dimmer_channel != "" + dimmer_output_required = required_dimmer_output_data_type != "" + wheel_speed = self.filter_configurations.get("wheel_speed", "300") + + for mapping_str in self.filter_configurations.get("color-mappings", "").split(";"): + hue, saturation, slot = mapping_str.split(":") + hue_values.append(float(hue)) + saturation_values.append(float(saturation)) + slots.append(int(slot)) + + match required_dimmer_output_data_type: + case "8bit": + output_dimmer_multiplier = "255" + case "16bit": + output_dimmer_multiplier = "65535" + case _: + output_dimmer_multiplier = "1" + + match dimmer_input_data_type: + case "8bit": + input_dimmer_multiplier = "255" + case "16bit": + input_dimmer_multiplier = "65535" + case _: + input_dimmer_multiplier = "1" + + script = _FILTER_CONTENT_TEMPLATE.render({ + "input_dimmer_channel_connected": input_dimmer_configured, + "input_dimmer_channel_data_type": dimmer_input_data_type, + "input_dimmer_multiplier": input_dimmer_multiplier, + "hue_values": hue_values, + "saturation_values": saturation_values, + "slots": slots, + "wheel_speed": wheel_speed, + "dimmer_output_required": dimmer_output_required, + "output_dimmer_data_type": required_dimmer_output_data_type, + "output_dimmer_multiplier": output_dimmer_multiplier, + "dim_when_off": self.filter_configurations.get("dim_when_off", "true") == "true", + "colorwheel_datatype": self.filter_configurations.get("colorwheel-datatype", "8bit"), + }) + + f = Filter(self.scene, self.filter_id, FilterTypeEnumeration.FILTER_SCRIPTING_LUA, pos=self.pos) + f.initial_parameters["script"] = script + f.filter_configurations["in_mapping"] = "input:color;time:float" + if input_dimmer_configured: + f.filter_configurations["in_mapping"] += f";in_dimmer:{dimmer_input_data_type}" + f.filter_configurations["out_mapping"] = f"colorwheel:{"16bit" if colorwheel_is_16bit else "8bit"}" + if dimmer_output_required: + f.filter_configurations["out_mapping"] += f";dimmer:{required_dimmer_output_data_type}" + f.channel_links.update(self.channel_links) + filter_list.append(f) \ No newline at end of file diff --git a/src/resources/data/color-to-colorwheel-template.lua.j2 b/src/resources/data/color-to-colorwheel-template.lua.j2 new file mode 100644 index 00000000..56df327d --- /dev/null +++ b/src/resources/data/color-to-colorwheel-template.lua.j2 @@ -0,0 +1,71 @@ +----------------- Parameters --------------------- +wheel_slots_hue = {{% for hv in hue_values %}{{ hv }}{{ ", " if not loop last }}{% endfor %}} +wheel_slots_saturation = {{% for sv in saturation_values %}{{ sv }}{{ ", " if not loop last }}{% endfor %}} +wheel_values = {{% for slot in slots %}{{ slot }}{{ ", " if not loop last }}{% endfor %}} +------------ End of Parameters --------------- + +------------ Required Ports ------------------- +-- input : color in +-- time : float in +{% if input_dimmer_channel_connected %} +-- in_dimmer : {{ input_dimmer_channel_data_type }} in +{% endif %} +-- dimmer : {{ output_dimmer_data_type }} out +-- colorwheel : {{ colorwheel_datatype }} out +----------------------------------------------- + +current_wheel_pos = -1 +last_change_time = 0 + +-- Convert colors to cartesian coordinates in order to save redoing it every time +for i = 1, #wheel_slots_hue do + h = math.rad(wheel_slots_hue[i]) + s = wheel_slots_saturation[i] + wheel_slots_hue[i] = s * math.cos(h) + wheel_slots_saturation[i] = s * math.sin(h) +end + +function get_target_position(hue, saturation) + best_diff = 361 + best_position = 0 + target_x = saturation * math.cos(hue) + target_y = saturation * math.sin(hue) + for i = 1, #wheel_slots_hue do + diff = math.sqrt(math.pow(target_x - wheel_slots_hue[i], 2) + math.pow(target_y - wheel_slots_saturation[i], 2)) + if diff < best_diff then + best_diff = diff + best_position = i + end + end + return best_position +end + +function update() + target_position = get_target_position(math.rad(input["h"]), input["s"]) + {% if dim_when_off %} + if current_wheel_pos ~= target_position then + dimmer = 0 + else + dimmer = {% if output_dimmer_multiplier != "1" %}{{ output_dimmer_multiplier }} * {% endif %}input["i"]{% if input_dimmer_channel_connected %} * (in_dimmer{% if input_dimmer_multiplier != "1" %} / {{ input_dimmer_multiplier }}{% endif %}){% endif %} + end + {% else %} + dimmer = {% if output_dimmer_multiplier != "1" %}{{ output_dimmer_multiplier }} * {% endif %}input["i"]{% if input_dimmer_channel_connected %} * (in_dimmer{% if input_dimmer_multiplier != "1" %} / {{ input_dimmer_multiplier }}{% endif %}){% endif %} + {% endif %} + if last_change_time + {{ wheel_speed }} < time then + if current_wheel_pos < target_position then + current_wheel_pos = current_wheel_pos + 1 + end + if current_wheel_pos > target_position then + current_wheel_pos = current_wheel_pos - 1 + end + last_change_time = time + end + colorwheel = wheel_values[target_position] +end + +function scene_activated() + current_wheel_pos = -1 + if time ~= nil then + last_change_time = time + end +end \ No newline at end of file From f1c056ea51d2f9535df880ad41c91182e8b20b44 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Mon, 12 Jan 2026 18:37:25 +0100 Subject: [PATCH 19/53] fix: duplicate declaration of closing event in main --- src/view/dialogs/asset_selection_dialog.py | 2 +- src/view/main_window.py | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/view/dialogs/asset_selection_dialog.py b/src/view/dialogs/asset_selection_dialog.py index f529b62b..21669fd8 100644 --- a/src/view/dialogs/asset_selection_dialog.py +++ b/src/view/dialogs/asset_selection_dialog.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, override from PySide6.QtCore import Qt, Signal -from PySide6.QtWidgets import QDialog, QWidget, QVBoxLayout, QDialogButtonBox +from PySide6.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout, QWidget from qasync import QApplication from model.media_assets.asset import MediaAsset diff --git a/src/view/main_window.py b/src/view/main_window.py index f4c2a79c..22936de1 100644 --- a/src/view/main_window.py +++ b/src/view/main_window.py @@ -218,14 +218,6 @@ def _setup_menubar(self) -> None: self._add_entries_to_menu(menu, entries) self.menuBar().addAction(menu.menuAction()) - @override - def closeEvent(self, event: QCloseEvent, /) -> None: - # TODO use event.ignore() here is there's still stuff to do - super().closeEvent(event) - QApplication.processEvents() - self._broadcaster.application_closing.emit() - QApplication.processEvents() - def _start_connection(self) -> None: # TODO rework to signals self._fish_connector.start(True) @@ -412,9 +404,17 @@ def _open_asset_mgmt_dialog(self) -> None: def closeEvent(self, event: QCloseEvent) -> None: if self._close_now: super().closeEvent(event) + QApplication.processEvents() + self._broadcaster.application_closing.emit() + QApplication.processEvents() else: event.ignore() - self._settings_dialog = YesNoDialog(self, "Close Editor", "Do you really want to close this window? Any unsaved changes will be lost.", self.close_callback) + self._settings_dialog = YesNoDialog( + self, + "Close Editor", + "Do you really want to close this window? Any unsaved changes will be lost.", + self.close_callback + ) self._settings_dialog.setModal(True) self._settings_dialog.show() From 964e849febc621df8139c813032352a70b78faf8 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Mon, 12 Jan 2026 18:39:25 +0100 Subject: [PATCH 20/53] fix: import formatting --- src/model/virtual_filters/color_to_colorwheel.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/model/virtual_filters/color_to_colorwheel.py b/src/model/virtual_filters/color_to_colorwheel.py index 9bed7178..70e1d668 100644 --- a/src/model/virtual_filters/color_to_colorwheel.py +++ b/src/model/virtual_filters/color_to_colorwheel.py @@ -7,7 +7,7 @@ from jinja2 import Environment -from model.filter import Filter, VirtualFilter, FilterTypeEnumeration +from model.filter import Filter, FilterTypeEnumeration, VirtualFilter from utility import resource_path if TYPE_CHECKING: @@ -16,7 +16,7 @@ from model import Scene with open(resource_path(os.path.join("resources", "data", "color-to-colorwheel-template.lua.j2")), "r") as f: - _FILTER_CONTENT_TEMPLATE: Template = Environment().from_string(f.read()) + _FILTER_CONTENT_TEMPLATE: Template = Environment().from_string(f.read()) # NOQA: S701 the editor is not a web page. class ColorToColorWheel(VirtualFilter): @@ -60,9 +60,9 @@ def __init__(self, scene: Scene, filter_id: str, pos: tuple[int] | None = None) self.filter_configurations["dimmer-output"] = "" if "colorwheel-datatype" not in self._filter_configurations: self.filter_configurations["colorwheel-datatype"] = "8bit" - if not "wheel_speed" in self.filter_configurations: + if "wheel_speed" not in self.filter_configurations: self.filter_configurations["wheel_speed"] = "300" - if not "dim_when_off" in self.filter_configurations: + if "dim_when_off" not in self.filter_configurations: self.filter_configurations["dim_when_off"] = "true" @override @@ -135,4 +135,4 @@ def instantiate_filters(self, filter_list: list[Filter]) -> None: if dimmer_output_required: f.filter_configurations["out_mapping"] += f";dimmer:{required_dimmer_output_data_type}" f.channel_links.update(self.channel_links) - filter_list.append(f) \ No newline at end of file + filter_list.append(f) From 25f4180ffbbd6ad165b44dca3e474ca3711e11c0 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Thu, 15 Jan 2026 16:41:03 +0100 Subject: [PATCH 21/53] fix: main window close callback to be private --- src/view/main_window.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/view/main_window.py b/src/view/main_window.py index 22936de1..888c1f21 100644 --- a/src/view/main_window.py +++ b/src/view/main_window.py @@ -413,11 +413,11 @@ def closeEvent(self, event: QCloseEvent) -> None: self, "Close Editor", "Do you really want to close this window? Any unsaved changes will be lost.", - self.close_callback + self._close_callback ) self._settings_dialog.setModal(True) self._settings_dialog.show() - def close_callback(self) -> None: + def _close_callback(self) -> None: self._close_now = True self.close() From 9172d619064f7125dd9d5e1ae159a583b2d7af64 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Tue, 17 Mar 2026 21:03:16 +0100 Subject: [PATCH 22/53] add: ColorToColorWheel support to vfilter_factory --- src/model/virtual_filters/vfilter_factory.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/model/virtual_filters/vfilter_factory.py b/src/model/virtual_filters/vfilter_factory.py index b8494eb0..3408547b 100644 --- a/src/model/virtual_filters/vfilter_factory.py +++ b/src/model/virtual_filters/vfilter_factory.py @@ -11,6 +11,7 @@ from model.filter import FilterTypeEnumeration from model.virtual_filters.auto_tracker_filter import AutoTrackerFilter from model.virtual_filters.color_mixer_vfilter import ColorMixerVFilter +from model.virtual_filters.color_to_colorwheel import ColorToColorWheel from model.virtual_filters.cue_vfilter import CueFilter from model.virtual_filters.effects_stacks.vfilter import EffectsStack from model.virtual_filters.import_vfilter import ImportVFilter @@ -76,5 +77,7 @@ def construct_virtual_filter_instance( return ColorMixerVFilter(scene, filter_id, pos=pos) case FilterTypeEnumeration.VFILTER_SEQUENCER: return SequencerFilter(scene, filter_id, pos=pos) + case FilterTypeEnumeration.VFILTER_COLOR_TO_COLORWHEEL: + return ColorToColorWheel(scene, filter_id, pos=pos) case _: raise ValueError(f"The requested filter type {filter_type} is not yet implemented.") From 37fd93a7a9e56f8799bb67635afce6e0d1099487 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Wed, 25 Mar 2026 18:57:52 +0100 Subject: [PATCH 23/53] add: method to automatically generate color mapping --- src/model/ofl/fixture.py | 18 +++++++++++++- .../virtual_filters/color_to_colorwheel.py | 24 ++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/model/ofl/fixture.py b/src/model/ofl/fixture.py index 129b197e..22d34e22 100644 --- a/src/model/ofl/fixture.py +++ b/src/model/ofl/fixture.py @@ -22,7 +22,7 @@ from numpy.typing import NDArray - from model import BoardConfiguration + from model import BoardConfiguration, ColorHSI logger = getLogger(__name__) @@ -108,6 +108,9 @@ def __init__( self._segment_map: dict[FixtureChannelType, NDArray[np.int_]] = segment_map self._color_support: Final[ColorSupport] = color_support + self._colorwheel_mappings: list[tuple[FixtureChannel, list[tuple[int, ColorHSI, ColorHSI | None]]]] = [] + # TODO populate list + self._color_on_stage: str = ( color if color else "#" + "".join([random.choice("0123456789ABCDEF") for _ in range(6)]) # noqa: S311 not a secret ) @@ -121,6 +124,19 @@ def uuid(self) -> UUID: """UUID of the fixture.""" return self._uuid + @property + def colorwheel_mappings(self) -> list[tuple[FixtureChannel, list[tuple[int, ColorHSI, ColorHSI | None]]]]: + """Get the color wheels of this fixture. + + This list contains tuples of the channels that contain color wheels as well as their colors. + Each slot is a tuple of the DMX value that should be send in order to get the color as well as the colors of the + position. If the third parameter of this tuple is not None, the position is right in between two slots. + + Returns: + A copy of the list. + """ + return list(self._colorwheel_mappings) + @property def power(self) -> float: """Fixture maximum continuous power draw (not accounting for capacitor charging as well as lamp warmup) in W.""" diff --git a/src/model/virtual_filters/color_to_colorwheel.py b/src/model/virtual_filters/color_to_colorwheel.py index 70e1d668..fd57689a 100644 --- a/src/model/virtual_filters/color_to_colorwheel.py +++ b/src/model/virtual_filters/color_to_colorwheel.py @@ -14,11 +14,33 @@ from jinja2.environment import Template from model import Scene + from model.ofl.fixture import UsedFixture with open(resource_path(os.path.join("resources", "data", "color-to-colorwheel-template.lua.j2")), "r") as f: _FILTER_CONTENT_TEMPLATE: Template = Environment().from_string(f.read()) # NOQA: S701 the editor is not a web page. +def extract_colorwheel_mappings_from_fixture(f: UsedFixture, selected_slot_index: int = 0) -> str: + """Get a color mapping from the provided fixture. + + Args: + f: The fixture to extract the filter configuration from. + selected_slot_index: Which color wheel slot of the fixture should be used? + + Returns: + A string usable as the color-mappings property of the filter configuration. + + """ + color_mappings: list[tuple[float, float, int]] = [] + for channel, mappings in f.colorwheel_mappings[selected_slot_index]: + for selection_value, color1, color2 in mappings: + if color2 is not None: + # We have a value that is in the middle between two slots. + continue + color_mappings.append((color1.hue, color1.saturation, selection_value)) + return ";".join([f"{hue}:{saturation}:{slot}" for hue, saturation, slot in color_mappings]) + + class ColorToColorWheel(VirtualFilter): """A vFilter that takes a color channel as an input and maps it to a color wheel channel.""" @@ -74,7 +96,7 @@ def resolve_output_port_id(self, virtual_port_id: str) -> str | None: @override def instantiate_filters(self, filter_list: list[Filter]) -> None: if self.filter_configurations["mode"] == "automatic": - # TODO + # TODO use extract_colorwheel_mappings_from_fixture method raise ValueError(f"Automatic mode is currently not implemented. Filter ID: {self.filter_id}") input_dimmer_channel = self.channel_links.get("in_dimmer", "") hue_values: list[float] = [] From e00353fc8847a1dbee513534b2cdae10ccf4a868 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Thu, 26 Mar 2026 07:39:46 +0100 Subject: [PATCH 24/53] add: automatic mode support to color wheel vfilter --- src/model/board_configuration.py | 15 ++++++++++--- .../virtual_filters/color_to_colorwheel.py | 21 ++++++++++++++++--- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/model/board_configuration.py b/src/model/board_configuration.py index 48ba7b61..0f6c03c3 100644 --- a/src/model/board_configuration.py +++ b/src/model/board_configuration.py @@ -2,6 +2,7 @@ from collections.abc import Callable, Sequence from logging import getLogger +from uuid import UUID import numpy as np from PySide6 import QtCore, QtGui @@ -30,7 +31,7 @@ def __init__(self, show_name: str = "", default_active_scene: int = 0, notes: st self._scenes_index: dict[int, int] = {} self._devices: list[Device] = [] self._universes: dict[int, Universe] = {} - self._fixtures: list[UsedFixture] = [] + self._fixtures: dict[str, UsedFixture] = {} self._ui_hints: dict[str, str] = {} self._macros: list[Macro] = [] @@ -100,7 +101,7 @@ def _add_universe(self, universe: Universe) -> None: self._universes.update({universe.id: universe}) def _add_fixture(self, used_fixture: UsedFixture) -> None: - self._fixtures.append(used_fixture) + self._fixtures[str(used_fixture.uuid)] = used_fixture def _delete_universe(self, universe: Universe) -> None: """Remove the passed universe from the list of universes. @@ -146,7 +147,7 @@ def universe(self, universe_id: int) -> Universe | None: @property def fixtures(self) -> Sequence[UsedFixture]: """Fixtures associated with this Show.""" - return self._fixtures + return self._fixtures.values() @property def show_name(self) -> str: @@ -312,3 +313,11 @@ def get_occupied_channels(self, universe_id: int) -> np.typing.NDArray[int]: ] return np.concatenate(ranges) if ranges else np.array([], dtype=int) + + def get_fixture(self, fixture_id: str | UUID) -> UsedFixture | None: + """Get the fixture specified by its id.""" + if fixture_id == "" or fixture_id is None: + return None + if isinstance(fixture_id, UUID): + fixture_id = str(fixture_id) + return self._fixtures.get(fixture_id) diff --git a/src/model/virtual_filters/color_to_colorwheel.py b/src/model/virtual_filters/color_to_colorwheel.py index fd57689a..41f6ee2b 100644 --- a/src/model/virtual_filters/color_to_colorwheel.py +++ b/src/model/virtual_filters/color_to_colorwheel.py @@ -56,6 +56,7 @@ def __init__(self, scene: Scene, filter_id: str, pos: tuple[int] | None = None) - colorwheel-datatype: The data type of the color wheel slot. Can either be "8bit" or "16bit". - wheel_speed: Time in ms to switch between two adjacent wheel slots in manual mode. - dim_when_off: If true, the brightness will be suspended as long as the wrong color wheel slot is dialed in. + - colorwheel-id: If in automatic mode, specifies which color wheel to use. The following ports are provided: - input: The color input channel (input, color) @@ -86,6 +87,8 @@ def __init__(self, scene: Scene, filter_id: str, pos: tuple[int] | None = None) self.filter_configurations["wheel_speed"] = "300" if "dim_when_off" not in self.filter_configurations: self.filter_configurations["dim_when_off"] = "true" + if "colorwheel-id" not in self.filter_configurations: + self.filter_configurations["colorwheel-id"] = "0" @override def resolve_output_port_id(self, virtual_port_id: str) -> str | None: @@ -93,11 +96,23 @@ def resolve_output_port_id(self, virtual_port_id: str) -> str | None: raise ValueError(f"Invalid virtual port ID. Filter ID: {self.filter_id}, Requested Port: {virtual_port_id}") return f"{self.filter_id}:{virtual_port_id}" + def _get_fixture(self) -> UsedFixture: + fixture_id = self.filter_configurations["fixture-uuid"] + if fixture_id == "": + raise ValueError(f"Fixture UUID cannot be empty. Filter-ID: {self.filter_id}") + f = self.scene.board_configuration.get_fixture(fixture_id) + if f is None: + raise ValueError(f"Fixture with UUID {fixture_id} not found in showfile. Filter-ID: {self.filter_id}") + return f + @override def instantiate_filters(self, filter_list: list[Filter]) -> None: if self.filter_configurations["mode"] == "automatic": - # TODO use extract_colorwheel_mappings_from_fixture method - raise ValueError(f"Automatic mode is currently not implemented. Filter ID: {self.filter_id}") + color_mapping_string = extract_colorwheel_mappings_from_fixture( + self._get_fixture(), selected_slot_index=int(self.filter_configurations["colorwheel-id"]) + ) + else: + color_mapping_string = self.filter_configurations.get("color-mappings", "") input_dimmer_channel = self.channel_links.get("in_dimmer", "") hue_values: list[float] = [] saturation_values: list[float] = [] @@ -111,7 +126,7 @@ def instantiate_filters(self, filter_list: list[Filter]) -> None: dimmer_output_required = required_dimmer_output_data_type != "" wheel_speed = self.filter_configurations.get("wheel_speed", "300") - for mapping_str in self.filter_configurations.get("color-mappings", "").split(";"): + for mapping_str in color_mapping_string.split(";"): hue, saturation, slot = mapping_str.split(":") hue_values.append(float(hue)) saturation_values.append(float(saturation)) From 940142305514dfc0845d08b6423bd03c538bf732 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Thu, 26 Mar 2026 09:05:27 +0100 Subject: [PATCH 25/53] add: filter node integration for color2colorwheel adapter --- .../virtual_filters/color_to_colorwheel.py | 2 +- .../data/color-to-colorwheel-template.lua.j2 | 6 ++-- .../editor/nodes/filter_node_library.py | 3 +- .../show_mode/editor/nodes/impl/adapters.py | 34 +++++++++++++++++++ .../editor/nodes/type_to_node_dict.py | 3 +- 5 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/model/virtual_filters/color_to_colorwheel.py b/src/model/virtual_filters/color_to_colorwheel.py index 41f6ee2b..940a0571 100644 --- a/src/model/virtual_filters/color_to_colorwheel.py +++ b/src/model/virtual_filters/color_to_colorwheel.py @@ -32,7 +32,7 @@ def extract_colorwheel_mappings_from_fixture(f: UsedFixture, selected_slot_index """ color_mappings: list[tuple[float, float, int]] = [] - for channel, mappings in f.colorwheel_mappings[selected_slot_index]: + for _, mappings in f.colorwheel_mappings[selected_slot_index]: for selection_value, color1, color2 in mappings: if color2 is not None: # We have a value that is in the middle between two slots. diff --git a/src/resources/data/color-to-colorwheel-template.lua.j2 b/src/resources/data/color-to-colorwheel-template.lua.j2 index 56df327d..a476bd88 100644 --- a/src/resources/data/color-to-colorwheel-template.lua.j2 +++ b/src/resources/data/color-to-colorwheel-template.lua.j2 @@ -1,7 +1,7 @@ ----------------- Parameters --------------------- -wheel_slots_hue = {{% for hv in hue_values %}{{ hv }}{{ ", " if not loop last }}{% endfor %}} -wheel_slots_saturation = {{% for sv in saturation_values %}{{ sv }}{{ ", " if not loop last }}{% endfor %}} -wheel_values = {{% for slot in slots %}{{ slot }}{{ ", " if not loop last }}{% endfor %}} +wheel_slots_hue = {% raw %}{{% endraw %}{% for hv in hue_values %}{{ hv }}{{ ", " if not loop.last }}{% endfor %}{% raw %}}{% endraw %} +wheel_slots_saturation = {% raw %}{{% endraw %}{% for sv in saturation_values %}{{ sv }}{{ ", " if not loop.last }}{% endfor %}{% raw %}}{% endraw %} +wheel_values = {% raw %}{{% endraw %}{% for slot in slots %}{{ slot }}{{ ", " if not loop.last }}{% endfor %}{% raw %}}{% endraw %} ------------ End of Parameters --------------- ------------ Required Ports ------------------- diff --git a/src/view/show_mode/editor/nodes/filter_node_library.py b/src/view/show_mode/editor/nodes/filter_node_library.py index a9f97cec..4b6bcfc7 100644 --- a/src/view/show_mode/editor/nodes/filter_node_library.py +++ b/src/view/show_mode/editor/nodes/filter_node_library.py @@ -19,7 +19,7 @@ AdapterFloatToRange, ColorBrightnessMixinNode, CombineTwo8BitToSingle16Bit, - Map8BitTo16Bit, + Map8BitTo16Bit, ColorToColorwheelAdapterNode, ) from view.show_mode.editor.nodes.impl.arithmetics import ( ArithmeticExponentialNode, @@ -159,6 +159,7 @@ def _register_adapters_nodes(self) -> None: self.addNodeType(CombineTwo8BitToSingle16Bit, [("Adapters",)]) self.addNodeType(Map8BitTo16Bit, [("Adapters",)]) self.addNodeType(ColorBrightnessMixinNode, [("Adapters",)]) + self.addNodeType(ColorToColorwheelAdapterNode, [("Adapters",)]) def _register_arithmetic_nodes(self) -> None: """Register all the arithmetics nodes.""" diff --git a/src/view/show_mode/editor/nodes/impl/adapters.py b/src/view/show_mode/editor/nodes/impl/adapters.py index 4d693530..42baf840 100644 --- a/src/view/show_mode/editor/nodes/impl/adapters.py +++ b/src/view/show_mode/editor/nodes/impl/adapters.py @@ -316,3 +316,37 @@ def __init__(self, model: Filter | Scene, name: str) -> None: self.filter.in_data_types["brightness"] = DataType.DT_8_BIT self.channel_hints["brightness"] = "[0-255, optional]" self.filter._configuration_supported = False + +class ColorToColorwheelAdapterNode(FilterNode): + nodeName = "Color to Color Wheel Adapter" + + def __init__(self, model: Filter | Scene, name: str) -> None: + super().__init__(model=model, filter_type=FilterTypeEnumeration.VFILTER_COLOR_TO_COLORWHEEL, name=name, + terminals={ + "input": {"io": "in"}, + "in_dimmer": {"io": "in"}, + "dimmer": {"io": "out"}, + "colorwheel": {"io": "out"} + }) + self.filter.in_data_types["input"] = DataType.DT_COLOR + match self.filter.filter_configurations.get("dimmer-input"): + case "16bit": + self.filter.in_data_types["in_dimmer"] = DataType.DT_16_BIT + case "float": + self.filter.in_data_types["in_dimmer"] = DataType.DT_DOUBLE + case _: + self.filter.in_data_types["in_dimmer"] = DataType.DT_8_BIT + match self.filter.filter_configurations.get("dimmer-output"): + case "16bit": + self.filter.out_data_types["dimmer"] = DataType.DT_16_BIT + case "float": + self.filter.out_data_types["dimmer"] = DataType.DT_DOUBLE + case _: + self.filter.out_data_types["dimmer"] = DataType.DT_8_BIT + match self.filter.filter_configurations.get("colorwheel-datatype"): + case "16bit": + self.filter.out_data_types["colorwheel"] = DataType.DT_16_BIT + case "float": + self.filter.out_data_types["colorwheel"] = DataType.DT_DOUBLE + case _: + self.filter.out_data_types["colorwheel"] = DataType.DT_8_BIT diff --git a/src/view/show_mode/editor/nodes/type_to_node_dict.py b/src/view/show_mode/editor/nodes/type_to_node_dict.py index 5114ac21..4a8ddf87 100644 --- a/src/view/show_mode/editor/nodes/type_to_node_dict.py +++ b/src/view/show_mode/editor/nodes/type_to_node_dict.py @@ -18,7 +18,7 @@ AdapterFloatToRange, ColorBrightnessMixinNode, CombineTwo8BitToSingle16Bit, - Map8BitTo16Bit, + Map8BitTo16Bit, ColorToColorwheelAdapterNode, ) from view.show_mode.editor.nodes.impl.arithmetics import ( ArithmeticExponentialNode, @@ -98,6 +98,7 @@ from view.show_mode.editor.nodes.import_node import ImportNode type_to_node: dict[int, str] = { + FilterTypeEnumeration.VFILTER_COLOR_TO_COLORWHEEL: ColorToColorwheelAdapterNode.nodeName, FilterTypeEnumeration.VFILTER_SEQUENCER: SequencerNode.nodeName, FilterTypeEnumeration.VFILTER_COLOR_MIXER: ColorMixerVFilterNode.nodeName, FilterTypeEnumeration.VFILTER_IMPORT: ImportNode.nodeName, From 21f93e3648141ceba93b5e7e435c6ed0db0b060f Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Thu, 26 Mar 2026 10:24:45 +0100 Subject: [PATCH 26/53] add: towards configuration widget for colorwheel adapter --- .../virtual_filters/color_to_colorwheel.py | 2 + .../show_mode/editor/filter_settings_item.py | 3 + ...r_to_colorwheel_adapter_settings_widget.py | 142 ++++++++++++++++++ .../show_mode/editor/nodes/impl/adapters.py | 6 + 4 files changed, 153 insertions(+) create mode 100644 src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py diff --git a/src/model/virtual_filters/color_to_colorwheel.py b/src/model/virtual_filters/color_to_colorwheel.py index 940a0571..2a0bd6b5 100644 --- a/src/model/virtual_filters/color_to_colorwheel.py +++ b/src/model/virtual_filters/color_to_colorwheel.py @@ -31,6 +31,8 @@ def extract_colorwheel_mappings_from_fixture(f: UsedFixture, selected_slot_index A string usable as the color-mappings property of the filter configuration. """ + if f is None: + return "" color_mappings: list[tuple[float, float, int]] = [] for _, mappings in f.colorwheel_mappings[selected_slot_index]: for selection_value, color1, color2 in mappings: diff --git a/src/view/show_mode/editor/filter_settings_item.py b/src/view/show_mode/editor/filter_settings_item.py index 916bb305..31254c0b 100644 --- a/src/view/show_mode/editor/filter_settings_item.py +++ b/src/view/show_mode/editor/filter_settings_item.py @@ -28,6 +28,7 @@ from .node_editor_widgets.autotracker_settings import AutotrackerSettingsWidget from .node_editor_widgets.color_mixing_setup_widget import ColorMixingSetupWidget +from .node_editor_widgets.color_to_colorwheel_adapter_settings_widget import ColorToColorwheelAdapterSetupWidget from .node_editor_widgets.column_select import ColumnSelect from .node_editor_widgets.import_vfilter_settings_widget import ImportVFilterSettingsWidget from .node_editor_widgets.lua_widget import LuaScriptConfigWidget @@ -131,6 +132,8 @@ def check_if_filter_has_special_widget(filter_: Filter) -> NodeEditorFilterConfi return ImportVFilterSettingsWidget(filter_) if filter_.filter_type == int(FilterTypeEnumeration.VFILTER_COLOR_MIXER): return ColorMixingSetupWidget() + if filter_.filter_type == int(FilterTypeEnumeration.VFILTER_COLOR_TO_COLORWHEEL): + return ColorToColorwheelAdapterSetupWidget(filter_) if filter_.filter_type == FilterTypeEnumeration.VFILTER_SEQUENCER: return SequencerEditor(f=filter_) diff --git a/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py b/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py new file mode 100644 index 00000000..858f74d8 --- /dev/null +++ b/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py @@ -0,0 +1,142 @@ +"""Provides configuration widget for Color2Colorwheel adapter.""" + +from __future__ import annotations + +from typing import override, TYPE_CHECKING + +from PySide6.QtWidgets import QWidget, QFormLayout, QHBoxLayout, QLabel, QPushButton, QComboBox, QCheckBox, QListWidget, \ + QSpinBox, QSpacerItem, QSizePolicy + +from model.ofl.fixture import UsedFixture +from view.show_mode.editor.node_editor_widgets import NodeEditorFilterConfigWidget + +if TYPE_CHECKING: + from view.show_mode.editor.nodes import FilterNode + from model.virtual_filters.color_to_colorwheel import ColorToColorWheel, extract_colorwheel_mappings_from_fixture + + +class ColorToColorwheelAdapterSetupWidget(NodeEditorFilterConfigWidget): + """Configuration widget for color to colorwheel vfilter.""" + + def __init__(self, filter: ColorToColorWheel) -> None: + """Initialize the configuration widget.""" + super().__init__() + self._widget = QWidget() + layout = QFormLayout() + + self._selected_fixture: UsedFixture | None = None + self._filter = filter + + fixture_selection_container = QWidget(parent=self._widget) + fixture_selection_container.setLayout(QHBoxLayout()) + self._selected_fixture_label = QLabel("No Fixture Selected.") + fixture_selection_container.layout().addWidget(self._selected_fixture_label) + self._fixture_selection_or_clear_button = QPushButton("Select Fixture") + fixture_selection_container.layout().addWidget(self._fixture_selection_or_clear_button) + layout.addRow("Selected Fixture: ", fixture_selection_container) + self._colorwheel_index_spinbox = QSpinBox() + self._colorwheel_index_spinbox.setMinimum(0) + self._colorwheel_index_spinbox.setMaximum(255) + self._colorwheel_index_spinbox.setValue(0) + self._colorwheel_index_spinbox.setEnabled(False) + layout.addRow("Color Wheel Index: ", self._colorwheel_index_spinbox) + + layout.addItem(QSpacerItem(0, 10, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)) + + self._enable_dimmer_input_cb = QCheckBox("Enable Dimmer Input") + layout.addRow("Enable Dimmer Input", self._enable_dimmer_input_cb) + self._dimmer_input_datatype_combobox = QComboBox() + # TODO add options and disable other texts + layout.addRow("Dimmer Input Type", self._dimmer_input_datatype_combobox) + self._dimmer_output_datatype_combobox = QComboBox() + # TODO add options and disable other texts + layout.addRow("Dimmer Output Type", self._dimmer_output_datatype_combobox) + self._colorwheel_datatype_combobox = QComboBox() + # TODO add options and disable other texts + layout.addRow("Color Wheel Data Type", self._colorwheel_datatype_combobox) + + self._color_mapping_list = QListWidget() + layout.addWidget(self._color_mapping_list) + # TODO add buttons for addition and removal of mappings + + self._wheel_speed_spinbox = QSpinBox() + self._wheel_speed_spinbox.setMinimum(0) + self._wheel_speed_spinbox.setMaximum(65565) + self._wheel_speed_spinbox.setValue(300) + self._wheel_speed_spinbox.setToolTip("How many ms does it take to switch between two adjacent color wheel slots?") + layout.addRow("Color Wheel Speed [ms]", self._wheel_speed_spinbox) + self._dimm_when_wheel_is_moving_cb = QCheckBox("Dim when Wheel Moving") + self._dimm_when_wheel_is_moving_cb.setChecked(True) + layout.addWidget(self._dimm_when_wheel_is_moving_cb) + + self._widget.setLayout(layout) + + def _load_from_fixture_clicked(self) -> None: + # TODO show selection dialog with callback self._update_selected_fixture + pass + + def _parse_color_mapping(self, mapping: str) -> None: + # TODO clear and populate color mapping table + pass + + def _update_selected_fixture(self) -> None: + if self._selected_fixture is None: + self._fixture_selection_or_clear_button.setText("Select Fixture") + self._colorwheel_index_spinbox.setEnabled(False) + self._color_mapping_list.setEnabled(True) + self._selected_fixture_label.setText("No Fixture Selected.") + else: + self._fixture_selection_or_clear_button.setText("Clear Selected Fixture") + self._colorwheel_index_spinbox.setEnabled(len(self._selected_fixture.colorwheel_mappings) > 0) + self._color_mapping_list.setEnabled(False) + self._selected_fixture_label.setText(self._selected_fixture.name) + # TODO disable mapping management buttons if selected fixture is not None + self._parse_color_mapping(extract_colorwheel_mappings_from_fixture( + self._selected_fixture, + selected_slot_index=self._colorwheel_index_spinbox.value() + )) + + def _compile_color_mapping_string(self) -> str: + return "" # TODO + + @override + def get_widget(self) -> QWidget: + return self._widget + + @override + def _get_configuration(self) -> dict[str, str]: + return { + "mode": "automatic" if self._selected_fixture is not None else "manual", + "fixture-uuid": self._selected_fixture.uuid if self._selected_fixture is not None else "", + "color-mappings": self._compile_color_mapping_string(), + "dimmer-input": self._dimmer_input_datatype_combobox.currentText(), + "dimmer-output": self._dimmer_output_datatype_combobox.currentText(), + "colorwheel-datatype": self._colorwheel_datatype_combobox.currentText(), + "wheel_speed": str(self._wheel_speed_spinbox.value()), + "dim_when_off": str(self._dimm_when_wheel_is_moving_cb.isChecked()), + "colorwheel-id": str(self._colorwheel_index_spinbox.value()), + } + + @override + def _load_configuration(self, conf: dict[str, str]) -> None: + self._selected_fixture = self._filter.scene.board_configuration.get_fixture(conf["fixture-uuid"]) + self._parse_color_mapping(conf["color-mappings"]) + self._update_selected_fixture() + self._dimmer_input_datatype_combobox.setCurrentText(conf["dimmer-input"]) + self._dimmer_output_datatype_combobox.setCurrentText(conf["dimmer-output"]) + self._colorwheel_datatype_combobox.setCurrentText(conf["colorwheel-datatype"]) + self._colorwheel_index_spinbox.setValue(int(conf["colorwheel-id"])) + self._wheel_speed_spinbox.setValue(int(conf["wheel_speed"])) + self._dimm_when_wheel_is_moving_cb.setChecked(conf["dim_when_off"] == "true") + + @override + def _load_parameters(self, parameters: dict[str, str]) -> dict: + pass # Nothing to do here + + @override + def _get_parameters(self) -> dict[str, str]: + pass # Nothing to do here + + @override + def parent_opened(self) -> None: + pass # Nothing to do here \ No newline at end of file diff --git a/src/view/show_mode/editor/nodes/impl/adapters.py b/src/view/show_mode/editor/nodes/impl/adapters.py index 42baf840..35d97121 100644 --- a/src/view/show_mode/editor/nodes/impl/adapters.py +++ b/src/view/show_mode/editor/nodes/impl/adapters.py @@ -1,4 +1,6 @@ """Adapters and converters filter nodes""" +from typing import override + from model import DataType, Scene from model.filter import Filter, FilterTypeEnumeration from view.show_mode.editor.nodes.base.filternode import FilterNode @@ -328,6 +330,10 @@ def __init__(self, model: Filter | Scene, name: str) -> None: "dimmer": {"io": "out"}, "colorwheel": {"io": "out"} }) + self.update_node_after_settings_changed() + + @override + def update_node_after_settings_changed(self): self.filter.in_data_types["input"] = DataType.DT_COLOR match self.filter.filter_configurations.get("dimmer-input"): case "16bit": From 4a354f3fbbb77bc11054dfe9640a97615dd81fe0 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Fri, 27 Mar 2026 15:11:06 +0100 Subject: [PATCH 27/53] add: color mapping list implementation --- ...r_to_colorwheel_adapter_settings_widget.py | 111 +++++++++++++++++- 1 file changed, 105 insertions(+), 6 deletions(-) diff --git a/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py b/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py index 858f74d8..4c7f14d1 100644 --- a/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py +++ b/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py @@ -5,22 +5,85 @@ from typing import override, TYPE_CHECKING from PySide6.QtWidgets import QWidget, QFormLayout, QHBoxLayout, QLabel, QPushButton, QComboBox, QCheckBox, QListWidget, \ - QSpinBox, QSpacerItem, QSizePolicy + QSpinBox, QSpacerItem, QSizePolicy, QListWidgetItem, QDialog, QDoubleSpinBox, QDialogButtonBox +from model import ColorHSI from model.ofl.fixture import UsedFixture from view.show_mode.editor.node_editor_widgets import NodeEditorFilterConfigWidget +from view.show_mode.show_ui_widgets.debug_viz_widgets import ColorLabel if TYPE_CHECKING: from view.show_mode.editor.nodes import FilterNode from model.virtual_filters.color_to_colorwheel import ColorToColorWheel, extract_colorwheel_mappings_from_fixture +class _ColorMappingListWidgetItem(QListWidgetItem): + """Purpose of this widget is to display a single mapping.""" + + def __init__(self, parent: QListWidget, color: ColorHSI, slot_value: int): + """Initializes and adds the widget based on the provided color and slot value.""" + super().__init__() + self.color = color + self.slot_value = slot_value + + widget = QWidget() + widget.setLayout(QHBoxLayout()) + self._color_label = ColorLabel() + self._color_label.set_color(self.color) + widget.layout().addWidget(self._color_label) + self._slot_label = QLabel(str(slot_value)) + widget.layout().addWidget(self._slot_label) + + parent.addItem(self) + parent.setItemWidget(self, widget) + self.setSizeHint(widget.sizeHint()) + + +class _ColorSlotInputDialog(QDialog): + """Query a color and a slot.""" + + def __init__(self, parent: QWidget, list_widget: QListWidget): + """Initializes the dialog.""" + super().__init__(parent) + + self._list_widget = list_widget + + layout = QFormLayout() + self._hue_tb = QDoubleSpinBox() + self._hue_tb.setRange(0, 360) + self._hue_tb.setDecimals(2) + layout.addRow("Hue", self._hue_tb) + self._saturation_tb = QDoubleSpinBox() + self._saturation_tb.setDecimals(5) + self._saturation_tb.setValue(1.0) + self._saturation_tb.setRange(0, 1) + layout.addRow("Saturation", self._saturation_tb) + self._slot_tb = QSpinBox() + self._slot_tb.setRange(0, 65565) + layout.addRow("Slot", self._slot_tb) + layout.addItem(QSpacerItem(0, 25)) + + self._button_box = QDialogButtonBox() + self._button_box.addButton(QDialogButtonBox.StandardButton.Cancel).clicked.connect(self.close) + self._button_box.addButton(QDialogButtonBox.StandardButton.Ok).clicked.connect(self.accept) + layout.addWidget(self._button_box) + self.setLayout(layout) + + @override + def accept(self): + _ColorMappingListWidgetItem(self._list_widget, + ColorHSI(self._hue_tb.value(), self._saturation_tb.value(), 0.5), + self._slot_tb.value()) + super().accept() + + class ColorToColorwheelAdapterSetupWidget(NodeEditorFilterConfigWidget): """Configuration widget for color to colorwheel vfilter.""" def __init__(self, filter: ColorToColorWheel) -> None: """Initialize the configuration widget.""" super().__init__() + self._input_dialog: _ColorSlotInputDialog | None = None self._widget = QWidget() layout = QFormLayout() @@ -57,7 +120,18 @@ def __init__(self, filter: ColorToColorWheel) -> None: self._color_mapping_list = QListWidget() layout.addWidget(self._color_mapping_list) - # TODO add buttons for addition and removal of mappings + self._mapping_manipulation_buttongroup = QWidget() + self._mapping_manipulation_buttongroup.setLayout(QHBoxLayout()) + self._mapping_manipulation_buttongroup.layout().addItem(QSpacerItem( + 10, 0, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + )) + self._add_mapping_button = QPushButton("Add Mapping") + self._add_mapping_button.clicked.connect(self._add_mapping_clicked) + self._mapping_manipulation_buttongroup.layout().addWidget(self._add_mapping_button) + self._remove_mapping_button = QPushButton("Remove Mapping") + self._remove_mapping_button.clicked.connect(self._remove_mapping_clicked) + self._mapping_manipulation_buttongroup.layout().addWidget(self._remove_mapping_button) + layout.addWidget(self._mapping_manipulation_buttongroup) self._wheel_speed_spinbox = QSpinBox() self._wheel_speed_spinbox.setMinimum(0) @@ -71,13 +145,29 @@ def __init__(self, filter: ColorToColorWheel) -> None: self._widget.setLayout(layout) + def _add_mapping_clicked(self) -> None: + self._input_dialog = _ColorSlotInputDialog(self._widget, self._color_mapping_list) + self._input_dialog.setModal(True) + self._input_dialog.show() + + def _remove_mapping_clicked(self) -> None: + items_to_remove = [index for index in self._color_mapping_list.selectedIndexes()] + items_to_remove.sort(reverse=True) + for item in items_to_remove: + self._color_mapping_list.takeItem(item) + def _load_from_fixture_clicked(self) -> None: # TODO show selection dialog with callback self._update_selected_fixture pass def _parse_color_mapping(self, mapping: str) -> None: - # TODO clear and populate color mapping table - pass + self._color_mapping_list.clear() + for entry_str in mapping.split(';'): + hue, saturation, slot = entry_str.split(':') + hue = float(hue) + saturation = float(saturation) + slot = int(slot) + _ColorMappingListWidgetItem(self._color_mapping_list, ColorHSI(hue, saturation, 0.5), slot) def _update_selected_fixture(self) -> None: if self._selected_fixture is None: @@ -85,19 +175,28 @@ def _update_selected_fixture(self) -> None: self._colorwheel_index_spinbox.setEnabled(False) self._color_mapping_list.setEnabled(True) self._selected_fixture_label.setText("No Fixture Selected.") + self._remove_mapping_button.setEnabled(True) + self._add_mapping_button.setEnabled(True) else: self._fixture_selection_or_clear_button.setText("Clear Selected Fixture") self._colorwheel_index_spinbox.setEnabled(len(self._selected_fixture.colorwheel_mappings) > 0) self._color_mapping_list.setEnabled(False) self._selected_fixture_label.setText(self._selected_fixture.name) - # TODO disable mapping management buttons if selected fixture is not None + self._remove_mapping_button.setEnabled(False) + self._add_mapping_button.setEnabled(False) self._parse_color_mapping(extract_colorwheel_mappings_from_fixture( self._selected_fixture, selected_slot_index=self._colorwheel_index_spinbox.value() )) def _compile_color_mapping_string(self) -> str: - return "" # TODO + parts: list[_ColorMappingListWidgetItem] = [] + for i in range(self._color_mapping_list.count()): + item = self._color_mapping_list.item(i) + if not isinstance(item, _ColorMappingListWidgetItem): + continue + parts.append(item) + return ";".join(f"{item.color.hue}:{item.color.saturation}:{item.slot_value}" for item in parts) @override def get_widget(self) -> QWidget: From 20e0d8cd737430ee3eef4aa955772c9d45d54dfe Mon Sep 17 00:00:00 2001 From: Doralitze Date: Thu, 2 Apr 2026 14:34:58 +0200 Subject: [PATCH 28/53] add: towards color wheel parsing from OFL --- src/model/ofl/fixture.py | 28 ++++++-- src/model/ofl/ofl_fixture.py | 96 +++++++++++++++++++++++++++ src/model/patching/fixture_channel.py | 60 +++++++++++++++-- 3 files changed, 171 insertions(+), 13 deletions(-) diff --git a/src/model/ofl/fixture.py b/src/model/ofl/fixture.py index 22d34e22..d14ca2f3 100644 --- a/src/model/ofl/fixture.py +++ b/src/model/ofl/fixture.py @@ -14,7 +14,7 @@ import numpy as np from PySide6 import QtCore -from model.ofl.ofl_fixture import FixtureMode, MatrixChannelInsert, OflFixture +from model.ofl.ofl_fixture import FixtureMode, MatrixChannelInsert, OflFixture, CapabilityType from model.patching.fixture_channel import FixtureChannel, FixtureChannelType if TYPE_CHECKING: @@ -66,6 +66,22 @@ def load_fixture(file: str) -> OflFixture | None: return OflFixture.model_validate(ob) +def _load_colorwheel_mappings(f: OflFixture, channels: list[FixtureChannel]) -> \ + list[tuple[FixtureChannel, list[tuple[int, ColorHSI, ColorHSI | None]]]]: + """Load color wheel mappings from OFL model.""" + l = [] + for channel in channels: + if not channel.type == FixtureChannelType.COLORWHEEL: + continue + if channel.channel_template is None: + logger.error("The channel %s is a color wheel but the template was not found.", channel.name) + continue + for capability in channel.channel_template.get_capabilities(): + if capability.type == CapabilityType.WHEEL_SLOT: + # TODO query parameters, add them to the list + pass + return l + class UsedFixture(QtCore.QObject): """Fixture in use with a specific mode.""" @@ -102,14 +118,14 @@ def __init__( self._mode_index: int = mode_index self._universe_id: int = parent_universe - channels, segment_map, color_support = self._generate_fixture_channels() + channels, segment_map, color_support = self._generate_fixture_channels(fixture) self._fixture_channels: Final[list[FixtureChannel]] = channels self._segment_map: dict[FixtureChannelType, NDArray[np.int_]] = segment_map self._color_support: Final[ColorSupport] = color_support - self._colorwheel_mappings: list[tuple[FixtureChannel, list[tuple[int, ColorHSI, ColorHSI | None]]]] = [] - # TODO populate list + self._colorwheel_mappings: list[tuple[FixtureChannel, list[tuple[int, ColorHSI, ColorHSI | None]]]] = \ + _load_colorwheel_mappings(OflFixture, self._fixture_channels) self._color_on_stage: str = ( color if color else "#" + "".join([random.choice("0123456789ABCDEF") for _ in range(6)]) # noqa: S311 not a secret @@ -231,13 +247,13 @@ def get_segment_in_universe_by_type(self, segment_type: FixtureChannelType) -> S return tuple((self._segment_map[segment_type] + self.start_index).tolist()) def _generate_fixture_channels( - self, + self, fixture_template: OflFixture ) -> tuple[list[FixtureChannel], dict[FixtureChannelType, NDArray[np.int_]], ColorSupport]: segment_map: dict[FixtureChannelType, list[int]] = defaultdict(list) fixture_channels: list[FixtureChannel] = [] def append_channel(cn: str, i: int) -> None: - channel = FixtureChannel(cn) + channel = FixtureChannel(cn, fixture_template) fixture_channels.append(channel) for channel_type in channel.type_as_list: segment_map[channel_type].append(i) diff --git a/src/model/ofl/ofl_fixture.py b/src/model/ofl/ofl_fixture.py index 45817930..0a96cafa 100644 --- a/src/model/ofl/ofl_fixture.py +++ b/src/model/ofl/ofl_fixture.py @@ -3,6 +3,7 @@ # ruff: noqa: N815 from __future__ import annotations +from enum import Enum from logging import getLogger from typing import Literal @@ -151,6 +152,98 @@ def resolve_list(obj: list | str, prefix: str = "") -> None: return repetition_list +class CapabilityType(Enum): + """Defines the capability type as used by OFL.""" + + NO_FUNCTION = "NoFunction" + GENERIC = "Generic" + SHUTTER_STROBE = "ShutterStrobe" + STROBE_SPEED = "StrobeSpeed" + STROBE_DURATION = "StrobeDuration" + INTENSITY = "Intensity" + COLOR_INTENSITY = "ColorIntensity" + COLOR_PRESET = "ColorPreset" + COLOR_TEMPERATURE = "ColorTemperature" + PAN = "Pan" + PAN_CONTINUOUS = "PanContinuous" + TILT = "Tilt" + TILT_CONTINUOUS = "TiltContinuous" + PAN_TILT_SPEED = "PanTiltSpeed" + WHEEL_SLOT = "WheelSlot" + WHEEL_SHAKE = "WheelShake" + WHEEL_SLOT_ROTATION = "WheelSlotRotation" + WHEEL_ROTATION = "WheelRotation" + EFFECT = "Effect" + EFFECT_SPEED = "EffectSpeed" + EFFECT_DURATION = "EffectDuration" + EFFECT_PARAMETER ="EffectParameter" + SOUND_SENSITIVITY = "SoundSensitivity" + BEAM_ANGLE = "BeamAngle" + BEAM_POSITION = "BeamPosition" + FOCUS = "Focus" + ZOOM = "Zoom" + IRIS = "Iris" + IRIS_EFFECT = "IrisEffect" + FROST = "Frost" + FROST_EFFECT = "FrostEffect" + PRISM = "Prism" + PRISM_ROTATION = "PrismRotation" + BLADE_INSERTION = "BladeInsertion" + BLADE_ROTATION = "BladeRotation" + BLADE_SYSTEM_ROTATION = "BladeSystemRotation" + FOG = "Fog" + FOG_OUTPUT = "FogOutput" + FOG_TYPE = "FogType" + ROTATION = "Rotation" + SPEED = "Speed" + TIME = "Time" + MAINTENANCE = "Maintenance" + + +class Capability(BaseModel): + """Capability of a channel.""" + + dmxRange: tuple[int, int] = (0, 0) + """Defines the range in which this capability is active.""" + + type: CapabilityType = CapabilityType.GENERIC + """Capability type.""" + + comment: str = "" + """Description of the capability if not obvious.""" + + # TODO how do we model the settings? for example a wheel slot has the parameter "slotNumber" and the linked wheel + # (found by the name of the channel) should be linked in order to let the software fetch the color or gobo picture + + +class ChannelTemplate(BaseModel): + """Capability templates of channel.""" + + fineChannelAliases: list[str] | None = None + """Channels matching this name will be associated as a file channel for this template.""" + + capabilities: list[Capability] = [] + """The capabilities of this channel.""" + + capability: Capability | None = None + """If this channel has only a single capability, this will be set and capabilities left empty""" + + switchChannels: dict[str, str] = {} + """Defines possible functionality switches based on the value of other channels.""" + + defaultValue: int | str = 0 + """The default DMX value that should be output. If a string is found, if is most likely a number followed by %.""" + + dmxValueResolution: str = "8bit" + """Defines the resolution of the channel. This might be 8bit 16bit or 24bit.""" + + def get_capabilities(self) -> list[Capability]: + """A unified method to access the capabilities.""" + if len(self.capabilities) > 0: + return self.capabilities + return [self.capability] if self.capability is not None else [] + + class OflFixture(BaseModel): """Complete fixture definition conforming to the Open Fixture Library schema.""" @@ -201,4 +294,7 @@ class OflFixture(BaseModel): Extended """ + availableChannels: dict[str, ChannelTemplate] = {} + """Contains the capability mappings of the channels.""" + model_config = ConfigDict(frozen=True) diff --git a/src/model/patching/fixture_channel.py b/src/model/patching/fixture_channel.py index 0d359621..a2dab026 100644 --- a/src/model/patching/fixture_channel.py +++ b/src/model/patching/fixture_channel.py @@ -1,11 +1,18 @@ """Channels of a Fixture.""" +from __future__ import annotations + from enum import IntFlag from logging import getLogger -from typing import Final +from typing import Final, TYPE_CHECKING + +from model.ofl.ofl_fixture import CapabilityType from PySide6 import QtCore +if TYPE_CHECKING: + from model.ofl.ofl_fixture import OflFixture, ChannelTemplate + logger = getLogger(__name__) @@ -25,6 +32,7 @@ class FixtureChannelType(IntFlag): TILT = 256 ROTATION = 512 SPEED = 1024 + COLORWHEEL = 2048 # TODO vielleicht als enum @@ -33,10 +41,11 @@ class FixtureChannel: updated: QtCore.Signal(int) = QtCore.Signal(int) - def __init__(self, name: str) -> None: + def __init__(self, name: str, parent_fixture_template: OflFixture) -> None: """Initialize a fixture channel.""" self._name: Final[str] = name - self._type: Final[FixtureChannelType] = self._get_channel_type_from_string() + self._channel_template: Final[ChannelTemplate | None] = parent_fixture_template.availableChannels.get(self.name) + self._type: Final[FixtureChannelType] = self._get_channel_type_from_template_or_string() self._ignore_black = True @property @@ -59,17 +68,54 @@ def ignore_black(self) -> bool: """Ignore this channel blackout.""" return self._ignore_black + @property + def channel_template(self) -> ChannelTemplate | None: + """Returns the channel template.""" + return self._channel_template + @ignore_black.setter def ignore_black(self, ignore_black: bool) -> None: self._ignore_black = ignore_black - def _get_channel_type_from_string(self) -> FixtureChannelType: + def _get_channel_type_from_template_or_string(self) -> FixtureChannelType: """Returns the channel type.""" types: FixtureChannelType = FixtureChannelType.UNDEFINED - # TODO vielleicht aus OFL sauber extrahieren + + name = self._name.lower() + + if self._channel_template: + for capability in self._channel_template.get_capabilities(): + match capability.type: + case CapabilityType.COLOR_INTENSITY: + if "red" in name: + types |= FixtureChannelType.RED + elif "green" in name: + types |= FixtureChannelType.GREEN + elif "blue" in name: + types |= FixtureChannelType.BLUE + elif "white" in name: + types |= FixtureChannelType.WHITE + elif "amber" in name: + types |= FixtureChannelType.AMBER + elif "uv" in name: + types |= FixtureChannelType.UV + case CapabilityType.PAN: + types |= FixtureChannelType.PAN + case CapabilityType.TILT: + types |= FixtureChannelType.TILT + case CapabilityType.ROTATION: + types |= FixtureChannelType.ROTATION + case CapabilityType.SPEED | CapabilityType.EFFECT_SPEED | CapabilityType.STROBE_SPEED | CapabilityType.PAN_TILT_SPEED: + types |= FixtureChannelType.SPEED + case CapabilityType.WHEEL_SLOT: + if "color" in name: + types |= FixtureChannelType.COLORWHEEL + case _: + continue + return types + for channel_type in FixtureChannelType: - name = self._name - if str(channel_type.name).lower() in name.lower(): + if str(channel_type.name).lower() in name: types &= channel_type if channel_type in (FixtureChannelType.PAN, FixtureChannelType.TILT): types &= FixtureChannelType.POSITION From 413654f6c79e3835bba29648b5ec631820de10ef Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Thu, 2 Apr 2026 19:17:45 +0200 Subject: [PATCH 29/53] add: fixture selection in config widget --- src/model/board_configuration.py | 21 +++++++++- .../virtual_filters/color_to_colorwheel.py | 2 + src/view/dialogs/selection_dialog.py | 15 +++++-- ...r_to_colorwheel_adapter_settings_widget.py | 40 ++++++++++++++----- 4 files changed, 64 insertions(+), 14 deletions(-) diff --git a/src/model/board_configuration.py b/src/model/board_configuration.py index 0f6c03c3..aa41fe0a 100644 --- a/src/model/board_configuration.py +++ b/src/model/board_configuration.py @@ -111,7 +111,10 @@ def _delete_universe(self, universe: Universe) -> None: """ try: - del self._universes[universe.id] + id = universe.id if isinstance(universe, Universe) else universe if isinstance(universe, int) else None + if id is None: + raise ValueError("Expected a universe.") + del self._universes[id] except ValueError: logger.exception("Unable to remove universe %s", universe.name) @@ -321,3 +324,19 @@ def get_fixture(self, fixture_id: str | UUID) -> UsedFixture | None: if isinstance(fixture_id, UUID): fixture_id = str(fixture_id) return self._fixtures.get(fixture_id) + + def get_fixture_by_address(self, fixture_univ: int, fixture_chan: int) -> UsedFixture | None: + """Search for a fixture matching the provided address. + + Args: + fixture_univ: The universe of the fixture. + fixture_chan: The first 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: + return fixture + return None diff --git a/src/model/virtual_filters/color_to_colorwheel.py b/src/model/virtual_filters/color_to_colorwheel.py index 2a0bd6b5..03c1d099 100644 --- a/src/model/virtual_filters/color_to_colorwheel.py +++ b/src/model/virtual_filters/color_to_colorwheel.py @@ -34,6 +34,8 @@ def extract_colorwheel_mappings_from_fixture(f: UsedFixture, selected_slot_index if f is None: return "" color_mappings: list[tuple[float, float, int]] = [] + if len(f.colorwheel_mappings) < 1: + return "" for _, mappings in f.colorwheel_mappings[selected_slot_index]: for selection_value, color1, color2 in mappings: if color2 is not None: diff --git a/src/view/dialogs/selection_dialog.py b/src/view/dialogs/selection_dialog.py index bd928a7b..c20f39a9 100644 --- a/src/view/dialogs/selection_dialog.py +++ b/src/view/dialogs/selection_dialog.py @@ -1,15 +1,16 @@ """Contains a selection dialog.""" -from typing import override +from typing import override, Callable from PySide6.QtGui import QStandardItem, QStandardItemModel, Qt -from PySide6.QtWidgets import QDialog, QDialogButtonBox, QFormLayout, QLabel, QListView, QWidget +from PySide6.QtWidgets import QDialog, QDialogButtonBox, QFormLayout, QLabel, QListView, QWidget, QAbstractItemView class SelectionDialog(QDialog): """A dialog allowing the user to select items in a list.""" - def __init__(self, title: str, message: str, items: list[str], parent: QWidget | None = None) -> None: + def __init__(self, title: str, message: str, items: list[str], parent: QWidget | None = None, + multi_selection_allowed: bool = True, selected_callback: Callable | None = None) -> None: """Initialize the dialog. Args: @@ -17,6 +18,8 @@ def __init__(self, title: str, message: str, items: list[str], parent: QWidget | message: The displayed message or help text of the dialog. items: The list of items to present in the dialog. parent: The parent Qt widget of the dialog. + multi_selection_allowed: Whether the dialog should allow selection of multiple items. + selected_callback: Optional callback function that will be called when selection is completed. """ super().__init__(parent) @@ -28,6 +31,7 @@ def __init__(self, title: str, message: str, items: list[str], parent: QWidget | self.setWindowTitle(title) for item_name in items: item = QStandardItem(item_name) + # TODO use radio buttons if multi_selection_allowed == False item.setCheckable(True) model.appendRow(item) self.list_view.setModel(model) @@ -35,9 +39,12 @@ def __init__(self, title: str, message: str, items: list[str], parent: QWidget | QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, Qt.Orientation.Horizontal, self ) form.addRow(button_box) + if not multi_selection_allowed: + self.list_view.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) button_box.accepted.connect(self.accept) button_box.rejected.connect(self.reject) self.setModal(True) + self._selection_callable = selected_callback @property def selected_items(self) -> list[str]: @@ -54,6 +61,8 @@ def selected_items(self) -> list[str]: @override def accept(self) -> None: super().accept() + if self._selection_callable is not None: + self._selection_callable(self) self.close() @override diff --git a/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py b/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py index 4c7f14d1..29ed6340 100644 --- a/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py +++ b/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py @@ -9,12 +9,14 @@ from model import ColorHSI from model.ofl.fixture import UsedFixture +from view.dialogs.selection_dialog import SelectionDialog from view.show_mode.editor.node_editor_widgets import NodeEditorFilterConfigWidget from view.show_mode.show_ui_widgets.debug_viz_widgets import ColorLabel +from model.virtual_filters.color_to_colorwheel import extract_colorwheel_mappings_from_fixture if TYPE_CHECKING: from view.show_mode.editor.nodes import FilterNode - from model.virtual_filters.color_to_colorwheel import ColorToColorWheel, extract_colorwheel_mappings_from_fixture + from model.virtual_filters.color_to_colorwheel import ColorToColorWheel class _ColorMappingListWidgetItem(QListWidgetItem): @@ -83,7 +85,7 @@ class ColorToColorwheelAdapterSetupWidget(NodeEditorFilterConfigWidget): def __init__(self, filter: ColorToColorWheel) -> None: """Initialize the configuration widget.""" super().__init__() - self._input_dialog: _ColorSlotInputDialog | None = None + self._input_dialog: _ColorSlotInputDialog | SelectionDialog | None = None self._widget = QWidget() layout = QFormLayout() @@ -95,6 +97,7 @@ def __init__(self, filter: ColorToColorWheel) -> None: self._selected_fixture_label = QLabel("No Fixture Selected.") fixture_selection_container.layout().addWidget(self._selected_fixture_label) self._fixture_selection_or_clear_button = QPushButton("Select Fixture") + self._fixture_selection_or_clear_button.clicked.connect(self._load_from_fixture_clicked) fixture_selection_container.layout().addWidget(self._fixture_selection_or_clear_button) layout.addRow("Selected Fixture: ", fixture_selection_container) self._colorwheel_index_spinbox = QSpinBox() @@ -157,12 +160,29 @@ def _remove_mapping_clicked(self) -> None: self._color_mapping_list.takeItem(item) def _load_from_fixture_clicked(self) -> None: - # TODO show selection dialog with callback self._update_selected_fixture - pass + if self._selected_fixture is not None: + self._selected_fixture = None + self._update_selected_fixture() + return + fixture_names = [f"[{f.universe_id}:{f.start_index}] {f.name}" for f in self._filter.scene.board_configuration.fixtures] + self._input_dialog = SelectionDialog("Select Fixture", "Please select the target fixture", + fixture_names, parent=self._widget, multi_selection_allowed=False, + selected_callback=self._fixture_selected_callback) + self._input_dialog.setModal(True) + self._input_dialog.show() + + def _fixture_selected_callback(self, sd: SelectionDialog) -> None: + fixture_univ, fixture_chan = sd.selected_items[0].split("] ", 1)[0].replace("[", "").split(":") + fixture_chan = int(fixture_chan) + fixture_univ = int(fixture_univ) + self._selected_fixture = self._filter.scene.board_configuration.get_fixture_by_address(fixture_univ, fixture_chan) + self._update_selected_fixture() def _parse_color_mapping(self, mapping: str) -> None: self._color_mapping_list.clear() for entry_str in mapping.split(';'): + if len(entry_str) == 0: + continue hue, saturation, slot = entry_str.split(':') hue = float(hue) saturation = float(saturation) @@ -184,10 +204,10 @@ def _update_selected_fixture(self) -> None: self._selected_fixture_label.setText(self._selected_fixture.name) self._remove_mapping_button.setEnabled(False) self._add_mapping_button.setEnabled(False) - self._parse_color_mapping(extract_colorwheel_mappings_from_fixture( - self._selected_fixture, - selected_slot_index=self._colorwheel_index_spinbox.value() - )) + self._parse_color_mapping(extract_colorwheel_mappings_from_fixture( + self._selected_fixture, + selected_slot_index=self._colorwheel_index_spinbox.value() + )) def _compile_color_mapping_string(self) -> str: parts: list[_ColorMappingListWidgetItem] = [] @@ -230,11 +250,11 @@ def _load_configuration(self, conf: dict[str, str]) -> None: @override def _load_parameters(self, parameters: dict[str, str]) -> dict: - pass # Nothing to do here + return parameters # Nothing to do here @override def _get_parameters(self) -> dict[str, str]: - pass # Nothing to do here + return {} # Nothing to do here @override def parent_opened(self) -> None: From 2aa0ca4abed11054d9f7d5165980a6c798917ab2 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Thu, 2 Apr 2026 19:24:25 +0200 Subject: [PATCH 30/53] add: combobox options in config widget --- .../color_to_colorwheel_adapter_settings_widget.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py b/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py index 29ed6340..feb351d7 100644 --- a/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py +++ b/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py @@ -112,13 +112,16 @@ def __init__(self, filter: ColorToColorWheel) -> None: self._enable_dimmer_input_cb = QCheckBox("Enable Dimmer Input") layout.addRow("Enable Dimmer Input", self._enable_dimmer_input_cb) self._dimmer_input_datatype_combobox = QComboBox() - # TODO add options and disable other texts + self._dimmer_input_datatype_combobox.setEditable(False) + self._dimmer_input_datatype_combobox.addItems(["8bit", "16bit", "float", ""]) layout.addRow("Dimmer Input Type", self._dimmer_input_datatype_combobox) self._dimmer_output_datatype_combobox = QComboBox() - # TODO add options and disable other texts + self._dimmer_output_datatype_combobox.setEditable(False) + self._dimmer_output_datatype_combobox.addItems(["8bit", "16bit", "float", ""]) layout.addRow("Dimmer Output Type", self._dimmer_output_datatype_combobox) self._colorwheel_datatype_combobox = QComboBox() - # TODO add options and disable other texts + self._colorwheel_datatype_combobox.setEditable(False) + self._colorwheel_datatype_combobox.addItems(["8bit", "16bit"]) layout.addRow("Color Wheel Data Type", self._colorwheel_datatype_combobox) self._color_mapping_list = QListWidget() From 91cc6471ed44cd2f794232290c4ff7fa9ade0e88 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Fri, 3 Apr 2026 22:57:47 +0200 Subject: [PATCH 31/53] add: color name parsing --- src/controller/file/read.py | 3 +- src/model/__init__.py | 1 - src/model/filter_data/cues/cue.py | 3 +- .../sequencer/sequencer_channel.py | 3 +- src/model/filter_data/sequencer/transition.py | 3 +- src/model/ofl/color_name_dict.py | 24 ++++++ src/model/ofl/fixture.py | 25 ++++++- src/model/ofl/ofl_fixture.py | 75 ++++++++++++++++++- .../virtual_filters/color_to_colorwheel.py | 8 +- src/resources/data/colornames.csv | 9 +++ .../constant_update_dialog.py | 3 +- src/view/dialogs/colum_dialog.py | 2 +- src/view/dialogs/temperature_dialog.py | 2 +- .../editor/editor_tab_widgets/bankset_tab.py | 2 +- ...r_to_colorwheel_adapter_settings_widget.py | 2 +- .../cue_editor/keyframe_state_edit_dialog.py | 2 +- .../show_mode/editor/nodes/impl/constants.py | 3 +- .../color_selection_uiwidget.py | 3 +- .../show_ui_widgets/debug_viz_widgets.py | 3 +- 19 files changed, 153 insertions(+), 23 deletions(-) create mode 100644 src/model/ofl/color_name_dict.py create mode 100644 src/resources/data/colornames.csv diff --git a/src/controller/file/read.py b/src/controller/file/read.py index 6737c371..339d1d62 100644 --- a/src/controller/file/read.py +++ b/src/controller/file/read.py @@ -12,7 +12,8 @@ from controller.file.deserialization.migrations import replace_old_filter_configurations from controller.file.deserialization.post_load_operations import link_patched_fixtures from controller.utils.process_notifications import get_process_notifier -from model import BoardConfiguration, ColorHSI, Filter, Scene, UIPage, Universe +from model import BoardConfiguration, Filter, Scene, UIPage, Universe +from model.color_hsi import ColorHSI from model.control_desk import BankSet, ColorDeskColumn, FaderBank, RawDeskColumn from model.events import EventSender, mark_sender_persistent from model.filter import VirtualFilter diff --git a/src/model/__init__.py b/src/model/__init__.py index 18d851e6..e2d1378c 100644 --- a/src/model/__init__.py +++ b/src/model/__init__.py @@ -2,7 +2,6 @@ from .board_configuration import BoardConfiguration from .broadcaster import Broadcaster -from .color_hsi import ColorHSI from .device import Device from .filter import DataType, Filter from .scene import Scene diff --git a/src/model/filter_data/cues/cue.py b/src/model/filter_data/cues/cue.py index 4ae8056a..9bdd559f 100644 --- a/src/model/filter_data/cues/cue.py +++ b/src/model/filter_data/cues/cue.py @@ -18,7 +18,8 @@ from logging import getLogger from typing import TYPE_CHECKING, Never, Union, override -from model import ColorHSI, DataType +from model import DataType +from model.color_hsi import ColorHSI from model.filter_data.transfer_function import TransferFunction from model.filter_data.utility import format_seconds diff --git a/src/model/filter_data/sequencer/sequencer_channel.py b/src/model/filter_data/sequencer/sequencer_channel.py index fd83fdfa..9e582f73 100644 --- a/src/model/filter_data/sequencer/sequencer_channel.py +++ b/src/model/filter_data/sequencer/sequencer_channel.py @@ -5,7 +5,8 @@ from enum import Enum from logging import getLogger -from model import ColorHSI, DataType +from model import DataType +from model.color_hsi import ColorHSI from model.filter_data.sequencer._utils import _rf logger = getLogger(__name__) diff --git a/src/model/filter_data/sequencer/transition.py b/src/model/filter_data/sequencer/transition.py index 13f07211..319136c9 100644 --- a/src/model/filter_data/sequencer/transition.py +++ b/src/model/filter_data/sequencer/transition.py @@ -11,7 +11,8 @@ from types import MappingProxyType from typing import TYPE_CHECKING -from model import ColorHSI, DataType +from model import DataType +from model.color_hsi import ColorHSI from model.filter_data.cues.cue import Cue, KeyFrame, State, StateColor, StateDouble, StateEightBit, StateSixteenBit from model.filter_data.sequencer._utils import _rf from model.filter_data.transfer_function import TransferFunction diff --git a/src/model/ofl/color_name_dict.py b/src/model/ofl/color_name_dict.py new file mode 100644 index 00000000..d0fd9d80 --- /dev/null +++ b/src/model/ofl/color_name_dict.py @@ -0,0 +1,24 @@ +import csv +import os +from logging import getLogger +from typing import TYPE_CHECKING + +from utility import resource_path + +from model.color_hsi import ColorHSI + +logger = getLogger(__name__) +_COLOR_DICT: dict[str, tuple[float, float, float]] = {} + +with open(resource_path(os.path.join("resources", "data", "colornames.csv")), "r") as f: + for row in csv.reader(f, delimiter=";"): + name, hue, saturation, value = row + _COLOR_DICT[name.lower()] = (float(hue), float(saturation), float(value)) + +def get_color_by_name(name: str) -> ColorHSI: + """This method queries the color name database and returns black if none was found.""" + color_tuple = _COLOR_DICT.get(name.lower()) + if color_tuple is not None: + return ColorHSI(color_tuple[0], color_tuple[1], color_tuple[2]) + logger.warning("No color name found for %s", name) + return ColorHSI(0, 0, 0) \ No newline at end of file diff --git a/src/model/ofl/fixture.py b/src/model/ofl/fixture.py index d14ca2f3..e5205a37 100644 --- a/src/model/ofl/fixture.py +++ b/src/model/ofl/fixture.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import math import os import random from collections import defaultdict @@ -71,15 +72,33 @@ def _load_colorwheel_mappings(f: OflFixture, channels: list[FixtureChannel]) -> """Load color wheel mappings from OFL model.""" l = [] for channel in channels: + fcl: list[tuple[int, ColorHSI, ColorHSI | None]] = [] if not channel.type == FixtureChannelType.COLORWHEEL: continue if channel.channel_template is None: logger.error("The channel %s is a color wheel but the template was not found.", channel.name) continue + color_wheel = f.wheels.get(channel.name) + if color_wheel is None: + logger.warning("The channel %s is has a color wheel but the wheel definition was not found.", + channel.name) + continue for capability in channel.channel_template.get_capabilities(): if capability.type == CapabilityType.WHEEL_SLOT: - # TODO query parameters, add them to the list - pass + slot_number = capability.capabilityProperties.get("slotNumber") + if len(capability.dmxRange) > 1: + capability_dmx_value = int((capability.dmxRange[0] + capability.dmxRange[1]) / 2) + else: + capability_dmx_value = capability.dmxRange[0] + if isinstance(slot_number, int): + wheel_slot = color_wheel.slots[slot_number % len(color_wheel.slots)] + fcl.append((capability_dmx_value, wheel_slot, None)) + else: + wheel_slot_a = color_wheel.slots[math.floor(slot_number) % len(color_wheel.slots)] + wheel_slot_b = color_wheel.slots[math.ceil(slot_number) % len(color_wheel.slots)] + fcl.append((capability_dmx_value, wheel_slot_a, wheel_slot_b)) + if len(fcl) > 0: + l.append((channel, fcl)) return l class UsedFixture(QtCore.QObject): @@ -125,7 +144,7 @@ def __init__( self._color_support: Final[ColorSupport] = color_support self._colorwheel_mappings: list[tuple[FixtureChannel, list[tuple[int, ColorHSI, ColorHSI | None]]]] = \ - _load_colorwheel_mappings(OflFixture, self._fixture_channels) + _load_colorwheel_mappings(fixture, self._fixture_channels) self._color_on_stage: str = ( color if color else "#" + "".join([random.choice("0123456789ABCDEF") for _ in range(6)]) # noqa: S311 not a secret diff --git a/src/model/ofl/ofl_fixture.py b/src/model/ofl/ofl_fixture.py index 0a96cafa..e206d9df 100644 --- a/src/model/ofl/ofl_fixture.py +++ b/src/model/ofl/ofl_fixture.py @@ -5,9 +5,15 @@ from enum import Enum from logging import getLogger -from typing import Literal +from typing import Literal, Any, TYPE_CHECKING from pydantic import BaseModel, ConfigDict +from setuptools.wheel import Wheel + +from model.ofl.color_name_dict import get_color_by_name + +if TYPE_CHECKING: + from model.color_hsi import ColorHSI logger = getLogger(__name__) @@ -212,8 +218,20 @@ class Capability(BaseModel): comment: str = "" """Description of the capability if not obvious.""" - # TODO how do we model the settings? for example a wheel slot has the parameter "slotNumber" and the linked wheel - # (found by the name of the channel) should be linked in order to let the software fetch the color or gobo picture + capabilityProperties: dict[str, Any] = {} + """Contains the properties of the capability.""" + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + if "capabilityProperties" in kwargs: + raise ValueError("Fixme: this should not be a property of the OFL JSON model.") + + # This is an ugly hack. Once we know how to deal with the fact that the capabilities are only available based + # on the specified type, we should improve this. + for k, v in kwargs.items(): + if k not in ["type", "comment", "dmxRange"]: + self.capabilityProperties[k] = v class ChannelTemplate(BaseModel): @@ -244,6 +262,54 @@ def get_capabilities(self) -> list[Capability]: return [self.capability] if self.capability is not None else [] +class WheelSlotType(Enum): + """The type of wheel slot.""" + + GENERIC = "" + OPEN = "Open" + COLOR = "Color" + GOBO = "Gobo" + ANIMATED_GOBO_START = "AnimationGoboStart" + ANIMATED_GOBO_END = "AnimationGoboEnd" + IRIS = "Iris" + PRISM = "Prism" + FROST = "Frost" + CLOSED = "Closed" + + +class WheelSlot(BaseModel): + """Defines a rotatable wheel slot.""" + + type: WheelSlotType = WheelSlotType.GENERIC + """The type of wheel slot.""" + + name: str = "" + + colorTemperature: str = "" + + resource: dict[str, Any] = {} + """Contains the gobo image, if any.""" + + @property + def resulting_color(self) -> ColorHSI: + """Returns the color of the wheel slot.""" + # query color parameters and use name as last resort + if self.type == WheelSlotType.OPEN: + return get_color_by_name("white") + elif self.type == WheelSlotType.CLOSED: + return get_color_by_name("black") + # TODO figure out what to do for hex color codes + # TODO query color temperature + return get_color_by_name(self.name) + + +class Wheel(BaseModel): + """Defines a rotatable wheel.""" + + slots: list[WheelSlot] = [] + """The slots of the wheel.""" + + class OflFixture(BaseModel): """Complete fixture definition conforming to the Open Fixture Library schema.""" @@ -297,4 +363,7 @@ class OflFixture(BaseModel): availableChannels: dict[str, ChannelTemplate] = {} """Contains the capability mappings of the channels.""" + wheels: dict[str, Wheel] = {} + """Containts the rotatable wheels""" + model_config = ConfigDict(frozen=True) diff --git a/src/model/virtual_filters/color_to_colorwheel.py b/src/model/virtual_filters/color_to_colorwheel.py index 03c1d099..55f79e3d 100644 --- a/src/model/virtual_filters/color_to_colorwheel.py +++ b/src/model/virtual_filters/color_to_colorwheel.py @@ -25,7 +25,7 @@ def extract_colorwheel_mappings_from_fixture(f: UsedFixture, selected_slot_index Args: f: The fixture to extract the filter configuration from. - selected_slot_index: Which color wheel slot of the fixture should be used? + selected_slot_index: Which color wheel of the fixture should be used? Returns: A string usable as the color-mappings property of the filter configuration. @@ -34,9 +34,11 @@ def extract_colorwheel_mappings_from_fixture(f: UsedFixture, selected_slot_index if f is None: return "" color_mappings: list[tuple[float, float, int]] = [] - if len(f.colorwheel_mappings) < 1: + template_colorwheel_mappings = f.colorwheel_mappings + if len(template_colorwheel_mappings) < 1: return "" - for _, mappings in f.colorwheel_mappings[selected_slot_index]: + # FIXME for some reason, the tuple stored in the colorwheel mappings gets automatically unpacked to the first entry + for _, mappings in template_colorwheel_mappings[selected_slot_index]: for selection_value, color1, color2 in mappings: if color2 is not None: # We have a value that is in the middle between two slots. diff --git a/src/resources/data/colornames.csv b/src/resources/data/colornames.csv new file mode 100644 index 00000000..51c6af85 --- /dev/null +++ b/src/resources/data/colornames.csv @@ -0,0 +1,9 @@ +red;0.0;1.0;1.0 +green;120.0;1.0;1.0 +blue;240.0;1.0;1.0 +white;0.0;0.0;1.0 +black;0.0;0.0;0.0 +dark blue;240.0;1.0;0.3 +yellow;59.0;1.0;0.6 +light blue;240.0;0.5;1.0 +magenta;300.0;1.0;0.6 \ No newline at end of file diff --git a/src/view/action_setup_view/constant_update_dialog.py b/src/view/action_setup_view/constant_update_dialog.py index cbb562ea..da2102ba 100644 --- a/src/view/action_setup_view/constant_update_dialog.py +++ b/src/view/action_setup_view/constant_update_dialog.py @@ -15,7 +15,8 @@ QWidget, ) -from model import BoardConfiguration, ColorHSI +from model import BoardConfiguration +from model.color_hsi import ColorHSI from model.filter import FilterTypeEnumeration from model.macro import Macro from view.action_setup_view._command_insertion_dialog import _CommandInsertionDialog diff --git a/src/view/dialogs/colum_dialog.py b/src/view/dialogs/colum_dialog.py index 5e14c6b3..6648184a 100644 --- a/src/view/dialogs/colum_dialog.py +++ b/src/view/dialogs/colum_dialog.py @@ -2,7 +2,7 @@ from PySide6 import QtWidgets from PySide6.QtCore import Qt -from model import ColorHSI +from model.color_hsi import ColorHSI from model.broadcaster import Broadcaster from model.control_desk import BankSet, ColorDeskColumn from view.dialogs.temperature_dialog import TemperatureDialog diff --git a/src/view/dialogs/temperature_dialog.py b/src/view/dialogs/temperature_dialog.py index 9761d7e4..27d7805a 100644 --- a/src/view/dialogs/temperature_dialog.py +++ b/src/view/dialogs/temperature_dialog.py @@ -2,7 +2,7 @@ import numpy as np from PySide6 import QtCore, QtGui, QtWidgets -from model import ColorHSI +from model.color_hsi import ColorHSI from model.control_desk import BankSet, ColorDeskColumn diff --git a/src/view/show_mode/editor/editor_tab_widgets/bankset_tab.py b/src/view/show_mode/editor/editor_tab_widgets/bankset_tab.py index de2d3f20..a4a9c70a 100644 --- a/src/view/show_mode/editor/editor_tab_widgets/bankset_tab.py +++ b/src/view/show_mode/editor/editor_tab_widgets/bankset_tab.py @@ -16,7 +16,7 @@ QWidget, ) -from model import ColorHSI +from model.color_hsi import ColorHSI from model.control_desk import BankSet, ColorDeskColumn, FaderBank, RawDeskColumn diff --git a/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py b/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py index feb351d7..2aac672a 100644 --- a/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py +++ b/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py @@ -7,7 +7,7 @@ from PySide6.QtWidgets import QWidget, QFormLayout, QHBoxLayout, QLabel, QPushButton, QComboBox, QCheckBox, QListWidget, \ QSpinBox, QSpacerItem, QSizePolicy, QListWidgetItem, QDialog, QDoubleSpinBox, QDialogButtonBox -from model import ColorHSI +from model.color_hsi import ColorHSI from model.ofl.fixture import UsedFixture from view.dialogs.selection_dialog import SelectionDialog from view.show_mode.editor.node_editor_widgets import NodeEditorFilterConfigWidget diff --git a/src/view/show_mode/editor/node_editor_widgets/cue_editor/keyframe_state_edit_dialog.py b/src/view/show_mode/editor/node_editor_widgets/cue_editor/keyframe_state_edit_dialog.py index c6ce27b1..8424bb3b 100644 --- a/src/view/show_mode/editor/node_editor_widgets/cue_editor/keyframe_state_edit_dialog.py +++ b/src/view/show_mode/editor/node_editor_widgets/cue_editor/keyframe_state_edit_dialog.py @@ -14,7 +14,7 @@ QWidget, ) -from model import ColorHSI +from model.color_hsi import ColorHSI from model.filter_data.cues.cue import KeyFrame, State, StateColor, StateDouble, StateEightBit, StateSixteenBit diff --git a/src/view/show_mode/editor/nodes/impl/constants.py b/src/view/show_mode/editor/nodes/impl/constants.py index 629030b2..bec32629 100644 --- a/src/view/show_mode/editor/nodes/impl/constants.py +++ b/src/view/show_mode/editor/nodes/impl/constants.py @@ -3,7 +3,8 @@ from PySide6.QtGui import QBrush, QColor, QFontMetrics, QPainter -from model import ColorHSI, DataType, Scene +from model import DataType, Scene +from model.color_hsi import ColorHSI from model.filter import Filter, FilterTypeEnumeration from view.show_mode.editor.nodes.base.filternode import FilterNode diff --git a/src/view/show_mode/show_ui_widgets/color_selection_uiwidget.py b/src/view/show_mode/show_ui_widgets/color_selection_uiwidget.py index 599926b6..a59438df 100644 --- a/src/view/show_mode/show_ui_widgets/color_selection_uiwidget.py +++ b/src/view/show_mode/show_ui_widgets/color_selection_uiwidget.py @@ -12,7 +12,8 @@ QWidget, ) -from model import ColorHSI, Filter, UIPage, UIWidget +from model import Filter, UIPage, UIWidget +from model.color_hsi import ColorHSI class ColorSelectionUIWidget(UIWidget): diff --git a/src/view/show_mode/show_ui_widgets/debug_viz_widgets.py b/src/view/show_mode/show_ui_widgets/debug_viz_widgets.py index 0e9a02d4..3fa30cbc 100644 --- a/src/view/show_mode/show_ui_widgets/debug_viz_widgets.py +++ b/src/view/show_mode/show_ui_widgets/debug_viz_widgets.py @@ -15,7 +15,8 @@ from PySide6.QtGui import QColor, QPainter, QPaintEvent from PySide6.QtWidgets import QComboBox, QDialog, QFormLayout, QHBoxLayout, QLabel, QSpinBox, QWidget -from model import ColorHSI, UIWidget +from model import UIWidget +from model.color_hsi import ColorHSI if TYPE_CHECKING: from collections.abc import Callable From f91c98955a9ed92bb290d323510c2b896fdcbb53 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Sat, 4 Apr 2026 20:35:05 +0200 Subject: [PATCH 32/53] fix: fixture color wheel decoding --- src/model/virtual_filters/color_to_colorwheel.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/model/virtual_filters/color_to_colorwheel.py b/src/model/virtual_filters/color_to_colorwheel.py index 55f79e3d..0d314687 100644 --- a/src/model/virtual_filters/color_to_colorwheel.py +++ b/src/model/virtual_filters/color_to_colorwheel.py @@ -37,13 +37,14 @@ def extract_colorwheel_mappings_from_fixture(f: UsedFixture, selected_slot_index template_colorwheel_mappings = f.colorwheel_mappings if len(template_colorwheel_mappings) < 1: return "" - # FIXME for some reason, the tuple stored in the colorwheel mappings gets automatically unpacked to the first entry - for _, mappings in template_colorwheel_mappings[selected_slot_index]: - for selection_value, color1, color2 in mappings: - if color2 is not None: - # We have a value that is in the middle between two slots. - continue - color_mappings.append((color1.hue, color1.saturation, selection_value)) + template_colorwheel_mappings = template_colorwheel_mappings[selected_slot_index] + mappings = template_colorwheel_mappings[1] + for selection_value, color1, color2 in mappings: + if color2 is not None: + # We have a value that is in the middle between two slots. + continue + resulting_color = color1.resulting_color + color_mappings.append((resulting_color.hue, resulting_color.saturation, selection_value)) return ";".join([f"{hue}:{saturation}:{slot}" for hue, saturation, slot in color_mappings]) From 0b582f1b058aebbfd332ca9b3060310bebbd9d58 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Sun, 5 Apr 2026 11:41:08 +0200 Subject: [PATCH 33/53] fix: disappearing FSI after removing dimmer channels --- .../show_mode/editor/filter_settings_item.py | 9 +++- ...r_to_colorwheel_adapter_settings_widget.py | 1 + .../node_editor_widgets/node_editor_widget.py | 2 + .../show_mode/editor/nodes/impl/adapters.py | 46 ++++++++++++------- 4 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/view/show_mode/editor/filter_settings_item.py b/src/view/show_mode/editor/filter_settings_item.py index 31254c0b..6826738e 100644 --- a/src/view/show_mode/editor/filter_settings_item.py +++ b/src/view/show_mode/editor/filter_settings_item.py @@ -60,11 +60,16 @@ def __init__(self, filter_node: FilterNode, parent: QGraphicsItem, filter_: Filt self._dialog = None self._filter_node = filter_node self._on_update = lambda: None + self._parent = parent self.setScale(0.2) - self.moveBy(parent.boundingRect().width() / 2 - 6, parent.boundingRect().height() - 20) + self.update_position() self._filter = filter_ self._mb_updated: bool = False + def update_position(self): + self.setPos(0, 0) + self.moveBy(self._parent.boundingRect().width() / 2 - 6, self._parent.boundingRect().height() - 20) + @override def focusOutEvent(self, ev: QFocusEvent) -> None: super().focusOutEvent(ev) @@ -237,6 +242,8 @@ def closeEvent(self, arg__1: QCloseEvent) -> None: self._special_widget.parent_closed(self._filter_node) else: self._filter_node.update_node_after_settings_changed() + if self._filter_node.fsi is not None: + self._filter_node.fsi.update_position() super().closeEvent(arg__1) @override diff --git a/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py b/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py index 2aac672a..e0f4c3eb 100644 --- a/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py +++ b/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py @@ -31,6 +31,7 @@ def __init__(self, parent: QListWidget, color: ColorHSI, slot_value: int): widget = QWidget() widget.setLayout(QHBoxLayout()) self._color_label = ColorLabel() + self._color_label.setFixedWidth(16) self._color_label.set_color(self.color) widget.layout().addWidget(self._color_label) self._slot_label = QLabel(str(slot_value)) diff --git a/src/view/show_mode/editor/node_editor_widgets/node_editor_widget.py b/src/view/show_mode/editor/node_editor_widgets/node_editor_widget.py index 9ebaa57a..701a79dc 100644 --- a/src/view/show_mode/editor/node_editor_widgets/node_editor_widget.py +++ b/src/view/show_mode/editor/node_editor_widgets/node_editor_widget.py @@ -55,6 +55,8 @@ def parent_closed(self, filter_node: "FilterNode") -> None: Arguments: filter_node -- might be used to alter the filter being presented.""" filter_node.update_node_after_settings_changed() + if filter_node.fsi is not None: + filter_node.fsi.update_position() @abstractmethod def parent_opened(self) -> None: diff --git a/src/view/show_mode/editor/nodes/impl/adapters.py b/src/view/show_mode/editor/nodes/impl/adapters.py index 35d97121..d89c850b 100644 --- a/src/view/show_mode/editor/nodes/impl/adapters.py +++ b/src/view/show_mode/editor/nodes/impl/adapters.py @@ -3,6 +3,7 @@ from model import DataType, Scene from model.filter import Filter, FilterTypeEnumeration +from view.show_mode.editor.filter_settings_item import FilterSettingsItem from view.show_mode.editor.nodes.base.filternode import FilterNode @@ -326,8 +327,6 @@ def __init__(self, model: Filter | Scene, name: str) -> None: super().__init__(model=model, filter_type=FilterTypeEnumeration.VFILTER_COLOR_TO_COLORWHEEL, name=name, terminals={ "input": {"io": "in"}, - "in_dimmer": {"io": "in"}, - "dimmer": {"io": "out"}, "colorwheel": {"io": "out"} }) self.update_node_after_settings_changed() @@ -335,20 +334,35 @@ def __init__(self, model: Filter | Scene, name: str) -> None: @override def update_node_after_settings_changed(self): self.filter.in_data_types["input"] = DataType.DT_COLOR - match self.filter.filter_configurations.get("dimmer-input"): - case "16bit": - self.filter.in_data_types["in_dimmer"] = DataType.DT_16_BIT - case "float": - self.filter.in_data_types["in_dimmer"] = DataType.DT_DOUBLE - case _: - self.filter.in_data_types["in_dimmer"] = DataType.DT_8_BIT - match self.filter.filter_configurations.get("dimmer-output"): - case "16bit": - self.filter.out_data_types["dimmer"] = DataType.DT_16_BIT - case "float": - self.filter.out_data_types["dimmer"] = DataType.DT_DOUBLE - case _: - self.filter.out_data_types["dimmer"] = DataType.DT_8_BIT + + dimmer_input_dt_str = self.filter.filter_configurations.get("dimmer-input", "") + if len(dimmer_input_dt_str) > 0: + if "in_dimmer" not in self.inputs().keys(): + self.addInput("in_dimmer") + match dimmer_input_dt_str: + case "16bit": + self.filter.in_data_types["in_dimmer"] = DataType.DT_16_BIT + case "float": + self.filter.in_data_types["in_dimmer"] = DataType.DT_DOUBLE + case _: + self.filter.in_data_types["in_dimmer"] = DataType.DT_8_BIT + else: + if "in_dimmer" in self.inputs().keys(): + self.removeTerminal("in_dimmer") + dimmer_output_dt_str = self.filter.filter_configurations.get("dimmer-output", "") + if len(dimmer_output_dt_str) > 0: + if "dimmer" not in self.outputs().keys(): + self.addOutput("dimmer") + match dimmer_output_dt_str: + case "16bit": + self.filter.out_data_types["dimmer"] = DataType.DT_16_BIT + case "float": + self.filter.out_data_types["dimmer"] = DataType.DT_DOUBLE + case _: + self.filter.out_data_types["dimmer"] = DataType.DT_8_BIT + else: + if "dimmer" in self.outputs().keys(): + self.removeTerminal("dimmer") match self.filter.filter_configurations.get("colorwheel-datatype"): case "16bit": self.filter.out_data_types["colorwheel"] = DataType.DT_16_BIT From c28c383d9d27d546905bea9cdc90bf1f39a0dde3 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Sun, 5 Apr 2026 11:53:40 +0200 Subject: [PATCH 34/53] fix: first half of ruff issues --- src/model/board_configuration.py | 6 +-- src/model/ofl/color_name_dict.py | 5 +- src/model/ofl/fixture.py | 10 ++-- src/model/ofl/ofl_fixture.py | 8 ++-- src/model/patching/fixture_channel.py | 11 +++-- src/view/dialogs/colum_dialog.py | 2 +- src/view/dialogs/selection_dialog.py | 5 +- .../show_mode/editor/filter_settings_item.py | 3 +- ...r_to_colorwheel_adapter_settings_widget.py | 48 ++++++++++++------- .../editor/nodes/filter_node_library.py | 3 +- .../editor/nodes/type_to_node_dict.py | 3 +- 11 files changed, 62 insertions(+), 42 deletions(-) diff --git a/src/model/board_configuration.py b/src/model/board_configuration.py index aa41fe0a..be46c962 100644 --- a/src/model/board_configuration.py +++ b/src/model/board_configuration.py @@ -111,10 +111,10 @@ def _delete_universe(self, universe: Universe) -> None: """ try: - id = universe.id if isinstance(universe, Universe) else universe if isinstance(universe, int) else None - if id is None: + uid = universe.id if isinstance(universe, Universe) else universe if isinstance(universe, int) else None + if uid is None: raise ValueError("Expected a universe.") - del self._universes[id] + del self._universes[uid] except ValueError: logger.exception("Unable to remove universe %s", universe.name) diff --git a/src/model/ofl/color_name_dict.py b/src/model/ofl/color_name_dict.py index d0fd9d80..20a0b730 100644 --- a/src/model/ofl/color_name_dict.py +++ b/src/model/ofl/color_name_dict.py @@ -3,9 +3,8 @@ from logging import getLogger from typing import TYPE_CHECKING -from utility import resource_path - from model.color_hsi import ColorHSI +from utility import resource_path logger = getLogger(__name__) _COLOR_DICT: dict[str, tuple[float, float, float]] = {} @@ -21,4 +20,4 @@ def get_color_by_name(name: str) -> ColorHSI: if color_tuple is not None: return ColorHSI(color_tuple[0], color_tuple[1], color_tuple[2]) logger.warning("No color name found for %s", name) - return ColorHSI(0, 0, 0) \ No newline at end of file + return ColorHSI(0, 0, 0) diff --git a/src/model/ofl/fixture.py b/src/model/ofl/fixture.py index e5205a37..70237e4b 100644 --- a/src/model/ofl/fixture.py +++ b/src/model/ofl/fixture.py @@ -15,7 +15,7 @@ import numpy as np from PySide6 import QtCore -from model.ofl.ofl_fixture import FixtureMode, MatrixChannelInsert, OflFixture, CapabilityType +from model.ofl.ofl_fixture import CapabilityType, FixtureMode, MatrixChannelInsert, OflFixture from model.patching.fixture_channel import FixtureChannel, FixtureChannelType if TYPE_CHECKING: @@ -70,10 +70,10 @@ def load_fixture(file: str) -> OflFixture | None: def _load_colorwheel_mappings(f: OflFixture, channels: list[FixtureChannel]) -> \ list[tuple[FixtureChannel, list[tuple[int, ColorHSI, ColorHSI | None]]]]: """Load color wheel mappings from OFL model.""" - l = [] + outer_mapping_list = [] for channel in channels: fcl: list[tuple[int, ColorHSI, ColorHSI | None]] = [] - if not channel.type == FixtureChannelType.COLORWHEEL: + if channel.type != FixtureChannelType.COLORWHEEL: continue if channel.channel_template is None: logger.error("The channel %s is a color wheel but the template was not found.", channel.name) @@ -98,8 +98,8 @@ def _load_colorwheel_mappings(f: OflFixture, channels: list[FixtureChannel]) -> wheel_slot_b = color_wheel.slots[math.ceil(slot_number) % len(color_wheel.slots)] fcl.append((capability_dmx_value, wheel_slot_a, wheel_slot_b)) if len(fcl) > 0: - l.append((channel, fcl)) - return l + outer_mapping_list.append((channel, fcl)) + return outer_mapping_list class UsedFixture(QtCore.QObject): """Fixture in use with a specific mode.""" diff --git a/src/model/ofl/ofl_fixture.py b/src/model/ofl/ofl_fixture.py index e206d9df..aa4346b9 100644 --- a/src/model/ofl/ofl_fixture.py +++ b/src/model/ofl/ofl_fixture.py @@ -5,10 +5,9 @@ from enum import Enum from logging import getLogger -from typing import Literal, Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Literal from pydantic import BaseModel, ConfigDict -from setuptools.wheel import Wheel from model.ofl.color_name_dict import get_color_by_name @@ -221,7 +220,8 @@ class Capability(BaseModel): capabilityProperties: dict[str, Any] = {} """Contains the properties of the capability.""" - def __init__(self, *args, **kwargs) -> None: + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Overrides default constructor and populates capability settings.""" super().__init__(*args, **kwargs) if "capabilityProperties" in kwargs: @@ -296,7 +296,7 @@ def resulting_color(self) -> ColorHSI: # query color parameters and use name as last resort if self.type == WheelSlotType.OPEN: return get_color_by_name("white") - elif self.type == WheelSlotType.CLOSED: + if self.type == WheelSlotType.CLOSED: return get_color_by_name("black") # TODO figure out what to do for hex color codes # TODO query color temperature diff --git a/src/model/patching/fixture_channel.py b/src/model/patching/fixture_channel.py index a2dab026..f6376a73 100644 --- a/src/model/patching/fixture_channel.py +++ b/src/model/patching/fixture_channel.py @@ -4,14 +4,14 @@ from enum import IntFlag from logging import getLogger -from typing import Final, TYPE_CHECKING - -from model.ofl.ofl_fixture import CapabilityType +from typing import TYPE_CHECKING, Final from PySide6 import QtCore +from model.ofl.ofl_fixture import CapabilityType + if TYPE_CHECKING: - from model.ofl.ofl_fixture import OflFixture, ChannelTemplate + from model.ofl.ofl_fixture import ChannelTemplate, OflFixture logger = getLogger(__name__) @@ -105,7 +105,8 @@ def _get_channel_type_from_template_or_string(self) -> FixtureChannelType: types |= FixtureChannelType.TILT case CapabilityType.ROTATION: types |= FixtureChannelType.ROTATION - case CapabilityType.SPEED | CapabilityType.EFFECT_SPEED | CapabilityType.STROBE_SPEED | CapabilityType.PAN_TILT_SPEED: + case CapabilityType.SPEED | CapabilityType.EFFECT_SPEED | CapabilityType.STROBE_SPEED | \ + CapabilityType.PAN_TILT_SPEED: types |= FixtureChannelType.SPEED case CapabilityType.WHEEL_SLOT: if "color" in name: diff --git a/src/view/dialogs/colum_dialog.py b/src/view/dialogs/colum_dialog.py index 6648184a..98d0d02c 100644 --- a/src/view/dialogs/colum_dialog.py +++ b/src/view/dialogs/colum_dialog.py @@ -2,8 +2,8 @@ from PySide6 import QtWidgets from PySide6.QtCore import Qt -from model.color_hsi import ColorHSI from model.broadcaster import Broadcaster +from model.color_hsi import ColorHSI from model.control_desk import BankSet, ColorDeskColumn from view.dialogs.temperature_dialog import TemperatureDialog diff --git a/src/view/dialogs/selection_dialog.py b/src/view/dialogs/selection_dialog.py index c20f39a9..f52ad948 100644 --- a/src/view/dialogs/selection_dialog.py +++ b/src/view/dialogs/selection_dialog.py @@ -1,9 +1,10 @@ """Contains a selection dialog.""" -from typing import override, Callable +from collections.abc import Callable +from typing import override from PySide6.QtGui import QStandardItem, QStandardItemModel, Qt -from PySide6.QtWidgets import QDialog, QDialogButtonBox, QFormLayout, QLabel, QListView, QWidget, QAbstractItemView +from PySide6.QtWidgets import QAbstractItemView, QDialog, QDialogButtonBox, QFormLayout, QLabel, QListView, QWidget class SelectionDialog(QDialog): diff --git a/src/view/show_mode/editor/filter_settings_item.py b/src/view/show_mode/editor/filter_settings_item.py index 6826738e..ccca6fd5 100644 --- a/src/view/show_mode/editor/filter_settings_item.py +++ b/src/view/show_mode/editor/filter_settings_item.py @@ -66,7 +66,8 @@ def __init__(self, filter_node: FilterNode, parent: QGraphicsItem, filter_: Filt self._filter = filter_ self._mb_updated: bool = False - def update_position(self): + def update_position(self) -> None: + """Updates the position of the button after the filter node size changed.""" self.setPos(0, 0) self.moveBy(self._parent.boundingRect().width() / 2 - 6, self._parent.boundingRect().height() - 20) diff --git a/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py b/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py index e0f4c3eb..8e7c7787 100644 --- a/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py +++ b/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py @@ -2,27 +2,41 @@ from __future__ import annotations -from typing import override, TYPE_CHECKING - -from PySide6.QtWidgets import QWidget, QFormLayout, QHBoxLayout, QLabel, QPushButton, QComboBox, QCheckBox, QListWidget, \ - QSpinBox, QSpacerItem, QSizePolicy, QListWidgetItem, QDialog, QDoubleSpinBox, QDialogButtonBox +from typing import TYPE_CHECKING, override + +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QDoubleSpinBox, + QFormLayout, + QHBoxLayout, + QLabel, + QListWidget, + QListWidgetItem, + QPushButton, + QSizePolicy, + QSpacerItem, + QSpinBox, + QWidget, +) from model.color_hsi import ColorHSI -from model.ofl.fixture import UsedFixture +from model.virtual_filters.color_to_colorwheel import extract_colorwheel_mappings_from_fixture from view.dialogs.selection_dialog import SelectionDialog from view.show_mode.editor.node_editor_widgets import NodeEditorFilterConfigWidget from view.show_mode.show_ui_widgets.debug_viz_widgets import ColorLabel -from model.virtual_filters.color_to_colorwheel import extract_colorwheel_mappings_from_fixture if TYPE_CHECKING: - from view.show_mode.editor.nodes import FilterNode from model.virtual_filters.color_to_colorwheel import ColorToColorWheel + from model.ofl.fixture import UsedFixture class _ColorMappingListWidgetItem(QListWidgetItem): """Purpose of this widget is to display a single mapping.""" - def __init__(self, parent: QListWidget, color: ColorHSI, slot_value: int): + def __init__(self, parent: QListWidget, color: ColorHSI, slot_value: int) -> None: """Initializes and adds the widget based on the provided color and slot value.""" super().__init__() self.color = color @@ -45,7 +59,7 @@ def __init__(self, parent: QListWidget, color: ColorHSI, slot_value: int): class _ColorSlotInputDialog(QDialog): """Query a color and a slot.""" - def __init__(self, parent: QWidget, list_widget: QListWidget): + def __init__(self, parent: QWidget, list_widget: QListWidget) -> None: """Initializes the dialog.""" super().__init__(parent) @@ -73,7 +87,7 @@ def __init__(self, parent: QWidget, list_widget: QListWidget): self.setLayout(layout) @override - def accept(self): + def accept(self) -> None: _ColorMappingListWidgetItem(self._list_widget, ColorHSI(self._hue_tb.value(), self._saturation_tb.value(), 0.5), self._slot_tb.value()) @@ -83,7 +97,7 @@ def accept(self): class ColorToColorwheelAdapterSetupWidget(NodeEditorFilterConfigWidget): """Configuration widget for color to colorwheel vfilter.""" - def __init__(self, filter: ColorToColorWheel) -> None: + def __init__(self, _filter: ColorToColorWheel) -> None: """Initialize the configuration widget.""" super().__init__() self._input_dialog: _ColorSlotInputDialog | SelectionDialog | None = None @@ -91,7 +105,7 @@ def __init__(self, filter: ColorToColorWheel) -> None: layout = QFormLayout() self._selected_fixture: UsedFixture | None = None - self._filter = filter + self._filter = _filter fixture_selection_container = QWidget(parent=self._widget) fixture_selection_container.setLayout(QHBoxLayout()) @@ -144,7 +158,9 @@ def __init__(self, filter: ColorToColorWheel) -> None: self._wheel_speed_spinbox.setMinimum(0) self._wheel_speed_spinbox.setMaximum(65565) self._wheel_speed_spinbox.setValue(300) - self._wheel_speed_spinbox.setToolTip("How many ms does it take to switch between two adjacent color wheel slots?") + self._wheel_speed_spinbox.setToolTip( + "How many ms does it take to switch between two adjacent color wheel slots?" + ) layout.addRow("Color Wheel Speed [ms]", self._wheel_speed_spinbox) self._dimm_when_wheel_is_moving_cb = QCheckBox("Dim when Wheel Moving") self._dimm_when_wheel_is_moving_cb.setChecked(True) @@ -184,10 +200,10 @@ def _fixture_selected_callback(self, sd: SelectionDialog) -> None: def _parse_color_mapping(self, mapping: str) -> None: self._color_mapping_list.clear() - for entry_str in mapping.split(';'): + for entry_str in mapping.split(";"): if len(entry_str) == 0: continue - hue, saturation, slot = entry_str.split(':') + hue, saturation, slot = entry_str.split(":") hue = float(hue) saturation = float(saturation) slot = int(slot) @@ -262,4 +278,4 @@ def _get_parameters(self) -> dict[str, str]: @override def parent_opened(self) -> None: - pass # Nothing to do here \ No newline at end of file + pass # Nothing to do here diff --git a/src/view/show_mode/editor/nodes/filter_node_library.py b/src/view/show_mode/editor/nodes/filter_node_library.py index 4b6bcfc7..858e1dac 100644 --- a/src/view/show_mode/editor/nodes/filter_node_library.py +++ b/src/view/show_mode/editor/nodes/filter_node_library.py @@ -18,8 +18,9 @@ AdapterFloatToColorNode, AdapterFloatToRange, ColorBrightnessMixinNode, + ColorToColorwheelAdapterNode, CombineTwo8BitToSingle16Bit, - Map8BitTo16Bit, ColorToColorwheelAdapterNode, + Map8BitTo16Bit, ) from view.show_mode.editor.nodes.impl.arithmetics import ( ArithmeticExponentialNode, diff --git a/src/view/show_mode/editor/nodes/type_to_node_dict.py b/src/view/show_mode/editor/nodes/type_to_node_dict.py index 4a8ddf87..9eef7d19 100644 --- a/src/view/show_mode/editor/nodes/type_to_node_dict.py +++ b/src/view/show_mode/editor/nodes/type_to_node_dict.py @@ -17,8 +17,9 @@ AdapterFloatToColorNode, AdapterFloatToRange, ColorBrightnessMixinNode, + ColorToColorwheelAdapterNode, CombineTwo8BitToSingle16Bit, - Map8BitTo16Bit, ColorToColorwheelAdapterNode, + Map8BitTo16Bit, ) from view.show_mode.editor.nodes.impl.arithmetics import ( ArithmeticExponentialNode, From 7c861415fc62b289e3f17afae05c0066fe3f07cb Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Sun, 5 Apr 2026 13:11:09 +0200 Subject: [PATCH 35/53] fix: remaining ruff issues --- src/model/ofl/ofl_fixture.py | 4 ++-- .../color_to_colorwheel_adapter_settings_widget.py | 11 +++++++---- src/view/show_mode/editor/nodes/impl/adapters.py | 12 ++++++------ 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/model/ofl/ofl_fixture.py b/src/model/ofl/ofl_fixture.py index aa4346b9..3af783db 100644 --- a/src/model/ofl/ofl_fixture.py +++ b/src/model/ofl/ofl_fixture.py @@ -220,9 +220,9 @@ class Capability(BaseModel): capabilityProperties: dict[str, Any] = {} """Contains the properties of the capability.""" - def __init__(self, *args: Any, **kwargs: Any) -> None: + def __init__(self, **kwargs: dict[str, Any]) -> None: """Overrides default constructor and populates capability settings.""" - super().__init__(*args, **kwargs) + super().__init__(**kwargs) if "capabilityProperties" in kwargs: raise ValueError("Fixme: this should not be a property of the OFL JSON model.") diff --git a/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py b/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py index 8e7c7787..0158e6fb 100644 --- a/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py +++ b/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py @@ -29,8 +29,8 @@ from view.show_mode.show_ui_widgets.debug_viz_widgets import ColorLabel if TYPE_CHECKING: - from model.virtual_filters.color_to_colorwheel import ColorToColorWheel from model.ofl.fixture import UsedFixture + from model.virtual_filters.color_to_colorwheel import ColorToColorWheel class _ColorMappingListWidgetItem(QListWidgetItem): @@ -174,7 +174,7 @@ def _add_mapping_clicked(self) -> None: self._input_dialog.show() def _remove_mapping_clicked(self) -> None: - items_to_remove = [index for index in self._color_mapping_list.selectedIndexes()] + items_to_remove = [index.row() for index in self._color_mapping_list.selectedIndexes()] items_to_remove.sort(reverse=True) for item in items_to_remove: self._color_mapping_list.takeItem(item) @@ -184,7 +184,9 @@ def _load_from_fixture_clicked(self) -> None: self._selected_fixture = None self._update_selected_fixture() return - fixture_names = [f"[{f.universe_id}:{f.start_index}] {f.name}" for f in self._filter.scene.board_configuration.fixtures] + fixture_names = [ + f"[{f.universe_id}:{f.start_index}] {f.name}" for f in self._filter.scene.board_configuration.fixtures + ] self._input_dialog = SelectionDialog("Select Fixture", "Please select the target fixture", fixture_names, parent=self._widget, multi_selection_allowed=False, selected_callback=self._fixture_selected_callback) @@ -195,7 +197,8 @@ def _fixture_selected_callback(self, sd: SelectionDialog) -> None: fixture_univ, fixture_chan = sd.selected_items[0].split("] ", 1)[0].replace("[", "").split(":") fixture_chan = int(fixture_chan) fixture_univ = int(fixture_univ) - self._selected_fixture = self._filter.scene.board_configuration.get_fixture_by_address(fixture_univ, fixture_chan) + self._selected_fixture = self._filter.scene.board_configuration.\ + get_fixture_by_address(fixture_univ, fixture_chan) self._update_selected_fixture() def _parse_color_mapping(self, mapping: str) -> None: diff --git a/src/view/show_mode/editor/nodes/impl/adapters.py b/src/view/show_mode/editor/nodes/impl/adapters.py index d89c850b..b4d5d204 100644 --- a/src/view/show_mode/editor/nodes/impl/adapters.py +++ b/src/view/show_mode/editor/nodes/impl/adapters.py @@ -321,7 +321,7 @@ def __init__(self, model: Filter | Scene, name: str) -> None: self.filter._configuration_supported = False class ColorToColorwheelAdapterNode(FilterNode): - nodeName = "Color to Color Wheel Adapter" + nodeName = "Color to Color Wheel Adapter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: super().__init__(model=model, filter_type=FilterTypeEnumeration.VFILTER_COLOR_TO_COLORWHEEL, name=name, @@ -332,12 +332,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: self.update_node_after_settings_changed() @override - def update_node_after_settings_changed(self): + def update_node_after_settings_changed(self) -> None: self.filter.in_data_types["input"] = DataType.DT_COLOR dimmer_input_dt_str = self.filter.filter_configurations.get("dimmer-input", "") if len(dimmer_input_dt_str) > 0: - if "in_dimmer" not in self.inputs().keys(): + if "in_dimmer" not in self.inputs(): self.addInput("in_dimmer") match dimmer_input_dt_str: case "16bit": @@ -347,11 +347,11 @@ def update_node_after_settings_changed(self): case _: self.filter.in_data_types["in_dimmer"] = DataType.DT_8_BIT else: - if "in_dimmer" in self.inputs().keys(): + if "in_dimmer" in self.inputs(): self.removeTerminal("in_dimmer") dimmer_output_dt_str = self.filter.filter_configurations.get("dimmer-output", "") if len(dimmer_output_dt_str) > 0: - if "dimmer" not in self.outputs().keys(): + if "dimmer" not in self.outputs(): self.addOutput("dimmer") match dimmer_output_dt_str: case "16bit": @@ -361,7 +361,7 @@ def update_node_after_settings_changed(self): case _: self.filter.out_data_types["dimmer"] = DataType.DT_8_BIT else: - if "dimmer" in self.outputs().keys(): + if "dimmer" in self.outputs(): self.removeTerminal("dimmer") match self.filter.filter_configurations.get("colorwheel-datatype"): case "16bit": From 1f5bff296edfd5e1ecc7733be0a7e322cce2cfe1 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Tue, 7 Apr 2026 16:56:35 +0200 Subject: [PATCH 36/53] fix: new ruff rule --- src/model/control_desk.py | 2 +- src/model/filter.py | 8 ++++---- src/model/ofl/fixture.py | 6 +++--- src/view/misc/termqt/_terminal_buffer.py | 4 ++-- src/view/misc/termqt/terminal_widget.py | 2 +- src/view/show_mode/show_ui_widgets/__init__.py | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/model/control_desk.py b/src/model/control_desk.py index f8008b1d..996c37df 100644 --- a/src/model/control_desk.py +++ b/src/model/control_desk.py @@ -34,7 +34,7 @@ def __init__(self, uid: str | None = None) -> None: uid: Unique identifier for the column. """ - self.id = uid if uid else _generate_unique_id() + self.id = uid or _generate_unique_id() self.bank_set: BankSet | None = None self._bottom_display_line_inverted = False self._top_display_line_inverted = False diff --git a/src/model/filter.py b/src/model/filter.py index 8ebd6392..897b307d 100644 --- a/src/model/filter.py +++ b/src/model/filter.py @@ -304,16 +304,16 @@ def copy(self, new_scene: Scene = None, new_id: str | None = None) -> Filter: if self.is_virtual_filter: f = construct_virtual_filter_instance( - new_scene if new_scene else self.scene, + new_scene or self.scene, self._filter_type, - new_id if new_id else self._filter_id, + new_id or self._filter_id, pos=self._pos, ) f.filter_configurations.update(self.filter_configurations.copy()) else: f = Filter( - new_scene if new_scene else self.scene, - new_id if new_id else self._filter_id, + new_scene or self.scene, + new_id or self._filter_id, self._filter_type, self._pos, self.filter_configurations.copy(), diff --git a/src/model/ofl/fixture.py b/src/model/ofl/fixture.py index 70237e4b..639c2432 100644 --- a/src/model/ofl/fixture.py +++ b/src/model/ofl/fixture.py @@ -131,7 +131,7 @@ def __init__( super().__init__() self._board_configuration: Final[BoardConfiguration] = board_configuration self._fixture: Final[OflFixture] = fixture - self._uuid: Final[UUID] = uuid if uuid else uuid4() + self._uuid: Final[UUID] = uuid or uuid4() self._start_index: int = start_index self._mode_index: int = mode_index @@ -147,9 +147,9 @@ def __init__( _load_colorwheel_mappings(fixture, self._fixture_channels) self._color_on_stage: str = ( - color if color else "#" + "".join([random.choice("0123456789ABCDEF") for _ in range(6)]) # noqa: S311 not a secret + color or "#" + "".join([random.choice("0123456789ABCDEF") for _ in range(6)]) # noqa: S311 not a secret ) - self._name_on_stage: str = self.short_name if self.short_name else self.name + self._name_on_stage: str = self.short_name or self.name self.parent_universe: int = parent_universe self._board_configuration.broadcaster.add_fixture.emit(self) diff --git a/src/view/misc/termqt/_terminal_buffer.py b/src/view/misc/termqt/_terminal_buffer.py index 8aca1cbd..c7373d98 100644 --- a/src/view/misc/termqt/_terminal_buffer.py +++ b/src/view/misc/termqt/_terminal_buffer.py @@ -840,8 +840,8 @@ def set_fg(self, color: QColor) -> None: def set_style(self, color: QColor | None, bg_color: QColor | None, bold: bool, underline: bool, reverse: bool) -> None: - self._fg_color = color if color else self._fg_color - self._bg_color = bg_color if bg_color else self._bg_color + self._fg_color = color or self._fg_color + self._bg_color = bg_color or self._bg_color self._bold = bool(bold) if bold != -1 else self._bold self._underline = bool(underline) if underline != -1 else \ self._underline diff --git a/src/view/misc/termqt/terminal_widget.py b/src/view/misc/termqt/terminal_widget.py index b75d382c..617133c1 100644 --- a/src/view/misc/termqt/terminal_widget.py +++ b/src/view/misc/termqt/terminal_widget.py @@ -104,7 +104,7 @@ def __init__(self, self.scroll_bar: QScrollBar = None - self.logger = logger if logger else logging.getLogger() + self.logger = logger or logging.getLogger() self.logger.info("Initializing Terminal...") TerminalBuffer.__init__(self, max(int(height / 10), 1), max(int(width / 10), 1), logger=self.logger, **kwargs) diff --git a/src/view/show_mode/show_ui_widgets/__init__.py b/src/view/show_mode/show_ui_widgets/__init__.py index f8e02d26..32ae3182 100644 --- a/src/view/show_mode/show_ui_widgets/__init__.py +++ b/src/view/show_mode/show_ui_widgets/__init__.py @@ -117,7 +117,7 @@ def filter_to_ui_widget( We used to construct widgets this way, but the WIDGET_LIBRARY method should be used instead. """ - selected_configuration = configuration if configuration else {} + selected_configuration = configuration or {} match filter_.filter_type: case 0 | 1 | 2: # number constants From f686f81e030a4a45f22b496452ed69a864ed3fa0 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Tue, 7 Apr 2026 17:27:46 +0200 Subject: [PATCH 37/53] chg: more elaborated requests by ruff --- src/controller/cli/cli_context.py | 2 +- src/controller/cli/command.py | 6 +++--- src/controller/joystick/joystick_handling.py | 2 +- src/model/virtual_filters/effects_stacks/vfilter.py | 4 +--- src/view/dialogs/colum_dialog.py | 10 +++++----- .../show_mode/editor/editor_tab_widgets/bankset_tab.py | 4 ++-- .../cue_editor/timeline_content_widget.py | 4 +--- src/view/show_mode/editor/show_browser/show_browser.py | 8 ++++---- .../show_mode/effect_stacks/effects_stack_editor.py | 4 ++-- .../autotracker/auto_track_dialog_widget.py | 2 +- .../show_mode/show_ui_widgets/autotracker/gui_tab.py | 8 ++++---- src/view/show_mode/show_ui_widgets/cue_control.py | 2 +- 12 files changed, 26 insertions(+), 30 deletions(-) diff --git a/src/controller/cli/cli_context.py b/src/controller/cli/cli_context.py index 51d67875..a0011d8b 100644 --- a/src/controller/cli/cli_context.py +++ b/src/controller/cli/cli_context.py @@ -118,7 +118,7 @@ def __init__(self, show: BoardConfiguration, network_manager: NetworkManager, ex help="subcommands help", dest="subparser_name" ) for c in self._commands: - c.configure_parser(subparsers.add_parser(c.name, help=c.help, exit_on_error=False)) + c.configure_parser(subparsers.add_parser(c.name, help=c.help_text, exit_on_error=False)) if exit_available: subparsers.add_parser("exit", exit_on_error=False, help="Close this remote connection") self._return_text = "" diff --git a/src/controller/cli/command.py b/src/controller/cli/command.py index fa9767d0..6d625aee 100644 --- a/src/controller/cli/command.py +++ b/src/controller/cli/command.py @@ -48,10 +48,10 @@ def name(self) -> str: return "Unnamed Command" @property - def help(self) -> str: + def help_text(self) -> str: """Help text.""" return self._help_text - @help.setter - def help(self, new_help: str) -> None: + @help_text.setter + def help_text(self, new_help: str) -> None: self._help_text = str(new_help) diff --git a/src/controller/joystick/joystick_handling.py b/src/controller/joystick/joystick_handling.py index 0c6de60e..c0d3ea28 100644 --- a/src/controller/joystick/joystick_handling.py +++ b/src/controller/joystick/joystick_handling.py @@ -51,5 +51,5 @@ def reformat(key: Key) -> None: def __new__(cls) -> Self: """Connect a joystick and setup the key bindings.""" - mngr = pyjoystick.ThreadEventManager(event_loop=run_event_loop, handle_key_event=lambda key: cls.reformat(key)) + mngr = pyjoystick.ThreadEventManager(event_loop=run_event_loop, handle_key_event=cls.reformat) mngr.start() diff --git a/src/model/virtual_filters/effects_stacks/vfilter.py b/src/model/virtual_filters/effects_stacks/vfilter.py index 831fedb1..43eb0735 100644 --- a/src/model/virtual_filters/effects_stacks/vfilter.py +++ b/src/model/virtual_filters/effects_stacks/vfilter.py @@ -112,12 +112,10 @@ def instantiate_filters(self, filter_list: list[Filter]) -> None: ("b", socket_target.get_segment_in_universe_by_type(FixtureChannelType.BLUE)), ("w", socket_target.get_segment_in_universe_by_type(FixtureChannelType.WHITE)), ("a", socket_target.get_segment_in_universe_by_type(FixtureChannelType.AMBER))]: - i = 0 - for segment in segment_list: + for i, segment in enumerate(segment_list): universe_filter.filter_configurations[str(segment)] = str(segment) universe_filter.channel_links[str(segment)] = \ f"{adapter_filters[i % len(adapter_filters)].filter_id}:{segment_channel_name}" - i += 1 else: for segment_list in [socket_target.get_segment_in_universe_by_type(FixtureChannelType.RED), socket_target.get_segment_in_universe_by_type(FixtureChannelType.GREEN), diff --git a/src/view/dialogs/colum_dialog.py b/src/view/dialogs/colum_dialog.py index 98d0d02c..de6a8560 100644 --- a/src/view/dialogs/colum_dialog.py +++ b/src/view/dialogs/colum_dialog.py @@ -24,12 +24,12 @@ def __init__(self, column: ColorDeskColumn, parent: object = None) -> None: # self.colorD.finished.connect(self._select_color) # color_label = QtWidgets.QLabel(column.color.format_for_filter()) color_picker = QtWidgets.QPushButton("pick Color") - color_picker.clicked.connect(lambda: self._broadcaster.view_to_color.emit()) - self.color_d.finished.connect(lambda: self._broadcaster.view_leave_color.emit()) + color_picker.clicked.connect(self._broadcaster.view_to_color.emit) + self.color_d.finished.connect(self._broadcaster.view_leave_color.emit) temperature_picker = QtWidgets.QPushButton("pick Temperature") - temperature_picker.clicked.connect(lambda: self._broadcaster.view_to_temperature.emit()) - self.temperature_d.finished.connect(lambda: self._broadcaster.view_leave_temperature.emit()) + temperature_picker.clicked.connect(self._broadcaster.view_to_temperature.emit) + self.temperature_d.finished.connect(self._broadcaster.view_leave_temperature.emit) color_layout = QtWidgets.QVBoxLayout() color_layout.addWidget(color_picker) @@ -39,7 +39,7 @@ def __init__(self, column: ColorDeskColumn, parent: object = None) -> None: color_widget.setLayout(color_layout) button = QtWidgets.QPushButton("close") - button.clicked.connect(lambda: self._broadcaster.view_leave_colum_select.emit()) + button.clicked.connect(self._broadcaster.view_leave_colum_select.emit) layout = QtWidgets.QVBoxLayout() layout.addWidget(color_widget) layout.addWidget(button) diff --git a/src/view/show_mode/editor/editor_tab_widgets/bankset_tab.py b/src/view/show_mode/editor/editor_tab_widgets/bankset_tab.py index a4a9c70a..eb573eb7 100644 --- a/src/view/show_mode/editor/editor_tab_widgets/bankset_tab.py +++ b/src/view/show_mode/editor/editor_tab_widgets/bankset_tab.py @@ -29,8 +29,8 @@ def __init__(self, parent: QWidget, bankset: BankSet) -> None: self._tool_bar = QToolBar() self._tool_bar.addAction(QIcon.fromTheme("system-software-update"), "Refresh bankset on fish", lambda: self._bankset.update() if self._bankset is not None else False) - self._tool_bar.addAction(QIcon.fromTheme("document-new"), "Add Bank", lambda: self._add_bank()) - self._tool_bar.addAction(QIcon.fromTheme("list-add"), "Add Column to current Bank", lambda: self._add_column()) + self._tool_bar.addAction(QIcon.fromTheme("document-new"), "Add Bank", self._add_bank) + self._tool_bar.addAction(QIcon.fromTheme("list-add"), "Add Column to current Bank", self._add_column) self._new_column_type_cbox = QComboBox() self._new_column_type_cbox.insertItems(0, ["Numbers", "Color"]) self._new_column_type_cbox.setCurrentIndex(1) diff --git a/src/view/show_mode/editor/node_editor_widgets/cue_editor/timeline_content_widget.py b/src/view/show_mode/editor/node_editor_widgets/cue_editor/timeline_content_widget.py index a4000663..f6292761 100644 --- a/src/view/show_mode/editor/node_editor_widgets/cue_editor/timeline_content_widget.py +++ b/src/view/show_mode/editor/node_editor_widgets/cue_editor/timeline_content_widget.py @@ -105,13 +105,12 @@ def paintEvent(self, ev: QPaintEvent) -> None: kf_line_brush.setStyle(Qt.HorPattern) for kf in self._frames: if kf: - i = 0 x = int(kf.timestamp / self._time_zoom) painter.setBrush(kf_line_brush) kf_states = kf._states painter.drawLine(x, 20, x, len(kf_states) * CHANNEL_DISPLAY_HEIGHT + 20) painter.setBrush(light_gray_brush) - for s in kf_states: + for i, s in enumerate(kf_states): if kf.only_on_channel is None: y = 40 + i * CHANNEL_DISPLAY_HEIGHT else: @@ -140,7 +139,6 @@ def paintEvent(self, ev: QPaintEvent) -> None: painter.drawText(x + 15, y + 21, str(s._value)) painter.fillPath(marker_path, selected_brush) painter.drawText(x + 15, y + 9, s.transition) - i += 1 # render cursor, timescale and bars painter.setBrush(light_gray_brush) 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 3de3ddf0..b75f98fd 100644 --- a/src/view/show_mode/editor/show_browser/show_browser.py +++ b/src/view/show_mode/editor/show_browser/show_browser.py @@ -73,11 +73,11 @@ def __init__(self, parent: QWidget, show: BoardConfiguration, editor_tab_browser self._filter_browsing_tree.setColumnCount(1) self._tool_bar = QToolBar() - self._tool_bar.addAction(QIcon.fromTheme("list-add"), "Add Scene", lambda: self._add_element_pressed()) - self._tool_bar.addAction(QIcon.fromTheme("document-properties"), "Edit", lambda: self._edit_element_pressed()) - self._tool_bar.addAction(QIcon.fromTheme("view-refresh"), "Refresh", lambda: self._refresh_all()) + self._tool_bar.addAction(QIcon.fromTheme("list-add"), "Add Scene", self._add_element_pressed) + self._tool_bar.addAction(QIcon.fromTheme("document-properties"), "Edit", self._edit_element_pressed) + self._tool_bar.addAction(QIcon.fromTheme("view-refresh"), "Refresh", self._refresh_all) self._tool_bar.addAction( - QIcon.fromTheme("document-send"), "Send showfile to fish", lambda: self._upload_showfile() + QIcon.fromTheme("document-send"), "Send showfile to fish", self._upload_showfile ) self._toolbar_edit_action = self._tool_bar.actions()[1] diff --git a/src/view/show_mode/effect_stacks/effects_stack_editor.py b/src/view/show_mode/effect_stacks/effects_stack_editor.py index f6d67336..83c34145 100644 --- a/src/view/show_mode/effect_stacks/effects_stack_editor.py +++ b/src/view/show_mode/effect_stacks/effects_stack_editor.py @@ -98,10 +98,10 @@ def _effect_add_button_clicked(self, e: Effect) -> None: def eventFilter(self, widget: QWidget, event: QEvent) -> bool: if event.type() == QEvent.KeyPress and widget is self._effect_placement_bar: key = event.key() - if key in [Qt.Key_Return, Qt.Key_Enter]: + if key in [Qt.Key.Key_Return, Qt.Key.Key_Enter]: self._compilation_widget.add_effect_to_slot(self._effect_placement_bar.value()) return True - if key in [Qt.Key_Escape]: + if key == Qt.Key.Key_Escape: self._compilation_widget.load_effect_to_add(None) self._effect_placement_bar.setEnabled(False) self._effect_placement_bar.setVisible(False) diff --git a/src/view/show_mode/show_ui_widgets/autotracker/auto_track_dialog_widget.py b/src/view/show_mode/show_ui_widgets/autotracker/auto_track_dialog_widget.py index e0b99638..a204ca4d 100644 --- a/src/view/show_mode/show_ui_widgets/autotracker/auto_track_dialog_widget.py +++ b/src/view/show_mode/show_ui_widgets/autotracker/auto_track_dialog_widget.py @@ -95,7 +95,7 @@ def register_tabs(self, tabs: list[GuiTab]) -> None: first = True for tab in tabs: self.addTab(tab, tab.name) - tab.id = self.count() - 1 + tab.tab_id = self.count() - 1 if first: tab.tab_activated() first = False diff --git a/src/view/show_mode/show_ui_widgets/autotracker/gui_tab.py b/src/view/show_mode/show_ui_widgets/autotracker/gui_tab.py index bcfc7ffb..b063c84c 100644 --- a/src/view/show_mode/show_ui_widgets/autotracker/gui_tab.py +++ b/src/view/show_mode/show_ui_widgets/autotracker/gui_tab.py @@ -43,7 +43,7 @@ def tab_changed(self, index: int) -> None: Args: index (int): The new index of the tab. """ - if index == self.id: + if index == self.tab_id: self.tab_activated() else: self.tab_deactivated() @@ -61,7 +61,7 @@ def tab_deactivated(self) -> None: self.active = False @property - def id(self) -> int: + def tab_id(self) -> int: """ Get or set the tab's internal identifier. @@ -70,8 +70,8 @@ def id(self) -> int: """ return self._id - @id.setter - def id(self, value: int) -> None: + @tab_id.setter + def tab_id(self, value: int) -> None: self._id = value def video_update(self) -> None: diff --git a/src/view/show_mode/show_ui_widgets/cue_control.py b/src/view/show_mode/show_ui_widgets/cue_control.py index 7a3c5981..d67f5a07 100644 --- a/src/view/show_mode/show_ui_widgets/cue_control.py +++ b/src/view/show_mode/show_ui_widgets/cue_control.py @@ -152,7 +152,7 @@ def _repopulate_lists(self) -> None: cue_list.clear() for cue in self._cues: item = AnnotatedListWidgetItem(cue_list) - label = _CueLabel(cue_list, cue[0] if cue[0] else "No Name") + label = _CueLabel(cue_list, cue[0] or "No Name") item.annotated_data = cue item.setSizeHint(label.sizeHint()) cue_list.addItem(item) From 910d64d1ee204c7df98a91271ab02b9d070461ec Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Thu, 9 Apr 2026 10:13:19 +0200 Subject: [PATCH 38/53] add: color from temperature --- src/model/color_hsi.py | 57 ++++++++++++++++++++++++++++++++ src/model/ofl/color_name_dict.py | 18 +++++++++- src/model/ofl/ofl_fixture.py | 15 +++++---- 3 files changed, 83 insertions(+), 7 deletions(-) diff --git a/src/model/color_hsi.py b/src/model/color_hsi.py index 1721e1f5..3be69924 100644 --- a/src/model/color_hsi.py +++ b/src/model/color_hsi.py @@ -5,6 +5,7 @@ import colorsys from typing import TYPE_CHECKING +import numpy as np from PySide6.QtGui import QColor if TYPE_CHECKING: @@ -55,6 +56,62 @@ def from_filter_str(cls, filter_format: str) -> ColorHSI: intensity = 1.0 return ColorHSI(hue, saturation, intensity) + @classmethod + def from_rgb(cls, red: int, green: int, blue: int) -> ColorHSI: + """Initialize an HSI color from the given RGB color. + + Args: + red: Red component of the color. It must be in the range [0, 255] + green: Green component of the color. It must be in the range [0, 255] + blue: Blue component of the color. It must be in the range [0, 255] + + Returns: + The HSI color object. + + """ + hue, luminescence, saturation = colorsys.rgb_to_hls(red / 255, green / 255, blue / 255) + return ColorHSI(hue, luminescence, saturation) + + @classmethod + def from_color_temperature(cls, temperature: float | str) -> ColorHSI: + """Initialize an HSI color from the given color temperature. + + If a string is provided it will be converted automatically. + + Args: + temperature: Color temperature in degrees Kelvin. + + Returns: + A ColorHSI object representing the given color temperature. + + """ + + def cutoff(value: float, min_val: int, max_val: int) -> int: + return max(min(int(value), max_val), min_val) + + if isinstance(temperature, str): + temperature = float(temperature.lower().replace(" ", "").replace("k", "")) + temperature = temperature / 100 + if temperature <= 66: + red = 255 + green = temperature + green = 99.4708025861 * np.log(green) - 161.1195681661 + if temperature <= 19: + blue = 0 + else: + blue = temperature - 10 + blue = 138.5177312231 * np.log(blue) - 305.0447927307 + else: + red = temperature - 60 + red = 329.698727446 * (red ** -0.1332047592) + green = temperature - 60 + green = 288.1221695283 * (green ** -0.0755148492) + blue = 255 + red = cutoff(red, 0, 255) + green = cutoff(green, 0, 255) + blue = cutoff(blue, 0, 255) + return ColorHSI.from_rgb(red, green, blue) + @property def hue(self) -> confloat(ge=0, le=360): """Color itself in the form of an angle between [0,360] degrees.""" diff --git a/src/model/ofl/color_name_dict.py b/src/model/ofl/color_name_dict.py index 20a0b730..8b6b3bf0 100644 --- a/src/model/ofl/color_name_dict.py +++ b/src/model/ofl/color_name_dict.py @@ -15,7 +15,23 @@ _COLOR_DICT[name.lower()] = (float(hue), float(saturation), float(value)) def get_color_by_name(name: str) -> ColorHSI: - """This method queries the color name database and returns black if none was found.""" + """This method queries the color name database and returns black if none was found. + + HTML color codes are supported. + + Args: + name: The name or color code of the color. + + Returns: + The inferred color object. + + """ + if name.startswith("#"): + name = name.replace("#", "") + r = int(name[0:2], 16) + g = int(name[2:4], 16) + b = int(name[4:6], 16) + return ColorHSI.from_rgb(r, g, b) color_tuple = _COLOR_DICT.get(name.lower()) if color_tuple is not None: return ColorHSI(color_tuple[0], color_tuple[1], color_tuple[2]) diff --git a/src/model/ofl/ofl_fixture.py b/src/model/ofl/ofl_fixture.py index 3af783db..f3d634aa 100644 --- a/src/model/ofl/ofl_fixture.py +++ b/src/model/ofl/ofl_fixture.py @@ -5,15 +5,13 @@ from enum import Enum from logging import getLogger -from typing import TYPE_CHECKING, Any, Literal +from typing import Any, Literal from pydantic import BaseModel, ConfigDict +from model.color_hsi import ColorHSI from model.ofl.color_name_dict import get_color_by_name -if TYPE_CHECKING: - from model.color_hsi import ColorHSI - logger = getLogger(__name__) @@ -290,6 +288,9 @@ class WheelSlot(BaseModel): resource: dict[str, Any] = {} """Contains the gobo image, if any.""" + colors: list[str] = [] + """Some fixtures contain a colors array instead of a name.""" + @property def resulting_color(self) -> ColorHSI: """Returns the color of the wheel slot.""" @@ -298,8 +299,10 @@ def resulting_color(self) -> ColorHSI: return get_color_by_name("white") if self.type == WheelSlotType.CLOSED: return get_color_by_name("black") - # TODO figure out what to do for hex color codes - # TODO query color temperature + if self.colorTemperature != "": + return ColorHSI.from_color_temperature(self.colorTemperature) + if len(self.colors) > 0: + return get_color_by_name(self.colors[0]) return get_color_by_name(self.name) From 85deb1e0300909359aea326a0e058b58d39ce47e Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Thu, 9 Apr 2026 10:26:40 +0200 Subject: [PATCH 39/53] fix: first documentation instances --- src/model/__init__.py | 2 +- src/model/ofl/color_name_dict.py | 4 ++- src/model/ofl/fixture.py | 1 + .../virtual_filters/effects_stacks/vfilter.py | 15 +++++++-- src/view/dialogs/colum_dialog.py | 5 +-- src/view/dialogs/temperature_dialog.py | 5 +-- .../node_editor_widgets/node_editor_widget.py | 31 ++++++++++++++----- 7 files changed, 46 insertions(+), 17 deletions(-) diff --git a/src/model/__init__.py b/src/model/__init__.py index e2d1378c..ae1f2436 100644 --- a/src/model/__init__.py +++ b/src/model/__init__.py @@ -1,4 +1,4 @@ -"""Public classes and methods from the model package""" +"""Public classes and methods from the model package.""" from .board_configuration import BoardConfiguration from .broadcaster import Broadcaster diff --git a/src/model/ofl/color_name_dict.py b/src/model/ofl/color_name_dict.py index 8b6b3bf0..bede6fe3 100644 --- a/src/model/ofl/color_name_dict.py +++ b/src/model/ofl/color_name_dict.py @@ -1,3 +1,5 @@ +"""Contains method to query color by name.""" + import csv import os from logging import getLogger @@ -15,7 +17,7 @@ _COLOR_DICT[name.lower()] = (float(hue), float(saturation), float(value)) def get_color_by_name(name: str) -> ColorHSI: - """This method queries the color name database and returns black if none was found. + """Method queries the color name database and returns black if none was found. HTML color codes are supported. diff --git a/src/model/ofl/fixture.py b/src/model/ofl/fixture.py index 639c2432..adbe521c 100644 --- a/src/model/ofl/fixture.py +++ b/src/model/ofl/fixture.py @@ -169,6 +169,7 @@ def colorwheel_mappings(self) -> list[tuple[FixtureChannel, list[tuple[int, Colo Returns: A copy of the list. + """ return list(self._colorwheel_mappings) diff --git a/src/model/virtual_filters/effects_stacks/vfilter.py b/src/model/virtual_filters/effects_stacks/vfilter.py index 43eb0735..a7086a5b 100644 --- a/src/model/virtual_filters/effects_stacks/vfilter.py +++ b/src/model/virtual_filters/effects_stacks/vfilter.py @@ -1,6 +1,7 @@ -"""This file provides the v-filter implementation of the effects stack system""" +"""Provides the v-filter implementation of the effects stack system.""" from logging import getLogger +from typing import override from model import Filter, Scene from model.filter import FilterTypeEnumeration, VirtualFilter @@ -14,18 +15,24 @@ class EffectsStack(VirtualFilter): - """The v-filter providing the effects stack. This filter provides a system enabling one to assign stackable effects - to fixtures, groups of fixtures or configurable output ports.""" + """The v-filter providing the effects stack. + + This filter provides a system enabling one to assign stackable effects + to fixtures, groups of fixtures or configurable output ports. + """ def __init__(self, scene: Scene, filter_id: str, pos: tuple[int] | None = None) -> None: + """Initialize vFilter.""" super().__init__(scene, filter_id, FilterTypeEnumeration.VFILTER_EFFECTSSTACK, pos=pos) self.sockets: list[EffectsSocket] = [] self.deserialize() + @override def resolve_output_port_id(self, virtual_port_id: str) -> str | None: # We only need to resolve ports for explicitly configured outputs pass + @override def instantiate_filters(self, filter_list: list[Filter]) -> None: for socket in self.sockets: socket_target = socket.target @@ -142,6 +149,7 @@ def instantiate_filters(self, filter_list: list[Filter]) -> None: constant_filter.initial_parameters["value"] = "0.0" filter_list.append(constant_filter) + @override def serialize(self) -> None: d = self.filter_configurations d.clear() @@ -150,6 +158,7 @@ def serialize(self) -> None: # TODO Encode start addresses in case of group or use uuid of fixture d[name] = s.serialize() + @override def deserialize(self) -> None: self.sockets.clear() for k, v in self._filter_configurations.items(): diff --git a/src/view/dialogs/colum_dialog.py b/src/view/dialogs/colum_dialog.py index de6a8560..c0aa282e 100644 --- a/src/view/dialogs/colum_dialog.py +++ b/src/view/dialogs/colum_dialog.py @@ -1,4 +1,4 @@ -"""modify a colum of XTouch""" +"""modify a colum of XTouch.""" from PySide6 import QtWidgets from PySide6.QtCore import Qt @@ -9,9 +9,10 @@ class ColumnDialog(QtWidgets.QDialog): - """select how to modify a colum of XTouch""" + """Select how to modify a colum of XTouch.""" def __init__(self, column: ColorDeskColumn, parent: object = None) -> None: + """Initialize dialog for provided column.""" super().__init__(parent) self._broadcaster = Broadcaster() self.setWindowTitle(f"Change Column {column.id}") diff --git a/src/view/dialogs/temperature_dialog.py b/src/view/dialogs/temperature_dialog.py index 27d7805a..cd869c0d 100644 --- a/src/view/dialogs/temperature_dialog.py +++ b/src/view/dialogs/temperature_dialog.py @@ -1,4 +1,4 @@ -"""dialog for change of Temperature""" +"""dialog for change of Temperature.""" import numpy as np from PySide6 import QtCore, QtGui, QtWidgets @@ -7,9 +7,10 @@ class TemperatureDialog(QtWidgets.QDialog): - """dialog for change of Temperature""" + """dialog for change of Temperature.""" def __init__(self, column: ColorDeskColumn, parent: object = None) -> None: + """Initialize dialog for provided column.""" super().__init__(parent) self._column = column self.temperature = QtWidgets.QSpinBox(self) diff --git a/src/view/show_mode/editor/node_editor_widgets/node_editor_widget.py b/src/view/show_mode/editor/node_editor_widgets/node_editor_widget.py index 701a79dc..fa35bed3 100644 --- a/src/view/show_mode/editor/node_editor_widgets/node_editor_widget.py +++ b/src/view/show_mode/editor/node_editor_widgets/node_editor_widget.py @@ -1,3 +1,5 @@ +"""Module provides abstract filter node configuration widget.""" + from abc import ABC, abstractmethod from typing import TYPE_CHECKING @@ -8,6 +10,13 @@ class NodeEditorFilterConfigWidget(ABC): + """Base class for node editor filter configuration widgets. + + All abstract methods must be implemented. They need to return a non-null value at all times. Dictionaries however, + might be returned empty if no changes should be made. + + """ + @abstractmethod def _get_configuration(self) -> dict[str, str]: """Retrieve the filter configuration parameters that should be updated.""" @@ -18,17 +27,17 @@ def _load_configuration(self, conf: dict[str, str]) -> None: @property def configuration(self) -> dict[str, str]: - """Returns the configuration of the filter""" + """Returns the configuration of the filter.""" return self._get_configuration() @configuration.setter def configuration(self, conf: dict[str, str]) -> None: - """Loads the configuration already present in the filter configuration""" + """Loads the configuration already present in the filter configuration.""" self._load_configuration(conf) @abstractmethod def get_widget(self) -> QWidget: - """Returns the widget that should be displayed""" + """Returns the widget that should be displayed.""" @abstractmethod def _load_parameters(self, parameters: dict[str, str]) -> dict: @@ -37,23 +46,29 @@ def _load_parameters(self, parameters: dict[str, str]) -> dict: @abstractmethod def _get_parameters(self) -> dict[str, str]: """Return the (initial) filter parameters deduced by the widget. - Only parameters that changed need to be updated here.""" + + Only parameters that changed need to be updated here. + + """ raise NotImplementedError @property def parameters(self) -> dict[str, str]: - """Returns the current filter parameters""" + """Returns the current filter parameters.""" return self._get_parameters() @parameters.setter def parameters(self, parameters: dict[str, str]) -> None: - """Sets the filter parameters on the widget""" + """Sets the filter parameters on the widget.""" self._load_parameters(parameters) def parent_closed(self, filter_node: "FilterNode") -> None: """This method might be overridden to listen for parent close events. - Arguments: - filter_node -- might be used to alter the filter being presented.""" + + Args: + filter_node -- might be used to alter the filter being presented. + + """ filter_node.update_node_after_settings_changed() if filter_node.fsi is not None: filter_node.fsi.update_position() From 56125eaff8d055dad2d486b6ca14a68a6b5799d5 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Thu, 9 Apr 2026 12:03:30 +0200 Subject: [PATCH 40/53] fix: docs in filter node classes --- .../editor/editor_tab_widgets/bankset_tab.py | 6 +++ .../node_editor_widgets/node_editor_widget.py | 4 +- .../show_mode/editor/nodes/impl/adapters.py | 52 ++++++++++++++++--- .../show_mode/editor/nodes/impl/constants.py | 30 ++++++++--- 4 files changed, 78 insertions(+), 14 deletions(-) diff --git a/src/view/show_mode/editor/editor_tab_widgets/bankset_tab.py b/src/view/show_mode/editor/editor_tab_widgets/bankset_tab.py index eb573eb7..d58fab25 100644 --- a/src/view/show_mode/editor/editor_tab_widgets/bankset_tab.py +++ b/src/view/show_mode/editor/editor_tab_widgets/bankset_tab.py @@ -1,3 +1,5 @@ +"""Contains BankSet editor tab widget""" + from PySide6.QtCore import Qt from PySide6.QtGui import QIcon from PySide6.QtWidgets import ( @@ -21,7 +23,10 @@ class BankSetTabWidget(QWidget): + """Editor Tab to edit BankSets.""" + def __init__(self, parent: QWidget, bankset: BankSet) -> None: + """Initialize tab widget for given BankSet.""" super().__init__(parent) self._bankset: BankSet | None = None @@ -53,6 +58,7 @@ def __init__(self, parent: QWidget, bankset: BankSet) -> None: @property def bankset(self) -> BankSet: + """Get or set the used BankSet.""" return self._bankset @bankset.setter diff --git a/src/view/show_mode/editor/node_editor_widgets/node_editor_widget.py b/src/view/show_mode/editor/node_editor_widgets/node_editor_widget.py index fa35bed3..1a6303a2 100644 --- a/src/view/show_mode/editor/node_editor_widgets/node_editor_widget.py +++ b/src/view/show_mode/editor/node_editor_widgets/node_editor_widget.py @@ -63,7 +63,7 @@ def parameters(self, parameters: dict[str, str]) -> None: self._load_parameters(parameters) def parent_closed(self, filter_node: "FilterNode") -> None: - """This method might be overridden to listen for parent close events. + """Method might be overridden to listen for parent close events. Args: filter_node -- might be used to alter the filter being presented. @@ -75,4 +75,4 @@ def parent_closed(self, filter_node: "FilterNode") -> None: @abstractmethod def parent_opened(self) -> None: - """This method might be overridden to listen for parent open events.""" + """Method might be overridden to listen for parent open events.""" diff --git a/src/view/show_mode/editor/nodes/impl/adapters.py b/src/view/show_mode/editor/nodes/impl/adapters.py index b4d5d204..5912a55c 100644 --- a/src/view/show_mode/editor/nodes/impl/adapters.py +++ b/src/view/show_mode/editor/nodes/impl/adapters.py @@ -1,4 +1,4 @@ -"""Adapters and converters filter nodes""" +"""Adapters and converters filter nodes.""" from typing import override from model import DataType, Scene @@ -9,9 +9,11 @@ class Adapter16BitTo8BitNode(FilterNode): """Filter to convert a 16 bit value to two 8 bit values.""" + nodeName = "16 bit to 8 bit converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=int(FilterTypeEnumeration.FILTER_ADAPTER_16BIT_TO_DUAL_8BIT), name=name, terminals={ "value": {"io": "in"}, @@ -26,11 +28,14 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Adapter16BitToBoolNode(FilterNode): """Filter to convert a 16 bit value to a boolean. + If input is 0, output is 0, else 1. """ + nodeName = "16 bit to bool converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_16BIT_TO_BOOL, name=name, terminals={ "value_in": {"io": "in"}, @@ -42,9 +47,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Adapter16bitToFloat(FilterNode): + """Filter to convert a 16 bit value to a float.""" + nodeName = "16bit to Float converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_TYPE_ADAPTER_16BIT_TO_FLOAT, name=name, terminals={ "value_in": {"io": "in"}, @@ -57,9 +65,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Adapter8bitToFloat(FilterNode): + """Filter to convert a 8bit value to a float.""" + nodeName = "8bit to Float converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_TYPE_ADAPTER_8BIT_TO_FLOAT, name=name, terminals={ "value_in": {"io": "in"}, @@ -73,9 +84,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterColorToRGBNode(FilterNode): """Filter to convert a color value to a rgb value.""" + nodeName = "Color to rgb converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_COLOR_TO_RGB, name=name, terminals={ "value": {"io": "in"}, @@ -92,9 +105,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterColorToRGBWNode(FilterNode): """Filter to convert a color value to a rgbw value.""" + nodeName = "Color to rgb-w converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_COLOR_TO_RGBW, name=name, terminals={ "value": {"io": "in"}, @@ -113,9 +128,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterColorToRGBWANode(FilterNode): """Filter to convert a color value to a RGBWA value.""" + nodeName = "Color to rgb-wa converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_COLOR_TO_RGBWA, name=name, terminals={ "value": {"io": "in"}, @@ -136,9 +153,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterFloatToColorNode(FilterNode): """Filter to convert a float/double value to a color value.""" + nodeName = "Float to color converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_FLOAT_TO_COLOR, name=name, terminals={ "h": {"io": "in"}, @@ -156,9 +175,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterColorToFloatsNode(FilterNode): """Filter that splits the HSI values into three individual float channels.""" + nodeName = "Color to Float converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_COLOR_TO_FLOAT, name=name, terminals={ "input": {"io": "in"}, @@ -174,13 +195,14 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterFloatToRange(FilterNode): - """Filter maps a range of floats to another range of specific type (template)""" + """Filter maps a range of floats to another range of specific type (template).""" nodeName = "float range to float range" # noqa: N815 def __init__(self, model: Filter | Scene, name: str, filter_type: FilterTypeEnumeration = FilterTypeEnumeration.FILTER_ADAPTER_FLOAT_TO_FLOAT_RANGE) -> None: + """Initialize.""" super().__init__(model, int(filter_type), name, terminals={ "value_in": {"io": "in"}, "value": {"io": "out"}, @@ -216,10 +238,12 @@ def __init__(self, model: Filter | Scene, name: str, class AdapterFloatTo8BitRange(AdapterFloatToRange): - """Filter maps a range of float to a range of 8bit""" + """Filter maps a range of float to a range of 8bit.""" + nodeName = "Float range to 8bit" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_FLOAT_TO_8BIT_RANGE, name=name) try: self.filter.initial_parameters["upper_bound_out"] = model.initial_parameters["upper_bound_out"] @@ -229,10 +253,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterFloatTo16BitRange(AdapterFloatToRange): - """Filter maps a range of float to a range of 16bit""" + """Filter maps a range of float to a range of 16bit.""" + nodeName = "Float range to 16bit" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_FLOAT_TO_16BIT_RANGE, name=name) try: self.filter.initial_parameters["upper_bound_out"] = model.initial_parameters["upper_bound_out"] @@ -242,10 +268,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Adapter16BitToRangeFloat(AdapterFloatToRange): - """Filter maps a range of 16bit to a range of float""" + """Filter maps a range of 16bit to a range of float.""" + nodeName = "16bit range to Float" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.VFILTER_FILTER_ADAPTER_16BIT_TO_FLOAT_RANGE, name=name) try: @@ -257,10 +285,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Adapter8BitToRangeFloat(AdapterFloatToRange): - """Filter maps a range of 8bit to a range of floats""" + """Filter maps a range of 8bit to a range of floats.""" + nodeName = "8bit range to Float" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.VFILTER_FILTER_ADAPTER_8BIT_TO_FLOAT_RANGE, name=name) try: @@ -273,9 +303,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class CombineTwo8BitToSingle16Bit(FilterNode): """Filter that combines two 8bit values to a 16bit one.""" + nodeName = "Dual 8bit to single 16bit" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_DUAL_BYTE_TO_16BIT, name=name, terminals={ "lower": {"io": "in"}, @@ -290,9 +322,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Map8BitTo16Bit(FilterNode): """Filter that maps an 8-Bit value to a 16-Bit one.""" + nodeName = "Map 8bit to 16bit" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_8BIT_TO_16BIT, name=name, terminals={ "value_in": {"io": "in"}, @@ -304,9 +338,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class ColorBrightnessMixinNode(FilterNode): + """Filter node to mix brightness values conveniently.""" + nodeName = "Color Brightness Mixin" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.VFILTER_COLOR_GLOBAL_BRIGHTNESS_MIXIN, name=name, terminals={ "out": {"io": "out"}, @@ -321,9 +358,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: self.filter._configuration_supported = False class ColorToColorwheelAdapterNode(FilterNode): + """Filter node to convert a color channel to color wheel control signals.""" + nodeName = "Color to Color Wheel Adapter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.VFILTER_COLOR_TO_COLORWHEEL, name=name, terminals={ "input": {"io": "in"}, diff --git a/src/view/show_mode/editor/nodes/impl/constants.py b/src/view/show_mode/editor/nodes/impl/constants.py index bec32629..e8d13c34 100644 --- a/src/view/show_mode/editor/nodes/impl/constants.py +++ b/src/view/show_mode/editor/nodes/impl/constants.py @@ -1,5 +1,6 @@ -"""Constants filter nodes""" +"""Constants filter nodes.""" from logging import getLogger +from typing import override from PySide6.QtGui import QBrush, QColor, QFontMetrics, QPainter @@ -15,6 +16,7 @@ class TextPreviewRendererMixin(FilterNode): + """Mixin to render text based previews in filter nodes.""" def __init__(self, model: Filter | Scene, filter_type: int, @@ -42,9 +44,11 @@ def _draw_preview(self, p: QPainter) -> None: class Constants8BitNode(TextPreviewRendererMixin): """Filter to represent an 8 bit value.""" + nodeName = "8_bit_filter" # noqa: N815 def __init__(self, model: Filter, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_CONSTANT_8BIT, name=name, terminals={ "value": {"io": "out"}, }) @@ -56,6 +60,7 @@ def __init__(self, model: Filter, name: str) -> None: self.filter.out_data_types["value"] = DataType.DT_8_BIT self.filter.gui_update_keys["value"] = DataType.DT_8_BIT + @override def update_node_after_settings_changed(self) -> None: try: self.filter.initial_parameters["value"] = str( @@ -67,9 +72,11 @@ def update_node_after_settings_changed(self) -> None: class Constants16BitNode(TextPreviewRendererMixin): """Filter to represent a 16 bit value.""" + nodeName = "16_bit_filter" # noqa: N815 def __init__(self, model: Filter, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_CONSTANT_16_BIT, name=name, terminals={ "value": {"io": "out"}, }) @@ -81,6 +88,7 @@ def __init__(self, model: Filter, name: str) -> None: self.filter.out_data_types["value"] = DataType.DT_16_BIT self.filter.gui_update_keys["value"] = DataType.DT_16_BIT + @override def update_node_after_settings_changed(self) -> None: try: self.filter.initial_parameters["value"] = str( @@ -92,9 +100,11 @@ def update_node_after_settings_changed(self) -> None: class ConstantsFloatNode(TextPreviewRendererMixin): """Filter to represent a float/double value.""" + nodeName = "Float_filter" # noqa: N815 def __init__(self, model: Filter, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_CONSTANT_FLOAT, name=name, terminals={ "value": {"io": "out"}, }) @@ -106,6 +116,7 @@ def __init__(self, model: Filter, name: str) -> None: self.filter.gui_update_keys["value"] = DataType.DT_DOUBLE self.graphicsItem().additional_rendering_method = self._draw_preview + @override def update_node_after_settings_changed(self) -> None: try: self.filter.initial_parameters["value"] = str( @@ -117,11 +128,15 @@ def update_node_after_settings_changed(self) -> None: class ConstantsColorNode(FilterNode): """Filter to represent a color value. - TODO specify color format + + Colors are stored as "h,s,i" where h is a float in [0,360], s and i are float in [0,1]. + """ + nodeName = "Color_filter" # noqa: N815 def __init__(self, model: Filter, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_CONSTANT_COLOR, name=name, terminals={ "value": {"io": "out"}, }) @@ -144,6 +159,7 @@ def _draw_preview(self, p: QPainter) -> None: p.setBrush(self._color_brush) p.drawRect(x + 3, y + 3, 20, 20) + @override def update_node_after_settings_changed(self) -> None: try: self._color_brush = QBrush(ColorHSI.from_filter_str(self.filter.initial_parameters["value"]).to_qt_color()) @@ -154,9 +170,11 @@ def update_node_after_settings_changed(self) -> None: class PanTiltConstant(FilterNode): """Filter to represent a pan/tilt position.""" + nodeName = "PanTilt_filter" # noqa: N815 def __init__(self, model: Filter, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.VFILTER_POSITION_CONSTANT, name=name, terminals={}, allow_add_output=True) try: @@ -176,7 +194,7 @@ def __init__(self, model: Filter, name: str) -> None: self.filter.filter_configurations["outputs"] = outputs_from_file except: self.filter.filter_configurations["outputs"] = "16bit" - self.setup_output_terminals() + self._setup_output_terminals() self.filter.gui_update_keys["pan"] = DataType.DT_DOUBLE self.graphicsItem().additional_rendering_method = self._draw_preview @@ -196,7 +214,7 @@ def _draw_preview(self, p: QPainter) -> None: p.drawText(x + 3, y + fm.height() + 3, value_pan_str) p.drawText(x + 3, y + sheight, value_tilt_str) - def setup_output_terminals(self) -> None: + def _setup_output_terminals(self) -> None: existing_output_keys = list(self.outputs().keys()) outputs = self.filter.filter_configurations["outputs"] match outputs: @@ -228,7 +246,7 @@ def setup_output_terminals(self) -> None: if "tilt16bit" not in existing_output_keys: self.addOutput("tilt16bit") - def outputs_changed(self, eight_bit: bool, sixteen_bit: bool) -> None: + def _outputs_changed(self, eight_bit: bool, sixteen_bit: bool) -> None: self.filter.filter_configurations["outputs"] = \ "both" if eight_bit and sixteen_bit else "8bit" if eight_bit else "16bit" - self.setup_output_terminals() + self._setup_output_terminals() From 89acc9f013044c3d153fd0e3dddaeed349cb7c8f Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Thu, 9 Apr 2026 12:10:03 +0200 Subject: [PATCH 41/53] fix: docs in color selection UI widget --- .../editor/editor_tab_widgets/bankset_tab.py | 2 +- .../show_ui_widgets/color_selection_uiwidget.py | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/view/show_mode/editor/editor_tab_widgets/bankset_tab.py b/src/view/show_mode/editor/editor_tab_widgets/bankset_tab.py index d58fab25..74735cb8 100644 --- a/src/view/show_mode/editor/editor_tab_widgets/bankset_tab.py +++ b/src/view/show_mode/editor/editor_tab_widgets/bankset_tab.py @@ -1,4 +1,4 @@ -"""Contains BankSet editor tab widget""" +"""Contains BankSet editor tab widget.""" from PySide6.QtCore import Qt from PySide6.QtGui import QIcon diff --git a/src/view/show_mode/show_ui_widgets/color_selection_uiwidget.py b/src/view/show_mode/show_ui_widgets/color_selection_uiwidget.py index a59438df..d88b21d6 100644 --- a/src/view/show_mode/show_ui_widgets/color_selection_uiwidget.py +++ b/src/view/show_mode/show_ui_widgets/color_selection_uiwidget.py @@ -1,3 +1,5 @@ +"""Module contains color selection widget.""" + from typing import override from PySide6.QtGui import QAction, QColor @@ -17,8 +19,15 @@ class ColorSelectionUIWidget(UIWidget): + """UI widget allowing the user to select a color.""" def __init__(self, parent: UIPage, configuration: dict[str, str]) -> None: + """Initialize the widget. + + Configuration Parameters: number_of_presets (integer) -- How many presets should be available in total? + stored_presets: ; separated list of filter-encoded colors. + + """ super().__init__(parent, configuration) self._value: ColorHSI = ColorHSI.from_filter_str("") @@ -31,6 +40,7 @@ def __init__(self, parent: UIPage, configuration: dict[str, str]) -> None: self._config_widget: QWidget | None = None self._filter = None + @override def set_filter(self, f: Filter, i: int) -> None: if not f: return @@ -43,7 +53,7 @@ def set_filter(self, f: Filter, i: int) -> None: def generate_update_content(self) -> list[tuple[str, str]]: return [("value", self._value.format_for_filter())] - def push_value(self, new_value: ColorHSI) -> None: + def _push_value(self, new_value: ColorHSI) -> None: self._value = new_value self.push_update() @@ -80,7 +90,7 @@ def _build_base_widget(self, parent: QWidget, for_player: bool) -> QWidget: column_layout.addWidget(color_label) transmit_button = QPushButton("Set", w) transmit_button.setEnabled(for_player) - transmit_button.clicked.connect(lambda _i=i: self.push_value(self._presets[_i])) + transmit_button.clicked.connect(lambda _i=i: self._push_value(self._presets[_i])) column_layout.addWidget(transmit_button) layout.addLayout(column_layout) self._presets: list[ColorHSI] = color_presets From c5fe98b2c415192f82449dccad2f1d8833959b26 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Thu, 9 Apr 2026 13:54:20 +0200 Subject: [PATCH 42/53] fix: requested autotracker docs --- .../show_mode/editor/nodes/impl/constants.py | 1 + .../effect_stacks/effects_stack_editor.py | 5 +-- .../autotracker/auto_track_dialog_widget.py | 26 +++++++-------- .../show_ui_widgets/autotracker/gui_tab.py | 32 ++++++++----------- 4 files changed, 30 insertions(+), 34 deletions(-) diff --git a/src/view/show_mode/editor/nodes/impl/constants.py b/src/view/show_mode/editor/nodes/impl/constants.py index e8d13c34..4ffad852 100644 --- a/src/view/show_mode/editor/nodes/impl/constants.py +++ b/src/view/show_mode/editor/nodes/impl/constants.py @@ -24,6 +24,7 @@ def __init__(self, model: Filter | Scene, terminals: dict[str, dict[str, str]] | None = None, allow_add_input: bool = False, allow_add_output: bool = False) -> None: + """Initialize.""" super().__init__(model, filter_type, name, terminals, allow_add_input, allow_add_output) self.graphicsItem().additional_rendering_method = self._draw_preview diff --git a/src/view/show_mode/effect_stacks/effects_stack_editor.py b/src/view/show_mode/effect_stacks/effects_stack_editor.py index 83c34145..a391dc1a 100644 --- a/src/view/show_mode/effect_stacks/effects_stack_editor.py +++ b/src/view/show_mode/effect_stacks/effects_stack_editor.py @@ -1,4 +1,4 @@ -"""This file provides the main control widget for the filter stacking v-filter.""" +"""Provides the main control widget for the filter stacking v-filter.""" from typing import override @@ -26,9 +26,10 @@ class EffectsStackEditor(QWidget): - """This configuration widget provides an editor enabling the user to compose effect onto sockets.""" + """Configuration widget provides an editor enabling the user to compose effect onto sockets.""" def __init__(self, f: Filter, parent: QWidget | None) -> None: + """Initializes the widget.""" super().__init__(parent=parent) if not isinstance(f, EffectsStack): raise ValueError("This filter is supposed to be an instance of the EffectsStack virtual filter.") diff --git a/src/view/show_mode/show_ui_widgets/autotracker/auto_track_dialog_widget.py b/src/view/show_mode/show_ui_widgets/autotracker/auto_track_dialog_widget.py index a204ca4d..e707dd26 100644 --- a/src/view/show_mode/show_ui_widgets/autotracker/auto_track_dialog_widget.py +++ b/src/view/show_mode/show_ui_widgets/autotracker/auto_track_dialog_widget.py @@ -1,3 +1,5 @@ +"""Module contains auto tracker control widget.""" + from typing import TYPE_CHECKING from PySide6.QtCore import QTimer @@ -18,8 +20,7 @@ class AutoTrackDialogWidget(QTabWidget): - """ - The `MainWindow` class represents the main application window. + """The `MainWindow` class represents the main application window. Attributes: instance (InstanceManager): An instance manager for managing application instances and settings. @@ -30,12 +31,11 @@ class AutoTrackDialogWidget(QTabWidget): - `video_update_all()`: Update video content for all active tabs. - `tab_changed(index)`: Handle tab change events. - `register_tabs(tab_widget, tabs)`: Register tabs in the main window. + """ def __init__(self, f: "AutoTrackerFilter", provided_instance: InstanceManager | None) -> None: - """ - Initialize the main application window. - """ + """Initialize the main application window.""" super().__init__() if not provided_instance: # We're constructing the player widget @@ -64,9 +64,7 @@ def __init__(self, f: "AutoTrackerFilter", provided_instance: InstanceManager | self.video_timer.start(1) def video_update_all(self) -> None: - """ - Update video content for all active tabs. - """ + """Update video content for all active tabs.""" for i in range(self.count()): tab = self.widget(i) if isinstance(tab, GuiTab): @@ -74,11 +72,11 @@ def video_update_all(self) -> None: # TODO call generate_update_content from ui widget def tab_changed(self, index: int) -> None: - """ - Handle tab change events. + """Handle tab change events. Args: - index (int): The index of the selected tab. + index: The index of the selected tab. + """ for i in range(self.count()): tab = self.widget(i) @@ -86,11 +84,11 @@ def tab_changed(self, index: int) -> None: tab.tab_changed(index) def register_tabs(self, tabs: list[GuiTab]) -> None: - """ - Register tabs in the main window. + """Register tabs in the main window. Args: - tabs : A list of tab objects to register. + tabs: A list of tab objects to register. + """ first = True for tab in tabs: diff --git a/src/view/show_mode/show_ui_widgets/autotracker/gui_tab.py b/src/view/show_mode/show_ui_widgets/autotracker/gui_tab.py index b063c84c..8f70db0e 100644 --- a/src/view/show_mode/show_ui_widgets/autotracker/gui_tab.py +++ b/src/view/show_mode/show_ui_widgets/autotracker/gui_tab.py @@ -1,11 +1,12 @@ +"""Module contains auto tracker GUI tab implementation.""" + from PySide6.QtWidgets import QWidget from controller.autotrack.Helpers.InstanceManager import InstanceManager class GuiTab(QWidget): - """ - The `GuiTab` class represents a tab within a graphical user interface. + """The `GuiTab` class represents a tab within a graphical user interface. Attributes: _id (int): An internal identifier for the tab. @@ -20,15 +21,16 @@ class GuiTab(QWidget): - `tab_deactivated()`: Called when the tab is deactivated. - `id`: Property for getting or setting the tab's internal identifier. - `video_update()`: Abstract method for updating video content within the tab. + """ def __init__(self, name: str, instance: InstanceManager) -> None: - """ - Initialize a GuiTab object. + """Initialize a GuiTab object. Args: - name (str): The name of the tab. + name: The name of the tab. instance: An instance associated with the tab. + """ super().__init__() self._id: int = -1 @@ -37,11 +39,11 @@ def __init__(self, name: str, instance: InstanceManager) -> None: self.active = False def tab_changed(self, index: int) -> None: - """ - Callback when the active tab is changed. + """Callback when the active tab is changed. Args: index (int): The new index of the tab. + """ if index == self.tab_id: self.tab_activated() @@ -49,24 +51,20 @@ def tab_changed(self, index: int) -> None: self.tab_deactivated() def tab_activated(self) -> None: - """ - Called when the tab is activated. - """ + """Called when the tab is activated.""" self.active = True def tab_deactivated(self) -> None: - """ - Called when the tab is deactivated. - """ + """Called when the tab is deactivated.""" self.active = False @property def tab_id(self) -> int: - """ - Get or set the tab's internal identifier. + """Get or set the tab's internal identifier. Returns: int: The internal identifier of the tab. + """ return self._id @@ -75,6 +73,4 @@ def tab_id(self, value: int) -> None: self._id = value def video_update(self) -> None: - """ - Abstract method for updating video content within the tab. - """ + """Abstract method for updating video content within the tab.""" From bfffe55cc98df95a0b7a5844bf6d1cb1837a39b2 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Thu, 9 Apr 2026 17:35:38 +0200 Subject: [PATCH 43/53] fix: doc string in node_editor_widget.py --- .../show_mode/editor/node_editor_widgets/node_editor_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/show_mode/editor/node_editor_widgets/node_editor_widget.py b/src/view/show_mode/editor/node_editor_widgets/node_editor_widget.py index 1a6303a2..17dba4d4 100644 --- a/src/view/show_mode/editor/node_editor_widgets/node_editor_widget.py +++ b/src/view/show_mode/editor/node_editor_widgets/node_editor_widget.py @@ -66,7 +66,7 @@ def parent_closed(self, filter_node: "FilterNode") -> None: """Method might be overridden to listen for parent close events. Args: - filter_node -- might be used to alter the filter being presented. + filter_node: might be used to alter the filter being presented. """ filter_node.update_node_after_settings_changed() From 5afc4c618813c96e99f7cc3251ad1d592fd4293b Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Wed, 15 Apr 2026 15:49:12 +0200 Subject: [PATCH 44/53] add: dmx default value support to scene model --- src/controller/file/read.py | 6 +++ .../file/serializing/scene_serialization.py | 6 +++ src/model/scene.py | 37 +++++++++++++++++++ submodules/docs | 2 +- 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/controller/file/read.py b/src/controller/file/read.py index 6737c371..84639da8 100644 --- a/src/controller/file/read.py +++ b/src/controller/file/read.py @@ -264,6 +264,10 @@ 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: @@ -300,6 +304,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) diff --git a/src/controller/file/serializing/scene_serialization.py b/src/controller/file/serializing/scene_serialization.py index 10c2ab21..cf6ae7ed 100644 --- a/src/controller/file/serializing/scene_serialization.py +++ b/src/controller/file/serializing/scene_serialization.py @@ -107,6 +107,12 @@ def generate_scene_xml_description( for ui_page in scene.ui_pages: _add_ui_page_to_element(scene_element, ui_page) + for default_value in scene.dmx_default_values: + ET.SubElement(scene_element, "dmxdefaultvalue", attrib={ + "universe": 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/scene.py b/src/model/scene.py index b49ce750..a583eb72 100644 --- a/src/model/scene.py +++ b/src/model/scene.py @@ -2,8 +2,11 @@ from __future__ import annotations +from collections import namedtuple from typing import TYPE_CHECKING, override +from .universe import Universe + if TYPE_CHECKING: from .board_configuration import BoardConfiguration from .control_desk import BankSet @@ -62,6 +65,9 @@ def copy(self, new_scene: Scene = None) -> FilterPage: return new_fp +DmxDefaultValue = namedtuple("DmxDefaultValue", ['universe_id', 'channel', 'value']) + + class Scene: """Scene for a show file.""" @@ -75,6 +81,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 +123,34 @@ 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) -> None: + """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. + + """ + if isinstance(universe, Universe): + universe_id = universe.id + else: + universe_id = 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)) + def insert_filterpage(self, fp: FilterPage) -> None: """Add a filterpage to the scene.""" self._filter_pages.append(fp) @@ -184,6 +219,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: 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 From 427051526bbbdb98680f5e0a7cca05b43a83f390 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Wed, 15 Apr 2026 16:08:22 +0200 Subject: [PATCH 45/53] fix: ruff issues --- src/controller/file/read.py | 6 +++++- src/model/scene.py | 14 ++++++++------ src/view/show_mode/editor/nodes/impl/adapters.py | 1 - src/view/utility_widgets/asset_selection_widget.py | 2 +- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/controller/file/read.py b/src/controller/file/read.py index c8428a27..cf45a06d 100644 --- a/src/controller/file/read.py +++ b/src/controller/file/read.py @@ -266,7 +266,11 @@ def _parse_filter_page(element: ET.Element, parent_scene: Scene, instantiated_pa 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"])) + scene.insert_dmx_default_value( + int(child.attrib["universe"]), + int(child.attrib["channel"]), + int(child.attrib["value"]) + ) def _parse_scene( diff --git a/src/model/scene.py b/src/model/scene.py index a583eb72..5f5c357f 100644 --- a/src/model/scene.py +++ b/src/model/scene.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections import namedtuple -from typing import TYPE_CHECKING, override +from typing import TYPE_CHECKING, NamedTuple, override from .universe import Universe @@ -65,7 +65,12 @@ def copy(self, new_scene: Scene = None) -> FilterPage: return new_fp -DmxDefaultValue = namedtuple("DmxDefaultValue", ['universe_id', 'channel', 'value']) +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: @@ -139,10 +144,7 @@ def insert_dmx_default_value(self, universe: Universe | int, channel: int, value value: value to set on scene entry. """ - if isinstance(universe, Universe): - universe_id = universe.id - else: - universe_id = universe + 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: diff --git a/src/view/show_mode/editor/nodes/impl/adapters.py b/src/view/show_mode/editor/nodes/impl/adapters.py index a861801d..ff2eeda0 100644 --- a/src/view/show_mode/editor/nodes/impl/adapters.py +++ b/src/view/show_mode/editor/nodes/impl/adapters.py @@ -3,7 +3,6 @@ from model import DataType, Scene from model.filter import Filter, FilterTypeEnumeration, VirtualFilter -from model.filter import Filter, FilterTypeEnumeration from view.show_mode.editor.filter_settings_item import FilterSettingsItem from view.show_mode.editor.nodes.base.filternode import FilterNode diff --git a/src/view/utility_widgets/asset_selection_widget.py b/src/view/utility_widgets/asset_selection_widget.py index 2d267e11..8296d9b0 100644 --- a/src/view/utility_widgets/asset_selection_widget.py +++ b/src/view/utility_widgets/asset_selection_widget.py @@ -201,7 +201,7 @@ def __init__(self, parent: QWidget | None = None, allowed_types: list[MediaType] self.setLayout(layout) self._update_filter() - self._asset_view.selectionModel().selectionChanged.connect(lambda: self.asset_selection_changed.emit()) + self._asset_view.selectionModel().selectionChanged.connect(self.asset_selection_changed.emit) def _update_filter(self, force: bool = False) -> None: selected_types: set[MediaType] = set() From 91ff1d5c3938ff1c985d72ce99812031629171bc Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Wed, 15 Apr 2026 17:29:35 +0200 Subject: [PATCH 46/53] add: skeleton --- src/model/broadcaster.py | 4 ++++ .../dmx_default_value_editor.py | 19 +++++++++++++++++++ .../editor/show_browser/show_browser.py | 14 ++++++++++++-- src/view/show_mode/editor/showmanager.py | 8 ++++++++ submodules/resources | 2 +- 5 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 src/view/show_mode/editor/editor_tab_widgets/dmx_default_value_editor.py diff --git a/src/model/broadcaster.py b/src/model/broadcaster.py index 0f8fd07c..ef4bf470 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 @@ -120,6 +121,9 @@ class Broadcaster(QtCore.QObject, metaclass=QObjectSingletonMeta): dmx_from_fish: QtCore.Signal = QtCore.Signal(proto.DirectMode_pb2.dmx_output) event_sender_update: QtCore.Signal = QtCore.Signal(proto.Events_pb2.event_sender) + def __init__(self, /, parent: PySide6.QtCore.QObject | None = ..., *, objectName: str | None = ...): + super().__init__(null, parent, null, objectName) + def __new__(cls) -> Self: """Override __new__ to implement singleton behavior.""" if not hasattr(cls, "instance") or cls.instance is None: 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..254c28b2 --- /dev/null +++ b/src/view/show_mode/editor/editor_tab_widgets/dmx_default_value_editor.py @@ -0,0 +1,19 @@ +"""Contains DMX default value editor tab widget.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PySide6.QtWidgets import QWidget + +if TYPE_CHECKING: + from model.scene import Scene + +class DMXDefaultValueEditorWidget(QWidget): + def __init__(self, scene: Scene, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._scene: Scene = scene + + @property + def scene(self) -> Scene: + return self._scene 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..6368e5ce 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 + not 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 not 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..66e3c2c2 100644 --- a/src/view/show_mode/editor/showmanager.py +++ b/src/view/show_mode/editor/showmanager.py @@ -67,6 +67,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)") @@ -189,3 +192,8 @@ def _remove_tab(self, scene_or_index: Scene | int) -> None: def _send_show_file(self) -> None: """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: + # TODO iterate over all open tabs and jump to open tab if correct was found and exit + # TODO create editor tab and add it to list + pass diff --git a/submodules/resources b/submodules/resources index 8567afa0..637a2861 160000 --- a/submodules/resources +++ b/submodules/resources @@ -1 +1 @@ -Subproject commit 8567afa061ae0a926c53b36081bf4908f91b9bb1 +Subproject commit 637a2861a89cf43d2677d62e7672054c742fba4b From 81e7154e630612a76b659bf7e0183851a0295146 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Wed, 15 Apr 2026 17:57:26 +0200 Subject: [PATCH 47/53] add: tab loading and closing mechanics --- src/model/broadcaster.py | 3 --- .../dmx_default_value_editor.py | 8 ++++++- .../editor/show_browser/show_browser.py | 4 ++-- src/view/show_mode/editor/showmanager.py | 21 ++++++++++++++++--- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/model/broadcaster.py b/src/model/broadcaster.py index ef4bf470..7d8d54e7 100644 --- a/src/model/broadcaster.py +++ b/src/model/broadcaster.py @@ -121,9 +121,6 @@ class Broadcaster(QtCore.QObject, metaclass=QObjectSingletonMeta): dmx_from_fish: QtCore.Signal = QtCore.Signal(proto.DirectMode_pb2.dmx_output) event_sender_update: QtCore.Signal = QtCore.Signal(proto.Events_pb2.event_sender) - def __init__(self, /, parent: PySide6.QtCore.QObject | None = ..., *, objectName: str | None = ...): - super().__init__(null, parent, null, objectName) - def __new__(cls) -> Self: """Override __new__ to implement singleton behavior.""" if not hasattr(cls, "instance") or cls.instance is None: 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 index 254c28b2..204fd56d 100644 --- 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 @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING -from PySide6.QtWidgets import QWidget +from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget if TYPE_CHECKING: from model.scene import Scene @@ -14,6 +14,12 @@ def __init__(self, scene: Scene, parent: QWidget | None = None) -> None: super().__init__(parent) self._scene: Scene = scene + layout = QVBoxLayout() + + layout.addWidget(QLabel("DMX Default Value Tab: " + str(scene.scene_id))) + + self.setLayout(layout) + @property def scene(self) -> Scene: return self._scene 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 6368e5ce..e0d473ca 100644 --- a/src/view/show_mode/editor/show_browser/show_browser.py +++ b/src/view/show_mode/editor/show_browser/show_browser.py @@ -290,7 +290,7 @@ def rename(c: ShowBrowser, scene: Scene | FilterPage, text: str) -> None: for si in items: if isinstance(si, AnnotatedTreeWidgetItem): if (isinstance(si.annotated_data, Scene) and - not si.data(1, Qt.ItemDataRole.WhatsThisRole) == "DMXDEFAULTDATA"): + 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) @@ -324,7 +324,7 @@ 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) and not item.data(1, Qt.ItemDataRole.WhatsThisRole) == "DMXDEFAULTDATA": + 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 diff --git a/src/view/show_mode/editor/showmanager.py b/src/view/show_mode/editor/showmanager.py index 66e3c2c2..98e2cdab 100644 --- a/src/view/show_mode/editor/showmanager.py +++ b/src/view/show_mode/editor/showmanager.py @@ -17,6 +17,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 @@ -30,6 +31,7 @@ def __init__(self, board_configuration: BoardConfiguration, bcaster: Broadcaster 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) @@ -187,6 +189,8 @@ 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: @@ -194,6 +198,17 @@ def _send_show_file(self) -> None: transmit_to_fish(self._board_configuration) def _open_dmx_default_value_editor(self, s: Scene) -> None: - # TODO iterate over all open tabs and jump to open tab if correct was found and exit - # TODO create editor tab and add it to list - pass + 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) From 1722447dd34550c72b21822703ee684bb03dd9cc Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Wed, 15 Apr 2026 23:09:55 +0200 Subject: [PATCH 48/53] add: crude editing UI --- src/model/board_configuration.py | 5 +- src/model/scene.py | 6 +- .../dmx_default_value_editor.py | 107 +++++++++++++++++- 3 files changed, 113 insertions(+), 5 deletions(-) diff --git a/src/model/board_configuration.py b/src/model/board_configuration.py index be46c962..435aad07 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/scene.py b/src/model/scene.py index 5f5c357f..75ce0309 100644 --- a/src/model/scene.py +++ b/src/model/scene.py @@ -133,7 +133,7 @@ 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) -> None: + def insert_dmx_default_value(self, universe: Universe | int, channel: int, value: int) -> bool: """Add a new default value to the scene. Existing values will be updated. @@ -143,6 +143,9 @@ def insert_dmx_default_value(self, universe: Universe | int, channel: int, value channel: target channel. value: value to set on scene entry. + 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 @@ -152,6 +155,7 @@ def insert_dmx_default_value(self, universe: Universe | int, channel: int, 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)) + return value_to_remove is None def insert_filterpage(self, fp: FilterPage) -> None: """Add a filterpage to the scene.""" 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 index 204fd56d..c3919340 100644 --- 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 @@ -4,22 +4,125 @@ from typing import TYPE_CHECKING -from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget +from PySide6.QtWidgets import ( + QComboBox, + QDialog, + QFormLayout, + QHBoxLayout, + QLabel, + QListWidget, + QListWidgetItem, + QPushButton, + QSizePolicy, + QSpinBox, + QVBoxLayout, + QWidget, +) 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) + 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 layout = QVBoxLayout() - layout.addWidget(QLabel("DMX Default Value Tab: " + str(scene.scene_id))) + self._value_list_widget = QListWidget() + self._value_list_widget.setSizePolicy(QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)) + 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._add_value_button) + layout.addLayout(buttons_layout) self.setLayout(layout) + def refresh(self) -> None: + """Refreshes the list view.""" + self._value_list_widget.clear() + for univ_id, channel, value in self._scene.dmx_default_values: + self.add_value_entry(univ_id, channel, value) + @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 = QListWidgetItem(self._value_list_widget) + 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}") From fadad978c08a03dcabee679a22ce811022ddfc2b Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Wed, 15 Apr 2026 23:13:24 +0200 Subject: [PATCH 49/53] fix: inverted update bug --- src/model/scene.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/model/scene.py b/src/model/scene.py index 75ce0309..444cafa6 100644 --- a/src/model/scene.py +++ b/src/model/scene.py @@ -155,7 +155,7 @@ def insert_dmx_default_value(self, universe: Universe | int, channel: int, 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)) - return value_to_remove is None + return value_to_remove is not None def insert_filterpage(self, fp: FilterPage) -> None: """Add a filterpage to the scene.""" From 4ce9b85b88c7b6868574bdc673a111f9ea7f6d12 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Wed, 15 Apr 2026 23:18:34 +0200 Subject: [PATCH 50/53] fix: docs in showmanager.py --- src/view/show_mode/editor/showmanager.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/view/show_mode/editor/showmanager.py b/src/view/show_mode/editor/showmanager.py index 98e2cdab..edf85740 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 @@ -25,6 +26,7 @@ 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 @@ -80,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: @@ -96,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] @@ -168,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): @@ -194,7 +201,7 @@ def _remove_tab(self, scene_or_index: Scene | int) -> None: 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: From 650b463241394edd17ce960b27a31c55e1f3373a Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Wed, 15 Apr 2026 23:19:44 +0200 Subject: [PATCH 51/53] fix: typo --- src/view/show_mode/editor/showmanager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/show_mode/editor/showmanager.py b/src/view/show_mode/editor/showmanager.py index edf85740..4fc6c13f 100644 --- a/src/view/show_mode/editor/showmanager.py +++ b/src/view/show_mode/editor/showmanager.py @@ -82,7 +82,7 @@ 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: From 4521faf9e98c5029b2be9fe7b0f25793fd429cab Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Thu, 16 Apr 2026 11:21:22 +0200 Subject: [PATCH 52/53] add: entry removal option --- src/model/scene.py | 33 +++++++++++++++++-- .../dmx_default_value_editor.py | 28 ++++++++++++++-- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/model/scene.py b/src/model/scene.py index 444cafa6..545a7a51 100644 --- a/src/model/scene.py +++ b/src/model/scene.py @@ -2,9 +2,10 @@ from __future__ import annotations -from collections import namedtuple from typing import TYPE_CHECKING, NamedTuple, override +from PySide6.QtCore import QObject, Signal + from .universe import Universe if TYPE_CHECKING: @@ -73,11 +74,14 @@ class DmxDefaultValue(NamedTuple): value: int -class Scene: +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 @@ -133,7 +137,8 @@ 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) -> bool: + 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. @@ -142,6 +147,8 @@ def insert_dmx_default_value(self, universe: Universe | int, channel: int, value 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. @@ -155,8 +162,28 @@ def insert_dmx_default_value(self, universe: Universe | int, channel: int, 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) 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 index c3919340..66839a5c 100644 --- 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 @@ -11,7 +11,6 @@ QHBoxLayout, QLabel, QListWidget, - QListWidgetItem, QPushButton, QSizePolicy, QSpinBox, @@ -19,6 +18,8 @@ QWidget, ) +from view.show_mode.editor.show_browser.annotated_item import AnnotatedListWidgetItem + if TYPE_CHECKING: from model.scene import Scene @@ -62,7 +63,7 @@ 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) + refresh_required = self._editor.scene.insert_dmx_default_value(univ_id, channel, value, supress_emission=True) if refresh_required: self._editor.refresh() else: @@ -83,11 +84,17 @@ def __init__(self, scene: Scene, parent: QWidget | None = None) -> None: """ 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") @@ -97,6 +104,8 @@ def __init__(self, scene: Scene, parent: QWidget | None = None) -> None: 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) @@ -105,8 +114,11 @@ def __init__(self, scene: Scene, parent: QWidget | None = None) -> None: 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: @@ -119,10 +131,20 @@ def _add_value_clicked(self) -> None: def add_value_entry(self, universe_id: int, channel: int, value: int) -> None: """Add an entry to the list view.""" - item = QListWidgetItem(self._value_list_widget) + 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() From d63816cec4b9fbe425f19612d119a65696eb5a2e Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Thu, 16 Apr 2026 14:58:15 +0200 Subject: [PATCH 53/53] add: default mapping from console --- .../file/serializing/scene_serialization.py | 1 + src/model/board_configuration.py | 2 +- src/model/scene.py | 9 ++++++ .../console_mode/console_universe_selector.py | 32 ++++++++++++++++++- .../console_mode/console_universe_widget.py | 6 ++++ 5 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/controller/file/serializing/scene_serialization.py b/src/controller/file/serializing/scene_serialization.py index cf6ae7ed..3f86f2ce 100644 --- a/src/controller/file/serializing/scene_serialization.py +++ b/src/controller/file/serializing/scene_serialization.py @@ -107,6 +107,7 @@ 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": default_value.universe_id, diff --git a/src/model/board_configuration.py b/src/model/board_configuration.py index 435aad07..281261a0 100644 --- a/src/model/board_configuration.py +++ b/src/model/board_configuration.py @@ -338,6 +338,6 @@ def get_fixture_by_address(self, fixture_univ: int, fixture_chan: int) -> UsedFi """ for fixture in self._fixtures.values(): if fixture.universe_id == fixture_univ and \ - fixture.start_index <= fixture_chan <= fixture.start_index + fixture.channel_length: + fixture.start_index <= fixture_chan < fixture.start_index + fixture.channel_length: return fixture return None diff --git a/src/model/scene.py b/src/model/scene.py index 545a7a51..93ebeee2 100644 --- a/src/model/scene.py +++ b/src/model/scene.py @@ -336,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/view/console_mode/console_universe_selector.py b/src/view/console_mode/console_universe_selector.py index d29cbcb4..d424f0b4 100644 --- a/src/view/console_mode/console_universe_selector.py +++ b/src/view/console_mode/console_universe_selector.py @@ -2,11 +2,12 @@ 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 @@ -32,6 +33,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 +56,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 +87,25 @@ def notify_activate(self) -> None: def _automap(self) -> None: for uw in self._universe_widgets: uw.automap() + + def _save_to_scene_default_clicked(self) -> None: + 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", "Please select the scene to apply the values to", + scene_list, self, False, + self._scene_selected_for_default_value_add) + self._dialog.show() + + def _scene_selected_for_default_value_add(self, dialog: SelectionDialog) -> None: + scene = self._board_configuration.get_scene_by_id(int(dialog.selected_items[0].split(": ", 1)[0])) + if scene is None: + return + for univ_widget in self._universe_widgets: + univ_widget.add_settings_to_scenes_default_values(scene) + 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