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
@@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
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.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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.
<!-- SECTION:FINAL_SUMMARY:END -->
+159 -49
View File
@@ -115,6 +115,10 @@ DAY_HEADER_Y = HEADER_Y
DAY_HEADER_H = HEADER_H DAY_HEADER_H = HEADER_H
DAY_HEADER_TEXT_H = HEADER_TEXT_H DAY_HEADER_TEXT_H = HEADER_TEXT_H
DAY_COUNT_TEXT_H = 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 view
DETAIL_COVER_SIZE = px(200) 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_Y = px(42)
KB_INPUT_H = px(28) KB_INPUT_H = px(28)
KB_INPUT_MARGIN = px(40) 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) KB_ROWS_START_Y = KB_INPUT_Y + KB_INPUT_H + px(12)
ALPHA_KEYS = [ ALPHA_KEYS = [
@@ -353,6 +363,44 @@ def _init_pens():
_pen_scrobble_done_text = display.create_pen(*SCROBBLE_DONE_TEXT) _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 # HELPER: TEXT WRAPPING
# ============================================================================ # ============================================================================
@@ -927,7 +975,7 @@ def draw_status(message):
x = (WIDTH - w) // 2 x = (WIDTH - w) // 2
display.text(line, x, start_y + i * line_h, scale=TEXT_SCALE) 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_month_header(app)
_draw_day_labels() _draw_day_labels()
_draw_day_grid(app) _draw_day_grid(app)
presto.update() _display_update()
HOME_BTN_W = px(30) 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_BUTTON1_Y, "Search Collection")
_draw_home_button(HOME_BUTTON2_Y, "Today's Records") _draw_home_button(HOME_BUTTON2_Y, "Today's Records")
presto.update() _display_update()
def _draw_home_button(y, label): def _draw_home_button(y, label):
@@ -1143,24 +1191,35 @@ def draw_search_input(app):
display.clear() display.clear()
_draw_search_input_header() _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.set_pen(_pen_cell_bg)
display.rectangle(KB_INPUT_MARGIN, KB_INPUT_Y, display.rectangle(KB_INPUT_X, KB_INPUT_Y, KB_INPUT_W, KB_INPUT_H)
WIDTH - 2 * KB_INPUT_MARGIN, KB_INPUT_H)
display.set_pen(_pen_header_text) display.set_pen(_pen_header_text)
display.set_font("bitmap8") display.set_font("bitmap8")
# Show cursor as underscore if query is empty
display_q = app.search.query if app.search.query else "_" display_q = app.search.query if app.search.query else "_"
display.text( display.text(
display_q, display_q,
KB_INPUT_MARGIN + px(8), KB_INPUT_X + px(8),
KB_INPUT_Y + px(12), KB_INPUT_Y + px(12),
scale=TEXT_SCALE 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(): 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) _draw_key(kx, ky, label, kw, kh, text_pen=_pen_today_text)
display.set_pen(_pen_arrow) display.set_pen(_pen_arrow)
display.rectangle(kx, ky, kw, kh) display.rectangle(kx, ky, kw, kh)
presto.update() _partial_display_update(kx, ky, kw, kh)
time.sleep(0.05) time.sleep(0.05)
@@ -1280,27 +1339,39 @@ def _flash_key(kx, ky, kw, kh, label):
# DRAWING: SEARCH RESULTS # DRAWING: SEARCH RESULTS
# ============================================================================ # ============================================================================
def draw_search_results(app): def draw_search_results(app, partial=False):
"""Render the search results view, reusing record-list rendering.""" """Render the search results view, reusing record-list rendering."""
display.set_pen(_pen_bg) if partial:
display.clear() _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: if app.search.error:
_draw_search_error() _draw_search_error()
presto.update() if partial:
_partial_scroll_viewport_update()
else:
_display_update()
return return
if not app.search.results: if not app.search.results:
_draw_search_empty() _draw_search_empty()
presto.update() if partial:
_partial_scroll_viewport_update()
else:
_display_update()
return return
_set_clip_below_header() _set_clip_below_header()
_draw_search_record_list(app) _draw_search_record_list(app)
_remove_clip() _remove_clip()
presto.update() if partial:
_partial_scroll_viewport_update()
else:
_display_update()
def _draw_search_results_header(app): def _draw_search_results_header(app):
@@ -1402,27 +1473,39 @@ def _draw_search_error():
# DRAWING: DAY VIEW (records list) # 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.""" """Render the day view showing records for the selected date."""
display.set_pen(_pen_bg) if partial:
display.clear() _clear_scroll_viewport()
else:
display.set_pen(_pen_bg)
display.clear()
_draw_day_header(app) _draw_day_header(app)
if app.day.error: if app.day.error:
_draw_day_error() _draw_day_error()
presto.update() if partial:
_partial_scroll_viewport_update()
else:
_display_update()
return return
if not app.day.records: if not app.day.records:
_draw_day_empty() _draw_day_empty()
presto.update() if partial:
_partial_scroll_viewport_update()
else:
_display_update()
return return
_set_clip_below_header() _set_clip_below_header()
_draw_record_list(app) _draw_record_list(app)
_remove_clip() _remove_clip()
presto.update() if partial:
_partial_scroll_viewport_update()
else:
_display_update()
def _draw_day_header(app): def _draw_day_header(app):
@@ -1862,11 +1945,14 @@ def _remove_clip():
pass pass
def draw_record_detail(app): def draw_record_detail(app, partial=False):
"""Render the individual record detail view. Layout order: """Render the individual record detail view. Layout order:
title, artists, large cover, genres, metadata, purchased at.""" title, artists, large cover, genres, metadata, purchased at."""
display.set_pen(_pen_bg) if partial:
display.clear() _clear_scroll_viewport()
else:
display.set_pen(_pen_bg)
display.clear()
if app.detail.source_screen == STATE_SEARCH_RESULTS: if app.detail.source_screen == STATE_SEARCH_RESULTS:
rec = app.search.results[app.detail.selected_index] rec = app.search.results[app.detail.selected_index]
@@ -1886,7 +1972,8 @@ def draw_record_detail(app):
offset = app.detail.scroll_offset offset = app.detail.scroll_offset
_draw_detail_header() if not partial:
_draw_detail_header()
# Clip scrolling content below the header # Clip scrolling content below the header
_set_clip_below_header() _set_clip_below_header()
@@ -1907,12 +1994,7 @@ def draw_record_detail(app):
# Scrobble button (at bottom of content, only if eligible) # Scrobble button (at bottom of content, only if eligible)
if rec.get("selected_release_id"): if rec.get("selected_release_id"):
# Compute button Y from measured content height (y doesn't track button_y = _detail_scrobble_button_y(app)
# 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)
# Only draw if the button is at least partially visible # Only draw if the button is at least partially visible
if (button_y + SCROBBLE_BUTTON_H > DAY_HEADER_Y + DAY_HEADER_H if (button_y + SCROBBLE_BUTTON_H > DAY_HEADER_Y + DAY_HEADER_H
and button_y < HEIGHT): and button_y < HEIGHT):
@@ -1931,7 +2013,16 @@ def draw_record_detail(app):
_remove_clip() _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(): 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) _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 # TOUCH HANDLING
# ============================================================================ # ============================================================================
@@ -2268,11 +2381,8 @@ def _scrobble_button_hit_test(app, x, y, rec):
if y <= DAY_HEADER_Y + DAY_HEADER_H: if y <= DAY_HEADER_Y + DAY_HEADER_H:
return False return False
# Compute button Y position — must match the formula used in # Detail content height already includes the button gap and height.
# draw_record_detail(). Detail content height already button_y = _detail_scrobble_button_y(app)
# 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)
bx = (WIDTH - SCROBBLE_BUTTON_W) // 2 bx = (WIDTH - SCROBBLE_BUTTON_W) // 2
return (bx <= x <= bx + SCROBBLE_BUTTON_W and 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 "..." # Set loading state and redraw immediately so the user sees "..."
app.detail.scrobble_state = "loading" app.detail.scrobble_state = "loading"
draw_record_detail(app) _redraw_scrobble_button(app, rec)
# Make the POST request. urequests has limited timeout support on # Make the POST request. urequests has limited timeout support on
# MicroPython; if WiFi drops mid-request, the default timeout may # 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" app.detail.scrobble_state = "idle"
gc.collect() gc.collect()
draw_record_detail(app) _redraw_scrobble_button(app, rec)
def handle_home_touch(app, x, y): def handle_home_touch(app, x, y):
@@ -2348,12 +2458,12 @@ def handle_search_input_touch(app, x, y):
if action == "space": if action == "space":
if len(app.search.query) < 50: if len(app.search.query) < 50:
app.search.query += " " app.search.query += " "
draw_search_input(app) _redraw_search_query_field(app)
return return
if action == "backspace": if action == "backspace":
app.search.query = app.search.query[:-1] app.search.query = app.search.query[:-1]
draw_search_input(app) _redraw_search_query_field(app)
return return
if action == "ok": if action == "ok":
@@ -2370,7 +2480,7 @@ def handle_search_input_touch(app, x, y):
# Character key # Character key
if len(app.search.query) < 50 and len(action) == 1: if len(app.search.query) < 50 and len(action) == 1:
app.search.query += action app.search.query += action
draw_search_input(app) _redraw_search_query_field(app)
def handle_search_results_touch(app, x, y): def handle_search_results_touch(app, x, y):
@@ -2618,7 +2728,7 @@ def main():
lambda: app.search.scroll_offset, lambda: app.search.scroll_offset,
lambda value: setattr(app.search, "scroll_offset", value), lambda value: setattr(app.search, "scroll_offset", value),
lambda: _search_max_scroll_offset(app), lambda: _search_max_scroll_offset(app),
lambda: draw_search_results(app), lambda: draw_search_results(app, partial=True),
True True
) )
@@ -2644,7 +2754,7 @@ def main():
lambda: app.day.scroll_offset, lambda: app.day.scroll_offset,
lambda value: setattr(app.day, "scroll_offset", value), lambda value: setattr(app.day, "scroll_offset", value),
lambda: _max_scroll_offset(app), lambda: _max_scroll_offset(app),
lambda: draw_day_view(app), lambda: draw_day_view(app, partial=True),
True True
) )
@@ -2672,7 +2782,7 @@ def main():
lambda: app.detail.scroll_offset, lambda: app.detail.scroll_offset,
lambda value: setattr(app.detail, "scroll_offset", value), lambda value: setattr(app.detail, "scroll_offset", value),
lambda: _max_detail_scroll_offset(app), lambda: _max_detail_scroll_offset(app),
lambda: draw_record_detail(app), lambda: draw_record_detail(app, partial=True),
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_thumbnail_url(rec).endswith("/small-0.jpg")
assert main_module._record_detail_cover_url(rec).endswith("/medium-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): def test_search_input_renders(self, main_module):
"""Search input screen with on-screen keyboard.""" """Search input screen with on-screen keyboard."""
app = self._make_app(main_module) app = self._make_app(main_module)