@typecad/ui 1.0.0-alpha.7 → 1.0.0-alpha.9

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