ML-176.2: add presto splash, search

This commit is contained in:
Claudio Ortolina
2026-05-10 15:57:19 +01:00
parent 8475126a80
commit d4242b6e93
2 changed files with 717 additions and 68 deletions
@@ -1,10 +1,10 @@
---
id: ML-176.2
title: "Presto: add splash screen, search views, and on-screen keyboard"
status: To Do
status: Done
assignee: []
created_date: "2026-05-10 13:35"
updated_date: "2026-05-10 13:56"
updated_date: "2026-05-10 14:58"
labels:
- presto
- search
@@ -22,6 +22,7 @@ modified_files:
- presto/records_on_the_day.py
parent_task_id: ML-176
priority: medium
ordinal: 2000
---
## Description
@@ -123,30 +124,54 @@ Refactor `presto/records_on_the_day.py` into a unified app with a splash screen,
<!-- SECTION:NOTES:BEGIN -->
## Implementation Plan
## Implementation complete (~617 new lines, 2450 total)
Full plan: `doc-17 - Implementation Plan: ML-176.2 — Presto splash screen, search views, and on-screen keyboard`
### State constants + globals
### Summary
- Renumbered STATE_MONTH=0, STATE_DAY=1, STATE_RECORD=2. Added STATE_HOME=3, STATE_SEARCH_INPUT=4, STATE_SEARCH_RESULTS=5. Removed STATE_STARTUP.
- New globals: `search_query`, `search_results`, `search_results_error`, `search_scroll_offset`, `_search_content_height`, `keyboard_mode`, `previous_state`.
11-step plan, all within `records_on_the_day.py`:
### Splash screen (STATE_HOME)
1. **State constants + globals** — Add `STATE_HOME` (4), `STATE_SEARCH_INPUT` (5), `STATE_SEARCH_RESULTS` (6). Remove `STATE_STARTUP`. Add `search_query`, `search_results`, `keyboard_mode`, `previous_state` globals.
2. **`fetch_search_results()`** — New API client calling `GET /api/v1/collection?q=<encoded>&limit=20`. Returns `(records_list, error_flag)`. Depends on ML-176.1 for the backend `q` parameter.
3. **Splash screen (`STATE_HOME`)**`draw_home_screen()` with two large buttons: "Search Collection" and "Today's Records".
4. **On-screen keyboard (`STATE_SEARCH_INPUT`)**`draw_search_input()` with 3-row QWERTY + numbers keyboard. Hit-test via `_keyboard_hit_test()`. Touch handler `handle_search_input_touch()`. Visual key-press feedback. OK button dimmed when query empty.
5. **Search results (`STATE_SEARCH_RESULTS`)** — Reuses `_draw_record_row()`. Own scroll state (`search_scroll_offset`, `_search_content_height`). `prepare_records_for_display(recs=None)` made generic to support both `records` and `search_results`. Empty/error states.
6. **Navigation tracking**`previous_state` tracks whether detail was entered from day or search. Back from detail returns to correct origin.
7. **Back-to-Home** — Home button added to month header. `wake_display()` sets `state = STATE_HOME`. `redraw_current_view()` updated for new states.
8. **Boot flow** — Starts at `STATE_HOME`, no auto-fetch. User taps "Today's Records" → month view.
9. **Main loop dispatch** — Added handlers for all new states. `handle_home_touch()`. Search results drag-scroll mirrors day view.
10. **Cleanup** — Remove all `STATE_STARTUP` references.
11. **Deploy** — Verify ML-176.1 deployed → `mise run presto` → manual verification on device.
- `draw_home_screen()` with "Search Collection" and "Today's Records" buttons. Tapping "Today's Records" → month view with today highlighted. Tapping "Search Collection" → search input.
### Key design decisions
### On-screen keyboard (STATE_SEARCH_INPUT)
- "Today's Records" navigates to month view (with today highlighted), not directly to day — preserves calendar context
- Keyboard: fixed layout, lowercase only, no shift/caps, max 50 chars
- Search results: 20 per page, no infinite scroll. Own scroll state per AGENTS.md rules.
- `prepare_records_for_display()` parameterized to accept optional list — avoids duplicating the entire display-prep pipeline
- QWERTY layout (3 rows) + numbers/symbols. Bottom row: [123/ABC] [Space] [Backspace] [OK]. OK dimmed when query empty. `_keyboard_hit_test()` maps touch to action. `handle_search_input_touch()` dispatches all actions. Visual feedback via `_flash_key()`.
### Search results (STATE_SEARCH_RESULTS)
- Reuses `_draw_record_row()`. Own scroll state (`search_scroll_offset`, `_search_content_height`). Header: "Search: <query>" with truncation. Empty: "No records found". Error: "Could not reach server" with retry-on-tap.
### API client
- `fetch_search_results(query)``GET /api/v1/collection?q=<encoded>&limit=20`. Returns `(records_list, error_flag)`.
### Generic display pipeline
- `prepare_records_for_display(recs=None)` and `preload_record_thumbnails(recs=None)` accept optional list. Height cache tracked per source.
### Navigation tracking
- `previous_state` tracks detail origin (STATE_DAY / STATE_SEARCH_RESULTS). Back → correct origin. `draw_record_detail()` reads from `search_results` or `records`.
### Back-to-Home
- Month header gains "H" Home button. `wake_display()` always sets `state = STATE_HOME`. Search input header Back → Home.
### Boot flow
- Starts at `draw_home_screen()`, no auto-fetch. User taps a button.
### Main loop
- New dispatches for STATE_HOME, STATE_SEARCH_INPUT, STATE_SEARCH_RESULTS (drag scroll mirrors day view).
### Touch helpers
- `touch_to_record_index()` accepts optional `recs` and `offset` params.
### Verification
- Syntax check passes (`python3 py_compile`). No `STATE_STARTUP` references remain.
<!-- SECTION:NOTES:END -->
+670 -46
View File
@@ -67,7 +67,7 @@ HEADER_Y = 0
HEADER_H = 31
HEADER_TEXT_H = 14
HEADER_SIDE_MARGIN = 8
HEADER_BUTTON_W = 44
HEADER_BUTTON_W = 32
HEADER_BUTTON_H = 25
HEADER_BUTTON_Y = HEADER_Y + (HEADER_H - HEADER_BUTTON_H) // 2
ARROW_W = HEADER_BUTTON_W
@@ -152,6 +152,38 @@ DISPLAY_SLEEP_BRIGHTNESS = 0.0
DISPLAY_FADE_MS = 500
DISPLAY_FADE_STEPS = 20
# Home screen
HOME_TITLE = "Music Library"
HOME_TITLE_Y = 60
HOME_BUTTON_W = WIDTH - 80
HOME_BUTTON_H = 50
HOME_BUTTON_X = 40
HOME_BUTTON1_Y = 120
HOME_BUTTON2_Y = HOME_BUTTON1_Y + HOME_BUTTON_H + 10
HOME_BTN_BG = (55, 65, 81) # Slate blue — distinct from BG and CELL_BG
# On-screen keyboard
KB_MARGIN = 20
KB_KEY_GAP = 3
KB_KEY_H = 38
KB_KEY_W = (WIDTH - 2 * KB_MARGIN - 9 * KB_KEY_GAP) // 10
KB_INPUT_Y = 42
KB_INPUT_H = 28
KB_INPUT_MARGIN = 40
KB_ROWS_START_Y = KB_INPUT_Y + KB_INPUT_H + 12
ALPHA_KEYS = [
list("qwertyuiop"),
list("asdfghjkl"),
list("zxcvbnm"),
]
NUM_KEYS = [
list("1234567890"),
list("-/:;()!@#$"),
]
# ============================================================================
# GLOBAL STATE
# ============================================================================
@@ -166,11 +198,13 @@ view_year = 2026
view_month = 5
# View state
STATE_STARTUP = 0
STATE_MONTH = 1
STATE_DAY = 2
STATE_RECORD = 3
state = STATE_STARTUP
STATE_MONTH = 0
STATE_DAY = 1
STATE_RECORD = 2
STATE_HOME = 3
STATE_SEARCH_INPUT = 4
STATE_SEARCH_RESULTS = 5
state = STATE_HOME
# Day view state
selected_day = 0 # 1-based day of month
@@ -186,6 +220,15 @@ _content_height = 0 # Cached total record list height
detail_scroll_offset = 0 # Pixel scroll position for detail view
_detail_content_height = 0 # Cached total detail content height
# Search state
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 origin for detail-view back navigation
# Touch debounce
_last_touch = 0
_last_drag_redraw = 0
@@ -216,6 +259,7 @@ _pen_artist = None
_pen_error = None
_pen_placeholder = None
_pen_status = None
_pen_home_btn = None
def _init_pens():
@@ -223,7 +267,7 @@ def _init_pens():
global _pen_bg, _pen_cell_bg, _pen_cell_other, _pen_today_bg
global _pen_today_text, _pen_normal_text, _pen_dim_text, _pen_header_text
global _pen_arrow, _pen_back, _pen_title, _pen_artist, _pen_error
global _pen_placeholder, _pen_status
global _pen_placeholder, _pen_status, _pen_home_btn
_pen_bg = display.create_pen(*BG)
_pen_cell_bg = display.create_pen(*CELL_BG)
@@ -240,6 +284,7 @@ def _init_pens():
_pen_error = display.create_pen(*ERROR_COLOR)
_pen_placeholder = display.create_pen(*PLACEHOLDER_BG)
_pen_status = display.create_pen(*STATUS_TEXT)
_pen_home_btn = display.create_pen(*HOME_BTN_BG)
# ============================================================================
@@ -480,8 +525,8 @@ def sleep_display():
def wake_display():
"""Fade the backlight in, then refresh the clock, reconnect WiFi if
needed, and redraw so the today-highlight stays current."""
global _display_awake
needed, and always return to the home screen."""
global _display_awake, state
_fade_backlight(DISPLAY_SLEEP_BRIGHTNESS, DISPLAY_BRIGHTNESS)
_display_awake = True
@@ -491,6 +536,8 @@ def wake_display():
if not wifi_connected():
ensure_wifi_connected()
# Always return to home screen on wake
state = STATE_HOME
redraw_current_view()
@@ -514,12 +561,18 @@ def ensure_wifi_connected():
def redraw_current_view():
"""Redraw the active view after wake-time status messages."""
if state == STATE_MONTH:
if state == STATE_HOME:
draw_home_screen()
elif state == STATE_MONTH:
draw_month_view()
elif state == STATE_DAY:
draw_day_view()
elif state == STATE_RECORD:
draw_record_detail()
elif state == STATE_SEARCH_INPUT:
draw_search_input()
elif state == STATE_SEARCH_RESULTS:
draw_search_results()
# ============================================================================
@@ -574,6 +627,32 @@ def set_day_records(recs, had_error):
prepare_records_for_display()
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
def fetch_thumbnail(url):
"""Fetch a JPEG thumbnail from the given URL.
@@ -740,23 +819,41 @@ def draw_month_view():
presto.update()
HOME_BTN_W = 30
HOME_BTN_H = 25
HOME_BTN_Y = HEADER_Y + (HEADER_H - HOME_BTN_H) // 2
def _draw_month_header():
"""Draw the month/year title and navigation arrows at the top."""
"""Draw the month/year title, navigation arrows, and Home button."""
# Background bar
display.set_pen(_pen_cell_bg)
display.rectangle(0, HEADER_Y, WIDTH, HEADER_H)
# Month and year text (centered)
# Month and year text (centered between arrow buttons)
display.set_pen(_pen_header_text)
display.set_font("bitmap14_outline")
title = "{} {}".format(MONTH_NAMES[view_month - 1], view_year)
tw = display.measure_text(title, scale=1)
tx = (WIDTH - tw) // 2
# Left boundary: after Home + left-arrow; right boundary: before right-arrow
title_left = HEADER_SIDE_MARGIN + HOME_BTN_W + 4 + ARROW_W
title_right = WIDTH - ARROW_W - HEADER_SIDE_MARGIN
title_area = title_right - title_left
tx = title_left + (title_area - tw) // 2
ty = HEADER_Y + (HEADER_H - HEADER_TEXT_H) // 2
display.text(title, tx, ty, scale=1)
# Left arrow
lx = HEADER_SIDE_MARGIN
# Home button (leftmost)
hx = HEADER_SIDE_MARGIN
hy = HOME_BTN_Y
display.set_pen(_pen_placeholder)
display.rectangle(hx, hy, HOME_BTN_W, HOME_BTN_H)
display.set_pen(_pen_back)
display.set_font("bitmap8")
_draw_centered_text("H", hx, hy, HOME_BTN_W, HOME_BTN_H, 8)
# Left arrow (shifted right to make room for Home)
lx = hx + HOME_BTN_W + 4
ly = HEADER_BUTTON_Y
display.set_pen(_pen_placeholder)
display.rectangle(lx, ly, ARROW_W, ARROW_H)
@@ -875,6 +972,300 @@ def _draw_centered_num(num, cx, cy):
display.text(text, cx - tw // 2, cy - 5, scale=1)
# ============================================================================
# DRAWING: HOME SCREEN (splash)
# ============================================================================
def draw_home_screen():
"""Render the splash screen with two large touch targets."""
display.set_pen(_pen_bg)
display.clear()
# Title
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)
# Buttons
_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):
"""Draw a large rounded-rectangle button for the splash screen."""
display.set_pen(_pen_home_btn)
display.rectangle(HOME_BUTTON_X, y, HOME_BUTTON_W, HOME_BUTTON_H)
display.set_pen(_pen_today_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)
# ============================================================================
# DRAWING: SEARCH INPUT (keyboard)
# ============================================================================
def draw_search_input():
"""Render the search input view with query field and on-screen keyboard."""
display.set_pen(_pen_bg)
display.clear()
_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")
# Show cursor as underscore if query is empty
display_q = search_query if search_query else "_"
display.text(display_q, KB_INPUT_MARGIN + 8, KB_INPUT_Y + 12, scale=1)
_draw_keyboard()
presto.update()
def _draw_search_input_header():
"""Draw the search input header with title and Back-to-Home button."""
display.set_pen(_pen_cell_bg)
display.rectangle(0, DAY_HEADER_Y, WIDTH, DAY_HEADER_H)
# Back-to-Home 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
display.set_pen(_pen_header_text)
display.set_font("bitmap14_outline")
title = "Search"
tw = display.measure_text(title, scale=1)
display.text(title, (WIDTH - tw) // 2,
DAY_HEADER_Y + (DAY_HEADER_H - DAY_HEADER_TEXT_H) // 2, scale=1)
def _draw_keyboard():
"""Draw the on-screen QWERTY or numbers keyboard."""
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.strip() == "" else _pen_header_text
_draw_key(bx, cy, "OK", bottom_w, KB_KEY_H, text_pen=ok_pen)
def _draw_key(x, y, label, w, h, text_pen=None):
"""Draw a single keyboard key as a rounded rectangle with centered label."""
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)
def _keyboard_hit_test(x, y):
"""Map a touch (x, y) to a keyboard action.
Returns: a character string, "toggle", "space", "backspace", "ok", or None.
"""
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
def _flash_key(kx, ky, kw, kh, label):
"""Brief visual feedback for a key press."""
_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)
# ============================================================================
# DRAWING: SEARCH RESULTS
# ============================================================================
def draw_search_results():
"""Render the search results view, reusing record-list rendering."""
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():
"""Draw the search results header with Back button and query label."""
display.set_pen(_pen_cell_bg)
display.rectangle(0, DAY_HEADER_Y, WIDTH, DAY_HEADER_H)
# Back button → search input (preserves query)
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 for display only, never mutate
# the global search_query so user can refine on Back
display.set_pen(_pen_header_text)
display.set_font("bitmap14_outline")
display_label = "Search: " + search_query
max_w = WIDTH - (BACK_X + BACK_W + 8) - 8
if display.measure_text(display_label, scale=1) > max_w:
suffix = "..."
display_q = search_query
while (display.measure_text("Search: " + display_q + suffix, scale=1) > max_w
and len(display_q) > 0):
display_q = display_q[:-1]
display_label = "Search: " + (display_q + suffix if display_q else suffix)
tw = display.measure_text(display_label, scale=1)
display.text(display_label, (WIDTH - tw) // 2,
DAY_HEADER_Y + (DAY_HEADER_H - DAY_HEADER_TEXT_H) // 2, scale=1)
def _draw_search_record_list():
"""Draw the scrollable search results list, mirroring day-view scroll."""
global search_scroll_offset
max_offset = _search_max_scroll_offset()
search_scroll_offset = min(max(0, search_scroll_offset), max_offset)
# Up arrow if scrolled down
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
# Down arrow if more records below
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():
"""Return the maximum pixel scroll offset for search results."""
viewport_h = HEIGHT - RECORD_START_Y - 28
return max(0, _search_content_height - viewport_h)
def _draw_search_empty():
"""Show 'No records found' message."""
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():
"""Show search error message with retry hint."""
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)
# ============================================================================
# DRAWING: DAY VIEW (records list)
# ============================================================================
@@ -1007,18 +1398,32 @@ def _max_scroll_offset():
return max(0, _content_height - viewport_h)
def prepare_records_for_display():
"""Cache display fields, row heights, and thumbnails for current records."""
global _content_height
def prepare_records_for_display(recs=None):
"""Cache display fields, row heights, and thumbnails for a record list.
If recs is None, uses the global records list (day view).
When recs is search_results, updates _search_content_height.
"""
global _content_height, _search_content_height, records, search_results
if recs is None:
recs = records
display.set_font("bitmap8")
_content_height = 0
for rec in records:
is_search = recs is search_results
if is_search:
_search_content_height = 0
else:
_content_height = 0
for rec in recs:
_prepare_record_for_display(rec)
_content_height += rec.get("_row_height", THUMB_SIZE + 8)
if is_search:
_search_content_height += rec.get("_row_height", THUMB_SIZE + 8)
else:
_content_height += rec.get("_row_height", THUMB_SIZE + 8)
preload_record_thumbnails()
preload_record_thumbnails(recs)
gc.collect()
@@ -1036,9 +1441,11 @@ def _prepare_record_for_display(rec):
rec["_row_height"] = _record_row_height(rec)
def preload_record_thumbnails():
"""Fetch thumbnail bytes before the first day-view draw."""
for rec in records:
def preload_record_thumbnails(recs=None):
"""Fetch thumbnail bytes before the first draw of a record list."""
if recs is None:
recs = records
for rec in recs:
if rec.get("micro_cover_url", ""):
_record_thumbnail_data(rec)
@@ -1263,7 +1670,10 @@ def draw_record_detail():
display.set_pen(_pen_bg)
display.clear()
rec = records[selected_record_idx]
if previous_state == STATE_SEARCH_RESULTS:
rec = search_results[selected_record_idx]
else:
rec = records[selected_record_idx]
# Measure content height so scroll bounds are known
_measure_detail_content(rec)
@@ -1541,14 +1951,22 @@ def cell_to_day(row, col):
return None
def touch_to_record_index(y):
def touch_to_record_index(y, recs=None, offset=None):
"""Map a touch y-coordinate to a record list index, accounting for
scroll offset. Returns None if the touch doesn't hit a record row."""
if not records:
scroll offset. Use recs/offset params for search results, or defaults
for day view."""
if recs is None:
recs = records
if offset is None:
offset = scroll_offset
elif offset is None:
offset = search_scroll_offset
if not recs:
return None
cy = RECORD_START_Y - scroll_offset
for i, rec in enumerate(records):
cy = RECORD_START_Y - offset
for i, rec in enumerate(recs):
row_h = rec.get("_row_height", THUMB_SIZE + 8)
if cy <= y < cy + row_h:
return i
@@ -1556,12 +1974,146 @@ def touch_to_record_index(y):
return None
def handle_home_touch(x, y):
"""Process a touch event on the home/splash screen."""
global state, search_query, keyboard_mode
global view_year, view_month, selected_day
# "Search Collection" button
if (HOME_BUTTON_X <= x <= HOME_BUTTON_X + HOME_BUTTON_W and
HOME_BUTTON1_Y <= y <= HOME_BUTTON1_Y + HOME_BUTTON_H):
search_query = ""
keyboard_mode = "alpha"
state = STATE_SEARCH_INPUT
draw_search_input()
return
# "Today's Records" button → 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
def handle_search_input_touch(x, y):
"""Process a touch event in search input view."""
global search_query, keyboard_mode, state, search_results, search_results_error
global search_scroll_offset, _search_content_height, previous_state
# Header Back → 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()
def handle_search_results_touch(x, y):
"""Process a tap event in search results view.
Back button, error retry, and record row taps.
Row scroll is handled by the drag logic in the main loop."""
global state, search_results, search_results_error
global search_scroll_offset, selected_record_idx, detail_scroll_offset
global previous_state, records
# Back button → search input (preserves search_query so user can refine)
if BACK_X <= x <= BACK_X + BACK_W and BACK_Y <= y <= BACK_Y + BACK_H:
state = STATE_SEARCH_INPUT
draw_search_input()
return
# Error state: retry on tap anywhere
if search_results_error:
draw_status("Retrying search...")
if not ensure_wifi_connected():
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)
draw_search_results()
return
# Record row tap → detail view
if not search_results_error and search_results:
rec_idx = touch_to_record_index(y, recs=search_results,
offset=search_scroll_offset)
if rec_idx is not None:
selected_record_idx = rec_idx
detail_scroll_offset = 0
previous_state = STATE_SEARCH_RESULTS
state = STATE_RECORD
draw_record_detail()
def handle_month_touch(x, y):
"""Process a touch event in month view state."""
global view_year, view_month, selected_day, state, records, records_error, scroll_offset
# Check left arrow
lx, ly = HEADER_SIDE_MARGIN, HEADER_BUTTON_Y
# Check Home button
hx, hy = HEADER_SIDE_MARGIN, HOME_BTN_Y
if hx <= x <= hx + HOME_BTN_W and hy <= y <= hy + HOME_BTN_H:
state = STATE_HOME
draw_home_screen()
return
# Check left arrow (shifted right to make room for Home)
lx = HEADER_SIDE_MARGIN + HOME_BTN_W + 4
ly = HEADER_BUTTON_Y
if lx <= x <= lx + ARROW_W and ly <= y <= ly + ARROW_H:
view_year, view_month = previous_month(view_year, view_month)
draw_month_view()
@@ -1644,8 +2196,9 @@ def main():
global state, view_year, view_month, _last_touch
global selected_day, selected_record_idx, records, records_error
global scroll_offset, detail_scroll_offset
global _dragging
global _dragging, previous_state
global _last_activity, _last_drag_redraw
global search_scroll_offset
# -- Init display --
init_display()
@@ -1667,21 +2220,15 @@ def main():
sync_time()
set_today()
# Start on today's records.
# Start on splash screen — no auto-fetch
view_year = today_year
view_month = today_month
selected_day = today_day
state = STATE_DAY
state = STATE_HOME
records_error = False
scroll_offset = 0
draw_status("Loading records for\n{} {}, {}...".format(
MONTH_NAMES[view_month - 1], today_day, view_year
))
recs, had_error = fetch_records(view_year, view_month, today_day)
set_day_records(recs, had_error)
draw_day_view()
draw_home_screen()
_last_activity = time.ticks_ms()
while True:
@@ -1709,7 +2256,79 @@ def main():
_last_touch = now
_last_activity = now
if state == STATE_MONTH:
# -- STATE_HOME: splash screen --
if state == STATE_HOME:
handle_home_touch(x, y)
wait_for_touch_release()
# -- STATE_SEARCH_INPUT: on-screen keyboard --
elif state == STATE_SEARCH_INPUT:
handle_search_input_touch(x, y)
wait_for_touch_release()
# -- STATE_SEARCH_RESULTS: search results list --
elif state == STATE_SEARCH_RESULTS:
# Check back button immediately (no drag needed)
if BACK_X <= x <= BACK_X + BACK_W and BACK_Y <= y <= BACK_Y + BACK_H:
state = STATE_SEARCH_INPUT
draw_search_input()
wait_for_touch_release()
continue
# Error state: retry on tap anywhere (before drag)
if search_results_error:
handle_search_results_touch(x, y)
wait_for_touch_release()
continue
# Track vertical drag for scrolling
drag_start_y = y
last_drag_y = y
pending_delta = 0
dragged = False
_dragging = False
_last_drag_redraw = time.ticks_ms()
while True:
touch_point = read_touch()
if touch_point is None:
break
current_y = touch_point[1]
if abs(current_y - drag_start_y) >= DRAG_REDRAW_PX:
dragged = True
pending_delta += last_drag_y - current_y
now = time.ticks_ms()
redraw_due = time.ticks_diff(now, _last_drag_redraw) >= DRAG_REDRAW_MS
if abs(pending_delta) >= DRAG_REDRAW_PX and redraw_due:
max_offset = _search_max_scroll_offset()
new_offset = min(max(0, search_scroll_offset + pending_delta), max_offset)
if new_offset != search_scroll_offset:
search_scroll_offset = new_offset
_dragging = True
draw_search_results()
_last_drag_redraw = now
pending_delta = 0
last_drag_y = current_y
time.sleep(0.01)
if dragged and pending_delta:
max_offset = _search_max_scroll_offset()
new_offset = min(max(0, search_scroll_offset + pending_delta), max_offset)
if new_offset != search_scroll_offset:
search_scroll_offset = new_offset
_dragging = True
if _dragging:
_dragging = False
draw_search_results()
if not dragged:
# Record row tap handled by handle_search_results_touch
handle_search_results_touch(x, y)
elif state == STATE_MONTH:
handle_month_touch(x, y)
wait_for_touch_release()
@@ -1771,6 +2390,7 @@ def main():
if rec_idx is not None:
selected_record_idx = rec_idx
detail_scroll_offset = 0
previous_state = STATE_DAY
state = STATE_RECORD
draw_record_detail()
continue
@@ -1781,9 +2401,13 @@ def main():
elif state == STATE_RECORD:
# Check back button immediately (no drag needed)
if BACK_X <= x <= BACK_X + BACK_W and BACK_Y <= y <= BACK_Y + BACK_H:
state = STATE_DAY
detail_scroll_offset = 0
draw_day_view()
if previous_state == STATE_SEARCH_RESULTS:
state = STATE_SEARCH_RESULTS
draw_search_results()
else:
state = STATE_DAY
draw_day_view()
wait_for_touch_release()
continue