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