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..f9149049f 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 @@ -229,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/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 fbab6f697..dd02f8d51 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(c.parameter.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: @@ -1262,6 +1270,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) @@ -1469,9 +1486,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 @@ -1834,7 +1852,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_midi_learn/test_v3_midi_learn_sub_range_saga/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 similarity index 100% rename from tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_sub_range_saga/bound_0p15.png rename to tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_uses_mapping_for_physical_grid_and_declared_ui_range/bound_0p15.png 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_uses_mapping_for_physical_grid_and_declared_ui_range/max_0p20.png similarity index 100% rename from tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_sub_range_saga/max_0p20.png rename to tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_uses_mapping_for_physical_grid_and_declared_ui_range/max_0p20.png 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_uses_mapping_for_physical_grid_and_declared_ui_range/min_0p10.png similarity index 100% rename from tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_sub_range_saga/min_0p10.png rename to 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/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 665e88e3e..a83090472 100644 Binary files a/tests/snapshots/v3/test_transport_bindings/test_encoder_bpm_turn_parameter_dialog_snapshot/bpm_dialog_121.png and b/tests/snapshots/v3/test_transport_bindings/test_encoder_bpm_turn_parameter_dialog_snapshot/bpm_dialog_121.png differ 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_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/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..2b2681fde 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 = { @@ -99,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 carrying a custom sub-range narrows the parameter's encoder - sweep and displayed endpoints live, without a pedalboard reload.""" +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 @@ -115,19 +115,16 @@ 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() 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): - """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.""" +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 @@ -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") @@ -338,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 @@ -500,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 @@ -512,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] @@ -522,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 @@ -531,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 @@ -561,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 @@ -572,12 +567,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/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}") 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: