Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
172c871
Paint Context object for simplifying clip
sastraxi May 12, 2026
78bee77
Fix bugs and add paint context unit tests
sastraxi May 12, 2026
af24bac
Add wifi SSID entry snapshot test
sastraxi May 18, 2026
9f8d8bf
Merge branch 'test/wifi-paint-snapshot' into refactor/paint-context
sastraxi May 18, 2026
cfdda1f
Implement a clip buffer for pixel-perfect rendering
sastraxi May 19, 2026
b7483de
Finish the refactor
sastraxi May 19, 2026
8642187
Use footswitch width/height everywhere
sastraxi May 19, 2026
ed5f281
Change to protocol strategy
sastraxi May 19, 2026
58e47de
Clarity
sastraxi May 19, 2026
952b164
Fix circular import
sastraxi May 19, 2026
88dfb12
Fix code review findings
sastraxi May 19, 2026
9b836a4
Paint primitives
sastraxi May 19, 2026
7496239
Add dirty painting
sastraxi May 19, 2026
eaa53cd
Virtual menu rendering
sastraxi May 19, 2026
66a3e8a
Scroll-by-blit tests
sastraxi May 19, 2026
5b77ff7
Cache valid; one small rendering bug.
sastraxi May 20, 2026
40f4aa6
Fix hacky SSID/Passwd "prompts"
sastraxi May 20, 2026
da651a1
Remove unnecessary tests
sastraxi May 20, 2026
fa2fe61
Fix text bleed rendering
sastraxi May 20, 2026
2e956bc
Don't over-invalidate parent hierarchy
sastraxi May 20, 2026
db5afdf
Seal the widget-facing API
sastraxi May 20, 2026
dfc59ec
add pygame
sastraxi May 20, 2026
63c8686
pygame swap WIP
sastraxi May 20, 2026
94c55aa
Nailed most of it
sastraxi May 20, 2026
5b500bf
Fix the menu item shape mask
sastraxi May 20, 2026
8aabc77
Continued iteration
sastraxi May 20, 2026
5e65820
Almost pristine text rendering
sastraxi May 20, 2026
f2b7327
Fixed rendering
sastraxi May 20, 2026
44876e9
Okay but still bad snapshots
sastraxi May 20, 2026
08284db
Revert "Okay but still bad snapshots"
sastraxi May 20, 2026
547fe9b
Override pygame colours with Pillow ones
sastraxi May 20, 2026
639ffa1
Better parity
sastraxi May 20, 2026
d64640f
Add new tests
sastraxi May 20, 2026
fba4ded
Fix final parity bugs
sastraxi May 20, 2026
41375ae
Accepted snapshots
sastraxi May 20, 2026
de087cf
Off-by-one text snapshots, due to using metrics instead of measuring …
sastraxi May 20, 2026
d9b7c67
Stop rendering the dialog titlebar background over the whole dialog; …
sastraxi May 20, 2026
b10b3d4
Use subsurface to simplify text painting
sastraxi May 21, 2026
607674c
Pre-multiply RoundedPanel mask into cache
sastraxi May 21, 2026
8c7618a
Replace push up with lazy pull
sastraxi May 21, 2026
b04461f
Replace _cache_valid with dirty_region and unions
sastraxi May 21, 2026
c690150
Viewports are subsurfacs for virtual containers
sastraxi May 21, 2026
6f9acaf
Cleaner way to do border radii and only fill titlebars
sastraxi May 21, 2026
51c54b8
Simplifications
sastraxi May 21, 2026
2e3bb8b
Encapsulation wins
sastraxi May 21, 2026
452905d
Add a paint readme
sastraxi May 21, 2026
aabd817
Merge branch 'pistomp-v3' into feat/pygame-swap
sastraxi May 21, 2026
227f5cf
Initial fixes for wifi menu text rendering
sastraxi May 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions pistomp/category.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
# along with pi-stomp. If not, see <https://www.gnu.org/licenses/>.

import logging
from PIL import ImageColor
import pygame
import common.util as util


Expand All @@ -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

Expand Down
44 changes: 25 additions & 19 deletions pistomp/lcd320x240.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -72,19 +73,22 @@ 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
self.display_height = 240
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]
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
5 changes: 3 additions & 2 deletions pistomp/ledstrip.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
# along with pi-stomp. If not, see <https://www.gnu.org/licenses/>.

import matplotlib
from PIL import ImageColor
import pygame

import common.util as Util
import pistomp.category as Category
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
66 changes: 29 additions & 37 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand All @@ -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):
Expand All @@ -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)
Expand All @@ -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
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/snapshots/test_lcd320x240/test_main_panel_snapshot/0.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/snapshots/test_lcd320x240/test_splash_snapshot/0.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/snapshots/test_lcd320x240/test_system_menu_snapshot/0.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/snapshots/test_lcd320x240/test_tap_tempo_snapshot/0.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/snapshots/test_lcd320x240/test_wifi_menu_snapshot/0.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/snapshots/v3/test_startup/test_v3_footswitch_press/0.png
Binary file modified tests/snapshots/v3/test_startup/test_v3_nav_to_system_menu/0.png
Binary file modified tests/snapshots/v3/test_startup/test_v3_startup_snapshot/0.png
Loading
Loading