Page template renderer: HERO2 and QUAD, Phase 1 (#59) #87

Merged
robert merged 1 commit from area/page-template-renderer into main 2026-09-04 10:30:42 +02:00
Owner

Closes part of #59 — scoped per the issue's own 2026-09-01/09-02 updates (D32): emery only, HERO2 and QUAD only. HERO1 and GRID6 move to Phase 1b alongside #51/#62, per the issue text, not a cut made here.

What this ships

page_render_geometry.h/.c — pure geometry (bounds in, rects out, zero <pebble.h>, zero SDK calls anywhere in this file — the issue's own acceptance criterion, and D34). Status strip layout, HERO2/QUAD slot grids, label/value split within a cell, and D29's emphasised-slot size lookup keyed on slot index (page_render_digit_height(template, slot)) — a 2-line array-plus-case extension covers GRID6's emphasis in Phase 1b, no new layout path needed.

Digits 0-9 and ., :, -, / are drawn procedurally (D33) from a seven-segment / rounded-bar geometry, continuously sized to any pixel height — zero font resources for numerals. Chose the seven-segment approach over an outline/stroke-font approach specifically because it's the one a non-graphics reader can verify by eye against the segment table in the code, and it needs no rotation/trig math anywhere (documented in the header's top comment, including why / is a staircase of small squares rather than a rotated bar).

Host-tested directly — no stub needed, same as fields.c — by watchapp/tests/test_page_render_geometry.c: 28 assertions covering slot rects for HERO2/QUAD at emery's real 200×228 dimensions (including exact tiling/no-clipping checks against content — the same property that makes the deferred round variant safe later), the D29 emphasis lookup, glyph-metric proportions at HERO2/QUAD's actual digit heights (44/56/76/96px), run-centring math, and the seven-segment table itself (segment counts per digit, a couple of exact segment rects, punctuation shapes).

page_render.h/.c — the SDK-touching draw entry point (GContext calls), split out the same way ride_link.c sits alongside state.c: not part of the host suite, needs the real SDK. page_render_draw(ctx, bounds, desc, now) draws the status strip then HERO2 or QUAD.

Stale (field_is_stale()) and unavailable (--) render visibly differently, never by colour alone (D44, and the issue's 2026-09-02 update / #56):

  • Stale: the same rects as normal, but hollow (graphics_draw_round_rect, stroke not fill) plus one strike bar across the whole value, in grey (PP_COLOR_STALE).
  • Unavailable: renders as the ordinary -- glyph pair, solid, in ink — no special-casing needed anywhere, because field_is_stale() is documented to always be false while a field is unavailable (fields.h), so -- flows through the exact same glyph pipeline as any digit and just happens to be a dash shape.

Labels use GTextOverflowModeTrailingEllipsis (a real, verified SDK feature) for overflow handling rather than hand-tuned per-font abbreviations — this is the actual answer to the issue's "labels checked for overflow in English" criterion, and it's a mechanism, not a guess, so it won't need revisiting when #51 (German) lands.

Status strip (20px, every page)

Clock and ride-state are real data: FIELD_CLOCK is now sourced from the watch's own RTC for the first time anywhere in this codebase (grepped before writing page_render.c — nothing else has ever set it), refreshed once per render call (this codebase has no periodic UI-repaint tick yet, so it's correct at every page transition/button press but won't itself advance mid-idle — a future redraw-tick issue gets that for free). Ride state and Bluetooth link both reuse state.h's existing ride_state_local()/ride_state_connected() (#11) — Bluetooth link is real, not a placeholder, since connection_service is already wired up.

GPS quality and active speed source have no data source anywhere in this codebase — no GPS code, no BLE sensor code exists (checked, not assumed) — and each renders one fixed placeholder shape (three signal-strength bars for GPS; a hollow diamond for speed source), documented in the code and here, same honest-scoping precedent as #60's unbuilt Map page. All four icons plus the clock are distinct shapes per the no-colour-alone rule; colour (where used) only reinforces.

Adds a second Layer (s_page_layer) alongside the existing confirmation TextLayer, toggled by layer_set_hidden() rather than resizing one layer between two jobs. Nothing allocates per render — heap_bytes_free() stays flat across every page transition in the emulator log (confirmed below).

Verification

Host tests: all 96 pass (28 new + 68 existing, zero regressions) — fields, page, state, and the new page_render_geometry, each compiled and run directly with a plain host gcc -std=c11 -Wall -Wextra -Werror (no cmake binary available in this environment, so I compiled each tests/CMakeLists.txt target's exact source/include list by hand — same flags, same sources).

Builds: pebble build clean for all three platforms (gabbro, emery, basalt) — zero warnings from any new or changed file. gabbro/basalt build because the shared code (palette.h, fields.c, page.c, and this PR's own files) has no emery-specific assumptions baked in, but nobody has looked at what either of them draws — that's #62's job (round) and general basalt degradation, both Phase 1b/out of scope here, per D32.

Emulator, live, emery — screenshots below. Since nothing decodes ride-metric AppMessage keys yet (confirmed in main.c's own inbox-handler comment — that's separate, unbuilt scope), every field would show -- with no way to see real digit rendering, so I temporarily seeded fields.c's store from main.c's prv_init() (a _TEMP function, clearly marked, reverted before this commit — git diff on main.c is empty in this PR) purely to drive the emulator for these screenshots.

HERO2 (Ride page), normal: hero "SPEED km/h" at 96px reads cleanly as "25.2"; "AVG SPEED km/h" cell at 44px reads "21.6"; "DIST km" shows -- (never set) — solid dashes, clearly distinct from a greyed number.

HERO2, stale: same three fields, FIELD_GROUP_RIDE aged past its 6s threshold — hero and cell both render hollow with a strike bar through the middle, in grey. DIST stays --, unchanged and unaffected (D44: unavailable and stale are independent).

QUAD (Effort page), normal: HEART (slot 0, emphasised) visibly and unmistakably larger than POWER/CADENCE (56px) — direct confirmation of D29. AVG PWR shows --.

QUAD, stale: HR/POWER/CADENCE all hollow+struck (shared FIELD_GROUP_RIDE/own-group aging both expired by the time of this shot), AVG PWR still --.

Also caught and fixed one real bug from the screenshots, not from reading the code: the GPS-quality icon's three "signal bars" were originally drawn hollow (graphics_draw_rect stroke), but at the icon's actual ~4px bar width a 3px stroke covers almost the whole shape, so hollow and solid were visually indistinguishable. Switched to filled bars — documented in page_render.c.

Render cost — measured, not assumed

carousel.c's prv_page_layer_update_proc() brackets every page_render_draw() call with time_ms() and logs the delta. From the emulator log across several page transitions:

page_render_draw(Effort, template=2): 26 ms
page_render_draw(Progress, template=2): 37 ms
page_render_draw(Ride, template=1): 24 ms
page_render_draw(Effort, template=2): 30 ms
page_render_draw(Progress, template=2): 28 ms

24-37ms per render, comfortably inside the 1Hz (1000ms) budget — about 2.5-3.7% of it. This is QEMU-emulator-measured, not on physical hardware; flagging that distinction rather than presenting it as an on-device number.

heap_bytes_free() is unchanged across every transition in the same log (103240 -> 103240) — no allocation anywhere in the render path, digit drawing included.

Not in this PR

  • HERO1, GRID6 — Phase 1b (D32), per the issue's own 2026-09-01 update.
  • gabbro/basalt degradation — builds clean, not rendered/verified. Geometry stays bounds-in/rects-out specifically so that door isn't closed.
  • German labels (#51).
  • A GPS-quality/speed-source real data source — no GPS or BLE sensor code exists yet anywhere in this codebase.
  • A periodic UI-repaint tick — the clock is correct at every render but doesn't itself tick live while the screen is idle; noted in page_render.c, not this issue's job to add.

https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt

Closes part of #59 — scoped per the issue's own 2026-09-01/09-02 updates (D32): **emery only, HERO2 and QUAD only.** HERO1 and GRID6 move to Phase 1b alongside #51/#62, per the issue text, not a cut made here. ## What this ships **`page_render_geometry.h`/`.c`** — pure geometry (bounds in, rects out, zero `<pebble.h>`, zero SDK calls anywhere in this file — the issue's own acceptance criterion, and D34). Status strip layout, HERO2/QUAD slot grids, label/value split within a cell, and D29's emphasised-slot size lookup keyed on slot index (`page_render_digit_height(template, slot)`) — a 2-line array-plus-case extension covers GRID6's emphasis in Phase 1b, no new layout path needed. Digits `0`-`9` and `.`, `:`, `-`, `/` are drawn procedurally (D33) from a seven-segment / rounded-bar geometry, continuously sized to any pixel height — zero font resources for numerals. Chose the seven-segment approach over an outline/stroke-font approach specifically because it's the one a non-graphics reader can verify by eye against the segment table in the code, and it needs no rotation/trig math anywhere (documented in the header's top comment, including why `/` is a staircase of small squares rather than a rotated bar). **Host-tested directly** — no stub needed, same as `fields.c` — by `watchapp/tests/test_page_render_geometry.c`: 28 assertions covering slot rects for HERO2/QUAD at emery's real 200×228 dimensions (including exact tiling/no-clipping checks against `content` — the same property that makes the deferred round variant safe later), the D29 emphasis lookup, glyph-metric proportions at HERO2/QUAD's actual digit heights (44/56/76/96px), run-centring math, and the seven-segment table itself (segment counts per digit, a couple of exact segment rects, punctuation shapes). **`page_render.h`/`.c`** — the SDK-touching draw entry point (`GContext` calls), split out the same way `ride_link.c` sits alongside `state.c`: not part of the host suite, needs the real SDK. `page_render_draw(ctx, bounds, desc, now)` draws the status strip then HERO2 or QUAD. Stale (`field_is_stale()`) and unavailable (`--`) render **visibly differently, never by colour alone** (D44, and the issue's 2026-09-02 update / #56): - **Stale**: the same rects as normal, but hollow (`graphics_draw_round_rect`, stroke not fill) plus one strike bar across the whole value, in grey (`PP_COLOR_STALE`). - **Unavailable**: renders as the ordinary `--` glyph pair, solid, in ink — no special-casing needed anywhere, because `field_is_stale()` is documented to always be false while a field is unavailable (fields.h), so `--` flows through the exact same glyph pipeline as any digit and just happens to be a dash shape. Labels use `GTextOverflowModeTrailingEllipsis` (a real, verified SDK feature) for overflow handling rather than hand-tuned per-font abbreviations — this is the actual answer to the issue's "labels checked for overflow in English" criterion, and it's a mechanism, not a guess, so it won't need revisiting when #51 (German) lands. ## Status strip (20px, every page) Clock and ride-state are **real data**: `FIELD_CLOCK` is now sourced from the watch's own RTC for the first time anywhere in this codebase (grepped before writing `page_render.c` — nothing else has ever set it), refreshed once per render call (this codebase has no periodic UI-repaint tick yet, so it's correct at every page transition/button press but won't itself advance mid-idle — a future redraw-tick issue gets that for free). Ride state and Bluetooth link both reuse `state.h`'s existing `ride_state_local()`/`ride_state_connected()` (#11) — Bluetooth link is real, not a placeholder, since `connection_service` is already wired up. **GPS quality and active speed source have no data source anywhere in this codebase** — no GPS code, no BLE sensor code exists (checked, not assumed) — and each renders one fixed placeholder shape (three signal-strength bars for GPS; a hollow diamond for speed source), documented in the code and here, same honest-scoping precedent as #60's unbuilt Map page. All four icons plus the clock are distinct shapes per the no-colour-alone rule; colour (where used) only reinforces. ## carousel.c Adds a second `Layer` (`s_page_layer`) alongside the existing confirmation `TextLayer`, toggled by `layer_set_hidden()` rather than resizing one layer between two jobs. Nothing allocates per render — `heap_bytes_free()` stays flat across every page transition in the emulator log (confirmed below). ## Verification **Host tests**: all 96 pass (28 new + 68 existing, zero regressions) — `fields`, `page`, `state`, and the new `page_render_geometry`, each compiled and run directly with a plain host `gcc -std=c11 -Wall -Wextra -Werror` (no `cmake` binary available in this environment, so I compiled each `tests/CMakeLists.txt` target's exact source/include list by hand — same flags, same sources). **Builds**: `pebble build` clean for all three platforms (`gabbro`, `emery`, `basalt`) — zero warnings from any new or changed file. `gabbro`/`basalt` build because the shared code (`palette.h`, `fields.c`, `page.c`, and this PR's own files) has no emery-specific assumptions baked in, but **nobody has looked at what either of them draws** — that's #62's job (round) and general basalt degradation, both Phase 1b/out of scope here, per D32. **Emulator, live, emery** — screenshots below. Since nothing decodes ride-metric AppMessage keys yet (confirmed in `main.c`'s own inbox-handler comment — that's separate, unbuilt scope), every field would show `--` with no way to see real digit rendering, so I temporarily seeded `fields.c`'s store from `main.c`'s `prv_init()` (a `_TEMP` function, clearly marked, reverted before this commit — `git diff` on `main.c` is empty in this PR) purely to drive the emulator for these screenshots. *HERO2 (Ride page), normal:* hero "SPEED km/h" at 96px reads cleanly as "25.2"; "AVG SPEED km/h" cell at 44px reads "21.6"; "DIST km" shows `--` (never set) — solid dashes, clearly distinct from a greyed number. *HERO2, stale:* same three fields, `FIELD_GROUP_RIDE` aged past its 6s threshold — hero and cell both render hollow with a strike bar through the middle, in grey. DIST stays `--`, unchanged and unaffected (D44: unavailable and stale are independent). *QUAD (Effort page), normal:* HEART (slot 0, emphasised) visibly and unmistakably larger than POWER/CADENCE (56px) — direct confirmation of D29. AVG PWR shows `--`. *QUAD, stale:* HR/POWER/CADENCE all hollow+struck (shared `FIELD_GROUP_RIDE`/own-group aging both expired by the time of this shot), AVG PWR still `--`. Also caught and fixed one real bug from the screenshots, not from reading the code: the GPS-quality icon's three "signal bars" were originally drawn hollow (`graphics_draw_rect` stroke), but at the icon's actual ~4px bar width a 3px stroke covers almost the whole shape, so hollow and solid were visually indistinguishable. Switched to filled bars — documented in `page_render.c`. ## Render cost — measured, not assumed `carousel.c`'s `prv_page_layer_update_proc()` brackets every `page_render_draw()` call with `time_ms()` and logs the delta. From the emulator log across several page transitions: ``` page_render_draw(Effort, template=2): 26 ms page_render_draw(Progress, template=2): 37 ms page_render_draw(Ride, template=1): 24 ms page_render_draw(Effort, template=2): 30 ms page_render_draw(Progress, template=2): 28 ms ``` 24-37ms per render, comfortably inside the 1Hz (1000ms) budget — about 2.5-3.7% of it. This is QEMU-emulator-measured, not on physical hardware; flagging that distinction rather than presenting it as an on-device number. `heap_bytes_free()` is unchanged across every transition in the same log (`103240 -> 103240`) — no allocation anywhere in the render path, digit drawing included. ## Not in this PR - `HERO1`, `GRID6` — Phase 1b (D32), per the issue's own 2026-09-01 update. - `gabbro`/`basalt` degradation — builds clean, not rendered/verified. Geometry stays bounds-in/rects-out specifically so that door isn't closed. - German labels (#51). - A GPS-quality/speed-source real data source — no GPS or BLE sensor code exists yet anywhere in this codebase. - A periodic UI-repaint tick — the clock is correct at every render but doesn't itself tick live while the screen is idle; noted in `page_render.c`, not this issue's job to add. https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
Page template renderer: HERO2 and QUAD, Phase 1 (#59)
Some checks failed
dev-artifact / build-pbw (push) Has been cancelled
dev-artifact / build-apk (push) Has been cancelled
fast-lane / host-c-tests (push) Has been cancelled
fast-lane / jvm-tests (push) Has been cancelled
fast-lane / pebble-build (push) Has been cancelled
fast-lane / lint-and-secrets (push) Has been cancelled
fast-lane / meta-declares-required-jobs (push) Has been cancelled
fast-lane / host-c-tests (pull_request) Has been cancelled
fast-lane / jvm-tests (pull_request) Has been cancelled
fast-lane / pebble-build (pull_request) Has been cancelled
fast-lane / lint-and-secrets (pull_request) Has been cancelled
fast-lane / meta-declares-required-jobs (pull_request) Has been cancelled
dev-artifact / publish (push) Has been cancelled
428d87d2fa
Scoped per the issue's own 2026-09-01/09-02 updates (D32): emery only,
HERO2 and QUAD only. HERO1 and GRID6 move to Phase 1b alongside #51/#62.

- page_render_geometry.h/.c: pure geometry (bounds in, rects out, zero
  <pebble.h> or SDK calls anywhere) — status strip, HERO2/QUAD slot grids,
  label/value split, and D29's emphasised-slot size lookup keyed on slot
  index (a 2-line extension covers GRID6 in Phase 1b, no new layout path).
  Digits 0-9 and . : - / are drawn from a seven-segment / rounded-bar
  geometry, continuously sized to any pixel height (D33) — no font
  resources for numerals. Host-tested directly (no stub needed, same as
  fields.c) by tests/test_page_render_geometry.c: 28 assertions covering
  slot rects at emery's real 200x228, tiling/no-clipping checks, the D29
  emphasis lookup, glyph metrics at HERO2/QUAD's actual digit heights, and
  the seven-segment table itself.

- page_render.h/.c: the SDK-touching draw entry point (GContext calls),
  split out the same way ride_link.c sits alongside state.c. Stale
  (field_is_stale()) and unavailable ("--") render visibly differently and
  never by colour alone (D44, #56): stale draws the same rects hollow
  (graphics_draw_round_rect) plus one strike bar, in grey; unavailable
  renders as the ordinary "--" glyph pair, solid, in ink — no special
  casing needed, since field_is_stale() is already false while a field is
  unavailable. Labels use GTextOverflowModeTrailingEllipsis for graceful
  overflow rather than hand-tuned abbreviations.

- Status strip (20px, every page): clock and ride-state glyph are real
  data (FIELD_CLOCK, now sourced from the watch's own RTC for the first
  time; ride_state_local()/ride_state_connected() from #11). GPS quality
  and active speed source have no data source anywhere in this codebase
  (no GPS code, no BLE sensor code) and render one fixed placeholder shape
  each, documented in code and here — same honest-scoping precedent as
  #60's unbuilt Map page.

- carousel.c: adds a second Layer (s_page_layer) alongside the existing
  confirmation TextLayer, toggled by visibility rather than resizing one
  layer between two jobs. Nothing allocates per render — heap_bytes_free()
  stays flat across every page transition in the emulator log.

Verified live in the emery emulator (screenshots in the PR description):
both templates, normal/stale/unavailable all distinguishable, D29 emphasis
visibly larger, real ticking clock, status icons all distinct shapes.
gabbro and basalt still build clean (shared code, no PBL_IF_ROUND_ELSE
needed yet — nothing here is emery-specific). Render cost measured in the
emulator: 24-37ms per page_render_draw() call, comfortably inside the 1Hz
budget.

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
Author
Owner

Screenshots referenced above, from the live emery emulator:

HERO2 (Ride), normal — hero "25.2" at 96px, cell "21.6" at 44px, DIST --:
HERO2 normal

HERO2, stale — same fields, hollow + strike-through, grey; DIST still -- and unaffected:
HERO2 stale

QUAD (Effort), normal — HEART (emphasised, D29) visibly larger than POWER/CADENCE; AVG PWR --:
QUAD normal

QUAD, stale — HR/POWER/CADENCE hollow + struck, AVG PWR still --; note the status strip's fixed GPS-bars/speed-source placeholders and the real ticking clock/Bluetooth dot/ride-state ring across all four:
QUAD stale

Screenshots referenced above, from the live emery emulator: **HERO2 (Ride), normal** — hero "25.2" at 96px, cell "21.6" at 44px, DIST `--`: ![HERO2 normal](https://git.butzei.de/attachments/79e43a65-35a1-471f-b691-790b00953616) **HERO2, stale** — same fields, hollow + strike-through, grey; DIST still `--` and unaffected: ![HERO2 stale](https://git.butzei.de/attachments/f5dcf86f-fa85-4ad0-b445-f19b3a519ace) **QUAD (Effort), normal** — HEART (emphasised, D29) visibly larger than POWER/CADENCE; AVG PWR `--`: ![QUAD normal](https://git.butzei.de/attachments/6c8272d2-36b6-49aa-95a2-ced48df185c2) **QUAD, stale** — HR/POWER/CADENCE hollow + struck, AVG PWR still `--`; note the status strip's fixed GPS-bars/speed-source placeholders and the real ticking clock/Bluetooth dot/ride-state ring across all four: ![QUAD stale](https://git.butzei.de/attachments/18c2597d-a846-4456-aa8c-b95c303a25a2)
robert merged commit 64c130ab25 into main 2026-09-04 10:30:42 +02:00
Sign in to join this conversation.
No description provided.