From f6700e9fcef97cc052a7f0c8c16dfb82fd057e9f Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Mon, 7 Sep 2026 20:41:09 -0400 Subject: [PATCH 1/3] Red test --- tests/v3/test_midi_learn.py | 95 +++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/tests/v3/test_midi_learn.py b/tests/v3/test_midi_learn.py index 2b2681fde..338f57853 100644 --- a/tests/v3/test_midi_learn.py +++ b/tests/v3/test_midi_learn.py @@ -6,6 +6,8 @@ from common.parameter import BYPASS_SYMBOL, Parameter, PortInfo, Symbol from common.parameter_editing import EditContext from tests.types import SystemFixture +from plugins.parameter_window import ParameterWindow +from uilib.misc import InputEvent LOG_PORT: PortInfo = { "shortName": "HP", @@ -333,6 +335,99 @@ def test_v3_midi_learn_adds_table_row_for_encoder(v3_system: SystemFixture, make assert effect.symbol == Symbol("gain") +def test_v3_midi_learn_updates_open_plugin_menu_badge(v3_system: SystemFixture, make_plugin, make_parameter): + """An open ParameterWindow reflects MIDI learn and unlearn without reopening.""" + handler = v3_system.handler + hw = v3_system.hw + ws_bridge = v3_system.ws_bridge + lcd = handler.lcd + + assert handler.current and lcd + + enc1 = next(e for e in hw.encoders if e.id == 1) + channel, cc = _binding_for(hw, enc1).split(":") + + params = { + "gain": make_parameter("Gain", "noise"), + "tone": make_parameter("Tone", "noise"), + "mix": make_parameter("Mix", "noise"), + "drive": make_parameter("Drive", "noise"), + } + target = make_parameter("Depth", "noise") + params["depth"] = target + plugin = make_plugin("noise", bypassed=False, parameters=params) + handler.current.pedalboard.plugins = [plugin] + lcd.link_data(handler.pedalboard_list, handler.current, hw.footswitches) + lcd.draw_main_panel() + + lcd.main_panel.sel_widget(lcd.w_plugins[0]) + lcd.main_panel.input_event(InputEvent.LONG_CLICK) + handler.poll_lcd_updates() + panel = lcd.pstack.current + assert isinstance(panel, ParameterWindow) + row = next(row for row in panel._list_rows if row.symbol == target.symbol) + assert row._badge_char is None + + ws_bridge.inject(f"midi_map /graph/noise depth {channel} {cc} 0.0 1.0") + handler.poll_ws_messages() + handler.poll_lcd_updates() + assert row._badge_char == "1" + + ws_bridge.inject("midi_map /graph/noise depth -1 -1 0.0 1.0") + handler.poll_ws_messages() + handler.poll_lcd_updates() + assert row._badge_char is None + +def test_v3_midi_unlearn_and_relearn_updates_open_plugin_menu_badge( + v3_system: SystemFixture, make_plugin, make_parameter +): + """An open ParameterWindow drops and restores a badge as MIDI learn changes.""" + handler = v3_system.handler + hw = v3_system.hw + ws_bridge = v3_system.ws_bridge + lcd = handler.lcd + + assert handler.current and lcd + + enc1 = next(e for e in hw.encoders if e.id == 1) + channel, cc = _binding_for(hw, enc1).split(":") + + params = { + "gain": make_parameter("Gain", "noise"), + "tone": make_parameter("Tone", "noise"), + "mix": make_parameter("Mix", "noise"), + "drive": make_parameter("Drive", "noise"), + } + target = make_parameter("Depth", "noise") + params["depth"] = target + plugin = make_plugin("noise", bypassed=False, parameters=params) + handler.current.pedalboard.plugins = [plugin] + lcd.link_data(handler.pedalboard_list, handler.current, hw.footswitches) + lcd.draw_main_panel() + + learn = f"midi_map /graph/noise depth {channel} {cc} 0.0 1.0" + ws_bridge.inject(learn) + handler.poll_ws_messages() + + lcd.main_panel.sel_widget(lcd.w_plugins[0]) + lcd.main_panel.input_event(InputEvent.LONG_CLICK) + handler.poll_lcd_updates() + panel = lcd.pstack.current + assert isinstance(panel, ParameterWindow) + row = next(row for row in panel._list_rows if row.symbol == target.symbol) + assert row._badge_char == "1" + + ws_bridge.inject("midi_map /graph/noise depth -1 -1 0.0 1.0") + handler.poll_ws_messages() + handler.poll_lcd_updates() + assert row._badge_char is None + + ws_bridge.inject(learn) + handler.poll_ws_messages() + handler.poll_lcd_updates() + assert row._badge_char == "1" + + def test_v3_midi_learn_reroutes_an_already_bound_pedalboard(v3_system: SystemFixture, make_plugin, make_parameter): """A parameter changes from WebSocket transport to encoder CC after MOD learns a mapping. The route is derived per commit, so a binding learned after bind cannot leave a stale route.""" From 528371745520893fa3daee1cef274eccdffaaf4f Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Mon, 7 Sep 2026 20:48:19 -0400 Subject: [PATCH 2/3] bindings reactivity in plugin panels (i.e. badges) --- modalapi/modhandler.py | 4 ++++ pistomp/controller_manager.py | 9 ++++++++- pistomp/handler.py | 5 +++++ plugins/base.py | 28 ++++++++++++++++++++-------- plugins/parameter_window.py | 14 ++++++++++++++ tests/test_plugin_panels.py | 1 + 6 files changed, 52 insertions(+), 9 deletions(-) diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index dd02f8d51..4d5089874 100644 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -1483,6 +1483,10 @@ def effective_table(self) -> ContextStack: render from.""" return self._controller_manager.effective_table + @property + def binding_revision(self) -> int: + return self._controller_manager.binding_revision + # # Parameter Stuff # diff --git a/pistomp/controller_manager.py b/pistomp/controller_manager.py index ca47c0532..6319c938c 100644 --- a/pistomp/controller_manager.py +++ b/pistomp/controller_manager.py @@ -66,11 +66,17 @@ class ControllerManager: def __init__(self, hardware: "Hardware"): self._hw = hardware self.effective_table = ContextStack(layers=[]) + self._binding_revision = 0 + + @property + def binding_revision(self) -> int: + return self._binding_revision def bind(self, current: Current | None) -> None: - """Create the runtime associations for the active pedalboard.""" + """Create the runtime associations of the active pedalboard.""" self.effective_table = ContextStack(layers=[]) if current is None: + self._binding_revision += 1 return current.close() @@ -84,6 +90,7 @@ def bind(self, current: Current | None) -> None: self._bind_encoder_longpress(layer) self._bind_footswitch_actions(layer) self.effective_table = ContextStack(layers=[layer]) + self._binding_revision += 1 def _bind_plugin_parameters(self, current: Current, pedalboard_layer: ContextLayer) -> None: """Bind controllers referenced by plugin parameters.""" diff --git a/pistomp/handler.py b/pistomp/handler.py index d025a96e8..e46743bc3 100755 --- a/pistomp/handler.py +++ b/pistomp/handler.py @@ -83,6 +83,11 @@ def lcd_poll_divisor(self) -> int: # it dynamically (e.g. when the tuner panel is visible). return 20 + @property + def binding_revision(self) -> int: + """Revision of the handler's effective hardware bindings.""" + return 0 + def noop(self): pass diff --git a/plugins/base.py b/plugins/base.py index c261efbc2..483dc06d8 100644 --- a/plugins/base.py +++ b/plugins/base.py @@ -117,6 +117,7 @@ class PluginPanel(Panel, Generic[TState], ABC): _btn_bypass: Button | None _badge_fn: Callable[[Parameter], str | None] | None _model_dirty: bool + _seen_binding_revision: int _unsub_model: Callable[[], None] | None def _init_plugin_state( @@ -132,6 +133,7 @@ def _init_plugin_state( self._param_queue = {} self._badge_fn = None self._model_dirty = False + self._seen_binding_revision = handler.binding_revision self._unsub_model = None def _badge_for(self, symbol: Symbol) -> str | None: @@ -144,8 +146,22 @@ def _badge_bypass(self) -> None: if self._btn_bypass is None: return badge_char = self._badge_for(BYPASS_SYMBOL) - if badge_char is not None: - self._btn_bypass.set_badge(BadgeGlyph(badge_char)) + self._btn_bypass.set_badge(BadgeGlyph(badge_char) if badge_char is not None else None) + + def _refresh_binding_badges(self) -> None: + self._badge_bypass() + + def _on_binding_revision(self, revision: int) -> None: + """Reconcile presentation derived from the effective bindings.""" + self._refresh_binding_badges() + + def _check_binding_revision(self) -> None: + revision = self.handler.binding_revision + if revision == self._seen_binding_revision: + return + self._seen_binding_revision = revision + self._on_binding_revision(revision) + def _start_observing(self) -> None: """Subscribe to plugin param changes. Call at the end of a child @@ -277,17 +293,13 @@ def set_param(self, symbol: Symbol, value: float) -> None: param.preview(value) def tick(self) -> None: - """Drain the coalesced parameter queue, then reconcile from the model - if any parameter changed under us since the last tick. - - Subclasses that override ``tick()`` **must** call ``super().tick()`` so - queued sends are not lost and the model-dirty drain runs. - """ + """Drain queued writes and reconcile model and binding changes.""" self._flush_param_queue() if self._model_dirty: self._model_dirty = False self.apply_state(self.snapshot_state()) self._refresh_bypass_style() + self._check_binding_revision() def _flush_param_queue(self) -> None: for symbol, value in self._param_queue.items(): diff --git a/plugins/parameter_window.py b/plugins/parameter_window.py index 8fa9c8cc1..808cd8466 100644 --- a/plugins/parameter_window.py +++ b/plugins/parameter_window.py @@ -227,6 +227,12 @@ def __init__( self._value_font = value_font self._bypassed: bool = False + def set_badge_char(self, badge_char: str | None) -> None: + if badge_char == self._badge_char: + return + self._badge_char = badge_char + self.refresh() + def _param(self) -> Parameter | None: return self._owner.plugin.parameters.get(self.symbol) @@ -631,6 +637,14 @@ def edit_symbol(self, symbol: Symbol, rotations: int, multiplier: float = 1.0) - return row.on_encoder_rotation(rotations, multiplier) return super().edit_symbol(symbol, rotations, multiplier) + def _refresh_binding_badges(self) -> None: + super()._refresh_binding_badges() + for widget in self._slot_widgets: + badge_char = self._badge_for(widget.slot.symbol) + widget.set_badge(BadgeGlyph(badge_char) if badge_char is not None else None) + for row in self._list_rows: + row.set_badge_char(self._badge_for(row.symbol)) + def _refresh_bypass_style(self) -> None: super()._refresh_bypass_style() bypassed = self.plugin.is_bypassed() diff --git a/tests/test_plugin_panels.py b/tests/test_plugin_panels.py index 7e32ec9d9..36f566107 100644 --- a/tests/test_plugin_panels.py +++ b/tests/test_plugin_panels.py @@ -33,6 +33,7 @@ def send_parameter(self, instance_id: str, symbol: str, value: float) -> bool: class FakeHandler: def __init__(self): self.ws_bridge = FakeWsBridge() + self.binding_revision = 0 self.locked: set[tuple[str, str]] = set() def is_symbol_locked(self, instance_id: str, symbol: str) -> bool: From c6edd11e4ebadaa3e2272bf8fb10ad9fcfaf6293 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Mon, 7 Sep 2026 21:11:00 -0400 Subject: [PATCH 3/3] Keep them updated --- pistomp/lcd320x240.py | 40 ++++++++++++++++++++++++++----------- tests/v3/test_midi_learn.py | 38 +++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py index 4735ed27e..229f77b9f 100644 --- a/pistomp/lcd320x240.py +++ b/pistomp/lcd320x240.py @@ -202,6 +202,7 @@ def __init__(self, cwd, handler: "Modhandler", flip=False, display=None, spi_spe self._subtitle_desc = "" # last selection description seen self._subtitle_changed_at = 0.0 # monotonic time of last nav change self.w_parameter_dialogs = {} + self._parameter_dialog_binding_revision: int | None = None # panels self.pstack = PanelStack(display, image_format="RGB", use_dimming=True) @@ -281,6 +282,7 @@ def poll_updates(self): self._poll_updates() def _poll_updates(self): + self._refresh_parameter_dialog_badges() for d in self.w_parameter_dialogs.values(): d.tick() if self.pstack.current is self.main_panel: @@ -725,6 +727,29 @@ def _external_tweak_badge_number(self, param: Parameter) -> int | None: return self._encoder_badge_for_control_id(control_id) return None + def _parameter_dialog_badge_state(self, parameter: Parameter) -> tuple[int | None, BadgeGlyph | None]: + plugin = ( + next((p for p in self.current.pedalboard.plugins if p.instance_id == parameter.instance_id), None) + if self.current is not None + else None + ) + if plugin is not None: + number = self.tweak_badge_number(plugin, parameter) + elif parameter.binding is not None: + number = self._external_tweak_badge_number(parameter) + else: + number = None + return number, _TWEAK_BADGES.get(number) if number is not None else None + + def _refresh_parameter_dialog_badges(self) -> None: + revision = self.handler.binding_revision + if revision == self._parameter_dialog_binding_revision: + return + self._parameter_dialog_binding_revision = revision + for dialog in self.w_parameter_dialogs.values(): + if isinstance(dialog, Parameterdialog) and dialog.parent is not None: + dialog.set_tweak_badge(*self._parameter_dialog_badge_state(dialog.parameter)) + def _active_analog_rotate_rows(self) -> Iterator[tuple[BindingDecl, str]]: """the rows a tweak badge can attribute to a real encoder""" if self.handler is None: @@ -792,6 +817,7 @@ def open_parameter_dialog(self, context: EditContext, timeout=None) -> Parameter parameter = context.parameter assert parameter.type not in (Type.ENUMERATION, Type.TOGGLED) + d = Parameterdialog( self.pstack, context, @@ -800,18 +826,8 @@ def open_parameter_dialog(self, context: EditContext, timeout=None) -> Parameter auto_destroy=True, timeout=timeout, ) - plugin = ( - next((p for p in self.current.pedalboard.plugins if p.instance_id == parameter.instance_id), None) - if self.current is not None - else None - ) - if plugin is not None: - n = self.tweak_badge_number(plugin, parameter) - elif parameter.binding is not None: - n = self._external_tweak_badge_number(parameter) - else: - n = None - d.set_tweak_badge(n, _TWEAK_BADGES.get(n) if n is not None else None) + d.set_tweak_badge(*self._parameter_dialog_badge_state(parameter)) + self.w_parameter_dialogs[context.cache_key] = d self.pstack.push_panel(d) return d diff --git a/tests/v3/test_midi_learn.py b/tests/v3/test_midi_learn.py index 338f57853..7ed3f22e5 100644 --- a/tests/v3/test_midi_learn.py +++ b/tests/v3/test_midi_learn.py @@ -8,6 +8,7 @@ from tests.types import SystemFixture from plugins.parameter_window import ParameterWindow from uilib.misc import InputEvent +from uilib.parameterdialog import Parameterdialog LOG_PORT: PortInfo = { "shortName": "HP", @@ -378,6 +379,7 @@ def test_v3_midi_learn_updates_open_plugin_menu_badge(v3_system: SystemFixture, handler.poll_lcd_updates() assert row._badge_char is None + def test_v3_midi_unlearn_and_relearn_updates_open_plugin_menu_badge( v3_system: SystemFixture, make_plugin, make_parameter ): @@ -428,6 +430,42 @@ def test_v3_midi_unlearn_and_relearn_updates_open_plugin_menu_badge( assert row._badge_char == "1" +def test_v3_midi_unlearn_updates_open_parameter_dialog_badge(v3_system: SystemFixture, make_plugin, make_parameter): + """An open Parameterdialog drops and restores its MIDI-learn badge live.""" + handler = v3_system.handler + hw = v3_system.hw + ws_bridge = v3_system.ws_bridge + lcd = handler.lcd + + assert handler.current and lcd + + enc1 = next(e for e in hw.encoders if e.id == 1) + channel, cc = _binding_for(hw, enc1).split(":") + gain = make_parameter("Gain", "noise") + plugin = make_plugin("noise", bypassed=False, parameters={"gain": gain}) + handler.current.pedalboard.plugins = [plugin] + lcd.link_data(handler.pedalboard_list, handler.current, hw.footswitches) + lcd.draw_main_panel() + + learn = f"midi_map /graph/noise gain {channel} {cc} 0.0 1.0" + ws_bridge.inject(learn) + handler.poll_ws_messages() + + dialog = lcd.open_parameter_editor(EditContext(gain, handler.parameter_ui_value_commit)) + assert isinstance(dialog, Parameterdialog) + assert dialog._tweak_id == 1 + + ws_bridge.inject("midi_map /graph/noise gain -1 -1 0.0 1.0") + handler.poll_ws_messages() + handler.poll_lcd_updates() + assert dialog._tweak_id is None + + ws_bridge.inject(learn) + handler.poll_ws_messages() + handler.poll_lcd_updates() + assert dialog._tweak_id == 1 + + def test_v3_midi_learn_reroutes_an_already_bound_pedalboard(v3_system: SystemFixture, make_plugin, make_parameter): """A parameter changes from WebSocket transport to encoder CC after MOD learns a mapping. The route is derived per commit, so a binding learned after bind cannot leave a stale route."""