From 09b15d68d38bf74e9b336fcf021486cb7b65577b Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Mon, 7 Sep 2026 17:50:50 -0400 Subject: [PATCH 1/3] Edit context --- common/param_roles.py | 2 +- common/parameter.py | 4 + ...arameter_steps.py => parameter_editing.py} | 71 ++++---- modalapi/modhandler.py | 35 +++- pistomp/handler.py | 6 +- pistomp/input/README.md | 6 +- pistomp/lcd320x240.py | 151 +++++++++--------- plugins/base.py | 19 +-- plugins/eq/graphic.py | 2 +- plugins/eq/parametric.py | 2 +- plugins/parameter_window.py | 30 ++-- .../bpm_dialog_121.png | Bin 8476 -> 8377 bytes tests/test_lcd320x240.py | 57 +++++-- tests/test_parameter_steps.py | 2 +- tests/test_plugin_panels.py | 2 + tests/v2/test_gx_cabinet_panel.py | 3 +- tests/v3/test_encoder_dispatch.py | 17 +- tests/v3/test_midi_learn.py | 35 ++-- tests/v3/test_plugins.py | 2 +- tests/v3/test_reactive_parameter.py | 8 +- uilib/__init__.py | 3 +- uilib/parameterdialog.py | 94 +++++------ 22 files changed, 318 insertions(+), 233 deletions(-) rename common/{parameter_steps.py => parameter_editing.py} (70%) diff --git a/common/param_roles.py b/common/param_roles.py index 2421ec920..4aa6572b4 100644 --- a/common/param_roles.py +++ b/common/param_roles.py @@ -23,7 +23,7 @@ The role drives ``Selectable.symbol_for(role)`` — e.g. an EQ band selection returns a different symbol per role (gain/freq/Q), a compressor arc returns the same symbol regardless. Step math is unified through ``ParameterSteps`` -(``common/parameter_steps.py``); roles no longer carry their own step sizes. +(``common/parameter_editing.py``); roles no longer carry their own step sizes. """ from enum import auto, Enum diff --git a/common/parameter.py b/common/parameter.py index 7fd66fbad..676189df8 100644 --- a/common/parameter.py +++ b/common/parameter.py @@ -185,6 +185,10 @@ def __init__( elif TTL_TOGGLED in properties: self.type = Type.TOGGLED + @property + def declared_extents(self) -> tuple[float, float]: + return self.declared_minimum, self.declared_maximum + @property def value(self) -> float: return self._value diff --git a/common/parameter_steps.py b/common/parameter_editing.py similarity index 70% rename from common/parameter_steps.py rename to common/parameter_editing.py index 085e835b8..9809ead31 100644 --- a/common/parameter_steps.py +++ b/common/parameter_editing.py @@ -15,18 +15,16 @@ # You should have received a copy of the GNU Affero General Public License # along with pi-stomp. If not, see . -"""Quantized step grid for encoder-driven parameter edits. - -Shared by EncoderController (v3 tweak encoders) and Parameterdialog (the nav -encoder, which is the only encoder on v2), so that one detent moves a parameter -by the same amount whichever control you turn. -""" +"""Shared models and step math for encoder-driven parameter edits.""" import bisect +from collections.abc import Callable +from dataclasses import dataclass from typing import List import common.util as util -from common.parameter import Parameter, Type +from common.parameter import Parameter, Symbol, Type + # Steps for a continuous parameter. Matches the 0-127 MIDI CC range, so a full # sweep of a CC-bound encoder emits every distinct MIDI value. @@ -40,24 +38,40 @@ # notch of a stepped range stays reachable. FULL_SWEEP_DETENTS = 32 # The raw multiplier at which a spin counts as "full speed" — the historic -# MAX_MULTIPLIER. At or above this, the per-parameter cap binds; below it, -# the multiplier is interpolated linearly between 1 step/detent and the cap. +# MAX_MULTIPLIER. At or above this, the per-parameter cap binds; below it, the +# multiplier is interpolated linearly between 1 step/detent and the cap. REFERENCE_FAST_MULTIPLIER = 4.0 -def resolution(parameter: Parameter | None) -> int: - """Detents needed to cross the parameter's range. +EditCommit = Callable[[Parameter, float], None] - An unbound encoder (parameter is None) is a free-running CC: give it the - full 128-value sweep. A bound discrete parameter gets one detent per - distinct value — extra steps would emit no additional MIDI values, since - the CC is derived from the parameter. - """ + +@dataclass(frozen=True) +class EditContext: + parameter: Parameter + commit: EditCommit + grid_range: tuple[float, float] | None = None + + @property + def cache_key(self) -> tuple[str | None, Symbol]: + return self.parameter.instance_id, self.parameter.symbol + + @property + def extents(self) -> tuple[float, float]: + if self.grid_range is not None: + return self.grid_range + return self.parameter.declared_extents + + +def resolution(parameter: Parameter | None, minimum: float | None = None, maximum: float | None = None) -> int: + """Detents needed to cross the selected parameter range.""" if parameter is None: return CONTINUOUS_STEPS + lo = parameter.minimum if minimum is None else minimum + hi = parameter.maximum if maximum is None else maximum match parameter.type: case Type.INTEGER: - return int(parameter.maximum - parameter.minimum) + 1 + return int(hi - lo) + 1 case Type.ENUMERATION: return len(parameter.get_enum_value_list()) case Type.TOGGLED: @@ -66,18 +80,18 @@ def resolution(parameter: Parameter | None) -> int: return CONTINUOUS_STEPS -def effective_multiplier(multiplier: float, parameter: Parameter | None) -> float: - """The multiplier actually applied to a parameter edit. - +def effective_multiplier( + multiplier: float, + parameter: Parameter | None, + minimum: float | None = None, + maximum: float | None = None, +) -> float: + """ Maps the encoder's raw speed multiplier onto the parameter's step range so a full-speed spin covers the same fraction of any grid in roughly the - same number of detents. At ``multiplier == 1`` (slow) every detent moves - one step — every notch of a stepped range is reachable. At - ``multiplier >= REFERENCE_FAST_MULTIPLIER`` (full speed) each detent moves - ``resolution / FULL_SWEEP_DETENTS`` steps, so the whole range sweeps in - ~32 detents regardless of grid size. + same number of detents. """ - res = resolution(parameter) + res = resolution(parameter, minimum, maximum) cap = res / FULL_SWEEP_DETENTS if cap <= 1.0: return multiplier # small grid: precision floor, no scaling @@ -106,8 +120,9 @@ def __init__(self, minimum: float, maximum: float, logarithmic: bool, num_steps: ] @classmethod - def for_parameter(cls, parameter: Parameter) -> "ParameterSteps": - steps = cls(parameter.minimum, parameter.maximum, parameter.is_logarithmic, resolution(parameter)) + def for_parameter(cls, parameter: Parameter, extents: tuple[float, float] | None = None) -> "ParameterSteps": + minimum, maximum = extents or (parameter.minimum, parameter.maximum) + steps = cls(minimum, maximum, parameter.is_logarithmic, resolution(parameter, minimum, maximum)) steps.set_value(parameter.value) return steps diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index ebb100349..c859d797a 100644 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -60,7 +60,7 @@ ) from common.parameter import BYPASS_SYMBOL, Parameter, PortInfo, Symbol from common.param_source import ParamSink -from common.parameter_steps import ParameterSteps, effective_multiplier +from common.parameter_editing import EditContext, ParameterSteps, effective_multiplier from modalapi.plugin import Plugin from blend.input_controller import InputController import modalapi.pedalboard as Pedalboard @@ -395,7 +395,8 @@ def _handle_encoder(self, event: EncoderEvent) -> bool: if c.type == ControlType.VOLUME and c.parameter is not None: new_value = ParameterSteps.for_parameter(c.parameter).move(delta) c.parameter.commit(new_value, self._sink_for(c.parameter)) - d = self.lcd.draw_audio_parameter_dialog(c.parameter, self.audio_parameter_commit) + context = EditContext(c.parameter, lambda p, value: self.audio_parameter_commit(p.symbol, value)) + d = self.lcd.open_audio_parameter_dialog(context) if d is not None: d.update_value(new_value) return True @@ -412,7 +413,12 @@ def _handle_encoder(self, event: EncoderEvent) -> bool: # encoder, the WebSocket for :bpm) owns the send. new_value = ParameterSteps.for_parameter(c.parameter).move(delta) c.parameter.commit(new_value, self._sink_for(c.parameter)) - self.lcd.display_parameter_value(c.parameter, c.parameter.value) + context = EditContext( + c.parameter, + self.parameter_value_commit, + grid_range=(c.parameter.minimum, c.parameter.maximum), + ) + self.lcd.display_parameter_value(context, c.parameter.value) return True # Unbound: no sink, no row. This fallback CC is the only way mod-ui sees @@ -585,13 +591,15 @@ def lcd(self): return self._lcd def open_parameter_dialog(self, parameter: Parameter) -> None: - self.lcd.draw_parameter_dialog(parameter) + context = EditContext(parameter, self.parameter_ui_value_commit) + self.lcd.open_parameter_editor(context) def open_parameter_submenu(self, plugin: Plugin, rows: tuple[tuple[str, Symbol], ...], title: str) -> None: self.lcd.draw_symbol_menu(plugin, rows, title) def open_audio_parameter_dialog(self, parameter: Parameter, commit_callback: Callable[[str, float], None]) -> None: - self.lcd.draw_audio_parameter_dialog(parameter, commit_callback) + context = EditContext(parameter, lambda p, value: commit_callback(p.symbol, value)) + self.lcd.open_audio_parameter_dialog(context) def poll_controls(self): if self.hardware: @@ -1264,6 +1272,15 @@ def _sink_for(self, param: Parameter) -> ParamSink | None: return functools.partial(self._publish_switch_cc, control) return self._publish_plugin_param + def _ui_sink_for(self, param: Parameter) -> ParamSink | None: + if param.instance_id == Pedalboard.TRANSPORT_INSTANCE_ID: + return self._sink_for(param) + if param.instance_id is None: + return self._publish_audio + if param.instance_id == ExternalMidi.EXTERNAL_INSTANCE_ID: + return self._sink_for(param) + return self._publish_plugin_param + def _publish_bpm(self, param: Parameter) -> bool: """Publish the BPM to the transport.""" return self.set_mod_tap_tempo(param.value) @@ -1471,9 +1488,10 @@ def effective_table(self) -> ContextStack: # # Parameter Stuff # + def parameter_ui_value_commit(self, param: Parameter, value: float) -> None: + param.commit(value, self._ui_sink_for(param)) + def parameter_value_commit(self, param: Parameter, value: float) -> None: - # The sink owns the route (WebSocket param_set, transport-bpm, external - # CC, local ALSA write); commit repaints, publishes through it, settles. param.commit(value, self._sink_for(param)) @property @@ -1836,7 +1854,8 @@ def _create_audio_parameter(self, name, symbol, min_val, max_val): def audio_parameter_change(self, direction: int | None, parameter, commit_callback): if parameter is None: return - d = self.lcd.draw_audio_parameter_dialog(parameter, commit_callback) + context = EditContext(parameter, lambda p, value: commit_callback(p.symbol, value)) + d = self.lcd.open_audio_parameter_dialog(context) if d is None or direction is None: return step = (parameter.maximum - parameter.minimum) / 127.0 diff --git a/pistomp/handler.py b/pistomp/handler.py index dcf4eef29..d025a96e8 100755 --- a/pistomp/handler.py +++ b/pistomp/handler.py @@ -110,6 +110,10 @@ def parameter_value_commit(self, param: "Parameter", value: float) -> None: Reverts on screen if the send never left.""" raise NotImplementedError() + def parameter_ui_value_commit(self, param: "Parameter", value: float) -> None: + """Commit a plugin-panel or NAV edit through its UI transport.""" + raise NotImplementedError() + def toggle_plugin_bypass(self, plugin: "Plugin") -> None: """Flip a plugin's bypass the one way the whole UI flips it: through the footswitch press path when the plugin has one (so mod-host's echo @@ -265,5 +269,3 @@ def _rebind_pedalboard(self) -> None: """Build the board's associations and rows again. A handler that owns an activation must override this.""" raise NotImplementedError() - - diff --git a/pistomp/input/README.md b/pistomp/input/README.md index 32574556c..07b9e9fbb 100644 --- a/pistomp/input/README.md +++ b/pistomp/input/README.md @@ -142,9 +142,9 @@ open for the same symbol(s): symbols at once) opens a `Menu` submenu over `menu_rows()`; a plain `Selectable` opens a single `Parameterdialog` for `symbol_for(ParamRole.GENERIC)`. Both go through - `Handler.open_parameter_dialog`/`open_parameter_submenu` - (`pistomp/handler.py` → `Lcd320x240.draw_parameter_dialog`/ - `draw_symbol_menu`). + ``Handler.open_parameter_dialog``/``open_parameter_submenu`` + (``pistomp/handler.py`` → ``Lcd320x240.open_parameter_editor``/ + ``draw_symbol_menu``). * Both take an `on_change` callback wired to `self.apply_state(self. snapshot_state())` — the same resync call the mod-ui `ParamSetMessage` echo handler uses. Needed because the generic dialog commits straight to diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py index 1e43fec5e..4735ed27e 100644 --- a/pistomp/lcd320x240.py +++ b/pistomp/lcd320x240.py @@ -57,6 +57,7 @@ ScrollingText, TextWidget, ) +from common.parameter_editing import EditContext from uilib.glyphs.badge import BadgeGlyph from uilib.menu import row_label from uilib.gridpanel import GridPanel, TILE_W, CHANNEL @@ -784,62 +785,91 @@ def _list_params(self): deco.title.set_text(title) self.pstack.push_panel(panel) - def draw_parameter_dialog(self, parameter, timeout=None): - # If we already have an active dialog for the parameter, use it - d = util.DICT_GET(self.w_parameter_dialogs, parameter.name) + def open_parameter_dialog(self, context: EditContext, timeout=None) -> Parameterdialog: + d = util.DICT_GET(self.w_parameter_dialogs, context.cache_key) if d is not None and d.parent is not None: return d - # Create a new dialog - title = parameter.instance_id + ":" + self._param_label(parameter) + parameter = context.parameter + assert parameter.type not in (Type.ENUMERATION, Type.TOGGLED) + d = Parameterdialog( + self.pstack, + context, + width=270, + height=130, + 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) + self.w_parameter_dialogs[context.cache_key] = d + self.pstack.push_panel(d) + return d + + def _open_parameter_menu(self, context: EditContext): + d = util.DICT_GET(self.w_parameter_dialogs, context.cache_key) + if d is not None and d.parent is not None: + return d + + parameter = context.parameter + assert parameter.type in (Type.ENUMERATION, Type.TOGGLED) + title = Parameterdialog.title_for(context) current_value = parameter.value if parameter.type == Type.ENUMERATION: - items = [] - for label, value in parameter.get_enum_value_list(): - item = (label, self.parameter_commit_enum, (parameter, value), value == current_value) - items.append(item) - d = self.draw_selection_menu(items, title, auto_dismiss=True) - elif parameter.type == Type.TOGGLED: items = [ - ("On", self.parameter_commit_enum, (parameter, 1), current_value == 1), - ("Off", self.parameter_commit_enum, (parameter, 0), current_value == 0), + (label, self.parameter_commit_enum, (context, value), value == current_value) + for label, value in parameter.get_enum_value_list() ] - d = self.draw_selection_menu(items, title, auto_dismiss=True) else: - d = Parameterdialog( - self.pstack, - parameter, - width=270, - height=130, - auto_destroy=True, - title=title, - timeout=timeout, - action=self.parameter_commit, - object=parameter, - ) - 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) - self.pstack.push_panel(d) + items = [ + ("On", self.parameter_commit_enum, (context, 1), current_value == 1), + ("Off", self.parameter_commit_enum, (context, 0), current_value == 0), + ] + d = self.draw_selection_menu(items, title, auto_dismiss=True) + self.w_parameter_dialogs[context.cache_key] = d + return d - self.w_parameter_dialogs[parameter.name] = d - return d # return the dialog so the parameter can be modified using the tweak knob + def open_parameter_editor(self, context: EditContext, timeout=None): + if context.parameter.type in (Type.ENUMERATION, Type.TOGGLED): + return self._open_parameter_menu(context) + return self.open_parameter_dialog(context, timeout) - def parameter_commit(self, parameter, value): - self.handler.parameter_value_commit(parameter, value) + def parameter_commit_enum(self, context_value): + context, value = context_value + context.commit(context.parameter, value) - def parameter_commit_enum(self, param_value_tuple): - # (parameter_object, value) - self.parameter_commit(param_value_tuple[0], param_value_tuple[1]) + def open_audio_parameter_dialog(self, context: EditContext): + d = util.DICT_GET(self.w_parameter_dialogs, context.cache_key) + if d is not None and d.parent is not None: + return d + + d = Parameterdialog( + self.pstack, + context, + width=270, + height=130, + auto_destroy=True, + timeout=PARAMETER_DIALOG_TIMEOUT, + ) + self.w_parameter_dialogs[context.cache_key] = d + self.pstack.push_panel(d) + return d + + def display_parameter_value(self, context: EditContext, value: float) -> None: + if context.parameter.type in (Type.ENUMERATION, Type.TOGGLED): + self.open_parameter_editor(context) + return + self.open_parameter_dialog(context).update_value(value) # # Footswitches @@ -1035,47 +1065,20 @@ def _dismiss_panel(self, panel_cls: type) -> None: if panel is not None: self.pstack.pop_panel(panel) - def draw_audio_parameter_dialog(self, parameter, commit_callback): - d = util.DICT_GET(self.w_parameter_dialogs, parameter.name) - if d is not None and d.parent is not None: - return d - - d = Parameterdialog( - self.pstack, - parameter, - width=270, - height=130, - auto_destroy=True, - title=self._param_label(parameter), - timeout=PARAMETER_DIALOG_TIMEOUT, - action=commit_callback, - object=parameter.symbol, - ) - self.w_parameter_dialogs[parameter.name] = d - self.pstack.push_panel(d) - return d - - def display_parameter_value(self, parameter: Parameter, value: float) -> None: - d = self.draw_parameter_dialog(parameter) - if isinstance(d, Parameterdialog): - d.update_value(value) - def draw_vu_calibration_dialog(self, symbol, value, commit_callback): if value is None: value = 512 # 1024 / 2 name = "VU Calibration" info = PortInfo(name=name, symbol=Symbol(symbol), ranges={"minimum": 0, "maximum": 1023}) param = Parameter(info, value, None) + context = EditContext(param, lambda _param, new_value: commit_callback(symbol, new_value)) d = Parameterdialog( self.pstack, - param, + context, width=270, height=130, auto_destroy=False, - title=name, timeout=PARAMETER_DIALOG_TIMEOUT, - action=commit_callback, - object=symbol, ) self.pstack.push_panel(d) return d diff --git a/plugins/base.py b/plugins/base.py index 66c09d338..c261efbc2 100644 --- a/plugins/base.py +++ b/plugins/base.py @@ -61,7 +61,7 @@ from common.param_roles import ParamRole from common.param_source import ParamSource from common.parameter import BYPASS_SYMBOL, Parameter, Symbol -from common.parameter_steps import ParameterSteps, effective_multiplier +from common.parameter_editing import ParameterSteps, effective_multiplier from modalapi.plugin import Plugin from pistomp.controller import ControlType from pistomp.input.dispatch import MultiSelectable, Selectable, fire, resolve_local @@ -181,9 +181,11 @@ def on_event(self, event: ControllerEvent) -> bool: # global encoder-longpress callback (e.g. previous/next_snapshot), which # reloads every parameter under the open panel. While a plugin editor is # open the encoders belong to the panel, so swallow it. - if (isinstance(event, SwitchEvent) - and event.kind is SwitchEventKind.LONGPRESS - and event.controller.type in (ControlType.KNOB, ControlType.VOLUME)): + if ( + isinstance(event, SwitchEvent) + and event.kind is SwitchEventKind.LONGPRESS + and event.controller.type in (ControlType.KNOB, ControlType.VOLUME) + ): return True if not isinstance(event, EncoderEvent): return False @@ -248,8 +250,9 @@ def edit_symbol(self, symbol: Symbol, rotations: int, multiplier: float = 1.0) - p = self.plugin.parameters.get(symbol) if p is None: return False - steps = ParameterSteps.for_parameter(p) - delta = int(round(rotations * effective_multiplier(multiplier, p))) + extents = (p.declared_minimum, p.declared_maximum) + steps = ParameterSteps.for_parameter(p, extents) + delta = int(round(rotations * effective_multiplier(multiplier, p, *extents))) if delta == 0: return False new_val = steps.move(delta) @@ -292,11 +295,9 @@ def _flush_param_queue(self) -> None: self._param_queue.clear() def _send_param(self, symbol: Symbol, value: float) -> None: - """A synthetic source (audiocard) overrides: the card is the single writer, - so there is no route to choose.""" param = self.plugin.parameters.get(symbol) if param is not None: - self.handler.parameter_value_commit(param, value) + self.handler.parameter_ui_value_commit(param, value) # ── chrome actions ───────────────────────────────────────────────────── diff --git a/plugins/eq/graphic.py b/plugins/eq/graphic.py index 2387d9668..5964dd36f 100644 --- a/plugins/eq/graphic.py +++ b/plugins/eq/graphic.py @@ -40,7 +40,7 @@ ) from common.param_roles import ParamRole from common.parameter import Symbol -from common.parameter_steps import ParameterSteps, effective_multiplier, resolution +from common.parameter_editing import ParameterSteps, effective_multiplier, resolution from plugins.fullscreen import FullscreenPluginPanel from plugins.eq.band_spec import GraphicBandSpec from plugins.eq.parametric import _fmt_freq as _fmt_freq_long diff --git a/plugins/eq/parametric.py b/plugins/eq/parametric.py index 06bb971f4..27b717af6 100644 --- a/plugins/eq/parametric.py +++ b/plugins/eq/parametric.py @@ -40,7 +40,7 @@ ) from common.param_roles import ParamRole from common.parameter import Symbol -from common.parameter_steps import ParameterSteps, effective_multiplier, resolution +from common.parameter_editing import ParameterSteps, effective_multiplier, resolution from plugins.fullscreen import FullscreenPluginPanel from plugins.eq.band_spec import BandSpec from plugins.eq.filters import as_q diff --git a/plugins/parameter_window.py b/plugins/parameter_window.py index 91bc886bf..8fa9c8cc1 100644 --- a/plugins/parameter_window.py +++ b/plugins/parameter_window.py @@ -40,7 +40,7 @@ SelectionEditEffect, ) from common.parameter import BYPASS_SYMBOL, Parameter, Symbol, Type -from common.parameter_steps import ParameterSteps, effective_multiplier +from common.parameter_editing import ParameterSteps, effective_multiplier from common.param_roles import ParamRole from modalapi.plugin import Plugin from modalapi.plugin_customization import PinnedParam @@ -98,7 +98,8 @@ def _discrete_formatter(param: Parameter) -> Callable[[float], tuple[str, str]] an ordered enum's scale-point label (Order 0/1/2 → "1"/"2"/"3") or a toggle's On/Off. Continuous params keep the default.""" if param.type == Type.TOGGLED: - midpoint = (param.minimum + param.maximum) / 2 + minimum, maximum = param.declared_extents + midpoint = (minimum + maximum) / 2 def fmt_toggle(value: float) -> tuple[str, str]: return ("On" if value >= midpoint else "Off", "") @@ -134,8 +135,8 @@ def __init__( super().__init__( box=box, label=slot.label, - minimum=param.minimum if param is not None else 0.0, - maximum=param.maximum if param is not None else 1.0, + minimum=param.declared_minimum if param is not None else 0.0, + maximum=param.declared_maximum if param is not None else 1.0, color=color_for_param(param), formatter=self._format, parent=parent, @@ -179,8 +180,9 @@ def on_encoder_rotation(self, rotations: int, multiplier: float = 1.0) -> bool: param = self._param() if param is None: return False - steps = ParameterSteps.for_parameter(param) - delta = int(round(rotations * effective_multiplier(multiplier, param))) + extents = param.declared_extents + steps = ParameterSteps.for_parameter(param, extents) + delta = int(round(rotations * effective_multiplier(multiplier, param, *extents))) if delta == 0: return False new_val = steps.move(delta) @@ -242,7 +244,8 @@ def _discrete_label(param: Parameter, value: float) -> str: idx = min(range(len(pairs)), key=lambda i: abs(pairs[i][1] - value)) return pairs[idx][0] return "%d" % round(value) - on = value >= (param.minimum + param.maximum) / 2 + minimum, maximum = param.declared_extents + on = value >= (minimum + maximum) / 2 return "On" if on else "Off" @staticmethod @@ -253,8 +256,9 @@ def _continuous_readout(param: Parameter, value: float) -> tuple[str, float]: num = "%d" % round(value) else: num = f"{value:.1f}".rstrip("0").rstrip(".") - span = param.maximum - param.minimum - frac = 0.0 if span <= 0 else (value - param.minimum) / span + minimum, maximum = param.declared_extents + span = maximum - minimum + frac = 0.0 if span <= 0 else (value - minimum) / span return f"{num}{param.unit_symbol or ''}", frac def set_bypassed(self, bypassed: bool) -> None: @@ -276,8 +280,9 @@ def on_encoder_rotation(self, rotations: int, multiplier: float = 1.0) -> bool: param = self._param() if param is None: return False - steps = ParameterSteps.for_parameter(param) - delta = int(round(rotations * effective_multiplier(multiplier, param))) + extents = param.declared_extents + steps = ParameterSteps.for_parameter(param, extents) + delta = int(round(rotations * effective_multiplier(multiplier, param, *extents))) if delta == 0: return False new_val = steps.move(delta) @@ -321,7 +326,8 @@ def _draw_label(self, ctx, param: Parameter, value: float, shade: float) -> None lw, _ = get_text_size(label, self._value_font) lx = ctx.width - _RIGHT_MARGIN - lw # An engaged toggle lifts its label out of the muted readout grey. - on = param.type == Type.TOGGLED and value >= (param.minimum + param.maximum) / 2 + minimum, maximum = param.declared_extents + on = param.type == Type.TOGGLED and value >= (minimum + maximum) / 2 color = (255, 255, 255) if on else READOUT_COLOR ctx.draw_text((lx, val_vy), label, font=self._value_font, fill=shade_color(color, shade)) diff --git a/tests/snapshots/v3/test_transport_bindings/test_encoder_bpm_turn_parameter_dialog_snapshot/bpm_dialog_121.png b/tests/snapshots/v3/test_transport_bindings/test_encoder_bpm_turn_parameter_dialog_snapshot/bpm_dialog_121.png index 665e88e3ef08422f8d0cfa746774480c86cc4074..a83090472627a65f84a0ea33374accaede597cfe 100644 GIT binary patch literal 8377 zcmaKybyQT{+xLg=5G15iq!ExVNd-i1M7kM5LXhr~l18MYM7q0cXb^Qkau~WBq+y6> z-_Nt2cdhsLuJ!WAnR5W zZN6S!%`Gh-T8xMzFd%jf1|`}*7}2a(|9VkoB0saMeg6CzBFEpM&;wyLJU%^r7~N0) zSbdcC_p-B>)I#4Uax}RT?Z$=%cQ-du%sIpQcS5sh(fE+(8ARr()IoYn^ zE5p{xir~9H`5PP7CE9ii4y&uHD$k^`sS!ani?8j7HG}0X^PRs zX+AkUJ6r$j1s)QYgv80tZf9$&`pWb1L;Q~)KT1kUp8Y7-D)I603HkUD4c1PGgM~#T z$`veSF(r#T0AZy)IzP9rL(s$)wzRZ#boe(9PuRIfq`YAJ{DRFv1PT5&&^$hTz#}Bw z+u5PanHSO)~*VorHG&DZG zz7+Uax0FaS{4nsZR!R8n?JZ4gctD9xS%FqbM8u#ihLq(%%y1G1iRk&w)d@Gz*JT0N zP>Ph4l)Qq1J`ZKX{Ke_%lVsJMKEk)OZLO{05fRf96KX_FGaR{u&!O1ei# z`CV!{eeLX&$<-7qMf}bbKcVWn#A?8>DE{{CTY33UXECr^pXGBkxsv(#_;`7F_xJZ( zRf2dZN03NEmc(Lht_@)s! z8}qeF4i>xM%g)77x>V0e!8r5t^LD%&nW+84!)fHQXy4`uQL3$&kj?K2H75^`^T{&Z zSKj<41hEb=ty@t(2g}P33Y3g{o*Va@EBjQaK2E|5>sdZ`FTwB%mXmwd75hX)M8^}Z zpEpJ)8!*>*Y7z9H4igjes2;rD;1Y@Y%^Gxc^ze)zuJ*)n%ihTjn%E6s{jztLdD~&# zG}O$lUq?&q#ZPz4i6h9#$XG~mcXoGQAwGCrt*4UEi6vxbYZP4^uaD$$5i($~V!yMs z{Z?AVT$N}>oUX28Xh{3;;X?`v_N3ur5fNQo-O}}z#>T0>Tbt_$Qq1R>67F{O+x-w$ z>+S9BlHy{BFH_Pm7Z;ZzOJYPsgdEd1;%V@*Bqb!&Wo>2MceL&7xFB?r9zRc4O^nyB zL_{g)noK{M2<&ZD!OB}Ta8e*8B$n#)*Wb< zHI!GV zO-_rz-i3sOfLo@&iX);n3_sEl-C?Fy)c1T3 zoKaFy0ihB^mxBC!OD^eFi@NM9;Ie~XqQ54)7IWqe@JG1li{;LDA<>lo+ ze*E}b8QfxY0H(H$EJlN6in|uKPJ91BA+;l;*>c)Hqg6b1gB=}mGd~UYJ63)y?6%bf zJ+57ACy{S5Uo@OVx4$!6KNb(=)~$p>p$>>zE8t{SR#qTdfN&0%zL;xtL{=KNiB!hw zjTgP1epRJa0%CMQK|%9oOu_^3aA4P>ChZ_HX>hgU{th+azc6q{t@k*ops2{S(@*@# zlPzG!kc6BZr}H_|B;Nb^C2|>(I~&O>#gdS<#hFIUb1qp~SqX_*5R;0EB4}6=UszaJ zykBS+uS8qu3cL>^pn@#j-J&L>AN*P%qQ*JB+gTVVWh6>sgSiHOZmYic-STm}zZecU z8h|fvYmy%2wvkloc8_%>p(5(p;jq#`h$)(|M^C@x8l7857GagN=%rCXxQ6(RHjzry zw}AWwr5wDEyqpSDoLmsC&F)iD7ZRPqAj5_PcQh`VSGS5jC5uv!Xv^h(y@d16a22DJ zV#sBD;Nu)WUm_XN7V$rq_%~Z`(_~})j<*-h#?&K^IPM8FYIg8RG;TGrid@e8z%A2TX9I}bUq{R%=P z4Z(&UZan1VBlGA|{vE~l=j6?D%ZnNB&vTI~XP=zdiNjT9C3mczjzw}OoOi2zd_Bhj z`(ZOIIF2(TBtM+@cM>)1VCYBj6qAD7)YNnyGER?(Pf7P(h>eYHZL-XB!o!Qv)gh@0 zcrH_1VOPu8^4xjmXhG0rwah-EiCF&6dl%H!Hijmw%D*`uDHF)elOTkbrD!%9m@;WxD=RIIuxa$Z7KvY5BOr&D69M zHSmw$N-pEl@4UE_-ab}-H6_-oz;3q(cdX@d^E78OST0WY$9Zo!{+L{rVhUr5huYz# z;_@~|(PfXj?`h<)=H}qeV~cJv*XTh&+m44|*PXt3cC*oyTan@5c6G+RLG|f<{@Crq z>v75TJd|Rx&%+Um@CUlz_#}!II_sCXT(H%pHLJhYU#)raQ1#TbfaZUom(O)AC zBU`(L`u@C^l$>Ir(1mr~;oX^N=%m>ZU0tlu&m3{MTvE%9UDl@AfGJUeDHZK%4SL1X zDYtUw1|aDf8A;UpiwWWrlLGC-!op5Xe=PS##4|`sOc`&%8^SZ(K*F^4gUH*yz{A56 zu-0fIh)rOvQLp;)b1_UgQoK0JhH?abyBA0U$#68~p*ZuP2epBRBD4_rL> z)KT#v3B9a5O@mQ`FkHk_LIHVV`lae>fjY^(=eP(S7!a|MpFRP#Nk~lG_@0wgKE<)` z`1m*~GIHs!S4Bm|#ld`DL_}EF)%CSWn+GooOV9ZDn*y!LX<5WJll%}Squ)_?$(teu zDK7!X+4=&t)F{R8J3Bj;gK;kl;!zUE7Z;}OUVqnylZ=gwfLu8_J_Zs?)`f_UnwpxP z9%#H$Ak(?H+$6u{hekw*3JV)``nA;5B;Lg`%MZDcFi7dOc{oC$YcY?6?k)#q1I`gP zKpAlB${biS3kW!$Z2VF!5R{hopRP3aJec!F?KhoyL`2{^D=I4L>CFJ8ps$ZeXyAU! zI|3DKFqj`5{iaF1e@4wL{Kcv3HU+-fb^L?d)c-75o6FJO9y~}?T-?UV)dZ$mD&PLz z-p?OD?3|nq_xFFn;Z>k~(9mEIfgKzi00sEv1)EmM=|C(kJ-xzx_*bUL-x`ka5Cdwn z(4S1xU{ZOK+D`A@nb#q*v!P8eI${nMmha`|>Yn!(Ad#7yn=3G4psuf#6&0%ydq9EU zki?u-3kn<+t7!7JZNcf8v9-98=jUhRuIjH*C^XpQBn1S>1~EE~?gvy*2b*&rlMlj-_&j@FG#ec_*U91PE4+VSsmEi|;nSeVvh31gQR8)Y zb2XC6@3|9@`=FMJh=|Dd;=s7xb_~p&Lp^PDZ0sKcgHmMb`qE)=iLveKiC`zeD|)@v zszg#jtkq+ATSV(aTm%lm23BBfXaYTJd~lfjM^p_cZy>+l@v)#LP>6cYnkY1xBxI@ecK3pXBL=XPzgke zmI{|SL(g6|_ZjXH5>hqW%7h!+)^PsFz?cs@AHA)lVOen-p+l81=c+8Cf93${rn?)P zgyA5jrIMm-YFaXM-*Z@nAycY?6+6^ABtE|6aI(%EL!9VLvn;ohA&wDeJ{s4JW9uNC zR&*)L6V|`}smAUZCbrB|WPdcyV$TgyzRDP>BwIWhS3s2h&&=8f{@Sn>S96p{voqSC z@d#>EY8On6zq{=)M31mc=eY&TL#mG%87~j%|4fv?EV@JSj#i>cAK}T~Ko}Sl)Jf^o z15OVENf-lf`er(1yl&4p!t)E|o%jW&3Md^1YBZXTBNfl6op7O10p%`d3X< z^!Q#)gIM5)!6t46pHyt0W{%%+pIT{B?Wm?&0H*Kd$kD+9yJt*Cd(c6;DfQOcfbH05GrI?)- z?SslQm+LF&bmjOL%5q6&&^5Sn=x47%y{@c|)4b=*Y$HO^tR?uUG_o;GO$CK4x8(zO zw3}URs?+T*-q&8=UcqzgwPlxG#2+1cF0vy#&Y(6)PBGCRXY>+Omw!IeYbGtnSFq28 z=%U|{ndP1z210HaU$+;`3T@h^`Q zTwd$(FDpxVLGDpq&^I~`Jbq^sQV7ZBelyz&9Sl#qDukW?X)U^$BGMp9FPX10UUFmf~*jH5p?n;7jl z&UkV~?=ba8zdl<_*7LpC$$3q~dQCkuPk(;Mc!o4I9JX|+Gq-i5S=v(BE;vHFfza@p zKIT|nIqoxuzNNUjKsqsl>{{!-yJWh9w?4u@Jiq;5(Y~GHdRd{_J4vdNk1OOuP_C7F z5K+~;WpqMcIF2xS@{gH!X=&6ai)EWIv}5ygOyz-%bs}Rz!u{s%@fSma0`(%nXv!PC zqqB6_)Um|jDgOw-Y@yT!13i$uTytCcC$OZ3X$NWv==KUzM$hOheR*&eefU&mOAuMx zXLku|gNS8$6N0Py3bo*J5{2CaBPJ@0y=sAaVmQ`sG)432(Krl+UB6g8oCev0e#5dE zUP4|mU+z=!{a%N)d9tzQo>3O5Sg4Ia{R@(89BTpNiB5P!>XgH)ghpkHWj! zuxn+mvx7n#DMlY1tbgPouwVMCY?Z9+{xVOLl$qQ-Xb>ZuQXda{#z3Inmd-4Q7f&I2 z;1RZ0xj_982uK^SQI>ERBm-X|ni?0wfiRr%DIK;RV>l(^0igi6bnnXvHnl=DU63o* zzaKHHIRAd6S^j%6GjtX6-;V_Ue@|v+-u`<@Le~Fxt7MB(q!W_QctJVYyHY)_x0XjY z#UF^U+GY9n549Q&tZ>cB=z&2aAC~-icZhd)+&s_)AT#ZyO=8fIk<|h|XV~<9c>NgE zjz52@Gcq#H13A1A@%l6Gi~W0*?a<@cpPLd{I$RJD${$+wGzpP{?Qd>~JzN=Irh5 zO-<I>7mY-Y8jNJnmhK(t)cmHi~*Oq zZEcdbE(`76t-CC|ys=D?K-y^Wb7*7&P9-ELsBA>_1(fLzFcUK~rRFd<;W>vpJAdeX zZ!Z7ZPgjnS1IT%HdJ46%0r&h7p)5KQ6GPZw@M`lB_2shx2N+4NFGva|R$~Jj<}AmOh#S`9h-?fC4ZS(=?5dkkwy#2?{G{0Cd01Ii zhT6z*DrR0-SkNlb_WW8|$wwKzzAe6|4~BIKVbbQB+($Mkv5fsl<_7wQkORQj3{j`J z_;^{bqi+kvdwULCFqXt8U~-a@NcoY7z0X5tb>*d{2AzIaz+5siG5{;|*qf=b{`K|y zcNR6rdXGnDe5t<2YcEYr7eyES-sEa(Vi55wKl*$81uS1JU_lx509Y>fg!S~ucZkT@ zSv?aLZaQqm*fIAfICK*$663mm$?zdz5P z--QqvzR0t?vjd*R&3eVnOg5#_*|JAo+2e7?8XwFrm9M@H;NQh34P zLI+ZdwnEx?=mYB9u?H56fwP6jMmAhyIS4rMy319%kPVMdNrZ#OnJz#{(4ku`Nf zClsI=i_BR6t^{~o6d>aHTH65JF8b{9&f|N>fDy5%nezwlXlh91r-J@Z8TD_tq{dGB zq*H+&lJ=Uk5)+NktC_6t=53inhgQOw8FxKaK@n1_MnN`Q{9V+ZEPo7lg8y{KPk%G% zy);9^qqJ;SLDDUiH#Se{@GjhgONOsHpBm z5j|+@05b;|5(Mm2e$#;chUu+;01ku?4{z`7X+L=gECMcje-nwNVBKqM%keoa&Fk-q z5_uq2r&)K%&CN}jZWTcM@UPFJ;hUzdZk(WDL*u(M!3i2PP76{sdhG$X*XtvxYHVZ> zuj}n1HC5F$m#cF9IsnJJ08|VK3DK{!*7qJJwKOAgXnA&}sKoIQA1_2;FV(4;ra3=< z4b&s2Pe3kfG19nIjT>3F6aMW+q3DC#X{P<`Ya~gd4^oDK0H9}47Ip!0uQlKRZ#`a6q?w9>1kchZ+blMot%oY zb+xtg^YgvDy*n;fqCxpJ7|&>03;hJjxU$JMk9`a2;h|f;guHH=nc1R5kNw#K(RF}; z3171&fB9n66_AxZ@ZVT>@3XBD0&ol{lt{iwg@V<8MlZ6gn6&`Argm!^SIS>2h zOexe}U6DqXor8lmSF*diI~EpJNlA%mk&zE3GBlwtD!n3w=Mz(7iBrrMq0cp})t%dD=LY~4qz&O|=7 zwnSc7p*BGIlR;mAe?~JnfxZ{Q3LX5noCI8CoG|;)YN+ap2>)Xjj)&q5zGKU?Ptq=8 zG9mA59E;J-xld=zbM%Pg`z@aGfogmb^SNf%D+SBY4lN0hPv}X_EbTnQFIL1)UJ^x-qrfl1{ld6w` zwV_DvpYHFyhy!hh9Z!Doto;`a83`N3a4Oj0=LjY|` zN=hSmvpKhWHo(wZ=jmD2Ktsna;O6fjaA-rR*8gnd{6|;kKMtQ_3_i~JF>X3#4?5j{Al_tDKDAQM)C`k)IX_Qc5KCV-v|CdB|>T+A6<9fk_F z7**<&iPOckwYAAHfj%CHGO7h+bhv!{0bNTk(1?kMtm+WIe*MA^llK(MfnS`QjN1x> zNiE4advjjiO7uLfe!=!t?1P1+WuA!-np}a_*~Nvgw|5Og(i3NI_vH)wMYN5-UUbANe5thQ%6zG=Vss4gIOim()1(1i z9&~1`lIk;F(_fpoQb<@G^{qv0V5Nm-oO#wWHBxAS72{*f2nj|t`a8D^=E zs&K^mXB#@8a5@=Nmp(r%EG#tHprD)X@%#cy7;I%_rMs(3BU?NtJ32l- zK0Q6%-zUZkM+=2O*x!vg)NNkD=bkaf(`?kcEp_$uJcID5y5{mB`r`uw1JTgX6l1BZ zg-z*+qn!5Sm2|{J*lT~hR zZWI`Q6}|Yv$i%d@xtWofNpqX2oIh4sS(%bTUL&yex*7DivXT!%VHhDVFMkzae{TjR zynqYF1AG7EWbNp}zpWj-ii?Xo2H4*@2*(Ys;f22$*|@sA6qiJ}xVSJdFqD^<3keCO zR7{SK@B6mK#SzyT{&Z3&Pzh{Aq+Oz>%ryS~rLMaP`y?uN>#>A8r71=pD)?5{i&}eVfK4*5Yx96wvJli#?u@{$+ z$k#@FvXQXiX3xyZ^ zs{az48y_E^oK&Q&+?aON*H4|n>R_odaB6yXlP+NW?v+0hiR3kDwi*mLFt%GYA5zvI zs%&WRsfBvtVSAGI5syif>2M_LaO}7mrOt$=K4(@t`U3|>I5e-7O>KRo7PEx;^CO~6 z-n@OQfCZe5(EAy&V$j*yd83ZFKmTK?>UPt#%{1ir@2}k8%YoA_bHbQWt&GuhPqCp* zi%?l&T3Vj?=Ov}3&NFVlr!%%^JAWEHH#A`WyvB`uMO};1e3tPU8B_(An@7~Rr&`52 zps#s(dBD*e$?xo8o<2UdhB=&^oX)es!lpP+k;exseb||%1bmjMAaWBDNEgwPRdltr z8B+(_+S*u1acf%UM@L6zXPvGzu0Y$w#6;-g{!(}S-dP4?-vY556G6bLEDl2$vE2WD z7=I`k-_3uzw;*FPL}7D&xHgz6?ChcSGz%G=df`te`tb6n$bxaDdFTOZFXZluAuB67 zD`Mu-e0*+hZhpSqX{I`!E5?8;=ZWp`c=5|GpFe};K~hQz%xoh=LkH*ZZ<*jiif z%+%P5uDjk1;sXDh85y;J$m!|n`Sgiijn~8a>C>lRinaLceJd|F=?Io8bzB7PE6h4_ zW~w>3xQ2#@+XF8=OiXD0`0L2pS>a&3`Sj^iA8mlse5)T=oWK`zqykRC-{IlmG=(|L z|K-bz!sYrGqZPCzh6$t!-@*5)z;v zJ+*W__$si>+dHDi(Eg&Wm7 z@y{y~LTrZ8I>+01(&j@nBO2KS_Ki6Nr$@=U=~Cq7xF~(yiN*ypW!8RFhYd0qVBIwe zHC`O~?*Fw*lfksn#Hd>8wV}JC2|myFK{Mk`lfcc%g2dwy?=R($@+8S!irKj|c-<@W zI#~V=CP?ffk>ND{FGWSqSrfna^~oIckY$Rvg26eA61G&&5F8L}Gb|UTr~g!0N!*ZS z5;=}8cY}5FjUVrPxq&%Y?Ubvn3FFq8Rt*n(3 z1UNW+-aU^=qv!p%xYwm!nSSGyYZu?{N6HN4%7mP3PJm?;5|^DVwc5n+@F{X%RV*+A z8aZ+*^%VB#n&qMC1>SlHj^i!aCP9Pj{>dBLfF zoHr5NyWverpIx_Ef>kc(BFxb0xZQTK(%%;ACoW#F`A2x&uPqwD^Wh^Z!QQkvuELpa zEZSpKz?MJc(&kl`lFbiwOpfy=S8;H@dA6e6(Mm+F7G7BRrur=bQ6tx{&V9N&Ijr^; zg4~%eCWogc)B6tv;v`G=aJ{qt((n|ASKL9ja#_2d3K(r5Jk-?WMp&*oV!0nflv1hvUKCAX_aF&5yD6% zOdH17-Bw4g{#InSLxd47S>{F-=ftcLfr33Z*U8Ac|Ck5QTT;`;nrkePY%!XB+B;@7 zP%HOb1#QNQ*{5QM;<(!2;X{Sf+$+z#`}5+GNi!-vQs{L=v$0X#n*{s^XJ;aWl~qhv zBzSJl&gH0`>6nHvj9vDU2%ipC^gU^8GI`~42_Mo`W0os(IjD|MXG>pRb$ra-Hp{*T zn}p5XsqI+Vp$eMcL-p>-F*Tzef9E(P@^+}v zrEj+v#@q`nRvN*ZW25~j7@#8MpOf)Tldxny*>n}FCFD;_x6O}6LdjajblP$L;N~Gu zw_JnyQ3=V#YEiA%d^7v;QF%3w>S@DsT_l=ioz|M6r>V!as{Fb3+>Tdw=}bu3D9gJbomb zTBBF6{;B=VsEj+p+QOl0)@NYlCyNveBhWXHnkz^6xX9?t03)zD;j4Z=17T#E>6UQ= z)(70TD|Z!VsGSxoqQtSpZVI);y

P+P|xmE*~{eO9)DVoIa*L8?L#sw=F7K&y^id zeKL1mejVr1W`^e0no#nuJpP|2qL3Yu69N4kLXM+Xh)`*_*WKu_`QQji2t5k@Q0DJ< zv$L!`29h)CPRncUdCy}zgjnPKsq)a5HT~jw+w^slsIC28vw+~MH<|mJ!2$(Uu}*Hn zb)6G<=K_zU@4jusrpt5=ldWbcm46U?S(qKJ>f>+wXF|RHl4p*gM%O1^z3)!)q*CDr zF9YG?osk-cnu_LaP78GFR;^cNmCevBMS8A?A-Dg!RD3Fc8l&oyD$SOZhXJY}tf^i? zTAJ42qb?h*XA)eKW8hRnDfMlQho@(32qpGCYSGt!P`39t#TdFwvo$D+CV19}7-Fxo^X<&UY_}J^X8L>J8lKC>ZW_ zLzq^_p&z26C_Eyia2O&neaDMlR8>_0fn#A|(QSkm78mrW%1U6S(S79^;|qED?sB8H zxjm(4&(5!|%-RF}W*gn5rKQEQzcv3JeZ?ea$2T8*wX@RqaiPi6cDX0|{X^cA!8P!T zB%;&f2xt+-!^1PIH1C|Qg3Zm&0y$JuUG1{aF6Bl@O-f2iM#e-M{bO)YRYgTj;*-@g zMMWT`jT_za>gy+cV;SW7y?u6PU$yyr>FA8F4kX?k7s>=34zK|O22;*D*vz{YCHz!jn`PEfb{6L$tvMO@zqVNO2{>jbFZEtU{uC7i)Lo+io1G=TG ztbBttjYN9>NP3JHE?2B$+T@Yn)U<~`Z=c~}kJ_<$gdQ_WTtrw>QW8T`ve4r55u^;~ zar1CYv`%2%yLa!@8bVwb+G$8gew|5Ic68iEXd;6i+ve z4V1|jEXMnIO7+(`P0d3?LvT18C>M_VczIeB#Ke37r`!3esTmq8%{L&-Jb;34@!rXh z3AuHwXcKlAFIrz;@9L66wsQr1!?qF5D<2uWWEHG|#p*LLSm95ug$ujAeoVZKC9#fK zp(q#C-7j6KV9yS_9A>q9}I&WZT=s?{|^*;gHVlKt11)n^m>F z-@52-9_BKNH{!z_Io6Tf76OxbU-J4AZQ4b6?O-8=#PvwN)j6yyVUnxr}SMR#KD0-;G}1O z9v*decNd(U+IL|r9+;1_`(D~dy?a9!g5kO+&5DE1M0fN3;r{IvQS0v+T;A4wMB1v= z-@lMa!<|;pk3Kz4rN`{_b$>(AzmnG_C88q><-LWALwQZZ>Kv6fW2DFpLn{>e~Sx%aoA-<@3$ z&+hAs)ryIBEN4by#Q8y`7w2rVLdneSGllr(xvyI<&M$09=T@zz>tiL=HCR!)rJW4M zlC=eV2x|J&?hiCtx|j>W636L$LTq2kVs!GdvxC%FlehjhsWv|c&Q#vK~;sdIkm=v(RI z*3vn8&xml=Qq;C|=@v-}=Z2U;)M#gw82{uKBn^?Qa#{_ah3_r9#r7 zy{TpiKT*&H$F>KTyC#kP$=muHYn?cV(P~yi%0?{?^s;ROoeHjDyThmEv^~1uXtN-F zNO6dvXZrKUW(x)A)!WGUxFB;o8Z`8nhkSlD;8y?;xMCqF1=fZifbz+KoBO5T#-B}QRbxTH879ytChU$&b_ek*B~o(z4v zmakT0gir(h8Ek5F7+rqLKi0sEl4tk3QK=hw1i3M=xOR!(YSks$?wN%h39aHX1p&9f z+rH45d-Ctg5Re*@2=W;m^dWKzLqUw21ru~anH4Fhy*%e9&e=iinTL}Irr3cJ5+qCP z%=0j(Feoi9XsE`A=*EJ zxFVs#k$DQ#Z=kH;+81LH1cl)$hB4>AlU0_?zf(`vlYb}1EVh5AH;pvr1l%6(v-o@w zUv*5?rLCY4-q6snh5-Ztz!snFFTZ@076{TU z*K_?{JMg;xp9U$NpfFEOaP+Fn67ww3E0uCvlr=a1q{A^+ZH=#vkSj_NtNm+(sj+0fzP_~$4TXh;Fu2JN2|fM6@o}AZpJV*6;LFR)+uKf{^I7&i zm;2pB6ApC;sB8%zh&cdALr50Yl-c&6md3_3zu)uo!I#GWl2?)`$BsVP(B>VF7vHol9Ja*O_{nWmB9(f z;MyULgtv~aZqU_E!w?04d->Y-;_8l$oFDs+4p3+8e(vu4>PW!wfU`ZC0D)M`&dyFr z1b~w8k#J96U&zUXz721FVc`=f(2ziBp-?DqL+Ku6kM|SRWo1sNvHYX;pQRNQuFlSL z&0bq2x}|{LY}bu>2p~Yv0Eu~fd;8C$ieD@g4Rc}y7EzDMSk)E}d;_KbaXQ3nd-c!zKG1-Q zL0MVZ(8vhgc0j!6^z5C(D_z|OG&IhJ=zTpsMFiW`V`zU2W|$4?93H46C46?bk9702 zd`~uYk+5Yz>TPUQPMU9_7@)+BRg`TL}J!!<-diDW8LL@nW!M0hld0N!cfsOh33gvpal70fh!jmaA zV|E$eDk^0B4p+BlYJRdJAYY(_jfTUyGBUip6;)L)fN%#3NV2{ue|a382N+>EiJVR2 zR8%-I)xCd76ai3}krr^0Wj+GFN96`kL8;7wIeRLDeYZl?45yyxrRvjeY@@s#yA6#< zw)#CK8Ekcuv!kwNH20QW>pk=>i;W@tExevr5!|+<$>`XKZ>iaY*~S`po-*BSg+J*x z2L5xk_r|p5csNV!2*fVX)S!$3D6p8A7^hwtsKr43c?7^-3+`m&`4$<;GP+epJAiBb zPK-;&rl%zwz$Cc1xBx>GuvuKlOgd2Z>R-x8L@jDAi5u9Me!n+Ie~i8!K{Xw`U^UXs z&cpNJbJ5vC$b!=kuyV07GsmbA&f0dy9j%6YVJ>qGW?k>NhB(^WS9uU-yn=5GX9oas z3+f2@+Ue=(_Wt%t1_lQH{{Fy9z%|41ALfMI`mLyfrT9mGzn#7PFFQeCji&U)DjZW@ zJ|c6Jhnaz4X|~Bz*c4M5otz)6K%vkYo1b_% zI0hiC7|U0EfQJc@y!`tG6BE;+&i{93qhj4Rw+5PDwTJ;1VC zlHcS-h6+_WR=J<`%*_S$#!$X`^{SU2WP2MUxwbBp$hkQaZS88XM{VSW%-7yMi+?U? zJDgQk#s%x>Q-j<7%&^{DlXVIgs=R4eK6gp=<1E8yR4i1(PAVntUHe^m5$O*ngfz^vi=>wN>Bd8l_->(b0@}7TNHoJ!=_NXj0 z@``T^%!{7|S^ThwHH)uct;Yei*=#%(2X5fEz-ZdECeN&*Qf+pdJ3KY zMnRdh=izV1iWm3m0B#3mg!j)rgPIw)QN5iY)q1Z+0aQaxZ5;I)+_6b6FDfPmfOZ#q zdkdEfGFsZ4^z@7ImzrSo{Q?3iOj`6Rrbg1AMB0bDI66xBAO8)*CF`+tfBpJ6g7A^| z&Tk>}&bY+?gkIUL$Km?6{*7|y|W9#YauC1;zv$37|aadbgE?*pN2vLFZp1AvR zPfs*iEHAfl<9;Mvu(`McCp)`Ay;J%@KCqQe%Kv!2Er2Svps~>xgfFPo)ZZr%6Xr#q zCGApFv&U-f#He1%+@>`D<1_I4je9)J(Hp`CIPMf!|IIro%3CcuG3y0NE$z@kDn={Ui&q^9GIHpo zfQ_h(Ovuj+AyBye$jy-YedXXj-uDb4$F!UIrRNnlyxd?H_1kMZOLNpBk&6NJ0Z;f*T73^Hf=^2S>9^f`Bp#0+Ja`~;bBv58W80=R`M+vK|DS)SpDxB} zKA_yc2z>V$fw_1nE)u^oxv{jFTrK0#WXu?kgCTV%%Ki>W8L$H^es%LZIP*$?fB*v} zo%rWpwTs6cgcM$&g(8s1myGdtb-$aN#mn{ZH84^ihM2h(Kt9q+WsGc0O-(rpQXvm4 ztH3tq| zv^Tf}dEz`gX*8X#A6t&hiWqdn4KcfiJ)M%H%J=Ul0aO2e3qUuLlAOc&-}a7mhVMRu zVpzEvkd?|Wl02!EyVmN$I(Qo%Riz1YR2j@}lv-A$*`GjdLkAu4LKc>u$X#Ut)HII; zRtoX#;X~0pjY3cvM*nyHpY2plicGPJD{E^ki34GB3?KW*a4>dvcLmai78Y`qD?GO$ zkCqOX_wc)kM#5=LeR%|P@ON~e!cy9sg5`SXU29%0j1P@+N&b%t;Qzy`X!VzOIxZ7m VeV+QNfG$Fm6*QiGlQVzwzW}aRtnvT= diff --git a/tests/test_lcd320x240.py b/tests/test_lcd320x240.py index 8b559da52..62438e2ea 100644 --- a/tests/test_lcd320x240.py +++ b/tests/test_lcd320x240.py @@ -23,6 +23,7 @@ ShadowState, ) from common.parameter import BYPASS_SYMBOL, Parameter, PortInfo, Symbol +from common.parameter_editing import EditContext from modalapi.external_midi import EXTERNAL_INSTANCE_ID from pistomp.encoder_controller import EncoderController from pistomp.footswitch import Footswitch @@ -290,10 +291,44 @@ def test_parameter_dialog_snapshot(lcd, snapshot): instance, _ = lcd setup_main_ui(instance) mock_param = _real_param(name="Gain", instance_id="delay", value=0.5) - instance.draw_parameter_dialog(mock_param) + instance.open_parameter_editor(EditContext(mock_param, instance.handler.parameter_ui_value_commit)) snapshot() +def test_parameter_dialog_cache_separates_parameter_instances(lcd): + instance, _ = lcd + setup_main_ui(instance) + first = _real_param(instance_id="amp_1") + second = _real_param(instance_id="amp_2") + first_dialog = instance.open_parameter_editor(EditContext(first, instance.handler.parameter_ui_value_commit)) + second_dialog = instance.open_parameter_editor(EditContext(second, instance.handler.parameter_ui_value_commit)) + assert first_dialog is not second_dialog + + +def test_parameter_dialog_uses_context_range_and_title(lcd): + instance, _ = lcd + setup_main_ui(instance) + info: PortInfo = { + "shortName": "Gain", + "symbol": "gain", + "ranges": {"minimum": 0.0, "maximum": 1.0}, + } + param = Parameter(info, 0.5, "0:70", None, binding_range=(0.25, 0.75)) + ui_dialog = instance.open_parameter_editor(EditContext(param, instance.handler.parameter_ui_value_commit)) + assert (ui_dialog.minimum, ui_dialog.maximum) == (0.0, 1.0) + assert ui_dialog.decorator.title.text == "Gain" + ui_dialog.pop() + + physical_context = EditContext( + param, + instance.handler.parameter_value_commit, + grid_range=(param.minimum, param.maximum), + ) + instance.display_parameter_value(physical_context, param.value) + physical_dialog = instance.w_parameter_dialogs[physical_context.cache_key] + assert (physical_dialog.minimum, physical_dialog.maximum) == (0.25, 0.75) + + def test_parameter_dialog_batches_detents(lcd): """A tick's worth of detents advances the value once, in a single render. @@ -303,7 +338,7 @@ def test_parameter_dialog_batches_detents(lcd): instance, _ = lcd setup_main_ui(instance) mock_param = _real_param(name="Gain", instance_id="delay", value=0.5) - dialog = instance.draw_parameter_dialog(mock_param) + dialog = instance.open_parameter_editor(EditContext(mock_param, instance.handler.parameter_ui_value_commit)) renders = 0 original = dialog._draw_graph @@ -326,7 +361,7 @@ def test_parameter_dialog_applies_encoder_multiplier(lcd): instance, _ = lcd setup_main_ui(instance) mock_param = _real_param(name="Gain", instance_id="delay", value=0.0) - dialog = instance.draw_parameter_dialog(mock_param) + dialog = instance.open_parameter_editor(EditContext(mock_param, instance.handler.parameter_ui_value_commit)) # 2 detents at 3x = 6 grid steps from the bottom. _enc_step(instance, 2, multiplier=3.0) @@ -372,7 +407,7 @@ def test_tweak_dialog_has_no_timeout_and_shows_close_button(lcd): """Tweak-encoder edits (display_parameter_value) must stay open with a Close button.""" instance, _ = lcd setup_main_ui(instance) - d = instance.draw_parameter_dialog(_mock_param()) + d = instance.open_parameter_editor(EditContext(_mock_param(), instance.handler.parameter_ui_value_commit)) assert d.timeout is None assert any(getattr(w, "text", None) == "Close" for w in d.children) @@ -380,7 +415,7 @@ def test_tweak_dialog_has_no_timeout_and_shows_close_button(lcd): def test_tweak_dialog_never_autocloses(lcd): instance, _ = lcd setup_main_ui(instance) - d = instance.draw_parameter_dialog(_mock_param()) + d = instance.open_parameter_editor(EditContext(_mock_param(), instance.handler.parameter_ui_value_commit)) d.parameter_value_change(1) # simulate a tweak; reset_timeout() is a no-op when timeout is None assert d.expiry_time is None d.tick() @@ -395,7 +430,7 @@ def test_tweak_button_click_closes_parameter_dialog(lcd): instance, _ = lcd setup_main_ui(instance) - d = instance.draw_parameter_dialog(_mock_param()) + d = instance.open_parameter_editor(EditContext(_mock_param(), instance.handler.parameter_value_commit)) assert d.parent is not None # open knob = Controller(midi_channel=0, midi_CC=None) @@ -409,7 +444,7 @@ def test_volume_dialog_autocloses_and_has_no_close_button(lcd): """The Volume/audio-card dialog must autoclose and never show a Close button.""" instance, _ = lcd setup_main_ui(instance) - d = instance.draw_audio_parameter_dialog(_mock_param(name="Volume"), commit_callback=lambda *_: None) + d = instance.open_audio_parameter_dialog(EditContext(_mock_param(name="Volume"), lambda *_: None)) assert d.timeout is not None assert not any(getattr(w, "text", None) == "Close" for w in d.children) @@ -417,7 +452,7 @@ def test_volume_dialog_autocloses_and_has_no_close_button(lcd): def test_volume_dialog_autocloses_after_timeout(lcd): instance, _ = lcd setup_main_ui(instance) - d = instance.draw_audio_parameter_dialog(_mock_param(name="Volume"), commit_callback=lambda *_: None) + d = instance.open_audio_parameter_dialog(EditContext(_mock_param(name="Volume"), lambda *_: None)) d.expiry_time = 1 # force expiry without sleeping d.tick() assert d.parent is None # popped @@ -427,7 +462,7 @@ def test_volume_dialog_still_autocloses_after_being_updated_again(lcd): """Regression: turning the volume encoder again (update_value) must keep autoclose armed.""" instance, _ = lcd setup_main_ui(instance) - d = instance.draw_audio_parameter_dialog(_mock_param(name="Volume"), commit_callback=lambda *_: None) + d = instance.open_audio_parameter_dialog(EditContext(_mock_param(name="Volume"), lambda *_: None)) d.update_value(0.7) assert d.timeout is not None d.expiry_time = 1 @@ -604,7 +639,7 @@ def test_parameter_dialog_shows_tweak_badge_snapshot(lcd, snapshot): ] ) - instance.draw_parameter_dialog(gain_param) + instance.open_parameter_editor(EditContext(gain_param, instance.handler.parameter_ui_value_commit)) snapshot() @@ -643,7 +678,7 @@ def test_parameter_dialog_shows_tweak_badge_for_external_param(lcd): ] ) - d = instance.draw_parameter_dialog(ext_param) + d = instance.open_parameter_editor(EditContext(ext_param, instance.handler.parameter_ui_value_commit)) assert d._badge is not None diff --git a/tests/test_parameter_steps.py b/tests/test_parameter_steps.py index 035cf5242..bc7cf2557 100644 --- a/tests/test_parameter_steps.py +++ b/tests/test_parameter_steps.py @@ -24,7 +24,7 @@ import common.util as util from common.parameter import Parameter, Symbol, Type -from common.parameter_steps import ( +from common.parameter_editing import ( CONTINUOUS_STEPS, FULL_SWEEP_DETENTS, REFERENCE_FAST_MULTIPLIER, diff --git a/tests/test_plugin_panels.py b/tests/test_plugin_panels.py index 0835d9f13..7e32ec9d9 100644 --- a/tests/test_plugin_panels.py +++ b/tests/test_plugin_panels.py @@ -42,6 +42,8 @@ def parameter_value_commit(self, param: Parameter, value: float) -> None: """Mirrors Modhandler: the route is chosen here, and commit reverts a value that never left.""" param.commit(value, lambda p: self.ws_bridge.send_parameter(str(p.instance_id), p.symbol, p.value)) + def parameter_ui_value_commit(self, param: Parameter, value: float) -> None: + self.parameter_value_commit(param, value) def toggle_plugin_bypass(self, plugin) -> None: """Mirrors Modhandler for a footswitch-less plugin: commit over the WS.""" diff --git a/tests/v2/test_gx_cabinet_panel.py b/tests/v2/test_gx_cabinet_panel.py index da8bd910d..8edd2d681 100644 --- a/tests/v2/test_gx_cabinet_panel.py +++ b/tests/v2/test_gx_cabinet_panel.py @@ -83,8 +83,7 @@ def test_gx_cabinet_nav_only_edits_model(v2_system: SystemFixture, nav_handler, nav_click(handler) handler.poll_lcd_updates() - # Enumeration params open a selection menu (not a Parameterdialog) — - # draw_parameter_dialog's own type-based dispatch, reused as-is. + # Enumeration params open a selection menu through open_parameter_editor. assert current_dialog(v2_system) is None assert lcd.pstack.current is not panel_before snapshot("model_menu_open") diff --git a/tests/v3/test_encoder_dispatch.py b/tests/v3/test_encoder_dispatch.py index 419680ae3..d3816c934 100644 --- a/tests/v3/test_encoder_dispatch.py +++ b/tests/v3/test_encoder_dispatch.py @@ -30,6 +30,7 @@ EventKind, MidiCcEffect, ) +from common.parameter_editing import EditContext from pistomp.encoder_controller import EncoderController from pistomp.input.event import EncoderEvent from rtmidi.midiconstants import CONTROL_CHANGE @@ -115,7 +116,7 @@ def test_main_panel_volume_encoder_sets_audiocard_master(v3_system: SystemFixtur enc3 = _enc(hw, 3) assert enc3.parameter is not None - from common.parameter_steps import ParameterSteps + from common.parameter_editing import ParameterSteps expected = ParameterSteps.for_parameter(enc3.parameter).move(1) enc3.refresh(1) @@ -129,7 +130,7 @@ def test_main_panel_volume_encoder_sets_audiocard_master(v3_system: SystemFixtur def test_main_panel_volume_encoder_commits(v3_system: SystemFixture): """The volume turn is one commit through the audio sink, so _confirmed tracks the card and a keycap-class observer sees committed values.""" - from common.parameter_steps import ParameterSteps + from common.parameter_editing import ParameterSteps _prime_main_panel(v3_system) handler = v3_system.handler @@ -202,7 +203,7 @@ def test_parameter_dialog_nav_change_emits_cc_for_external_param(v3_system: Syst ] ) - d = handler.lcd.draw_parameter_dialog(ext_param) + d = handler.lcd.open_parameter_editor(EditContext(ext_param, handler.parameter_ui_value_commit)) hw.midiout.send_message.reset_mock() d.input_event(InputEvent.RIGHT) @@ -223,10 +224,10 @@ def test_parameter_dialog_nav_change_emits_cc_for_external_param(v3_system: Syst def _open_dialog_for_param(v3_system, param, *, tweak_id: int | None = None): - """Push a Parameterdialog for *param* and (optionally) badge it with a - tweak id, mirroring what draw_parameter_dialog does for an external CC.""" + """Push a Parameterdialog for *param* and (optionally) badge its + tweak id, mirroring what ``open_parameter_editor`` does for an external CC.""" handler = v3_system.handler - d = handler.lcd.draw_parameter_dialog(param) + d = handler.lcd.open_parameter_editor(EditContext(param, handler.parameter_ui_value_commit)) if tweak_id is not None: from uilib.glyphs.badge import BadgeGlyph @@ -355,8 +356,8 @@ def test_tweak_bound_to_same_param_as_dialog_edits_once(v3_system: SystemFixture ] ) - handler.lcd.draw_parameter_dialog(ext_param) - # draw_parameter_dialog already badges it to tweak1 via tweak_badge_number. + handler.lcd.open_parameter_editor(EditContext(ext_param, handler.parameter_ui_value_commit)) + # open_parameter_editor already badges it to tweak1 via tweak_badge_number. hw.midiout.send_message.reset_mock() before = ext_param.value diff --git a/tests/v3/test_midi_learn.py b/tests/v3/test_midi_learn.py index 853101ede..7b4e6c2c7 100644 --- a/tests/v3/test_midi_learn.py +++ b/tests/v3/test_midi_learn.py @@ -4,6 +4,7 @@ import common.util as util from common.contexts import ControlClass, EventKind, MidiCcEffect, ParamEffect from common.parameter import BYPASS_SYMBOL, Parameter, PortInfo, Symbol +from common.parameter_editing import EditContext from tests.types import SystemFixture LOG_PORT: PortInfo = { @@ -100,8 +101,8 @@ def test_v3_param_set_syncs_bound_footswitch(v3_system: SystemFixture, make_plug def test_v3_midi_learn_applies_custom_sub_range(v3_system: SystemFixture, make_plugin, make_parameter): - """A midi_map carrying a custom sub-range narrows the parameter's encoder - sweep and displayed endpoints live, without a pedalboard reload.""" + """A midi_map custom sub-range changes physical CC conversion. The UI + dialog keeps the plugin's declared range.""" handler = v3_system.handler hw = v3_system.hw ws_bridge = v3_system.ws_bridge @@ -115,7 +116,6 @@ def test_v3_midi_learn_applies_custom_sub_range(v3_system: SystemFixture, make_p assert (gain.minimum, gain.maximum) == (0.0, 1.0) plugin = make_plugin("noise", bypassed=False, parameters={"gain": gain}) handler.current.pedalboard.plugins = [plugin] - ws_bridge.inject(f"midi_map /graph/noise gain {channel} {cc} 0.0 0.5") handler.poll_ws_messages() @@ -123,11 +123,8 @@ def test_v3_midi_learn_applies_custom_sub_range(v3_system: SystemFixture, make_p def test_v3_midi_learn_sub_range_saga(v3_system: SystemFixture, make_plugin, make_parameter, snapshot): - """End-to-end: MIDI-learn a plugin param to a tweak encoder with a custom - sub-range, then reach both extents by spinning. The parameter saturates at - the sub-range endpoints (0.1..0.2) — never the plugin's declared 0..1 — and - the emitted CC spans the full 7-bit range across that sub-range. The open - parameter dialog paints the sub-range endpoints, not 0.0..1.0.""" + """A mapped physical control uses its custom sub-range for CC conversion, + while its UI dialog uses the plugin's declared range.""" handler = v3_system.handler hw = v3_system.hw ws_bridge = v3_system.ws_bridge @@ -148,21 +145,20 @@ def test_v3_midi_learn_sub_range_saga(v3_system: SystemFixture, make_plugin, mak assert enc1.parameter is gain assert (gain.minimum, gain.maximum) == (0.1, 0.2) - # The dialog draws param.format(minimum)/param.format(maximum) as its axis - # endpoints — the visual proof the sub-range replaced the declared 0.0..1.0. - handler.lcd.draw_parameter_dialog(gain) + physical_context = EditContext( + gain, + handler.parameter_value_commit, + grid_range=(gain.minimum, gain.maximum), + ) + handler.lcd.display_parameter_value(physical_context, gain.value) snapshot("bound_0p15") - # Spin up hard — enough detents to saturate the 128-step grid at the top. - # The parameter stops at the sub-range max (0.2), never the declared 1.0, - # and the CC pi-stomp would emit (bar_midi_value) reaches the 7-bit ceiling. for _ in range(200): enc1.refresh(1) assert gain.value == 0.2 assert enc1.bar_midi_value() == 127 snapshot("max_0p20") - # Spin down hard — saturate at the sub-range min (0.1), never 0.0, CC → 0. for _ in range(200): enc1.refresh(-1) assert gain.value == 0.1 @@ -185,7 +181,7 @@ def test_v3_log_parameter_dialog_paints_geometric_curve(v3_system: SystemFixture handler.lcd.link_data(handler.pedalboard_list, handler.current, hw.footswitches) handler.lcd.draw_main_panel() - handler.lcd.draw_parameter_dialog(freq) + handler.lcd.open_parameter_editor(EditContext(freq, handler.parameter_ui_value_commit)) snapshot("log_dialog_midpoint") @@ -572,12 +568,7 @@ def test_v3_midi_learn_free_cc_preserves_sub_range(v3_system: SystemFixture, mak assert handler.current used = set(hw.controllers) - binding = next( - "%d:%d" % (ch, cc) - for ch in range(1, 16) - for cc in range(0, 127) - if "%d:%d" % (ch, cc) not in used - ) + binding = next("%d:%d" % (ch, cc) for ch in range(1, 16) for cc in range(0, 127) if "%d:%d" % (ch, cc) not in used) channel, cc = binding.split(":") gain = make_parameter("Gain", "noise", value=0.5) diff --git a/tests/v3/test_plugins.py b/tests/v3/test_plugins.py index e56ed255f..80f856abc 100644 --- a/tests/v3/test_plugins.py +++ b/tests/v3/test_plugins.py @@ -12,7 +12,7 @@ from pistomp.encoder_controller import EncoderController as Encoder from pistomp.footswitch import Footswitch from common.parameter import BYPASS_SYMBOL, Parameter, PortInfo, Symbol -from common.parameter_steps import ParameterSteps +from common.parameter_editing import ParameterSteps from modalapi.plugin import Plugin from pistomp.controller import ControlType from pistomp.config.adapt_v1 import adapt diff --git a/tests/v3/test_reactive_parameter.py b/tests/v3/test_reactive_parameter.py index 2c71ebe67..e707523e6 100644 --- a/tests/v3/test_reactive_parameter.py +++ b/tests/v3/test_reactive_parameter.py @@ -16,6 +16,7 @@ import pistomp.switchstate as switchstate from common.parameter import BYPASS_SYMBOL, Parameter, PortInfo, Symbol +from common.parameter_editing import EditContext from modalapi.plugin import Plugin from plugins.fullscreen import FullscreenPluginPanel from plugins.window import PluginWindow @@ -590,7 +591,12 @@ def test_rapid_footswitch_with_panel_open_coalesces(v3_system: SystemFixture, ma def _open_dialog(v3_system: SystemFixture, plugin: Plugin) -> Parameterdialog: param = plugin.parameters[Symbol("gain")] - return cast(Parameterdialog, v3_system.handler.lcd.draw_parameter_dialog(param)) + return cast( + Parameterdialog, + v3_system.handler.lcd.open_parameter_editor( + EditContext(param, v3_system.handler.parameter_ui_value_commit) + ), + ) def test_open_dialog_follows_external_param_set(v3_system: SystemFixture, make_plugin): diff --git a/uilib/__init__.py b/uilib/__init__.py index 98bdb7cb3..3475b2a67 100644 --- a/uilib/__init__.py +++ b/uilib/__init__.py @@ -35,6 +35,7 @@ "Panel", "PanelDecorator", "PanelStack", + "EditContext", "Parameterdialog", "PluginTile", "RoundedPanel", @@ -79,7 +80,7 @@ trace, ) from uilib.panel import LcdBase, Panel, PanelDecorator, PanelStack, RoundedPanel, ShroudedPanel +from common.parameter_editing import EditContext from uilib.parameterdialog import Parameterdialog from uilib.text import Button, LetterSelector, PluginTile, ScrollingText, TextEditor, TextWidget from uilib.widget import Widget - diff --git a/uilib/parameterdialog.py b/uilib/parameterdialog.py index 389c52cf7..dc110069c 100644 --- a/uilib/parameterdialog.py +++ b/uilib/parameterdialog.py @@ -15,6 +15,8 @@ # You should have received a copy of the GNU Affero General Public License # along with pi-stomp. If not, see . +from __future__ import annotations + from uilib.box import Box from uilib.config import Config from uilib.dialog import Dialog @@ -34,7 +36,7 @@ ) import common.util as util from common.parameter import Parameter, Symbol -from common.parameter_steps import ParameterSteps, effective_multiplier +from common.parameter_editing import EditContext, ParameterSteps, effective_multiplier from pistomp.input.dispatch import resolve_local, fire from pistomp.input.event import ControllerEvent, EncoderEvent @@ -52,6 +54,7 @@ # render exactly as they always have. MIN_BAR_PX = 3 + # Bar geometry/colors are fixed constants so the rendered bar surface depends # only on the taper shape and color. A log port's height curve depends only on # max/min (bar height = the value at sweep position p as a linear fraction of @@ -95,55 +98,53 @@ def _draw_erase(self, ctx): pass + + class Parameterdialog(Dialog): - # TODO detailed dimensions, colors, etc. should not be defined in uilib GRAPH_Y0 = 80 GRAPH_X_OFFSET = 10 - BAR_FILLED = (255, 255, 0) # 'yellow' + BAR_FILLED = (255, 255, 0) BAR_UNFILLED = (100, 100, 240) - def __init__(self, stack, parameter, width, height, title, title_font=None, timeout=None, **kwargs): + @staticmethod + def title_for(edit_context: EditContext) -> str: + parameter = edit_context.parameter + if parameter.instance_id is None: + return parameter.name + return f"{parameter.instance_id}:{parameter.name}" + + def __init__( + self, + stack, + edit_context: EditContext, + width, + height, + title: str | None = None, + title_font=None, + timeout=None, + **kwargs, + ): self._init_attrs(Widget.INH_ATTRS, kwargs) - super(Parameterdialog, self).__init__(width, height, title, title_font, **kwargs) - self.stack = ( - stack # TODO very LAME to require the stack to be passed, ideally panel would be able to pop itself - ) - self.parameter: Parameter = parameter - - # The tweak encoder (1/2/3) TTL/config-bound to this dialog's parameter - # (set by Lcd320x240.draw_parameter_dialog from tweak_badge_number). - # When set, the dialog declares a PANEL row for it so a turn drives the - # dialog's parameter through the binding table instead of falling - # through to Modhandler._handle_encoder (which would write the tweak's - # pedalboard-bound parameter underneath — see input/README.md). + self.stack = stack + super().__init__(width, height, title or self.title_for(edit_context), title_font, **kwargs) + self.edit_context = edit_context + self.parameter = edit_context.parameter + self.minimum, self.maximum = edit_context.extents self._tweak_id: int | None = None - - # The nav encoder steps this dialog through the same quantized grid a v3 - # tweak encoder uses, so a detent moves the value identically whichever - # control you turn (v2 nav, v3 nav, v3 tweak). - self.steps = ParameterSteps.for_parameter(self.parameter) + self.steps = ParameterSteps.for_parameter(self.parameter, (self.minimum, self.maximum)) self.timeout = timeout self.expiry_time = None if self.timeout: self.reset_timeout() - # "graph" are the y-scaled values, "actual" are the actual non-scaled values - self.num_actual = 256 # High resolution for better stepping + self.num_actual = 256 self.num_points = 60 self.bar_width = 4 self.actual_abscissa = np.linspace(0, self.num_actual, self.num_actual) - self.actual_points = self._calc_graph_points( - self.actual_abscissa, self.parameter.minimum, self.parameter.maximum - ) - - # Value at which each bar becomes filled. Nondecreasing, so the filled - # bars are always the prefix [0, k) and a value change dirties only the - # columns between the old and new k. + self.actual_points = self._calc_graph_points(self.actual_abscissa, self.minimum, self.maximum) self.bar_thresholds = self.actual_points[(np.arange(self.num_points) * self.num_actual) // self.num_points] - self.graph_width = self.GRAPH_X_OFFSET + self.bar_width * self.num_points - # +1 row of headroom so a max-height bar's bottom edge isn't clipped. self.graph_height = self.GRAPH_Y0 + 1 self.w_value = None @@ -151,7 +152,7 @@ def __init__(self, stack, parameter, width, height, title, title_font=None, time self._graph_surface: pygame.Surface | None = None self._bars_filled: pygame.Surface | None = None self._bars_unfilled: pygame.Surface | None = None - self.last_param_value: float = self.parameter.value + self.last_param_value = self.parameter.value self._draw_contents() self._unsub: Callable[[], None] | None = self.parameter.subscribe(self._on_param_changed) @@ -170,9 +171,7 @@ def _unsubscribe(self) -> None: def _calc_graph_points(self, x, min, max): # Same curve the step grid and the CC lattice use, so the bar the dial # paints for a value matches where a detent puts it. - return np.array( - [util.from_normalized(p, min, max, self.parameter.is_logarithmic) for p in x / len(x)] - ) + return np.array([util.from_normalized(p, min, max, self.parameter.is_logarithmic) for p in x / len(x)]) def _draw_contents(self): if self.timeout is None: @@ -191,10 +190,10 @@ def _draw_contents(self): self._draw_graph() def _update_text_widget(self): - y0 = 80 val_text = self.parameter.format(self.parameter.value) - min_text = self.parameter.format(self.parameter.minimum) - max_text = self.parameter.format(self.parameter.maximum) + y0 = 80 + min_text = self.parameter.format(self.minimum) + max_text = self.parameter.format(self.maximum) # Calculate centered position font = Config().get_font("default") @@ -255,8 +254,11 @@ def _draw_graph(self): if self.w_graph is None: self._graph_surface = pygame.Surface((self.graph_width, self.graph_height), pygame.SRCALPHA) - pmin, pmax = self.parameter.minimum, self.parameter.maximum - log_ratio = pmax / pmin if self.parameter.is_logarithmic and pmin > 0.0 and pmax > 0.0 else None + log_ratio = ( + self.maximum / self.minimum + if self.parameter.is_logarithmic and self.minimum > 0.0 and self.maximum > 0.0 + else None + ) args = ( log_ratio, self.num_points, @@ -320,10 +322,9 @@ def update_value(self, new_value: float) -> None: def parameter_value_change(self, direction, count: int = 1, multiplier: float = 1.0): self.reset_timeout() - # Same arithmetic as EncoderController.refresh: the multiplier scales the - # number of grid steps, not the value. effective_multiplier caps it per - # parameter so a full-speed spin covers the same fraction of any grid. - delta = int(round(direction * count * effective_multiplier(multiplier, self.parameter))) + delta = int( + round(effective_multiplier(multiplier, self.parameter, self.minimum, self.maximum) * direction * count) + ) if delta == 0: return new_value = self.steps.move(delta) @@ -331,8 +332,7 @@ def parameter_value_change(self, direction, count: int = 1, multiplier: float = return self.parameter.preview(new_value) - if self.action is not None: - self.action(self.object, new_value) + self.edit_context.commit(self.parameter, new_value) def input_event(self, event): if event == InputEvent.CLICK: From 2e438c7a7ec4b58a04a6c791c67892de0ba0b21d Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Mon, 7 Sep 2026 19:03:05 -0400 Subject: [PATCH 2/3] Don't reclamp -- only outgoing midi needs to be clamped --- common/parameter.py | 16 +--- .../bound_0p15.png | Bin 0 -> 7871 bytes .../max_0p20.png | Bin 0 -> 7830 bytes .../min_0p10.png | Bin 0 -> 7742 bytes tests/test_parameter_binding_range.py | 70 ++++++------------ tests/v3/test_midi_learn.py | 39 +++++----- tests/v3/test_sink_routing.py | 20 ++--- 7 files changed, 55 insertions(+), 90 deletions(-) create mode 100644 tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_uses_mapping_for_physical_grid_and_declared_ui_range/bound_0p15.png create mode 100644 tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_uses_mapping_for_physical_grid_and_declared_ui_range/max_0p20.png create mode 100644 tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_uses_mapping_for_physical_grid_and_declared_ui_range/min_0p10.png diff --git a/common/parameter.py b/common/parameter.py index 676189df8..f9149049f 100644 --- a/common/parameter.py +++ b/common/parameter.py @@ -233,23 +233,15 @@ def _notify_committed(self) -> None: observe(self) def set_binding_range(self, binding_range: tuple[float, float]) -> None: - """Set the effective extents from a MIDI-CC (sub-)range.""" + """Set the physical-control extents without changing the port value.""" if (self.minimum, self.maximum) != binding_range: - self._reclamp(binding_range) + self.minimum, self.maximum = binding_range def clear_binding_range(self) -> None: - """Restore effective extents to the plugin's declared LV2 range.""" + """Restore physical-control extents to the declared LV2 range.""" declared = (self.declared_minimum, self.declared_maximum) if (self.minimum, self.maximum) != declared: - self._reclamp(declared) - - def _reclamp(self, extents: tuple[float, float]) -> None: - self.minimum, self.maximum = extents - self._value = max(self.minimum, min(self._value, self.maximum)) - self._confirmed = max(self.minimum, min(self._confirmed, self.maximum)) - for observe in self._observers: - observe(self) - self._notify_committed() + self.minimum, self.maximum = declared def subscribe(self, cb: Callable[[Parameter], None]) -> Callable[[], None]: """Register *cb* to fire on every changed-value write. Returns its own diff --git a/tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_uses_mapping_for_physical_grid_and_declared_ui_range/bound_0p15.png b/tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_uses_mapping_for_physical_grid_and_declared_ui_range/bound_0p15.png new file mode 100644 index 0000000000000000000000000000000000000000..628fd624451677cfd6a49df399526b544fc356da GIT binary patch literal 7871 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 literal 0 HcmV?d00001 diff --git a/tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_uses_mapping_for_physical_grid_and_declared_ui_range/max_0p20.png b/tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_uses_mapping_for_physical_grid_and_declared_ui_range/max_0p20.png new file mode 100644 index 0000000000000000000000000000000000000000..663682a8b9183cfbfb8c63e8d619077da743a605 GIT binary patch literal 7830 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 literal 0 HcmV?d00001 diff --git a/tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_uses_mapping_for_physical_grid_and_declared_ui_range/min_0p10.png b/tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_uses_mapping_for_physical_grid_and_declared_ui_range/min_0p10.png new file mode 100644 index 0000000000000000000000000000000000000000..be61fbdb8204f2119831fd4edeb518e4c2a5f607 GIT binary patch literal 7742 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 Oo7I(dl)flhzxrPs859=) literal 0 HcmV?d00001 diff --git a/tests/test_parameter_binding_range.py b/tests/test_parameter_binding_range.py index 944866589..1660209e1 100644 --- a/tests/test_parameter_binding_range.py +++ b/tests/test_parameter_binding_range.py @@ -13,9 +13,10 @@ # You should have received a copy of the GNU General Public License # along with pi-stomp. If not, see . -"""A MIDI-CC binding can carry a custom sub-range (mod-ui's "Advanced" addressing). -While that binding holds, the encoder sweeps and the LCD reads the sub-range, not -the plugin's declared LV2 range.""" +"""A MIDI-CC binding carries a physical-control sub-range (mod-ui's "Advanced" addressing). +The plugin keeps its declared range; the sub-range controls physical MIDI conversion +and step grids only. +""" from common.parameter import MidiCC, Parameter, PortInfo from modalapi.pedalboard import Pedalboard @@ -25,69 +26,66 @@ def _port(minimum: float = 0.0, maximum: float = 1.0) -> PortInfo: return PortInfo(shortName="gain", symbol="gain", ranges={"minimum": minimum, "maximum": maximum}) -def test_binding_range_overrides_declared_range(): +def test_binding_range_sets_physical_extents(): p = Parameter(_port(0.0, 1.0), 0.25, binding="0:70", binding_range=(0.0, 0.5)) assert p.minimum == 0.0 assert p.maximum == 0.5 -def test_no_binding_range_uses_declared_range(): +def test_unmapped_parameter_physical_extents_match_declared(): p = Parameter(_port(0.0, 1.0), 0.25, binding="0:70") assert p.minimum == 0.0 assert p.maximum == 1.0 -def test_set_binding_range_live_narrows_sweep(): +def test_set_binding_range_updates_physical_extents(): p = Parameter(_port(0.0, 1.0), 0.25, binding=None) p.set_binding_range((0.2, 0.6)) assert (p.minimum, p.maximum) == (0.2, 0.6) -def test_set_binding_range_widens_to_full_range(): - """mod-host re-sends the effective range on re-address — a re-map back to the - full range arrives as the declared extents and overwrites the sub-range.""" +def test_set_binding_range_updates_physical_extents_to_new_mapping(): + """A re-address can replace a custom MIDI sub-range with the full range.""" p = Parameter(_port(0.0, 1.0), 0.25, binding="0:70", binding_range=(0.0, 0.5)) p.set_binding_range((0.0, 1.0)) assert (p.minimum, p.maximum) == (0.0, 1.0) -def test_set_binding_range_preserves_identity(): - """The range mutates in place — the same Parameter object, so every - controller/dialog/subscriber holding it stays wired.""" +def test_set_binding_range_preserves_parameter_identity(): + """The same Parameter object keeps all controller and UI references valid.""" p = Parameter(_port(0.0, 1.0), 0.25, binding=None) before = id(p) p.set_binding_range((0.2, 0.6)) assert id(p) == before -def test_clear_binding_range_restores_declared_range(): - """Calling clear_binding_range resets minimum and maximum to declared_minimum and declared_maximum.""" +def test_clear_binding_range_restores_declared_physical_extents(): p = Parameter(_port(30.0, 800.0), 100.0, binding="0:70", binding_range=(100.0, 400.0)) assert (p.minimum, p.maximum) == (100.0, 400.0) p.clear_binding_range() assert (p.minimum, p.maximum) == (30.0, 800.0) -def test_binding_range_notifies_subscribers_and_clamps_value(): - """set_binding_range and clear_binding_range notify observers and clamp value if out of bounds.""" +def test_binding_range_change_preserves_parameter_value(): + """Changing MIDI coverage must not change the MOD-owned port value.""" p = Parameter(_port(0.0, 1.0), 0.9, binding="0:70", binding_range=(0.0, 1.0)) notifications = [] p.subscribe(lambda param: notifications.append(param.value)) - # Narrow range past current value (0.9 -> 0.5 max) p.set_binding_range((0.0, 0.5)) - assert p.value == 0.5 - assert len(notifications) == 1 + assert p.value == 0.9 + assert p._confirmed == 0.9 + assert notifications == [] - # Clear binding range back to 0.0 .. 1.0 p.clear_binding_range() assert (p.minimum, p.maximum) == (0.0, 1.0) - assert p.value == 0.5 # Remains at 0.5 when restored - assert len(notifications) == 2 + assert p.value == 0.9 + assert p._confirmed == 0.9 + assert notifications == [] def test_set_binding_range_is_idempotent(): - """A connect-dump replay re-sends the same range; equality guard suppresses it.""" + """A connect dump can repeat the same physical extents without a value event.""" p = Parameter(_port(0.0, 1.0), 0.5, binding="0:70", binding_range=(0.0, 0.5)) notifications = [] p.subscribe(lambda param: notifications.append(param.value)) @@ -96,34 +94,14 @@ def test_set_binding_range_is_idempotent(): def test_clear_binding_range_is_idempotent(): - """A replayed unmap (-1:-1) after the range is already restored is a no-op.""" + """A repeated unmap does not create a value event.""" p = Parameter(_port(30.0, 800.0), 400.0, binding="0:70", binding_range=(100.0, 400.0)) notifications = [] p.subscribe(lambda param: notifications.append(param.value)) p.clear_binding_range() - assert len(notifications) == 1 - p.clear_binding_range() - assert len(notifications) == 1 - - -def test_reclamp_pulls_confirmed_into_the_new_extents(): - """A failed commit rolls back to _confirmed, so a stale out-of-range - confirmed value would repaint outside the sub-range.""" - p = Parameter(_port(0.0, 1.0), 0.9, binding=None) - p.set_binding_range((0.0, 0.5)) - assert p._confirmed == 0.5 - + assert len(notifications) == 0 p.clear_binding_range() - assert p._confirmed == 0.5 - - -def test_reclamp_notifies_committed_observers(): - p = Parameter(_port(0.0, 1.0), 0.9, binding=None) - committed: list[float] = [] - p.on_commit(lambda param: committed.append(param.value)) - - p.set_binding_range((0.0, 0.5)) - assert committed == [0.5] + assert len(notifications) == 0 # ── Pedalboard._binding_range (the static pedalboard/info midiCC dict) ────── diff --git a/tests/v3/test_midi_learn.py b/tests/v3/test_midi_learn.py index 7b4e6c2c7..2b2681fde 100644 --- a/tests/v3/test_midi_learn.py +++ b/tests/v3/test_midi_learn.py @@ -100,9 +100,8 @@ def test_v3_param_set_syncs_bound_footswitch(v3_system: SystemFixture, make_plug assert fs0.toggled is True # synced on → LED/keycap on -def test_v3_midi_learn_applies_custom_sub_range(v3_system: SystemFixture, make_plugin, make_parameter): - """A midi_map custom sub-range changes physical CC conversion. The UI - dialog keeps the plugin's declared range.""" +def test_v3_midi_learn_updates_physical_extents(v3_system: SystemFixture, make_plugin, make_parameter): + """A custom MIDI mapping range changes physical CC conversion; the UI keeps the declared range.""" handler = v3_system.handler hw = v3_system.hw ws_bridge = v3_system.ws_bridge @@ -122,9 +121,10 @@ def test_v3_midi_learn_applies_custom_sub_range(v3_system: SystemFixture, make_p assert (gain.minimum, gain.maximum) == (0.0, 0.5) -def test_v3_midi_learn_sub_range_saga(v3_system: SystemFixture, make_plugin, make_parameter, snapshot): - """A mapped physical control uses its custom sub-range for CC conversion, - while its UI dialog uses the plugin's declared range.""" +def test_v3_midi_learn_uses_mapping_for_physical_grid_and_declared_ui_range( + v3_system: SystemFixture, make_plugin, make_parameter, snapshot +): + """A physical control uses the mapping range while the UI uses the declared range.""" handler = v3_system.handler hw = v3_system.hw ws_bridge = v3_system.ws_bridge @@ -334,9 +334,8 @@ def test_v3_midi_learn_adds_table_row_for_encoder(v3_system: SystemFixture, make def test_v3_midi_learn_reroutes_an_already_bound_pedalboard(v3_system: SystemFixture, make_plugin, make_parameter): - """A param that was WebSocket-routed at bind time switches to its encoder's CC - once mod-ui learns the mapping. The route is derived per commit, so a binding - learned after bind can't leave a stale one behind.""" + """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.""" handler = v3_system.handler hw = v3_system.hw ws_bridge = v3_system.ws_bridge @@ -496,9 +495,10 @@ def press_rows(): assert fs0.toggled is True -def test_v3_midi_learn_updated_binding_range_on_same_parameter(v3_system: SystemFixture, make_plugin, make_parameter): - """Re-addressing an already bound parameter to a different sub-range on the same CC - updates the parameter's binding range and endpoints without bailing early.""" +def test_v3_midi_learn_updates_physical_extents_on_same_parameter( + v3_system: SystemFixture, make_plugin, make_parameter +): + """Re-addressing one CC updates its physical extents without changing the parameter value.""" handler = v3_system.handler hw = v3_system.hw ws_bridge = v3_system.ws_bridge @@ -508,7 +508,7 @@ def test_v3_midi_learn_updated_binding_range_on_same_parameter(v3_system: System enc1 = next(e for e in hw.encoders if e.id == 1) channel, cc = _binding_for(hw, enc1).split(":") - gain = make_parameter("Gain", "noise", value=0.5) + gain = make_parameter("Gain", "noise", value=0.9) plugin = make_plugin("noise", bypassed=False, parameters={"gain": gain}) handler.current.pedalboard.plugins = [plugin] @@ -518,6 +518,7 @@ def test_v3_midi_learn_updated_binding_range_on_same_parameter(v3_system: System assert gain.binding == f"{channel}:{cc}" assert (gain.minimum, gain.maximum) == (0.0, 0.5) + assert gain.value == 0.9 assert enc1.parameter is gain assert plugin.controllers.count(enc1) == 1 @@ -527,13 +528,13 @@ def test_v3_midi_learn_updated_binding_range_on_same_parameter(v3_system: System assert gain.binding == f"{channel}:{cc}" assert (gain.minimum, gain.maximum) == (0.2, 0.8) + assert gain.value == 0.9 assert enc1.parameter is gain assert plugin.controllers.count(enc1) == 1 -def test_v3_midi_unlearn_restores_declared_range(v3_system: SystemFixture, make_plugin, make_parameter): - """Unmapping (-1:-1) restores the parameter's declared LV2 range rather than - keeping the narrowed sub-range or applying the 0..1 unmap frame default.""" +def test_v3_midi_unlearn_restores_declared_physical_extents(v3_system: SystemFixture, make_plugin, make_parameter): + """Unmapping restores the declared extents after the physical mapping is removed.""" handler = v3_system.handler hw = v3_system.hw ws_bridge = v3_system.ws_bridge @@ -557,10 +558,8 @@ def test_v3_midi_unlearn_restores_declared_range(v3_system: SystemFixture, make_ assert (gain.minimum, gain.maximum) == (gain.declared_minimum, gain.declared_maximum) -def test_v3_midi_learn_free_cc_preserves_sub_range(v3_system: SystemFixture, make_plugin, make_parameter): - """A midi_map naming a CC with no physical pi-stomp control (an external/free - MIDI CC) must still apply its sub-range — the guard keys off the -1:-1 unmap - sentinel, not controller presence, so a real external device's extents are shown.""" +def test_v3_midi_learn_free_cc_preserves_physical_extents(v3_system: SystemFixture, make_plugin, make_parameter): + """A free external CC keeps its physical mapping range without a pi-Stomp control.""" handler = v3_system.handler hw = v3_system.hw ws_bridge = v3_system.ws_bridge diff --git a/tests/v3/test_sink_routing.py b/tests/v3/test_sink_routing.py index 77095a52f..bfbe3b270 100644 --- a/tests/v3/test_sink_routing.py +++ b/tests/v3/test_sink_routing.py @@ -85,9 +85,8 @@ def test_footswitch_press_toggles_between_the_advanced_endpoints(v3_system, make assert hw.midiout.send_message.call_args[0][0][2] == 0 -def test_ui_edit_between_the_endpoints_takes_the_websocket(v3_system, make_plugin, make_parameter): - """The switch's CC has only two codes. A mid-range edit sent that way comes - back from mod-host as an endpoint, against a screen showing the real value.""" +def test_switch_sink_midrange_value_uses_websocket(v3_system, make_plugin, make_parameter): + """A footswitch CC has only endpoint codes, so a mid-range sink value uses WebSocket.""" handler, hw, fs, gain = _learn_footswitch_to_gain(v3_system, make_plugin, make_parameter, (2.0, 8.0)) hw.midiout.send_message.reset_mock() @@ -97,7 +96,7 @@ def test_ui_edit_between_the_endpoints_takes_the_websocket(v3_system, make_plugi assert v3_system.ws_bridge.sent_values_for("amp", gain.symbol) == [5.0] -def test_ui_edit_landing_on_an_endpoint_rides_the_cc(v3_system, make_plugin, make_parameter): +def test_switch_sink_endpoint_value_uses_cc(v3_system, make_plugin, make_parameter): handler, hw, fs, gain = _learn_footswitch_to_gain(v3_system, make_plugin, make_parameter, (2.0, 8.0)) hw.midiout.send_message.reset_mock() @@ -108,10 +107,8 @@ def test_ui_edit_landing_on_an_endpoint_rides_the_cc(v3_system, make_plugin, mak @pytest.mark.parametrize("value", [5.0, 8.0]) -def test_load_window_refuses_cc_publishes(v3_system, make_plugin, make_parameter, value): - """A scrub mid-load must not reach mod-host — the WS path already refuses, - so the CC path must too, or the failed send advances _confirmed against a - loading screen that never got it.""" +def test_loading_window_refuses_switch_sink_publishes(v3_system, make_plugin, make_parameter, value): + """A load window refuses switch transport sends and keeps the confirmed value.""" handler, hw, fs, gain = _learn_footswitch_to_gain(v3_system, make_plugin, make_parameter, (2.0, 8.0)) handler._is_pedalboard_loading = True hw.midiout.send_message.reset_mock() @@ -123,7 +120,7 @@ def test_load_window_refuses_cc_publishes(v3_system, make_plugin, make_parameter assert gain._confirmed == 2.0 -def test_load_window_refuses_encoder_cc_publishes(v3_system, make_plugin): +def test_loading_window_refuses_encoder_cc_publishes(v3_system, make_plugin): handler, hw = v3_system.handler, v3_system.hw enc = next(e for e in hw.encoders if e.midi_CC is not None and e.parameter is None) _, param = _plugin_with_bound_param(handler, make_plugin, f"{enc.midi_channel}:{enc.midi_CC}") @@ -136,9 +133,8 @@ def test_load_window_refuses_encoder_cc_publishes(v3_system, make_plugin): assert param.value == 0.5 -def test_encoder_bound_param_rides_the_cc(v3_system, make_plugin): - """Every UI edit shares this entry point, panels included, so a bound param - never leaves as a param_set.""" +def test_physical_encoder_bound_param_rides_cc(v3_system, make_plugin): + """A physical encoder edit uses MIDI CC, not the WebSocket transport.""" handler, hw = v3_system.handler, v3_system.hw enc = next(e for e in hw.encoders if e.midi_CC is not None and e.parameter is None) _, param = _plugin_with_bound_param(handler, make_plugin, f"{enc.midi_channel}:{enc.midi_CC}") From 9793becc9a4a2e3c11361d891c936cba863c46f5 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Mon, 7 Sep 2026 19:04:03 -0400 Subject: [PATCH 3/3] Remove orphaned snapshots --- .../bound_0p15.png | Bin 7871 -> 0 bytes .../max_0p20.png | Bin 7830 -> 0 bytes .../min_0p10.png | Bin 7742 -> 0 bytes 3 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_sub_range_saga/bound_0p15.png delete mode 100644 tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_sub_range_saga/max_0p20.png delete mode 100644 tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_sub_range_saga/min_0p10.png diff --git a/tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_sub_range_saga/bound_0p15.png b/tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_sub_range_saga/bound_0p15.png deleted file mode 100644 index 628fd624451677cfd6a49df399526b544fc356da..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7871 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 diff --git a/tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_sub_range_saga/max_0p20.png b/tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_sub_range_saga/max_0p20.png deleted file mode 100644 index 663682a8b9183cfbfb8c63e8d619077da743a605..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7830 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 diff --git a/tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_sub_range_saga/min_0p10.png b/tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_sub_range_saga/min_0p10.png deleted file mode 100644 index be61fbdb8204f2119831fd4edeb518e4c2a5f607..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7742 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 Oo7I(dl)flhzxrPs859=)