ML-186: improve presto redraw performance with partial updates

This commit is contained in:
Claudio Ortolina
2026-05-16 21:29:02 +01:00
parent d61dd6c7b8
commit aab16b2c66
3 changed files with 358 additions and 49 deletions
+159 -49
View File
@@ -115,6 +115,10 @@ DAY_HEADER_Y = HEADER_Y
DAY_HEADER_H = HEADER_H
DAY_HEADER_TEXT_H = HEADER_TEXT_H
DAY_COUNT_TEXT_H = TEXT_H
SCROLL_VIEWPORT_X = 0
SCROLL_VIEWPORT_Y = DAY_HEADER_Y + DAY_HEADER_H
SCROLL_VIEWPORT_W = WIDTH
SCROLL_VIEWPORT_H = HEIGHT - SCROLL_VIEWPORT_Y
# Detail view
DETAIL_COVER_SIZE = px(200)
@@ -202,6 +206,12 @@ KB_KEY_W = (WIDTH - 2 * KB_MARGIN - 9 * KB_KEY_GAP) // 10
KB_INPUT_Y = px(42)
KB_INPUT_H = px(28)
KB_INPUT_MARGIN = px(40)
KB_INPUT_X = KB_INPUT_MARGIN
KB_INPUT_W = WIDTH - (2 * KB_INPUT_MARGIN)
KB_INPUT_UPDATE_X = 0
KB_INPUT_UPDATE_Y = KB_INPUT_Y
KB_INPUT_UPDATE_W = WIDTH
KB_INPUT_UPDATE_H = KB_INPUT_H
KB_ROWS_START_Y = KB_INPUT_Y + KB_INPUT_H + px(12)
ALPHA_KEYS = [
@@ -353,6 +363,44 @@ def _init_pens():
_pen_scrobble_done_text = display.create_pen(*SCROBBLE_DONE_TEXT)
# ============================================================================
# HELPER: DISPLAY UPDATES
# ============================================================================
def _display_update():
"""Push the full display buffer to the screen."""
presto.update()
def _partial_display_update(x, y, w, h):
"""Push a bounded display region, falling back to a full update."""
if w <= 0 or h <= 0:
return
try:
presto.partial_update(x, y, w, h)
except Exception:
_display_update()
def _clear_region(x, y, w, h):
"""Clear a bounded display region to the app background."""
display.set_pen(_pen_bg)
display.rectangle(x, y, w, h)
def _clear_scroll_viewport():
"""Clear the scrollable content area below the fixed header."""
_clear_region(SCROLL_VIEWPORT_X, SCROLL_VIEWPORT_Y,
SCROLL_VIEWPORT_W, SCROLL_VIEWPORT_H)
def _partial_scroll_viewport_update():
"""Push the scrollable content area below the fixed header."""
_partial_display_update(SCROLL_VIEWPORT_X, SCROLL_VIEWPORT_Y,
SCROLL_VIEWPORT_W, SCROLL_VIEWPORT_H)
# ============================================================================
# HELPER: TEXT WRAPPING
# ============================================================================
@@ -927,7 +975,7 @@ def draw_status(message):
x = (WIDTH - w) // 2
display.text(line, x, start_y + i * line_h, scale=TEXT_SCALE)
presto.update()
_display_update()
# ============================================================================
@@ -943,7 +991,7 @@ def draw_month_view(app):
_draw_month_header(app)
_draw_day_labels()
_draw_day_grid(app)
presto.update()
_display_update()
HOME_BTN_W = px(30)
@@ -1118,7 +1166,7 @@ def draw_home_screen(app):
_draw_home_button(HOME_BUTTON1_Y, "Search Collection")
_draw_home_button(HOME_BUTTON2_Y, "Today's Records")
presto.update()
_display_update()
def _draw_home_button(y, label):
@@ -1143,24 +1191,35 @@ def draw_search_input(app):
display.clear()
_draw_search_input_header()
_draw_search_query_field(app)
_draw_keyboard(app)
_display_update()
def _draw_search_query_field(app):
"""Draw the search query field without touching the keyboard."""
_clear_region(KB_INPUT_UPDATE_X, KB_INPUT_UPDATE_Y,
KB_INPUT_UPDATE_W, KB_INPUT_UPDATE_H)
# Query text field
display.set_pen(_pen_cell_bg)
display.rectangle(KB_INPUT_MARGIN, KB_INPUT_Y,
WIDTH - 2 * KB_INPUT_MARGIN, KB_INPUT_H)
display.rectangle(KB_INPUT_X, KB_INPUT_Y, KB_INPUT_W, KB_INPUT_H)
display.set_pen(_pen_header_text)
display.set_font("bitmap8")
# Show cursor as underscore if query is empty
display_q = app.search.query if app.search.query else "_"
display.text(
display_q,
KB_INPUT_MARGIN + px(8),
KB_INPUT_X + px(8),
KB_INPUT_Y + px(12),
scale=TEXT_SCALE
)
_draw_keyboard(app)
presto.update()
def _redraw_search_query_field(app):
"""Redraw and push only the search query field row."""
_draw_search_query_field(app)
_partial_display_update(KB_INPUT_UPDATE_X, KB_INPUT_UPDATE_Y,
KB_INPUT_UPDATE_W, KB_INPUT_UPDATE_H)
def _draw_search_input_header():
@@ -1272,7 +1331,7 @@ def _flash_key(kx, ky, kw, kh, label):
_draw_key(kx, ky, label, kw, kh, text_pen=_pen_today_text)
display.set_pen(_pen_arrow)
display.rectangle(kx, ky, kw, kh)
presto.update()
_partial_display_update(kx, ky, kw, kh)
time.sleep(0.05)
@@ -1280,27 +1339,39 @@ def _flash_key(kx, ky, kw, kh, label):
# DRAWING: SEARCH RESULTS
# ============================================================================
def draw_search_results(app):
def draw_search_results(app, partial=False):
"""Render the search results view, reusing record-list rendering."""
display.set_pen(_pen_bg)
display.clear()
if partial:
_clear_scroll_viewport()
else:
display.set_pen(_pen_bg)
display.clear()
_draw_search_results_header(app)
_draw_search_results_header(app)
if app.search.error:
_draw_search_error()
presto.update()
if partial:
_partial_scroll_viewport_update()
else:
_display_update()
return
if not app.search.results:
_draw_search_empty()
presto.update()
if partial:
_partial_scroll_viewport_update()
else:
_display_update()
return
_set_clip_below_header()
_draw_search_record_list(app)
_remove_clip()
presto.update()
if partial:
_partial_scroll_viewport_update()
else:
_display_update()
def _draw_search_results_header(app):
@@ -1402,27 +1473,39 @@ def _draw_search_error():
# DRAWING: DAY VIEW (records list)
# ============================================================================
def draw_day_view(app):
def draw_day_view(app, partial=False):
"""Render the day view showing records for the selected date."""
display.set_pen(_pen_bg)
display.clear()
if partial:
_clear_scroll_viewport()
else:
display.set_pen(_pen_bg)
display.clear()
_draw_day_header(app)
_draw_day_header(app)
if app.day.error:
_draw_day_error()
presto.update()
if partial:
_partial_scroll_viewport_update()
else:
_display_update()
return
if not app.day.records:
_draw_day_empty()
presto.update()
if partial:
_partial_scroll_viewport_update()
else:
_display_update()
return
_set_clip_below_header()
_draw_record_list(app)
_remove_clip()
presto.update()
if partial:
_partial_scroll_viewport_update()
else:
_display_update()
def _draw_day_header(app):
@@ -1862,11 +1945,14 @@ def _remove_clip():
pass
def draw_record_detail(app):
def draw_record_detail(app, partial=False):
"""Render the individual record detail view. Layout order:
title, artists, large cover, genres, metadata, purchased at."""
display.set_pen(_pen_bg)
display.clear()
if partial:
_clear_scroll_viewport()
else:
display.set_pen(_pen_bg)
display.clear()
if app.detail.source_screen == STATE_SEARCH_RESULTS:
rec = app.search.results[app.detail.selected_index]
@@ -1886,7 +1972,8 @@ def draw_record_detail(app):
offset = app.detail.scroll_offset
_draw_detail_header()
if not partial:
_draw_detail_header()
# Clip scrolling content below the header
_set_clip_below_header()
@@ -1907,12 +1994,7 @@ def draw_record_detail(app):
# Scrobble button (at bottom of content, only if eligible)
if rec.get("selected_release_id"):
# Compute button Y from measured content height (y doesn't track
# the bottom because _draw_detail_info_below_cover's return is
# discarded — use detail content height which already includes
# the button height and gap from Step 4).
button_y = (DETAIL_COVER_Y - offset
+ app.detail.content_height - SCROBBLE_BUTTON_H)
button_y = _detail_scrobble_button_y(app)
# Only draw if the button is at least partially visible
if (button_y + SCROBBLE_BUTTON_H > DAY_HEADER_Y + DAY_HEADER_H
and button_y < HEIGHT):
@@ -1931,7 +2013,16 @@ def draw_record_detail(app):
_remove_clip()
presto.update()
if partial:
_partial_scroll_viewport_update()
else:
_display_update()
def _detail_scrobble_button_y(app):
"""Return the scrobble button Y position for the current detail offset."""
return (DETAIL_COVER_Y - app.detail.scroll_offset
+ app.detail.content_height - SCROBBLE_BUTTON_H)
def _draw_detail_header():
@@ -2141,6 +2232,28 @@ def _draw_scrobble_button(app, rec, y):
_draw_centered_text(label, bx, y, SCROBBLE_BUTTON_W, SCROBBLE_BUTTON_H, TEXT_H)
def _redraw_scrobble_button(app, rec):
"""Redraw and push only the visible scrobble button region."""
if not rec.get("selected_release_id"):
return
if app.detail.content_height == 0:
_measure_detail_content(app, rec)
button_y = _detail_scrobble_button_y(app)
update_y = max(button_y, SCROLL_VIEWPORT_Y)
update_bottom = min(button_y + SCROBBLE_BUTTON_H, HEIGHT)
if update_bottom <= update_y:
return
bx = (WIDTH - SCROBBLE_BUTTON_W) // 2
_set_clip_below_header()
_draw_scrobble_button(app, rec, button_y)
_remove_clip()
_partial_display_update(bx, update_y, SCROBBLE_BUTTON_W,
update_bottom - update_y)
# ============================================================================
# TOUCH HANDLING
# ============================================================================
@@ -2268,11 +2381,8 @@ def _scrobble_button_hit_test(app, x, y, rec):
if y <= DAY_HEADER_Y + DAY_HEADER_H:
return False
# Compute button Y position — must match the formula used in
# draw_record_detail(). Detail content height already
# includes SCROBBLE_BUTTON_GAP + SCROBBLE_BUTTON_H from _measure_detail_content.
button_y = (DETAIL_COVER_Y - app.detail.scroll_offset
+ app.detail.content_height - SCROBBLE_BUTTON_H)
# Detail content height already includes the button gap and height.
button_y = _detail_scrobble_button_y(app)
bx = (WIDTH - SCROBBLE_BUTTON_W) // 2
return (bx <= x <= bx + SCROBBLE_BUTTON_W and
@@ -2290,7 +2400,7 @@ def handle_scrobble_touch(app, rec):
# Set loading state and redraw immediately so the user sees "..."
app.detail.scrobble_state = "loading"
draw_record_detail(app)
_redraw_scrobble_button(app, rec)
# Make the POST request. urequests has limited timeout support on
# MicroPython; if WiFi drops mid-request, the default timeout may
@@ -2307,7 +2417,7 @@ def handle_scrobble_touch(app, rec):
app.detail.scrobble_state = "idle"
gc.collect()
draw_record_detail(app)
_redraw_scrobble_button(app, rec)
def handle_home_touch(app, x, y):
@@ -2348,12 +2458,12 @@ def handle_search_input_touch(app, x, y):
if action == "space":
if len(app.search.query) < 50:
app.search.query += " "
draw_search_input(app)
_redraw_search_query_field(app)
return
if action == "backspace":
app.search.query = app.search.query[:-1]
draw_search_input(app)
_redraw_search_query_field(app)
return
if action == "ok":
@@ -2370,7 +2480,7 @@ def handle_search_input_touch(app, x, y):
# Character key
if len(app.search.query) < 50 and len(action) == 1:
app.search.query += action
draw_search_input(app)
_redraw_search_query_field(app)
def handle_search_results_touch(app, x, y):
@@ -2618,7 +2728,7 @@ def main():
lambda: app.search.scroll_offset,
lambda value: setattr(app.search, "scroll_offset", value),
lambda: _search_max_scroll_offset(app),
lambda: draw_search_results(app),
lambda: draw_search_results(app, partial=True),
True
)
@@ -2644,7 +2754,7 @@ def main():
lambda: app.day.scroll_offset,
lambda value: setattr(app.day, "scroll_offset", value),
lambda: _max_scroll_offset(app),
lambda: draw_day_view(app),
lambda: draw_day_view(app, partial=True),
True
)
@@ -2672,7 +2782,7 @@ def main():
lambda: app.detail.scroll_offset,
lambda value: setattr(app.detail, "scroll_offset", value),
lambda: _max_detail_scroll_offset(app),
lambda: draw_record_detail(app),
lambda: draw_record_detail(app, partial=True),
True
)
+130
View File
@@ -137,6 +137,136 @@ class TestSmokeScreens:
assert main_module._record_thumbnail_url(rec).endswith("/small-0.jpg")
assert main_module._record_detail_cover_url(rec).endswith("/medium-0.jpg")
def test_partial_update_uses_region_when_available(self, main_module, monkeypatch):
"""Bounded display updates call the Presto partial update API."""
calls = []
monkeypatch.setattr(
main_module.presto,
"partial_update",
lambda *args: calls.append(("partial", args))
)
monkeypatch.setattr(
main_module.presto,
"update",
lambda: calls.append(("full", ()))
)
main_module._partial_display_update(1, 2, 3, 4)
assert calls == [("partial", (1, 2, 3, 4))]
def test_partial_update_falls_back_to_full_update(self, main_module, monkeypatch):
"""Bounded display updates fall back when partial update fails."""
calls = []
def _raise_partial(*_args):
raise RuntimeError("partial update unavailable")
monkeypatch.setattr(main_module.presto, "partial_update", _raise_partial)
monkeypatch.setattr(
main_module.presto,
"update",
lambda: calls.append(("full", ()))
)
main_module._partial_display_update(1, 2, 3, 4)
assert calls == [("full", ())]
def test_day_view_partial_redraw_updates_scroll_viewport(
self, main_module, monkeypatch
):
"""Day-list bounded redraws update only the scroll viewport."""
app = self._make_app(main_module)
app.screen = main_module.STATE_DAY
app.view_year = 2026
app.view_month = 5
app.day.selected_day = 15
recs = [make_mock_record(i) for i in range(3)]
app.day.records = recs
app.day.content_height = main_module.prepare_record_list(app, recs)
calls = []
monkeypatch.setattr(
main_module.presto,
"partial_update",
lambda *args: calls.append(args)
)
monkeypatch.setattr(main_module.presto, "update", lambda: calls.append("full"))
main_module.draw_day_view(app, partial=True)
assert calls == [(
main_module.SCROLL_VIEWPORT_X,
main_module.SCROLL_VIEWPORT_Y,
main_module.SCROLL_VIEWPORT_W,
main_module.SCROLL_VIEWPORT_H,
)]
def test_search_query_field_partial_redraw(self, main_module, monkeypatch):
"""Typing in search redraws only the query field row."""
app = self._make_app(main_module)
app.screen = main_module.STATE_SEARCH_INPUT
app.search.query = "blue"
calls = []
monkeypatch.setattr(
main_module.presto,
"partial_update",
lambda *args: calls.append(args)
)
monkeypatch.setattr(main_module.presto, "update", lambda: calls.append("full"))
main_module._redraw_search_query_field(app)
assert calls == [(
main_module.KB_INPUT_UPDATE_X,
main_module.KB_INPUT_UPDATE_Y,
main_module.KB_INPUT_UPDATE_W,
main_module.KB_INPUT_UPDATE_H,
)]
def test_scrobble_button_partial_redraw(self, main_module, monkeypatch):
"""Scrobble feedback redraws only the visible button region."""
app = self._make_app(main_module)
app.screen = main_module.STATE_RECORD
app.day.selected_day = 15
rec = make_mock_record(0)
app.day.records = [rec]
app.detail.selected_index = 0
app.detail.source_screen = main_module.STATE_DAY
main_module._measure_detail_content(app, rec)
app.detail.scroll_offset = main_module._max_detail_scroll_offset(app)
calls = []
monkeypatch.setattr(
main_module.presto,
"partial_update",
lambda *args: calls.append(args)
)
monkeypatch.setattr(main_module.presto, "update", lambda: calls.append("full"))
app.detail.scrobble_state = "loading"
main_module._redraw_scrobble_button(app, rec)
button_x = (main_module.WIDTH - main_module.SCROBBLE_BUTTON_W) // 2
button_y = main_module._detail_scrobble_button_y(app)
update_y = max(button_y, main_module.SCROLL_VIEWPORT_Y)
update_h = min(
button_y + main_module.SCROBBLE_BUTTON_H,
main_module.HEIGHT
) - update_y
assert calls == [(
button_x,
update_y,
main_module.SCROBBLE_BUTTON_W,
update_h,
)]
def test_search_input_renders(self, main_module):
"""Search input screen with on-screen keyboard."""
app = self._make_app(main_module)