@typecad/ui 1.0.0-alpha.13 → 1.0.0-alpha.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +1457 -1469
  2. package/dist/cli.js +22 -19
  3. package/dist/preview/host-ui-runtime.js +0 -1
  4. package/dist/ui-engine/model.js +7 -1
  5. package/dist/ui-engine/runtime-header/canvas-scrollbar.js +115 -115
  6. package/dist/ui-engine/runtime-header/dirty-scroll-mutators.js +182 -182
  7. package/dist/ui-engine/runtime-header/forward-decls.js +227 -216
  8. package/dist/ui-engine/runtime-header/init-press-input.js +138 -138
  9. package/dist/ui-engine/runtime-header/keyboard.js +676 -676
  10. package/dist/ui-engine/runtime-header/node-draw-body.js +1658 -1656
  11. package/dist/ui-engine/runtime-header/paint-order-coords.js +259 -259
  12. package/dist/ui-engine/runtime-header/paint-rects-repair.js +736 -736
  13. package/dist/ui-engine/runtime-header/scroll-physics.js +4 -4
  14. package/dist/ui-engine/runtime-header/structs.js +228 -228
  15. package/dist/ui-engine/runtime-header/text-rendering.js +888 -888
  16. package/dist/ui-engine/runtime-header/tick/bindings-phase.js +118 -118
  17. package/dist/ui-engine/runtime-header/tick/dirty-draw-phase.js +896 -896
  18. package/dist/ui-engine/runtime-header/tick/scroll-canvas-phase.js +24 -24
  19. package/dist/ui-engine/runtime-header/touch-keyboard-fwd.js +11 -11
  20. package/dist/ui-engine/runtime-header/types-defines.js +110 -110
  21. package/dist/ui-engine/shadcn-kit.d.ts +1 -1
  22. package/dist/ui-engine/shadcn-kit.js +4 -0
  23. package/dist/ui-engine/ui-lowering.d.ts +1 -1
  24. package/dist/ui-engine/ui-lowering.js +14 -5
  25. package/dist/ui-engine/ui-registry.js +9 -1
  26. package/dist/wizard/display-catalog.d.ts +0 -3
  27. package/dist/wizard/display-catalog.js +3 -12
  28. package/dist/wizard/index.d.ts +2 -0
  29. package/dist/wizard/index.js +1 -0
  30. package/dist/wizard/integration-wizard.d.ts +3 -0
  31. package/dist/wizard/integration-wizard.js +69 -20
  32. package/dist/wizard/package-writer.d.ts +20 -0
  33. package/dist/wizard/package-writer.js +79 -0
  34. package/package.json +4 -4
  35. package/src/cli.ts +90 -87
  36. package/src/preview/host-ui-runtime.ts +0 -1
  37. package/src/ui-engine/model.ts +6 -1
  38. package/src/ui-engine/runtime-header/canvas-scrollbar.ts +121 -121
  39. package/src/ui-engine/runtime-header/dirty-scroll-mutators.ts +188 -188
  40. package/src/ui-engine/runtime-header/forward-decls.ts +238 -227
  41. package/src/ui-engine/runtime-header/init-press-input.ts +144 -144
  42. package/src/ui-engine/runtime-header/keyboard.ts +689 -689
  43. package/src/ui-engine/runtime-header/node-draw-body.ts +1683 -1681
  44. package/src/ui-engine/runtime-header/paint-order-coords.ts +265 -265
  45. package/src/ui-engine/runtime-header/paint-rects-repair.ts +742 -742
  46. package/src/ui-engine/runtime-header/scroll-physics.ts +4 -4
  47. package/src/ui-engine/runtime-header/structs.ts +234 -234
  48. package/src/ui-engine/runtime-header/text-rendering.ts +894 -894
  49. package/src/ui-engine/runtime-header/tick/bindings-phase.ts +124 -124
  50. package/src/ui-engine/runtime-header/tick/dirty-draw-phase.ts +902 -902
  51. package/src/ui-engine/runtime-header/tick/scroll-canvas-phase.ts +30 -30
  52. package/src/ui-engine/runtime-header/touch-keyboard-fwd.ts +11 -11
  53. package/src/ui-engine/runtime-header/types-defines.ts +116 -116
  54. package/src/ui-engine/shadcn-kit.ts +4 -0
  55. package/src/ui-engine/ui-lowering.ts +12 -4
  56. package/src/ui-engine/ui-registry.ts +9 -1
  57. package/src/wizard/display-catalog.ts +261 -273
  58. package/src/wizard/index.ts +46 -38
  59. package/src/wizard/integration-wizard.ts +679 -619
  60. package/src/wizard/package-writer.ts +98 -0
@@ -1,1681 +1,1683 @@
1
- // Slice of the C++ runtime header.
2
- // The per-node kind switch, extracted into a callable function so the
3
- // strip/band renderer (ui_render_scroll_bands) can compose scroll-subtree
4
- // nodes into a band canvas without duplicating ~700 lines of kind-specific
5
- // draw code.
6
- //
7
- // The caller fills a UINodeDrawCtx with the computed draw state (drawY, text
8
- // metrics, colors, text source) and invokes ui_draw_node_body. Both the main
9
- // dirty-node draw loop (dirty-draw-phase.ts) and the band renderer set the ctx
10
- // up the same way; the difference is only what __ui_gfx points at (display /
11
- // framebuffer / scroll canvas / band canvas) and how the node's box coords are
12
- // translated, both of which the caller handles before calling this.
13
- //
14
- // The NODE_LIST case is special: it manages its own canvas push, static
15
- // decoration, coordinate restore, dirty-clear, and then returns 1 (a sentinel
16
- // meaning "I fully handled this node — skip the caller's post-switch
17
- // epilogue"). Every other case returns 0 and the caller runs its epilogue
18
- // (outline, paint-canvas push, coordinate restore, dirty-clear, refresh rect).
19
- // This return-sentinel preserves the original 'continue;' the list case used
20
- // to short-circuit the rest of the per-node loop body.
21
- //
22
- // See docs/superpowers/specs/2026-07-12-split-runtime-header-design.md.
23
- export function emitNodeDrawBody(): string {
24
- return `
25
- // Computed draw state for one node, passed into ui_draw_node_body so it can be
26
- // called from both the main dirty-node loop and the strip/band renderer.
27
- struct UINodeDrawCtx {
28
- int16_t drawY; // node's screen-space draw Y (scroll-adjusted)
29
- UI_COLOR_T bColor; // border color (borderColor or fg, opacity-blended)
30
- UI_COLOR_T fillBg; // fill background (bg, opacity-blended toward parent)
31
- uint8_t ts; // text size
32
- uint16_t textMaxW; // max text width (node box content width)
33
- uint16_t tw; // laid-out text width
34
- uint16_t th; // laid-out text height
35
- uint16_t paintTextW; // paint-rect text width (tw + insets)
36
- uint16_t paintTextH; // paint-rect text height (th + insets)
37
- const char* displayText; // text source (binding buffer or flash literal)
38
- // Call-site draw state the NODE_LIST case reads. These mirror the main draw
39
- // loop's locals; the band renderer sets them to its own (target = band canvas,
40
- // drawingBufferedScroll = 0, origBox = the node's untranslated box).
41
- uint8_t drawingBufferedScroll; // 1 when drawing into the buffered scroll canvas
42
- CuttlefishDisplayTarget* drawTarget; // __ui_draw_target (framebuffer or display)
43
- int16_t origBoxX; // node's untranslated box.x (restored after draw)
44
- int16_t origBoxY; // node's untranslated box.y (restored after draw)
45
- };
46
-
47
- // Returns 1 when the node fully handled its own canvas push, decoration,
48
- // coordinate restore, and dirty-clear (NODE_LIST). Returns 0 otherwise, in
49
- // which case the caller is responsible for the post-switch epilogue.
50
- //
51
- // The ctx parameter is typed const void* (cast back to UINodeDrawCtx* below):
52
- // the Arduino .ino preprocessor auto-inserts a forward declaration of every
53
- // function near the top of the sketch, BEFORE this struct is defined, so a
54
- // struct-typed parameter makes that generated prototype fail to compile
55
- // ("'UINodeDrawCtx' does not name a type"). Primitive-only parameters keep
56
- // the auto-generated prototype valid; call sites still pass &ctx, which
57
- // converts implicitly to const void*.
58
- static inline uint8_t ui_draw_node_body(int16_t i, const void* rawCtx) {
59
- const UINodeDrawCtx* ctx = static_cast<const UINodeDrawCtx*>(rawCtx);
60
- int16_t drawY = ctx->drawY;
61
- UI_COLOR_T bColor = ctx->bColor;
62
- UI_COLOR_T fillBg = ctx->fillBg;
63
- uint8_t ts = ctx->ts;
64
- uint16_t textMaxW = ctx->textMaxW;
65
- uint16_t tw = ctx->tw;
66
- uint16_t th = ctx->th;
67
- uint16_t paintTextW = ctx->paintTextW;
68
- uint16_t paintTextH = ctx->paintTextH;
69
- const char* displayText = ctx->displayText;
70
- uint8_t drawingBufferedScroll = ctx->drawingBufferedScroll;
71
- CuttlefishDisplayTarget* __ui_draw_target = ctx->drawTarget;
72
- int16_t origBoxX = ctx->origBoxX;
73
- int16_t origBoxY = ctx->origBoxY;
74
- switch (__ui_nodes[i].kind) {
75
- case NODE_FILL:
76
- if (__ui_nodes[i].gradientEnabled > 0) {
77
- ui_draw_gradient_fill(i, drawY);
78
- } else if (__ui_nodes[i].borderRadius > 0 && __ui_nodes[i].hasBg) {
79
- int16_t fillW = ui_rotated_face_w(i, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
80
- int16_t fillH = ui_rotated_face_h(i, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
81
- ui_display_fill_round_rect(__ui_nodes[i].box.x, drawY, fillW, fillH, __ui_nodes[i].borderRadius, fillBg);
82
- } else if (__ui_nodes[i].hasBg) {
83
- int16_t fillW = ui_rotated_face_w(i, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
84
- int16_t fillH = ui_rotated_face_h(i, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
85
- ui_display_fill_rect(__ui_nodes[i].box.x, drawY, fillW, fillH, fillBg);
86
- }
87
- ui_draw_shadow(i, drawY, 1);
88
- if (__ui_nodes[i].borderStyle != 0) {
89
- UI_COLOR_T bColor = __ui_nodes[i].borderColor ? __ui_nodes[i].borderColor : __ui_nodes[i].fg;
90
- int16_t borderW = ui_rotated_face_w(i, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
91
- int16_t borderH = ui_rotated_face_h(i, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
92
- ui_draw_rect_outline(__ui_nodes[i].box.x, drawY, borderW, borderH,
93
- __ui_nodes[i].borderRadius, __ui_nodes[i].borderStyle, __ui_nodes[i].borderWidth, bColor);
94
- }
95
- break;
96
- case NODE_TEXT:
97
- {
98
- int16_t insetL = static_cast<int16_t>(__ui_nodes[i].borderWidth) + static_cast<int16_t>(__ui_nodes[i].paddingLeft);
99
- int16_t insetR = static_cast<int16_t>(__ui_nodes[i].borderWidth) + static_cast<int16_t>(__ui_nodes[i].paddingRight);
100
- int16_t insetT = static_cast<int16_t>(__ui_nodes[i].borderWidth) + static_cast<int16_t>(__ui_nodes[i].paddingTop);
101
- int16_t textX = __ui_nodes[i].box.x + insetL;
102
- int16_t textY = drawY + insetT;
103
- int16_t textW = static_cast<int16_t>(__ui_nodes[i].box.w) - insetL - insetR;
104
- if (textW < 1) textW = 1;
105
- uint8_t textBoxPainted = 0;
106
- if (__ui_nodes[i].gradientEnabled > 0) {
107
- ui_draw_gradient_fill(i, drawY);
108
- textBoxPainted = 1;
109
- } else if (__ui_nodes[i].borderRadius > 0 && __ui_nodes[i].hasBg) {
110
- ui_display_fill_round_rect(__ui_nodes[i].box.x, drawY,
111
- __ui_nodes[i].box.w, __ui_nodes[i].box.h, __ui_nodes[i].borderRadius, fillBg);
112
- textBoxPainted = 1;
113
- } else if (__ui_nodes[i].hasBg) {
114
- ui_display_fill_rect(__ui_nodes[i].box.x, drawY,
115
- __ui_nodes[i].box.w, __ui_nodes[i].box.h, fillBg);
116
- textBoxPainted = 1;
117
- }
118
- {
119
- uint16_t clearW = __ui_nodes[i].box.w;
120
- int16_t paintedTextW = static_cast<int16_t>(__ui_nodes[i].lastTextWidth) + insetL + insetR;
121
- if (__ui_nodes[i].lastTextWidth > 0 && paintedTextW > static_cast<int16_t>(clearW)) {
122
- clearW = static_cast<uint16_t>(paintedTextW);
123
- }
124
- uint16_t paddedTw = static_cast<uint16_t>(static_cast<int16_t>(tw) + insetL + insetR);
125
- if (paddedTw > clearW) clearW = paddedTw;
126
- // overflow:hidden/scroll: never clear past the node's own box. A nowrap
127
- // line wider than its box would otherwise erase the parent's border.
128
- if (__ui_nodes[i].scrollable) clearW = __ui_nodes[i].box.w;
129
- uint16_t clearH = __ui_nodes[i].box.h;
130
- int16_t paintedTextH = static_cast<int16_t>(__ui_nodes[i].lastTextHeight) + insetT + static_cast<int16_t>(__ui_nodes[i].paddingBottom) + static_cast<int16_t>(__ui_nodes[i].borderWidth);
131
- if (__ui_nodes[i].lastTextHeight > 0 && paintedTextH > static_cast<int16_t>(clearH)) {
132
- clearH = static_cast<uint16_t>(paintedTextH);
133
- }
134
- uint16_t paddedTh = static_cast<uint16_t>(static_cast<int16_t>(th) + insetT + static_cast<int16_t>(__ui_nodes[i].paddingBottom) + static_cast<int16_t>(__ui_nodes[i].borderWidth));
135
- if (paddedTh > clearH) clearH = paddedTh;
136
- // Dynamic transparent text still needs a clear, otherwise old glyph
137
- // pixels accumulate when only this text node is dirty.
138
- UI_COLOR_T clearCol = __ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor;
139
- // Blend the clear toward the backdrop by opacity so a translucent text
140
- // node (inherited from an opacity:<1 parent) doesn't repaint a solid
141
- // block of its parent's fill around the glyphs.
142
- if (__ui_nodes[i].opacity < 100) {
143
- clearCol = ui_blend(clearCol, ui_parent_clear_color(i), __ui_nodes[i].opacity);
144
- }
145
- if (!textBoxPainted) {
146
- ui_display_fill_rect(__ui_nodes[i].box.x, drawY, clearW, clearH, clearCol);
147
- }
148
- __ui_nodes[i].lastTextWidth = tw;
149
- __ui_nodes[i].lastTextHeight = th;
150
- }
151
- ui_draw_shadow(i, drawY, 1);
152
- if (__ui_nodes[i].borderStyle != 0) {
153
- ui_draw_node_border(i, __ui_nodes[i].box.x, drawY, bColor);
154
- }
155
- // Rich-text (inline runs): draw from precomputed geometry instead of the
156
- // single-string wrapped path. Geometry is baked at transpile time; the
157
- // runtime does not re-wrap.
158
- if (__ui_nodes[i].runCount > 0) {
159
- UI_COLOR_T richTextBg;
160
- if (__ui_nodes[i].opacity < 100) {
161
- uint16_t p = __ui_nodes[i].parent;
162
- UI_COLOR_T source = (p != UI_NO_PARENT && __ui_nodes[p].hasBg) ? __ui_nodes[p].bg
163
- : (__ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor);
164
- richTextBg = ui_blend(source, __ui_nodes[i].clearColor, __ui_nodes[i].opacity);
165
- } else {
166
- richTextBg = __ui_nodes[i].hasBg ? __ui_nodes[i].bg : ui_parent_clear_color(i);
167
- }
168
- if (__ui_nodes[i].textShadowCount > 0) {
169
- UI_COLOR_T tsClear = __ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor;
170
- uint32_t tsCol = ui_blend(__ui_nodes[i].textShadowColor, tsClear, __ui_nodes[i].textShadowAlpha);
171
- // Shadow pass: draw the rich block in the shadow color at the offset.
172
- // (Per-segment shadow color is approximated by drawing the whole
173
- // block once in tsCol.)
174
- ui_draw_rich_text(i,
175
- textX + __ui_nodes[i].textShadowOffsetX,
176
- textY + __ui_nodes[i].textShadowOffsetY,
177
- tsClear, __ui_nodes[i].fontAntialias, 1, tsCol, static_cast<uint16_t>(textW));
178
- }
179
- ui_draw_rich_text(i, textX, textY, richTextBg, __ui_nodes[i].fontAntialias, 0, 0, static_cast<uint16_t>(textW));
180
- break;
181
- }
182
- {
183
- // Text shadow: draw the text in the shadow color at the offset first.
184
- UI_COLOR_T tsClear = __ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor;
185
- if (__ui_nodes[i].textShadowCount > 0) {
186
- uint32_t tsCol = ui_blend(__ui_nodes[i].textShadowColor, tsClear, __ui_nodes[i].textShadowAlpha);
187
- ui_draw_wrapped_text(displayText,
188
- textX + __ui_nodes[i].textShadowOffsetX,
189
- textY + __ui_nodes[i].textShadowOffsetY,
190
- static_cast<uint16_t>(textW), tsCol, tsCol, ts, __ui_nodes[i].fontAntialias,
191
- __ui_nodes[i].fontFace, __ui_nodes[i].letterSpacing, __ui_nodes[i].lineHeight,
192
- __ui_nodes[i].whiteSpaceMode, __ui_nodes[i].textAlign, 0, __ui_nodes[i].textOverflow);
193
- }
194
- // Use the parent's clear color as the text background when the node
195
- // has no own background. This makes Adafruit_GFX's opaque glyph-cell
196
- // fill blend with the parent (instead of drawing solid fg blocks that
197
- // overlap adjacent lines/elements). For AA text, fg != bg so the
198
- // edge-detection path still runs correctly.
199
- // Glyph-cell background. For a translucent text node sitting on a
200
- // filled translucent parent, the glyph cells must match the parent's
201
- // blended fill: blend565(parent.bg, backdrop, opacity). The node's own
202
- // clearColor carries the backdrop (set by flatten), and opacity has
203
- // already inherited from the parent. Use the parent's raw bg as the
204
- // source so the glyph cells reproduce the parent's translucent fill.
205
- UI_COLOR_T textBg;
206
- if (__ui_nodes[i].opacity < 100) {
207
- uint16_t p = __ui_nodes[i].parent;
208
- UI_COLOR_T source = (p != UI_NO_PARENT && __ui_nodes[p].hasBg) ? __ui_nodes[p].bg
209
- : (__ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor);
210
- textBg = ui_blend(source, __ui_nodes[i].clearColor, __ui_nodes[i].opacity);
211
- } else {
212
- textBg = __ui_nodes[i].hasBg ? __ui_nodes[i].bg : ui_parent_clear_color(i);
213
- }
214
- ui_draw_wrapped_text(displayText, textX, textY, static_cast<uint16_t>(textW),
215
- __ui_nodes[i].fg, textBg, ts, __ui_nodes[i].fontAntialias,
216
- __ui_nodes[i].fontFace, __ui_nodes[i].letterSpacing, __ui_nodes[i].lineHeight,
217
- __ui_nodes[i].whiteSpaceMode, __ui_nodes[i].textAlign, __ui_nodes[i].underline, __ui_nodes[i].textOverflow);
218
- }
219
- }
220
- break;
221
- case NODE_BUTTON:
222
- // HTML disabled: halve the drawn colors (web-like faded control).
223
- if (__ui_nodes[i].disabled) {
224
- fillBg = (fillBg >> 1) & UI_DIM_MASK;
225
- bColor = (bColor >> 1) & UI_DIM_MASK;
226
- }
227
- if (__ui_nodes[i].borderRadius > 0 && __ui_nodes[i].hasBg)
228
- ui_display_fill_round_rect(__ui_nodes[i].box.x, drawY, __ui_nodes[i].box.w, __ui_nodes[i].box.h, __ui_nodes[i].borderRadius, fillBg);
229
- else if (__ui_nodes[i].hasBg)
230
- ui_display_fill_rect(__ui_nodes[i].box.x, drawY, __ui_nodes[i].box.w, __ui_nodes[i].box.h, fillBg);
231
- ui_draw_shadow(i, drawY, 1);
232
- if (__ui_nodes[i].borderStyle != 0) {
233
- ui_draw_node_border(i, __ui_nodes[i].box.x, drawY, bColor);
234
- }
235
- {
236
- int16_t insetL = static_cast<int16_t>(__ui_nodes[i].borderWidth) + static_cast<int16_t>(__ui_nodes[i].paddingLeft);
237
- int16_t insetR = static_cast<int16_t>(__ui_nodes[i].borderWidth) + static_cast<int16_t>(__ui_nodes[i].paddingRight);
238
- int16_t insetT = static_cast<int16_t>(__ui_nodes[i].borderWidth) + static_cast<int16_t>(__ui_nodes[i].paddingTop);
239
- int16_t insetB = static_cast<int16_t>(__ui_nodes[i].borderWidth) + static_cast<int16_t>(__ui_nodes[i].paddingBottom);
240
- int16_t textX = __ui_nodes[i].box.x + insetL;
241
- int16_t textY = drawY + insetT;
242
- int16_t textW = static_cast<int16_t>(__ui_nodes[i].box.w) - insetL - insetR;
243
- int16_t textH = static_cast<int16_t>(__ui_nodes[i].box.h) - insetT - insetB;
244
- if (textW < 1) textW = 1;
245
- if (textH < 1) textH = static_cast<int16_t>(th);
246
- ui_draw_wrapped_text(displayText,
247
- textX,
248
- textY + (textH - static_cast<int16_t>(th)) / 2,
249
- static_cast<uint16_t>(textW),
250
- __ui_nodes[i].fg,
251
- __ui_nodes[i].hasBg ? fillBg : ui_parent_clear_color(i),
252
- ts, __ui_nodes[i].fontAntialias, __ui_nodes[i].fontFace, __ui_nodes[i].letterSpacing,
253
- __ui_nodes[i].lineHeight, __ui_nodes[i].whiteSpaceMode, __ui_nodes[i].textAlign, __ui_nodes[i].underline, __ui_nodes[i].textOverflow);
254
- }
255
- break;
256
- case NODE_SELECT:
257
- // <select>: a bordered, optionally rounded box with its current option
258
- // text drawn VERTICALLY CENTERED (unlike NODE_TEXT, which top-aligns).
259
- // Mirrors NODE_BUTTON's centering so the label sits mid-box when the
260
- // control is enlarged for touch (min-height). The label is dynamic
261
- // (auto-bound to the selected option's text), so clear the previous
262
- // text rect before redrawing, like NODE_CHECK does.
263
- {
264
- uint16_t clearW = __ui_nodes[i].box.w;
265
- if (__ui_nodes[i].lastTextWidth > 0 && __ui_nodes[i].lastTextWidth > static_cast<int16_t>(clearW)) {
266
- clearW = static_cast<uint16_t>(__ui_nodes[i].lastTextWidth);
267
- }
268
- if (paintTextW > clearW) clearW = paintTextW;
269
- uint16_t clearH = __ui_nodes[i].box.h;
270
- if (__ui_nodes[i].lastTextHeight > 0 && __ui_nodes[i].lastTextHeight > static_cast<int16_t>(clearH)) {
271
- clearH = static_cast<uint16_t>(__ui_nodes[i].lastTextHeight);
272
- }
273
- if (paintTextH > clearH) clearH = paintTextH;
274
- ui_display_fill_rect(__ui_nodes[i].box.x, drawY, clearW, clearH,
275
- __ui_nodes[i].hasBg ? fillBg : ui_parent_clear_color(i));
276
- __ui_nodes[i].lastTextWidth = paintTextW;
277
- __ui_nodes[i].lastTextHeight = paintTextH;
278
- }
279
- if (__ui_nodes[i].borderRadius > 0 && __ui_nodes[i].hasBg)
280
- ui_display_fill_round_rect(__ui_nodes[i].box.x, drawY, __ui_nodes[i].box.w, __ui_nodes[i].box.h, __ui_nodes[i].borderRadius, fillBg);
281
- else if (__ui_nodes[i].hasBg)
282
- ui_display_fill_rect(__ui_nodes[i].box.x, drawY, __ui_nodes[i].box.w, __ui_nodes[i].box.h, fillBg);
283
- ui_draw_shadow(i, drawY, 1);
284
- if (__ui_nodes[i].borderStyle != 0) {
285
- ui_draw_node_border(i, __ui_nodes[i].box.x, drawY, bColor);
286
- }
287
- {
288
- int16_t insetL = static_cast<int16_t>(__ui_nodes[i].borderWidth) + static_cast<int16_t>(__ui_nodes[i].paddingLeft);
289
- int16_t insetR = static_cast<int16_t>(__ui_nodes[i].borderWidth) + static_cast<int16_t>(__ui_nodes[i].paddingRight);
290
- int16_t insetT = static_cast<int16_t>(__ui_nodes[i].borderWidth) + static_cast<int16_t>(__ui_nodes[i].paddingTop);
291
- int16_t insetB = static_cast<int16_t>(__ui_nodes[i].borderWidth) + static_cast<int16_t>(__ui_nodes[i].paddingBottom);
292
- int16_t textX = __ui_nodes[i].box.x + insetL;
293
- int16_t textY = drawY + insetT;
294
- // The right end reserves 14px for the dropdown chevron — the label
295
- // wraps against the reduced width, never under the chevron.
296
- int16_t textW = static_cast<int16_t>(__ui_nodes[i].box.w) - insetL - insetR - 14;
297
- int16_t textH = static_cast<int16_t>(__ui_nodes[i].box.h) - insetT - insetB;
298
- if (textW < 1) textW = 1;
299
- if (textH < 1) textH = static_cast<int16_t>(th);
300
- ui_draw_wrapped_text(displayText,
301
- textX,
302
- textY + (textH - static_cast<int16_t>(th)) / 2,
303
- static_cast<uint16_t>(textW),
304
- __ui_nodes[i].fg,
305
- __ui_nodes[i].hasBg ? fillBg : ui_parent_clear_color(i),
306
- ts, __ui_nodes[i].fontAntialias, __ui_nodes[i].fontFace, __ui_nodes[i].letterSpacing,
307
- __ui_nodes[i].lineHeight, __ui_nodes[i].whiteSpaceMode, __ui_nodes[i].textAlign, __ui_nodes[i].underline, __ui_nodes[i].textOverflow);
308
- // Dropdown chevron: a small solid v at the right end — the
309
- // affordance that the control opens a menu of options.
310
- {
311
- int16_t chX = __ui_nodes[i].box.x + static_cast<int16_t>(__ui_nodes[i].box.w) - insetR - 11;
312
- int16_t chY = drawY + (static_cast<int16_t>(__ui_nodes[i].box.h) - 5) / 2;
313
- ui_display_fill_rect(chX, chY, 9, 1, __ui_nodes[i].fg);
314
- ui_display_fill_rect(chX + 1, chY + 1, 7, 1, __ui_nodes[i].fg);
315
- ui_display_fill_rect(chX + 2, chY + 2, 5, 1, __ui_nodes[i].fg);
316
- ui_display_fill_rect(chX + 3, chY + 3, 3, 1, __ui_nodes[i].fg);
317
- ui_display_fill_rect(chX + 4, chY + 4, 1, 1, __ui_nodes[i].fg);
318
- }
319
- }
320
- break;
321
- case NODE_CHECK:
322
- {
323
- uint16_t clearW = __ui_nodes[i].box.w;
324
- if (__ui_nodes[i].lastTextWidth > 0 && __ui_nodes[i].lastTextWidth > static_cast<int16_t>(clearW)) {
325
- clearW = static_cast<uint16_t>(__ui_nodes[i].lastTextWidth);
326
- }
327
- if (paintTextW > clearW) clearW = paintTextW;
328
- uint16_t clearH = __ui_nodes[i].box.h;
329
- if (__ui_nodes[i].lastTextHeight > 0 && __ui_nodes[i].lastTextHeight > static_cast<int16_t>(clearH)) {
330
- clearH = static_cast<uint16_t>(__ui_nodes[i].lastTextHeight);
331
- }
332
- if (paintTextH > clearH) clearH = paintTextH;
333
- // border-radius > 0 (kit .switch pills): clear the full text rect to
334
- // the backdrop, then paint the rounded box. Mirrors the preview's
335
- // drawCheckNode pill path. The :checked pair (kit wires it to
336
- // --primary/--primary-foreground) swaps the TRACK color when on.
337
- if (__ui_nodes[i].borderRadius > 0 && __ui_nodes[i].hasBg) {
338
- ui_display_fill_rect(__ui_nodes[i].box.x, drawY, clearW, clearH,
339
- ui_parent_clear_color(i));
340
- ui_display_fill_round_rect(__ui_nodes[i].box.x, drawY,
341
- __ui_nodes[i].box.w, __ui_nodes[i].box.h,
342
- __ui_nodes[i].borderRadius,
343
- (__ui_nodes[i].value && __ui_nodes[i].hasCheckedBg) ? __ui_nodes[i].checkedBg : fillBg);
344
- } else {
345
- ui_display_fill_rect(__ui_nodes[i].box.x, drawY, clearW, clearH,
346
- __ui_nodes[i].hasBg ? fillBg : ui_parent_clear_color(i));
347
- }
348
- __ui_nodes[i].lastTextWidth = paintTextW;
349
- __ui_nodes[i].lastTextHeight = paintTextH;
350
- }
351
- {
352
- // Pills inset the 16px indicator from the left edge so the knob
353
- // doesn't touch the rounded end; plain checkboxes keep it flush.
354
- // The knob SLIDES with state — left when off, right when on — the
355
- // switch affordance (static jump; no travel animation).
356
- int16_t knobSlide = __ui_nodes[i].borderRadius > 0
357
- ? static_cast<int16_t>(__ui_nodes[i].box.w) - 16 - 6
358
- : 0;
359
- if (knobSlide < 0) knobSlide = 0;
360
- int16_t cbX = __ui_nodes[i].box.x
361
- + (__ui_nodes[i].borderRadius > 0 ? 3 : 0)
362
- + (__ui_nodes[i].value ? knobSlide : 0);
363
- // Vertically center the 16px indicator within the box so a tall
364
- // (touch-friendly) checkbox doesn't pin the indicator to the top.
365
- // (box.h - 16) / 2 is 0 for the default 16px-tall box, so existing
366
- // checkboxes render byte-identically.
367
- int16_t cbY = drawY + (static_cast<int16_t>(__ui_nodes[i].box.h) - 16) / 2;
368
- if (cbY < drawY) cbY = drawY;
369
- if (__ui_nodes[i].value && __ui_nodes[i].borderRadius > 0) {
370
- // Switch pill: the knob stays a knob — a solid circle when on, no
371
- // checkbox square + checkmark. Same geometry as the radio dot.
372
- // The knob carries the :checked color (primary-foreground).
373
- ui_display_fill_circle(cbX + 8, cbY + 8, 7,
374
- __ui_nodes[i].hasCheckedFg ? __ui_nodes[i].checkedFg : __ui_nodes[i].fg);
375
- } else if (__ui_nodes[i].value) {
376
- // Checked face carries the :checked background (primary), the
377
- // checkmark its color (primary-foreground).
378
- ui_display_fill_rect(cbX, cbY, 16, 16,
379
- __ui_nodes[i].hasCheckedBg ? __ui_nodes[i].checkedBg : __ui_nodes[i].fg);
380
- UI_COLOR_T inv = __ui_nodes[i].hasCheckedFg ? __ui_nodes[i].checkedFg
381
- : (__ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor);
382
- #ifdef UI_AA
383
- {
384
- // Draw the checkmark to a 16×16 AA canvas for smooth diagonals.
385
- CuttlefishCanvas16* c = ui_aa_begin(16, 16, __ui_nodes[i].fg);
386
- // First stroke: down-left (3,8 → 7,12)
387
- if (c) {
388
- ui_aa_line(c, 4.0f, 8.0f, 7.0f, 12.0f, inv);
389
- ui_aa_line(c, 5.0f, 8.0f, 8.0f, 12.0f, inv);
390
- // Second stroke: up-right (7,11 → 13,4)
391
- ui_aa_line(c, 7.0f, 11.0f, 13.0f, 4.0f, inv);
392
- ui_aa_line(c, 8.0f, 11.0f, 14.0f, 4.0f, inv);
393
- ui_aa_push(c, cbX, cbY);
394
- } else {
395
- ui_display_draw_line(cbX + 3, cbY + 8, cbX + 7, cbY + 12, inv);
396
- ui_display_draw_line(cbX + 4, cbY + 8, cbX + 8, cbY + 12, inv);
397
- ui_display_draw_line(cbX + 7, cbY + 12, cbX + 13, cbY + 4, inv);
398
- ui_display_draw_line(cbX + 8, cbY + 12, cbX + 14, cbY + 4, inv);
399
- }
400
- }
401
- #else
402
- ui_display_draw_line(cbX + 3, cbY + 8, cbX + 7, cbY + 12, inv);
403
- ui_display_draw_line(cbX + 4, cbY + 8, cbX + 8, cbY + 12, inv);
404
- ui_display_draw_line(cbX + 3, cbY + 9, cbX + 7, cbY + 13, inv);
405
- ui_display_draw_line(cbX + 7, cbY + 12, cbX + 13, cbY + 4, inv);
406
- ui_display_draw_line(cbX + 8, cbY + 12, cbX + 14, cbY + 4, inv);
407
- ui_display_draw_line(cbX + 7, cbY + 13, cbX + 13, cbY + 5, inv);
408
- #endif
409
- } else if (__ui_nodes[i].borderRadius > 0) {
410
- // Off-state pill: hollow circular knob outline on the track.
411
- ui_display_draw_circle(cbX + 8, cbY + 8, 7, __ui_nodes[i].fg);
412
- } else {
413
- ui_display_draw_rect(cbX, cbY, 16, 16, __ui_nodes[i].fg);
414
- }
415
- }
416
- // Center the label on the same baseline as the indicator: apply the
417
- // indicator's vertical offset to the text origin too, so a tall box
418
- // keeps the indicator + label aligned as a row rather than straddling
419
- // the box top/bottom.
420
- {
421
- int16_t checkOff = (static_cast<int16_t>(__ui_nodes[i].box.h) - 16) / 2;
422
- if (checkOff < 0) checkOff = 0;
423
- ui_draw_wrapped_text(displayText, __ui_nodes[i].box.x + 22, drawY + checkOff, textMaxW,
424
- __ui_nodes[i].fg, __ui_nodes[i].hasBg ? fillBg : ui_parent_clear_color(i),
425
- ts, __ui_nodes[i].fontAntialias, __ui_nodes[i].fontFace, __ui_nodes[i].letterSpacing,
426
- __ui_nodes[i].lineHeight, __ui_nodes[i].whiteSpaceMode, 0, __ui_nodes[i].underline, __ui_nodes[i].textOverflow);
427
- }
428
- break;
429
- case NODE_RADIO:
430
- {
431
- uint16_t clearW = __ui_nodes[i].box.w;
432
- if (__ui_nodes[i].lastTextWidth > 0 && __ui_nodes[i].lastTextWidth > static_cast<int16_t>(clearW)) {
433
- clearW = static_cast<uint16_t>(__ui_nodes[i].lastTextWidth);
434
- }
435
- if (paintTextW > clearW) clearW = paintTextW;
436
- uint16_t clearH = __ui_nodes[i].box.h;
437
- if (__ui_nodes[i].lastTextHeight > 0 && __ui_nodes[i].lastTextHeight > static_cast<int16_t>(clearH)) {
438
- clearH = static_cast<uint16_t>(__ui_nodes[i].lastTextHeight);
439
- }
440
- if (paintTextH > clearH) clearH = paintTextH;
441
- ui_display_fill_rect(__ui_nodes[i].box.x, drawY, clearW, clearH,
442
- __ui_nodes[i].hasBg ? fillBg : ui_parent_clear_color(i));
443
- __ui_nodes[i].lastTextWidth = paintTextW;
444
- __ui_nodes[i].lastTextHeight = paintTextH;
445
- int16_t cbX = __ui_nodes[i].box.x;
446
- // Vertically center the 16px indicator within the box (see NODE_CHECK).
447
- int16_t cbY = drawY + (static_cast<int16_t>(__ui_nodes[i].box.h) - 16) / 2;
448
- if (cbY < drawY) cbY = drawY;
449
- #ifdef UI_AA
450
- {
451
- // Render the radio circle to a 16×16 AA canvas, then push.
452
- UI_COLOR_T radioBg = __ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor;
453
- // The selected ring carries the :checked background (primary).
454
- UI_COLOR_T ringCol = (__ui_nodes[i].value && __ui_nodes[i].hasCheckedBg)
455
- ? __ui_nodes[i].checkedBg : __ui_nodes[i].fg;
456
- CuttlefishCanvas16* c = ui_aa_begin(16, 16, radioBg);
457
- if (c) {
458
- if (__ui_nodes[i].value) {
459
- ui_aa_fill_circle(c, 8, 8, 7.0f, ringCol);
460
- ui_aa_fill_circle(c, 8, 8, 3.0f, radioBg);
461
- } else {
462
- ui_aa_circle(c, 8, 8, 7.0f, __ui_nodes[i].fg);
463
- }
464
- ui_aa_push(c, cbX, cbY);
465
- } else if (__ui_nodes[i].value) {
466
- ui_display_fill_circle(cbX + 8, cbY + 8, 7, ringCol);
467
- ui_display_fill_circle(cbX + 8, cbY + 8, 3, radioBg);
468
- } else {
469
- ui_display_draw_circle(cbX + 8, cbY + 8, 7, __ui_nodes[i].fg);
470
- }
471
- }
472
- #else
473
- if (__ui_nodes[i].value) {
474
- ui_display_fill_circle(cbX + 8, cbY + 8, 7,
475
- __ui_nodes[i].hasCheckedBg ? __ui_nodes[i].checkedBg : __ui_nodes[i].fg);
476
- ui_display_fill_circle(cbX + 8, cbY + 8, 3, __ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor);
477
- } else {
478
- ui_display_draw_circle(cbX + 8, cbY + 8, 7, __ui_nodes[i].fg);
479
- }
480
- #endif
481
- }
482
- // Center the label on the same baseline as the indicator (see NODE_CHECK).
483
- {
484
- int16_t radioOff = (static_cast<int16_t>(__ui_nodes[i].box.h) - 16) / 2;
485
- if (radioOff < 0) radioOff = 0;
486
- ui_draw_wrapped_text(displayText, __ui_nodes[i].box.x + 22, drawY + radioOff, textMaxW,
487
- __ui_nodes[i].fg, __ui_nodes[i].hasBg ? fillBg : ui_parent_clear_color(i),
488
- ts, __ui_nodes[i].fontAntialias, __ui_nodes[i].fontFace, __ui_nodes[i].letterSpacing,
489
- __ui_nodes[i].lineHeight, __ui_nodes[i].whiteSpaceMode, 0, __ui_nodes[i].underline, __ui_nodes[i].textOverflow);
490
- }
491
- break;
492
- case NODE_PROGRESS:
493
- // Progress bar: outline track + filled portion based on .value (0-100).
494
- // Incremental redraw — only draws/clears the delta to avoid flashing.
495
- {
496
- int16_t bx = __ui_nodes[i].box.x;
497
- int16_t by = drawY;
498
- int16_t bw = __ui_nodes[i].box.w;
499
- int16_t bh = __ui_nodes[i].box.h;
500
- UI_COLOR_T bgCol = __ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor;
501
- UI_COLOR_T fgCol = __ui_nodes[i].fg;
502
-
503
- // On first draw (lastTextWidth < 0), draw everything.
504
- // Otherwise incremental: only update the changed portion.
505
- uint8_t pct = constrain(__ui_nodes[i].value, 0, 100);
506
- int16_t fillW = (static_cast<int32_t>(bw - 2) * pct) / 100;
507
- int16_t prevW = __ui_nodes[i].lastTextWidth; // reused as previous fill width
508
-
509
- if (prevW < 0) {
510
- // Full redraw: outline + background + fill
511
- ui_display_draw_rect(bx, by, bw, bh, fgCol);
512
- ui_display_fill_rect(bx + 1, by + 1, bw - 2, bh - 2, bgCol);
513
- if (fillW > 0) {
514
- ui_display_fill_rect(bx + 1, by + 1, fillW, bh - 2, fgCol);
515
- }
516
- } else if (fillW > prevW) {
517
- // Value increased: draw new fill segment on top (no clear needed)
518
- ui_display_fill_rect(bx + 1 + prevW, by + 1, fillW - prevW, bh - 2, fgCol);
519
- } else if (fillW < prevW) {
520
- // Value decreased: clear the removed portion
521
- ui_display_fill_rect(bx + 1 + fillW, by + 1, prevW - fillW, bh - 2, bgCol);
522
- }
523
- // Remember current fill width for next incremental update
524
- __ui_nodes[i].lastTextWidth = fillW;
525
- }
526
- break;
527
- case NODE_RANGE:
528
- // Range slider: horizontal track + draggable thumb.
529
- // Incremental redraw (like NODE_PROGRESS): lastTextWidth holds the
530
- // previous fill width. We erase the delta region between old and new
531
- // thumb positions with the background, then redraw the track portion
532
- // and the new thumb — so dragging backward doesn't leave ghost thumbs.
533
- {
534
- int16_t bx = __ui_nodes[i].box.x;
535
- int16_t by = drawY;
536
- int16_t bw = __ui_nodes[i].box.w;
537
- int16_t bh = __ui_nodes[i].box.h;
538
- UI_COLOR_T fgCol = __ui_nodes[i].fg;
539
- UI_COLOR_T bgCol = __ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor;
540
- UI_COLOR_T dimFg = (UI_COLOR_T)((fgCol >> 1) & UI_DIM_MASK);
541
-
542
- int16_t trackY = by + bh / 2;
543
- int16_t rMin = __ui_nodes[i].rangeMin;
544
- int16_t rMax = __ui_nodes[i].rangeMax;
545
- int16_t range = rMax - rMin;
546
- if (range <= 0) range = 100;
547
- int16_t pct = constrain(__ui_nodes[i].value, rMin, rMax) - rMin;
548
- int16_t fillW = (static_cast<int32_t>(bw - 8) * pct) / range;
549
- // lastTextWidth carries the previous fill width, or -1 if this node
550
- // has never been drawn (fillW=0 at value=min is a valid thumb pos).
551
- int16_t prevFillW = __ui_nodes[i].lastTextWidth;
552
- int16_t newThumbX = bx + 4 + fillW - 3;
553
-
554
- if (prevFillW < 0) {
555
- // First draw: redraw the whole track + fill from scratch.
556
- ui_display_draw_fast_hline(bx, trackY, bw, dimFg);
557
- ui_display_draw_fast_hline(bx + 4, trackY, fillW, fgCol);
558
- } else {
559
- // Incremental: wipe the strip between the old and new thumb
560
- // positions (whichever extends further on each side), then restore
561
- // the track line. This is symmetric — old thumbs disappear whether
562
- // the drag moves forward or backward.
563
- int16_t prevThumbX = bx + 4 + prevFillW - 3;
564
- int16_t left = prevThumbX < newThumbX ? prevThumbX : newThumbX;
565
- int16_t right = prevThumbX + 6 > newThumbX + 6 ? prevThumbX + 6 : newThumbX + 6;
566
- if (left < bx) left = bx;
567
- if (right > bx + bw) right = bx + bw;
568
- // Erase the thumb band (10px tall) to background.
569
- ui_display_fill_rect(left, trackY - 5, right - left, 10, bgCol);
570
- // Restore the track line over the wiped strip: bright up to the
571
- // current fill end, dim beyond it.
572
- int16_t fillEnd = bx + 4 + fillW;
573
- if (right <= fillEnd) {
574
- ui_display_draw_fast_hline(left, trackY, right - left, fgCol);
575
- } else if (left >= fillEnd) {
576
- ui_display_draw_fast_hline(left, trackY, right - left, dimFg);
577
- } else {
578
- ui_display_draw_fast_hline(left, trackY, fillEnd - left, fgCol);
579
- ui_display_draw_fast_hline(fillEnd, trackY, right - fillEnd, dimFg);
580
- }
581
- }
582
-
583
- // Thumb: small filled rectangle at the current position.
584
- if (newThumbX < bx + 1) newThumbX = bx + 1;
585
- if (newThumbX > bx + bw - 7) newThumbX = bx + bw - 7;
586
- ui_display_fill_rect(newThumbX, trackY - 5, 6, 10, fgCol);
587
-
588
- // Remember current fill width for the next incremental update.
589
- __ui_nodes[i].lastTextWidth = fillW;
590
- }
591
- break;
592
- case NODE_INPUT:
593
- // Input field: bordered rect + current text (or placeholder), clipped to box width.
594
- {
595
- int16_t bx = __ui_nodes[i].box.x;
596
- int16_t by = drawY;
597
- int16_t bw = __ui_nodes[i].box.w;
598
- int16_t bh = __ui_nodes[i].box.h;
599
- UI_COLOR_T fgCol = __ui_nodes[i].fg;
600
- UI_COLOR_T bgCol = __ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor;
601
- // HTML disabled: halve the drawn colors (web-like faded control).
602
- if (__ui_nodes[i].disabled) {
603
- fgCol = (fgCol >> 1) & UI_DIM_MASK;
604
- bgCol = (bgCol >> 1) & UI_DIM_MASK;
605
- }
606
- if (__ui_nodes[i].borderRadius > 0) {
607
- ui_display_fill_round_rect(bx, by, bw, bh, __ui_nodes[i].borderRadius, bgCol);
608
- } else {
609
- ui_display_fill_rect(bx, by, bw, bh, bgCol);
610
- }
611
- UI_COLOR_T inputBorder = __ui_nodes[i].borderColor ? __ui_nodes[i].borderColor : fgCol;
612
- uint8_t inputBorderStyle = __ui_nodes[i].borderStyle ? __ui_nodes[i].borderStyle : 1;
613
- uint8_t inputBorderWidth = __ui_nodes[i].borderWidth ? __ui_nodes[i].borderWidth : 1;
614
- ui_draw_rect_outline(bx, by, bw, bh, __ui_nodes[i].borderRadius, inputBorderStyle, inputBorderWidth, inputBorder);
615
- // Show typed text (textBuffer) in fg color, or placeholder (.text)
616
- // dimmed gray when the buffer is empty.
617
- UI_COLOR_T textCol = fgCol;
618
- const char* disp = (__ui_nodes[i].textBuffer[0] != 0)
619
- ? __ui_nodes[i].textBuffer
620
- : (__ui_nodes[i].text ? __ui_nodes[i].text : "");
621
- #if defined(UI_HIDE_OSK)
622
- // Desktop target: when this input is the active edit target, suppress
623
- // the placeholder. The editing buffer is loaded from the (likely empty)
624
- // textBuffer on focus, so without this the placeholder ("enter name")
625
- // would render with the caret at its end. Hide it so the caret shows
626
- // on a clean field at position 0 until the user types.
627
- if (__ui_kb_visible && static_cast<int16_t>(__ui_kb_target) == static_cast<int16_t>(i) && __ui_nodes[i].textBuffer[0] == 0) {
628
- disp = "";
629
- }
630
- #endif
631
- if (__ui_nodes[i].textBuffer[0] == 0) {
632
- #if UI_COLOR_DEPTH == 888
633
- textCol = 0x848484;
634
- #else
635
- textCol = 0x8410;
636
- #endif
637
- }
638
- // Note: the actual text draw is via ui_draw_text (which uses __ui_gfx).
639
- // The setCursor/setTextColor/setTextSize below are legacy — ui_draw_text
640
- // handles its own cursor/colors. Keep them on __ui_gfx for consistency
641
- // (in case __ui_gfx is the scroll canvas, not __tc_display).
642
- ui_display_set_cursor(bx + 4, by + (bh - ts * 8) / 2);
643
- ui_display_set_text_color(textCol, bgCol);
644
- ui_display_set_text_size(ts);
645
- // Clip: at textSize ts, each char is ts*6px advance. Only print chars
646
- // that fit within the box (bw - 8px margin), so text never overflows
647
- // the border or wraps to the next line.
648
- int16_t maxChars = (bw - 8) / (ts * 6);
649
- if (maxChars < 0) maxChars = 0;
650
- int16_t len = static_cast<int16_t>(strlen(disp));
651
- if (len > maxChars) len = maxChars;
652
- if (len > UI_TEXT_BUF) len = UI_TEXT_BUF;
653
- char clipped[UI_TEXT_BUF + 1];
654
- for (int16_t c = 0; c < len; c++) {
655
- clipped[c] = disp[c];
656
- }
657
- clipped[len] = 0;
658
- ui_draw_text(clipped, bx + 4, by + (bh - ui_text_height(ts, __ui_nodes[i].fontFace)) / 2,
659
- textCol, bgCol, ts, __ui_nodes[i].fontAntialias, __ui_nodes[i].fontFace, __ui_nodes[i].letterSpacing);
660
- #if defined(UI_HIDE_OSK)
661
- // Desktop target: with no OSK grid there's no focus indicator. Draw a
662
- // blinking caret at the end of the typed text on the active edit target
663
- // so the user sees which field they're editing. Blink ~3×/sec via the
664
- // top bits of __ui_kb_blink (mask 0x20 toggles every 32 ticks ≈ 530ms).
665
- if (__ui_kb_visible && static_cast<int16_t>(__ui_kb_target) == static_cast<int16_t>(i) && (__ui_kb_blink & 0x20)) {
666
- uint16_t caretW = ui_text_width(clipped, ts, __ui_nodes[i].fontFace, __ui_nodes[i].letterSpacing);
667
- int16_t caretX = bx + 4 + static_cast<int16_t>(caretW);
668
- int16_t caretYTop = by + (bh - ts * 8) / 2;
669
- // fgCol (not textCol): the placeholder-dimming path sets textCol to
670
- // gray, which would make the caret nearly invisible on a focused
671
- // empty field. The caret should always be the input's foreground.
672
- ui_display_fill_rect(caretX, caretYTop, static_cast<int16_t>(ts > 1 ? 2 : 1), static_cast<int16_t>(ts * 8), fgCol);
673
- }
674
- #endif
675
- }
676
- break;
677
- case NODE_IMG:
678
- {
679
- UI_COLOR_T imgBg = __ui_nodes[i].hasBg ? fillBg : __ui_nodes[i].clearColor;
680
- int16_t fillW = ui_rotated_face_w(i, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
681
- int16_t fillH = ui_rotated_face_h(i, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
682
- ui_display_fill_rect(__ui_nodes[i].box.x, drawY, fillW, fillH, imgBg);
683
- }
684
- if (__ui_nodes[i].imgDataId < __ui_image_count) {
685
- const UIImage* img = &__ui_images[__ui_nodes[i].imgDataId];
686
- int16_t targetW = __ui_nodes[i].box.w;
687
- int16_t targetH = __ui_nodes[i].box.h;
688
- ui_draw_image_with_fit(img, __ui_nodes[i].box.x, drawY, __ui_nodes[i].rotateDeg, __ui_nodes[i].objectFit, targetW, targetH);
689
- }
690
- if (__ui_nodes[i].borderStyle != 0) {
691
- int16_t borderW = ui_rotated_face_w(i, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
692
- int16_t borderH = ui_rotated_face_h(i, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
693
- ui_draw_rect_outline(__ui_nodes[i].box.x, drawY, borderW, borderH,
694
- __ui_nodes[i].borderRadius, __ui_nodes[i].borderStyle, __ui_nodes[i].borderWidth, bColor);
695
- }
696
- break;
697
- case NODE_CANVAS: {
698
- // Find this node's draw callback.
699
- void (*__ui_canvas_fn)(CuttlefishCanvas16*) = nullptr;
700
- for (uint16_t b = 0; b < __ui_canvas_binding_count; b++) {
701
- if (__ui_canvas_bindings[b].node == i) { __ui_canvas_fn = __ui_canvas_bindings[b].fn; break; }
702
- }
703
- if (__ui_canvas_fn) {
704
- int16_t __ui_cw = __ui_nodes[i].canvasW;
705
- int16_t __ui_ch = __ui_nodes[i].canvasH;
706
- if (__ui_cw > 0 && __ui_ch > 0) {
707
- uint8_t __ui_canvas_drawn = 0;
708
- if (ui_display_is_default_target()) {
709
- // Cache a canvas sized to the buffer for direct hardware draws so
710
- // the callback's many primitives push as one bitmap. When the
711
- // active target is already a memory canvas (scroll/repair/full
712
- // framebuffer), skip the nested allocation and draw directly below.
713
- // A canvas can construct but fail its internal pixel-buffer malloc
714
- // (non-null canvas, null buffer). Treat that as "no canvas" so a
715
- // transient malloc failure self-heals instead of blanking the node.
716
- if (!__ui_node_canvas || !display_canvasBuffer(__ui_node_canvas) ||
717
- display_canvasWidth(__ui_node_canvas) != __ui_cw || display_canvasHeight(__ui_node_canvas) != __ui_ch) {
718
- display_deleteCanvas(__ui_node_canvas);
719
- __ui_node_canvas = display_createCanvas(__ui_cw, __ui_ch);
720
- }
721
- CuttlefishCanvas16* __ui_lc = __ui_node_canvas;
722
- if (__ui_lc && display_canvasBuffer(__ui_lc)) {
723
- display_canvasFillScreen(__ui_lc, __ui_nodes[i].clearColor);
724
- CuttlefishDisplayTarget* __ui_prev_target = ui_display_get_target();
725
- ui_display_set_target((CuttlefishDisplayTarget*)__ui_lc);
726
- __ui_canvas_fn(__ui_lc);
727
- ui_display_set_target(__ui_prev_target);
728
- ui_draw_canvas_rect(__ui_lc, __ui_nodes[i].box.x, drawY, __ui_cw, __ui_ch);
729
- __ui_canvas_drawn = 1;
730
- }
731
- }
732
- if (!__ui_canvas_drawn) {
733
- UI_COLOR_T __ui_canvas_bg = __ui_nodes[i].hasBg ? fillBg : __ui_nodes[i].clearColor;
734
- ui_display_fill_rect(__ui_nodes[i].box.x, drawY, __ui_nodes[i].box.w, __ui_nodes[i].box.h, __ui_canvas_bg);
735
- int16_t __ui_prev_off_x = __ui_draw_off_x;
736
- int16_t __ui_prev_off_y = __ui_draw_off_y;
737
- int16_t __ui_prev_canvas_w = __ui_canvas_fallback_w;
738
- int16_t __ui_prev_canvas_h = __ui_canvas_fallback_h;
739
- __ui_canvas_fallback_w = __ui_cw;
740
- __ui_canvas_fallback_h = __ui_ch;
741
- __ui_draw_off_x = static_cast<int16_t>(__ui_prev_off_x + __ui_nodes[i].box.x);
742
- __ui_draw_off_y = static_cast<int16_t>(__ui_prev_off_y + drawY);
743
- __ui_canvas_fn(nullptr);
744
- __ui_draw_off_x = __ui_prev_off_x;
745
- __ui_draw_off_y = __ui_prev_off_y;
746
- __ui_canvas_fallback_w = __ui_prev_canvas_w;
747
- __ui_canvas_fallback_h = __ui_prev_canvas_h;
748
- }
749
- }
750
- }
751
- break;
752
- }
753
- case NODE_LIST: {
754
- // Virtualized list: state lives on the node now (listCountFn/listItemFn/
755
- // listCount/scrollY/contentHeight/listItemHeight), not in a side table.
756
- // Resolve lazily as a safety net for targets whose startup path runs
757
- // before the generated binding table has been copied onto the node.
758
- if (!__ui_nodes[i].listItemFn) {
759
- for (uint16_t b = 0; b < __ui_list_binding_count; b++) {
760
- if (__ui_list_bindings[b].node == static_cast<uint16_t>(i)) {
761
- __ui_nodes[i].listCountFn = __ui_list_bindings[b].countFn;
762
- __ui_nodes[i].listItemFn = __ui_list_bindings[b].itemFn;
763
- __ui_nodes[i].listTapFn = __ui_list_bindings[b].tapFn;
764
- break;
765
- }
766
- }
767
- }
768
- if (!__ui_nodes[i].listItemFn) break;
769
- int16_t bx = __ui_nodes[i].box.x;
770
- int16_t by = drawY;
771
- int16_t bw = __ui_nodes[i].box.w;
772
- int16_t bh = __ui_nodes[i].box.h;
773
- uint16_t ih = __ui_nodes[i].listItemHeight > 0 ? __ui_nodes[i].listItemHeight : 24;
774
- uint16_t itemCount = __ui_nodes[i].listCount;
775
- int16_t listScrollY = __ui_nodes[i].scrollY;
776
- int16_t listContentH = __ui_nodes[i].contentHeight;
777
- UI_COLOR_T clearCol = __ui_nodes[i].clearColor;
778
- uint8_t listFullRepaint = 0;
779
- // Render to a viewport-sized canvas so edge glyphs are naturally clipped.
780
- // Treat a null buffer (failed internal malloc) as "no canvas" and retry —
781
- // see __ui_node_canvas for the zombie-caching rationale.
782
- // __ui_list_canvas is file-scoped so ui_release_canvas_state() can free it.
783
- if (!__ui_list_canvas || !display_canvasBuffer(__ui_list_canvas) ||
784
- display_canvasWidth(__ui_list_canvas) != bw || display_canvasHeight(__ui_list_canvas) != bh) {
785
- display_deleteCanvas(__ui_list_canvas);
786
- __ui_list_canvas_node = -1;
787
- __ui_list_canvas = ui_create_canvas_best(bw, bh);
788
- listFullRepaint = 1;
789
- }
790
- CuttlefishCanvas16* lc = __ui_list_canvas;
791
- if (!lc || !display_canvasBuffer(lc)) {
792
- // Viewport canvas won't allocate (no PSRAM / over SRAM budget on a
793
- // full-width list). Fall back to the band renderer: composite the
794
- // visible rows into the ~10KB band canvas strip-by-strip, pushing
795
- // each band tear-free. Mirrors the generic scroll path's Mode B →
796
- // ui_render_scroll_bands fallback. If even the band canvas fails,
797
- // give up (render nothing) — same as the old break.
798
- if (ui_render_list_bands(static_cast<uint16_t>(i))) {
799
- return 1; // band path handled push + decoration + dirty-clear
800
- }
801
- // Last-resort visible fallback: if even the small band canvas cannot
802
- // allocate, draw the fully visible rows in one target transaction
803
- // rather than leaving a black/empty list. This path is only reached
804
- // under extreme memory pressure; normal renders remain atomic canvas
805
- // pushes.
806
- if (ui_render_list_direct(static_cast<uint16_t>(i))) {
807
- __ui_nodes[i].box.x = origBoxX;
808
- __ui_nodes[i].box.y = origBoxY;
809
- return 1;
810
- }
811
- break;
812
- }
813
- if (__ui_list_canvas_node != static_cast<int16_t>(i)) listFullRepaint = 1;
814
- int16_t repaintY = 0;
815
- int16_t repaintH = bh;
816
- int16_t deltaY = listScrollY - __ui_nodes[i].lastPaintedScrollY;
817
- int16_t absDelta = deltaY < 0 ? -deltaY : deltaY;
818
- uint8_t canShiftList = (!listFullRepaint && deltaY != 0 && absDelta < bh);
819
- if (canShiftList) {
820
- ui_shift_container_canvas(lc, deltaY, clearCol, &repaintY, &repaintH);
821
- } else {
822
- display_canvasFillScreen(lc, clearCol);
823
- repaintY = 0;
824
- repaintH = bh;
825
- }
826
- // GFXcanvas text has no clipping. For shift-and-repair frames, draw row
827
- // text into a strip-sized repair canvas first, then blit only that strip
828
- // into the shifted list canvas. This prevents a 1-5px repair from
829
- // repainting full glyphs across pixels that were already shifted.
830
- CuttlefishCanvas16* listTextCanvas = lc;
831
- int16_t listTextOffsetY = 0;
832
- uint8_t drawingListRepair = 0;
833
- if (canShiftList && repaintH > 0 && repaintH < bh) {
834
- CuttlefishCanvas16* rc = ui_get_repair_canvas(bw, repaintH);
835
- if (rc) {
836
- display_canvasFillScreen(rc, clearCol);
837
- listTextCanvas = rc;
838
- listTextOffsetY = repaintY;
839
- drawingListRepair = 1;
840
- } else {
841
- display_canvasFillScreen(lc, clearCol);
842
- repaintY = 0;
843
- repaintH = bh;
844
- canShiftList = 0;
845
- }
846
- }
847
- // Compute visible range for the repainted strip. Previously-rendered
848
- // pixels are shifted in-place; only the exposed band needs new rows.
849
- uint16_t first = (listScrollY + repaintY) / ih;
850
- uint16_t last = (listScrollY + repaintY + repaintH - 1) / ih + 1;
851
- if (itemCount > 0 && last >= itemCount) last = itemCount - 1;
852
- // Draw each visible item (canvas-local coords: 0,0 = viewport top).
853
- char listBuf[UI_TEXT_BUF + 1];
854
- CuttlefishDisplayTarget* listPrevTarget = ui_display_get_target();
855
- if (itemCount > 0 && repaintH > 0) {
856
- for (uint16_t idx = first; idx <= last; idx++) {
857
- int16_t itemY = static_cast<int16_t>(idx * ih) - listScrollY - listTextOffsetY;
858
- __ui_nodes[i].listItemFn(idx, listBuf, UI_TEXT_BUF + 1);
859
- listBuf[UI_TEXT_BUF] = 0;
860
- ui_display_set_target((CuttlefishDisplayTarget*)listTextCanvas);
861
- int16_t __ui_saved_off_x2 = __ui_draw_off_x;
862
- int16_t __ui_saved_off_y2 = __ui_draw_off_y;
863
- __ui_draw_off_x = 0;
864
- __ui_draw_off_y = 0;
865
- // Keep classic list glyphs in the canvas pixel buffer. Native
866
- // CuttlefishGFX text can disappear when copied from a canvas to
867
- // the panel, while the explicit glyph path remains compositable.
868
- ui_draw_list_text(listBuf, 4, itemY + static_cast<int16_t>(ih - 16) / 2,
869
- __ui_nodes[i].fg, clearCol, 2, __ui_nodes[i].fontFace, __ui_nodes[i].letterSpacing);
870
- __ui_draw_off_x = __ui_saved_off_x2;
871
- __ui_draw_off_y = __ui_saved_off_y2;
872
- ui_display_set_target(listPrevTarget);
873
- }
874
- }
875
- if (drawingListRepair) {
876
- CuttlefishDisplayTarget* prevTarget = ui_display_get_target();
877
- ui_display_set_target((CuttlefishDisplayTarget*)lc);
878
- ui_draw_canvas_rect(listTextCanvas, 0, repaintY, bw, repaintH);
879
- ui_display_set_target(prevTarget);
880
- }
881
- // Scrollbar (canvas-local coords).
882
- if (listContentH > bh) {
883
- int16_t tx = bw - 4;
884
- uint16_t thumbH = static_cast<uint32_t>(bh) * bh / listContentH;
885
- if (thumbH < 8) thumbH = 8;
886
- int16_t maxScroll = listContentH - bh;
887
- uint16_t thumbY = maxScroll > 0 ? static_cast<uint32_t>(bh - thumbH) * listScrollY / maxScroll : 0;
888
- UI_COLOR_T dimFg = (UI_COLOR_T)((__ui_nodes[i].fg >> 1) & UI_DIM_MASK);
889
- display_canvasFillRect(lc, tx, 0, 3, bh, dimFg);
890
- display_canvasFillRect(lc, tx, thumbY, 3, thumbH, __ui_nodes[i].fg);
891
- }
892
- // Outset shadows are static decoration. Redrawing the hard shadow
893
- // directly to the panel before every small scroll-frame creates a
894
- // visible shadow-then-content intermediate state on SPI TFTs. Keep it
895
- // for full list repaints, but skip it for shift-and-repair scrolls.
896
- if (!drawingBufferedScroll && !__ui_fb && !canShiftList) {
897
- ui_draw_shadow(i, by, 0);
898
- }
899
- // Standalone lists push directly. Lists inside a buffered scroll
900
- // container must composite into that scroll canvas; their box has
901
- // already been translated to canvas-local coordinates.
902
- if (drawingBufferedScroll) {
903
- ui_draw_canvas_rect(lc, bx, by, bw, bh);
904
- } else {
905
- ui_push_canvas_rect(lc, bx, by, bw, bh);
906
- }
907
- // Draw static decoration after the scrollable pixels are composited.
908
- // Keeping border rows out of __ui_list_canvas prevents the cached
909
- // shift step from dragging top/bottom border pixels through the list.
910
- ui_draw_shadow(i, by, 1);
911
- if (__ui_nodes[i].borderStyle != 0) {
912
- ui_draw_node_border(i, bx, by, bColor);
913
- }
914
- ui_draw_node_outline(i, bx, by);
915
- __ui_list_canvas_node = static_cast<int16_t>(i);
916
- __ui_nodes[i].lastPaintedScrollY = listScrollY;
917
- __ui_nodes[i].dirty = 0;
918
- if (__ui_fb) {
919
- UIRect listPaintRect;
920
- ui_node_paint_rect(i, origBoxX, origBoxY, origBoxX, origBoxY,
921
- static_cast<uint16_t>(bw), static_cast<uint16_t>(bh), &listPaintRect);
922
- ui_fb_add_rect(listPaintRect.x, listPaintRect.y, listPaintRect.w, listPaintRect.h);
923
- }
924
- ui_display_set_target(__ui_draw_target);
925
- __ui_nodes[i].box.x = origBoxX;
926
- __ui_nodes[i].box.y = origBoxY;
927
- return 1; // list handled canvas push, decoration, and coordinate restore
928
- }
929
- }
930
- return 0;
931
- }
932
-
933
- // ── Band renderer: tear-free scroll compositing without a viewport canvas ──
934
- // When a scroll container's viewport canvas won't allocate (no PSRAM, tight
935
- // SRAM, or a viewport larger than the budget), the previous fallback
936
- // (direct-full) painted each child straight to the display — one SPI
937
- // transaction per primitive, visible as tearing during fast drags. The band
938
- // renderer instead composes the whole visible subtree into a short horizontal
939
- // band canvas (vw × UI_STRIP_BAND_HEIGHT, e.g. 320×16 ≈ 10KB at RGB565),
940
- // pushes that band in one SPI transaction, then moves to the next band. Each
941
- // band completes before it is pushed, so the panel never shows a half-drawn
942
- // frame — eliminating tearing while keeping memory bounded regardless of
943
- // program size (the canvas height is fixed; only width tracks the viewport).
944
- //
945
- // Returns 1 when the subtree was rendered and pushed (the caller must skip the
946
- // normal direct-full child repaint). Returns 0 when the band canvas could not
947
- // be allocated (caller falls back to direct-full).
948
- // Shared band-canvas alloc helper. The three band renderers reuse the
949
- // persistent __ui_band_canvas, reallocating only when the width changes so
950
- // display_canvasWidth == w == stride (ui_push_canvas_rect's single-write fast
951
- // path requires w == stride — a sub-width push takes a row-by-row path some
952
- // ST7796S drivers mishandle). Returns the canvas (with a valid buffer) or null.
953
- static inline CuttlefishCanvas16* ui_band_canvas_for_width(int16_t w) {
954
- if (!__ui_band_canvas || !display_canvasBuffer(__ui_band_canvas) ||
955
- display_canvasWidth(__ui_band_canvas) != w) {
956
- display_deleteCanvas(__ui_band_canvas);
957
- __ui_band_canvas = ui_create_canvas_best(w, UI_STRIP_BAND_HEIGHT);
958
- }
959
- CuttlefishCanvas16* band = __ui_band_canvas;
960
- return (band && display_canvasBuffer(band)) ? band : nullptr;
961
- }
962
- static inline uint8_t ui_render_scroll_bands(uint16_t s) {
963
- if (s >= __ui_node_count) return 0;
964
- int16_t vw = __ui_nodes[s].box.w;
965
- int16_t vh = __ui_nodes[s].box.h;
966
- if (vw <= 0 || vh <= 0) return 0;
967
- int16_t vox = __ui_nodes[s].box.x;
968
- int16_t voy = __ui_nodes[s].box.y;
969
- UI_COLOR_T scrollBg = __ui_nodes[s].hasBg ? __ui_nodes[s].bg : __ui_nodes[s].clearColor;
970
- // One persistent band canvas, reused across bands and frames, reallocated
971
- // only when the viewport width changes (so stride == vw for the single-write
972
- // push fast path). See ui_band_canvas_for_width.
973
- CuttlefishCanvas16* band = ui_band_canvas_for_width(vw);
974
- if (!band) return 0;
975
- int16_t bandH = display_canvasHeight(band);
976
- // Build a compact list of descendants that can intersect the viewport once per
977
- // scroll repaint. The old nested band×subtree walk repeated visibility, screen,
978
- // and Y culling for every band; the candidate list keeps that work O(subtree)
979
- // and each band only visits rows that can actually contribute pixels.
980
- uint16_t subtreeEnd = __ui_nodes[s].subtreeEnd;
981
- if (subtreeEnd > __ui_node_count) subtreeEnd = __ui_node_count;
982
- // The scrollbar occupies the rightmost 4px gutter (tx = vw - 4, 3px wide).
983
- // Rather than draw it separately at frame end (which made each band push erase
984
- // the previous frame's scrollbar slice-by-slice, flashing it during scroll),
985
- // composite this band's scrollbar slice into the gutter of the band canvas
986
- // itself. Each band then pushes the FULL viewport width in one SPI transaction
987
- // (the single-write fast path that works on every driver — a sub-width push
988
- // forces a row-by-row path that some ST7796S drivers mishandle, showing only
989
- // the first row). The scrollbar is thus part of each band's atomic push: no
990
- // separate erase/redraw cycle, no flash, and the fast path is preserved.
991
- int16_t contentW = vw > 4 ? static_cast<int16_t>(vw - 4) : vw;
992
- // Scrollbar geometry (document/viewport coords): track spans [0, vh); thumb
993
- // spans [thumbY, thumbY + thumbH). Both are 3px wide at canvas-x contentW.
994
- uint16_t sbThumbH = 0;
995
- uint16_t sbThumbY = 0;
996
- uint8_t sbVisible = 0;
997
- UI_COLOR_T sbTrackCol = (UI_COLOR_T)((__ui_nodes[s].fg >> 1) & UI_DIM_MASK);
998
- UI_COLOR_T sbThumbCol = __ui_nodes[s].fg;
999
- if (__ui_nodes[s].contentHeight > vh) {
1000
- sbVisible = 1;
1001
- sbThumbH = static_cast<uint32_t>(vh) * vh / __ui_nodes[s].contentHeight;
1002
- if (sbThumbH < 8) sbThumbH = 8;
1003
- // Clamp to vh so the (vh - sbThumbH) subtraction below can't go negative on
1004
- // a degenerate sub-8px viewport (which would cast to a huge uint32_t and
1005
- // place the thumb off-screen). ui_draw_scrollbar_direct has the same min-8
1006
- // clamp; this extra guard keeps the band path's slice math safe.
1007
- if (sbThumbH > static_cast<uint16_t>(vh)) sbThumbH = static_cast<uint16_t>(vh);
1008
- int16_t maxScroll = __ui_nodes[s].contentHeight - vh;
1009
- sbThumbY = maxScroll > 0 ? static_cast<uint32_t>(vh - sbThumbH) * __ui_nodes[s].scrollY / maxScroll : 0;
1010
- }
1011
- CuttlefishDisplayTarget* prevTarget = ui_display_get_target();
1012
- if (!__ui_scroll_candidates) return 0;
1013
- __ui_scroll_candidate_count = 0;
1014
- for (uint16_t c = s + 1; c < subtreeEnd && __ui_scroll_candidate_count < __ui_node_count; c++) {
1015
- if (!ui_is_effectively_visible(c) || __ui_nodes[c].screenId != __ui_active_screen) continue;
1016
- UIScrollPaintCandidate& candidate = __ui_scroll_candidates[__ui_scroll_candidate_count++];
1017
- candidate.node = c;
1018
- candidate.screenY = ui_draw_y_for_node(c);
1019
- candidate.faceH = __ui_nodes[c].box.h;
1020
- }
1021
-
1022
- // Top→bottom bands over the viewport.
1023
- for (int16_t bandTop = 0; bandTop < vh; bandTop += bandH) {
1024
- int16_t bandBot = bandTop + bandH;
1025
- if (bandBot > vh) bandBot = vh;
1026
- int16_t thisH = static_cast<int16_t>(bandBot - bandTop);
1027
- display_canvasFillRect(band, 0, 0, contentW, bandH, scrollBg);
1028
- ui_display_set_target(band);
1029
- for (uint16_t candidateIdx = 0; candidateIdx < __ui_scroll_candidate_count; candidateIdx++) {
1030
- uint16_t c = __ui_scroll_candidates[candidateIdx].node;
1031
- int16_t screenY = __ui_scroll_candidates[candidateIdx].screenY;
1032
- int16_t faceH = __ui_scroll_candidates[candidateIdx].faceH;
1033
- // Cull nodes whose face does not intersect this band's Y range before
1034
- // doing any expensive per-node work (AGENTS.md: clip before the inner
1035
- // loop). A node fully above or below the band contributes no pixels.
1036
- if (screenY + faceH <= voy + bandTop) continue;
1037
- if (screenY >= voy + bandBot) continue;
1038
- // Translate the node's box into band-local coordinates, mirroring the main
1039
- // loop's drawingBufferedScroll setup: subtract the viewport origin and the
1040
- // band's top row. ui_draw_y_for_node then re-derives the band-local Y
1041
- // (document Y - scrollY - voy - bandTop) = (screenY - voy - bandTop). The
1042
- // box.x translate below is read by ui_draw_x_for_node (which adds
1043
- // transformOffsetX + pressedOffsetX), so it must be set before computing
1044
- // drawX; the body needs box.x = drawX (including the pressed offset,
1045
- // matching the main loop and ui_render_node_bands).
1046
- int16_t origBoxX = __ui_nodes[c].box.x;
1047
- int16_t origBoxY = __ui_nodes[c].box.y;
1048
- __ui_nodes[c].box.x = static_cast<int16_t>(origBoxX - vox);
1049
- __ui_nodes[c].box.y = static_cast<int16_t>(origBoxY - voy - bandTop);
1050
- int16_t drawX = ui_draw_x_for_node(c);
1051
- int16_t drawY = ui_draw_y_for_node(c);
1052
- // Outset shadow at the rest position, under the face — parity with the
1053
- // main dirty loop and ui_render_node_bands. This renderer omitted it,
1054
- // so band-composited scroll viewports (viewport canvas won't allocate)
1055
- // lost every shadow: theme --shadow-* tokens rendered in the preview
1056
- // but not on device. Lists manage their own shadow elsewhere.
1057
- if (__ui_nodes[c].kind != NODE_LIST) {
1058
- __ui_nodes[c].box.x = ui_base_draw_x_for_node(c);
1059
- ui_draw_shadow(c, ui_base_draw_y_for_node(c), 0);
1060
- }
1061
- __ui_nodes[c].box.x = drawX;
1062
- uint8_t ts = __ui_nodes[c].textSize ? __ui_nodes[c].textSize : 2;
1063
- uint16_t textMaxW = ui_node_text_max_width(c);
1064
- uint16_t tw = 0;
1065
- uint16_t th = 0;
1066
- ui_node_text_layout_metrics(c, textMaxW, &tw, &th);
1067
- uint16_t paintTextW = tw;
1068
- uint16_t paintTextH = th;
1069
- if (__ui_nodes[c].kind == NODE_TEXT || __ui_nodes[c].kind == NODE_SELECT) {
1070
- uint16_t hInset = static_cast<uint16_t>(__ui_nodes[c].paddingLeft) + static_cast<uint16_t>(__ui_nodes[c].paddingRight) + static_cast<uint16_t>(__ui_nodes[c].borderWidth) * 2;
1071
- uint16_t vInset = static_cast<uint16_t>(__ui_nodes[c].paddingTop) + static_cast<uint16_t>(__ui_nodes[c].paddingBottom) + static_cast<uint16_t>(__ui_nodes[c].borderWidth) * 2;
1072
- paintTextW = static_cast<uint16_t>(tw + hInset);
1073
- paintTextH = static_cast<uint16_t>(th + vInset);
1074
- }
1075
- if (__ui_nodes[c].kind == NODE_CHECK || __ui_nodes[c].kind == NODE_RADIO) {
1076
- paintTextW = static_cast<uint16_t>(tw + 22);
1077
- if (paintTextH < 16) paintTextH = 16;
1078
- }
1079
- const char* displayText = __ui_nodes[c].hasTextBinding
1080
- ? __ui_nodes[c].textBuffer
1081
- : __ui_nodes[c].text;
1082
- UI_COLOR_T bColor = __ui_nodes[c].borderColor ? __ui_nodes[c].borderColor : __ui_nodes[c].fg;
1083
- UI_COLOR_T fillBg = __ui_nodes[c].bg;
1084
- if (__ui_nodes[c].opacity < 100) {
1085
- UI_COLOR_T backdrop = ui_parent_clear_color(c);
1086
- bColor = ui_blend(bColor, backdrop, __ui_nodes[c].opacity);
1087
- fillBg = ui_blend(__ui_nodes[c].bg, backdrop, __ui_nodes[c].opacity);
1088
- }
1089
- UINodeDrawCtx ctx;
1090
- ctx.drawY = drawY;
1091
- ctx.bColor = bColor;
1092
- ctx.fillBg = fillBg;
1093
- ctx.ts = ts;
1094
- ctx.textMaxW = textMaxW;
1095
- ctx.tw = tw;
1096
- ctx.th = th;
1097
- ctx.paintTextW = paintTextW;
1098
- ctx.paintTextH = paintTextH;
1099
- ctx.displayText = displayText;
1100
- ctx.drawingBufferedScroll = 0;
1101
- ctx.drawTarget = prevTarget;
1102
- ctx.origBoxX = origBoxX;
1103
- ctx.origBoxY = origBoxY;
1104
- // The band draws onto a freshly cleared canvas every band, so the node's
1105
- // incremental-paint caches (lastTextWidth/lastTextHeight, which carry
1106
- // across frames for the main loop's NODE_TEXT/NODE_PROGRESS/NODE_RANGE
1107
- // incremental redraws) are bogus here — reset to force a full repaint into
1108
- // the blank band. Save/restore them around the draw so the band path is
1109
- // transient: it must not corrupt the persistent incremental-redraw state
1110
- // the main loop relies on if this container later transitions to canvas
1111
- // mode (the values are also stale across the multiple bands a tall node
1112
- // spans, so restoring the pre-band value keeps things consistent).
1113
- int16_t savedLastTextW = __ui_nodes[c].lastTextWidth;
1114
- uint16_t savedLastTextH = __ui_nodes[c].lastTextHeight;
1115
- if (__ui_nodes[c].kind == NODE_PROGRESS || __ui_nodes[c].kind == NODE_RANGE) {
1116
- __ui_nodes[c].lastTextWidth = -1;
1117
- }
1118
- __ui_nodes[c].lastTextHeight = 0;
1119
- ui_draw_node_body(static_cast<int16_t>(c), &ctx);
1120
- __ui_nodes[c].lastTextWidth = savedLastTextW;
1121
- __ui_nodes[c].lastTextHeight = savedLastTextH;
1122
- __ui_nodes[c].box.x = origBoxX;
1123
- __ui_nodes[c].box.y = origBoxY;
1124
- }
1125
- ui_display_set_target(prevTarget);
1126
- // Composite this band's scrollbar slice into the gutter. The track covers
1127
- // every row; the thumb covers viewport-rows [sbThumbY, sbThumbY+sbThumbH).
1128
- // Intersect that with this band's [bandTop, bandBot) range and fill the
1129
- // overlapping canvas-local rows with the thumb color.
1130
- if (sbVisible) {
1131
- // Full gutter width (vw - contentW, normally 4px): child decorations
1132
- // (outset shadows) poke past the content area and would survive as 1px
1133
- // ticks in any uncovered edge column.
1134
- display_canvasFillRect(band, contentW, 0, vw - contentW, thisH, sbTrackCol);
1135
- int16_t thumbTop = static_cast<int16_t>(sbThumbY);
1136
- int16_t thumbBot = static_cast<int16_t>(sbThumbY + sbThumbH);
1137
- int16_t ovTop = thumbTop > bandTop ? thumbTop : bandTop;
1138
- int16_t ovBot = thumbBot < bandBot ? thumbBot : bandBot;
1139
- if (ovBot > ovTop) {
1140
- display_canvasFillRect(band, contentW, static_cast<int16_t>(ovTop - bandTop), 3, static_cast<int16_t>(ovBot - ovTop), sbThumbCol);
1141
- }
1142
- } else {
1143
- // No scrollbar (content fits) — clear the gutter to the scroll bg so the
1144
- // previous screen's scrollbar pixels don't bleed through.
1145
- display_canvasFillRect(band, contentW, 0, vw - contentW, thisH, scrollBg);
1146
- }
1147
- // Push the FULL viewport width in one transaction (single-write fast path:
1148
- // w == stride). Each band — content + scrollbar slice — is fully rendered
1149
- // before it touches the panel, eliminating both tearing and scrollbar flash.
1150
- ui_push_canvas_rect(band, vox, static_cast<int16_t>(voy + bandTop), vw, thisH);
1151
- }
1152
- __ui_nodes[s].lastPaintedScrollY = __ui_nodes[s].scrollY;
1153
- // The subtree was fully rendered this frame; the main loop's scroll-defer
1154
- // path will skip these nodes (bufferedScrollDirectFull stays clear), and the
1155
- // caller has already marked the owner non-dirty.
1156
- for (uint16_t c = s + 1; c < subtreeEnd; c++) {
1157
- __ui_nodes[c].dirty = 0;
1158
- }
1159
- return 1;
1160
- }
1161
-
1162
- // ── Whole-frame band composition (drawer slide frames) ────────────────────
1163
- // Composes every visible active-screen node in draw order over a display
1164
- // rect, one horizontal band at a time, pushing each band in a single
1165
- // transaction. The drawer slide uses it over the union of the panel's old
1166
- // and new paint rects: a mark-all-dirty slide step instead repainted the
1167
- // whole screen with direct per-node clears (visible flashing on SPI TFTs,
1168
- // and only ~3 steps fit inside the 180ms slide). Returns 0 when the band
1169
- // canvas or draw order is unavailable (caller falls back to mark-all-dirty);
1170
- // a rect fully outside the panel returns 1 (nothing to compose).
1171
- static inline uint8_t ui_render_screen_bands(int16_t rx, int16_t ry, int16_t rw, int16_t rh) {
1172
- if (rw <= 0 || rh <= 0) return 1;
1173
- if (!ui_clip_rect_to_display_target(&rx, &ry, &rw, &rh)) return 1;
1174
- if (!__ui_draw_order || !__ui_scroll_candidates) return 0;
1175
- CuttlefishCanvas16* band = ui_band_canvas_for_width(rw);
1176
- if (!band) return 0;
1177
- int16_t bandH = display_canvasHeight(band);
1178
- // Seed color: the active screen's background (the screen fill node paints
1179
- // over it in draw order anyway; the seed keeps uncovered rows defined).
1180
- UI_COLOR_T screenBg = static_cast<UI_COLOR_T>(0);
1181
- if (__ui_active_screen_bg_node < __ui_node_count) {
1182
- screenBg = __ui_nodes[__ui_active_screen_bg_node].hasBg
1183
- ? __ui_nodes[__ui_active_screen_bg_node].bg
1184
- : __ui_nodes[__ui_active_screen_bg_node].clearColor;
1185
- }
1186
- // Draw-ordered candidate list once per composition (same shape as the
1187
- // scroll band path); each band then only Y-culls against it.
1188
- __ui_scroll_candidate_count = 0;
1189
- for (uint16_t pass = 0; pass < __ui_node_count; pass++) {
1190
- uint16_t c = __ui_draw_order[pass];
1191
- if (__ui_nodes[c].screenId != __ui_active_screen) continue;
1192
- if (!ui_is_effectively_visible(c)) continue;
1193
- if (__ui_scroll_candidate_count >= __ui_node_count) break;
1194
- UIScrollPaintCandidate& cand = __ui_scroll_candidates[__ui_scroll_candidate_count++];
1195
- cand.node = c;
1196
- cand.screenY = ui_draw_y_for_node(c);
1197
- cand.faceH = __ui_nodes[c].box.h;
1198
- // Scrolled children clip to their scroll viewport (CSS overflow): a
1199
- // compose over a region touching the viewport edge must not paint their
1200
- // faces at absolute positions beyond it. The initial whole-screen compose
1201
- // left stale button rows below the fold, and composes over the header
1202
- // region smeared scrolled content into the header — bars of previously
1203
- // scrolled pixels that nothing repaints. The viewport rect is the scroll
1204
- // ancestor's own draw position (its scrollY applies to children, not to
1205
- // itself).
1206
- int16_t san = ui_scroll_ancestor_for_node(c);
1207
- if (san >= 0 && __ui_nodes[san].box.h > 0 && __ui_nodes[san].box.w > 0) {
1208
- cand.clipTop = ui_draw_y_for_node(static_cast<uint16_t>(san));
1209
- cand.clipBottom = static_cast<int16_t>(cand.clipTop + __ui_nodes[san].box.h);
1210
- cand.clipLeft = ui_draw_x_for_node(static_cast<uint16_t>(san));
1211
- cand.clipRight = static_cast<int16_t>(cand.clipLeft + __ui_nodes[san].box.w);
1212
- } else {
1213
- cand.clipTop = -32767;
1214
- cand.clipBottom = 32767;
1215
- cand.clipLeft = -32767;
1216
- cand.clipRight = 32767;
1217
- }
1218
- }
1219
- CuttlefishDisplayTarget* prevTarget = ui_display_get_target();
1220
- for (int16_t bandTop = 0; bandTop < rh; bandTop += bandH) {
1221
- int16_t bandBot = static_cast<int16_t>(bandTop + bandH);
1222
- if (bandBot > rh) bandBot = rh;
1223
- int16_t thisH = static_cast<int16_t>(bandBot - bandTop);
1224
- display_canvasFillRect(band, 0, 0, rw, thisH, screenBg);
1225
- ui_display_set_target(band);
1226
- for (uint16_t ci = 0; ci < __ui_scroll_candidate_count; ci++) {
1227
- uint16_t c = __ui_scroll_candidates[ci].node;
1228
- int16_t screenY = __ui_scroll_candidates[ci].screenY;
1229
- int16_t faceH = __ui_scroll_candidates[ci].faceH;
1230
- // Clip before the per-node work (AGENTS.md): skip nodes whose face
1231
- // does not intersect this band's Y range.
1232
- if (screenY + faceH <= ry + bandTop) continue;
1233
- if (screenY >= ry + bandBot) continue;
1234
- int16_t origBoxX = __ui_nodes[c].box.x;
1235
- int16_t origBoxY = __ui_nodes[c].box.y;
1236
- int16_t origBoxW = __ui_nodes[c].box.w;
1237
- int16_t origBoxH = __ui_nodes[c].box.h;
1238
- __ui_nodes[c].box.x = static_cast<int16_t>(origBoxX - rx);
1239
- __ui_nodes[c].box.y = static_cast<int16_t>(origBoxY - ry - bandTop);
1240
- // Clamp the face to the scroll viewport (band-local coords). The
1241
- // shifted box/draw geometry stays within the candidate's clip bounds;
1242
- // text wrapping and layout metrics shrink with the box. Fully clipped
1243
- // faces drop out here.
1244
- {
1245
- // int32 math: the unclipped sentinels are ±32767 and the band-local
1246
- // conversion subtracts region/band offsets int16 arithmetic wraps
1247
- // (a wrapped-positive clipTop clamped every face to garbage and
1248
- // dropped the candidate: the main screen's header card vanished
1249
- // into the dark screen background, reading as a black rect).
1250
- int32_t clipTopL = static_cast<int32_t>(__ui_scroll_candidates[ci].clipTop) - ry - bandTop;
1251
- int32_t clipBotL = static_cast<int32_t>(__ui_scroll_candidates[ci].clipBottom) - ry - bandTop;
1252
- int32_t clipLeftL = static_cast<int32_t>(__ui_scroll_candidates[ci].clipLeft) - rx;
1253
- int32_t clipRightL = static_cast<int32_t>(__ui_scroll_candidates[ci].clipRight) - rx;
1254
- int16_t boxY0 = __ui_nodes[c].box.y;
1255
- if (boxY0 < clipTopL) {
1256
- __ui_nodes[c].box.y = static_cast<int16_t>(clipTopL);
1257
- __ui_nodes[c].box.h = static_cast<int16_t>(__ui_nodes[c].box.h - (clipTopL - boxY0));
1258
- }
1259
- if (static_cast<int32_t>(__ui_nodes[c].box.y) + __ui_nodes[c].box.h > clipBotL) {
1260
- __ui_nodes[c].box.h = static_cast<int16_t>(clipBotL - __ui_nodes[c].box.y);
1261
- }
1262
- int16_t boxX0 = __ui_nodes[c].box.x;
1263
- if (boxX0 < clipLeftL) {
1264
- __ui_nodes[c].box.x = static_cast<int16_t>(clipLeftL);
1265
- __ui_nodes[c].box.w = static_cast<int16_t>(__ui_nodes[c].box.w - (clipLeftL - boxX0));
1266
- }
1267
- if (static_cast<int32_t>(__ui_nodes[c].box.x) + __ui_nodes[c].box.w > clipRightL) {
1268
- __ui_nodes[c].box.w = static_cast<int16_t>(clipRightL - __ui_nodes[c].box.x);
1269
- }
1270
- if (__ui_nodes[c].box.w <= 0 || __ui_nodes[c].box.h <= 0) {
1271
- __ui_nodes[c].box.x = origBoxX;
1272
- __ui_nodes[c].box.y = origBoxY;
1273
- __ui_nodes[c].box.w = origBoxW;
1274
- __ui_nodes[c].box.h = origBoxH;
1275
- continue;
1276
- }
1277
- }
1278
- int16_t baseDrawX = ui_base_draw_x_for_node(c);
1279
- int16_t baseDrawY = ui_base_draw_y_for_node(c);
1280
- int16_t drawX = ui_draw_x_for_node(c);
1281
- int16_t drawY = ui_draw_y_for_node(c);
1282
- // Outset shadow at the rest position, under the face (mirrors the
1283
- // main loop; lists manage their own shadow elsewhere).
1284
- if (__ui_nodes[c].kind != NODE_LIST) {
1285
- __ui_nodes[c].box.x = baseDrawX;
1286
- ui_draw_shadow(c, baseDrawY, 0);
1287
- }
1288
- __ui_nodes[c].box.x = drawX;
1289
- uint8_t ts = __ui_nodes[c].textSize ? __ui_nodes[c].textSize : 2;
1290
- uint16_t textMaxW = ui_node_text_max_width(c);
1291
- uint16_t tw = 0;
1292
- uint16_t th = 0;
1293
- ui_node_text_layout_metrics(c, textMaxW, &tw, &th);
1294
- uint16_t paintTextW = tw;
1295
- uint16_t paintTextH = th;
1296
- if (__ui_nodes[c].kind == NODE_TEXT || __ui_nodes[c].kind == NODE_SELECT) {
1297
- uint16_t hInset = static_cast<uint16_t>(__ui_nodes[c].paddingLeft) + static_cast<uint16_t>(__ui_nodes[c].paddingRight) + static_cast<uint16_t>(__ui_nodes[c].borderWidth) * 2;
1298
- uint16_t vInset = static_cast<uint16_t>(__ui_nodes[c].paddingTop) + static_cast<uint16_t>(__ui_nodes[c].paddingBottom) + static_cast<uint16_t>(__ui_nodes[c].borderWidth) * 2;
1299
- paintTextW = static_cast<uint16_t>(tw + hInset);
1300
- paintTextH = static_cast<uint16_t>(th + vInset);
1301
- }
1302
- if (__ui_nodes[c].kind == NODE_CHECK || __ui_nodes[c].kind == NODE_RADIO) {
1303
- paintTextW = static_cast<uint16_t>(tw + 22);
1304
- if (paintTextH < 16) paintTextH = 16;
1305
- }
1306
- const char* displayText = __ui_nodes[c].hasTextBinding
1307
- ? __ui_nodes[c].textBuffer
1308
- : __ui_nodes[c].text;
1309
- UI_COLOR_T bColor = __ui_nodes[c].borderColor ? __ui_nodes[c].borderColor : __ui_nodes[c].fg;
1310
- UI_COLOR_T fillBg = __ui_nodes[c].bg;
1311
- if (__ui_nodes[c].opacity < 100) {
1312
- UI_COLOR_T backdrop = ui_parent_clear_color(c);
1313
- bColor = ui_blend(bColor, backdrop, __ui_nodes[c].opacity);
1314
- fillBg = ui_blend(__ui_nodes[c].bg, backdrop, __ui_nodes[c].opacity);
1315
- }
1316
- UINodeDrawCtx ctx;
1317
- ctx.drawY = drawY;
1318
- ctx.bColor = bColor;
1319
- ctx.fillBg = fillBg;
1320
- ctx.ts = ts;
1321
- ctx.textMaxW = textMaxW;
1322
- ctx.tw = tw;
1323
- ctx.th = th;
1324
- ctx.paintTextW = paintTextW;
1325
- ctx.paintTextH = paintTextH;
1326
- ctx.displayText = displayText;
1327
- ctx.drawingBufferedScroll = 0;
1328
- ctx.drawTarget = prevTarget;
1329
- ctx.origBoxX = origBoxX;
1330
- ctx.origBoxY = origBoxY;
1331
- // Each band starts on a freshly seeded canvas, so the persistent
1332
- // incremental-paint caches (lastTextWidth/Height) are invalid mid-band.
1333
- // Save/restore so the band path is transient (same contract as
1334
- // ui_render_scroll_bands / ui_render_node_bands).
1335
- int16_t savedLastTextW = __ui_nodes[c].lastTextWidth;
1336
- uint16_t savedLastTextH = __ui_nodes[c].lastTextHeight;
1337
- if (__ui_nodes[c].kind == NODE_PROGRESS || __ui_nodes[c].kind == NODE_RANGE) {
1338
- __ui_nodes[c].lastTextWidth = -1;
1339
- }
1340
- __ui_nodes[c].lastTextHeight = 0;
1341
- ui_draw_node_body(static_cast<int16_t>(c), &ctx);
1342
- __ui_nodes[c].lastTextWidth = savedLastTextW;
1343
- __ui_nodes[c].lastTextHeight = savedLastTextH;
1344
- __ui_nodes[c].box.x = origBoxX;
1345
- __ui_nodes[c].box.y = origBoxY;
1346
- __ui_nodes[c].box.w = origBoxW;
1347
- __ui_nodes[c].box.h = origBoxH;
1348
- }
1349
- ui_display_set_target(prevTarget);
1350
- ui_push_canvas_rect(band, rx, static_cast<int16_t>(ry + bandTop), rw, thisH);
1351
- }
1352
- return 1;
1353
- }
1354
-
1355
- // ── Per-node band renderer ────────────────────────────────────────────────
1356
- // Tear-free single-node repaint when the full node-sized repair canvas won't
1357
- // allocate (e.g. a full-width button on a no-PSRAM target: a 284×52 paint rect
1358
- // is ~29KB, too big for internal SRAM, so ui_get_repair_canvas returns null and
1359
- // the repaint would otherwise fall back to direct clear→redraw-to-SPI, which
1360
- // flashes). Mirrors ui_render_scroll_bands but composites a SINGLE node over
1361
- // its paint rect, reusing the persistent __ui_band_canvas (~10KB at
1362
- // UI_STRIP_BAND_HEIGHT, proven to allocate on no-PSRAM targets). Each band is
1363
- // seeded with the parent backdrop, the node's shadow+body+outline composited
1364
- // into it, then pushed one transaction per band — so nothing reaches the
1365
- // panel until the band is complete (no tearing). Returns 1 if the band canvas
1366
- // allocated and the node was rendered; 0 if even the band canvas can't
1367
- // allocate (caller falls back to the direct-draw path).
1368
- //
1369
- // prX/prY/prW/prH = the node's paint rect in DISPLAY coords (the union of
1370
- // rest-shadow + pressed-face + outline, as computed by ui_node_paint_rect).
1371
- static inline uint8_t ui_render_node_bands(uint16_t i, int16_t prX, int16_t prY, int16_t prW, int16_t prH) {
1372
- if (i >= __ui_node_count || prW <= 0 || prH <= 0) return 0;
1373
- // Band canvas: reuse the persistent slot, reallocated to prW width (so stride
1374
- // == prW for the single-write push fast path). See ui_band_canvas_for_width.
1375
- CuttlefishCanvas16* band = ui_band_canvas_for_width(prW);
1376
- if (!band) return 0;
1377
- int16_t bandH = display_canvasHeight(band);
1378
-
1379
- // Display-space draw coords for the node (untranslated — band-local offsets
1380
- // are applied per band below). baseDraw excludes the pressed offset; draw
1381
- // includes it (a pressed button's face sits 3px below its shadow).
1382
- int16_t dispBaseDrawX = ui_base_draw_x_for_node(i);
1383
- int16_t dispBaseDrawY = ui_base_draw_y_for_node(i);
1384
- int16_t dispDrawX = ui_draw_x_for_node(i);
1385
- int16_t dispDrawY = ui_draw_y_for_node(i);
1386
- int16_t origBoxX = __ui_nodes[i].box.x;
1387
- int16_t origBoxY = __ui_nodes[i].box.y;
1388
-
1389
- // Per-kind draw metrics (same as the main loop / scroll band path).
1390
- uint8_t ts = __ui_nodes[i].textSize ? __ui_nodes[i].textSize : 2;
1391
- uint16_t textMaxW = ui_node_text_max_width(i);
1392
- uint16_t tw = 0;
1393
- uint16_t th = 0;
1394
- ui_node_text_layout_metrics(i, textMaxW, &tw, &th);
1395
- uint16_t paintTextW = tw;
1396
- uint16_t paintTextH = th;
1397
- if (__ui_nodes[i].kind == NODE_TEXT || __ui_nodes[i].kind == NODE_SELECT) {
1398
- uint16_t hInset = static_cast<uint16_t>(__ui_nodes[i].paddingLeft) + static_cast<uint16_t>(__ui_nodes[i].paddingRight) + static_cast<uint16_t>(__ui_nodes[i].borderWidth) * 2;
1399
- uint16_t vInset = static_cast<uint16_t>(__ui_nodes[i].paddingTop) + static_cast<uint16_t>(__ui_nodes[i].paddingBottom) + static_cast<uint16_t>(__ui_nodes[i].borderWidth) * 2;
1400
- paintTextW = static_cast<uint16_t>(tw + hInset);
1401
- paintTextH = static_cast<uint16_t>(th + vInset);
1402
- }
1403
- if (__ui_nodes[i].kind == NODE_CHECK || __ui_nodes[i].kind == NODE_RADIO) {
1404
- paintTextW = static_cast<uint16_t>(tw + 22);
1405
- if (paintTextH < 16) paintTextH = 16;
1406
- }
1407
- const char* displayText = __ui_nodes[i].hasTextBinding
1408
- ? __ui_nodes[i].textBuffer
1409
- : __ui_nodes[i].text;
1410
- UI_COLOR_T bColor = __ui_nodes[i].borderColor ? __ui_nodes[i].borderColor : __ui_nodes[i].fg;
1411
- UI_COLOR_T fillBg = __ui_nodes[i].bg;
1412
- if (__ui_nodes[i].opacity < 100) {
1413
- UI_COLOR_T backdrop = ui_parent_clear_color(i);
1414
- bColor = ui_blend(bColor, backdrop, __ui_nodes[i].opacity);
1415
- fillBg = ui_blend(__ui_nodes[i].bg, backdrop, __ui_nodes[i].opacity);
1416
- }
1417
-
1418
- CuttlefishDisplayTarget* prevTarget = ui_display_get_target();
1419
- // Top→bottom bands over the paint rect.
1420
- for (int16_t bandTop = 0; bandTop < prH; bandTop += bandH) {
1421
- int16_t bandBot = bandTop + bandH;
1422
- if (bandBot > prH) bandBot = prH;
1423
- int16_t thisH = static_cast<int16_t>(bandBot - bandTop);
1424
- // Seed the band with the parent backdrop at this band's Y slice. The seed
1425
- // fills the whole band; bandTop shifts the parent fill rect so its rows
1426
- // land in [0, thisH) of the canvas.
1427
- ui_seed_paint_canvas_for_node(i, band, prX, prY + bandTop, bandTop);
1428
- ui_display_set_target(band);
1429
- // Band-local coords: subtract the paint-rect origin and the band's top row.
1430
- int16_t baseDrawX = static_cast<int16_t>(dispBaseDrawX - prX);
1431
- int16_t baseDrawY = static_cast<int16_t>(dispBaseDrawY - prY - bandTop);
1432
- int16_t drawX = static_cast<int16_t>(dispDrawX - prX);
1433
- int16_t drawY = static_cast<int16_t>(dispDrawY - prY - bandTop);
1434
- // Outset shadow at the rest position (baseDraw), under the face. Mirrors
1435
- // the main loop's pre-body shadow draw. Lists skip the outset shadow in
1436
- // the main loop (their shadow is drawn elsewhere); match that here.
1437
- uint8_t skipListOutsetShadow = (__ui_nodes[i].kind == NODE_LIST);
1438
- if (!skipListOutsetShadow) {
1439
- __ui_nodes[i].box.x = baseDrawX;
1440
- ui_draw_shadow(i, baseDrawY, 0);
1441
- }
1442
- __ui_nodes[i].box.x = drawX;
1443
- UINodeDrawCtx ctx;
1444
- ctx.drawY = drawY;
1445
- ctx.bColor = bColor;
1446
- ctx.fillBg = fillBg;
1447
- ctx.ts = ts;
1448
- ctx.textMaxW = textMaxW;
1449
- ctx.tw = tw;
1450
- ctx.th = th;
1451
- ctx.paintTextW = paintTextW;
1452
- ctx.paintTextH = paintTextH;
1453
- ctx.displayText = displayText;
1454
- ctx.drawingBufferedScroll = 0;
1455
- ctx.drawTarget = prevTarget;
1456
- ctx.origBoxX = origBoxX;
1457
- ctx.origBoxY = origBoxY;
1458
- // Each band starts on a freshly seeded canvas, so the persistent
1459
- // incremental-paint caches (lastTextWidth/Height) are invalid mid-band.
1460
- // Save/restore so the band path is transient (see ui_render_scroll_bands).
1461
- int16_t savedLastTextW = __ui_nodes[i].lastTextWidth;
1462
- uint16_t savedLastTextH = __ui_nodes[i].lastTextHeight;
1463
- if (__ui_nodes[i].kind == NODE_PROGRESS || __ui_nodes[i].kind == NODE_RANGE) {
1464
- __ui_nodes[i].lastTextWidth = -1;
1465
- }
1466
- __ui_nodes[i].lastTextHeight = 0;
1467
- if (!ui_draw_node_body(static_cast<int16_t>(i), &ctx)) {
1468
- ui_draw_node_outline(i, __ui_nodes[i].box.x, drawY);
1469
- }
1470
- __ui_nodes[i].lastTextWidth = savedLastTextW;
1471
- __ui_nodes[i].lastTextHeight = savedLastTextH;
1472
- __ui_nodes[i].box.x = origBoxX;
1473
- __ui_nodes[i].box.y = origBoxY;
1474
- ui_display_set_target(prevTarget);
1475
- // Push the full paint-rect width (== stride) in one transaction: each band
1476
- // is complete before it touches the panel, eliminating the press flash.
1477
- ui_push_canvas_rect(band, prX, static_cast<int16_t>(prY + bandTop), prW, thisH);
1478
- }
1479
- return 1;
1480
- }
1481
-
1482
- // ── List band renderer ────────────────────────────────────────────────────
1483
- // Tear-free <list> rendering when the viewport-sized list canvas won't allocate
1484
- // (a full-width list viewport is ~29KB+, too big for no-PSRAM SRAM, so the
1485
- // shift-and-repair cache can't be built and the list would otherwise render
1486
- // nothing). Mirrors ui_render_scroll_bands but composites the list's OWN
1487
- // virtualized rows (via listItemFn) instead of a subtree walk. Reuses the
1488
- // persistent __ui_band_canvas (~10KB at UI_STRIP_BAND_HEIGHT); each band is
1489
- // fully rendered content rows + scrollbar slice before its single SPI
1490
- // push, so the panel never shows a half-drawn frame. Returns 1 if the band
1491
- // canvas allocated and the list was rendered+pushed+decorated; 0 if even the
1492
- // band canvas can't allocate (caller renders nothing).
1493
- //
1494
- // Shift-and-repair is impossible without the viewport cache, so every repaint
1495
- // re-renders all visible rows. For a typical ~10-20 row text list this is
1496
- // affordable; lastPaintedScrollY is still updated so a later transition to
1497
- // canvas mode (memory frees) starts correctly.
1498
- static inline uint8_t ui_render_list_bands(uint16_t i) {
1499
- if (i >= __ui_node_count) return 0;
1500
- if (!__ui_nodes[i].listItemFn) return 0;
1501
- int16_t bx = __ui_nodes[i].box.x;
1502
- int16_t by = ui_draw_y_for_node(i);
1503
- int16_t bw = __ui_nodes[i].box.w;
1504
- int16_t bh = __ui_nodes[i].box.h;
1505
- if (bw <= 0 || bh <= 0) return 0;
1506
- uint16_t ih = __ui_nodes[i].listItemHeight > 0 ? __ui_nodes[i].listItemHeight : 24;
1507
- uint16_t itemCount = __ui_nodes[i].listCount;
1508
- int16_t listScrollY = __ui_nodes[i].scrollY;
1509
- int16_t listContentH = __ui_nodes[i].contentHeight;
1510
- UI_COLOR_T clearCol = __ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor;
1511
- int16_t origBoxX = __ui_nodes[i].box.x;
1512
- int16_t origBoxY = __ui_nodes[i].box.y;
1513
-
1514
- // Reuse the persistent band canvas, reallocated to bw width (so stride == bw
1515
- // for the single-write push fast path). See ui_band_canvas_for_width.
1516
- CuttlefishCanvas16* band = ui_band_canvas_for_width(bw);
1517
- if (!band) return 0;
1518
- int16_t bandH = display_canvasHeight(band);
1519
-
1520
- // Scrollbar geometry (viewport coords), same as the cached-canvas list path.
1521
- // Track spans [0, bh); thumb spans [thumbY, thumbY + thumbH). 3px wide at
1522
- // canvas-x (bw - 4). Composited per band so the full-width push is atomic.
1523
- int16_t contentW = bw > 4 ? static_cast<int16_t>(bw - 4) : bw;
1524
- uint8_t sbVisible = (listContentH > bh) ? 1 : 0;
1525
- uint16_t sbThumbH = 0;
1526
- uint16_t sbThumbY = 0;
1527
- UI_COLOR_T sbTrackCol = (UI_COLOR_T)((__ui_nodes[i].fg >> 1) & UI_DIM_MASK);
1528
- UI_COLOR_T sbThumbCol = __ui_nodes[i].fg;
1529
- if (sbVisible) {
1530
- sbThumbH = static_cast<uint32_t>(bh) * bh / listContentH;
1531
- if (sbThumbH < 8) sbThumbH = 8;
1532
- if (sbThumbH > static_cast<uint16_t>(bh)) sbThumbH = static_cast<uint16_t>(bh);
1533
- int16_t maxScroll = listContentH - bh;
1534
- sbThumbY = maxScroll > 0 ? static_cast<uint32_t>(bh - sbThumbH) * listScrollY / maxScroll : 0;
1535
- }
1536
-
1537
- char listBuf[UI_TEXT_BUF + 1];
1538
- CuttlefishDisplayTarget* listBandPrevTarget = ui_display_get_target();
1539
- // Top→bottom bands over the list viewport.
1540
- for (int16_t bandTop = 0; bandTop < bh; bandTop += bandH) {
1541
- int16_t bandBot = bandTop + bandH;
1542
- if (bandBot > bh) bandBot = bh;
1543
- int16_t thisH = static_cast<int16_t>(bandBot - bandTop);
1544
- // Seed content area with the list bg; the gutter is filled by the
1545
- // scrollbar composite below (or cleared to bg when no scrollbar).
1546
- display_canvasFillRect(band, 0, 0, contentW, bandH, clearCol);
1547
- if (!sbVisible) {
1548
- display_canvasFillRect(band, contentW, 0, bw - contentW, bandH, clearCol);
1549
- }
1550
- // Draw visible items whose row intersects this band (canvas-local Y).
1551
- display_targetSetTextWrap((CuttlefishDisplayTarget*)band, false);
1552
- if (itemCount > 0) {
1553
- // First/last item index whose [idx*ih - listScrollY, +ih) overlaps the
1554
- // band's viewport-Y range [bandTop, bandBot).
1555
- uint16_t first = static_cast<uint16_t>((listScrollY + bandTop) / ih);
1556
- int32_t lastSigned = (static_cast<int32_t>(listScrollY) + bandBot - 1) / ih;
1557
- uint16_t last = lastSigned < 0 ? 0 : static_cast<uint16_t>(lastSigned);
1558
- if (itemCount > 0 && last >= itemCount) last = itemCount - 1;
1559
- for (uint16_t idx = first; idx <= last; idx++) {
1560
- int16_t itemY = static_cast<int16_t>(idx * ih) - listScrollY - bandTop;
1561
- // Cull rows fully outside this band (the first/last estimate can be
1562
- // off by one at the edges).
1563
- if (itemY + static_cast<int16_t>(ih) <= 0) continue;
1564
- if (itemY >= thisH) continue;
1565
- __ui_nodes[i].listItemFn(idx, listBuf, UI_TEXT_BUF + 1);
1566
- listBuf[UI_TEXT_BUF] = 0;
1567
- ui_display_set_target(band);
1568
- int16_t __ui_saved_off_x = __ui_draw_off_x;
1569
- int16_t __ui_saved_off_y = __ui_draw_off_y;
1570
- __ui_draw_off_x = 0;
1571
- __ui_draw_off_y = 0;
1572
- // Keep classic list glyphs in the canvas pixel buffer. Native
1573
- // CuttlefishGFX text can disappear when copied from a canvas to
1574
- // the panel, while the explicit glyph path remains compositable.
1575
- ui_draw_list_text(listBuf, 4, itemY + static_cast<int16_t>(ih - 16) / 2,
1576
- __ui_nodes[i].fg, clearCol, 2, __ui_nodes[i].fontFace, __ui_nodes[i].letterSpacing);
1577
- __ui_draw_off_x = __ui_saved_off_x;
1578
- __ui_draw_off_y = __ui_saved_off_y;
1579
- ui_display_set_target(listBandPrevTarget);
1580
- }
1581
- }
1582
- // Composite this band's scrollbar slice into the gutter (atomic full-width
1583
- // push — no separate erase/redraw cycle, no scrollbar flash).
1584
- if (sbVisible) {
1585
- // Full gutter width (bw - contentW): child decorations poking past the
1586
- // content area must not survive in an uncovered edge column.
1587
- display_canvasFillRect(band, contentW, 0, bw - contentW, thisH, sbTrackCol);
1588
- int16_t thumbTop = static_cast<int16_t>(sbThumbY);
1589
- int16_t thumbBot = static_cast<int16_t>(sbThumbY + sbThumbH);
1590
- int16_t ovTop = thumbTop > bandTop ? thumbTop : bandTop;
1591
- int16_t ovBot = thumbBot < bandBot ? thumbBot : bandBot;
1592
- if (ovBot > ovTop) {
1593
- display_canvasFillRect(band, contentW, static_cast<int16_t>(ovTop - bandTop), 3, static_cast<int16_t>(ovBot - ovTop), sbThumbCol);
1594
- }
1595
- }
1596
- ui_push_canvas_rect(band, bx, static_cast<int16_t>(by + bandTop), bw, thisH);
1597
- }
1598
- // Static decoration on top of the composited content (same order as the
1599
- // cached-canvas list path: outset shadow, border, outline).
1600
- if (!__ui_fb) {
1601
- ui_draw_shadow(i, by, 0);
1602
- }
1603
- ui_draw_shadow(i, by, 1);
1604
- if (__ui_nodes[i].borderStyle != 0) {
1605
- UI_COLOR_T bColor = __ui_nodes[i].borderColor ? __ui_nodes[i].borderColor : __ui_nodes[i].fg;
1606
- ui_draw_node_border(i, bx, by, bColor);
1607
- }
1608
- ui_draw_node_outline(i, bx, by);
1609
- __ui_list_canvas_node = static_cast<int16_t>(i);
1610
- __ui_nodes[i].lastPaintedScrollY = listScrollY;
1611
- __ui_nodes[i].dirty = 0;
1612
- __ui_nodes[i].box.x = origBoxX;
1613
- __ui_nodes[i].box.y = origBoxY;
1614
- return 1;
1615
- }
1616
-
1617
- // Last-resort list renderer used only when neither the viewport canvas nor the
1618
- // bounded band canvas can allocate. It keeps the list usable under severe heap
1619
- // pressure; normal paths remain canvas-composited and tear-resistant.
1620
- static inline uint8_t ui_render_list_direct(uint16_t i) {
1621
- if (i >= __ui_node_count || !__ui_nodes[i].listItemFn) return 0;
1622
- int16_t bx = __ui_nodes[i].box.x;
1623
- int16_t by = ui_draw_y_for_node(i);
1624
- int16_t bw = __ui_nodes[i].box.w;
1625
- int16_t bh = __ui_nodes[i].box.h;
1626
- if (bw <= 0 || bh <= 0) return 0;
1627
- uint16_t ih = __ui_nodes[i].listItemHeight > 0 ? __ui_nodes[i].listItemHeight : 24;
1628
- uint16_t count = __ui_nodes[i].listCount;
1629
- int16_t scrollY = __ui_nodes[i].scrollY;
1630
- UI_COLOR_T bg = __ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor;
1631
- CuttlefishDisplayTarget* target = ui_display_get_target();
1632
- ui_display_set_target(target);
1633
- // This fallback is used only when no framebuffer is active. Keep the fill,
1634
- // native text, and scrollbar in one panel transaction so the visible fallback
1635
- // remains coherent on SPI TFTs even though it does not use a canvas.
1636
- if (!__ui_fb) display_startWrite();
1637
- ui_display_fill_rect(bx, by, bw, bh, bg);
1638
- char buf[UI_TEXT_BUF + 1];
1639
- uint16_t first = static_cast<uint16_t>((scrollY + 15) / ih);
1640
- int16_t lastY = static_cast<int16_t>(scrollY + bh - 16);
1641
- uint16_t last = lastY >= 0 ? static_cast<uint16_t>(lastY / ih) : 0;
1642
- if (count > 0 && last >= count) last = count - 1;
1643
- if (count > 0 && first < count && first <= last) {
1644
- for (uint16_t idx = first; idx <= last; idx++) {
1645
- int16_t itemY = static_cast<int16_t>(idx * ih) - scrollY;
1646
- __ui_nodes[i].listItemFn(idx, buf, UI_TEXT_BUF + 1);
1647
- buf[UI_TEXT_BUF] = 0;
1648
- int16_t savedX = __ui_draw_off_x;
1649
- int16_t savedY = __ui_draw_off_y;
1650
- __ui_draw_off_x = 0;
1651
- __ui_draw_off_y = 0;
1652
- ui_draw_list_text_direct(buf, bx + 4, by + itemY + static_cast<int16_t>(ih - 16) / 2,
1653
- __ui_nodes[i].fg, bg, 2, __ui_nodes[i].fontFace, __ui_nodes[i].letterSpacing);
1654
- __ui_draw_off_x = savedX;
1655
- __ui_draw_off_y = savedY;
1656
- }
1657
- }
1658
- if (__ui_nodes[i].contentHeight > bh) {
1659
- int16_t tx = bx + bw - 4;
1660
- uint16_t thumbH = static_cast<uint32_t>(bh) * bh / __ui_nodes[i].contentHeight;
1661
- if (thumbH < 8) thumbH = 8;
1662
- if (thumbH > static_cast<uint16_t>(bh)) thumbH = static_cast<uint16_t>(bh);
1663
- int16_t maxScroll = __ui_nodes[i].contentHeight - bh;
1664
- uint16_t thumbY = maxScroll > 0 ? static_cast<uint32_t>(bh - thumbH) * scrollY / maxScroll : 0;
1665
- UI_COLOR_T dimFg = (UI_COLOR_T)((__ui_nodes[i].fg >> 1) & UI_DIM_MASK);
1666
- ui_display_fill_rect(tx, by, 3, bh, dimFg);
1667
- ui_display_fill_rect(tx, static_cast<int16_t>(by + thumbY), 3, thumbH, __ui_nodes[i].fg);
1668
- }
1669
- if (!__ui_fb) display_endWrite();
1670
- ui_draw_shadow(i, by, 1);
1671
- if (__ui_nodes[i].borderStyle != 0) {
1672
- UI_COLOR_T border = __ui_nodes[i].borderColor ? __ui_nodes[i].borderColor : __ui_nodes[i].fg;
1673
- ui_draw_node_border(i, bx, by, border);
1674
- }
1675
- ui_draw_node_outline(i, bx, by);
1676
- __ui_nodes[i].lastPaintedScrollY = scrollY;
1677
- __ui_nodes[i].dirty = 0;
1678
- return 1;
1679
- }
1680
- `;
1681
- }
1
+ // Slice of the C++ runtime header.
2
+ // The per-node kind switch, extracted into a callable function so the
3
+ // strip/band renderer (ui_render_scroll_bands) can compose scroll-subtree
4
+ // nodes into a band canvas without duplicating ~700 lines of kind-specific
5
+ // draw code.
6
+ //
7
+ // The caller fills a UINodeDrawCtx with the computed draw state (drawY, text
8
+ // metrics, colors, text source) and invokes ui_draw_node_body. Both the main
9
+ // dirty-node draw loop (dirty-draw-phase.ts) and the band renderer set the ctx
10
+ // up the same way; the difference is only what __ui_gfx points at (display /
11
+ // framebuffer / scroll canvas / band canvas) and how the node's box coords are
12
+ // translated, both of which the caller handles before calling this.
13
+ //
14
+ // The NODE_LIST case is special: it manages its own canvas push, static
15
+ // decoration, coordinate restore, dirty-clear, and then returns 1 (a sentinel
16
+ // meaning "I fully handled this node — skip the caller's post-switch
17
+ // epilogue"). Every other case returns 0 and the caller runs its epilogue
18
+ // (outline, paint-canvas push, coordinate restore, dirty-clear, refresh rect).
19
+ // This return-sentinel preserves the original 'continue;' the list case used
20
+ // to short-circuit the rest of the per-node loop body.
21
+ //
22
+ // See docs/superpowers/specs/2026-07-12-split-runtime-header-design.md.
23
+ export function emitNodeDrawBody(): string {
24
+ return `
25
+ // Computed draw state for one node, passed into ui_draw_node_body so it can be
26
+ // called from both the main dirty-node loop and the strip/band renderer.
27
+ struct UINodeDrawCtx {
28
+ int16_t drawY; // node's screen-space draw Y (scroll-adjusted)
29
+ UI_COLOR_T bColor; // border color (borderColor or fg, opacity-blended)
30
+ UI_COLOR_T fillBg; // fill background (bg, opacity-blended toward parent)
31
+ uint8_t ts; // text size
32
+ uint16_t textMaxW; // max text width (node box content width)
33
+ uint16_t tw; // laid-out text width
34
+ uint16_t th; // laid-out text height
35
+ uint16_t paintTextW; // paint-rect text width (tw + insets)
36
+ uint16_t paintTextH; // paint-rect text height (th + insets)
37
+ const char* displayText; // text source (binding buffer or flash literal)
38
+ // Call-site draw state the NODE_LIST case reads. These mirror the main draw
39
+ // loop's locals; the band renderer sets them to its own (target = band canvas,
40
+ // drawingBufferedScroll = 0, origBox = the node's untranslated box).
41
+ uint8_t drawingBufferedScroll; // 1 when drawing into the buffered scroll canvas
42
+ CuttlefishDisplayTarget* drawTarget; // __ui_draw_target (framebuffer or display)
43
+ int16_t origBoxX; // node's untranslated box.x (restored after draw)
44
+ int16_t origBoxY; // node's untranslated box.y (restored after draw)
45
+ };
46
+
47
+ // Returns 1 when the node fully handled its own canvas push, decoration,
48
+ // coordinate restore, and dirty-clear (NODE_LIST). Returns 0 otherwise, in
49
+ // which case the caller is responsible for the post-switch epilogue.
50
+ //
51
+ // The ctx parameter is typed const void* (cast back to UINodeDrawCtx* below):
52
+ // the emitter auto-inserts a forward declaration of every function near the
53
+ // top of the program, BEFORE this struct is defined, so a struct-typed
54
+ // parameter makes that generated prototype fail to compile
55
+ // ("'UINodeDrawCtx' does not name a type"). Primitive-only parameters keep
56
+ // the auto-generated prototype valid; call sites still pass &ctx, which
57
+ // converts implicitly to const void*.
58
+ static inline uint8_t ui_draw_node_body(int16_t i, const void* rawCtx) {
59
+ const UINodeDrawCtx* ctx = static_cast<const UINodeDrawCtx*>(rawCtx);
60
+ int16_t drawY = ctx->drawY;
61
+ UI_COLOR_T bColor = ctx->bColor;
62
+ UI_COLOR_T fillBg = ctx->fillBg;
63
+ uint8_t ts = ctx->ts;
64
+ uint16_t textMaxW = ctx->textMaxW;
65
+ uint16_t tw = ctx->tw;
66
+ uint16_t th = ctx->th;
67
+ uint16_t paintTextW = ctx->paintTextW;
68
+ uint16_t paintTextH = ctx->paintTextH;
69
+ const char* displayText = ctx->displayText;
70
+ uint8_t drawingBufferedScroll = ctx->drawingBufferedScroll;
71
+ CuttlefishDisplayTarget* __ui_draw_target = ctx->drawTarget;
72
+ int16_t origBoxX = ctx->origBoxX;
73
+ int16_t origBoxY = ctx->origBoxY;
74
+ switch (__ui_nodes[i].kind) {
75
+ case NODE_FILL:
76
+ if (__ui_nodes[i].gradientEnabled > 0) {
77
+ ui_draw_gradient_fill(i, drawY);
78
+ } else if (__ui_nodes[i].borderRadius > 0 && __ui_nodes[i].hasBg) {
79
+ int16_t fillW = ui_rotated_face_w(i, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
80
+ int16_t fillH = ui_rotated_face_h(i, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
81
+ ui_display_fill_round_rect(__ui_nodes[i].box.x, drawY, fillW, fillH, __ui_nodes[i].borderRadius, fillBg);
82
+ } else if (__ui_nodes[i].hasBg) {
83
+ int16_t fillW = ui_rotated_face_w(i, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
84
+ int16_t fillH = ui_rotated_face_h(i, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
85
+ ui_display_fill_rect(__ui_nodes[i].box.x, drawY, fillW, fillH, fillBg);
86
+ }
87
+ ui_draw_shadow(i, drawY, 1);
88
+ if (__ui_nodes[i].borderStyle != 0) {
89
+ UI_COLOR_T bColor = __ui_nodes[i].borderColor ? __ui_nodes[i].borderColor : __ui_nodes[i].fg;
90
+ int16_t borderW = ui_rotated_face_w(i, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
91
+ int16_t borderH = ui_rotated_face_h(i, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
92
+ ui_draw_rect_outline(__ui_nodes[i].box.x, drawY, borderW, borderH,
93
+ __ui_nodes[i].borderRadius, __ui_nodes[i].borderStyle, __ui_nodes[i].borderWidth, bColor);
94
+ }
95
+ break;
96
+ case NODE_TEXT:
97
+ {
98
+ int16_t insetL = static_cast<int16_t>(__ui_nodes[i].borderWidth) + static_cast<int16_t>(__ui_nodes[i].paddingLeft);
99
+ int16_t insetR = static_cast<int16_t>(__ui_nodes[i].borderWidth) + static_cast<int16_t>(__ui_nodes[i].paddingRight);
100
+ int16_t insetT = static_cast<int16_t>(__ui_nodes[i].borderWidth) + static_cast<int16_t>(__ui_nodes[i].paddingTop);
101
+ int16_t textX = __ui_nodes[i].box.x + insetL;
102
+ int16_t textY = drawY + insetT;
103
+ int16_t textW = static_cast<int16_t>(__ui_nodes[i].box.w) - insetL - insetR;
104
+ if (textW < 1) textW = 1;
105
+ uint8_t textBoxPainted = 0;
106
+ if (__ui_nodes[i].gradientEnabled > 0) {
107
+ ui_draw_gradient_fill(i, drawY);
108
+ textBoxPainted = 1;
109
+ } else if (__ui_nodes[i].borderRadius > 0 && __ui_nodes[i].hasBg) {
110
+ ui_display_fill_round_rect(__ui_nodes[i].box.x, drawY,
111
+ __ui_nodes[i].box.w, __ui_nodes[i].box.h, __ui_nodes[i].borderRadius, fillBg);
112
+ textBoxPainted = 1;
113
+ } else if (__ui_nodes[i].hasBg) {
114
+ ui_display_fill_rect(__ui_nodes[i].box.x, drawY,
115
+ __ui_nodes[i].box.w, __ui_nodes[i].box.h, fillBg);
116
+ textBoxPainted = 1;
117
+ }
118
+ {
119
+ uint16_t clearW = __ui_nodes[i].box.w;
120
+ int16_t paintedTextW = static_cast<int16_t>(__ui_nodes[i].lastTextWidth) + insetL + insetR;
121
+ if (__ui_nodes[i].lastTextWidth > 0 && paintedTextW > static_cast<int16_t>(clearW)) {
122
+ clearW = static_cast<uint16_t>(paintedTextW);
123
+ }
124
+ uint16_t paddedTw = static_cast<uint16_t>(static_cast<int16_t>(tw) + insetL + insetR);
125
+ if (paddedTw > clearW) clearW = paddedTw;
126
+ // overflow:hidden/scroll: never clear past the node's own box. A nowrap
127
+ // line wider than its box would otherwise erase the parent's border.
128
+ if (__ui_nodes[i].scrollable) clearW = __ui_nodes[i].box.w;
129
+ uint16_t clearH = __ui_nodes[i].box.h;
130
+ int16_t paintedTextH = static_cast<int16_t>(__ui_nodes[i].lastTextHeight) + insetT + static_cast<int16_t>(__ui_nodes[i].paddingBottom) + static_cast<int16_t>(__ui_nodes[i].borderWidth);
131
+ if (__ui_nodes[i].lastTextHeight > 0 && paintedTextH > static_cast<int16_t>(clearH)) {
132
+ clearH = static_cast<uint16_t>(paintedTextH);
133
+ }
134
+ uint16_t paddedTh = static_cast<uint16_t>(static_cast<int16_t>(th) + insetT + static_cast<int16_t>(__ui_nodes[i].paddingBottom) + static_cast<int16_t>(__ui_nodes[i].borderWidth));
135
+ if (paddedTh > clearH) clearH = paddedTh;
136
+ // Dynamic transparent text still needs a clear, otherwise old glyph
137
+ // pixels accumulate when only this text node is dirty.
138
+ UI_COLOR_T clearCol = __ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor;
139
+ // Blend the clear toward the backdrop by opacity so a translucent text
140
+ // node (inherited from an opacity:<1 parent) doesn't repaint a solid
141
+ // block of its parent's fill around the glyphs.
142
+ if (__ui_nodes[i].opacity < 100) {
143
+ clearCol = ui_blend(clearCol, ui_parent_clear_color(i), __ui_nodes[i].opacity);
144
+ }
145
+ if (!textBoxPainted) {
146
+ ui_display_fill_rect(__ui_nodes[i].box.x, drawY, clearW, clearH, clearCol);
147
+ }
148
+ __ui_nodes[i].lastTextWidth = tw;
149
+ __ui_nodes[i].lastTextHeight = th;
150
+ }
151
+ ui_draw_shadow(i, drawY, 1);
152
+ if (__ui_nodes[i].borderStyle != 0) {
153
+ ui_draw_node_border(i, __ui_nodes[i].box.x, drawY, bColor);
154
+ }
155
+ // Rich-text (inline runs): draw from precomputed geometry instead of the
156
+ // single-string wrapped path. Geometry is baked at transpile time; the
157
+ // runtime does not re-wrap.
158
+ if (__ui_nodes[i].runCount > 0) {
159
+ UI_COLOR_T richTextBg;
160
+ if (__ui_nodes[i].opacity < 100) {
161
+ uint16_t p = __ui_nodes[i].parent;
162
+ UI_COLOR_T source = (p != UI_NO_PARENT && __ui_nodes[p].hasBg) ? __ui_nodes[p].bg
163
+ : (__ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor);
164
+ richTextBg = ui_blend(source, __ui_nodes[i].clearColor, __ui_nodes[i].opacity);
165
+ } else {
166
+ richTextBg = __ui_nodes[i].hasBg ? __ui_nodes[i].bg : ui_parent_clear_color(i);
167
+ }
168
+ if (__ui_nodes[i].textShadowCount > 0) {
169
+ UI_COLOR_T tsClear = __ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor;
170
+ uint32_t tsCol = ui_blend(__ui_nodes[i].textShadowColor, tsClear, __ui_nodes[i].textShadowAlpha);
171
+ // Shadow pass: draw the rich block in the shadow color at the offset.
172
+ // (Per-segment shadow color is approximated by drawing the whole
173
+ // block once in tsCol.)
174
+ ui_draw_rich_text(i,
175
+ textX + __ui_nodes[i].textShadowOffsetX,
176
+ textY + __ui_nodes[i].textShadowOffsetY,
177
+ tsClear, __ui_nodes[i].fontAntialias, 1, tsCol, static_cast<uint16_t>(textW));
178
+ }
179
+ ui_draw_rich_text(i, textX, textY, richTextBg, __ui_nodes[i].fontAntialias, 0, 0, static_cast<uint16_t>(textW));
180
+ break;
181
+ }
182
+ {
183
+ // Text shadow: draw the text in the shadow color at the offset first.
184
+ UI_COLOR_T tsClear = __ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor;
185
+ if (__ui_nodes[i].textShadowCount > 0) {
186
+ uint32_t tsCol = ui_blend(__ui_nodes[i].textShadowColor, tsClear, __ui_nodes[i].textShadowAlpha);
187
+ ui_draw_wrapped_text(displayText,
188
+ textX + __ui_nodes[i].textShadowOffsetX,
189
+ textY + __ui_nodes[i].textShadowOffsetY,
190
+ static_cast<uint16_t>(textW), tsCol, tsCol, ts, __ui_nodes[i].fontAntialias,
191
+ __ui_nodes[i].fontFace, __ui_nodes[i].letterSpacing, __ui_nodes[i].lineHeight,
192
+ __ui_nodes[i].whiteSpaceMode, __ui_nodes[i].textAlign, 0, __ui_nodes[i].textOverflow);
193
+ }
194
+ // Use the parent's clear color as the text background when the node
195
+ // has no own background. This makes Adafruit_GFX's opaque glyph-cell
196
+ // fill blend with the parent (instead of drawing solid fg blocks that
197
+ // overlap adjacent lines/elements). For AA text, fg != bg so the
198
+ // edge-detection path still runs correctly.
199
+ // Glyph-cell background. For a translucent text node sitting on a
200
+ // filled translucent parent, the glyph cells must match the parent's
201
+ // blended fill: blend565(parent.bg, backdrop, opacity). The node's own
202
+ // clearColor carries the backdrop (set by flatten), and opacity has
203
+ // already inherited from the parent. Use the parent's raw bg as the
204
+ // source so the glyph cells reproduce the parent's translucent fill.
205
+ UI_COLOR_T textBg;
206
+ if (__ui_nodes[i].opacity < 100) {
207
+ uint16_t p = __ui_nodes[i].parent;
208
+ UI_COLOR_T source = (p != UI_NO_PARENT && __ui_nodes[p].hasBg) ? __ui_nodes[p].bg
209
+ : (__ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor);
210
+ textBg = ui_blend(source, __ui_nodes[i].clearColor, __ui_nodes[i].opacity);
211
+ } else {
212
+ textBg = __ui_nodes[i].hasBg ? __ui_nodes[i].bg : ui_parent_clear_color(i);
213
+ }
214
+ ui_draw_wrapped_text(displayText, textX, textY, static_cast<uint16_t>(textW),
215
+ __ui_nodes[i].fg, textBg, ts, __ui_nodes[i].fontAntialias,
216
+ __ui_nodes[i].fontFace, __ui_nodes[i].letterSpacing, __ui_nodes[i].lineHeight,
217
+ __ui_nodes[i].whiteSpaceMode, __ui_nodes[i].textAlign, __ui_nodes[i].underline, __ui_nodes[i].textOverflow);
218
+ }
219
+ }
220
+ break;
221
+ case NODE_BUTTON:
222
+ // HTML disabled: halve the drawn colors (web-like faded control).
223
+ if (__ui_nodes[i].disabled) {
224
+ fillBg = (fillBg >> 1) & UI_DIM_MASK;
225
+ bColor = (bColor >> 1) & UI_DIM_MASK;
226
+ }
227
+ if (__ui_nodes[i].borderRadius > 0 && __ui_nodes[i].hasBg)
228
+ ui_display_fill_round_rect(__ui_nodes[i].box.x, drawY, __ui_nodes[i].box.w, __ui_nodes[i].box.h, __ui_nodes[i].borderRadius, fillBg);
229
+ else if (__ui_nodes[i].hasBg)
230
+ ui_display_fill_rect(__ui_nodes[i].box.x, drawY, __ui_nodes[i].box.w, __ui_nodes[i].box.h, fillBg);
231
+ ui_draw_shadow(i, drawY, 1);
232
+ if (__ui_nodes[i].borderStyle != 0) {
233
+ ui_draw_node_border(i, __ui_nodes[i].box.x, drawY, bColor);
234
+ }
235
+ {
236
+ int16_t insetL = static_cast<int16_t>(__ui_nodes[i].borderWidth) + static_cast<int16_t>(__ui_nodes[i].paddingLeft);
237
+ int16_t insetR = static_cast<int16_t>(__ui_nodes[i].borderWidth) + static_cast<int16_t>(__ui_nodes[i].paddingRight);
238
+ int16_t insetT = static_cast<int16_t>(__ui_nodes[i].borderWidth) + static_cast<int16_t>(__ui_nodes[i].paddingTop);
239
+ int16_t insetB = static_cast<int16_t>(__ui_nodes[i].borderWidth) + static_cast<int16_t>(__ui_nodes[i].paddingBottom);
240
+ int16_t textX = __ui_nodes[i].box.x + insetL;
241
+ int16_t textY = drawY + insetT;
242
+ int16_t textW = static_cast<int16_t>(__ui_nodes[i].box.w) - insetL - insetR;
243
+ int16_t textH = static_cast<int16_t>(__ui_nodes[i].box.h) - insetT - insetB;
244
+ if (textW < 1) textW = 1;
245
+ if (textH < 1) textH = static_cast<int16_t>(th);
246
+ ui_draw_wrapped_text(displayText,
247
+ textX,
248
+ textY + (textH - static_cast<int16_t>(th)) / 2,
249
+ static_cast<uint16_t>(textW),
250
+ __ui_nodes[i].fg,
251
+ __ui_nodes[i].hasBg ? fillBg : ui_parent_clear_color(i),
252
+ ts, __ui_nodes[i].fontAntialias, __ui_nodes[i].fontFace, __ui_nodes[i].letterSpacing,
253
+ __ui_nodes[i].lineHeight, __ui_nodes[i].whiteSpaceMode, __ui_nodes[i].textAlign, __ui_nodes[i].underline, __ui_nodes[i].textOverflow);
254
+ }
255
+ break;
256
+ case NODE_SELECT:
257
+ // <select>: a bordered, optionally rounded box with its current option
258
+ // text drawn VERTICALLY CENTERED (unlike NODE_TEXT, which top-aligns).
259
+ // Mirrors NODE_BUTTON's centering so the label sits mid-box when the
260
+ // control is enlarged for touch (min-height). The label is dynamic
261
+ // (auto-bound to the selected option's text), so clear the previous
262
+ // text rect before redrawing, like NODE_CHECK does.
263
+ {
264
+ uint16_t clearW = __ui_nodes[i].box.w;
265
+ if (__ui_nodes[i].lastTextWidth > 0 && __ui_nodes[i].lastTextWidth > static_cast<int16_t>(clearW)) {
266
+ clearW = static_cast<uint16_t>(__ui_nodes[i].lastTextWidth);
267
+ }
268
+ if (paintTextW > clearW) clearW = paintTextW;
269
+ uint16_t clearH = __ui_nodes[i].box.h;
270
+ if (__ui_nodes[i].lastTextHeight > 0 && __ui_nodes[i].lastTextHeight > static_cast<int16_t>(clearH)) {
271
+ clearH = static_cast<uint16_t>(__ui_nodes[i].lastTextHeight);
272
+ }
273
+ if (paintTextH > clearH) clearH = paintTextH;
274
+ ui_display_fill_rect(__ui_nodes[i].box.x, drawY, clearW, clearH,
275
+ __ui_nodes[i].hasBg ? fillBg : ui_parent_clear_color(i));
276
+ __ui_nodes[i].lastTextWidth = paintTextW;
277
+ __ui_nodes[i].lastTextHeight = paintTextH;
278
+ }
279
+ if (__ui_nodes[i].borderRadius > 0 && __ui_nodes[i].hasBg)
280
+ ui_display_fill_round_rect(__ui_nodes[i].box.x, drawY, __ui_nodes[i].box.w, __ui_nodes[i].box.h, __ui_nodes[i].borderRadius, fillBg);
281
+ else if (__ui_nodes[i].hasBg)
282
+ ui_display_fill_rect(__ui_nodes[i].box.x, drawY, __ui_nodes[i].box.w, __ui_nodes[i].box.h, fillBg);
283
+ ui_draw_shadow(i, drawY, 1);
284
+ if (__ui_nodes[i].borderStyle != 0) {
285
+ ui_draw_node_border(i, __ui_nodes[i].box.x, drawY, bColor);
286
+ }
287
+ {
288
+ int16_t insetL = static_cast<int16_t>(__ui_nodes[i].borderWidth) + static_cast<int16_t>(__ui_nodes[i].paddingLeft);
289
+ int16_t insetR = static_cast<int16_t>(__ui_nodes[i].borderWidth) + static_cast<int16_t>(__ui_nodes[i].paddingRight);
290
+ int16_t insetT = static_cast<int16_t>(__ui_nodes[i].borderWidth) + static_cast<int16_t>(__ui_nodes[i].paddingTop);
291
+ int16_t insetB = static_cast<int16_t>(__ui_nodes[i].borderWidth) + static_cast<int16_t>(__ui_nodes[i].paddingBottom);
292
+ int16_t textX = __ui_nodes[i].box.x + insetL;
293
+ int16_t textY = drawY + insetT;
294
+ // The right end reserves 14px for the dropdown chevron — the label
295
+ // wraps against the reduced width, never under the chevron.
296
+ int16_t textW = static_cast<int16_t>(__ui_nodes[i].box.w) - insetL - insetR - 14;
297
+ int16_t textH = static_cast<int16_t>(__ui_nodes[i].box.h) - insetT - insetB;
298
+ if (textW < 1) textW = 1;
299
+ if (textH < 1) textH = static_cast<int16_t>(th);
300
+ ui_draw_wrapped_text(displayText,
301
+ textX,
302
+ textY + (textH - static_cast<int16_t>(th)) / 2,
303
+ static_cast<uint16_t>(textW),
304
+ __ui_nodes[i].fg,
305
+ __ui_nodes[i].hasBg ? fillBg : ui_parent_clear_color(i),
306
+ ts, __ui_nodes[i].fontAntialias, __ui_nodes[i].fontFace, __ui_nodes[i].letterSpacing,
307
+ __ui_nodes[i].lineHeight, __ui_nodes[i].whiteSpaceMode, __ui_nodes[i].textAlign, __ui_nodes[i].underline, __ui_nodes[i].textOverflow);
308
+ // Dropdown chevron: a small solid v at the right end — the
309
+ // affordance that the control opens a menu of options.
310
+ {
311
+ int16_t chX = __ui_nodes[i].box.x + static_cast<int16_t>(__ui_nodes[i].box.w) - insetR - 11;
312
+ int16_t chY = drawY + (static_cast<int16_t>(__ui_nodes[i].box.h) - 5) / 2;
313
+ ui_display_fill_rect(chX, chY, 9, 1, __ui_nodes[i].fg);
314
+ ui_display_fill_rect(chX + 1, chY + 1, 7, 1, __ui_nodes[i].fg);
315
+ ui_display_fill_rect(chX + 2, chY + 2, 5, 1, __ui_nodes[i].fg);
316
+ ui_display_fill_rect(chX + 3, chY + 3, 3, 1, __ui_nodes[i].fg);
317
+ ui_display_fill_rect(chX + 4, chY + 4, 1, 1, __ui_nodes[i].fg);
318
+ }
319
+ }
320
+ break;
321
+ case NODE_CHECK:
322
+ {
323
+ uint16_t clearW = __ui_nodes[i].box.w;
324
+ if (__ui_nodes[i].lastTextWidth > 0 && __ui_nodes[i].lastTextWidth > static_cast<int16_t>(clearW)) {
325
+ clearW = static_cast<uint16_t>(__ui_nodes[i].lastTextWidth);
326
+ }
327
+ if (paintTextW > clearW) clearW = paintTextW;
328
+ uint16_t clearH = __ui_nodes[i].box.h;
329
+ if (__ui_nodes[i].lastTextHeight > 0 && __ui_nodes[i].lastTextHeight > static_cast<int16_t>(clearH)) {
330
+ clearH = static_cast<uint16_t>(__ui_nodes[i].lastTextHeight);
331
+ }
332
+ if (paintTextH > clearH) clearH = paintTextH;
333
+ // border-radius > 0 (kit .switch pills): clear the full text rect to
334
+ // the backdrop, then paint the rounded box. Mirrors the preview's
335
+ // drawCheckNode pill path. The :checked pair (kit wires it to
336
+ // --primary/--primary-foreground) swaps the TRACK color when on.
337
+ if (__ui_nodes[i].borderRadius > 0 && __ui_nodes[i].hasBg) {
338
+ ui_display_fill_rect(__ui_nodes[i].box.x, drawY, clearW, clearH,
339
+ ui_parent_clear_color(i));
340
+ ui_display_fill_round_rect(__ui_nodes[i].box.x, drawY,
341
+ __ui_nodes[i].box.w, __ui_nodes[i].box.h,
342
+ __ui_nodes[i].borderRadius,
343
+ (__ui_nodes[i].value && __ui_nodes[i].hasCheckedBg) ? __ui_nodes[i].checkedBg : fillBg);
344
+ } else {
345
+ ui_display_fill_rect(__ui_nodes[i].box.x, drawY, clearW, clearH,
346
+ __ui_nodes[i].hasBg ? fillBg : ui_parent_clear_color(i));
347
+ }
348
+ __ui_nodes[i].lastTextWidth = paintTextW;
349
+ __ui_nodes[i].lastTextHeight = paintTextH;
350
+ }
351
+ {
352
+ // Pills inset the 16px indicator from the left edge so the knob
353
+ // doesn't touch the rounded end; plain checkboxes keep it flush.
354
+ // The knob SLIDES with state — left when off, right when on — the
355
+ // switch affordance (static jump; no travel animation).
356
+ int16_t knobSlide = __ui_nodes[i].borderRadius > 0
357
+ ? static_cast<int16_t>(__ui_nodes[i].box.w) - 16 - 6
358
+ : 0;
359
+ if (knobSlide < 0) knobSlide = 0;
360
+ int16_t cbX = __ui_nodes[i].box.x
361
+ + (__ui_nodes[i].borderRadius > 0 ? 3 : 0)
362
+ + (__ui_nodes[i].value ? knobSlide : 0);
363
+ // Vertically center the 16px indicator within the box so a tall
364
+ // (touch-friendly) checkbox doesn't pin the indicator to the top.
365
+ // (box.h - 16) / 2 is 0 for the default 16px-tall box, so existing
366
+ // checkboxes render byte-identically.
367
+ int16_t cbY = drawY + (static_cast<int16_t>(__ui_nodes[i].box.h) - 16) / 2;
368
+ if (cbY < drawY) cbY = drawY;
369
+ if (__ui_nodes[i].value && __ui_nodes[i].borderRadius > 0) {
370
+ // Switch pill: the knob stays a knob — a solid circle when on, no
371
+ // checkbox square + checkmark. Same geometry as the radio dot.
372
+ // The knob carries the :checked color (primary-foreground).
373
+ ui_display_fill_circle(cbX + 8, cbY + 8, 7,
374
+ __ui_nodes[i].hasCheckedFg ? __ui_nodes[i].checkedFg : __ui_nodes[i].fg);
375
+ } else if (__ui_nodes[i].value) {
376
+ // Checked face carries the :checked background (primary), the
377
+ // checkmark its color (primary-foreground).
378
+ ui_display_fill_rect(cbX, cbY, 16, 16,
379
+ __ui_nodes[i].hasCheckedBg ? __ui_nodes[i].checkedBg : __ui_nodes[i].fg);
380
+ UI_COLOR_T inv = __ui_nodes[i].hasCheckedFg ? __ui_nodes[i].checkedFg
381
+ : (__ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor);
382
+ #ifdef UI_AA
383
+ {
384
+ // Draw the checkmark to a 16×16 AA canvas for smooth diagonals.
385
+ CuttlefishCanvas16* c = ui_aa_begin(16, 16, __ui_nodes[i].fg);
386
+ // First stroke: down-left (3,8 → 7,12)
387
+ if (c) {
388
+ ui_aa_line(c, 4.0f, 8.0f, 7.0f, 12.0f, inv);
389
+ ui_aa_line(c, 5.0f, 8.0f, 8.0f, 12.0f, inv);
390
+ // Second stroke: up-right (7,11 → 13,4)
391
+ ui_aa_line(c, 7.0f, 11.0f, 13.0f, 4.0f, inv);
392
+ ui_aa_line(c, 8.0f, 11.0f, 14.0f, 4.0f, inv);
393
+ ui_aa_push(c, cbX, cbY);
394
+ } else {
395
+ ui_display_draw_line(cbX + 3, cbY + 8, cbX + 7, cbY + 12, inv);
396
+ ui_display_draw_line(cbX + 4, cbY + 8, cbX + 8, cbY + 12, inv);
397
+ ui_display_draw_line(cbX + 7, cbY + 12, cbX + 13, cbY + 4, inv);
398
+ ui_display_draw_line(cbX + 8, cbY + 12, cbX + 14, cbY + 4, inv);
399
+ }
400
+ }
401
+ #else
402
+ ui_display_draw_line(cbX + 3, cbY + 8, cbX + 7, cbY + 12, inv);
403
+ ui_display_draw_line(cbX + 4, cbY + 8, cbX + 8, cbY + 12, inv);
404
+ ui_display_draw_line(cbX + 3, cbY + 9, cbX + 7, cbY + 13, inv);
405
+ ui_display_draw_line(cbX + 7, cbY + 12, cbX + 13, cbY + 4, inv);
406
+ ui_display_draw_line(cbX + 8, cbY + 12, cbX + 14, cbY + 4, inv);
407
+ ui_display_draw_line(cbX + 7, cbY + 13, cbX + 13, cbY + 5, inv);
408
+ #endif
409
+ } else if (__ui_nodes[i].borderRadius > 0) {
410
+ // Off-state pill: hollow circular knob outline on the track.
411
+ ui_display_draw_circle(cbX + 8, cbY + 8, 7, __ui_nodes[i].fg);
412
+ } else {
413
+ ui_display_draw_rect(cbX, cbY, 16, 16, __ui_nodes[i].fg);
414
+ }
415
+ }
416
+ // Center the label on the same baseline as the indicator: apply the
417
+ // indicator's vertical offset to the text origin too, so a tall box
418
+ // keeps the indicator + label aligned as a row rather than straddling
419
+ // the box top/bottom.
420
+ {
421
+ int16_t checkOff = (static_cast<int16_t>(__ui_nodes[i].box.h) - 16) / 2;
422
+ if (checkOff < 0) checkOff = 0;
423
+ ui_draw_wrapped_text(displayText, __ui_nodes[i].box.x + 22, drawY + checkOff, textMaxW,
424
+ __ui_nodes[i].fg, __ui_nodes[i].hasBg ? fillBg : ui_parent_clear_color(i),
425
+ ts, __ui_nodes[i].fontAntialias, __ui_nodes[i].fontFace, __ui_nodes[i].letterSpacing,
426
+ __ui_nodes[i].lineHeight, __ui_nodes[i].whiteSpaceMode, 0, __ui_nodes[i].underline, __ui_nodes[i].textOverflow);
427
+ }
428
+ break;
429
+ case NODE_RADIO:
430
+ {
431
+ uint16_t clearW = __ui_nodes[i].box.w;
432
+ if (__ui_nodes[i].lastTextWidth > 0 && __ui_nodes[i].lastTextWidth > static_cast<int16_t>(clearW)) {
433
+ clearW = static_cast<uint16_t>(__ui_nodes[i].lastTextWidth);
434
+ }
435
+ if (paintTextW > clearW) clearW = paintTextW;
436
+ uint16_t clearH = __ui_nodes[i].box.h;
437
+ if (__ui_nodes[i].lastTextHeight > 0 && __ui_nodes[i].lastTextHeight > static_cast<int16_t>(clearH)) {
438
+ clearH = static_cast<uint16_t>(__ui_nodes[i].lastTextHeight);
439
+ }
440
+ if (paintTextH > clearH) clearH = paintTextH;
441
+ ui_display_fill_rect(__ui_nodes[i].box.x, drawY, clearW, clearH,
442
+ __ui_nodes[i].hasBg ? fillBg : ui_parent_clear_color(i));
443
+ __ui_nodes[i].lastTextWidth = paintTextW;
444
+ __ui_nodes[i].lastTextHeight = paintTextH;
445
+ int16_t cbX = __ui_nodes[i].box.x;
446
+ // Vertically center the 16px indicator within the box (see NODE_CHECK).
447
+ int16_t cbY = drawY + (static_cast<int16_t>(__ui_nodes[i].box.h) - 16) / 2;
448
+ if (cbY < drawY) cbY = drawY;
449
+ #ifdef UI_AA
450
+ {
451
+ // Render the radio circle to a 16×16 AA canvas, then push.
452
+ UI_COLOR_T radioBg = __ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor;
453
+ // The selected ring carries the :checked background (primary).
454
+ UI_COLOR_T ringCol = (__ui_nodes[i].value && __ui_nodes[i].hasCheckedBg)
455
+ ? __ui_nodes[i].checkedBg : __ui_nodes[i].fg;
456
+ CuttlefishCanvas16* c = ui_aa_begin(16, 16, radioBg);
457
+ if (c) {
458
+ if (__ui_nodes[i].value) {
459
+ ui_aa_fill_circle(c, 8, 8, 7.0f, ringCol);
460
+ ui_aa_fill_circle(c, 8, 8, 3.0f, radioBg);
461
+ } else {
462
+ ui_aa_circle(c, 8, 8, 7.0f, __ui_nodes[i].fg);
463
+ }
464
+ ui_aa_push(c, cbX, cbY);
465
+ } else if (__ui_nodes[i].value) {
466
+ ui_display_fill_circle(cbX + 8, cbY + 8, 7, ringCol);
467
+ ui_display_fill_circle(cbX + 8, cbY + 8, 3, radioBg);
468
+ } else {
469
+ ui_display_draw_circle(cbX + 8, cbY + 8, 7, __ui_nodes[i].fg);
470
+ }
471
+ }
472
+ #else
473
+ if (__ui_nodes[i].value) {
474
+ ui_display_fill_circle(cbX + 8, cbY + 8, 7,
475
+ __ui_nodes[i].hasCheckedBg ? __ui_nodes[i].checkedBg : __ui_nodes[i].fg);
476
+ ui_display_fill_circle(cbX + 8, cbY + 8, 3, __ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor);
477
+ } else {
478
+ ui_display_draw_circle(cbX + 8, cbY + 8, 7, __ui_nodes[i].fg);
479
+ }
480
+ #endif
481
+ }
482
+ // Center the label on the same baseline as the indicator (see NODE_CHECK).
483
+ {
484
+ int16_t radioOff = (static_cast<int16_t>(__ui_nodes[i].box.h) - 16) / 2;
485
+ if (radioOff < 0) radioOff = 0;
486
+ ui_draw_wrapped_text(displayText, __ui_nodes[i].box.x + 22, drawY + radioOff, textMaxW,
487
+ __ui_nodes[i].fg, __ui_nodes[i].hasBg ? fillBg : ui_parent_clear_color(i),
488
+ ts, __ui_nodes[i].fontAntialias, __ui_nodes[i].fontFace, __ui_nodes[i].letterSpacing,
489
+ __ui_nodes[i].lineHeight, __ui_nodes[i].whiteSpaceMode, 0, __ui_nodes[i].underline, __ui_nodes[i].textOverflow);
490
+ }
491
+ break;
492
+ case NODE_PROGRESS:
493
+ // Progress bar: outline track + filled portion based on .value (0-100).
494
+ // Incremental redraw — only draws/clears the delta to avoid flashing.
495
+ {
496
+ int16_t bx = __ui_nodes[i].box.x;
497
+ int16_t by = drawY;
498
+ int16_t bw = __ui_nodes[i].box.w;
499
+ int16_t bh = __ui_nodes[i].box.h;
500
+ UI_COLOR_T bgCol = __ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor;
501
+ UI_COLOR_T fgCol = __ui_nodes[i].fg;
502
+
503
+ // On first draw (lastTextWidth < 0), draw everything.
504
+ // Otherwise incremental: only update the changed portion.
505
+ uint8_t pct = __ui_constrain(__ui_nodes[i].value, 0, 100);
506
+ int16_t fillW = (static_cast<int32_t>(bw - 2) * pct) / 100;
507
+ int16_t prevW = __ui_nodes[i].lastTextWidth; // reused as previous fill width
508
+
509
+ if (prevW < 0) {
510
+ // Full redraw: outline + background + fill
511
+ ui_display_draw_rect(bx, by, bw, bh, fgCol);
512
+ ui_display_fill_rect(bx + 1, by + 1, bw - 2, bh - 2, bgCol);
513
+ if (fillW > 0) {
514
+ ui_display_fill_rect(bx + 1, by + 1, fillW, bh - 2, fgCol);
515
+ }
516
+ } else if (fillW > prevW) {
517
+ // Value increased: draw new fill segment on top (no clear needed)
518
+ ui_display_fill_rect(bx + 1 + prevW, by + 1, fillW - prevW, bh - 2, fgCol);
519
+ } else if (fillW < prevW) {
520
+ // Value decreased: clear the removed portion
521
+ ui_display_fill_rect(bx + 1 + fillW, by + 1, prevW - fillW, bh - 2, bgCol);
522
+ }
523
+ // Remember current fill width for next incremental update
524
+ __ui_nodes[i].lastTextWidth = fillW;
525
+ }
526
+ break;
527
+ case NODE_RANGE:
528
+ // Range slider: horizontal track + draggable thumb.
529
+ // Incremental redraw (like NODE_PROGRESS): lastTextWidth holds the
530
+ // previous fill width. We erase the delta region between old and new
531
+ // thumb positions with the background, then redraw the track portion
532
+ // and the new thumb — so dragging backward doesn't leave ghost thumbs.
533
+ {
534
+ int16_t bx = __ui_nodes[i].box.x;
535
+ int16_t by = drawY;
536
+ int16_t bw = __ui_nodes[i].box.w;
537
+ int16_t bh = __ui_nodes[i].box.h;
538
+ UI_COLOR_T fgCol = __ui_nodes[i].fg;
539
+ UI_COLOR_T bgCol = __ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor;
540
+ UI_COLOR_T dimFg = (UI_COLOR_T)((fgCol >> 1) & UI_DIM_MASK);
541
+
542
+ int16_t trackY = by + bh / 2;
543
+ int16_t rMin = __ui_nodes[i].rangeMin;
544
+ int16_t rMax = __ui_nodes[i].rangeMax;
545
+ int16_t range = rMax - rMin;
546
+ if (range <= 0) range = 100;
547
+ int16_t pct = __ui_constrain(__ui_nodes[i].value, rMin, rMax) - rMin;
548
+ int16_t fillW = (static_cast<int32_t>(bw - 8) * pct) / range;
549
+ // lastTextWidth carries the previous fill width, or -1 if this node
550
+ // has never been drawn (fillW=0 at value=min is a valid thumb pos).
551
+ int16_t prevFillW = __ui_nodes[i].lastTextWidth;
552
+ int16_t newThumbX = bx + 4 + fillW - 3;
553
+
554
+ if (prevFillW < 0) {
555
+ // First draw: redraw the whole track + fill from scratch.
556
+ ui_display_draw_fast_hline(bx, trackY, bw, dimFg);
557
+ ui_display_draw_fast_hline(bx + 4, trackY, fillW, fgCol);
558
+ } else {
559
+ // Incremental: wipe the strip between the old and new thumb
560
+ // positions (whichever extends further on each side), then restore
561
+ // the track line. This is symmetric — old thumbs disappear whether
562
+ // the drag moves forward or backward.
563
+ int16_t prevThumbX = bx + 4 + prevFillW - 3;
564
+ int16_t left = prevThumbX < newThumbX ? prevThumbX : newThumbX;
565
+ int16_t right = prevThumbX + 6 > newThumbX + 6 ? prevThumbX + 6 : newThumbX + 6;
566
+ if (left < bx) left = bx;
567
+ if (right > bx + bw) right = bx + bw;
568
+ // Erase the thumb band (10px tall) to background.
569
+ ui_display_fill_rect(left, trackY - 5, right - left, 10, bgCol);
570
+ // Restore the track line over the wiped strip: bright up to the
571
+ // current fill end, dim beyond it.
572
+ int16_t fillEnd = bx + 4 + fillW;
573
+ if (right <= fillEnd) {
574
+ ui_display_draw_fast_hline(left, trackY, right - left, fgCol);
575
+ } else if (left >= fillEnd) {
576
+ ui_display_draw_fast_hline(left, trackY, right - left, dimFg);
577
+ } else {
578
+ ui_display_draw_fast_hline(left, trackY, fillEnd - left, fgCol);
579
+ ui_display_draw_fast_hline(fillEnd, trackY, right - fillEnd, dimFg);
580
+ }
581
+ }
582
+
583
+ // Thumb: small filled rectangle at the current position.
584
+ if (newThumbX < bx + 1) newThumbX = bx + 1;
585
+ if (newThumbX > bx + bw - 7) newThumbX = bx + bw - 7;
586
+ ui_display_fill_rect(newThumbX, trackY - 5, 6, 10, fgCol);
587
+
588
+ // Remember current fill width for the next incremental update.
589
+ __ui_nodes[i].lastTextWidth = fillW;
590
+ }
591
+ break;
592
+ case NODE_INPUT:
593
+ // Input field: bordered rect + current text (or placeholder), clipped to box width.
594
+ {
595
+ int16_t bx = __ui_nodes[i].box.x;
596
+ int16_t by = drawY;
597
+ int16_t bw = __ui_nodes[i].box.w;
598
+ int16_t bh = __ui_nodes[i].box.h;
599
+ UI_COLOR_T fgCol = __ui_nodes[i].fg;
600
+ UI_COLOR_T bgCol = __ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor;
601
+ // HTML disabled: halve the drawn colors (web-like faded control).
602
+ if (__ui_nodes[i].disabled) {
603
+ fgCol = (fgCol >> 1) & UI_DIM_MASK;
604
+ bgCol = (bgCol >> 1) & UI_DIM_MASK;
605
+ }
606
+ if (__ui_nodes[i].borderRadius > 0) {
607
+ ui_display_fill_round_rect(bx, by, bw, bh, __ui_nodes[i].borderRadius, bgCol);
608
+ } else {
609
+ ui_display_fill_rect(bx, by, bw, bh, bgCol);
610
+ }
611
+ UI_COLOR_T inputBorder = __ui_nodes[i].borderColor ? __ui_nodes[i].borderColor : fgCol;
612
+ uint8_t inputBorderStyle = __ui_nodes[i].borderStyle ? __ui_nodes[i].borderStyle : 1;
613
+ uint8_t inputBorderWidth = __ui_nodes[i].borderWidth ? __ui_nodes[i].borderWidth : 1;
614
+ ui_draw_rect_outline(bx, by, bw, bh, __ui_nodes[i].borderRadius, inputBorderStyle, inputBorderWidth, inputBorder);
615
+ // Show typed text (textBuffer) in fg color, or placeholder (.text)
616
+ // dimmed gray when the buffer is empty.
617
+ UI_COLOR_T textCol = fgCol;
618
+ const char* disp = (__ui_nodes[i].textBuffer[0] != 0)
619
+ ? __ui_nodes[i].textBuffer
620
+ : (__ui_nodes[i].text ? __ui_nodes[i].text : "");
621
+ #if defined(UI_HIDE_OSK)
622
+ // Desktop target: when this input is the active edit target, suppress
623
+ // the placeholder. The editing buffer is loaded from the (likely empty)
624
+ // textBuffer on focus, so without this the placeholder ("enter name")
625
+ // would render with the caret at its end. Hide it so the caret shows
626
+ // on a clean field at position 0 until the user types.
627
+ if (__ui_kb_visible && static_cast<int16_t>(__ui_kb_target) == static_cast<int16_t>(i) && __ui_nodes[i].textBuffer[0] == 0) {
628
+ disp = "";
629
+ }
630
+ #endif
631
+ if (__ui_nodes[i].textBuffer[0] == 0) {
632
+ #if UI_COLOR_DEPTH == 888
633
+ textCol = 0x848484;
634
+ #else
635
+ textCol = 0x8410;
636
+ #endif
637
+ }
638
+ // Note: the actual text draw is via ui_draw_text (which uses __ui_gfx).
639
+ // The setCursor/setTextColor/setTextSize below are legacy — ui_draw_text
640
+ // handles its own cursor/colors. Keep them on __ui_gfx for consistency
641
+ // (in case __ui_gfx is the scroll canvas, not __tc_display).
642
+ ui_display_set_cursor(bx + 4, by + (bh - ts * 8) / 2);
643
+ ui_display_set_text_color(textCol, bgCol);
644
+ ui_display_set_text_size(ts);
645
+ // Clip: at textSize ts, each char is ts*6px advance. Only print chars
646
+ // that fit within the box (bw - 8px margin), so text never overflows
647
+ // the border or wraps to the next line.
648
+ int16_t maxChars = (bw - 8) / (ts * 6);
649
+ if (maxChars < 0) maxChars = 0;
650
+ int16_t len = static_cast<int16_t>(strlen(disp));
651
+ if (len > maxChars) len = maxChars;
652
+ if (len > UI_TEXT_BUF) len = UI_TEXT_BUF;
653
+ char clipped[UI_TEXT_BUF + 1];
654
+ for (int16_t c = 0; c < len; c++) {
655
+ clipped[c] = disp[c];
656
+ }
657
+ clipped[len] = 0;
658
+ ui_draw_text(clipped, bx + 4, by + (bh - ui_text_height(ts, __ui_nodes[i].fontFace)) / 2,
659
+ textCol, bgCol, ts, __ui_nodes[i].fontAntialias, __ui_nodes[i].fontFace, __ui_nodes[i].letterSpacing);
660
+ #if defined(UI_HIDE_OSK)
661
+ // Desktop target: with no OSK grid there's no focus indicator. Draw a
662
+ // blinking caret at the end of the typed text on the active edit target
663
+ // so the user sees which field they're editing. Blink ~3×/sec via the
664
+ // top bits of __ui_kb_blink (mask 0x20 toggles every 32 ticks ≈ 530ms).
665
+ if (__ui_kb_visible && static_cast<int16_t>(__ui_kb_target) == static_cast<int16_t>(i) && (__ui_kb_blink & 0x20)) {
666
+ uint16_t caretW = ui_text_width(clipped, ts, __ui_nodes[i].fontFace, __ui_nodes[i].letterSpacing);
667
+ int16_t caretX = bx + 4 + static_cast<int16_t>(caretW);
668
+ int16_t caretYTop = by + (bh - ts * 8) / 2;
669
+ // fgCol (not textCol): the placeholder-dimming path sets textCol to
670
+ // gray, which would make the caret nearly invisible on a focused
671
+ // empty field. The caret should always be the input's foreground.
672
+ ui_display_fill_rect(caretX, caretYTop, static_cast<int16_t>(ts > 1 ? 2 : 1), static_cast<int16_t>(ts * 8), fgCol);
673
+ }
674
+ #endif
675
+ }
676
+ break;
677
+ case NODE_IMG:
678
+ {
679
+ UI_COLOR_T imgBg = __ui_nodes[i].hasBg ? fillBg : __ui_nodes[i].clearColor;
680
+ int16_t fillW = ui_rotated_face_w(i, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
681
+ int16_t fillH = ui_rotated_face_h(i, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
682
+ ui_display_fill_rect(__ui_nodes[i].box.x, drawY, fillW, fillH, imgBg);
683
+ }
684
+ if (__ui_nodes[i].imgDataId < __ui_image_count) {
685
+ const UIImage* img = &__ui_images[__ui_nodes[i].imgDataId];
686
+ int16_t targetW = __ui_nodes[i].box.w;
687
+ int16_t targetH = __ui_nodes[i].box.h;
688
+ ui_draw_image_with_fit(img, __ui_nodes[i].box.x, drawY, __ui_nodes[i].rotateDeg, __ui_nodes[i].objectFit, targetW, targetH);
689
+ }
690
+ if (__ui_nodes[i].borderStyle != 0) {
691
+ int16_t borderW = ui_rotated_face_w(i, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
692
+ int16_t borderH = ui_rotated_face_h(i, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
693
+ ui_draw_rect_outline(__ui_nodes[i].box.x, drawY, borderW, borderH,
694
+ __ui_nodes[i].borderRadius, __ui_nodes[i].borderStyle, __ui_nodes[i].borderWidth, bColor);
695
+ }
696
+ break;
697
+ case NODE_CANVAS: {
698
+ // Find this node's draw callback.
699
+ void (*__ui_canvas_fn)(CuttlefishCanvas16*) = nullptr;
700
+ for (uint16_t b = 0; b < __ui_canvas_binding_count; b++) {
701
+ if (__ui_canvas_bindings[b].node == i) { __ui_canvas_fn = __ui_canvas_bindings[b].fn; break; }
702
+ }
703
+ if (__ui_canvas_fn) {
704
+ int16_t __ui_cw = __ui_nodes[i].canvasW;
705
+ int16_t __ui_ch = __ui_nodes[i].canvasH;
706
+ if (__ui_cw > 0 && __ui_ch > 0) {
707
+ uint8_t __ui_canvas_drawn = 0;
708
+ if (ui_display_is_default_target()) {
709
+ // Cache a canvas sized to the buffer for direct hardware draws so
710
+ // the callback's many primitives push as one bitmap. When the
711
+ // active target is already a memory canvas (scroll/repair/full
712
+ // framebuffer), skip the nested allocation and draw directly below.
713
+ // A canvas can construct but fail its internal pixel-buffer malloc
714
+ // (non-null canvas, null buffer). Treat that as "no canvas" so a
715
+ // transient malloc failure self-heals instead of blanking the node.
716
+ if (!__ui_node_canvas || !display_canvasBuffer(__ui_node_canvas) ||
717
+ display_canvasWidth(__ui_node_canvas) != __ui_cw || display_canvasHeight(__ui_node_canvas) != __ui_ch) {
718
+ display_deleteCanvas(__ui_node_canvas);
719
+ __ui_node_canvas = display_createCanvas(__ui_cw, __ui_ch);
720
+ }
721
+ CuttlefishCanvas16* __ui_lc = __ui_node_canvas;
722
+ if (__ui_lc && display_canvasBuffer(__ui_lc)) {
723
+ display_canvasFillScreen(__ui_lc, __ui_nodes[i].clearColor);
724
+ CuttlefishDisplayTarget* __ui_prev_target = ui_display_get_target();
725
+ ui_display_set_target((CuttlefishDisplayTarget*)__ui_lc);
726
+ __ui_canvas_fn(__ui_lc);
727
+ ui_display_set_target(__ui_prev_target);
728
+ ui_draw_canvas_rect(__ui_lc, __ui_nodes[i].box.x, drawY, __ui_cw, __ui_ch);
729
+ __ui_canvas_drawn = 1;
730
+ }
731
+ }
732
+ if (!__ui_canvas_drawn) {
733
+ UI_COLOR_T __ui_canvas_bg = __ui_nodes[i].hasBg ? fillBg : __ui_nodes[i].clearColor;
734
+ ui_display_fill_rect(__ui_nodes[i].box.x, drawY, __ui_nodes[i].box.w, __ui_nodes[i].box.h, __ui_canvas_bg);
735
+ int16_t __ui_prev_off_x = __ui_draw_off_x;
736
+ int16_t __ui_prev_off_y = __ui_draw_off_y;
737
+ int16_t __ui_prev_canvas_w = __ui_canvas_fallback_w;
738
+ int16_t __ui_prev_canvas_h = __ui_canvas_fallback_h;
739
+ __ui_canvas_fallback_w = __ui_cw;
740
+ __ui_canvas_fallback_h = __ui_ch;
741
+ __ui_draw_off_x = static_cast<int16_t>(__ui_prev_off_x + __ui_nodes[i].box.x);
742
+ __ui_draw_off_y = static_cast<int16_t>(__ui_prev_off_y + drawY);
743
+ __ui_canvas_fn(nullptr);
744
+ __ui_draw_off_x = __ui_prev_off_x;
745
+ __ui_draw_off_y = __ui_prev_off_y;
746
+ __ui_canvas_fallback_w = __ui_prev_canvas_w;
747
+ __ui_canvas_fallback_h = __ui_prev_canvas_h;
748
+ }
749
+ }
750
+ }
751
+ break;
752
+ }
753
+ case NODE_LIST: {
754
+ // Virtualized list: state lives on the node now (listCountFn/listItemFn/
755
+ // listCount/scrollY/contentHeight/listItemHeight), not in a side table.
756
+ // Resolve lazily as a safety net for targets whose startup path runs
757
+ // before the generated binding table has been copied onto the node.
758
+ if (!__ui_nodes[i].listItemFn) {
759
+ for (uint16_t b = 0; b < __ui_list_binding_count; b++) {
760
+ if (__ui_list_bindings[b].node == static_cast<uint16_t>(i)) {
761
+ __ui_nodes[i].listCountFn = __ui_list_bindings[b].countFn;
762
+ __ui_nodes[i].listItemFn = __ui_list_bindings[b].itemFn;
763
+ __ui_nodes[i].listTapFn = __ui_list_bindings[b].tapFn;
764
+ break;
765
+ }
766
+ }
767
+ }
768
+ if (!__ui_nodes[i].listItemFn) break;
769
+ int16_t bx = __ui_nodes[i].box.x;
770
+ int16_t by = drawY;
771
+ int16_t bw = __ui_nodes[i].box.w;
772
+ int16_t bh = __ui_nodes[i].box.h;
773
+ uint16_t ih = __ui_nodes[i].listItemHeight > 0 ? __ui_nodes[i].listItemHeight : 24;
774
+ uint16_t itemCount = __ui_nodes[i].listCount;
775
+ int16_t listScrollY = __ui_nodes[i].scrollY;
776
+ int16_t listContentH = __ui_nodes[i].contentHeight;
777
+ UI_COLOR_T clearCol = __ui_nodes[i].clearColor;
778
+ uint8_t listFullRepaint = 0;
779
+ // Render to a viewport-sized canvas so edge glyphs are naturally clipped.
780
+ // Treat a null buffer (failed internal malloc) as "no canvas" and retry —
781
+ // see __ui_node_canvas for the zombie-caching rationale.
782
+ // __ui_list_canvas is file-scoped so ui_release_canvas_state() can free it.
783
+ if (!__ui_list_canvas || !display_canvasBuffer(__ui_list_canvas) ||
784
+ display_canvasWidth(__ui_list_canvas) != bw || display_canvasHeight(__ui_list_canvas) != bh) {
785
+ display_deleteCanvas(__ui_list_canvas);
786
+ __ui_list_canvas_node = -1;
787
+ __ui_list_canvas = ui_create_canvas_best(bw, bh);
788
+ listFullRepaint = 1;
789
+ }
790
+ CuttlefishCanvas16* lc = __ui_list_canvas;
791
+ if (!lc || !display_canvasBuffer(lc)) {
792
+ // Viewport canvas won't allocate (no PSRAM / over SRAM budget on a
793
+ // full-width list). Fall back to the band renderer: composite the
794
+ // visible rows into the ~10KB band canvas strip-by-strip, pushing
795
+ // each band tear-free. Mirrors the generic scroll path's Mode B →
796
+ // ui_render_scroll_bands fallback. If even the band canvas fails,
797
+ // give up (render nothing) — same as the old break.
798
+ if (ui_render_list_bands(static_cast<uint16_t>(i))) {
799
+ return 1; // band path handled push + decoration + dirty-clear
800
+ }
801
+ // Last-resort visible fallback: if even the small band canvas cannot
802
+ // allocate, draw the fully visible rows in one target transaction
803
+ // rather than leaving a black/empty list. This path is only reached
804
+ // under extreme memory pressure; normal renders remain atomic canvas
805
+ // pushes.
806
+ if (ui_render_list_direct(static_cast<uint16_t>(i))) {
807
+ __ui_nodes[i].box.x = origBoxX;
808
+ __ui_nodes[i].box.y = origBoxY;
809
+ return 1;
810
+ }
811
+ break;
812
+ }
813
+ if (__ui_list_canvas_node != static_cast<int16_t>(i)) listFullRepaint = 1;
814
+ int16_t repaintY = 0;
815
+ int16_t repaintH = bh;
816
+ int16_t deltaY = listScrollY - __ui_nodes[i].lastPaintedScrollY;
817
+ int16_t absDelta = deltaY < 0 ? -deltaY : deltaY;
818
+ uint8_t canShiftList = (!listFullRepaint && deltaY != 0 && absDelta < bh);
819
+ if (canShiftList) {
820
+ ui_shift_container_canvas(lc, deltaY, clearCol, &repaintY, &repaintH);
821
+ } else {
822
+ display_canvasFillScreen(lc, clearCol);
823
+ repaintY = 0;
824
+ repaintH = bh;
825
+ }
826
+ // GFXcanvas text has no clipping. For shift-and-repair frames, draw row
827
+ // text into a strip-sized repair canvas first, then blit only that strip
828
+ // into the shifted list canvas. This prevents a 1-5px repair from
829
+ // repainting full glyphs across pixels that were already shifted.
830
+ CuttlefishCanvas16* listTextCanvas = lc;
831
+ int16_t listTextOffsetY = 0;
832
+ uint8_t drawingListRepair = 0;
833
+ if (canShiftList && repaintH > 0 && repaintH < bh) {
834
+ CuttlefishCanvas16* rc = ui_get_repair_canvas(bw, repaintH);
835
+ if (rc) {
836
+ display_canvasFillScreen(rc, clearCol);
837
+ listTextCanvas = rc;
838
+ listTextOffsetY = repaintY;
839
+ drawingListRepair = 1;
840
+ } else {
841
+ display_canvasFillScreen(lc, clearCol);
842
+ repaintY = 0;
843
+ repaintH = bh;
844
+ canShiftList = 0;
845
+ }
846
+ }
847
+ // Compute visible range for the repainted strip. Previously-rendered
848
+ // pixels are shifted in-place; only the exposed band needs new rows.
849
+ uint16_t first = (listScrollY + repaintY) / ih;
850
+ uint16_t last = (listScrollY + repaintY + repaintH - 1) / ih + 1;
851
+ if (itemCount > 0 && last >= itemCount) last = itemCount - 1;
852
+ // Draw each visible item (canvas-local coords: 0,0 = viewport top).
853
+ char listBuf[UI_TEXT_BUF + 1];
854
+ CuttlefishDisplayTarget* listPrevTarget = ui_display_get_target();
855
+ if (itemCount > 0 && repaintH > 0) {
856
+ for (uint16_t idx = first; idx <= last; idx++) {
857
+ int16_t itemY = static_cast<int16_t>(idx * ih) - listScrollY - listTextOffsetY;
858
+ __ui_nodes[i].listItemFn(idx, listBuf, UI_TEXT_BUF + 1);
859
+ listBuf[UI_TEXT_BUF] = 0;
860
+ ui_display_set_target((CuttlefishDisplayTarget*)listTextCanvas);
861
+ int16_t __ui_saved_off_x2 = __ui_draw_off_x;
862
+ int16_t __ui_saved_off_y2 = __ui_draw_off_y;
863
+ __ui_draw_off_x = 0;
864
+ __ui_draw_off_y = 0;
865
+ // Keep classic list glyphs in the canvas pixel buffer. Native
866
+ // CuttlefishGFX text can disappear when copied from a canvas to
867
+ // the panel, while the explicit glyph path remains compositable.
868
+ ui_draw_list_text(listBuf, 4, itemY + static_cast<int16_t>(ih - 16) / 2,
869
+ __ui_nodes[i].fg, clearCol, 2, __ui_nodes[i].fontFace, __ui_nodes[i].letterSpacing);
870
+ __ui_draw_off_x = __ui_saved_off_x2;
871
+ __ui_draw_off_y = __ui_saved_off_y2;
872
+ ui_display_set_target(listPrevTarget);
873
+ }
874
+ }
875
+ if (drawingListRepair) {
876
+ CuttlefishDisplayTarget* prevTarget = ui_display_get_target();
877
+ ui_display_set_target((CuttlefishDisplayTarget*)lc);
878
+ ui_draw_canvas_rect(listTextCanvas, 0, repaintY, bw, repaintH);
879
+ ui_display_set_target(prevTarget);
880
+ }
881
+ // Scrollbar (canvas-local coords).
882
+ if (listContentH > bh) {
883
+ int16_t tx = bw - 4;
884
+ uint16_t thumbH = static_cast<uint32_t>(bh) * bh / listContentH;
885
+ if (thumbH < 8) thumbH = 8;
886
+ int16_t maxScroll = listContentH - bh;
887
+ uint16_t thumbY = maxScroll > 0 ? static_cast<uint32_t>(bh - thumbH) * listScrollY / maxScroll : 0;
888
+ UI_COLOR_T dimFg = (UI_COLOR_T)((__ui_nodes[i].fg >> 1) & UI_DIM_MASK);
889
+ display_canvasFillRect(lc, tx, 0, 3, bh, dimFg);
890
+ display_canvasFillRect(lc, tx, thumbY, 3, thumbH, __ui_nodes[i].fg);
891
+ }
892
+ // Outset shadows are static decoration. Redrawing the hard shadow
893
+ // directly to the panel before every small scroll-frame creates a
894
+ // visible shadow-then-content intermediate state on SPI TFTs. Keep it
895
+ // for full list repaints, but skip it for shift-and-repair scrolls.
896
+ if (!drawingBufferedScroll && !__ui_fb && !canShiftList) {
897
+ ui_draw_shadow(i, by, 0);
898
+ }
899
+ // Standalone lists push directly. Lists inside a buffered scroll
900
+ // container must composite into that scroll canvas; their box has
901
+ // already been translated to canvas-local coordinates.
902
+ if (drawingBufferedScroll) {
903
+ ui_draw_canvas_rect(lc, bx, by, bw, bh);
904
+ } else {
905
+ ui_push_canvas_rect(lc, bx, by, bw, bh);
906
+ }
907
+ // Draw static decoration after the scrollable pixels are composited.
908
+ // Keeping border rows out of __ui_list_canvas prevents the cached
909
+ // shift step from dragging top/bottom border pixels through the list.
910
+ ui_draw_shadow(i, by, 1);
911
+ if (__ui_nodes[i].borderStyle != 0) {
912
+ ui_draw_node_border(i, bx, by, bColor);
913
+ }
914
+ ui_draw_node_outline(i, bx, by);
915
+ __ui_list_canvas_node = static_cast<int16_t>(i);
916
+ __ui_nodes[i].lastPaintedScrollY = listScrollY;
917
+ __ui_nodes[i].dirty = 0;
918
+ if (__ui_fb) {
919
+ UIRect listPaintRect;
920
+ ui_node_paint_rect(i, origBoxX, origBoxY, origBoxX, origBoxY,
921
+ static_cast<uint16_t>(bw), static_cast<uint16_t>(bh), &listPaintRect);
922
+ ui_fb_add_rect(listPaintRect.x, listPaintRect.y, listPaintRect.w, listPaintRect.h);
923
+ }
924
+ ui_display_set_target(__ui_draw_target);
925
+ __ui_nodes[i].box.x = origBoxX;
926
+ __ui_nodes[i].box.y = origBoxY;
927
+ return 1; // list handled canvas push, decoration, and coordinate restore
928
+ }
929
+ }
930
+ return 0;
931
+ }
932
+
933
+ // ── Band renderer: tear-free scroll compositing without a viewport canvas ──
934
+ // When a scroll container's viewport canvas won't allocate (no PSRAM, tight
935
+ // SRAM, or a viewport larger than the budget), the previous fallback
936
+ // (direct-full) painted each child straight to the display — one SPI
937
+ // transaction per primitive, visible as tearing during fast drags. The band
938
+ // renderer instead composes the whole visible subtree into a short horizontal
939
+ // band canvas (vw × UI_STRIP_BAND_HEIGHT, e.g. 320×16 ≈ 10KB at RGB565),
940
+ // pushes that band in one SPI transaction, then moves to the next band. Each
941
+ // band completes before it is pushed, so the panel never shows a half-drawn
942
+ // frame — eliminating tearing while keeping memory bounded regardless of
943
+ // program size (the canvas height is fixed; only width tracks the viewport).
944
+ //
945
+ // Returns 1 when the subtree was rendered and pushed (the caller must skip the
946
+ // normal direct-full child repaint). Returns 0 when the band canvas could not
947
+ // be allocated (caller falls back to direct-full).
948
+ // Shared band-canvas alloc helper. The three band renderers reuse the
949
+ // persistent __ui_band_canvas, reallocating only when the width changes so
950
+ // display_canvasWidth == w == stride (ui_push_canvas_rect's single-write fast
951
+ // path requires w == stride — a sub-width push takes a row-by-row path some
952
+ // ST7796S drivers mishandle). Returns the canvas (with a valid buffer) or null.
953
+ static inline CuttlefishCanvas16* ui_band_canvas_for_width(int16_t w) {
954
+ if (!__ui_band_canvas || !display_canvasBuffer(__ui_band_canvas) ||
955
+ display_canvasWidth(__ui_band_canvas) != w) {
956
+ display_deleteCanvas(__ui_band_canvas);
957
+ __ui_band_canvas = ui_create_canvas_best(w, UI_STRIP_BAND_HEIGHT);
958
+ }
959
+ CuttlefishCanvas16* band = __ui_band_canvas;
960
+ return (band && display_canvasBuffer(band)) ? band : nullptr;
961
+ }
962
+ static inline uint8_t ui_render_scroll_bands(uint16_t s) {
963
+ if (s >= __ui_node_count) return 0;
964
+ int16_t vw = __ui_nodes[s].box.w;
965
+ int16_t vh = __ui_nodes[s].box.h;
966
+ if (vw <= 0 || vh <= 0) return 0;
967
+ int16_t vox = __ui_nodes[s].box.x;
968
+ int16_t voy = __ui_nodes[s].box.y;
969
+ UI_COLOR_T scrollBg = __ui_nodes[s].hasBg ? __ui_nodes[s].bg : __ui_nodes[s].clearColor;
970
+ // One persistent band canvas, reused across bands and frames, reallocated
971
+ // only when the viewport width changes (so stride == vw for the single-write
972
+ // push fast path). See ui_band_canvas_for_width.
973
+ CuttlefishCanvas16* band = ui_band_canvas_for_width(vw);
974
+ if (!band) return 0;
975
+ int16_t bandH = display_canvasHeight(band);
976
+ // Build a compact list of descendants that can intersect the viewport once per
977
+ // scroll repaint. The old nested band×subtree walk repeated visibility, screen,
978
+ // and Y culling for every band; the candidate list keeps that work O(subtree)
979
+ // and each band only visits rows that can actually contribute pixels.
980
+ uint16_t subtreeEnd = __ui_nodes[s].subtreeEnd;
981
+ if (subtreeEnd > __ui_node_count) subtreeEnd = __ui_node_count;
982
+ // The scrollbar occupies the rightmost 4px gutter (tx = vw - 4, 3px wide).
983
+ // Rather than draw it separately at frame end (which made each band push erase
984
+ // the previous frame's scrollbar slice-by-slice, flashing it during scroll),
985
+ // composite this band's scrollbar slice into the gutter of the band canvas
986
+ // itself. Each band then pushes the FULL viewport width in one SPI transaction
987
+ // (the single-write fast path that works on every driver — a sub-width push
988
+ // forces a row-by-row path that some ST7796S drivers mishandle, showing only
989
+ // the first row). The scrollbar is thus part of each band's atomic push: no
990
+ // separate erase/redraw cycle, no flash, and the fast path is preserved.
991
+ int16_t contentW = vw > 4 ? static_cast<int16_t>(vw - 4) : vw;
992
+ // Scrollbar geometry (document/viewport coords): track spans [0, vh); thumb
993
+ // spans [thumbY, thumbY + thumbH). Both are 3px wide at canvas-x contentW.
994
+ uint16_t sbThumbH = 0;
995
+ uint16_t sbThumbY = 0;
996
+ uint8_t sbVisible = 0;
997
+ UI_COLOR_T sbTrackCol = (UI_COLOR_T)((__ui_nodes[s].fg >> 1) & UI_DIM_MASK);
998
+ UI_COLOR_T sbThumbCol = __ui_nodes[s].fg;
999
+ if (__ui_nodes[s].contentHeight > vh) {
1000
+ sbVisible = 1;
1001
+ sbThumbH = static_cast<uint32_t>(vh) * vh / __ui_nodes[s].contentHeight;
1002
+ if (sbThumbH < 8) sbThumbH = 8;
1003
+ // Clamp to vh so the (vh - sbThumbH) subtraction below can't go negative on
1004
+ // a degenerate sub-8px viewport (which would cast to a huge uint32_t and
1005
+ // place the thumb off-screen). ui_draw_scrollbar_direct has the same min-8
1006
+ // clamp; this extra guard keeps the band path's slice math safe.
1007
+ if (sbThumbH > static_cast<uint16_t>(vh)) sbThumbH = static_cast<uint16_t>(vh);
1008
+ int16_t maxScroll = __ui_nodes[s].contentHeight - vh;
1009
+ sbThumbY = maxScroll > 0 ? static_cast<uint32_t>(vh - sbThumbH) * __ui_nodes[s].scrollY / maxScroll : 0;
1010
+ }
1011
+ CuttlefishDisplayTarget* prevTarget = ui_display_get_target();
1012
+ if (!__ui_scroll_candidates) return 0;
1013
+ __ui_scroll_candidate_count = 0;
1014
+ for (uint16_t c = s + 1; c < subtreeEnd && __ui_scroll_candidate_count < __ui_node_count; c++) {
1015
+ if (!ui_is_effectively_visible(c) || __ui_nodes[c].screenId != __ui_active_screen) continue;
1016
+ UIScrollPaintCandidate& candidate = __ui_scroll_candidates[__ui_scroll_candidate_count++];
1017
+ candidate.node = c;
1018
+ candidate.screenY = ui_draw_y_for_node(c);
1019
+ candidate.faceH = __ui_nodes[c].box.h;
1020
+ }
1021
+
1022
+ // Top→bottom bands over the viewport.
1023
+ for (int16_t bandTop = 0; bandTop < vh; bandTop += bandH) {
1024
+ int16_t bandBot = bandTop + bandH;
1025
+ if (bandBot > vh) bandBot = vh;
1026
+ int16_t thisH = static_cast<int16_t>(bandBot - bandTop);
1027
+ display_canvasFillRect(band, 0, 0, contentW, bandH, scrollBg);
1028
+ ui_display_set_target(band);
1029
+ for (uint16_t candidateIdx = 0; candidateIdx < __ui_scroll_candidate_count; candidateIdx++) {
1030
+ uint16_t c = __ui_scroll_candidates[candidateIdx].node;
1031
+ int16_t screenY = __ui_scroll_candidates[candidateIdx].screenY;
1032
+ int16_t faceH = __ui_scroll_candidates[candidateIdx].faceH;
1033
+ // Cull nodes whose face does not intersect this band's Y range before
1034
+ // doing any expensive per-node work (AGENTS.md: clip before the inner
1035
+ // loop). A node fully above or below the band contributes no pixels.
1036
+ if (screenY + faceH <= voy + bandTop) continue;
1037
+ if (screenY >= voy + bandBot) continue;
1038
+ // Translate the node's box into band-local coordinates, mirroring the main
1039
+ // loop's drawingBufferedScroll setup: subtract the viewport origin and the
1040
+ // band's top row. ui_draw_y_for_node then re-derives the band-local Y
1041
+ // (document Y - scrollY - voy - bandTop) = (screenY - voy - bandTop). The
1042
+ // box.x translate below is read by ui_draw_x_for_node (which adds
1043
+ // transformOffsetX + pressedOffsetX), so it must be set before computing
1044
+ // drawX; the body needs box.x = drawX (including the pressed offset,
1045
+ // matching the main loop and ui_render_node_bands).
1046
+ int16_t origBoxX = __ui_nodes[c].box.x;
1047
+ int16_t origBoxY = __ui_nodes[c].box.y;
1048
+ __ui_nodes[c].box.x = static_cast<int16_t>(origBoxX - vox);
1049
+ __ui_nodes[c].box.y = static_cast<int16_t>(origBoxY - voy - bandTop);
1050
+ int16_t drawX = ui_draw_x_for_node(c);
1051
+ int16_t drawY = ui_draw_y_for_node(c);
1052
+ // Outset shadow at the rest position, under the face — parity with the
1053
+ // main dirty loop and ui_render_node_bands. This renderer omitted it,
1054
+ // so band-composited scroll viewports (viewport canvas won't allocate)
1055
+ // lost every shadow: theme --shadow-* tokens rendered in the preview
1056
+ // but not on device. Lists manage their own shadow elsewhere.
1057
+ if (__ui_nodes[c].kind != NODE_LIST) {
1058
+ __ui_nodes[c].box.x = ui_base_draw_x_for_node(c);
1059
+ ui_draw_shadow(c, ui_base_draw_y_for_node(c), 0);
1060
+ }
1061
+ __ui_nodes[c].box.x = drawX;
1062
+ uint8_t ts = __ui_nodes[c].textSize ? __ui_nodes[c].textSize : 2;
1063
+ uint16_t textMaxW = ui_node_text_max_width(c);
1064
+ uint16_t tw = 0;
1065
+ uint16_t th = 0;
1066
+ ui_node_text_layout_metrics(c, textMaxW, &tw, &th);
1067
+ uint16_t paintTextW = tw;
1068
+ uint16_t paintTextH = th;
1069
+ if (__ui_nodes[c].kind == NODE_TEXT || __ui_nodes[c].kind == NODE_SELECT) {
1070
+ uint16_t hInset = static_cast<uint16_t>(__ui_nodes[c].paddingLeft) + static_cast<uint16_t>(__ui_nodes[c].paddingRight) + static_cast<uint16_t>(__ui_nodes[c].borderWidth) * 2;
1071
+ uint16_t vInset = static_cast<uint16_t>(__ui_nodes[c].paddingTop) + static_cast<uint16_t>(__ui_nodes[c].paddingBottom) + static_cast<uint16_t>(__ui_nodes[c].borderWidth) * 2;
1072
+ paintTextW = static_cast<uint16_t>(tw + hInset);
1073
+ paintTextH = static_cast<uint16_t>(th + vInset);
1074
+ }
1075
+ if (__ui_nodes[c].kind == NODE_CHECK || __ui_nodes[c].kind == NODE_RADIO) {
1076
+ paintTextW = static_cast<uint16_t>(tw + 22);
1077
+ if (paintTextH < 16) paintTextH = 16;
1078
+ }
1079
+ const char* displayText = __ui_nodes[c].hasTextBinding
1080
+ ? __ui_nodes[c].textBuffer
1081
+ : __ui_nodes[c].text;
1082
+ UI_COLOR_T bColor = __ui_nodes[c].borderColor ? __ui_nodes[c].borderColor : __ui_nodes[c].fg;
1083
+ UI_COLOR_T fillBg = __ui_nodes[c].bg;
1084
+ if (__ui_nodes[c].opacity < 100) {
1085
+ UI_COLOR_T backdrop = ui_parent_clear_color(c);
1086
+ bColor = ui_blend(bColor, backdrop, __ui_nodes[c].opacity);
1087
+ fillBg = ui_blend(__ui_nodes[c].bg, backdrop, __ui_nodes[c].opacity);
1088
+ }
1089
+ UINodeDrawCtx ctx;
1090
+ ctx.drawY = drawY;
1091
+ ctx.bColor = bColor;
1092
+ ctx.fillBg = fillBg;
1093
+ ctx.ts = ts;
1094
+ ctx.textMaxW = textMaxW;
1095
+ ctx.tw = tw;
1096
+ ctx.th = th;
1097
+ ctx.paintTextW = paintTextW;
1098
+ ctx.paintTextH = paintTextH;
1099
+ ctx.displayText = displayText;
1100
+ ctx.drawingBufferedScroll = 0;
1101
+ ctx.drawTarget = prevTarget;
1102
+ ctx.origBoxX = origBoxX;
1103
+ ctx.origBoxY = origBoxY;
1104
+ // The band draws onto a freshly cleared canvas every band, so the node's
1105
+ // incremental-paint caches (lastTextWidth/lastTextHeight, which carry
1106
+ // across frames for the main loop's NODE_TEXT/NODE_PROGRESS/NODE_RANGE
1107
+ // incremental redraws) are bogus here — reset to force a full repaint into
1108
+ // the blank band. Save/restore them around the draw so the band path is
1109
+ // transient: it must not corrupt the persistent incremental-redraw state
1110
+ // the main loop relies on if this container later transitions to canvas
1111
+ // mode (the values are also stale across the multiple bands a tall node
1112
+ // spans, so restoring the pre-band value keeps things consistent).
1113
+ int16_t savedLastTextW = __ui_nodes[c].lastTextWidth;
1114
+ uint16_t savedLastTextH = __ui_nodes[c].lastTextHeight;
1115
+ if (__ui_nodes[c].kind == NODE_PROGRESS || __ui_nodes[c].kind == NODE_RANGE) {
1116
+ __ui_nodes[c].lastTextWidth = -1;
1117
+ }
1118
+ __ui_nodes[c].lastTextHeight = 0;
1119
+ ui_draw_node_body(static_cast<int16_t>(c), &ctx);
1120
+ __ui_nodes[c].lastTextWidth = savedLastTextW;
1121
+ __ui_nodes[c].lastTextHeight = savedLastTextH;
1122
+ __ui_nodes[c].box.x = origBoxX;
1123
+ __ui_nodes[c].box.y = origBoxY;
1124
+ }
1125
+ ui_display_set_target(prevTarget);
1126
+ // Composite this band's scrollbar slice into the gutter. The track covers
1127
+ // every row; the thumb covers viewport-rows [sbThumbY, sbThumbY+sbThumbH).
1128
+ // Intersect that with this band's [bandTop, bandBot) range and fill the
1129
+ // overlapping canvas-local rows with the thumb color.
1130
+ if (sbVisible) {
1131
+ // Full gutter width (vw - contentW, normally 4px): child decorations
1132
+ // (outset shadows) poke past the content area and would survive as 1px
1133
+ // ticks in any uncovered edge column.
1134
+ display_canvasFillRect(band, contentW, 0, vw - contentW, thisH, sbTrackCol);
1135
+ int16_t thumbTop = static_cast<int16_t>(sbThumbY);
1136
+ int16_t thumbBot = static_cast<int16_t>(sbThumbY + sbThumbH);
1137
+ int16_t ovTop = thumbTop > bandTop ? thumbTop : bandTop;
1138
+ int16_t ovBot = thumbBot < bandBot ? thumbBot : bandBot;
1139
+ if (ovBot > ovTop) {
1140
+ display_canvasFillRect(band, contentW, static_cast<int16_t>(ovTop - bandTop), 3, static_cast<int16_t>(ovBot - ovTop), sbThumbCol);
1141
+ }
1142
+ } else {
1143
+ // No scrollbar (content fits) — clear the gutter to the scroll bg so the
1144
+ // previous screen's scrollbar pixels don't bleed through.
1145
+ display_canvasFillRect(band, contentW, 0, vw - contentW, thisH, scrollBg);
1146
+ }
1147
+ // Push the FULL viewport width in one transaction (single-write fast path:
1148
+ // w == stride). Each band — content + scrollbar slice — is fully rendered
1149
+ // before it touches the panel, eliminating both tearing and scrollbar flash.
1150
+ ui_push_canvas_rect(band, vox, static_cast<int16_t>(voy + bandTop), vw, thisH);
1151
+ }
1152
+ __ui_nodes[s].lastPaintedScrollY = __ui_nodes[s].scrollY;
1153
+ // The subtree was fully rendered this frame; the main loop's scroll-defer
1154
+ // path will skip these nodes (bufferedScrollDirectFull stays clear), and the
1155
+ // caller has already marked the owner non-dirty.
1156
+ for (uint16_t c = s + 1; c < subtreeEnd; c++) {
1157
+ __ui_nodes[c].dirty = 0;
1158
+ }
1159
+ return 1;
1160
+ }
1161
+
1162
+ // ── Whole-frame band composition (drawer slide frames) ────────────────────
1163
+ // Composes every visible active-screen node in draw order over a display
1164
+ // rect, one horizontal band at a time, pushing each band in a single
1165
+ // transaction. The drawer slide uses it over the union of the panel's old
1166
+ // and new paint rects: a mark-all-dirty slide step instead repainted the
1167
+ // whole screen with direct per-node clears (visible flashing on SPI TFTs,
1168
+ // and only ~3 steps fit inside the 180ms slide). Returns 0 when the band
1169
+ // canvas or draw order is unavailable (caller falls back to mark-all-dirty);
1170
+ // a rect fully outside the panel returns 1 (nothing to compose).
1171
+ static inline uint8_t ui_render_screen_bands(int16_t rx, int16_t ry, int16_t rw, int16_t rh) {
1172
+ if (rw <= 0 || rh <= 0) return 1;
1173
+ if (!ui_clip_rect_to_display_target(&rx, &ry, &rw, &rh)) return 1;
1174
+ if (!__ui_draw_order || !__ui_scroll_candidates) return 0;
1175
+ CuttlefishCanvas16* band = ui_band_canvas_for_width(rw);
1176
+ if (!band) return 0;
1177
+ int16_t bandH = display_canvasHeight(band);
1178
+ // Seed color: the active screen's background (the screen fill node paints
1179
+ // over it in draw order anyway; the seed keeps uncovered rows defined).
1180
+ UI_COLOR_T screenBg = static_cast<UI_COLOR_T>(0);
1181
+ if (__ui_active_screen_bg_node < __ui_node_count) {
1182
+ screenBg = __ui_nodes[__ui_active_screen_bg_node].hasBg
1183
+ ? __ui_nodes[__ui_active_screen_bg_node].bg
1184
+ : __ui_nodes[__ui_active_screen_bg_node].clearColor;
1185
+ }
1186
+ // Draw-ordered candidate list once per composition (same shape as the
1187
+ // scroll band path); each band then only Y-culls against it.
1188
+ __ui_scroll_candidate_count = 0;
1189
+ for (uint16_t pass = 0; pass < __ui_node_count; pass++) {
1190
+ uint16_t c = __ui_draw_order[pass];
1191
+ if (__ui_nodes[c].screenId != __ui_active_screen) continue;
1192
+ if (!ui_is_effectively_visible(c)) continue;
1193
+ if (__ui_scroll_candidate_count >= __ui_node_count) break;
1194
+ UIScrollPaintCandidate& cand = __ui_scroll_candidates[__ui_scroll_candidate_count++];
1195
+ cand.node = c;
1196
+ cand.screenY = ui_draw_y_for_node(c);
1197
+ cand.faceH = __ui_nodes[c].box.h;
1198
+ // Scrolled children clip to their scroll viewport (CSS overflow): a
1199
+ // compose over a region touching the viewport edge must not paint their
1200
+ // faces at absolute positions beyond it. The initial whole-screen compose
1201
+ // left stale button rows below the fold, and composes over the header
1202
+ // region smeared scrolled content into the header — bars of previously
1203
+ // scrolled pixels that nothing repaints. The viewport rect is the scroll
1204
+ // ancestor's own draw position (its scrollY applies to children, not to
1205
+ // itself).
1206
+ int16_t san = ui_scroll_ancestor_for_node(c);
1207
+ if (san >= 0 && __ui_nodes[san].box.h > 0 && __ui_nodes[san].box.w > 0) {
1208
+ cand.clipTop = ui_draw_y_for_node(static_cast<uint16_t>(san));
1209
+ cand.clipBottom = static_cast<int16_t>(cand.clipTop + __ui_nodes[san].box.h);
1210
+ cand.clipLeft = ui_draw_x_for_node(static_cast<uint16_t>(san));
1211
+ cand.clipRight = static_cast<int16_t>(cand.clipLeft + __ui_nodes[san].box.w);
1212
+ } else {
1213
+ cand.clipTop = -32767;
1214
+ cand.clipBottom = 32767;
1215
+ cand.clipLeft = -32767;
1216
+ cand.clipRight = 32767;
1217
+ }
1218
+ }
1219
+ CuttlefishDisplayTarget* prevTarget = ui_display_get_target();
1220
+ for (int16_t bandTop = 0; bandTop < rh; bandTop += bandH) {
1221
+ int16_t bandBot = static_cast<int16_t>(bandTop + bandH);
1222
+ if (bandBot > rh) bandBot = rh;
1223
+ int16_t thisH = static_cast<int16_t>(bandBot - bandTop);
1224
+ display_canvasFillRect(band, 0, 0, rw, thisH, screenBg);
1225
+ ui_display_set_target(band);
1226
+ for (uint16_t ci = 0; ci < __ui_scroll_candidate_count; ci++) {
1227
+ uint16_t c = __ui_scroll_candidates[ci].node;
1228
+ int16_t screenY = __ui_scroll_candidates[ci].screenY;
1229
+ int16_t faceH = __ui_scroll_candidates[ci].faceH;
1230
+ // Clip before the per-node work (AGENTS.md): skip nodes whose face
1231
+ // does not intersect this band's Y range.
1232
+ if (screenY + faceH <= ry + bandTop) continue;
1233
+ if (screenY >= ry + bandBot) continue;
1234
+ int16_t origBoxX = __ui_nodes[c].box.x;
1235
+ int16_t origBoxY = __ui_nodes[c].box.y;
1236
+ int16_t origBoxW = __ui_nodes[c].box.w;
1237
+ int16_t origBoxH = __ui_nodes[c].box.h;
1238
+ __ui_nodes[c].box.x = static_cast<int16_t>(origBoxX - rx);
1239
+ __ui_nodes[c].box.y = static_cast<int16_t>(origBoxY - ry - bandTop);
1240
+ // Clamp the face to the scroll viewport (band-local DRAW coords). The
1241
+ // clamp must operate on the node's DRAW position — box.y is
1242
+ // content-local and the ancestor scroll is subtracted later, inside
1243
+ // ui_draw_y_for_node, so comparing box.y against display-space clip
1244
+ // bounds passed scrolled content whose CONTENT coordinate numerically
1245
+ // fell inside the viewport: a button scrolled up past the viewport top
1246
+ // painted over the header, and the smearing stuck (nothing repaints
1247
+ // the header afterwards). Clamp the draw position and shift the box by
1248
+ // the delta; text wrapping and layout metrics shrink with the box.
1249
+ // Fully clipped faces drop out here.
1250
+ {
1251
+ // int32 math: the unclipped sentinels are ±32767 and the band-local
1252
+ // conversion subtracts region/band offsets — int16 arithmetic wraps
1253
+ // (a wrapped-positive clipTop clamped every face to garbage and
1254
+ // dropped the candidate: the main screen's header card vanished
1255
+ // into the dark screen background, reading as a black rect).
1256
+ int32_t clipTopL = static_cast<int32_t>(__ui_scroll_candidates[ci].clipTop) - ry - bandTop;
1257
+ int32_t clipBotL = static_cast<int32_t>(__ui_scroll_candidates[ci].clipBottom) - ry - bandTop;
1258
+ int32_t clipLeftL = static_cast<int32_t>(__ui_scroll_candidates[ci].clipLeft) - rx;
1259
+ int32_t clipRightL = static_cast<int32_t>(__ui_scroll_candidates[ci].clipRight) - rx;
1260
+ int16_t dispY0 = ui_draw_y_for_node(c);
1261
+ int16_t dispX0 = ui_draw_x_for_node(c);
1262
+ int32_t y0 = dispY0 < clipTopL ? clipTopL : dispY0;
1263
+ int32_t y1 = static_cast<int32_t>(dispY0) + __ui_nodes[c].box.h > clipBotL ? clipBotL : static_cast<int32_t>(dispY0) + __ui_nodes[c].box.h;
1264
+ int32_t x0 = dispX0 < clipLeftL ? clipLeftL : dispX0;
1265
+ int32_t x1 = static_cast<int32_t>(dispX0) + __ui_nodes[c].box.w > clipRightL ? clipRightL : static_cast<int32_t>(dispX0) + __ui_nodes[c].box.w;
1266
+ if (x1 - x0 <= 0 || y1 - y0 <= 0) {
1267
+ __ui_nodes[c].box.x = origBoxX;
1268
+ __ui_nodes[c].box.y = origBoxY;
1269
+ __ui_nodes[c].box.w = origBoxW;
1270
+ __ui_nodes[c].box.h = origBoxH;
1271
+ continue;
1272
+ }
1273
+ // box.y/box.x already carry the band shift; add the clamp delta on
1274
+ // top (y0 - dispY0 is 0 when unclipped, preserving the shift).
1275
+ __ui_nodes[c].box.y = static_cast<int16_t>(__ui_nodes[c].box.y + (y0 - dispY0));
1276
+ __ui_nodes[c].box.h = static_cast<int16_t>(y1 - y0);
1277
+ __ui_nodes[c].box.x = static_cast<int16_t>(__ui_nodes[c].box.x + (x0 - dispX0));
1278
+ __ui_nodes[c].box.w = static_cast<int16_t>(x1 - x0);
1279
+ }
1280
+ int16_t baseDrawX = ui_base_draw_x_for_node(c);
1281
+ int16_t baseDrawY = ui_base_draw_y_for_node(c);
1282
+ int16_t drawX = ui_draw_x_for_node(c);
1283
+ int16_t drawY = ui_draw_y_for_node(c);
1284
+ // Outset shadow at the rest position, under the face (mirrors the
1285
+ // main loop; lists manage their own shadow elsewhere).
1286
+ if (__ui_nodes[c].kind != NODE_LIST) {
1287
+ __ui_nodes[c].box.x = baseDrawX;
1288
+ ui_draw_shadow(c, baseDrawY, 0);
1289
+ }
1290
+ __ui_nodes[c].box.x = drawX;
1291
+ uint8_t ts = __ui_nodes[c].textSize ? __ui_nodes[c].textSize : 2;
1292
+ uint16_t textMaxW = ui_node_text_max_width(c);
1293
+ uint16_t tw = 0;
1294
+ uint16_t th = 0;
1295
+ ui_node_text_layout_metrics(c, textMaxW, &tw, &th);
1296
+ uint16_t paintTextW = tw;
1297
+ uint16_t paintTextH = th;
1298
+ if (__ui_nodes[c].kind == NODE_TEXT || __ui_nodes[c].kind == NODE_SELECT) {
1299
+ uint16_t hInset = static_cast<uint16_t>(__ui_nodes[c].paddingLeft) + static_cast<uint16_t>(__ui_nodes[c].paddingRight) + static_cast<uint16_t>(__ui_nodes[c].borderWidth) * 2;
1300
+ uint16_t vInset = static_cast<uint16_t>(__ui_nodes[c].paddingTop) + static_cast<uint16_t>(__ui_nodes[c].paddingBottom) + static_cast<uint16_t>(__ui_nodes[c].borderWidth) * 2;
1301
+ paintTextW = static_cast<uint16_t>(tw + hInset);
1302
+ paintTextH = static_cast<uint16_t>(th + vInset);
1303
+ }
1304
+ if (__ui_nodes[c].kind == NODE_CHECK || __ui_nodes[c].kind == NODE_RADIO) {
1305
+ paintTextW = static_cast<uint16_t>(tw + 22);
1306
+ if (paintTextH < 16) paintTextH = 16;
1307
+ }
1308
+ const char* displayText = __ui_nodes[c].hasTextBinding
1309
+ ? __ui_nodes[c].textBuffer
1310
+ : __ui_nodes[c].text;
1311
+ UI_COLOR_T bColor = __ui_nodes[c].borderColor ? __ui_nodes[c].borderColor : __ui_nodes[c].fg;
1312
+ UI_COLOR_T fillBg = __ui_nodes[c].bg;
1313
+ if (__ui_nodes[c].opacity < 100) {
1314
+ UI_COLOR_T backdrop = ui_parent_clear_color(c);
1315
+ bColor = ui_blend(bColor, backdrop, __ui_nodes[c].opacity);
1316
+ fillBg = ui_blend(__ui_nodes[c].bg, backdrop, __ui_nodes[c].opacity);
1317
+ }
1318
+ UINodeDrawCtx ctx;
1319
+ ctx.drawY = drawY;
1320
+ ctx.bColor = bColor;
1321
+ ctx.fillBg = fillBg;
1322
+ ctx.ts = ts;
1323
+ ctx.textMaxW = textMaxW;
1324
+ ctx.tw = tw;
1325
+ ctx.th = th;
1326
+ ctx.paintTextW = paintTextW;
1327
+ ctx.paintTextH = paintTextH;
1328
+ ctx.displayText = displayText;
1329
+ ctx.drawingBufferedScroll = 0;
1330
+ ctx.drawTarget = prevTarget;
1331
+ ctx.origBoxX = origBoxX;
1332
+ ctx.origBoxY = origBoxY;
1333
+ // Each band starts on a freshly seeded canvas, so the persistent
1334
+ // incremental-paint caches (lastTextWidth/Height) are invalid mid-band.
1335
+ // Save/restore so the band path is transient (same contract as
1336
+ // ui_render_scroll_bands / ui_render_node_bands).
1337
+ int16_t savedLastTextW = __ui_nodes[c].lastTextWidth;
1338
+ uint16_t savedLastTextH = __ui_nodes[c].lastTextHeight;
1339
+ if (__ui_nodes[c].kind == NODE_PROGRESS || __ui_nodes[c].kind == NODE_RANGE) {
1340
+ __ui_nodes[c].lastTextWidth = -1;
1341
+ }
1342
+ __ui_nodes[c].lastTextHeight = 0;
1343
+ ui_draw_node_body(static_cast<int16_t>(c), &ctx);
1344
+ __ui_nodes[c].lastTextWidth = savedLastTextW;
1345
+ __ui_nodes[c].lastTextHeight = savedLastTextH;
1346
+ __ui_nodes[c].box.x = origBoxX;
1347
+ __ui_nodes[c].box.y = origBoxY;
1348
+ __ui_nodes[c].box.w = origBoxW;
1349
+ __ui_nodes[c].box.h = origBoxH;
1350
+ }
1351
+ ui_display_set_target(prevTarget);
1352
+ ui_push_canvas_rect(band, rx, static_cast<int16_t>(ry + bandTop), rw, thisH);
1353
+ }
1354
+ return 1;
1355
+ }
1356
+
1357
+ // ── Per-node band renderer ────────────────────────────────────────────────
1358
+ // Tear-free single-node repaint when the full node-sized repair canvas won't
1359
+ // allocate (e.g. a full-width button on a no-PSRAM target: a 284×52 paint rect
1360
+ // is ~29KB, too big for internal SRAM, so ui_get_repair_canvas returns null and
1361
+ // the repaint would otherwise fall back to direct clear→redraw-to-SPI, which
1362
+ // flashes). Mirrors ui_render_scroll_bands but composites a SINGLE node over
1363
+ // its paint rect, reusing the persistent __ui_band_canvas (~10KB at
1364
+ // UI_STRIP_BAND_HEIGHT, proven to allocate on no-PSRAM targets). Each band is
1365
+ // seeded with the parent backdrop, the node's shadow+body+outline composited
1366
+ // into it, then pushed one transaction per band so nothing reaches the
1367
+ // panel until the band is complete (no tearing). Returns 1 if the band canvas
1368
+ // allocated and the node was rendered; 0 if even the band canvas can't
1369
+ // allocate (caller falls back to the direct-draw path).
1370
+ //
1371
+ // prX/prY/prW/prH = the node's paint rect in DISPLAY coords (the union of
1372
+ // rest-shadow + pressed-face + outline, as computed by ui_node_paint_rect).
1373
+ static inline uint8_t ui_render_node_bands(uint16_t i, int16_t prX, int16_t prY, int16_t prW, int16_t prH) {
1374
+ if (i >= __ui_node_count || prW <= 0 || prH <= 0) return 0;
1375
+ // Band canvas: reuse the persistent slot, reallocated to prW width (so stride
1376
+ // == prW for the single-write push fast path). See ui_band_canvas_for_width.
1377
+ CuttlefishCanvas16* band = ui_band_canvas_for_width(prW);
1378
+ if (!band) return 0;
1379
+ int16_t bandH = display_canvasHeight(band);
1380
+
1381
+ // Display-space draw coords for the node (untranslated band-local offsets
1382
+ // are applied per band below). baseDraw excludes the pressed offset; draw
1383
+ // includes it (a pressed button's face sits 3px below its shadow).
1384
+ int16_t dispBaseDrawX = ui_base_draw_x_for_node(i);
1385
+ int16_t dispBaseDrawY = ui_base_draw_y_for_node(i);
1386
+ int16_t dispDrawX = ui_draw_x_for_node(i);
1387
+ int16_t dispDrawY = ui_draw_y_for_node(i);
1388
+ int16_t origBoxX = __ui_nodes[i].box.x;
1389
+ int16_t origBoxY = __ui_nodes[i].box.y;
1390
+
1391
+ // Per-kind draw metrics (same as the main loop / scroll band path).
1392
+ uint8_t ts = __ui_nodes[i].textSize ? __ui_nodes[i].textSize : 2;
1393
+ uint16_t textMaxW = ui_node_text_max_width(i);
1394
+ uint16_t tw = 0;
1395
+ uint16_t th = 0;
1396
+ ui_node_text_layout_metrics(i, textMaxW, &tw, &th);
1397
+ uint16_t paintTextW = tw;
1398
+ uint16_t paintTextH = th;
1399
+ if (__ui_nodes[i].kind == NODE_TEXT || __ui_nodes[i].kind == NODE_SELECT) {
1400
+ uint16_t hInset = static_cast<uint16_t>(__ui_nodes[i].paddingLeft) + static_cast<uint16_t>(__ui_nodes[i].paddingRight) + static_cast<uint16_t>(__ui_nodes[i].borderWidth) * 2;
1401
+ uint16_t vInset = static_cast<uint16_t>(__ui_nodes[i].paddingTop) + static_cast<uint16_t>(__ui_nodes[i].paddingBottom) + static_cast<uint16_t>(__ui_nodes[i].borderWidth) * 2;
1402
+ paintTextW = static_cast<uint16_t>(tw + hInset);
1403
+ paintTextH = static_cast<uint16_t>(th + vInset);
1404
+ }
1405
+ if (__ui_nodes[i].kind == NODE_CHECK || __ui_nodes[i].kind == NODE_RADIO) {
1406
+ paintTextW = static_cast<uint16_t>(tw + 22);
1407
+ if (paintTextH < 16) paintTextH = 16;
1408
+ }
1409
+ const char* displayText = __ui_nodes[i].hasTextBinding
1410
+ ? __ui_nodes[i].textBuffer
1411
+ : __ui_nodes[i].text;
1412
+ UI_COLOR_T bColor = __ui_nodes[i].borderColor ? __ui_nodes[i].borderColor : __ui_nodes[i].fg;
1413
+ UI_COLOR_T fillBg = __ui_nodes[i].bg;
1414
+ if (__ui_nodes[i].opacity < 100) {
1415
+ UI_COLOR_T backdrop = ui_parent_clear_color(i);
1416
+ bColor = ui_blend(bColor, backdrop, __ui_nodes[i].opacity);
1417
+ fillBg = ui_blend(__ui_nodes[i].bg, backdrop, __ui_nodes[i].opacity);
1418
+ }
1419
+
1420
+ CuttlefishDisplayTarget* prevTarget = ui_display_get_target();
1421
+ // Top→bottom bands over the paint rect.
1422
+ for (int16_t bandTop = 0; bandTop < prH; bandTop += bandH) {
1423
+ int16_t bandBot = bandTop + bandH;
1424
+ if (bandBot > prH) bandBot = prH;
1425
+ int16_t thisH = static_cast<int16_t>(bandBot - bandTop);
1426
+ // Seed the band with the parent backdrop at this band's Y slice. The seed
1427
+ // fills the whole band; bandTop shifts the parent fill rect so its rows
1428
+ // land in [0, thisH) of the canvas.
1429
+ ui_seed_paint_canvas_for_node(i, band, prX, prY + bandTop, bandTop);
1430
+ ui_display_set_target(band);
1431
+ // Band-local coords: subtract the paint-rect origin and the band's top row.
1432
+ int16_t baseDrawX = static_cast<int16_t>(dispBaseDrawX - prX);
1433
+ int16_t baseDrawY = static_cast<int16_t>(dispBaseDrawY - prY - bandTop);
1434
+ int16_t drawX = static_cast<int16_t>(dispDrawX - prX);
1435
+ int16_t drawY = static_cast<int16_t>(dispDrawY - prY - bandTop);
1436
+ // Outset shadow at the rest position (baseDraw), under the face. Mirrors
1437
+ // the main loop's pre-body shadow draw. Lists skip the outset shadow in
1438
+ // the main loop (their shadow is drawn elsewhere); match that here.
1439
+ uint8_t skipListOutsetShadow = (__ui_nodes[i].kind == NODE_LIST);
1440
+ if (!skipListOutsetShadow) {
1441
+ __ui_nodes[i].box.x = baseDrawX;
1442
+ ui_draw_shadow(i, baseDrawY, 0);
1443
+ }
1444
+ __ui_nodes[i].box.x = drawX;
1445
+ UINodeDrawCtx ctx;
1446
+ ctx.drawY = drawY;
1447
+ ctx.bColor = bColor;
1448
+ ctx.fillBg = fillBg;
1449
+ ctx.ts = ts;
1450
+ ctx.textMaxW = textMaxW;
1451
+ ctx.tw = tw;
1452
+ ctx.th = th;
1453
+ ctx.paintTextW = paintTextW;
1454
+ ctx.paintTextH = paintTextH;
1455
+ ctx.displayText = displayText;
1456
+ ctx.drawingBufferedScroll = 0;
1457
+ ctx.drawTarget = prevTarget;
1458
+ ctx.origBoxX = origBoxX;
1459
+ ctx.origBoxY = origBoxY;
1460
+ // Each band starts on a freshly seeded canvas, so the persistent
1461
+ // incremental-paint caches (lastTextWidth/Height) are invalid mid-band.
1462
+ // Save/restore so the band path is transient (see ui_render_scroll_bands).
1463
+ int16_t savedLastTextW = __ui_nodes[i].lastTextWidth;
1464
+ uint16_t savedLastTextH = __ui_nodes[i].lastTextHeight;
1465
+ if (__ui_nodes[i].kind == NODE_PROGRESS || __ui_nodes[i].kind == NODE_RANGE) {
1466
+ __ui_nodes[i].lastTextWidth = -1;
1467
+ }
1468
+ __ui_nodes[i].lastTextHeight = 0;
1469
+ if (!ui_draw_node_body(static_cast<int16_t>(i), &ctx)) {
1470
+ ui_draw_node_outline(i, __ui_nodes[i].box.x, drawY);
1471
+ }
1472
+ __ui_nodes[i].lastTextWidth = savedLastTextW;
1473
+ __ui_nodes[i].lastTextHeight = savedLastTextH;
1474
+ __ui_nodes[i].box.x = origBoxX;
1475
+ __ui_nodes[i].box.y = origBoxY;
1476
+ ui_display_set_target(prevTarget);
1477
+ // Push the full paint-rect width (== stride) in one transaction: each band
1478
+ // is complete before it touches the panel, eliminating the press flash.
1479
+ ui_push_canvas_rect(band, prX, static_cast<int16_t>(prY + bandTop), prW, thisH);
1480
+ }
1481
+ return 1;
1482
+ }
1483
+
1484
+ // ── List band renderer ────────────────────────────────────────────────────
1485
+ // Tear-free <list> rendering when the viewport-sized list canvas won't allocate
1486
+ // (a full-width list viewport is ~29KB+, too big for no-PSRAM SRAM, so the
1487
+ // shift-and-repair cache can't be built and the list would otherwise render
1488
+ // nothing). Mirrors ui_render_scroll_bands but composites the list's OWN
1489
+ // virtualized rows (via listItemFn) instead of a subtree walk. Reuses the
1490
+ // persistent __ui_band_canvas (~10KB at UI_STRIP_BAND_HEIGHT); each band is
1491
+ // fully rendered content rows + scrollbar slice before its single SPI
1492
+ // push, so the panel never shows a half-drawn frame. Returns 1 if the band
1493
+ // canvas allocated and the list was rendered+pushed+decorated; 0 if even the
1494
+ // band canvas can't allocate (caller renders nothing).
1495
+ //
1496
+ // Shift-and-repair is impossible without the viewport cache, so every repaint
1497
+ // re-renders all visible rows. For a typical ~10-20 row text list this is
1498
+ // affordable; lastPaintedScrollY is still updated so a later transition to
1499
+ // canvas mode (memory frees) starts correctly.
1500
+ static inline uint8_t ui_render_list_bands(uint16_t i) {
1501
+ if (i >= __ui_node_count) return 0;
1502
+ if (!__ui_nodes[i].listItemFn) return 0;
1503
+ int16_t bx = __ui_nodes[i].box.x;
1504
+ int16_t by = ui_draw_y_for_node(i);
1505
+ int16_t bw = __ui_nodes[i].box.w;
1506
+ int16_t bh = __ui_nodes[i].box.h;
1507
+ if (bw <= 0 || bh <= 0) return 0;
1508
+ uint16_t ih = __ui_nodes[i].listItemHeight > 0 ? __ui_nodes[i].listItemHeight : 24;
1509
+ uint16_t itemCount = __ui_nodes[i].listCount;
1510
+ int16_t listScrollY = __ui_nodes[i].scrollY;
1511
+ int16_t listContentH = __ui_nodes[i].contentHeight;
1512
+ UI_COLOR_T clearCol = __ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor;
1513
+ int16_t origBoxX = __ui_nodes[i].box.x;
1514
+ int16_t origBoxY = __ui_nodes[i].box.y;
1515
+
1516
+ // Reuse the persistent band canvas, reallocated to bw width (so stride == bw
1517
+ // for the single-write push fast path). See ui_band_canvas_for_width.
1518
+ CuttlefishCanvas16* band = ui_band_canvas_for_width(bw);
1519
+ if (!band) return 0;
1520
+ int16_t bandH = display_canvasHeight(band);
1521
+
1522
+ // Scrollbar geometry (viewport coords), same as the cached-canvas list path.
1523
+ // Track spans [0, bh); thumb spans [thumbY, thumbY + thumbH). 3px wide at
1524
+ // canvas-x (bw - 4). Composited per band so the full-width push is atomic.
1525
+ int16_t contentW = bw > 4 ? static_cast<int16_t>(bw - 4) : bw;
1526
+ uint8_t sbVisible = (listContentH > bh) ? 1 : 0;
1527
+ uint16_t sbThumbH = 0;
1528
+ uint16_t sbThumbY = 0;
1529
+ UI_COLOR_T sbTrackCol = (UI_COLOR_T)((__ui_nodes[i].fg >> 1) & UI_DIM_MASK);
1530
+ UI_COLOR_T sbThumbCol = __ui_nodes[i].fg;
1531
+ if (sbVisible) {
1532
+ sbThumbH = static_cast<uint32_t>(bh) * bh / listContentH;
1533
+ if (sbThumbH < 8) sbThumbH = 8;
1534
+ if (sbThumbH > static_cast<uint16_t>(bh)) sbThumbH = static_cast<uint16_t>(bh);
1535
+ int16_t maxScroll = listContentH - bh;
1536
+ sbThumbY = maxScroll > 0 ? static_cast<uint32_t>(bh - sbThumbH) * listScrollY / maxScroll : 0;
1537
+ }
1538
+
1539
+ char listBuf[UI_TEXT_BUF + 1];
1540
+ CuttlefishDisplayTarget* listBandPrevTarget = ui_display_get_target();
1541
+ // Top→bottom bands over the list viewport.
1542
+ for (int16_t bandTop = 0; bandTop < bh; bandTop += bandH) {
1543
+ int16_t bandBot = bandTop + bandH;
1544
+ if (bandBot > bh) bandBot = bh;
1545
+ int16_t thisH = static_cast<int16_t>(bandBot - bandTop);
1546
+ // Seed content area with the list bg; the gutter is filled by the
1547
+ // scrollbar composite below (or cleared to bg when no scrollbar).
1548
+ display_canvasFillRect(band, 0, 0, contentW, bandH, clearCol);
1549
+ if (!sbVisible) {
1550
+ display_canvasFillRect(band, contentW, 0, bw - contentW, bandH, clearCol);
1551
+ }
1552
+ // Draw visible items whose row intersects this band (canvas-local Y).
1553
+ display_targetSetTextWrap((CuttlefishDisplayTarget*)band, false);
1554
+ if (itemCount > 0) {
1555
+ // First/last item index whose [idx*ih - listScrollY, +ih) overlaps the
1556
+ // band's viewport-Y range [bandTop, bandBot).
1557
+ uint16_t first = static_cast<uint16_t>((listScrollY + bandTop) / ih);
1558
+ int32_t lastSigned = (static_cast<int32_t>(listScrollY) + bandBot - 1) / ih;
1559
+ uint16_t last = lastSigned < 0 ? 0 : static_cast<uint16_t>(lastSigned);
1560
+ if (itemCount > 0 && last >= itemCount) last = itemCount - 1;
1561
+ for (uint16_t idx = first; idx <= last; idx++) {
1562
+ int16_t itemY = static_cast<int16_t>(idx * ih) - listScrollY - bandTop;
1563
+ // Cull rows fully outside this band (the first/last estimate can be
1564
+ // off by one at the edges).
1565
+ if (itemY + static_cast<int16_t>(ih) <= 0) continue;
1566
+ if (itemY >= thisH) continue;
1567
+ __ui_nodes[i].listItemFn(idx, listBuf, UI_TEXT_BUF + 1);
1568
+ listBuf[UI_TEXT_BUF] = 0;
1569
+ ui_display_set_target(band);
1570
+ int16_t __ui_saved_off_x = __ui_draw_off_x;
1571
+ int16_t __ui_saved_off_y = __ui_draw_off_y;
1572
+ __ui_draw_off_x = 0;
1573
+ __ui_draw_off_y = 0;
1574
+ // Keep classic list glyphs in the canvas pixel buffer. Native
1575
+ // CuttlefishGFX text can disappear when copied from a canvas to
1576
+ // the panel, while the explicit glyph path remains compositable.
1577
+ ui_draw_list_text(listBuf, 4, itemY + static_cast<int16_t>(ih - 16) / 2,
1578
+ __ui_nodes[i].fg, clearCol, 2, __ui_nodes[i].fontFace, __ui_nodes[i].letterSpacing);
1579
+ __ui_draw_off_x = __ui_saved_off_x;
1580
+ __ui_draw_off_y = __ui_saved_off_y;
1581
+ ui_display_set_target(listBandPrevTarget);
1582
+ }
1583
+ }
1584
+ // Composite this band's scrollbar slice into the gutter (atomic full-width
1585
+ // push no separate erase/redraw cycle, no scrollbar flash).
1586
+ if (sbVisible) {
1587
+ // Full gutter width (bw - contentW): child decorations poking past the
1588
+ // content area must not survive in an uncovered edge column.
1589
+ display_canvasFillRect(band, contentW, 0, bw - contentW, thisH, sbTrackCol);
1590
+ int16_t thumbTop = static_cast<int16_t>(sbThumbY);
1591
+ int16_t thumbBot = static_cast<int16_t>(sbThumbY + sbThumbH);
1592
+ int16_t ovTop = thumbTop > bandTop ? thumbTop : bandTop;
1593
+ int16_t ovBot = thumbBot < bandBot ? thumbBot : bandBot;
1594
+ if (ovBot > ovTop) {
1595
+ display_canvasFillRect(band, contentW, static_cast<int16_t>(ovTop - bandTop), 3, static_cast<int16_t>(ovBot - ovTop), sbThumbCol);
1596
+ }
1597
+ }
1598
+ ui_push_canvas_rect(band, bx, static_cast<int16_t>(by + bandTop), bw, thisH);
1599
+ }
1600
+ // Static decoration on top of the composited content (same order as the
1601
+ // cached-canvas list path: outset shadow, border, outline).
1602
+ if (!__ui_fb) {
1603
+ ui_draw_shadow(i, by, 0);
1604
+ }
1605
+ ui_draw_shadow(i, by, 1);
1606
+ if (__ui_nodes[i].borderStyle != 0) {
1607
+ UI_COLOR_T bColor = __ui_nodes[i].borderColor ? __ui_nodes[i].borderColor : __ui_nodes[i].fg;
1608
+ ui_draw_node_border(i, bx, by, bColor);
1609
+ }
1610
+ ui_draw_node_outline(i, bx, by);
1611
+ __ui_list_canvas_node = static_cast<int16_t>(i);
1612
+ __ui_nodes[i].lastPaintedScrollY = listScrollY;
1613
+ __ui_nodes[i].dirty = 0;
1614
+ __ui_nodes[i].box.x = origBoxX;
1615
+ __ui_nodes[i].box.y = origBoxY;
1616
+ return 1;
1617
+ }
1618
+
1619
+ // Last-resort list renderer used only when neither the viewport canvas nor the
1620
+ // bounded band canvas can allocate. It keeps the list usable under severe heap
1621
+ // pressure; normal paths remain canvas-composited and tear-resistant.
1622
+ static inline uint8_t ui_render_list_direct(uint16_t i) {
1623
+ if (i >= __ui_node_count || !__ui_nodes[i].listItemFn) return 0;
1624
+ int16_t bx = __ui_nodes[i].box.x;
1625
+ int16_t by = ui_draw_y_for_node(i);
1626
+ int16_t bw = __ui_nodes[i].box.w;
1627
+ int16_t bh = __ui_nodes[i].box.h;
1628
+ if (bw <= 0 || bh <= 0) return 0;
1629
+ uint16_t ih = __ui_nodes[i].listItemHeight > 0 ? __ui_nodes[i].listItemHeight : 24;
1630
+ uint16_t count = __ui_nodes[i].listCount;
1631
+ int16_t scrollY = __ui_nodes[i].scrollY;
1632
+ UI_COLOR_T bg = __ui_nodes[i].hasBg ? __ui_nodes[i].bg : __ui_nodes[i].clearColor;
1633
+ CuttlefishDisplayTarget* target = ui_display_get_target();
1634
+ ui_display_set_target(target);
1635
+ // This fallback is used only when no framebuffer is active. Keep the fill,
1636
+ // native text, and scrollbar in one panel transaction so the visible fallback
1637
+ // remains coherent on SPI TFTs even though it does not use a canvas.
1638
+ if (!__ui_fb) display_startWrite();
1639
+ ui_display_fill_rect(bx, by, bw, bh, bg);
1640
+ char buf[UI_TEXT_BUF + 1];
1641
+ uint16_t first = static_cast<uint16_t>((scrollY + 15) / ih);
1642
+ int16_t lastY = static_cast<int16_t>(scrollY + bh - 16);
1643
+ uint16_t last = lastY >= 0 ? static_cast<uint16_t>(lastY / ih) : 0;
1644
+ if (count > 0 && last >= count) last = count - 1;
1645
+ if (count > 0 && first < count && first <= last) {
1646
+ for (uint16_t idx = first; idx <= last; idx++) {
1647
+ int16_t itemY = static_cast<int16_t>(idx * ih) - scrollY;
1648
+ __ui_nodes[i].listItemFn(idx, buf, UI_TEXT_BUF + 1);
1649
+ buf[UI_TEXT_BUF] = 0;
1650
+ int16_t savedX = __ui_draw_off_x;
1651
+ int16_t savedY = __ui_draw_off_y;
1652
+ __ui_draw_off_x = 0;
1653
+ __ui_draw_off_y = 0;
1654
+ ui_draw_list_text_direct(buf, bx + 4, by + itemY + static_cast<int16_t>(ih - 16) / 2,
1655
+ __ui_nodes[i].fg, bg, 2, __ui_nodes[i].fontFace, __ui_nodes[i].letterSpacing);
1656
+ __ui_draw_off_x = savedX;
1657
+ __ui_draw_off_y = savedY;
1658
+ }
1659
+ }
1660
+ if (__ui_nodes[i].contentHeight > bh) {
1661
+ int16_t tx = bx + bw - 4;
1662
+ uint16_t thumbH = static_cast<uint32_t>(bh) * bh / __ui_nodes[i].contentHeight;
1663
+ if (thumbH < 8) thumbH = 8;
1664
+ if (thumbH > static_cast<uint16_t>(bh)) thumbH = static_cast<uint16_t>(bh);
1665
+ int16_t maxScroll = __ui_nodes[i].contentHeight - bh;
1666
+ uint16_t thumbY = maxScroll > 0 ? static_cast<uint32_t>(bh - thumbH) * scrollY / maxScroll : 0;
1667
+ UI_COLOR_T dimFg = (UI_COLOR_T)((__ui_nodes[i].fg >> 1) & UI_DIM_MASK);
1668
+ ui_display_fill_rect(tx, by, 3, bh, dimFg);
1669
+ ui_display_fill_rect(tx, static_cast<int16_t>(by + thumbY), 3, thumbH, __ui_nodes[i].fg);
1670
+ }
1671
+ if (!__ui_fb) display_endWrite();
1672
+ ui_draw_shadow(i, by, 1);
1673
+ if (__ui_nodes[i].borderStyle != 0) {
1674
+ UI_COLOR_T border = __ui_nodes[i].borderColor ? __ui_nodes[i].borderColor : __ui_nodes[i].fg;
1675
+ ui_draw_node_border(i, bx, by, border);
1676
+ }
1677
+ ui_draw_node_outline(i, bx, by);
1678
+ __ui_nodes[i].lastPaintedScrollY = scrollY;
1679
+ __ui_nodes[i].dirty = 0;
1680
+ return 1;
1681
+ }
1682
+ `;
1683
+ }