diff --git a/pistomp/category.py b/pistomp/category.py index 9c1f3340..d7324187 100644 --- a/pistomp/category.py +++ b/pistomp/category.py @@ -14,7 +14,7 @@ # along with pi-stomp. If not, see . import logging -from PIL import ImageColor +import pygame import common.util as util @@ -39,8 +39,9 @@ def valid_color(color): if color is None: return None try: - return ImageColor.getrgb(color) - except ValueError: + c = pygame.Color(color) + return (c.r, c.g, c.b) + except (ValueError, TypeError): logging.error("Cannot convert color name: %s" % color) return None diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py index 95bde8af..a14ba4cb 100644 --- a/pistomp/lcd320x240.py +++ b/pistomp/lcd320x240.py @@ -24,9 +24,10 @@ import pistomp.category as Category import pistomp.lcd as abstract_lcd import pistomp.switchstate as switchstate -from PIL import ImageColor +import pygame from uilib import * +from uilib._pygame_init import freetype as _get_freetype from uilib.lcd_ili9341 import * from pistomp.footswitch import Footswitch # TODO would like to avoid this module knowing such details @@ -72,10 +73,13 @@ def __init__(self, cwd, handler=None, flip=False): } # TODO get fonts from config.json - self.title_font = ImageFont.truetype("DejaVuSans-Bold.ttf", 26) - self.splash_font = ImageFont.truetype('DejaVuSans.ttf', 48) - self.small_font = ImageFont.truetype("DejaVuSans.ttf", 20) - self.tiny_font = ImageFont.truetype("DejaVuSans.ttf", 16) + from pathlib import Path + _fonts_dir = Path(__file__).resolve().parent.parent / "fonts" + _ft = _get_freetype() + self.title_font = _ft.Font(str(_fonts_dir / "DejaVuSans-Bold.ttf"), 26) + self.splash_font = _ft.Font(str(_fonts_dir / "DejaVuSans.ttf"), 48) + self.small_font = _ft.Font(str(_fonts_dir / "DejaVuSans.ttf"), 20) + self.tiny_font = _ft.Font(str(_fonts_dir / "DejaVuSans.ttf"), 16) self.title_split_orig = 190 self.title_split = self.title_split_orig self.display_width = 320 @@ -83,8 +87,8 @@ def __init__(self, cwd, handler=None, flip=False): self.plugin_width = 78 self.plugin_height = 29 self.plugin_label_length = 7 - self.footswitch_height = 60 - self.footswitch_width = 56 + self.footswitch_height = 64 + self.footswitch_width = 60 # space between footswitch icons where index is the footswitch count # 0 1 2 3 4 5 6 7 self.footswitch_pitch_options = [120, 120, 120, 128, 86, 65, 65, 65] @@ -218,12 +222,14 @@ def draw_title(self): def draw_pedalboard(self, pedalboard_name): pedalboard_name += ":" - self.title_split = min(self.title_font.getmask(pedalboard_name).getbbox()[2], self.title_split_orig) + _tw, _ = get_text_size(pedalboard_name, self.title_font) + self.title_split = min(_tw, self.title_split_orig) + box_w = self.title_split + 4 if self.w_pedalboard is not None: self.w_pedalboard.set_text(pedalboard_name) - self.w_pedalboard.set_box(box=Box.xywh(0, 20, self.title_split, 36), realign=True, refresh=True) + self.w_pedalboard.set_box(box=Box.xywh(0, 20, box_w, 36), realign=True, refresh=True) return - self.w_pedalboard = TextWidget(box=Box.xywh(0, 20, self.title_split, 36), text=pedalboard_name, + self.w_pedalboard = TextWidget(box=Box.xywh(0, 20, box_w, 36), text=pedalboard_name, font=self.title_font, parent=self.main_panel, action=self.draw_pedalboard_menu) self.main_panel.add_sel_widget(self.w_pedalboard) @@ -361,8 +367,9 @@ def valid_color(self, color): if color is None: return self.foreground try: - return ImageColor.getrgb(color) - except ValueError: + c = pygame.Color(color) + return (c.r, c.g, c.b) + except (ValueError, TypeError): logging.error("Cannot convert color name: %s" % color) return self.foreground @@ -444,7 +451,7 @@ def draw_footswitch(self, plugin): x = self.get_footswitch_pitch() * fs_id self.footswitch_slots[fs_id] = label color = self.get_plugin_color(plugin) - p = FootswitchWidget(Box.xywh(x, y, self.plugin_width, self.plugin_height), self.small_font, + p = FootswitchWidget(Box.xywh(x, y, self.footswitch_width, self.footswitch_height), self.small_font, label, color, plugin.is_bypassed(), parent=self.footswitch_panel, object=c) self.w_footswitches.append(p) self.footswitch_panel.add_widget(p) @@ -459,7 +466,7 @@ def draw_unbound_footswitches(self): label = "" if dl is None else dl y = 0 x = self.get_footswitch_pitch() * slot - p = FootswitchWidget(Box.xywh(x, y, self.plugin_width, self.plugin_height), self.small_font, + p = FootswitchWidget(Box.xywh(x, y, self.footswitch_width, self.footswitch_height), self.small_font, label, None, True, parent=self.footswitch_panel, object=fs) self.w_footswitches.append(p) self.footswitch_panel.add_widget(p) @@ -709,12 +716,12 @@ def draw_analog_assignments(self, controllers): text_color = color if control_type == Token.KNOB: - w = Icon(box=Box.xywh(x, y, 0, 0), text=name, text_color=text_color, parent=self.main_panel, outline=0) + w = Icon(box=Box.xywh(x, y, width_per_control, 18), text=name, text_color=text_color, parent=self.main_panel, outline=0) w.set_foreground(color) w.add_knob() self.w_controls.append(w) elif control_type == Token.EXPRESSION: - w = Icon(box=Box.xywh(x, y, 0, 0), text=name, text_color=text_color, parent=self.main_panel, outline=0) + w = Icon(box=Box.xywh(x, y, width_per_control, 18), text=name, text_color=text_color, parent=self.main_panel, outline=0) w.set_foreground(color) w.add_pedal() self.w_controls.append(w) @@ -745,9 +752,8 @@ def shorten_name(self, name, width): text = "" for x in name.lower().replace('_', '').replace('/', '').replace(' ', ''): test = text + x - test_bbox = self.small_font.getbbox(test) - test_size = test_bbox[2] - test_bbox[0] - if test_size >= width: + tw, _ = get_text_size(test, self.small_font) + if tw >= width: break text = test return text diff --git a/pistomp/ledstrip.py b/pistomp/ledstrip.py index 96bd64e0..994e65b5 100644 --- a/pistomp/ledstrip.py +++ b/pistomp/ledstrip.py @@ -14,7 +14,7 @@ # along with pi-stomp. If not, see . import matplotlib -from PIL import ImageColor +import pygame import common.util as Util import pistomp.category as Category @@ -74,7 +74,8 @@ def set_color(self, color): c = Util.DICT_GET(self.color_cache, color) if c is None: c = matplotlib.colors.cnames[color] - c = ImageColor.getcolor(c, "RGB") + pc = pygame.Color(c) + c = (pc.r, pc.g, pc.b) self.color_cache[color] = c except: c = color diff --git a/pyproject.toml b/pyproject.toml index 7b31c9eb..5af7d888 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "pyalsaaudio>=0.9; sys_platform == 'linux'", "websockets>=15.0.1", "gpiozero>=2.0; sys_platform == 'linux'", + "pygame-ce>=2.5.7", ] [project.optional-dependencies] diff --git a/tests/conftest.py b/tests/conftest.py index dd0e956b..1345eb7c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,11 @@ from unittest.mock import MagicMock import pytest -from PIL import Image, ImageFont + +# Initialize pygame headlessly before any uilib import so SDL is ready. +from uilib._pygame_init import init as _pg_init +_pg_init() +import pygame from uilib.panel import LcdBase @@ -45,29 +49,6 @@ sys.modules[_mod] = MagicMock() -# --------------------------------------------------------------------------- -# Force consistent font rendering across platforms (Linux / MacOS) -# --------------------------------------------------------------------------- - - -_FONTS_DIR = PROJECT_ROOT / "fonts" - - -@pytest.fixture(autouse=True) -def force_basic_layout(monkeypatch): - original = ImageFont.truetype - - def patched(font, size=10, **kwargs): - if isinstance(font, str) and not Path(font).is_absolute() and not Path(font).exists(): - candidate = _FONTS_DIR / font - if candidate.exists(): - font = str(candidate) - kwargs["layout_engine"] = ImageFont.Layout.BASIC - return original(font, size, **kwargs) - - monkeypatch.setattr(ImageFont, "truetype", patched) - - # --------------------------------------------------------------------------- # Snapshot helpers # --------------------------------------------------------------------------- @@ -84,29 +65,36 @@ def snapshot_update(request): return request.config.getoption("--snapshot-update") -def assert_snapshot(image: Image.Image, name: str, *, update: bool = False): +def _surface_to_rgb_bytes(surface: pygame.Surface) -> tuple[bytes, tuple[int, int]]: + rgb = pygame.image.tobytes(surface, "RGB") + return rgb, surface.get_size() + + +def assert_snapshot(surface: pygame.Surface, name: str, *, update: bool = False): path = _SNAPSHOT_DIR / f"{name}.png" - rgb = image.convert("RGB") + rgb_bytes, size = _surface_to_rgb_bytes(surface) if update or not path.exists(): path.parent.mkdir(parents=True, exist_ok=True) - rgb.save(path) + # Use pygame to write the PNG so reads and writes share the same encoder. + rgb_surface = pygame.image.frombytes(rgb_bytes, size, "RGB") + pygame.image.save(rgb_surface, str(path)) return - expected = Image.open(path).convert("RGB") - assert rgb.tobytes() == expected.tobytes(), f"Snapshot mismatch: {name} (re-run with --snapshot-update to accept)" + expected_surface = pygame.image.load(str(path)).convert(24) + expected_bytes = pygame.image.tobytes(expected_surface, "RGB") + assert rgb_bytes == expected_bytes, ( + f"Snapshot mismatch: {name} (re-run with --snapshot-update to accept)" + ) @pytest.fixture def snapshot(request, fake_lcd, snapshot_update): """Assert the latest LCD frame matches a stored PNG snapshot. - Path is auto-derived from the test file and function name so no manual - string is needed. Call snapshot() for an auto-numbered frame or - snapshot("label") for a named one. Re-use the same label to assert the - screen returned to an earlier state. + Path is auto-derived from the test file and function name. """ counter = [0] rel = Path(request.fspath).relative_to(_TESTS_DIR) - module = str(rel.with_suffix("")) # e.g. "v3/test_startup" + module = str(rel.with_suffix("")) test = request.node.name def _assert(suffix=None): @@ -125,7 +113,7 @@ def _assert(suffix=None): class FakeLcd(LcdBase): def __init__(self): - self.frames: list[Image.Image] = [] + self.frames: list[pygame.Surface] = [] def dimensions(self): return (320, 240) @@ -136,8 +124,12 @@ def default_format(self): def clear(self): pass - def update(self, image: Image.Image, box=None): - self.frames.append(image.copy()) + def update(self, surface: pygame.Surface, box=None): + # Always capture a 24-bit RGB snapshot so per-frame format never drifts. + size = surface.get_size() + rgb_bytes = pygame.image.tobytes(surface, "RGB") + snap = pygame.image.frombytes(rgb_bytes, size, "RGB") + self.frames.append(snap) def update_bypass(self, enabled: bool, latched: bool): pass 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 1420ade2..5e0fdeea 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 2e5cea5c..3bd30737 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 fbf26909..1803041d 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_splash_snapshot/0.png b/tests/snapshots/test_lcd320x240/test_splash_snapshot/0.png index 363989f6..6e1901a5 100644 Binary files a/tests/snapshots/test_lcd320x240/test_splash_snapshot/0.png and b/tests/snapshots/test_lcd320x240/test_splash_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 09770855..4a2ff030 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_snapshot/0.png b/tests/snapshots/test_lcd320x240/test_tap_tempo_snapshot/0.png index 6784fa85..d48d26e4 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 26574199..20710f88 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 c7a7c5fd..9c4b06f3 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/0.png b/tests/snapshots/test_lcd320x240/test_wifi_menu_snapshot/0.png index e80fc87d..8569ede7 100644 Binary files a/tests/snapshots/test_lcd320x240/test_wifi_menu_snapshot/0.png and b/tests/snapshots/test_lcd320x240/test_wifi_menu_snapshot/0.png differ diff --git a/tests/snapshots/test_virtual_container/test_initial_render/initial.png b/tests/snapshots/test_virtual_container/test_initial_render/initial.png new file mode 100644 index 00000000..cb3be334 Binary files /dev/null and b/tests/snapshots/test_virtual_container/test_initial_render/initial.png differ diff --git a/tests/snapshots/test_virtual_container/test_scroll_back_and_forth/0.png b/tests/snapshots/test_virtual_container/test_scroll_back_and_forth/0.png new file mode 100644 index 00000000..54d1d040 Binary files /dev/null and b/tests/snapshots/test_virtual_container/test_scroll_back_and_forth/0.png differ diff --git a/tests/snapshots/test_virtual_container/test_scroll_back_and_forth/1.png b/tests/snapshots/test_virtual_container/test_scroll_back_and_forth/1.png new file mode 100644 index 00000000..de905b49 Binary files /dev/null and b/tests/snapshots/test_virtual_container/test_scroll_back_and_forth/1.png differ diff --git a/tests/snapshots/test_virtual_container/test_scroll_back_and_forth/2.png b/tests/snapshots/test_virtual_container/test_scroll_back_and_forth/2.png new file mode 100644 index 00000000..426c31bc Binary files /dev/null and b/tests/snapshots/test_virtual_container/test_scroll_back_and_forth/2.png differ diff --git a/tests/snapshots/test_virtual_container/test_scroll_back_and_forth/3.png b/tests/snapshots/test_virtual_container/test_scroll_back_and_forth/3.png new file mode 100644 index 00000000..9949f6d0 Binary files /dev/null and b/tests/snapshots/test_virtual_container/test_scroll_back_and_forth/3.png differ diff --git a/tests/snapshots/test_virtual_container/test_scroll_back_and_forth/4.png b/tests/snapshots/test_virtual_container/test_scroll_back_and_forth/4.png new file mode 100644 index 00000000..c8d80cc8 Binary files /dev/null and b/tests/snapshots/test_virtual_container/test_scroll_back_and_forth/4.png differ diff --git a/tests/snapshots/test_virtual_container/test_scroll_shows_later_items/initial.png b/tests/snapshots/test_virtual_container/test_scroll_shows_later_items/initial.png new file mode 100644 index 00000000..cb3be334 Binary files /dev/null and b/tests/snapshots/test_virtual_container/test_scroll_shows_later_items/initial.png differ diff --git a/tests/snapshots/test_virtual_container/test_scroll_shows_later_items/scrolled_to_last.png b/tests/snapshots/test_virtual_container/test_scroll_shows_later_items/scrolled_to_last.png new file mode 100644 index 00000000..8a2fd0f9 Binary files /dev/null and b/tests/snapshots/test_virtual_container/test_scroll_shows_later_items/scrolled_to_last.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 fd2de71f..21b9290f 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 fd2de71f..21b9290f 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_parameter_edit/param_closed.png b/tests/snapshots/v3/test_plugins/test_v3_parameter_edit/param_closed.png index afe308fb..0106c5ce 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 9a3920ef..a90e33d0 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 afe308fb..0106c5ce 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 e9ae75ad..237eefc4 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 c981e7f6..0c7e6da0 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_preset_change_plugin_update/0.png b/tests/snapshots/v3/test_plugins/test_v3_preset_change_plugin_update/0.png index 3c762360..8bb5893d 100644 Binary files a/tests/snapshots/v3/test_plugins/test_v3_preset_change_plugin_update/0.png and b/tests/snapshots/v3/test_plugins/test_v3_preset_change_plugin_update/0.png differ diff --git a/tests/snapshots/v3/test_plugins/test_v3_toggle_plugin_bypass_direct/0.png b/tests/snapshots/v3/test_plugins/test_v3_toggle_plugin_bypass_direct/0.png index 95e3568a..b160e325 100644 Binary files a/tests/snapshots/v3/test_plugins/test_v3_toggle_plugin_bypass_direct/0.png and b/tests/snapshots/v3/test_plugins/test_v3_toggle_plugin_bypass_direct/0.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 06bce87d..b62a2da7 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 06bce87d..b62a2da7 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 9b6c96d7..82f042a4 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 cd72ffaa..68b27d37 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 ba358de3..9ded59b0 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 b6033311..dd781f94 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 cd8b3a63..791e9435 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 06bce87d..b62a2da7 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_paint/test_v3_wifi_ssid_entry/main.png b/tests/snapshots/v3/test_wifi_paint/test_v3_wifi_ssid_entry/main.png new file mode 100644 index 00000000..b62a2da7 Binary files /dev/null and b/tests/snapshots/v3/test_wifi_paint/test_v3_wifi_ssid_entry/main.png differ diff --git a/tests/snapshots/v3/test_wifi_paint/test_v3_wifi_ssid_entry/ssid_editor.png b/tests/snapshots/v3/test_wifi_paint/test_v3_wifi_ssid_entry/ssid_editor.png new file mode 100644 index 00000000..01b7db41 Binary files /dev/null and b/tests/snapshots/v3/test_wifi_paint/test_v3_wifi_ssid_entry/ssid_editor.png differ diff --git a/tests/snapshots/v3/test_wifi_paint/test_v3_wifi_ssid_entry/wifi_dialog.png b/tests/snapshots/v3/test_wifi_paint/test_v3_wifi_ssid_entry/wifi_dialog.png new file mode 100644 index 00000000..d1091d1b Binary files /dev/null and b/tests/snapshots/v3/test_wifi_paint/test_v3_wifi_ssid_entry/wifi_dialog.png differ diff --git a/tests/snapshots/v3/test_wifi_paint/test_v3_wifi_ssid_entry/wifi_menu.png b/tests/snapshots/v3/test_wifi_paint/test_v3_wifi_ssid_entry/wifi_menu.png new file mode 100644 index 00000000..f99fb572 Binary files /dev/null and b/tests/snapshots/v3/test_wifi_paint/test_v3_wifi_ssid_entry/wifi_menu.png differ diff --git a/tests/test_cache_valid.py b/tests/test_cache_valid.py new file mode 100644 index 00000000..9a5a83fa --- /dev/null +++ b/tests/test_cache_valid.py @@ -0,0 +1,409 @@ +""" +Tests for ContainerWidget._dirty_region accumulation and lazy-rebuild semantics. + +Contracts verified: + 1. Dirty-region transitions — set on init/invalidation, cleared on rebuild + 2. Cache-hit skips rebuild — no child _draw calls when dirty_region is None + 3. Rebuild pixel parity — a stale cache rebuilt on demand produces the same + pixels as a freshly-painted one + 4. Small-clip refresh stays cheap — Widget.refresh(small_box) confines the + parent's next rebuild to only the children that intersect that box +""" + +import pygame + +from uilib.box import Box +from uilib.paint import PaintContext +from uilib.container import ContainerWidget +from uilib.widget import Widget + + +W, H = 100, 60 + + +class _ColorWidget(Widget): + def __init__(self, color, **kwargs): + self.color: tuple[int, int, int] = color + super().__init__(**kwargs) + + def _draw(self, ctx): + ctx.fill(self.color) + + +def _container(w=W, h=H): + return ContainerWidget(box=Box.xywh(0, 0, w, h)) + + +def _render(container, w=W, h=H): + """Blit container into a fresh surface and return it.""" + surf = pygame.Surface((w, h)) + surf.fill((128, 128, 128)) + ctx = PaintContext(surf, Box.xywh(0, 0, w, h)) + container.do_draw(ctx, Box.xywh(0, 0, w, h)) + return surf + + +def _bytes(surf): + return pygame.image.tobytes(surf, "RGB") + + +def _force_dirty(c): + """Mark a container fully stale (test helper for forcing a rebuild).""" + c._dirty_region = c._content_bounds() + + +# --------------------------------------------------------------------------- +# 1. Dirty-region transitions +# --------------------------------------------------------------------------- + + +class TestDirtyRegionTransitions: + def test_initial_full_dirty(self): + c = _container() + assert c._dirty_region == c._content_bounds() + + def test_clean_after_nonvirtual_refresh(self): + c = _container() + c.refresh() + assert c._dirty_region is None + + def test_clean_after_virtual_refresh(self): + c = ContainerWidget(box=Box.xywh(0, 0, W, H), virtual=True, content_height=H * 3) + c.refresh() + assert c._dirty_region is None + + def test_dirty_after_child_attach(self): + c = _container() + c.refresh() + assert c._dirty_region is None + _ColorWidget(color=(255, 0, 0), box=Box.xywh(0, 0, 10, 10), parent=c) + assert c._dirty_region is not None + + def test_dirty_after_child_detach(self): + c = _container() + leaf = _ColorWidget(color=(255, 0, 0), box=Box.xywh(0, 0, 10, 10), parent=c) + c.refresh() + assert c._dirty_region is None + leaf.detach() + assert c._dirty_region is not None + + def test_dirty_after_setup_realloc(self): + c = _container(W, H) + c.refresh() + assert c._dirty_region is None + c.set_box(Box.xywh(0, 0, W + 10, H + 10), refresh=False) + c._setup() + assert c._dirty_region is not None + + def test_invalidation_bubbles_to_ancestor(self): + outer = _container() + inner = ContainerWidget(box=Box.xywh(0, 0, W, H), parent=outer) + outer.refresh() + assert outer._dirty_region is None + assert inner._dirty_region is None + + _ColorWidget(color=(0, 255, 0), box=Box.xywh(0, 0, 10, 10), parent=inner) + + assert inner._dirty_region is not None + assert outer._dirty_region is not None + + def test_invalidation_bubbles_through_panelstack(self): + from uilib.panel import PanelStack + from tests.conftest import FakeLcd + + lcd = FakeLcd() + stack = PanelStack(lcd) + + child = ContainerWidget(box=Box.xywh(0, 0, 50, 50), parent=stack) + child.refresh() + assert child._dirty_region is None + + _ColorWidget(color=(0, 0, 255), box=Box.xywh(0, 0, 10, 10), parent=child) + assert child._dirty_region is not None + assert stack._dirty_region is not None + + def test_disjoint_invalidations_union_into_bbox(self): + c = _container() + c.refresh() + assert c._dirty_region is None + c._invalidate_cache(Box.xywh(0, 0, 10, 10)) + c._invalidate_cache(Box.xywh(80, 50, 10, 10)) + assert c._dirty_region == Box.xywh(0, 0, 90, 60) + + +# --------------------------------------------------------------------------- +# 2. Cache-hit skips rebuild +# --------------------------------------------------------------------------- + + +class TestCacheHitSkipsRebuild: + def _spy_container(self): + c = _container() + COLORS = [(255, 0, 0), (0, 255, 0), (0, 0, 255)] + leaves = [] + for i, color in enumerate(COLORS): + w = _ColorWidget(color=color, box=Box.xywh(i * 30, 0, 30, H), parent=c) + leaves.append(w) + c.refresh() + assert c._dirty_region is None + + counts = {i: 0 for i in range(len(leaves))} + for i, w in enumerate(leaves): + orig = w._draw + + def make_spy(orig, idx): + def _spy(ctx): + counts[idx] += 1 + orig(ctx) + + return _spy + + w._draw = make_spy(orig, i) + + return c, leaves, counts + + def test_full_clip_no_child_draw(self): + c, _, counts = self._spy_container() + _render(c) + assert all(v == 0 for v in counts.values()), ( + f"Expected no child _draw calls on cache hit (full clip), got {counts}" + ) + + def test_partial_clip_no_child_draw(self): + c, _, counts = self._spy_container() + surf = pygame.Surface((W, H)) + surf.fill((128, 128, 128)) + partial_clip = Box.xywh(0, 0, W // 2, H) + ctx = PaintContext(surface=surf, clip=partial_clip) + c.do_draw(ctx, Box.xywh(0, 0, W, H)) + assert all(v == 0 for v in counts.values()), ( + f"Expected no child _draw calls on cache hit (partial clip), got {counts}" + ) + + def test_cache_miss_does_invoke_child_draw(self): + c, _, counts = self._spy_container() + _force_dirty(c) + _render(c) + assert all(v == 1 for v in counts.values()), f"Expected one _draw per child on cache miss, got {counts}" + + +# --------------------------------------------------------------------------- +# 3. Rebuild pixel parity — stale-cache rebuild matches fresh paint +# --------------------------------------------------------------------------- + + +class TestRebuildPixelParity: + def test_initial_render_blit_equals_rebuild(self): + c = _container() + _ColorWidget(color=(200, 100, 50), box=Box.xywh(0, 0, 50, 30), parent=c) + _ColorWidget(color=(50, 150, 200), box=Box.xywh(50, 30, 50, 30), parent=c) + c.refresh() + + cached = _render(c) + _force_dirty(c) + rebuilt = _render(c) + + assert _bytes(cached) == _bytes(rebuilt), "Cache blit diverged from rebuild on initial render" + + def test_leaf_color_change_rebuild_parity(self): + outer = _container() + inner = ContainerWidget(box=Box.xywh(0, 0, W, H), parent=outer) + leaf = _ColorWidget(color=(255, 0, 0), box=Box.xywh(10, 10, 40, 20), parent=inner) + + outer.refresh() + leaf.color = (0, 0, 255) + leaf.refresh() + + lazy_rebuild = _render(outer) + + _force_dirty(outer) + _force_dirty(inner) + forced_rebuild = _render(outer) + + assert _bytes(lazy_rebuild) == _bytes(forced_rebuild) + + def test_leaf_hide_rebuild_parity(self): + outer = _container() + inner = ContainerWidget(box=Box.xywh(0, 0, W, H), parent=outer) + leaf = _ColorWidget(color=(0, 200, 0), box=Box.xywh(5, 5, 30, 20), parent=inner) + _ColorWidget(color=(200, 0, 0), box=Box.xywh(40, 5, 30, 20), parent=inner) + + outer.refresh() + leaf.hide() + + lazy_rebuild = _render(outer) + + _force_dirty(outer) + _force_dirty(inner) + forced_rebuild = _render(outer) + + assert _bytes(lazy_rebuild) == _bytes(forced_rebuild) + + def test_partial_scroll_rebuild_parity(self): + outer = _container() + virtual = ContainerWidget( + box=Box.xywh(0, 0, W, H), + virtual=True, + content_height=H * 3, + parent=outer, + ) + ITEM_H = H // 3 + COLORS = [ + (255, 0, 0), (0, 255, 0), (0, 0, 255), + (255, 255, 0), (0, 255, 255), (255, 0, 255), + (128, 128, 0), (0, 128, 128), (128, 0, 128), + ] + for i, color in enumerate(COLORS): + _ColorWidget(color=color, box=Box.xywh(0, i * ITEM_H, W, ITEM_H), parent=virtual) + + outer.refresh() + virtual.scroll((0, H)) + + lazy_rebuild = _render(outer) + + _force_dirty(outer) + forced_rebuild = _render(outer) + + assert _bytes(lazy_rebuild) == _bytes(forced_rebuild) + + +# --------------------------------------------------------------------------- +# 4. Small-clip refresh stays cheap +# --------------------------------------------------------------------------- + + +class TestSmallClipRefreshIsCheap: + """A leaf widget that calls Widget.refresh(box=small_box) for frequent + point updates (e.g. an animated VU meter, a single tab toggle) must not + force a full sibling re-paint on the parent's next render. The parent's + dirty_region scopes the rebuild to only the children that overlap.""" + + def _make(self): + """Three side-by-side leaves on a parent that's already cached.""" + c = _container() + leaves = [ + _ColorWidget(color=(255, 0, 0), box=Box.xywh(0, 0, 30, H), parent=c), + _ColorWidget(color=(0, 255, 0), box=Box.xywh(30, 0, 30, H), parent=c), + _ColorWidget(color=(0, 0, 255), box=Box.xywh(60, 0, 30, H), parent=c), + ] + # Establish the cache. + c.refresh() + assert c._dirty_region is None + + counts = {i: 0 for i in range(len(leaves))} + for i, w in enumerate(leaves): + orig = w._draw + + def make_spy(orig, idx): + def _spy(ctx): + counts[idx] += 1 + orig(ctx) + + return _spy + + w._draw = make_spy(orig, i) + return c, leaves, counts + + def test_widget_refresh_box_marks_only_that_rect_dirty(self): + c, leaves, _ = self._make() + small = Box.xywh(2, 2, 5, 5) + leaves[0].refresh(box=small) + # Leaf painted into c.surface directly (clip-respecting). Outer cache + # was already clean — propagate_dirty from c bubbles to its parent + # (None here), so c.dirty_region stays None. The leaf itself doesn't + # accumulate region in c — only propagate_dirty does. Wrap c in a + # parent to observe accumulation. + assert c._dirty_region is None # c is its own paint target; no upward dirty + + def test_parent_rebuild_scoped_to_dirty_rect(self): + """Widget.refresh(box) on a leaf invalidates the grandparent's cache + with that rect; the grandparent's next render rebuilds only via the + children whose boxes intersect the rect.""" + outer = _container() + inner = ContainerWidget(box=Box.xywh(0, 0, W, H), parent=outer) + leaves = [ + _ColorWidget(color=(255, 0, 0), box=Box.xywh(0, 0, 30, H), parent=inner), + _ColorWidget(color=(0, 255, 0), box=Box.xywh(30, 0, 30, H), parent=inner), + _ColorWidget(color=(0, 0, 255), box=Box.xywh(60, 0, 30, H), parent=inner), + ] + outer.refresh() + assert outer._dirty_region is None + + # Spy on leaf draws AFTER the warm-up render. + counts = {i: 0 for i in range(len(leaves))} + for i, w in enumerate(leaves): + orig = w._draw + + def make_spy(orig, idx): + def _spy(ctx): + counts[idx] += 1 + orig(ctx) + + return _spy + + w._draw = make_spy(orig, i) + + # A tiny in-leaf-0 redraw. + small = Box.xywh(2, 2, 5, 5) + leaves[0].refresh(box=small) + + # outer's dirty_region must be exactly the small rect (no sibling + # contribution): leaf0.box + inner.box offsets = same rect since both + # are at (0,0). + assert outer._dirty_region == small + + # Now render outer. Cache-miss inside outer scopes the rebuild to + # `small`. Only inner intersects, so inner.do_draw fires once (cache + # hit ⇒ pure blit, no child _draw). Leaves 1 and 2 must NOT see _draw. + _render(outer) + # leaf 0 painted once during refresh(box=small) (direct paint into + # inner.surface). Outer's rebuild sees inner as a cache hit (inner's + # own dirty_region is None) ⇒ pure blit, no child _draw. + assert counts[0] == 1, f"leaf 0 painted exactly once via refresh, got {counts}" + assert counts[1] == 0, f"leaf 1 outside dirty rect — must not re-paint, got {counts}" + assert counts[2] == 0, f"leaf 2 outside dirty rect — must not re-paint, got {counts}" + + def test_repeated_small_refreshes_dont_force_full_rebuild(self): + """Many disjoint small refreshes accumulate into a bounding box but + still skip children that fall entirely outside that box.""" + outer = _container(200, 60) + inner = ContainerWidget(box=Box.xywh(0, 0, 200, 60), parent=outer) + leaves = [ + _ColorWidget(color=(255, 0, 0), box=Box.xywh(0, 0, 50, 60), parent=inner), + _ColorWidget(color=(0, 255, 0), box=Box.xywh(50, 0, 50, 60), parent=inner), + _ColorWidget(color=(0, 0, 255), box=Box.xywh(100, 0, 50, 60), parent=inner), + _ColorWidget(color=(255, 255, 0), box=Box.xywh(150, 0, 50, 60), parent=inner), + ] + outer.refresh() + surf = pygame.Surface((200, 60)) + surf.fill((0, 0, 0)) + outer.do_draw(PaintContext(surf, Box.xywh(0, 0, 200, 60)), Box.xywh(0, 0, 200, 60)) + + counts = {i: 0 for i in range(len(leaves))} + for i, w in enumerate(leaves): + orig = w._draw + + def make_spy(orig, idx): + def _spy(ctx): + counts[idx] += 1 + orig(ctx) + + return _spy + + w._draw = make_spy(orig, i) + + # Refresh tiny rects inside leaves 0 and 1 only. + leaves[0].refresh(box=Box.xywh(2, 2, 5, 5)) + leaves[1].refresh(box=Box.xywh(52, 2, 5, 5)) + + # Outer's dirty_region is the bbox covering both refresh rects: + # (2,2)-(7,7) ∪ (52,2)-(57,7) = (2,2)-(57,7). + assert outer._dirty_region == Box(2, 2, 57, 7) + + outer.do_draw(PaintContext(surf, Box.xywh(0, 0, 200, 60)), Box.xywh(0, 0, 200, 60)) + + # Leaves 0 and 1 already wrote into inner directly (counts stay 0 — + # spies attached after the refreshes). Leaves 2 and 3 must never be + # asked to repaint because they fall outside (57,7). + assert counts[2] == 0, f"leaf 2 outside dirty bbox — must not re-paint, got {counts}" + assert counts[3] == 0, f"leaf 3 outside dirty bbox — must not re-paint, got {counts}" diff --git a/tests/test_paint_context.py b/tests/test_paint_context.py new file mode 100644 index 00000000..e3bf7add --- /dev/null +++ b/tests/test_paint_context.py @@ -0,0 +1,240 @@ +""" +Unit tests for paint-context drawing logic: + - Box.contains + - Widget._draw_erase (safe-interior vs full-frame erase) + - ContainerWidget.propagate_dirty scroll-offset translation + - Relative-coord API contract + - SDL clip containment (formerly slow-path scissor) +""" + +import pytest +import pygame + +from uilib.box import Box +from uilib.paint import PaintContext +from uilib.container import ContainerWidget +from uilib.widget import Widget + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _container(w=100, h=100, outline_radius=None, **kwargs): + box = Box(0, 0, w, h) + return ContainerWidget(box=box, outline_radius=outline_radius, **kwargs) + + +def _surface(w, h, color=(255, 255, 255), alpha=False): + flags = pygame.SRCALPHA if alpha else 0 + surf = pygame.Surface((w, h), flags) + surf.fill(color) + return surf + + +# --------------------------------------------------------------------------- +# Box.contains +# --------------------------------------------------------------------------- + + +class TestBoxContains: + def test_identical_boxes(self): + b = Box(10, 10, 50, 50) + assert b.contains(b) + + def test_inner_fully_inside(self): + outer = Box(0, 0, 100, 100) + inner = Box(10, 10, 90, 90) + assert outer.contains(inner) + + def test_touching_edge_is_contained(self): + outer = Box(0, 0, 100, 100) + edge = Box(0, 0, 100, 50) + assert outer.contains(edge) + + def test_partial_overlap_is_not_contained(self): + a = Box(0, 0, 60, 60) + b = Box(40, 40, 100, 100) + assert not a.contains(b) + + def test_larger_box_not_contained(self): + inner = Box(10, 10, 90, 90) + outer = Box(0, 0, 100, 100) + assert not inner.contains(outer) + + def test_empty_box_contained(self): + outer = Box(0, 0, 100, 100) + empty = Box(50, 50, 50, 50) + assert outer.contains(empty) + + +# --------------------------------------------------------------------------- +# Widget._draw_erase +# --------------------------------------------------------------------------- + + +class TestDrawErase: + """_draw_erase erases with a plain rect when the dirty region fits in the + safe interior; falls back to a rounded rect when clip == bounds.""" + + def _erase_and_read(self, clip, frame, outline_radius=None): + surf = _surface(100, 100, (255, 255, 255)) + ctx = PaintContext(surf, clip, frame=frame) + + w = Widget(box=frame) + w.outline_radius = outline_radius + w.bkgnd_color = (0, 0, 0) + + w._draw_erase(ctx) + return surf + + def test_no_radius_erases_only_clip(self): + frame = Box(0, 0, 100, 100) + clip = Box(10, 10, 50, 50) + surf = self._erase_and_read(clip, frame, outline_radius=None) + assert surf.get_at((20, 20))[:3] == (0, 0, 0) + assert surf.get_at((80, 80))[:3] == (255, 255, 255) + + def test_radius_safe_interior_erases_only_clip(self): + frame = Box(0, 0, 100, 100) + clip = Box(20, 20, 80, 80) + surf = self._erase_and_read(clip, frame, outline_radius=10) + assert surf.get_at((50, 50))[:3] == (0, 0, 0) + assert surf.get_at((5, 5))[:3] == (255, 255, 255) + + def test_radius_partial_clip_erases_only_intersection(self): + frame = Box(0, 0, 100, 100) + clip = Box(0, 0, 20, 20) + surf = self._erase_and_read(clip, frame, outline_radius=10) + assert surf.get_at((10, 10))[:3] == (0, 0, 0) + assert surf.get_at((50, 50))[:3] == (255, 255, 255) + + def test_radius_full_frame_uses_rounded_rectangle(self): + frame = Box(0, 0, 100, 100) + surf = self._erase_and_read(frame, frame, outline_radius=10) + assert surf.get_at((50, 50))[:3] == (0, 0, 0) + # Absolute corner pixel NOT erased (rounded rect leaves it) + assert surf.get_at((0, 0))[:3] == (255, 255, 255) + + +# --------------------------------------------------------------------------- +# propagate_dirty scroll offset +# --------------------------------------------------------------------------- + + +class TestPropagateDirtyScrollOffset: + def test_no_scroll_translates_by_box_position(self): + received = [] + + class CapturingParent(Widget): + def propagate_dirty(self, clip): + received.append(clip) + + parent = CapturingParent(box=Box(0, 0, 200, 200)) + c = _container(w=100, h=100) + c.box = Box(20, 30, 120, 130) + c.parent = parent + + c.propagate_dirty(Box(10, 10, 50, 50)) + + assert len(received) == 1 + assert received[0] == Box(30, 40, 70, 80) + + def test_scroll_offset_shifts_propagated_clip(self): + received = [] + + class CapturingParent(Widget): + def propagate_dirty(self, clip): + received.append(clip) + + parent = CapturingParent(box=Box(0, 0, 200, 200)) + c = _container(w=100, h=100) + c.box = Box(20, 30, 120, 130) + c.parent = parent + c.offset = (5, 10) + + c.propagate_dirty(Box(10, 10, 50, 50)) + + assert len(received) == 1 + assert received[0] == Box(25, 30, 65, 70) + + +# --------------------------------------------------------------------------- +# Relative-coordinate API contract +# --------------------------------------------------------------------------- + + +class _RelDrawWidget(Widget): + def _draw(self, ctx): + ctx.fill((255, 255, 255)) + ctx.draw_rectangle(Box(0, 0, 1, 1), fill=(255, 0, 0)) + ctx.draw_rectangle(Box(ctx.width - 1, ctx.height - 1, ctx.width, ctx.height), fill=(0, 255, 0)) + + +class TestRelativeCoords: + @pytest.mark.parametrize( + "frame", + [ + Box(0, 0, 20, 20), + Box(50, 30, 70, 50), + Box(99, 99, 119, 119), + ], + ) + def test_origin_marker_lands_at_frame_topleft(self, frame): + surf = _surface(200, 200, (0, 0, 0)) + ctx = PaintContext(surf, Box(0, 0, 200, 200)) + + w = _RelDrawWidget(box=frame) + w.bkgnd_color = (0, 0, 0) + w.fgnd_color = (255, 255, 255) + w.do_draw(ctx, frame) + + assert surf.get_at(frame.topleft)[:3] == (255, 0, 0) + far = (frame.x1 - 1, frame.y1 - 1) + if 0 <= far[0] < 200 and 0 <= far[1] < 200: + assert surf.get_at(far)[:3] == (0, 255, 0) + if frame.x0 > 0: + assert surf.get_at((frame.x0 - 1, frame.y0))[:3] == (0, 0, 0) + + +# --------------------------------------------------------------------------- +# SDL clip containment (replaces former slow-path BufferPool scissor) +# --------------------------------------------------------------------------- + + +class _SloppyWidget(Widget): + """Intentionally draws past its own frame to test that the SDL clip + (set by PaintContext.painting()) discards out-of-clip pixels.""" + + def _draw(self, ctx): + ctx.draw_rectangle( + Box(-10, -10, ctx.width + 10, ctx.height + 10), + fill=(255, 0, 0), + ) + + +class TestSloppyDrawContainment: + def test_sdl_clip_scissors_oversized_draw(self): + surf = _surface(200, 200, (0, 0, 0), alpha=True) + # Clip strictly smaller than frame: anything outside clip must drop. + frame = Box(50, 50, 150, 150) + clip = Box(60, 60, 140, 140) + ctx = PaintContext(surf, clip) + + w = _SloppyWidget(box=frame) + w.bkgnd_color = (0, 0, 0, 0) + w.fgnd_color = (255, 255, 255) + w.do_draw(ctx, frame) + + # Inside clip: red + assert surf.get_at((100, 100))[:3] == (255, 0, 0) + + # Inside frame, outside clip: SDL clip dropped it + assert surf.get_at((55, 100))[:3] == (0, 0, 0) + assert surf.get_at((145, 100))[:3] == (0, 0, 0) + assert surf.get_at((100, 55))[:3] == (0, 0, 0) + + # Well outside frame: untouched + assert surf.get_at((10, 10))[:3] == (0, 0, 0) + assert surf.get_at((190, 190))[:3] == (0, 0, 0) diff --git a/tests/test_virtual_container.py b/tests/test_virtual_container.py new file mode 100644 index 00000000..83cfa4f7 --- /dev/null +++ b/tests/test_virtual_container.py @@ -0,0 +1,515 @@ +""" +Unit tests for virtual container (JIT paint) behavior. + +A ContainerWidget(virtual=True, content_height=N) keeps a "tall" backing surface +sized to N pixels. Only children whose boxes intersect the current viewport +are painted; others are marked dirty and deferred until they scroll into view. + +Contracts verified here: + - Tall surface creation + - refresh() gates on viewport: visible → painted, off-screen → dirty + - Widget.refresh() marks dirty when off-screen; paints when on-screen + - scroll() paints dirty/unpainted children that scroll into view + - scroll() blits the correct tall-surface slice (pixel-level check) + - set_selected + scroll_into_view ordering (mutation before blit) + - Dirty-state transitions: never-painted → clean → dirty → clean +""" + +import pytest +import pygame + +from uilib.box import Box +from uilib.paint import PaintContext +from uilib.container import ContainerWidget +from uilib.widget import Widget + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +VIEWPORT_W = 100 +VIEWPORT_H = 60 # shows 3 rows of 20px each +ITEM_H = 20 +N_ITEMS = 6 # 6 rows → content_height = 120 + + +def _virtual_container(n=N_ITEMS, item_h=ITEM_H, viewport_h=VIEWPORT_H): + content_h = n * item_h + box = Box.xywh(0, 0, VIEWPORT_W, viewport_h) + return ContainerWidget(box=box, virtual=True, content_height=content_h) + + +def _pix(surf, xy): + return tuple(surf.get_at(xy))[:3] + + +class _ColorWidget(Widget): + def __init__(self, color, **kwargs): + self.color = color + super().__init__(**kwargs) + + def _draw(self, ctx): + ctx.fill(self.color) + + +def _attach_items(container, n=N_ITEMS, item_h=ITEM_H): + items = [] + for i in range(n): + color = (i * 40, 0, 255 - i * 40) + w = _ColorWidget(color=color, box=Box.xywh(0, i * item_h, VIEWPORT_W, item_h), parent=container) + items.append((w, color)) + return items + + +# --------------------------------------------------------------------------- +# 1. Tall surface creation +# --------------------------------------------------------------------------- + + +class TestVirtualImageSize: + def test_surface_height_is_content_height(self): + c = _virtual_container() + assert c.surface is not None + assert c.surface.get_height() == N_ITEMS * ITEM_H + + def test_surface_width_matches_box(self): + c = _virtual_container() + assert c.surface is not None + assert c.surface.get_width() == VIEWPORT_W + + def test_non_virtual_surface_matches_box(self): + box = Box.xywh(0, 0, VIEWPORT_W, VIEWPORT_H) + c = ContainerWidget(box=box) + assert c.surface is not None + assert c.surface.get_height() == VIEWPORT_H + + +# --------------------------------------------------------------------------- +# 2. refresh() — viewport gating +# --------------------------------------------------------------------------- + + +class TestRefreshViewportGating: + def test_visible_items_painted_after_refresh(self): + c = _virtual_container() + items = _attach_items(c) + c.refresh() + + visible_count = VIEWPORT_H // ITEM_H + for i, (w, _) in enumerate(items): + if i < visible_count: + assert w._painted, f"item {i} should be painted" + assert not w._dirty, f"item {i} should not be dirty" + else: + assert w._dirty, f"item {i} should be marked dirty" + assert not w._painted, f"item {i} should not be painted yet" + + def test_visible_item_pixels_land_in_tall_surface(self): + c = _virtual_container() + items = _attach_items(c) + c.refresh() + + visible_count = VIEWPORT_H // ITEM_H + for i, (w, color) in enumerate(items[:visible_count]): + sample_y = i * ITEM_H + ITEM_H // 2 + assert _pix(c.surface, (VIEWPORT_W // 2, sample_y)) == color, ( + f"item {i} color {color} not found at tall-surface y={sample_y}" + ) + + def test_off_screen_item_pixels_not_in_surface_background(self): + c = _virtual_container() + items = _attach_items(c) + c.refresh() + + for i, (w, color) in enumerate(items[VIEWPORT_H // ITEM_H :], start=VIEWPORT_H // ITEM_H): + sample_y = i * ITEM_H + ITEM_H // 2 + assert _pix(c.surface, (VIEWPORT_W // 2, sample_y)) != color, ( + f"item {i} color leaked into tall surface before scroll" + ) + + +# --------------------------------------------------------------------------- +# 3. Widget.refresh() — per-widget dirty / paint decision +# --------------------------------------------------------------------------- + + +class TestWidgetRefreshJIT: + def test_off_screen_widget_refresh_marks_dirty(self): + c = _virtual_container() + items = _attach_items(c) + c.refresh() + + off_screen = items[N_ITEMS - 1][0] + assert not off_screen._painted + + off_screen.refresh() + + assert off_screen._dirty + assert not off_screen._painted + + def test_on_screen_widget_refresh_paints(self): + c = _virtual_container() + items = _attach_items(c) + c.refresh() + + on_screen = items[0][0] + on_screen._painted = False + on_screen._dirty = True + + on_screen.refresh() + + assert on_screen._painted + assert not on_screen._dirty + + def test_off_screen_refresh_does_not_paint_pixels(self): + c = _virtual_container() + items = _attach_items(c) + c.refresh() + + idx = N_ITEMS - 1 + w, color = items[idx] + sample_y = idx * ITEM_H + ITEM_H // 2 + + before = _pix(c.surface, (VIEWPORT_W // 2, sample_y)) + w.refresh() + after = _pix(c.surface, (VIEWPORT_W // 2, sample_y)) + + assert before == after + + +# --------------------------------------------------------------------------- +# 4. scroll() — paints newly visible dirty children +# --------------------------------------------------------------------------- + + +class TestScrollPaintsNewlyVisible: + def test_scroll_paints_dirty_children(self): + c = _virtual_container() + items = _attach_items(c) + c.refresh() + + c.scroll((0, VIEWPORT_H)) + + newly_visible_start = VIEWPORT_H // ITEM_H + for i, (w, _) in enumerate(items): + if i >= newly_visible_start: + assert w._painted, f"item {i} should be painted after scroll" + assert not w._dirty, f"item {i} should be clean after scroll" + + def test_scroll_paints_correct_pixels_in_tall_surface(self): + c = _virtual_container() + items = _attach_items(c) + c.refresh() + + c.scroll((0, VIEWPORT_H)) + + for i, (w, color) in enumerate(items[VIEWPORT_H // ITEM_H :], start=VIEWPORT_H // ITEM_H): + sample_y = i * ITEM_H + ITEM_H // 2 + assert _pix(c.surface, (VIEWPORT_W // 2, sample_y)) == color, ( + f"item {i} color {color} not found at tall-surface y={sample_y} after scroll" + ) + + def test_scroll_skips_already_clean_children(self): + c = _virtual_container() + items = _attach_items(c) + c.refresh() + + draw_counts = {i: 0 for i in range(N_ITEMS)} + for i, (w, _) in enumerate(items): + + def make_counter(orig, item_i): + def _draw_counted(ctx): + draw_counts[item_i] += 1 + orig(ctx) + + return _draw_counted + + w._draw = make_counter(w._draw, i) + + c.scroll((0, VIEWPORT_H)) + + newly_visible_start = VIEWPORT_H // ITEM_H + for i in range(newly_visible_start, N_ITEMS): + assert draw_counts[i] == 1, f"item {i} should have been drawn once" + for i in range(newly_visible_start): + assert draw_counts[i] == 0, f"item {i} should not have been redrawn" + + +# --------------------------------------------------------------------------- +# 5. do_draw blits the correct tall-surface slice into the parent +# --------------------------------------------------------------------------- + + +def _parent_ctx(w=VIEWPORT_W, h=VIEWPORT_H, fill=(128, 128, 128)): + surf = pygame.Surface((w, h)) + surf.fill(fill) + ctx = PaintContext(surf, Box.xywh(0, 0, w, h)) + return surf, ctx + + +class TestDoDrawBlit: + def test_do_draw_shows_scrolled_content_in_parent(self): + c = _virtual_container() + items = _attach_items(c) + c.refresh() + c.scroll((0, VIEWPORT_H)) + + parent_surf, ctx = _parent_ctx() + c.do_draw(ctx, Box.xywh(0, 0, VIEWPORT_W, VIEWPORT_H)) + + _, item3_color = items[VIEWPORT_H // ITEM_H] + top_pixel = _pix(parent_surf, (VIEWPORT_W // 2, ITEM_H // 2)) + assert top_pixel == item3_color, f"Expected item3 color {item3_color} at top of parent, got {top_pixel}" + + _, item0_color = items[0] + assert top_pixel != item0_color + + def test_do_draw_no_scroll_shows_first_items(self): + c = _virtual_container() + items = _attach_items(c) + c.refresh() + + parent_surf, ctx = _parent_ctx() + c.do_draw(ctx, Box.xywh(0, 0, VIEWPORT_W, VIEWPORT_H)) + + _, item0_color = items[0] + top_pixel = _pix(parent_surf, (VIEWPORT_W // 2, ITEM_H // 2)) + assert top_pixel == item0_color + + +# --------------------------------------------------------------------------- +# 5b. Scroll-by-blit: scrolling back over already-painted children must not +# re-invoke child _draw, and do_draw on a virtual container is pure blit. +# --------------------------------------------------------------------------- + + +class TestScrollByBlitNoRebuild: + def _paint_all_and_spy(self, c, items): + c.refresh() + c.scroll((0, (N_ITEMS - VIEWPORT_H // ITEM_H) * ITEM_H)) + c.scroll((0, 0)) + + for w, _ in items: + assert w._painted and not w._dirty + + counts = {i: 0 for i in range(len(items))} + for i, (w, _) in enumerate(items): + + def make_spy(orig, idx): + def _spy(ctx): + counts[idx] += 1 + orig(ctx) + + return _spy + + w._draw = make_spy(w._draw, i) + + return counts + + def test_scroll_over_painted_children_does_no_child_draw(self): + c = _virtual_container() + items = _attach_items(c) + counts = self._paint_all_and_spy(c, items) + + c.scroll((0, VIEWPORT_H)) + + assert all(v == 0 for v in counts.values()), f"expected zero child redraws on scroll-over-painted, got {counts}" + + def test_do_draw_after_scroll_is_pure_blit(self): + c = _virtual_container() + items = _attach_items(c) + counts = self._paint_all_and_spy(c, items) + + c.scroll((0, VIEWPORT_H)) + parent_surf, ctx = _parent_ctx() + c.do_draw(ctx, Box.xywh(0, 0, VIEWPORT_W, VIEWPORT_H)) + + assert all(v == 0 for v in counts.values()), f"do_draw on virtual must not invoke child _draw; got {counts}" + + _, item3_color = items[VIEWPORT_H // ITEM_H] + top_pixel = _pix(parent_surf, (VIEWPORT_W // 2, ITEM_H // 2)) + assert top_pixel == item3_color + + +# --------------------------------------------------------------------------- +# 6. Pixel-level blit: scroll emits the right slice via propagate_dirty +# --------------------------------------------------------------------------- + + +class TestScrollBlit: + def test_viewport_slice_corresponds_to_scroll_offset(self): + received = [] + + class CapturingParent(Widget): + def propagate_dirty(self, clip): + received.append(clip.copy()) + + c = _virtual_container() + _attach_items(c) + c.refresh() + + c.box = Box.xywh(10, 5, VIEWPORT_W, VIEWPORT_H) + c.parent = CapturingParent(box=Box.xywh(0, 0, 200, 200)) + c.scroll((0, VIEWPORT_H)) + + assert received + last = received[-1] + expected = Box.xywh(10, 5, VIEWPORT_W, VIEWPORT_H) + assert last == expected + + +# --------------------------------------------------------------------------- +# 7. set_selected + scroll_into_view ordering +# --------------------------------------------------------------------------- + + +class TestSelectionScrollOrdering: + def test_selected_state_visible_after_scroll(self): + c = _virtual_container() + items = _attach_items(c) + c.refresh() + + last_w, _ = items[N_ITEMS - 1] + drawn_selected = [] + orig_draw = last_w._draw + + def _spy_draw(ctx): + drawn_selected.append(last_w.selected) + orig_draw(ctx) + + last_w._draw = _spy_draw + last_w.selectable = True + last_w.selected = True + + scrolled = last_w.scroll_into_view() + if scrolled: + assert drawn_selected + assert drawn_selected[-1] is True + + +# --------------------------------------------------------------------------- +# 8. Dirty-state transitions +# --------------------------------------------------------------------------- + + +class TestDirtyStateTransitions: + def test_initial_state_never_painted(self): + c = _virtual_container() + items = _attach_items(c) + for w, _ in items: + assert not w._painted + assert not w._dirty + + def test_after_refresh_visible_clean(self): + c = _virtual_container() + items = _attach_items(c) + c.refresh() + visible_count = VIEWPORT_H // ITEM_H + for w, _ in items[:visible_count]: + assert w._painted and not w._dirty + + def test_after_refresh_off_screen_dirty(self): + c = _virtual_container() + items = _attach_items(c) + c.refresh() + visible_count = VIEWPORT_H // ITEM_H + for w, _ in items[visible_count:]: + assert w._dirty and not w._painted + + def test_widget_refresh_on_screen_clears_dirty(self): + c = _virtual_container() + items = _attach_items(c) + c.refresh() + w = items[0][0] + w._dirty = True + w.refresh() + assert w._painted + assert not w._dirty + + def test_widget_refresh_off_screen_sets_dirty(self): + c = _virtual_container() + items = _attach_items(c) + c.refresh() + w = items[N_ITEMS - 1][0] + assert not w._painted + w.refresh() + assert w._dirty + assert not w._painted + + def test_scroll_into_view_clears_dirty(self): + c = _virtual_container() + items = _attach_items(c) + c.refresh() + w, _ = items[N_ITEMS - 1] + assert w._dirty + c.scroll((0, (N_ITEMS - 1) * ITEM_H)) + assert w._painted + assert not w._dirty + + +# --------------------------------------------------------------------------- +# 9. Menu snapshot — full PanelStack → LCD render path +# --------------------------------------------------------------------------- + + +@pytest.fixture +def ui_config(): + import os + from uilib.config import Config + + Config._instance = None + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + Config(os.path.join(project_root, "ui", "config.json")) + + +class TestMenuSnapshot: + _ITEM_NAMES = [f"Item {i}" for i in range(8)] + + def _make_stack(self, fake_lcd): + from uilib.panel import PanelStack + + return PanelStack(fake_lcd) + + def _make_menu(self, stack): + from uilib.menu import Menu + + menu = Menu( + items=[(name, None, None) for name in self._ITEM_NAMES], + max_height=100, + title="Test Menu", + dismiss_option=True, + ) + stack.push_panel(menu) + return menu + + def test_initial_render(self, fake_lcd, snapshot, ui_config): + stack = self._make_stack(fake_lcd) + self._make_menu(stack) + snapshot("initial") + + def test_scroll_shows_later_items(self, fake_lcd, snapshot, ui_config): + stack = self._make_stack(fake_lcd) + menu = self._make_menu(stack) + snapshot("initial") + + for _ in range(len(menu.sel_list) - 1): + menu.sel_next() + + snapshot("scrolled_to_last") + + def test_scroll_back_and_forth(self, fake_lcd, snapshot, ui_config): + stack = self._make_stack(fake_lcd) + menu = self._make_menu(stack) + + for _ in range(4): + menu.sel_next() + snapshot() + menu.sel_next() + snapshot() + menu.sel_next() + snapshot() + menu.sel_prev() + snapshot() + menu.sel_prev() + snapshot() diff --git a/tests/v3/test_wifi_paint.py b/tests/v3/test_wifi_paint.py new file mode 100644 index 00000000..4dda69c3 --- /dev/null +++ b/tests/v3/test_wifi_paint.py @@ -0,0 +1,39 @@ +"""Snapshot the WiFi menu / SSID-entry flow to catch paint-context regressions. + +The refactor/paint-context branch reworked widget `_draw(ctx, frame)` signatures. +The wifi dialog exercises a prompt-prefixed TextWidget plus a nested TextEditor +panel — areas the refactor touched — so snapshots at each level surface any +clip/frame coordinate mistakes. +""" + +from tests.types import SystemFixture +from uilib.misc import InputEvent + + +def test_v3_wifi_ssid_entry(v3_system: SystemFixture, snapshot): + handler = v3_system.handler + hw = v3_system.hw + + assert handler.current + assert handler.lcd + assert handler.wifi_manager + + handler.wifi_manager.get_ssid.return_value = "MyNet" + handler.wifi_manager.get_psk.return_value = "secret" + handler.wifi_status = {"hotspot_active": False, "wifi_connected": True} + + handler.lcd.link_data(handler.pedalboard_list, handler.current, hw.footswitches) + handler.lcd.draw_main_panel() + snapshot("main") + + handler.lcd.draw_wifi_menu(None, None) + snapshot("wifi_menu") + + handler.lcd.draw_wifi_dialog(None) + snapshot("wifi_dialog") + + # Click the SSID field to open the TextEditor (letter selector). + assert handler.lcd.w_wifi_ssid is not None + handler.lcd.w_wifi_ssid.input_event(InputEvent.CLICK) + handler.poll_lcd_updates() + snapshot("ssid_editor") diff --git a/ui/wifi_menu.py b/ui/wifi_menu.py index 8f9b06a4..110ae0ac 100644 --- a/ui/wifi_menu.py +++ b/ui/wifi_menu.py @@ -15,9 +15,10 @@ from typing import TYPE_CHECKING, Callable, NotRequired, Optional, Protocol, TypedDict, cast -from PIL import ImageFont +from pathlib import Path import common.util as util +from uilib._pygame_init import freetype as _get_freetype from modalapi.wifi import ( ConnectSavedCmd, ConnectScannedCmd, @@ -99,7 +100,8 @@ def __init__(self, ssid: str, pstack, on_submit: PasswordCallback) -> None: self._on_submit = on_submit self._curline = '' - font = ImageFont.truetype("DejaVuSans.ttf", 18) + _fonts = Path(__file__).resolve().parent.parent / "fonts" + font = _get_freetype().Font(str(_fonts / "DejaVuSans.ttf"), 18) box = Box(0, 0, 300, 80) box = box.centre(pstack.box) super().__init__(box=box, parent=pstack, auto_destroy=True) @@ -526,12 +528,20 @@ def _open_password_prompt(self, ssid: str, on_submit: PasswordCallback) -> None: def _open_join_dialog(self, _: object = None) -> None: d = Dialog(width=240, height=120, auto_destroy=True, title='Join other network') - ssid_w = TextWidget(box=Box.xywh(0, 0, 190, 0), text='', prompt='SSID :', parent=d, + font = Config().get_font('default') + from uilib.misc import get_text_size # local import — uilib.__init__ doesn't re-export + ssid_label_w = get_text_size('SSID :', font)[0] + pw_label_w = get_text_size('Passwd :', font)[0] + TextWidget(box=Box.xywh(0, 0, ssid_label_w, 0), text='SSID :', parent=d, + h_margin=0, v_margin=3, align=WidgetAlign.NONE) + ssid_w = TextWidget(box=Box.xywh(ssid_label_w, 0, 190, 0), text='', parent=d, outline=1, sel_width=3, outline_radius=5, align=WidgetAlign.NONE, name='ssid_field', edit_message='WiFi SSID') d.add_sel_widget(ssid_w) - pw_w = TextWidget(box=Box.xywh(0, 30, 169, 0), text='', prompt='Passwd :', parent=d, + TextWidget(box=Box.xywh(0, 30, pw_label_w, 0), text='Passwd :', parent=d, + h_margin=0, v_margin=3, align=WidgetAlign.NONE) + pw_w = TextWidget(box=Box.xywh(pw_label_w, 30, 169, 0), text='', parent=d, outline=1, sel_width=3, outline_radius=5, align=WidgetAlign.NONE, name='pw_field', edit_message='Password') diff --git a/uilib/README.md b/uilib/README.md new file mode 100644 index 00000000..03c7aec1 --- /dev/null +++ b/uilib/README.md @@ -0,0 +1,43 @@ +# Paint system + +The UI is a tree of widgets, each widget knowing its own rectangle in its parent's coordinate space. Non-leaf nodes (`ContainerWidget`s) each own a `pygame.Surface` that holds the composite of itself and its descendants. Leaf widgets have no buffer of their own: they draw straight into the nearest ancestor surface. The root of the tree is a `PanelStack`, whose surface is the one pushed to the LCD. + +Drawing happens via `do_draw`; a `PaintContext` is passed to each widget's concrete `_draw` method. This context includes + +* the (PyGame) surface being drawn into, +* the dirty `clip` rect in surface coords, and +* the current widget's `frame`. + +The context manager `PaintContext.painting(frame)` builds a "sub-context" for drawing children. It uses SDL's capability to drop any primitive that strays outside the clip, so drawing methods can treat their own rect as if it were the whole world. + +## Virtual painting + +A container's surface is usually the same size as its box, but a virtual container can hold a surface taller (or wider) than its viewport. This is currently used for scrollable menus where content might run past the screen extents (we're working with a 320x240 LCD). + +The container's `offset` field is the (x, y) of the viewport's top-left within that "tall" surface, while `_viewport_view()` returns a `pygame.Surface` subsurface of the cache at the current offset. For non-virtual containers `viewport == bounds`, so the view is the whole surface; for virtual containers it's a moving window. Either way, the same blit path serves both. + +Virtual containers do, however, diverge from the standard path in a couple ways: + +1. Their `refresh()` paints into "content" coordinates rather than "physical" ones, though children don't need to care about this because they draw in local coordinates anyhow. + +2. `do_draw()` skips the lazy-rebuild path that non-virtual containers use because their cache is maintained externally by `refresh()` and `scroll()`: off-viewport children get a `_dirty` flag so that scrolling lazily paints them as they come into view, without losing previously-painted pixels. + +## Caching + +Each container caches its composite, keeping track of which regions are pending re-draws via `_dirty_Region: Box | None`. `None` means clean — the surface can be blitted as-is. A Box means that rectangle is stale and the rest of the cache is up-to-date. + +When `do_draw` is called on a non-virtual container with a dirty region, it rebuilds only that slice: the `painting(frame)` clip drops everything outside it, and children whose boxes don't intersect the rect are skipped entirely. + +Cache invalidation happens two ways: + +The first method `propagate_dirty(clip)` is called after pixels have been written somewhere (e.g. a leaf called `Widget.refresh(box)`). New pixels exist, but every cached composite is stale (for a certain rectangle) up to the tree root. The new "dirty rectangle" is unioned (after coordinate translation) with ancestors' existing `_dirty_rect`s. + +The chain terminates at `PanelStack.propagate_dirty`, which is the only `propagate_dirty` that actually does something visible: it composes the stacked panels into the root surface and pushes the result to the LCD. + +The second method `_invalidate_cache(box)` is called when a widget is attached or detached from the widget tree; it uses the same logic to mark that area as stale. + +## Masking + +`RoundedPanel` introduces a per-corner alpha mask. For non-virtual panels the mask is multiplied into the cache once, in the `_finalize_cache()` hook called at the end of every rebuild, so the panel blits out as plain pixels from then on. Virtual panels can't pre-multiply, because the mask applies to different parts of the backing surface (via the viewport): instead, they apply the mask per-blit against a temporary copy of the viewport slice. + +Subclasses define their shape by overriding `_build_shape_mask()`. diff --git a/uilib/_pygame_init.py b/uilib/_pygame_init.py new file mode 100644 index 00000000..b634b3b8 --- /dev/null +++ b/uilib/_pygame_init.py @@ -0,0 +1,56 @@ +# This file is part of pi-stomp. +# +# pi-stomp is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +"""Idempotent pygame + pygame._freetype initialization. + +Use pygame._freetype (the C extension) rather than pygame.freetype: the public +pygame.freetype module triggers a circular import with pygame.font on Python +3.14 / pygame 2.6.1. +""" + +import os + +_initialized = False + + +def init(headless: bool = True): + global _initialized + if _initialized: + return + if headless and "SDL_VIDEODRIVER" not in os.environ: + os.environ["SDL_VIDEODRIVER"] = "dummy" + import pygame + import pygame._freetype as _freetype + + pygame.init() + _freetype.init() + _apply_css_color_overrides(pygame) + _initialized = True + + +# Pillow colours +_CSS_OVERRIDES = { + "gray": (128, 128, 128, 255), + "grey": (128, 128, 128, 255), + "green": (0, 128, 0, 255), + "purple": (128, 0, 128, 255), + "maroon": (128, 0, 0, 255), +} + + +def _apply_css_color_overrides(pygame): + table = pygame.color.THECOLORS + for name, rgba in _CSS_OVERRIDES.items(): + table[name] = rgba + + +def freetype(): + """Return the pygame._freetype module, initializing if needed.""" + init() + import pygame._freetype as _freetype + + return _freetype diff --git a/uilib/box.py b/uilib/box.py index b3ee453c..e0181777 100644 --- a/uilib/box.py +++ b/uilib/box.py @@ -62,6 +62,11 @@ def height(self): def height(self, value): self.y1 = self.y0 + int(value) + @property + def size(self): + """Return width and height as a tuple""" + return (self.width, self.height) + @property def topleft(self): x0,y0,x1,y1 = self.box @@ -171,6 +176,24 @@ def intersection(self, box): y1 = min(self.box[3], box.box[3]) return Box(x0,y0,x1,y1) + def union(self, box): + """Returns the bounding rectangle that contains both rectangles. + Empty boxes are skipped (treated as the identity).""" + if self.is_empty(): + return box + if box.is_empty(): + return self + x0 = min(self.box[0], box.box[0]) + y0 = min(self.box[1], box.box[1]) + x1 = max(self.box[2], box.box[2]) + y1 = max(self.box[3], box.box[3]) + return Box(x0,y0,x1,y1) + + def contains(self, other): + """Returns True if other is fully contained within this box""" + return (other.box[0] >= self.box[0] and other.box[1] >= self.box[1] and + other.box[2] <= self.box[2] and other.box[3] <= self.box[3]) + def is_empty(self): return self.box[0] >= self.box[2] or self.box[1] >= self.box[3] diff --git a/uilib/builder.py b/uilib/builder.py index f0f5d60f..64ec7152 100644 --- a/uilib/builder.py +++ b/uilib/builder.py @@ -14,8 +14,8 @@ # along with pi-stomp. If not, see . import json -from PIL import ImageFont +from uilib._pygame_init import freetype as _get_freetype from uilib.panel import * from uilib.dialog import * from uilib.icon import * @@ -35,8 +35,8 @@ def _translate_attr(kv): if key == 'font' or key == 'title_font': if isinstance(value, list): fname, size = tuple(value) - return ImageFont.TrueType(fname, size) - return Config().get_font(kv) + return _get_freetype().Font(fname, size) + return Config().get_font(value) if (key == 'fgnd' or key == 'bkgnd' or key == 'sel_color' or key == 'outline_color' or key == 'title_color'): if isinstance(value, list): diff --git a/uilib/config.py b/uilib/config.py index ca087cc1..f03a3ca3 100644 --- a/uilib/config.py +++ b/uilib/config.py @@ -14,7 +14,12 @@ # along with pi-stomp. If not, see . import json -from PIL import ImageFont +import os +from pathlib import Path + +from uilib._pygame_init import freetype as _get_freetype + +_FONTS_DIR = Path(__file__).resolve().parent.parent / "fonts" class Config(): _instance = None @@ -36,21 +41,26 @@ def __init__(self, config_json = None): def _set_defaults(self): if 'default' not in self.fonts: - add_font('default', 'DejaVuSans.ttf', 16) + self.add_font('default', 'DejaVuSans.ttf', 16) if 'default_title' not in self.fonts: - add_font('default_title', 'DejaVuSans-Bold.ttf', 16) + self.add_font('default_title', 'DejaVuSans-Bold.ttf', 16) if 'default_fgnd' not in self.colors: - add_color('default_fgnd', (255, 255, 255)) + self.add_color('default_fgnd', (255, 255, 255)) if 'default_bkgnd' not in self.colors: - add_color('default_bkgnd', (0, 0, 0)) + self.add_color('default_bkgnd', (0, 0, 0)) if 'default_title_fgnd' not in self.colors: - add_color('default_title_fgnd', (255, 191, 63)) + self.add_color('default_title_fgnd', (255, 191, 63)) if 'default_title_bkgnd' not in self.colors: - add_color('default_title_bkgnd', (63, 63, 63)) + self.add_color('default_title_bkgnd', (63, 63, 63)) def add_font(self, label, file_name, size): - f = ImageFont.truetype(file_name, size) - # XXX Add some error handling + # Resolve bare filenames against the bundled fonts directory. + path = file_name + if not os.path.isabs(file_name) and not os.path.exists(file_name): + candidate = _FONTS_DIR / file_name + if candidate.exists(): + path = str(candidate) + f = _get_freetype().Font(path, size) self.fonts[label] = f def get_font(self, label): diff --git a/uilib/container.py b/uilib/container.py index 5afaeb66..8b0d261b 100644 --- a/uilib/container.py +++ b/uilib/container.py @@ -13,27 +13,38 @@ # You should have received a copy of the GNU General Public License # along with pi-stomp. If not, see . +from typing import Optional, Tuple + +import pygame + from uilib.widget import * -from PIL import Image, ImageDraw +from uilib.paint import PaintContext, _pg_rect + class ContainerWidget(Widget): - """A Widget container with an Image backing store, Children are drawn inside - the container. - A container also supports scrolling its content. + """A Widget container with a pygame.Surface backing store. Children are + drawn inside the container. A container also supports scrolling its content. """ # Inherited attributes with defaults INH_ATTRS = { 'image_format' : 'RGB' } def __init__(self, box, **kwargs): # Non-inherited attributes - self.mask_format = self._get_arg(kwargs, 'mask_format', None) + self.virtual = self._get_arg(kwargs, 'virtual', False) + self._content_height = self._get_arg(kwargs, 'content_height', None) + kwargs.pop('virtual', None) + kwargs.pop('content_height', None) # Inheritable attributes self._init_attrs(ContainerWidget.INH_ATTRS, kwargs) - self.image = None + self.surface: Optional[pygame.Surface] = None self.old_box = None - self.offset = (0, 0) + self.offset: Tuple[int, int] = (0, 0) + # Surface-local rect of stale pixels — None ⇒ cache is fully valid. + # do_draw rebuilds only the dirty region on a cache miss, so frequent + # small-clip refreshes stay cheap. + self._dirty_region: Optional[Box] = None super(ContainerWidget,self).__init__(box = box, **kwargs) @@ -46,112 +57,186 @@ def _setup(self): super(ContainerWidget,self)._setup() w = self.box.width - h = self.box.height + h = self._content_height if (self.virtual and self._content_height) else self.box.height # Check if we are already setup for this box - if (self.image != None and self.old_box != None and - self.old_box.width == w and self.old_box.height == h): + if (self.surface is not None and self.old_box is not None and + self.old_box.width == w and self.old_box.height == self.box.height and + self.surface.get_height() == h): return trace(self, "container setup, box=", self.box, "old_box=", self.old_box) - # Create new image and draw instance + # Create new pygame surface self.old_box = self.box.copy() - self.image = Image.new(self.image_format, (w, h)) - self.draw = ImageDraw.Draw(self.image) self.has_alpha = self.image_format == 'RGBA' - if self.mask_format is not None: - self.mask = Image.new(self.mask_format, (w, h)) + if self.has_alpha: + self.surface = pygame.Surface((int(w), int(h)), pygame.SRCALPHA) else: - self.mask = None - + self.surface = pygame.Surface((int(w), int(h))) + self._dirty_region = Box(0, 0, int(w), int(h)) + + def _viewport(self) -> Box: + """Visible region in content (surface) coords.""" + ox, oy = self.offset + return Box.xywh(ox, oy, self.box.width, self.box.height) + + def _content_bounds(self) -> Box: + """Full backing surface bounds — used as clip ceiling for children.""" + assert self.surface is not None + return Box(0, 0, self.surface.get_width(), self.surface.get_height()) + def _visible_box(self, box): - """Returns if any part of the box intersects this widget""" if box is None: return False return box.intersects(self.box.norm()) - - def _focus(self, box): - box = box.deoffset(self.offset) - if self.visible and self._visible_box(box): - return (self.image, self.draw, box) + + def refresh(self): + """Redraw the container's backing surface and notify the parent of the change.""" + trace(self, "ContainerWidget.refresh: vis=", self.visible, "parent=", self.parent) + if self.surface is None: + return + if self.virtual: + viewport = self._viewport() + local_frame = self._content_bounds() + ctx = PaintContext(self.surface, local_frame, frame=local_frame) + self._draw_erase(ctx) + self._draw(ctx) + for c in self.children: + if c.visible: + if viewport.intersects(c.box): + c.do_draw(ctx, c.box) + c._painted = True + c._dirty = False + else: + c._dirty = True + self._draw_outline(ctx) + self._draw_selection(ctx) + self._finalize_cache() + self._dirty_region = None + if self.visible and self.parent is not None: + self.propagate_dirty(viewport) else: - return (None, None, None) + local_clip = self.box.norm() + local_frame = self.box.norm() + ctx = PaintContext(self.surface, local_clip, frame=local_frame) + self._draw_erase(ctx) + self._draw(ctx) + for c in self.children: + if c.visible: + c.do_draw(ctx, c.box.offset(local_frame)) + self._draw_outline(ctx) + self._draw_selection(ctx) + self._finalize_cache() + self._dirty_region = None + if self.visible and self.parent is not None: + self.propagate_dirty(local_clip) - def _unfocus(self, box): - # A child updated itself, tell parent to "compose" a subsection of ourselves - if self.visible and self.parent: - box = box.deoffset(self.offset) - self.parent._compose(self, box, box.offset(self.box)) + def _finalize_cache(self) -> None: + """Hook called after the backing surface is rebuilt. Subclasses can + apply composite effects (e.g. corner masking) so steady-state blits + out of this container are plain. Default: no-op.""" + pass - def _compose(self, widget, orig_box, real_box): - assert isinstance(widget, ContainerWidget) + def do_draw(self, ctx: PaintContext, frame: Box): + """Draw this container's pixels into a parent's PaintContext. - real_box.deoffset(self.offset) # XXX: result is discarded + On a cache miss we rebuild only the dirty region (SDL clip clamps + every primitive to that rect), then blit into the parent. Virtual + containers maintain their cache via refresh()/scroll() and never + rebuild here.""" + assert self.surface is not None + with ctx.painting(frame) as pctx: + pframe = pctx.frame + assert pframe is not None + local_clip = pctx.clip.deoffset(pframe.topleft) + local_frame = self.box.norm() - # Crop real box to this image box. This avoids trying to copy pixels - # that are outside of it - crop = real_box.intersection(self.box.norm()) - if crop.is_empty(): - return + if not self.virtual and self._dirty_region is not None: + dirty = self._dirty_region + base_ctx = PaintContext(self.surface, dirty) + with base_ctx.painting(local_frame) as full_ctx: + self._draw_erase(full_ctx) + self._draw(full_ctx) + for c in self.children: + if c.visible: + cf = c.box.offset(local_frame) + if cf.intersects(dirty): + c.do_draw(full_ctx, cf) + self._draw_outline(full_ctx) + self._draw_selection(full_ctx) + self._finalize_cache() + self._dirty_region = None - # XXX TODO: Fast path the case where no cropping occurs + dst_topleft = (pframe.x0 + local_clip.x0, pframe.y0 + local_clip.y0) + self._blit_into(pctx.surface, local_clip, dst_topleft) - # Now create a new orig box that is cropped as well - offset = orig_box.get_offset(real_box) - orig_crop = crop.deoffset(offset) + def _viewport_view(self) -> pygame.Surface: + """Viewport-local view of the backing surface. - # Alpha path: If both images have alpha channels, then do an - # alpha composition which handles the cropping - if self.has_alpha and widget.has_alpha: - self.image.alpha_composite(widget.image, crop.topleft, orig_crop.rect) - else: - sub_image = widget.image.crop(orig_crop.rect) - if widget.mask is not None: - sub_mask = widget.mask.crop(orig_crop.rect) - else: - sub_mask = None - self.image.paste(sub_image, crop.rect, sub_mask) - # Compose ourselves into parent if we are visible - if self.visible and self.parent != None: - self.parent._compose(self, crop, crop) + Non-virtual: viewport == bounds, so this is the whole surface. + Virtual: returns a subsurface slice of the tall surface at the current + scroll offset, clamped to surface bounds (the viewport can extend past + the content when scrolled near the end). Either way, callers treat + `local_clip` as viewport-local coords with no offset math.""" + assert self.surface is not None + vp = self._viewport().intersection(self._content_bounds()) + return self.surface.subsurface(_pg_rect(vp)) - def refresh(self): - trace(self, "ContainerWidget.refresh: vis=",self.visible,"parent=", self.parent) - if not self.image: - return + def _blit_into(self, target_surface: pygame.Surface, local_clip: Box, dst_topleft: Tuple[int, int]) -> None: + """Copy the viewport-local clip from our cache into target_surface.""" + target_surface.blit(self._viewport_view(), _ipt(dst_topleft), area=_pg_rect(local_clip)) - # Refresh the content of the container - self._do_draw(self.image, self.draw, self.box.norm()) + def propagate_dirty(self, local_clip: Box): + """Bubble a dirty region (in our local coords) up to our parent. - # Update into parent container (call the parent refresh who will do the job) - if self.visible and self.parent != None: - self.parent._compose(self, self.box, self.box) + Cached composites above us hold stale pixels of our region, so we + invalidate the parent's cache with the precise rect — the next + do_draw rebuilds only that slice.""" + if not self.visible or self.parent is None: + return + parent_clip = local_clip.deoffset(self.offset).offset(self.box) + parent = self.parent + if isinstance(parent, ContainerWidget): + parent._invalidate_cache(parent_clip) + parent.propagate_dirty(parent_clip) - def _do_draw(self, image, draw, real_box): - # We replace the base Widget implementation because of how we deal with - # offsets: The erase and outline aren't offsetted, the rest is - off_real_box = real_box.deoffset(self.offset) - self._draw_erase(image, draw, real_box) - self._draw(image, draw, off_real_box) - for c in self.children: - crb = c.box.offset(off_real_box) - c._do_draw(image, draw, crb) - self._draw_outline(image, draw, real_box) - self._draw_selection(image, draw, real_box) + def _invalidate_cache(self, box: Optional[Box] = None) -> None: + """Mark a region of our cache stale and bubble up. - # Then update the parent unless we are drawing ourselves - if image is not self.image: - image.paste(self.image, real_box.rect) + box=None means "fully invalidate" (used by child attach/detach where + we don't have a precise rect). Otherwise unions box into our dirty + region and bubbles a parent-coord rect to the parent.""" + if self.surface is None: + return + full = self._content_bounds() + region = full if box is None else box.intersection(full) + if region.is_empty(): + return + self._dirty_region = region if self._dirty_region is None else self._dirty_region.union(region) + if self.visible and self.parent is not None: + self.parent._invalidate_cache(region.deoffset(self.offset).offset(self.box)) def scroll(self, offset): - print(offset) self.offset = offset - # XXX Optimize ? at least optionally for things like menus, use a local blit - # of the backing store instead of a full refresh to work around slow text - # drawing speed with Pillow on 64bit ? - self.refresh() - + if not self.virtual: + self.refresh() + return + if self.surface is None: + return + viewport = self._viewport() + content_frame = self._content_bounds() + ctx = PaintContext(self.surface, content_frame, frame=content_frame) + for c in self.children: + if c.visible and viewport.intersects(c.box): + if not c._painted or c._dirty: + c.do_draw(ctx, c.box) + c._painted = True + c._dirty = False + self._dirty_region = None + if self.visible and self.parent is not None: + self.propagate_dirty(viewport) + def __adj_off_step(self, off, step): aoff = abs(off) s = (aoff + (step - 1)) // step @@ -179,11 +264,12 @@ def _scroll_into_view(self, box): ox += self.__adj_off_step(movex, box.width) oy += self.__adj_off_step(movey, box.height) if b0.y0 == 0: - # XXX hack to allow scrolling to reset to original location when box.y0 is 0 (container top) - # TODO would prefer a better way self.scroll((ox, 0)) else: self.scroll((ox, oy)) return True return False + +def _ipt(p): + return (int(p[0]), int(p[1])) diff --git a/uilib/dialog.py b/uilib/dialog.py index c506576c..2ee4967a 100644 --- a/uilib/dialog.py +++ b/uilib/dialog.py @@ -15,7 +15,12 @@ import functools import textwrap +from typing_extensions import override +import pygame + +from uilib.box import Box +from uilib.radius import Radius from uilib.panel import * from uilib.text import * @@ -51,40 +56,56 @@ def _adjust_box(self): self.title.set_box(tbox, refresh = False) self.title.show(refresh = False) - def _draw(self, image, draw, real_box): - trace(self, "DialogDecorator draw, real_box=", real_box, "self.box=", self.box) - line_xy = (real_box.x0, real_box.y0 + self.th + 1, - real_box.x1 - self.outline, real_box.y0 + self.th + 1) + @override + def _draw_erase(self, ctx): + # Paint only the titlebar strip — the panel body owns its own pixels, + # and filling under it would leak through any transparent areas. + titlebar_h = self.panel.box.y0 - self.box.y0 # decorator-local + strip = Box(0, 0, self.box.width, titlebar_h) + ctx.draw_rectangle(strip, fill=self.bkgnd_color, radius=Radius.top(self.outline_radius)) + + def _draw(self, ctx): + trace(self, "DialogDecorator draw, self.box=", self.box) + y = self.th + 1 # The +2 here is magic ... need to figure out what's up, otherwise we get only 1 pixel - draw.line(line_xy, fill=self.fgnd_color, width=self.outline + 2) + ctx.draw_line(((0, y), (ctx.width - self.outline, y)), fill=self.fgnd_color, width=self.outline + 2) + +class Dialog(RoundedPanel): + """A pop-up dialog with a title decorator. -class Dialog(Panel): - def __init__(self, width, height, title, title_font = None, **kwargs): + Only the BOTTOM corners are rounded on the panel itself — the titlebar + decorator sits above with its own rounded top, so the panel's top corners + must stay square (otherwise we'd clip the top of the first content widget). + """ + + def __init__(self, width, height, title, title_font=None, **kwargs): box = Box.xywh(0, 0, width, height) - # Fixed radius for now radius = 10 - if title_font == None: + if title_font is None: title_font = Config().get_font('default_title') - deco = functools.partial(DialogDecorator, title = title, title_font = title_font, outline_radius = radius) - if 'mask_format' not in kwargs: - kwargs['mask_format'] = '1' - super(Dialog,self).__init__(box = box, align = WidgetAlign.CENTRE, radius = radius, - decorator = deco, **kwargs) - # Setup mask - mdraw = ImageDraw.Draw(self.mask) - # Base is a rounded rectangle - b = self.box.norm() - mdraw.rounded_rectangle(b.PIL_rect, radius, 1, None, 0) - # Fill up the top corners - b.height = int(b.height / 2) - mdraw.rectangle(b.PIL_rect, 1, None, 0) + deco = functools.partial(DialogDecorator, title=title, title_font=title_font, outline_radius=radius) + super(Dialog, self).__init__(box=box, align=WidgetAlign.CENTRE, radius=radius, + decorator=deco, **kwargs) + + @override + def _build_shape_mask(self) -> pygame.Surface: + # Only the bottom corners round — the titlebar decorator owns the top + # corners and the panel's top edge must stay square to meet it + # seamlessly. + size = (int(self.box.width), int(self.box.height)) + mask = pygame.Surface(size, pygame.SRCALPHA) + mask.fill((0, 0, 0, 0)) + pygame.draw.rect(mask, (255, 255, 255, 255), + pygame.Rect(0, 0, size[0], size[1]), 0, + **Radius.bottom(self.radius).as_pygame_kwargs()) + return mask class MessageDialog(Dialog): def __init__(self, panelstack, message, title="Error", width=200, height=90): super(MessageDialog, self).__init__(width=width, height=height, title=title, auto_destroy=True) - bbox = Config().get_font('default_title').getbbox("a") - chars_per_line = width // int(bbox[2] - bbox[0]) + char_w = Config().get_font('default_title').get_rect("a").width + chars_per_line = width // max(1, int(char_w)) chunks = textwrap.wrap(message, width=chars_per_line) wrapped = '\n'.join(chunks) diff --git a/uilib/footswitch.py b/uilib/footswitch.py index fc1c50c1..0456300d 100644 --- a/uilib/footswitch.py +++ b/uilib/footswitch.py @@ -30,40 +30,46 @@ def __init__(self, box, font, label, color, is_bypassed, **kwargs): self.foreground = (255, 255, 255) self.color_plugin_bypassed = (80, 80, 80) - def _draw(self, image, draw, real_box): - self.xy1 = (real_box.x0, real_box.y0) - self.xy2 = (real_box.x0 + 60, real_box.y0 + 40) # TODO should these offsets be here? - self.draw = draw + # Visual constants, top-anchored so the label area lives inside the frame + # (SDL clips to the widget frame; the old PIL renderer let text bleed past). + CAP_INSET_X = 10 + CAP_HEIGHT = 16 + CAP_STACK_OFFSET = 6 + CAP_TOP_Y = 0 # top edge of upper cap + CAP_BOTTOM_Y = 6 # top edge of lower cap (= CAP_TOP_Y + CAP_STACK_OFFSET) + HALO_INSET_X = 2 + HALO_TOP = 10 + HALO_BOTTOM = 38 # bottom of halo, just under the lower cap + LABEL_Y = 40 # baseline-area for the label, below the cap - # halo - self._draw_halo() + def _draw(self, ctx): + w = ctx.width - # cap bottom - fx1 = self.xy1[0] + 10 - fy1 = self.xy2[1] - 34 - fx2 = self.xy2[0] - 10 - fy2 = fy1 + 16 - draw.ellipse(((fx1, fy1), (fx2, fy2)), fill=self.background, outline="gray", width=2) + self._draw_halo(ctx) + + cap_x0 = self.CAP_INSET_X + cap_x1 = w - self.CAP_INSET_X + # cap bottom + ctx.draw_ellipse(Box(cap_x0, self.CAP_BOTTOM_Y, cap_x1, self.CAP_BOTTOM_Y + self.CAP_HEIGHT), + fill=self.background, outline="gray", width=2) # cap top - fy1 -= 6 - fy2 -= 6 - draw.ellipse(((fx1, fy1), (fx2, fy2)), fill=self.background, outline="gray", width=2) + ctx.draw_ellipse(Box(cap_x0, self.CAP_TOP_Y, cap_x1, self.CAP_TOP_Y + self.CAP_HEIGHT), + fill=self.background, outline="gray", width=2) - # label - draw.text((self.xy1[0], self.xy2[1]), self.label, self.foreground, self.font) + # Label sits below the cap, inside the frame. + ctx.draw_text((0, self.LABEL_Y), self.label, self.foreground, self.font) - def _draw_halo(self): - hx1 = self.xy1[0] + 2 - hy1 = self.xy1[1] + 10 - hx2 = self.xy2[0] - 2 - hy2 = self.xy2[1] - 2 - color = self.color_plugin_bypassed if self.is_bypassed else self.color - self.draw.ellipse(((hx1, hy1), (hx2, hy2)), fill=None, outline=color, width=self.footswitch_ring_width) + def _draw_halo(self, ctx): + # When an unbound footswitch toggles active, self.color is None. PIL's + # ImageDraw silently fell back to its default ink (white); pygame skips + # the draw entirely. Fall back to foreground to preserve the look. + color = self.color_plugin_bypassed if self.is_bypassed else (self.color or self.foreground) + ctx.draw_ellipse( + Box(self.HALO_INSET_X, self.HALO_TOP, + ctx.width - self.HALO_INSET_X, self.HALO_BOTTOM), + fill=None, outline=color, width=self.footswitch_ring_width, + ) def toggle(self, is_bypassed): self.is_bypassed = is_bypassed - self._draw_halo() - - - diff --git a/uilib/icon.py b/uilib/icon.py index 96f8c095..998d153d 100644 --- a/uilib/icon.py +++ b/uilib/icon.py @@ -35,9 +35,10 @@ def __init__(self, box, text='', text_color=None, height=13, outline_width=2, ** self.text_color = text_color if text_color is not None else self.fgnd_color def add_knob(self): - loc = (self.box.x0, self.box.y0 + 2) # TODO use box directly, replace height with box height + # Widget-relative coords from (0, 0). + loc = (0, 2) e = { - 'xy': ((loc[0], loc[1]), (loc[0] + self.height, loc[1] + self.height)), + 'box': Box(loc[0], loc[1], loc[0] + self.height, loc[1] + self.height), 'fill': self.bkgnd_color, 'outline': self.fgnd_color, 'height' : self.outline_width @@ -54,7 +55,7 @@ def add_knob(self): self.lines.append(l) def add_pedal(self): - loc = (self.box.x0, self.box.y0 - 1) # TODO use box directly, replace height with box height + loc = (0, -1) l = { 'xy': ((loc[0], loc[1] + self.height), (loc[0] + self.height, loc[1] + int(self.height / 3))), @@ -72,26 +73,22 @@ def add_pedal(self): self.lines.append(l) - def _draw(self, image, draw, real_box): - # Draw shapes and text - # The loc calculation lines are a copy/paste from TextWidget._draw() - # + def _draw(self, ctx): h_margin, v_margin = self._get_margins() extra = self.outline - hroom = real_box.width - h_margin - extra - vroom = real_box.height - v_margin - extra + hroom = ctx.width - h_margin - extra + vroom = ctx.height - v_margin - extra if hroom < 0 or vroom < 0: return h_margin = 1 - loc = (real_box.x0 + h_margin, real_box.y0 + v_margin) + loc = (h_margin, v_margin) - # Draw features for e in self.ellipses: - draw.ellipse(xy=e['xy'], fill=e['fill'], outline=e['outline'], width=e['height']) + ctx.draw_ellipse(e['box'], fill=e['fill'], outline=e['outline'], width=e['height']) for l in self.lines: - draw.line(xy=l['xy'], fill=l['fill'], width=l['height']) + ctx.draw_line(l['xy'], fill=l['fill'], width=l['height']) - draw.text((loc[0] + self.height + h_margin, loc[1]), self.text, fill=self.text_color, font=self.font) + ctx.draw_text((loc[0] + self.height + h_margin, loc[1]), self.text, fill=self.text_color, font=self.font) diff --git a/uilib/image.py b/uilib/image.py index cbb4262b..23d53241 100644 --- a/uilib/image.py +++ b/uilib/image.py @@ -13,30 +13,36 @@ # You should have received a copy of the GNU General Public License # along with pi-stomp. If not, see . +import pygame + from uilib.widget import * -from PIL import Image + + +def _load(image_path: str) -> pygame.Surface: + # convert_alpha needs a video surface; under SDL_VIDEODRIVER=dummy a + # display surface exists after pygame.init(), so this is safe. + surf = pygame.image.load(image_path) + try: + return surf.convert_alpha() + except pygame.error: + return surf + class ImageWidget(Widget): - """A simple widget with an image""" + """A simple widget that paints a pygame.Surface centered in its frame.""" + def __init__(self, image_path, **kwargs): self._init_attrs(Widget.INH_ATTRS, kwargs) - super(ImageWidget,self).__init__(**kwargs) - self.image = Image.open(image_path) - - def _draw(self, image, draw, real_box): - # XXX TODO Centre and crop it ? For now just centre. XXX Assume box > image size, - # this needs to be cleaned up and made shinnier, possibly with a Box() helper - width,height = self.image.size - offx = int((real_box.width - width) / 2) - offy = int((real_box.height - height) / 2) - loc = real_box.offset((offx,offy)).topleft - - # Draw image - mask = self.image if self.image.mode == 'RGBA' else None - image.paste(self.image, loc, mask) + super(ImageWidget, self).__init__(**kwargs) + self.image = _load(image_path) + + def _draw(self, ctx): + width, height = self.image.get_size() + offx = int((ctx.width - width) / 2) + offy = int((ctx.height - height) / 2) + ctx.paste(self.image, (offx, offy)) def replace_img(self, image_path): - # XXX Note that the new image must be the same size as the original - self.image = Image.open(image_path) + # XXX the new image should be the same size as the original + self.image = _load(image_path) self.refresh() - diff --git a/uilib/lcd_ili9341.py b/uilib/lcd_ili9341.py index 2a8b386f..b3d9959f 100644 --- a/uilib/lcd_ili9341.py +++ b/uilib/lcd_ili9341.py @@ -14,6 +14,8 @@ # along with pi-stomp. If not, see . import adafruit_rgb_display.ili9341 as ili9341 +import pygame +from PIL import Image from uilib.panel import LcdBase, Box from functools import cached_property @@ -25,22 +27,19 @@ class LcdIli9341(LcdBase): - # XXX # TODO: Turn "flip" into all 90deg angle combinations def __init__(self, spi, cs_pin, dc_pin, reset_pin, baudrate, flip=True): rst = reset_pin if not self.has_system_splash else None self.disp = ili9341.ILI9341(spi, cs=cs_pin, dc=dc_pin, rst=rst, baudrate=baudrate) - # Use this to assure we don't have multiple threads trying to change the screen - # All methods which do change the screen (eg. dist. calls) should acquire/release self.lock = threading.Lock() if not self.has_system_splash: self.clear() self._set_stamp() - # Test full screen image + # Portrait dimensions (the panel itself is landscape; we rotate at push). self.width = self.disp.height self.height = self.disp.width self.flip = flip @@ -68,35 +67,39 @@ def clear(self): self.disp.fill(0) self.lock.release() - def update(self, image, box = None): + def update(self, surface: pygame.Surface, box=None): + """Push (a sub-rect of) the composed pygame surface to the LCD. + + Converts surface → packed RGB888 bytes → PIL.Image at the seam and + hands it to adafruit_rgb_display, which handles the SPI bulk write.""" if self.lock.locked(): logging.debug("LCD update was locked by another thread") self.lock.acquire() - # LCD coordinates - # - # portrait mode, connector = bottom - # - # on pi-stomp, X=0 is "bottom" (away from jacks) - # Y=0 is "left" (out jack side) - # - img_width, img_height = image.size - if box is None: - box = Box(0, 0, img_width, img_height) - - # Check if we need to crop the image to the LCD size - x1, y1, x2, y2 = box.rect - if x2 > self.width: - x2 = self.width - if y2 > self.height: - y2 = self.height - if x1 != 0 or y1 != 0 or x2 != img_width or y2 != img_width: - image = image.crop((x1, y1, x2, y2)) - if self.flip: - x = self.height - y2 - y = x1 + try: + img_width, img_height = surface.get_size() + if box is None: + box = Box(0, 0, img_width, img_height) + + x1, y1, x2, y2 = box.rect + if x2 > self.width: + x2 = self.width + if y2 > self.height: + y2 = self.height + + cropped = x1 != 0 or y1 != 0 or x2 != img_width or y2 != img_height + if cropped: + sub_rect = pygame.Rect(x1, y1, x2 - x1, y2 - y1) + sub = surface.subsurface(sub_rect) + if self.flip: + x, y = self.height - y2, x1 + else: + x, y = y1, self.width - x2 else: - x = y1 - y = self.width - x2 - self.disp.image(image, 270 if self.flip else 90, x, y) - self.lock.release() - + sub = surface + x, y = 0, 0 + + rgb_bytes = pygame.image.tobytes(sub, "RGB") + pil_img = Image.frombytes("RGB", sub.get_size(), rgb_bytes) + self.disp.image(pil_img, 270 if self.flip else 90, x, y) + finally: + self.lock.release() diff --git a/uilib/menu.py b/uilib/menu.py index daa57813..51100d7c 100644 --- a/uilib/menu.py +++ b/uilib/menu.py @@ -35,7 +35,7 @@ def __init__(self, items, font = None, max_width = None, max_height = None, if font is None: font = Config().get_font('default') self.font = font - self.font_metrics = font.getmetrics() + self.font_metrics = None self.item_h = 0 self.text_halign = text_halign self.default_item = default_item @@ -100,8 +100,6 @@ def _adjust_box(self): trace(self, "item <",t,"> tw=", tw, "th=", th) tw = tw + h_margin * 2 th = th + v_margin * 2 - #if tw > w: - # w = tw if h == 0: self.item_h = th h = th * len(self.items) @@ -110,7 +108,9 @@ def _adjust_box(self): if mw is not None and w > mw: w = 240 if mh is not None and h > mh: + # Content taller than viewport: enable JIT paint with a tall backing image + self.virtual = True + self._content_height = h h = mh - print("-> adjusted w,h:", w, h) self.box = Box.xywh(0,0,w,h) super(Menu,self)._adjust_box() diff --git a/uilib/misc.py b/uilib/misc.py index 4e9ca505..1bb889e9 100644 --- a/uilib/misc.py +++ b/uilib/misc.py @@ -56,19 +56,60 @@ def trace(obj, *args): print(str(type(obj)), n, args) # Utility function (from stack overflow). TODO: Move to a TextUtils -def get_text_size(text_string, font, metrics = None): - # https://stackoverflow.com/a/46220683/9263761 - if metrics is not None: - ascent, descent = metrics - else: - ascent, descent = font.getmetrics() +def get_text_size(text_string, font, metrics=None): + """Return (width, height) of `text_string` rendered with `font`. -# text_width = font.getmask(text_string).getbbox()[2] -# text_height = font.getmask(text_string).getbbox()[3] + descent - bbox = font.getbbox(text_string) - text_width = bbox[2] - bbox[0] - text_height = bbox[3] + descent + Width matches PIL's `font.getbbox(text)[2] - getbbox(text)[0]` exactly: + bbox_left = min(0, min_glyph_ink_left_in_pen_coords) + bbox_right = max(pen_after_last_glyph, max_glyph_ink_right_in_pen_coords) + width = bbox_right - bbox_left + Neither pygame's `rect.x + rect.width` nor `sum(advance_x)` alone matches + PIL — the former undercounts when ink overhangs past the advance (e.g. 'j' + LSB<0, '█' max_x>advance), the latter overcounts in the same cases. - return (text_width, text_height) + Height = font ascender + font descender + per-text glyph descent overflow + (for descender glyphs like g/p/y), matching PIL's `bbox[3] + descent`. + """ + asc = int(font.get_sized_ascender()) + desc = abs(int(font.get_sized_descender())) + line_height = asc + desc + if not text_string: + return (0, line_height) + # pygame.freetype.Font.get_metrics returns per-glyph + # (min_x, max_x, min_y, max_y, advance_x, advance_y). Negative values come + # back as 32-bit unsigned ints — wrap them. + def _signed(v): + return v - 0x100000000 if v >= 0x80000000 else v + + pen = 0.0 + ink_left = 0.0 + ink_right = 0.0 + has_any = False + glyph_desc = 0 + for m in font.get_metrics(text_string): + if m is None: + continue + min_x = _signed(m[0]) + max_x = _signed(m[1]) + min_y = _signed(m[2]) + adv_x = m[4] + l = pen + min_x + r = pen + max_x + if not has_any: + ink_left, ink_right, has_any = l, r, True + else: + if l < ink_left: + ink_left = l + if r > ink_right: + ink_right = r + pen += adv_x + if min_y < 0 and -min_y > glyph_desc: + glyph_desc = -min_y + if not has_any: + return (0, line_height) + right_edge = max(ink_right, pen) + left_edge = min(0.0, ink_left) + width = int(round(right_edge - left_edge)) + return (width, line_height + glyph_desc) diff --git a/uilib/paint.py b/uilib/paint.py new file mode 100644 index 00000000..4206acc5 --- /dev/null +++ b/uilib/paint.py @@ -0,0 +1,259 @@ +# This file is part of pi-stomp. +# +# pi-stomp is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# pi-stomp is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with pi-stomp. If not, see . + +from dataclasses import dataclass, replace +from typing import Generator, Optional, Sequence, Tuple, Union +from contextlib import contextmanager + +from uilib._pygame_init import init as _pg_init + +_pg_init() + +import pygame +import pygame._freetype as _freetype +import pygame.gfxdraw as gfxdraw + +from uilib.box import Box +from uilib.radius import Radius + + +# Color spec accepted by uilib's PaintContext primitives. +ColorLike = Union[pygame.Color, str, int, Tuple[int, int, int], Tuple[int, int, int, int], Sequence[int]] +Point = Tuple[int, int] +PointSeq = Sequence[Point] +FlatCoords = Sequence[int] + + +def _color(c: ColorLike) -> pygame.Color: + """Coerce a non-None color spec to a pygame.Color.""" + if isinstance(c, pygame.Color): + return c + if isinstance(c, str): + return pygame.Color(c) + return pygame.Color(*c) if not isinstance(c, int) else pygame.Color(c) + + +def _ipt(p: Sequence[int]) -> Point: + return (int(p[0]), int(p[1])) + + +def _pg_rect(box: Box) -> pygame.Rect: + return pygame.Rect(int(box.x0), int(box.y0), max(0, int(box.width)), max(0, int(box.height))) + + +@dataclass(frozen=True) +class PaintContext: + """Immutable paint state passed down the widget tree. + + surface : pygame.Surface being drawn into + clip : dirty rect in surface-coordinate space + frame : the current widget's rect in surface-coordinate space; widget-relative + drawing methods translate (0,0) → frame.topleft. None on root + contexts before painting() has been entered. + """ + + surface: pygame.Surface + clip: Box + frame: Optional[Box] = None + + # --- Widget-relative geometry helpers --- + + def _f(self) -> Box: + assert self.frame is not None, "PaintContext drawing requires a frame; enter via painting()" + return self.frame + + @property + def width(self) -> int: + return self._f().width + + @property + def height(self) -> int: + return self._f().height + + @property + def bounds(self) -> Box: + """The widget's own coordinate space: Box(0, 0, width, height).""" + f = self._f() + return Box(0, 0, f.width, f.height) + + @property + def dirty_bounds(self) -> Box: + """Widget-relative dirty rect: bounds ∩ (clip in widget coords).""" + f = self._f() + return self.bounds.intersection(self.clip.deoffset(f.topleft)) + + def _abs_xy(self, xy: Sequence[int]) -> Point: + ox, oy = self._f().topleft + return (int(xy[0]) + ox, int(xy[1]) + oy) + + def _abs_box(self, box: Box) -> Box: + return box.offset(self._f().topleft) + + def _abs_points(self, xy: Union[PointSeq, FlatCoords]) -> Sequence[Point]: + ox, oy = self._f().topleft + if len(xy) == 0: + return [] + first = xy[0] + if isinstance(first, (tuple, list)): + return [(int(p[0]) + ox, int(p[1]) + oy) for p in xy] # type: ignore[index] + out: list[Point] = [] + for i in range(0, len(xy), 2): + out.append((int(xy[i]) + ox, int(xy[i + 1]) + oy)) # type: ignore[arg-type] + return out + + # --- Widget-relative drawing primitives --- + + def fill(self, color: ColorLike) -> None: + self.surface.fill(_color(color), _pg_rect(self._abs_box(self.bounds))) + + def draw_rectangle( + self, + box: Box, + fill: Optional[ColorLike] = None, + outline: Optional[ColorLike] = None, + width: int = 0, + radius: int | Radius | None = None, + ) -> None: + rect = _pg_rect(self._abs_box(box)) + if rect.width <= 0 or rect.height <= 0: + return + kwargs = Radius._coerce(radius).as_pygame_kwargs() + if fill is not None: + pygame.draw.rect(self.surface, _color(fill), rect, 0, **kwargs) + if outline is not None and int(width) > 0: + pygame.draw.rect(self.surface, _color(outline), rect, int(width), **kwargs) + + def draw_ellipse( + self, box: Box, fill: Optional[ColorLike] = None, outline: Optional[ColorLike] = None, width: int = 0 + ) -> None: + """Draw a non-AA ellipse matching PIL's ImageDraw.ellipse aesthetic. + + gfxdraw.filled_ellipse for the fill (closest coverage to PIL), + pygame.draw.ellipse with width for the outline (PIL-equivalent jaggy + stroke; handles thick widths natively). AA versions blend edges to + semi-transparent gray which clashes with the design language.""" + rect = _pg_rect(self._abs_box(box)) + # XXX: adding 1 to width/height gave us parity with Pillow... + rect.width += 1 + rect.height += 1 + if rect.width <= 0 or rect.height <= 0: + return + if fill is not None: + # gfxdraw.filled_ellipse covers [cx-rx, cx+rx] inclusive (2*rx+1 + # pixels). To fill the full Box, use rx = (width-1)//2 and place + # the center on the upper-left of the two center pixels for even + # sizes — matches PIL's coverage exactly. + cx = rect.x + (rect.width - 1) // 2 + cy = rect.y + (rect.height - 1) // 2 + rx = max(0, (rect.width - 1) // 2) + ry = max(0, (rect.height - 1) // 2) + gfxdraw.filled_ellipse(self.surface, cx, cy, rx, ry, _color(fill)) + if outline is not None and int(width) > 0: + pygame.draw.ellipse(self.surface, _color(outline), rect, int(width)) + + def draw_line(self, xy: Union[PointSeq, FlatCoords], fill: Optional[ColorLike] = None, width: int = 0) -> None: + """Draw a polyline. + + PIL stamps a `width`×`width` box at each step along the bresenham path, + so diagonals end up ~1px thicker than axis-aligned strokes of the same + nominal width. pygame strokes exactly `width` perpendicular to the + segment. To match PIL's visual weight on icon knob pointers / pedal + graphics, bump width by 1 for non-axis-aligned segments when width>=2. + """ + if fill is None: + return + color = _color(fill) + w = max(1, int(width)) + pts = self._abs_points(xy) + if len(pts) < 2: + return + ipts = [_ipt(p) for p in pts] + for i in range(len(ipts) - 1): + p0, p1 = ipts[i], ipts[i + 1] + seg_w = w if (p0[0] == p1[0] or p0[1] == p1[1] or w < 2) else w + 1 + pygame.draw.line(self.surface, color, p0, p1, seg_w) + + def draw_text( + self, + pos: Sequence[int], + text: str, + fill: Optional[ColorLike] = None, + font: Optional[_freetype.Font] = None, + anchor: Optional[str] = None, + ) -> None: + """Draw text using a pygame._freetype Font. + + Default anchor matches PIL's `la` (left, ascender): `pos` is the + top-left of the line box (ascender line), not of the visible glyph + bbox. This keeps text vertical alignment consistent regardless of + which characters appear (with/without ascenders or descenders). + Also supports anchor='mm' (middle/middle of the glyph bbox). + """ + if not text or font is None or fill is None: + return + color = _color(fill) + x, y = self._abs_xy(pos) + asc = int(font.get_sized_ascender()) + if anchor == "mm": + # PIL anchor='mm' centers on (PIL.getbbox(text).w / 2, (asc+desc)/2). + # uilib.misc.get_text_size matches PIL getbbox semantics. Use int() + # (floor for positive operands) — not round() — because PIL's BASIC + # layout effectively floors the fractional pen position; Python's + # banker's rounding on .5 boundaries (e.g. 51.5 → 52) would push + # the glyph one pixel right of PIL. + from uilib.misc import get_text_size + desc = abs(int(font.get_sized_descender())) + tw, _ = get_text_size(text, font) + base_dst = (int(x - tw / 2), int(y - (asc + desc) / 2)) + else: + base_dst = (int(x), int(y)) + # pygame._freetype.Font.render_to bypasses surface.set_clip (it clamps + # only to the destination surface's bounds). To enforce the active + # clip without a temp+blit, render into a subsurface of the current + # clip rect — the rasterizer then clamps to that, giving us SDL-style + # clipping for free. `painting()` guarantees a non-empty clip. + clip = self.surface.get_clip() + if clip.width <= 0 or clip.height <= 0: + return + sub = self.surface.subsurface(clip) + pen = (base_dst[0] - clip.x, base_dst[1] + asc - clip.y) + prev_origin = font.origin + font.origin = True + try: + font.render_to(sub, pen, text, fgcolor=color) + finally: + font.origin = prev_origin + + def paste(self, src: pygame.Surface, pos: Sequence[int], mask: Optional[pygame.Surface] = None) -> None: + """Blit a surface onto self.surface at widget-relative coords.""" + self.surface.blit(src, _ipt(self._abs_xy(pos))) + + @contextmanager + def painting(self, frame: Box) -> Generator["PaintContext", None, None]: + """Yield a PaintContext scoped to `frame`. + + Sets an SDL clip rectangle = clip ∩ frame so primitives that draw past + the widget's frame are silently dropped. Pops the previous clip on exit. + """ + visible = self.clip.intersection(frame) + if visible.is_empty(): + yield replace(self, frame=frame, clip=visible) + return + old_clip = self.surface.get_clip() + self.surface.set_clip(_pg_rect(visible)) + try: + yield replace(self, frame=frame, clip=visible) + finally: + self.surface.set_clip(old_clip) diff --git a/uilib/panel.py b/uilib/panel.py index 505426a8..aa12c01f 100644 --- a/uilib/panel.py +++ b/uilib/panel.py @@ -13,8 +13,13 @@ # You should have received a copy of the GNU General Public License # along with pi-stomp. If not, see . +from typing import Optional, Tuple + +import pygame + +from uilib.box import Box from uilib.container import * -from pathlib import Path +from uilib.paint import PaintContext, _pg_rect # # Note about coordinates: @@ -25,10 +30,7 @@ # class Panel(ContainerWidget): - """A Panel. This is kind of a 'window' in the traditional sense and holds - a bunch of widgets. It also can track selectable widgets and can be - placed into a PanelStack - """ + """A Panel. Holds widgets, tracks selectable items, can be pushed onto a PanelStack.""" def __init__(self, auto_destroy = False, decorator = None, **kwargs): self.sel_list = [] self.sel = None @@ -52,12 +54,10 @@ def del_sel_widget(self, widget): else: self.sel = None if len(self.sel_list) != 0: - # XXX Maybe be smarter at picking up a new item if previously_selectable: self._select_widget_idx(0) - + def add_sel_widget(self, widget): - """Add a widget to the selectable list""" assert(widget.visible) self.sel_list.append(widget) widget.selectable = True @@ -102,7 +102,7 @@ def sel_next(self): else: new_sel = (self.sel + 1) % len(self.sel_list) self._select_widget_idx(new_sel) - + def sel_prev(self): if len(self.sel_list) == 0: return @@ -136,25 +136,69 @@ def destroy(self): def _get_panel(self): return self - + + class RoundedPanel(Panel): - def __init__(self, radius = 10, **kwargs): - if 'mask_format' not in kwargs: - kwargs['mask_format'] = '1' - super(RoundedPanel,self).__init__(**kwargs) + """A panel with rounded corners. + + Non-virtual panels pre-multiply the rounded mask into the cached surface + in `_finalize_cache()` so steady-state blits are plain. Virtual panels + apply the mask at blit time since the mask tracks the viewport (which + moves through the tall content surface on scroll).""" + + def __init__(self, radius: int = 10, **kwargs): + # Set radius *before* super().__init__() — ContainerWidget.__init__ + # calls _setup() which calls _build_shape_mask(); the mask builder + # reads self.radius. The shape mask itself is populated by _setup(). self.radius = radius + self._shape_mask: Optional[pygame.Surface] = None + kwargs['image_format'] = 'RGBA' + super(RoundedPanel, self).__init__(**kwargs) + + def _build_shape_mask(self) -> pygame.Surface: + """Build the per-corner viewport-sized alpha mask for this panel's outline shape.""" + size = (int(self.box.width), int(self.box.height)) + mask = pygame.Surface(size, pygame.SRCALPHA) + mask.fill((0, 0, 0, 0)) + pygame.draw.rect(mask, (255, 255, 255, 255), + pygame.Rect(0, 0, size[0], size[1]), 0, + border_radius=self.radius) + return mask + + def _setup(self): + super()._setup() + # _setup may have just (re)allocated the backing surface; rebuild the + # mask so it matches the current box. + if self.surface is not None: + self._shape_mask = self._build_shape_mask() + + def _finalize_cache(self) -> None: + if self.virtual or self._shape_mask is None or self.surface is None: + return + self.surface.blit(self._shape_mask, (0, 0), special_flags=pygame.BLEND_RGBA_MULT) - # Setup mask plans - mdraw = ImageDraw.Draw(self.mask) - mdraw.rounded_rectangle(self.box.norm().PIL_rect, radius, 1, None, 0) + def _blit_into(self, target_surface: pygame.Surface, local_clip: Box, dst_topleft: Tuple[int, int]) -> None: + if not self.virtual: + super()._blit_into(target_surface, local_clip, dst_topleft) + return + # Virtual: mask follows the viewport, so we composite per-blit off a + # viewport-local view of the tall cache. local_clip may extend past + # the surface (viewport-clamped); clip rect intersected with view. + assert self._shape_mask is not None + view = self._viewport_view() + view_rect = view.get_rect() + clip_rect = _pg_rect(local_clip).clip(view_rect) + if clip_rect.width <= 0 or clip_rect.height <= 0: + return + tmp = view.subsurface(clip_rect).copy() + tmp.blit(self._shape_mask, (0, 0), area=clip_rect, special_flags=pygame.BLEND_RGBA_MULT) + target_surface.blit(tmp, (int(dst_topleft[0]), int(dst_topleft[1]))) - def _draw_outline(self, image, draw, real_box): + def _draw_outline(self, ctx): if self.outline != 0: - if self.outline_color is not None: - color = self.outline_color - else: - color = self.fgnd_color - draw.rounded_rectangle(real_box.PIL_rect, self.radius, None, color, self.outline) + color = self.outline_color if self.outline_color is not None else self.fgnd_color + ctx.draw_rectangle(ctx.bounds, None, color, self.outline, radius=self.radius) + class LcdBase: def dimensions(self): @@ -163,38 +207,38 @@ def dimensions(self): def default_format(self): pass - def update(self, image, box = None): + def update(self, image, box=None): pass @property def has_system_splash(self) -> bool: return False + class PanelStack(ContainerWidget): - def __init__(self, lcd, box = None, image_format = None, use_dimming = True): + def __init__(self, lcd, box: Optional[Box] = None, image_format: Optional[str] = None, use_dimming: bool = True): # XXX This implementation currently assumes box is at (0,0) in the LCD - # and the offset remains 0,0 (dont' try to scroll) + # and the offset remains 0,0 (don't try to scroll) if box is None: box = Box((0,0), lcd.dimensions()) if image_format is None: image_format = lcd.default_format() - - trace(self, "Panel stack initializing with box=", box) - # Dimming, when enabled, causes panels below the frontmost one to - # be "dimmed" (the further back the more they get dimmed) if use_dimming: image_format = 'RGBA' - super(PanelStack,self).__init__(box = box, image_format = image_format) + + trace(self, "Panel stack initializing with box=", box) + super(PanelStack,self).__init__(box=box, image_format=image_format) self.stack = [] self.current = None self.lcd = lcd self.visible = True if use_dimming: - size = (box.width, box.height) - self.dimmer = Image.new('RGBA', size, (0,0,0,64)) + size = (int(box.width), int(box.height)) + self.dimmer: Optional[pygame.Surface] = pygame.Surface(size, pygame.SRCALPHA) + self.dimmer.fill((0, 0, 0, 64)) else: self.dimmer = None - + # We don't have a parent, establish all the defaults self._setup_act_attrs() self._setup() @@ -205,50 +249,37 @@ def poll_updates(self): if self.lcd_needs_update: self.refresh() - def _compose(self, widget, orig_box, real_box): - # This always called with widget = a Panel which is a direct - # child of the stack, so we can drop orig_box - real_box = real_box.intersection(self.box.norm()) - if not real_box.is_empty(): - self._do_refresh(widget, real_box) - def refresh(self): - self._do_refresh(None, self.box) + self.propagate_dirty(self.box.norm()) self.lcd_needs_update = False - def _do_refresh(self, panel, box): - # XXX TODO: Optimize the case where there is only one panel, - # or the refreshed box only intersects the top level one: - # go straight to LCD ! (If we want to do stacked panels with - # alpha this can get complicated...) - - # Erase image - self._draw_erase(self.image, self.draw, box) - - # XXX Do some alpha blending to "dim" inactive panels ? + def propagate_dirty(self, local_clip: Box): + """Recompose the dirty clip region from all stacked panels, then push to LCD.""" + assert self.surface is not None + clip = local_clip + erase_ctx = PaintContext(self.surface, clip, frame=clip) + self._draw_erase(erase_ctx) - # Compose panels for p in self.stack: if self.dimmer is not None: - self.image.alpha_composite(self.dimmer, box.topleft, box.rect) + self.surface.blit(self.dimmer, clip.topleft, area=_pg_rect(clip)) d = p.decorator if d is not None: - inter = box.intersection(d.box) + inter = clip.intersection(d.box) if not inter.is_empty(): - d.refresh(inter) - inter = box.intersection(p.box) + ctx = PaintContext(self.surface, inter) + d.do_draw(ctx, d.box) + inter = clip.intersection(p.box) if not inter.is_empty(): - # Get intersection in panel local coordinates - local_inter = inter.deoffset(p.box) - super(PanelStack,self)._compose(p, local_inter, inter) + ctx = PaintContext(self.surface, inter) + p.do_draw(ctx, p.box) - # Update LCD - trace(self, "updating lcd with image", self.image, "box=", box) - self.lcd.update(self.image, box) + trace(self, "updating lcd with surface", self.surface, "box=", clip) + self.lcd.update(self.surface, clip) + + def do_draw(self, ctx: PaintContext, frame: Box): + assert False - def _do_draw(self, image, draw, real_box): - assert(False) - def _get_stack(self): return self @@ -256,33 +287,28 @@ def push_panel(self, panel, refresh=True): assert panel not in self.stack assert isinstance(panel, Panel) - # Check if we haven't been attached yet - if panel.parent == None: + if panel.parent is None: panel.attach(self) self.stack.append(panel) - # Input target self.current = panel - panel.show(refresh = False) + panel.show(refresh=False) if refresh: self.refresh() def pop_panel(self, panel): - # panel == None is a special case meaning just pop the current panel if panel is None: panel = self.current assert panel in self.stack self.stack.remove(panel) - panel.hide(refresh = False) + panel.hide(refresh=False) if panel == self.current: if len(self.stack) == 0: current = None else: current = self.stack[-1] self.current = current - # queue a refresh self.lcd_needs_update = True if panel.auto_destroy: -# panel.detach() panel.destroy() def find_panel_type(self, type): @@ -297,10 +323,9 @@ def input_event(self, event): return self.current.input_event(event) return False + class PanelDecorator(Widget): def __init__(self, panel, **kwargs): self.panel = panel - # Default box, will be updated by subclass kwargs['box'] = Box(0,0,0,0) super(PanelDecorator,self).__init__(**kwargs) - diff --git a/uilib/radius.py b/uilib/radius.py new file mode 100644 index 00000000..7e5f3f30 --- /dev/null +++ b/uilib/radius.py @@ -0,0 +1,73 @@ +# This file is part of pi-stomp. +# +# pi-stomp is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# pi-stomp is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with pi-stomp. If not, see . + +from dataclasses import dataclass +from typing import TypedDict + + +class PygameBorderRadiusKwargs(TypedDict): + """The per-corner border-radius kwargs accepted by `pygame.draw.rect`.""" + + border_radius: int + border_top_left_radius: int + border_top_right_radius: int + border_bottom_left_radius: int + border_bottom_right_radius: int + + +@dataclass(frozen=True) +class Radius: + """Per-corner border radii for `PaintContext.draw_rectangle`. + + Pass as `radius=Radius.top(10)` (or `.bottom(...)`, `.uniform(...)`) when + only some corners should round. A bare int is also accepted by + `draw_rectangle` and treated as `Radius.uniform(int)`. + """ + + top_left: int = 0 + top_right: int = 0 + bottom_left: int = 0 + bottom_right: int = 0 + + @classmethod + def uniform(cls, r: int) -> "Radius": + return cls(r, r, r, r) + + @classmethod + def top(cls, r: int) -> "Radius": + return cls(top_left=r, top_right=r) + + @classmethod + def bottom(cls, r: int) -> "Radius": + return cls(bottom_left=r, bottom_right=r) + + @classmethod + def _coerce(cls, value: "int | Radius | None") -> "Radius": + if value is None: + return cls() + if isinstance(value, Radius): + return value + return cls.uniform(int(value)) + + def as_pygame_kwargs(self) -> PygameBorderRadiusKwargs: + # pygame.draw.rect treats negative per-corner radii as "use border_radius". + # Setting border_radius=0 and explicit per-corner values is unambiguous. + return PygameBorderRadiusKwargs( + border_radius=0, + border_top_left_radius=int(self.top_left), + border_top_right_radius=int(self.top_right), + border_bottom_left_radius=int(self.bottom_left), + border_bottom_right_radius=int(self.bottom_right), + ) diff --git a/uilib/text.py b/uilib/text.py index 5860d653..466c42a1 100644 --- a/uilib/text.py +++ b/uilib/text.py @@ -14,8 +14,8 @@ # along with pi-stomp. If not, see . from math import log -from PIL import ImageFont +from uilib._pygame_init import freetype as _get_freetype from uilib.panel import * from uilib.misc import * from uilib.config import * @@ -41,11 +41,20 @@ def __set_mode(self, mode): self.mode = mode cs = self.charsets[mode] mw, mh = 0, 0 + # PIL bbox[3] = asc + max(0, -glyph_min_y). PIL's original code used + # `font.getbbox(c)[3]` as the per-char height, which equals this. + # pygame's rect.height alone is 3px too short for non-descender glyphs + # at 18pt — it would put loc.y 1-2px above where PIL placed it. + asc = int(self.font.get_sized_ascender()) for c in cs: - bbox = self.font.getbbox(c) - w, h = bbox[2] - bbox[0], bbox[3] - mw = max(mw,w) - mh = max(mh,h) + cw, _ = get_text_size(c, self.font) + mw = max(mw, cw) + m = self.font.get_metrics(c)[0] + min_y = m[2] + if min_y >= 0x80000000: + min_y -= 0x100000000 + ch = asc + max(0, -min_y) + mh = max(mh, ch) self.l_w = mw self.l_h = mh self.l_idx %= len(cs) @@ -54,8 +63,8 @@ def __set_mode(self, mode): self.l_count -= 1 self.l_half = self.l_count // 2 - def _draw(self, image, draw, real_box): - loc = (real_box.x0 + self.l_w // 2, real_box.y0 + self.l_h // 2) + def _draw(self, ctx): + loc = (self.l_w // 2, self.l_h // 2) cs = self.charsets[self.mode] for i in range(self.l_idx - self.l_half, self.l_idx + self.l_half): ci = i % len(cs) @@ -68,13 +77,13 @@ def _draw(self, image, draw, real_box): if i != self.l_idx: a = log(abs(self.l_idx - i) + 1) + 1 color = (int(color[0]/a),int(color[1]/a),int(color[2]/a)) - draw.text(loc, cs[ci], fill = color, font = self.font, anchor = 'mm') + ctx.draw_text(loc, cs[ci], fill=color, font=self.font, anchor='mm') loc = (loc[0] + self.l_w, loc[1]) - def _draw_selection(self, image, draw, real_box): - l = real_box.x0 + self.l_w * self.l_half - b = Box(l, real_box.y0, l + self.l_w, real_box.y1) - draw.rounded_rectangle(b.PIL_rect, self.l_w//4, None, self.sel_color, 1) + def _draw_selection(self, ctx): + l = self.l_w * self.l_half + b = Box(l, 0, l + self.l_w, ctx.height) + ctx.draw_rectangle(b, None, self.sel_color, 1, radius=self.l_w // 4) def input_event(self, event): @@ -128,9 +137,10 @@ def __init__(self, widget): self.set_outline(2, (255,255,255)) self.outline = 2 self.curline = widget.text - self.font = ImageFont.truetype("DejaVuSans.ttf", 18) - bbox = self.font.getbbox(widget.edit_message) - msg_w, msg_h = bbox[2] - bbox[0], bbox[3] + from pathlib import Path + _fonts = Path(__file__).resolve().parent.parent / "fonts" + self.font = _get_freetype().Font(str(_fonts / "DejaVuSans.ttf"), 18) + msg_w, msg_h = get_text_size(widget.edit_message, self.font) msg_box = Box.xywh(10, 10, msg_w, msg_h) self.msg = TextWidget(box = msg_box, text = widget.edit_message, font = self.font, parent = self) edit_box = Box.xywh(10,30,280,20) @@ -169,10 +179,9 @@ def __input_action(self, event, data): # XXX TODO: Add alignment features class TextWidget(Widget): """A simple widget with a text string""" - def __init__(self, box, text='', prompt=None, font = None, edit_message = None, h_margin = None, v_margin = None, + def __init__(self, box, text='', font = None, edit_message = None, h_margin = None, v_margin = None, text_halign = None, **kwargs): self.text = text - self.prompt = prompt if font == None: font = Config().get_font('default') self.font = font @@ -180,15 +189,8 @@ def __init__(self, box, text='', prompt=None, font = None, edit_message = None, self.h_margin = h_margin self.v_margin = v_margin self.text_halign = text_halign - self.font_metrics = font.getmetrics() + self.font_metrics = None # legacy field, pygame.freetype encodes size in get_rect self.text_size_valid = False - # TODO Kindof a hack - self.prompt_offset = 0 - if self.prompt is not None: - w, h = get_text_size(self.prompt, self.font, self.font_metrics) - box.x0 += w - box.x1 += w - self.prompt_offset = w super(TextWidget,self).__init__(box, **kwargs) def _get_text_size(self): @@ -220,10 +222,11 @@ def _adjust_box(self): return h_margin, v_margin = self._get_margins() tw, th = self._get_text_size() - # For height, always use at least a full line height so empty-text - # widgets don't collapse to near-zero. - ascent, descent = self.font_metrics - th = max(th, ascent + descent) + # Always use at least a full line height so short / empty text doesn't + # collapse the widget. pygame's get_text_size('', font) returns + # (0, asc+desc) — reuse it instead of PIL-style font.getmetrics(). + _, line_h = get_text_size('', self.font) + th = max(th, line_h) # Add outline to account for PIL rectangles being "inset" extra = self.outline trace(self, "margins=", h_margin, v_margin, "text_size=", tw, th) @@ -246,23 +249,17 @@ def set_edit_message(self, message): def set_font(self, font): self.font = font - self.font_metrics = font.getmetrics() + self.font_metrics = None self.text_size_valid = False self.refresh() SPLIT_SEP = '\u001F' # if present in text exactly once, render as left + right halves - def _draw(self, image, draw, real_box): - # Draw text - # - # XXX TODO: Handle cropping etc... (using continuation characters ?) - # Should we use a local image & support scroll ? basically make this a - # ContainerWidget subclass ? For now assume it fits ... - # + def _draw(self, ctx): h_margin, v_margin = self._get_margins() extra = self.outline - hroom = real_box.width - h_margin - extra - vroom = real_box.height - v_margin - extra + hroom = ctx.width - h_margin - extra + vroom = ctx.height - v_margin - extra if hroom < 0 or vroom < 0: return @@ -271,17 +268,16 @@ def _draw(self, image, draw, real_box): if len(parts) != 2: raise ValueError("TextWidget split text must contain exactly one separator") left, right = parts - lw, lh = get_text_size(left, self.font, self.font_metrics) - rw, rh = get_text_size(right, self.font, self.font_metrics) + _, lh = get_text_size(left, self.font) + rw, rh = get_text_size(right, self.font) th = max(lh, rh) if th > vroom: th = vroom - y = real_box.y0 + v_margin # Extra padding for split rows so the right half doesn't hug the edge. split_pad = 3 - draw.text((real_box.x0 + h_margin + split_pad, y), left, fill=self.fgnd_color, font=self.font) - draw.text((real_box.x0 + real_box.width - h_margin - extra - split_pad - rw, y), - right, fill=self.fgnd_color, font=self.font) + ctx.draw_text((h_margin + split_pad, v_margin), left, fill=self.fgnd_color, font=self.font) + ctx.draw_text((ctx.width - h_margin - extra - split_pad - rw, v_margin), + right, fill=self.fgnd_color, font=self.font) return tw, th = self._get_text_size() @@ -295,11 +291,8 @@ def _draw(self, image, draw, real_box): hoffset = hroom - tw else: hoffset = int((hroom - tw) / 2) - loc = (real_box.x0 + h_margin + hoffset, real_box.y0 + v_margin) - if self.prompt is not None: - #draw.text((loc[0] - self.prompt_offset, loc[1]), self.prompt, fill=self.fgnd_color, font=self.font) - draw.text((0, loc[1]), self.prompt, fill=self.fgnd_color, font=self.font) - draw.text(loc, self.text, fill=self.fgnd_color, font=self.font) + loc = (h_margin + hoffset, v_margin) + ctx.draw_text(loc, self.text, fill=self.fgnd_color, font=self.font) def input_event(self, event): if self.edit_message is not None: diff --git a/uilib/widget.py b/uilib/widget.py index 490bbe28..d881c5b2 100644 --- a/uilib/widget.py +++ b/uilib/widget.py @@ -13,9 +13,14 @@ # You should have received a copy of the GNU General Public License # along with pi-stomp. If not, see . -from enum import Flag +from typing import TYPE_CHECKING, Optional, Tuple from uilib.misc import * from uilib.box import * +from uilib.paint import PaintContext + +if TYPE_CHECKING: + from uilib.container import ContainerWidget + from uilib.panel import PanelStack # This is the root of all evil: the Widget class, parent of all things # displayed on the screen. @@ -103,6 +108,8 @@ def __init__(self, box, align = None, parent = None, visible = True, object=None self.object = object self.selected = False self.selectable = False + self._painted = False + self._dirty = False # Non-inherited attributes self.label = self._get_arg(kwargs, 'label', None) @@ -225,36 +232,47 @@ def _setup_act_attrs(self): # box is established early and thus rely on the stack bounding box. When a # panel is popped off the stack, it still keeps its reference to said stack - def _focus(self, box): - """Prepare for drawing. Called by children of this - box : Box object to draw relative to self origin - Returns a tuple (image, draw, box) where: - image : The image to draw into - draw : An ImageDraw instance for it - box : The box parameter translated into image coordinates - """ - if not self.visible: - return None, None, None - return self.parent._focus(box.offset(self.box)) + def _build_paint_target(self, dirty: Box) -> Tuple["ContainerWidget", Box, Box] | Tuple[None, None, None]: + """Walk up to the nearest ContainerWidget, accumulating frame offset. - def _unfocus(self, box): - """Child finished drawing, handles updates of parent container or - screen as needed + Returns (container, frame, clip) where: + container : the nearest ContainerWidget ancestor (owns the image) + frame : self.box translated into container-local coords + clip : dirty translated into container-local coords, clipped to container bounds + Returns (None, None, None) if no visible ContainerWidget ancestor found. """ - if self.visible: - self.parent._unfocus(box.offset(self.box)) - - def _compose(self, widget, orig_box, real_box): - """ContainerWidget child updated itself""" - if self.visible: - self.parent._compose(widget, orig_box, real_box.offset(self.box)) + from uilib.container import ContainerWidget + + off_x, off_y = 0, 0 + curr = self + while curr is not None: + if not curr.visible: + return None, None, None + + off_x += curr.box.x0 + off_y += curr.box.y0 + + parent = curr.parent + if isinstance(parent, ContainerWidget): + # We found our backing image owner. + # frame = where we are in container local coords + frame = Box.xywh(off_x, off_y, self.box.width, self.box.height) + # clip = the dirty region re-anchored to the same container coords + clip = dirty.offset((off_x - self.box.x0, off_y - self.box.y0)) + return (parent, frame, clip.intersection(parent._content_bounds())) + + curr = parent + + return (None, None, None) def set_outline(self, width, color = None): self.outline = width self.outline_color = color def set_selected(self, selected): - self.selected = selected + if self.selected is not selected: + self.selected = selected + self._dirty = True if selected: if self.scroll_into_view(): # Don't refresh if scroll has made it happen @@ -322,14 +340,24 @@ def attach(self, parent): if self.visible: self._setup_act_attrs() self._setup() + parent._invalidate_cache() def detach(self): """Detach a widget from the parent""" trace(self, "Widget detach, parent=",self.parent) if self.parent is not None: + parent = self.parent self.parent.children.remove(self) self.parent._notify_detach(self) self.parent = None + parent._invalidate_cache() + + def _invalidate_cache(self, box=None): + """Bubble cache invalidation up. Containers override to accumulate + a dirty region before bubbling further. `box` is in self-local coords + (or None ⇒ fully invalidate).""" + if self.parent is not None: + self.parent._invalidate_cache(box) def _adjust_box(self): trace(self, "adjusting box, parent=", self.parent) @@ -373,20 +401,33 @@ def _notify_detach(self, widget): if self.parent: self.parent.notify_detach(widget) - def refresh(self, box = None): - """Refresh widget (and children) + def refresh(self, box=None): + """Refresh widget (and children). + + SDL clipping (set in PaintContext.painting) keeps any out-of-frame + primitives from leaking past the widget's frame, so we draw straight + into the container's surface — no temp buffer. """ - trace(self, "Widget.refresh: vis=",self.visible,"parent=", self.parent) + trace(self, "Widget.refresh: vis=", self.visible, "parent=", self.parent) if self.parent is None or not self.visible: return if box is None: box = self.box if box is None: return - image, draw, real_box = self.parent._focus(box) - if image is not None: - self._do_draw(image, draw, real_box) - self.parent._unfocus(box) + container, frame, clip = self._build_paint_target(box) + if container is None: + return + if clip.is_empty(): + return + if container.virtual and not container._viewport().intersects(frame): + self._dirty = True + return + ctx = PaintContext(container.surface, clip, frame=frame) + self.do_draw(ctx, frame) + self._painted = True + self._dirty = False + container.propagate_dirty(clip) def scroll_into_view(self): """Scroll parent if necessary to ensure this object is into view. Only works @@ -401,50 +442,45 @@ def _scroll_into_view(self, box): return self.parent._scroll_into_view(box.offset(self.box)) return False - def _do_draw(self, image, draw, real_box): - """Draw self and children, internal use only""" - # Note: This erase becomes redudant when refreshing a whole hierarchy, - # not sure it's worth optimizing though - self._draw_erase(image, draw, real_box) - self._draw(image, draw, real_box) - for c in self.children: - if c.visible: - crb = c.box.offset(real_box) - c._do_draw(image, draw, crb) - self._draw_outline(image, draw, real_box) - self._draw_selection(image, draw, real_box) - - # Draw helpers - def _draw_erase(self, image, draw, box): - # Workaround Pillow rectangle off-by-one bug - if self.outline_radius is None: - draw.rectangle(box.PIL_rect, self.bkgnd_color, None, 0) + def do_draw(self, ctx: PaintContext, frame: Box): + """Draw self and children. frame is self's rect in ctx.image coords.""" + if ctx.clip.intersection(frame).is_empty(): + return + with ctx.painting(frame) as pctx: + assert pctx.frame is not None + self._draw_erase(pctx) + self._draw(pctx) + child_origin = pctx.frame.topleft + for c in self.children: + if c.visible: + c.do_draw(pctx, c.box.offset(child_origin)) + self._draw_outline(pctx) + self._draw_selection(pctx) + + def _draw_erase(self, ctx: PaintContext): + erase = ctx.dirty_bounds + if erase.is_empty(): + return + if self.outline_radius is not None and erase == ctx.bounds: + ctx.draw_rectangle(ctx.bounds, fill=self.bkgnd_color, radius=self.outline_radius) else: - draw.rounded_rectangle(box.PIL_rect, self.outline_radius, self.bkgnd_color, None, 0) + ctx.draw_rectangle(erase, fill=self.bkgnd_color) - def _draw_outline(self, image, draw, real_box): + def _draw_outline(self, ctx: PaintContext): if self.outline != 0: - if self.outline_color is not None: - color = self.outline_color - else: - color = self.fgnd_color - if self.outline_radius is None: - draw.rectangle(real_box.PIL_rect, None, color, self.outline) - else: - draw.rounded_rectangle(real_box.PIL_rect, self.outline_radius, None, color, self.outline) + color = self.outline_color if self.outline_color is not None else self.fgnd_color + ctx.draw_rectangle(ctx.bounds, None, color, self.outline, radius=self.outline_radius) - def _draw_selection(self, image, draw, real_box): + def _draw_selection(self, ctx: PaintContext): if self.selected: radius = self.sel_radius if radius is None: radius = self.outline_radius - if radius is None or radius == 0: - draw.rectangle(real_box.PIL_rect, None, self.sel_color, self.sel_width) - else: - draw.rounded_rectangle(real_box.PIL_rect, radius, None, self.sel_color, self.sel_width) + if radius == 0: + radius = None + ctx.draw_rectangle(ctx.bounds, None, self.sel_color, self.sel_width, radius=radius) - def _draw(self, image, draw, real_box): - # It's ok for widgets to not have anything to draw, some are pure rectangles + def _draw(self, ctx: PaintContext): pass def input_event(self, event): @@ -456,7 +492,7 @@ def input_event(self, event): return True return False - def _get_stack(self): + def _get_stack(self) -> PanelStack | None: """Helper to return the top-level panel stack. Useful for creating pop-up dialogs such as text editing helpers """ diff --git a/uv.lock b/uv.lock index c9131ede..4e489915 100644 --- a/uv.lock +++ b/uv.lock @@ -853,6 +853,7 @@ dependencies = [ { name = "jsonschema" }, { name = "pillow" }, { name = "pyalsaaudio", marker = "sys_platform == 'linux'" }, + { name = "pygame-ce" }, { name = "python-rtmidi" }, { name = "pyyaml" }, { name = "requests" }, @@ -895,6 +896,7 @@ requires-dist = [ { name = "matplotlib", marker = "extra == 'hardware'", specifier = ">=3.5" }, { name = "pillow", specifier = ">=9.4" }, { name = "pyalsaaudio", marker = "sys_platform == 'linux'", specifier = ">=0.9" }, + { name = "pygame-ce", specifier = ">=2.5.7" }, { name = "python-rtmidi", specifier = ">=1.4" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "requests", specifier = ">=2.28" }, @@ -1030,6 +1032,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/cd/0731490946e037e954ef83719f07c7672cf32bc90dd9c75201c40b827664/pyftdi-0.57.1-py3-none-any.whl", hash = "sha256:efd3f5a7d43202dc883ff261a7b1cb4dcbbe65b19628f8603a8b1183a7bc2841", size = 146180, upload-time = "2025-08-14T15:59:16.164Z" }, ] +[[package]] +name = "pygame-ce" +version = "2.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/0d/bb6a6fdf1b227c0bb9a44437a70962a3854bcc541533c261e5021a5ee691/pygame_ce-2.5.7.tar.gz", hash = "sha256:86beb797cd73c141299a29b56f7df2b0543fbdc81d428022458329ff694aaa51", size = 5935870, upload-time = "2026-03-02T09:26:59.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/db/4f899c372e114e6f7eab1546c46233fae621594cb0271cd8a967f1b38a06/pygame_ce-2.5.7-cp311-cp311-macosx_10_11_universal2.whl", hash = "sha256:903eab0a59563fd0d134e502a11c9f144d21dc93ee1f5b4b4eec31f8745142b4", size = 17229304, upload-time = "2026-03-02T09:25:33.293Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c7/78d3fc4e27b4372cef878996a3973749bebf553758106dcc0ca7976acf9e/pygame_ce-2.5.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ec2f2d8c95d1a5c9bfc9ffbae4c356fd71bcbc4fde70e59e3d050261bacfe908", size = 12720096, upload-time = "2026-03-02T09:25:36.088Z" }, + { url = "https://files.pythonhosted.org/packages/72/9a/202c2c3f5e0eb8a016ce8bce185992c979866bb73ad7d8ab01d0de39577b/pygame_ce-2.5.7-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:a980109b5aee984b78c884d607d4a406a92b63a8a7044180d0e2f8a26a7276a7", size = 13245692, upload-time = "2026-03-02T09:25:39.14Z" }, + { url = "https://files.pythonhosted.org/packages/4a/de/694c1bffb4c2b1afa5451549b38ae79372552085ec2cac8ec3244691c425/pygame_ce-2.5.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d35105bf5a453ffb64333bc3be685f8c55cbc4695c05733a68849a92345dcb45", size = 12820387, upload-time = "2026-03-02T09:25:41.817Z" }, + { url = "https://files.pythonhosted.org/packages/8c/1f/70b974faa39f3c8d622128eb6554f19b0531792c8fdc8d2575954c320d17/pygame_ce-2.5.7-cp311-cp311-win32.whl", hash = "sha256:ecac266ccd459e354d693f2150a065fc695a6ffcdf15eaca06709620bb837fea", size = 9835598, upload-time = "2026-03-02T09:25:44.387Z" }, + { url = "https://files.pythonhosted.org/packages/12/76/0366387cae2b9f4a2c0ccaa9a84f86db545e835bd933dc14d011765962f9/pygame_ce-2.5.7-cp311-cp311-win_amd64.whl", hash = "sha256:5ca51a671b5b6cfd747399f54fa91259d4b01980f517c9e8043f5c3e795f06ac", size = 10420750, upload-time = "2026-03-02T09:25:46.96Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c1/e69e7ae0f66fc21e8af25d456f396e15c47002bdba86924b1e223d4b7fbd/pygame_ce-2.5.7-cp311-cp311-win_arm64.whl", hash = "sha256:1f5a7e5d08f26dc8e4a899be502e6ef06ac5a2800a442750267141bca7703ba7", size = 10858323, upload-time = "2026-03-02T09:25:49.572Z" }, + { url = "https://files.pythonhosted.org/packages/57/a7/cd305034f505bfa1a1acdafd3d86af54da14b29151a0d99f348306272773/pygame_ce-2.5.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dca2f8ba56bf3b4b8c73c4863d0603773403f4f66bc2fe25784ea74aee3fffc3", size = 17210332, upload-time = "2026-03-02T09:25:52.18Z" }, + { url = "https://files.pythonhosted.org/packages/e5/94/7f6304a31ccc7d11d4443e590cbfa6fce2bd34077575a7790d4f0a14f440/pygame_ce-2.5.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4dc989462faf2c5947881f708fa406d7ac13c1bc987c1588d630ca62ad369989", size = 12709001, upload-time = "2026-03-02T09:25:54.806Z" }, + { url = "https://files.pythonhosted.org/packages/09/8b/a7886b7bfe874fa381201ee538928af3c1cab2fe3e36927ed08f3f1d0b61/pygame_ce-2.5.7-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:538689d77c44f5dbefc0abeab5b9806d6c90ebc6d64078736c8a4c731f08b37e", size = 13236706, upload-time = "2026-03-02T09:25:57.524Z" }, + { url = "https://files.pythonhosted.org/packages/db/17/70ada4cef84eca48a995cc9e0f2f087e6190b3e4111e9c8ec3c7a8f689bd/pygame_ce-2.5.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0d8fa8822c0f4b5ae6b44ada7be9f2193fd512da1381c37fa3c5bda8971d64cb", size = 12812909, upload-time = "2026-03-02T09:25:59.971Z" }, + { url = "https://files.pythonhosted.org/packages/40/db/1cfcfb7813ce6d2202919ad87c7e1b278a4f820902ba826df43dc8906e34/pygame_ce-2.5.7-cp312-cp312-win32.whl", hash = "sha256:604207c8813a594919bb7e6d3ed3834f79aa099d2d648e8a8fc3d786ad3fa1c8", size = 9837595, upload-time = "2026-03-02T09:26:02.573Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d4/43b006affa13e2f4572aa28a0d1f64c91c330d289c30a022b5416245714c/pygame_ce-2.5.7-cp312-cp312-win_amd64.whl", hash = "sha256:b6b7eec2779fac11ed265a18ab926b3829654120a5c9a07c36eeedeb012b8c3c", size = 10414958, upload-time = "2026-03-02T09:26:05.25Z" }, + { url = "https://files.pythonhosted.org/packages/18/1b/971a432d8bab8c52031ddbd0e681750a57348213ec0f8a0e0d6713e8df46/pygame_ce-2.5.7-cp312-cp312-win_arm64.whl", hash = "sha256:eb99a8a7185057064163610b3ca3e1d3307f0eb3dfff6e19556fa5132c6bca87", size = 10859829, upload-time = "2026-03-02T09:26:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/21/96/d28381210ec2ed5d7a04f77cd8f6949a7734f75a7a7d9a95fe8ed60fe3ba/pygame_ce-2.5.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8766985d802017e150fcfb9adc659967d8548a961d491b8974f0748412cc0427", size = 17203240, upload-time = "2026-03-02T09:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f0/a3e4d0ad2519d106d178652ae7ec692e8acadf3d2260493840c27f8eabb8/pygame_ce-2.5.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eabfc184dd28c682e5e6ccfce01224bb12653c6648babcd0bb9d9bd662d938", size = 12705532, upload-time = "2026-03-02T09:26:13.795Z" }, + { url = "https://files.pythonhosted.org/packages/13/0d/6d6c29aa5ecf63a66983bdf04066dc019290e7412f64b0ae5133c5e53d47/pygame_ce-2.5.7-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:92d5732cd512c8ec0b47e9a3e6c683fb811bdf2090362580778ce698222a64ab", size = 13233559, upload-time = "2026-03-02T09:26:16.202Z" }, + { url = "https://files.pythonhosted.org/packages/fe/96/e400d3a2c6456e3a334d9fae1f8704d964027b60064935278028dd79293f/pygame_ce-2.5.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:48e9a4ece43b08adc8ffb0bbbe0fe183eef4bb501a7d31552ae7398592ec0a82", size = 12811409, upload-time = "2026-03-02T09:26:19.148Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ed/8e3c6f5d4ac763f6766c2043f842b2c52028b30a4ce2ce2f5dced5961354/pygame_ce-2.5.7-cp313-cp313-win32.whl", hash = "sha256:f6439100bdee81da96f5acffb280f20511c93dd67178d8a8cf29d6cfd1aa46dd", size = 9835553, upload-time = "2026-03-02T09:26:22.123Z" }, + { url = "https://files.pythonhosted.org/packages/06/a0/7acd164b3a206327bf53df5e5399227a5baf858ae2944cba562a0ae77e0a/pygame_ce-2.5.7-cp313-cp313-win_amd64.whl", hash = "sha256:25047c97760fc640a6a8bbfdf3398c5825adfd55986a7523a65c95a1fb61d759", size = 10413012, upload-time = "2026-03-02T09:26:24.747Z" }, + { url = "https://files.pythonhosted.org/packages/47/d5/e4419a340ae24bd0dde5a6861406c24114f59a543aad44f1d2f2ae4752d5/pygame_ce-2.5.7-cp313-cp313-win_arm64.whl", hash = "sha256:f5a7197096ef82d588539f13b0f7ade767f800c7b0b015a94e386f488e1039f6", size = 10856939, upload-time = "2026-03-02T09:26:27.107Z" }, + { url = "https://files.pythonhosted.org/packages/26/08/9fe0003d69077ff8faff242a85260b8a192d3b7111c02332b8b2f515d40c/pygame_ce-2.5.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:addfbf7d119647eec157d611f463e5d572836fa74d0d222c59210294ed618b9d", size = 17211017, upload-time = "2026-03-02T09:26:30.243Z" }, + { url = "https://files.pythonhosted.org/packages/40/c7/217c5430c8c612879162beb2d18cb10ad8b1b4db6ce03245289a3de6bc1b/pygame_ce-2.5.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1c92feb289e34ac323a7b62b91d230a4e32b44059c4d7fb95631e504b1d9b365", size = 12710845, upload-time = "2026-03-02T09:26:33.165Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/6fb0054bfb2522c4ad3ff82ecbc9c1a3c6694c50d24f2451717121636a1d/pygame_ce-2.5.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c1ed4100311015cc67d24fe4e4d7c2f4cb25c34e043545061271c1d018c02d4", size = 12812020, upload-time = "2026-03-02T09:26:35.953Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/0e0996573a9dd4bb34874c10643224a72372818242e07486c9eb4a22eebb/pygame_ce-2.5.7-cp314-cp314-win32.whl", hash = "sha256:1b7e641af3aeac92bb7e4df1402d6802271f4bd09c747d2665f154ef9b07a4e6", size = 9968590, upload-time = "2026-03-02T09:26:38.517Z" }, + { url = "https://files.pythonhosted.org/packages/96/96/c94d0e508954093e37a77b03101a3cf51a242e02cc0ec726edcb117f8885/pygame_ce-2.5.7-cp314-cp314-win_amd64.whl", hash = "sha256:595f4257c15fed11cd816ae0bfabad3d99f8f04a000d7d164a22c0f9afd6cb65", size = 10581240, upload-time = "2026-03-02T09:26:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/81/bb/93c5dadb66ac9733ca52952e063cce117a9312d6f97facd5dc4932f3d777/pygame_ce-2.5.7-cp314-cp314-win_arm64.whl", hash = "sha256:3e6ce74fd5a5f146f1bcf0224ae3e880c733917a9edbb53f790329d235520433", size = 11057480, upload-time = "2026-03-02T09:26:43.871Z" }, + { url = "https://files.pythonhosted.org/packages/69/55/c4397d5c0c0d459d3c0dbda0cb55f7fae778117c6a3d89736968f2978a3e/pygame_ce-2.5.7-pp311-pypy311_pp73-macosx_10_15_universal2.whl", hash = "sha256:8718bc75cd4ec4bd2b0b4862e5c615b119a45038e4572c9030731db3c6740cfe", size = 17126026, upload-time = "2026-03-02T09:26:46.441Z" }, + { url = "https://files.pythonhosted.org/packages/28/7c/dad02bf97b55a71bff3e60e8909ac2be58f27520094e9182ed00ea70e4e4/pygame_ce-2.5.7-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6e4289438b0b530bbfa6e2a9088a5b4a9dc0524b6d60989c846e2c74cd149f77", size = 12670197, upload-time = "2026-03-02T09:26:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/1c/da/1506934539127781ea52bd826ceb620c4a7456c52bd5410d12259a95741c/pygame_ce-2.5.7-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:01c48daf8fccb66e8750d5d26bf20e3f0dc484a5f7748fa98fd7e2ac69a2f7f5", size = 13186464, upload-time = "2026-03-02T09:26:51.784Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6f/4ba6bd545113767ea1fc8204d2d6616fd749bb4f931c5357f90adf45ddd7/pygame_ce-2.5.7-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8ac7120a6c39c042abdc6bd2f5491f7121936c68c425235b52dc451ed9583dbf", size = 12767531, upload-time = "2026-03-02T09:26:54.314Z" }, + { url = "https://files.pythonhosted.org/packages/21/e8/5e3782901f10ce39724797b4ed48138da69ce03332dde7bc21c15475684f/pygame_ce-2.5.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d79c4f961ffa89183380c4284914ed2f693591baec7fb92e42f05857dd8664bd", size = 10376956, upload-time = "2026-03-02T09:26:56.724Z" }, +] + [[package]] name = "pygments" version = "2.19.2"