diff --git a/CHANGELOG.md b/CHANGELOG.md index af7eb7821..656f2c3c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ 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 +- "Set MIDI Tempo" option in the System Menu to adjust global BPM directly from the hardware encoders. +- Permanent, real-time top-bar MIDI BPM display on the color LCD (v2/v3) that updates instantly on tap-tempo stamps or WebSocket updates, and acts as a clickable shortcut to open the MIDI Tempo editor dialog. + +### Fixed +- Fixed tap-tempo lag and timing jitter by routing stomp button BPM updates asynchronously via the WebSocket bridge instead of blocking synchronous HTTP REST requests (removing a 50-200ms stall from the 10ms critical path polling thread). +- Improved BPM adjustment encoder granularity to 1.0 BPM per notch. +- Immediate local hardware tap-tempo sync for BPM updates (fixing delay/out-of-sync LCD footswitches and LED blinking). +- High-efficiency reuse of line widgets in `Parameterdialog` to eliminate memory leaks and SPI rendering lag. +- Legacy MOD REST API exception safety to protect the system main loop from crashing if MOD host is unreachable. +- Thread-safe deferred BPM updates to prevent UI crashes/restarts when adjusting BPM or performing tap-tempo. +- Synced Parameterdialog BPM display to use rounded integers (matching top-bar status and footswitch displays) instead of truncation. +- Isolated modal dialog rendering in the UI library to prevent background panels (like the parent Menu) from leaking visually behind active popups/dialogs. + ## [v3.1.0] - 2026-06-20 ### Added - On-device (LCD) strobe tuner with mute (v3 hardware only). Defaults to longpress on footswitch C diff --git a/modalapi/mod.py b/modalapi/mod.py index 4cc1183a2..3d3c096e0 100755 --- a/modalapi/mod.py +++ b/modalapi/mod.py @@ -21,6 +21,7 @@ import requests as req import subprocess import sys +import time import yaml from typing import Any @@ -53,6 +54,7 @@ class TopEncoderMode(Enum): SYSTEM_MENU = 5 HEADPHONE_VOLUME = 6 INPUT_GAIN = 7 + BPM = 8 class BotEncoderMode(Enum): DEFAULT = 0 @@ -76,6 +78,7 @@ class UniversalEncoderMode(Enum): DEEP_EDIT = 13 VALUE_EDIT = 14 LOADING = 15 + BPM = 16 class SelectedType(Enum): PEDALBOARD = 0 @@ -140,6 +143,8 @@ def __init__(self, audiocard, homedir): # Stores snapshot index from loading_end until pedalboard change is detected self.next_pedalboard_preset_index = None + # Thread-safe queueing of BPM updates to be processed on the main thread (avoiding Pygame/SPI concurrent writes) + self._pending_bpm_update = None self.selected_menu_index = 0 self.menu_items = None @@ -239,6 +244,8 @@ def top_encoder_sw(self, value): self.top_encoder_mode = TopEncoderMode.SYSTEM_MENU elif mode == TopEncoderMode.INPUT_GAIN: self.top_encoder_mode = TopEncoderMode.SYSTEM_MENU + elif mode == TopEncoderMode.BPM: + self.top_encoder_mode = TopEncoderMode.SYSTEM_MENU else: if len(self.current.presets) > 0: self.top_encoder_mode = TopEncoderMode.PRESET_SELECT @@ -277,12 +284,15 @@ def top_encoder_select(self, direction): self.parameter_value_change(direction, self.headphone_volume_commit) elif mode == TopEncoderMode.INPUT_GAIN: self.parameter_value_change(direction, self.input_gain_commit) + elif mode == TopEncoderMode.BPM: + self.parameter_value_change(direction, self.bpm_commit) def bottom_encoder_sw(self, value): # State machine for bottom rotary encoder switch if (self.top_encoder_mode == TopEncoderMode.SYSTEM_MENU or self.top_encoder_mode == TopEncoderMode.HEADPHONE_VOLUME or - self.top_encoder_mode == TopEncoderMode.INPUT_GAIN): + self.top_encoder_mode == TopEncoderMode.INPUT_GAIN or + self.top_encoder_mode == TopEncoderMode.BPM): return # Ignore bottom encoder if top encoder has navigated to the system menu mode = self.bot_encoder_mode if value == switchstate.Value.RELEASED: @@ -303,7 +313,8 @@ def bottom_encoder_sw(self, value): def bot_encoder_select(self, direction): if (self.top_encoder_mode == TopEncoderMode.SYSTEM_MENU or self.top_encoder_mode == TopEncoderMode.HEADPHONE_VOLUME or - self.top_encoder_mode == TopEncoderMode.INPUT_GAIN): + self.top_encoder_mode == TopEncoderMode.INPUT_GAIN or + self.top_encoder_mode == TopEncoderMode.BPM): return mode = self.bot_encoder_mode if mode == BotEncoderMode.DEFAULT: @@ -358,6 +369,9 @@ def universal_encoder_sw(self, value): elif mode == UniversalEncoderMode.INPUT_GAIN: self.universal_encoder_mode = UniversalEncoderMode.SYSTEM_MENU self.system_menu_show() + elif mode == UniversalEncoderMode.BPM: + self.universal_encoder_mode = UniversalEncoderMode.SYSTEM_MENU + self.system_menu_show() elif mode == UniversalEncoderMode.EQ1_GAIN: self.universal_encoder_mode = UniversalEncoderMode.SYSTEM_MENU self.system_audio_menu() @@ -410,6 +424,8 @@ def universal_encoder_select(self, direction): self.parameter_value_change(direction, self.headphone_volume_commit) elif mode == UniversalEncoderMode.INPUT_GAIN: self.parameter_value_change(direction, self.input_gain_commit) + elif mode == UniversalEncoderMode.BPM: + self.parameter_value_change(direction, self.bpm_commit) elif mode == UniversalEncoderMode.EQ1_GAIN: self.parameter_value_change(direction, self.eq1_gain_commit) elif mode == UniversalEncoderMode.EQ2_GAIN: @@ -491,6 +507,9 @@ def _handle_ws_message(self, msg: WebSocketMessage): elif isinstance(msg, LoadingEndMessage): logging.debug(f"WebSocket: Pedalboard loading finished, snapshot={msg.snapshot_id}") self.next_pedalboard_preset_index = msg.snapshot_id + mod_bundle = self.get_current_pedalboard_bundle_path() + if self.current and mod_bundle == self.current.pedalboard.bundle: + self._is_pedalboard_loading = False elif isinstance(msg, PedalSnapshotMessage): if self.next_pedalboard_preset_index is not None: @@ -518,10 +537,13 @@ def _handle_ws_message(self, msg: WebSocketMessage): elif isinstance(msg, TransportMessage): if self.hardware and self.hardware.taptempo: - self.hardware.taptempo.set_bpm(msg.bpm) - if self.hardware.taptempo.is_enabled(): - fs = next((f for f in self.hardware.footswitches if f.taptempo is self.hardware.taptempo), None) - self.update_lcd_fs(footswitch=fs) + # Prevent WebSocket feedback echo from overriding recently adjusted local BPM (0.5s cooldown) + if not hasattr(self, '_last_bpm_change_time') or (time.time() - self._last_bpm_change_time > 0.5): + self.hardware.taptempo.set_bpm(msg.bpm) + self._pending_bpm_update = msg.bpm + if self.hardware.taptempo.is_enabled(): + fs = next((f for f in self.hardware.footswitches if f.taptempo is self.hardware.taptempo), None) + self.update_lcd_fs(footswitch=fs) elif isinstance(msg, ParamSetMessage): # Mirror mod-ui's live value: refresh the cache (so a later edit opens @@ -540,6 +562,17 @@ def _handle_ws_message(self, msg: WebSocketMessage): def poll_ws_messages(self): """Drain and dispatch inbound WebSocket messages (fast ~10ms cadence).""" + # Defer BPM display updates to the main thread's fast 10ms execution loop. + bpm = getattr(self, '_pending_bpm_update', None) + if bpm is not None: + self._pending_bpm_update = None + if self.lcd: + self.lcd.update_bpm(bpm) + if self.hardware and self.hardware.taptempo and self.hardware.taptempo.is_enabled(): + fs = next((f for f in self.hardware.footswitches if f.taptempo is self.hardware.taptempo), None) + if fs: + self.update_lcd_fs(footswitch=fs) + for msg in self.ws_bridge.get_received_messages(): try: self._handle_ws_message(parse_message(msg)) @@ -552,10 +585,10 @@ def poll_modui_changes(self): # Check for pedalboard change via last.json if self.last_json_monitor.check_for_change(): - self._is_pedalboard_loading = True - self.lcd.draw_info_message("Loading...") mod_bundle = read_pedalboard_bundle(self.last_json_monitor.path) if mod_bundle and mod_bundle != self.current.pedalboard.bundle: + self._is_pedalboard_loading = True + self.lcd.draw_info_message("Loading...") logging.info(f"Pedalboard changed via MOD from: {self.current.pedalboard.bundle} to: {mod_bundle}") if mod_bundle not in self.pedalboards: @@ -981,7 +1014,8 @@ def system_menu_show(self): "5": {Token.NAME: "Reload pedalboards", Token.ACTION: self.system_menu_reload}, "6": {Token.NAME: "Restart sound engine", Token.ACTION: self.system_menu_restart_sound}, "7": {Token.NAME: "Audio Options", Token.ACTION: self.system_audio_menu}, - "8": {Token.NAME: "Advanced Settings", Token.ACTION: self.system_advanced_menu}} + "8": {Token.NAME: "Advanced Settings", Token.ACTION: self.system_advanced_menu}, + "9": {Token.NAME: "Set MIDI Tempo", Token.ACTION: self.system_menu_bpm}} self.lcd.menu_show("System menu", self.menu_items) # Trick: we display the wifi status in the menu, Ideally we need a better # state handling to know what needs to be displayed or not based on whether @@ -1318,7 +1352,42 @@ def get_callback(self, callback_name): def set_mod_tap_tempo(self, bpm): if bpm is not None: - req.post(self.root_uri + "set_bpm", json={"value": bpm}) + self._last_bpm_change_time = time.time() + # Send BPM updates via WebSocket bridge if connected for low-latency; fallback to REST API + if not self.ws_bridge or not self.ws_bridge.is_connected or not self.ws_bridge.send_bpm(bpm): + try: + req.post(self.root_uri + "set_bpm", json={"value": bpm}, timeout=1.0) + except Exception as e: + logging.error("Failed to set BPM: %s", e) + self._pending_bpm_update = bpm + + def get_bpm(self): + url = self.root_uri + "get_bpm" + try: + resp = req.get(url, timeout=2.0) + if resp.status_code != 200: + return 0.0 + return float(resp.text) + except Exception: + return 0.0 + + def system_menu_bpm(self): + title = "BPM" + self.top_encoder_mode = TopEncoderMode.BPM + self.universal_encoder_mode = UniversalEncoderMode.BPM + value = self.get_bpm() + info = {"shortName": title, "symbol": "bpm", "ranges": {"minimum": 40.0, "maximum": 240.0}} + self.deep = self.Deep(None) + param = Parameter.Parameter(info, value, None) + self.deep.selected_parameter = param + self.lcd.draw_value_edit_graph(param, value) + self.lcd.draw_info_message(title) + + def bpm_commit(self): + value = self.deep.selected_parameter.value + self.set_mod_tap_tempo(value) + if self.hardware and self.hardware.taptempo: + self.hardware.taptempo.set_bpm(value) # # Parameter Edit @@ -1357,7 +1426,10 @@ def parameter_value_change(self, direction, commit_callback): param = self.deep.selected_parameter value = float(param.value) # TODO tweak value won't change from call to call, cache it - tweak = util.renormalize_float(self.parameter_tweak_amount, 0, 127, param.minimum, param.maximum) + if param.symbol == "bpm": + tweak = 1.0 + else: + tweak = util.renormalize_float(self.parameter_tweak_amount, 0, 127, param.minimum, param.maximum) new_value = round(value + direction * tweak, 2) if new_value > param.maximum: new_value = param.maximum @@ -1382,6 +1454,8 @@ def update_lcd(self): # TODO rename to imply the home screen self.lcd.draw_tools(SelectedType.WIFI, SelectedType.EQ, SelectedType.BYPASS, SelectedType.SYSTEM) self.lcd.update_bypass(self.hardware.relay.enabled) self.lcd.update_eq(self.eq_status) + if self.lcd: + self.lcd.update_bpm(self.get_bpm()) self.update_lcd_title() self.lcd.draw_analog_assignments(self.current.analog_controllers) self.lcd.draw_plugins(self.current.pedalboard.plugins) diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index fca4874d1..50580e92f 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -21,6 +21,7 @@ import json import logging import os +import time import requests as req from requests import Response import subprocess @@ -85,6 +86,8 @@ def __init__(self, audiocard: Audiocard, homedir, data_dir="/home/pistomp/data") # Stores snapshot index from loading_end until pedalboard change is detected self.next_pedalboard_preset_index = None + # Thread-safe queueing of BPM updates to be processed on the main thread (avoiding Pygame/SPI concurrent writes) + self._pending_bpm_update = None # Backup self.backup_dir = "/media/usb0/backups" @@ -346,6 +349,13 @@ def _handle_ws_message(self, msg: WebSocketMessage): logging.debug(f"WebSocket: Pedalboard loading finished, snapshot={msg.snapshot_id}") # Sometimes mod-ui sends us -1 for preset index, but shows 0 anyway ("Default") self.next_pedalboard_preset_index = max(0, msg.snapshot_id) + mod_bundle = self.get_current_pedalboard_bundle_path() + if self.current and mod_bundle == self.current.pedalboard.bundle: + self.current.preset_index = self.next_pedalboard_preset_index + self._handle_blend_mode_snapshot_change(self.next_pedalboard_preset_index) + self.next_pedalboard_preset_index = None + self.lcd.draw_title() + self._is_pedalboard_loading = False elif isinstance(msg, PedalSnapshotMessage): if self.next_pedalboard_preset_index is not None: @@ -389,10 +399,13 @@ def _handle_ws_message(self, msg: WebSocketMessage): elif isinstance(msg, TransportMessage): if self.hardware and self.hardware.taptempo: - self.hardware.taptempo.set_bpm(msg.bpm) - if self.hardware.taptempo.is_enabled(): - fs = next((f for f in self.hardware.footswitches if f.taptempo is self.hardware.taptempo), None) - self.update_lcd_fs(footswitch=fs) + # Prevent WebSocket feedback echo from overriding recently adjusted local BPM (0.5s cooldown) + if not hasattr(self, '_last_bpm_change_time') or (time.time() - self._last_bpm_change_time > 0.5): + self.hardware.taptempo.set_bpm(msg.bpm) + self._pending_bpm_update = msg.bpm + if self.hardware.taptempo.is_enabled(): + fs = next((f for f in self.hardware.footswitches if f.taptempo is self.hardware.taptempo), None) + self.update_lcd_fs(footswitch=fs) elif isinstance(msg, ParamSetMessage): # Mirror mod-ui's live value: refresh the cache (so a later edit opens @@ -412,6 +425,17 @@ def _handle_ws_message(self, msg: WebSocketMessage): def poll_ws_messages(self): """Drain inbound WS messages (fast ~10ms cadence). Main-thread only. Must not touch next_pedalboard_preset_index (owned by the file-watch path).""" + # Defer BPM display updates to the main thread's fast 10ms execution loop. + bpm = getattr(self, '_pending_bpm_update', None) + if bpm is not None: + self._pending_bpm_update = None + if self.lcd: + self.lcd.update_bpm(bpm) + if self.hardware and self.hardware.taptempo and self.hardware.taptempo.is_enabled(): + fs = next((f for f in self.hardware.footswitches if f.taptempo is self.hardware.taptempo), None) + if fs: + self.update_lcd_fs(footswitch=fs) + for msg in self.ws_bridge.get_received_messages(): try: self._handle_ws_message(parse_message(msg)) @@ -426,10 +450,10 @@ def poll_modui_changes(self): # Check for pedalboard change via last.json if self.last_json_monitor.check_for_change(): - self._is_pedalboard_loading = True - self.lcd.draw_info_message("Loading...") mod_bundle = read_pedalboard_bundle(self.last_json_monitor.path) if mod_bundle and self.current and mod_bundle != self.current.pedalboard.bundle: + self._is_pedalboard_loading = True + self.lcd.draw_info_message("Loading...") logging.info(f"Pedalboard changed via MOD from: {self.current.pedalboard.bundle} to: {mod_bundle}") if mod_bundle not in self.pedalboards: @@ -438,12 +462,15 @@ def poll_modui_changes(self): pb = self.reload_pedalboard(mod_bundle) self.set_current_pedalboard(pb) elif mod_bundle and self.current and self.next_pedalboard_preset_index is not None: + self._is_pedalboard_loading = True + self.lcd.draw_info_message("Loading...") # Same pedalboard reloaded with a pending snapshot - apply it now logging.info(f"Applying pending snapshot {self.next_pedalboard_preset_index} to current pedalboard") self.current.preset_index = self.next_pedalboard_preset_index self._handle_blend_mode_snapshot_change(self.next_pedalboard_preset_index) self.next_pedalboard_preset_index = None self.lcd.draw_title() + self._is_pedalboard_loading = False # Look for a change in banks file if self.banks_monitor.check_for_change(): @@ -837,6 +864,8 @@ def system_info_load(self): self.eq_status = self.audiocard.get_switch_parameter(self.audiocard.DAC_EQ) self.lcd.update_eq(self.eq_status) + if self.lcd: + self.lcd.update_bpm(self.get_bpm()) if self.hardware.relay is not None: enabled = not self.hardware.relay.get() self.lcd.update_bypass(enabled, enabled) @@ -983,9 +1012,9 @@ def system_toggle_bypass(self, arg=None): def change_bypass_preference(self, pref): self.settings.set_setting(Token.BYPASS, pref) - def audio_parameter_change(self, direction, name, symbol, value, min, max, commit_callback): + def audio_parameter_change(self, direction, name, symbol, value, min, max, commit_callback, step=None): if symbol is not None: - d = self.lcd.draw_audio_parameter_dialog(name, symbol, value, min, max, commit_callback) + d = self.lcd.draw_audio_parameter_dialog(name, symbol, value, min, max, commit_callback, step=step) if d is not None: self.lcd.enc_step_widget(d, direction) @@ -1047,19 +1076,38 @@ def get_callback(self, callback_name): def set_mod_tap_tempo(self, bpm): if bpm is not None: - self._rest_post(self.root_uri + "set_bpm", json={"value": bpm}) + self._last_bpm_change_time = time.time() + # Send BPM updates via WebSocket bridge if connected for low-latency; fallback to REST API + if not self.ws_bridge or not self.ws_bridge.is_connected or not self.ws_bridge.send_bpm(bpm): + self._rest_post(self.root_uri + "set_bpm", json={"value": bpm}) + self._pending_bpm_update = bpm def get_bpm(self): url = self.root_uri + "get_bpm" resp = self._rest_get(url) if resp is None or resp.status_code != 200: return 0.0 - return float(resp.text) + try: + return float(resp.text) + except ValueError: + return 0.0 def toggle_tap_tempo_enable(self, *argv): self.hardware.toggle_tap_tempo_enable(self.get_bpm()) self.lcd.update_footswitches() + def system_menu_bpm(self, arg): + value = self.get_bpm() + if arg is None: + arg = 0 + self.audio_parameter_change(arg, "BPM", "bpm", value, + 40.0, 240.0, self.bpm_commit_callback, step=1.0) + + def bpm_commit_callback(self, symbol, value): + self.set_mod_tap_tempo(value) + if self.hardware and self.hardware.taptempo: + self.hardware.taptempo.set_bpm(value) + def set_tuner_source_factory(self, factory: TunerSourceFactory) -> None: self._tuner_source_factory = factory diff --git a/modalapi/websocket_bridge.py b/modalapi/websocket_bridge.py index 3c490996c..c80a88197 100644 --- a/modalapi/websocket_bridge.py +++ b/modalapi/websocket_bridge.py @@ -127,6 +127,7 @@ async def _process_queue(self, ws): continue await ws.send(msg) + logging.debug(f"WebSocket: Sent message to MOD-UI: {msg}") self.messages_sent += 1 self.command_queue.task_done() @@ -203,6 +204,22 @@ def __init__(self, ws_url: str = "ws://localhost:80/websocket", backpressure_thr self._worker = WebSocketWorker(ws_url, backpressure_threshold, self.command_queue, self.received_queue) self._thread: Optional[threading.Thread] = None + @property + def is_connected(self) -> bool: + """Return True if the WebSocket connection is active and open.""" + ws = self._worker.ws + if ws is None: + return False + # Handle websockets package state checking safely across different versions. + if hasattr(ws, 'state'): + try: + return ws.state.name == "OPEN" + except AttributeError: + pass + if hasattr(ws, 'closed'): + return not ws.closed + return True + def start(self): """Start background async worker thread.""" if self._worker.running: diff --git a/pistomp/lcd.py b/pistomp/lcd.py index 1293d402f..cfcdc9827 100755 --- a/pistomp/lcd.py +++ b/pistomp/lcd.py @@ -57,6 +57,10 @@ def update_eq(self, eq_status): def update_bypass(self, bypass): pass + @abstractmethod + def update_bpm(self, bpm): + pass + @abstractmethod def draw_tool_select(self, tool_type): pass diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py index 5387e237f..814c0db1a 100644 --- a/pistomp/lcd320x240.py +++ b/pistomp/lcd320x240.py @@ -107,6 +107,7 @@ def __init__(self, cwd, handler=None, flip=False, display=None, spi_speed_mhz=24 self.footswitch_slots = {} # widgets + self.w_bpm = None self.w_wifi = None self._wifi_frames: list[Image.Image] = [ Image.open(os.path.join(self.imagedir, f'wifi_processing_{i}.png')) @@ -218,6 +219,15 @@ def hide_tuner_panel(self) -> None: def draw_tools(self, wifi_type=None, eq_type=None, bypass_type=None, system_type=None): if self.w_wifi is not None: return + bpm = self.handler.get_bpm() if self.handler else 120.0 + self.w_bpm = TextWidget( + box=Box.xywh(5, 0, 80, 20), + text=f"{round(bpm)} BPM" if bpm else "--- BPM", + font=self.tiny_font, + parent=self.main_panel, + action=self.click_bpm + ) + self.main_panel.add_sel_widget(self.w_bpm) self.w_wifi = ImageWidget( box=Box.xywh(210, 0, 20, 20), image=os.path.join(self.imagedir, 'wifi_gray.png'), @@ -574,6 +584,7 @@ def get_footswitch_pitch(self): def draw_system_menu(self, event, widget): items = [("System info", self.draw_system_info_dialog, None), ("Tuner", self._toggle_tuner_from_menu, None), + ("Set MIDI Tempo", self.handler.system_menu_bpm, None), ("System shutdown", self.handler.system_menu_shutdown, None), ("System reboot", self.handler.system_menu_reboot, None), ("Restart sound engine", self.handler.system_menu_restart_sound, None), @@ -651,14 +662,14 @@ def draw_audio_menu(self, event, widget): ("High Band Gain", self.handler.system_menu_eq5_gain, None)] self.draw_selection_menu(items, "Audio Menu") - def draw_audio_parameter_dialog(self, name, symbol, value, min, max, commit_callback): + def draw_audio_parameter_dialog(self, name, symbol, value, min, max, commit_callback, step=None): d = util.DICT_GET(self.w_parameter_dialogs, symbol) if d is not None and d.parent is not None: return d d = Parameterdialog(self.pstack, name, value, min, max, width=270, height=130, auto_destroy=True, title=name, timeout=2.2, - action=commit_callback, object=symbol, taper=1) + action=commit_callback, object=symbol, taper=1, step=step) self.w_parameter_dialogs[symbol] = d self.pstack.push_panel(d) return d @@ -741,6 +752,14 @@ def update_bypass(self, bypass_left, bypass_right): image_path = os.path.join(self.imagedir, img) self.w_power.replace_img(image_path) + def click_bpm(self, event, widget): + if event == InputEvent.CLICK and self.handler: + self.handler.system_menu_bpm(None) + + def update_bpm(self, bpm): + if self.w_bpm is not None: + self.w_bpm.set_text(f"{round(bpm)} BPM" if bpm else "--- BPM") + def draw_tool_select(self, tool_type): pass @@ -833,6 +852,11 @@ def draw_info_message(self, text, refresh=False): sel_width=0) else: self.w_info_msg.set_text(text) + if self.w_bpm is not None: + if text: + self.w_bpm.hide(refresh=False) + else: + self.w_bpm.show(refresh=False) if refresh: self.main_panel.refresh() diff --git a/pistomp/lcdgfx.py b/pistomp/lcdgfx.py index 1261d7095..52b44d5cd 100755 --- a/pistomp/lcdgfx.py +++ b/pistomp/lcdgfx.py @@ -134,6 +134,9 @@ def update_wifi(self, wifi_status): def update_bypass(self, bypass): pass + def update_bpm(self, bpm): + pass + def update_eq(self, eq_status): pass diff --git a/tests/conftest.py b/tests/conftest.py index dbcede28e..d7025d145 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -129,6 +129,7 @@ class FakeWebSocketBridge: def __init__(self): self.sent: list[str] = [] self._inbox: list[str] = [] + self.is_connected = True def start(self) -> None: pass diff --git a/tests/integration/test_tap_tempo.py b/tests/integration/test_tap_tempo.py index 2702864e7..82e24d568 100644 --- a/tests/integration/test_tap_tempo.py +++ b/tests/integration/test_tap_tempo.py @@ -6,24 +6,21 @@ def test_set_mod_tap_tempo(modhandler_system: SystemFixture): - """set_mod_tap_tempo() POSTs to /set_bpm with the BPM value.""" + """set_mod_tap_tempo() sends the BPM via the ws_bridge.""" handler = modhandler_system.handler - mock_post = modhandler_system.mock_post + ws_bridge = modhandler_system.ws_bridge handler.set_mod_tap_tempo(120) - mock_post.assert_called_once() - call_args = mock_post.call_args - assert "set_bpm" in call_args.args[0] - assert call_args.kwargs.get("json", {}).get("value") == 120 + assert "transport-bpm 120" in ws_bridge.sent def test_set_mod_tap_tempo_none(modhandler_system: SystemFixture): """set_mod_tap_tempo(None) is a no-op.""" handler = modhandler_system.handler - mock_post = modhandler_system.mock_post + ws_bridge = modhandler_system.ws_bridge handler.set_mod_tap_tempo(None) - mock_post.assert_not_called() + assert not any(m.startswith("transport-bpm") for m in ws_bridge.sent) def test_get_bpm(modhandler_system: SystemFixture, get_urls): @@ -56,3 +53,219 @@ def get_side_effect(url, **kwargs): mock_get.side_effect = get_side_effect handler.toggle_tap_tempo_enable() + + +def test_system_menu_bpm_opens_dialog(modhandler_system: SystemFixture): + """system_menu_bpm() opens a Parameterdialog with the current BPM.""" + handler = modhandler_system.handler + mock_get = modhandler_system.mock_get + + def get_side_effect(url, **kwargs): + resp = MagicMock() + resp.status_code = 200 + resp.text = "120.0" if "get_bpm" in url else "{}" + return resp + + mock_get.side_effect = get_side_effect + + # Trigger system menu BPM action + handler.system_menu_bpm(None) + + # Verify the dialog is opened on the display stack and has correct parameter value + dialog = handler.lcd.w_parameter_dialogs.get("bpm") + assert dialog is not None + assert dialog.param_value == 120.0 + assert dialog.param_min == 40.0 + assert dialog.param_max == 240.0 + assert dialog.step == 1.0 + + +def test_system_menu_bpm_commits(modhandler_system: SystemFixture): + """bpm_commit_callback() sends the updated BPM value to ws_bridge and sets local hardware BPM.""" + handler = modhandler_system.handler + ws_bridge = modhandler_system.ws_bridge + + handler.bpm_commit_callback("bpm", 135.0) + + assert "transport-bpm 135.0" in ws_bridge.sent + assert handler.hardware.taptempo.get_bpm() == 135.0 + + +def test_ws_bpm_gating(modhandler_system: SystemFixture): + """WebSocket TransportMessage updates are ignored for 500ms after manual set_mod_tap_tempo.""" + handler = modhandler_system.handler + from modalapi.ws_protocol import TransportMessage + import time + + # Initially, local hardware BPM can be updated via WS + handler.hardware.taptempo.set_bpm(100.0) + handler._handle_ws_message(TransportMessage(rolling=True, bpm=120.0)) + assert handler.hardware.taptempo.get_bpm() == 120.0 + + # User manually edits BPM, setting the timestamp gate + handler.set_mod_tap_tempo(130.0) + + # Inbound WebSocket message with different BPM should be ignored during the gate + handler._handle_ws_message(TransportMessage(rolling=True, bpm=140.0)) + assert handler.hardware.taptempo.get_bpm() == 120.0 # Ignored! + + # Simulate 600ms passing + handler._last_bpm_change_time = time.time() - 0.6 + + # Inbound WebSocket message should now be accepted + handler._handle_ws_message(TransportMessage(rolling=True, bpm=140.0)) + assert handler.hardware.taptempo.get_bpm() == 140.0 + + +def test_legacy_set_mod_tap_tempo_exception_handling(): + """Legacy Mod.set_mod_tap_tempo handles connection exceptions gracefully.""" + from modalapi.mod import Mod + from unittest.mock import patch, MagicMock + + Mod._Mod__single = None + mock_audiocard = MagicMock() + with patch("requests.post", side_effect=Exception("Connection refused")), patch("pistomp.settings.Settings.load_settings"): + handler = Mod(mock_audiocard, "/tmp") + handler.root_uri = "http://localhost:80/" + # Should not raise exception + handler.set_mod_tap_tempo(120.0) + + +def test_legacy_bpm_parameter_tweak_step(): + """Legacy Mod.parameter_value_change uses a tweak size of 1.0 for the bpm symbol.""" + from modalapi.mod import Mod + from unittest.mock import patch, MagicMock + from common.parameter import Parameter + + Mod._Mod__single = None + mock_audiocard = MagicMock() + with patch("pistomp.settings.Settings.load_settings"): + handler = Mod(mock_audiocard, "/tmp") + handler.deep = MagicMock() + handler.lcd = MagicMock() + + # Configure a mock parameter representing BPM + info = {"shortName": "BPM", "symbol": "bpm", "ranges": {"minimum": 40.0, "maximum": 240.0}} + param = Parameter(info, 120.0, None) + handler.deep.selected_parameter = param + + # Simulate encoder increment (direction=1) + commit_mock = MagicMock() + handler.parameter_value_change(1, commit_mock) + assert handler.deep.selected_parameter.value == 121.0 + commit_mock.assert_called_once() + + +def test_lcd_bpm_widget_presence_and_updates(modhandler_system: SystemFixture): + """LCD w_bpm widget is created, updates on change, and triggers system_menu_bpm on click.""" + handler = modhandler_system.handler + lcd = handler.lcd + + # w_bpm should be present on color LCD (v2/v3) + if hasattr(lcd, "w_bpm") and lcd.w_bpm is not None: + # Check initial value + assert "BPM" in lcd.w_bpm.text + + # Check update_bpm + lcd.update_bpm(145.2) + assert lcd.w_bpm.text == "145 BPM" + + # Check click action triggers system_menu_bpm + from unittest.mock import patch + with patch.object(handler, "system_menu_bpm") as mock_menu: + from uilib.misc import InputEvent + lcd.w_bpm.action(InputEvent.CLICK, lcd.w_bpm) + mock_menu.assert_called_once_with(None) + + # Check info message hides/shows w_bpm + lcd.draw_info_message("Loading...") + assert lcd.w_bpm.visible is False + lcd.draw_info_message("") + assert lcd.w_bpm.visible is True + + +def test_parameter_dialog_bpm_rounding(): + """Parameterdialog formats BPM parameter values using round(), and non-BPM using format_float.""" + from uilib.parameterdialog import Parameterdialog + from unittest.mock import MagicMock + + mock_stack = MagicMock() + + # 1. BPM Parameter (should round) + dialog_bpm = Parameterdialog( + stack=mock_stack, + param_name="BPM", + param_value=128.6, + param_min=40.0, + param_max=240.0, + width=240, + height=120, + title="BPM Edit" + ) + assert dialog_bpm.w_value.text == "129" + + # 2. Non-BPM Parameter (should format with format_float, which truncates > 10) + dialog_other = Parameterdialog( + stack=mock_stack, + param_name="Gain", + param_value=12.8, + param_min=0.0, + param_max=20.0, + width=240, + height=120, + title="Gain Edit" + ) + assert dialog_other.w_value.text == "12" + + +def test_websocket_bridge_is_connected_version_agnostic(): + """AsyncWebSocketBridge.is_connected handles websockets v13-v16 (.state) and legacy (.closed).""" + from modalapi.websocket_bridge import AsyncWebSocketBridge + from unittest.mock import MagicMock + + bridge = AsyncWebSocketBridge() + + # Case 1: ws is None + bridge._worker.ws = None + assert bridge.is_connected is False + + # Case 2: Modern websockets connection (v13–v16) with .state + mock_ws_modern = MagicMock() + del mock_ws_modern.closed # Ensure no .closed attribute + mock_ws_modern.state.name = "OPEN" + bridge._worker.ws = mock_ws_modern + assert bridge.is_connected is True + + mock_ws_modern.state.name = "CLOSED" + assert bridge.is_connected is False + + # Case 3: Legacy websockets connection (with .closed) + mock_ws_legacy = MagicMock() + del mock_ws_legacy.state # Ensure no .state attribute + mock_ws_legacy.closed = False + bridge._worker.ws = mock_ws_legacy + assert bridge.is_connected is True + + mock_ws_legacy.closed = True + assert bridge.is_connected is False + + +def test_set_mod_tap_tempo_rest_fallback(modhandler_system: SystemFixture): + """set_mod_tap_tempo() falls back to REST when WebSocket bridge is disconnected.""" + handler = modhandler_system.handler + ws_bridge = modhandler_system.ws_bridge + mock_post = modhandler_system.mock_post + + # Simulate disconnected WebSocket bridge + ws_bridge.is_connected = False + + handler.set_mod_tap_tempo(120) + + # Should NOT have sent via WebSocket + assert not any(m.startswith("transport-bpm") for m in ws_bridge.sent) + + # Should have fallen back to REST + mock_post.assert_called_once() + call_args = mock_post.call_args + assert "set_bpm" in call_args.args[0] + assert call_args.kwargs.get("json", {}).get("value") == 120 diff --git a/tests/snapshots/test_lcd320x240/test_analog_assignments_snapshot/0.png b/tests/snapshots/test_lcd320x240/test_analog_assignments_snapshot/0.png index 1420ade22..a046738a9 100644 Binary files a/tests/snapshots/test_lcd320x240/test_analog_assignments_snapshot/0.png and b/tests/snapshots/test_lcd320x240/test_analog_assignments_snapshot/0.png differ diff --git a/tests/snapshots/test_lcd320x240/test_main_panel_snapshot/0.png b/tests/snapshots/test_lcd320x240/test_main_panel_snapshot/0.png index 2e5cea5c6..5dda03953 100644 Binary files a/tests/snapshots/test_lcd320x240/test_main_panel_snapshot/0.png and b/tests/snapshots/test_lcd320x240/test_main_panel_snapshot/0.png differ diff --git a/tests/snapshots/test_lcd320x240/test_parameter_dialog_snapshot/0.png b/tests/snapshots/test_lcd320x240/test_parameter_dialog_snapshot/0.png index fbf269095..a0e1432f3 100644 Binary files a/tests/snapshots/test_lcd320x240/test_parameter_dialog_snapshot/0.png and b/tests/snapshots/test_lcd320x240/test_parameter_dialog_snapshot/0.png differ diff --git a/tests/snapshots/test_lcd320x240/test_system_menu_snapshot/0.png b/tests/snapshots/test_lcd320x240/test_system_menu_snapshot/0.png index c39d07bc8..cb383c1bc 100644 Binary files a/tests/snapshots/test_lcd320x240/test_system_menu_snapshot/0.png and b/tests/snapshots/test_lcd320x240/test_system_menu_snapshot/0.png differ diff --git a/tests/snapshots/test_lcd320x240/test_tap_tempo_disable_clears_label/tap_tempo_disabled.png b/tests/snapshots/test_lcd320x240/test_tap_tempo_disable_clears_label/tap_tempo_disabled.png index 2a00c075b..f209f26cf 100644 Binary files a/tests/snapshots/test_lcd320x240/test_tap_tempo_disable_clears_label/tap_tempo_disabled.png and b/tests/snapshots/test_lcd320x240/test_tap_tempo_disable_clears_label/tap_tempo_disabled.png differ diff --git a/tests/snapshots/test_lcd320x240/test_tap_tempo_disable_clears_label/tap_tempo_enabled.png b/tests/snapshots/test_lcd320x240/test_tap_tempo_disable_clears_label/tap_tempo_enabled.png index 6784fa852..a41e7db4e 100644 Binary files a/tests/snapshots/test_lcd320x240/test_tap_tempo_disable_clears_label/tap_tempo_enabled.png and b/tests/snapshots/test_lcd320x240/test_tap_tempo_disable_clears_label/tap_tempo_enabled.png differ diff --git a/tests/snapshots/test_lcd320x240/test_tap_tempo_snapshot/0.png b/tests/snapshots/test_lcd320x240/test_tap_tempo_snapshot/0.png index 6784fa852..a41e7db4e 100644 Binary files a/tests/snapshots/test_lcd320x240/test_tap_tempo_snapshot/0.png and b/tests/snapshots/test_lcd320x240/test_tap_tempo_snapshot/0.png differ diff --git a/tests/snapshots/test_lcd320x240/test_update_footswitch_off_snapshot/0.png b/tests/snapshots/test_lcd320x240/test_update_footswitch_off_snapshot/0.png index 265741990..35aeea9ad 100644 Binary files a/tests/snapshots/test_lcd320x240/test_update_footswitch_off_snapshot/0.png and b/tests/snapshots/test_lcd320x240/test_update_footswitch_off_snapshot/0.png differ diff --git a/tests/snapshots/test_lcd320x240/test_update_footswitch_on_snapshot/0.png b/tests/snapshots/test_lcd320x240/test_update_footswitch_on_snapshot/0.png index c7a7c5fd0..f098f72ba 100644 Binary files a/tests/snapshots/test_lcd320x240/test_update_footswitch_on_snapshot/0.png and b/tests/snapshots/test_lcd320x240/test_update_footswitch_on_snapshot/0.png differ diff --git a/tests/snapshots/test_lcd320x240/test_wifi_menu_snapshot/wifi_menu.png b/tests/snapshots/test_lcd320x240/test_wifi_menu_snapshot/wifi_menu.png index ee033e19d..2146b0017 100644 Binary files a/tests/snapshots/test_lcd320x240/test_wifi_menu_snapshot/wifi_menu.png and b/tests/snapshots/test_lcd320x240/test_wifi_menu_snapshot/wifi_menu.png differ diff --git a/tests/snapshots/v3/test_blend_mode/test_lcd_reflects_blend_activation/blend_active.png b/tests/snapshots/v3/test_blend_mode/test_lcd_reflects_blend_activation/blend_active.png index 8667e4c5b..107ec9e4d 100644 Binary files a/tests/snapshots/v3/test_blend_mode/test_lcd_reflects_blend_activation/blend_active.png and b/tests/snapshots/v3/test_blend_mode/test_lcd_reflects_blend_activation/blend_active.png differ diff --git a/tests/snapshots/v3/test_blend_mode/test_lcd_reflects_blend_deactivation/blend_inactive.png b/tests/snapshots/v3/test_blend_mode/test_lcd_reflects_blend_deactivation/blend_inactive.png index e58f78e86..a6aff49de 100644 Binary files a/tests/snapshots/v3/test_blend_mode/test_lcd_reflects_blend_deactivation/blend_inactive.png and b/tests/snapshots/v3/test_blend_mode/test_lcd_reflects_blend_deactivation/blend_inactive.png differ diff --git a/tests/snapshots/v3/test_blend_mode/test_switching_between_blend_modes_applies_correct_initial_values/blend_b_full.png b/tests/snapshots/v3/test_blend_mode/test_switching_between_blend_modes_applies_correct_initial_values/blend_b_full.png index 528293aab..7c6bf932d 100644 Binary files a/tests/snapshots/v3/test_blend_mode/test_switching_between_blend_modes_applies_correct_initial_values/blend_b_full.png and b/tests/snapshots/v3/test_blend_mode/test_switching_between_blend_modes_applies_correct_initial_values/blend_b_full.png differ diff --git a/tests/snapshots/v3/test_blend_mode/test_switching_between_blend_modes_applies_correct_initial_values/blend_b_half.png b/tests/snapshots/v3/test_blend_mode/test_switching_between_blend_modes_applies_correct_initial_values/blend_b_half.png index 528293aab..7c6bf932d 100644 Binary files a/tests/snapshots/v3/test_blend_mode/test_switching_between_blend_modes_applies_correct_initial_values/blend_b_half.png and b/tests/snapshots/v3/test_blend_mode/test_switching_between_blend_modes_applies_correct_initial_values/blend_b_half.png differ diff --git a/tests/snapshots/v3/test_footswitch_presets/test_preset_change_does_not_blank_the_label/after_preset_change.png b/tests/snapshots/v3/test_footswitch_presets/test_preset_change_does_not_blank_the_label/after_preset_change.png index 8476af267..3816275b1 100644 Binary files a/tests/snapshots/v3/test_footswitch_presets/test_preset_change_does_not_blank_the_label/after_preset_change.png and b/tests/snapshots/v3/test_footswitch_presets/test_preset_change_does_not_blank_the_label/after_preset_change.png differ diff --git a/tests/snapshots/v3/test_footswitch_presets/test_preset_change_does_not_blank_the_label/before_preset_change.png b/tests/snapshots/v3/test_footswitch_presets/test_preset_change_does_not_blank_the_label/before_preset_change.png index 1a33b7cf0..d21abac4a 100644 Binary files a/tests/snapshots/v3/test_footswitch_presets/test_preset_change_does_not_blank_the_label/before_preset_change.png and b/tests/snapshots/v3/test_footswitch_presets/test_preset_change_does_not_blank_the_label/before_preset_change.png differ diff --git a/tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_binds_footswitch_live/bound.png b/tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_binds_footswitch_live/bound.png index 2ce42aacb..191ccfc76 100644 Binary files a/tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_binds_footswitch_live/bound.png and b/tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_binds_footswitch_live/bound.png differ diff --git a/tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_binds_footswitch_live/unbound.png b/tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_binds_footswitch_live/unbound.png index 2d312520a..ab83d8fdf 100644 Binary files a/tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_binds_footswitch_live/unbound.png and b/tests/snapshots/v3/test_midi_learn/test_v3_midi_learn_binds_footswitch_live/unbound.png differ diff --git a/tests/snapshots/v3/test_pedalboards/test_v3_pedalboard_change_via_lcd/0.png b/tests/snapshots/v3/test_pedalboards/test_v3_pedalboard_change_via_lcd/0.png index fd2de71f9..e842b979f 100644 Binary files a/tests/snapshots/v3/test_pedalboards/test_v3_pedalboard_change_via_lcd/0.png and b/tests/snapshots/v3/test_pedalboards/test_v3_pedalboard_change_via_lcd/0.png differ diff --git a/tests/snapshots/v3/test_pedalboards/test_v3_pedalboard_change_via_modui/0.png b/tests/snapshots/v3/test_pedalboards/test_v3_pedalboard_change_via_modui/0.png index fd2de71f9..e842b979f 100644 Binary files a/tests/snapshots/v3/test_pedalboards/test_v3_pedalboard_change_via_modui/0.png and b/tests/snapshots/v3/test_pedalboards/test_v3_pedalboard_change_via_modui/0.png differ diff --git a/tests/snapshots/v3/test_plugins/test_v3_bypass_echo_is_idempotent/active.png b/tests/snapshots/v3/test_plugins/test_v3_bypass_echo_is_idempotent/active.png index 95e3568a3..3ed5b52a9 100644 Binary files a/tests/snapshots/v3/test_plugins/test_v3_bypass_echo_is_idempotent/active.png and b/tests/snapshots/v3/test_plugins/test_v3_bypass_echo_is_idempotent/active.png differ diff --git a/tests/snapshots/v3/test_plugins/test_v3_bypass_echo_is_idempotent/bypassed.png b/tests/snapshots/v3/test_plugins/test_v3_bypass_echo_is_idempotent/bypassed.png index 3c7623606..e315e08b3 100644 Binary files a/tests/snapshots/v3/test_plugins/test_v3_bypass_echo_is_idempotent/bypassed.png and b/tests/snapshots/v3/test_plugins/test_v3_bypass_echo_is_idempotent/bypassed.png differ diff --git a/tests/snapshots/v3/test_plugins/test_v3_footswitch_states_snapshot/initial.png b/tests/snapshots/v3/test_plugins/test_v3_footswitch_states_snapshot/initial.png index 5ff997ff9..598c7c5d9 100644 Binary files a/tests/snapshots/v3/test_plugins/test_v3_footswitch_states_snapshot/initial.png and b/tests/snapshots/v3/test_plugins/test_v3_footswitch_states_snapshot/initial.png differ diff --git a/tests/snapshots/v3/test_plugins/test_v3_footswitch_states_snapshot/toggled.png b/tests/snapshots/v3/test_plugins/test_v3_footswitch_states_snapshot/toggled.png index 6e54902f2..e3b5dd8f7 100644 Binary files a/tests/snapshots/v3/test_plugins/test_v3_footswitch_states_snapshot/toggled.png and b/tests/snapshots/v3/test_plugins/test_v3_footswitch_states_snapshot/toggled.png differ diff --git a/tests/snapshots/v3/test_plugins/test_v3_handle_bypass_event_updates_plugin/0.png b/tests/snapshots/v3/test_plugins/test_v3_handle_bypass_event_updates_plugin/0.png index 3c7623606..e315e08b3 100644 Binary files a/tests/snapshots/v3/test_plugins/test_v3_handle_bypass_event_updates_plugin/0.png and b/tests/snapshots/v3/test_plugins/test_v3_handle_bypass_event_updates_plugin/0.png differ diff --git a/tests/snapshots/v3/test_plugins/test_v3_parameter_edit/param_closed.png b/tests/snapshots/v3/test_plugins/test_v3_parameter_edit/param_closed.png index afe308fb6..6f3078316 100644 Binary files a/tests/snapshots/v3/test_plugins/test_v3_parameter_edit/param_closed.png and b/tests/snapshots/v3/test_plugins/test_v3_parameter_edit/param_closed.png differ diff --git a/tests/snapshots/v3/test_plugins/test_v3_parameter_edit/param_dialog.png b/tests/snapshots/v3/test_plugins/test_v3_parameter_edit/param_dialog.png index 9a3920ef5..5d0b5c994 100644 Binary files a/tests/snapshots/v3/test_plugins/test_v3_parameter_edit/param_dialog.png and b/tests/snapshots/v3/test_plugins/test_v3_parameter_edit/param_dialog.png differ diff --git a/tests/snapshots/v3/test_plugins/test_v3_parameter_edit/param_menu.png b/tests/snapshots/v3/test_plugins/test_v3_parameter_edit/param_menu.png index afe308fb6..6f3078316 100644 Binary files a/tests/snapshots/v3/test_plugins/test_v3_parameter_edit/param_menu.png and b/tests/snapshots/v3/test_plugins/test_v3_parameter_edit/param_menu.png differ diff --git a/tests/snapshots/v3/test_plugins/test_v3_parameter_edit/param_tweaked.png b/tests/snapshots/v3/test_plugins/test_v3_parameter_edit/param_tweaked.png index e9ae75adf..aed95f9ff 100644 Binary files a/tests/snapshots/v3/test_plugins/test_v3_parameter_edit/param_tweaked.png and b/tests/snapshots/v3/test_plugins/test_v3_parameter_edit/param_tweaked.png differ diff --git a/tests/snapshots/v3/test_plugins/test_v3_parameter_midi_change/0.png b/tests/snapshots/v3/test_plugins/test_v3_parameter_midi_change/0.png index c981e7f6f..b035208cb 100644 Binary files a/tests/snapshots/v3/test_plugins/test_v3_parameter_midi_change/0.png and b/tests/snapshots/v3/test_plugins/test_v3_parameter_midi_change/0.png differ diff --git a/tests/snapshots/v3/test_plugins/test_v3_pedalboard_switch_multi_fs_same_plugin_show_bound_off_color/beths.png b/tests/snapshots/v3/test_plugins/test_v3_pedalboard_switch_multi_fs_same_plugin_show_bound_off_color/beths.png index 908b1b341..6218d41a8 100644 Binary files a/tests/snapshots/v3/test_plugins/test_v3_pedalboard_switch_multi_fs_same_plugin_show_bound_off_color/beths.png and b/tests/snapshots/v3/test_plugins/test_v3_pedalboard_switch_multi_fs_same_plugin_show_bound_off_color/beths.png differ diff --git a/tests/snapshots/v3/test_plugins/test_v3_pedalboard_switch_multi_fs_same_plugin_show_bound_off_color/doom_bass.png b/tests/snapshots/v3/test_plugins/test_v3_pedalboard_switch_multi_fs_same_plugin_show_bound_off_color/doom_bass.png index 37d5dbcb9..7c9baaacb 100644 Binary files a/tests/snapshots/v3/test_plugins/test_v3_pedalboard_switch_multi_fs_same_plugin_show_bound_off_color/doom_bass.png and b/tests/snapshots/v3/test_plugins/test_v3_pedalboard_switch_multi_fs_same_plugin_show_bound_off_color/doom_bass.png differ diff --git a/tests/snapshots/v3/test_plugins/test_v3_reconnect_dump_reseeds_bypass_via_poll/both_active.png b/tests/snapshots/v3/test_plugins/test_v3_reconnect_dump_reseeds_bypass_via_poll/both_active.png index a37033eaa..55d698c64 100644 Binary files a/tests/snapshots/v3/test_plugins/test_v3_reconnect_dump_reseeds_bypass_via_poll/both_active.png and b/tests/snapshots/v3/test_plugins/test_v3_reconnect_dump_reseeds_bypass_via_poll/both_active.png differ diff --git a/tests/snapshots/v3/test_plugins/test_v3_reconnect_dump_reseeds_bypass_via_poll/delay_bypassed.png b/tests/snapshots/v3/test_plugins/test_v3_reconnect_dump_reseeds_bypass_via_poll/delay_bypassed.png index 3e939d620..7de77b807 100644 Binary files a/tests/snapshots/v3/test_plugins/test_v3_reconnect_dump_reseeds_bypass_via_poll/delay_bypassed.png and b/tests/snapshots/v3/test_plugins/test_v3_reconnect_dump_reseeds_bypass_via_poll/delay_bypassed.png differ diff --git a/tests/snapshots/v3/test_plugins/test_v3_snapshot_sequence_applies_bypass_via_ws/clean.png b/tests/snapshots/v3/test_plugins/test_v3_snapshot_sequence_applies_bypass_via_ws/clean.png index a37033eaa..55d698c64 100644 Binary files a/tests/snapshots/v3/test_plugins/test_v3_snapshot_sequence_applies_bypass_via_ws/clean.png and b/tests/snapshots/v3/test_plugins/test_v3_snapshot_sequence_applies_bypass_via_ws/clean.png differ diff --git a/tests/snapshots/v3/test_plugins/test_v3_snapshot_sequence_applies_bypass_via_ws/lead.png b/tests/snapshots/v3/test_plugins/test_v3_snapshot_sequence_applies_bypass_via_ws/lead.png index d78ca1ba6..ae83d251a 100644 Binary files a/tests/snapshots/v3/test_plugins/test_v3_snapshot_sequence_applies_bypass_via_ws/lead.png and b/tests/snapshots/v3/test_plugins/test_v3_snapshot_sequence_applies_bypass_via_ws/lead.png differ diff --git a/tests/snapshots/v3/test_plugins/test_v3_toggle_plugin_bypass_no_footswitch_sends_websocket/active.png b/tests/snapshots/v3/test_plugins/test_v3_toggle_plugin_bypass_no_footswitch_sends_websocket/active.png index 95e3568a3..3ed5b52a9 100644 Binary files a/tests/snapshots/v3/test_plugins/test_v3_toggle_plugin_bypass_no_footswitch_sends_websocket/active.png and b/tests/snapshots/v3/test_plugins/test_v3_toggle_plugin_bypass_no_footswitch_sends_websocket/active.png differ diff --git a/tests/snapshots/v3/test_plugins/test_v3_toggle_plugin_bypass_no_footswitch_sends_websocket/bypassed.png b/tests/snapshots/v3/test_plugins/test_v3_toggle_plugin_bypass_no_footswitch_sends_websocket/bypassed.png index 3c7623606..e315e08b3 100644 Binary files a/tests/snapshots/v3/test_plugins/test_v3_toggle_plugin_bypass_no_footswitch_sends_websocket/bypassed.png and b/tests/snapshots/v3/test_plugins/test_v3_toggle_plugin_bypass_no_footswitch_sends_websocket/bypassed.png differ diff --git a/tests/snapshots/v3/test_presets/test_v3_preset_change_via_footswitch_longpress/0.png b/tests/snapshots/v3/test_presets/test_v3_preset_change_via_footswitch_longpress/0.png index 06bce87d0..801d9926f 100644 Binary files a/tests/snapshots/v3/test_presets/test_v3_preset_change_via_footswitch_longpress/0.png and b/tests/snapshots/v3/test_presets/test_v3_preset_change_via_footswitch_longpress/0.png differ diff --git a/tests/snapshots/v3/test_presets/test_v3_preset_change_via_lcd/nav_A.png b/tests/snapshots/v3/test_presets/test_v3_preset_change_via_lcd/nav_A.png index 06bce87d0..801d9926f 100644 Binary files a/tests/snapshots/v3/test_presets/test_v3_preset_change_via_lcd/nav_A.png and b/tests/snapshots/v3/test_presets/test_v3_preset_change_via_lcd/nav_A.png differ diff --git a/tests/snapshots/v3/test_presets/test_v3_preset_change_via_lcd/nav_B.png b/tests/snapshots/v3/test_presets/test_v3_preset_change_via_lcd/nav_B.png index 9b6c96d77..329930ba8 100644 Binary files a/tests/snapshots/v3/test_presets/test_v3_preset_change_via_lcd/nav_B.png and b/tests/snapshots/v3/test_presets/test_v3_preset_change_via_lcd/nav_B.png differ diff --git a/tests/snapshots/v3/test_presets/test_v3_preset_change_via_lcd/nav_C.png b/tests/snapshots/v3/test_presets/test_v3_preset_change_via_lcd/nav_C.png index cd72ffaa5..3a9b8a192 100644 Binary files a/tests/snapshots/v3/test_presets/test_v3_preset_change_via_lcd/nav_C.png and b/tests/snapshots/v3/test_presets/test_v3_preset_change_via_lcd/nav_C.png differ diff --git a/tests/snapshots/v3/test_presets/test_v3_preset_change_via_lcd/nav_D.png b/tests/snapshots/v3/test_presets/test_v3_preset_change_via_lcd/nav_D.png index ba358de3c..dad90751c 100644 Binary files a/tests/snapshots/v3/test_presets/test_v3_preset_change_via_lcd/nav_D.png and b/tests/snapshots/v3/test_presets/test_v3_preset_change_via_lcd/nav_D.png differ diff --git a/tests/snapshots/v3/test_startup/test_v3_footswitch_press/0.png b/tests/snapshots/v3/test_startup/test_v3_footswitch_press/0.png index b6033311d..82c2d2639 100644 Binary files a/tests/snapshots/v3/test_startup/test_v3_footswitch_press/0.png and b/tests/snapshots/v3/test_startup/test_v3_footswitch_press/0.png differ diff --git a/tests/snapshots/v3/test_startup/test_v3_nav_to_system_menu/0.png b/tests/snapshots/v3/test_startup/test_v3_nav_to_system_menu/0.png index 943b28d56..13eebf287 100644 Binary files a/tests/snapshots/v3/test_startup/test_v3_nav_to_system_menu/0.png and b/tests/snapshots/v3/test_startup/test_v3_nav_to_system_menu/0.png differ diff --git a/tests/snapshots/v3/test_startup/test_v3_startup_snapshot/0.png b/tests/snapshots/v3/test_startup/test_v3_startup_snapshot/0.png index 06bce87d0..801d9926f 100644 Binary files a/tests/snapshots/v3/test_startup/test_v3_startup_snapshot/0.png and b/tests/snapshots/v3/test_startup/test_v3_startup_snapshot/0.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_dialog_cancel_returns_to_menu/nearby_after_cancel.png b/tests/snapshots/v3/test_wifi_menu/test_dialog_cancel_returns_to_menu/nearby_after_cancel.png index 954989efb..5dff97d7e 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_dialog_cancel_returns_to_menu/nearby_after_cancel.png and b/tests/snapshots/v3/test_wifi_menu/test_dialog_cancel_returns_to_menu/nearby_after_cancel.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_disconnect_active/root_after_disconnect.png b/tests/snapshots/v3/test_wifi_menu/test_disconnect_active/root_after_disconnect.png index 0b5e7cad9..d9355544e 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_disconnect_active/root_after_disconnect.png and b/tests/snapshots/v3/test_wifi_menu/test_disconnect_active/root_after_disconnect.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_duplicate_ssids_dedup/nearby_dedup.png b/tests/snapshots/v3/test_wifi_menu/test_duplicate_ssids_dedup/nearby_dedup.png index 920e3069a..dd9047159 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_duplicate_ssids_dedup/nearby_dedup.png and b/tests/snapshots/v3/test_wifi_menu/test_duplicate_ssids_dedup/nearby_dedup.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_empty_psk_submit_blocked/empty_psk_ok_pressed.png b/tests/snapshots/v3/test_wifi_menu/test_empty_psk_submit_blocked/empty_psk_ok_pressed.png index ac2cdf4a7..53658e415 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_empty_psk_submit_blocked/empty_psk_ok_pressed.png and b/tests/snapshots/v3/test_wifi_menu/test_empty_psk_submit_blocked/empty_psk_ok_pressed.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_empty_scan/root_empty.png b/tests/snapshots/v3/test_wifi_menu/test_empty_scan/root_empty.png index 2e70fa386..5bab92226 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_empty_scan/root_empty.png and b/tests/snapshots/v3/test_wifi_menu/test_empty_scan/root_empty.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_error_dialog_snapshot/error_dialog.png b/tests/snapshots/v3/test_wifi_menu/test_error_dialog_snapshot/error_dialog.png index 405fdba5f..540fcae5c 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_error_dialog_snapshot/error_dialog.png and b/tests/snapshots/v3/test_wifi_menu/test_error_dialog_snapshot/error_dialog.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_forget_active_falls_back_to_best_saved/root_after_fallback.png b/tests/snapshots/v3/test_wifi_menu/test_forget_active_falls_back_to_best_saved/root_after_fallback.png index 3879d4f44..d858b71a6 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_forget_active_falls_back_to_best_saved/root_after_fallback.png and b/tests/snapshots/v3/test_wifi_menu/test_forget_active_falls_back_to_best_saved/root_after_fallback.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_forget_active_falls_back_to_best_saved/root_before_forget.png b/tests/snapshots/v3/test_wifi_menu/test_forget_active_falls_back_to_best_saved/root_before_forget.png index 2b5439a4f..09d7904b5 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_forget_active_falls_back_to_best_saved/root_before_forget.png and b/tests/snapshots/v3/test_wifi_menu/test_forget_active_falls_back_to_best_saved/root_before_forget.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_forget_then_reload/root_after_forget.png b/tests/snapshots/v3/test_wifi_menu/test_forget_then_reload/root_after_forget.png index 2e70fa386..5bab92226 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_forget_then_reload/root_after_forget.png and b/tests/snapshots/v3/test_wifi_menu/test_forget_then_reload/root_after_forget.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_hotspot_active_indicator/root_hotspot_on.png b/tests/snapshots/v3/test_wifi_menu/test_hotspot_active_indicator/root_hotspot_on.png index 5af5e4414..2a1faa61a 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_hotspot_active_indicator/root_hotspot_on.png and b/tests/snapshots/v3/test_wifi_menu/test_hotspot_active_indicator/root_hotspot_on.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_join_other_empty_ssid_blocked/join_empty_ssid.png b/tests/snapshots/v3/test_wifi_menu/test_join_other_empty_ssid_blocked/join_empty_ssid.png index 6ce508653..76e5a914e 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_join_other_empty_ssid_blocked/join_empty_ssid.png and b/tests/snapshots/v3/test_wifi_menu/test_join_other_empty_ssid_blocked/join_empty_ssid.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_join_other_with_space_in_ssid/join_dialog_ssid_with_space.png b/tests/snapshots/v3/test_wifi_menu/test_join_other_with_space_in_ssid/join_dialog_ssid_with_space.png index d9b31d83a..5017e7497 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_join_other_with_space_in_ssid/join_dialog_ssid_with_space.png and b/tests/snapshots/v3/test_wifi_menu/test_join_other_with_space_in_ssid/join_dialog_ssid_with_space.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_join_other_with_space_in_ssid/space_char_selected.png b/tests/snapshots/v3/test_wifi_menu/test_join_other_with_space_in_ssid/space_char_selected.png index 997b6ef95..8dae88ee1 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_join_other_with_space_in_ssid/space_char_selected.png and b/tests/snapshots/v3/test_wifi_menu/test_join_other_with_space_in_ssid/space_char_selected.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_long_press_active_submenu/active_actions_submenu.png b/tests/snapshots/v3/test_wifi_menu/test_long_press_active_submenu/active_actions_submenu.png index 0b5e7cad9..d9355544e 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_long_press_active_submenu/active_actions_submenu.png and b/tests/snapshots/v3/test_wifi_menu/test_long_press_active_submenu/active_actions_submenu.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_many_saved_all_at_root/root_all_four_visible.png b/tests/snapshots/v3/test_wifi_menu/test_many_saved_all_at_root/root_all_four_visible.png index 82d50a5e2..335885c9d 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_many_saved_all_at_root/root_all_four_visible.png and b/tests/snapshots/v3/test_wifi_menu/test_many_saved_all_at_root/root_all_four_visible.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_multiple_profiles_same_ssid/root_disambiguated_by_name.png b/tests/snapshots/v3/test_wifi_menu/test_multiple_profiles_same_ssid/root_disambiguated_by_name.png index 27ff7cc5b..37121894e 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_multiple_profiles_same_ssid/root_disambiguated_by_name.png and b/tests/snapshots/v3/test_wifi_menu/test_multiple_profiles_same_ssid/root_disambiguated_by_name.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_nearby_loading_then_populated/nearby_populated.png b/tests/snapshots/v3/test_wifi_menu/test_nearby_loading_then_populated/nearby_populated.png index 1ada2d76f..aca736dff 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_nearby_loading_then_populated/nearby_populated.png and b/tests/snapshots/v3/test_wifi_menu/test_nearby_loading_then_populated/nearby_populated.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_nearby_loading_then_populated/nearby_scanning.png b/tests/snapshots/v3/test_wifi_menu/test_nearby_loading_then_populated/nearby_scanning.png index 76840c141..e46ba48a6 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_nearby_loading_then_populated/nearby_scanning.png and b/tests/snapshots/v3/test_wifi_menu/test_nearby_loading_then_populated/nearby_scanning.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_nearby_loading_then_populated/root_before_scan.png b/tests/snapshots/v3/test_wifi_menu/test_nearby_loading_then_populated/root_before_scan.png index 2e70fa386..5bab92226 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_nearby_loading_then_populated/root_before_scan.png and b/tests/snapshots/v3/test_wifi_menu/test_nearby_loading_then_populated/root_before_scan.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_open_network_badge_in_nearby_list/nearby_with_open_badge.png b/tests/snapshots/v3/test_wifi_menu/test_open_network_badge_in_nearby_list/nearby_with_open_badge.png index 028ba2ca6..9e826ca9c 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_open_network_badge_in_nearby_list/nearby_with_open_badge.png and b/tests/snapshots/v3/test_wifi_menu/test_open_network_badge_in_nearby_list/nearby_with_open_badge.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_open_network_connect/connected_open.png b/tests/snapshots/v3/test_wifi_menu/test_open_network_connect/connected_open.png index fb1453a60..0075576a9 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_open_network_connect/connected_open.png and b/tests/snapshots/v3/test_wifi_menu/test_open_network_connect/connected_open.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_password_special_chars/psk_special_chars_dialog.png b/tests/snapshots/v3/test_wifi_menu/test_password_special_chars/psk_special_chars_dialog.png index 954989efb..5dff97d7e 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_password_special_chars/psk_special_chars_dialog.png and b/tests/snapshots/v3/test_wifi_menu/test_password_special_chars/psk_special_chars_dialog.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_replace_password_flow/replace_psk_dialog.png b/tests/snapshots/v3/test_wifi_menu/test_replace_password_flow/replace_psk_dialog.png index 11b1c11a7..aa6084c83 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_replace_password_flow/replace_psk_dialog.png and b/tests/snapshots/v3/test_wifi_menu/test_replace_password_flow/replace_psk_dialog.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_replace_password_flow/root_after_replace.png b/tests/snapshots/v3/test_wifi_menu/test_replace_password_flow/root_after_replace.png index 48e98d664..875a7ebaf 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_replace_password_flow/root_after_replace.png and b/tests/snapshots/v3/test_wifi_menu/test_replace_password_flow/root_after_replace.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_saved_in_range_active/root_active_first.png b/tests/snapshots/v3/test_wifi_menu/test_saved_in_range_active/root_active_first.png index 2b5439a4f..09d7904b5 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_saved_in_range_active/root_active_first.png and b/tests/snapshots/v3/test_wifi_menu/test_saved_in_range_active/root_active_first.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_saved_wrong_psk_shows_error/saved_auth_failed_dialog.png b/tests/snapshots/v3/test_wifi_menu/test_saved_wrong_psk_shows_error/saved_auth_failed_dialog.png index 75253de62..540fcae5c 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_saved_wrong_psk_shows_error/saved_auth_failed_dialog.png and b/tests/snapshots/v3/test_wifi_menu/test_saved_wrong_psk_shows_error/saved_auth_failed_dialog.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_signal_bar_levels/nearby_signal_levels.png b/tests/snapshots/v3/test_wifi_menu/test_signal_bar_levels/nearby_signal_levels.png index d608df415..0d8489820 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_signal_bar_levels/nearby_signal_levels.png and b/tests/snapshots/v3/test_wifi_menu/test_signal_bar_levels/nearby_signal_levels.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_signal_bar_levels/root_signal_levels.png b/tests/snapshots/v3/test_wifi_menu/test_signal_bar_levels/root_signal_levels.png index 2e70fa386..5bab92226 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_signal_bar_levels/root_signal_levels.png and b/tests/snapshots/v3/test_wifi_menu/test_signal_bar_levels/root_signal_levels.png differ diff --git a/tests/snapshots/v3/test_wifi_menu/test_wifi_unsupported/root_unsupported.png b/tests/snapshots/v3/test_wifi_menu/test_wifi_unsupported/root_unsupported.png index f68e8bdb4..6a93546fb 100644 Binary files a/tests/snapshots/v3/test_wifi_menu/test_wifi_unsupported/root_unsupported.png and b/tests/snapshots/v3/test_wifi_menu/test_wifi_unsupported/root_unsupported.png differ diff --git a/tests/snapshots/v3/test_wifi_menu_navigation/test_wifi_menu_navigation/initial_menu.png b/tests/snapshots/v3/test_wifi_menu_navigation/test_wifi_menu_navigation/initial_menu.png index 2e70fa386..5bab92226 100644 Binary files a/tests/snapshots/v3/test_wifi_menu_navigation/test_wifi_menu_navigation/initial_menu.png and b/tests/snapshots/v3/test_wifi_menu_navigation/test_wifi_menu_navigation/initial_menu.png differ diff --git a/tests/snapshots/v3/test_wifi_menu_navigation/test_wifi_menu_navigation/navigated_down.png b/tests/snapshots/v3/test_wifi_menu_navigation/test_wifi_menu_navigation/navigated_down.png index 9dc4600cd..add0e550b 100644 Binary files a/tests/snapshots/v3/test_wifi_menu_navigation/test_wifi_menu_navigation/navigated_down.png and b/tests/snapshots/v3/test_wifi_menu_navigation/test_wifi_menu_navigation/navigated_down.png differ diff --git a/tests/snapshots/v3/test_wifi_menu_navigation/test_wifi_menu_navigation/password_dialog.png b/tests/snapshots/v3/test_wifi_menu_navigation/test_wifi_menu_navigation/password_dialog.png index c94333cf6..d3b0b44cf 100644 Binary files a/tests/snapshots/v3/test_wifi_menu_navigation/test_wifi_menu_navigation/password_dialog.png and b/tests/snapshots/v3/test_wifi_menu_navigation/test_wifi_menu_navigation/password_dialog.png differ diff --git a/uilib/dialog.py b/uilib/dialog.py index 8af5c97b6..597cd2e8f 100644 --- a/uilib/dialog.py +++ b/uilib/dialog.py @@ -76,6 +76,8 @@ def __init__(self, width, height, title, title_font=None, **kwargs): if "mask_format" not in kwargs: kwargs["mask_format"] = "1" super(Dialog, self).__init__(box=box, align=WidgetAlign.CENTRE, radius=radius, decorator=deco, **kwargs) + # Mark all popup dialogs (menus, parameter adjustments, messages) as modal. + self.is_modal = True # Setup mask mdraw = ImageDraw.Draw(self.mask) # FIXME: self.mask can be None # Base is a rounded rectangle diff --git a/uilib/panel.py b/uilib/panel.py index 0bb74344d..42d39d0d3 100644 --- a/uilib/panel.py +++ b/uilib/panel.py @@ -42,6 +42,9 @@ def __init__(self, auto_destroy=False, decorator=None, **kwargs): self.sel_list = [] self.sel = None self.auto_destroy = auto_destroy + # Set to True for modal dialogs to prevent underlying modal panels (like Menu) + # from being drawn/blended behind them, avoiding visual overlap/leakage. + self.is_modal = False if decorator: self.decorator = decorator(self) else: @@ -237,8 +240,22 @@ def _do_refresh(self, panel, box): # XXX Do some alpha blending to "dim" inactive panels ? - # Compose panels + # Compose panels. + # Find the highest/top-most modal panel in the stack. + top_modal = None + for p in reversed(self.stack): + if getattr(p, "is_modal", False): + top_modal = p + break + for p in self.stack: + # If there are multiple modal panels (e.g. Menu and Parameterdialog), + # only render the active (top-most) modal panel. Sibling/underlying + # non-modal panels (like main_panel and footswitch_panel) should still + # be drawn in the background. + if getattr(p, "is_modal", False) and p is not top_modal: + continue + if self.dimmer is not None: self.image.alpha_composite(self.dimmer, box.topleft, box.rect) d = p.decorator diff --git a/uilib/parameterdialog.py b/uilib/parameterdialog.py index e5f3fe96d..fa83c783e 100644 --- a/uilib/parameterdialog.py +++ b/uilib/parameterdialog.py @@ -26,6 +26,8 @@ class Parameterdialog(Dialog): def __init__(self, stack, param_name, param_value, param_min, param_max, width, height, title, title_font=None, timeout=None, taper=1, **kwargs): + # Optional step size for parameter increment/decrement (e.g. 1.0 for BPM). + self.step = kwargs.pop('step', None) self._init_attrs(Widget.INH_ATTRS, kwargs) super(Parameterdialog,self).__init__(width, height, title, title_font, **kwargs) self.stack = stack # TODO very LAME to require the stack to be passed, ideally panel would be able to pop itself @@ -71,7 +73,11 @@ def _draw_graph(self): # TODO detailed dimensions, colors, etc. should not be defined in uilib y0 = 80 x_offset = 10 - val_text = util.format_float(self.param_value) + # Round BPM to nearest integer to match status bar and footswitch displays. + if self.param_name == "BPM": + val_text = f"{round(self.param_value)}" + else: + val_text = util.format_float(self.param_value) if self.w_value is None: self.w_value = TextWidget(box=Box.xywh(118, 20, 0, 0), text=val_text, parent=self, align=WidgetAlign.NONE, name='value') @@ -83,21 +89,28 @@ def _draw_graph(self): else: self.w_value.set_text(val_text) - # TODO would be nice to only redraw the lines that need changing - x = 0 - for i in self.graph_abscissa: - i = int(i) - 1 # abscissa start at 1, arrays start at 0 + # Reuse/cache line widgets instead of recreating them on every redraw to prevent memory leaks and SPI rendering lag. + if not hasattr(self, 'line_widgets'): + self.line_widgets = [] + x = 0 + for i in self.graph_abscissa: + i = int(i) - 1 # abscissa start at 1, arrays start at 0 + g = self.graph_points[i] + line_box = Box.xywh(x + x_offset, y0 - g, 1, g) + w = Widget(box=line_box, parent=self, outline=1, sel_width=0, outline_radius=0, + align=WidgetAlign.NONE) + self.line_widgets.append(w) + x = x + self.points_per_actual + + for idx, i in enumerate(self.graph_abscissa): + i = int(i) - 1 a = int(np.ceil(i) / self.points_per_actual) p = self.actual_points[a] - g = self.graph_points[i] - line_box = Box.xywh(x + x_offset, y0 - g, 1, g) - w = Widget(box=line_box, parent=self, outline=1, sel_width=0, outline_radius=0, - align=WidgetAlign.NONE) + w = self.line_widgets[idx] if p <= self.param_value: w.set_foreground('yellow') else: w.set_foreground((100, 100, 240)) - x = x + self.points_per_actual self.refresh() @@ -114,15 +127,20 @@ def parameter_value_change(self, direction): # Find the point on the graph for the current param_value, then get the previous or next value value = float(self.param_value) - i = self._find_nearest_element_index(self.actual_points, value) - new = i-1 if (direction != 1) else i+1 - new_value = self.actual_points[new] if (0 <= new < self.num_actual) else value + # Use simple step-based adjustment if a step size is specified (e.g. for BPM) + # otherwise find the nearest point on the curve/taper points. + if self.step is not None: + new_value = round(value + direction * self.step, 1) + else: + i = self._find_nearest_element_index(self.actual_points, value) + new = i-1 if (direction != 1) else i+1 + new_value = self.actual_points[new] if (0 <= new < self.num_actual) else value if new_value > self.param_max: new_value = self.param_max if new_value < self.param_min: new_value = self.param_min - if new_value is value: + if new_value == value: return self.param_value = new_value if self.action is not None: