[presto] Improve touch responsiveness and visual layout

This commit is contained in:
Claudio Ortolina
2026-05-05 19:34:11 +01:00
parent 55a73b6913
commit 3649ee34c2
+271 -90
View File
@@ -27,7 +27,6 @@ Setup:
# ============================================================================ # ============================================================================
import gc import gc
import json
import time import time
import network import network
import ntptime import ntptime
@@ -66,9 +65,13 @@ MAX_ROWS = 6
# Header # Header
HEADER_Y = 0 HEADER_Y = 0
HEADER_H = 46 HEADER_H = 46
MONTH_Y = HEADER_Y + 6 HEADER_TEXT_H = 14
ARROW_W = 48 HEADER_SIDE_MARGIN = 8
ARROW_H = 34 HEADER_BUTTON_W = 44
HEADER_BUTTON_H = 32
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 # Day-of-week labels
DOW_Y = HEADER_Y + HEADER_H + 2 DOW_Y = HEADER_Y + HEADER_H + 2
@@ -84,12 +87,16 @@ GRID_LEFT = (WIDTH - (CELLS_PER_ROW * CELL_SIZE + (CELLS_PER_ROW - 1) * CELL_GAP
CALENDAR_TOP = DOW_Y + DOW_H + 6 CALENDAR_TOP = DOW_Y + DOW_H + 6
# Day view (proportional to cell size) # Day view (proportional to cell size)
BACK_X = 8 BACK_X = HEADER_SIDE_MARGIN
BACK_Y = 8 BACK_Y = HEADER_BUTTON_Y
BACK_W = 44 BACK_W = HEADER_BUTTON_W
BACK_H = 32 BACK_H = HEADER_BUTTON_H
RECORD_START_Y = BACK_Y + BACK_H + 12 DAY_HEADER_Y = HEADER_Y
THUMB_SIZE = 75 DAY_HEADER_H = HEADER_H
DAY_HEADER_TEXT_H = HEADER_TEXT_H
DAY_COUNT_TEXT_H = 8
RECORD_START_Y = DAY_HEADER_Y + DAY_HEADER_H + 8
THUMB_SIZE = 40
THUMB_MARGIN = 8 THUMB_MARGIN = 8
TEXT_X = THUMB_MARGIN + THUMB_SIZE + 12 TEXT_X = THUMB_MARGIN + THUMB_SIZE + 12
TEXT_W = WIDTH - TEXT_X - 12 TEXT_W = WIDTH - TEXT_X - 12
@@ -149,8 +156,9 @@ selected_day = 0 # 1-based day of month
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
scroll_offset = 0 # Scroll position for day view scroll_offset = 0 # Pixel scroll position for day view
_visible_count = 1 # How many records fit on screen (set during draw) _visible_count = 1 # How many records fit on screen (set during draw)
_dragging = False # True while fast scroll redraws are active
# Touch debounce # Touch debounce
_last_touch = 0 _last_touch = 0
@@ -246,6 +254,32 @@ def draw_wrapped(text, x, y, max_width, pen, scale=1):
return cy return cy
def _wrapped_line_count(text, max_width, scale=1):
"""Return how many wrapped lines the text will occupy."""
words = text.split(" ")
lines = 0
current_line = ""
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 += 1
if display.measure_text(word, scale=scale) > max_width:
lines += 1
current_line = ""
else:
current_line = word
if current_line:
lines += 1
return max(1, lines)
# ============================================================================ # ============================================================================
# HELPER: DATE / CALENDAR MATH # HELPER: DATE / CALENDAR MATH
# ============================================================================ # ============================================================================
@@ -425,6 +459,15 @@ def fetch_thumbnail(url):
# JPEG RENDERING # JPEG RENDERING
# ============================================================================ # ============================================================================
def _jpeg_scale_options():
"""Return JPEG scale flags paired with their output-size divisors."""
full = getattr(_jpegdec_lib, "JPEG_SCALE_FULL", 0)
half = getattr(_jpegdec_lib, "JPEG_SCALE_HALF", 1)
quarter = getattr(_jpegdec_lib, "JPEG_SCALE_QUARTER", 2)
eighth = getattr(_jpegdec_lib, "JPEG_SCALE_EIGHTH", 3)
return ((full, 1), (half, 2), (quarter, 4), (eighth, 8))
def draw_jpeg(data, x, y, max_w, max_h): def draw_jpeg(data, x, y, max_w, max_h):
"""Decode and draw a JPEG image, scaled to fit within max_w x max_h """Decode and draw a JPEG image, scaled to fit within max_w x max_h
and centered in the bounding box. and centered in the bounding box.
@@ -449,16 +492,18 @@ def draw_jpeg(data, x, y, max_w, max_h):
try: try:
img_h = jpeg.get_height() img_h = jpeg.get_height()
img_w = jpeg.get_width() img_w = jpeg.get_width()
# Try scales from largest to smallest; use first that fits # Try scales from largest to smallest; use the first that fits.
# (0=full, 2=half, 4=quarter, 8=eighth) scale = None
for s in (2, 4, 8): divisor = 1
if img_w // s <= max_w and img_h // s <= max_h: for scale_flag, scale_divisor in _jpeg_scale_options():
scale = s if img_w // scale_divisor <= max_w and img_h // scale_divisor <= max_h:
scale = scale_flag
divisor = scale_divisor
break break
else: if scale is None:
scale = 0 scale, divisor = _jpeg_scale_options()[-1]
sw = img_w // max(1, scale) sw = img_w // divisor
sh = img_h // max(1, scale) sh = img_h // divisor
ox = x + (max_w - sw) // 2 ox = x + (max_w - sw) // 2
oy = y + (max_h - sh) // 2 oy = y + (max_h - sh) // 2
jpeg.decode(ox, oy, scale) jpeg.decode(ox, oy, scale)
@@ -467,10 +512,7 @@ def draw_jpeg(data, x, y, max_w, max_h):
pass pass
# Fallback: decode at quarter scale (works for most cover art) # Fallback: decode at quarter scale (works for most cover art)
jpeg.decode(x, y, 4) jpeg.decode(x, y, getattr(_jpegdec_lib, "JPEG_SCALE_QUARTER", 2))
return
except Exception:
pass
return return
except Exception: except Exception:
pass pass
@@ -559,25 +601,34 @@ def _draw_month_header():
title = "{} {}".format(MONTH_NAMES[view_month - 1], view_year) title = "{} {}".format(MONTH_NAMES[view_month - 1], view_year)
tw = display.measure_text(title, scale=1) tw = display.measure_text(title, scale=1)
tx = (WIDTH - tw) // 2 tx = (WIDTH - tw) // 2
ty = MONTH_Y ty = HEADER_Y + (HEADER_H - HEADER_TEXT_H) // 2
display.text(title, tx, ty, scale=1) display.text(title, tx, ty, scale=1)
# Left arrow # Left arrow
lx = 12 lx = HEADER_SIDE_MARGIN
ly = HEADER_Y + (HEADER_H - ARROW_H) // 2 ly = HEADER_BUTTON_Y
display.set_pen(_pen_cell_bg) # Match header bg display.set_pen(_pen_placeholder)
display.rectangle(lx, ly, ARROW_W, ARROW_H) display.rectangle(lx, ly, ARROW_W, ARROW_H)
display.set_pen(_pen_arrow) display.set_pen(_pen_back)
display.set_font("bitmap14_outline") display.set_font("bitmap8")
display.text("<", lx + 14, ly + 8, scale=1) _draw_centered_text("<", lx, ly, ARROW_W, ARROW_H, 8)
# Right arrow # Right arrow
rx = WIDTH - ARROW_W - 12 rx = WIDTH - ARROW_W - HEADER_SIDE_MARGIN
ry = HEADER_Y + (HEADER_H - ARROW_H) // 2 ry = HEADER_BUTTON_Y
display.set_pen(_pen_cell_bg) display.set_pen(_pen_placeholder)
display.rectangle(rx, ry, ARROW_W, ARROW_H) display.rectangle(rx, ry, ARROW_W, ARROW_H)
display.set_pen(_pen_arrow) display.set_pen(_pen_back)
display.text(">", rx + 12, ry + 8, scale=1) display.set_font("bitmap8")
_draw_centered_text(">", rx, ry, ARROW_W, ARROW_H, 8)
def _draw_centered_text(text, x, y, w, h, text_h, scale=1):
"""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(): def _draw_day_labels():
@@ -682,19 +733,20 @@ def draw_day_view():
display.set_pen(_pen_bg) display.set_pen(_pen_bg)
display.clear() display.clear()
_draw_day_header()
if records_error: if records_error:
_draw_day_error() _draw_day_error()
_draw_day_header()
presto.update() presto.update()
return return
if not records: if not records:
_draw_day_empty() _draw_day_empty()
_draw_day_header()
presto.update() presto.update()
return return
_draw_record_list() _draw_record_list()
_draw_day_header()
presto.update() presto.update()
@@ -702,7 +754,7 @@ def _draw_day_header():
"""Draw the header with formatted date and back button.""" """Draw the header with formatted date and back button."""
# Background bar # Background bar
display.set_pen(_pen_cell_bg) display.set_pen(_pen_cell_bg)
display.rectangle(0, BACK_Y - 4, WIDTH, BACK_H + 8) display.rectangle(0, DAY_HEADER_Y, WIDTH, DAY_HEADER_H)
# Back button # Back button
bx, by = BACK_X, BACK_Y bx, by = BACK_X, BACK_Y
@@ -710,7 +762,7 @@ def _draw_day_header():
display.rectangle(bx, by, BACK_W, BACK_H) display.rectangle(bx, by, BACK_W, BACK_H)
display.set_pen(_pen_back) display.set_pen(_pen_back)
display.set_font("bitmap8") display.set_font("bitmap8")
display.text("< Back", bx + 10, by + 14, scale=1) _draw_centered_text("<", bx, by, BACK_W, BACK_H, DAY_COUNT_TEXT_H)
# Formatted date # Formatted date
date_str = "{} {}, {}".format( date_str = "{} {}, {}".format(
@@ -719,15 +771,24 @@ def _draw_day_header():
display.set_pen(_pen_header_text) display.set_pen(_pen_header_text)
display.set_font("bitmap14_outline") display.set_font("bitmap14_outline")
tw = display.measure_text(date_str, scale=1) tw = display.measure_text(date_str, scale=1)
display.text(date_str, (WIDTH - tw) // 2, BACK_Y + 8, scale=1) display.text(
date_str,
(WIDTH - tw) // 2,
DAY_HEADER_Y + (DAY_HEADER_H - DAY_HEADER_TEXT_H) // 2,
scale=1
)
# Record count # Record count
count_str = "{} record{}".format(len(records), "s" if len(records) != 1 else "") count_str = "{} record{}".format(len(records), "s" if len(records) != 1 else "")
display.set_pen(_pen_dim_text) display.set_pen(_pen_dim_text)
display.set_font("bitmap8") display.set_font("bitmap8")
cw = display.measure_text(count_str, scale=1) cw = display.measure_text(count_str, scale=1)
# display.text(count_str, WIDTH - cw - 16, BACK_Y + BACK_H - 4, scale=1) display.text(
display.text(count_str, WIDTH - cw - 16, BACK_Y + 14, scale=1) count_str,
WIDTH - cw - 16,
DAY_HEADER_Y + (DAY_HEADER_H - DAY_COUNT_TEXT_H) // 2,
scale=1
)
def _draw_day_error(): def _draw_day_error():
@@ -756,8 +817,10 @@ def _draw_day_empty():
def _draw_record_list(): def _draw_record_list():
"""Draw the scrollable list of record rows with cover thumbnails. """Draw the scrollable list of record rows with cover thumbnails.
Uses dynamic row heights so wrapped text doesn't break layout.""" Uses dynamic row heights so wrapped text doesn't break layout."""
start_idx = scroll_offset global scroll_offset, _visible_count
total = len(records)
max_offset = _max_scroll_offset()
scroll_offset = min(max(0, scroll_offset), max_offset)
# Up arrow if scrolled down # Up arrow if scrolled down
if scroll_offset > 0: if scroll_offset > 0:
@@ -765,29 +828,83 @@ def _draw_record_list():
display.set_font("bitmap14_outline") display.set_font("bitmap14_outline")
display.text("^", WIDTH // 2 - 8, RECORD_START_Y - 28, scale=1) display.text("^", WIDTH // 2 - 8, RECORD_START_Y - 28, scale=1)
# Draw rows with dynamic heights cy = RECORD_START_Y - scroll_offset
cy = RECORD_START_Y
visible_count = 0 visible_count = 0
for i in range(start_idx, total):
rec = records[i] for rec in records:
row_bottom = _draw_record_row(rec, cy) row_h = _record_row_height(rec)
# Stop if the row would overflow the display row_bottom = cy + row_h
if row_bottom > HEIGHT - 28:
break if row_bottom <= RECORD_START_Y:
cy = row_bottom cy = row_bottom
continue
if cy >= HEIGHT - 28:
break
_draw_record_row(rec, cy)
visible_count += 1 visible_count += 1
cy = row_bottom
# Down arrow if more records below # Down arrow if more records below
if start_idx + visible_count < total: if scroll_offset < max_offset:
display.set_pen(_pen_arrow) display.set_pen(_pen_arrow)
display.set_font("bitmap14_outline") display.set_font("bitmap14_outline")
display.text("v", WIDTH // 2 - 8, cy + 4, scale=1) display.text("v", WIDTH // 2 - 8, HEIGHT - 24, scale=1)
# Store how many fit for scroll logic # Store how many fit for scroll logic
global _visible_count
_visible_count = max(1, visible_count) _visible_count = max(1, visible_count)
def _max_scroll_offset():
"""Return the maximum pixel scroll offset for the current records."""
viewport_h = HEIGHT - RECORD_START_Y - 28
content_h = 0
display.set_font("bitmap8")
for rec in records:
content_h += _record_row_height(rec)
return max(0, content_h - viewport_h)
def _record_row_height(rec):
"""Calculate a record row height without drawing it."""
display.set_font("bitmap8")
min_h = THUMB_SIZE + 8
title = rec.get("title", "Unknown Title")
ty = _wrapped_line_count(title, TEXT_W, scale=1) * 12
artists = rec.get("artists", [])
if artists:
ty += 2 + _wrapped_line_count(", ".join(artists), TEXT_W, scale=1) * 12
content_h = max(THUMB_SIZE, ty) + 10
return max(min_h, content_h)
def _record_thumbnail_data(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("mini_cover_url", "") or rec.get("thumb_url", "")
if not thumb_url:
return None
data = fetch_thumbnail(thumb_url)
if data is None:
rec["_thumb_failed"] = True
else:
rec["_thumb_data"] = data
return data
def _draw_record_row(rec, y): def _draw_record_row(rec, y):
"""Draw a single record row. Returns the Y position after the row """Draw a single record row. Returns the Y position after the row
(including separator), so the next row can be positioned correctly (including separator), so the next row can be positioned correctly
@@ -795,10 +912,12 @@ def _draw_record_row(rec, y):
min_h = THUMB_SIZE + 8 # Minimum row height (thumbnail + padding) min_h = THUMB_SIZE + 8 # Minimum row height (thumbnail + padding)
# Fetch and draw thumbnail (top-aligned in row) # Fetch and draw thumbnail (top-aligned in row)
thumb_url = rec.get("mini_cover_url", "")
thumb_y = y + 4 thumb_y = y + 4
if thumb_url: if _dragging:
jpeg_data = fetch_thumbnail(thumb_url) _draw_placeholder(THUMB_MARGIN, thumb_y, THUMB_SIZE, THUMB_SIZE)
else:
jpeg_data = _record_thumbnail_data(rec)
if jpeg_data is not None:
draw_jpeg(jpeg_data, THUMB_MARGIN, thumb_y, THUMB_SIZE, THUMB_SIZE) draw_jpeg(jpeg_data, THUMB_MARGIN, thumb_y, THUMB_SIZE, THUMB_SIZE)
gc.collect() gc.collect()
else: else:
@@ -831,6 +950,62 @@ def _draw_record_row(rec, y):
# ============================================================================ # ============================================================================
# TOUCH HANDLING # 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 _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]:
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 wait_for_touch_release():
"""Block until the active touch is released."""
while read_touch() is not None:
time.sleep(0.02)
def touch_to_calendar_cell(x, y): def touch_to_calendar_cell(x, y):
"""Map a touch (x, y) to (row, col) in the calendar grid, or None """Map a touch (x, y) to (row, col) in the calendar grid, or None
if the touch is outside the grid.""" if the touch is outside the grid."""
@@ -866,14 +1041,14 @@ def handle_month_touch(x, y):
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
# Check left arrow # Check left arrow
lx, ly = 12, HEADER_Y + (HEADER_H - ARROW_H) // 2 lx, ly = HEADER_SIDE_MARGIN, HEADER_BUTTON_Y
if lx <= x <= lx + ARROW_W and ly <= y <= ly + ARROW_H: if lx <= x <= lx + ARROW_W and ly <= y <= ly + ARROW_H:
view_year, view_month = previous_month(view_year, view_month) view_year, view_month = previous_month(view_year, view_month)
draw_month_view() draw_month_view()
return return
# Check right arrow # Check right arrow
rx, ry = WIDTH - ARROW_W - 12, HEADER_Y + (HEADER_H - ARROW_H) // 2 rx, ry = WIDTH - ARROW_W - HEADER_SIDE_MARGIN, HEADER_BUTTON_Y
if rx <= x <= rx + ARROW_W and ry <= y <= ry + ARROW_H: if rx <= x <= rx + ARROW_W and ry <= y <= ry + ARROW_H:
view_year, view_month = next_month(view_year, view_month) view_year, view_month = next_month(view_year, view_month)
draw_month_view() draw_month_view()
@@ -942,6 +1117,7 @@ def main():
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, records, records_error, scroll_offset
global _dragging
# -- Init display -- # -- Init display --
init_display() init_display()
@@ -959,14 +1135,14 @@ def main():
sync_time() sync_time()
set_today() set_today()
# Start at current month and today's day view # Start on today's records.
view_year = today_year view_year = today_year
view_month = today_month view_month = today_month
selected_day = today_day selected_day = today_day
state = STATE_DAY state = STATE_DAY
records_error = False records_error = False
scroll_offset = 0
# Fetch today's records
draw_status("Loading records for\n{} {}, {}...".format( draw_status("Loading records for\n{} {}, {}...".format(
MONTH_NAMES[view_month - 1], today_day, view_year MONTH_NAMES[view_month - 1], today_day, view_year
)) ))
@@ -974,58 +1150,64 @@ def main():
records = recs records = recs
records_error = had_error records_error = had_error
# Enter main loop
draw_day_view() draw_day_view()
while True: while True:
touch.poll() touch_point = read_touch()
if touch_point is None:
if not touch.state:
time.sleep(0.03) time.sleep(0.03)
continue continue
x, y = int(touch.x), int(touch.y) x, y = touch_point
now = time.ticks_ms() now = time.ticks_ms()
if time.ticks_diff(now, _last_touch) < DEBOUNCE_MS: if time.ticks_diff(now, _last_touch) < DEBOUNCE_MS:
time.sleep(0.02)
continue continue
_last_touch = now _last_touch = now
if state == STATE_MONTH: if state == STATE_MONTH:
handle_month_touch(x, y) handle_month_touch(x, y)
# Wait for release to avoid re-triggering wait_for_touch_release()
while touch.state:
touch.poll()
time.sleep(0.02)
elif state == STATE_DAY: elif state == STATE_DAY:
# Check back button immediately (no drag needed) # Check back button immediately (no drag needed)
if BACK_X <= x <= BACK_X + BACK_W and BACK_Y <= y <= BACK_Y + BACK_H: if BACK_X <= x <= BACK_X + BACK_W and BACK_Y <= y <= BACK_Y + BACK_H:
state = STATE_MONTH state = STATE_MONTH
draw_month_view() draw_month_view()
while touch.state: wait_for_touch_release()
touch.poll()
time.sleep(0.02)
continue continue
# Track vertical drag for scrolling # Track vertical drag for scrolling
drag_start_y = y drag_start_y = y
last_drag_y = y
pending_delta = 0
dragged = False dragged = False
while touch.state: _dragging = False
touch.poll() while True:
time.sleep(0.02) touch_point = read_touch()
if touch.state: if touch_point is None:
dy = drag_start_y - int(touch.y) break
if abs(dy) > 5:
current_y = touch_point[1]
if abs(current_y - drag_start_y) > 5:
dragged = True dragged = True
# Every ~50px of drag = one record scroll
if abs(dy) >= 50: pending_delta += last_drag_y - current_y
total = len(records) if abs(pending_delta) >= 3:
if dy > 0: max_offset = _max_scroll_offset()
scroll_offset = min(total - _visible_count, scroll_offset + 1) new_offset = min(max(0, scroll_offset + pending_delta), max_offset)
else: if new_offset != scroll_offset:
scroll_offset = max(0, scroll_offset - 1) scroll_offset = new_offset
_dragging = True
draw_day_view()
pending_delta = 0
last_drag_y = current_y
time.sleep(0.01)
if _dragging:
_dragging = False
draw_day_view() draw_day_view()
drag_start_y = int(touch.y)
if not dragged: if not dragged:
# It was a tap — handle normally # It was a tap — handle normally
@@ -1037,4 +1219,3 @@ def main():
# ============================================================================ # ============================================================================
main() main()