diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5441fd88c..84ca917a2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## [Unreleased]
+### Added
+- Expression pedal can be turned on from the LCD. It no longer needs an SSH session and an edit to `default_config.yml`.
+- The analog row is now a nav stop. Click it to see every input with its resolved MIDI binding, and to turn one on or off. The choice is kept across pedalboards.
+### Changed
+- Pedalboard/snapshot titles now auto-scroll only while selected with the NAV encoder (at most one thing scrolls at a time, and they sit at their leftmost position otherwise) — LCD updates over SPI are audible on the DAC at high gain, so the screen stays quiet while you play
### Fixed
- The parameter dialog on the LCD sometimes did not change values due to a race condition with respect to MOD-UI's `last.json`. pi-Stomp then did not send parameter changes to MOD-UI until you selected a different pedalboard. Parameters on a knob or an encoder continued to work, because they send MIDI CC.
- The LCD showed a bypass that MOD-UI did not receive, if you tapped it while a pedalboard loaded. The LCD now keeps the last value that MOD-UI confirmed.
diff --git a/docs/architecture.md b/docs/architecture.md
index bb6851a0b..7b545c064 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -383,6 +383,13 @@ full-range input despite ADC noise.
Types: `KNOB` and `EXPRESSION` (config-driven). When `autosync: true`, `initialize()`
reads the ADC and sends current position on pedalboard load.
+**On/off is a device fact, not a config fact** (`pistomp/input_enable.py`). An empty
+jack reads noise, so an `EXPRESSION` input is off until the user turns it on from the
+analog row on the LCD; every other control is on until turned off. The choice lives in
+`settings.yml`, which the software owns, and it masks `disabled` at both create time and
+`reinit`. `disable: true` in a config file is a different statement: that control is
+never created, and the menu cannot offer it.
+
### LCD System
- **v1**: `pistomp/lcdgfx.py` — monochrome 128×64 display via gfxhat library. Direct
diff --git a/emulator/hardware_base.py b/emulator/hardware_base.py
index 89513f060..ab2bd42be 100644
--- a/emulator/hardware_base.py
+++ b/emulator/hardware_base.py
@@ -70,6 +70,7 @@ def init_analog_controls(self):
if b.disable or b.midi_CC is None:
continue
ctrl = MockAnalogControl(b.midi_CC, b.midi_channel, b.type, b.id)
+ ctrl.disabled = not self.input_enable.is_enabled(b.id, b.type)
self.analog_controls.append(ctrl)
self.register_controller(ctrl)
diff --git a/pistomp/handler.py b/pistomp/handler.py
index 476c8eba8..4864437fb 100755
--- a/pistomp/handler.py
+++ b/pistomp/handler.py
@@ -32,11 +32,13 @@
from modalapi.plugin import Plugin
from modalapi.websocket_bridge import AsyncWebSocketBridge
from pistomp.hardware import Hardware
+ from pistomp.settings import Settings
from pistomp.tuner.source import TunerSourceFactory
class Handler(InputSink):
_ws_bridge: "AsyncWebSocketBridge | None" = None
+ settings: "Settings"
@property
def ws_bridge(self) -> "AsyncWebSocketBridge":
diff --git a/pistomp/hardware.py b/pistomp/hardware.py
index 46a1dd2c2..1106a0984 100755
--- a/pistomp/hardware.py
+++ b/pistomp/hardware.py
@@ -41,6 +41,7 @@
PresetStep,
)
from pistomp.config.schema_v1 import ConfigDocument
+from pistomp.input_enable import InputEnable
import pistomp.relay as Relay
_Binding = TypeVar("_Binding", FootswitchBinding, EncoderBinding, AnalogBinding)
@@ -56,6 +57,7 @@ def __init__(self, default_config, handler, midiout, refresh_callback):
self.test_pass = False
self.test_sentinel = None
+ self._input_enable: InputEnable | None = None
self.default_cfg: ConfigDocument = default_config
self.config = config.resolve(default_config)
self.base_config = self.config
@@ -76,6 +78,14 @@ def __init__(self, default_config, handler, midiout, refresh_callback):
# reinit (mutated in place).
self.external_routing: dict[Controller, RoutingInfo] = {}
+ @property
+ def input_enable(self) -> InputEnable:
+ """The user's on/off choice per input. Built late: the handler owns the
+ settings file and is not complete when the hardware is constructed."""
+ if self._input_enable is None:
+ self._input_enable = InputEnable(self.handler.settings)
+ return self._input_enable
+
@property
def version(self) -> float:
return self.config.version
@@ -132,6 +142,20 @@ def sync_analog_controls(self):
except Exception as e:
logging.warning(f"Failed to sync analog control {control.midi_CC}: {e}")
+ def set_input_enabled(self, control: Controller, enabled: bool) -> None:
+ """Turn one analog input or encoder on or off, and remember the choice
+ across pedalboards. A control that the config disables never gets here:
+ it is not created at all."""
+ if control.id is None:
+ return
+ self.input_enable.set_enabled(control.id, enabled)
+ control.disabled = not enabled
+ if enabled and isinstance(control, AnalogMidiControl.AnalogMidiControl) and control.autosync:
+ try:
+ control.send_current_value()
+ except Exception:
+ logging.warning("Failed to sync analog control %s on enable", control.midi_CC)
+
def longpress_action(self, fs: Footswitch.Footswitch) -> LongpressAction | None:
"""The mapping form of longpress, which has no home on the footswitch."""
binding = self.config.footswitch(fs.id) if fs.id is not None else None
@@ -295,6 +319,7 @@ def create_analog_controls(self, config: PedalboardConfig) -> None:
control = AnalogMidiControl.AnalogMidiControl(
self.spi, b.adc_input, b.threshold, b.midi_CC, b.midi_channel, b.type, b.id, b.autosync
)
+ control.disabled = not self.input_enable.is_enabled(b.id, b.type)
self.analog_controls.append(control)
self.register_controller(control)
logging.debug(
@@ -320,6 +345,7 @@ def create_encoders(self, config: PedalboardConfig) -> None:
# FIXME: add_encoder returns None for emulator v1/v2 stubs that don't
# implement config-driven encoders, forcing the return type to be optional.
if control is not None:
+ control.disabled = not self.input_enable.is_enabled(b.id, b.type)
self.encoders.append(control)
self.register_controller(control)
logging.debug("Created Encoder: %d, Midi Chan: %d, CC: %s", b.id, b.midi_channel, b.midi_CC)
@@ -387,7 +413,7 @@ def __apply_footswitch(self, fs: Footswitch.Footswitch, binding: FootswitchBindi
def __apply_encoder(self, enc: Controller, binding: EncoderBinding) -> None:
enc.type = binding.type
- enc.disabled = binding.disable
+ enc.disabled = binding.disable or not self.input_enable.is_enabled(binding.id, binding.type)
enc.midi_channel = binding.midi_channel
enc.midi_CC = binding.midi_CC
if isinstance(enc, EncoderController.EncoderController):
@@ -396,7 +422,7 @@ def __apply_encoder(self, enc: Controller, binding: EncoderBinding) -> None:
self.__route(enc, binding.midi_port)
def __apply_analog_control(self, control: Controller, binding: AnalogBinding) -> None:
- control.disabled = binding.disable
+ control.disabled = binding.disable or not self.input_enable.is_enabled(binding.id, binding.type)
control.midi_channel = binding.midi_channel
control.midi_CC = binding.midi_CC
if isinstance(control, AnalogMidiControl.AnalogMidiControl):
diff --git a/pistomp/input_enable.py b/pistomp/input_enable.py
new file mode 100644
index 000000000..c76ae85f8
--- /dev/null
+++ b/pistomp/input_enable.py
@@ -0,0 +1,55 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+#
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with pi-stomp. If not, see .
+
+"""Which hardware inputs the user turned on, kept across pedalboards.
+
+This is a device fact, not a config fact: it says what is plugged in, so it
+lives in settings.yml and not in a config file that the user owns. An
+EXPRESSION input is off until the user turns it on, because an empty jack
+reads ADC noise. Every other control is on until the user turns it off.
+
+The id space is the screen position that `draw_analog_assignments` paints, so
+one analog control and one encoder never share an id.
+"""
+
+from __future__ import annotations
+
+from pistomp.controller import ControlType
+from pistomp.settings import Settings
+
+SETTING = "input_enabled"
+
+
+class InputEnable:
+ def __init__(self, settings: Settings) -> None:
+ self._settings = settings
+ stored = settings.get_setting(SETTING)
+ self._choices: dict[int, bool] = (
+ {int(k): bool(v) for k, v in stored.items()} if isinstance(stored, dict) else {}
+ )
+
+ def is_enabled(self, control_id: int | None, control_type: ControlType) -> bool:
+ if control_id is None:
+ return True
+ chosen = self._choices.get(control_id)
+ if chosen is None:
+ return control_type is not ControlType.EXPRESSION
+ return chosen
+
+ def set_enabled(self, control_id: int, enabled: bool) -> None:
+ self._choices[control_id] = enabled
+ self._settings.set_setting(SETTING, dict(self._choices))
diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py
index 5936a477f..b326913e9 100644
--- a/pistomp/lcd320x240.py
+++ b/pistomp/lcd320x240.py
@@ -29,6 +29,7 @@
from common.contexts import BindingDecl, ControlClass, EventKind, MidiCcEffect, ParamEffect, ShadowState
from common.parameter import BYPASS_SYMBOL, Parameter, PortInfo, Symbol, Type
from modalapi.plugin import Plugin
+from ui.analog_menu import AnalogMenu
from ui.ethernet_menu import EthernetMenu
from ui.footswitch_menu import FootswitchMenu
from ui.wifi_menu import WifiMenu
@@ -39,6 +40,7 @@
import pygame
from uilib import (
+ AnalogBarPanel,
Box,
Config,
ContainerWidget,
@@ -76,6 +78,12 @@
# Parameter dialog auto-dismiss timeout (seconds)
PARAMETER_DIALOG_TIMEOUT = 1.0
+# The analog row sits in its own panel so it is one nav stop. The pad leaves
+# room for the selection border above and below the icons.
+ANALOG_ROW_TOP = 54
+ANALOG_ROW_PAD = 2
+ANALOG_OFF_COLOR = (90, 90, 90)
+
# Subtitle auto-hide after no nav encoder movement (seconds)
SUBTITLE_TIMEOUT = 1.3
@@ -220,6 +228,11 @@ def __init__(self, cwd, handler: "Modhandler", flip=False, display=None, spi_spe
no_dim=True,
accepts_input=False,
)
+ self.analog_menu: AnalogMenu = AnalogMenu(self)
+ # Built on the first draw, not here: the row overlaps the pedalboard
+ # and snapshot names, and a child paints in attach order, so it has to
+ # attach after them to own that band.
+ self.analog_panel: AnalogBarPanel | None = None
self.pedalboards = {}
@@ -1197,12 +1210,20 @@ def draw_analog_assignments(self, controllers):
height_per_control = 19
text_per_control = TILE_W - 16 # minus height of control icon
+ if self.analog_panel is None:
+ self.analog_panel = AnalogBarPanel(
+ box=Box.xywh(0, ANALOG_ROW_TOP, self.display_width, 2 * ANALOG_ROW_PAD + 19),
+ on_press=self.analog_menu.open,
+ subtitle="Analog Inputs",
+ parent=self.main_panel,
+ )
+
# clean up previous control widgets
for w in self.w_controls:
w.destroy()
self.w_controls = []
- y = 56 # vertical position on screen
+ y = ANALOG_ROW_PAD # vertical position inside the analog panel
for i in range(0, num):
x = i * pitch
k = None
@@ -1278,6 +1299,12 @@ def draw_analog_assignments(self, controllers):
else:
text_color = color
+ if analog_control is not None and analog_control.disabled:
+ name = "off"
+ subtitle = f"{subtitle} (off)"
+ color = ANALOG_OFF_COLOR
+ text_color = ANALOG_OFF_COLOR
+
blend_initial_progress = None
if isinstance(icon_object, BlendMode):
text_color = TILE_DEFAULT_COLOR
@@ -1301,7 +1328,7 @@ def draw_analog_assignments(self, controllers):
box=Box.xywh(x, y, TILE_W, height_per_control),
text=name,
text_color=text_color,
- parent=self.main_panel,
+ parent=self.analog_panel,
outline=0,
object=icon_object,
subtitle=subtitle,
@@ -1316,7 +1343,7 @@ def draw_analog_assignments(self, controllers):
box=Box.xywh(x, y, TILE_W, height_per_control),
text=name,
text_color=text_color,
- parent=self.main_panel,
+ parent=self.analog_panel,
outline=0,
object=icon_object,
subtitle=subtitle,
@@ -1332,12 +1359,20 @@ def draw_analog_assignments(self, controllers):
if control_label_fn is not None and control_param is not None and w is not None:
w.bind_label(control_param, control_label_fn)
+ self.main_panel.add_sel_widget(self.analog_panel)
+
# Rebuild path: widget create/destroy above marks regions dirty, but
# the LCD push only fires on a refresh. Called standalone from
# _rebind_pedalboard (midi-learn of an encoder), where there's no
# enclosing draw_main_panel to refresh for us.
+ self.analog_panel.refresh()
self.main_panel.refresh()
+ def refresh_analog_row(self) -> None:
+ """Repaint the analog row after an input is turned on or off."""
+ if self.current is not None:
+ self.draw_analog_assignments(self.current.analog_controllers)
+
def draw_info_message(self, text, refresh=False):
if self.w_info_msg is None:
self.w_info_msg = TextWidget(
diff --git a/setup/config_templates/default_config.yml b/setup/config_templates/default_config.yml
index 8d74eca76..d9c99c6b3 100755
--- a/setup/config_templates/default_config.yml
+++ b/setup/config_templates/default_config.yml
@@ -64,13 +64,17 @@ hardware:
# Falls back to the virtual port only if the device is unavailable; must be the device name (e.g. 'Source Audio C4 Synth')
# midi_channel: Override MIDI channel for this control (0-15); required when midi_port is set
# autosync: Whether to send current value on pedalboard load (optional, default: false)
+ # disable: Never create this control at all (optional, default: false)
#
- #analog_controllers:
- # - adc_input: 5
- # id: 0
- # type: EXPRESSION
- # midi_CC: 75
- # autosync: true
+ # An EXPRESSION control starts off, because an empty jack reads ADC noise. Turn
+ # it on from the analog row on the LCD; the choice is kept in settings.yml.
+ #
+ analog_controllers:
+ - adc_input: 5
+ id: 0
+ type: EXPRESSION
+ midi_CC: 75
+ autosync: true
# encoders:
# Each encoder definition is a list which starts with the id
diff --git a/setup/config_templates/default_config_3fs_2knob.yml b/setup/config_templates/default_config_3fs_2knob.yml
index daf6c7421..65b5e9651 100755
--- a/setup/config_templates/default_config_3fs_2knob.yml
+++ b/setup/config_templates/default_config_3fs_2knob.yml
@@ -49,13 +49,17 @@ hardware:
# type: The control type, used to represent the control on the screen (optional)
# midi_CC: The MIDI CC message to be sent when the control is adjusted (optional)
# autosync: Whether to send current value on pedalboard load (optional, default: false)
+ # disable: Never create this control at all (optional, default: false)
+ #
+ # An EXPRESSION control starts off, because an empty jack reads ADC noise. Turn
+ # it on from the analog row on the LCD; the choice is kept in settings.yml.
#
analog_controllers:
- #- adc_input: 7
- # id: 0
- # midi_CC: 77
- # type: EXPRESSION
- # autosync: true
+ - adc_input: 7
+ id: 0
+ midi_CC: 77
+ type: EXPRESSION
+ autosync: true
- adc_input: 0
id: 1
midi_CC: 70
diff --git a/setup/config_templates/default_config_3fs_2knob_exp.yml b/setup/config_templates/default_config_3fs_2knob_exp.yml
index ffd4bd6c1..0ea55b6ee 100755
--- a/setup/config_templates/default_config_3fs_2knob_exp.yml
+++ b/setup/config_templates/default_config_3fs_2knob_exp.yml
@@ -49,6 +49,10 @@ hardware:
# type: The control type, used to represent the control on the screen (optional)
# midi_CC: The MIDI CC message to be sent when the control is adjusted (optional)
# autosync: Whether to send current value on pedalboard load (optional, default: false)
+ # disable: Never create this control at all (optional, default: false)
+ #
+ # An EXPRESSION control starts off, because an empty jack reads ADC noise. Turn
+ # it on from the analog row on the LCD; the choice is kept in settings.yml.
#
analog_controllers:
- adc_input: 7
diff --git a/setup/config_templates/default_config_pistompcore.yml b/setup/config_templates/default_config_pistompcore.yml
index fba746d71..37b3ad949 100755
--- a/setup/config_templates/default_config_pistompcore.yml
+++ b/setup/config_templates/default_config_pistompcore.yml
@@ -49,13 +49,17 @@ hardware:
# type: The control type, used to represent the control on the screen (optional)
# midi_CC: The MIDI CC message to be sent when the control is adjusted (optional)
# autosync: Whether to send current value on pedalboard load (optional, default: false)
+ # disable: Never create this control at all (optional, default: false)
#
-# analog_controllers:
-# - adc_input: 7
-# id: 0
-# midi_CC: 77
-# type: EXPRESSION
-# autosync: true
+ # An EXPRESSION control starts off, because an empty jack reads ADC noise. Turn
+ # it on from the analog row on the LCD; the choice is kept in settings.yml.
+ #
+ analog_controllers:
+ - adc_input: 7
+ id: 0
+ midi_CC: 77
+ type: EXPRESSION
+ autosync: true
# - adc_input: 0
# id: 1
# midi_CC: 70
diff --git a/setup/config_templates/default_config_pistomptre.yml b/setup/config_templates/default_config_pistomptre.yml
index 60b6303b8..0f2e9e4d1 100644
--- a/setup/config_templates/default_config_pistomptre.yml
+++ b/setup/config_templates/default_config_pistomptre.yml
@@ -64,13 +64,17 @@ hardware:
# Falls back to the virtual port only if the device is unavailable; must be the device name (e.g. 'Source Audio C4 Synth')
# midi_channel: Override MIDI channel for this control (0-15); required when midi_port is set
# autosync: Whether to send current value on pedalboard load (optional, default: false)
+ # disable: Never create this control at all (optional, default: false)
#
- #analog_controllers:
- # - adc_input: 5
- # id: 0
- # type: EXPRESSION
- # midi_CC: 75
- # autosync: true
+ # An EXPRESSION control starts off, because an empty jack reads ADC noise. Turn
+ # it on from the analog row on the LCD; the choice is kept in settings.yml.
+ #
+ analog_controllers:
+ - adc_input: 5
+ id: 0
+ type: EXPRESSION
+ midi_CC: 75
+ autosync: true
# encoders:
# Each encoder definition is a list which starts with the id
diff --git a/tests/conftest.py b/tests/conftest.py
index 8859c2ee1..26358890e 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -235,6 +235,15 @@ def fake_lcd():
return FakeLcd()
+@pytest.fixture
+def input_enable_seed():
+ """Seeds settings.yml's input_enabled for the stack fixtures. Override with
+ None in a test that wants the shipped device default (EXPRESSION off)."""
+ from tests.integration.conftest import DEFAULT_INPUT_ENABLE_SEED
+
+ return DEFAULT_INPUT_ENABLE_SEED
+
+
class Tickable(Protocol):
def tick(self) -> None: ...
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
index 3d2cb5444..79f2ead02 100644
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -15,9 +15,14 @@
from tests.conftest import FakeWebSocketBridge
from tests.types import CapturedLcd, SystemFixture
import common.token as Token
+from pistomp.input_enable import SETTING as INPUT_ENABLE_SETTING
PROJECT_ROOT = Path(__file__).parent.parent.parent
+# A test rig has every input on, so an unbound one paints "none" and not "off".
+# Pass None for the shipped device default, where EXPRESSION starts off.
+DEFAULT_INPUT_ENABLE_SEED = {0: True}
+
with patch("pistomp.settings.Settings.load_settings"), patch("pistomp.settings.Settings.set_setting"):
from modalapi.modhandler import Modhandler
from pistomp.hardware import Hardware
@@ -35,6 +40,7 @@ def _build_stack(
fake_lcd: CapturedLcd,
cfg_path: Path,
tmp_path: Path,
+ input_enable_seed: dict[int, bool] | None = None,
) -> Generator[SystemFixture, None, None]:
cwd = str(PROJECT_ROOT)
@@ -92,7 +98,10 @@ def post_side_effect(*args, **kwargs):
mock_post.side_effect = post_side_effect
- mock_settings_cls.return_value.get_setting.return_value = None
+ def get_setting(name):
+ return input_enable_seed if name == INPUT_ENABLE_SETTING else None
+
+ mock_settings_cls.return_value.get_setting.side_effect = get_setting
mock_audiocard = MagicMock()
mock_audiocard.get_volume_parameter.return_value = 0.0
@@ -100,7 +109,7 @@ def post_side_effect(*args, **kwargs):
handler.software_version = "3.0.0"
handler.recovery_available = False # no pistomp-recovery in test env
assert isinstance(handler.settings, MagicMock)
- handler.settings.get_setting.return_value = None
+ handler.settings.get_setting.side_effect = get_setting
midiout = MagicMock()
hw = hw_class(cfg, handler, midiout, handler.update_lcd_fs)
@@ -131,14 +140,14 @@ def post_side_effect(*args, **kwargs):
# ---------------------------------------------------------------------------
-def _v2_stack(fake_lcd, tmp_path) -> Generator[SystemFixture, None, None]:
+def _v2_stack(fake_lcd, tmp_path, input_enable_seed=DEFAULT_INPUT_ENABLE_SEED) -> Generator[SystemFixture, None, None]:
cfg_path = PROJECT_ROOT / "setup" / "config_templates" / "default_config_pistompcore.yml"
- yield from _build_stack(Pistompcore, fake_lcd, cfg_path, tmp_path)
+ yield from _build_stack(Pistompcore, fake_lcd, cfg_path, tmp_path, input_enable_seed)
-def _v3_stack(fake_lcd, tmp_path) -> Generator[SystemFixture, None, None]:
+def _v3_stack(fake_lcd, tmp_path, input_enable_seed=DEFAULT_INPUT_ENABLE_SEED) -> Generator[SystemFixture, None, None]:
cfg_path = PROJECT_ROOT / "setup" / "config_templates" / "default_config_pistomptre.yml"
- yield from _build_stack(Pistomptre, fake_lcd, cfg_path, tmp_path)
+ yield from _build_stack(Pistomptre, fake_lcd, cfg_path, tmp_path, input_enable_seed)
_BUILDERS = {
@@ -153,6 +162,6 @@ def _v3_stack(fake_lcd, tmp_path) -> Generator[SystemFixture, None, None]:
@pytest.fixture(params=["v2", "v3"])
-def modhandler_system(request, fake_lcd, tmp_path) -> Generator[SystemFixture, None, None]:
+def modhandler_system(request, fake_lcd, tmp_path, input_enable_seed) -> Generator[SystemFixture, None, None]:
"""Full Modhandler + hardware stack, parametrized across supported versions."""
- yield from _BUILDERS[request.param](fake_lcd, tmp_path)
+ yield from _BUILDERS[request.param](fake_lcd, tmp_path, input_enable_seed)
diff --git a/tests/snapshots/v3/test_analog_menu/test_analog_menu_snapshot/menu.png b/tests/snapshots/v3/test_analog_menu/test_analog_menu_snapshot/menu.png
new file mode 100644
index 000000000..28e559bd6
Binary files /dev/null and b/tests/snapshots/v3/test_analog_menu/test_analog_menu_snapshot/menu.png differ
diff --git a/tests/snapshots/v3/test_analog_menu/test_analog_menu_snapshot/menu_expression_on.png b/tests/snapshots/v3/test_analog_menu/test_analog_menu_snapshot/menu_expression_on.png
new file mode 100644
index 000000000..7aea99000
Binary files /dev/null and b/tests/snapshots/v3/test_analog_menu/test_analog_menu_snapshot/menu_expression_on.png differ
diff --git a/tests/snapshots/v3/test_analog_menu/test_analog_menu_snapshot/row_expression_off.png b/tests/snapshots/v3/test_analog_menu/test_analog_menu_snapshot/row_expression_off.png
new file mode 100644
index 000000000..0f6c39084
Binary files /dev/null and b/tests/snapshots/v3/test_analog_menu/test_analog_menu_snapshot/row_expression_off.png differ
diff --git a/tests/snapshots/v3/test_analog_menu/test_analog_menu_snapshot/row_expression_on.png b/tests/snapshots/v3/test_analog_menu/test_analog_menu_snapshot/row_expression_on.png
new file mode 100644
index 000000000..0e7f177aa
Binary files /dev/null and b/tests/snapshots/v3/test_analog_menu/test_analog_menu_snapshot/row_expression_on.png differ
diff --git a/tests/test_lcd320x240.py b/tests/test_lcd320x240.py
index 8b559da52..256d46c06 100644
--- a/tests/test_lcd320x240.py
+++ b/tests/test_lcd320x240.py
@@ -829,7 +829,7 @@ def test_tall_parallel_scrolled_to_last(lcd, snapshot):
tiles = instance.grid_panel.tile_order
col0_x = tiles[0].box.x0
col0_count = sum(1 for t in tiles if t.box.x0 == col0_x)
- for _ in range(col0_count - 1 + 3):
+ for _ in range(col0_count - 1 + 4):
instance.main_panel.sel_next()
snapshot("scrolled_to_last")
diff --git a/tests/v2/conftest.py b/tests/v2/conftest.py
index 9697e46a6..94937dcc7 100644
--- a/tests/v2/conftest.py
+++ b/tests/v2/conftest.py
@@ -9,8 +9,8 @@
@pytest.fixture
-def v2_system(fake_lcd, tmp_path) -> Generator[SystemFixture, None, None]:
- yield from _v2_stack(fake_lcd, tmp_path)
+def v2_system(fake_lcd, tmp_path, input_enable_seed) -> Generator[SystemFixture, None, None]:
+ yield from _v2_stack(fake_lcd, tmp_path, input_enable_seed)
@pytest.fixture
diff --git a/tests/v3/conftest.py b/tests/v3/conftest.py
index c59df59fd..653bee584 100644
--- a/tests/v3/conftest.py
+++ b/tests/v3/conftest.py
@@ -29,8 +29,8 @@ def freeze_monotonic(monkeypatch):
@pytest.fixture
-def v3_system(fake_lcd, tmp_path) -> Generator[SystemFixture, None, None]:
- yield from _v3_stack(fake_lcd, tmp_path)
+def v3_system(fake_lcd, tmp_path, input_enable_seed) -> Generator[SystemFixture, None, None]:
+ yield from _v3_stack(fake_lcd, tmp_path, input_enable_seed)
# ---------------------------------------------------------------------------
@@ -262,15 +262,19 @@ def blend_system_exp(
"""v3 stack with blend mode on expression pedal (id=0, last_read=512 ≈ 50%)."""
def _add_exp_pedal(hw):
- exp_pedal = MockAnalogControl(
- midi_CC=75,
- midi_channel=0,
- midiout=None,
- control_type=ControlType.EXPRESSION,
- id=0,
- )
+ # The config already builds the expression control; a second one with
+ # the same id would shadow it in the analog row lookup.
+ exp_pedal = next((c for c in hw.analog_controls if c.id == 0), None)
+ if exp_pedal is None:
+ exp_pedal = MockAnalogControl(
+ midi_CC=75,
+ midi_channel=0,
+ midiout=None,
+ control_type=ControlType.EXPRESSION,
+ id=0,
+ )
+ hw.analog_controls.append(exp_pedal)
exp_pedal.last_read = 512
- hw.analog_controls.append(exp_pedal)
yield _build_blend_system(
v3_system,
diff --git a/tests/v3/test_analog_menu.py b/tests/v3/test_analog_menu.py
new file mode 100644
index 000000000..10bf46138
--- /dev/null
+++ b/tests/v3/test_analog_menu.py
@@ -0,0 +1,132 @@
+"""The analog row: single-widget NAV selection, and the menu that turns one
+input on or off (ui/analog_menu.py)."""
+
+from __future__ import annotations
+
+from typing import cast
+from unittest.mock import MagicMock
+
+import pytest
+
+from pistomp.controller import ControlType
+from pistomp.input_enable import SETTING
+from tests.types import SystemFixture
+from tests.v3.nav_helpers import nav_click, nav_step
+from uilib.analog_bar import AnalogBarPanel
+from uilib.text import TextWidget
+
+
+@pytest.fixture
+def input_enable_seed():
+ """This module pins the shipped device default, not the test-rig default."""
+ return None
+
+
+def _row_texts(dialog) -> list[str]:
+ return [w.text for w in dialog.children if isinstance(w, TextWidget) and TextWidget.SPLIT_SEP in w.text]
+
+
+def _select_analog_row(handler) -> AnalogBarPanel:
+ lcd = handler.lcd
+ assert lcd is not None
+ bar = lcd.analog_panel
+ assert isinstance(bar, AnalogBarPanel)
+ while lcd.main_panel.sel_ref is not bar:
+ nav_step(handler, 1)
+ return bar
+
+
+def _expression(hw):
+ return next(c for c in hw.analog_controls if c.type is ControlType.EXPRESSION)
+
+
+def test_expression_input_is_off_until_the_user_turns_it_on(v3_system: SystemFixture):
+ """An empty jack reads noise, so EXPRESSION starts off. Every other input
+ starts on."""
+ hw = v3_system.hw
+ assert _expression(hw).disabled
+ assert not any(e.disabled for e in hw.encoders)
+
+
+def test_analog_row_selection_and_menu(v3_system: SystemFixture):
+ handler = v3_system.handler
+ lcd = handler.lcd
+ assert lcd is not None
+
+ bar = _select_analog_row(handler)
+ assert bar.selected
+
+ nav_click(handler)
+ menu_panel = lcd.analog_menu._panel
+ assert menu_panel is not None
+ assert lcd.pstack.stack[-1] is menu_panel
+ assert _row_texts(menu_panel) == [
+ "EXP CC 75" + TextWidget.SPLIT_SEP + "off",
+ "K1 CC 70" + TextWidget.SPLIT_SEP + "on",
+ "K2 CC 71" + TextWidget.SPLIT_SEP + "on",
+ "VOL output volume" + TextWidget.SPLIT_SEP + "on",
+ ]
+
+ nav_step(handler, len(_row_texts(menu_panel))) # past the last row, onto Back
+ nav_click(handler)
+ assert lcd.analog_menu._panel is None
+ assert menu_panel not in lcd.pstack.stack
+
+
+def test_toggle_expression_on_and_off(v3_system: SystemFixture):
+ handler = v3_system.handler
+ hw = v3_system.hw
+ lcd = handler.lcd
+ assert lcd is not None
+ control = _expression(hw)
+
+ _select_analog_row(handler)
+ nav_click(handler)
+ menu_panel = lcd.analog_menu._panel
+ assert menu_panel is not None
+
+ settings = cast(MagicMock, handler.settings)
+
+ nav_click(handler) # the EXP row is the first stop
+ assert not control.disabled
+ assert _row_texts(menu_panel)[0] == "EXP CC 75" + TextWidget.SPLIT_SEP + "on"
+ settings.set_setting.assert_called_with(SETTING, {0: True})
+
+ nav_click(handler)
+ assert control.disabled
+ settings.set_setting.assert_called_with(SETTING, {0: False})
+
+
+def test_the_choice_outlives_a_pedalboard_load(v3_system: SystemFixture):
+ """`reinit` applies the config of the new board to every control, so the
+ user's choice has to survive it."""
+ handler = v3_system.handler
+ hw = v3_system.hw
+ control = _expression(hw)
+
+ hw.set_input_enabled(control, True)
+ hw.reinit(hw.config)
+ assert not control.disabled
+
+ hw.set_input_enabled(control, False)
+ hw.reinit(hw.config)
+ assert control.disabled
+
+
+def test_analog_menu_snapshot(v3_system: SystemFixture, snapshot):
+ handler = v3_system.handler
+ lcd = handler.lcd
+ assert lcd is not None
+
+ snapshot("row_expression_off")
+ _select_analog_row(handler)
+ nav_click(handler)
+ snapshot("menu")
+
+ nav_click(handler) # turn the expression pedal on
+ snapshot("menu_expression_on")
+
+ nav_step(handler, 4)
+ nav_click(handler) # Back
+ assert lcd.analog_menu._panel is None
+ snapshot("row_expression_on")
diff --git a/tests/v3/test_dynamic_pedalboard.py b/tests/v3/test_dynamic_pedalboard.py
index 95bfadd43..06d6be723 100644
--- a/tests/v3/test_dynamic_pedalboard.py
+++ b/tests/v3/test_dynamic_pedalboard.py
@@ -568,12 +568,13 @@ def test_v3_parallel_beths_dynamic_epic(parallel_beths_system: SystemFixture, sn
# ── Phase 7: navigate encoder to ExtraChorus ─────────────────────────────
# draw_main_panel() resets selection to the wrench. Selector chain:
- # wrench → pedalboard title → preset title → [grid tiles in layout order]
+ # wrench → pedalboard title → preset title → analog row → [grid tiles in layout order]
# Grid order is column-major L→R, rows within each column.
snapshot("07_nav_start_wrench")
- # 3 steps from wrench to first plugin tile
+ # 4 steps from wrench to first plugin tile
+ nav_handler(1)
nav_handler(1)
nav_handler(1)
nav_handler(1)
diff --git a/tests/v3/test_plugins.py b/tests/v3/test_plugins.py
index e56ed255f..eb42950bb 100644
--- a/tests/v3/test_plugins.py
+++ b/tests/v3/test_plugins.py
@@ -358,7 +358,8 @@ def test_v3_parameter_edit(v3_system: SystemFixture, nav_handler, make_parameter
handler.lcd.link_data(handler.pedalboard_list, handler.current, hw.footswitches)
handler.lcd.draw_main_panel()
- # wrench → pedalboard → preset → plugin
+ # wrench → pedalboard → preset → analog row → plugin
+ nav_handler(1)
nav_handler(1)
nav_handler(1)
nav_handler(1)
diff --git a/ui/analog_menu.py b/ui/analog_menu.py
new file mode 100644
index 000000000..326a34323
--- /dev/null
+++ b/ui/analog_menu.py
@@ -0,0 +1,133 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+#
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# 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 pathlib import Path
+from typing import TYPE_CHECKING
+
+from pistomp.controller import ControlType, Controller
+from plugins.chrome import BTN_GAP, BTN_H
+from uilib import Box, Config, Dialog, TextWidget, WidgetAlign, get_text_size
+from uilib.misc import InputEvent
+from uilib.pygame_init import font as _make_font
+from uilib.text import Button
+
+if TYPE_CHECKING:
+ from pistomp.lcd320x240 import Lcd
+
+_FONTS_DIR = Path(__file__).resolve().parent.parent / "fonts"
+
+WIDTH = 220
+LINE_H = 18
+ROW_PAD = 4
+
+
+def control_name(control: Controller) -> str:
+ """The short name of one input, by what it does and where it sits."""
+ if control.type is ControlType.EXPRESSION:
+ return "EXP"
+ if control.type is ControlType.VOLUME:
+ return "VOL"
+ return f"K{control.id}"
+
+
+def _detail(lcd: "Lcd", control: Controller) -> str:
+ if control.type is ControlType.VOLUME:
+ return "output volume"
+ if control.midi_CC is None:
+ return "unassigned"
+ port_name = lcd.handler.hardware.external_port_name(control)
+ if port_name is not None:
+ return f"{port_name}:{control.midi_CC}"
+ return f"CC {control.midi_CC}"
+
+
+def _row_text(lcd: "Lcd", control: Controller) -> str:
+ state = "off" if control.disabled else "on"
+ return f"{control_name(control)} {_detail(lcd, control)}{TextWidget.SPLIT_SEP}{state}"
+
+
+class AnalogMenu:
+ """Shows every analog input and encoder with its resolved binding, and
+ turns one on or off."""
+
+ def __init__(self, lcd: "Lcd") -> None:
+ self.lcd = lcd
+ self._panel: Dialog | None = None
+
+ def controls(self) -> list[Controller]:
+ hardware = self.lcd.handler.hardware
+ rows = [c for c in hardware.analog_controls + hardware.encoders if c.type is not ControlType.NAV]
+ return sorted(rows, key=lambda c: c.id if c.id is not None else 0)
+
+ def open(self) -> None:
+ controls = self.controls()
+ height = min(220, 2 * ROW_PAD + LINE_H * len(controls) + BTN_H + 12)
+
+ d = Dialog(width=WIDTH, height=height, title="Analog Inputs", auto_destroy=True)
+ font = _make_font(_FONTS_DIR / "DejaVuSans.ttf", 14)
+
+ y = ROW_PAD
+ for control in controls:
+ TextWidget(
+ box=Box.xywh(8, y, WIDTH - 16, LINE_H),
+ text=_row_text(self.lcd, control),
+ font=font,
+ parent=d,
+ outline=0,
+ sel_width=1,
+ align=WidgetAlign.NONE,
+ object=control,
+ action=self._toggle,
+ )
+ y += LINE_H
+ for row in d.children:
+ if isinstance(row, TextWidget) and row.object is not None:
+ d.add_sel_widget(row)
+
+ btn_font = Config().get_font("small")
+ _, btn_text_h = get_text_size("Back", btn_font)
+ btn_w = (WIDTH - 4 * BTN_GAP) // 3
+ back_btn = Button(
+ box=Box.xywh((WIDTH - btn_w) // 2, height - BTN_H - 6, btn_w, BTN_H),
+ text="Back",
+ font=btn_font,
+ v_margin=max(0, (BTN_H - btn_text_h) // 2),
+ outline_radius=4,
+ parent=d,
+ action=self._on_back,
+ name="analog_menu_back_btn",
+ )
+ d.add_sel_widget(back_btn)
+
+ self._panel = d
+ self.lcd.pstack.push_panel(d)
+ d.refresh()
+
+ def _toggle(self, _event: InputEvent, widget: TextWidget, control: Controller) -> None:
+ self.lcd.handler.hardware.set_input_enabled(control, control.disabled)
+ widget.set_text(_row_text(self.lcd, control))
+ if self._panel is not None:
+ self._panel.refresh()
+ self.lcd.refresh_analog_row()
+
+ def _on_back(self, _event: object = None, _widget: object = None) -> None:
+ if self._panel is not None:
+ old = self._panel
+ self._panel = None
+ self.lcd.pstack.pop_panel(old)
diff --git a/uilib/__init__.py b/uilib/__init__.py
index 98bdb7cb3..2f22240ea 100644
--- a/uilib/__init__.py
+++ b/uilib/__init__.py
@@ -16,6 +16,7 @@
# along with pi-stomp. If not, see .
__all__ = [
+ "AnalogBarPanel",
"Box",
"Button",
"Config",
@@ -57,6 +58,7 @@
"trace",
]
+from uilib.analog_bar import AnalogBarPanel
from uilib.box import Box
from uilib.config import Config
from uilib.container import ContainerWidget
diff --git a/uilib/analog_bar.py b/uilib/analog_bar.py
new file mode 100644
index 000000000..a620ead0b
--- /dev/null
+++ b/uilib/analog_bar.py
@@ -0,0 +1,52 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+#
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# 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 typing import Callable
+
+from uilib.box import Box
+from uilib.misc import InputEvent
+from uilib.container import ContainerWidget
+
+
+class AnalogBarPanel(ContainerWidget):
+ """The row of analog inputs, selectable as one whole widget (never per
+ input: the Icon children are never added to any sel_list).
+
+ CLICK and LONG_CLICK both delegate to ``on_press`` — this panel holds no
+ opinion on what that opens."""
+
+ def __init__(
+ self,
+ box: Box,
+ on_press: Callable[[], None] | None = None,
+ **kwargs,
+ ):
+ kwargs.setdefault("image_format", "RGBA")
+ kwargs.setdefault("bkgnd_color", (0, 0, 0, 0))
+ super(AnalogBarPanel, self).__init__(box=box, **kwargs)
+ self.on_press = on_press
+
+ def sel_children(self):
+ return [self]
+
+ def input_event(self, event: InputEvent) -> bool:
+ if event in (InputEvent.CLICK, InputEvent.LONG_CLICK) and self.on_press is not None:
+ self.on_press()
+ return True
+ return False