From aab16b2c664ad97b1a7dcb347dbf6ce4327ead9a Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Sat, 16 May 2026 21:29:02 +0100 Subject: [PATCH] ML-186: improve presto redraw performance with partial updates --- ...prove-Presto-display-redraw-performance.md | 69 ++++++ presto/main.py | 208 +++++++++++++----- presto/tests/test_screens.py | 130 +++++++++++ 3 files changed, 358 insertions(+), 49 deletions(-) create mode 100644 backlog/tasks/ml-186 - Improve-Presto-display-redraw-performance.md diff --git a/backlog/tasks/ml-186 - Improve-Presto-display-redraw-performance.md b/backlog/tasks/ml-186 - Improve-Presto-display-redraw-performance.md new file mode 100644 index 00000000..85c04f1b --- /dev/null +++ b/backlog/tasks/ml-186 - Improve-Presto-display-redraw-performance.md @@ -0,0 +1,69 @@ +--- +id: ML-186 +title: Improve Presto display redraw performance +status: Done +assignee: + - Codex +created_date: "2026-05-16 20:14" +updated_date: "2026-05-16 20:22" +labels: + - presto + - performance +dependencies: [] +references: + - "https://github.com/pimoroni/presto/issues/56" +documentation: + - "https://github.com/pimoroni/presto/blob/main/docs/presto.md" +modified_files: + - presto/main.py + - presto/tests/test_screens.py +priority: medium +ordinal: 22000 +--- + +## Description + + + +Use Pimoroni Presto partial display updates for bounded redraws in the MicroPython app where doing so preserves existing behavior. The current app performs full-screen updates for all draws; the desired outcome is to reduce display transfer work for high-frequency or small-area updates without changing visible UI behavior or making physical-device claims without on-device verification. + + + +## Acceptance Criteria + + + +- [x] #1 Bounded UI redraws use partial display updates when the Presto firmware supports them and safely fall back to full updates otherwise. +- [x] #2 Scrollable fixed-header views continue to render correctly during drag and release redraws. +- [x] #3 Search input and scrobble feedback preserve existing visible behavior while avoiding unnecessary full-screen display transfers where practical. +- [x] #4 Headless smoke tests pass, and any unverified physical-device behavior is clearly not claimed. + + +## Implementation Plan + + + +1. Add safe display update helpers in `presto/main.py`: a full update wrapper, a partial update wrapper with fallback, and named rectangles for the scroll viewport/search input/scrobble button. +2. Keep full-screen updates for full state transitions and broad redraws, but use partial updates for bounded areas where the buffer content can be refreshed independently. +3. For fixed-header scrollable views, add a bounded redraw mode that clears/redraws only the viewport below the header and calls `partial_update` for that region during drag redraws. +4. For small feedback updates, redraw only the search input field after query text changes and redraw only the scrobble button area when its state changes. +5. Add focused tests around fallback/partial-update behavior if the emulator can observe it, then run the Presto smoke test suite. + + +## Implementation Notes + + + +Implemented safe partial display update helpers with full-update fallback, viewport-only redraws for scrollable fixed-header views, query-field partial redraws, and scrobble-button partial redraws. After user correction, kept the detail cover layout at `DETAIL_COVER_SIZE = px(200)` and updated tests/docs that incorrectly described the cover as 460px. + + + +## Final Summary + + + +Implemented bounded display update support for the Presto MicroPython app. Full-screen transitions still use full updates, while scroll redraws now refresh only the content viewport, search typing refreshes only the query field row, and scrobble loading/done feedback refreshes only the visible button region. Added tests for partial-update dispatch, fallback behavior, scroll viewport updates, search field updates, and scrobble button updates. Kept the detail cover layout at `DETAIL_COVER_SIZE = px(200)` and updated stale tests/docs that incorrectly described the detail cover as 460px. + +Verification: `python3 -c "import py_compile; py_compile.compile('main.py', cfile='/tmp/main.pyc', doraise=True)"`; `python3 -m py_compile tests/test_screens.py`; `mise run test` (14 passed). Physical Presto behavior was not verified on-device. + + diff --git a/presto/main.py b/presto/main.py index 7431f8b4..6c1e6f77 100644 --- a/presto/main.py +++ b/presto/main.py @@ -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 ) diff --git a/presto/tests/test_screens.py b/presto/tests/test_screens.py index 4e07cfa1..09074aac 100644 --- a/presto/tests/test_screens.py +++ b/presto/tests/test_screens.py @@ -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)