[presto] Add record detail page

This commit is contained in:
Claudio Ortolina
2026-05-06 17:15:32 +01:00
parent afbce40de9
commit 717727e7a1
+366 -3
View File
@@ -95,6 +95,15 @@ DAY_HEADER_Y = HEADER_Y
DAY_HEADER_H = HEADER_H DAY_HEADER_H = HEADER_H
DAY_HEADER_TEXT_H = HEADER_TEXT_H DAY_HEADER_TEXT_H = HEADER_TEXT_H
DAY_COUNT_TEXT_H = 8 DAY_COUNT_TEXT_H = 8
# Detail view
DETAIL_COVER_SIZE = 150
DETAIL_COVER_X = (WIDTH - DETAIL_COVER_SIZE) // 2
DETAIL_COVER_Y = DAY_HEADER_Y + DAY_HEADER_H + 12
DETAIL_INFO_GAP = 16
DETAIL_TEXT_X = 40
DETAIL_TEXT_W = WIDTH - (2 * DETAIL_TEXT_X)
RECORD_START_Y = DAY_HEADER_Y + DAY_HEADER_H + 8 RECORD_START_Y = DAY_HEADER_Y + DAY_HEADER_H + 8
THUMB_SIZE = 40 THUMB_SIZE = 40
THUMB_MARGIN = 8 THUMB_MARGIN = 8
@@ -158,10 +167,12 @@ view_month = 5
STATE_STARTUP = 0 STATE_STARTUP = 0
STATE_MONTH = 1 STATE_MONTH = 1
STATE_DAY = 2 STATE_DAY = 2
STATE_RECORD = 3
state = STATE_STARTUP state = STATE_STARTUP
# Day view state # Day view state
selected_day = 0 # 1-based day of month selected_day = 0 # 1-based day of month
selected_record_idx = None # Index into records[] for detail view
records = [] # List of record dicts from API records = [] # List of record dicts from API
records_error = False # True if API call failed records_error = False # True if API call failed
# Scroll state # Scroll state
@@ -169,6 +180,10 @@ scroll_offset = 0 # Pixel scroll position for day view
_dragging = False # True while fast scroll redraws are active _dragging = False # True while fast scroll redraws are active
_content_height = 0 # Cached total record list height _content_height = 0 # Cached total record list height
# Detail view scroll
detail_scroll_offset = 0 # Pixel scroll position for detail view
_detail_content_height = 0 # Cached total detail content height
# Touch debounce # Touch debounce
_last_touch = 0 _last_touch = 0
_last_drag_redraw = 0 _last_drag_redraw = 0
@@ -492,6 +507,8 @@ def redraw_current_view():
draw_month_view() draw_month_view()
elif state == STATE_DAY: elif state == STATE_DAY:
draw_day_view() draw_day_view()
elif state == STATE_RECORD:
draw_record_detail()
# ============================================================================ # ============================================================================
@@ -1127,6 +1144,280 @@ def _draw_record_row(rec, y):
) )
# ============================================================================
# DRAWING: RECORD DETAIL
# ============================================================================
def _max_detail_scroll_offset():
"""Return the maximum pixel scroll offset for the detail view."""
if _detail_content_height == 0:
return 0
content_bottom = DETAIL_COVER_Y + _detail_content_height
return max(0, content_bottom - HEIGHT)
def _measure_detail_text_height(text, font="bitmap8", scale=1, 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 = 8 * scale + 4
return len(lines) * line_height
def _measure_detail_content(rec):
"""Compute and cache the total height of the scrollable detail content."""
global _detail_content_height
h = 0
# Title
title = rec.get("_display_title",
display_text(rec.get("title", "Unknown Title")))
h += _measure_detail_text_height(title, font="bitmap14_outline", scale=1)
h += 6
# Artists
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:
h += _measure_detail_text_height(artist_str)
h += 4
# Genres
genres = rec.get("genres", [])
if genres:
genre_str = display_text(", ".join(genres))
h += _measure_detail_text_height(genre_str)
h += 4
h += 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)
h += _measure_detail_text_height(meta_str)
h += 4
# Purchased at
purchased_at = rec.get("purchased_at", "")
if purchased_at:
purch_str = "Purchased: " + display_text(str(purchased_at))
h += _measure_detail_text_height(purch_str)
# Total content height: cover + gap + info
_detail_content_height = DETAIL_COVER_SIZE + DETAIL_INFO_GAP + 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():
"""Render the individual record detail view with large cover art
and extended metadata. Supports vertical scrolling."""
global detail_scroll_offset
display.set_pen(_pen_bg)
display.clear()
rec = records[selected_record_idx]
# Measure content height so scroll bounds are known
_measure_detail_content(rec)
# Clamp scroll offset
max_off = _max_detail_scroll_offset()
if detail_scroll_offset > max_off:
detail_scroll_offset = max_off
if detail_scroll_offset < 0:
detail_scroll_offset = 0
offset = detail_scroll_offset
_draw_detail_header()
# Clip scrolling content below the header
_set_clip_below_header()
_draw_detail_cover(rec, offset_y=offset)
_draw_detail_info(rec, offset_y=offset)
# Scroll indicators (inside clip, below header)
display.set_font("bitmap14_outline")
if offset > 0:
display.set_pen(_pen_arrow)
display.text("^", WIDTH // 2 - 8, DAY_HEADER_H + 4, scale=1)
if offset < max_off:
display.set_pen(_pen_arrow)
display.text("v", WIDTH // 2 - 8, HEIGHT - 24, scale=1)
_remove_clip()
presto.update()
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=1)
display.text(
label,
(WIDTH - tw) // 2,
DAY_HEADER_Y + (DAY_HEADER_H - DAY_HEADER_TEXT_H) // 2,
scale=1
)
def _draw_detail_cover(rec, offset_y=0):
"""Fetch and draw the large cover art (prefers thumb_url)."""
cy = DETAIL_COVER_Y - offset_y
# Skip if entirely outside viewport
if cy + DETAIL_COVER_SIZE <= DAY_HEADER_Y + DAY_HEADER_H:
return
if cy >= HEIGHT:
return
thumb_url = (
rec.get("thumb_url", "")
or rec.get("mini_cover_url", "")
or rec.get("micro_cover_url", "")
)
if not thumb_url:
_draw_placeholder(DETAIL_COVER_X, cy,
DETAIL_COVER_SIZE, DETAIL_COVER_SIZE)
return
# Use cached data or fetch
data = rec.get("_detail_thumb_data", None)
if data is None and not rec.get("_detail_thumb_failed", False):
data = fetch_thumbnail(thumb_url)
if data is None:
rec["_detail_thumb_failed"] = True
else:
rec["_detail_thumb_data"] = data
if data is not None:
draw_jpeg(data, DETAIL_COVER_X, cy,
DETAIL_COVER_SIZE, DETAIL_COVER_SIZE)
else:
_draw_placeholder(DETAIL_COVER_X, cy,
DETAIL_COVER_SIZE, DETAIL_COVER_SIZE)
def _draw_detail_info(rec, offset_y=0):
"""Draw the extended record metadata below the cover image."""
info_y = DETAIL_COVER_Y + DETAIL_COVER_SIZE + DETAIL_INFO_GAP - offset_y
# Title (larger, bold-style font)
title = rec.get("_display_title",
display_text(rec.get("title", "Unknown Title")))
info_y = _draw_detail_text_line(title, info_y, _pen_title,
font="bitmap14_outline", scale=1)
info_y += 6
# Artists
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:
info_y = _draw_detail_text_line(artist_str, info_y, _pen_artist)
info_y += 4
# Genres
genres = rec.get("genres", [])
if genres:
genre_str = display_text(", ".join(genres))
info_y = _draw_detail_text_line(genre_str, info_y, _pen_dim_text)
info_y += 4
info_y += 6
# Metadata: 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)
info_y = _draw_detail_text_line(meta_str, info_y, _pen_dim_text)
info_y += 4
# Purchased at (full date)
purchased_at = rec.get("purchased_at", "")
if purchased_at:
purch_str = "Purchased: " + display_text(str(purchased_at))
info_y = _draw_detail_text_line(purch_str, info_y, _pen_dim_text)
def _draw_detail_text_line(text, y, pen, font="bitmap8", scale=1):
"""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 = 8 * scale + 4
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
# ============================================================================ # ============================================================================
# TOUCH HANDLING # TOUCH HANDLING
# ============================================================================ # ============================================================================
@@ -1220,6 +1511,21 @@ def cell_to_day(row, col):
return None return None
def touch_to_record_index(y):
"""Map a touch y-coordinate to a record list index, accounting for
scroll offset. Returns None if the touch doesn't hit a record row."""
if not records:
return None
cy = RECORD_START_Y - scroll_offset
for i, rec in enumerate(records):
row_h = rec.get("_row_height", THUMB_SIZE + 8)
if cy <= y < cy + row_h:
return i
cy += row_h
return None
def handle_month_touch(x, y): def handle_month_touch(x, y):
"""Process a touch event in month view state.""" """Process a touch event in month view state."""
global view_year, view_month, selected_day, state, records, records_error, scroll_offset global view_year, view_month, selected_day, state, records, records_error, scroll_offset
@@ -1286,7 +1592,7 @@ def handle_day_touch(x, y):
draw_day_view() draw_day_view()
return return
# Non-error row taps are intentionally ignored; there is no detail view. # Row taps are handled by the main loop before this function is called.
# ============================================================================ # ============================================================================
@@ -1306,7 +1612,8 @@ def main():
"""Application entry point. Runs the boot sequence then enters the """Application entry point. Runs the boot sequence then enters the
main event loop.""" main event loop."""
global state, view_year, view_month, _last_touch global state, view_year, view_month, _last_touch
global selected_day, records, records_error, scroll_offset global selected_day, selected_record_idx, records, records_error
global scroll_offset, detail_scroll_offset
global _dragging global _dragging
global _last_activity, _last_drag_redraw global _last_activity, _last_drag_redraw
@@ -1428,9 +1735,65 @@ def main():
draw_day_view() draw_day_view()
if not dragged: if not dragged:
# It was a tap — handle normally # Check for record row tap -> detail view
if not records_error and records:
rec_idx = touch_to_record_index(y)
if rec_idx is not None:
selected_record_idx = rec_idx
detail_scroll_offset = 0
state = STATE_RECORD
draw_record_detail()
continue
# Otherwise handle as normal tap
handle_day_touch(x, y) handle_day_touch(x, y)
elif state == STATE_RECORD:
# Check back button immediately (no drag needed)
if BACK_X <= x <= BACK_X + BACK_W and BACK_Y <= y <= BACK_Y + BACK_H:
state = STATE_DAY
detail_scroll_offset = 0
draw_day_view()
wait_for_touch_release()
continue
# Track vertical drag for scrolling detail view
drag_start_y = y
last_drag_y = y
pending_delta = 0
dragged = False
_last_drag_redraw = time.ticks_ms()
while True:
touch_point = read_touch()
if touch_point is None:
break
current_y = touch_point[1]
if abs(current_y - drag_start_y) >= DRAG_REDRAW_PX:
dragged = True
pending_delta += last_drag_y - current_y
now = time.ticks_ms()
redraw_due = time.ticks_diff(now, _last_drag_redraw) >= DRAG_REDRAW_MS
if abs(pending_delta) >= DRAG_REDRAW_PX and redraw_due:
max_offset = _max_detail_scroll_offset()
new_offset = min(max(0, detail_scroll_offset + pending_delta), max_offset)
if new_offset != detail_scroll_offset:
detail_scroll_offset = new_offset
draw_record_detail()
_last_drag_redraw = now
pending_delta = 0
last_drag_y = current_y
time.sleep(0.01)
if dragged and pending_delta:
max_offset = _max_detail_scroll_offset()
new_offset = min(max(0, detail_scroll_offset + pending_delta), max_offset)
if new_offset != detail_scroll_offset:
detail_scroll_offset = new_offset
draw_record_detail()
# ============================================================================ # ============================================================================
# STARTUP # STARTUP