Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ Notable user visible changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Changed
- Pedalboard/snapshot titles now auto-scroll only while selected with the NAV encoder (at most one thing scrolls at a time, and they sit at their leftmost position otherwise) — LCD updates over SPI are audible on the DAC at high gain, so the screen stays quiet while you play
### Fixed
- Long titles scrolled slightly past their last pixel before bouncing back; the scroll window now matches the drawn text area exactly

## [v3.3.1] - 2026-08-14
### Fixed
- Pedalboards list no longer shows deleted pedalboards after they are removed from MOD-UI
Expand Down
2 changes: 2 additions & 0 deletions docs/lcd_worker_thread.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
A full-frame push blocks the UI thread for **21.4 ms on v2** and **25.2 ms on v3**,
against a 10 ms tick. Nothing mitigates this today.

LCD SPI transfers can be audible through the DAC at high gain, so avoid LCD updates that are not associated with the user's input.

`PanelStack.propagate_dirty` gates pushes on `transfer_ms(clip) <= INLINE_BUDGET_MS`
(8 ms), which *looks* like it protects the poll loop. It doesn't. The deferred path
— `poll_lcd_updates()` → `flush()` → `lcd.update()` — runs on the **same UI thread**
Expand Down
11 changes: 5 additions & 6 deletions pistomp/lcd320x240.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,10 +282,11 @@ def poll_updates(self):
def _poll_updates(self):
for d in self.w_parameter_dialogs.values():
d.tick()
if self.w_pedalboard is not None:
self.w_pedalboard.tick()
if self.w_preset is not None:
self.w_preset.tick()
if self.pstack.current is self.main_panel:
if self.w_pedalboard is not None:
self.w_pedalboard.tick()
if self.w_preset is not None:
self.w_preset.tick()
for wfs in self.w_footswitches:
wfs.tick()

Expand Down Expand Up @@ -481,7 +482,6 @@ def draw_pedalboard(self, pedalboard_name):
font=self.title_font,
parent=self.main_panel,
action=self.draw_pedalboard_menu,
lcd_poll_divisor=self.poll_divisor,
subtitle="Pedalboard",
)
self.main_panel.add_sel_widget(self.w_pedalboard)
Expand Down Expand Up @@ -514,7 +514,6 @@ def draw_preset(self, preset_name):
font=self.title_font,
parent=self.main_panel,
action=self.draw_preset_menu,
lcd_poll_divisor=self.poll_divisor,
subtitle="Snapshot",
)
self.main_panel.add_sel_widget(self.w_preset)
Expand Down
25 changes: 25 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@

import os
import sys
import time
from pathlib import Path
from typing import Protocol
from unittest.mock import MagicMock

import numpy as np
Expand Down Expand Up @@ -233,6 +235,29 @@ def fake_lcd():
return FakeLcd()


class Tickable(Protocol):
def tick(self) -> None: ...


class FakeClock:
def __init__(self, monkeypatch: pytest.MonkeyPatch, start: float = 0.0) -> None:
self.now = start
monkeypatch.setattr(time, "monotonic", lambda: self.now)

def advance(self, dt: float) -> None:
self.now += dt

def drive(self, widget: Tickable, dt: float, n: int = 1) -> None:
for _ in range(n):
self.advance(dt)
widget.tick()


@pytest.fixture
def fake_clock(monkeypatch: pytest.MonkeyPatch) -> FakeClock:
return FakeClock(monkeypatch)


# ---------------------------------------------------------------------------
# Shared factory fixtures (available to all test directories)
# ---------------------------------------------------------------------------
Expand Down
119 changes: 119 additions & 0 deletions tests/test_lcd320x240.py
Original file line number Diff line number Diff line change
Expand Up @@ -832,3 +832,122 @@ def test_tall_parallel_scrolled_to_last(lcd, snapshot):
for _ in range(col0_count - 1 + 3):
instance.main_panel.sel_next()
snapshot("scrolled_to_last")


@pytest.fixture
def long_title_lcd(lcd, fake_clock):
instance, fake = lcd
pb = _make_pedalboard("The Extremely Long Pedalboard Name That Will Not Fit", [], [])
_setup_pedalboard(instance, pb)
instance.main_panel.sel_widget(instance.w_wrench)
for _ in range(20):
fake_clock.advance(0.08)
instance._poll_updates()
fake.frames.clear()
return instance, fake


def test_long_title_stays_still_until_selected(long_title_lcd, fake_clock):
instance, fake = long_title_lcd
title = instance.w_pedalboard
assert title is not None and title._should_scroll()

for _ in range(100):
fake_clock.advance(0.08)
instance._poll_updates()
assert fake.frames == []
assert title.scroll_offset == 0


def test_long_title_scrolls_only_while_selected(long_title_lcd, fake_clock):
instance, fake = long_title_lcd
title = instance.w_pedalboard
assert title is not None

def settle():
for _ in range(20):
fake_clock.advance(0.08)
instance._poll_updates()
fake.frames.clear()

for _ in range(25):
fake_clock.advance(0.08)
instance._poll_updates()
assert title.scroll_offset == 0
assert fake.frames == []

instance.main_panel.input_step(1, 1, 1.0)
assert instance.main_panel.sel_ref is title
assert title.selected
settle()

for _ in range(35):
fake_clock.advance(0.08)
instance._poll_updates()
assert title.scroll_offset > 0
assert len(fake.frames) > 0

instance.main_panel.input_step(1, 1, 1.0)
assert not title.selected
assert title.scroll_offset == 0
settle()
for _ in range(50):
fake_clock.advance(0.08)
instance._poll_updates()
assert fake.frames == []
assert title.scroll_offset == 0
fake.frames.clear()


def test_title_does_not_tick_off_main_panel(long_title_lcd, fake_clock):
instance, fake = long_title_lcd
title = instance.w_pedalboard
assert title is not None
instance.main_panel.sel_widget(title)
for _ in range(35):
fake_clock.advance(0.08)
instance._poll_updates()
assert title.scroll_offset > 0
fake.frames.clear()

instance.pstack.current = None
offset = title.scroll_offset
for _ in range(50):
fake_clock.advance(0.08)
instance._poll_updates()
assert title.scroll_offset == offset
assert fake.frames == []

def test_at_most_one_title_scrolls(long_title_lcd, fake_clock):
instance, _ = long_title_lcd
instance.draw_preset("An Equally Long Snapshot Name That Overflows")
pb_title = instance.w_pedalboard
preset_title = instance.w_preset
assert pb_title is not None and preset_title is not None
assert pb_title._should_scroll() and preset_title._should_scroll()

def moving():
return [w for w in (pb_title, preset_title) if w.scroll_offset != 0]

instance.main_panel.sel_widget(instance.w_wrench)
for _ in range(100):
fake_clock.advance(0.08)
instance._poll_updates()
assert moving() == []

instance.main_panel.sel_widget(pb_title)
for _ in range(120):
fake_clock.advance(0.08)
instance._poll_updates()
assert len(moving()) <= 1
assert preset_title.scroll_offset == 0
assert pb_title.scroll_offset > 0

instance.main_panel.sel_widget(preset_title)
assert pb_title.scroll_offset == 0
for _ in range(120):
fake_clock.advance(0.08)
instance._poll_updates()
assert len(moving()) <= 1
assert pb_title.scroll_offset == 0
assert preset_title.scroll_offset > 0
179 changes: 179 additions & 0 deletions tests/test_scrolling_text.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@

import pytest

from common.fonts import font_path
from tests.conftest import FakeClock
from uilib.box import Box
from uilib.panel import Panel
from uilib.pygame_init import font as make_font
from uilib.text import ScrollingText


LONG = "The Extremely Long Pedalboard Name That Will Not Fit"
SHORT = "Rig"

DT = 0.1


@pytest.fixture
def font():
return make_font(font_path("DejaVuSans.ttf"), 26)


def make_text(font, text=LONG, width=60, parent=None):
w = ScrollingText(
box=Box.xywh(0, 0, width, 36),
text=text,
font=font,
parent=parent,
)
assert w.box is not None
return w


def make_panel():
p = Panel(box=Box.xywh(0, 0, 320, 240))
p.visible = True
return p


def max_offset_of(w):
h_margin, _ = w._get_margins()
return w.cached_text_width - (w.box.width - h_margin - w.outline)


@pytest.fixture
def clock(monkeypatch):
return FakeClock(monkeypatch)




def test_unselected_never_scrolls(clock, font):
w = make_text(font)
w._render_text_to_cache()
assert w._should_scroll(), "fixture must overflow"

end = w.pause_start_sec + (max_offset_of(w) / w.pixels_per_second) * 2 + w.pause_end_sec + 5.0
t = 0.0
while t < end:
t += DT
clock.now = t
w.tick()
assert w.scroll_offset == 0, f"unselected widget scrolled to {w.scroll_offset} at t={t}"


def test_hidden_never_scrolls(clock, font):
w = make_text(font)
w.selected = True
w.visible = False
clock.drive(w, DT, n=60)
assert w.scroll_offset == 0




def test_selected_scrolls_and_pingpongs(clock, font):
w = make_text(font)
w._render_text_to_cache()
w.selected = True

max_off = max_offset_of(w)
scroll_dur = max_off / w.pixels_per_second

clock.drive(w, DT)
t0 = clock.now

while clock.now < t0 + w.pause_start_sec:
clock.drive(w, DT)
assert w.scroll_offset == 0

last = 0
t_end = t0 + w.pause_start_sec + scroll_dur
while clock.now < t_end:
clock.drive(w, DT)
assert w.scroll_offset >= last
last = w.scroll_offset
clock.now = t_end
w.tick()
assert w.scroll_offset == max_off

while clock.now < t_end + w.pause_end_sec:
clock.drive(w, DT)
assert w.scroll_offset == max_off

t_home = t_end + w.pause_end_sec + scroll_dur
while clock.now < t_home:
clock.drive(w, DT)
clock.now = t_home
w.tick()
assert w.scroll_offset == 0


def test_text_that_fits_never_scrolls(clock, font):
w = make_text(font, text=SHORT, width=200)
w.selected = True
clock.drive(w, DT, n=100)
assert w.scroll_offset == 0




def test_deselect_snaps_home_and_stays(clock, font):
w = make_text(font)
w._render_text_to_cache()
w.selected = True

max_off = max_offset_of(w)
clock.drive(w, DT)
t0 = clock.now
while clock.now < t0 + w.pause_start_sec + max_off / w.pixels_per_second:
clock.drive(w, DT)
assert w.scroll_offset == max_off

w.set_selected(False)
assert w.scroll_offset == 0

clock.drive(w, DT, n=200)
assert w.scroll_offset == 0
assert w._anchor_time is None




def test_panel_selection_moves_the_single_scroller(clock, font):
panel = make_panel()
a = make_text(font, parent=panel, width=60)
b = make_text(font, parent=panel, width=60)
panel.add_sel_widget(a)
panel.add_sel_widget(b)
panel.sel_ref = a
a.selected = True
a._render_text_to_cache()
b._render_text_to_cache()

def offsets():
return (a.scroll_offset, b.scroll_offset)

clock.drive(a, DT, n=int((a.pause_start_sec + max_offset_of(a) / a.pixels_per_second * 0.5) / DT))
oa, ob = offsets()
assert oa > 0
assert ob == 0

panel.sel_widget(b)
clock.drive(b, DT, n=int((b.pause_start_sec + max_offset_of(b) / b.pixels_per_second * 0.5) / DT))
assert a.scroll_offset == 0
assert b.scroll_offset > 0

panel.sel_widget(_plain_widget(panel, font))
clock.drive(a, DT, n=50)
clock.drive(b, DT, n=50)
assert offsets() == (0, 0)


def _plain_widget(panel, font):
from uilib.text import TextWidget

w = TextWidget(box=Box.xywh(0, 100, 60, 36), text="plain", font=font, parent=panel)
panel.add_sel_widget(w)
return w
Loading
Loading