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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +1457 -1469
  2. package/dist/cli.js +22 -19
  3. package/dist/preview/host-ui-runtime.js +0 -1
  4. package/dist/ui-engine/model.js +7 -1
  5. package/dist/ui-engine/runtime-header/canvas-scrollbar.js +115 -115
  6. package/dist/ui-engine/runtime-header/dirty-scroll-mutators.js +182 -182
  7. package/dist/ui-engine/runtime-header/forward-decls.js +227 -216
  8. package/dist/ui-engine/runtime-header/init-press-input.js +138 -138
  9. package/dist/ui-engine/runtime-header/keyboard.js +676 -676
  10. package/dist/ui-engine/runtime-header/node-draw-body.js +1658 -1656
  11. package/dist/ui-engine/runtime-header/paint-order-coords.js +259 -259
  12. package/dist/ui-engine/runtime-header/paint-rects-repair.js +736 -736
  13. package/dist/ui-engine/runtime-header/scroll-physics.js +4 -4
  14. package/dist/ui-engine/runtime-header/structs.js +228 -228
  15. package/dist/ui-engine/runtime-header/text-rendering.js +888 -888
  16. package/dist/ui-engine/runtime-header/tick/bindings-phase.js +118 -118
  17. package/dist/ui-engine/runtime-header/tick/dirty-draw-phase.js +896 -896
  18. package/dist/ui-engine/runtime-header/tick/scroll-canvas-phase.js +24 -24
  19. package/dist/ui-engine/runtime-header/touch-keyboard-fwd.js +11 -11
  20. package/dist/ui-engine/runtime-header/types-defines.js +110 -110
  21. package/dist/ui-engine/shadcn-kit.d.ts +1 -1
  22. package/dist/ui-engine/shadcn-kit.js +4 -0
  23. package/dist/ui-engine/ui-lowering.d.ts +1 -1
  24. package/dist/ui-engine/ui-lowering.js +14 -5
  25. package/dist/ui-engine/ui-registry.js +9 -1
  26. package/dist/wizard/display-catalog.d.ts +0 -3
  27. package/dist/wizard/display-catalog.js +3 -12
  28. package/dist/wizard/index.d.ts +2 -0
  29. package/dist/wizard/index.js +1 -0
  30. package/dist/wizard/integration-wizard.d.ts +3 -0
  31. package/dist/wizard/integration-wizard.js +69 -20
  32. package/dist/wizard/package-writer.d.ts +20 -0
  33. package/dist/wizard/package-writer.js +79 -0
  34. package/package.json +4 -4
  35. package/src/cli.ts +90 -87
  36. package/src/preview/host-ui-runtime.ts +0 -1
  37. package/src/ui-engine/model.ts +6 -1
  38. package/src/ui-engine/runtime-header/canvas-scrollbar.ts +121 -121
  39. package/src/ui-engine/runtime-header/dirty-scroll-mutators.ts +188 -188
  40. package/src/ui-engine/runtime-header/forward-decls.ts +238 -227
  41. package/src/ui-engine/runtime-header/init-press-input.ts +144 -144
  42. package/src/ui-engine/runtime-header/keyboard.ts +689 -689
  43. package/src/ui-engine/runtime-header/node-draw-body.ts +1683 -1681
  44. package/src/ui-engine/runtime-header/paint-order-coords.ts +265 -265
  45. package/src/ui-engine/runtime-header/paint-rects-repair.ts +742 -742
  46. package/src/ui-engine/runtime-header/scroll-physics.ts +4 -4
  47. package/src/ui-engine/runtime-header/structs.ts +234 -234
  48. package/src/ui-engine/runtime-header/text-rendering.ts +894 -894
  49. package/src/ui-engine/runtime-header/tick/bindings-phase.ts +124 -124
  50. package/src/ui-engine/runtime-header/tick/dirty-draw-phase.ts +902 -902
  51. package/src/ui-engine/runtime-header/tick/scroll-canvas-phase.ts +30 -30
  52. package/src/ui-engine/runtime-header/touch-keyboard-fwd.ts +11 -11
  53. package/src/ui-engine/runtime-header/types-defines.ts +116 -116
  54. package/src/ui-engine/shadcn-kit.ts +4 -0
  55. package/src/ui-engine/ui-lowering.ts +12 -4
  56. package/src/ui-engine/ui-registry.ts +9 -1
  57. package/src/wizard/display-catalog.ts +261 -273
  58. package/src/wizard/index.ts +46 -38
  59. package/src/wizard/integration-wizard.ts +679 -619
  60. package/src/wizard/package-writer.ts +98 -0
@@ -1,689 +1,689 @@
1
- // Slice of the C++ runtime header (original source lines 5434-5769).
2
- // On-screen keyboard: add_key/insert/delete/compute_box/open/close/rect/touch/tick/tap/draw.
3
- // See docs/superpowers/specs/2026-07-12-split-runtime-header-design.md.
4
- export function emitKeyboard(): string {
5
- return `
6
- // ── On-screen keyboard subsystem ───────────────────────────────────────────
7
- // (UIKey struct, UI_KB_* defines, __ui_kb_box, __ui_kb_visible, __ui_kb_bs_held,
8
- // __ui_last_touch_x/y are declared earlier near the touch state machine so
9
- // the touch functions can reference them.)
10
-
11
- // Populated by the per-keyboard loader function (emitted by the lowering).
12
- // (__ui_kb_keys is forward-declared earlier near the touch state machine.)
13
- static uint8_t __ui_kb_keyCount;
14
- static uint8_t __ui_kb_rows;
15
- static uint8_t __ui_kb_cols;
16
- static char __ui_kb_buffer[UI_TEXT_BUF + 1];
17
- static uint8_t __ui_kb_len;
18
- static uint8_t __ui_kb_maxlen;
19
- static uint8_t __ui_kb_shift;
20
- // __ui_kb_visible, __ui_kb_bs_held, __ui_last_touch_x/y are forward-declared
21
- // earlier (near the touch state machine) because ui_touch_up references them.
22
- // __ui_kb_target is also forward-declared there (the UI_HIDE_OSK caret/blink
23
- // paths in ui_tick and the input draw reference it before this point).
24
- static uint32_t __ui_kb_bs_repeat; // last auto-repeat deletion time
25
- // __ui_kb_dirty is forward-declared earlier (near the touch state machine).
26
- static void (*__ui_kb_onchange)();
27
- // Dispatch table: one loader per input node. Indexed by input position.
28
- extern void (*__ui_kb_loaders[])();
29
- extern const uint16_t __ui_kb_loader_count;
30
-
31
- static inline void ui_kb_add_key(char ch, uint8_t special, UI_COLOR_T bg, UI_COLOR_T fg, UI_COLOR_T borderColor) {
32
- if (__ui_kb_keyCount >= UI_KB_MAX) return;
33
- __ui_kb_keys[__ui_kb_keyCount] = { ch, special };
34
- __ui_kb_styles[__ui_kb_keyCount] = { bg, fg, borderColor };
35
- __ui_kb_keyCount++;
36
- }
37
-
38
- // Insert a character into the buffer (if space permits).
39
- static inline void ui_kb_insert(char c) {
40
- if (__ui_kb_maxlen > 0 && __ui_kb_len >= __ui_kb_maxlen) return;
41
- if (__ui_kb_len >= UI_TEXT_BUF) return;
42
- __ui_kb_buffer[__ui_kb_len++] = c;
43
- __ui_kb_buffer[__ui_kb_len] = 0;
44
- #if defined(UI_HIDE_OSK)
45
- // Desktop target: the OSK grid isn't drawn, so the input field itself is the
46
- // only place the in-progress text appears. Sync the buffer into the target
47
- // node's textBuffer and mark it dirty so the field repaints on the next tick
48
- // — without this, typing appears to do nothing until Enter commits at close.
49
- if (__ui_kb_target >= 0) {
50
- strncpy(__ui_nodes[__ui_kb_target].textBuffer, __ui_kb_buffer, UI_TEXT_BUF);
51
- __ui_nodes[__ui_kb_target].textBuffer[UI_TEXT_BUF] = 0;
52
- ui_mark_dirty(static_cast<uint16_t>(__ui_kb_target));
53
- }
54
- #endif
55
- }
56
-
57
- // Delete one character from the buffer.
58
- static inline void ui_kb_delete() {
59
- if (__ui_kb_len == 0) return;
60
- __ui_kb_buffer[--__ui_kb_len] = 0;
61
- #if defined(UI_HIDE_OSK)
62
- if (__ui_kb_target >= 0) {
63
- strncpy(__ui_nodes[__ui_kb_target].textBuffer, __ui_kb_buffer, UI_TEXT_BUF);
64
- __ui_nodes[__ui_kb_target].textBuffer[UI_TEXT_BUF] = 0;
65
- ui_mark_dirty(static_cast<uint16_t>(__ui_kb_target));
66
- }
67
- #endif
68
- }
69
-
70
- // Compute the keyboard box on open from display dimensions + grid shape.
71
- // Alpha (wide grid) docks to the bottom 75%; number (narrow grid) centers at 60%.
72
- // Uses the display profile dimensions if available, else 320×240.
73
- #ifndef __ui_display_w
74
- #define __ui_display_w 320
75
- #endif
76
- #ifndef __ui_display_h
77
- #define __ui_display_h 240
78
- #endif
79
- static inline void ui_kb_compute_box() {
80
- uint8_t isNumber = (__ui_kb_cols <= 4);
81
- uint16_t h = isNumber ? (__ui_display_h * 60 / 100) : (__ui_display_h * 75 / 100);
82
- __ui_kb_box.w = isNumber ? (__ui_display_w * 50 / 100) : __ui_display_w;
83
- __ui_kb_box.h = h;
84
- __ui_kb_box.x = isNumber ? (__ui_display_w - __ui_kb_box.w) / 2 : 0;
85
- __ui_kb_box.y = __ui_display_h - h;
86
- }
87
-
88
- // Open the keyboard for an input node.
89
- static inline void ui_kb_open(uint16_t nodeIdx, uint8_t inputPosition) {
90
- __ui_kb_target = nodeIdx;
91
- strncpy(__ui_kb_buffer, __ui_nodes[nodeIdx].textBuffer, UI_TEXT_BUF);
92
- __ui_kb_buffer[UI_TEXT_BUF] = 0;
93
- __ui_kb_len = strlen(__ui_kb_buffer);
94
- uint16_t ml = __ui_nodes[nodeIdx].maxlen;
95
- __ui_kb_maxlen = (ml > 0 && ml <= UI_TEXT_BUF) ? static_cast<uint8_t>(ml) : UI_TEXT_BUF;
96
- __ui_kb_shift = 0;
97
- __ui_kb_bs_held = 0;
98
- // Load the key set via the dispatch table.
99
- if (inputPosition < __ui_kb_loader_count) __ui_kb_loaders[inputPosition]();
100
- __ui_kb_set_onchange();
101
- ui_kb_compute_box();
102
- __ui_kb_visible = 1;
103
- __ui_kb_dirty = 1; // redraw on the first visible frame
104
- // Do NOT pre-mark the tree dirty here. While the keyboard is visible the
105
- // draw pass is skipped (its opaque background covers app nodes), so any
106
- // dirty flags set now are never consumed/cleared — they survive until close,
107
- // and the first post-close frame then repaints every dirty node = full-screen
108
- // flash on SPI TFTs. The close path scopes the repaint to nodes whose paint
109
- // rect intersects __ui_kb_box, which is the only region that needs restoring.
110
- #if defined(UI_HIDE_OSK)
111
- // Desktop target: the OSK isn't drawn, so the draw pass runs normally and the
112
- // input field must repaint on focus to show the caret BEFORE the first
113
- // keystroke. (The full-screen-flash concern above doesn't apply — there's no
114
- // opaque overlay being skipped.) Seed the blink phase so the caret is ON for
115
- // the first ~530ms (bit 0x20 set → visible), so focus feels immediate.
116
- if (__ui_kb_target >= 0) ui_mark_dirty(static_cast<uint16_t>(__ui_kb_target));
117
- __ui_kb_blink = 0x20;
118
- #endif
119
- }
120
-
121
- // Close the keyboard: commit buffer back to the input node.
122
- static inline void ui_kb_close() {
123
- if (__ui_kb_target >= 0) {
124
- strncpy(__ui_nodes[__ui_kb_target].textBuffer, __ui_kb_buffer, UI_TEXT_BUF);
125
- __ui_nodes[__ui_kb_target].textBuffer[UI_TEXT_BUF] = 0;
126
- if (__ui_kb_onchange) __ui_kb_onchange();
127
- }
128
- // Capture the keyboard box before clearing visibility — it identifies the
129
- // screen region the opaque overlay covered and that now needs restoring.
130
- UIRect kbBox = __ui_kb_box;
131
- __ui_kb_visible = 0;
132
- __ui_kb_target = -1;
133
- __ui_kb_bs_held = 0;
134
- // The <screen> root's paint rect spans the whole display, so it always
135
- // intersects kbBox. Routing it through ui_mark_dirty would cascade via
136
- // ui_mark_overlapping_higher_layers_dirty into marking every node on the
137
- // active screen dirty (its rect overlaps everything), which is the
138
- // full-screen flash this function exists to avoid. Repaint just the
139
- // keyboard-box slice of its background directly instead.
140
- int16_t screenRoot = -1;
141
- for (uint16_t i = 0; i < __ui_node_count; i++) {
142
- if (__ui_nodes[i].parent == UI_NO_PARENT && __ui_nodes[i].screenId == __ui_active_screen) {
143
- screenRoot = static_cast<int16_t>(i);
144
- break;
145
- }
146
- }
147
- if (screenRoot >= 0) {
148
- UI_COLOR_T rootBg = (UI_COLOR_T)(__ui_nodes[screenRoot].hasBg
149
- ? __ui_nodes[screenRoot].bg
150
- : __ui_nodes[screenRoot].clearColor);
151
- ui_display_fill_rect(kbBox.x, kbBox.y, kbBox.w, kbBox.h, rootBg);
152
- }
153
- // Repaint everything else the keyboard overlay actually overwrote: nodes
154
- // whose paint rect intersects the keyboard box, plus the edited input
155
- // itself (its text just changed). ui_mark_dirty handles overlap repair +
156
- // scroll clipping — safe here since these nodes are bounded in size, unlike
157
- // the screen root.
158
- for (uint16_t i = 0; i < __ui_node_count; i++) {
159
- if (static_cast<int16_t>(i) == screenRoot) continue;
160
- UIRect r;
161
- ui_node_current_paint_rect(i, &r);
162
- if (r.w > 0 && r.h > 0 &&
163
- ui_rects_intersect(r.x, r.y, r.w, r.h, kbBox.x, kbBox.y, kbBox.w, kbBox.h)) {
164
- ui_mark_dirty(i);
165
- }
166
- }
167
- }
168
-
169
- // Compute a key's rect from its index, given the grid + box.
170
- // The top UI_KB_TEXT_H pixels of the box are reserved for the preview text row;
171
- // keys fill the area below it.
172
- static inline void ui_kb_key_rect(uint8_t idx, UIRect* out) {
173
- uint8_t col = idx % __ui_kb_cols;
174
- uint8_t row = idx / __ui_kb_cols;
175
- int16_t keysH = __ui_kb_box.h - UI_KB_TEXT_H; // key area height (below text row)
176
- out->x = __ui_kb_box.x + static_cast<int16_t>(col) * __ui_kb_box.w / __ui_kb_cols;
177
- out->y = __ui_kb_box.y + UI_KB_TEXT_H + static_cast<int16_t>(row) * keysH / __ui_kb_rows;
178
- out->w = __ui_kb_box.w / __ui_kb_cols;
179
- out->h = keysH / __ui_kb_rows;
180
- }
181
-
182
- // Handle a touch-down inside the keyboard box. tx,ty are display coords.
183
- // Handle a touch-down inside the keyboard box. Only fires on the initial
184
- // down edge (tracked by __ui_touch_state in the modal path), NOT every poll.
185
- // Records WHICH key is under the finger — the same key is activated on release
186
- // (ui_kb_handle_tap), avoiding mis-targeting from coordinate drift on release.
187
- static inline void ui_kb_handle_touch(int16_t tx, int16_t ty) {
188
- __ui_kb_pressed_key = -1;
189
- for (uint8_t i = 0; i < __ui_kb_keyCount; i++) {
190
- UIKey k = __ui_kb_keys[i];
191
- if (k.special == 255) continue; // padding cell, skip
192
- UIRect r;
193
- ui_kb_key_rect(i, &r);
194
- if (tx >= r.x && tx < r.x + r.w && ty >= r.y && ty < r.y + r.h) {
195
- __ui_kb_pressed_key = static_cast<int8_t>(i); // remember for release
196
- __ui_kb_repaint_key = static_cast<int8_t>(i);
197
- __ui_kb_dirty = 2; // targeted: redraw just this key's highlight
198
- // Backspace starts deleting immediately + arms auto-repeat.
199
- if (k.special == 2) {
200
- __ui_kb_bs_held = 1;
201
- __ui_kb_bs_repeat = millis();
202
- ui_kb_delete();
203
- }
204
- return;
205
- }
206
- }
207
- }
208
-
209
- // Called each frame while the keyboard is visible + a touch is held.
210
- // Handles ⌫ auto-repeat.
211
- static inline void ui_kb_tick(uint32_t now) {
212
- if (!__ui_kb_bs_held) return;
213
- if (now - __ui_kb_bs_repeat >= UI_KB_REPEAT_MS) {
214
- ui_kb_delete();
215
- __ui_kb_bs_repeat = now;
216
- __ui_kb_repaint_key = __ui_kb_pressed_key; // ⌫ key stays highlighted
217
- __ui_kb_dirty = 2; // targeted: text row + ⌫ key
218
- }
219
- }
220
-
221
- // Handle a tap release. Activates the key that was under the finger on
222
- // touch-down (__ui_kb_pressed_key) — NOT a fresh hit-test, which would
223
- // mis-target due to coordinate drift on a resistive panel at release.
224
- static inline void ui_kb_handle_tap(int16_t tx, int16_t ty) {
225
- (void)tx; (void)ty; // key was recorded on touch-down; no re-hit-test
226
- if (__ui_kb_pressed_key < 0) return;
227
- UIKey k = __ui_kb_keys[__ui_kb_pressed_key];
228
- int8_t releasedKey = __ui_kb_pressed_key;
229
- __ui_kb_pressed_key = -1; // clear pressed state → highlight reverts
230
- switch (k.special) {
231
- case 0: { // char
232
- char c = k.ch;
233
- uint8_t wasShift = __ui_kb_shift;
234
- if (wasShift && c >= 'a' && c <= 'z') c -= 32;
235
- ui_kb_insert(c);
236
- __ui_kb_shift = 0; // shift resets after one char
237
- // Text row changes; if shift was active, repaint shift key too.
238
- __ui_kb_repaint_key = wasShift ? -1 : releasedKey;
239
- __ui_kb_dirty = 2;
240
- break;
241
- }
242
- case 1: // shift toggle — all letter keys change case, full redraw
243
- __ui_kb_shift = !__ui_kb_shift;
244
- __ui_kb_dirty = 1;
245
- break;
246
- case 2: // backspace: handled on down + repeat; nothing on tap-up
247
- __ui_kb_repaint_key = releasedKey; // revert highlight
248
- __ui_kb_dirty = 2;
249
- break;
250
- case 3: // OK
251
- ui_kb_close();
252
- break;
253
- case 4: { // page-swap — new key layout, full redraw
254
- extern void __ui_kb_load_default_alpha();
255
- extern void __ui_kb_load_default_number();
256
- if (__ui_kb_cols <= 4) __ui_kb_load_default_alpha();
257
- else __ui_kb_load_default_number();
258
- ui_kb_compute_box();
259
- __ui_kb_dirty = 1;
260
- break;
261
- }
262
- }
263
- }
264
-
265
- // Draw a single key by index. Shared by the full draw + targeted redraw.
266
- static inline void ui_kb_draw_key(uint8_t i) {
267
- UIKey k = __ui_kb_keys[i];
268
- UIKeyStyle ks = __ui_kb_styles[i];
269
- UIRect r;
270
- ui_kb_key_rect(i, &r);
271
- UI_COLOR_T bg = ks.bg;
272
- UI_COLOR_T fg = ks.fg;
273
- UI_COLOR_T border = ks.borderColor;
274
- // Shift-active highlight: brighten the shift key's background — but only
275
- // when not pressed, so the press inversion stays high-contrast.
276
- if (k.special == 1 && __ui_kb_shift && static_cast<int8_t>(i) != __ui_kb_pressed_key) {
277
- #if UI_COLOR_DEPTH == 888
278
- bg = 0xBDEFFF;
279
- #else
280
- bg = 0xBDF7;
281
- #endif
282
- }
283
- // Pressed key: invert colors for clear tap feedback.
284
- if (static_cast<int8_t>(i) == __ui_kb_pressed_key) { UI_COLOR_T t = bg; bg = fg; fg = t; }
285
- ui_display_fill_rect(r.x + 1, r.y + 1, r.w - 2, r.h - 2, bg);
286
- ui_display_draw_rect(r.x + 1, r.y + 1, r.w - 2, r.h - 2, border);
287
- ui_display_set_text_color(fg, bg);
288
- ui_display_set_text_size(1);
289
- // Derive the label string + its length for centering.
290
- const char* labelStr;
291
- char single[2];
292
- switch (k.special) {
293
- case 1: labelStr = "Aa"; break;
294
- case 2: labelStr = "DEL"; break;
295
- case 3: labelStr = "OK"; break;
296
- case 4: labelStr = __ui_kb_cols <= 4 ? "ABC" : "123"; break;
297
- default:
298
- single[0] = (__ui_kb_shift && k.ch >= 'a' && k.ch <= 'z') ? static_cast<char>(k.ch - 32) : k.ch;
299
- single[1] = 0;
300
- labelStr = single;
301
- break;
302
- }
303
- // Center: textW = len * 6px, textH = 8px. Position inside the key rect.
304
- uint8_t len = strlen(labelStr);
305
- int16_t textW = static_cast<int16_t>(len) * 6;
306
- int16_t textH = 8;
307
- int16_t cx = r.x + (r.w - textW) / 2;
308
- int16_t cy = r.y + (r.h - textH) / 2;
309
- if (cx < r.x + 1) cx = r.x + 1; // clamp if label wider than key
310
- ui_display_set_cursor(cx, cy);
311
- ui_display_print(labelStr);
312
- }
313
-
314
- // Redraw only the text display row (top of keyboard box). Used when a char is
315
- // inserted/deleted without changing key highlights.
316
- static inline void ui_kb_draw_text_row() {
317
- // Clear the text row area (top UI_KB_TEXT_H px of the keyboard box).
318
- ui_display_fill_rect(__ui_kb_box.x, __ui_kb_box.y, __ui_kb_box.w, UI_KB_TEXT_H, __ui_kb_bg);
319
- ui_display_set_cursor(__ui_kb_box.x + 4, __ui_kb_box.y + 4);
320
- #if UI_COLOR_DEPTH == 888
321
- ui_display_set_text_color(0xFFFFFF, 0x000000);
322
- #else
323
- ui_display_set_text_color(0xFFFF, 0x0000);
324
- #endif
325
- ui_display_set_text_size(2);
326
- ui_display_print(__ui_kb_buffer);
327
- ui_display_print("_"); // cursor
328
- }
329
-
330
- // Draw the full keyboard overlay (background + text row + all keys).
331
- // Renders into a dedicated offscreen canvas first, then pushes in a single
332
- // SPI transaction — avoids the per-key/per-rect display writes that show up
333
- // as visible row-by-row painting on SPI TFTs.
334
- // (__ui_kb_canvas is forward-declared in the touch-keyboard-fwd slice.)
335
- static inline void ui_kb_draw() {
336
- int16_t kw = __ui_kb_box.w;
337
- int16_t kh = __ui_kb_box.h;
338
- if (kw <= 0 || kh <= 0) return;
339
- // (Re)allocate the keyboard canvas to exact size (see repair-canvas comment
340
- // — stride mismatch corrupts the push when reusing a differently-sized canvas).
341
- // ui_create_canvas_best prefers PSRAM when available (a full-width keyboard
342
- // canvas can be ~30KB, too big for no-PSRAM internal SRAM) and falls back to
343
- // SRAM — same policy as the list/scroll viewport canvases.
344
- if (!__ui_kb_canvas || !display_canvasBuffer(__ui_kb_canvas) ||
345
- display_canvasWidth(__ui_kb_canvas) != kw ||
346
- display_canvasHeight(__ui_kb_canvas) != kh) {
347
- display_deleteCanvas(__ui_kb_canvas);
348
- __ui_kb_canvas = ui_create_canvas_best(kw, kh);
349
- }
350
- if (!__ui_kb_canvas || !display_canvasBuffer(__ui_kb_canvas)) {
351
- // Canvas alloc failed — fall back to direct draw (slow but correct).
352
- ui_display_fill_rect(__ui_kb_box.x, __ui_kb_box.y, kw, kh, __ui_kb_bg);
353
- ui_kb_draw_text_row();
354
- for (uint8_t i = 0; i < __ui_kb_keyCount; i++) {
355
- if (__ui_kb_keys[i].special == 255) continue;
356
- ui_kb_draw_key(i);
357
- }
358
- return;
359
- }
360
- // Redirect drawing into the canvas. All ui_display_* calls below go to RAM.
361
- CuttlefishDisplayTarget* __kb_prev_target = ui_display_get_target();
362
- ui_display_set_target(__ui_kb_canvas);
363
- // Opaque background over the keyboard box (canvas-local 0,0).
364
- ui_display_fill_rect(0, 0, kw, kh, __ui_kb_bg);
365
- // Text row + keys: temporarily shift box origin to canvas-local so the
366
- // existing draw_text_row / draw_key functions compute coords at (0,0).
367
- int16_t saveBoxX = __ui_kb_box.x;
368
- int16_t saveBoxY = __ui_kb_box.y;
369
- __ui_kb_box.x = 0;
370
- __ui_kb_box.y = 0;
371
- ui_kb_draw_text_row();
372
- for (uint8_t i = 0; i < __ui_kb_keyCount; i++) {
373
- if (__ui_kb_keys[i].special == 255) continue;
374
- ui_kb_draw_key(i);
375
- }
376
- __ui_kb_box.x = saveBoxX;
377
- __ui_kb_box.y = saveBoxY;
378
- // Restore display target and push the canvas in one transaction.
379
- ui_display_set_target(__kb_prev_target);
380
- display_startWrite();
381
- display_setAddrWindow(saveBoxX, saveBoxY, kw, kh);
382
- display_writePixels(display_canvasBuffer(__ui_kb_canvas), static_cast<uint32_t>(kw) * kh);
383
- display_endWrite();
384
- }
385
-
386
- static inline void ui_select_menu_geom(uint16_t nodeIdx, UIRect* out, int16_t* rowH) {
387
- const UINode* n = &__ui_nodes[nodeIdx];
388
- int16_t rows = static_cast<int16_t>(n->optionCount);
389
- if (rows < 1) rows = 1;
390
- *rowH = 22;
391
- // Anchor to the select itself: same left edge and width, so the modal
392
- // reads as the control's own dropdown (never overhangs toward a scrollbar).
393
- // Clamp to the screen for selects that extend past it.
394
- int16_t x = n->box.x;
395
- if (x < 0) x = 0;
396
- int16_t w = n->box.w;
397
- if (w < 120) w = 120;
398
- if (x + w > static_cast<int16_t>(__ui_display_w)) w = static_cast<int16_t>(__ui_display_w) - x;
399
- int16_t h = rows * (*rowH) + 8;
400
- int16_t maxH = static_cast<int16_t>(__ui_display_h) * 3 / 5;
401
- if (h > maxH) h = maxH;
402
- out->x = x;
403
- out->y = static_cast<int16_t>((static_cast<int16_t>(__ui_display_h) - h) / 2);
404
- out->w = w;
405
- out->h = h;
406
- }
407
-
408
- static inline void ui_select_menu_open(uint16_t nodeIdx) {
409
- if (__ui_nodes[nodeIdx].kind != NODE_SELECT) return;
410
- if (__ui_nodes[nodeIdx].optionCount < 1) return;
411
- __ui_select_menu = static_cast<int16_t>(nodeIdx);
412
- __ui_select_menu_dirty = 1;
413
- }
414
-
415
- static inline void ui_select_menu_close(uint8_t repaint) {
416
- if (__ui_select_menu < 0) return;
417
- __ui_select_menu = -1;
418
- __ui_select_menu_dirty = 0;
419
- if (repaint) {
420
- // Erase repaint: the whole active screen redraws beneath the vanished
421
- // overlay (same contract as navigate's mark-all-dirty).
422
- for (uint16_t i = 0; i < __ui_node_count; i++) {
423
- __ui_nodes[i].dirty = 1;
424
- __ui_nodes[i].lastTextHeight = 0;
425
- }
426
- }
427
- }
428
-
429
- static inline void ui_select_menu_draw() {
430
- if (__ui_select_menu < 0) return;
431
- uint16_t idx = static_cast<uint16_t>(__ui_select_menu);
432
- const UINode* n = &__ui_nodes[idx];
433
- UIRect g;
434
- int16_t rowH;
435
- ui_select_menu_geom(idx, &g, &rowH);
436
- // Themed panel: the select's own radius/border/bg — the modal reads as
437
- // the control's popover, matching the surrounding UI (device parity with
438
- // the preview's drawSelectMenu theming).
439
- int16_t radius = n->borderRadius > 0 ? n->borderRadius : 6;
440
- if (radius > g.w / 2) radius = g.w / 2;
441
- if (radius > g.h / 2) radius = g.h / 2;
442
- UI_COLOR_T panel = n->hasBg ? n->bg : n->clearColor;
443
- UI_COLOR_T borderCol = (n->borderStyle != 0 && n->borderColor != 0) ? n->borderColor : n->fg;
444
- ui_display_fill_round_rect(g.x, g.y, g.w, g.h, radius, panel);
445
- ui_display_draw_round_rect(g.x, g.y, g.w, g.h, radius, borderCol);
446
- char buf[UI_TEXT_BUF + 1];
447
- int16_t rowInset = radius / 2; if (rowInset < 2) rowInset = 2;
448
- int16_t rowRadius = radius; if (rowRadius > 6) rowRadius = 6;
449
- for (uint8_t r = 0; r < n->optionCount; r++) {
450
- int16_t ry = g.y + 4 + static_cast<int16_t>(r) * rowH;
451
- uint8_t current = (r == static_cast<uint8_t>(n->value));
452
- // The selected row carries the :checked pair (the kit wires it to
453
- // --accent/--accent-foreground, shadcn's SelectItem selected state).
454
- UI_COLOR_T rowBg = n->hasCheckedBg ? static_cast<UI_COLOR_T>(n->checkedBg) : n->fg;
455
- UI_COLOR_T rowFg = n->hasCheckedFg ? static_cast<UI_COLOR_T>(n->checkedFg) : panel;
456
- if (current) {
457
- ui_display_fill_round_rect(g.x + rowInset, ry, g.w - 2 * rowInset, rowH, rowRadius, rowBg);
458
- }
459
- buf[0] = 0;
460
- if (n->optionTextFn) n->optionTextFn(r, buf, UI_TEXT_BUF + 1);
461
- ui_draw_wrapped_text(buf,
462
- g.x + 22, ry + (rowH - static_cast<int16_t>(8 * n->textSize)) / 2,
463
- static_cast<uint16_t>(g.w - 30),
464
- current ? rowFg : n->fg,
465
- panel, n->textSize, n->fontAntialias, n->fontFace, n->letterSpacing,
466
- n->lineHeight, n->whiteSpaceMode, 0, 0, 0);
467
- if (current) {
468
- int16_t cx = g.x + 7;
469
- int16_t cy = ry + rowH / 2;
470
- ui_display_draw_line(cx, cy, cx + 3, cy + 3, rowFg);
471
- ui_display_draw_line(cx + 3, cy + 3, cx + 8, cy - 4, rowFg);
472
- }
473
- }
474
- __ui_select_menu_dirty = 0;
475
- }
476
-
477
- static inline void ui_select_menu_tap(int16_t tx, int16_t ty) {
478
- if (__ui_select_menu < 0) return;
479
- uint16_t idx = static_cast<uint16_t>(__ui_select_menu);
480
- UIRect g;
481
- int16_t rowH;
482
- ui_select_menu_geom(idx, &g, &rowH);
483
- if (tx >= g.x && tx < g.x + g.w && ty >= g.y && ty < g.y + g.h) {
484
- int16_t row = (ty - g.y - 4) / rowH;
485
- if (row >= 0 && row < static_cast<int16_t>(__ui_nodes[idx].optionCount)) {
486
- __ui_nodes[idx].value = row;
487
- ui_mark_dirty(idx);
488
- }
489
- }
490
- ui_select_menu_close(1);
491
- }
492
-
493
- // ── <drawer> implementation (device twin of the preview drawer) ──────────
494
-
495
- static int8_t __ui_drawer_slot_of(uint16_t nodeIdx) {
496
- for (uint8_t s = 0; s < __ui_drawer_slots; s++) {
497
- if (__ui_drawer_idx[s] == static_cast<int16_t>(nodeIdx)) return static_cast<int8_t>(s);
498
- }
499
- return -1;
500
- }
501
-
502
- static void ui_drawer_discover() {
503
- __ui_drawer_slots = 0;
504
- for (uint16_t i = 0; i < __ui_node_count && __ui_drawer_slots < UI_DRAWER_MAX; i++) {
505
- if (__ui_nodes[i].drawerSide < 0) continue;
506
- uint8_t s = __ui_drawer_slots++;
507
- __ui_drawer_idx[s] = static_cast<int16_t>(i);
508
- __ui_drawer_open[s] = 0;
509
- __ui_drawer_progress[s] = 0;
510
- // Seed closed: subtree offsets at full travel (never paints at rest).
511
- ui_drawer_apply(s, 0);
512
- }
513
- }
514
-
515
- static void ui_drawer_apply(uint8_t slot, uint8_t progress) {
516
- if (slot >= __ui_drawer_slots || __ui_drawer_idx[slot] < 0) return;
517
- uint16_t di = static_cast<uint16_t>(__ui_drawer_idx[slot]);
518
- UINode* d = &__ui_nodes[di];
519
- int16_t travel = (d->drawerSide == 2 || d->drawerSide == 3)
520
- ? static_cast<int16_t>(d->box.w) : static_cast<int16_t>(d->box.h);
521
- if (d->drawerSide == 4) travel = 0; // <dialog>: centered, no slide
522
- else {
523
- // Edge panels must slide FULLY off the display, not just their own
524
- // height: a bottom panel anchored at the viewport bottom (bottom:0) is
525
- // already AT the edge, so travel == box.h parks its top row exactly at
526
- // the fold — a sliver stayed visible after close. The seeded closed
527
- // state never showed that sliver before the first open (the initial
528
- // paint's scroll-clip dropped it), and a closed panel can't be tapped
529
- // open, so the post-close "peek" read as a rendering artifact, not an
530
- // affordance — it is gone for drawers and toasts alike. Rest position =
531
- // box minus ancestor scroll (transformOffset still holds the previous
532
- // apply's slide at this point, so it cannot be used here).
533
- int16_t restX = static_cast<int16_t>(d->box.x);
534
- int16_t restY = static_cast<int16_t>(d->box.y);
535
- uint16_t pa = d->parent;
536
- while (pa != UI_NO_PARENT && pa < __ui_node_count) {
537
- if (__ui_nodes[pa].scrollable) restY = static_cast<int16_t>(restY - __ui_nodes[pa].scrollY);
538
- pa = __ui_nodes[pa].parent;
539
- }
540
- int16_t dl = 0, dt = 0, dr = 0, db = 0;
541
- ui_display_target_bounds(&dl, &dt, &dr, &db);
542
- int16_t need = d->drawerSide == 0 ? static_cast<int16_t>(db - restY)
543
- : d->drawerSide == 1 ? static_cast<int16_t>(restY + d->box.h - dt)
544
- : d->drawerSide == 2 ? static_cast<int16_t>(restX + d->box.w - dl)
545
- : static_cast<int16_t>(dr - restX);
546
- if (need > travel) travel = need;
547
- }
548
- int16_t off = static_cast<int16_t>((static_cast<int32_t>(travel) * (255 - progress)) / 255);
549
- int16_t dx = d->drawerSide == 2 ? -off : d->drawerSide == 3 ? off : 0;
550
- int16_t dy = d->drawerSide == 1 ? -off : d->drawerSide == 0 ? off : 0;
551
- d->transformOffsetX = dx;
552
- d->transformOffsetY = dy;
553
- for (uint16_t i = di + 1; i < d->subtreeEnd; i++) {
554
- // Descendants don't inherit transform offsets: their own box coords are
555
- // layout-local, so shift their draw origin via transform offsets too.
556
- __ui_nodes[i].transformOffsetX += dx - (__ui_drawer_last_dx[slot]);
557
- __ui_nodes[i].transformOffsetY += dy - (__ui_drawer_last_dy[slot]);
558
- }
559
- __ui_drawer_last_dx[slot] = dx;
560
- __ui_drawer_last_dy[slot] = dy;
561
- __ui_drawer_progress[slot] = progress;
562
- }
563
-
564
- static void ui_drawer_open(uint16_t nodeIdx) {
565
- int8_t s = __ui_drawer_slot_of(nodeIdx);
566
- if (s < 0) return;
567
- __ui_drawer_open[s] = 1;
568
- __ui_toast_elapsed[s] = 0; // <toast>: restart the auto-close window
569
- }
570
-
571
- static void ui_drawer_close(uint16_t nodeIdx) {
572
- int8_t s = __ui_drawer_slot_of(nodeIdx);
573
- if (s < 0) return;
574
- __ui_drawer_open[s] = 0;
575
- }
576
-
577
- static void ui_drawer_close_all() {
578
- for (uint8_t s = 0; s < __ui_drawer_slots; s++) __ui_drawer_open[s] = 0;
579
- }
580
-
581
- static void ui_drawer_tick(uint32_t deltaMs) {
582
- uint8_t step = static_cast<uint8_t>((deltaMs * 255UL) / 180UL);
583
- if (step == 0) step = 1;
584
- for (uint8_t s = 0; s < __ui_drawer_slots; s++) {
585
- if (__ui_drawer_idx[s] < 0) continue;
586
- uint16_t tnode = static_cast<uint16_t>(__ui_drawer_idx[s]);
587
- // <toast duration>: once fully open, count up and auto-close. A reopen
588
- // (ui_drawer_open) restarts the window; manual close just wins.
589
- if (__ui_nodes[tnode].toastDuration > 0 && __ui_drawer_open[s] &&
590
- __ui_drawer_progress[s] == 255) {
591
- __ui_toast_elapsed[s] += deltaMs;
592
- if (__ui_toast_elapsed[s] >= __ui_nodes[tnode].toastDuration) {
593
- __ui_drawer_open[s] = 0;
594
- }
595
- }
596
- uint8_t target = __ui_drawer_open[s] ? 255 : 0;
597
- uint8_t p = __ui_drawer_progress[s];
598
- if (p == target) continue;
599
- // Compose each slide frame as the UNION of the drawer's old and new
600
- // SUBTREE paint rects in tear-free bands (ui_render_screen_bands). The
601
- // previous contract — mark the whole active screen dirty per slide step —
602
- // repainted the entire screen with direct per-node clears on no-fb
603
- // targets: visible flashing, and only ~3 steps fit inside the 180ms
604
- // slide. The banded union is a fraction of the SPI traffic and never
605
- // exposes an intermediate state. Falls back to the whole-screen dirty
606
- // repaint when the band canvas can't allocate or a retained framebuffer
607
- // composes the frame in RAM (its dirty-union push is already tear-free).
608
- //
609
- // SUBTREE extent, not the root's paint rect: a <dialog> root whose
610
- // children are absolutely positioned lays out at h=0 — the root-rect
611
- // union degenerated to an empty band (a no-op "composed" success), so
612
- // NOTHING repainted on open or close on hardware. The preview already
613
- // used its currentSubtreePaintRect here. Side-4 dialogs also snap
614
- // instead of animating: travel is 0, so intermediate progress values
615
- // are 180ms of identical frames — the snap composes ONE band frame for
616
- // the open state and ONE for the erase, so modal toggles are tear-free
617
- // on no-fb SPI targets too (mark-all's per-node clears flashed the
618
- // whole screen).
619
- uint16_t di = static_cast<uint16_t>(__ui_drawer_idx[s]);
620
- uint8_t next = target > p ? (p + step > target ? target : p + step)
621
- : (p < step || p - step < target ? target : p - step);
622
- if (__ui_nodes[di].drawerSide == 4) next = target;
623
- UIRect oldRect;
624
- uint8_t hasOld = ui_subtree_current_paint_rect(di, &oldRect);
625
- ui_drawer_apply(s, next);
626
- UIRect newRect;
627
- uint8_t hasNew = ui_subtree_current_paint_rect(di, &newRect);
628
- // A failed rect query leaves x/y UNWRITTEN — unioning it against the
629
- // good side mixed stack garbage into the band region (huge regions clipped
630
- // to full-screen, or empty ones that "composed" nothing). When one side
631
- // has no on-screen extent (a dialog snapping open from fully-closed, or
632
- // erasing to fully-closed), the union is just the side that exists.
633
- if (!hasOld && !hasNew) continue;
634
- if (!hasOld) oldRect = newRect;
635
- if (!hasNew) newRect = oldRect;
636
- uint8_t composed = 0;
637
- if (!ui_get_framebuffer()) {
638
- int16_t ux0 = oldRect.x < newRect.x ? oldRect.x : newRect.x;
639
- int16_t uy0 = oldRect.y < newRect.y ? oldRect.y : newRect.y;
640
- int16_t ux1 = oldRect.x + oldRect.w > newRect.x + newRect.w ? static_cast<int16_t>(oldRect.x + oldRect.w) : static_cast<int16_t>(newRect.x + newRect.w);
641
- int16_t uy1 = oldRect.y + oldRect.h > newRect.y + newRect.h ? static_cast<int16_t>(oldRect.y + oldRect.h) : static_cast<int16_t>(newRect.y + newRect.h);
642
- composed = ui_render_screen_bands(ux0, uy0, static_cast<int16_t>(ux1 - ux0), static_cast<int16_t>(uy1 - uy0));
643
- if (composed) {
644
- // The drawer crossed a scroll viewport that caches its pixels; the
645
- // canvas holds pre-slide content where the drawer now sits. Invalidate
646
- // it so the next scroll recomposites instead of shifting stale pixels.
647
- ui_invalidate_scroll_canvas_for_node(di);
648
- // The composed bands are the AUTHORITATIVE frame for this region —
649
- // every node fully inside it is already painted in correct stacking
650
- // order. Clear those dirty flags or the dirty pass repaints them
651
- // through the per-node ladder: a viewport-sized overlay (the dialog
652
- // scrim) is too big for the repair canvas and falls to the DIRECT
653
- // ladder — one flat full-screen fill that erased the just-composed
654
- // frame, then the card/title/buttons re-popped band by band. That
655
- // erase + re-layer sequence was the modal-toggle flash on hardware.
656
- // Bindings run after the drawer tick, so any dirty flag they raise
657
- // this frame still repaints normally.
658
- for (uint16_t q = 0; q < __ui_node_count; q++) {
659
- if (__ui_nodes[q].screenId != __ui_active_screen) continue;
660
- if (!__ui_nodes[q].dirty) continue;
661
- UIRect pr;
662
- ui_node_current_paint_rect(q, &pr);
663
- if (pr.w <= 0 || pr.h <= 0) continue;
664
- if (pr.x >= ux0 && pr.y >= uy0 &&
665
- static_cast<int16_t>(pr.x + pr.w) <= static_cast<int16_t>(ux1) &&
666
- static_cast<int16_t>(pr.y + pr.h) <= static_cast<int16_t>(uy1)) {
667
- __ui_nodes[q].dirty = 0;
668
- }
669
- }
670
- }
671
- }
672
- if (!composed) {
673
- for (uint16_t i = 0; i < __ui_node_count; i++) {
674
- if (__ui_nodes[i].screenId != __ui_active_screen) continue;
675
- __ui_nodes[i].dirty = 1;
676
- __ui_nodes[i].lastTextHeight = 0;
677
- }
678
- }
679
- }
680
- }
681
- `;
682
- }
683
-
684
- // ── Modal <select> option list ────────────────────────────────────────────
685
- // Device twin of the preview's select modal: tapping a select opens a
686
- // centered list of its options (capped to ~60% of the panel height); tapping
687
- // a row sets the value and closes; tapping outside dismisses. The overlay is
688
- // stamped after the dirty pass every frame while open; closing marks the
689
- // whole tree dirty for the erase repaint.
1
+ // Slice of the C++ runtime header (original source lines 5434-5769).
2
+ // On-screen keyboard: add_key/insert/delete/compute_box/open/close/rect/touch/tick/tap/draw.
3
+ // See docs/superpowers/specs/2026-07-12-split-runtime-header-design.md.
4
+ export function emitKeyboard(): string {
5
+ return `
6
+ // ── On-screen keyboard subsystem ───────────────────────────────────────────
7
+ // (UIKey struct, UI_KB_* defines, __ui_kb_box, __ui_kb_visible, __ui_kb_bs_held,
8
+ // __ui_last_touch_x/y are declared earlier near the touch state machine so
9
+ // the touch functions can reference them.)
10
+
11
+ // Populated by the per-keyboard loader function (emitted by the lowering).
12
+ // (__ui_kb_keys is forward-declared earlier near the touch state machine.)
13
+ static uint8_t __ui_kb_keyCount;
14
+ static uint8_t __ui_kb_rows;
15
+ static uint8_t __ui_kb_cols;
16
+ static char __ui_kb_buffer[UI_TEXT_BUF + 1];
17
+ static uint8_t __ui_kb_len;
18
+ static uint8_t __ui_kb_maxlen;
19
+ static uint8_t __ui_kb_shift;
20
+ // __ui_kb_visible, __ui_kb_bs_held, __ui_last_touch_x/y are forward-declared
21
+ // earlier (near the touch state machine) because ui_touch_up references them.
22
+ // __ui_kb_target is also forward-declared there (the UI_HIDE_OSK caret/blink
23
+ // paths in ui_tick and the input draw reference it before this point).
24
+ static uint32_t __ui_kb_bs_repeat; // last auto-repeat deletion time
25
+ // __ui_kb_dirty is forward-declared earlier (near the touch state machine).
26
+ static void (*__ui_kb_onchange)();
27
+ // Dispatch table: one loader per input node. Indexed by input position.
28
+ extern void (*__ui_kb_loaders[])();
29
+ extern const uint16_t __ui_kb_loader_count;
30
+
31
+ static inline void ui_kb_add_key(char ch, uint8_t special, UI_COLOR_T bg, UI_COLOR_T fg, UI_COLOR_T borderColor) {
32
+ if (__ui_kb_keyCount >= UI_KB_MAX) return;
33
+ __ui_kb_keys[__ui_kb_keyCount] = { ch, special };
34
+ __ui_kb_styles[__ui_kb_keyCount] = { bg, fg, borderColor };
35
+ __ui_kb_keyCount++;
36
+ }
37
+
38
+ // Insert a character into the buffer (if space permits).
39
+ static inline void ui_kb_insert(char c) {
40
+ if (__ui_kb_maxlen > 0 && __ui_kb_len >= __ui_kb_maxlen) return;
41
+ if (__ui_kb_len >= UI_TEXT_BUF) return;
42
+ __ui_kb_buffer[__ui_kb_len++] = c;
43
+ __ui_kb_buffer[__ui_kb_len] = 0;
44
+ #if defined(UI_HIDE_OSK)
45
+ // Desktop target: the OSK grid isn't drawn, so the input field itself is the
46
+ // only place the in-progress text appears. Sync the buffer into the target
47
+ // node's textBuffer and mark it dirty so the field repaints on the next tick
48
+ // — without this, typing appears to do nothing until Enter commits at close.
49
+ if (__ui_kb_target >= 0) {
50
+ strncpy(__ui_nodes[__ui_kb_target].textBuffer, __ui_kb_buffer, UI_TEXT_BUF);
51
+ __ui_nodes[__ui_kb_target].textBuffer[UI_TEXT_BUF] = 0;
52
+ ui_mark_dirty(static_cast<uint16_t>(__ui_kb_target));
53
+ }
54
+ #endif
55
+ }
56
+
57
+ // Delete one character from the buffer.
58
+ static inline void ui_kb_delete() {
59
+ if (__ui_kb_len == 0) return;
60
+ __ui_kb_buffer[--__ui_kb_len] = 0;
61
+ #if defined(UI_HIDE_OSK)
62
+ if (__ui_kb_target >= 0) {
63
+ strncpy(__ui_nodes[__ui_kb_target].textBuffer, __ui_kb_buffer, UI_TEXT_BUF);
64
+ __ui_nodes[__ui_kb_target].textBuffer[UI_TEXT_BUF] = 0;
65
+ ui_mark_dirty(static_cast<uint16_t>(__ui_kb_target));
66
+ }
67
+ #endif
68
+ }
69
+
70
+ // Compute the keyboard box on open from display dimensions + grid shape.
71
+ // Alpha (wide grid) docks to the bottom 75%; number (narrow grid) centers at 60%.
72
+ // Uses the display profile dimensions if available, else 320×240.
73
+ #ifndef __ui_display_w
74
+ #define __ui_display_w 320
75
+ #endif
76
+ #ifndef __ui_display_h
77
+ #define __ui_display_h 240
78
+ #endif
79
+ static inline void ui_kb_compute_box() {
80
+ uint8_t isNumber = (__ui_kb_cols <= 4);
81
+ uint16_t h = isNumber ? (__ui_display_h * 60 / 100) : (__ui_display_h * 75 / 100);
82
+ __ui_kb_box.w = isNumber ? (__ui_display_w * 50 / 100) : __ui_display_w;
83
+ __ui_kb_box.h = h;
84
+ __ui_kb_box.x = isNumber ? (__ui_display_w - __ui_kb_box.w) / 2 : 0;
85
+ __ui_kb_box.y = __ui_display_h - h;
86
+ }
87
+
88
+ // Open the keyboard for an input node.
89
+ static inline void ui_kb_open(uint16_t nodeIdx, uint8_t inputPosition) {
90
+ __ui_kb_target = nodeIdx;
91
+ strncpy(__ui_kb_buffer, __ui_nodes[nodeIdx].textBuffer, UI_TEXT_BUF);
92
+ __ui_kb_buffer[UI_TEXT_BUF] = 0;
93
+ __ui_kb_len = strlen(__ui_kb_buffer);
94
+ uint16_t ml = __ui_nodes[nodeIdx].maxlen;
95
+ __ui_kb_maxlen = (ml > 0 && ml <= UI_TEXT_BUF) ? static_cast<uint8_t>(ml) : UI_TEXT_BUF;
96
+ __ui_kb_shift = 0;
97
+ __ui_kb_bs_held = 0;
98
+ // Load the key set via the dispatch table.
99
+ if (inputPosition < __ui_kb_loader_count) __ui_kb_loaders[inputPosition]();
100
+ __ui_kb_set_onchange();
101
+ ui_kb_compute_box();
102
+ __ui_kb_visible = 1;
103
+ __ui_kb_dirty = 1; // redraw on the first visible frame
104
+ // Do NOT pre-mark the tree dirty here. While the keyboard is visible the
105
+ // draw pass is skipped (its opaque background covers app nodes), so any
106
+ // dirty flags set now are never consumed/cleared — they survive until close,
107
+ // and the first post-close frame then repaints every dirty node = full-screen
108
+ // flash on SPI TFTs. The close path scopes the repaint to nodes whose paint
109
+ // rect intersects __ui_kb_box, which is the only region that needs restoring.
110
+ #if defined(UI_HIDE_OSK)
111
+ // Desktop target: the OSK isn't drawn, so the draw pass runs normally and the
112
+ // input field must repaint on focus to show the caret BEFORE the first
113
+ // keystroke. (The full-screen-flash concern above doesn't apply — there's no
114
+ // opaque overlay being skipped.) Seed the blink phase so the caret is ON for
115
+ // the first ~530ms (bit 0x20 set → visible), so focus feels immediate.
116
+ if (__ui_kb_target >= 0) ui_mark_dirty(static_cast<uint16_t>(__ui_kb_target));
117
+ __ui_kb_blink = 0x20;
118
+ #endif
119
+ }
120
+
121
+ // Close the keyboard: commit buffer back to the input node.
122
+ static inline void ui_kb_close() {
123
+ if (__ui_kb_target >= 0) {
124
+ strncpy(__ui_nodes[__ui_kb_target].textBuffer, __ui_kb_buffer, UI_TEXT_BUF);
125
+ __ui_nodes[__ui_kb_target].textBuffer[UI_TEXT_BUF] = 0;
126
+ if (__ui_kb_onchange) __ui_kb_onchange();
127
+ }
128
+ // Capture the keyboard box before clearing visibility — it identifies the
129
+ // screen region the opaque overlay covered and that now needs restoring.
130
+ UIRect kbBox = __ui_kb_box;
131
+ __ui_kb_visible = 0;
132
+ __ui_kb_target = -1;
133
+ __ui_kb_bs_held = 0;
134
+ // The <screen> root's paint rect spans the whole display, so it always
135
+ // intersects kbBox. Routing it through ui_mark_dirty would cascade via
136
+ // ui_mark_overlapping_higher_layers_dirty into marking every node on the
137
+ // active screen dirty (its rect overlaps everything), which is the
138
+ // full-screen flash this function exists to avoid. Repaint just the
139
+ // keyboard-box slice of its background directly instead.
140
+ int16_t screenRoot = -1;
141
+ for (uint16_t i = 0; i < __ui_node_count; i++) {
142
+ if (__ui_nodes[i].parent == UI_NO_PARENT && __ui_nodes[i].screenId == __ui_active_screen) {
143
+ screenRoot = static_cast<int16_t>(i);
144
+ break;
145
+ }
146
+ }
147
+ if (screenRoot >= 0) {
148
+ UI_COLOR_T rootBg = (UI_COLOR_T)(__ui_nodes[screenRoot].hasBg
149
+ ? __ui_nodes[screenRoot].bg
150
+ : __ui_nodes[screenRoot].clearColor);
151
+ ui_display_fill_rect(kbBox.x, kbBox.y, kbBox.w, kbBox.h, rootBg);
152
+ }
153
+ // Repaint everything else the keyboard overlay actually overwrote: nodes
154
+ // whose paint rect intersects the keyboard box, plus the edited input
155
+ // itself (its text just changed). ui_mark_dirty handles overlap repair +
156
+ // scroll clipping — safe here since these nodes are bounded in size, unlike
157
+ // the screen root.
158
+ for (uint16_t i = 0; i < __ui_node_count; i++) {
159
+ if (static_cast<int16_t>(i) == screenRoot) continue;
160
+ UIRect r;
161
+ ui_node_current_paint_rect(i, &r);
162
+ if (r.w > 0 && r.h > 0 &&
163
+ ui_rects_intersect(r.x, r.y, r.w, r.h, kbBox.x, kbBox.y, kbBox.w, kbBox.h)) {
164
+ ui_mark_dirty(i);
165
+ }
166
+ }
167
+ }
168
+
169
+ // Compute a key's rect from its index, given the grid + box.
170
+ // The top UI_KB_TEXT_H pixels of the box are reserved for the preview text row;
171
+ // keys fill the area below it.
172
+ static inline void ui_kb_key_rect(uint8_t idx, UIRect* out) {
173
+ uint8_t col = idx % __ui_kb_cols;
174
+ uint8_t row = idx / __ui_kb_cols;
175
+ int16_t keysH = __ui_kb_box.h - UI_KB_TEXT_H; // key area height (below text row)
176
+ out->x = __ui_kb_box.x + static_cast<int16_t>(col) * __ui_kb_box.w / __ui_kb_cols;
177
+ out->y = __ui_kb_box.y + UI_KB_TEXT_H + static_cast<int16_t>(row) * keysH / __ui_kb_rows;
178
+ out->w = __ui_kb_box.w / __ui_kb_cols;
179
+ out->h = keysH / __ui_kb_rows;
180
+ }
181
+
182
+ // Handle a touch-down inside the keyboard box. tx,ty are display coords.
183
+ // Handle a touch-down inside the keyboard box. Only fires on the initial
184
+ // down edge (tracked by __ui_touch_state in the modal path), NOT every poll.
185
+ // Records WHICH key is under the finger — the same key is activated on release
186
+ // (ui_kb_handle_tap), avoiding mis-targeting from coordinate drift on release.
187
+ static inline void ui_kb_handle_touch(int16_t tx, int16_t ty) {
188
+ __ui_kb_pressed_key = -1;
189
+ for (uint8_t i = 0; i < __ui_kb_keyCount; i++) {
190
+ UIKey k = __ui_kb_keys[i];
191
+ if (k.special == 255) continue; // padding cell, skip
192
+ UIRect r;
193
+ ui_kb_key_rect(i, &r);
194
+ if (tx >= r.x && tx < r.x + r.w && ty >= r.y && ty < r.y + r.h) {
195
+ __ui_kb_pressed_key = static_cast<int8_t>(i); // remember for release
196
+ __ui_kb_repaint_key = static_cast<int8_t>(i);
197
+ __ui_kb_dirty = 2; // targeted: redraw just this key's highlight
198
+ // Backspace starts deleting immediately + arms auto-repeat.
199
+ if (k.special == 2) {
200
+ __ui_kb_bs_held = 1;
201
+ __ui_kb_bs_repeat = __tc_now_ms();
202
+ ui_kb_delete();
203
+ }
204
+ return;
205
+ }
206
+ }
207
+ }
208
+
209
+ // Called each frame while the keyboard is visible + a touch is held.
210
+ // Handles ⌫ auto-repeat.
211
+ static inline void ui_kb_tick(uint32_t now) {
212
+ if (!__ui_kb_bs_held) return;
213
+ if (now - __ui_kb_bs_repeat >= UI_KB_REPEAT_MS) {
214
+ ui_kb_delete();
215
+ __ui_kb_bs_repeat = now;
216
+ __ui_kb_repaint_key = __ui_kb_pressed_key; // ⌫ key stays highlighted
217
+ __ui_kb_dirty = 2; // targeted: text row + ⌫ key
218
+ }
219
+ }
220
+
221
+ // Handle a tap release. Activates the key that was under the finger on
222
+ // touch-down (__ui_kb_pressed_key) — NOT a fresh hit-test, which would
223
+ // mis-target due to coordinate drift on a resistive panel at release.
224
+ static inline void ui_kb_handle_tap(int16_t tx, int16_t ty) {
225
+ (void)tx; (void)ty; // key was recorded on touch-down; no re-hit-test
226
+ if (__ui_kb_pressed_key < 0) return;
227
+ UIKey k = __ui_kb_keys[__ui_kb_pressed_key];
228
+ int8_t releasedKey = __ui_kb_pressed_key;
229
+ __ui_kb_pressed_key = -1; // clear pressed state → highlight reverts
230
+ switch (k.special) {
231
+ case 0: { // char
232
+ char c = k.ch;
233
+ uint8_t wasShift = __ui_kb_shift;
234
+ if (wasShift && c >= 'a' && c <= 'z') c -= 32;
235
+ ui_kb_insert(c);
236
+ __ui_kb_shift = 0; // shift resets after one char
237
+ // Text row changes; if shift was active, repaint shift key too.
238
+ __ui_kb_repaint_key = wasShift ? -1 : releasedKey;
239
+ __ui_kb_dirty = 2;
240
+ break;
241
+ }
242
+ case 1: // shift toggle — all letter keys change case, full redraw
243
+ __ui_kb_shift = !__ui_kb_shift;
244
+ __ui_kb_dirty = 1;
245
+ break;
246
+ case 2: // backspace: handled on down + repeat; nothing on tap-up
247
+ __ui_kb_repaint_key = releasedKey; // revert highlight
248
+ __ui_kb_dirty = 2;
249
+ break;
250
+ case 3: // OK
251
+ ui_kb_close();
252
+ break;
253
+ case 4: { // page-swap — new key layout, full redraw
254
+ extern void __ui_kb_load_default_alpha();
255
+ extern void __ui_kb_load_default_number();
256
+ if (__ui_kb_cols <= 4) __ui_kb_load_default_alpha();
257
+ else __ui_kb_load_default_number();
258
+ ui_kb_compute_box();
259
+ __ui_kb_dirty = 1;
260
+ break;
261
+ }
262
+ }
263
+ }
264
+
265
+ // Draw a single key by index. Shared by the full draw + targeted redraw.
266
+ static inline void ui_kb_draw_key(uint8_t i) {
267
+ UIKey k = __ui_kb_keys[i];
268
+ UIKeyStyle ks = __ui_kb_styles[i];
269
+ UIRect r;
270
+ ui_kb_key_rect(i, &r);
271
+ UI_COLOR_T bg = ks.bg;
272
+ UI_COLOR_T fg = ks.fg;
273
+ UI_COLOR_T border = ks.borderColor;
274
+ // Shift-active highlight: brighten the shift key's background — but only
275
+ // when not pressed, so the press inversion stays high-contrast.
276
+ if (k.special == 1 && __ui_kb_shift && static_cast<int8_t>(i) != __ui_kb_pressed_key) {
277
+ #if UI_COLOR_DEPTH == 888
278
+ bg = 0xBDEFFF;
279
+ #else
280
+ bg = 0xBDF7;
281
+ #endif
282
+ }
283
+ // Pressed key: invert colors for clear tap feedback.
284
+ if (static_cast<int8_t>(i) == __ui_kb_pressed_key) { UI_COLOR_T t = bg; bg = fg; fg = t; }
285
+ ui_display_fill_rect(r.x + 1, r.y + 1, r.w - 2, r.h - 2, bg);
286
+ ui_display_draw_rect(r.x + 1, r.y + 1, r.w - 2, r.h - 2, border);
287
+ ui_display_set_text_color(fg, bg);
288
+ ui_display_set_text_size(1);
289
+ // Derive the label string + its length for centering.
290
+ const char* labelStr;
291
+ char single[2];
292
+ switch (k.special) {
293
+ case 1: labelStr = "Aa"; break;
294
+ case 2: labelStr = "DEL"; break;
295
+ case 3: labelStr = "OK"; break;
296
+ case 4: labelStr = __ui_kb_cols <= 4 ? "ABC" : "123"; break;
297
+ default:
298
+ single[0] = (__ui_kb_shift && k.ch >= 'a' && k.ch <= 'z') ? static_cast<char>(k.ch - 32) : k.ch;
299
+ single[1] = 0;
300
+ labelStr = single;
301
+ break;
302
+ }
303
+ // Center: textW = len * 6px, textH = 8px. Position inside the key rect.
304
+ uint8_t len = strlen(labelStr);
305
+ int16_t textW = static_cast<int16_t>(len) * 6;
306
+ int16_t textH = 8;
307
+ int16_t cx = r.x + (r.w - textW) / 2;
308
+ int16_t cy = r.y + (r.h - textH) / 2;
309
+ if (cx < r.x + 1) cx = r.x + 1; // clamp if label wider than key
310
+ ui_display_set_cursor(cx, cy);
311
+ ui_display_print(labelStr);
312
+ }
313
+
314
+ // Redraw only the text display row (top of keyboard box). Used when a char is
315
+ // inserted/deleted without changing key highlights.
316
+ static inline void ui_kb_draw_text_row() {
317
+ // Clear the text row area (top UI_KB_TEXT_H px of the keyboard box).
318
+ ui_display_fill_rect(__ui_kb_box.x, __ui_kb_box.y, __ui_kb_box.w, UI_KB_TEXT_H, __ui_kb_bg);
319
+ ui_display_set_cursor(__ui_kb_box.x + 4, __ui_kb_box.y + 4);
320
+ #if UI_COLOR_DEPTH == 888
321
+ ui_display_set_text_color(0xFFFFFF, 0x000000);
322
+ #else
323
+ ui_display_set_text_color(0xFFFF, 0x0000);
324
+ #endif
325
+ ui_display_set_text_size(2);
326
+ ui_display_print(__ui_kb_buffer);
327
+ ui_display_print("_"); // cursor
328
+ }
329
+
330
+ // Draw the full keyboard overlay (background + text row + all keys).
331
+ // Renders into a dedicated offscreen canvas first, then pushes in a single
332
+ // SPI transaction — avoids the per-key/per-rect display writes that show up
333
+ // as visible row-by-row painting on SPI TFTs.
334
+ // (__ui_kb_canvas is forward-declared in the touch-keyboard-fwd slice.)
335
+ static inline void ui_kb_draw() {
336
+ int16_t kw = __ui_kb_box.w;
337
+ int16_t kh = __ui_kb_box.h;
338
+ if (kw <= 0 || kh <= 0) return;
339
+ // (Re)allocate the keyboard canvas to exact size (see repair-canvas comment
340
+ // — stride mismatch corrupts the push when reusing a differently-sized canvas).
341
+ // ui_create_canvas_best prefers PSRAM when available (a full-width keyboard
342
+ // canvas can be ~30KB, too big for no-PSRAM internal SRAM) and falls back to
343
+ // SRAM — same policy as the list/scroll viewport canvases.
344
+ if (!__ui_kb_canvas || !display_canvasBuffer(__ui_kb_canvas) ||
345
+ display_canvasWidth(__ui_kb_canvas) != kw ||
346
+ display_canvasHeight(__ui_kb_canvas) != kh) {
347
+ display_deleteCanvas(__ui_kb_canvas);
348
+ __ui_kb_canvas = ui_create_canvas_best(kw, kh);
349
+ }
350
+ if (!__ui_kb_canvas || !display_canvasBuffer(__ui_kb_canvas)) {
351
+ // Canvas alloc failed — fall back to direct draw (slow but correct).
352
+ ui_display_fill_rect(__ui_kb_box.x, __ui_kb_box.y, kw, kh, __ui_kb_bg);
353
+ ui_kb_draw_text_row();
354
+ for (uint8_t i = 0; i < __ui_kb_keyCount; i++) {
355
+ if (__ui_kb_keys[i].special == 255) continue;
356
+ ui_kb_draw_key(i);
357
+ }
358
+ return;
359
+ }
360
+ // Redirect drawing into the canvas. All ui_display_* calls below go to RAM.
361
+ CuttlefishDisplayTarget* __kb_prev_target = ui_display_get_target();
362
+ ui_display_set_target(__ui_kb_canvas);
363
+ // Opaque background over the keyboard box (canvas-local 0,0).
364
+ ui_display_fill_rect(0, 0, kw, kh, __ui_kb_bg);
365
+ // Text row + keys: temporarily shift box origin to canvas-local so the
366
+ // existing draw_text_row / draw_key functions compute coords at (0,0).
367
+ int16_t saveBoxX = __ui_kb_box.x;
368
+ int16_t saveBoxY = __ui_kb_box.y;
369
+ __ui_kb_box.x = 0;
370
+ __ui_kb_box.y = 0;
371
+ ui_kb_draw_text_row();
372
+ for (uint8_t i = 0; i < __ui_kb_keyCount; i++) {
373
+ if (__ui_kb_keys[i].special == 255) continue;
374
+ ui_kb_draw_key(i);
375
+ }
376
+ __ui_kb_box.x = saveBoxX;
377
+ __ui_kb_box.y = saveBoxY;
378
+ // Restore display target and push the canvas in one transaction.
379
+ ui_display_set_target(__kb_prev_target);
380
+ display_startWrite();
381
+ display_setAddrWindow(saveBoxX, saveBoxY, kw, kh);
382
+ display_writePixels(display_canvasBuffer(__ui_kb_canvas), static_cast<uint32_t>(kw) * kh);
383
+ display_endWrite();
384
+ }
385
+
386
+ static inline void ui_select_menu_geom(uint16_t nodeIdx, UIRect* out, int16_t* rowH) {
387
+ const UINode* n = &__ui_nodes[nodeIdx];
388
+ int16_t rows = static_cast<int16_t>(n->optionCount);
389
+ if (rows < 1) rows = 1;
390
+ *rowH = 22;
391
+ // Anchor to the select itself: same left edge and width, so the modal
392
+ // reads as the control's own dropdown (never overhangs toward a scrollbar).
393
+ // Clamp to the screen for selects that extend past it.
394
+ int16_t x = n->box.x;
395
+ if (x < 0) x = 0;
396
+ int16_t w = n->box.w;
397
+ if (w < 120) w = 120;
398
+ if (x + w > static_cast<int16_t>(__ui_display_w)) w = static_cast<int16_t>(__ui_display_w) - x;
399
+ int16_t h = rows * (*rowH) + 8;
400
+ int16_t maxH = static_cast<int16_t>(__ui_display_h) * 3 / 5;
401
+ if (h > maxH) h = maxH;
402
+ out->x = x;
403
+ out->y = static_cast<int16_t>((static_cast<int16_t>(__ui_display_h) - h) / 2);
404
+ out->w = w;
405
+ out->h = h;
406
+ }
407
+
408
+ static inline void ui_select_menu_open(uint16_t nodeIdx) {
409
+ if (__ui_nodes[nodeIdx].kind != NODE_SELECT) return;
410
+ if (__ui_nodes[nodeIdx].optionCount < 1) return;
411
+ __ui_select_menu = static_cast<int16_t>(nodeIdx);
412
+ __ui_select_menu_dirty = 1;
413
+ }
414
+
415
+ static inline void ui_select_menu_close(uint8_t repaint) {
416
+ if (__ui_select_menu < 0) return;
417
+ __ui_select_menu = -1;
418
+ __ui_select_menu_dirty = 0;
419
+ if (repaint) {
420
+ // Erase repaint: the whole active screen redraws beneath the vanished
421
+ // overlay (same contract as navigate's mark-all-dirty).
422
+ for (uint16_t i = 0; i < __ui_node_count; i++) {
423
+ __ui_nodes[i].dirty = 1;
424
+ __ui_nodes[i].lastTextHeight = 0;
425
+ }
426
+ }
427
+ }
428
+
429
+ static inline void ui_select_menu_draw() {
430
+ if (__ui_select_menu < 0) return;
431
+ uint16_t idx = static_cast<uint16_t>(__ui_select_menu);
432
+ const UINode* n = &__ui_nodes[idx];
433
+ UIRect g;
434
+ int16_t rowH;
435
+ ui_select_menu_geom(idx, &g, &rowH);
436
+ // Themed panel: the select's own radius/border/bg — the modal reads as
437
+ // the control's popover, matching the surrounding UI (device parity with
438
+ // the preview's drawSelectMenu theming).
439
+ int16_t radius = n->borderRadius > 0 ? n->borderRadius : 6;
440
+ if (radius > g.w / 2) radius = g.w / 2;
441
+ if (radius > g.h / 2) radius = g.h / 2;
442
+ UI_COLOR_T panel = n->hasBg ? n->bg : n->clearColor;
443
+ UI_COLOR_T borderCol = (n->borderStyle != 0 && n->borderColor != 0) ? n->borderColor : n->fg;
444
+ ui_display_fill_round_rect(g.x, g.y, g.w, g.h, radius, panel);
445
+ ui_display_draw_round_rect(g.x, g.y, g.w, g.h, radius, borderCol);
446
+ char buf[UI_TEXT_BUF + 1];
447
+ int16_t rowInset = radius / 2; if (rowInset < 2) rowInset = 2;
448
+ int16_t rowRadius = radius; if (rowRadius > 6) rowRadius = 6;
449
+ for (uint8_t r = 0; r < n->optionCount; r++) {
450
+ int16_t ry = g.y + 4 + static_cast<int16_t>(r) * rowH;
451
+ uint8_t current = (r == static_cast<uint8_t>(n->value));
452
+ // The selected row carries the :checked pair (the kit wires it to
453
+ // --accent/--accent-foreground, shadcn's SelectItem selected state).
454
+ UI_COLOR_T rowBg = n->hasCheckedBg ? static_cast<UI_COLOR_T>(n->checkedBg) : n->fg;
455
+ UI_COLOR_T rowFg = n->hasCheckedFg ? static_cast<UI_COLOR_T>(n->checkedFg) : panel;
456
+ if (current) {
457
+ ui_display_fill_round_rect(g.x + rowInset, ry, g.w - 2 * rowInset, rowH, rowRadius, rowBg);
458
+ }
459
+ buf[0] = 0;
460
+ if (n->optionTextFn) n->optionTextFn(r, buf, UI_TEXT_BUF + 1);
461
+ ui_draw_wrapped_text(buf,
462
+ g.x + 22, ry + (rowH - static_cast<int16_t>(8 * n->textSize)) / 2,
463
+ static_cast<uint16_t>(g.w - 30),
464
+ current ? rowFg : n->fg,
465
+ panel, n->textSize, n->fontAntialias, n->fontFace, n->letterSpacing,
466
+ n->lineHeight, n->whiteSpaceMode, 0, 0, 0);
467
+ if (current) {
468
+ int16_t cx = g.x + 7;
469
+ int16_t cy = ry + rowH / 2;
470
+ ui_display_draw_line(cx, cy, cx + 3, cy + 3, rowFg);
471
+ ui_display_draw_line(cx + 3, cy + 3, cx + 8, cy - 4, rowFg);
472
+ }
473
+ }
474
+ __ui_select_menu_dirty = 0;
475
+ }
476
+
477
+ static inline void ui_select_menu_tap(int16_t tx, int16_t ty) {
478
+ if (__ui_select_menu < 0) return;
479
+ uint16_t idx = static_cast<uint16_t>(__ui_select_menu);
480
+ UIRect g;
481
+ int16_t rowH;
482
+ ui_select_menu_geom(idx, &g, &rowH);
483
+ if (tx >= g.x && tx < g.x + g.w && ty >= g.y && ty < g.y + g.h) {
484
+ int16_t row = (ty - g.y - 4) / rowH;
485
+ if (row >= 0 && row < static_cast<int16_t>(__ui_nodes[idx].optionCount)) {
486
+ __ui_nodes[idx].value = row;
487
+ ui_mark_dirty(idx);
488
+ }
489
+ }
490
+ ui_select_menu_close(1);
491
+ }
492
+
493
+ // ── <drawer> implementation (device twin of the preview drawer) ──────────
494
+
495
+ static int8_t __ui_drawer_slot_of(uint16_t nodeIdx) {
496
+ for (uint8_t s = 0; s < __ui_drawer_slots; s++) {
497
+ if (__ui_drawer_idx[s] == static_cast<int16_t>(nodeIdx)) return static_cast<int8_t>(s);
498
+ }
499
+ return -1;
500
+ }
501
+
502
+ static void ui_drawer_discover() {
503
+ __ui_drawer_slots = 0;
504
+ for (uint16_t i = 0; i < __ui_node_count && __ui_drawer_slots < UI_DRAWER_MAX; i++) {
505
+ if (__ui_nodes[i].drawerSide < 0) continue;
506
+ uint8_t s = __ui_drawer_slots++;
507
+ __ui_drawer_idx[s] = static_cast<int16_t>(i);
508
+ __ui_drawer_open[s] = 0;
509
+ __ui_drawer_progress[s] = 0;
510
+ // Seed closed: subtree offsets at full travel (never paints at rest).
511
+ ui_drawer_apply(s, 0);
512
+ }
513
+ }
514
+
515
+ static void ui_drawer_apply(uint8_t slot, uint8_t progress) {
516
+ if (slot >= __ui_drawer_slots || __ui_drawer_idx[slot] < 0) return;
517
+ uint16_t di = static_cast<uint16_t>(__ui_drawer_idx[slot]);
518
+ UINode* d = &__ui_nodes[di];
519
+ int16_t travel = (d->drawerSide == 2 || d->drawerSide == 3)
520
+ ? static_cast<int16_t>(d->box.w) : static_cast<int16_t>(d->box.h);
521
+ if (d->drawerSide == 4) travel = 0; // <dialog>: centered, no slide
522
+ else {
523
+ // Edge panels must slide FULLY off the display, not just their own
524
+ // height: a bottom panel anchored at the viewport bottom (bottom:0) is
525
+ // already AT the edge, so travel == box.h parks its top row exactly at
526
+ // the fold — a sliver stayed visible after close. The seeded closed
527
+ // state never showed that sliver before the first open (the initial
528
+ // paint's scroll-clip dropped it), and a closed panel can't be tapped
529
+ // open, so the post-close "peek" read as a rendering artifact, not an
530
+ // affordance — it is gone for drawers and toasts alike. Rest position =
531
+ // box minus ancestor scroll (transformOffset still holds the previous
532
+ // apply's slide at this point, so it cannot be used here).
533
+ int16_t restX = static_cast<int16_t>(d->box.x);
534
+ int16_t restY = static_cast<int16_t>(d->box.y);
535
+ uint16_t pa = d->parent;
536
+ while (pa != UI_NO_PARENT && pa < __ui_node_count) {
537
+ if (__ui_nodes[pa].scrollable) restY = static_cast<int16_t>(restY - __ui_nodes[pa].scrollY);
538
+ pa = __ui_nodes[pa].parent;
539
+ }
540
+ int16_t dl = 0, dt = 0, dr = 0, db = 0;
541
+ ui_display_target_bounds(&dl, &dt, &dr, &db);
542
+ int16_t need = d->drawerSide == 0 ? static_cast<int16_t>(db - restY)
543
+ : d->drawerSide == 1 ? static_cast<int16_t>(restY + d->box.h - dt)
544
+ : d->drawerSide == 2 ? static_cast<int16_t>(restX + d->box.w - dl)
545
+ : static_cast<int16_t>(dr - restX);
546
+ if (need > travel) travel = need;
547
+ }
548
+ int16_t off = static_cast<int16_t>((static_cast<int32_t>(travel) * (255 - progress)) / 255);
549
+ int16_t dx = d->drawerSide == 2 ? -off : d->drawerSide == 3 ? off : 0;
550
+ int16_t dy = d->drawerSide == 1 ? -off : d->drawerSide == 0 ? off : 0;
551
+ d->transformOffsetX = dx;
552
+ d->transformOffsetY = dy;
553
+ for (uint16_t i = di + 1; i < d->subtreeEnd; i++) {
554
+ // Descendants don't inherit transform offsets: their own box coords are
555
+ // layout-local, so shift their draw origin via transform offsets too.
556
+ __ui_nodes[i].transformOffsetX += dx - (__ui_drawer_last_dx[slot]);
557
+ __ui_nodes[i].transformOffsetY += dy - (__ui_drawer_last_dy[slot]);
558
+ }
559
+ __ui_drawer_last_dx[slot] = dx;
560
+ __ui_drawer_last_dy[slot] = dy;
561
+ __ui_drawer_progress[slot] = progress;
562
+ }
563
+
564
+ static void ui_drawer_open(uint16_t nodeIdx) {
565
+ int8_t s = __ui_drawer_slot_of(nodeIdx);
566
+ if (s < 0) return;
567
+ __ui_drawer_open[s] = 1;
568
+ __ui_toast_elapsed[s] = 0; // <toast>: restart the auto-close window
569
+ }
570
+
571
+ static void ui_drawer_close(uint16_t nodeIdx) {
572
+ int8_t s = __ui_drawer_slot_of(nodeIdx);
573
+ if (s < 0) return;
574
+ __ui_drawer_open[s] = 0;
575
+ }
576
+
577
+ static void ui_drawer_close_all() {
578
+ for (uint8_t s = 0; s < __ui_drawer_slots; s++) __ui_drawer_open[s] = 0;
579
+ }
580
+
581
+ static void ui_drawer_tick(uint32_t deltaMs) {
582
+ uint8_t step = static_cast<uint8_t>((deltaMs * 255UL) / 180UL);
583
+ if (step == 0) step = 1;
584
+ for (uint8_t s = 0; s < __ui_drawer_slots; s++) {
585
+ if (__ui_drawer_idx[s] < 0) continue;
586
+ uint16_t tnode = static_cast<uint16_t>(__ui_drawer_idx[s]);
587
+ // <toast duration>: once fully open, count up and auto-close. A reopen
588
+ // (ui_drawer_open) restarts the window; manual close just wins.
589
+ if (__ui_nodes[tnode].toastDuration > 0 && __ui_drawer_open[s] &&
590
+ __ui_drawer_progress[s] == 255) {
591
+ __ui_toast_elapsed[s] += deltaMs;
592
+ if (__ui_toast_elapsed[s] >= __ui_nodes[tnode].toastDuration) {
593
+ __ui_drawer_open[s] = 0;
594
+ }
595
+ }
596
+ uint8_t target = __ui_drawer_open[s] ? 255 : 0;
597
+ uint8_t p = __ui_drawer_progress[s];
598
+ if (p == target) continue;
599
+ // Compose each slide frame as the UNION of the drawer's old and new
600
+ // SUBTREE paint rects in tear-free bands (ui_render_screen_bands). The
601
+ // previous contract — mark the whole active screen dirty per slide step —
602
+ // repainted the entire screen with direct per-node clears on no-fb
603
+ // targets: visible flashing, and only ~3 steps fit inside the 180ms
604
+ // slide. The banded union is a fraction of the SPI traffic and never
605
+ // exposes an intermediate state. Falls back to the whole-screen dirty
606
+ // repaint when the band canvas can't allocate or a retained framebuffer
607
+ // composes the frame in RAM (its dirty-union push is already tear-free).
608
+ //
609
+ // SUBTREE extent, not the root's paint rect: a <dialog> root whose
610
+ // children are absolutely positioned lays out at h=0 — the root-rect
611
+ // union degenerated to an empty band (a no-op "composed" success), so
612
+ // NOTHING repainted on open or close on hardware. The preview already
613
+ // used its currentSubtreePaintRect here. Side-4 dialogs also snap
614
+ // instead of animating: travel is 0, so intermediate progress values
615
+ // are 180ms of identical frames — the snap composes ONE band frame for
616
+ // the open state and ONE for the erase, so modal toggles are tear-free
617
+ // on no-fb SPI targets too (mark-all's per-node clears flashed the
618
+ // whole screen).
619
+ uint16_t di = static_cast<uint16_t>(__ui_drawer_idx[s]);
620
+ uint8_t next = target > p ? (p + step > target ? target : p + step)
621
+ : (p < step || p - step < target ? target : p - step);
622
+ if (__ui_nodes[di].drawerSide == 4) next = target;
623
+ UIRect oldRect;
624
+ uint8_t hasOld = ui_subtree_current_paint_rect(di, &oldRect);
625
+ ui_drawer_apply(s, next);
626
+ UIRect newRect;
627
+ uint8_t hasNew = ui_subtree_current_paint_rect(di, &newRect);
628
+ // A failed rect query leaves x/y UNWRITTEN — unioning it against the
629
+ // good side mixed stack garbage into the band region (huge regions clipped
630
+ // to full-screen, or empty ones that "composed" nothing). When one side
631
+ // has no on-screen extent (a dialog snapping open from fully-closed, or
632
+ // erasing to fully-closed), the union is just the side that exists.
633
+ if (!hasOld && !hasNew) continue;
634
+ if (!hasOld) oldRect = newRect;
635
+ if (!hasNew) newRect = oldRect;
636
+ uint8_t composed = 0;
637
+ if (!ui_get_framebuffer()) {
638
+ int16_t ux0 = oldRect.x < newRect.x ? oldRect.x : newRect.x;
639
+ int16_t uy0 = oldRect.y < newRect.y ? oldRect.y : newRect.y;
640
+ int16_t ux1 = oldRect.x + oldRect.w > newRect.x + newRect.w ? static_cast<int16_t>(oldRect.x + oldRect.w) : static_cast<int16_t>(newRect.x + newRect.w);
641
+ int16_t uy1 = oldRect.y + oldRect.h > newRect.y + newRect.h ? static_cast<int16_t>(oldRect.y + oldRect.h) : static_cast<int16_t>(newRect.y + newRect.h);
642
+ composed = ui_render_screen_bands(ux0, uy0, static_cast<int16_t>(ux1 - ux0), static_cast<int16_t>(uy1 - uy0));
643
+ if (composed) {
644
+ // The drawer crossed a scroll viewport that caches its pixels; the
645
+ // canvas holds pre-slide content where the drawer now sits. Invalidate
646
+ // it so the next scroll recomposites instead of shifting stale pixels.
647
+ ui_invalidate_scroll_canvas_for_node(di);
648
+ // The composed bands are the AUTHORITATIVE frame for this region —
649
+ // every node fully inside it is already painted in correct stacking
650
+ // order. Clear those dirty flags or the dirty pass repaints them
651
+ // through the per-node ladder: a viewport-sized overlay (the dialog
652
+ // scrim) is too big for the repair canvas and falls to the DIRECT
653
+ // ladder — one flat full-screen fill that erased the just-composed
654
+ // frame, then the card/title/buttons re-popped band by band. That
655
+ // erase + re-layer sequence was the modal-toggle flash on hardware.
656
+ // Bindings run after the drawer tick, so any dirty flag they raise
657
+ // this frame still repaints normally.
658
+ for (uint16_t q = 0; q < __ui_node_count; q++) {
659
+ if (__ui_nodes[q].screenId != __ui_active_screen) continue;
660
+ if (!__ui_nodes[q].dirty) continue;
661
+ UIRect pr;
662
+ ui_node_current_paint_rect(q, &pr);
663
+ if (pr.w <= 0 || pr.h <= 0) continue;
664
+ if (pr.x >= ux0 && pr.y >= uy0 &&
665
+ static_cast<int16_t>(pr.x + pr.w) <= static_cast<int16_t>(ux1) &&
666
+ static_cast<int16_t>(pr.y + pr.h) <= static_cast<int16_t>(uy1)) {
667
+ __ui_nodes[q].dirty = 0;
668
+ }
669
+ }
670
+ }
671
+ }
672
+ if (!composed) {
673
+ for (uint16_t i = 0; i < __ui_node_count; i++) {
674
+ if (__ui_nodes[i].screenId != __ui_active_screen) continue;
675
+ __ui_nodes[i].dirty = 1;
676
+ __ui_nodes[i].lastTextHeight = 0;
677
+ }
678
+ }
679
+ }
680
+ }
681
+ `;
682
+ }
683
+
684
+ // ── Modal <select> option list ────────────────────────────────────────────
685
+ // Device twin of the preview's select modal: tapping a select opens a
686
+ // centered list of its options (capped to ~60% of the panel height); tapping
687
+ // a row sets the value and closes; tapping outside dismisses. The overlay is
688
+ // stamped after the dirty pass every frame while open; closing marks the
689
+ // whole tree dirty for the erase repaint.