Round display support (gabbro): round template variants and arc status (issue #62) #125

Merged
robert merged 5 commits from area/round-display-support into main 2026-09-06 13:56:15 +02:00
Owner

Closes most of #62 — see "Scoped out" below for what is deliberately not in this PR.

Root cause fixed: page_render_content_rect_round()

page_render_content_rect() used to return gabbro's raw 260x240 rectangular bounding box, which is what #121 flagged as the source of the ~16px bezel overshoot on HERO1 and the wrong (non-fallback) GRID6 threshold decision. This PR adds page_render_content_rect_round(bounds): the largest square inscribed in the circle bounds describes, centred in it, side computed as diameter/sqrt(2) via an integer Newton's-method-style prv_isqrt_round() (no <math.h>, no float anywhere in page_render_geometry.c — consistent with that file's own existing rule). For gabbro (260x260) this is exactly 184x184, matching D24/DESIGN.md's own figure.

Critically, every other template composes with this unchanged:

  • page_render_hero1_hero_rect(content) — same function, just handed the round square instead of the rectangle.
  • page_render_quad_cell_rect(content, slot) — same function; 184/2 = 92 exactly, so QUAD's four cells are 92x92 with no remainder, matching the issue's own number.
  • page_render_resolve_template(requested, content) — same function, zero gabbro-specific code; a 184x184 square gives a 92x61 GRID6 cell, under the existing 100x69 threshold on both axes, so GRID6 falls back to QUAD automatically, exactly like basalt.

This is the "every other function in this file can then correctly build on it" mechanism the issue asked for, rather than each template inventing its own round math.

HERO2: the one template that needed real chord math

HERO2 is different: DESIGN.md's own words are "hero centred, the two cells inset to the chord width at their vertical position", and re-using the shared inscribed square would under-use the wider chord available near the circle's true vertical centre (238px there vs the square's fixed 184px). So page_render_hero2_round_hero_rect(bounds)/page_render_hero2_round_cell_rect(bounds, index) take the full round screen, not content, and compute each row's own safe half-width via prv_chord_half_width() (Pythagoras, again via the integer isqrt) at the row's farther edge from centre — a rectangle sized this way is provably inside the circle on every corner (proof and a direct per-corner host-test assertion are both in the code/tests, not just argued in a comment). On gabbro this gives a 196px hero (wider than the rectangle's own 200px, despite gabbro's smaller inscribed square) and ~99x65 cells (bigger than QUAD's 92x92).

Status arc

page_render_status_arc_slot(slot) (page_render_geometry.c, host-tested, no SDK) reports each of the same five status slots as an angular span in TRIG_MAX_ANGLE units, -60°..+60° from the top of the circle, same "clock gets 2/5, four icons split the rest evenly" rule as the rectangular strip. page_render.c's prv_draw_status_arc() is the one place that turns those angles into pixels, via graphics_draw_arc() (the visible reference arc), grect_centered_from_polar() (each icon/clock's own centred rect) and gpoint_from_polar()'s underlying math — checked directly against the installed Core Devices SDK 4.33.1 header, not assumed from older docs. Every icon reuses the exact same prv_draw_bluetooth_glyph()/prv_draw_gps_quality_glyph()/prv_draw_speed_source_glyph()/prv_draw_ride_state_glyph() functions the rectangular strip already uses — they only ever look at the PageRect they're handed.

Composes with #121, doesn't bypass it

page_render_fit_digit_height() (issue #121, width-only) is kept exactly as it shipped — same signature, same behaviour, all its own tests pass unmodified — by delegating to a new page_render_fit_digit_height_2d(text, max_width, max_height, requested_height). The new function exists because round's tighter cells exposed a real vertical-overflow case #121 never had to handle: a digit-height ceiling that exceeds its own value area's height, for a value short enough that the width check alone never catches it. Two concrete, checked cases: HERO1's round 170px ceiling against its own 164px value area (184 square minus the 20px label strip), and QUAD's existing, unchanged 76px emphasised ceiling against a round 92x92 cell's 72px value area — the literal "QUAD's emphasised slot 1 (D29) works within the inscribed square" acceptance criterion. prv_draw_cell() (the one production call site) now calls the 2D version on every template, round or not; on emery/basalt this is a no-op, verified directly by a new host test asserting no existing ceiling ever exceeded its value area's height there.

Host tests (issue #68)

20 new assertions in test_page_render_geometry.c, 67/67 passing (up from 47/47): the round content rect's exact geometry (cross-checked against a real floating-point sqrt() at a size other than gabbro's own, so it isn't just re-deriving the number the function was written against), GRID6→QUAD fallback composing correctly, QUAD's 92x92 cells and the D29 emphasised-slot criterion as a literal assertion, the status arc's angular tiling, HERO1's round ceiling, HERO2's rows checked directly against the circle equation (dx² + dy² ≤ r² on all four corners, not trusted from a comment), and the height-aware fit clamp — including a comprehensive test_no_slot_exceeds_its_bounds_on_{emery_or_basalt,gabbro_round} pair that is the issue's own "no slot exceeds its bounds ... as a real assertion, not a look" criterion, verbatim.

All four host suites (test_fields/test_page/test_state/test_backlight/test_page_render_geometry) hand-compiled with gcc -std=c11 -Wall -Wextra -Werror and run directly (no cmake in this sandbox): 28+16+55+27+67 = 193 assertions, 0 failures.

Real build + emulator verification

pebble build succeeds clean on all three targets (emery/gabbro/basalt), no warnings. Screenshots taken in the real gabbro emulator: HERO2 (Ride page — chord-inset cells + status arc), QUAD (Effort page — 92x92 inscribed-square cells), and HERO1 (temporarily swapped into the Ride slot for screenshot purposes only — no default page ships HERO1 yet — then fully reverted, git diff on page.c is clean). emery and basalt were re-screenshotted after this change too, to confirm no regression on the rectangular path.

Scoped out: NAV and MAP

page_render.c's own dispatch switch has said for a while now that "NAV/MAP have no carousel entry point at all today" — confirmed again before starting this PR: issues #35 (view_nav) and #42 (view_map polyline renderer) are both still open, so there is no live NAV or MAP page anywhere in this app to lay out round-natively, let alone verify in the gabbro emulator. Building a temporary NAV/MAP page just to exercise this issue's own NAV/MAP criteria would be building real, unreviewed scope for #35/#42 as a side effect, so this PR does not do that. Dependency edges recorded on #62: depends on #35 and #42 (checked both for cycles first — neither depends, directly or transitively, on #62 — no cycle introduced).

What's covered vs deferred, against the issue's own checklist

  • HERO1 centred with ~170px digits
  • HERO2 hero centred, two cells inset to the chord width at their vertical position
  • QUAD inset to the inscribed square, ~92x92 cells
  • GRID6 falls back to QUAD on gabbro
  • Status becomes an arc via graphics_draw_arc()/gpoint_from_polar(), glyphs placed radially
  • NAV centred turn arrow — deferred, depends on #35
  • MAP round-native heading-up map — deferred, depends on #42
  • Variants selected with PBL_ROUND/PBL_IF_ROUND_ELSE(), never runtime screen-size checks
  • No hard-coded coordinates — everything derived from layer_get_bounds()/page_render_content_rect_round()
  • gbitmap_get_data_row_info() — not applicable: nothing in this codebase touches the framebuffer directly (no graphics_capture_frame_buffer() anywhere, grepped); flagged rather than silently skipped, same precedent as the existing GPath/heap-allocation note in this file
  • text_layer_enable_screen_text_flow_and_paging() — enabled on carousel.c's one TextLayer (the Stop/Resume/Leave/protocol-mismatch confirmation prompts) on round, via PBL_IF_ROUND_ELSE; page_render.c itself draws all its own text with graphics_draw_text() directly, never a TextLayer, so there was no other real call site for this API
  • Every page verified in the gabbro emulator (HERO1/HERO2/QUAD + status arc); hardware verification not done in this session — Robert has both watches in hand for that
  • QUAD's emphasised slot 1 (D29) works within the inscribed square — literal host-test assertion, see above
  • Template geometry covered by host-side tests (#68) — "no slot exceeds its bounds on emery, gabbro or basalt" as a real assertion

Files: watchapp/src/c/page_render_geometry.h/.c, watchapp/src/c/page_render.c/.h, watchapp/src/c/carousel.c (the text-flow-and-paging criterion), watchapp/tests/test_page_render_geometry.c. No status_bar.c — the status strip/arc has always lived inside page_render.c/page_render_geometry.c, confirmed by reading the current tree before starting rather than assuming the issue's file list.

Closes most of #62 — see "Scoped out" below for what is deliberately not in this PR. ## Root cause fixed: page_render_content_rect_round() `page_render_content_rect()` used to return gabbro's raw 260x240 rectangular bounding box, which is what #121 flagged as the source of the ~16px bezel overshoot on HERO1 and the wrong (non-fallback) GRID6 threshold decision. This PR adds `page_render_content_rect_round(bounds)`: the largest square inscribed in the circle `bounds` describes, centred in it, side computed as `diameter/sqrt(2)` via an integer Newton's-method-style `prv_isqrt_round()` (no `<math.h>`, no float anywhere in `page_render_geometry.c` — consistent with that file's own existing rule). For gabbro (260x260) this is exactly 184x184, matching D24/DESIGN.md's own figure. Critically, every other template composes with this **unchanged**: - `page_render_hero1_hero_rect(content)` — same function, just handed the round square instead of the rectangle. - `page_render_quad_cell_rect(content, slot)` — same function; 184/2 = 92 exactly, so QUAD's four cells are 92x92 with no remainder, matching the issue's own number. - `page_render_resolve_template(requested, content)` — same function, zero gabbro-specific code; a 184x184 square gives a 92x61 GRID6 cell, under the existing 100x69 threshold on both axes, so GRID6 falls back to QUAD automatically, exactly like basalt. This is the "every other function in this file can then correctly build on it" mechanism the issue asked for, rather than each template inventing its own round math. ## HERO2: the one template that needed real chord math HERO2 is different: DESIGN.md's own words are "hero centred, the two cells inset to the chord width at their vertical position", and re-using the shared inscribed square would under-use the wider chord available near the circle's true vertical centre (238px there vs the square's fixed 184px). So `page_render_hero2_round_hero_rect(bounds)`/`page_render_hero2_round_cell_rect(bounds, index)` take the **full round screen**, not `content`, and compute each row's own safe half-width via `prv_chord_half_width()` (Pythagoras, again via the integer isqrt) at the row's *farther* edge from centre — a rectangle sized this way is provably inside the circle on every corner (proof and a direct per-corner host-test assertion are both in the code/tests, not just argued in a comment). On gabbro this gives a 196px hero (wider than the rectangle's own 200px, despite gabbro's smaller inscribed square) and ~99x65 cells (bigger than QUAD's 92x92). ## Status arc `page_render_status_arc_slot(slot)` (page_render_geometry.c, host-tested, no SDK) reports each of the same five status slots as an angular span in `TRIG_MAX_ANGLE` units, -60°..+60° from the top of the circle, same "clock gets 2/5, four icons split the rest evenly" rule as the rectangular strip. `page_render.c`'s `prv_draw_status_arc()` is the one place that turns those angles into pixels, via `graphics_draw_arc()` (the visible reference arc), `grect_centered_from_polar()` (each icon/clock's own centred rect) and `gpoint_from_polar()`'s underlying math — checked directly against the installed Core Devices SDK 4.33.1 header, not assumed from older docs. Every icon reuses the *exact same* `prv_draw_bluetooth_glyph()`/`prv_draw_gps_quality_glyph()`/`prv_draw_speed_source_glyph()`/`prv_draw_ride_state_glyph()` functions the rectangular strip already uses — they only ever look at the `PageRect` they're handed. ## Composes with #121, doesn't bypass it `page_render_fit_digit_height()` (issue #121, width-only) is kept **exactly as it shipped** — same signature, same behaviour, all its own tests pass unmodified — by delegating to a new `page_render_fit_digit_height_2d(text, max_width, max_height, requested_height)`. The new function exists because round's tighter cells exposed a real vertical-overflow case #121 never had to handle: a digit-height *ceiling* that exceeds its own value area's *height*, for a value short enough that the width check alone never catches it. Two concrete, checked cases: HERO1's round 170px ceiling against its own 164px value area (184 square minus the 20px label strip), and QUAD's *existing, unchanged* 76px emphasised ceiling against a round 92x92 cell's 72px value area — the literal "QUAD's emphasised slot 1 (D29) works within the inscribed square" acceptance criterion. `prv_draw_cell()` (the one production call site) now calls the 2D version on every template, round or not; on emery/basalt this is a no-op, verified directly by a new host test asserting no existing ceiling ever exceeded its value area's height there. ## Host tests (issue #68) 20 new assertions in `test_page_render_geometry.c`, 67/67 passing (up from 47/47): the round content rect's exact geometry (cross-checked against a real floating-point `sqrt()` at a size other than gabbro's own, so it isn't just re-deriving the number the function was written against), GRID6→QUAD fallback composing correctly, QUAD's 92x92 cells and the D29 emphasised-slot criterion as a literal assertion, the status arc's angular tiling, HERO1's round ceiling, HERO2's rows checked directly against the circle equation (`dx² + dy² ≤ r²` on all four corners, not trusted from a comment), and the height-aware fit clamp — including a comprehensive `test_no_slot_exceeds_its_bounds_on_{emery_or_basalt,gabbro_round}` pair that is the issue's own "no slot exceeds its bounds ... as a real assertion, not a look" criterion, verbatim. All four host suites (`test_fields`/`test_page`/`test_state`/`test_backlight`/`test_page_render_geometry`) hand-compiled with `gcc -std=c11 -Wall -Wextra -Werror` and run directly (no cmake in this sandbox): 28+16+55+27+67 = 193 assertions, 0 failures. ## Real build + emulator verification `pebble build` succeeds clean on all three targets (emery/gabbro/basalt), no warnings. Screenshots taken in the real gabbro emulator: HERO2 (Ride page — chord-inset cells + status arc), QUAD (Effort page — 92x92 inscribed-square cells), and HERO1 (temporarily swapped into the Ride slot for screenshot purposes only — no default page ships HERO1 yet — then fully reverted, `git diff` on `page.c` is clean). emery and basalt were re-screenshotted after this change too, to confirm no regression on the rectangular path. ## Scoped out: NAV and MAP `page_render.c`'s own dispatch switch has said for a while now that "NAV/MAP have no carousel entry point at all today" — confirmed again before starting this PR: issues #35 (view_nav) and #42 (view_map polyline renderer) are both still open, so there is no live NAV or MAP page anywhere in this app to lay out round-natively, let alone verify in the gabbro emulator. Building a temporary NAV/MAP page just to exercise this issue's own NAV/MAP criteria would be building real, unreviewed scope for #35/#42 as a side effect, so this PR does not do that. Dependency edges recorded on #62: depends on #35 and #42 (checked both for cycles first — neither depends, directly or transitively, on #62 — no cycle introduced). ## What's covered vs deferred, against the issue's own checklist - [x] HERO1 centred with ~170px digits - [x] HERO2 hero centred, two cells inset to the chord width at their vertical position - [x] QUAD inset to the inscribed square, ~92x92 cells - [x] GRID6 falls back to QUAD on gabbro - [x] Status becomes an arc via graphics_draw_arc()/gpoint_from_polar(), glyphs placed radially - [ ] NAV centred turn arrow — deferred, depends on #35 - [ ] MAP round-native heading-up map — deferred, depends on #42 - [x] Variants selected with PBL_ROUND/PBL_IF_ROUND_ELSE(), never runtime screen-size checks - [x] No hard-coded coordinates — everything derived from layer_get_bounds()/page_render_content_rect_round() - [ ] gbitmap_get_data_row_info() — not applicable: nothing in this codebase touches the framebuffer directly (no `graphics_capture_frame_buffer()` anywhere, grepped); flagged rather than silently skipped, same precedent as the existing GPath/heap-allocation note in this file - [x] text_layer_enable_screen_text_flow_and_paging() — enabled on carousel.c's one TextLayer (the Stop/Resume/Leave/protocol-mismatch confirmation prompts) on round, via PBL_IF_ROUND_ELSE; page_render.c itself draws all its own text with graphics_draw_text() directly, never a TextLayer, so there was no other real call site for this API - [x] Every page verified in the gabbro emulator (HERO1/HERO2/QUAD + status arc); hardware verification not done in this session — Robert has both watches in hand for that - [x] QUAD's emphasised slot 1 (D29) works within the inscribed square — literal host-test assertion, see above - [x] Template geometry covered by host-side tests (#68) — "no slot exceeds its bounds on emery, gabbro or basalt" as a real assertion Files: `watchapp/src/c/page_render_geometry.h`/`.c`, `watchapp/src/c/page_render.c`/`.h`, `watchapp/src/c/carousel.c` (the text-flow-and-paging criterion), `watchapp/tests/test_page_render_geometry.c`. No `status_bar.c` — the status strip/arc has always lived inside `page_render.c`/`page_render_geometry.c`, confirmed by reading the current tree before starting rather than assuming the issue's file list.
Header-only checkpoint while page_render_geometry.c/.h round support is
still being written — committed promptly per branch hygiene, not a
complete increment on its own.

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
page_render_geometry.c/.h additions, no wiring into page_render.c yet:
- page_render_content_rect_round(): inscribed-square usable area for a
  round display, via an integer isqrt (no float, no <math.h>).
- page_render_hero2_round_hero_rect()/_cell_rect(): chord-width-aware rows
  for HERO2's round variant, using the full circle rather than the shared
  inscribed square (QUAD/HERO1/GRID6 use that square unchanged).
- page_render_digit_height_round(): HERO1's ~170px round ceiling.
- page_render_status_arc_slot(): the five status slots as angular spans.
- page_render_fit_digit_height_2d(): height-aware sibling of #121's
  width-only fit, needed because round's tighter cells expose ceilings
  that exceed their own value-area height (HERO1 170 vs 164, QUAD's
  existing 76 vs a round 92x92 cell's 72). Original 3-arg function
  delegates unchanged; all 47 existing host tests still pass.

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
20 new assertions in test_page_render_geometry.c: round content rect
(exact 184x184 on gabbro, cross-checked against a real sqrt() at a
different size), GRID6->QUAD fallback composing correctly with the round
content rect, QUAD's 92x92 cells and D29's emphasised-slot-within-the-
inscribed-square criterion, the status arc's angular tiling, HERO1's round
ceiling, HERO2's round rows checked directly against the circle equation
(not just trusted from the implementation comment), and the height-aware
fit clamp (page_render_fit_digit_height_2d) including the literal 'no slot
exceeds its bounds on emery, gabbro or basalt' comprehensive assertion.

67/67 host tests pass.

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
Wire round variants into page_render.c (issue #62)
Some checks failed
dev-artifact / build-pbw (push) Failing after 0s
dev-artifact / build-apk (push) Failing after 0s
dev-artifact / publish (push) Has been skipped
fast-lane / host-c-tests (push) Failing after 0s
fast-lane / jvm-tests (push) Failing after 0s
fast-lane / pebble-build (push) Failing after 0s
fast-lane / lint-and-secrets (push) Failing after 0s
fast-lane / meta-declares-required-jobs (push) Failing after 0s
11701aec39
page_render_draw() now selects is_round exactly once via
PBL_IF_ROUND_ELSE(), threaded down as a plain bool — page_render_geometry.c
still never sees an SDK macro directly (D34):

- Status: prv_draw_status_arc() (round) vs prv_draw_status_strip() (rect).
  Uses graphics_draw_arc()/gpoint_from_polar()/grect_centered_from_polar()
  against the installed Core Devices SDK 4.33.1 (checked directly, not
  assumed) — every icon glyph reuses the exact same
  prv_draw_bluetooth_glyph()/prv_draw_gps_quality_glyph()/
  prv_draw_speed_source_glyph()/prv_draw_ride_state_glyph() functions the
  rectangular strip uses, just handed a PageRect centred at a polar angle
  instead of an x-range.
- Content rect: page_render_content_rect_round() vs
  page_render_content_rect() — the inscribed square QUAD/HERO1/GRID6-
  fallback all now correctly build on.
- HERO2: real round variant (prv_draw_hero2_round(), the full screen +
  chord math) vs the existing rectangular one.
- HERO1/QUAD: same draw functions either way, digit heights switched via
  page_render_digit_height_round()/page_render_digit_height().
- prv_draw_cell() now calls page_render_fit_digit_height_2d() (width AND
  height), fixing a real vertical overflow round's tighter cells exposed
  (QUAD's 76px emphasised ceiling vs a round 92x92 cell's 72px value area;
  HERO1's round 170px ceiling vs its own 164px value area) — a no-op on
  emery/basalt, where no existing ceiling ever exceeded its cell's height.

Verified with a real  on emery/gabbro/basalt (all three
compile clean, no warnings) and real gabbro-emulator screenshots: HERO2
(Ride, round chord-inset cells + status arc), QUAD (Effort, 92x92 inscribed
cells), and HERO1 (temporarily swapped into the Ride slot for screenshot
purposes only, then reverted — no default page ships HERO1 yet). emery and
basalt re-screenshotted after this change to confirm no regression.

All four host suites still pass (28+16+55+27+67 = 193 assertions across
fields/page/state/backlight/page_render_geometry, 0 failures).

NAV and MAP round variants are out of scope — see this PR's own
description for why and the dependency edges recorded on #62.

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
Enable round text flow/paging on the one TextLayer in the app (issue #62)
Some checks failed
dev-artifact / build-pbw (push) Failing after 0s
dev-artifact / build-apk (push) Failing after 0s
dev-artifact / publish (push) Has been skipped
fast-lane / host-c-tests (push) Failing after 0s
fast-lane / jvm-tests (push) Failing after 0s
fast-lane / pebble-build (push) Failing after 0s
fast-lane / lint-and-secrets (push) Failing after 0s
fast-lane / meta-declares-required-jobs (push) Failing after 0s
fast-lane / host-c-tests (pull_request) Failing after 0s
fast-lane / jvm-tests (pull_request) Failing after 0s
fast-lane / pebble-build (pull_request) Failing after 0s
fast-lane / lint-and-secrets (pull_request) Failing after 0s
fast-lane / meta-declares-required-jobs (pull_request) Failing after 0s
3002d18b21
carousel.c's confirmation prompt (Stop ride? / Resume ride? / Leave app? /
the protocol-mismatch notice) is the only real TextLayer in the whole app —
page_render.c draws every template with graphics_draw_text() directly, so
there was no other call site for text_layer_enable_screen_text_flow_and_
paging(). It spans the full screen width on a circular display and the
protocol-mismatch notice embeds two runtime version numbers, so it is a
real (if narrow) overflow risk on round. Enabled via PBL_IF_ROUND_ELSE,
after layer_add_child() per the SDK's own documented requirement
(checked directly against the installed 4.33.1 header). No-op on
emery/basalt.

Verified in the gabbro emulator: triggered the Stop-ride confirmation,
text renders centred and fully on-screen.

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
robert merged commit 11b5e6a906 into main 2026-09-06 13:56:15 +02:00
Sign in to join this conversation.
No description provided.