From 19e097e514242e6b65064e704b2359eba9619e86 Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Tue, 26 May 2026 08:59:01 +0300 Subject: [PATCH] ML-197: add Elm-style Presto application --- ...-style-Presto-Music-Library-application.md | 47 +- presto/README.md | 34 +- presto/mise.toml | 6 + presto/music_library.py | 2888 +++++++++++++++++ presto/tests/conftest.py | 32 +- .../tests/test_music_library_architecture.py | 143 + presto/tests/test_screens.py | 10 +- 7 files changed, 3128 insertions(+), 32 deletions(-) create mode 100644 presto/music_library.py create mode 100644 presto/tests/test_music_library_architecture.py diff --git a/backlog/tasks/ml-197 - Provide-Elm-style-Presto-Music-Library-application.md b/backlog/tasks/ml-197 - Provide-Elm-style-Presto-Music-Library-application.md index 970e7024..89c36028 100644 --- a/backlog/tasks/ml-197 - Provide-Elm-style-Presto-Music-Library-application.md +++ b/backlog/tasks/ml-197 - Provide-Elm-style-Presto-Music-Library-application.md @@ -1,11 +1,11 @@ --- id: ML-197 title: Provide Elm-style Presto Music Library application -status: In Progress +status: Done assignee: - Codex created_date: "2026-05-26 05:39" -updated_date: "2026-05-26 05:43" +updated_date: "2026-05-26 05:56" labels: - presto dependencies: [] @@ -16,6 +16,13 @@ documentation: - presto/main.py - presto/tests/test_screens.py - presto/mise.toml +modified_files: + - presto/music_library.py + - presto/tests/conftest.py + - presto/tests/test_screens.py + - presto/tests/test_music_library_architecture.py + - presto/mise.toml + - presto/README.md priority: medium ordinal: 30000 --- @@ -32,10 +39,10 @@ Add an alternative Presto Music Library application organized around the message -- [ ] #1 A `music_library.py` application exposes the existing Home, calendar/day browsing, search, record detail, scrobble, network error, and display sleep interactions through the Elm-style runtime. -- [ ] #2 Scrolling, cover loading, wake handling, and API request behavior remain consistent with the current Presto app constraints. -- [ ] #3 Headless verification covers the new application screens and prevents accidental network access during render tests. -- [ ] #4 User-facing or deployment documentation is updated if introducing the alternative application changes available usage or verification instructions. +- [x] #1 A `music_library.py` application exposes the existing Home, calendar/day browsing, search, record detail, scrobble, network error, and display sleep interactions through the Elm-style runtime. +- [x] #2 Scrolling, cover loading, wake handling, and API request behavior remain consistent with the current Presto app constraints. +- [x] #3 Headless verification covers the new application screens and prevents accidental network access during render tests. +- [x] #4 User-facing or deployment documentation is updated if introducing the alternative application changes available usage or verification instructions. ## Implementation Plan @@ -60,4 +67,32 @@ Inspected `presto/poc.py`, `presto/main.py`, test harness, and `presto/mise.toml User approved the implementation plan on 2026-05-26 and requested that this task record be committed before application work begins. +Implemented the standalone architecture-based entrypoint with `Model`/`Runtime`, message-driven transitions, command dispatch/effects, centralized rendering, prepared detail-cover effects, nonblocking drag messages, display sleep/wake consumption, API loading/reconnect effects, and scrobble feedback effects. + +Extended the emulator screen contract suite to cover both `main.py` and `music_library.py`, and added focused tests for the status state, render-without-cover-fetch, detail preparation messages, wake consumption, WiFi reconnect before search, and throttled drag updates. + +Added optional deployment documentation and a `music_library` mise deploy task. A compatible concurrent change switching the emulator task target to `music_library.py` was present in `presto/mise.toml`; it is not part of the agent-authored deploy-task hunk and will be left unstaged. + +Verified `main.py` and `music_library.py` with `py_compile` output directed to `/tmp`; verified the headless suite with `mise run test` (36 passed). Physical Presto hardware was not tested. + + +## Final Summary + + + +## Summary + +- Added `presto/music_library.py`, a standalone Music Library entrypoint organized around the Elm-style `Model` / `Runtime` / message / effect / render architecture while preserving the current UI and API interactions. +- Routed blocking requests and detail-cover preparation through effects, retaining cached or placeholder-only redraw behavior during scroll interactions and wake/sleep handling. +- Added deployment/documentation support and expanded emulator coverage to exercise both entrypoints plus the architecture-specific event/effect behavior. + +## Verification + +- `python3 -c "import py_compile; py_compile.compile('main.py', cfile='/tmp/main.pyc', doraise=True); py_compile.compile('music_library.py', cfile='/tmp/music_library.pyc', doraise=True)"` +- `mise run test` (36 passed) + +## Remaining Validation + +- The architecture-based entrypoint has not been exercised on a physical Pimoroni Presto device. + diff --git a/presto/README.md b/presto/README.md index e9b78182..56cc7de5 100644 --- a/presto/README.md +++ b/presto/README.md @@ -56,6 +56,17 @@ That task copies `main.py` to `:main.py` and resets the Presto. It does not copy The Presto runs `main.py` on boot, connects to WiFi, syncs time, then opens the home screen. +An equivalent Elm-style implementation is available in `music_library.py`. It +uses messages, model updates, and effects for the application runtime while +providing the same Music Library screens and API interactions. Deploy it as +the device entrypoint with: + +```bash +mise run music_library +``` + +This task copies `music_library.py` to `:main.py` on the device and resets it. + ## Usage **Home:** tap **Search Collection** to search by text, or **Today's Records** to open the current month with today highlighted. @@ -78,24 +89,29 @@ Run the headless smoke tests from this directory: mise run test ``` -The tests use `pimoroni-emulator` mock MicroPython modules, import `main.py` without starting the event loop, and block accidental network calls. They render all screen states and save screenshots to `tests/fixtures/` for manual inspection. +The tests use `pimoroni-emulator` mock MicroPython modules, import both +`main.py` and `music_library.py` without starting their event loops, and block +accidental network calls. They render all screen states and save screenshots +to `tests/fixtures/` for manual inspection. Additional runtime tests cover the +message/effect boundary in `music_library.py`. For a syntax-only check without creating `__pycache__`: ```bash python3 -c "import py_compile; py_compile.compile('main.py', cfile='/tmp/main.pyc', doraise=True)" +python3 -c "import py_compile; py_compile.compile('music_library.py', cfile='/tmp/music_library.pyc', doraise=True)" ``` ## Troubleshooting -| Symptom | Likely cause | Fix | -| --- | --- | --- | -| `secrets.py not found` | Credentials are missing on the device | Copy `secrets.py` to the Presto root | -| `WIFI_SSID not set` or `API_TOKEN not set` | Required value is blank | Check `secrets.py` | -| `WiFi connection failed` | Wrong credentials or network unavailable | Verify SSID, password, and network | -| `Could not reach server` | API token invalid, server down, or network lost | Check `API_TOKEN` and server status | -| Grey cover placeholders | JPEG decoder unavailable or image URL failed | Check firmware and internet access | -| Black display after idle | Display sleep is active | Touch once to wake | +| Symptom | Likely cause | Fix | +| ------------------------------------------ | ----------------------------------------------- | ------------------------------------ | +| `secrets.py not found` | Credentials are missing on the device | Copy `secrets.py` to the Presto root | +| `WIFI_SSID not set` or `API_TOKEN not set` | Required value is blank | Check `secrets.py` | +| `WiFi connection failed` | Wrong credentials or network unavailable | Verify SSID, password, and network | +| `Could not reach server` | API token invalid, server down, or network lost | Check `API_TOKEN` and server status | +| Grey cover placeholders | JPEG decoder unavailable or image URL failed | Check firmware and internet access | +| Black display after idle | Display sleep is active | Touch once to wake | ## API Contract diff --git a/presto/mise.toml b/presto/mise.toml index d57f3e8c..4fbc6e5e 100644 --- a/presto/mise.toml +++ b/presto/mise.toml @@ -10,6 +10,12 @@ mpremote fs cp main.py :main.py mpremote reset ''' +[tasks.music_library] +run = ''' +mpremote fs cp music_library.py :main.py +mpremote reset +''' + [tasks.poc] run = ''' mpremote fs cp poc.py :main.py diff --git a/presto/music_library.py b/presto/music_library.py new file mode 100644 index 00000000..04e19d9d --- /dev/null +++ b/presto/music_library.py @@ -0,0 +1,2888 @@ +""" +Elm-style Presto Music Library application. + +This is a standalone device entrypoint for the Pimoroni Presto. It retains the +Music Library screens and API behavior from ``main.py``, while routing user +input and blocking hardware work through messages and effects in the style of +``poc.py``. Deploy this file to the device as ``main.py``. +""" + +# ============================================================================ +# IMPORTS +# ============================================================================ + +import gc +import time +import network +import ntptime + +import urequests + +# Presto hardware module (provided by stock firmware) +from presto import Presto + +# JPEG decoder (provided by stock firmware; may be named jpegdec or picographics +# depending on firmware version — adjust import if needed) +try: + import jpegdec as _jpegdec_lib + _HAS_JPEGDEC = True +except ImportError: + _HAS_JPEGDEC = False + +# ============================================================================ +# CONFIGURATION +# ============================================================================ + +# API endpoint (production server) +API_BASE = "https://music-library.claudio-ortolina.org" + +# Setup for the Presto display in full-resolution 480x480 mode. +presto = Presto(full_res=True) +display = presto.display +WIDTH, HEIGHT = display.get_bounds() +touch = presto.touch + + +UI_SCALE = 2 +TEXT_SCALE = UI_SCALE +TITLE_TEXT_SCALE = UI_SCALE +PLACEHOLDER_TEXT_SCALE = UI_SCALE * 2 + + +def px(value): + """Scale a low-resolution layout measurement to full-resolution pixels.""" + return value * UI_SCALE + + +TEXT_H = px(8) +TEXT_LINE_H = px(12) +TITLE_LINE_H = px(14) + +# Calendar layout (computed from display bounds) +CELL_GAP = px(3) +CELLS_PER_ROW = 7 +MAX_ROWS = 6 + +# Header +HEADER_Y = 0 +HEADER_H = px(31) +HEADER_TEXT_H = px(14) +HEADER_SIDE_MARGIN = px(8) +HEADER_BUTTON_W = px(32) +HEADER_BUTTON_H = px(25) +HEADER_BUTTON_Y = HEADER_Y + (HEADER_H - HEADER_BUTTON_H) // 2 +ARROW_W = HEADER_BUTTON_W +ARROW_H = HEADER_BUTTON_H + +# Day-of-week labels +DOW_Y = HEADER_Y + HEADER_H + px(2) +DOW_H = px(16) + +# Grid starts after day-of-week labels with a small gap +CALENDAR_TOP = DOW_Y + DOW_H + px(6) + +# Day cells: fit 7 across with comfortable side margins +CELL_SIZE_BY_WIDTH = (WIDTH - px(16) - (CELLS_PER_ROW - 1) * CELL_GAP) // CELLS_PER_ROW +CELL_SIZE_BY_HEIGHT = ( + HEIGHT - CALENDAR_TOP - px(8) - (MAX_ROWS - 1) * CELL_GAP +) // MAX_ROWS +CELL_SIZE = min(CELL_SIZE_BY_WIDTH, CELL_SIZE_BY_HEIGHT, px(78)) +GRID_LEFT = (WIDTH - (CELLS_PER_ROW * CELL_SIZE + (CELLS_PER_ROW - 1) * CELL_GAP)) // 2 + +# Day view (proportional to cell size) +BACK_X = HEADER_SIDE_MARGIN +BACK_Y = HEADER_BUTTON_Y +BACK_W = HEADER_BUTTON_W +BACK_H = HEADER_BUTTON_H +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) +DETAIL_COVER_X = (WIDTH - DETAIL_COVER_SIZE) // 2 +DETAIL_COVER_Y = DAY_HEADER_Y + DAY_HEADER_H + px(12) +DETAIL_INFO_GAP = px(4) +DETAIL_TEXT_X = px(40) +DETAIL_TEXT_W = WIDTH - (2 * DETAIL_TEXT_X) + +# Scrobble button (detail view) +SCROBBLE_BUTTON_W = px(200) +SCROBBLE_BUTTON_H = px(40) +SCROBBLE_BUTTON_GAP = px(12) # vertical gap before button + +RECORD_START_Y = DAY_HEADER_Y + DAY_HEADER_H + px(8) +THUMB_SIZE = px(40) +THUMB_MARGIN = px(8) +ROW_PAD_Y = px(8) +ROW_SEPARATOR_H = px(1) +TEXT_X = THUMB_MARGIN + THUMB_SIZE + px(12) +TEXT_W = WIDTH - TEXT_X - px(12) + +# Colors (RGB tuples, converted to pens at runtime) +BG = (24, 24, 27) +CELL_BG = (39, 39, 42) +CELL_OTHER_MONTH = (30, 30, 34) +TODAY_BG = (239, 68, 68) +TODAY_TEXT = (255, 255, 255) +NORMAL_TEXT = (212, 212, 216) +DIM_TEXT = (113, 113, 122) +HEADER_TEXT = (228, 228, 231) +ARROW_COLOR = (161, 161, 170) +BACK_COLOR = (212, 212, 216) +TITLE_COLOR = (228, 228, 231) +ARTIST_COLOR = (161, 161, 170) +ERROR_COLOR = (59, 130, 246) +PLACEHOLDER_BG = (63, 63, 70) +STATUS_TEXT = (161, 161, 170) + +# Scrobble button colors +SCROBBLE_BG = (55, 65, 81) # Same slate as HOME_BTN_BG +SCROBBLE_TEXT = (228, 228, 231) # Same as TITLE_COLOR +SCROBBLE_DONE_BG = (34, 197, 94) # Green success +SCROBBLE_DONE_TEXT = (255, 255, 255) + +# Days of week (Monday first, ISO 8601) +DAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] +MONTH_NAMES = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +] + +# Touch debounce (milliseconds) +DEBOUNCE_MS = 300 +KEYBOARD_DEBOUNCE_MS = 80 +DRAG_REDRAW_MS = 40 +DRAG_REDRAW_PX = px(8) + +# WiFi connection timeout (seconds) +WIFI_TIMEOUT = 30 + +# Display sleep +DISPLAY_SLEEP_MS = 60_000 +DISPLAY_BRIGHTNESS = 1.0 +DISPLAY_SLEEP_BRIGHTNESS = 0.0 +DISPLAY_FADE_MS = 500 +DISPLAY_FADE_STEPS = 20 +LOOP_SLEEP_MS = 30 +WIFI_RETRY_MS = 10_000 + +# Home screen +HOME_TITLE = "Music Library" +HOME_TITLE_Y = px(60) +HOME_BUTTON_W = WIDTH - px(80) +HOME_BUTTON_H = px(50) +HOME_BUTTON_X = px(40) +HOME_BUTTON1_Y = px(120) +HOME_BUTTON2_Y = HOME_BUTTON1_Y + HOME_BUTTON_H + px(10) +HOME_BTN_BG = (55, 65, 81) # Slate blue — distinct from BG and CELL_BG + +# On-screen keyboard +KB_MARGIN = px(20) +KB_KEY_GAP = px(3) +KB_KEY_H = px(36) +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 = [ + list("qwertyuiop"), + list("asdfghjkl"), + list("zxcvbnm"), +] + +NUM_KEYS = [ + list("1234567890"), + list("-/:;()!@#$"), +] + +# ============================================================================ +# STATE +# ============================================================================ + +# View state constants +STATE_MONTH = 0 +STATE_DAY = 1 +STATE_RECORD = 2 +STATE_HOME = 3 +STATE_SEARCH_INPUT = 4 +STATE_SEARCH_RESULTS = 5 +STATE_STATUS = 6 + + +class DayListState: + """Mutable state for the day records list.""" + + def __init__(self): + self.selected_day = 0 + self.records = [] + self.error = False + self.scroll_offset = 0 + self.content_height = 0 + + +class SearchState: + """Mutable state for search input and search results.""" + + def __init__(self): + self.query = "" + self.results = [] + self.error = False + self.scroll_offset = 0 + self.content_height = 0 + self.keyboard_mode = "alpha" + + +class DetailState: + """Mutable state for the record detail view.""" + + def __init__(self): + self.selected_index = None + self.source_screen = None + self.scroll_offset = 0 + self.content_height = 0 + self.scrobble_state = "idle" + + +class TouchState: + """Mutable state for touch debounce, drag, and display sleep.""" + + def __init__(self): + self.last_touch = 0 + self.last_drag_redraw = 0 + self.dragging = False + self.last_activity = 0 + self.display_awake = True + self.consume_until_release = False + self.gesture_screen = None + self.start_x = 0 + self.start_y = 0 + self.last_y = 0 + self.pending_delta = 0 + self.dragged = False + self.redrew_drag = False + + +class Model: + """Navigation and interaction state transitioned by ``update()``.""" + + def __init__(self): + self.api_token = "" + self.screen = STATE_HOME + self.status_message = "Music Library\nStarting up..." + self.next_wifi_retry_ms = 0 + self.today_year = 2026 + self.today_month = 5 + self.today_day = 1 + self.view_year = 2026 + self.view_month = 5 + self.day = DayListState() + self.search = SearchState() + self.detail = DetailState() + self.touch = TouchState() + + +class Runtime: + """Hardware polling state kept outside the application model.""" + + def __init__(self): + self.touch_down = False + + +# Compatibility name for direct renderer smoke tests. +AppState = Model + + +# Messages +MSG_BOOT = 1 +MSG_WIFI_CONNECTED = 2 +MSG_WIFI_FAILED = 3 +MSG_TIME_SYNCED = 4 +MSG_WIFI_RETRY = 5 +MSG_TOUCH_DOWN = 6 +MSG_TOUCH_MOVE = 7 +MSG_TOUCH_UP = 8 +MSG_IDLE_TIMEOUT = 9 +MSG_WAKE_COMPLETE = 10 +MSG_DAY_LOADED = 11 +MSG_SEARCH_LOADED = 12 +MSG_DETAIL_READY = 13 +MSG_SCROBBLED = 14 + +# Effects +FX_RENDER = 1 +FX_RENDER_PARTIAL = 2 +FX_RENDER_QUERY = 3 +FX_RENDER_SCROBBLE = 4 +FX_CONNECT_WIFI = 5 +FX_SYNC_TIME = 6 +FX_LOAD_DAY = 7 +FX_LOAD_SEARCH = 8 +FX_PREPARE_DETAIL = 9 +FX_SCROBBLE = 10 +FX_SLEEP_DISPLAY = 11 +FX_WAKE_DISPLAY = 12 +FX_COLLECT_GARBAGE = 13 + +# ============================================================================ +# HELPER: PEN CREATION +# ============================================================================ + +# We create pens once and reuse them to reduce GC pressure. +# Stored as module-level variables, initialised in init_display(). + +_pen_bg = None +_pen_cell_bg = None +_pen_cell_other = None +_pen_today_bg = None +_pen_today_text = None +_pen_normal_text = None +_pen_dim_text = None +_pen_header_text = None +_pen_arrow = None +_pen_back = None +_pen_title = None +_pen_artist = None +_pen_error = None +_pen_placeholder = None +_pen_status = None +_pen_home_btn = None + +# Scrobble button pens +_pen_scrobble_bg = None +_pen_scrobble_text = None +_pen_scrobble_done_bg = None +_pen_scrobble_done_text = None + + +def _init_pens(): + """Pre-create all pens from the RGB color constants.""" + 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, _pen_home_btn + + _pen_bg = display.create_pen(*BG) + _pen_cell_bg = display.create_pen(*CELL_BG) + _pen_cell_other = display.create_pen(*CELL_OTHER_MONTH) + _pen_today_bg = display.create_pen(*TODAY_BG) + _pen_today_text = display.create_pen(*TODAY_TEXT) + _pen_normal_text = display.create_pen(*NORMAL_TEXT) + _pen_dim_text = display.create_pen(*DIM_TEXT) + _pen_header_text = display.create_pen(*HEADER_TEXT) + _pen_arrow = display.create_pen(*ARROW_COLOR) + _pen_back = display.create_pen(*BACK_COLOR) + _pen_title = display.create_pen(*TITLE_COLOR) + _pen_artist = display.create_pen(*ARTIST_COLOR) + _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) + + # Scrobble button + global _pen_scrobble_bg, _pen_scrobble_text + global _pen_scrobble_done_bg, _pen_scrobble_done_text + _pen_scrobble_bg = display.create_pen(*SCROBBLE_BG) + _pen_scrobble_text = display.create_pen(*SCROBBLE_TEXT) + _pen_scrobble_done_bg = display.create_pen(*SCROBBLE_DONE_BG) + _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 +# ============================================================================ + +def draw_wrapped(text, x, y, max_width, pen, scale=TEXT_SCALE): + """Draw text, wrapping at word boundaries to fit within max_width pixels. + + PicoGraphics doesn't support automatic wrapping, so we manually break + long strings. Returns the Y position after the last line drawn. + """ + lines = _wrapped_lines(text, max_width, scale=scale) + + line_height = _text_line_height(scale) + cy = y + display.set_pen(pen) + for line in lines: + display.text(line, x, cy, scale=scale) + cy += line_height + + return cy + + +def draw_wrapped_lines(lines, x, y, pen, scale=TEXT_SCALE): + """Draw pre-wrapped text lines and return the Y position after them.""" + line_height = _text_line_height(scale) + cy = y + display.set_pen(pen) + for line in lines: + display.text(line, x, cy, scale=scale) + cy += line_height + + return cy + + +def _text_line_height(scale=TEXT_SCALE): + """Return a bitmap8 line height including scaled inter-line spacing.""" + return 8 * scale + px(4) + + +def display_text(text): + """Replace characters not available in the bitmap font.""" + replacements = ( + ("–", "-"), + ("—", "-"), + ("−", "-"), + ("‐", "-"), + ("‑", "-"), + ("‒", "-"), + ("“", '"'), + ("”", '"'), + ("„", '"'), + ("«", '"'), + ("»", '"'), + ("‘", "'"), + ("’", "'"), + ("‚", "'"), + ("′", "'"), + ("…", "..."), + ("•", "*"), + ("·", "-"), + ("\u00a0", " "), + ("\u202f", " "), + ("\u2009", " "), + ("\u200a", " "), + ("\u200b", ""), + ) + + rendered = str(text) + for original, replacement in replacements: + rendered = rendered.replace(original, replacement) + + return rendered + + +def _wrapped_line_count(text, max_width, scale=TEXT_SCALE): + """Return how many wrapped lines the text will occupy.""" + return max(1, len(_wrapped_lines(text, max_width, scale=scale))) + + +def _wrapped_lines(text, max_width, scale=TEXT_SCALE): + """Return wrapped lines for text using the display font metrics.""" + words = str(text).split() + lines = [] + current_line = "" + + if not words: + return lines + + for word in words: + test_line = current_line + (" " if current_line else "") + word + w = display.measure_text(test_line, scale=scale) + if w <= max_width: + current_line = test_line + else: + if current_line: + lines.append(current_line) + if display.measure_text(word, scale=scale) > max_width: + lines.append(word) + current_line = "" + else: + current_line = word + + if current_line: + lines.append(current_line) + + return lines + + +# ============================================================================ +# HELPER: DATE / CALENDAR MATH +# ============================================================================ + +def days_in_month(year, month): + """Return the number of days in the given month.""" + if month == 2: + # Leap year check + if (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0): + return 29 + return 28 + if month in (4, 6, 9, 11): + return 30 + return 31 + + +def day_of_week(year, month, day): + """Return 0=Monday .. 6=Sunday using Zeller-like algorithm adjusted + for our calendar (Monday-first weeks).""" + # Tomohiko Sakamoto's algorithm, adjusted for Monday=0 + t = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4] + y = year - (1 if month < 3 else 0) + d = (y + y // 4 - y // 100 + y // 400 + t[month - 1] + day) % 7 + # Convert Sunday=0 to Sunday=6, Monday=1 to Monday=0, etc. + return (d + 6) % 7 + + +def previous_month(year, month): + """Return (year, month) for the previous month.""" + if month == 1: + return year - 1, 12 + return year, month - 1 + + +def next_month(year, month): + """Return (year, month) for the next month.""" + if month == 12: + return year + 1, 1 + return year, month + 1 + + +# ============================================================================ +# NETWORKING +# ============================================================================ + +def connect_wifi_effect(app): + """Connect to WiFi and return ``(connected, detail, api_token)``.""" + try: + import secrets + except ImportError: + return False, "ERROR: secrets.py not found.\nCopy config.example.py to secrets.py", "" + + ssid = getattr(secrets, "WIFI_SSID", "") + password = getattr(secrets, "WIFI_PASSWORD", "") + api_token = getattr(secrets, "API_TOKEN", "") + + if not ssid: + return False, "ERROR: WIFI_SSID not set in secrets.py", api_token + if not api_token: + return False, "ERROR: API_TOKEN not set in secrets.py", api_token + + wlan = network.WLAN(network.STA_IF) + wlan.active(True) + + if wlan.isconnected(): + return True, wlan.ifconfig()[0], api_token + + wlan.connect(ssid, password) + + elapsed = 0 + while not wlan.isconnected() and elapsed < WIFI_TIMEOUT: + time.sleep(0.5) + elapsed += 0.5 + + if wlan.isconnected(): + return True, wlan.ifconfig()[0], api_token + + return ( + False, + "WiFi connection failed.\nCheck credentials.\nRetrying in 10 seconds...", + api_token, + ) + + +def sync_time_effect(): + """Synchronise system time via NTP and return the available date.""" + try: + ntptime.settime() + except Exception: + pass + return current_date() + + +def current_date(): + """Read the current calendar date from the system clock.""" + lt = time.localtime() + return lt[0], lt[1], lt[2] + + +def make_date_string(year, month, day): + """Format a date as YYYY-MM-DD for the API.""" + return "{:04d}-{:02d}-{:02d}".format(year, month, day) + + +# ============================================================================ +# DISPLAY SLEEP +# ============================================================================ + +def _fade_backlight(start, end, duration_ms=DISPLAY_FADE_MS, steps=DISPLAY_FADE_STEPS): + """Gradually fade backlight from start to end brightness.""" + step_delay = duration_ms / 1000.0 / steps + for i in range(steps + 1): + t = i / steps + brightness = start + (end - start) * t + try: + presto.set_backlight(brightness) + except Exception: + return + time.sleep(step_delay) + + +def sleep_display_effect(): + """Turn off the display backlight with a gradual fade.""" + _fade_backlight(DISPLAY_BRIGHTNESS, DISPLAY_SLEEP_BRIGHTNESS) + + +def wake_display_effect(app): + """Restore backlight, refresh the date, and best-effort reconnect WiFi.""" + _fade_backlight(DISPLAY_SLEEP_BRIGHTNESS, DISPLAY_BRIGHTNESS) + + if not wifi_connected(): + connect_wifi_effect(app) + + return current_date() + + +def wifi_connected(): + """Return True if the WLAN interface is currently connected.""" + try: + wlan = network.WLAN(network.STA_IF) + wlan.active(True) + return wlan.isconnected() + except Exception: + return False + + +# ============================================================================ +# API CLIENT +# ============================================================================ + +def _auth_header(app): + """Return the Authorization header dict for urequests.""" + return {"Authorization": "Bearer " + app.api_token} + + +def fetch_records(app, year, month, day): + """Fetch records released on the given date from the API. + + Returns (records_list, error_flag). + - On success: (list_of_dicts, False) + - On failure: ([], True) + """ + date_str = make_date_string(year, month, day) + url = API_BASE + "/api/v1/collection/on_this_day?date=" + date_str + + try: + resp = urequests.get(url, headers=_auth_header(app)) + 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 as e: + # OSError for network failures, ValueError for JSON parse errors + gc.collect() + return [], True + + +def fetch_search_results(app, 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(app)) + 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(app, url): + """Fetch a JPEG thumbnail from the given URL. + + Returns the raw bytes on success, None on failure. + """ + try: + resp = urequests.get(url, headers=_auth_header(app)) + if resp.status_code == 200: + data = resp.content + resp.close() + return data + resp.close() + except Exception: + pass + return None + + +# ============================================================================ +# EFFECT DATA PREPARATION +# ============================================================================ + +def load_day_effect(app, year, month, day): + """Load and prepare a day list before it becomes visible.""" + if not ensure_wifi_connected_effect(app): + return MSG_DAY_LOADED, [], True, 0 + + recs, had_error = fetch_records(app, year, month, day) + content_height = 0 if had_error else prepare_record_list(app, recs) + return MSG_DAY_LOADED, recs, had_error, content_height + + +def load_search_effect(app, query): + """Load and prepare search results before they become visible.""" + if not ensure_wifi_connected_effect(app): + return MSG_SEARCH_LOADED, [], True, 0 + + recs, had_error = fetch_search_results(app, query) + content_height = 0 if had_error else prepare_record_list(app, recs) + return MSG_SEARCH_LOADED, recs, had_error, content_height + + +def ensure_wifi_connected_effect(app): + """Reconnect as part of request effects when the interface dropped.""" + if wifi_connected(): + return True + + connected, _detail, _api_token = connect_wifi_effect(app) + return connected + + +def selected_record(app): + """Return the record selected for the detail screen.""" + if app.detail.source_screen == STATE_SEARCH_RESULTS: + return app.search.results[app.detail.selected_index] + return app.day.records[app.detail.selected_index] + + +def prepare_detail_effect(app): + """Prepare detail layout and medium cover bytes outside rendering.""" + rec = dict(selected_record(app)) + content_height = _measure_detail_content(app, rec) + cover_url = _record_detail_cover_url(rec) + + if cover_url and rec.get("_detail_thumb_data", None) is None: + if not rec.get("_detail_thumb_failed", False): + data = fetch_thumbnail(app, cover_url) + if data is None: + rec["_detail_thumb_failed"] = True + else: + rec["_detail_thumb_data"] = data + + return MSG_DETAIL_READY, content_height, rec + + +def scrobble_effect(app, rec): + """Post a scrobble request and return whether it succeeded.""" + rec_id = rec.get("id") + if not rec_id: + return MSG_SCROBBLED, False + + url = API_BASE + "/api/v1/collection/" + str(rec_id) + "/scrobble" + succeeded = False + try: + resp = urequests.post(url, headers=_auth_header(app)) + succeeded = resp.status_code == 200 + resp.close() + except Exception: + succeeded = False + + gc.collect() + return MSG_SCROBBLED, succeeded + + +def _close_jpeg(jpeg): + """Release a jpegdec object if the firmware exposes close().""" + if jpeg is None: + return + + try: + jpeg.close() + except Exception: + pass + + +def draw_jpeg(data, x, y, placeholder_w, placeholder_h): + """Decode and draw an API-sized JPEG image at x, y. + + Uses jpegdec module (standard on Pimoroni firmware). + Falls back to a placeholder rectangle on failure. + """ + if data is None: + _draw_placeholder(x, y, placeholder_w, placeholder_h) + return + + # Try jpegdec module. + if _HAS_JPEGDEC: + jpeg = None + try: + jpeg = _jpegdec_lib.JPEG(display) + try: + jpeg.open_RAM(memoryview(data)) + except Exception: + jpeg.open_RAM(data) + + try: + jpeg.decode(x, y) + except TypeError: + # Older firmware expects a third full-size decode argument. + jpeg.decode(x, y, 0) + _close_jpeg(jpeg) + return + except Exception: + _close_jpeg(jpeg) + pass + + # Method 2: PicoGraphics built-in JPEG support + try: + if hasattr(display, "open_jpeg_from_RAM"): + display.open_jpeg_from_RAM(data) + display.decode_jpeg(x, y) + return + except Exception: + pass + + # Method 3: alternate PicoGraphics JPEG API + try: + if hasattr(display, "draw_jpeg"): + display.draw_jpeg(data, x, y) + return + except Exception: + pass + + # All methods failed — draw placeholder + _draw_placeholder(x, y, placeholder_w, placeholder_h) + + +def _draw_placeholder(x, y, w, h): + """Draw a grey placeholder rectangle with a question mark.""" + display.set_pen(_pen_placeholder) + display.rectangle(x, y, w, h) + display.set_pen(_pen_dim_text) + cx = x + w // 2 - display.measure_text("?", scale=PLACEHOLDER_TEXT_SCALE) // 2 + cy = y + h // 2 - px(10) + display.text("?", cx, cy, scale=PLACEHOLDER_TEXT_SCALE) + + +# ============================================================================ +# DRAWING: STATUS SCREEN (startup / error) +# ============================================================================ + +def draw_status(message): + """Draw a simple centered status message on the display (used during + startup and for persistent errors).""" + display.set_pen(_pen_bg) + display.clear() + display.set_pen(_pen_status) + display.set_font("bitmap8") + + lines = message.split("\n") + line_h = px(20) + total_h = len(lines) * line_h + start_y = (HEIGHT - total_h) // 2 + + for i, line in enumerate(lines): + w = display.measure_text(line, scale=TEXT_SCALE) + x = (WIDTH - w) // 2 + display.text(line, x, start_y + i * line_h, scale=TEXT_SCALE) + + _display_update() + + +# ============================================================================ +# DRAWING: MONTH CALENDAR +# ============================================================================ + +def draw_month_view(app): + """Render the full month calendar view including header, day-of-week + labels, day grid, and today highlight.""" + display.set_pen(_pen_bg) + display.clear() + + _draw_month_header(app) + _draw_day_labels() + _draw_day_grid(app) + _display_update() + + +HOME_BTN_W = px(30) +HOME_BTN_H = px(25) +HOME_BTN_Y = HEADER_Y + (HEADER_H - HOME_BTN_H) // 2 + + +def _draw_month_header(app): + """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 between arrow buttons) + display.set_pen(_pen_header_text) + display.set_font("bitmap14_outline") + title = "{} {}".format(MONTH_NAMES[app.view_month - 1], app.view_year) + tw = display.measure_text(title, scale=TEXT_SCALE) + # Left boundary: after Home + left-arrow; right boundary: before right-arrow + title_left = HEADER_SIDE_MARGIN + HOME_BTN_W + px(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=TEXT_SCALE) + + # 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, TEXT_H) + + # Left arrow (shifted right to make room for Home) + lx = hx + HOME_BTN_W + px(4) + ly = HEADER_BUTTON_Y + display.set_pen(_pen_placeholder) + display.rectangle(lx, ly, ARROW_W, ARROW_H) + display.set_pen(_pen_back) + display.set_font("bitmap8") + _draw_centered_text("<", lx, ly, ARROW_W, ARROW_H, TEXT_H) + + # Right arrow + rx = WIDTH - ARROW_W - HEADER_SIDE_MARGIN + ry = HEADER_BUTTON_Y + display.set_pen(_pen_placeholder) + display.rectangle(rx, ry, ARROW_W, ARROW_H) + display.set_pen(_pen_back) + display.set_font("bitmap8") + _draw_centered_text(">", rx, ry, ARROW_W, ARROW_H, TEXT_H) + + +def _draw_centered_text(text, x, y, w, h, text_h, scale=TEXT_SCALE): + """Draw text centered inside a rectangular area.""" + tw = display.measure_text(text, scale=scale) + tx = x + (w - tw) // 2 + ty = y + (h - text_h) // 2 + display.text(text, tx, ty, scale=scale) + + +def _draw_day_labels(): + """Draw Mon..Sun labels below the header.""" + display.set_pen(_pen_dim_text) + display.set_font("bitmap8") + for i, name in enumerate(DAY_NAMES): + cx = GRID_LEFT + i * (CELL_SIZE + CELL_GAP) + CELL_SIZE // 2 + tw = display.measure_text(name, scale=TEXT_SCALE) + display.text(name, cx - tw // 2, DOW_Y, scale=TEXT_SCALE) + + +def _draw_day_grid(app): + """Draw the 7x6 grid of day cells for the current view month.""" + days = days_in_month(app.view_year, app.view_month) + first_dow = day_of_week(app.view_year, app.view_month, 1) + + display.set_font("bitmap8") + + # Draw cells for 6 rows + for row in range(MAX_ROWS): + for col in range(CELLS_PER_ROW): + cell_index = row * CELLS_PER_ROW + col + day_num = cell_index - first_dow + 1 + + cx = GRID_LEFT + col * (CELL_SIZE + CELL_GAP) + cy = CALENDAR_TOP + row * (CELL_SIZE + CELL_GAP) + + if day_num < 1 or day_num > days: + # Cell belongs to previous/next month — dim it + display.set_pen(_pen_cell_other) + display.rectangle(cx, cy, CELL_SIZE, CELL_SIZE) + if day_num > days: + # Show overflow day numbers dimmed + overflow = day_num - days + display.set_pen(_pen_dim_text) + _draw_centered_num(overflow, cx + CELL_SIZE // 2, cy + CELL_SIZE // 2) + continue + + # Determine cell style + is_today = ( + app.view_year == app.today_year + and app.view_month == app.today_month + and day_num == app.today_day + ) + is_selected = (app.screen == STATE_DAY and day_num == app.day.selected_day) + + # Handle today + selected combo specially (show both highlight and border) + if is_today and is_selected: + display.set_pen(_pen_today_bg) + display.rectangle(cx, cy, CELL_SIZE, CELL_SIZE) + # Selection border on top of today highlight + display.set_pen(_pen_arrow) + display.line(cx, cy, cx + CELL_SIZE - 1, cy) + display.line(cx, cy + CELL_SIZE - 1, cx + CELL_SIZE - 1, cy + CELL_SIZE - 1) + display.line(cx, cy, cx, cy + CELL_SIZE - 1) + display.line(cx + CELL_SIZE - 1, cy, cx + CELL_SIZE - 1, cy + CELL_SIZE - 1) + display.set_pen(_pen_today_text) + _draw_centered_num(day_num, cx + CELL_SIZE // 2, cy + CELL_SIZE // 2) + continue + + if is_today: + display.set_pen(_pen_today_bg) + elif is_selected: + # Outline the selected cell (non-today) + display.set_pen(_pen_cell_bg) + display.rectangle(cx, cy, CELL_SIZE, CELL_SIZE) + display.set_pen(_pen_arrow) + display.line(cx, cy, cx + CELL_SIZE - 1, cy) + display.line(cx, cy + CELL_SIZE - 1, cx + CELL_SIZE - 1, cy + CELL_SIZE - 1) + display.line(cx, cy, cx, cy + CELL_SIZE - 1) + display.line(cx + CELL_SIZE - 1, cy, cx + CELL_SIZE - 1, cy + CELL_SIZE - 1) + display.set_pen(_pen_normal_text) + _draw_centered_num(day_num, cx + CELL_SIZE // 2, cy + CELL_SIZE // 2) + continue + else: + display.set_pen(_pen_cell_bg) + + display.rectangle(cx, cy, CELL_SIZE, CELL_SIZE) + + # Day number + if is_today: + display.set_pen(_pen_today_text) + else: + display.set_pen(_pen_normal_text) + _draw_centered_num(day_num, cx + CELL_SIZE // 2, cy + CELL_SIZE // 2) + + +def _draw_centered_num(num, cx, cy): + """Draw a number centered at (cx, cy).""" + text = str(num) + tw = display.measure_text(text, scale=TEXT_SCALE) + display.text(text, cx - tw // 2, cy - px(5), scale=TEXT_SCALE) + + +# ============================================================================ +# DRAWING: HOME SCREEN (splash) +# ============================================================================ + +def draw_home_screen(app): + """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=TEXT_SCALE) + display.text(HOME_TITLE, (WIDTH - tw) // 2, HOME_TITLE_Y, scale=TEXT_SCALE) + + # Buttons + _draw_home_button(HOME_BUTTON1_Y, "Search Collection") + _draw_home_button(HOME_BUTTON2_Y, "Today's Records") + + _display_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=TEXT_SCALE) + tx = HOME_BUTTON_X + (HOME_BUTTON_W - tw) // 2 + ty = y + (HOME_BUTTON_H - TEXT_H) // 2 + display.text(label, tx, ty, scale=TEXT_SCALE) + + +# ============================================================================ +# DRAWING: SEARCH INPUT (keyboard) +# ============================================================================ + +def draw_search_input(app): + """Render the search input view with query field and on-screen keyboard.""" + display.set_pen(_pen_bg) + 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) + + display.set_pen(_pen_cell_bg) + display.rectangle(KB_INPUT_X, KB_INPUT_Y, KB_INPUT_W, KB_INPUT_H) + display.set_pen(_pen_header_text) + display.set_font("bitmap8") + + display_q = app.search.query if app.search.query else "_" + display.text( + display_q, + KB_INPUT_X + px(8), + KB_INPUT_Y + px(12), + scale=TEXT_SCALE + ) + + +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(): + """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=TEXT_SCALE) + display.text(title, (WIDTH - tw) // 2, + DAY_HEADER_Y + (DAY_HEADER_H - DAY_HEADER_TEXT_H) // 2, scale=TEXT_SCALE) + + +def _draw_keyboard(app): + """Draw the on-screen QWERTY or numbers keyboard.""" + display.set_font("bitmap8") + keys_matrix = ALPHA_KEYS if app.search.keyboard_mode == "alpha" else NUM_KEYS + toggle_label = "123" if app.search.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 app.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=TEXT_SCALE) + tx = x + (w - tw) // 2 + ty = y + (h - TEXT_H) // 2 + display.text(label, tx, ty, scale=TEXT_SCALE) + + +def _keyboard_hit_test(app, 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 app.search.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) + _partial_display_update(kx, ky, kw, kh) + time.sleep(0.05) + + +# ============================================================================ +# DRAWING: SEARCH RESULTS +# ============================================================================ + +def draw_search_results(app, partial=False): + """Render the search results view, reusing record-list rendering.""" + if partial: + _clear_scroll_viewport() + else: + display.set_pen(_pen_bg) + display.clear() + + _draw_search_results_header(app) + + if app.search.error: + _draw_search_error() + if partial: + _partial_scroll_viewport_update() + else: + _display_update() + return + + if not app.search.results: + _draw_search_empty() + if partial: + _partial_scroll_viewport_update() + else: + _display_update() + return + + _set_clip_below_header() + _draw_search_record_list(app) + _remove_clip() + if partial: + _partial_scroll_viewport_update() + else: + _display_update() + + +def _draw_search_results_header(app): + """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: " — truncate for display only, never mutate + # the stored search query so user can refine on Back + display.set_pen(_pen_header_text) + display.set_font("bitmap14_outline") + display_label = "Search: " + app.search.query + max_w = WIDTH - (BACK_X + BACK_W + px(8)) - px(8) + if display.measure_text(display_label, scale=TEXT_SCALE) > max_w: + suffix = "..." + display_q = app.search.query + while (display.measure_text("Search: " + display_q + suffix, scale=TEXT_SCALE) > 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=TEXT_SCALE) + display.text(display_label, (WIDTH - tw) // 2, + DAY_HEADER_Y + (DAY_HEADER_H - DAY_HEADER_TEXT_H) // 2, scale=TEXT_SCALE) + + +def _draw_search_record_list(app): + """Draw the scrollable search results list, mirroring day-view scroll.""" + max_offset = _search_max_scroll_offset(app) + app.search.scroll_offset = min(max(0, app.search.scroll_offset), max_offset) + + # Up arrow if scrolled down + if app.search.scroll_offset > 0: + display.set_pen(_pen_arrow) + display.set_font("bitmap14_outline") + arrow_w = display.measure_text("^", scale=TEXT_SCALE) + display.text("^", (WIDTH - arrow_w) // 2, DAY_HEADER_H + px(4), scale=TEXT_SCALE) + + cy = RECORD_START_Y - app.search.scroll_offset + for rec in app.search.results: + row_h = rec.get("_row_height", THUMB_SIZE + px(8)) + row_bottom = cy + row_h + + if row_bottom <= RECORD_START_Y: + cy = row_bottom + continue + + if cy >= HEIGHT - px(28): + break + + _draw_record_row(app, rec, cy) + cy = row_bottom + + # Down arrow if more records below + if app.search.scroll_offset < max_offset: + display.set_pen(_pen_arrow) + display.set_font("bitmap14_outline") + arrow_w = display.measure_text("v", scale=TEXT_SCALE) + display.text("v", (WIDTH - arrow_w) // 2, HEIGHT - px(24), scale=TEXT_SCALE) + + +def _search_max_scroll_offset(app): + """Return the maximum pixel scroll offset for search results.""" + viewport_h = HEIGHT - RECORD_START_Y - px(28) + return max(0, app.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=TEXT_SCALE) + display.text(msg, (WIDTH - mw) // 2, HEIGHT // 2 - px(10), scale=TEXT_SCALE) + + +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=TEXT_SCALE) + display.text(msg, (WIDTH - mw) // 2, HEIGHT // 2 - px(20), scale=TEXT_SCALE) + + display.set_pen(_pen_dim_text) + hint = "Tap to retry" + hw = display.measure_text(hint, scale=TEXT_SCALE) + display.text(hint, (WIDTH - hw) // 2, HEIGHT // 2 + px(10), scale=TEXT_SCALE) + + +# ============================================================================ +# DRAWING: DAY VIEW (records list) +# ============================================================================ + +def draw_day_view(app, partial=False): + """Render the day view showing records for the selected date.""" + if partial: + _clear_scroll_viewport() + else: + display.set_pen(_pen_bg) + display.clear() + + _draw_day_header(app) + + if app.day.error: + _draw_day_error() + if partial: + _partial_scroll_viewport_update() + else: + _display_update() + return + + if not app.day.records: + _draw_day_empty() + if partial: + _partial_scroll_viewport_update() + else: + _display_update() + return + + _set_clip_below_header() + _draw_record_list(app) + _remove_clip() + if partial: + _partial_scroll_viewport_update() + else: + _display_update() + + +def _draw_day_header(app): + """Draw the header with formatted date and back button.""" + # Background bar + 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) + + # Formatted date + date_str = "{} {}, {}".format( + MONTH_NAMES[app.view_month - 1], app.day.selected_day, app.view_year + ) + display.set_pen(_pen_header_text) + display.set_font("bitmap14_outline") + tw = display.measure_text(date_str, scale=TEXT_SCALE) + display.text( + date_str, + (WIDTH - tw) // 2, + DAY_HEADER_Y + (DAY_HEADER_H - DAY_HEADER_TEXT_H) // 2, + scale=TEXT_SCALE + ) + + # Record count + record_count = len(app.day.records) + count_str = "{} record{}".format(record_count, "s" if record_count != 1 else "") + display.set_pen(_pen_dim_text) + display.set_font("bitmap8") + cw = display.measure_text(count_str, scale=TEXT_SCALE) + display.text( + count_str, + WIDTH - cw - px(11), + DAY_HEADER_Y + (DAY_HEADER_H - DAY_COUNT_TEXT_H) // 2, + scale=TEXT_SCALE + ) + + +def _draw_day_error(): + """Show error message when API call failed.""" + display.set_pen(_pen_error) + display.set_font("bitmap8") + msg = "Could not reach server" + mw = display.measure_text(msg, scale=TEXT_SCALE) + display.text(msg, (WIDTH - mw) // 2, HEIGHT // 2 - px(20), scale=TEXT_SCALE) + + display.set_pen(_pen_dim_text) + hint = "Tap < Back to return" + hw = display.measure_text(hint, scale=TEXT_SCALE) + display.text(hint, (WIDTH - hw) // 2, HEIGHT // 2 + px(10), scale=TEXT_SCALE) + + +def _draw_day_empty(): + """Show message when no records exist for the selected date.""" + display.set_pen(_pen_dim_text) + display.set_font("bitmap8") + msg = "No records on this day" + mw = display.measure_text(msg, scale=TEXT_SCALE) + display.text(msg, (WIDTH - mw) // 2, HEIGHT // 2 - px(10), scale=TEXT_SCALE) + + +def _draw_record_list(app): + """Draw the scrollable list of record rows with cover thumbnails. + Uses dynamic row heights so wrapped text doesn't break layout.""" + max_offset = _max_scroll_offset(app) + app.day.scroll_offset = min(max(0, app.day.scroll_offset), max_offset) + + # Up arrow if scrolled down + if app.day.scroll_offset > 0: + display.set_pen(_pen_arrow) + display.set_font("bitmap14_outline") + arrow_w = display.measure_text("^", scale=TEXT_SCALE) + display.text("^", (WIDTH - arrow_w) // 2, DAY_HEADER_H + px(4), scale=TEXT_SCALE) + + cy = RECORD_START_Y - app.day.scroll_offset + + for rec in app.day.records: + row_h = rec.get("_row_height", THUMB_SIZE + px(8)) + row_bottom = cy + row_h + + if row_bottom <= RECORD_START_Y: + cy = row_bottom + continue + + if cy >= HEIGHT - px(28): + break + + _draw_record_row(app, rec, cy) + cy = row_bottom + + # Down arrow if more records below + if app.day.scroll_offset < max_offset: + display.set_pen(_pen_arrow) + display.set_font("bitmap14_outline") + arrow_w = display.measure_text("v", scale=TEXT_SCALE) + display.text("v", (WIDTH - arrow_w) // 2, HEIGHT - px(24), scale=TEXT_SCALE) + +def _max_scroll_offset(app): + """Return the maximum pixel scroll offset for the current records.""" + viewport_h = HEIGHT - RECORD_START_Y - px(28) + return max(0, app.day.content_height - viewport_h) + + +def prepare_record_list(app, recs): + """Cache display fields, row heights, and thumbnails for a record list. + + Returns the list content height for the caller's view state. + """ + display.set_font("bitmap8") + + content_height = 0 + for rec in recs: + _prepare_record_for_display(rec) + content_height += rec.get("_row_height", THUMB_SIZE + px(8)) + + preload_record_thumbnails(app, recs) + gc.collect() + return content_height + + +def _prepare_record_for_display(rec): + """Prepare one record's display text and row height.""" + title = display_text(rec.get("title", "Unknown Title")) + artists = rec.get("artists", []) + artist_str = display_text(", ".join(artists)) if artists else "" + meta_text = _record_meta_text(rec) + + rec["_display_title"] = title + rec["_display_artists"] = artist_str + rec["_display_meta"] = meta_text + rec["_display_title_lines"] = _wrapped_lines(title, TEXT_W, scale=TEXT_SCALE) + rec["_display_artist_lines"] = ( + _wrapped_lines(artist_str, TEXT_W, scale=TEXT_SCALE) if artist_str else [] + ) + rec["_display_meta_lines"] = ( + _wrapped_lines(meta_text, TEXT_W, scale=TEXT_SCALE) if meta_text else [] + ) + rec["_thumb_url"] = _record_thumbnail_url(rec) + rec["_row_height"] = _record_row_height(rec) + + +def preload_record_thumbnails(app, recs): + """Fetch thumbnail bytes before the first draw of a record list.""" + for rec in recs: + if _record_thumbnail_url(rec): + _record_thumbnail_data(app, rec) + + +def _record_row_height(rec): + """Calculate a record row height without drawing it.""" + min_h = THUMB_SIZE + ROW_PAD_Y * 2 + ROW_SEPARATOR_H + title_lines = rec.get("_display_title_lines", None) + if title_lines is None: + title = rec.get("_display_title", display_text(rec.get("title", "Unknown Title"))) + title_lines = _wrapped_lines(title, TEXT_W, scale=TEXT_SCALE) + ty = max(1, len(title_lines)) * TEXT_LINE_H + + artist_lines = rec.get("_display_artist_lines", None) + if artist_lines is None: + artist_str = rec.get("_display_artists", "") + artist_lines = _wrapped_lines(artist_str, TEXT_W, scale=TEXT_SCALE) if artist_str else [] + if artist_lines: + ty += px(2) + len(artist_lines) * TEXT_LINE_H + + meta_lines = rec.get("_display_meta_lines", None) + if meta_lines is None: + meta_text = rec.get("_display_meta", _record_meta_text(rec)) + meta_lines = _wrapped_lines(meta_text, TEXT_W, scale=TEXT_SCALE) if meta_text else [] + if meta_lines: + ty += px(2) + len(meta_lines) * TEXT_LINE_H + + content_h = max(THUMB_SIZE, ty) + ROW_PAD_Y * 2 + ROW_SEPARATOR_H + return max(min_h, content_h) + + +def _record_meta_text(rec): + """Return compact format/date metadata for a record row.""" + record_format = rec.get("format", "") + release_year = _release_year(rec.get("release_date", "")) + + if record_format and release_year: + return "{} | {}".format(record_format, release_year) + if record_format: + return record_format + return release_year + + +def _release_year(release_date): + """Extract a display year from an API release_date value.""" + if not release_date: + return "" + return str(release_date)[:4] + + +def _record_thumbnail_data(app, rec): + """Fetch thumbnail bytes once per record and reuse them on redraw.""" + if rec.get("_thumb_failed", False): + return None + + cached = rec.get("_thumb_data", None) + if cached is not None: + return cached + + thumb_url = rec.get("_thumb_url", "") or _record_thumbnail_url(rec) + if not thumb_url: + return None + + data = fetch_thumbnail(app, thumb_url) + if data is None: + rec["_thumb_failed"] = True + else: + rec["_thumb_data"] = data + + return data + + +def _record_thumbnail_url(rec): + """Return the API-sized cover URL for a record row.""" + return _record_cover_url(rec, "small") + + +def _record_detail_cover_url(rec): + """Return the API-sized cover URL for a record detail page.""" + return _record_cover_url(rec, "medium") + + +def _record_cover_url(rec, size): + """Return a named cover URL from the API covers object.""" + covers = rec.get("covers", {}) + if not covers: + return "" + return covers.get(size, "") or "" + + +def _draw_record_row(app, rec, y): + """Draw a single record row using its cached layout height.""" + row_h = rec.get("_row_height", None) + if row_h is None: + row_h = _record_row_height(rec) + row_bottom = y + row_h + + # Fetch and draw thumbnail (top-aligned in row) + thumb_y = y + ROW_PAD_Y + if app.touch.dragging: + _draw_placeholder(THUMB_MARGIN, thumb_y, THUMB_SIZE, THUMB_SIZE) + else: + jpeg_data = _record_thumbnail_data(app, rec) + if jpeg_data is not None: + draw_jpeg(jpeg_data, THUMB_MARGIN, thumb_y, THUMB_SIZE, THUMB_SIZE) + else: + _draw_placeholder(THUMB_MARGIN, thumb_y, THUMB_SIZE, THUMB_SIZE) + + # Title (starts at same height as thumbnail) + display.set_font("bitmap8") + ty = y + ROW_PAD_Y + title_lines = rec.get("_display_title_lines", None) + if title_lines is None: + title = rec.get("_display_title", display_text(rec.get("title", "Unknown Title"))) + ty = draw_wrapped(title, TEXT_X, ty, TEXT_W, _pen_title, scale=TEXT_SCALE) + else: + ty = draw_wrapped_lines(title_lines, TEXT_X, ty, _pen_title, scale=TEXT_SCALE) + + # Artists + artist_lines = rec.get("_display_artist_lines", None) + if artist_lines is None: + artist_str = rec.get("_display_artists", "") + artist_lines = _wrapped_lines(artist_str, TEXT_W, scale=TEXT_SCALE) if artist_str else [] + if artist_lines: + ty += px(2) + ty = draw_wrapped_lines(artist_lines, TEXT_X, ty, _pen_artist, scale=TEXT_SCALE) + + meta_lines = rec.get("_display_meta_lines", None) + if meta_lines is None: + meta_text = rec.get("_display_meta", _record_meta_text(rec)) + meta_lines = _wrapped_lines(meta_text, TEXT_W, scale=TEXT_SCALE) if meta_text else [] + if meta_lines: + ty += px(2) + ty = draw_wrapped_lines(meta_lines, TEXT_X, ty, _pen_dim_text, scale=TEXT_SCALE) + + # Separator line at the computed bottom + display.set_pen(_pen_cell_other) + display.line( + THUMB_MARGIN, + row_bottom - ROW_SEPARATOR_H, + WIDTH - THUMB_MARGIN, + row_bottom - ROW_SEPARATOR_H + ) + + +# ============================================================================ +# DRAWING: RECORD DETAIL +# ============================================================================ + +def _max_detail_scroll_offset(app): + """Return the maximum pixel scroll offset for the detail view.""" + if app.detail.content_height == 0: + return 0 + content_bottom = DETAIL_COVER_Y + app.detail.content_height + return max(0, content_bottom - HEIGHT) + + +def _measure_detail_text_height(text, font="bitmap8", scale=TEXT_SCALE, max_w=None): + """Return the pixel height that text would occupy when drawn wrapped.""" + if max_w is None: + max_w = DETAIL_TEXT_W + lines = _wrapped_lines(text, max_w, scale=scale) + line_height = _line_height_for_font(font, scale) + return len(lines) * line_height + + +def _line_height_for_font(font, scale=TEXT_SCALE): + """Return scaled line height for the bitmap font used by a detail section.""" + if font == "bitmap14_outline": + return 14 * scale + return _text_line_height(scale) + + +def _line_metrics(lines, font="bitmap8", scale=TEXT_SCALE): + """Pair pre-wrapped text lines with their measured widths.""" + display.set_font(font) + metrics = [] + for line in lines: + metrics.append((line, display.measure_text(line, scale=scale))) + return metrics + + +def _measure_detail_content(app, rec): + """Compute display caches and return scrollable detail content height.""" + h = 0 + + # Title (above cover) + title = rec.get("_display_title", + display_text(rec.get("title", "Unknown Title"))) + display.set_font("bitmap14_outline") + title_lines = _wrapped_lines(title, DETAIL_TEXT_W, scale=TEXT_SCALE) + rec["_detail_title_lines"] = _line_metrics( + title_lines, font="bitmap14_outline", scale=TEXT_SCALE + ) + h += len(title_lines) * TITLE_LINE_H + h += px(6) + + # Artists (above cover) + artist_str = rec.get("_display_artists", "") + if not artist_str: + artists = rec.get("artists", []) + artist_str = display_text(", ".join(artists)) if artists else "" + if artist_str: + display.set_font("bitmap8") + artist_lines = _wrapped_lines(artist_str, DETAIL_TEXT_W, scale=TEXT_SCALE) + rec["_detail_artist_lines"] = _line_metrics(artist_lines, scale=TEXT_SCALE) + h += len(artist_lines) * TEXT_LINE_H + h += px(4) + else: + rec["_detail_artist_lines"] = [] + + h += DETAIL_INFO_GAP # gap before cover + + # Cover + h += DETAIL_COVER_SIZE + h += DETAIL_INFO_GAP + + # Genres (below cover) + genres = rec.get("genres", []) + if genres: + genre_str = display_text(", ".join(genres)) + display.set_font("bitmap8") + genre_lines = _wrapped_lines(genre_str, DETAIL_TEXT_W, scale=TEXT_SCALE) + rec["_detail_genre_lines"] = _line_metrics(genre_lines, scale=TEXT_SCALE) + h += len(genre_lines) * TEXT_LINE_H + h += px(4) + else: + rec["_detail_genre_lines"] = [] + + h += px(6) + + # Metadata line: record_type | format | year + meta_parts = [] + record_type = rec.get("record_type", "") + if record_type: + meta_parts.append(display_text(record_type)) + record_format = rec.get("format", "") + if record_format: + meta_parts.append(display_text(record_format)) + release_date = rec.get("release_date", "") + if release_date: + meta_parts.append(str(release_date)[:4]) + + if meta_parts: + meta_str = " | ".join(meta_parts) + display.set_font("bitmap8") + meta_lines = _wrapped_lines(meta_str, DETAIL_TEXT_W, scale=TEXT_SCALE) + rec["_detail_meta_lines"] = _line_metrics(meta_lines, scale=TEXT_SCALE) + h += len(meta_lines) * TEXT_LINE_H + h += px(4) + else: + rec["_detail_meta_lines"] = [] + + # Purchased at + purchased_at = rec.get("purchased_at", "") + if purchased_at: + purch_str = "Purchased: " + display_text(str(purchased_at)[:10]) + display.set_font("bitmap8") + purchased_lines = _wrapped_lines(purch_str, DETAIL_TEXT_W, scale=TEXT_SCALE) + rec["_detail_purchased_lines"] = _line_metrics(purchased_lines, scale=TEXT_SCALE) + h += len(purchased_lines) * TEXT_LINE_H + else: + rec["_detail_purchased_lines"] = [] + + # Scrobble button (at bottom, only if scrobble-eligible) + if rec.get("selected_release_id"): + h += SCROBBLE_BUTTON_GAP + h += SCROBBLE_BUTTON_H + + return h + + +def _set_clip_below_header(): + """Restrict drawing to the area below the fixed header.""" + try: + display.set_clip(0, DAY_HEADER_Y + DAY_HEADER_H, + WIDTH, HEIGHT - (DAY_HEADER_Y + DAY_HEADER_H)) + except Exception: + pass + + +def _remove_clip(): + """Remove any active clip rectangle.""" + try: + display.remove_clip() + except Exception: + pass + + +def draw_record_detail(app, partial=False): + """Render the individual record detail view. Layout order: + title, artists, large cover, genres, metadata, purchased at.""" + 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] + else: + rec = app.day.records[app.detail.selected_index] + + # The application prepares layout as an effect; direct smoke draws fall + # back to preparing once and drag redraws only consume cached values. + if app.detail.content_height == 0: + app.detail.content_height = _measure_detail_content(app, rec) + + # Clamp scroll offset + max_off = _max_detail_scroll_offset(app) + if app.detail.scroll_offset > max_off: + app.detail.scroll_offset = max_off + if app.detail.scroll_offset < 0: + app.detail.scroll_offset = 0 + + offset = app.detail.scroll_offset + + if not partial: + _draw_detail_header() + + # Clip scrolling content below the header + _set_clip_below_header() + + # Start drawing below the header + y = DETAIL_COVER_Y - offset + + # Title and artists (above cover) + y = _draw_detail_title_artists(rec, y) + y += DETAIL_INFO_GAP # gap before cover + + # Cover image + _draw_detail_cover(app, rec, y) + + # Genres, metadata, purchased at (below cover) + y = y + DETAIL_COVER_SIZE + DETAIL_INFO_GAP + _draw_detail_info_below_cover(rec, y) + + # Scrobble button (at bottom of content, only if eligible) + if rec.get("selected_release_id"): + 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): + _draw_scrobble_button(app, rec, button_y) + + # Scroll indicators + display.set_font("bitmap14_outline") + if offset > 0: + display.set_pen(_pen_arrow) + arrow_w = display.measure_text("^", scale=TEXT_SCALE) + display.text("^", (WIDTH - arrow_w) // 2, DAY_HEADER_H + px(4), scale=TEXT_SCALE) + if offset < max_off: + display.set_pen(_pen_arrow) + arrow_w = display.measure_text("v", scale=TEXT_SCALE) + display.text("v", (WIDTH - arrow_w) // 2, HEIGHT - px(24), scale=TEXT_SCALE) + + _remove_clip() + + 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(): + """Draw the record detail header with back button.""" + 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 label + display.set_pen(_pen_header_text) + display.set_font("bitmap14_outline") + label = "Record" + tw = display.measure_text(label, scale=TEXT_SCALE) + display.text( + label, + (WIDTH - tw) // 2, + DAY_HEADER_Y + (DAY_HEADER_H - DAY_HEADER_TEXT_H) // 2, + scale=TEXT_SCALE + ) + + +def _draw_detail_title_artists(rec, y): + """Draw title and artists above the cover. Returns y after drawing.""" + # Title + title_lines = rec.get("_detail_title_lines", None) + if title_lines is None: + title = rec.get("_display_title", + display_text(rec.get("title", "Unknown Title"))) + y = _draw_detail_text_line(title, y, _pen_title, + font="bitmap14_outline", scale=TEXT_SCALE) + else: + y = _draw_detail_text_lines(title_lines, y, _pen_title, + font="bitmap14_outline", scale=TEXT_SCALE) + y += px(6) + + # Artists + artist_lines = rec.get("_detail_artist_lines", None) + if artist_lines is None: + artist_str = rec.get("_display_artists", "") + if not artist_str: + artists = rec.get("artists", []) + artist_str = display_text(", ".join(artists)) if artists else "" + artist_lines = _wrapped_lines(artist_str, DETAIL_TEXT_W, scale=TEXT_SCALE) if artist_str else [] + if artist_lines: + y = _draw_detail_text_lines(artist_lines, y, _pen_artist) + y += px(4) + + return y + + +def _draw_detail_cover(app, rec, y): + """Draw already-prepared cover art at the given y position.""" + # Skip if entirely outside viewport + if y + DETAIL_COVER_SIZE <= DAY_HEADER_Y + DAY_HEADER_H: + return + if y >= HEIGHT: + return + + if app.touch.dragging: + _draw_placeholder(DETAIL_COVER_X, y, + DETAIL_COVER_SIZE, DETAIL_COVER_SIZE) + return + + data = rec.get("_detail_thumb_data", None) + + if data is not None: + draw_jpeg(data, DETAIL_COVER_X, y, + DETAIL_COVER_SIZE, DETAIL_COVER_SIZE) + else: + _draw_placeholder(DETAIL_COVER_X, y, + DETAIL_COVER_SIZE, DETAIL_COVER_SIZE) + + +def _draw_detail_info_below_cover(rec, y): + """Draw genres, metadata line, and purchased-at below the cover.""" + # Genres + genre_lines = rec.get("_detail_genre_lines", None) + if genre_lines is None: + genres = rec.get("genres", []) + if genres: + genre_str = display_text(", ".join(genres)) + genre_lines = _wrapped_lines(genre_str, DETAIL_TEXT_W, scale=TEXT_SCALE) + else: + genre_lines = [] + if genre_lines: + y = _draw_detail_text_lines(genre_lines, y, _pen_dim_text) + y += px(4) + + y += px(6) + + # Metadata: record_type | format | year + meta_lines = rec.get("_detail_meta_lines", None) + if meta_lines is None: + meta_parts = [] + record_type = rec.get("record_type", "") + if record_type: + meta_parts.append(display_text(record_type)) + record_format = rec.get("format", "") + if record_format: + meta_parts.append(display_text(record_format)) + release_date = rec.get("release_date", "") + if release_date: + meta_parts.append(str(release_date)[:4]) + meta_lines = _wrapped_lines(" | ".join(meta_parts), DETAIL_TEXT_W, scale=TEXT_SCALE) if meta_parts else [] + + if meta_lines: + y = _draw_detail_text_lines(meta_lines, y, _pen_dim_text) + y += px(4) + + # Purchased at + purchased_lines = rec.get("_detail_purchased_lines", None) + if purchased_lines is None: + purchased_at = rec.get("purchased_at", "") + if purchased_at: + purch_str = "Purchased: " + display_text(str(purchased_at)[:10]) + purchased_lines = _wrapped_lines(purch_str, DETAIL_TEXT_W, scale=TEXT_SCALE) + else: + purchased_lines = [] + if purchased_lines: + y = _draw_detail_text_lines(purchased_lines, y, _pen_dim_text) + + return y + + +def _draw_detail_text_line(text, y, pen, font="bitmap8", scale=TEXT_SCALE): + """Draw text centered horizontally at y, wrapping if needed. + Returns the y-position after drawing (including wrapped lines).""" + max_w = DETAIL_TEXT_W + display.set_pen(pen) + display.set_font(font) + + lines = _wrapped_lines(text, max_w, scale=scale) + line_height = _line_height_for_font(font, scale) + cy = y + for line in lines: + tw = display.measure_text(line, scale=scale) + display.text(line, (WIDTH - tw) // 2, cy, scale=scale) + cy += line_height + + return cy + + +def _draw_detail_text_lines(lines, y, pen, font="bitmap8", scale=TEXT_SCALE): + """Draw pre-wrapped detail text centered horizontally.""" + display.set_pen(pen) + display.set_font(font) + + line_height = _line_height_for_font(font, scale) + cy = y + for item in lines: + if isinstance(item, tuple): + line, tw = item + else: + line = item + tw = display.measure_text(line, scale=scale) + display.text(line, (WIDTH - tw) // 2, cy, scale=scale) + cy += line_height + + return cy + + +def _draw_scrobble_button(app, rec, y): + """Draw the scrobble button at (x, y). Button is horizontally centered. + Visual state depends on app detail scrobble state.""" + + bx = (WIDTH - SCROBBLE_BUTTON_W) // 2 + + if app.detail.scrobble_state == "done": + bg_pen = _pen_scrobble_done_bg + text_pen = _pen_scrobble_done_text + label = "Done" + elif app.detail.scrobble_state == "loading": + bg_pen = _pen_scrobble_bg + text_pen = _pen_scrobble_text + label = "..." + else: + bg_pen = _pen_scrobble_bg + text_pen = _pen_scrobble_text + label = "Scrobble" + + display.set_pen(bg_pen) + display.rectangle(bx, y, SCROBBLE_BUTTON_W, SCROBBLE_BUTTON_H) + + display.set_pen(text_pen) + display.set_font("bitmap8") + _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: + app.detail.content_height = _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 +# ============================================================================ +def read_touch(): + """Return the current touch as (x, y), or None when not pressed.""" + try: + touch.poll() + except Exception: + pass + + try: + if touch.state: + return int(touch.x), int(touch.y) + except Exception: + pass + + for method_name in ("read", "get_touch"): + method = getattr(touch, method_name, None) + if method is None: + continue + + try: + point = method() + except Exception: + continue + + normalised = _normalise_touch(point) + if normalised is not None: + return normalised + + return None + + +def _touch_debounce_ms(app): + """Return the active debounce threshold for the current screen.""" + if app.screen == STATE_SEARCH_INPUT: + return KEYBOARD_DEBOUNCE_MS + return DEBOUNCE_MS + + +def _normalise_touch(point): + """Convert common touch return shapes to (x, y).""" + if not point: + return None + + if isinstance(point, (tuple, list)): + if len(point) == 2: + return int(point[0]), int(point[1]) + if len(point) >= 3 and point[0] is not None: + return int(point[1]), int(point[2]) + return None + + try: + if hasattr(point, "state") and not point.state: + return None + return int(point.x), int(point.y) + except Exception: + return None + + +def touch_to_calendar_cell(x, y): + """Map a touch (x, y) to (row, col) in the calendar grid, or None + if the touch is outside the grid.""" + if y < CALENDAR_TOP: + return None + + col = (x - GRID_LEFT) // (CELL_SIZE + CELL_GAP) + row = (y - CALENDAR_TOP) // (CELL_SIZE + CELL_GAP) + + # Check within cell (not in gap) + cx = GRID_LEFT + col * (CELL_SIZE + CELL_GAP) + cy = CALENDAR_TOP + row * (CELL_SIZE + CELL_GAP) + if cx <= x < cx + CELL_SIZE and cy <= y < cy + CELL_SIZE: + if 0 <= col < CELLS_PER_ROW and 0 <= row < MAX_ROWS: + return row, col + return None + + +def cell_to_day(app, row, col): + """Convert a calendar cell (row, col) to a day number (1-based) + for the current view month. Returns None if the cell is outside + the current month.""" + first_dow = day_of_week(app.view_year, app.view_month, 1) + day_num = row * CELLS_PER_ROW + col - first_dow + 1 + days = days_in_month(app.view_year, app.view_month) + if 1 <= day_num <= days: + return day_num + return None + + +def touch_to_record_index(app, y, recs=None, offset=None): + """Map a touch y-coordinate to a record list index, accounting for + scroll offset. Use recs/offset params for search results, or defaults + for day view.""" + if recs is None: + recs = app.day.records + if offset is None: + offset = app.day.scroll_offset + elif offset is None: + offset = app.search.scroll_offset + + if not recs: + return None + + cy = RECORD_START_Y - offset + for i, rec in enumerate(recs): + row_h = rec.get("_row_height", THUMB_SIZE + px(8)) + if cy <= y < cy + row_h: + return i + cy += row_h + return None + + +def _scrobble_button_hit_test(app, x, y, rec): + """Return True if the touch (x, y) falls within the scrobble button bounds. + Accounts for current detail scroll offset. + Ignores touches in the fixed header area (handled separately).""" + if not rec.get("selected_release_id"): + return False + + # Reject touches in the fixed header zone (handled by back-button logic) + if y <= DAY_HEADER_Y + DAY_HEADER_H: + return False + + # 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 + button_y <= y <= button_y + SCROBBLE_BUTTON_H) + + +# ============================================================================ +# UPDATE +# ============================================================================ + +def update(app, msg): + """Apply a message to the model and return effect commands.""" + tag = msg[0] + + if tag == MSG_BOOT: + app.screen = STATE_STATUS + app.status_message = "Music Library\nStarting up..." + return [(FX_RENDER,), (FX_CONNECT_WIFI,)] + + if tag == MSG_WIFI_CONNECTED: + app.api_token = msg[2] + app.next_wifi_retry_ms = 0 + app.status_message = "Connected.\nIP: {}\nSyncing time...".format(msg[1]) + return [(FX_RENDER,), (FX_SYNC_TIME,), (FX_COLLECT_GARBAGE,)] + + if tag == MSG_WIFI_FAILED: + app.api_token = msg[2] + app.screen = STATE_STATUS + app.status_message = msg[1] + app.next_wifi_retry_ms = time.ticks_add(time.ticks_ms(), WIFI_RETRY_MS) + return [(FX_RENDER,), (FX_COLLECT_GARBAGE,)] + + if tag == MSG_WIFI_RETRY: + app.next_wifi_retry_ms = 0 + app.status_message = "Connecting to WiFi..." + return [(FX_RENDER,), (FX_CONNECT_WIFI,)] + + if tag == MSG_TIME_SYNCED: + app.today_year, app.today_month, app.today_day = msg[1], msg[2], msg[3] + app.view_year = app.today_year + app.view_month = app.today_month + app.day.selected_day = app.today_day + app.day.error = False + app.day.scroll_offset = 0 + app.screen = STATE_HOME + app.touch.last_activity = time.ticks_ms() + return [(FX_RENDER,)] + + if tag == MSG_IDLE_TIMEOUT: + if not app.touch.display_awake: + return [] + app.touch.display_awake = False + return [(FX_SLEEP_DISPLAY,)] + + if tag == MSG_WAKE_COMPLETE: + app.today_year, app.today_month, app.today_day = msg[1], msg[2], msg[3] + return [(FX_RENDER,)] + + if tag == MSG_DAY_LOADED: + app.day.records = msg[1] + app.day.error = msg[2] + app.day.content_height = msg[3] + app.day.scroll_offset = 0 + app.screen = STATE_DAY + return [(FX_RENDER,)] + + if tag == MSG_SEARCH_LOADED: + app.search.results = msg[1] + app.search.error = msg[2] + app.search.content_height = msg[3] + app.search.scroll_offset = 0 + app.screen = STATE_SEARCH_RESULTS + return [(FX_RENDER,)] + + if tag == MSG_DETAIL_READY: + if app.detail.source_screen == STATE_SEARCH_RESULTS: + app.search.results[app.detail.selected_index] = msg[2] + else: + app.day.records[app.detail.selected_index] = msg[2] + app.detail.content_height = msg[1] + app.screen = STATE_RECORD + return [(FX_RENDER,)] + + if tag == MSG_SCROBBLED: + app.detail.scrobble_state = "done" if msg[1] else "idle" + return [(FX_RENDER_SCROBBLE,)] + + if tag == MSG_TOUCH_DOWN: + return touch_down_update(app, msg[1], msg[2], msg[3]) + + if tag == MSG_TOUCH_MOVE: + return touch_move_update(app, msg[1], msg[2]) + + if tag == MSG_TOUCH_UP: + return touch_up_update(app) + + return [] + + +def touch_down_update(app, x, y, now): + """Start a touch gesture or consume the wake touch.""" + if not app.touch.display_awake: + app.touch.display_awake = True + app.touch.consume_until_release = True + app.touch.last_activity = now + app.touch.last_touch = now + return [(FX_WAKE_DISPLAY,)] + + if time.ticks_diff(now, app.touch.last_touch) < _touch_debounce_ms(app): + app.touch.consume_until_release = True + return [] + + app.touch.last_touch = now + app.touch.last_activity = now + app.touch.consume_until_release = False + app.touch.gesture_screen = None + + if scrollable_screen(app.screen): + app.touch.gesture_screen = app.screen + app.touch.start_x = x + app.touch.start_y = y + app.touch.last_y = y + app.touch.pending_delta = 0 + app.touch.dragged = False + app.touch.redrew_drag = False + app.touch.dragging = False + app.touch.last_drag_redraw = now + return [] + + return tap_update(app, x, y) + + +def touch_move_update(app, y, now): + """Update a scroll gesture while retaining redraw throttling.""" + if app.touch.consume_until_release or app.touch.gesture_screen is None: + return [] + + if abs(y - app.touch.start_y) >= DRAG_REDRAW_PX: + app.touch.dragged = True + + app.touch.pending_delta += app.touch.last_y - y + app.touch.last_y = y + redraw_due = time.ticks_diff(now, app.touch.last_drag_redraw) >= DRAG_REDRAW_MS + + if abs(app.touch.pending_delta) < DRAG_REDRAW_PX or not redraw_due: + return [] + + changed = apply_scroll_delta(app, app.touch.gesture_screen, app.touch.pending_delta) + app.touch.pending_delta = 0 + app.touch.last_drag_redraw = now + + if not changed: + return [] + + app.touch.dragging = True + app.touch.redrew_drag = True + return [(FX_RENDER_PARTIAL,)] + + +def touch_up_update(app): + """Complete a gesture, issuing a tap or final scroll redraw.""" + if app.touch.consume_until_release: + app.touch.consume_until_release = False + app.touch.gesture_screen = None + return [] + + gesture_screen = app.touch.gesture_screen + if gesture_screen is None: + return [] + + x, y = app.touch.start_x, app.touch.start_y + if app.touch.dragged: + changed = apply_scroll_delta(app, gesture_screen, app.touch.pending_delta) + redrew_drag = app.touch.redrew_drag + reset_gesture(app) + if changed or redrew_drag: + return [(FX_RENDER_PARTIAL,)] + return [] + + reset_gesture(app) + if app.screen != gesture_screen: + return [] + return tap_update(app, x, y) + + +def reset_gesture(app): + """Clear state owned by an in-progress scroll gesture.""" + app.touch.gesture_screen = None + app.touch.pending_delta = 0 + app.touch.dragged = False + app.touch.redrew_drag = False + app.touch.dragging = False + + +def scrollable_screen(screen): + """Return whether a view distinguishes vertical drag from row taps.""" + return screen in (STATE_DAY, STATE_SEARCH_RESULTS, STATE_RECORD) + + +def scroll_offset(app, screen): + if screen == STATE_DAY: + return app.day.scroll_offset + if screen == STATE_SEARCH_RESULTS: + return app.search.scroll_offset + return app.detail.scroll_offset + + +def max_scroll_offset(app, screen): + if screen == STATE_DAY: + return _max_scroll_offset(app) + if screen == STATE_SEARCH_RESULTS: + return _search_max_scroll_offset(app) + return _max_detail_scroll_offset(app) + + +def set_scroll_offset(app, screen, offset): + if screen == STATE_DAY: + app.day.scroll_offset = offset + elif screen == STATE_SEARCH_RESULTS: + app.search.scroll_offset = offset + else: + app.detail.scroll_offset = offset + + +def apply_scroll_delta(app, screen, delta): + """Clamp and apply one prepared scroll delta.""" + old_offset = scroll_offset(app, screen) + new_offset = min(max(0, old_offset + delta), max_scroll_offset(app, screen)) + if new_offset == old_offset: + return False + set_scroll_offset(app, screen, new_offset) + return True + + +def tap_update(app, x, y): + """Handle one accepted tap by changing model state and returning effects.""" + if app.screen == STATE_HOME: + return home_tap_update(app, x, y) + if app.screen == STATE_SEARCH_INPUT: + return search_input_tap_update(app, x, y) + if app.screen == STATE_SEARCH_RESULTS: + return search_results_tap_update(app, x, y) + if app.screen == STATE_MONTH: + return month_tap_update(app, x, y) + if app.screen == STATE_DAY: + return day_tap_update(app, x, y) + if app.screen == STATE_RECORD: + return record_tap_update(app, x, y) + return [] + + +def home_tap_update(app, x, y): + if (HOME_BUTTON_X <= x <= HOME_BUTTON_X + HOME_BUTTON_W and + HOME_BUTTON1_Y <= y <= HOME_BUTTON1_Y + HOME_BUTTON_H): + app.search.query = "" + app.search.keyboard_mode = "alpha" + app.screen = STATE_SEARCH_INPUT + return [(FX_RENDER,)] + + if (HOME_BUTTON_X <= x <= HOME_BUTTON_X + HOME_BUTTON_W and + HOME_BUTTON2_Y <= y <= HOME_BUTTON2_Y + HOME_BUTTON_H): + app.view_year = app.today_year + app.view_month = app.today_month + app.day.selected_day = app.today_day + app.screen = STATE_MONTH + return [(FX_RENDER,)] + + return [] + + +def search_input_tap_update(app, x, y): + if BACK_X <= x <= BACK_X + BACK_W and BACK_Y <= y <= BACK_Y + BACK_H: + app.screen = STATE_HOME + return [(FX_RENDER,)] + + action = _keyboard_hit_test(app, x, y) + if action is None: + return [] + if action == "toggle": + app.search.keyboard_mode = ( + "numbers" if app.search.keyboard_mode == "alpha" else "alpha" + ) + return [(FX_RENDER,)] + if action == "space": + if len(app.search.query) < 50: + app.search.query += " " + return [(FX_RENDER_QUERY,)] + return [] + if action == "backspace": + app.search.query = app.search.query[:-1] + return [(FX_RENDER_QUERY,)] + if action == "ok": + if not app.search.query.strip(): + return [] + app.screen = STATE_STATUS + app.status_message = "Searching for:\n" + app.search.query + return [(FX_RENDER,), (FX_LOAD_SEARCH, app.search.query)] + if len(app.search.query) < 50 and len(action) == 1: + app.search.query += action + return [(FX_RENDER_QUERY,)] + return [] + + +def search_results_tap_update(app, x, y): + if BACK_X <= x <= BACK_X + BACK_W and BACK_Y <= y <= BACK_Y + BACK_H: + app.screen = STATE_SEARCH_INPUT + return [(FX_RENDER,)] + if app.search.error: + app.screen = STATE_STATUS + app.status_message = "Retrying search..." + return [(FX_RENDER,), (FX_LOAD_SEARCH, app.search.query)] + if app.search.results: + index = touch_to_record_index( + app, y, recs=app.search.results, offset=app.search.scroll_offset + ) + if index is not None: + return open_record_update(app, STATE_SEARCH_RESULTS, index) + return [] + + +def month_tap_update(app, x, y): + hx, hy = HEADER_SIDE_MARGIN, HOME_BTN_Y + if hx <= x <= hx + HOME_BTN_W and hy <= y <= hy + HOME_BTN_H: + app.screen = STATE_HOME + return [(FX_RENDER,)] + + lx = HEADER_SIDE_MARGIN + HOME_BTN_W + px(4) + if lx <= x <= lx + ARROW_W and HEADER_BUTTON_Y <= y <= HEADER_BUTTON_Y + ARROW_H: + app.view_year, app.view_month = previous_month(app.view_year, app.view_month) + return [(FX_RENDER,)] + + rx = WIDTH - ARROW_W - HEADER_SIDE_MARGIN + if rx <= x <= rx + ARROW_W and HEADER_BUTTON_Y <= y <= HEADER_BUTTON_Y + ARROW_H: + app.view_year, app.view_month = next_month(app.view_year, app.view_month) + return [(FX_RENDER,)] + + cell = touch_to_calendar_cell(x, y) + if cell is None: + return [] + day_num = cell_to_day(app, cell[0], cell[1]) + if day_num is None: + return [] + + app.day.selected_day = day_num + app.day.records = [] + app.day.error = False + app.day.scroll_offset = 0 + app.day.content_height = 0 + app.screen = STATE_STATUS + app.status_message = "Loading records for\n{} {}, {}...".format( + MONTH_NAMES[app.view_month - 1], day_num, app.view_year + ) + return [ + (FX_RENDER,), + (FX_LOAD_DAY, app.view_year, app.view_month, day_num), + ] + + +def day_tap_update(app, x, y): + if BACK_X <= x <= BACK_X + BACK_W and BACK_Y <= y <= BACK_Y + BACK_H: + app.screen = STATE_MONTH + return [(FX_RENDER,)] + if app.day.error: + app.screen = STATE_STATUS + app.status_message = "Retrying..." + return [ + (FX_RENDER,), + (FX_LOAD_DAY, app.view_year, app.view_month, app.day.selected_day), + ] + if app.day.records: + index = touch_to_record_index(app, y) + if index is not None: + return open_record_update(app, STATE_DAY, index) + return [] + + +def record_tap_update(app, x, y): + if BACK_X <= x <= BACK_X + BACK_W and BACK_Y <= y <= BACK_Y + BACK_H: + source_screen = app.detail.source_screen + app.detail.scroll_offset = 0 + app.detail.content_height = 0 + app.detail.scrobble_state = "idle" + app.detail.selected_index = None + app.detail.source_screen = None + app.screen = STATE_SEARCH_RESULTS if source_screen == STATE_SEARCH_RESULTS else STATE_DAY + return [(FX_RENDER,)] + + rec = selected_record(app) + if _scrobble_button_hit_test(app, x, y, rec): + app.detail.scrobble_state = "loading" + return [(FX_RENDER_SCROBBLE,), (FX_SCROBBLE, rec)] + return [] + + +def open_record_update(app, source_screen, index): + """Select a record and request effectful detail preparation.""" + app.detail.selected_index = index + app.detail.source_screen = source_screen + app.detail.scroll_offset = 0 + app.detail.content_height = 0 + app.detail.scrobble_state = "idle" + app.screen = STATE_STATUS + app.status_message = "Loading record..." + return [(FX_RENDER,), (FX_PREPARE_DETAIL,)] + + +# ============================================================================ +# EVENT WIRING AND EFFECT RUNNER +# ============================================================================ + +def wire_events(runtime, app): + """Convert polling and elapsed time into application messages.""" + now = time.ticks_ms() + messages = [] + + if (app.touch.display_awake and app.touch.last_activity + and time.ticks_diff(now, app.touch.last_activity) >= DISPLAY_SLEEP_MS): + messages.append((MSG_IDLE_TIMEOUT,)) + + if (app.next_wifi_retry_ms + and time.ticks_diff(now, app.next_wifi_retry_ms) >= 0): + messages.append((MSG_WIFI_RETRY,)) + + point = read_touch() + if point is None: + if runtime.touch_down: + runtime.touch_down = False + messages.append((MSG_TOUCH_UP,)) + elif not runtime.touch_down: + runtime.touch_down = True + messages.append((MSG_TOUCH_DOWN, point[0], point[1], now)) + else: + messages.append((MSG_TOUCH_MOVE, point[1], now)) + + return messages + + +def dispatch(app, first_msg): + """Process queued messages and enqueue effect results.""" + messages = [first_msg] + index = 0 + + while index < len(messages): + commands = update(app, messages[index]) + index += 1 + for command in commands: + follow_up = run_effect(app, command) + if follow_up: + messages.append(follow_up) + + +def run_effect(app, command): + """Execute hardware/network commands and return any resulting message.""" + kind = command[0] + + if kind == FX_RENDER: + render(app) + elif kind == FX_RENDER_PARTIAL: + render(app, partial=True) + elif kind == FX_RENDER_QUERY: + _redraw_search_query_field(app) + elif kind == FX_RENDER_SCROBBLE: + _redraw_scrobble_button(app, selected_record(app)) + elif kind == FX_CONNECT_WIFI: + connected, detail, api_token = connect_wifi_effect(app) + if connected: + return MSG_WIFI_CONNECTED, detail, api_token + return MSG_WIFI_FAILED, detail, api_token + elif kind == FX_SYNC_TIME: + date = sync_time_effect() + return MSG_TIME_SYNCED, date[0], date[1], date[2] + elif kind == FX_LOAD_DAY: + return load_day_effect(app, command[1], command[2], command[3]) + elif kind == FX_LOAD_SEARCH: + return load_search_effect(app, command[1]) + elif kind == FX_PREPARE_DETAIL: + return prepare_detail_effect(app) + elif kind == FX_SCROBBLE: + return scrobble_effect(app, command[1]) + elif kind == FX_SLEEP_DISPLAY: + sleep_display_effect() + elif kind == FX_WAKE_DISPLAY: + date = wake_display_effect(app) + return MSG_WAKE_COMPLETE, date[0], date[1], date[2] + elif kind == FX_COLLECT_GARBAGE: + gc.collect() + + return None + + +# ============================================================================ +# RENDER +# ============================================================================ + +def render(app, partial=False): + """Render only the current model state; effects prepare external data.""" + if app.screen == STATE_STATUS: + draw_status(app.status_message) + elif app.screen == STATE_HOME: + draw_home_screen(app) + elif app.screen == STATE_MONTH: + draw_month_view(app) + elif app.screen == STATE_DAY: + draw_day_view(app, partial=partial) + elif app.screen == STATE_RECORD: + draw_record_detail(app, partial=partial) + elif app.screen == STATE_SEARCH_INPUT: + draw_search_input(app) + elif app.screen == STATE_SEARCH_RESULTS: + draw_search_results(app, partial=partial) + + +# ============================================================================ +# MAIN LOOP +# ============================================================================ + +def init_display(): + """Initialise pens (display is already set up at module level).""" + try: + display.set_font("bitmap8") + except Exception: + pass + _init_pens() + + +def main(): + """Initialise the runtime, then continuously dispatch wired messages.""" + init_display() + try: + presto.set_backlight(DISPLAY_BRIGHTNESS) + except Exception: + pass + + app = Model() + runtime = Runtime() + dispatch(app, (MSG_BOOT,)) + + while True: + for msg in wire_events(runtime, app): + dispatch(app, msg) + time.sleep(LOOP_SLEEP_MS / 1000.0) + + +# ============================================================================ +# STARTUP +# ============================================================================ + +if __name__ == "__main__": + main() diff --git a/presto/tests/conftest.py b/presto/tests/conftest.py index 457b7301..092a6b79 100644 --- a/presto/tests/conftest.py +++ b/presto/tests/conftest.py @@ -1,11 +1,11 @@ """Presto smoke test configuration and helpers. -Uses the pimoroni-emulator's mock system so ``main.py`` can be imported -on CPython without real MicroPython hardware. The emulator's headless -display is set up as a session-scoped fixture; ``main`` is imported once -after the mocks are in place. +Uses the pimoroni-emulator's mock system so the Presto entrypoints can be +imported on CPython without real MicroPython hardware. The emulator's +headless display is set up as a session-scoped fixture. """ +import importlib import sys from pathlib import Path @@ -50,25 +50,31 @@ def _emulator(): # --------------------------------------------------------------------------- -# Module fixture — import main once +# Module fixture - render both complete entrypoints # --------------------------------------------------------------------------- -@pytest.fixture(scope="session") -def main_module(_emulator): - """Import ``main.py`` after the emulator mocks are installed. +@pytest.fixture(scope="session", params=("main", "music_library")) +def main_module(_emulator, request): + """Import each application after emulator mocks are installed. - The ``__name__ == "__main__"`` guard ensures ``main()`` does not run. + The ``__name__ == "__main__"`` guards ensure ``main()`` does not run. ``fetch_thumbnail`` is monkey-patched to raise on any network call. """ - import main + module = importlib.import_module(request.param) # Guard against accidental network access in smoke tests. def _raise_on_network(*_args, **_kwargs): - raise RuntimeError("Network call in smoke test — use mock data") + raise RuntimeError("Network call in smoke test - use mock data") - main.fetch_thumbnail = _raise_on_network + module.fetch_thumbnail = _raise_on_network - return main + return module + + +@pytest.fixture(scope="session") +def music_library_module(_emulator): + """Import the architecture-based application for transition tests.""" + return importlib.import_module("music_library") # --------------------------------------------------------------------------- diff --git a/presto/tests/test_music_library_architecture.py b/presto/tests/test_music_library_architecture.py new file mode 100644 index 00000000..54f75eee --- /dev/null +++ b/presto/tests/test_music_library_architecture.py @@ -0,0 +1,143 @@ +"""Behavioral checks for the Elm-style Presto application runtime.""" + +import pytest + +from tests.conftest import make_mock_record + + +@pytest.fixture(autouse=True) +def _init_display(music_library_module): + music_library_module.init_display() + + +def test_status_screen_renders(music_library_module): + from emulator.testing import screenshot + + app = music_library_module.Model() + app.screen = music_library_module.STATE_STATUS + app.status_message = "Loading records..." + + music_library_module.render(app) + screenshot("tests/fixtures/status-music-library.png") + + +def test_detail_render_does_not_fetch_cover(music_library_module, monkeypatch): + app = music_library_module.Model() + app.screen = music_library_module.STATE_RECORD + app.detail.selected_index = 0 + app.detail.source_screen = music_library_module.STATE_DAY + + rec = make_mock_record() + rec["_detail_thumb_failed"] = False + app.day.records = [rec] + + def _raise_on_fetch(*_args): + raise RuntimeError("render attempted network work") + + monkeypatch.setattr(music_library_module, "fetch_thumbnail", _raise_on_fetch) + + music_library_module.draw_record_detail(app) + + +def test_detail_cover_is_prepared_by_effect_message(music_library_module, monkeypatch): + app = music_library_module.Model() + rec = make_mock_record() + rec["_detail_thumb_failed"] = False + app.day.records = [rec] + + monkeypatch.setattr( + music_library_module, + "fetch_thumbnail", + lambda *_args: b"prepared-jpeg", + ) + + commands = music_library_module.open_record_update( + app, music_library_module.STATE_DAY, 0 + ) + assert commands == [ + (music_library_module.FX_RENDER,), + (music_library_module.FX_PREPARE_DETAIL,), + ] + assert app.screen == music_library_module.STATE_STATUS + + msg = music_library_module.run_effect( + app, (music_library_module.FX_PREPARE_DETAIL,) + ) + assert app.day.records[0]["_detail_thumb_data"] is None + + render_commands = music_library_module.update(app, msg) + + assert app.screen == music_library_module.STATE_RECORD + assert app.day.records[0]["_detail_thumb_data"] == b"prepared-jpeg" + assert render_commands == [(music_library_module.FX_RENDER,)] + + +def test_wake_touch_is_consumed(music_library_module): + app = music_library_module.Model() + app.screen = music_library_module.STATE_HOME + app.touch.display_awake = False + + commands = music_library_module.update( + app, + ( + music_library_module.MSG_TOUCH_DOWN, + music_library_module.HOME_BUTTON_X + 1, + music_library_module.HOME_BUTTON2_Y + 1, + 1_000, + ), + ) + + assert commands == [(music_library_module.FX_WAKE_DISPLAY,)] + assert app.touch.consume_until_release is True + assert music_library_module.update(app, (music_library_module.MSG_TOUCH_UP,)) == [] + assert app.screen == music_library_module.STATE_HOME + + +def test_search_effect_reconnects_before_request(music_library_module, monkeypatch): + app = music_library_module.Model() + calls = [] + + monkeypatch.setattr(music_library_module, "wifi_connected", lambda: False) + monkeypatch.setattr( + music_library_module, + "connect_wifi_effect", + lambda _app: (calls.append("connect") or True, "127.0.0.1", "token"), + ) + monkeypatch.setattr( + music_library_module, + "fetch_search_results", + lambda _app, _query: (calls.append("fetch") or [], False), + ) + + msg = music_library_module.load_search_effect(app, "blue") + + assert calls == ["connect", "fetch"] + assert msg == (music_library_module.MSG_SEARCH_LOADED, [], False, 0) + + +def test_drag_updates_are_throttled_through_messages(music_library_module): + app = music_library_module.Model() + app.screen = music_library_module.STATE_DAY + app.day.content_height = 1_000 + app.touch.last_touch = 0 + + assert music_library_module.update( + app, (music_library_module.MSG_TOUCH_DOWN, 100, 250, 1_000) + ) == [] + + commands = music_library_module.update( + app, + ( + music_library_module.MSG_TOUCH_MOVE, + 150, + 1_000 + music_library_module.DRAG_REDRAW_MS, + ), + ) + + assert commands == [(music_library_module.FX_RENDER_PARTIAL,)] + assert app.day.scroll_offset == 100 + assert app.touch.dragging is True + assert music_library_module.update( + app, (music_library_module.MSG_TOUCH_UP,) + ) == [(music_library_module.FX_RENDER_PARTIAL,)] + assert app.touch.dragging is False diff --git a/presto/tests/test_screens.py b/presto/tests/test_screens.py index b113b5fd..95653106 100644 --- a/presto/tests/test_screens.py +++ b/presto/tests/test_screens.py @@ -1,4 +1,4 @@ -"""Smoke tests — verify every Presto screen renders without crashing. +"""Smoke tests - verify every complete Presto entrypoint renders its screens. Each test: 1. Creates an ``AppState`` (requires ``init_display`` fixture). @@ -7,7 +7,7 @@ Each test: 4. Asserts no exception occurred. 5. Saves a screenshot to ``presto/tests/fixtures/`` for manual inspection. -No network calls are made — ``fetch_thumbnail`` is monkey-patched to raise, +No network calls are made - ``fetch_thumbnail`` is monkey-patched to raise, and mock records have ``_thumb_failed=True`` / ``_thumb_data=None``. """ @@ -18,7 +18,7 @@ from tests.conftest import make_mock_record @pytest.mark.usefixtures("init_display") class TestSmokeScreens: - """Smoke tests for all 7 Presto screens.""" + """Smoke tests for all visible screens in both complete applications.""" # ------------------------------------------------------------------ # Helpers @@ -251,7 +251,9 @@ class TestSmokeScreens: app.day.records = [rec] app.detail.selected_index = 0 app.detail.source_screen = main_module.STATE_DAY - main_module._measure_detail_content(app, rec) + detail_height = main_module._measure_detail_content(app, rec) + if detail_height is not None: + app.detail.content_height = detail_height app.detail.scroll_offset = main_module._max_detail_scroll_offset(app) calls = []