29 KiB
id, title, type, created_date, updated_date, tags
| id | title | type | created_date | updated_date | tags | ||||
|---|---|---|---|---|---|---|---|---|---|
| doc-17 | Implementation Plan: ML-176.2 — Presto splash screen, search views, and on-screen keyboard | specification | 2026-05-10 13:48 | 2026-05-10 13:56 |
|
Implementation Plan — ML-176.2: Presto splash screen, search views, and on-screen keyboard
0. Dependencies
- ML-176.1 (In Progress): API accepts
qparameter onGET /api/v1/collection. Must be completed beforefetch_search_results()works end-to-end, but Presto-side implementation can proceed in parallel — theqparameter is additive (absent → same as current behavior). Presto code can be written and syntax-checked independently.
1. Objective Alignment
The current records_on_the_day.py is a single-purpose calendar app that boots directly into today's records. The objective is to refactor it into a multi-screen app: a splash screen (home) with two entry points ("Search Collection" and "Today's Records"), a search input view with a custom on-screen QWERTY/numbers keyboard, and a search results view that reuses the existing record-list rendering infrastructure. The app must also navigate correctly (back from detail returns to the origin screen) and always return to the splash screen on display wake.
Each section below maps a concrete implementation step to a verifiable outcome.
2. Simplicity and Alternatives Considered
Chosen approach: Everything stays in the single records_on_the_day.py file. No new modules, no class refactor. Use flat state constants and new drawing/touch-handler functions. Reuse existing _draw_record_list(), _draw_record_row(), prepare_records_for_display(), draw_record_detail(), and scroll infrastructure verbatim — search results is just STATE_DAY with a different header and data source.
Alternatives evaluated and rejected:
- Separate files per screen — adds import complexity on MicroPython, increases flash fragmentation risk. Rejected.
- Class-based screen hierarchy — class allocation and method dispatch add memory overhead. Flat function dispatch keeps GC pressure low and is consistent with existing code. Rejected.
- Virtual keyboard as a component module — same fragmentation argument. Inline in the main file keeps everything in one compilation unit. Rejected.
- Shift/caps support on keyboard — task explicitly says lowercase only. Deferred.
- Infinite scroll for search results — task explicitly says 20 per page, no infinite scroll. Rejected.
3. Completeness and Sequencing
Step 1: Add new state constants and global variables
Add at the top of the state globals section (after STATE_RECORD = 3):
STATE_HOME = 4
STATE_SEARCH_INPUT = 5
STATE_SEARCH_RESULTS = 6
Change default state = STATE_HOME (was STATE_STARTUP).
Add new globals:
search_query = "" # Current search buffer (max ~50 chars)
search_results = [] # Records from search API
search_results_error = False
search_scroll_offset = 0 # Separate scroll offset for search results
_search_content_height = 0 # Separate content height cache for search results
keyboard_mode = "alpha" # "alpha" or "numbers"
previous_state = None # Track where we came from for detail view back
Remove STATE_STARTUP = 0. Replace all references to STATE_STARTUP with STATE_HOME.
Verification: python3 -c "import py_compile; py_compile.compile('records_on_the_day.py', cfile='/tmp/records_on_the_day.pyc', doraise=True)" passes.
Step 2: Add fetch_search_results() API client function
Depends on: ML-176.1 (backend q parameter support).
Add after fetch_records() in the API section:
def fetch_search_results(query):
"""Fetch search results from the API.
Returns (records_list, error_flag). Same shape as fetch_records().
"""
# urequests doesn't have url-encode built-in; hand-encode common chars
encoded = query.replace(" ", "+")
url = API_BASE + "/api/v1/collection?q=" + encoded + "&limit=20"
try:
resp = urequests.get(url, headers=_auth_header())
if resp.status_code == 200:
data = resp.json()
recs = data.get("records", [])
resp.close()
gc.collect()
return recs, False
else:
resp.close()
gc.collect()
return [], True
except Exception:
gc.collect()
return [], True
Verification: After ML-176.1 is deployed, tapping OK on keyboard with a valid query shows results. Tapping OK with no network shows "Could not reach server".
Step 3: Implement STATE_HOME (splash screen)
3a. Layout constants
HOME_BUTTON_W = WIDTH - 80
HOME_BUTTON_H = 90
HOME_BUTTON_X = 40
HOME_BUTTON1_Y = 220
HOME_BUTTON2_Y = HOME_BUTTON1_Y + HOME_BUTTON_H + 30
HOME_TITLE_Y = 100
HOME_TITLE = "Music Library"
3b. Drawing function draw_home_screen()
def draw_home_screen():
display.set_pen(_pen_bg)
display.clear()
display.set_pen(_pen_title)
display.set_font("bitmap14_outline")
tw = display.measure_text(HOME_TITLE, scale=1)
display.text(HOME_TITLE, (WIDTH - tw) // 2, HOME_TITLE_Y, scale=1)
_draw_home_button(HOME_BUTTON1_Y, "Search Collection")
_draw_home_button(HOME_BUTTON2_Y, "Today's Records")
presto.update()
def _draw_home_button(y, label):
display.set_pen(_pen_cell_bg)
display.rectangle(HOME_BUTTON_X, y, HOME_BUTTON_W, HOME_BUTTON_H)
display.set_pen(_pen_header_text)
display.set_font("bitmap8")
tw = display.measure_text(label, scale=1)
tx = HOME_BUTTON_X + (HOME_BUTTON_W - tw) // 2
ty = y + (HOME_BUTTON_H - 8) // 2
display.text(label, tx, ty, scale=1)
Verification: On boot, splash screen renders with two buttons. Tapping "Today's Records" transitions to STATE_MONTH. Tapping "Search Collection" transitions to STATE_SEARCH_INPUT.
Step 4: Implement on-screen keyboard (STATE_SEARCH_INPUT)
4a. Keyboard layout constants
KB_MARGIN = 20
KB_KEY_GAP = 4
KB_KEY_H = 48
KB_KEY_W = (WIDTH - 2 * KB_MARGIN - 9 * KB_KEY_GAP) // 10 # computed from 10-key alpha row
KB_INPUT_Y = 30
KB_INPUT_H = 40
KB_INPUT_MARGIN = 40
KB_ROWS_START_Y = KB_INPUT_Y + KB_INPUT_H + 16
ALPHA_KEYS = [
list("qwertyuiop"),
list("asdfghjkl"),
list("zxcvbnm"),
]
NUM_KEYS = [
list("1234567890"),
list("!@#$%^&*()_"),
]
4b. Drawing function draw_search_input()
def draw_search_input():
display.set_pen(_pen_bg)
display.clear()
# Header with "Search Collection" title and Back (→ Home) button
_draw_search_input_header()
# 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.set_pen(_pen_header_text)
display.set_font("bitmap8")
display.text(search_query, KB_INPUT_MARGIN + 8, KB_INPUT_Y + 12, scale=1)
display.set_font("bitmap8")
keys_matrix = ALPHA_KEYS if keyboard_mode == "alpha" else NUM_KEYS
toggle_label = "123" if keyboard_mode == "alpha" else "ABC"
cy = KB_ROWS_START_Y
for row_keys in keys_matrix:
num_keys = len(row_keys)
total_gap = (num_keys - 1) * KB_KEY_GAP
total_key_w = num_keys * KB_KEY_W
row_start_x = (WIDTH - total_key_w - total_gap) // 2
for i, char in enumerate(row_keys):
kx = row_start_x + i * (KB_KEY_W + KB_KEY_GAP)
_draw_key(kx, cy, char, KB_KEY_W, KB_KEY_H)
cy += KB_KEY_H + KB_KEY_GAP
# Bottom row: toggle | space | backspace | OK
bottom_w = (WIDTH - 2 * KB_MARGIN - 3 * KB_KEY_GAP) // 4
bx = KB_MARGIN
_draw_key(bx, cy, toggle_label, bottom_w, KB_KEY_H)
bx += bottom_w + KB_KEY_GAP
_draw_key(bx, cy, "Space", bottom_w, KB_KEY_H)
bx += bottom_w + KB_KEY_GAP
_draw_key(bx, cy, "<-", bottom_w, KB_KEY_H)
bx += bottom_w + KB_KEY_GAP
ok_pen = _pen_dim_text if search_query == "" else _pen_header_text
_draw_key(bx, cy, "OK", bottom_w, KB_KEY_H, text_pen=ok_pen)
presto.update()
def _draw_key(x, y, label, w, h, text_pen=None):
display.set_pen(_pen_cell_bg)
display.rectangle(x, y, w, h)
pen = text_pen if text_pen is not None else _pen_normal_text
display.set_pen(pen)
tw = display.measure_text(label, scale=1)
tx = x + (w - tw) // 2
ty = y + (h - 8) // 2
display.text(label, tx, ty, scale=1)
4c. Keyboard touch handler handle_search_input_touch(x, y)
def handle_search_input_touch(x, y):
global search_query, keyboard_mode, state, search_results, search_results_error
global search_scroll_offset, _search_content_height, previous_state
# Header Back button → Home
if BACK_X <= x <= BACK_X + BACK_W and BACK_Y <= y <= BACK_Y + BACK_H:
state = STATE_HOME
draw_home_screen()
return
action = _keyboard_hit_test(x, y)
if action is None:
return
if action == "toggle":
keyboard_mode = "numbers" if keyboard_mode == "alpha" else "alpha"
draw_search_input()
return
if action == "space":
if len(search_query) < 50:
search_query += " "
draw_search_input()
return
if action == "backspace":
search_query = search_query[:-1]
draw_search_input()
return
if action == "ok":
if not search_query.strip():
return
draw_status("Searching for:\n" + search_query)
if not ensure_wifi_connected():
search_results_error = True
search_results = []
state = STATE_SEARCH_RESULTS
draw_search_results()
return
recs, had_error = fetch_search_results(search_query)
search_results = recs
search_results_error = had_error
search_scroll_offset = 0
if not had_error and recs:
prepare_records_for_display(recs)
previous_state = STATE_SEARCH_INPUT
state = STATE_SEARCH_RESULTS
draw_search_results()
return
# Character key
if len(search_query) < 50 and len(action) == 1:
search_query += action
draw_search_input()
4d. Hit-testing function _keyboard_hit_test(x, y)
def _keyboard_hit_test(x, y):
if y < KB_ROWS_START_Y:
return None
keys_matrix = ALPHA_KEYS if keyboard_mode == "alpha" else NUM_KEYS
cy = KB_ROWS_START_Y
for row_keys in keys_matrix:
num_keys = len(row_keys)
total_gap = (num_keys - 1) * KB_KEY_GAP
total_key_w = num_keys * KB_KEY_W
row_start_x = (WIDTH - total_key_w - total_gap) // 2
for i, char in enumerate(row_keys):
kx = row_start_x + i * (KB_KEY_W + KB_KEY_GAP)
if kx <= x < kx + KB_KEY_W and cy <= y < cy + KB_KEY_H:
return char
cy += KB_KEY_H + KB_KEY_GAP
# Bottom row
bottom_w = (WIDTH - 2 * KB_MARGIN - 3 * KB_KEY_GAP) // 4
bx = KB_MARGIN
if bx <= x < bx + bottom_w and cy <= y < cy + KB_KEY_H:
return "toggle"
bx += bottom_w + KB_KEY_GAP
if bx <= x < bx + bottom_w and cy <= y < cy + KB_KEY_H:
return "space"
bx += bottom_w + KB_KEY_GAP
if bx <= x < bx + bottom_w and cy <= y < cy + KB_KEY_H:
return "backspace"
bx += bottom_w + KB_KEY_GAP
if bx <= x < bx + bottom_w and cy <= y < cy + KB_KEY_H:
return "ok"
return None
4e. Key press visual feedback
Brief highlight flash on key press. Call _flash_key() before the action is applied; the subsequent draw_search_input() overwrites it:
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()
time.sleep(0.05)
Verification: All keys appear on a 720×720 display. Tapping letter keys appends to the query field. Backspace removes last char. 123/ABC toggles layouts. OK dim when empty and no-ops. OK with text triggers API call.
Step 5: Implement STATE_SEARCH_RESULTS
5a. Drawing function draw_search_results()
Reuses _draw_record_row() from the day view. Unique elements: search-specific header, own scroll state, empty/error states.
def draw_search_results():
global search_scroll_offset
display.set_pen(_pen_bg)
display.clear()
_draw_search_results_header()
if search_results_error:
_draw_search_error()
presto.update()
return
if not search_results:
_draw_search_empty()
presto.update()
return
_draw_search_record_list()
presto.update()
def _draw_search_results_header():
display.set_pen(_pen_cell_bg)
display.rectangle(0, DAY_HEADER_Y, WIDTH, DAY_HEADER_H)
# Back button
bx, by = BACK_X, BACK_Y
display.set_pen(_pen_placeholder)
display.rectangle(bx, by, BACK_W, BACK_H)
display.set_pen(_pen_back)
display.set_font("bitmap8")
_draw_centered_text("<", bx, by, BACK_W, BACK_H, DAY_COUNT_TEXT_H)
# Title "Search: <query>" — truncate if needed
display.set_pen(_pen_header_text)
display.set_font("bitmap14_outline")
label = "Search: " + search_query
max_w = WIDTH - (BACK_X + BACK_W + 8) - 8
if display.measure_text(label, scale=1) > max_w:
display_ellipsis = "..."
while (display.measure_text("Search: " + search_query + display_ellipsis, scale=1) > max_w
and len(search_query) > 0):
search_query = search_query[:-1]
label = "Search: " + search_query + display_ellipsis if search_query else "Search: ..."
tw = display.measure_text(label, scale=1)
display.text(label, (WIDTH - tw)//2,
DAY_HEADER_Y + (DAY_HEADER_H - DAY_HEADER_TEXT_H)//2, scale=1)
5b. Search record list scroll (own state)
def _draw_search_record_list():
global search_scroll_offset
max_offset = _search_max_scroll_offset()
search_scroll_offset = min(max(0, search_scroll_offset), max_offset)
if search_scroll_offset > 0:
display.set_pen(_pen_arrow)
display.set_font("bitmap14_outline")
display.text("^", WIDTH // 2 - 8, RECORD_START_Y - 28, scale=1)
cy = RECORD_START_Y - search_scroll_offset
for rec in search_results:
row_h = rec.get("_row_height", THUMB_SIZE + 8)
row_bottom = cy + row_h
if row_bottom <= RECORD_START_Y:
cy = row_bottom
continue
if cy >= HEIGHT - 28:
break
_draw_record_row(rec, cy)
cy = row_bottom
if search_scroll_offset < max_offset:
display.set_pen(_pen_arrow)
display.set_font("bitmap14_outline")
display.text("v", WIDTH // 2 - 8, HEIGHT - 24, scale=1)
def _search_max_scroll_offset():
viewport_h = HEIGHT - RECORD_START_Y - 28
return max(0, _search_content_height - viewport_h)
5c. Generic prepare_records_for_display(recs=None)
Make the existing preparation pipeline accept an optional list so both records and search_results can use it:
def prepare_records_for_display(recs=None):
global _content_height, _search_content_height, records, search_results
if recs is None:
recs = records
display.set_font("bitmap8")
if recs is search_results:
_search_content_height = 0
else:
_content_height = 0
for rec in recs:
_prepare_record_for_display(rec)
if recs is search_results:
_search_content_height += rec.get("_row_height", THUMB_SIZE + 8)
else:
_content_height += rec.get("_row_height", THUMB_SIZE + 8)
preload_record_thumbnails(recs)
gc.collect()
Similarly parameterize preload_record_thumbnails(recs=None).
5d. Empty and error states
def _draw_search_empty():
display.set_pen(_pen_dim_text)
display.set_font("bitmap8")
msg = "No records found"
mw = display.measure_text(msg, scale=1)
display.text(msg, (WIDTH - mw) // 2, HEIGHT // 2 - 10, scale=1)
def _draw_search_error():
display.set_pen(_pen_error)
display.set_font("bitmap8")
msg = "Could not reach server"
mw = display.measure_text(msg, scale=1)
display.text(msg, (WIDTH - mw) // 2, HEIGHT // 2 - 20, scale=1)
display.set_pen(_pen_dim_text)
hint = "Tap to retry"
hw = display.measure_text(hint, scale=1)
display.text(hint, (WIDTH - hw) // 2, HEIGHT // 2 + 10, scale=1)
Verification: Search results display with cover art, titles, artists, format, year — identical layout to day view. Back returns to STATE_SEARCH_INPUT with query preserved. Tapping a record opens detail. Empty/error states work. Scroll works identically to day view.
Step 6: Navigation state tracking for Back from detail
Track previous_state on transitions to STATE_RECORD:
- From
STATE_DAY→previous_state = STATE_DAY - From
STATE_SEARCH_RESULTS→previous_state = STATE_SEARCH_RESULTS
On Back in STATE_RECORD, navigate to previous_state instead of always STATE_DAY.
Verification: Detail view Back → search results when entered from search. Back → day when entered from day.
Step 7: Update existing views for Back-to-Home
7a. Month view Home button
Add a "Home" button in the month header, leftmost position. Month header layout becomes: [Home] [<] [Month Year] [>].
Update _draw_month_header() to draw the home button and shift the left arrow right. Update handle_month_touch() to check the home button first.
7b. Display wake returns to home
In wake_display(), set state = STATE_HOME before calling redraw_current_view().
7c. redraw_current_view() updated
Add cases for STATE_HOME, STATE_SEARCH_INPUT, STATE_SEARCH_RESULTS.
Verification: From any view, after display sleep + wake, splash screen appears. Month view has Home button. Search input Back → Home.
Step 8: Update main() globals and boot flow
Change boot flow:
state = STATE_HOME- After WiFi + NTP sync +
set_today(), calldraw_home_screen()— no auto-fetch of today's records - User must tap "Today's Records" to trigger the calendar flow
Verification: On cold boot, splash screen is the first thing shown. No API call until user taps a button.
Step 9: Update main loop dispatch
Add handlers for STATE_HOME, STATE_SEARCH_INPUT, STATE_SEARCH_RESULTS in the main loop. Implement handle_home_touch(x, y) for "Search Collection" and "Today's Records" buttons. Implement drag-scroll for STATE_SEARCH_RESULTS mirroring day-view scroll. Wire previous_state tracking for detail navigation.
def handle_home_touch(x, y):
global state
# "Search Collection"
if (HOME_BUTTON_X <= x <= HOME_BUTTON_X + HOME_BUTTON_W and
HOME_BUTTON1_Y <= y <= HOME_BUTTON1_Y + HOME_BUTTON_H):
state = STATE_SEARCH_INPUT
search_query = ""
keyboard_mode = "alpha"
draw_search_input()
return
# "Today's Records" → month view with today highlighted
if (HOME_BUTTON_X <= x <= HOME_BUTTON_X + HOME_BUTTON_W and
HOME_BUTTON2_Y <= y <= HOME_BUTTON2_Y + HOME_BUTTON_H):
view_year = today_year
view_month = today_month
selected_day = today_day
state = STATE_MONTH
draw_month_view()
return
Design decision: "Today's Records" goes to month view (with today highlighted) rather than directly to day view. This gives the user full calendar context — they can tap today or any other day. Consistent with the existing month-first navigation pattern.
Verification: All touch handlers dispatch correctly. No crashes on state transitions. Scroll works in search results. Record taps in search results open detail.
Step 10: Delete unused STATE_STARTUP references
Remove STATE_STARTUP = 0. Replace remaining references with STATE_HOME.
Verification: grep -n "STATE_STARTUP" records_on_the_day.py returns nothing.
Step 11: Test and deploy
- Syntax check:
python3 -c "import py_compile; py_compile.compile('records_on_the_day.py', cfile='/tmp/records_on_the_day.pyc', doraise=True)" - Verify ML-176.1 is deployed to production
- Deploy to Presto:
mise run presto - Manual verification on device for all 19 acceptance criteria
4. Verifiability
| Step | Verification |
|---|---|
| 1 | Syntax check passes |
| 2 | Functional after ML-176.1 deployed; OK triggers API call, network error handled |
| 3 | Presto boots to splash screen; two buttons visible and tappable |
| 4 | All keys visible; typing works; backspace works; 123/ABC toggles; OK dim when empty |
| 5 | Search results render with cover art; scroll works; empty/error states display correctly |
| 6 | Back from detail → correct origin (search results or day view) |
| 7 | Home button on month view works; wake always returns to home |
| 8 | On cold boot, splash screen shows first |
| 9 | All touch handlers dispatch correctly; no crashes |
| 10 | No STATE_STARTUP references remain |
| 11 | All 19 ACs pass on physical device |
5. Architecture Impact Analysis
| Touchpoint | Impact |
|---|---|
records_on_the_day.py |
Primary change. ~250–350 new lines for keyboard, splash, search views. Existing drawing functions reused. prepare_records_for_display() and preload_record_thumbnails() made generic (accept optional list param). |
collection_controller.ex |
No change — handled by ML-176.1 |
collection_json.ex |
No change — index/1 already returns correct shape |
Collection.search_records/2 |
No change — already accepts query string |
| Routes | No change — existing GET /api/v1/collection reused |
| PubSub | No impact |
| Supervision tree | No impact |
| External APIs | Search API call added to Presto. Same auth header, same error handling pattern as fetch_records() |
| Month view UI | Header gains Home button; left/right arrow positions shift right |
6. Performance Profile
- Keyboard drawing: O(1) — fixed ~34 keys drawn per keystroke. Each key is a rectangle + text call. No network calls during typing. Estimated <5ms per redraw on RP2350B at 150 MHz.
- Search results rendering: O(n) where n ≤ 20 records per page. Same scroll performance rules as day view: layout/text pre-computed in
prepare_records_for_display(), drag scroll uses placeholders for thumbnails. No measurable difference from day view. - Memory: Search query capped at 50 chars. Keyboard layout arrays ~200 bytes total (module-level constants). Search results reuse
search_resultslist (same structure asrecords). GC pressure identical to current app. - Network: One additional API call per search (same
urequestspattern). No network calls during scroll or typing. - N+1 risks: None. Both API endpoints return full record data in a single response. Thumbnails preloaded in batch.
- Latency: Same 1–3 second API call latency as day-view fetch. Keyboard is instant (local only).
7. Benchmarking Requirements
No formal benchmarks needed. The keyboard is a fixed-size static layout with O(1) redraw. Search results use the same rendering path as day view, already validated on hardware. The only quantitative check:
- Keyboard tap-to-redraw latency: Verify that tapping a key and seeing the character appear feels instant (<100ms). If noticeable lag occurs, profile with
time.ticks_ms()arounddraw_search_input().
Manual verification on the Presto device is sufficient.
8. Cost Profile
No additional paid resources:
- API calls: Search uses the same
GET /api/v1/collectionendpoint (ML-176.1). No third-party API calls. Incremental server load is one extra request per search, same magnitude as a day-view request. - Storage: No new storage requirements. Keyboard layouts are constant strings in the MicroPython module.
- Compute: No additional server-side processing beyond what
search_recordsalready handles.
9. Production Infrastructure Steps
Production Changes
1. Deploy ML-176.1 backend change first
git push origin main # triggers Coolify deploy (includes ML-176.1 commit)
Verify: curl -H "Authorization: Bearer <token>" "https://music-library.claudio-ortolina.org/api/v1/collection?q=test&limit=1" returns results.
2. Deploy updated Presto firmware
mise run presto
Rollback: copy the previous version of records_on_the_day.py to the device.
No environment variable changes, no service provisioning, no DNS changes, no firewall rules.
10. Documentation Updates
| File | Change needed |
|---|---|
presto/README.md |
Add sections: "Splash Screen", "Search", "On-Screen Keyboard". Update "Features" list. Add API endpoint GET /api/v1/collection?q=&limit=20 to "API Endpoint" section with response format. |
presto/AGENTS.md |
Update "State Management" section: add STATE_HOME, STATE_SEARCH_INPUT, STATE_SEARCH_RESULTS. Update "API Contract" section with search endpoint. Update "Scroll Performance" to mention per-view offset/cache discipline for search results. Add keyboard constraints: fixed layouts, no shift, max 50 chars. |
docs/architecture.md |
No change needed. |
docs/project-conventions.md |
No change needed. |
docs/production-infrastructure.md |
No change needed. |