From ddca585e8b3b1bdc57956957008f965f39828d87 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Thu, 27 Aug 2026 23:32:18 -0400 Subject: [PATCH] WIP adc toggler --- CHANGELOG.md | 5 + docs/architecture.md | 7 + emulator/hardware_base.py | 1 + pistomp/handler.py | 2 + pistomp/hardware.py | 30 +++- pistomp/input_enable.py | 55 ++++++++ pistomp/lcd320x240.py | 41 +++++- setup/config_templates/default_config.yml | 16 ++- .../default_config_3fs_2knob.yml | 14 +- .../default_config_3fs_2knob_exp.yml | 4 + .../default_config_pistompcore.yml | 16 ++- .../default_config_pistomptre.yml | 16 ++- tests/conftest.py | 9 ++ tests/integration/conftest.py | 25 ++-- .../test_analog_menu_snapshot/menu.png | Bin 0 -> 10403 bytes .../menu_expression_on.png | Bin 0 -> 10468 bytes .../row_expression_off.png | Bin 0 -> 6989 bytes .../row_expression_on.png | Bin 0 -> 6720 bytes tests/test_lcd320x240.py | 2 +- tests/v2/conftest.py | 4 +- tests/v3/conftest.py | 24 ++-- tests/v3/test_analog_menu.py | 132 +++++++++++++++++ tests/v3/test_dynamic_pedalboard.py | 5 +- tests/v3/test_plugins.py | 3 +- ui/analog_menu.py | 133 ++++++++++++++++++ uilib/__init__.py | 2 + uilib/analog_bar.py | 52 +++++++ 27 files changed, 546 insertions(+), 52 deletions(-) create mode 100644 pistomp/input_enable.py create mode 100644 tests/snapshots/v3/test_analog_menu/test_analog_menu_snapshot/menu.png create mode 100644 tests/snapshots/v3/test_analog_menu/test_analog_menu_snapshot/menu_expression_on.png create mode 100644 tests/snapshots/v3/test_analog_menu/test_analog_menu_snapshot/row_expression_off.png create mode 100644 tests/snapshots/v3/test_analog_menu/test_analog_menu_snapshot/row_expression_on.png create mode 100644 tests/v3/test_analog_menu.py create mode 100644 ui/analog_menu.py create mode 100644 uilib/analog_bar.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b7a6b2bab..c4c9e2bf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ Notable user visible changes to this project will be documented in this file. 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. + ## [v3.3.1] - 2026-08-14 ### Fixed - Pedalboards list no longer shows deleted pedalboards after they are removed from MOD-UI diff --git a/docs/architecture.md b/docs/architecture.md index a5ca57e7d..f920d9e8a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -355,6 +355,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 78482d69f..a1df5d7a4 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 cc1f9b709..47245346a 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, @@ -75,6 +77,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 @@ -225,6 +233,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 = {} @@ -1211,12 +1224,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 @@ -1288,6 +1309,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 @@ -1311,7 +1338,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, @@ -1326,7 +1353,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, @@ -1342,12 +1369,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 2bbff1c3d..02b90c96a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -233,6 +233,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 + + # --------------------------------------------------------------------------- # Shared factory fixtures (available to all test directories) # --------------------------------------------------------------------------- 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 0000000000000000000000000000000000000000..28e559bd62802b5014dedc41068eb4e0eec87b4c GIT binary patch literal 10403 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 Z5MjzRk&j+`@PGfL_)<-_LdN|4e*oDANEHA8 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0f6c39084088816d297e8ce469dc030062de0ba5 GIT binary patch literal 6989 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0e7f177aa577b1e5b96a2d6faef338b6067485bf GIT binary patch literal 6720 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 wHmkmrSb|$-7?k 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 408d34460..878d5772a 100644 --- a/tests/v3/test_plugins.py +++ b/tests/v3/test_plugins.py @@ -360,7 +360,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