@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
@@ -2,901 +2,901 @@
2
2
  // refresh begin, kb dirty flush, scroll Mode B/C, fb target, stacking draw loop.
3
3
  // See docs/superpowers/specs/2026-07-12-split-runtime-header-design.md.
4
4
  export function emitTickDirtyDrawPhase() {
5
- return `
6
-
7
- // ② Draw dirty nodes directly to the display object.
8
- // Begin a deferred-refresh frame: resets the dirty-rect accumulator. On TFT
9
- // (no backing store) this compiles to a no-op.
10
- ui_refresh_begin_frame();
11
- // Skip the node draw pass while the keyboard overlay is visible — its opaque
12
- // background covers everything underneath, so redrawing app nodes wastes SPI
13
- // bandwidth and causes flashing. Nodes redraw once when the keyboard closes
14
- // (ui_kb_close marks the edited input dirty; ui_kb_open had marked all dirty
15
- // on open so they're stale-but-covered while the keyboard is up).
16
- //
17
- // UI_HIDE_OSK (desktop SDL): the editing session still runs (buffer, target,
18
- // commit-on-close) so real-keyboard typing works, but the 6×4 grid isn't drawn
19
- // and the node pass ISN'T skipped — the app keeps rendering normally with the
20
- // edited input's textBuffer showing the typed text. The grid is redundant when
21
- // the host has a real keyboard.
22
- #if !defined(UI_HIDE_OSK)
23
- if (__ui_kb_visible) {
24
- // Redraw only what changed:
25
- // 1 = full redraw (open, shift toggle, page swap)
26
- // 2 = text row + single key (char insert, delete, highlight change)
27
- if (__ui_kb_dirty == 1) {
28
- ui_kb_draw();
29
- } else if (__ui_kb_dirty == 2) {
30
- // Buffer the targeted update through the keyboard canvas too — drawing
31
- // the text row directly to the display (fill_rect clear + text redraw)
32
- // flashes on SPI TFTs because the clear is visible for one frame.
33
- // Re-render into the canvas and push in one transaction. The canvas
34
- // already holds the last full-keyboard frame, so we only need to redraw
35
- // the text row + the single changed key on top of it.
36
- // (__ui_kb_canvas is declared static in the keyboard slice, earlier in
37
- // this same translation unit — no extern needed.)
38
- if (__ui_kb_canvas && display_canvasBuffer(__ui_kb_canvas)) {
39
- int16_t kw = __ui_kb_box.w;
40
- int16_t saveBoxX = __ui_kb_box.x;
41
- int16_t saveBoxY = __ui_kb_box.y;
42
- CuttlefishDisplayTarget* __kb_prev = ui_display_get_target();
43
- ui_display_set_target(__ui_kb_canvas);
44
- __ui_kb_box.x = 0;
45
- __ui_kb_box.y = 0;
46
- ui_kb_draw_text_row();
47
- uint8_t has_key_repaint = (__ui_kb_repaint_key >= 0 && __ui_kb_keys[__ui_kb_repaint_key].special != 255) ? 1 : 0;
48
- if (has_key_repaint) {
49
- ui_kb_draw_key(static_cast<uint8_t>(__ui_kb_repaint_key));
50
- }
51
- __ui_kb_box.x = saveBoxX;
52
- __ui_kb_box.y = saveBoxY;
53
- ui_display_set_target(__kb_prev);
54
- // Push only the changed region: text row alone (UI_KB_TEXT_H) when no
55
- // key highlight changed, or the full canvas when a key was repainted
56
- // (the key could be anywhere in the keyboard grid).
57
- display_startWrite();
58
- if (has_key_repaint) {
59
- display_setAddrWindow(saveBoxX, saveBoxY, kw, __ui_kb_box.h);
60
- display_writePixels(display_canvasBuffer(__ui_kb_canvas), static_cast<uint32_t>(kw) * __ui_kb_box.h);
61
- } else {
62
- display_setAddrWindow(saveBoxX, saveBoxY, kw, UI_KB_TEXT_H);
63
- display_writePixels(display_canvasBuffer(__ui_kb_canvas), static_cast<uint32_t>(kw) * UI_KB_TEXT_H);
64
- }
65
- display_endWrite();
66
- } else {
67
- // No canvas — fall back to direct draw (slow but correct).
68
- ui_kb_draw_text_row();
69
- if (__ui_kb_repaint_key >= 0 && __ui_kb_keys[__ui_kb_repaint_key].special != 255) {
70
- ui_kb_draw_key(static_cast<uint8_t>(__ui_kb_repaint_key));
71
- }
72
- }
73
- }
74
- __ui_kb_dirty = 0;
75
- __ui_kb_repaint_key = -1;
76
- return;
77
- }
78
- #endif
79
- // Select and seed the framebuffer before scroll compositors run. Band/list
80
- // renderers can then composite into this same off-screen target instead of
81
- // pushing their bands to the live panel before the main draw pass starts.
82
- // This prevents intermediate software-rendering seams; the final SPI transfer
83
- // remains sequential on panels without TE/vblank synchronization.
84
- CuttlefishCanvas16* __ui_fb = ui_get_framebuffer();
85
- CuttlefishDisplayTarget* __ui_draw_target = __ui_fb ? (CuttlefishDisplayTarget*)__ui_fb : display_defaultTarget();
86
- ui_fb_begin_frame();
87
- // A retained framebuffer only needs an SPI transfer when composition changed.
88
- // Avoid pushing a full frame on idle ticks; this is the main steady-state cost
89
- // of the PSRAM path on otherwise static screens.
90
- uint8_t __ui_fb_frame_dirty = 0;
91
- // Whether any node painted this tick. The select overlay below re-stamps
92
- // only when it or the frame beneath changed; an unconditional stamp pushes
93
- // the modal region over SPI every frame, which reads as constant
94
- // refreshing/tearing on no-TE TFT panels.
95
- uint8_t __ui_frame_painted = 0;
96
- if (__ui_fb && __ui_fb_needs_compose) {
97
- UI_COLOR_T fbBg = (UI_COLOR_T)0;
98
- if (__ui_active_screen_bg_node < __ui_node_count) {
99
- fbBg = __ui_nodes[__ui_active_screen_bg_node].hasBg
100
- ? __ui_nodes[__ui_active_screen_bg_node].bg
101
- : __ui_nodes[__ui_active_screen_bg_node].clearColor;
102
- }
103
- display_canvasFillScreen(__ui_fb, fbBg);
104
- ui_fb_add_rect(0, 0, display_width(), display_height());
105
- // A retained framebuffer cannot reuse the previous screen's pixels. Mark
106
- // every node on the active screen dirty for one coherent composition.
107
- for (uint16_t f = 0; f < __ui_node_count; f++) {
108
- if (__ui_nodes[f].screenId == __ui_active_screen) {
109
- __ui_nodes[f].dirty = 1;
110
- __ui_nodes[f].lastTextHeight = 0;
111
- __ui_nodes[f].layoutCacheKey = 0;
112
- if (__ui_nodes[f].kind == NODE_PROGRESS || __ui_nodes[f].kind == NODE_RANGE) __ui_nodes[f].lastTextWidth = -1;
113
- else __ui_nodes[f].lastTextWidth = 0;
114
- }
115
- }
116
- __ui_fb_needs_compose = 0;
117
- __ui_fb_frame_dirty = 1;
118
- }
119
-
120
- if (__ui_scroll_render_locked) {
121
- for (uint16_t lock = 0; lock < __ui_node_count; lock++) __ui_scroll_render_locked[lock] = 0;
122
- }
123
-
124
- // Process each dirty scroll container (Mode B shift-and-repair). A scroll
125
- // delta shifts existing canvas pixels by the delta and repaints only the
126
- // newly-exposed strip; a full invalidation (or no canvas) redraws the subtree.
127
- // One container per frame — the active scroll owner repaints via its canvas.
128
- int16_t bufferedScrollNode = -1; // int16: node index can exceed 127
129
- int16_t bufferedScrollVX = 0; // viewport origin X for coord translation
130
- int16_t bufferedScrollVY = 0; // viewport origin Y
131
- CuttlefishCanvas16* bufferedScrollCanvas = nullptr;
132
- CuttlefishCanvas16* bufferedScrollRepaintCanvas = nullptr;
133
- int16_t bufferedScrollRepaintY = 0;
134
- int16_t bufferedScrollRepaintH = 0;
135
- // Kept as a compatibility flag for the old compositor state machine. It is
136
- // never enabled: direct per-node SPI drawing is a visible tearing path, so a
137
- // missing canvas freezes the affected viewport instead.
138
- uint8_t bufferedScrollDirectFull = 0;
139
- // Local retained-canvas repair: dirty descendants draw directly into the
140
- // full-frame framebuffer while the existing scroll canvas is invalidated for
141
- // the next actual scroll. This avoids copying an unchanged viewport.
142
- uint8_t bufferedScrollLocalRepair = 0;
143
- // Band renderer pending: like bufferedScrollDirectFull but the actual paint
144
- // is deferred to the scroll owner's z-order slot in the main draw loop. The
145
- // band composites the whole subtree at once, so it must paint AFTER lower-z
146
- // nodes (e.g. the screen background fill) but BEFORE higher-z ones (header).
147
- // Painting it here (before the main loop) lets the bg fill overwrite it.
148
- uint8_t bufferedScrollBands = 0;
149
- for (uint16_t oi = 0; ; oi++) {
150
- uint16_t s;
151
- if (__ui_scroll_owners) {
152
- if (oi >= __ui_scroll_owner_count) break;
153
- s = __ui_scroll_owners[oi];
154
- } else {
155
- if (oi >= __ui_node_count) break;
156
- s = oi;
157
- if (!__ui_nodes[s].scrollable || __ui_nodes[s].virtualized) continue;
158
- }
159
- if (!ui_is_effectively_visible(s)) continue;
160
- if (__ui_nodes[s].screenId != __ui_active_screen) continue;
161
- if (__ui_nodes[s].contentHeight <= __ui_nodes[s].box.h) continue;
162
- if (!__ui_nodes[s].dirty) continue;
163
- // Virtualized lists manage their own rendering (NODE_LIST case in
164
- // ui_draw_node_body). Skip them in the scroll dispatch — the band
165
- // renderer walks their subtree (which is empty for virtualized lists),
166
- // leaving the list node itself unpainted.
167
- if (__ui_nodes[s].virtualized) continue;
168
- // The retained framebuffer only needs the full viewport for actual scroll
169
- // movement or the first composition. A local child update is repaired in
170
- // the existing scroll canvas and contributes its own paint rect later;
171
- // pushing the whole viewport here is the Forms-screen flash.
172
- if (__ui_fb) __ui_fb_frame_dirty = 1;
173
-
174
- int16_t vw = __ui_nodes[s].box.w;
175
- int16_t vh = __ui_nodes[s].box.h;
176
- int16_t vox = __ui_nodes[s].box.x;
177
- int16_t voy = __ui_nodes[s].box.y;
178
- UI_COLOR_T scrollBg = __ui_nodes[s].hasBg ? __ui_nodes[s].bg : __ui_nodes[s].clearColor;
179
- bufferedScrollCanvas = ui_get_container_canvas(vw, vh);
180
- // Track canvas-allocation success so ui_apply_scroll_delta can lock scrolling
181
- // for containers whose canvas won't fit (frozen-but-not-torn contract).
182
- if (__ui_scroll_canvas_ok) {
183
- __ui_scroll_canvas_ok[s] = bufferedScrollCanvas ? 1 : 0;
184
- }
185
- if (bufferedScrollCanvas) {
186
- bufferedScrollNode = static_cast<int16_t>(s);
187
- bufferedScrollVX = vox;
188
- bufferedScrollVY = voy;
189
-
190
- // Mode B: shift delta = how far scrollY moved since this canvas was last
191
- // painted. Small non-zero delta within one viewport → shift + repair strip.
192
- int16_t deltaY = __ui_nodes[s].scrollY - __ui_nodes[s].lastPaintedScrollY;
193
- int16_t absDelta = deltaY < 0 ? -deltaY : deltaY;
194
- uint8_t canShift = (deltaY != 0 && absDelta < vh);
195
-
196
- if (canShift) {
197
- int16_t exposedY = 0;
198
- int16_t exposedH = 0;
199
- ui_shift_container_canvas(bufferedScrollCanvas, deltaY, scrollBg, &exposedY, &exposedH);
200
- bufferedScrollRepaintCanvas = ui_get_repair_canvas(vw, exposedH);
201
- if (bufferedScrollRepaintCanvas) {
202
- bufferedScrollRepaintY = exposedY;
203
- bufferedScrollRepaintH = exposedH;
204
- display_canvasFillScreen(bufferedScrollRepaintCanvas, scrollBg);
205
- UIRect exposed = { vox, static_cast<int16_t>(voy + exposedY), vw, exposedH };
206
- for (uint16_t c = s + 1; c < __ui_nodes[s].subtreeEnd; c++) {
207
- if (!ui_is_effectively_visible(c) || __ui_nodes[c].screenId != __ui_active_screen) {
208
- __ui_nodes[c].dirty = 0;
209
- continue;
210
- }
211
- if (!__ui_nodes[c].dirty && exposedH > 0) {
212
- UIRect cr;
213
- ui_node_current_paint_rect(c, &cr);
214
- if (cr.w > 0 && cr.h > 0 &&
215
- ui_rects_intersect(cr.x, cr.y, cr.w, cr.h, exposed.x, exposed.y, exposed.w, exposed.h)) {
216
- __ui_nodes[c].dirty = 1;
217
- }
218
- }
219
- if (__ui_nodes[c].dirty) {
220
- if (__ui_nodes[c].kind == NODE_PROGRESS) __ui_nodes[c].lastTextWidth = -1;
221
- else if (__ui_nodes[c].kind == NODE_RANGE) __ui_nodes[c].lastTextWidth = -1;
222
- __ui_nodes[c].lastTextHeight = 0;
223
- }
224
- }
225
- } else {
226
- canShift = 0;
227
- }
228
- }
229
- if (!canShift) {
230
- // A zero-delta dirty scroll owner is usually a local child update (a
231
- // form binding, button transition, or animation). Keep the retained
232
- // canvas and its existing pixels in that case; the dirty descendants
233
- // will redraw in place and the final framebuffer union stays local.
234
- uint8_t localRepair = __ui_fb && deltaY == 0 &&
235
- __ui_nodes[s].lastPaintedScrollY == __ui_nodes[s].scrollY &&
236
- ui_scroll_subtree_has_dirty(s);
237
- if (localRepair) {
238
- // The framebuffer is already the authoritative composition surface for
239
- // this zero-delta update. Let dirty descendants draw there directly;
240
- // invalidate the retained scroll canvas after each child draw so it is
241
- // rebuilt before the next scroll gesture.
242
- bufferedScrollLocalRepair = 1;
243
- bufferedScrollCanvas = nullptr;
244
- bufferedScrollRepaintCanvas = nullptr;
245
- } else {
246
- // Initial composition, a large jump, or an owner-only invalidation:
247
- // seed the whole viewport and repaint its complete stacking subtree.
248
- for (uint16_t c = s; c < __ui_nodes[s].subtreeEnd; c++) {
249
- __ui_nodes[c].dirty = 1;
250
- if (__ui_nodes[c].kind == NODE_PROGRESS) __ui_nodes[c].lastTextWidth = -1;
251
- else if (__ui_nodes[c].kind == NODE_RANGE) __ui_nodes[c].lastTextWidth = -1;
252
- __ui_nodes[c].lastTextHeight = 0;
253
- }
254
- display_canvasFillScreen(bufferedScrollCanvas, scrollBg);
255
- if (__ui_fb) ui_fb_add_rect(__ui_nodes[s].box.x, __ui_nodes[s].box.y,
256
- __ui_nodes[s].box.w, __ui_nodes[s].box.h);
257
- }
258
- } else if (__ui_fb) {
259
- // Shift-and-repair changes the entire viewport's visible mapping.
260
- ui_fb_add_rect(__ui_nodes[s].box.x, __ui_nodes[s].box.y,
261
- __ui_nodes[s].box.w, __ui_nodes[s].box.h);
262
- }
263
- // The scroll owner is represented by bufferedScrollCanvas this frame.
264
- // Drawing it directly first clears the live display and makes scrolling
265
- // visibly flash before the canvas is pushed.
266
- __ui_nodes[s].dirty = 0;
267
- } else {
268
- // Canvas won't fit (no PSRAM / over budget). The band renderer composes the
269
- // whole visible subtree into a short horizontal band canvas (vw ×
270
- // UI_STRIP_BAND_HEIGHT) and pushes one band at a time, so each band
271
- // completes before it touches the panel — tear-free, with ~10KB of SRAM
272
- // regardless of program size. If the band canvas also cannot allocate,
273
- // the viewport is retained rather than using direct per-node SPI draws.
274
- if (__ui_scroll_canvas_ok) __ui_scroll_canvas_ok[s] = 0;
275
- // Defer the band render to the scroll owner's z-order slot in the main
276
- // draw loop (see bufferedScrollBands handling below). Painting here would
277
- // let lower-z nodes (screen bg fill) draw over the bands afterward. The
278
- // owner stays dirty so the main loop reaches its z-slot and triggers the
279
- // render; the band code clears it once painted. The subtree children are
280
- // cleared now so the main loop never draws them directly — the band path
281
- // is the sole renderer for the subtree this frame.
282
- bufferedScrollNode = static_cast<int16_t>(s);
283
- bufferedScrollVX = vox;
284
- bufferedScrollVY = voy;
285
- bufferedScrollBands = 1;
286
- for (uint16_t c = s + 1; c < __ui_nodes[s].subtreeEnd; c++) {
287
- __ui_nodes[c].dirty = 0;
288
- }
289
- break;
290
- } // Only one scroll container per frame (the canvas is reused for subsequent
291
- // ones in the next dirty frame). This matches the original design.
292
- break;
293
- }
294
- // The framebuffer target was selected and seeded before scroll compositors;
295
- // the stacking draw pass below uses the same __ui_draw_target. The retained
296
- // single buffer is a composition surface, not a synchronized panel swap.
297
-
298
- // Per-frame compose-region inflation (set by the overlap classification
299
- // below, consumed by the drawer subtree compose).
300
- for (uint8_t ds = 0; ds < UI_DRAWER_MAX; ds++) __ui_drawer_inflate_used[ds] = 0;
301
-
302
- // ── Dirty-rect merging (LVGL refresh-cycle technique) ─────────────────────
303
- // When several nodes repaint this frame, per-node transactions each pay
304
- // SPI setup overhead. Group the frame's dirty paint rects into merged
305
- // regions (inflate + greedy absorb, waste-capped) and composite each
306
- // multi-node region through the band renderer in ONE set of pushes.
307
- // Single-node and unmergeable rects keep the ordinary per-node paths.
308
- // Skipped entirely when a retained framebuffer composes the frame (it
309
- // already unions), when the keyboard overlay owns the screen, and for
310
- // nodes inside overflow scroll subtrees (the Mode B machinery owns them).
311
- if (!__ui_fb) {
312
- UIRect __ui_merge_rects[UI_MERGE_MAX_REGIONS];
313
- uint16_t __ui_merge_nodes[UI_MERGE_MAX_REGIONS][UI_MERGE_MAX_NODES];
314
- uint16_t __ui_merge_counts[UI_MERGE_MAX_REGIONS];
315
- uint16_t __ui_merge_region_count = 0;
316
- for (uint16_t mi = 0; mi < __ui_node_count && __ui_merge_region_count < UI_MERGE_MAX_REGIONS; mi++) {
317
- uint16_t m = __ui_draw_order ? __ui_draw_order[mi] : mi;
318
- if (!__ui_nodes[m].dirty) continue;
319
- if (__ui_nodes[m].screenId != __ui_active_screen) continue;
320
- if (!ui_is_effectively_visible(m)) continue;
321
- if (__ui_nodes[m].kind == NODE_LIST || __ui_nodes[m].virtualized) continue;
322
- if (ui_overflow_scroll_compositor(m) >= 0) continue; // scroll machinery owns it
323
- // A dirty ancestor's ladder turn owns this node's repaint: the ancestor
324
- // composes its whole subtree region (or ladders and re-marks its
325
- // descendants). Merging the node here composes it first, and the
326
- // ancestor's clear+fill then erases it — the erase + re-pop sequence
327
- // was the visible flash when pressing a button inside an open drawer.
328
- {
329
- uint16_t ap = __ui_nodes[m].parent;
330
- while (ap != UI_NO_PARENT && ap < __ui_node_count) {
331
- if (__ui_nodes[ap].dirty &&
332
- __ui_nodes[ap].screenId == __ui_active_screen) break;
333
- ap = __ui_nodes[ap].parent;
334
- }
335
- if (ap != UI_NO_PARENT && ap < __ui_node_count) continue;
336
- }
337
- UIRect mr;
338
- ui_node_current_paint_rect(m, &mr);
339
- if (mr.w <= 0 || mr.h <= 0) continue;
340
- // Greedy absorb into an existing region when they overlap after a small
341
- // inflation and the merged waste stays bounded.
342
- uint8_t absorbed = 0;
343
- for (uint16_t r = 0; r < __ui_merge_region_count && !absorbed; r++) {
344
- UIRect* reg = &__ui_merge_rects[r];
345
- int16_t x0 = reg->x - UI_MERGE_INFLATE, y0 = reg->y - UI_MERGE_INFLATE;
346
- int16_t x1 = static_cast<int16_t>(reg->x + reg->w + UI_MERGE_INFLATE), y1 = static_cast<int16_t>(reg->y + reg->h + UI_MERGE_INFLATE);
347
- if (mr.x >= x1 || static_cast<int16_t>(mr.x + mr.w) <= x0 ||
348
- mr.y >= y1 || static_cast<int16_t>(mr.y + mr.h) <= y0) continue;
349
- int16_t nx0 = reg->x < mr.x ? reg->x : mr.x;
350
- int16_t ny0 = reg->y < mr.y ? reg->y : mr.y;
351
- int16_t nx1 = static_cast<int16_t>(reg->x + reg->w > mr.x + mr.w ? reg->x + reg->w : mr.x + mr.w);
352
- int16_t ny1 = static_cast<int16_t>(reg->y + reg->h > mr.y + mr.h ? reg->y + reg->h : mr.y + mr.h);
353
- uint32_t mergedArea = static_cast<uint32_t>(nx1 - nx0) * static_cast<uint32_t>(ny1 - ny0);
354
- if (mergedArea > UI_MAX_BUFFERED_PAINT_PIXELS) continue; // too big to band-composite
355
- if (__ui_merge_counts[r] >= UI_MERGE_MAX_NODES) continue;
356
- reg->x = nx0; reg->y = ny0;
357
- reg->w = static_cast<int16_t>(nx1 - nx0); reg->h = static_cast<int16_t>(ny1 - ny0);
358
- __ui_merge_nodes[r][__ui_merge_counts[r]++] = m;
359
- absorbed = 1;
360
- }
361
- if (!absorbed) {
362
- __ui_merge_rects[__ui_merge_region_count] = mr;
363
- __ui_merge_nodes[__ui_merge_region_count][0] = m;
364
- __ui_merge_counts[__ui_merge_region_count] = 1;
365
- __ui_merge_region_count++;
366
- }
367
- }
368
- // Composite multi-node regions as one banded render; singletons keep the
369
- // per-node ladder (repair canvas etc. is cheaper for one node).
370
- for (uint16_t r = 0; r < __ui_merge_region_count; r++) {
371
- if (__ui_merge_counts[r] < 2) continue;
372
- const UIRect* reg = &__ui_merge_rects[r];
373
- if (ui_render_screen_bands(reg->x, reg->y, reg->w, reg->h)) {
374
- for (uint16_t k = 0; k < __ui_merge_counts[r]; k++) {
375
- __ui_nodes[__ui_merge_nodes[r][k]].dirty = 0;
376
- ui_invalidate_scroll_canvas_for_node(__ui_merge_nodes[r][k]);
377
- ui_refresh_add_rect(__ui_nodes[__ui_merge_nodes[r][k]].box.x, __ui_nodes[__ui_merge_nodes[r][k]].box.y,
378
- __ui_nodes[__ui_merge_nodes[r][k]].box.w, __ui_nodes[__ui_merge_nodes[r][k]].box.h);
379
- }
380
- __ui_frame_painted = 1;
381
- }
382
- // Band allocation failure: leave the nodes dirty; the per-node ladder
383
- // below still repaints them correctly.
384
- }
385
- }
386
-
387
- // Draw dirty nodes in stacking order: lower z-index first, then source order.
388
- // __ui_draw_order is built once in ui_init so each frame is O(N).
389
- for (uint16_t __ui_draw_pass = 0; __ui_draw_pass < __ui_node_count; ) {
390
- int16_t i;
391
- if (__ui_draw_order) {
392
- i = static_cast<int16_t>(__ui_draw_order[__ui_draw_pass++]);
393
- if (!__ui_nodes[i].dirty) continue;
394
- if (__ui_fb) __ui_fb_frame_dirty = 1;
395
- if (!ui_is_effectively_visible(i)) { __ui_nodes[i].dirty = 0; continue; }
396
- if (__ui_nodes[i].screenId != __ui_active_screen) { __ui_nodes[i].dirty = 0; continue; }
397
- } else {
398
- // malloc failed at startup: preserve correct z-order via selection sort.
399
- i = -1;
400
- for (uint16_t candidate = 0; candidate < __ui_node_count; candidate++) {
401
- if (!__ui_nodes[candidate].dirty) continue;
402
- if (__ui_fb) __ui_fb_frame_dirty = 1;
403
- if (!ui_is_effectively_visible(candidate)) { __ui_nodes[candidate].dirty = 0; continue; }
404
- if (__ui_nodes[candidate].screenId != __ui_active_screen) { __ui_nodes[candidate].dirty = 0; continue; }
405
- if (i < 0 || ui_node_draws_before(candidate, static_cast<uint16_t>(i))) i = static_cast<int16_t>(candidate);
406
- }
407
- if (i < 0) break;
408
- __ui_draw_pass++;
409
- }
410
-
411
- // Open-drawer overlap classification: the drawer subtree draws above other
412
- // content (z-order + later draw slot). A dirty node underneath would
413
- // paint its clear+redraw over the panel's pixels — a bound text behind an
414
- // open drawer erases the drawer's buttons on repaint. Fully covered by an
415
- // opaque open drawer → none of it is visible; skip the repaint entirely.
416
- // Partially covered → re-dirty that drawer's subtree so it re-stamps on
417
- // top later in this same pass (its z sorts it after the covered node).
418
- if (__ui_drawer_slots > 0) {
419
- for (uint8_t dslot = 0; dslot < __ui_drawer_slots; dslot++) {
420
- if (__ui_drawer_idx[dslot] < 0) continue;
421
- uint16_t droot = static_cast<uint16_t>(__ui_drawer_idx[dslot]);
422
- if (static_cast<uint16_t>(i) == droot ||
423
- (i > static_cast<int16_t>(droot) && static_cast<uint16_t>(i) < __ui_nodes[droot].subtreeEnd)) continue;
424
- if (__ui_drawer_progress[dslot] < 255) continue; // closed or sliding
425
- int16_t dX = ui_draw_x_for_node(droot);
426
- int16_t dY = ui_draw_y_for_node(droot);
427
- int16_t nX = ui_draw_x_for_node(static_cast<uint16_t>(i));
428
- int16_t nY = ui_draw_y_for_node(static_cast<uint16_t>(i));
429
- if (!ui_rects_intersect(nX, nY, __ui_nodes[i].box.w, __ui_nodes[i].box.h,
430
- dX, dY, __ui_nodes[droot].box.w, __ui_nodes[droot].box.h)) continue;
431
- uint8_t fullyCovered = (nX >= dX && nY >= dY &&
432
- nX + __ui_nodes[i].box.w <= dX + __ui_nodes[droot].box.w &&
433
- nY + __ui_nodes[i].box.h <= dY + __ui_nodes[droot].box.h) ? 1 : 0;
434
- if (fullyCovered && __ui_nodes[droot].hasBg && __ui_nodes[droot].opacity >= 100) {
435
- __ui_nodes[i].dirty = 0;
436
- } else if (ui_overflow_scroll_compositor(droot) >= 0) {
437
- // Partial coverage, but the drawer sits inside an overflow scroll
438
- // subtree that is NOT being composited this frame. Re-dirtying the
439
- // drawer here sends its whole subtree through the defer fall-through
440
- // (direct band/paint-canvas pushes over a scroll viewport) — the
441
- // tearing path AGENTS.md forbids, and on hardware it corrupts the
442
- // panel content (drawer text and buttons vanish while remaining
443
- // tappable). Skip the covered node instead: its uncovered sliver
444
- // (typically a few px of a full-width label) is not worth the
445
- // unsafe redraw. When the scroll owner IS composited (dirty this
446
- // frame), the re-dirty below is safe — the composition redraws the
447
- // whole viewport including the drawer, in order.
448
- __ui_nodes[i].dirty = 0;
449
- } else {
450
- // Partial coverage, display path. The node's own ladder would paint
451
- // its FULL rect — including the covered part — straight over the
452
- // open panel, flashing underlying content as a bar until the panel
453
- // re-stamps. Skip its direct repaint and let the drawer's subtree
454
- // compose paint it (stacking order puts it under the panel); that
455
- // compose region is inflated to reach the node's uncovered sliver.
456
- UIRect nr = { nX, nY, __ui_nodes[i].box.w, __ui_nodes[i].box.h };
457
- if (!__ui_drawer_inflate_used[dslot]) {
458
- __ui_drawer_inflate_rect[dslot] = nr;
459
- __ui_drawer_inflate_used[dslot] = 1;
460
- } else {
461
- UIRect* inf = &__ui_drawer_inflate_rect[dslot];
462
- int16_t x1 = static_cast<int16_t>(inf->x + inf->w > nr.x + nr.w ? inf->x + inf->w : nr.x + nr.w);
463
- int16_t y1 = static_cast<int16_t>(inf->y + inf->h > nr.y + nr.h ? inf->y + inf->h : nr.y + nr.h);
464
- if (nr.x < inf->x) inf->x = nr.x;
465
- if (nr.y < inf->y) inf->y = nr.y;
466
- inf->w = static_cast<int16_t>(x1 - inf->x);
467
- inf->h = static_cast<int16_t>(y1 - inf->y);
468
- }
469
- __ui_nodes[i].dirty = 0;
470
- __ui_nodes[droot].dirty = 1;
471
- __ui_nodes[droot].lastTextHeight = 0;
472
- for (uint16_t c = droot + 1; c < __ui_nodes[droot].subtreeEnd; c++) {
473
- __ui_nodes[c].dirty = 1;
474
- __ui_nodes[c].lastTextHeight = 0;
475
- }
476
- }
477
- break;
478
- }
479
- if (!__ui_nodes[i].dirty) continue;
480
- }
481
-
482
- // Defer direct display draws for overflow scroll subtrees not composited this
483
- // frame (Mode B canvas, Mode C strip, direct-full fallback, or band render).
484
- // Drawing them directly clears the live viewport and produces sequential
485
- // flashes (AGENTS.md) — except in direct-full mode, where there is no canvas
486
- // and direct draw is the only way to show content.
487
- {
488
- int16_t scrollComp = ui_overflow_scroll_compositor(static_cast<uint16_t>(i));
489
- if (scrollComp >= 0) {
490
- uint8_t compositing = scrollComp == bufferedScrollNode &&
491
- (bufferedScrollCanvas || bufferedScrollDirectFull || bufferedScrollBands || bufferedScrollLocalRepair);
492
- if (!compositing) {
493
- if (static_cast<uint16_t>(i) == static_cast<uint16_t>(scrollComp)) break;
494
- // Not composited this frame. A child FULLY inside its scroll
495
- // viewport repaints directly through its own buffered paint (paint
496
- // canvas or bands — a single atomic push; the epilogue invalidates
497
- // the retained scroll canvas so the next scroll recomposes). Only
498
- // partially/fully clipped children defer: their edge pixels must
499
- // come from the composited canvas, and drawing them directly would
500
- // paint outside the viewport.
501
- if (ui_is_rect_clipped_by_scroll(static_cast<uint16_t>(i),
502
- ui_draw_x_for_node(static_cast<uint16_t>(i)),
503
- ui_draw_y_for_node(static_cast<uint16_t>(i)),
504
- __ui_nodes[i].box.w, __ui_nodes[i].box.h)) {
505
- __ui_nodes[i].dirty = 0;
506
- continue;
507
- }
508
- }
509
- }
510
- }
511
-
512
- // Band renderer: paint the whole subtree at the scroll owner's z-order slot.
513
- // This must run AFTER lower-z nodes (e.g. the screen background fill, which
514
- // has lower source order) have been drawn, so they don't overwrite the bands.
515
- // When the main loop reaches the scroll owner itself, render the bands now
516
- // (its z-position), mark the owner handled, and continue.
517
- if (bufferedScrollBands && bufferedScrollNode >= 0 &&
518
- static_cast<uint16_t>(i) == static_cast<uint16_t>(bufferedScrollNode)) {
519
- if (ui_render_scroll_bands(static_cast<uint16_t>(bufferedScrollNode))) {
520
- __ui_frame_painted = 1;
521
- bufferedScrollBands = 0;
522
- bufferedScrollNode = -1;
523
- __ui_nodes[i].dirty = 0;
524
- continue;
525
- }
526
- // Band canvas allocation failed. Retain the existing viewport pixels rather
527
- // than falling back to direct-full SPI primitives; direct-full is the
528
- // tearing path this compositor is designed to prevent.
529
- bufferedScrollBands = 0;
530
- if (__ui_scroll_render_locked) __ui_scroll_render_locked[static_cast<uint16_t>(bufferedScrollNode)] = 1;
531
- __ui_nodes[static_cast<uint16_t>(bufferedScrollNode)].dirty = 0;
532
- bufferedScrollNode = -1;
533
- } // Band renderer early-push: if we're about to draw a node that comes AFTER
534
- // the scroll owner in draw order (e.g. a header with higher source order)
535
- // and the bands haven't rendered yet, render them first so they land below
536
- // that higher-z node. Mirrors the Mode B canvas early-push below.
537
- if (bufferedScrollBands && bufferedScrollNode >= 0 &&
538
- !(i > bufferedScrollNode && i < __ui_nodes[bufferedScrollNode].subtreeEnd) &&
539
- ui_node_draws_before(static_cast<uint16_t>(bufferedScrollNode), static_cast<uint16_t>(i))) {
540
- if (ui_render_scroll_bands(static_cast<uint16_t>(bufferedScrollNode))) {
541
- __ui_frame_painted = 1;
542
- bufferedScrollBands = 0;
543
- bufferedScrollNode = -1;
544
- } else {
545
- // Band alloc failed — retain the previous viewport rather than exposing
546
- // a direct-full per-primitive SPI repaint.
547
- bufferedScrollBands = 0;
548
- if (__ui_scroll_render_locked) __ui_scroll_render_locked[static_cast<uint16_t>(bufferedScrollNode)] = 1;
549
- __ui_nodes[static_cast<uint16_t>(bufferedScrollNode)].dirty = 0;
550
- bufferedScrollNode = -1;
551
- }
552
- }
553
-
554
- // Mode C strip / direct-full: scroll owner is not drawn directly (would fill
555
- // the viewport); its children are drawn directly below.
556
- if ((bufferedScrollDirectFull || bufferedScrollLocalRepair) && bufferedScrollNode >= 0 &&
557
- static_cast<uint16_t>(i) == static_cast<uint16_t>(bufferedScrollNode)) {
558
- __ui_nodes[i].dirty = 0;
559
- continue;
560
- }
561
-
562
- if (bufferedScrollNode >= 0 && bufferedScrollCanvas &&
563
- !(i > bufferedScrollNode && i < __ui_nodes[bufferedScrollNode].subtreeEnd) &&
564
- ui_node_draws_before(static_cast<uint16_t>(bufferedScrollNode), static_cast<uint16_t>(i)) &&
565
- !ui_scroll_subtree_has_dirty(static_cast<uint16_t>(bufferedScrollNode))) {
566
- ui_push_buffered_scroll_canvas(bufferedScrollCanvas, bufferedScrollRepaintCanvas,
567
- bufferedScrollNode, bufferedScrollVX, bufferedScrollVY,
568
- bufferedScrollRepaintY, bufferedScrollRepaintH, __ui_draw_target);
569
- __ui_frame_painted = 1;
570
- bufferedScrollNode = -1;
571
- bufferedScrollCanvas = nullptr;
572
- bufferedScrollRepaintCanvas = nullptr;
573
- }
574
-
575
- // Ladder-drawing a CONTAINER (fill/view panel) clears and redraws its
576
- // whole paint rect — everything drawn inside it (its descendants) is
577
- // erased by this pass. Mark-time propagation (ui_mark_dirty) already
578
- // marks descendants, but the merge pass composes and CLEARS some of
579
- // them before this node's ladder turn — a press inside an open drawer
580
- // left the panel for the ladder (too big to merge) while its texts and
581
- // buttons were merge-composed first; the panel's clear+fill then erased
582
- // them and nothing re-drew them. Re-mark the descendants here: they sort
583
- // after this node, so they restore their pixels later in this same pass.
584
- // Scroll owners never reach this point (the compositor paths skip them),
585
- // so scroll-subtree repaints do not amplify.
586
- if (__ui_nodes[i].subtreeEnd > static_cast<uint16_t>(i) + 1 &&
587
- __ui_nodes[i].kind != NODE_LIST) {
588
- for (uint16_t c = static_cast<uint16_t>(i) + 1;
589
- c < __ui_nodes[i].subtreeEnd && c < __ui_node_count; c++) {
590
- if (!__ui_nodes[c].visible) continue;
591
- if (__ui_nodes[c].screenId != __ui_active_screen) continue;
592
- __ui_nodes[c].dirty = 1;
593
- }
594
- }
595
-
596
- // Redirect to the scroll canvas if this node is inside the buffered container
597
- // AND a real canvas is active this frame (Mode B only). Direct-strip and
598
- // direct-full have no canvas — their children must draw to the display target
599
- // (and thus qualify for the per-node paint-canvas optimization), not redirect
600
- // to a null scroll canvas. Without this, a strip-drag frame sets
601
- // drawingBufferedScroll=1, which skips the paint canvas and forces the
602
- // repainting children to direct-draw per-pixel to SPI (~100ms/text node).
603
- uint8_t drawingBufferedScroll = !bufferedScrollDirectFull &&
604
- (bufferedScrollCanvas != nullptr) && bufferedScrollNode >= 0 &&
605
- i > bufferedScrollNode && i < __ui_nodes[bufferedScrollNode].subtreeEnd;
606
- // A container with descendants reaches its ladder turn only when the
607
- // merge pass couldn't own it (too big to merge, or a dirty ancestor
608
- // excluded the children). The ladder clears + redraws just this node —
609
- // erasing every descendant pixel — and the re-marked descendants would
610
- // then pop back one ladder at a time: an erase + re-pop the eye reads
611
- // as a flash (pressing a button inside an open drawer). Instead compose
612
- // the container's whole subtree REGION through the band renderer — one
613
- // pass, correct stacking order, no intermediate state — and clear every
614
- // dirty node inside the region (the compose already painted them in
615
- // final form). Falls back to the ladder + re-marks when the band canvas
616
- // can't allocate. Display path only: into an active scroll canvas the
617
- // ladder draws (children re-mark into the canvas afterwards).
618
- if (!drawingBufferedScroll &&
619
- __ui_nodes[i].subtreeEnd > static_cast<uint16_t>(i) + 1 &&
620
- __ui_nodes[i].kind != NODE_LIST) {
621
- UIRect subtreeRegion;
622
- if (ui_subtree_current_paint_rect(static_cast<uint16_t>(i), &subtreeRegion) &&
623
- subtreeRegion.w > 0 && subtreeRegion.h > 0) {
624
- // An open drawer's compose region reaches the nodes the overlap
625
- // classification skipped this frame — they owe their uncovered
626
- // sliver, and the compose paints them under the panel in stacking
627
- // order.
628
- int8_t islot = __ui_drawer_slot_of(static_cast<uint16_t>(i));
629
- if (islot >= 0 && __ui_drawer_inflate_used[islot]) {
630
- const UIRect* inf = &__ui_drawer_inflate_rect[islot];
631
- int16_t x1 = static_cast<int16_t>(subtreeRegion.x + subtreeRegion.w > inf->x + inf->w ? subtreeRegion.x + subtreeRegion.w : inf->x + inf->w);
632
- int16_t y1 = static_cast<int16_t>(subtreeRegion.y + subtreeRegion.h > inf->y + inf->h ? subtreeRegion.y + subtreeRegion.h : inf->y + inf->h);
633
- if (inf->x < subtreeRegion.x) subtreeRegion.x = inf->x;
634
- if (inf->y < subtreeRegion.y) subtreeRegion.y = inf->y;
635
- subtreeRegion.w = static_cast<int16_t>(x1 - subtreeRegion.x);
636
- subtreeRegion.h = static_cast<int16_t>(y1 - subtreeRegion.y);
637
- __ui_drawer_inflate_used[islot] = 0;
638
- }
639
- if (ui_render_screen_bands(subtreeRegion.x, subtreeRegion.y, subtreeRegion.w, subtreeRegion.h)) {
640
- __ui_frame_painted = 1;
641
- ui_invalidate_scroll_canvas_for_node(static_cast<uint16_t>(i));
642
- for (uint16_t q = 0; q < __ui_node_count; q++) {
643
- if (__ui_nodes[q].screenId != __ui_active_screen) continue;
644
- if (!__ui_nodes[q].dirty) continue;
645
- UIRect qr;
646
- ui_node_current_paint_rect(q, &qr);
647
- if (qr.w <= 0 || qr.h <= 0) continue;
648
- if (qr.x >= subtreeRegion.x && qr.y >= subtreeRegion.y &&
649
- static_cast<int16_t>(qr.x + qr.w) <= static_cast<int16_t>(subtreeRegion.x + subtreeRegion.w) &&
650
- static_cast<int16_t>(qr.y + qr.h) <= static_cast<int16_t>(subtreeRegion.y + subtreeRegion.h)) {
651
- __ui_nodes[q].dirty = 0;
652
- }
653
- }
654
- continue;
655
- }
656
- }
657
- }
658
- int16_t origBoxX = __ui_nodes[i].box.x;
659
- int16_t origBoxY = __ui_nodes[i].box.y;
660
- if (drawingBufferedScroll) {
661
- CuttlefishCanvas16* scrollDrawCanvas = bufferedScrollRepaintCanvas ? bufferedScrollRepaintCanvas : bufferedScrollCanvas;
662
- ui_display_set_target(scrollDrawCanvas);
663
- // Translate display coords → canvas-local coords (subtract viewport origin).
664
- __ui_nodes[i].box.x = origBoxX - bufferedScrollVX;
665
- __ui_nodes[i].box.y = origBoxY - bufferedScrollVY - (bufferedScrollRepaintCanvas ? bufferedScrollRepaintY : 0);
666
- } else {
667
- ui_display_set_target(__ui_draw_target);
668
- }
669
- int16_t baseDrawX = ui_base_draw_x_for_node(i);
670
- int16_t baseDrawY = ui_base_draw_y_for_node(i);
671
- int16_t drawX = ui_draw_x_for_node(i);
672
- int16_t drawY = ui_draw_y_for_node(i);
673
- // Source selection: text-bound nodes show their dynamic buffer; others show
674
- // the immutable flash literal.
675
- const char* displayText = __ui_nodes[i].hasTextBinding
676
- ? __ui_nodes[i].textBuffer
677
- : __ui_nodes[i].text;
678
- uint8_t ts = __ui_nodes[i].textSize ? __ui_nodes[i].textSize : 2;
679
- uint16_t textMaxW = ui_node_text_max_width(i);
680
- uint16_t tw = 0;
681
- uint16_t th = 0;
682
- ui_node_text_layout_metrics(i, textMaxW, &tw, &th);
683
- uint16_t paintTextW = tw;
684
- uint16_t paintTextH = th;
685
- if (__ui_nodes[i].kind == NODE_TEXT || __ui_nodes[i].kind == NODE_SELECT) {
686
- 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;
687
- 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;
688
- paintTextW = static_cast<uint16_t>(tw + hInset);
689
- paintTextH = static_cast<uint16_t>(th + vInset);
690
- }
691
- if (__ui_nodes[i].kind == NODE_CHECK || __ui_nodes[i].kind == NODE_RADIO) {
692
- paintTextW = tw + 22;
693
- if (paintTextH < 16) paintTextH = 16;
694
- }
695
-
696
- UIRect paintRect;
697
- ui_node_paint_rect(i, baseDrawX, baseDrawY, drawX, drawY, paintTextW, paintTextH, &paintRect);
698
- if (drawingBufferedScroll) {
699
- // Canvas-local clip: skip nodes fully outside the viewport (0..vw, 0..vh).
700
- // Use the node's face rect (box.w/h), NOT the paint rect — the paint rect
701
- // includes shadow/border extents, which legitimately overflow a scroll
702
- // viewport. Clipping on the paint rect falsely rejects nodes whose shadow
703
- // pokes past the container edge while the face is fully inside (showed up
704
- // as the home-nav buttons never painting on ST7796S, where the nav
705
- // container is exactly button-width).
706
- int16_t faceX = drawX;
707
- int16_t faceY = drawY;
708
- int16_t faceW = __ui_nodes[i].box.w;
709
- int16_t faceH = __ui_nodes[i].box.h;
710
- CuttlefishCanvas16* scrollDrawCanvas = bufferedScrollRepaintCanvas ? bufferedScrollRepaintCanvas : bufferedScrollCanvas;
711
- // Guard against a null canvas (the subtree was flagged drawingBufferedScroll
712
- // but no canvas is available this frame — e.g. a second scroll owner whose
713
- // canvas couldn't allocate). Skip the canvas-local clip and draw the node
714
- // to the display target; the scroll-viewport clip below still bounds it.
715
- if (scrollDrawCanvas) {
716
- int16_t scrollDrawW = display_canvasWidth(scrollDrawCanvas);
717
- int16_t scrollDrawH = display_canvasHeight(scrollDrawCanvas);
718
- if (bufferedScrollRepaintCanvas) {
719
- scrollDrawW = __ui_nodes[bufferedScrollNode].box.w;
720
- scrollDrawH = bufferedScrollRepaintH;
721
- }
722
- if (faceY + faceH <= 0 || faceY >= scrollDrawH ||
723
- faceX + faceW <= 0 || faceX >= scrollDrawW) {
724
- __ui_nodes[i].box.x = origBoxX;
725
- __ui_nodes[i].box.y = origBoxY;
726
- __ui_nodes[i].dirty = 0;
727
- continue;
728
- }
729
- }
730
- } else if (ui_is_rect_clipped_by_scroll(i, drawX, drawY, __ui_nodes[i].box.w, __ui_nodes[i].box.h)) {
731
- __ui_nodes[i].box.x = origBoxX;
732
- __ui_nodes[i].box.y = origBoxY;
733
- __ui_nodes[i].dirty = 0;
734
- continue;
735
- }
736
-
737
- uint8_t drawingPaintCanvas = 0;
738
- CuttlefishCanvas16* paintCanvas = nullptr;
739
- int16_t paintCanvasX = paintRect.x;
740
- int16_t paintCanvasY = paintRect.y;
741
- int16_t paintCanvasW = paintRect.w;
742
- int16_t paintCanvasH = paintRect.h;
743
- // RAM-composite pixel-heavy nodes before SPI push. Skip when already drawing
744
- // into a scroll canvas or a full-screen framebuffer (both are RAM targets).
745
- uint8_t wantedBuffer = !drawingBufferedScroll && !__ui_fb && ui_should_buffer_paint(i, paintCanvasW, paintCanvasH);
746
- // Large paint rects prefer the band renderer (small ~10KB SRAM canvas,
747
- // strip-at-a-time) over the repair canvas even when the repair canvas could
748
- // allocate (e.g. in PSRAM). A large repair canvas has higher alloc/seed/push
749
- // latency than banding, which shows as a press/scroll flash; the band canvas
750
- // lives in fast internal SRAM and pushes smaller per-band transactions.
751
- uint8_t preferBand = wantedBuffer && (UI_BAND_PREFER_PIXELS > 0) &&
752
- (static_cast<uint32_t>(paintCanvasW) * static_cast<uint32_t>(paintCanvasH) > static_cast<uint32_t>(UI_BAND_PREFER_PIXELS));
753
- if (wantedBuffer && !preferBand) {
754
- paintCanvas = ui_get_repair_canvas(paintCanvasW, paintCanvasH);
755
- if (paintCanvas) {
756
- drawingPaintCanvas = 1;
757
- ui_display_set_target(paintCanvas);
758
- ui_seed_paint_canvas_for_node(i, paintCanvas, paintCanvasX, paintCanvasY, 0);
759
- baseDrawX -= paintCanvasX;
760
- baseDrawY -= paintCanvasY;
761
- drawX -= paintCanvasX;
762
- drawY -= paintCanvasY;
763
- if (__ui_nodes[i].kind == NODE_PROGRESS || __ui_nodes[i].kind == NODE_RANGE) {
764
- __ui_nodes[i].lastTextWidth = -1;
765
- }
766
- }
767
- }
768
- if (!drawingPaintCanvas) {
769
- // No repair canvas this frame — either the paint rect is large enough to
770
- // prefer the band renderer (preferBand: lower-latency than a big PSRAM
771
- // repair canvas), or the repair canvas wouldn't allocate (over budget /
772
- // heap fragmentation). Composite the node into the ~10KB band canvas one
773
- // horizontal strip at a time, pushing each band tear-free. Avoids the
774
- // direct clear→redraw-to-SPI that visibly flashes on press/scroll.
775
- if (wantedBuffer && ui_render_node_bands(static_cast<uint16_t>(i), paintCanvasX, paintCanvasY, paintCanvasW, paintCanvasH)) {
776
- __ui_frame_painted = 1;
777
- if (!drawingBufferedScroll) {
778
- ui_invalidate_scroll_canvas_for_node(i);
779
- }
780
- __ui_nodes[i].box.x = origBoxX;
781
- __ui_nodes[i].box.y = origBoxY;
782
- __ui_nodes[i].dirty = 0;
783
- ui_refresh_add_rect(__ui_nodes[i].box.x, __ui_nodes[i].box.y, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
784
- continue;
785
- }
786
- // Band canvas also failed — last resort: retry the repair canvas (it
787
- // might fit when the band canvas is contended). If both allocations fail,
788
- // lock this scroll owner for the frame and retain its previous pixels;
789
- // never fall through to direct primitive SPI draws, which visibly tear.
790
- if (wantedBuffer && preferBand) {
791
- paintCanvas = ui_get_repair_canvas(paintCanvasW, paintCanvasH);
792
- if (paintCanvas) {
793
- drawingPaintCanvas = 1;
794
- ui_display_set_target(paintCanvas);
795
- ui_seed_paint_canvas_for_node(i, paintCanvas, paintCanvasX, paintCanvasY, 0);
796
- baseDrawX -= paintCanvasX;
797
- baseDrawY -= paintCanvasY;
798
- drawX -= paintCanvasX;
799
- drawY -= paintCanvasY;
800
- }
801
- }
802
- if (!drawingPaintCanvas) {
803
- if (wantedBuffer && preferBand && __ui_scroll_render_locked &&
804
- __ui_nodes[i].scrollable) {
805
- __ui_scroll_render_locked[i] = 1;
806
- __ui_nodes[i].dirty = 0;
807
- __ui_nodes[i].box.x = origBoxX;
808
- __ui_nodes[i].box.y = origBoxY;
809
- continue;
810
- }
811
- ui_clear_press_offset_area(i, baseDrawX, baseDrawY, drawX, drawY, paintTextW, paintTextH);
812
- }
813
- }
814
- uint8_t skipListOutsetShadow =
815
- (__ui_nodes[i].kind == NODE_LIST && !drawingBufferedScroll && !__ui_fb);
816
- if (!skipListOutsetShadow) {
817
- __ui_nodes[i].box.x = baseDrawX;
818
- ui_draw_shadow(i, baseDrawY, 0);
819
- __ui_nodes[i].box.x = drawX;
820
- } else {
821
- __ui_nodes[i].box.x = drawX;
822
- }
823
-
824
- // Border color: use borderColor if set, otherwise fg.
825
- UI_COLOR_T bColor = __ui_nodes[i].borderColor ? __ui_nodes[i].borderColor : __ui_nodes[i].fg;
826
- // Background color blended toward clearColor by opacity (raw bg when 100%).
827
- // NODE_FILL draws the fill at this color so opacity actually fades the
828
- // element's background toward what's behind it.
829
- UI_COLOR_T fillBg = __ui_nodes[i].bg;
830
- // Apply opacity: blend fg/bg/border toward what's BEHIND the node when <100%.
831
- // NOTE: blend toward the parent's clear color (ui_parent_clear_color), not
832
- // the node's own clearColor — a filled node's clearColor IS its own bg, so
833
- // blending bg toward it is a no-op (red toward red = red). The parent clear
834
- // is the actual backdrop showing through the translucent element.
835
- if (__ui_nodes[i].opacity < 100) {
836
- UI_COLOR_T backdrop = ui_parent_clear_color(i);
837
- bColor = ui_blend(bColor, backdrop, __ui_nodes[i].opacity);
838
- fillBg = ui_blend(__ui_nodes[i].bg, backdrop, __ui_nodes[i].opacity);
839
- }
840
- // ── Per-kind draw dispatch ─────────────────────────────────────────────
841
- // The kind switch lives in ui_draw_node_body (node-draw-body slice) so the
842
- // strip/band renderer can reuse it. The ctx carries the draw state both
843
- // paths share; the list case may return 1 (it handled canvas push,
844
- // decoration, coord restore, and dirty-clear itself) in which case this
845
- // node is done — skip the post-switch epilogue exactly as the old inline
846
- // 'continue;' did.
847
- UINodeDrawCtx __ui_ctx;
848
- __ui_ctx.drawY = drawY;
849
- __ui_ctx.bColor = bColor;
850
- __ui_ctx.fillBg = fillBg;
851
- __ui_ctx.ts = ts;
852
- __ui_ctx.textMaxW = textMaxW;
853
- __ui_ctx.tw = tw;
854
- __ui_ctx.th = th;
855
- __ui_ctx.paintTextW = paintTextW;
856
- __ui_ctx.paintTextH = paintTextH;
857
- __ui_ctx.displayText = displayText;
858
- __ui_ctx.drawingBufferedScroll = drawingBufferedScroll;
859
- __ui_ctx.drawTarget = __ui_draw_target;
860
- __ui_ctx.origBoxX = origBoxX;
861
- __ui_ctx.origBoxY = origBoxY;
862
- __ui_frame_painted = 1;
863
- if (ui_draw_node_body(i, &__ui_ctx)) {
864
- continue;
865
- }
866
- if (__ui_nodes[i].kind == NODE_FILL && ui_rotation_quadrant(__ui_nodes[i].rotateDeg) != 0 &&
867
- __ui_nodes[i].outlineStyle != 0 && __ui_nodes[i].outlineWidth > 0) {
868
- uint8_t w = __ui_nodes[i].outlineWidth;
869
- int16_t outlineW = ui_rotated_face_w(i, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
870
- int16_t outlineH = ui_rotated_face_h(i, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
871
- ui_draw_rect_outline(__ui_nodes[i].box.x - w, drawY - w,
872
- outlineW + 2 * w, outlineH + 2 * w,
873
- __ui_nodes[i].borderRadius + w, __ui_nodes[i].outlineStyle, w, __ui_nodes[i].outlineColor);
874
- } else {
875
- ui_draw_node_outline(i, __ui_nodes[i].box.x, drawY);
876
- }
877
- if (drawingPaintCanvas) {
878
- ui_display_set_target(__ui_draw_target);
879
- ui_push_canvas_rect(paintCanvas, paintCanvasX, paintCanvasY, paintCanvasW, paintCanvasH);
880
- }
881
- if (!drawingBufferedScroll) {
882
- ui_invalidate_scroll_canvas_for_node(i);
883
- }
884
- // Restore original box coords (translated for canvas-local drawing above).
885
- __ui_nodes[i].box.x = origBoxX;
886
- __ui_nodes[i].box.y = origBoxY;
887
- __ui_nodes[i].dirty = 0;
888
- // Report this node's bounding box to the deferred-refresh accumulator. Phase
889
- // 4 uses the box as the dirty rect (a safe over-estimate); Phase 5 tightens
890
- // to the actual paint rect. No-op on TFT (compiles to nothing).
891
- if (__ui_fb) ui_fb_add_rect(paintRect.x, paintRect.y, paintRect.w, paintRect.h);
892
- ui_refresh_add_rect(__ui_nodes[i].box.x, __ui_nodes[i].box.y, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
893
- }
894
-
895
- // Modal <select> list: stamp the overlay after the dirty pass so tree
896
- // redraws never bury it. Stamped only when the overlay itself is dirty
897
- // (open) or something painted beneath it this frame; the close path marks
898
- // the whole tree dirty for the erase repaint.
899
- if (__ui_select_menu >= 0 && (__ui_select_menu_dirty || __ui_frame_painted)) {
900
- ui_select_menu_draw();
5
+ return `
6
+
7
+ // ② Draw dirty nodes directly to the display object.
8
+ // Begin a deferred-refresh frame: resets the dirty-rect accumulator. On TFT
9
+ // (no backing store) this compiles to a no-op.
10
+ ui_refresh_begin_frame();
11
+ // Skip the node draw pass while the keyboard overlay is visible — its opaque
12
+ // background covers everything underneath, so redrawing app nodes wastes SPI
13
+ // bandwidth and causes flashing. Nodes redraw once when the keyboard closes
14
+ // (ui_kb_close marks the edited input dirty; ui_kb_open had marked all dirty
15
+ // on open so they're stale-but-covered while the keyboard is up).
16
+ //
17
+ // UI_HIDE_OSK (desktop SDL): the editing session still runs (buffer, target,
18
+ // commit-on-close) so real-keyboard typing works, but the 6×4 grid isn't drawn
19
+ // and the node pass ISN'T skipped — the app keeps rendering normally with the
20
+ // edited input's textBuffer showing the typed text. The grid is redundant when
21
+ // the host has a real keyboard.
22
+ #if !defined(UI_HIDE_OSK)
23
+ if (__ui_kb_visible) {
24
+ // Redraw only what changed:
25
+ // 1 = full redraw (open, shift toggle, page swap)
26
+ // 2 = text row + single key (char insert, delete, highlight change)
27
+ if (__ui_kb_dirty == 1) {
28
+ ui_kb_draw();
29
+ } else if (__ui_kb_dirty == 2) {
30
+ // Buffer the targeted update through the keyboard canvas too — drawing
31
+ // the text row directly to the display (fill_rect clear + text redraw)
32
+ // flashes on SPI TFTs because the clear is visible for one frame.
33
+ // Re-render into the canvas and push in one transaction. The canvas
34
+ // already holds the last full-keyboard frame, so we only need to redraw
35
+ // the text row + the single changed key on top of it.
36
+ // (__ui_kb_canvas is declared static in the keyboard slice, earlier in
37
+ // this same translation unit — no extern needed.)
38
+ if (__ui_kb_canvas && display_canvasBuffer(__ui_kb_canvas)) {
39
+ int16_t kw = __ui_kb_box.w;
40
+ int16_t saveBoxX = __ui_kb_box.x;
41
+ int16_t saveBoxY = __ui_kb_box.y;
42
+ CuttlefishDisplayTarget* __kb_prev = ui_display_get_target();
43
+ ui_display_set_target(__ui_kb_canvas);
44
+ __ui_kb_box.x = 0;
45
+ __ui_kb_box.y = 0;
46
+ ui_kb_draw_text_row();
47
+ uint8_t has_key_repaint = (__ui_kb_repaint_key >= 0 && __ui_kb_keys[__ui_kb_repaint_key].special != 255) ? 1 : 0;
48
+ if (has_key_repaint) {
49
+ ui_kb_draw_key(static_cast<uint8_t>(__ui_kb_repaint_key));
50
+ }
51
+ __ui_kb_box.x = saveBoxX;
52
+ __ui_kb_box.y = saveBoxY;
53
+ ui_display_set_target(__kb_prev);
54
+ // Push only the changed region: text row alone (UI_KB_TEXT_H) when no
55
+ // key highlight changed, or the full canvas when a key was repainted
56
+ // (the key could be anywhere in the keyboard grid).
57
+ display_startWrite();
58
+ if (has_key_repaint) {
59
+ display_setAddrWindow(saveBoxX, saveBoxY, kw, __ui_kb_box.h);
60
+ display_writePixels(display_canvasBuffer(__ui_kb_canvas), static_cast<uint32_t>(kw) * __ui_kb_box.h);
61
+ } else {
62
+ display_setAddrWindow(saveBoxX, saveBoxY, kw, UI_KB_TEXT_H);
63
+ display_writePixels(display_canvasBuffer(__ui_kb_canvas), static_cast<uint32_t>(kw) * UI_KB_TEXT_H);
64
+ }
65
+ display_endWrite();
66
+ } else {
67
+ // No canvas — fall back to direct draw (slow but correct).
68
+ ui_kb_draw_text_row();
69
+ if (__ui_kb_repaint_key >= 0 && __ui_kb_keys[__ui_kb_repaint_key].special != 255) {
70
+ ui_kb_draw_key(static_cast<uint8_t>(__ui_kb_repaint_key));
71
+ }
72
+ }
73
+ }
74
+ __ui_kb_dirty = 0;
75
+ __ui_kb_repaint_key = -1;
76
+ return;
77
+ }
78
+ #endif
79
+ // Select and seed the framebuffer before scroll compositors run. Band/list
80
+ // renderers can then composite into this same off-screen target instead of
81
+ // pushing their bands to the live panel before the main draw pass starts.
82
+ // This prevents intermediate software-rendering seams; the final SPI transfer
83
+ // remains sequential on panels without TE/vblank synchronization.
84
+ CuttlefishCanvas16* __ui_fb = ui_get_framebuffer();
85
+ CuttlefishDisplayTarget* __ui_draw_target = __ui_fb ? (CuttlefishDisplayTarget*)__ui_fb : display_defaultTarget();
86
+ ui_fb_begin_frame();
87
+ // A retained framebuffer only needs an SPI transfer when composition changed.
88
+ // Avoid pushing a full frame on idle ticks; this is the main steady-state cost
89
+ // of the PSRAM path on otherwise static screens.
90
+ uint8_t __ui_fb_frame_dirty = 0;
91
+ // Whether any node painted this tick. The select overlay below re-stamps
92
+ // only when it or the frame beneath changed; an unconditional stamp pushes
93
+ // the modal region over SPI every frame, which reads as constant
94
+ // refreshing/tearing on no-TE TFT panels.
95
+ uint8_t __ui_frame_painted = 0;
96
+ if (__ui_fb && __ui_fb_needs_compose) {
97
+ UI_COLOR_T fbBg = (UI_COLOR_T)0;
98
+ if (__ui_active_screen_bg_node < __ui_node_count) {
99
+ fbBg = __ui_nodes[__ui_active_screen_bg_node].hasBg
100
+ ? __ui_nodes[__ui_active_screen_bg_node].bg
101
+ : __ui_nodes[__ui_active_screen_bg_node].clearColor;
102
+ }
103
+ display_canvasFillScreen(__ui_fb, fbBg);
104
+ ui_fb_add_rect(0, 0, display_width(), display_height());
105
+ // A retained framebuffer cannot reuse the previous screen's pixels. Mark
106
+ // every node on the active screen dirty for one coherent composition.
107
+ for (uint16_t f = 0; f < __ui_node_count; f++) {
108
+ if (__ui_nodes[f].screenId == __ui_active_screen) {
109
+ __ui_nodes[f].dirty = 1;
110
+ __ui_nodes[f].lastTextHeight = 0;
111
+ __ui_nodes[f].layoutCacheKey = 0;
112
+ if (__ui_nodes[f].kind == NODE_PROGRESS || __ui_nodes[f].kind == NODE_RANGE) __ui_nodes[f].lastTextWidth = -1;
113
+ else __ui_nodes[f].lastTextWidth = 0;
114
+ }
115
+ }
116
+ __ui_fb_needs_compose = 0;
117
+ __ui_fb_frame_dirty = 1;
118
+ }
119
+
120
+ if (__ui_scroll_render_locked) {
121
+ for (uint16_t lock = 0; lock < __ui_node_count; lock++) __ui_scroll_render_locked[lock] = 0;
122
+ }
123
+
124
+ // Process each dirty scroll container (Mode B shift-and-repair). A scroll
125
+ // delta shifts existing canvas pixels by the delta and repaints only the
126
+ // newly-exposed strip; a full invalidation (or no canvas) redraws the subtree.
127
+ // One container per frame — the active scroll owner repaints via its canvas.
128
+ int16_t bufferedScrollNode = -1; // int16: node index can exceed 127
129
+ int16_t bufferedScrollVX = 0; // viewport origin X for coord translation
130
+ int16_t bufferedScrollVY = 0; // viewport origin Y
131
+ CuttlefishCanvas16* bufferedScrollCanvas = nullptr;
132
+ CuttlefishCanvas16* bufferedScrollRepaintCanvas = nullptr;
133
+ int16_t bufferedScrollRepaintY = 0;
134
+ int16_t bufferedScrollRepaintH = 0;
135
+ // Kept as a compatibility flag for the old compositor state machine. It is
136
+ // never enabled: direct per-node SPI drawing is a visible tearing path, so a
137
+ // missing canvas freezes the affected viewport instead.
138
+ uint8_t bufferedScrollDirectFull = 0;
139
+ // Local retained-canvas repair: dirty descendants draw directly into the
140
+ // full-frame framebuffer while the existing scroll canvas is invalidated for
141
+ // the next actual scroll. This avoids copying an unchanged viewport.
142
+ uint8_t bufferedScrollLocalRepair = 0;
143
+ // Band renderer pending: like bufferedScrollDirectFull but the actual paint
144
+ // is deferred to the scroll owner's z-order slot in the main draw loop. The
145
+ // band composites the whole subtree at once, so it must paint AFTER lower-z
146
+ // nodes (e.g. the screen background fill) but BEFORE higher-z ones (header).
147
+ // Painting it here (before the main loop) lets the bg fill overwrite it.
148
+ uint8_t bufferedScrollBands = 0;
149
+ for (uint16_t oi = 0; ; oi++) {
150
+ uint16_t s;
151
+ if (__ui_scroll_owners) {
152
+ if (oi >= __ui_scroll_owner_count) break;
153
+ s = __ui_scroll_owners[oi];
154
+ } else {
155
+ if (oi >= __ui_node_count) break;
156
+ s = oi;
157
+ if (!__ui_nodes[s].scrollable || __ui_nodes[s].virtualized) continue;
158
+ }
159
+ if (!ui_is_effectively_visible(s)) continue;
160
+ if (__ui_nodes[s].screenId != __ui_active_screen) continue;
161
+ if (__ui_nodes[s].contentHeight <= __ui_nodes[s].box.h) continue;
162
+ if (!__ui_nodes[s].dirty) continue;
163
+ // Virtualized lists manage their own rendering (NODE_LIST case in
164
+ // ui_draw_node_body). Skip them in the scroll dispatch — the band
165
+ // renderer walks their subtree (which is empty for virtualized lists),
166
+ // leaving the list node itself unpainted.
167
+ if (__ui_nodes[s].virtualized) continue;
168
+ // The retained framebuffer only needs the full viewport for actual scroll
169
+ // movement or the first composition. A local child update is repaired in
170
+ // the existing scroll canvas and contributes its own paint rect later;
171
+ // pushing the whole viewport here is the Forms-screen flash.
172
+ if (__ui_fb) __ui_fb_frame_dirty = 1;
173
+
174
+ int16_t vw = __ui_nodes[s].box.w;
175
+ int16_t vh = __ui_nodes[s].box.h;
176
+ int16_t vox = __ui_nodes[s].box.x;
177
+ int16_t voy = __ui_nodes[s].box.y;
178
+ UI_COLOR_T scrollBg = __ui_nodes[s].hasBg ? __ui_nodes[s].bg : __ui_nodes[s].clearColor;
179
+ bufferedScrollCanvas = ui_get_container_canvas(vw, vh);
180
+ // Track canvas-allocation success so ui_apply_scroll_delta can lock scrolling
181
+ // for containers whose canvas won't fit (frozen-but-not-torn contract).
182
+ if (__ui_scroll_canvas_ok) {
183
+ __ui_scroll_canvas_ok[s] = bufferedScrollCanvas ? 1 : 0;
184
+ }
185
+ if (bufferedScrollCanvas) {
186
+ bufferedScrollNode = static_cast<int16_t>(s);
187
+ bufferedScrollVX = vox;
188
+ bufferedScrollVY = voy;
189
+
190
+ // Mode B: shift delta = how far scrollY moved since this canvas was last
191
+ // painted. Small non-zero delta within one viewport → shift + repair strip.
192
+ int16_t deltaY = __ui_nodes[s].scrollY - __ui_nodes[s].lastPaintedScrollY;
193
+ int16_t absDelta = deltaY < 0 ? -deltaY : deltaY;
194
+ uint8_t canShift = (deltaY != 0 && absDelta < vh);
195
+
196
+ if (canShift) {
197
+ int16_t exposedY = 0;
198
+ int16_t exposedH = 0;
199
+ ui_shift_container_canvas(bufferedScrollCanvas, deltaY, scrollBg, &exposedY, &exposedH);
200
+ bufferedScrollRepaintCanvas = ui_get_repair_canvas(vw, exposedH);
201
+ if (bufferedScrollRepaintCanvas) {
202
+ bufferedScrollRepaintY = exposedY;
203
+ bufferedScrollRepaintH = exposedH;
204
+ display_canvasFillScreen(bufferedScrollRepaintCanvas, scrollBg);
205
+ UIRect exposed = { vox, static_cast<int16_t>(voy + exposedY), vw, exposedH };
206
+ for (uint16_t c = s + 1; c < __ui_nodes[s].subtreeEnd; c++) {
207
+ if (!ui_is_effectively_visible(c) || __ui_nodes[c].screenId != __ui_active_screen) {
208
+ __ui_nodes[c].dirty = 0;
209
+ continue;
210
+ }
211
+ if (!__ui_nodes[c].dirty && exposedH > 0) {
212
+ UIRect cr;
213
+ ui_node_current_paint_rect(c, &cr);
214
+ if (cr.w > 0 && cr.h > 0 &&
215
+ ui_rects_intersect(cr.x, cr.y, cr.w, cr.h, exposed.x, exposed.y, exposed.w, exposed.h)) {
216
+ __ui_nodes[c].dirty = 1;
217
+ }
218
+ }
219
+ if (__ui_nodes[c].dirty) {
220
+ if (__ui_nodes[c].kind == NODE_PROGRESS) __ui_nodes[c].lastTextWidth = -1;
221
+ else if (__ui_nodes[c].kind == NODE_RANGE) __ui_nodes[c].lastTextWidth = -1;
222
+ __ui_nodes[c].lastTextHeight = 0;
223
+ }
224
+ }
225
+ } else {
226
+ canShift = 0;
227
+ }
228
+ }
229
+ if (!canShift) {
230
+ // A zero-delta dirty scroll owner is usually a local child update (a
231
+ // form binding, button transition, or animation). Keep the retained
232
+ // canvas and its existing pixels in that case; the dirty descendants
233
+ // will redraw in place and the final framebuffer union stays local.
234
+ uint8_t localRepair = __ui_fb && deltaY == 0 &&
235
+ __ui_nodes[s].lastPaintedScrollY == __ui_nodes[s].scrollY &&
236
+ ui_scroll_subtree_has_dirty(s);
237
+ if (localRepair) {
238
+ // The framebuffer is already the authoritative composition surface for
239
+ // this zero-delta update. Let dirty descendants draw there directly;
240
+ // invalidate the retained scroll canvas after each child draw so it is
241
+ // rebuilt before the next scroll gesture.
242
+ bufferedScrollLocalRepair = 1;
243
+ bufferedScrollCanvas = nullptr;
244
+ bufferedScrollRepaintCanvas = nullptr;
245
+ } else {
246
+ // Initial composition, a large jump, or an owner-only invalidation:
247
+ // seed the whole viewport and repaint its complete stacking subtree.
248
+ for (uint16_t c = s; c < __ui_nodes[s].subtreeEnd; c++) {
249
+ __ui_nodes[c].dirty = 1;
250
+ if (__ui_nodes[c].kind == NODE_PROGRESS) __ui_nodes[c].lastTextWidth = -1;
251
+ else if (__ui_nodes[c].kind == NODE_RANGE) __ui_nodes[c].lastTextWidth = -1;
252
+ __ui_nodes[c].lastTextHeight = 0;
253
+ }
254
+ display_canvasFillScreen(bufferedScrollCanvas, scrollBg);
255
+ if (__ui_fb) ui_fb_add_rect(__ui_nodes[s].box.x, __ui_nodes[s].box.y,
256
+ __ui_nodes[s].box.w, __ui_nodes[s].box.h);
257
+ }
258
+ } else if (__ui_fb) {
259
+ // Shift-and-repair changes the entire viewport's visible mapping.
260
+ ui_fb_add_rect(__ui_nodes[s].box.x, __ui_nodes[s].box.y,
261
+ __ui_nodes[s].box.w, __ui_nodes[s].box.h);
262
+ }
263
+ // The scroll owner is represented by bufferedScrollCanvas this frame.
264
+ // Drawing it directly first clears the live display and makes scrolling
265
+ // visibly flash before the canvas is pushed.
266
+ __ui_nodes[s].dirty = 0;
267
+ } else {
268
+ // Canvas won't fit (no PSRAM / over budget). The band renderer composes the
269
+ // whole visible subtree into a short horizontal band canvas (vw ×
270
+ // UI_STRIP_BAND_HEIGHT) and pushes one band at a time, so each band
271
+ // completes before it touches the panel — tear-free, with ~10KB of SRAM
272
+ // regardless of program size. If the band canvas also cannot allocate,
273
+ // the viewport is retained rather than using direct per-node SPI draws.
274
+ if (__ui_scroll_canvas_ok) __ui_scroll_canvas_ok[s] = 0;
275
+ // Defer the band render to the scroll owner's z-order slot in the main
276
+ // draw loop (see bufferedScrollBands handling below). Painting here would
277
+ // let lower-z nodes (screen bg fill) draw over the bands afterward. The
278
+ // owner stays dirty so the main loop reaches its z-slot and triggers the
279
+ // render; the band code clears it once painted. The subtree children are
280
+ // cleared now so the main loop never draws them directly — the band path
281
+ // is the sole renderer for the subtree this frame.
282
+ bufferedScrollNode = static_cast<int16_t>(s);
283
+ bufferedScrollVX = vox;
284
+ bufferedScrollVY = voy;
285
+ bufferedScrollBands = 1;
286
+ for (uint16_t c = s + 1; c < __ui_nodes[s].subtreeEnd; c++) {
287
+ __ui_nodes[c].dirty = 0;
288
+ }
289
+ break;
290
+ } // Only one scroll container per frame (the canvas is reused for subsequent
291
+ // ones in the next dirty frame). This matches the original design.
292
+ break;
293
+ }
294
+ // The framebuffer target was selected and seeded before scroll compositors;
295
+ // the stacking draw pass below uses the same __ui_draw_target. The retained
296
+ // single buffer is a composition surface, not a synchronized panel swap.
297
+
298
+ // Per-frame compose-region inflation (set by the overlap classification
299
+ // below, consumed by the drawer subtree compose).
300
+ for (uint8_t ds = 0; ds < UI_DRAWER_MAX; ds++) __ui_drawer_inflate_used[ds] = 0;
301
+
302
+ // ── Dirty-rect merging (LVGL refresh-cycle technique) ─────────────────────
303
+ // When several nodes repaint this frame, per-node transactions each pay
304
+ // SPI setup overhead. Group the frame's dirty paint rects into merged
305
+ // regions (inflate + greedy absorb, waste-capped) and composite each
306
+ // multi-node region through the band renderer in ONE set of pushes.
307
+ // Single-node and unmergeable rects keep the ordinary per-node paths.
308
+ // Skipped entirely when a retained framebuffer composes the frame (it
309
+ // already unions), when the keyboard overlay owns the screen, and for
310
+ // nodes inside overflow scroll subtrees (the Mode B machinery owns them).
311
+ if (!__ui_fb) {
312
+ UIRect __ui_merge_rects[UI_MERGE_MAX_REGIONS];
313
+ uint16_t __ui_merge_nodes[UI_MERGE_MAX_REGIONS][UI_MERGE_MAX_NODES];
314
+ uint16_t __ui_merge_counts[UI_MERGE_MAX_REGIONS];
315
+ uint16_t __ui_merge_region_count = 0;
316
+ for (uint16_t mi = 0; mi < __ui_node_count && __ui_merge_region_count < UI_MERGE_MAX_REGIONS; mi++) {
317
+ uint16_t m = __ui_draw_order ? __ui_draw_order[mi] : mi;
318
+ if (!__ui_nodes[m].dirty) continue;
319
+ if (__ui_nodes[m].screenId != __ui_active_screen) continue;
320
+ if (!ui_is_effectively_visible(m)) continue;
321
+ if (__ui_nodes[m].kind == NODE_LIST || __ui_nodes[m].virtualized) continue;
322
+ if (ui_overflow_scroll_compositor(m) >= 0) continue; // scroll machinery owns it
323
+ // A dirty ancestor's ladder turn owns this node's repaint: the ancestor
324
+ // composes its whole subtree region (or ladders and re-marks its
325
+ // descendants). Merging the node here composes it first, and the
326
+ // ancestor's clear+fill then erases it — the erase + re-pop sequence
327
+ // was the visible flash when pressing a button inside an open drawer.
328
+ {
329
+ uint16_t ap = __ui_nodes[m].parent;
330
+ while (ap != UI_NO_PARENT && ap < __ui_node_count) {
331
+ if (__ui_nodes[ap].dirty &&
332
+ __ui_nodes[ap].screenId == __ui_active_screen) break;
333
+ ap = __ui_nodes[ap].parent;
334
+ }
335
+ if (ap != UI_NO_PARENT && ap < __ui_node_count) continue;
336
+ }
337
+ UIRect mr;
338
+ ui_node_current_paint_rect(m, &mr);
339
+ if (mr.w <= 0 || mr.h <= 0) continue;
340
+ // Greedy absorb into an existing region when they overlap after a small
341
+ // inflation and the merged waste stays bounded.
342
+ uint8_t absorbed = 0;
343
+ for (uint16_t r = 0; r < __ui_merge_region_count && !absorbed; r++) {
344
+ UIRect* reg = &__ui_merge_rects[r];
345
+ int16_t x0 = reg->x - UI_MERGE_INFLATE, y0 = reg->y - UI_MERGE_INFLATE;
346
+ int16_t x1 = static_cast<int16_t>(reg->x + reg->w + UI_MERGE_INFLATE), y1 = static_cast<int16_t>(reg->y + reg->h + UI_MERGE_INFLATE);
347
+ if (mr.x >= x1 || static_cast<int16_t>(mr.x + mr.w) <= x0 ||
348
+ mr.y >= y1 || static_cast<int16_t>(mr.y + mr.h) <= y0) continue;
349
+ int16_t nx0 = reg->x < mr.x ? reg->x : mr.x;
350
+ int16_t ny0 = reg->y < mr.y ? reg->y : mr.y;
351
+ int16_t nx1 = static_cast<int16_t>(reg->x + reg->w > mr.x + mr.w ? reg->x + reg->w : mr.x + mr.w);
352
+ int16_t ny1 = static_cast<int16_t>(reg->y + reg->h > mr.y + mr.h ? reg->y + reg->h : mr.y + mr.h);
353
+ uint32_t mergedArea = static_cast<uint32_t>(nx1 - nx0) * static_cast<uint32_t>(ny1 - ny0);
354
+ if (mergedArea > UI_MAX_BUFFERED_PAINT_PIXELS) continue; // too big to band-composite
355
+ if (__ui_merge_counts[r] >= UI_MERGE_MAX_NODES) continue;
356
+ reg->x = nx0; reg->y = ny0;
357
+ reg->w = static_cast<int16_t>(nx1 - nx0); reg->h = static_cast<int16_t>(ny1 - ny0);
358
+ __ui_merge_nodes[r][__ui_merge_counts[r]++] = m;
359
+ absorbed = 1;
360
+ }
361
+ if (!absorbed) {
362
+ __ui_merge_rects[__ui_merge_region_count] = mr;
363
+ __ui_merge_nodes[__ui_merge_region_count][0] = m;
364
+ __ui_merge_counts[__ui_merge_region_count] = 1;
365
+ __ui_merge_region_count++;
366
+ }
367
+ }
368
+ // Composite multi-node regions as one banded render; singletons keep the
369
+ // per-node ladder (repair canvas etc. is cheaper for one node).
370
+ for (uint16_t r = 0; r < __ui_merge_region_count; r++) {
371
+ if (__ui_merge_counts[r] < 2) continue;
372
+ const UIRect* reg = &__ui_merge_rects[r];
373
+ if (ui_render_screen_bands(reg->x, reg->y, reg->w, reg->h)) {
374
+ for (uint16_t k = 0; k < __ui_merge_counts[r]; k++) {
375
+ __ui_nodes[__ui_merge_nodes[r][k]].dirty = 0;
376
+ ui_invalidate_scroll_canvas_for_node(__ui_merge_nodes[r][k]);
377
+ ui_refresh_add_rect(__ui_nodes[__ui_merge_nodes[r][k]].box.x, __ui_nodes[__ui_merge_nodes[r][k]].box.y,
378
+ __ui_nodes[__ui_merge_nodes[r][k]].box.w, __ui_nodes[__ui_merge_nodes[r][k]].box.h);
379
+ }
380
+ __ui_frame_painted = 1;
381
+ }
382
+ // Band allocation failure: leave the nodes dirty; the per-node ladder
383
+ // below still repaints them correctly.
384
+ }
385
+ }
386
+
387
+ // Draw dirty nodes in stacking order: lower z-index first, then source order.
388
+ // __ui_draw_order is built once in ui_init so each frame is O(N).
389
+ for (uint16_t __ui_draw_pass = 0; __ui_draw_pass < __ui_node_count; ) {
390
+ int16_t i;
391
+ if (__ui_draw_order) {
392
+ i = static_cast<int16_t>(__ui_draw_order[__ui_draw_pass++]);
393
+ if (!__ui_nodes[i].dirty) continue;
394
+ if (__ui_fb) __ui_fb_frame_dirty = 1;
395
+ if (!ui_is_effectively_visible(i)) { __ui_nodes[i].dirty = 0; continue; }
396
+ if (__ui_nodes[i].screenId != __ui_active_screen) { __ui_nodes[i].dirty = 0; continue; }
397
+ } else {
398
+ // malloc failed at startup: preserve correct z-order via selection sort.
399
+ i = -1;
400
+ for (uint16_t candidate = 0; candidate < __ui_node_count; candidate++) {
401
+ if (!__ui_nodes[candidate].dirty) continue;
402
+ if (__ui_fb) __ui_fb_frame_dirty = 1;
403
+ if (!ui_is_effectively_visible(candidate)) { __ui_nodes[candidate].dirty = 0; continue; }
404
+ if (__ui_nodes[candidate].screenId != __ui_active_screen) { __ui_nodes[candidate].dirty = 0; continue; }
405
+ if (i < 0 || ui_node_draws_before(candidate, static_cast<uint16_t>(i))) i = static_cast<int16_t>(candidate);
406
+ }
407
+ if (i < 0) break;
408
+ __ui_draw_pass++;
409
+ }
410
+
411
+ // Open-drawer overlap classification: the drawer subtree draws above other
412
+ // content (z-order + later draw slot). A dirty node underneath would
413
+ // paint its clear+redraw over the panel's pixels — a bound text behind an
414
+ // open drawer erases the drawer's buttons on repaint. Fully covered by an
415
+ // opaque open drawer → none of it is visible; skip the repaint entirely.
416
+ // Partially covered → re-dirty that drawer's subtree so it re-stamps on
417
+ // top later in this same pass (its z sorts it after the covered node).
418
+ if (__ui_drawer_slots > 0) {
419
+ for (uint8_t dslot = 0; dslot < __ui_drawer_slots; dslot++) {
420
+ if (__ui_drawer_idx[dslot] < 0) continue;
421
+ uint16_t droot = static_cast<uint16_t>(__ui_drawer_idx[dslot]);
422
+ if (static_cast<uint16_t>(i) == droot ||
423
+ (i > static_cast<int16_t>(droot) && static_cast<uint16_t>(i) < __ui_nodes[droot].subtreeEnd)) continue;
424
+ if (__ui_drawer_progress[dslot] < 255) continue; // closed or sliding
425
+ int16_t dX = ui_draw_x_for_node(droot);
426
+ int16_t dY = ui_draw_y_for_node(droot);
427
+ int16_t nX = ui_draw_x_for_node(static_cast<uint16_t>(i));
428
+ int16_t nY = ui_draw_y_for_node(static_cast<uint16_t>(i));
429
+ if (!ui_rects_intersect(nX, nY, __ui_nodes[i].box.w, __ui_nodes[i].box.h,
430
+ dX, dY, __ui_nodes[droot].box.w, __ui_nodes[droot].box.h)) continue;
431
+ uint8_t fullyCovered = (nX >= dX && nY >= dY &&
432
+ nX + __ui_nodes[i].box.w <= dX + __ui_nodes[droot].box.w &&
433
+ nY + __ui_nodes[i].box.h <= dY + __ui_nodes[droot].box.h) ? 1 : 0;
434
+ if (fullyCovered && __ui_nodes[droot].hasBg && __ui_nodes[droot].opacity >= 100) {
435
+ __ui_nodes[i].dirty = 0;
436
+ } else if (ui_overflow_scroll_compositor(droot) >= 0) {
437
+ // Partial coverage, but the drawer sits inside an overflow scroll
438
+ // subtree that is NOT being composited this frame. Re-dirtying the
439
+ // drawer here sends its whole subtree through the defer fall-through
440
+ // (direct band/paint-canvas pushes over a scroll viewport) — the
441
+ // tearing path AGENTS.md forbids, and on hardware it corrupts the
442
+ // panel content (drawer text and buttons vanish while remaining
443
+ // tappable). Skip the covered node instead: its uncovered sliver
444
+ // (typically a few px of a full-width label) is not worth the
445
+ // unsafe redraw. When the scroll owner IS composited (dirty this
446
+ // frame), the re-dirty below is safe — the composition redraws the
447
+ // whole viewport including the drawer, in order.
448
+ __ui_nodes[i].dirty = 0;
449
+ } else {
450
+ // Partial coverage, display path. The node's own ladder would paint
451
+ // its FULL rect — including the covered part — straight over the
452
+ // open panel, flashing underlying content as a bar until the panel
453
+ // re-stamps. Skip its direct repaint and let the drawer's subtree
454
+ // compose paint it (stacking order puts it under the panel); that
455
+ // compose region is inflated to reach the node's uncovered sliver.
456
+ UIRect nr = { nX, nY, __ui_nodes[i].box.w, __ui_nodes[i].box.h };
457
+ if (!__ui_drawer_inflate_used[dslot]) {
458
+ __ui_drawer_inflate_rect[dslot] = nr;
459
+ __ui_drawer_inflate_used[dslot] = 1;
460
+ } else {
461
+ UIRect* inf = &__ui_drawer_inflate_rect[dslot];
462
+ int16_t x1 = static_cast<int16_t>(inf->x + inf->w > nr.x + nr.w ? inf->x + inf->w : nr.x + nr.w);
463
+ int16_t y1 = static_cast<int16_t>(inf->y + inf->h > nr.y + nr.h ? inf->y + inf->h : nr.y + nr.h);
464
+ if (nr.x < inf->x) inf->x = nr.x;
465
+ if (nr.y < inf->y) inf->y = nr.y;
466
+ inf->w = static_cast<int16_t>(x1 - inf->x);
467
+ inf->h = static_cast<int16_t>(y1 - inf->y);
468
+ }
469
+ __ui_nodes[i].dirty = 0;
470
+ __ui_nodes[droot].dirty = 1;
471
+ __ui_nodes[droot].lastTextHeight = 0;
472
+ for (uint16_t c = droot + 1; c < __ui_nodes[droot].subtreeEnd; c++) {
473
+ __ui_nodes[c].dirty = 1;
474
+ __ui_nodes[c].lastTextHeight = 0;
475
+ }
476
+ }
477
+ break;
478
+ }
479
+ if (!__ui_nodes[i].dirty) continue;
480
+ }
481
+
482
+ // Defer direct display draws for overflow scroll subtrees not composited this
483
+ // frame (Mode B canvas, Mode C strip, direct-full fallback, or band render).
484
+ // Drawing them directly clears the live viewport and produces sequential
485
+ // flashes (AGENTS.md) — except in direct-full mode, where there is no canvas
486
+ // and direct draw is the only way to show content.
487
+ {
488
+ int16_t scrollComp = ui_overflow_scroll_compositor(static_cast<uint16_t>(i));
489
+ if (scrollComp >= 0) {
490
+ uint8_t compositing = scrollComp == bufferedScrollNode &&
491
+ (bufferedScrollCanvas || bufferedScrollDirectFull || bufferedScrollBands || bufferedScrollLocalRepair);
492
+ if (!compositing) {
493
+ if (static_cast<uint16_t>(i) == static_cast<uint16_t>(scrollComp)) break;
494
+ // Not composited this frame. A child FULLY inside its scroll
495
+ // viewport repaints directly through its own buffered paint (paint
496
+ // canvas or bands — a single atomic push; the epilogue invalidates
497
+ // the retained scroll canvas so the next scroll recomposes). Only
498
+ // partially/fully clipped children defer: their edge pixels must
499
+ // come from the composited canvas, and drawing them directly would
500
+ // paint outside the viewport.
501
+ if (ui_is_rect_clipped_by_scroll(static_cast<uint16_t>(i),
502
+ ui_draw_x_for_node(static_cast<uint16_t>(i)),
503
+ ui_draw_y_for_node(static_cast<uint16_t>(i)),
504
+ __ui_nodes[i].box.w, __ui_nodes[i].box.h)) {
505
+ __ui_nodes[i].dirty = 0;
506
+ continue;
507
+ }
508
+ }
509
+ }
510
+ }
511
+
512
+ // Band renderer: paint the whole subtree at the scroll owner's z-order slot.
513
+ // This must run AFTER lower-z nodes (e.g. the screen background fill, which
514
+ // has lower source order) have been drawn, so they don't overwrite the bands.
515
+ // When the main loop reaches the scroll owner itself, render the bands now
516
+ // (its z-position), mark the owner handled, and continue.
517
+ if (bufferedScrollBands && bufferedScrollNode >= 0 &&
518
+ static_cast<uint16_t>(i) == static_cast<uint16_t>(bufferedScrollNode)) {
519
+ if (ui_render_scroll_bands(static_cast<uint16_t>(bufferedScrollNode))) {
520
+ __ui_frame_painted = 1;
521
+ bufferedScrollBands = 0;
522
+ bufferedScrollNode = -1;
523
+ __ui_nodes[i].dirty = 0;
524
+ continue;
525
+ }
526
+ // Band canvas allocation failed. Retain the existing viewport pixels rather
527
+ // than falling back to direct-full SPI primitives; direct-full is the
528
+ // tearing path this compositor is designed to prevent.
529
+ bufferedScrollBands = 0;
530
+ if (__ui_scroll_render_locked) __ui_scroll_render_locked[static_cast<uint16_t>(bufferedScrollNode)] = 1;
531
+ __ui_nodes[static_cast<uint16_t>(bufferedScrollNode)].dirty = 0;
532
+ bufferedScrollNode = -1;
533
+ } // Band renderer early-push: if we're about to draw a node that comes AFTER
534
+ // the scroll owner in draw order (e.g. a header with higher source order)
535
+ // and the bands haven't rendered yet, render them first so they land below
536
+ // that higher-z node. Mirrors the Mode B canvas early-push below.
537
+ if (bufferedScrollBands && bufferedScrollNode >= 0 &&
538
+ !(i > bufferedScrollNode && i < __ui_nodes[bufferedScrollNode].subtreeEnd) &&
539
+ ui_node_draws_before(static_cast<uint16_t>(bufferedScrollNode), static_cast<uint16_t>(i))) {
540
+ if (ui_render_scroll_bands(static_cast<uint16_t>(bufferedScrollNode))) {
541
+ __ui_frame_painted = 1;
542
+ bufferedScrollBands = 0;
543
+ bufferedScrollNode = -1;
544
+ } else {
545
+ // Band alloc failed — retain the previous viewport rather than exposing
546
+ // a direct-full per-primitive SPI repaint.
547
+ bufferedScrollBands = 0;
548
+ if (__ui_scroll_render_locked) __ui_scroll_render_locked[static_cast<uint16_t>(bufferedScrollNode)] = 1;
549
+ __ui_nodes[static_cast<uint16_t>(bufferedScrollNode)].dirty = 0;
550
+ bufferedScrollNode = -1;
551
+ }
552
+ }
553
+
554
+ // Mode C strip / direct-full: scroll owner is not drawn directly (would fill
555
+ // the viewport); its children are drawn directly below.
556
+ if ((bufferedScrollDirectFull || bufferedScrollLocalRepair) && bufferedScrollNode >= 0 &&
557
+ static_cast<uint16_t>(i) == static_cast<uint16_t>(bufferedScrollNode)) {
558
+ __ui_nodes[i].dirty = 0;
559
+ continue;
560
+ }
561
+
562
+ if (bufferedScrollNode >= 0 && bufferedScrollCanvas &&
563
+ !(i > bufferedScrollNode && i < __ui_nodes[bufferedScrollNode].subtreeEnd) &&
564
+ ui_node_draws_before(static_cast<uint16_t>(bufferedScrollNode), static_cast<uint16_t>(i)) &&
565
+ !ui_scroll_subtree_has_dirty(static_cast<uint16_t>(bufferedScrollNode))) {
566
+ ui_push_buffered_scroll_canvas(bufferedScrollCanvas, bufferedScrollRepaintCanvas,
567
+ bufferedScrollNode, bufferedScrollVX, bufferedScrollVY,
568
+ bufferedScrollRepaintY, bufferedScrollRepaintH, __ui_draw_target);
569
+ __ui_frame_painted = 1;
570
+ bufferedScrollNode = -1;
571
+ bufferedScrollCanvas = nullptr;
572
+ bufferedScrollRepaintCanvas = nullptr;
573
+ }
574
+
575
+ // Ladder-drawing a CONTAINER (fill/view panel) clears and redraws its
576
+ // whole paint rect — everything drawn inside it (its descendants) is
577
+ // erased by this pass. Mark-time propagation (ui_mark_dirty) already
578
+ // marks descendants, but the merge pass composes and CLEARS some of
579
+ // them before this node's ladder turn — a press inside an open drawer
580
+ // left the panel for the ladder (too big to merge) while its texts and
581
+ // buttons were merge-composed first; the panel's clear+fill then erased
582
+ // them and nothing re-drew them. Re-mark the descendants here: they sort
583
+ // after this node, so they restore their pixels later in this same pass.
584
+ // Scroll owners never reach this point (the compositor paths skip them),
585
+ // so scroll-subtree repaints do not amplify.
586
+ if (__ui_nodes[i].subtreeEnd > static_cast<uint16_t>(i) + 1 &&
587
+ __ui_nodes[i].kind != NODE_LIST) {
588
+ for (uint16_t c = static_cast<uint16_t>(i) + 1;
589
+ c < __ui_nodes[i].subtreeEnd && c < __ui_node_count; c++) {
590
+ if (!__ui_nodes[c].visible) continue;
591
+ if (__ui_nodes[c].screenId != __ui_active_screen) continue;
592
+ __ui_nodes[c].dirty = 1;
593
+ }
594
+ }
595
+
596
+ // Redirect to the scroll canvas if this node is inside the buffered container
597
+ // AND a real canvas is active this frame (Mode B only). Direct-strip and
598
+ // direct-full have no canvas — their children must draw to the display target
599
+ // (and thus qualify for the per-node paint-canvas optimization), not redirect
600
+ // to a null scroll canvas. Without this, a strip-drag frame sets
601
+ // drawingBufferedScroll=1, which skips the paint canvas and forces the
602
+ // repainting children to direct-draw per-pixel to SPI (~100ms/text node).
603
+ uint8_t drawingBufferedScroll = !bufferedScrollDirectFull &&
604
+ (bufferedScrollCanvas != nullptr) && bufferedScrollNode >= 0 &&
605
+ i > bufferedScrollNode && i < __ui_nodes[bufferedScrollNode].subtreeEnd;
606
+ // A container with descendants reaches its ladder turn only when the
607
+ // merge pass couldn't own it (too big to merge, or a dirty ancestor
608
+ // excluded the children). The ladder clears + redraws just this node —
609
+ // erasing every descendant pixel — and the re-marked descendants would
610
+ // then pop back one ladder at a time: an erase + re-pop the eye reads
611
+ // as a flash (pressing a button inside an open drawer). Instead compose
612
+ // the container's whole subtree REGION through the band renderer — one
613
+ // pass, correct stacking order, no intermediate state — and clear every
614
+ // dirty node inside the region (the compose already painted them in
615
+ // final form). Falls back to the ladder + re-marks when the band canvas
616
+ // can't allocate. Display path only: into an active scroll canvas the
617
+ // ladder draws (children re-mark into the canvas afterwards).
618
+ if (!drawingBufferedScroll &&
619
+ __ui_nodes[i].subtreeEnd > static_cast<uint16_t>(i) + 1 &&
620
+ __ui_nodes[i].kind != NODE_LIST) {
621
+ UIRect subtreeRegion;
622
+ if (ui_subtree_current_paint_rect(static_cast<uint16_t>(i), &subtreeRegion) &&
623
+ subtreeRegion.w > 0 && subtreeRegion.h > 0) {
624
+ // An open drawer's compose region reaches the nodes the overlap
625
+ // classification skipped this frame — they owe their uncovered
626
+ // sliver, and the compose paints them under the panel in stacking
627
+ // order.
628
+ int8_t islot = __ui_drawer_slot_of(static_cast<uint16_t>(i));
629
+ if (islot >= 0 && __ui_drawer_inflate_used[islot]) {
630
+ const UIRect* inf = &__ui_drawer_inflate_rect[islot];
631
+ int16_t x1 = static_cast<int16_t>(subtreeRegion.x + subtreeRegion.w > inf->x + inf->w ? subtreeRegion.x + subtreeRegion.w : inf->x + inf->w);
632
+ int16_t y1 = static_cast<int16_t>(subtreeRegion.y + subtreeRegion.h > inf->y + inf->h ? subtreeRegion.y + subtreeRegion.h : inf->y + inf->h);
633
+ if (inf->x < subtreeRegion.x) subtreeRegion.x = inf->x;
634
+ if (inf->y < subtreeRegion.y) subtreeRegion.y = inf->y;
635
+ subtreeRegion.w = static_cast<int16_t>(x1 - subtreeRegion.x);
636
+ subtreeRegion.h = static_cast<int16_t>(y1 - subtreeRegion.y);
637
+ __ui_drawer_inflate_used[islot] = 0;
638
+ }
639
+ if (ui_render_screen_bands(subtreeRegion.x, subtreeRegion.y, subtreeRegion.w, subtreeRegion.h)) {
640
+ __ui_frame_painted = 1;
641
+ ui_invalidate_scroll_canvas_for_node(static_cast<uint16_t>(i));
642
+ for (uint16_t q = 0; q < __ui_node_count; q++) {
643
+ if (__ui_nodes[q].screenId != __ui_active_screen) continue;
644
+ if (!__ui_nodes[q].dirty) continue;
645
+ UIRect qr;
646
+ ui_node_current_paint_rect(q, &qr);
647
+ if (qr.w <= 0 || qr.h <= 0) continue;
648
+ if (qr.x >= subtreeRegion.x && qr.y >= subtreeRegion.y &&
649
+ static_cast<int16_t>(qr.x + qr.w) <= static_cast<int16_t>(subtreeRegion.x + subtreeRegion.w) &&
650
+ static_cast<int16_t>(qr.y + qr.h) <= static_cast<int16_t>(subtreeRegion.y + subtreeRegion.h)) {
651
+ __ui_nodes[q].dirty = 0;
652
+ }
653
+ }
654
+ continue;
655
+ }
656
+ }
657
+ }
658
+ int16_t origBoxX = __ui_nodes[i].box.x;
659
+ int16_t origBoxY = __ui_nodes[i].box.y;
660
+ if (drawingBufferedScroll) {
661
+ CuttlefishCanvas16* scrollDrawCanvas = bufferedScrollRepaintCanvas ? bufferedScrollRepaintCanvas : bufferedScrollCanvas;
662
+ ui_display_set_target(scrollDrawCanvas);
663
+ // Translate display coords → canvas-local coords (subtract viewport origin).
664
+ __ui_nodes[i].box.x = origBoxX - bufferedScrollVX;
665
+ __ui_nodes[i].box.y = origBoxY - bufferedScrollVY - (bufferedScrollRepaintCanvas ? bufferedScrollRepaintY : 0);
666
+ } else {
667
+ ui_display_set_target(__ui_draw_target);
668
+ }
669
+ int16_t baseDrawX = ui_base_draw_x_for_node(i);
670
+ int16_t baseDrawY = ui_base_draw_y_for_node(i);
671
+ int16_t drawX = ui_draw_x_for_node(i);
672
+ int16_t drawY = ui_draw_y_for_node(i);
673
+ // Source selection: text-bound nodes show their dynamic buffer; others show
674
+ // the immutable flash literal.
675
+ const char* displayText = __ui_nodes[i].hasTextBinding
676
+ ? __ui_nodes[i].textBuffer
677
+ : __ui_nodes[i].text;
678
+ uint8_t ts = __ui_nodes[i].textSize ? __ui_nodes[i].textSize : 2;
679
+ uint16_t textMaxW = ui_node_text_max_width(i);
680
+ uint16_t tw = 0;
681
+ uint16_t th = 0;
682
+ ui_node_text_layout_metrics(i, textMaxW, &tw, &th);
683
+ uint16_t paintTextW = tw;
684
+ uint16_t paintTextH = th;
685
+ if (__ui_nodes[i].kind == NODE_TEXT || __ui_nodes[i].kind == NODE_SELECT) {
686
+ 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;
687
+ 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;
688
+ paintTextW = static_cast<uint16_t>(tw + hInset);
689
+ paintTextH = static_cast<uint16_t>(th + vInset);
690
+ }
691
+ if (__ui_nodes[i].kind == NODE_CHECK || __ui_nodes[i].kind == NODE_RADIO) {
692
+ paintTextW = tw + 22;
693
+ if (paintTextH < 16) paintTextH = 16;
694
+ }
695
+
696
+ UIRect paintRect;
697
+ ui_node_paint_rect(i, baseDrawX, baseDrawY, drawX, drawY, paintTextW, paintTextH, &paintRect);
698
+ if (drawingBufferedScroll) {
699
+ // Canvas-local clip: skip nodes fully outside the viewport (0..vw, 0..vh).
700
+ // Use the node's face rect (box.w/h), NOT the paint rect — the paint rect
701
+ // includes shadow/border extents, which legitimately overflow a scroll
702
+ // viewport. Clipping on the paint rect falsely rejects nodes whose shadow
703
+ // pokes past the container edge while the face is fully inside (showed up
704
+ // as the home-nav buttons never painting on ST7796S, where the nav
705
+ // container is exactly button-width).
706
+ int16_t faceX = drawX;
707
+ int16_t faceY = drawY;
708
+ int16_t faceW = __ui_nodes[i].box.w;
709
+ int16_t faceH = __ui_nodes[i].box.h;
710
+ CuttlefishCanvas16* scrollDrawCanvas = bufferedScrollRepaintCanvas ? bufferedScrollRepaintCanvas : bufferedScrollCanvas;
711
+ // Guard against a null canvas (the subtree was flagged drawingBufferedScroll
712
+ // but no canvas is available this frame — e.g. a second scroll owner whose
713
+ // canvas couldn't allocate). Skip the canvas-local clip and draw the node
714
+ // to the display target; the scroll-viewport clip below still bounds it.
715
+ if (scrollDrawCanvas) {
716
+ int16_t scrollDrawW = display_canvasWidth(scrollDrawCanvas);
717
+ int16_t scrollDrawH = display_canvasHeight(scrollDrawCanvas);
718
+ if (bufferedScrollRepaintCanvas) {
719
+ scrollDrawW = __ui_nodes[bufferedScrollNode].box.w;
720
+ scrollDrawH = bufferedScrollRepaintH;
721
+ }
722
+ if (faceY + faceH <= 0 || faceY >= scrollDrawH ||
723
+ faceX + faceW <= 0 || faceX >= scrollDrawW) {
724
+ __ui_nodes[i].box.x = origBoxX;
725
+ __ui_nodes[i].box.y = origBoxY;
726
+ __ui_nodes[i].dirty = 0;
727
+ continue;
728
+ }
729
+ }
730
+ } else if (ui_is_rect_clipped_by_scroll(i, drawX, drawY, __ui_nodes[i].box.w, __ui_nodes[i].box.h)) {
731
+ __ui_nodes[i].box.x = origBoxX;
732
+ __ui_nodes[i].box.y = origBoxY;
733
+ __ui_nodes[i].dirty = 0;
734
+ continue;
735
+ }
736
+
737
+ uint8_t drawingPaintCanvas = 0;
738
+ CuttlefishCanvas16* paintCanvas = nullptr;
739
+ int16_t paintCanvasX = paintRect.x;
740
+ int16_t paintCanvasY = paintRect.y;
741
+ int16_t paintCanvasW = paintRect.w;
742
+ int16_t paintCanvasH = paintRect.h;
743
+ // RAM-composite pixel-heavy nodes before SPI push. Skip when already drawing
744
+ // into a scroll canvas or a full-screen framebuffer (both are RAM targets).
745
+ uint8_t wantedBuffer = !drawingBufferedScroll && !__ui_fb && ui_should_buffer_paint(i, paintCanvasW, paintCanvasH);
746
+ // Large paint rects prefer the band renderer (small ~10KB SRAM canvas,
747
+ // strip-at-a-time) over the repair canvas even when the repair canvas could
748
+ // allocate (e.g. in PSRAM). A large repair canvas has higher alloc/seed/push
749
+ // latency than banding, which shows as a press/scroll flash; the band canvas
750
+ // lives in fast internal SRAM and pushes smaller per-band transactions.
751
+ uint8_t preferBand = wantedBuffer && (UI_BAND_PREFER_PIXELS > 0) &&
752
+ (static_cast<uint32_t>(paintCanvasW) * static_cast<uint32_t>(paintCanvasH) > static_cast<uint32_t>(UI_BAND_PREFER_PIXELS));
753
+ if (wantedBuffer && !preferBand) {
754
+ paintCanvas = ui_get_repair_canvas(paintCanvasW, paintCanvasH);
755
+ if (paintCanvas) {
756
+ drawingPaintCanvas = 1;
757
+ ui_display_set_target(paintCanvas);
758
+ ui_seed_paint_canvas_for_node(i, paintCanvas, paintCanvasX, paintCanvasY, 0);
759
+ baseDrawX -= paintCanvasX;
760
+ baseDrawY -= paintCanvasY;
761
+ drawX -= paintCanvasX;
762
+ drawY -= paintCanvasY;
763
+ if (__ui_nodes[i].kind == NODE_PROGRESS || __ui_nodes[i].kind == NODE_RANGE) {
764
+ __ui_nodes[i].lastTextWidth = -1;
765
+ }
766
+ }
767
+ }
768
+ if (!drawingPaintCanvas) {
769
+ // No repair canvas this frame — either the paint rect is large enough to
770
+ // prefer the band renderer (preferBand: lower-latency than a big PSRAM
771
+ // repair canvas), or the repair canvas wouldn't allocate (over budget /
772
+ // heap fragmentation). Composite the node into the ~10KB band canvas one
773
+ // horizontal strip at a time, pushing each band tear-free. Avoids the
774
+ // direct clear→redraw-to-SPI that visibly flashes on press/scroll.
775
+ if (wantedBuffer && ui_render_node_bands(static_cast<uint16_t>(i), paintCanvasX, paintCanvasY, paintCanvasW, paintCanvasH)) {
776
+ __ui_frame_painted = 1;
777
+ if (!drawingBufferedScroll) {
778
+ ui_invalidate_scroll_canvas_for_node(i);
779
+ }
780
+ __ui_nodes[i].box.x = origBoxX;
781
+ __ui_nodes[i].box.y = origBoxY;
782
+ __ui_nodes[i].dirty = 0;
783
+ ui_refresh_add_rect(__ui_nodes[i].box.x, __ui_nodes[i].box.y, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
784
+ continue;
785
+ }
786
+ // Band canvas also failed — last resort: retry the repair canvas (it
787
+ // might fit when the band canvas is contended). If both allocations fail,
788
+ // lock this scroll owner for the frame and retain its previous pixels;
789
+ // never fall through to direct primitive SPI draws, which visibly tear.
790
+ if (wantedBuffer && preferBand) {
791
+ paintCanvas = ui_get_repair_canvas(paintCanvasW, paintCanvasH);
792
+ if (paintCanvas) {
793
+ drawingPaintCanvas = 1;
794
+ ui_display_set_target(paintCanvas);
795
+ ui_seed_paint_canvas_for_node(i, paintCanvas, paintCanvasX, paintCanvasY, 0);
796
+ baseDrawX -= paintCanvasX;
797
+ baseDrawY -= paintCanvasY;
798
+ drawX -= paintCanvasX;
799
+ drawY -= paintCanvasY;
800
+ }
801
+ }
802
+ if (!drawingPaintCanvas) {
803
+ if (wantedBuffer && preferBand && __ui_scroll_render_locked &&
804
+ __ui_nodes[i].scrollable) {
805
+ __ui_scroll_render_locked[i] = 1;
806
+ __ui_nodes[i].dirty = 0;
807
+ __ui_nodes[i].box.x = origBoxX;
808
+ __ui_nodes[i].box.y = origBoxY;
809
+ continue;
810
+ }
811
+ ui_clear_press_offset_area(i, baseDrawX, baseDrawY, drawX, drawY, paintTextW, paintTextH);
812
+ }
813
+ }
814
+ uint8_t skipListOutsetShadow =
815
+ (__ui_nodes[i].kind == NODE_LIST && !drawingBufferedScroll && !__ui_fb);
816
+ if (!skipListOutsetShadow) {
817
+ __ui_nodes[i].box.x = baseDrawX;
818
+ ui_draw_shadow(i, baseDrawY, 0);
819
+ __ui_nodes[i].box.x = drawX;
820
+ } else {
821
+ __ui_nodes[i].box.x = drawX;
822
+ }
823
+
824
+ // Border color: use borderColor if set, otherwise fg.
825
+ UI_COLOR_T bColor = __ui_nodes[i].borderColor ? __ui_nodes[i].borderColor : __ui_nodes[i].fg;
826
+ // Background color blended toward clearColor by opacity (raw bg when 100%).
827
+ // NODE_FILL draws the fill at this color so opacity actually fades the
828
+ // element's background toward what's behind it.
829
+ UI_COLOR_T fillBg = __ui_nodes[i].bg;
830
+ // Apply opacity: blend fg/bg/border toward what's BEHIND the node when <100%.
831
+ // NOTE: blend toward the parent's clear color (ui_parent_clear_color), not
832
+ // the node's own clearColor — a filled node's clearColor IS its own bg, so
833
+ // blending bg toward it is a no-op (red toward red = red). The parent clear
834
+ // is the actual backdrop showing through the translucent element.
835
+ if (__ui_nodes[i].opacity < 100) {
836
+ UI_COLOR_T backdrop = ui_parent_clear_color(i);
837
+ bColor = ui_blend(bColor, backdrop, __ui_nodes[i].opacity);
838
+ fillBg = ui_blend(__ui_nodes[i].bg, backdrop, __ui_nodes[i].opacity);
839
+ }
840
+ // ── Per-kind draw dispatch ─────────────────────────────────────────────
841
+ // The kind switch lives in ui_draw_node_body (node-draw-body slice) so the
842
+ // strip/band renderer can reuse it. The ctx carries the draw state both
843
+ // paths share; the list case may return 1 (it handled canvas push,
844
+ // decoration, coord restore, and dirty-clear itself) in which case this
845
+ // node is done — skip the post-switch epilogue exactly as the old inline
846
+ // 'continue;' did.
847
+ UINodeDrawCtx __ui_ctx;
848
+ __ui_ctx.drawY = drawY;
849
+ __ui_ctx.bColor = bColor;
850
+ __ui_ctx.fillBg = fillBg;
851
+ __ui_ctx.ts = ts;
852
+ __ui_ctx.textMaxW = textMaxW;
853
+ __ui_ctx.tw = tw;
854
+ __ui_ctx.th = th;
855
+ __ui_ctx.paintTextW = paintTextW;
856
+ __ui_ctx.paintTextH = paintTextH;
857
+ __ui_ctx.displayText = displayText;
858
+ __ui_ctx.drawingBufferedScroll = drawingBufferedScroll;
859
+ __ui_ctx.drawTarget = __ui_draw_target;
860
+ __ui_ctx.origBoxX = origBoxX;
861
+ __ui_ctx.origBoxY = origBoxY;
862
+ __ui_frame_painted = 1;
863
+ if (ui_draw_node_body(i, &__ui_ctx)) {
864
+ continue;
865
+ }
866
+ if (__ui_nodes[i].kind == NODE_FILL && ui_rotation_quadrant(__ui_nodes[i].rotateDeg) != 0 &&
867
+ __ui_nodes[i].outlineStyle != 0 && __ui_nodes[i].outlineWidth > 0) {
868
+ uint8_t w = __ui_nodes[i].outlineWidth;
869
+ int16_t outlineW = ui_rotated_face_w(i, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
870
+ int16_t outlineH = ui_rotated_face_h(i, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
871
+ ui_draw_rect_outline(__ui_nodes[i].box.x - w, drawY - w,
872
+ outlineW + 2 * w, outlineH + 2 * w,
873
+ __ui_nodes[i].borderRadius + w, __ui_nodes[i].outlineStyle, w, __ui_nodes[i].outlineColor);
874
+ } else {
875
+ ui_draw_node_outline(i, __ui_nodes[i].box.x, drawY);
876
+ }
877
+ if (drawingPaintCanvas) {
878
+ ui_display_set_target(__ui_draw_target);
879
+ ui_push_canvas_rect(paintCanvas, paintCanvasX, paintCanvasY, paintCanvasW, paintCanvasH);
880
+ }
881
+ if (!drawingBufferedScroll) {
882
+ ui_invalidate_scroll_canvas_for_node(i);
883
+ }
884
+ // Restore original box coords (translated for canvas-local drawing above).
885
+ __ui_nodes[i].box.x = origBoxX;
886
+ __ui_nodes[i].box.y = origBoxY;
887
+ __ui_nodes[i].dirty = 0;
888
+ // Report this node's bounding box to the deferred-refresh accumulator. Phase
889
+ // 4 uses the box as the dirty rect (a safe over-estimate); Phase 5 tightens
890
+ // to the actual paint rect. No-op on TFT (compiles to nothing).
891
+ if (__ui_fb) ui_fb_add_rect(paintRect.x, paintRect.y, paintRect.w, paintRect.h);
892
+ ui_refresh_add_rect(__ui_nodes[i].box.x, __ui_nodes[i].box.y, __ui_nodes[i].box.w, __ui_nodes[i].box.h);
893
+ }
894
+
895
+ // Modal <select> list: stamp the overlay after the dirty pass so tree
896
+ // redraws never bury it. Stamped only when the overlay itself is dirty
897
+ // (open) or something painted beneath it this frame; the close path marks
898
+ // the whole tree dirty for the erase repaint.
899
+ if (__ui_select_menu >= 0 && (__ui_select_menu_dirty || __ui_frame_painted)) {
900
+ ui_select_menu_draw();
901
901
  }`;
902
902
  }