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: