From 4cffdc235a21bf320b38f6547c828183e292516e Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sat, 29 Aug 2026 15:22:51 -0400 Subject: [PATCH 1/2] Fast black screen shutdown and remove LCD splash --- common/util.py | 3 ++ modalapi/ethernet/manager.py | 3 +- modalapi/modhandler.py | 11 ++---- modalapi/websocket_bridge.py | 3 +- modalapi/wifi/commands.py | 4 +- modalapi/wifi/manager.py | 4 +- pistomp/lcd320x240.py | 35 ++++-------------- tests/integration/test_system_menu.py | 22 ++++++++--- .../test_cleanup_blacks_the_panel/0.png | Bin 0 -> 303 bytes .../test_splash_snapshot/0.png | Bin 3979 -> 0 bytes tests/test_lcd320x240.py | 8 ++-- uilib/lcd_ili9341.py | 4 -- uilib/panel.py | 5 --- 13 files changed, 46 insertions(+), 56 deletions(-) create mode 100644 tests/snapshots/test_lcd320x240/test_cleanup_blacks_the_panel/0.png delete mode 100644 tests/snapshots/test_lcd320x240/test_splash_snapshot/0.png diff --git a/common/util.py b/common/util.py index 06a6fb660..2d6d710da 100755 --- a/common/util.py +++ b/common/util.py @@ -18,6 +18,9 @@ import math from typing import Any +# Teardown join budget for subprocesses +TEARDOWN_JOIN_S = 0.25 + def LILV_FOREACH(collection, func): itr = collection.begin() diff --git a/modalapi/ethernet/manager.py b/modalapi/ethernet/manager.py index f4e59cdc2..3e3a22644 100644 --- a/modalapi/ethernet/manager.py +++ b/modalapi/ethernet/manager.py @@ -23,6 +23,7 @@ from functools import cached_property from typing import Optional +from common.util import TEARDOWN_JOIN_S from pistomp.alsa_pcm import read_hw_params # Contract with the JackBridge service: truncate-on-start, atomic-rewrite of a @@ -76,7 +77,7 @@ def __init__(self) -> None: def shutdown(self) -> None: self._stop.set() - self._thread.join(timeout=2.0) + self._thread.join(timeout=TEARDOWN_JOIN_S) # ----- background polling ----- diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 36722ee91..3434de435 100644 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -1545,16 +1545,14 @@ def recovery_available(self) -> bool: ) def system_menu_shutdown(self, arg): - self.lcd.splash_show(False) + self.lcd.cleanup() logging.info("System Shutdown") - os.system("sudo systemctl --no-wall poweroff") - os._exit(0) + os.system("sudo systemctl --no-wall --no-block poweroff") def system_menu_reboot(self, arg): - self.lcd.splash_show(False) + self.lcd.cleanup() logging.info("System Reboot") - os.system("sudo systemctl reboot") - os._exit(0) + os.system("sudo systemctl --no-wall --no-block reboot") def system_menu_recovery_mode(self, arg): self.lcd.draw_info_message("Entering recovery mode...", refresh=True) @@ -1752,7 +1750,6 @@ def restart_ui_stack(self) -> None: logging.error("restart_ui_stack: %s", e) def system_menu_restart_sound(self, arg): - self.lcd.splash_show() logging.info("Restart sound engine (jack)") os.system("sudo systemctl restart jack") diff --git a/modalapi/websocket_bridge.py b/modalapi/websocket_bridge.py index 45de1c348..6a8b22a7e 100644 --- a/modalapi/websocket_bridge.py +++ b/modalapi/websocket_bridge.py @@ -33,6 +33,7 @@ import websockets import uvloop from common.parameter import Symbol +from common.util import TEARDOWN_JOIN_S # Service will restart after this MAX_RECONNECT_ATTEMPTS = 4 @@ -286,7 +287,7 @@ def stop(self): self._worker.running = False self._worker.signal_stop() if self._thread and not sys.is_finalizing(): - self._thread.join(timeout=2.0) + self._thread.join(timeout=TEARDOWN_JOIN_S) logging.info(f"WebSocket worker stopped (sent={self._worker.messages_sent})") def send_bpm(self, bpm: float) -> bool: diff --git a/modalapi/wifi/commands.py b/modalapi/wifi/commands.py index 0abe37e5e..09da43b09 100644 --- a/modalapi/wifi/commands.py +++ b/modalapi/wifi/commands.py @@ -22,6 +22,8 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Callable, Generic, Optional, TypeVar +from common.util import TEARDOWN_JOIN_S + if TYPE_CHECKING: from .manager import WifiManager @@ -201,4 +203,4 @@ def pending_op_count(self) -> int: def shutdown(self) -> None: self._cmd_queue.put(_SHUTDOWN_SENTINEL) - self._worker.join(timeout=2.0) + self._worker.join(timeout=TEARDOWN_JOIN_S) diff --git a/modalapi/wifi/manager.py b/modalapi/wifi/manager.py index 46ce2b98c..9683c473e 100644 --- a/modalapi/wifi/manager.py +++ b/modalapi/wifi/manager.py @@ -20,6 +20,8 @@ import threading from typing import Callable, Optional +from common.util import TEARDOWN_JOIN_S + from . import ops from .commands import CommandQueue from .nmcli import nmcli, parse_kv_lines @@ -72,7 +74,7 @@ def shutdown(self) -> None: except Exception: pass if self.thread is not None: - self.thread.join(timeout=2.0) + self.thread.join(timeout=TEARDOWN_JOIN_S) def _is_wifi_supported(self) -> bool: if self.wireless_supported: diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py index 569bbc393..52e14c2e0 100644 --- a/pistomp/lcd320x240.py +++ b/pistomp/lcd320x240.py @@ -158,12 +158,9 @@ def __init__(self, cwd, handler: "Modhandler", flip=False, display=None, spi_spe # Colors self.background = (0, 0, 0) self.foreground = (255, 255, 255) - self.color_splash_up = (70, 255, 70) - self.color_splash_down = (255, 20, 20) # TODO get fonts from config.json self.title_font = _make_font(font_path("DejaVuSans-Bold.ttf"), 26) - self.splash_font = _make_font(font_path("DejaVuSans.ttf"), 48) self.small_font = _make_font(font_path("DejaVuSans.ttf"), 20) self.tiny_font = _make_font(font_path("DejaVuSans.ttf"), 16) self.subtitle_font = _make_font(font_path("DejaVuSans.ttf"), 14) @@ -199,7 +196,6 @@ def __init__(self, cwd, handler: "Modhandler", flip=False, display=None, spi_spe self.grid_panel: Optional[GridPanel] = None self.w_footswitches = [] self.w_controls = [] - self.w_splash = None self.w_info_msg = None self.w_subtitle: Optional[Subtitle] = None self._subtitle_desc = "" # last selection description seen @@ -208,8 +204,6 @@ def __init__(self, cwd, handler: "Modhandler", flip=False, display=None, spi_spe # panels self.pstack = PanelStack(display, image_format="RGB", use_dimming=True) - self.splash_panel = Panel(box=Box.xywh(0, 0, self.display_width, self.display_height)) - self.pstack.push_panel(self.splash_panel, refresh=False) self.main_panel = Panel( box=Box.xywh(0, 0, self.display_width, self.display_height), persist_on_board_change=True ) @@ -233,9 +227,6 @@ def __init__(self, cwd, handler: "Modhandler", flip=False, display=None, spi_spe # the PanelStack, which is created earlier in this block. self.wifi_menu: WifiMenu = WifiMenu(self) - if not display.has_system_splash: - self.splash_show(True) - # # Main # @@ -330,9 +321,7 @@ def _poll_updates(self): elif isinstance(icon.object, EncoderController): enc = icon.object midi_value = ( - enc.bar_midi_value() - if enc.parameter is not None - else self.handler.encoder_fallback(enc) + enc.bar_midi_value() if enc.parameter is not None else self.handler.encoder_fallback(enc) ) elif isinstance(icon.object, BlendMode): ic = icon.object.input_controller @@ -1083,18 +1072,6 @@ def draw_vu_calibration_dialog(self, symbol, value, commit_callback): # # General # - def splash_show(self, boot=True): - color = self.color_splash_up if boot else self.color_splash_down - if self.w_splash is None: - self.w_splash = TextWidget( - box=Box.xywh(12, 80, self.display_width, self.display_height), - text="pi Stomp!", - font=self.splash_font, - parent=self.splash_panel, - ) - self.w_splash.set_foreground(color) - self.splash_panel.refresh() - def cleanup(self): # Walk every input-accepting panel (dialogs, tuner, plugin panels, …) # so buried panels are destroyed too, not just the top-most one. @@ -1104,7 +1081,7 @@ def cleanup(self): self.pstack.pop_panel(self.footswitch_panel) if self.main_panel_pushed and self.main_panel in self.pstack.stack: self.pstack.pop_panel(self.main_panel) - self.splash_show(False) + self.pstack.refresh() # black screen def clear(self): pass @@ -1252,8 +1229,12 @@ def draw_analog_assignments(self, controllers): if k is None: # Non-mapped control name = "none" - control_type = ControlType.EXPRESSION if i == 0 else ControlType.KNOB # HACK cuz we don't know type of unmapped - subtitle = "Expression pedal (unassigned)" if control_type == ControlType.EXPRESSION else "Knob (unassigned)" + control_type = ( + ControlType.EXPRESSION if i == 0 else ControlType.KNOB + ) # HACK cuz we don't know type of unmapped + subtitle = ( + "Expression pedal (unassigned)" if control_type == ControlType.EXPRESSION else "Knob (unassigned)" + ) color = accent_color_for(None) text_color = color control_label_fn = None diff --git a/tests/integration/test_system_menu.py b/tests/integration/test_system_menu.py index 67fddf3c3..f9cd04127 100644 --- a/tests/integration/test_system_menu.py +++ b/tests/integration/test_system_menu.py @@ -13,18 +13,28 @@ def test_system_menu_shutdown(modhandler_system: SystemFixture): handler = modhandler_system.handler - with patch.object(handler.lcd, "cleanup"), patch("os.system") as mock_os, patch("os._exit") as mock_exit: + with patch("os.system") as mock_os: handler.system_menu_shutdown(None) - mock_os.assert_called_once_with("sudo systemctl --no-wall poweroff") - mock_exit.assert_called_once_with(0) + mock_os.assert_called_once_with("sudo systemctl --no-wall --no-block poweroff") def test_system_menu_reboot(modhandler_system: SystemFixture): handler = modhandler_system.handler - with patch("os.system") as mock_os, patch("os._exit") as mock_exit: + with patch("os.system") as mock_os: handler.system_menu_reboot(None) - mock_os.assert_called_once_with("sudo systemctl reboot") - mock_exit.assert_called_once_with(0) + mock_os.assert_called_once_with("sudo systemctl --no-wall --no-block reboot") + + +def test_shutdown_survives_polls_before_sigterm(modhandler_system: SystemFixture): + """The loop keeps ticking between the poweroff request and systemd's SIGTERM.""" + handler = modhandler_system.handler + with patch("os.system"): + handler.system_menu_shutdown(None) + handler.lcd.update_bypass(True, True) + handler.lcd.update_wifi({"hotspot_active": False, "wifi_connected": True}) + handler.poll_lcd_updates() + assert handler.lcd.pstack.stack == [] + assert handler.lcd.pstack.current is None def test_system_menu_reload(modhandler_system: SystemFixture): diff --git a/tests/snapshots/test_lcd320x240/test_cleanup_blacks_the_panel/0.png b/tests/snapshots/test_lcd320x240/test_cleanup_blacks_the_panel/0.png new file mode 100644 index 0000000000000000000000000000000000000000..1307b6450bc7c3af7b56fc6e381a4f06adefd866 GIT binary patch literal 303 zcmeAS@N?(olHy`uVBq!ia0y~yU~~YoKX5Ps$$$P@HbCl|r;B4q#hkYX85x1RCWZg& h^E@0t%wY^JEM#nzWnc_w)BOnYrKhW(%Q~loCIA6j6zTu~ literal 0 HcmV?d00001 diff --git a/tests/snapshots/test_lcd320x240/test_splash_snapshot/0.png b/tests/snapshots/test_lcd320x240/test_splash_snapshot/0.png deleted file mode 100644 index 52245757ce5c612cbb5d104d9fe2fe5f40b0de50..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3979 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 diff --git a/tests/test_lcd320x240.py b/tests/test_lcd320x240.py index 06bf68a57..9ca3dfe34 100644 --- a/tests/test_lcd320x240.py +++ b/tests/test_lcd320x240.py @@ -167,10 +167,12 @@ def setup_main_ui(instance): instance.draw_main_panel() -def test_splash_snapshot(lcd, snapshot): - _, fake = lcd +def test_cleanup_blacks_the_panel(lcd, snapshot): + instance, fake = lcd + setup_main_ui(instance) + instance.cleanup() fake.flush() - assert len(fake.frames) > 0, "expected at least one frame from splash_show during __init__" + assert instance.pstack.stack == [] snapshot() diff --git a/uilib/lcd_ili9341.py b/uilib/lcd_ili9341.py index fcce1dc2e..c40aa9c48 100644 --- a/uilib/lcd_ili9341.py +++ b/uilib/lcd_ili9341.py @@ -142,10 +142,6 @@ def _block_fast(self, x0, y0, x1, y1, data=None): # Release lock once spi.unlock() - @property - def has_system_splash(self) -> bool: - return has_system_splash() - def _set_stamp(self): try: with open(INIT_STAMP, "w") as _f: diff --git a/uilib/panel.py b/uilib/panel.py index 8c4adc491..a0f4bb232 100644 --- a/uilib/panel.py +++ b/uilib/panel.py @@ -404,11 +404,6 @@ def transfer_ms(self, box: Optional[Box] = None) -> float: """Estimated ms to push a clip of this box's size. 0 = no cost.""" return 0.0 - @property - def has_system_splash(self) -> bool: - return False - - class PanelStack(ContainerWidget): # A push estimated to take longer than this is coalesced rather than pushed # inline, leaving headroom under the 10ms tick. From 0f1433ac2865eee2cddc3c5479889258660f5edf Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Mon, 31 Aug 2026 19:25:07 -0400 Subject: [PATCH 2/2] PR feedback --- emulator/modhandler.py | 1 + modalapi/modhandler.py | 24 ++++----- pistomp/lcd320x240.py | 4 +- plugins/audio_midi/panel.py | 3 +- tests/integration/test_system_menu.py | 20 +++++++- .../test_busy_dialog_snapshot/busy_dialog.png | Bin 0 -> 11720 bytes tests/test_lcd320x240.py | 33 ++++++++++-- uilib/dialog.py | 48 +++++++++++------- 8 files changed, 90 insertions(+), 43 deletions(-) create mode 100644 tests/snapshots/test_lcd320x240/test_busy_dialog_snapshot/busy_dialog.png diff --git a/emulator/modhandler.py b/emulator/modhandler.py index e66b55334..7dbf410fc 100644 --- a/emulator/modhandler.py +++ b/emulator/modhandler.py @@ -106,6 +106,7 @@ def system_menu_reboot(self, arg): def system_menu_restart_sound(self, arg): logging.info("Emulator: restart sound is a no-op") + raise KeyboardInterrupt def system_menu_reload(self, arg): logging.info("Emulator: reload configs is a no-op") diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 3434de435..d2e81f3e6 100644 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -284,9 +284,7 @@ def _rest_get_with_retry(self, url: str) -> Response | None: resp = self._rest_get(url) if resp is not None and resp.status_code == 200: return resp - logging.info( - "mod-ui not ready, retrying (%d/%d) in %ss...", attempt, len(STARTUP_REST_BACKOFF_S), delay - ) + logging.info("mod-ui not ready, retrying (%d/%d) in %ss...", attempt, len(STARTUP_REST_BACKOFF_S), delay) time.sleep(delay) return self._rest_get(url) @@ -450,9 +448,7 @@ def _handle_switch(self, event: SwitchEvent) -> bool: # ControllerManager._bind_encoder_longpress. if event.kind == SwitchEventKind.LONGPRESS and controller.midi_CC is not None: key = f"{controller.midi_channel}:{controller.midi_CC}" - winner = self.effective_table.resolve( - ControlRef(cls=ControlClass.ANALOG, id=key), EventKind.LONGPRESS - ) + winner = self.effective_table.resolve(ControlRef(cls=ControlClass.ANALOG, id=key), EventKind.LONGPRESS) if winner is not None: self._fire_row(winner, event) return True @@ -467,9 +463,7 @@ def _handle_footswitch(self, fs: Footswitch, kind: SwitchEventKind, timestamp: f Handler._handle_footswitch imperative if-chain.""" if kind == SwitchEventKind.LONGPRESS: key = fs.dispatch_key - winner = self.effective_table.resolve( - ControlRef(cls=ControlClass.FOOTSWITCH, id=key), EventKind.LONGPRESS - ) + winner = self.effective_table.resolve(ControlRef(cls=ControlClass.FOOTSWITCH, id=key), EventKind.LONGPRESS) if winner is not None: self._fire_row(winner, SwitchEvent(controller=fs, kind=kind, timestamp=timestamp)) return True @@ -479,9 +473,7 @@ def _handle_footswitch(self, fs: Footswitch, kind: SwitchEventKind, timestamp: f # Short press key = fs.dispatch_key - winner = self.effective_table.resolve( - ControlRef(cls=ControlClass.FOOTSWITCH, id=key), EventKind.PRESS - ) + winner = self.effective_table.resolve(ControlRef(cls=ControlClass.FOOTSWITCH, id=key), EventKind.PRESS) if winner is not None: self._fire_row(winner, SwitchEvent(controller=fs, kind=kind, timestamp=timestamp)) return True @@ -1245,7 +1237,7 @@ def _publish_bpm(self, param: Parameter) -> bool: return self.set_mod_tap_tempo(param.value) def _publish_audio(self, param: Parameter) -> bool: - """ A local ALSA write. No remote echo, so the send always lands.""" + """A local ALSA write. No remote echo, so the send always lands.""" self.audio_parameter_commit(param.symbol, param.value) return True @@ -1521,6 +1513,7 @@ def maybe_show_welcome(self): if self.settings.get_setting(Token.WELCOME_SEEN): return from ui.welcome import WelcomePanel + self.lcd.pstack.push_panel(WelcomePanel(self)) def get_software_version(self) -> str: @@ -1545,13 +1538,13 @@ def recovery_available(self) -> bool: ) def system_menu_shutdown(self, arg): - self.lcd.cleanup() logging.info("System Shutdown") + self.lcd.draw_message_dialog("Shutting down…", title="Please wait", dismissable=False) os.system("sudo systemctl --no-wall --no-block poweroff") def system_menu_reboot(self, arg): - self.lcd.cleanup() logging.info("System Reboot") + self.lcd.draw_message_dialog("Rebooting…", title="Please wait", dismissable=False) os.system("sudo systemctl --no-wall --no-block reboot") def system_menu_recovery_mode(self, arg): @@ -1751,6 +1744,7 @@ def restart_ui_stack(self) -> None: def system_menu_restart_sound(self, arg): logging.info("Restart sound engine (jack)") + self.lcd.draw_message_dialog("Restarting sound engine…", title="Please wait", dismissable=False) os.system("sudo systemctl restart jack") def system_disable_eq(self): diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py index 52e14c2e0..1cb6afe61 100644 --- a/pistomp/lcd320x240.py +++ b/pistomp/lcd320x240.py @@ -577,8 +577,8 @@ def menu_action(event, params): self.pstack.push_panel(m) return m - def draw_message_dialog(self, text, title="Error", on_dismiss=None): - d = MessageDialog(self.pstack, text, title=title, on_dismiss=on_dismiss) + def draw_message_dialog(self, text, title="Error", on_dismiss=None, dismissable=True): + d = MessageDialog(self.pstack, text, title=title, on_dismiss=on_dismiss, dismissable=dismissable) self.pstack.push_panel(d) def draw_plugins(self): diff --git a/plugins/audio_midi/panel.py b/plugins/audio_midi/panel.py index d768a38d1..0f6fb8903 100644 --- a/plugins/audio_midi/panel.py +++ b/plugins/audio_midi/panel.py @@ -681,7 +681,8 @@ def _on_toggle_mute(self) -> None: def _on_restart(self) -> None: # Mirrors handler.system_menu_restart_sound — restarts jack (which - # cascades to mod-host/mod-ui). The splash covers the teardown. + # cascades to mod-host/mod-ui). The uncancellable dialog covers the + # teardown until this service is SIGTERMed. self._handler.system_menu_restart_sound(None) def wants_fast_tick(self) -> bool: diff --git a/tests/integration/test_system_menu.py b/tests/integration/test_system_menu.py index f9cd04127..569ba23bc 100644 --- a/tests/integration/test_system_menu.py +++ b/tests/integration/test_system_menu.py @@ -16,6 +16,7 @@ def test_system_menu_shutdown(modhandler_system: SystemFixture): with patch("os.system") as mock_os: handler.system_menu_shutdown(None) mock_os.assert_called_once_with("sudo systemctl --no-wall --no-block poweroff") + assert handler.lcd.pstack.current is not None # uncancellable dialog stays up def test_system_menu_reboot(modhandler_system: SystemFixture): @@ -23,6 +24,7 @@ def test_system_menu_reboot(modhandler_system: SystemFixture): with patch("os.system") as mock_os: handler.system_menu_reboot(None) mock_os.assert_called_once_with("sudo systemctl --no-wall --no-block reboot") + assert handler.lcd.pstack.current is not None # uncancellable dialog stays up def test_shutdown_survives_polls_before_sigterm(modhandler_system: SystemFixture): @@ -33,8 +35,22 @@ def test_shutdown_survives_polls_before_sigterm(modhandler_system: SystemFixture handler.lcd.update_bypass(True, True) handler.lcd.update_wifi({"hotspot_active": False, "wifi_connected": True}) handler.poll_lcd_updates() - assert handler.lcd.pstack.stack == [] - assert handler.lcd.pstack.current is None + assert handler.lcd.pstack.current is not None # dialog still up, not black-screened + + +def test_uncancellable_message_dialog_has_no_ok_button(modhandler_system: SystemFixture): + """dismissable=False drops the Ok button and its selectable — nothing to + press, so the dialog swallows everything and can't be dismissed.""" + handler = modhandler_system.handler + from pistomp.input.event import EncoderEvent + from tests.v3.nav_helpers import nav_encoder + + handler.lcd.draw_message_dialog("Please wait…", dismissable=False) + d = handler.lcd.pstack.current + assert d is not None + assert d.sel_ref is None # no selectable Ok button + + assert d.handle(EncoderEvent(controller=nav_encoder(handler), rotations=1)) is True # NAV swallowed def test_system_menu_reload(modhandler_system: SystemFixture): diff --git a/tests/snapshots/test_lcd320x240/test_busy_dialog_snapshot/busy_dialog.png b/tests/snapshots/test_lcd320x240/test_busy_dialog_snapshot/busy_dialog.png new file mode 100644 index 0000000000000000000000000000000000000000..c2fd9b77f844f4c54cdd6089d179f82c84732817 GIT binary patch literal 11720 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 MxSUv}sJ{Py0U9#3X#fBK literal 0 HcmV?d00001 diff --git a/tests/test_lcd320x240.py b/tests/test_lcd320x240.py index 9ca3dfe34..15639566f 100644 --- a/tests/test_lcd320x240.py +++ b/tests/test_lcd320x240.py @@ -62,8 +62,13 @@ def _make_pedalboard(title: str, plugins: list[Plugin], connections: list[Connec def _make_footswitch(id: int, toggled: bool = False, display_label: str = "", taptempo=None) -> Footswitch: fs = Footswitch( - id=id, led_pin=None, pixel=None, midi_CC=1, midi_channel=0, - refresh_callback=lambda *a, **k: None, taptempo=taptempo, + id=id, + led_pin=None, + pixel=None, + midi_CC=1, + midi_channel=0, + refresh_callback=lambda *a, **k: None, + taptempo=taptempo, ) fs.toggled = toggled fs.display_label = display_label @@ -132,15 +137,25 @@ def setup_main_ui(instance): mock_mix = _real_param(name="Mix", symbol="mix", instance_id="reverb", value=0.4) plugins = [ _make_plugin( - "distortion", uri="mock://distortion", category="Distortion", has_footswitch=True, + "distortion", + uri="mock://distortion", + category="Distortion", + has_footswitch=True, parameters={Symbol("gain"): mock_gain}, ), _make_plugin( - "delay", uri="mock://delay", category="Delay", has_footswitch=True, + "delay", + uri="mock://delay", + category="Delay", + has_footswitch=True, parameters={Symbol("time"): mock_time}, ), _make_plugin( - "reverb", uri="mock://reverb", category="Reverb", has_footswitch=True, bypassed=True, + "reverb", + uri="mock://reverb", + category="Reverb", + has_footswitch=True, + bypassed=True, parameters={Symbol("mix"): mock_mix}, ), _make_plugin("chorus", uri="mock://chorus", category="Modulator", has_footswitch=False), @@ -215,6 +230,14 @@ def test_system_menu_snapshot(lcd, snapshot): snapshot() +def test_busy_dialog_snapshot(lcd, snapshot): + """Uncancellable "Please wait" dialog: message only, no Ok button.""" + instance, _ = lcd + setup_main_ui(instance) + instance.draw_message_dialog("Restarting sound engine…", title="Please wait", dismissable=False) + snapshot("busy_dialog") + + def test_system_info_dialog_snapshot(lcd, snapshot): """The System Info MessageDialog must show all 5 lines without clipping.""" instance, _ = lcd diff --git a/uilib/dialog.py b/uilib/dialog.py index ac27bb928..e35f693db 100644 --- a/uilib/dialog.py +++ b/uilib/dialog.py @@ -38,6 +38,7 @@ class DialogScheme: When None is passed to Dialog/DialogDecorator, the current Config defaults are used (system menus render pixel-identical). """ + title_fgnd: tuple[int, int, int] title_bkgnd: tuple[int, int, int] outline_color: tuple[int, int, int] @@ -130,7 +131,9 @@ def __init__(self, width, height, title, title_font=None, scheme=None, **kwargs) if title_font is None: title_font = Config().get_font("default_title") self._title_strip_h = get_text_size(title, title_font)[1] + 2 - deco = functools.partial(DialogDecorator, title=title, title_font=title_font, outline_radius=radius, scheme=scheme) + deco = functools.partial( + DialogDecorator, title=title, title_font=title_font, outline_radius=radius, scheme=scheme + ) super(Dialog, self).__init__(box=box, align=WidgetAlign.CENTRE, radius=radius, decorator=deco, **kwargs) def _adjust_box(self): @@ -155,9 +158,14 @@ def tick(self) -> None: class MessageDialog(Dialog): - def __init__(self, panelstack, message, title="Error", width=200, height=90, on_dismiss=None): - super(MessageDialog, self).__init__(width=width, height=height, title=title, auto_destroy=True) - + def __init__(self, panelstack, message, title="Error", width=200, height=90, on_dismiss=None, dismissable=True): + super(MessageDialog, self).__init__( + width=width, + height=height, + title=title, + auto_destroy=True, + dismissable=dismissable, + ) font = Config().get_font("default_title") char_w = font.get_rect("a").width if font else 0 chars_per_line = width // max(1, int(char_w)) @@ -168,8 +176,11 @@ def __init__(self, panelstack, message, title="Error", width=200, height=90, on_ num_lines = wrapped.count("\n") + 1 text_h = line_h * num_lines text_box_h = max(0, min(text_h, height - 34)) + # No Ok button — center the message in the body instead of leaving a + # top-heavy block above the empty button slot. + y_offset = (height - text_box_h) // 2 if not dismissable else 0 t = TextWidget( - box=Box.xywh(5, 0, width - 10, text_box_h), + box=Box.xywh(5, y_offset, width - 10, text_box_h), text=wrapped, parent=self, outline=0, @@ -183,19 +194,20 @@ def _dismiss(x, y): if on_dismiss: on_dismiss() - b = TextWidget( - box=Box.xywh(int((width / 2) - 20), height - 30, 0, 0), - text="Ok", - parent=self, - outline=1, - sel_width=3, - outline_radius=5, - action=_dismiss, - align=WidgetAlign.NONE, - name="ok_btn", - ) - self.add_sel_widget(b) - self.sel_widget(b) + if dismissable: + b = TextWidget( + box=Box.xywh(int((width / 2) - 20), height - 30, 0, 0), + text="Ok", + parent=self, + outline=1, + sel_width=3, + outline_radius=5, + action=_dismiss, + align=WidgetAlign.NONE, + name="ok_btn", + ) + self.add_sel_widget(b) + self.sel_widget(b) class ConfirmDialog(Dialog):