@geastack/apple 0.2.8

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 (113) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +54 -0
  3. package/dist/index.d.ts +127 -0
  4. package/dist/index.js +3076 -0
  5. package/generated/AVFoundation.d.ts +112 -0
  6. package/generated/ActivityKit.d.ts +7 -0
  7. package/generated/AppKit.d.ts +268 -0
  8. package/generated/CoreGraphics.d.ts +18 -0
  9. package/generated/CoreLocation.d.ts +28 -0
  10. package/generated/CoreMedia.d.ts +8 -0
  11. package/generated/Dispatch.d.ts +2 -0
  12. package/generated/Foundation.d.ts +13 -0
  13. package/generated/MapKit.d.ts +28 -0
  14. package/generated/Metal.d.ts +91 -0
  15. package/generated/MetalKit.d.ts +22 -0
  16. package/generated/Photos.d.ts +5 -0
  17. package/generated/QuartzCore.d.ts +12 -0
  18. package/generated/SwiftData.d.ts +11 -0
  19. package/generated/UIKit.d.ts +187 -0
  20. package/package.json +123 -0
  21. package/runtime/AVFoundation.js +136 -0
  22. package/runtime/ActivityKit.js +18 -0
  23. package/runtime/AppKit.js +313 -0
  24. package/runtime/CoreGraphics.js +15 -0
  25. package/runtime/CoreLocation.js +30 -0
  26. package/runtime/CoreMedia.js +7 -0
  27. package/runtime/Dispatch.js +11 -0
  28. package/runtime/Foundation.js +21 -0
  29. package/runtime/MapKit.js +22 -0
  30. package/runtime/Metal.js +126 -0
  31. package/runtime/MetalKit.js +15 -0
  32. package/runtime/Photos.js +9 -0
  33. package/runtime/QuartzCore.js +9 -0
  34. package/runtime/SwiftData.js +29 -0
  35. package/runtime/UIKit.js +242 -0
  36. package/runtime/empty.js +1 -0
  37. package/targets/ios/Info.plist.in +25 -0
  38. package/targets/ios/README.md +46 -0
  39. package/targets/ios/build-ios.sh +395 -0
  40. package/targets/ios/detect-development-team.mjs +109 -0
  41. package/targets/ios/generate-resident-entry.mjs +67 -0
  42. package/targets/ios/generate-resident-registry.mjs +162 -0
  43. package/targets/ios/generate-xcode-project.mjs +520 -0
  44. package/targets/ios/main/canvas_view.h +7 -0
  45. package/targets/ios/main/canvas_view.mm +94 -0
  46. package/targets/ios/main/font_registry.h +17 -0
  47. package/targets/ios/main/font_registry.mm +129 -0
  48. package/targets/ios/main/image_bridge.h +13 -0
  49. package/targets/ios/main/image_bridge.mm +53 -0
  50. package/targets/ios/main/ios_apps.c +28 -0
  51. package/targets/ios/main/ios_camera.mm +446 -0
  52. package/targets/ios/main/ios_display.mm +344 -0
  53. package/targets/ios/main/ios_main.mm +811 -0
  54. package/targets/ios/main/ios_memory.cpp +55 -0
  55. package/targets/ios/main/ios_renderer.h +20 -0
  56. package/targets/ios/main/ios_renderer.mm +27 -0
  57. package/targets/ios/main/ios_root_background.cpp +20 -0
  58. package/targets/ios/main/ios_root_background.h +11 -0
  59. package/targets/ios/main/ios_sensors.mm +244 -0
  60. package/targets/ios/main/ios_timers.mm +53 -0
  61. package/targets/ios/main/renderer/ios_renderer_internal.h +65 -0
  62. package/targets/ios/main/renderer/ios_renderer_support.mm +111 -0
  63. package/targets/ios/main/renderer/native_button.mm +179 -0
  64. package/targets/ios/main/renderer/native_label.mm +161 -0
  65. package/targets/ios/main/renderer/native_scroll_container.mm +279 -0
  66. package/targets/ios/main/renderer/view_reconciler.mm +274 -0
  67. package/targets/macos/Info.plist.in +26 -0
  68. package/targets/macos/README.md +131 -0
  69. package/targets/macos/build-child-lifecycle.sh +119 -0
  70. package/targets/macos/build-macos.sh +2156 -0
  71. package/targets/macos/check-module-graph-freshness.mjs +82 -0
  72. package/targets/macos/compiler-input-fingerprint.mjs +32 -0
  73. package/targets/macos/finalize-apple-native-output.mjs +77 -0
  74. package/targets/macos/generate-resident-entry.mjs +80 -0
  75. package/targets/macos/generate-resident-registry.mjs +141 -0
  76. package/targets/macos/heavy-build-lock.sh +176 -0
  77. package/targets/macos/main/canvas_view.h +9 -0
  78. package/targets/macos/main/canvas_view.mm +176 -0
  79. package/targets/macos/main/color_convert.h +18 -0
  80. package/targets/macos/main/color_convert.mm +39 -0
  81. package/targets/macos/main/font_registry.h +35 -0
  82. package/targets/macos/main/font_registry.mm +350 -0
  83. package/targets/macos/main/image_bridge.h +16 -0
  84. package/targets/macos/main/image_bridge.mm +52 -0
  85. package/targets/macos/main/macos_app_platform.mm +95 -0
  86. package/targets/macos/main/macos_apps.c +31 -0
  87. package/targets/macos/main/macos_device_control.mm +56 -0
  88. package/targets/macos/main/macos_display.mm +484 -0
  89. package/targets/macos/main/macos_host_device_control.mm +95 -0
  90. package/targets/macos/main/macos_main.mm +839 -0
  91. package/targets/macos/main/macos_memory.cpp +31 -0
  92. package/targets/macos/main/macos_native_shell.h +31 -0
  93. package/targets/macos/main/macos_native_shell.mm +396 -0
  94. package/targets/macos/main/macos_network.mm +253 -0
  95. package/targets/macos/main/macos_renderer.h +39 -0
  96. package/targets/macos/main/macos_renderer.mm +1969 -0
  97. package/targets/macos/main/macos_sensors.cpp +148 -0
  98. package/targets/macos/main/macos_smoke_app.cpp +65 -0
  99. package/targets/macos/main/macos_timers.mm +52 -0
  100. package/targets/macos/main/press_bridge.h +21 -0
  101. package/targets/macos/main/press_bridge.mm +72 -0
  102. package/targets/macos/main/thermalright_hid_display.h +14 -0
  103. package/targets/macos/main/thermalright_hid_display.mm +995 -0
  104. package/targets/macos/native-artifact-provenance.mjs +421 -0
  105. package/targets/macos/order-compile-edges.mjs +64 -0
  106. package/targets/macos/preflight-hard-muted-artifact.mjs +93 -0
  107. package/targets/macos/prune-generated-modules.mjs +147 -0
  108. package/targets/macos/sync-resources.mjs +153 -0
  109. package/targets/shared/analyze-audio-capture.mjs +131 -0
  110. package/targets/shared/apple_audio.mm +1194 -0
  111. package/targets/shared/apple_audio_selftest.mm +602 -0
  112. package/targets/shared/apple_audio_testing.h +34 -0
  113. package/targets/shared/build-audio-selftest.sh +50 -0
@@ -0,0 +1,1969 @@
1
+ #import <AppKit/AppKit.h>
2
+ #import <QuartzCore/QuartzCore.h>
3
+ // CIGaussianBlur for `filter: blur()`. QuartzCore re-exports CoreImage, but the
4
+ // umbrella header only pulls it in when modules are off — import it directly.
5
+ #import <CoreImage/CoreImage.h>
6
+
7
+ #include "macos_renderer.h"
8
+ #include "canvas_view.h"
9
+ #include "color_convert.h"
10
+ #include "font_registry.h"
11
+ #include "image_bridge.h"
12
+ #include "press_bridge.h"
13
+
14
+ #include "ui/tree_internal.h"
15
+ #include "ui/node_model.h"
16
+
17
+ #include "events.h"
18
+
19
+ #import <objc/runtime.h>
20
+
21
+ #include <algorithm>
22
+ #include <cmath>
23
+ #include <string>
24
+ #include <cstring>
25
+ #include <cstdlib>
26
+ #include <vector>
27
+
28
+ // NSTextField defaults to consuming mouse events even when bezeled / editable /
29
+ // selectable are all NO. That blocks the NSClickGestureRecognizer on the
30
+ // containing View (e.g. an app-launcher tile) from firing when the user
31
+ // clicks on the label, because the label sits on top and hit-tests first.
32
+ // Override hitTest to make the label transparent — mouse events pass through
33
+ // to the containing View where the recognizer can pick them up. We only ever
34
+ // render non-editable, non-selectable labels via makeTextField, so this is
35
+ // safe; if/when input fields land they'll need their own NSTextField
36
+ // subclass that accepts mouse.
37
+ // NSTextFieldCell reserves a couple of pixels on each edge for the focus
38
+ // ring even when bezeled/bordered are NO — so a field sized for the
39
+ // measured text width clips a few pixels off the right glyph ("JUMP" →
40
+ // "JUM"). This subclass overrides drawingRectForBounds: to use the field's
41
+ // full bounds, with no implicit padding, so the text fits the same space
42
+ // our host measurement reported.
43
+ @interface GeaTextCell : NSTextFieldCell
44
+ @end
45
+ @implementation GeaTextCell
46
+ - (NSRect)drawingRectForBounds:(NSRect)bounds { return bounds; }
47
+ - (NSRect)titleRectForBounds:(NSRect)bounds { return bounds; }
48
+ @end
49
+
50
+ @interface GeaLabelTextField : NSTextField
51
+ @end
52
+ @implementation GeaLabelTextField
53
+ + (Class)cellClass { return [GeaTextCell class]; }
54
+ - (NSView *)hitTest:(NSPoint)point { (void)point; return nil; }
55
+ @end
56
+
57
+ // Editable text field used to materialize `<input>` JSX elements. Holds the
58
+ // owning node id and forwards `controlTextDidChange:` to the framework as an
59
+ // `input` PointerEvent; the framework's per-node listener registry then routes
60
+ // it to whatever `onInput={...}` handler the app installed. We also override
61
+ // the field-editor's keyDown: path via NSTextViewDelegate's
62
+ // `textView:doCommandBySelector:` so Enter / arrows / etc. dispatch as
63
+ // `keydown` events with the corresponding `keyCode` value JS apps expect
64
+ // (matching the browser keyCode table for the few keys the framework cares
65
+ // about — Enter, Backspace, arrows, Escape, Tab).
66
+ @interface GeaInputField : NSTextField <NSTextFieldDelegate>
67
+ @property(nonatomic, assign) int nodeId;
68
+ @end
69
+
70
+ @implementation GeaInputField
71
+
72
+ - (instancetype)initWithFrame:(NSRect)frameRect
73
+ {
74
+ if ((self = [super initWithFrame:frameRect])) {
75
+ _nodeId = -1;
76
+ self.delegate = self;
77
+ }
78
+ return self;
79
+ }
80
+
81
+ static int geaKeyCodeForCommand(SEL sel)
82
+ {
83
+ // Map AppKit's responder selectors (the field editor calls these on its
84
+ // delegate when the user presses control keys) to the keyCode values JS
85
+ // apps read from `event.keyCode`. Only the keys real apps actually
86
+ // branch on; everything else falls through with 0 (no keydown fired).
87
+ if (sel == @selector(insertNewline:)) return 13; // Enter
88
+ if (sel == @selector(insertLineBreak:)) return 13; // Shift+Enter
89
+ if (sel == @selector(insertTab:)) return 9; // Tab
90
+ if (sel == @selector(insertBacktab:)) return 9;
91
+ if (sel == @selector(cancelOperation:)) return 27; // Esc
92
+ if (sel == @selector(deleteBackward:)) return 8; // Backspace
93
+ if (sel == @selector(deleteForward:)) return 46; // Delete
94
+ if (sel == @selector(moveLeft:)) return 37;
95
+ if (sel == @selector(moveUp:)) return 38;
96
+ if (sel == @selector(moveRight:)) return 39;
97
+ if (sel == @selector(moveDown:)) return 40;
98
+ return 0;
99
+ }
100
+
101
+ - (void)dispatchEventOfType:(gea::framework::events::PointerEventType)type withKeyCode:(int)keyCode
102
+ {
103
+ using gea::framework::events::PointerEvent;
104
+ if (_nodeId < 0) return;
105
+ auto &tree = gea::embedded::ui::Tree::instance();
106
+ if (_nodeId >= tree.nodeCount()) return;
107
+ PointerEvent event;
108
+ event.type = type;
109
+ event.targetId = _nodeId;
110
+ event.keyCode = keyCode;
111
+ tree.dispatchEvent(event);
112
+ }
113
+
114
+ - (void)controlTextDidChange:(NSNotification *)note
115
+ {
116
+ (void)note;
117
+ if (_nodeId < 0) return;
118
+ auto &tree = gea::embedded::ui::Tree::instance();
119
+ if (_nodeId >= tree.nodeCount()) return;
120
+ const std::string newValue = std::string([self.stringValue UTF8String] ?: "");
121
+ // Mirror the current text into the node's `value` attribute. The
122
+ // framework's DOM-shim reads `event.currentTarget.value` by falling
123
+ // through to `getAttribute("value")`, so the user's onInput handler sees
124
+ // the latest text without us needing a dedicated event-payload slot.
125
+ tree.setAttribute(_nodeId, "value", newValue.c_str());
126
+ [self dispatchEventOfType:gea::framework::events::PointerEventType::Input withKeyCode:0];
127
+ }
128
+
129
+ - (BOOL)control:(NSControl *)control textView:(NSTextView *)textView doCommandBySelector:(SEL)commandSelector
130
+ {
131
+ (void)control;
132
+ (void)textView;
133
+ const int keyCode = geaKeyCodeForCommand(commandSelector);
134
+ if (keyCode != 0) {
135
+ [self dispatchEventOfType:gea::framework::events::PointerEventType::KeyDown withKeyCode:keyCode];
136
+ // Returning NO lets the field editor still process the command
137
+ // (Enter still commits, arrows still move the caret). Apps that
138
+ // want to override should call preventDefault on the event — not
139
+ // yet wired through.
140
+ }
141
+ return NO;
142
+ }
143
+
144
+ @end
145
+
146
+ // Multi-line text editor. Materialized by `<textarea>` in JSX. The framework
147
+ // only knows about `<input>` (single-line NSTextField); textarea adds proper
148
+ // NSTextView semantics — Enter inserts a newline, soft-wrap, scrolling — for
149
+ // notes-style apps. Lives inside its own NSScrollView so long bodies scroll
150
+ // natively. Mirrors GeaInputField's contract: holds the owning node id,
151
+ // listens for text changes and reflects them to the node's `value` attribute
152
+ // so user code reading `event.currentTarget.value` (or observing the store
153
+ // bound to it) sees the latest text.
154
+ @interface GeaTextAreaView : NSTextView <NSTextViewDelegate>
155
+ @property(nonatomic, assign) int nodeId;
156
+ @property(nonatomic, assign) BOOL suppressChangeNotification;
157
+ @end
158
+
159
+ @implementation GeaTextAreaView
160
+ - (instancetype)initWithFrame:(NSRect)frameRect textContainer:(NSTextContainer *)container
161
+ {
162
+ if ((self = [super initWithFrame:frameRect textContainer:container])) {
163
+ _nodeId = -1;
164
+ _suppressChangeNotification = NO;
165
+ self.delegate = self;
166
+ self.allowsUndo = YES;
167
+ self.richText = NO;
168
+ self.importsGraphics = NO;
169
+ self.usesFontPanel = NO;
170
+ self.usesRuler = NO;
171
+ self.smartInsertDeleteEnabled = YES;
172
+ self.automaticQuoteSubstitutionEnabled = NO;
173
+ self.automaticDashSubstitutionEnabled = NO;
174
+ self.automaticTextReplacementEnabled = NO;
175
+ self.automaticSpellingCorrectionEnabled = NO;
176
+ }
177
+ return self;
178
+ }
179
+
180
+ - (void)textDidChange:(NSNotification *)note
181
+ {
182
+ (void)note;
183
+ if (_nodeId < 0 || _suppressChangeNotification) return;
184
+ using gea::framework::events::PointerEvent;
185
+ using gea::framework::events::PointerEventType;
186
+ auto &tree = gea::embedded::ui::Tree::instance();
187
+ if (_nodeId >= tree.nodeCount()) return;
188
+ const std::string newValue = std::string([self.string UTF8String] ?: "");
189
+ tree.setAttribute(_nodeId, "value", newValue.c_str());
190
+ PointerEvent event;
191
+ event.type = PointerEventType::Input;
192
+ event.targetId = _nodeId;
193
+ event.keyCode = 0;
194
+ tree.dispatchEvent(event);
195
+ }
196
+ @end
197
+
198
+ // Root-level click handler. Installed once on the contentView during
199
+ // MacosRenderer::sync. On click, asks the recognizer for the location,
200
+ // hit-tests against the root, then walks up via nodeIdForView to find the
201
+ // deepest view that's tagged with a node id, and dispatches click+press
202
+ // to that nodeId. Matches the framework's event-delegation model — listeners
203
+ // bound on document.body fire and check event.target to figure out which
204
+ // node was clicked.
205
+ @interface GeaRootClickBridge : NSObject <NSGestureRecognizerDelegate>
206
+ @property(nonatomic, weak) NSView *rootView;
207
+ @property(nonatomic, assign) int pressedNodeId; // -1 when no press in flight
208
+ - (void)fire:(NSGestureRecognizer *)gr;
209
+ @end
210
+
211
+ namespace gea::macos {
212
+ int nodeIdForView(NSView *view)
213
+ {
214
+ while (view) {
215
+ NSNumber *n = objc_getAssociatedObject(view, "gea.node_id");
216
+ if (n) return n.intValue;
217
+ view = view.superview;
218
+ }
219
+ return -1;
220
+ }
221
+ } // namespace gea::macos
222
+
223
+ @implementation GeaRootClickBridge
224
+
225
+ - (instancetype)init
226
+ {
227
+ if ((self = [super init])) {
228
+ _pressedNodeId = -1;
229
+ }
230
+ return self;
231
+ }
232
+
233
+ - (void)fire:(NSGestureRecognizer *)gr
234
+ {
235
+ using gea::framework::events::PointerEvent;
236
+ using gea::framework::events::PointerEventType;
237
+ NSView *root = self.rootView;
238
+ if (!root) return;
239
+
240
+ auto fireEvent = [&](int nodeId, PointerEventType type) {
241
+ if (nodeId < 0) return;
242
+ auto &tree = gea::embedded::ui::Tree::instance();
243
+ if (nodeId >= tree.nodeCount()) return;
244
+ PointerEvent ev;
245
+ ev.type = type;
246
+ ev.targetId = nodeId;
247
+ tree.dispatchEvent(ev);
248
+ };
249
+
250
+ switch (gr.state) {
251
+ case NSGestureRecognizerStateBegan: {
252
+ // Clicking a non-editable target (a note/folder row, a button) ends any
253
+ // in-flight text editing first — exactly like clicking away in Notes.
254
+ // This commits the edit AND drops first-responder, so next frame the
255
+ // editor's value guard no longer skips, letting the title/body refresh
256
+ // to the newly selected note. (Clicks on editable fields never reach
257
+ // this recognizer — the delegate declines them.)
258
+ if (root.window.firstResponder != root.window) {
259
+ [root.window makeFirstResponder:nil];
260
+ }
261
+ // Mouse-down: hit-test now, remember the node so the up phase
262
+ // dispatches to the same target even if the cursor drifts.
263
+ const NSPoint pInRoot = [gr locationInView:root];
264
+ const NSPoint pInSuper = [root convertPoint:pInRoot toView:root.superview];
265
+ NSView *hit = [root.superview hitTest:pInSuper];
266
+ self.pressedNodeId = gea::macos::nodeIdForView(hit);
267
+ fireEvent(self.pressedNodeId, PointerEventType::TouchStart);
268
+ break;
269
+ }
270
+ case NSGestureRecognizerStateEnded:
271
+ case NSGestureRecognizerStateCancelled:
272
+ case NSGestureRecognizerStateFailed: {
273
+ const int nodeId = self.pressedNodeId;
274
+ self.pressedNodeId = -1;
275
+ fireEvent(nodeId, PointerEventType::TouchEnd);
276
+ if (gr.state == NSGestureRecognizerStateEnded) {
277
+ fireEvent(nodeId, PointerEventType::Click);
278
+ }
279
+ break;
280
+ }
281
+ default:
282
+ break;
283
+ }
284
+ }
285
+
286
+ // Don't let the root press-recognizer swallow clicks that land on an editable
287
+ // text control (the note title <input> / body <textarea>). If we recognized
288
+ // those, the field never becomes first responder and the user can't focus or
289
+ // type. Returning NO lets the event flow through AppKit's normal hit-test /
290
+ // responder path so the field edits natively. Non-editable label rows (sidebar
291
+ // folders, note rows) still recognize → gea Press fires.
292
+ - (BOOL)gestureRecognizer:(NSGestureRecognizer *)gestureRecognizer
293
+ shouldAttemptToRecognizeWithEvent:(NSEvent *)event
294
+ {
295
+ (void)gestureRecognizer;
296
+ NSView *root = self.rootView;
297
+ if (!root || !root.window) return YES;
298
+ NSView *content = root.window.contentView;
299
+ const NSPoint p = [content convertPoint:event.locationInWindow fromView:nil];
300
+ NSView *hit = [content hitTest:p];
301
+ for (NSView *v = hit; v != nil; v = v.superview) {
302
+ if ([v isKindOfClass:[NSTextView class]]) return NO;
303
+ if ([v isKindOfClass:[NSTextField class]] && ((NSTextField *)v).isEditable) return NO;
304
+ // Canvas views dispatch their own coordinate-carrying touch events
305
+ // (mouseDown/Dragged/Up in GeaCanvasView) — recognizing here too would
306
+ // double-fire the press, with this path's events carrying no coords.
307
+ if ([v isKindOfClass:[GeaCanvasView class]]) return NO;
308
+ }
309
+ return YES;
310
+ }
311
+ @end
312
+
313
+ // Flipped clip view so scroll content is TOP-aligned: the first row sits at the
314
+ // top of the viewport, and content shorter than the viewport leaves its empty
315
+ // space at the BOTTOM (natural for a top-down list). With the default
316
+ // (unflipped) clip view, short content bottom-aligns, leaving a gap above the
317
+ // first row. The document view itself stays unflipped, so syncRecursive's
318
+ // y-flip against scroll_content_height is unchanged.
319
+ @interface GeaFlippedClipView : NSClipView
320
+ @end
321
+ @implementation GeaFlippedClipView
322
+ - (BOOL)isFlipped { return YES; }
323
+ @end
324
+
325
+ // NSSlider materialized by `<input type="range">`. Continuous; on each value
326
+ // change it writes its integer value into the node's `value` attribute and
327
+ // dispatches an `input` PointerEvent, so the app's onInput handler reads the new
328
+ // value off event.currentTarget.value (same convention as GeaInputField).
329
+ @interface GeaSlider : NSSlider
330
+ @property(nonatomic, assign) int nodeId;
331
+ @property(nonatomic, assign) BOOL geaDragging;
332
+ @end
333
+
334
+ @implementation GeaSlider
335
+ - (instancetype)initWithFrame:(NSRect)frameRect
336
+ {
337
+ if ((self = [super initWithFrame:frameRect])) {
338
+ _nodeId = -1;
339
+ _geaDragging = NO;
340
+ }
341
+ return self;
342
+ }
343
+ - (void)mouseDown:(NSEvent *)event
344
+ {
345
+ // Guard the tracking loop so applySliderProps doesn't fight the user's drag
346
+ // by writing back a (possibly lagging) bound value mid-gesture.
347
+ _geaDragging = YES;
348
+ [super mouseDown:event];
349
+ _geaDragging = NO;
350
+ }
351
+ - (void)geaChanged:(id)sender
352
+ {
353
+ (void)sender;
354
+ if (_nodeId < 0) return;
355
+ auto &tree = gea::embedded::ui::Tree::instance();
356
+ if (_nodeId >= tree.nodeCount()) return;
357
+ const int v = (int)(self.doubleValue + 0.5);
358
+ tree.setAttribute(_nodeId, "value", std::to_string(v).c_str());
359
+ gea::framework::events::PointerEvent event;
360
+ event.type = gea::framework::events::PointerEventType::Input;
361
+ event.targetId = _nodeId;
362
+ tree.dispatchEvent(event);
363
+ }
364
+ @end
365
+
366
+ // NSSwitch materialized by `<input type="checkbox">`. On toggle it writes
367
+ // "true"/"false" into the node's `checked` attribute and dispatches an `input`
368
+ // PointerEvent, so the app's onInput handler flips the bound store field.
369
+ @interface GeaSwitch : NSSwitch
370
+ @property(nonatomic, assign) int nodeId;
371
+ @end
372
+
373
+ @implementation GeaSwitch
374
+ - (instancetype)initWithFrame:(NSRect)frameRect
375
+ {
376
+ if ((self = [super initWithFrame:frameRect])) {
377
+ _nodeId = -1;
378
+ }
379
+ return self;
380
+ }
381
+ - (void)geaChanged:(id)sender
382
+ {
383
+ (void)sender;
384
+ if (_nodeId < 0) return;
385
+ auto &tree = gea::embedded::ui::Tree::instance();
386
+ if (_nodeId >= tree.nodeCount()) return;
387
+ const bool on = self.state == NSControlStateValueOn;
388
+ tree.setAttribute(_nodeId, "checked", on ? "true" : "false");
389
+ gea::framework::events::PointerEvent event;
390
+ event.type = gea::framework::events::PointerEventType::Input;
391
+ event.targetId = _nodeId;
392
+ tree.dispatchEvent(event);
393
+ }
394
+ @end
395
+
396
+ namespace gea::macos {
397
+
398
+ namespace {
399
+
400
+ // nodeId → NSView. Lifetimes are owned by the dictionary (strong refs); when
401
+ // teardown removes the entry the view is released and removeFromSuperview is
402
+ // idempotent so we don't leak stale subview pointers.
403
+ NSMutableDictionary<NSNumber *, NSView *> *nodeIdToView()
404
+ {
405
+ static NSMutableDictionary<NSNumber *, NSView *> *map = [NSMutableDictionary new];
406
+ return map;
407
+ }
408
+
409
+ bool isScrollView(NSView *view)
410
+ {
411
+ return [view isKindOfClass:[NSScrollView class]];
412
+ }
413
+
414
+ NSView *makeTextField()
415
+ {
416
+ GeaLabelTextField *tf = [[GeaLabelTextField alloc] initWithFrame:NSZeroRect];
417
+ tf.bezeled = NO;
418
+ tf.bordered = NO;
419
+ tf.drawsBackground = NO;
420
+ tf.editable = NO;
421
+ tf.selectable = NO;
422
+ tf.usesSingleLineMode = NO;
423
+ // Word-wrap so multi-word labels break at spaces — applyTextProps
424
+ // builds the actual attributed string with the same setting plus
425
+ // kerning=0 + fixed line height, so AppKit's rendered geometry
426
+ // matches what the framework's layout engine measured.
427
+ tf.lineBreakMode = NSLineBreakByWordWrapping;
428
+ tf.cell.wraps = YES;
429
+ tf.cell.truncatesLastVisibleLine = NO;
430
+ [tf setWantsLayer:YES];
431
+ return tf;
432
+ }
433
+
434
+ NSView *makeCanvasView()
435
+ {
436
+ GeaCanvasView *cv = [[GeaCanvasView alloc] initWithFrame:NSZeroRect];
437
+ [cv setWantsLayer:YES];
438
+ return cv;
439
+ }
440
+
441
+ NSView *makeScrollView()
442
+ {
443
+ NSScrollView *sv = [[NSScrollView alloc] initWithFrame:NSZeroRect];
444
+ sv.hasVerticalScroller = YES;
445
+ sv.hasHorizontalScroller = NO;
446
+ sv.autohidesScrollers = YES;
447
+ sv.borderType = NSNoBorder;
448
+ sv.drawsBackground = NO;
449
+ sv.verticalScrollElasticity = NSScrollElasticityAllowed;
450
+ sv.horizontalScrollElasticity = NSScrollElasticityNone;
451
+ sv.usesPredominantAxisScrolling = YES;
452
+ // The clip view (NSClipView) draws controlBackgroundColor (gray) by default,
453
+ // which shows through wherever the scrolled content is transparent. Turn it
454
+ // off so the app's own background shows behind the scroll content.
455
+ GeaFlippedClipView *clip = [[GeaFlippedClipView alloc] initWithFrame:NSZeroRect];
456
+ clip.drawsBackground = NO;
457
+ sv.contentView = clip;
458
+ NSView *content = [[NSView alloc] initWithFrame:NSZeroRect];
459
+ [content setWantsLayer:YES];
460
+ sv.documentView = content;
461
+ return sv;
462
+ }
463
+
464
+ NSView *makeImageView()
465
+ {
466
+ NSImageView *iv = [[NSImageView alloc] initWithFrame:NSZeroRect];
467
+ iv.imageScaling = NSImageScaleAxesIndependently;
468
+ iv.editable = NO;
469
+ [iv setWantsLayer:YES];
470
+ return iv;
471
+ }
472
+
473
+ NSView *makeSlider()
474
+ {
475
+ GeaSlider *sl = [[GeaSlider alloc] initWithFrame:NSZeroRect];
476
+ sl.sliderType = NSSliderTypeLinear;
477
+ sl.minValue = 0;
478
+ sl.maxValue = 100;
479
+ sl.continuous = YES;
480
+ sl.target = sl;
481
+ sl.action = @selector(geaChanged:);
482
+ return sl;
483
+ }
484
+
485
+ NSView *makeSwitch()
486
+ {
487
+ GeaSwitch *sw = [[GeaSwitch alloc] initWithFrame:NSZeroRect];
488
+ sw.target = sw;
489
+ sw.action = @selector(geaChanged:);
490
+ return sw;
491
+ }
492
+
493
+ NSView *makeButton()
494
+ {
495
+ NSButton *btn = [[NSButton alloc] initWithFrame:NSZeroRect];
496
+ btn.buttonType = NSButtonTypeMomentaryLight;
497
+ btn.bezelStyle = NSBezelStyleRounded;
498
+ [btn setWantsLayer:YES];
499
+ PressBridge *bridge = [[PressBridge alloc] init];
500
+ bridge.nodeId = -1; // set during applyButtonProps
501
+ objc_setAssociatedObject(btn, "gea.press_bridge", bridge, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
502
+ btn.target = bridge;
503
+ btn.action = @selector(fire:);
504
+ return btn;
505
+ }
506
+
507
+ NSView *makeInputField()
508
+ {
509
+ GeaInputField *tf = [[GeaInputField alloc] initWithFrame:NSZeroRect];
510
+ tf.bezeled = NO;
511
+ tf.bordered = NO;
512
+ tf.drawsBackground = NO;
513
+ tf.editable = YES;
514
+ tf.selectable = YES;
515
+ tf.usesSingleLineMode = YES;
516
+ tf.focusRingType = NSFocusRingTypeNone;
517
+ [tf setWantsLayer:YES];
518
+ return tf;
519
+ }
520
+
521
+ NSVisualEffectMaterial vibrancyMaterialFromString(const char *name)
522
+ {
523
+ if (!name || !name[0]) return NSVisualEffectMaterialSidebar;
524
+ if (std::strcmp(name, "sidebar") == 0) return NSVisualEffectMaterialSidebar;
525
+ if (std::strcmp(name, "header") == 0) return NSVisualEffectMaterialHeaderView;
526
+ if (std::strcmp(name, "content") == 0) return NSVisualEffectMaterialContentBackground;
527
+ if (std::strcmp(name, "under-window") == 0) return NSVisualEffectMaterialUnderWindowBackground;
528
+ if (std::strcmp(name, "under-page") == 0) return NSVisualEffectMaterialUnderPageBackground;
529
+ if (std::strcmp(name, "window-background") == 0) return NSVisualEffectMaterialWindowBackground;
530
+ if (std::strcmp(name, "titlebar") == 0) return NSVisualEffectMaterialTitlebar;
531
+ if (std::strcmp(name, "menu") == 0) return NSVisualEffectMaterialMenu;
532
+ if (std::strcmp(name, "popover") == 0) return NSVisualEffectMaterialPopover;
533
+ if (std::strcmp(name, "hud") == 0) return NSVisualEffectMaterialHUDWindow;
534
+ if (std::strcmp(name, "selection") == 0) return NSVisualEffectMaterialSelection;
535
+ if (std::strcmp(name, "tooltip") == 0) return NSVisualEffectMaterialToolTip;
536
+ if (std::strcmp(name, "fullscreen-ui") == 0) return NSVisualEffectMaterialFullScreenUI;
537
+ return NSVisualEffectMaterialSidebar;
538
+ }
539
+
540
+ NSVisualEffectBlendingMode vibrancyBlendingFromString(const char *name)
541
+ {
542
+ if (!name || !name[0]) return NSVisualEffectBlendingModeBehindWindow;
543
+ if (std::strcmp(name, "within") == 0) return NSVisualEffectBlendingModeWithinWindow;
544
+ if (std::strcmp(name, "within-window") == 0) return NSVisualEffectBlendingModeWithinWindow;
545
+ if (std::strcmp(name, "behind") == 0) return NSVisualEffectBlendingModeBehindWindow;
546
+ if (std::strcmp(name, "behind-window") == 0) return NSVisualEffectBlendingModeBehindWindow;
547
+ return NSVisualEffectBlendingModeBehindWindow;
548
+ }
549
+
550
+ NSView *makeVibrancyView()
551
+ {
552
+ NSVisualEffectView *ve = [[NSVisualEffectView alloc] initWithFrame:NSZeroRect];
553
+ ve.material = NSVisualEffectMaterialSidebar;
554
+ ve.blendingMode = NSVisualEffectBlendingModeBehindWindow;
555
+ ve.state = NSVisualEffectStateFollowsWindowActiveState;
556
+ ve.wantsLayer = YES;
557
+ return ve;
558
+ }
559
+
560
+ // `<symbol data-symbol="folder">` → NSImageView showing the named SF Symbol.
561
+ // SF Symbols are template images; contentTintColor (from CSS color via
562
+ // data-color, or labelColor) colors them. Tagged so applyTypeSpecificProps
563
+ // routes it to applySymbolProps rather than the bitmap-image path.
564
+ NSView *makeSymbolView()
565
+ {
566
+ NSImageView *iv = [[NSImageView alloc] initWithFrame:NSZeroRect];
567
+ iv.imageScaling = NSImageScaleProportionallyUpOrDown;
568
+ iv.imageAlignment = NSImageAlignCenter;
569
+ iv.editable = NO;
570
+ [iv setWantsLayer:YES];
571
+ objc_setAssociatedObject(iv, "gea.is_symbol", @(YES), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
572
+ return iv;
573
+ }
574
+
575
+ // `<textarea>` materializes as an NSScrollView containing a GeaTextAreaView.
576
+ // applyViewStyle frames the scroll view; applyTextAreaProps drives the inner
577
+ // text view's string / font / color and pushes user edits back into the
578
+ // node's `value` attribute. The text view's textContainer is set up to track
579
+ // the scroll view's content width, so wrapping behaves like a typical
580
+ // document editor.
581
+ NSView *makeTextArea()
582
+ {
583
+ NSScrollView *sv = [[NSScrollView alloc] initWithFrame:NSZeroRect];
584
+ sv.hasVerticalScroller = YES;
585
+ sv.hasHorizontalScroller = NO;
586
+ sv.autohidesScrollers = YES;
587
+ sv.borderType = NSNoBorder;
588
+ sv.drawsBackground = NO;
589
+ sv.verticalScrollElasticity = NSScrollElasticityAllowed;
590
+ sv.horizontalScrollElasticity = NSScrollElasticityNone;
591
+ [sv setWantsLayer:YES];
592
+
593
+ const NSSize cs = sv.contentSize;
594
+ NSTextContainer *tc = [[NSTextContainer alloc] initWithContainerSize:NSMakeSize(cs.width, CGFLOAT_MAX)];
595
+ tc.widthTracksTextView = YES;
596
+ NSLayoutManager *lm = [[NSLayoutManager alloc] init];
597
+ [lm addTextContainer:tc];
598
+ NSTextStorage *ts = [[NSTextStorage alloc] init];
599
+ [ts addLayoutManager:lm];
600
+
601
+ GeaTextAreaView *tv = [[GeaTextAreaView alloc] initWithFrame:NSMakeRect(0, 0, cs.width, cs.height)
602
+ textContainer:tc];
603
+ tv.minSize = NSMakeSize(0, cs.height);
604
+ tv.maxSize = NSMakeSize(CGFLOAT_MAX, CGFLOAT_MAX);
605
+ tv.verticallyResizable = YES;
606
+ tv.horizontallyResizable = NO;
607
+ tv.autoresizingMask = NSViewWidthSizable;
608
+ tv.drawsBackground = NO;
609
+ tv.textContainerInset = NSMakeSize(0, 0);
610
+
611
+ sv.documentView = tv;
612
+ objc_setAssociatedObject(sv, "gea.text_view", tv, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
613
+ return sv;
614
+ }
615
+
616
+ // `<input>` JSX elements go through createElement with no special tag handling
617
+ // at the framework level — they fall through to `createView()`, so node.type
618
+ // is View but node.tag_name == "input". The renderer-side dispatch teaches
619
+ // makeViewForType about that distinction so an editable NSTextField gets
620
+ // materialized instead of an empty NSView. Calls to applyInputProps later
621
+ // pick up placeholder / value / font / color from the node.
622
+ NSView *makeViewForType(gea::embedded::ui::NodeType type, const char *tagName,
623
+ const gea::embedded::ui::ComputedStyle &style, const char *inputType)
624
+ {
625
+ using gea::embedded::ui::NodeType;
626
+ if (type == NodeType::View && tagName && std::strcmp(tagName, "input") == 0) {
627
+ if (inputType && std::strcmp(inputType, "range") == 0) return makeSlider();
628
+ if (inputType && std::strcmp(inputType, "checkbox") == 0) return makeSwitch();
629
+ return makeInputField();
630
+ }
631
+ if (type == NodeType::View && tagName && std::strcmp(tagName, "textarea") == 0) {
632
+ return makeTextArea();
633
+ }
634
+ if (type == NodeType::View && tagName && std::strcmp(tagName, "vibrancy") == 0) {
635
+ return makeVibrancyView();
636
+ }
637
+ if (type == NodeType::View && tagName && std::strcmp(tagName, "symbol") == 0) {
638
+ return makeSymbolView();
639
+ }
640
+ switch (type) {
641
+ case NodeType::Text:
642
+ return makeTextField();
643
+ case NodeType::Button:
644
+ return makeButton();
645
+ case NodeType::Image:
646
+ return makeImageView();
647
+ case NodeType::Canvas:
648
+ return makeCanvasView();
649
+ case NodeType::VirtualList:
650
+ return makeScrollView();
651
+ case NodeType::View:
652
+ default: {
653
+ // overflow == 2 is `scroll` in the framework's style enum. Materialize
654
+ // such a View as an NSScrollView so AppKit handles wheel/trackpad
655
+ // scrolling and clipping natively, matching what the framework's
656
+ // pixel-renderer would do on hardware.
657
+ if (style.overflow == 2) return makeScrollView();
658
+ NSView *view = [[NSView alloc] initWithFrame:NSZeroRect];
659
+ [view setWantsLayer:YES];
660
+ return view;
661
+ }
662
+ }
663
+ }
664
+
665
+ // ---------------------------------------------------------------------------
666
+ // CSS border-radius (incl. percentages), filter: blur() and box-shadow.
667
+ //
668
+ // This target does not rasterise with the engine — it maps the node tree onto
669
+ // real AppKit views — so anything the engine expresses only inside its software
670
+ // rasteriser is lost unless it is re-expressed with CALayer primitives here.
671
+ // Three properties were in exactly that state: parsed, stored, then dropped.
672
+ //
673
+ // * `border-radius: <percent>` lives in ComputedStyle::border_radius_percent[]
674
+ // (ui/node_model.h), a SEPARATE array from the pixel radii, and resolves to
675
+ // an ELLIPTICAL rx/ry pair (w*p, h*p). CALayer.cornerRadius is a single
676
+ // scalar: it cannot express rx != ry, nor four different corners. Those
677
+ // cases need a real path, so they get a CAShapeLayer.
678
+ // * `filter: blur(n)` maps to CALayer.filters — NOT backgroundFilters: CSS
679
+ // `filter` blurs the element and its subtree, `backgroundFilters` blurs what
680
+ // is painted behind it (that is CSS `backdrop-filter`).
681
+ // * `box-shadow`: the engine's parser only ever stores an INSET shadow — see
682
+ // style.cpp parseInsetBoxShadow, which `continue`s past every non-inset
683
+ // layer and zeroes box_shadow_alpha. So an inset shadow is the only shadow
684
+ // that reaches a node, and CALayer's shadow* properties (which cast
685
+ // outwards) cannot draw it directly; it is drawn from a shadowPath ring.
686
+ //
687
+ // The radius resolution mirrors the engine's own rule — ui/view.cpp
688
+ // resolvedBorderRadii8 and ui/tree_render.cpp resolveNodeCircularRadii:
689
+ // percent → (w*p, h*p), pixel → (r, r), then the CSS 9.4 overlap scale-down so
690
+ // two radii on one side never sum past that side's length. It is restated here
691
+ // rather than shared because both engine helpers are file-local to raster
692
+ // translation units this target never links against.
693
+ // ---------------------------------------------------------------------------
694
+
695
+ struct ResolvedRadii {
696
+ CGFloat rx[4]; // CSS corner order: top-left, top-right, bottom-right, bottom-left
697
+ CGFloat ry[4];
698
+ bool uniform; // all four corners circular AND equal → layer.cornerRadius suffices
699
+ };
700
+
701
+ ResolvedRadii resolveRadii(const gea::embedded::ui::Node &node, CGFloat w, CGFloat h)
702
+ {
703
+ ResolvedRadii out{};
704
+ const double width = std::max(0.0, static_cast<double>(w));
705
+ const double height = std::max(0.0, static_cast<double>(h));
706
+ double rx[4]{};
707
+ double ry[4]{};
708
+ for (int i = 0; i < 4; ++i) {
709
+ if (node.style.border_radius_percent[i] != gea::embedded::ui::kUnset) {
710
+ const double p = static_cast<double>(node.style.border_radius_percent[i]) / 1000.0;
711
+ rx[i] = std::max(0.0, width * p);
712
+ ry[i] = std::max(0.0, height * p);
713
+ } else {
714
+ const double r = std::max(0, static_cast<int>(node.style.border_radius[i]));
715
+ rx[i] = r;
716
+ ry[i] = r;
717
+ }
718
+ }
719
+
720
+ double scale = 1.0;
721
+ const auto constrain = [&](double limit, double sum) {
722
+ if (limit > 0.0 && sum > limit) scale = std::min(scale, limit / sum);
723
+ };
724
+ constrain(width, rx[0] + rx[1]);
725
+ constrain(width, rx[3] + rx[2]);
726
+ constrain(height, ry[0] + ry[3]);
727
+ constrain(height, ry[1] + ry[2]);
728
+
729
+ out.uniform = true;
730
+ for (int i = 0; i < 4; ++i) {
731
+ out.rx[i] = static_cast<CGFloat>(rx[i] * scale);
732
+ out.ry[i] = static_cast<CGFloat>(ry[i] * scale);
733
+ if (std::fabs(out.rx[i] - out.ry[i]) > 0.5) out.uniform = false;
734
+ if (std::fabs(out.rx[i] - out.rx[0]) > 0.5) out.uniform = false;
735
+ if (std::fabs(out.ry[i] - out.ry[0]) > 0.5) out.uniform = false;
736
+ }
737
+ return out;
738
+ }
739
+
740
+ // Rounded rect with per-corner ELLIPTICAL radii. CGPathAddArcToPoint only draws
741
+ // circular arcs, so each corner is the standard cubic approximation of a
742
+ // quarter ellipse (kappa = 4/3 * (sqrt(2) - 1)).
743
+ //
744
+ // `flipped` is the owning view's isFlipped: an unflipped NSView's layer has y
745
+ // growing upward, so CSS "top" is max-y; a flipped one (NSClipView and friends)
746
+ // has y growing downward and the top/bottom corner pairs swap.
747
+ CGPathRef createRoundedRectPath(CGRect rect, const CGFloat rxIn[4], const CGFloat ryIn[4], bool flipped)
748
+ {
749
+ static const CGFloat kKappa = 0.5522847498307936;
750
+ // Index by visual position in the layer's own coordinate space.
751
+ const int upperLeft = flipped ? 3 : 0;
752
+ const int upperRight = flipped ? 2 : 1;
753
+ const int lowerRight = flipped ? 1 : 2;
754
+ const int lowerLeft = flipped ? 0 : 3;
755
+ const CGFloat x = CGRectGetMinX(rect);
756
+ const CGFloat y = CGRectGetMinY(rect);
757
+ const CGFloat w = CGRectGetWidth(rect);
758
+ const CGFloat h = CGRectGetHeight(rect);
759
+ const CGFloat ulx = rxIn[upperLeft], uly = ryIn[upperLeft];
760
+ const CGFloat urx = rxIn[upperRight], ury = ryIn[upperRight];
761
+ const CGFloat lrx = rxIn[lowerRight], lry = ryIn[lowerRight];
762
+ const CGFloat llx = rxIn[lowerLeft], lly = ryIn[lowerLeft];
763
+
764
+ CGMutablePathRef path = CGPathCreateMutable();
765
+ CGPathMoveToPoint(path, NULL, x + llx, y);
766
+ CGPathAddLineToPoint(path, NULL, x + w - lrx, y);
767
+ CGPathAddCurveToPoint(path, NULL, x + w - lrx + kKappa * lrx, y, x + w, y + lry - kKappa * lry,
768
+ x + w, y + lry);
769
+ CGPathAddLineToPoint(path, NULL, x + w, y + h - ury);
770
+ CGPathAddCurveToPoint(path, NULL, x + w, y + h - ury + kKappa * ury,
771
+ x + w - urx + kKappa * urx, y + h, x + w - urx, y + h);
772
+ CGPathAddLineToPoint(path, NULL, x + ulx, y + h);
773
+ CGPathAddCurveToPoint(path, NULL, x + ulx - kKappa * ulx, y + h, x, y + h - uly + kKappa * uly,
774
+ x, y + h - uly);
775
+ CGPathAddLineToPoint(path, NULL, x, y + lly);
776
+ CGPathAddCurveToPoint(path, NULL, x, y + lly - kKappa * lly, x + llx - kKappa * llx, y,
777
+ x + llx, y);
778
+ CGPathCloseSubpath(path);
779
+ return path;
780
+ }
781
+
782
+ // Layers this file attaches to a view are cached on it (keyed by the address of
783
+ // one of the k*Key bytes below) so a sync pass that changes nothing allocates
784
+ // nothing.
785
+ CAShapeLayer *cachedShapeLayer(NSView *view, const void *key)
786
+ {
787
+ CAShapeLayer *layer = objc_getAssociatedObject(view, key);
788
+ if (!layer) {
789
+ layer = [CAShapeLayer layer];
790
+ // Sync passes run every frame; implicit CA animations would smear every
791
+ // geometry change over a quarter second and lag the node tree.
792
+ layer.actions = @{
793
+ @"path" : [NSNull null],
794
+ @"bounds" : [NSNull null],
795
+ @"position" : [NSNull null],
796
+ @"fillColor" : [NSNull null],
797
+ @"strokeColor" : [NSNull null],
798
+ @"shadowPath" : [NSNull null],
799
+ @"shadowOpacity" : [NSNull null],
800
+ @"hidden" : [NSNull null],
801
+ };
802
+ objc_setAssociatedObject(view, key, layer, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
803
+ }
804
+ return layer;
805
+ }
806
+
807
+ void dropShapeLayer(NSView *view, const void *key)
808
+ {
809
+ CAShapeLayer *layer = objc_getAssociatedObject(view, key);
810
+ if (!layer) return;
811
+ if (layer.superlayer) [layer removeFromSuperlayer];
812
+ if (view.layer.mask == layer) view.layer.mask = nil;
813
+ objc_setAssociatedObject(view, key, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
814
+ }
815
+
816
+ // Association keys — the ADDRESSES identify the slot, so these must not be
817
+ // `const` (identical const objects are mergeable, which would alias the slots).
818
+ char kShapeMaskKey; // CAShapeLayer installed as view.layer.mask
819
+ char kShapeFillKey; // CAShapeLayer painting bg + border when a mask can't be used
820
+ char kShapeStrokeKey; // CAShapeLayer drawing the border along a non-rect shape
821
+ char kInsetShadowKey; // CAShapeLayer casting the inset box-shadow
822
+
823
+ CGColorRef createStyleCGColor(gea::embedded::ui::style_color_t color, uint8_t alpha)
824
+ {
825
+ CGColor *base = gea::macos::rgb565ToCGColor(color);
826
+ if (alpha == 255) return base;
827
+ CGColor *faded = CGColorCreateCopyWithAlpha(base, alpha / 255.0);
828
+ CGColorRelease(base);
829
+ return faded;
830
+ }
831
+
832
+ // border-radius. Uniform circular radii stay on layer.cornerRadius (the cheap
833
+ // path every already-correct app takes); elliptical or per-corner radii get a
834
+ // CAShapeLayer, because cornerRadius is one scalar and physically cannot say
835
+ // rx != ry — a `border-radius: 50%` on a 360x34 box is an ellipse, not a
836
+ // stadium, and rendered square before this.
837
+ void applyCornerRadius(NSView *view, const gea::embedded::ui::Node &node, CGFloat w, CGFloat h,
838
+ bool blurred)
839
+ {
840
+ const ResolvedRadii radii = resolveRadii(node, w, h);
841
+ const CGFloat borderWidth = static_cast<CGFloat>(std::max(0, static_cast<int>(node.style.border_width)));
842
+
843
+ if (radii.uniform) {
844
+ dropShapeLayer(view, &kShapeMaskKey);
845
+ dropShapeLayer(view, &kShapeFillKey);
846
+ dropShapeLayer(view, &kShapeStrokeKey);
847
+ view.layer.mask = nil;
848
+ view.layer.cornerRadius = radii.rx[0];
849
+ view.layer.masksToBounds = view.layer.cornerRadius > 0 || node.style.overflow == 1;
850
+ return;
851
+ }
852
+
853
+ view.layer.cornerRadius = 0;
854
+ const CGRect bounds = CGRectMake(0, 0, w, h);
855
+ const bool flipped = view.isFlipped;
856
+
857
+ if (blurred) {
858
+ // A `filter: blur()` halo extends far past the element's box (CSS blurs
859
+ // the rendered element, then composites). A layer mask is applied to the
860
+ // layer's FINAL composite, so masking to the shape would shear the halo
861
+ // off at the element edge. Paint the shape into a sublayer instead and
862
+ // leave the view's own layer unclipped.
863
+ //
864
+ // The fill path is inset by half the border width and stroked with the
865
+ // full width, so the stroke's outer edge lands exactly on the border box
866
+ // — CSS borders are drawn inside the box — and one layer covers both.
867
+ dropShapeLayer(view, &kShapeMaskKey);
868
+ dropShapeLayer(view, &kShapeStrokeKey);
869
+ view.layer.mask = nil;
870
+ CAShapeLayer *fill = cachedShapeLayer(view, &kShapeFillKey);
871
+ if (fill.superlayer != view.layer) [view.layer insertSublayer:fill atIndex:0];
872
+ fill.frame = bounds;
873
+ const CGFloat inset = borderWidth * 0.5;
874
+ CGFloat rx[4];
875
+ CGFloat ry[4];
876
+ for (int i = 0; i < 4; ++i) {
877
+ rx[i] = std::max<CGFloat>(0, radii.rx[i] - inset);
878
+ ry[i] = std::max<CGFloat>(0, radii.ry[i] - inset);
879
+ }
880
+ CGPathRef path = createRoundedRectPath(CGRectInset(bounds, inset, inset), rx, ry, flipped);
881
+ fill.path = path;
882
+ CGPathRelease(path);
883
+ if (node.style.has_bg) {
884
+ CGColorRef bg = createStyleCGColor(node.style.bg_color, node.style.bg_alpha);
885
+ fill.fillColor = bg;
886
+ CGColorRelease(bg);
887
+ } else {
888
+ fill.fillColor = nil;
889
+ }
890
+ if (borderWidth > 0) {
891
+ CGColorRef bc = createStyleCGColor(node.style.border_color, node.style.border_alpha);
892
+ fill.strokeColor = bc;
893
+ CGColorRelease(bc);
894
+ fill.lineWidth = borderWidth;
895
+ } else {
896
+ fill.strokeColor = nil;
897
+ fill.lineWidth = 0;
898
+ }
899
+ // The shape layer now owns background and border; the view's layer must
900
+ // not paint a rectangular one underneath it.
901
+ view.layer.backgroundColor = nil;
902
+ view.layer.borderWidth = 0;
903
+ view.layer.masksToBounds = NO;
904
+ return;
905
+ }
906
+
907
+ dropShapeLayer(view, &kShapeFillKey);
908
+ CAShapeLayer *mask = cachedShapeLayer(view, &kShapeMaskKey);
909
+ mask.frame = bounds;
910
+ CGPathRef path = createRoundedRectPath(bounds, radii.rx, radii.ry, flipped);
911
+ mask.path = path;
912
+ CGPathRelease(path);
913
+ mask.fillColor = CGColorGetConstantColor(kCGColorBlack);
914
+ if (view.layer.mask != mask) view.layer.mask = mask;
915
+ // The mask already clips this layer and its subviews to the shape.
916
+ view.layer.masksToBounds = node.style.overflow == 1;
917
+
918
+ // layer.borderWidth strokes the RECTANGULAR bounds; under the mask that
919
+ // leaves a border on the straight edges and nothing on the curves. Stroke
920
+ // the real shape instead, inset by half the width so it stays inside the
921
+ // border box (and so the mask does not eat its outer half).
922
+ if (borderWidth > 0) {
923
+ view.layer.borderWidth = 0;
924
+ CAShapeLayer *stroke = cachedShapeLayer(view, &kShapeStrokeKey);
925
+ if (stroke.superlayer != view.layer) [view.layer addSublayer:stroke];
926
+ stroke.frame = bounds;
927
+ const CGFloat inset = borderWidth * 0.5;
928
+ CGFloat rx[4];
929
+ CGFloat ry[4];
930
+ for (int i = 0; i < 4; ++i) {
931
+ rx[i] = std::max<CGFloat>(0, radii.rx[i] - inset);
932
+ ry[i] = std::max<CGFloat>(0, radii.ry[i] - inset);
933
+ }
934
+ CGPathRef strokePath = createRoundedRectPath(CGRectInset(bounds, inset, inset), rx, ry, flipped);
935
+ stroke.path = strokePath;
936
+ CGPathRelease(strokePath);
937
+ stroke.fillColor = nil;
938
+ CGColorRef bc = createStyleCGColor(node.style.border_color, node.style.border_alpha);
939
+ stroke.strokeColor = bc;
940
+ CGColorRelease(bc);
941
+ stroke.lineWidth = borderWidth;
942
+ } else {
943
+ dropShapeLayer(view, &kShapeStrokeKey);
944
+ }
945
+ }
946
+
947
+ // filter: blur(<n>px). CALayer.filters applies Core Image to the layer AND its
948
+ // sublayers, which is CSS `filter`'s scope (the element plus its subtree).
949
+ // NSView refuses to route CIFilters into its backing layer unless
950
+ // layerUsesCoreImageFilters is set, and the halo is clipped unless the layer
951
+ // stops masking to its bounds.
952
+ void applyFilterBlur(NSView *view, const gea::embedded::ui::Node &node)
953
+ {
954
+ const int radius = gea::embedded::ui::rstyle(node.style).filter_blur_radius;
955
+ if (radius <= 0) {
956
+ if (view.layer.filters != nil) view.layer.filters = nil;
957
+ return;
958
+ }
959
+ if (!view.layerUsesCoreImageFilters) view.layerUsesCoreImageFilters = YES;
960
+ view.layer.masksToBounds = NO;
961
+ NSNumber *applied = [view.layer valueForKey:@"geaFilterBlurRadius"];
962
+ if (applied && applied.intValue == radius && view.layer.filters.count == 1) return;
963
+ CIFilter *blur = [CIFilter filterWithName:@"CIGaussianBlur"];
964
+ if (!blur) return;
965
+ [blur setDefaults];
966
+ // CSS blur(<len>) is a Gaussian with standard deviation <len>, which is what
967
+ // CIGaussianBlur's inputRadius is (it is the sigma, not the kernel extent).
968
+ [blur setValue:@(static_cast<double>(radius)) forKey:@"inputRadius"];
969
+ view.layer.filters = @[ blur ];
970
+ [view.layer setValue:@(radius) forKey:@"geaFilterBlurRadius"];
971
+ }
972
+
973
+ // box-shadow. The engine's parser keeps only inset shadows (style.cpp
974
+ // parseInsetBoxShadow skips every non-inset layer and leaves box_shadow_alpha
975
+ // at 0), so an inset shadow is the only one a node can carry.
976
+ //
977
+ // CALayer's shadow casts OUTWARD from the layer's silhouette, so it cannot draw
978
+ // an inset shadow directly. The construction: a layer with no content at all
979
+ // whose shadowPath is (a rect well outside the box) MINUS (the box's own shape,
980
+ // moved by the offset and shrunk by the spread) — a ring whose shadow therefore
981
+ // falls INWARD — with masksToBounds clipping it to the element. shadowPath makes
982
+ // the shadow independent of the layer's (empty) contents, so nothing is painted;
983
+ // the subtraction is by opposite winding, see the path build below.
984
+ //
985
+ // Measured against the CSS model (sigma = blur/2, alpha, offset) on a
986
+ // `inset 0 8px 26px rgba(0,0,0,0.75)` probe: the rendered edge profile matched
987
+ // 255*(1 - a*Phi((offset-d)/sigma)) to within 3/255 at every depth d.
988
+ void applyBoxShadow(NSView *view, const gea::embedded::ui::Node &node, CGFloat w, CGFloat h)
989
+ {
990
+ const auto &rare = gea::embedded::ui::rstyle(node.style);
991
+ if (!rare.box_shadow_inset || rare.box_shadow_alpha == 0 || w <= 0 || h <= 0) {
992
+ dropShapeLayer(view, &kInsetShadowKey);
993
+ return;
994
+ }
995
+
996
+ const CGFloat blur = std::max(0, static_cast<int>(rare.box_shadow_blur_radius));
997
+ const CGFloat spread = static_cast<CGFloat>(rare.box_shadow_spread);
998
+ const CGFloat offsetX = static_cast<CGFloat>(rare.box_shadow_offset_x);
999
+ const CGFloat offsetY = static_cast<CGFloat>(rare.box_shadow_offset_y);
1000
+ const CGRect bounds = CGRectMake(0, 0, w, h);
1001
+ const bool flipped = view.isFlipped;
1002
+
1003
+ CAShapeLayer *shadow = cachedShapeLayer(view, &kInsetShadowKey);
1004
+ // Above the background (which lives on view.layer itself), below the
1005
+ // children — the CSS paint order for an inset shadow.
1006
+ if (shadow.superlayer != view.layer) [view.layer insertSublayer:shadow atIndex:0];
1007
+ shadow.frame = bounds;
1008
+ shadow.masksToBounds = YES;
1009
+ shadow.fillColor = nil;
1010
+ shadow.strokeColor = nil;
1011
+
1012
+ const ResolvedRadii radii = resolveRadii(node, w, h);
1013
+ // The shadow-casting hole: the padding box shrunk by the spread and moved by
1014
+ // the CSS offset. AppKit's y grows upward on an unflipped view, so a
1015
+ // positive CSS offsetY (downward) is a negative dy here.
1016
+ const CGFloat dy = flipped ? offsetY : -offsetY;
1017
+ CGRect hole = CGRectInset(bounds, spread, spread);
1018
+ hole = CGRectOffset(hole, offsetX, dy);
1019
+ CGFloat rx[4];
1020
+ CGFloat ry[4];
1021
+ for (int i = 0; i < 4; ++i) {
1022
+ rx[i] = std::max<CGFloat>(0, radii.rx[i] - spread);
1023
+ ry[i] = std::max<CGFloat>(0, radii.ry[i] - spread);
1024
+ }
1025
+ const CGFloat margin = blur * 3 + std::fabs(offsetX) + std::fabs(offsetY) + std::fabs(spread) + 8;
1026
+ // CALayer fills shadowPath with the NON-ZERO winding rule, so the hole only
1027
+ // survives if it winds opposite to the outer rect. createRoundedRectPath
1028
+ // runs counter-clockwise, so the outer rect is emitted clockwise by hand —
1029
+ // CGPathAddRect would wind the same way and blanket the whole view in
1030
+ // shadow (measured: a 0.75-alpha inset shadow flooded the entire element).
1031
+ const CGRect outer = CGRectInset(bounds, -margin, -margin);
1032
+ CGMutablePathRef ring = CGPathCreateMutable();
1033
+ CGPathMoveToPoint(ring, NULL, CGRectGetMinX(outer), CGRectGetMinY(outer));
1034
+ CGPathAddLineToPoint(ring, NULL, CGRectGetMinX(outer), CGRectGetMaxY(outer));
1035
+ CGPathAddLineToPoint(ring, NULL, CGRectGetMaxX(outer), CGRectGetMaxY(outer));
1036
+ CGPathAddLineToPoint(ring, NULL, CGRectGetMaxX(outer), CGRectGetMinY(outer));
1037
+ CGPathCloseSubpath(ring);
1038
+ if (hole.size.width > 0 && hole.size.height > 0) {
1039
+ CGPathRef inner = createRoundedRectPath(hole, rx, ry, flipped);
1040
+ CGPathAddPath(ring, NULL, inner);
1041
+ CGPathRelease(inner);
1042
+ }
1043
+ shadow.shadowPath = ring;
1044
+ CGPathRelease(ring);
1045
+ CGColorRef color = createStyleCGColor(rare.box_shadow_color, 255);
1046
+ shadow.shadowColor = color;
1047
+ CGColorRelease(color);
1048
+ // CSS blur-radius is twice the Gaussian sigma; CALayer.shadowRadius is the
1049
+ // sigma. Same convention the web platform uses.
1050
+ shadow.shadowRadius = blur * 0.5;
1051
+ shadow.shadowOffset = CGSizeZero; // already folded into the hole's position
1052
+ shadow.shadowOpacity = rare.box_shadow_alpha / 255.0;
1053
+ }
1054
+
1055
+ void applyViewStyle(NSView *view, const gea::embedded::ui::Node &node,
1056
+ int parentAbsX, int parentAbsY, int parentHeight)
1057
+ {
1058
+ // layout.x/y are ABSOLUTE document coordinates after resolveAbsoluteCoords.
1059
+ // Convert to coordinates relative to the parent NSView, then y-flip against
1060
+ // the parent's height to land in AppKit's (default-unflipped) bottom-left
1061
+ // origin space.
1062
+ const int relX = node.layout.x - parentAbsX;
1063
+ const int w = node.layout.width;
1064
+ const int h = node.layout.height;
1065
+ const int relY = node.layout.y - parentAbsY;
1066
+ const CGFloat yBottom = static_cast<CGFloat>(parentHeight - relY - h);
1067
+
1068
+ view.frame = NSMakeRect(relX, yBottom, w, h);
1069
+
1070
+ // `filter: blur()` needs CIFilter-backed layer filters, and NSView rebuilds
1071
+ // its backing layer when this flag flips — so it has to be set before
1072
+ // anything (background, mask, shape sublayers) is put on that layer.
1073
+ if (gea::embedded::ui::rstyle(node.style).filter_blur_radius > 0 && !view.layerUsesCoreImageFilters)
1074
+ view.layerUsesCoreImageFilters = YES;
1075
+
1076
+ // NSVisualEffectView IS the background — the system renders the chosen
1077
+ // material into the view's bounds, and a layer-level backgroundColor on
1078
+ // top would defeat the vibrancy entirely. Skip the CSS background-color
1079
+ // channel for these views; corner radius / border still apply below.
1080
+ if (![view isKindOfClass:[NSVisualEffectView class]]) {
1081
+ if (node.style.has_bg) {
1082
+ // rgb565 carries no alpha channel — the style system keeps the CSS
1083
+ // alpha in bg_alpha (255 = opaque). Without honoring it, translucent
1084
+ // backgrounds (weather's rgba(255,255,255,0.12) city chips) painted
1085
+ // fully opaque.
1086
+ CGColor *bg = gea::macos::rgb565ToCGColor(node.style.bg_color);
1087
+ if (node.style.bg_alpha != 255) {
1088
+ CGColor *faded = CGColorCreateCopyWithAlpha(bg, node.style.bg_alpha / 255.0);
1089
+ CGColorRelease(bg);
1090
+ bg = faded;
1091
+ }
1092
+ view.layer.backgroundColor = bg;
1093
+ CGColorRelease(bg);
1094
+ } else {
1095
+ view.layer.backgroundColor = nil;
1096
+ }
1097
+ }
1098
+
1099
+ view.alphaValue = static_cast<CGFloat>(node.style.opacity) / 255.0;
1100
+ view.hidden = (node.style.display == 1);
1101
+
1102
+ // Border (border_alpha carries the CSS alpha, same as bg_alpha above).
1103
+ if (node.style.border_width > 0) {
1104
+ view.layer.borderWidth = node.style.border_width;
1105
+ CGColor *bc = gea::macos::rgb565ToCGColor(node.style.border_color);
1106
+ if (node.style.border_alpha != 255) {
1107
+ CGColor *faded = CGColorCreateCopyWithAlpha(bc, node.style.border_alpha / 255.0);
1108
+ CGColorRelease(bc);
1109
+ bc = faded;
1110
+ }
1111
+ view.layer.borderColor = bc;
1112
+ CGColorRelease(bc);
1113
+ } else {
1114
+ view.layer.borderWidth = 0;
1115
+ }
1116
+
1117
+ // Corner radius. Uniform circular radii ride layer.cornerRadius; percent
1118
+ // radii on a non-square box (rx != ry) and per-corner radii need a real
1119
+ // path, which applyCornerRadius installs as a CAShapeLayer. It also takes
1120
+ // over background/border painting on the blurred path, so it must run after
1121
+ // both are set above.
1122
+ const bool nodeIsBlurred = gea::embedded::ui::rstyle(node.style).filter_blur_radius > 0;
1123
+ applyCornerRadius(view, node, w, h, nodeIsBlurred);
1124
+ applyBoxShadow(view, node, w, h);
1125
+
1126
+ // Rotation. transform_rotate is stored as TENTHS of degrees (see
1127
+ // style.cpp's numericRotateTenths — `degrees * 10`). CSS rotate is
1128
+ // clockwise-positive; in an unflipped NSView (Y-up), CATransform3D's
1129
+ // Z-rotation is counter-clockwise-positive, so negate.
1130
+ //
1131
+ // We do NOT touch view.layer.anchorPoint to position the rotation pivot
1132
+ // — AppKit owns the anchor for layer-backed NSViews and silently keeps
1133
+ // it at (0, 0) (the layer's bottom-left in local coords), so any
1134
+ // anchorPoint we set is reverted. CALayer applies the layer's transform
1135
+ // AS IF the anchor were the origin, which means our rotation pivots
1136
+ // around layer-local (0, 0) by default. To pivot around the CSS
1137
+ // transform-origin (px, py) in layer-local coords instead, compose
1138
+ // translate(px,py) · rotate · translate(−px,−py): bring the pivot to
1139
+ // the anchor, rotate, then send it back.
1140
+ if (gea::embedded::ui::rstyle(node.style).transform_rotate != 0) {
1141
+ const CGFloat degrees = static_cast<CGFloat>(gea::embedded::ui::rstyle(node.style).transform_rotate) / 10.0;
1142
+ const CGFloat radians = -degrees * (M_PI / 180.0);
1143
+ // transform_origin is stored per-mille (0..1000) of the layer's
1144
+ // bounds, in CSS coords (Y top→bottom). Convert to NSView local
1145
+ // coords (Y bottom→top) so the pivot lines up with the visual
1146
+ // position the CSS author intended.
1147
+ const CGFloat px = static_cast<CGFloat>(gea::embedded::ui::rstyle(node.style).transform_origin_x) / 1000.0 * w;
1148
+ const CGFloat cssPivotY = static_cast<CGFloat>(gea::embedded::ui::rstyle(node.style).transform_origin_y) / 1000.0 * h;
1149
+ const CGFloat py = h - cssPivotY;
1150
+ CATransform3D t = CATransform3DIdentity;
1151
+ t = CATransform3DTranslate(t, px, py, 0);
1152
+ t = CATransform3DRotate(t, radians, 0, 0, 1);
1153
+ t = CATransform3DTranslate(t, -px, -py, 0);
1154
+ view.layer.transform = t;
1155
+ } else if (!CATransform3DIsIdentity(view.layer.transform)) {
1156
+ view.layer.transform = CATransform3DIdentity;
1157
+ }
1158
+
1159
+ // Last: the blur clears masksToBounds so its halo can spread, and every
1160
+ // branch above may have set it.
1161
+ applyFilterBlur(view, node);
1162
+ }
1163
+
1164
+ void addTextDecorationAttributes(NSMutableDictionary *attrs, int textDecoration)
1165
+ {
1166
+ if (!attrs) return;
1167
+ NSNumber *style = @((NSInteger)NSUnderlineStyleSingle);
1168
+ if (textDecoration == 1) {
1169
+ attrs[NSUnderlineStyleAttributeName] = style;
1170
+ } else if (textDecoration == 2) {
1171
+ attrs[NSStrikethroughStyleAttributeName] = style;
1172
+ }
1173
+ }
1174
+
1175
+ NSMutableDictionary *textAttributes(NSFont *font, NSColor *color, int textDecoration)
1176
+ {
1177
+ NSMutableDictionary *attrs = [NSMutableDictionary dictionary];
1178
+ attrs[NSFontAttributeName] = font ?: [NSFont systemFontOfSize:12];
1179
+ attrs[NSForegroundColorAttributeName] = color ?: [NSColor labelColor];
1180
+ addTextDecorationAttributes(attrs, textDecoration);
1181
+ return attrs;
1182
+ }
1183
+
1184
+ bool attributedStringHasTextDecoration(NSAttributedString *value)
1185
+ {
1186
+ if (!value || value.length == 0) return false;
1187
+ return [value attribute:NSUnderlineStyleAttributeName atIndex:0 effectiveRange:nil] != nil ||
1188
+ [value attribute:NSStrikethroughStyleAttributeName atIndex:0 effectiveRange:nil] != nil;
1189
+ }
1190
+
1191
+ void applyTextProps(NSTextField *tf, const gea::embedded::ui::Node &node)
1192
+ {
1193
+ NSString *raw = [NSString stringWithUTF8String:(node.text.empty() ? "" : node.text.c_str())];
1194
+ NSFont *font = gea::macos::fontForId(node.style.font_id, node.style.font_size);
1195
+ NSColor *color = gea::macos::rgb565ToNSColor(node.style.text_color);
1196
+
1197
+ NSTextAlignment alignment;
1198
+ switch (node.style.text_align) {
1199
+ case 1: alignment = NSTextAlignmentCenter; break;
1200
+ case 2: alignment = NSTextAlignmentRight; break;
1201
+ default: alignment = NSTextAlignmentLeft;
1202
+ }
1203
+
1204
+ // Use the same attributes the host measurement hook (font_registry.mm's
1205
+ // gea_host_measure_text) uses — same font, same break mode, default
1206
+ // kerning. Identical attributes guarantee the rendered NSTextField has
1207
+ // exactly the width/height the framework's layout engine allocated for
1208
+ // the text node, so titles never end up with a gap below them from
1209
+ // over-allocation and long labels never overflow into clipped territory
1210
+ // from under-allocation.
1211
+ //
1212
+ // Alignment is deliberately NOT one of those attributes. An
1213
+ // NSParagraphStyle whose `alignment` is NSTextAlignmentCenter makes
1214
+ // NSTextFieldCell demand FOUR MORE POINTS of width for the same string
1215
+ // (measured: "Reset"/Inter 15 needs 44pt with a natural or left/right
1216
+ // paragraph and 48pt with a centred one). The measurement hook sets only
1217
+ // `lineBreakMode`, so every centre-aligned label was laid out 4pt narrower
1218
+ // than AppKit would draw it, `wraps = YES` broke the word onto a second
1219
+ // line, and the field's one-line height hid it: "COUNTER" rendered as
1220
+ // "COUNTE", "Reset" as "Rese", "Ready" as "Read". Single-glyph labels
1221
+ // survived only because their boxes had more than 4pt of slack.
1222
+ //
1223
+ // `tf.alignment` below still centres the text and costs nothing, because
1224
+ // the cell -- not the paragraph style -- applies it. Keep alignment there.
1225
+ NSMutableParagraphStyle *paragraph = [[NSMutableParagraphStyle alloc] init];
1226
+ const CGFloat availableWidth = node.layout.width > 0 ? node.layout.width : CGFLOAT_MAX;
1227
+ paragraph.lineBreakMode = gea::macos::lineBreakModeForText(raw, font, availableWidth);
1228
+ NSMutableDictionary *attrs = textAttributes(font ?: [NSFont systemFontOfSize:node.style.font_size > 0 ? node.style.font_size : 12],
1229
+ color,
1230
+ node.style.text_decoration);
1231
+ attrs[NSParagraphStyleAttributeName] = paragraph;
1232
+ NSAttributedString *attributed = [[NSAttributedString alloc] initWithString:raw attributes:attrs];
1233
+ if (![tf.attributedStringValue isEqualToAttributedString:attributed]) {
1234
+ tf.attributedStringValue = attributed;
1235
+ }
1236
+ tf.alignment = alignment;
1237
+ tf.textColor = color;
1238
+ tf.font = font;
1239
+ }
1240
+
1241
+ void applyInputProps(GeaInputField *tf, const gea::embedded::ui::Node &node, int nodeId)
1242
+ {
1243
+ using gea::embedded::ui::Tree;
1244
+ tf.nodeId = nodeId;
1245
+
1246
+ // The store drives `value` via the framework's reactive-attribute machinery,
1247
+ // which writes to `node.setAttribute("value", ...)`. Mirror that to the
1248
+ // field's stringValue — but only when the displayed string actually differs
1249
+ // from the desired value. While the user is typing, `controlTextDidChange:`
1250
+ // keeps `value` in sync with every keystroke, so `tf.stringValue == desired`
1251
+ // and we skip the redundant assignment (which would otherwise reset the
1252
+ // caret/selection). When a programmatic change fires (note selection, new
1253
+ // note), the values genuinely differ and we always update — regardless of
1254
+ // whether the field is currently focused — so the editor clears correctly.
1255
+ auto &tree = Tree::instance();
1256
+ const char *value = tree.getAttribute(nodeId, "value");
1257
+ tf.textColor = gea::macos::rgb565ToNSColor(node.style.text_color);
1258
+ tf.font = gea::macos::fontForId(node.style.font_id, node.style.font_size);
1259
+ switch (node.style.text_align) {
1260
+ case 1: tf.alignment = NSTextAlignmentCenter; break;
1261
+ case 2: tf.alignment = NSTextAlignmentRight; break;
1262
+ default: tf.alignment = NSTextAlignmentLeft;
1263
+ }
1264
+ NSMutableDictionary *attrs = textAttributes(tf.font, tf.textColor, node.style.text_decoration);
1265
+
1266
+ if (value) {
1267
+ NSString *desired = [NSString stringWithUTF8String:value];
1268
+ if (node.style.text_decoration != 0) {
1269
+ NSAttributedString *decoratedValue = [[NSAttributedString alloc] initWithString:desired attributes:attrs];
1270
+ if (![tf.attributedStringValue isEqualToAttributedString:decoratedValue]) tf.attributedStringValue = decoratedValue;
1271
+ } else if (![tf.stringValue isEqualToString:desired] ||
1272
+ attributedStringHasTextDecoration(tf.attributedStringValue)) {
1273
+ tf.stringValue = desired;
1274
+ }
1275
+ }
1276
+
1277
+ const char *placeholder = tree.getAttribute(nodeId, "placeholder");
1278
+ if (placeholder && placeholder[0]) {
1279
+ NSString *p = [NSString stringWithUTF8String:placeholder];
1280
+ if (![tf.placeholderString isEqualToString:p]) tf.placeholderString = p;
1281
+ }
1282
+ }
1283
+
1284
+ void applySliderProps(GeaSlider *sl, const gea::embedded::ui::Node &node, int nodeId)
1285
+ {
1286
+ (void)node;
1287
+ using gea::embedded::ui::Tree;
1288
+ auto &tree = Tree::instance();
1289
+ sl.nodeId = nodeId;
1290
+ const char *minA = tree.getAttribute(nodeId, "min");
1291
+ const char *maxA = tree.getAttribute(nodeId, "max");
1292
+ if (minA && minA[0]) sl.minValue = atof(minA);
1293
+ if (maxA && maxA[0]) sl.maxValue = atof(maxA);
1294
+ // `value` is driven reactively; atof ignores a trailing unit (e.g. "75%").
1295
+ // Don't overwrite the thumb while the user is actively dragging.
1296
+ const char *value = tree.getAttribute(nodeId, "value");
1297
+ if (value && value[0] && !sl.geaDragging) {
1298
+ const double v = atof(value);
1299
+ if (sl.doubleValue != v) sl.doubleValue = v;
1300
+ }
1301
+ }
1302
+
1303
+ void applySwitchProps(GeaSwitch *sw, const gea::embedded::ui::Node &node, int nodeId)
1304
+ {
1305
+ (void)node;
1306
+ using gea::embedded::ui::Tree;
1307
+ auto &tree = Tree::instance();
1308
+ sw.nodeId = nodeId;
1309
+ const char *checked = tree.getAttribute(nodeId, "checked");
1310
+ const bool hasChecked = tree.hasAttribute(nodeId, "checked");
1311
+ const bool explicitlyOff = checked && (std::strcmp(checked, "false") == 0 || std::strcmp(checked, "0") == 0);
1312
+ const bool on = hasChecked && !explicitlyOff;
1313
+ const NSControlStateValue want = on ? NSControlStateValueOn : NSControlStateValueOff;
1314
+ if (sw.state != want) sw.state = want;
1315
+ }
1316
+
1317
+ void applySymbolProps(NSImageView *iv, const gea::embedded::ui::Node &node, int nodeId)
1318
+ {
1319
+ using gea::embedded::ui::Tree;
1320
+ Tree &tree = Tree::instance();
1321
+ const char *symbol = tree.getAttribute(nodeId, "data-symbol");
1322
+ NSNumber *currentSym = objc_getAssociatedObject(iv, "gea.symbol_name_hash");
1323
+ const NSUInteger wantHash = symbol ? [[NSString stringWithUTF8String:symbol] hash] : 0;
1324
+ if (!currentSym || currentSym.unsignedIntegerValue != wantHash) {
1325
+ if (symbol && symbol[0]) {
1326
+ NSString *name = [NSString stringWithUTF8String:symbol];
1327
+ NSImage *img = nil;
1328
+ if (@available(macOS 11.0, *)) {
1329
+ img = [NSImage imageWithSystemSymbolName:name accessibilityDescription:name];
1330
+ }
1331
+ iv.image = img;
1332
+ } else {
1333
+ iv.image = nil;
1334
+ }
1335
+ objc_setAssociatedObject(iv, "gea.symbol_name_hash", @(wantHash), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
1336
+ }
1337
+ // SF Symbols render as template images; tint with the node's CSS color
1338
+ // (falls back to the system label color so they adapt to dark mode).
1339
+ if (node.style.text_color != 0) {
1340
+ iv.contentTintColor = gea::macos::rgb565ToNSColor(node.style.text_color);
1341
+ } else {
1342
+ iv.contentTintColor = [NSColor labelColor];
1343
+ }
1344
+ }
1345
+
1346
+ void applyVibrancyProps(NSVisualEffectView *ve, const gea::embedded::ui::Node &node, int nodeId)
1347
+ {
1348
+ (void)node;
1349
+ using gea::embedded::ui::Tree;
1350
+ Tree &tree = Tree::instance();
1351
+ const char *material = tree.getAttribute(nodeId, "data-material");
1352
+ const char *blending = tree.getAttribute(nodeId, "data-blending");
1353
+ const char *appearance = tree.getAttribute(nodeId, "data-appearance");
1354
+ const NSVisualEffectMaterial wantMaterial = vibrancyMaterialFromString(material);
1355
+ const NSVisualEffectBlendingMode wantBlending = vibrancyBlendingFromString(blending);
1356
+ if (ve.material != wantMaterial) ve.material = wantMaterial;
1357
+ if (ve.blendingMode != wantBlending) ve.blendingMode = wantBlending;
1358
+ if (appearance && appearance[0]) {
1359
+ NSAppearance *want = nil;
1360
+ if (std::strcmp(appearance, "dark") == 0) {
1361
+ want = [NSAppearance appearanceNamed:NSAppearanceNameDarkAqua];
1362
+ } else if (std::strcmp(appearance, "light") == 0) {
1363
+ want = [NSAppearance appearanceNamed:NSAppearanceNameAqua];
1364
+ }
1365
+ if (want && ve.appearance != want) ve.appearance = want;
1366
+ }
1367
+ }
1368
+
1369
+ void applyTextAreaProps(NSScrollView *sv, const gea::embedded::ui::Node &node, int nodeId)
1370
+ {
1371
+ using gea::embedded::ui::Tree;
1372
+ Tree &tree = Tree::instance();
1373
+ GeaTextAreaView *tv = objc_getAssociatedObject(sv, "gea.text_view");
1374
+ if (!tv) return;
1375
+ tv.nodeId = nodeId;
1376
+
1377
+ NSFont *font = gea::macos::fontForId(node.style.font_id, node.style.font_size > 0 ? node.style.font_size : 14);
1378
+ NSColor *color = gea::macos::rgb565ToNSColor(node.style.text_color);
1379
+ tv.font = font ?: [NSFont systemFontOfSize:14];
1380
+ tv.textColor = color ?: [NSColor labelColor];
1381
+ tv.insertionPointColor = tv.textColor;
1382
+
1383
+ // Mirror the store's `value` attribute to the NSTextView — but only when
1384
+ // the displayed string actually differs. While typing, `textDidChange:`
1385
+ // keeps `value` in sync with every keystroke so `tv.string == desired` and
1386
+ // we skip the redundant assignment (which would reset selection/layout).
1387
+ // Programmatic changes (new note, note selection) produce a genuine diff
1388
+ // and always win — regardless of focus — so the body clears correctly.
1389
+ const char *value = tree.getAttribute(nodeId, "value");
1390
+ if (value) {
1391
+ NSString *desired = [NSString stringWithUTF8String:value] ?: @"";
1392
+ if (![tv.string isEqualToString:desired]) {
1393
+ tv.suppressChangeNotification = YES;
1394
+ [tv setString:desired];
1395
+ tv.suppressChangeNotification = NO;
1396
+ }
1397
+ }
1398
+
1399
+ // Update font/color of already-laid-out text. NSTextView caches these on
1400
+ // the typingAttributes for the next inserted run, so users get the same
1401
+ // appearance when they keep typing.
1402
+ tv.typingAttributes = @{
1403
+ NSFontAttributeName: tv.font,
1404
+ NSForegroundColorAttributeName: tv.textColor,
1405
+ };
1406
+ }
1407
+
1408
+ void applyButtonProps(NSButton *btn, const gea::embedded::ui::Node &node, int nodeId)
1409
+ {
1410
+ using namespace gea::embedded::ui;
1411
+ Tree &tree = Tree::instance();
1412
+
1413
+ // Title from the first Text child, if any. JSX <Button>label</Button>
1414
+ // produces a Button with a text child.
1415
+ NSString *title = @"";
1416
+ for (int child = node.first_child; child >= 0; child = tree.node(child).next_sibling) {
1417
+ const Node &cn = tree.node(child);
1418
+ if (cn.type == NodeType::Text && !cn.text.empty()) {
1419
+ title = [NSString stringWithUTF8String:cn.text.c_str()];
1420
+ break;
1421
+ }
1422
+ }
1423
+ // Buttons whose label is composed of ELEMENT children (weather's city
1424
+ // chips: <button><span>name</span><span>temp</span></button>) have no
1425
+ // direct Text child, and the renderer deliberately never descends into
1426
+ // buttons — so their title vanished entirely. Collect descendant text in
1427
+ // document order instead, joined with spaces.
1428
+ if (title.length == 0) {
1429
+ NSMutableArray<NSString *> *parts = [NSMutableArray array];
1430
+ std::vector<int> stack;
1431
+ for (int child = node.first_child; child >= 0; child = tree.node(child).next_sibling) stack.push_back(child);
1432
+ std::reverse(stack.begin(), stack.end());
1433
+ while (!stack.empty()) {
1434
+ const int id = stack.back();
1435
+ stack.pop_back();
1436
+ const Node &cn = tree.node(id);
1437
+ if (cn.type == NodeType::Text && !cn.text.empty()) {
1438
+ [parts addObject:[NSString stringWithUTF8String:cn.text.c_str()] ?: @""];
1439
+ }
1440
+ std::vector<int> children;
1441
+ for (int child = cn.first_child; child >= 0; child = tree.node(child).next_sibling) children.push_back(child);
1442
+ for (auto it = children.rbegin(); it != children.rend(); ++it) stack.push_back(*it);
1443
+ }
1444
+ title = [parts componentsJoinedByString:@" "];
1445
+ }
1446
+
1447
+ // A CSS background on the button means the app styles the chip itself
1448
+ // (weather's translucent city chips). The default Aqua bezel would paint
1449
+ // opaque white over that background and pin the title to black — go
1450
+ // borderless so applyViewStyle's layer background/corner radius show, and
1451
+ // carry the node's text color/font into an attributed title. Plain
1452
+ // unstyled <button>s keep the native bezel and title exactly as before.
1453
+ const bool cssStyled = node.style.has_bg;
1454
+ if (btn.bordered != static_cast<BOOL>(!cssStyled)) btn.bordered = !cssStyled;
1455
+ if (cssStyled) {
1456
+ NSFont *font = gea::macos::fontForId(node.style.font_id, node.style.font_size);
1457
+ NSColor *color = node.style.text_color != 0 ? gea::macos::rgb565ToNSColor(node.style.text_color)
1458
+ : [NSColor labelColor];
1459
+ NSMutableParagraphStyle *paragraph = [[NSMutableParagraphStyle alloc] init];
1460
+ paragraph.alignment = NSTextAlignmentCenter;
1461
+ NSDictionary *attrs = @{
1462
+ NSFontAttributeName: font ?: [NSFont systemFontOfSize:[NSFont systemFontSize]],
1463
+ NSForegroundColorAttributeName: color,
1464
+ NSParagraphStyleAttributeName: paragraph,
1465
+ };
1466
+ NSAttributedString *attributed = [[NSAttributedString alloc] initWithString:title attributes:attrs];
1467
+ if (![btn.attributedTitle isEqualToAttributedString:attributed]) btn.attributedTitle = attributed;
1468
+ } else {
1469
+ if (![btn.title isEqualToString:title]) btn.title = title;
1470
+ }
1471
+
1472
+ PressBridge *bridge = objc_getAssociatedObject(btn, "gea.press_bridge");
1473
+ if (bridge) bridge.nodeId = nodeId;
1474
+ }
1475
+
1476
+ void ensureViewClickRecognizer(NSView *view, int nodeId, bool wantsClick)
1477
+ {
1478
+ PressBridge *bridge = objc_getAssociatedObject(view, "gea.press_bridge");
1479
+ BOOL hasRecognizer = NO;
1480
+ for (NSGestureRecognizer *gr in view.gestureRecognizers) {
1481
+ if ([gr isKindOfClass:[NSClickGestureRecognizer class]]) { hasRecognizer = YES; break; }
1482
+ }
1483
+ if (wantsClick) {
1484
+ if (!bridge) {
1485
+ bridge = [[PressBridge alloc] init];
1486
+ objc_setAssociatedObject(view, "gea.press_bridge", bridge, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
1487
+ }
1488
+ bridge.nodeId = nodeId;
1489
+ if (!hasRecognizer) {
1490
+ NSClickGestureRecognizer *gr =
1491
+ [[NSClickGestureRecognizer alloc] initWithTarget:bridge action:@selector(fire:)];
1492
+ [view addGestureRecognizer:gr];
1493
+ }
1494
+ }
1495
+ // If wantsClick became false, leave the recognizer in place — toggling
1496
+ // it per-frame would churn AppKit state. Real apps rarely toggle
1497
+ // pressId at runtime.
1498
+ }
1499
+
1500
+ void applyImageProps(NSImageView *iv, const gea::embedded::ui::Node &node)
1501
+ {
1502
+ // Re-decode when image_id changes; otherwise leave the current NSImage in
1503
+ // place to avoid rebuilding the bitmap every frame. We stash the last
1504
+ // imageId on the view via associated object.
1505
+ NSNumber *currentId = objc_getAssociatedObject(iv, "gea.image_id");
1506
+ if (!currentId || currentId.intValue != node.image_id) {
1507
+ iv.image = gea::macos::imageForId(node.image_id);
1508
+ objc_setAssociatedObject(iv, "gea.image_id", @(node.image_id),
1509
+ OBJC_ASSOCIATION_RETAIN_NONATOMIC);
1510
+ }
1511
+ switch (node.style.image_fit) {
1512
+ case 0: iv.imageScaling = NSImageScaleAxesIndependently; break;
1513
+ case 1: iv.imageScaling = NSImageScaleProportionallyUpOrDown; break;
1514
+ case 2: iv.imageScaling = NSImageScaleProportionallyUpOrDown; break; // "cover" ~ fit
1515
+ case 3: iv.imageScaling = NSImageScaleNone; break;
1516
+ case 4: iv.imageScaling = NSImageScaleProportionallyDown; break;
1517
+ default: iv.imageScaling = NSImageScaleAxesIndependently;
1518
+ }
1519
+ }
1520
+
1521
+ void applyScrollContentSize(NSScrollView *sv, const gea::embedded::ui::Node &node)
1522
+ {
1523
+ NSView *content = sv.documentView;
1524
+ if (!content) return;
1525
+ const CGFloat w = node.layout.width;
1526
+ const CGFloat h = node.layout.scroll_content_height > 0
1527
+ ? node.layout.scroll_content_height
1528
+ : node.layout.height;
1529
+ // documentView always sized to the full scroll content; NSScrollView's
1530
+ // own scrollers handle the visible window. We don't apply the tree's
1531
+ // scroll_y here — AppKit owns the scroll position via NSScrollView.
1532
+ if (content.frame.size.width != w || content.frame.size.height != h) {
1533
+ content.frame = NSMakeRect(0, 0, w, h);
1534
+ }
1535
+ }
1536
+
1537
+ void applyCanvasProps(GeaCanvasView *cv, const gea::embedded::ui::Node &node, int nodeId)
1538
+ {
1539
+ cv.nodeId = nodeId;
1540
+ // Always redraw for now — the canvas pixel buffer can change every frame
1541
+ // (e.g. an animation app). Per-frame setNeedsDisplay forces drawRect: to
1542
+ // pick up the latest pixels. A future optimisation would gate this on
1543
+ // canvas->dirty() to avoid full redraws when nothing changed.
1544
+ (void)node;
1545
+ [cv setNeedsDisplay:YES];
1546
+ }
1547
+
1548
+ void applyTypeSpecificProps(NSView *view, const gea::embedded::ui::Node &node, int nodeId)
1549
+ {
1550
+ using gea::embedded::ui::NodeType;
1551
+ if (node.type == NodeType::Text) {
1552
+ applyTextProps((NSTextField *)view, node);
1553
+ } else if (node.type == NodeType::Button) {
1554
+ applyButtonProps((NSButton *)view, node, nodeId);
1555
+ } else if (node.type == NodeType::Image) {
1556
+ applyImageProps((NSImageView *)view, node);
1557
+ } else if (node.type == NodeType::Canvas) {
1558
+ applyCanvasProps((GeaCanvasView *)view, node, nodeId);
1559
+ } else if ([view isKindOfClass:[GeaSlider class]]) {
1560
+ applySliderProps((GeaSlider *)view, node, nodeId);
1561
+ } else if ([view isKindOfClass:[GeaSwitch class]]) {
1562
+ applySwitchProps((GeaSwitch *)view, node, nodeId);
1563
+ } else if ([view isKindOfClass:[GeaInputField class]]) {
1564
+ applyInputProps((GeaInputField *)view, node, nodeId);
1565
+ } else if (objc_getAssociatedObject(view, "gea.is_symbol")) {
1566
+ applySymbolProps((NSImageView *)view, node, nodeId);
1567
+ } else if ([view isKindOfClass:[NSVisualEffectView class]]) {
1568
+ applyVibrancyProps((NSVisualEffectView *)view, node, nodeId);
1569
+ } else if (isScrollView(view) && objc_getAssociatedObject(view, "gea.text_view")) {
1570
+ // `<textarea>` materializes as NSScrollView whose documentView is a
1571
+ // GeaTextAreaView. Apply the editor-style props (font, value) rather
1572
+ // than the generic scroll-content size dance, because the textarea's
1573
+ // document view manages its own scrollable height.
1574
+ applyTextAreaProps((NSScrollView *)view, node, nodeId);
1575
+ } else if (isScrollView(view)) {
1576
+ // Both NodeType::VirtualList and overflow:scroll Views land here.
1577
+ applyScrollContentSize((NSScrollView *)view, node);
1578
+ }
1579
+
1580
+ // Per-View click recognizers don't fit the framework's event-delegation
1581
+ // pattern (listeners are bound on document.body and check event.target).
1582
+ // Click handling is centralized in GeaRootClickBridge installed on the
1583
+ // root NSView in MacosRenderer::sync — it hit-tests the click and
1584
+ // dispatches with the deepest matching nodeId.
1585
+ (void)nodeId;
1586
+ }
1587
+
1588
+ bool viewMatchesNode(NSView *view, const gea::embedded::ui::Node &node,
1589
+ const char *tagName, const char *inputType)
1590
+ {
1591
+ using gea::embedded::ui::NodeType;
1592
+ if (!view) return false;
1593
+ const bool isSymbolView = objc_getAssociatedObject(view, "gea.is_symbol") != nil;
1594
+ const bool isTextAreaView = isScrollView(view) && objc_getAssociatedObject(view, "gea.text_view") != nil;
1595
+ const bool isPlainScrollView = isScrollView(view) && !isTextAreaView;
1596
+
1597
+ if (node.type == NodeType::View && tagName && std::strcmp(tagName, "input") == 0) {
1598
+ if (inputType && std::strcmp(inputType, "range") == 0) return [view isKindOfClass:[GeaSlider class]];
1599
+ if (inputType && std::strcmp(inputType, "checkbox") == 0) return [view isKindOfClass:[GeaSwitch class]];
1600
+ return [view isKindOfClass:[GeaInputField class]];
1601
+ }
1602
+ if (node.type == NodeType::View && tagName && std::strcmp(tagName, "textarea") == 0) {
1603
+ return isTextAreaView;
1604
+ }
1605
+ if (node.type == NodeType::View && tagName && std::strcmp(tagName, "vibrancy") == 0) {
1606
+ return [view isKindOfClass:[NSVisualEffectView class]];
1607
+ }
1608
+ if (node.type == NodeType::View && tagName && std::strcmp(tagName, "symbol") == 0) {
1609
+ return [view isKindOfClass:[NSImageView class]] && isSymbolView;
1610
+ }
1611
+ if (node.type == NodeType::VirtualList ||
1612
+ (node.type == NodeType::View && node.style.overflow == 2)) {
1613
+ return isPlainScrollView;
1614
+ }
1615
+
1616
+ switch (node.type) {
1617
+ case NodeType::Text: return [view isKindOfClass:[GeaLabelTextField class]];
1618
+ case NodeType::Button: return [view isKindOfClass:[NSButton class]];
1619
+ case NodeType::Image: return [view isKindOfClass:[NSImageView class]] && !isSymbolView;
1620
+ case NodeType::Canvas: return [view isKindOfClass:[GeaCanvasView class]];
1621
+ case NodeType::View:
1622
+ default:
1623
+ return [view isKindOfClass:[NSView class]] &&
1624
+ ![view isKindOfClass:[NSTextField class]] &&
1625
+ ![view isKindOfClass:[NSButton class]] &&
1626
+ ![view isKindOfClass:[NSImageView class]] &&
1627
+ ![view isKindOfClass:[NSScrollView class]] &&
1628
+ ![view isKindOfClass:[NSVisualEffectView class]] &&
1629
+ ![view isKindOfClass:[GeaCanvasView class]] &&
1630
+ ![view isKindOfClass:[GeaSlider class]] &&
1631
+ ![view isKindOfClass:[GeaSwitch class]] &&
1632
+ ![view isKindOfClass:[GeaInputField class]];
1633
+ }
1634
+ }
1635
+
1636
+ NSView *ensureViewForNode(int nodeId, const gea::embedded::ui::Node &node)
1637
+ {
1638
+ NSNumber *key = @(nodeId);
1639
+ NSView *view = nodeIdToView()[key];
1640
+ const char *tagName = gea::embedded::ui::Tree::instance().tagName(nodeId);
1641
+ const char *inputType = gea::embedded::ui::Tree::instance().getAttribute(nodeId, "type");
1642
+ if (view && !viewMatchesNode(view, node, tagName, inputType)) {
1643
+ [view removeFromSuperview];
1644
+ [nodeIdToView() removeObjectForKey:key];
1645
+ view = nil;
1646
+ }
1647
+ if (view) return view;
1648
+ view = makeViewForType(node.type, tagName, node.style, inputType);
1649
+ // Stamp the nodeId on the view so the root-level click handler can map
1650
+ // hit-tested NSViews back to tree nodes. Used by GeaRootClickBridge to
1651
+ // dispatch with the deepest matching node's id (event delegation —
1652
+ // the framework binds listeners on document.body and checks event.target).
1653
+ objc_setAssociatedObject(view, "gea.node_id", key, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
1654
+ nodeIdToView()[key] = view;
1655
+ return view;
1656
+ }
1657
+
1658
+
1659
+ // Returns true if `nodeId` is a paragraph-style inline-flow container — a
1660
+ // <p> or heading whose children should AppKit-render as a single attributed
1661
+ // string with real inline flow. The framework's flex layout otherwise treats
1662
+ // each child as its own flex item, squeezing inline spans into 22-px slots
1663
+ // that vertically chop "div" into "di" / "v".
1664
+ //
1665
+ // We key off tag_name rather than tree shape because the framework collapses
1666
+ // `<span>text</span>` into a bare Text node — so a `<div>` containing
1667
+ // `<span class="title">A</span><p>body</p>` looks structurally identical to
1668
+ // a `<p>raw text <span class="x">x</span> more</p>` after lowering. tag_name
1669
+ // is the only signal that survives: <p> / <h1>-<h6> mean inline flow, <div>
1670
+ // means block container.
1671
+ bool nodeIsInlineComposable(int nodeId)
1672
+ {
1673
+ using namespace gea::embedded::ui;
1674
+ Tree &tree = Tree::instance();
1675
+ if (nodeId < 0 || nodeId >= tree.nodeCount()) return false;
1676
+ const Node &node = tree.node(nodeId);
1677
+ if (node.first_child < 0) return false;
1678
+ const char *tag = tree.tagName(nodeId);
1679
+ if (!tag) return false;
1680
+ const bool inlineTag = (std::strcmp(tag, "p") == 0) ||
1681
+ (std::strcmp(tag, "h1") == 0) ||
1682
+ (std::strcmp(tag, "h2") == 0) ||
1683
+ (std::strcmp(tag, "h3") == 0) ||
1684
+ (std::strcmp(tag, "h4") == 0) ||
1685
+ (std::strcmp(tag, "h5") == 0) ||
1686
+ (std::strcmp(tag, "h6") == 0);
1687
+ if (!inlineTag) return false;
1688
+ // Still require >1 child — a paragraph with a single text run renders
1689
+ // fine via the normal Text-node path, no composition gymnastics needed.
1690
+ int count = 0;
1691
+ for (int c = node.first_child; c >= 0; c = tree.node(c).next_sibling) {
1692
+ const Node &cn = tree.node(c);
1693
+ // Anything beyond inline-style content (Text, or a View whose own
1694
+ // descendants are all Text) means this isn't a pure inline run.
1695
+ if (cn.type == NodeType::Text) { ++count; continue; }
1696
+ if (cn.type != NodeType::View) return false;
1697
+ if (cn.first_child < 0) return false;
1698
+ for (int gc = cn.first_child; gc >= 0; gc = tree.node(gc).next_sibling) {
1699
+ if (tree.node(gc).type != NodeType::Text) return false;
1700
+ }
1701
+ ++count;
1702
+ }
1703
+ return count >= 2;
1704
+ }
1705
+
1706
+ // Walk `nodeId`'s inline subtree and append run-attributed text. `inheritColor`
1707
+ // supplies the foreground color when the child doesn't set its own. Text
1708
+ // decoration stays per run so AppKit can place underline/strike natively.
1709
+ void appendInlineRuns(NSMutableAttributedString *out, int nodeId,
1710
+ NSFont *parentFont, NSColor *parentColor)
1711
+ {
1712
+ using namespace gea::embedded::ui;
1713
+ Tree &tree = Tree::instance();
1714
+ const Node &node = tree.node(nodeId);
1715
+ if (node.type == NodeType::Text) {
1716
+ if (node.text.empty()) return;
1717
+ NSString *str = [NSString stringWithUTF8String:node.text.c_str()] ?: @"";
1718
+ NSColor *col = parentColor;
1719
+ NSFont *font = parentFont;
1720
+ // Text node style is already resolved by the framework's CSS cascade
1721
+ // (color/font_id/font_size on a text child come from its <span>
1722
+ // ancestor's class). When the framework has filled them in we use
1723
+ // them; otherwise we keep the parent's value as the inherited default.
1724
+ if (node.style.text_color != 0) {
1725
+ col = gea::macos::rgb565ToNSColor(node.style.text_color);
1726
+ }
1727
+ NSFont *resolved = gea::macos::fontForId(node.style.font_id, node.style.font_size);
1728
+ if (resolved) font = resolved;
1729
+ NSMutableDictionary *attrs = textAttributes(font, col, node.style.text_decoration);
1730
+ [out appendAttributedString:[[NSAttributedString alloc] initWithString:str attributes:attrs]];
1731
+ return;
1732
+ }
1733
+ // Inline View (span). Resolve its own color/font once and recurse into
1734
+ // children with those as the new inherited values.
1735
+ NSColor *col = parentColor;
1736
+ NSFont *font = parentFont;
1737
+ if (node.style.text_color != 0) {
1738
+ col = gea::macos::rgb565ToNSColor(node.style.text_color);
1739
+ }
1740
+ NSFont *resolved = gea::macos::fontForId(node.style.font_id, node.style.font_size);
1741
+ if (resolved) font = resolved;
1742
+ for (int c = node.first_child; c >= 0; c = tree.node(c).next_sibling) {
1743
+ appendInlineRuns(out, c, font, col);
1744
+ }
1745
+ }
1746
+
1747
+ // Make the parent node render as a single NSTextField holding the composite
1748
+ // attributed string built from all its inline descendants. Hides any per-child
1749
+ // NSViews so they don't double-render on top. Returns the field for sizing.
1750
+ NSTextField *renderInlineComposition(int nodeId, NSView *parentNSView,
1751
+ int parentAbsX, int parentAbsY, int parentHeight,
1752
+ NSMutableSet<NSNumber *> *unseen)
1753
+ {
1754
+ using namespace gea::embedded::ui;
1755
+ Tree &tree = Tree::instance();
1756
+ const Node &node = tree.node(nodeId);
1757
+
1758
+ // Reuse a single NSTextField stashed on the parent's NSView so re-renders
1759
+ // don't pile up subviews. Tagged with the parent nodeId so unseen-cleanup
1760
+ // doesn't sweep it.
1761
+ GeaLabelTextField *field = objc_getAssociatedObject(parentNSView, "gea.inline_field");
1762
+ if (!field) {
1763
+ field = [[GeaLabelTextField alloc] initWithFrame:NSZeroRect];
1764
+ field.bezeled = NO;
1765
+ field.bordered = NO;
1766
+ field.drawsBackground = NO;
1767
+ field.editable = NO;
1768
+ field.selectable = NO;
1769
+ field.usesSingleLineMode = NO;
1770
+ field.lineBreakMode = NSLineBreakByWordWrapping;
1771
+ field.cell.wraps = YES;
1772
+ field.cell.truncatesLastVisibleLine = NO;
1773
+ [field setWantsLayer:YES];
1774
+ [parentNSView addSubview:field];
1775
+ objc_setAssociatedObject(parentNSView, "gea.inline_field", field, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
1776
+ }
1777
+
1778
+ NSFont *parentFont = gea::macos::fontForId(node.style.font_id, node.style.font_size);
1779
+ NSColor *parentColor = gea::macos::rgb565ToNSColor(node.style.text_color);
1780
+ NSMutableAttributedString *composite = [[NSMutableAttributedString alloc] init];
1781
+ for (int c = node.first_child; c >= 0; c = tree.node(c).next_sibling) {
1782
+ appendInlineRuns(composite, c, parentFont, parentColor);
1783
+ }
1784
+ // Word-wrap paragraph style — same flow rules the rest of the renderer
1785
+ // uses, so the visual style stays consistent.
1786
+ NSMutableParagraphStyle *ps = [[NSMutableParagraphStyle alloc] init];
1787
+ ps.lineBreakMode = NSLineBreakByWordWrapping;
1788
+ [composite addAttribute:NSParagraphStyleAttributeName value:ps range:NSMakeRange(0, composite.length)];
1789
+
1790
+ field.attributedStringValue = composite;
1791
+ // Fill the parent's content box. The framework already laid the parent
1792
+ // out wide enough for its children to flow, so AppKit's wrap point will
1793
+ // match that available width.
1794
+ field.frame = NSMakeRect(0, 0, parentNSView.bounds.size.width, parentNSView.bounds.size.height);
1795
+ field.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
1796
+
1797
+ // Mark every per-child view stale so the unseen-sweep removes them this
1798
+ // frame — composition replaces per-child rendering entirely.
1799
+ for (int c = node.first_child; c >= 0; c = tree.node(c).next_sibling) {
1800
+ (void)parentAbsX; (void)parentAbsY; (void)parentHeight; // unused here
1801
+ [unseen addObject:@(c)];
1802
+ }
1803
+ return field;
1804
+ }
1805
+
1806
+ void syncRecursive(int nodeId, NSView *parent, int parentAbsX, int parentAbsY, int parentHeight,
1807
+ NSMutableSet<NSNumber *> *unseen)
1808
+ {
1809
+ using namespace gea::embedded::ui;
1810
+ Tree &tree = Tree::instance();
1811
+ if (nodeId < 0 || nodeId >= tree.nodeCount()) return;
1812
+ const Node &node = tree.node(nodeId);
1813
+
1814
+ NSNumber *key = @(nodeId);
1815
+ [unseen removeObject:key];
1816
+
1817
+ NSView *view = ensureViewForNode(nodeId, node);
1818
+ if (view.superview != parent) {
1819
+ [view removeFromSuperview];
1820
+ [parent addSubview:view];
1821
+ }
1822
+ applyViewStyle(view, node, parentAbsX, parentAbsY, parentHeight);
1823
+ applyTypeSpecificProps(view, node, nodeId);
1824
+
1825
+ // NSButton renders its own title from the Text child we read in
1826
+ // applyButtonProps; materializing the child as an NSTextField on top
1827
+ // would just be a redundant overlay. Skip descending into Buttons.
1828
+ if (node.type == NodeType::Button) return;
1829
+
1830
+ // `<textarea>` owns its NSTextView as its only descendant — JSX children
1831
+ // (if any were authored) would otherwise be pushed inside NSTextView and
1832
+ // confuse text layout. The node's text content flows through the `value`
1833
+ // attribute, not via DOM children.
1834
+ if (isScrollView(view) && objc_getAssociatedObject(view, "gea.text_view")) return;
1835
+
1836
+ // Inline composition path: parents like <p>{text}<span>...</span>{text}
1837
+ // render correctly only when AppKit gets the whole run as a single
1838
+ // attributed string. Detect that shape and short-circuit per-child
1839
+ // recursion in favour of one composite NSTextField on this view.
1840
+ if (node.type == NodeType::View && nodeIsInlineComposable(nodeId)) {
1841
+ renderInlineComposition(nodeId, view, parentAbsX, parentAbsY, parentHeight, unseen);
1842
+ return;
1843
+ }
1844
+
1845
+ // Scrollable containers (VirtualList OR View with overflow:scroll) attach
1846
+ // children to the NSScrollView's documentView. parentHeight for those
1847
+ // children is the document's scrollable height so y-flip happens against
1848
+ // the full scroll content, not just the visible window. The children's
1849
+ // absolute coords use the node's own (layout.x, layout.y) because the
1850
+ // scroll document logically starts where the node starts.
1851
+ NSView *parentForChildren = view;
1852
+ int parentHeightForChildren = node.layout.height;
1853
+ int parentAbsXForChildren = node.layout.x;
1854
+ int parentAbsYForChildren = node.layout.y;
1855
+ if (isScrollView(view)) {
1856
+ NSScrollView *sv = (NSScrollView *)view;
1857
+ parentForChildren = sv.documentView ?: view;
1858
+ parentHeightForChildren = node.layout.scroll_content_height > 0
1859
+ ? node.layout.scroll_content_height
1860
+ : node.layout.height;
1861
+ }
1862
+
1863
+ for (int child = node.first_child; child >= 0; child = tree.node(child).next_sibling) {
1864
+ syncRecursive(child, parentForChildren, parentAbsXForChildren, parentAbsYForChildren,
1865
+ parentHeightForChildren, unseen);
1866
+ }
1867
+ }
1868
+
1869
+ } // namespace
1870
+
1871
+ MacosRenderer &MacosRenderer::instance()
1872
+ {
1873
+ static MacosRenderer r;
1874
+ return r;
1875
+ }
1876
+
1877
+ namespace {
1878
+ // Install the centralized press/click handler on a root container once.
1879
+ // Idempotent via an associated object so repeated sync passes don't stack
1880
+ // recognizers.
1881
+ void ensureRootClickBridge(NSView *parentForRoot)
1882
+ {
1883
+ if (objc_getAssociatedObject(parentForRoot, "gea.root_click_bridge")) return;
1884
+ GeaRootClickBridge *bridge = [[GeaRootClickBridge alloc] init];
1885
+ bridge.rootView = parentForRoot;
1886
+ objc_setAssociatedObject(parentForRoot, "gea.root_click_bridge", bridge, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
1887
+ // NSPressGestureRecognizer (not NSClickGestureRecognizer) — we need the
1888
+ // began/ended state transitions so press-and-hold semantics work.
1889
+ // minimumPressDuration=0 fires began immediately on mouseDown.
1890
+ NSPressGestureRecognizer *gr =
1891
+ [[NSPressGestureRecognizer alloc] initWithTarget:bridge action:@selector(fire:)];
1892
+ gr.minimumPressDuration = 0;
1893
+ gr.allowableMovement = 10000; // don't cancel on drag — keep the press
1894
+ // The delegate declines clicks on editable text controls so they focus and
1895
+ // edit natively instead of being swallowed by this recognizer.
1896
+ gr.delegate = bridge;
1897
+ [parentForRoot addGestureRecognizer:gr];
1898
+ }
1899
+ } // namespace
1900
+
1901
+ void MacosRenderer::sync(NSView *parentForRoot, int rootNodeId)
1902
+ {
1903
+ using namespace gea::embedded::ui;
1904
+ Tree &tree = Tree::instance();
1905
+ if (rootNodeId < 0 || rootNodeId >= tree.nodeCount()) return;
1906
+
1907
+ ensureRootClickBridge(parentForRoot);
1908
+
1909
+ // Mark every existing entry stale; syncRecursive removes the keys it
1910
+ // touches. Anything still in the set after the walk corresponds to a
1911
+ // node that was removed from the tree (or moved out of the subtree
1912
+ // rooted at rootNodeId) — drop the NSView for it.
1913
+ NSMutableSet<NSNumber *> *unseen =
1914
+ [NSMutableSet setWithArray:[nodeIdToView() allKeys]];
1915
+
1916
+ const int parentHeight = static_cast<int>(parentForRoot.bounds.size.height);
1917
+ // Root's parent is the AppKit contentView. The root's own layout.x/y act
1918
+ // as document origin (0,0 for typical apps that fill the window), so we
1919
+ // pass (0, 0) as the parent-absolute-origin baseline.
1920
+ syncRecursive(rootNodeId, parentForRoot, 0, 0, parentHeight, unseen);
1921
+
1922
+ for (NSNumber *gone in unseen) {
1923
+ NSView *v = nodeIdToView()[gone];
1924
+ [v removeFromSuperview];
1925
+ [nodeIdToView() removeObjectForKey:gone];
1926
+ }
1927
+ }
1928
+
1929
+ void MacosRenderer::syncPanes(NSArray *paneViews, const int *rootNodeIds)
1930
+ {
1931
+ using namespace gea::embedded::ui;
1932
+ Tree &tree = Tree::instance();
1933
+ const NSUInteger paneCount = paneViews.count;
1934
+
1935
+ // One combined unseen set across ALL panes. If we instead called sync()
1936
+ // per pane, each call would sweep every map entry it didn't visit —
1937
+ // evicting the other panes' views. Build the stale set once, un-mark
1938
+ // across every pane walk, then sweep what's left.
1939
+ NSMutableSet<NSNumber *> *unseen =
1940
+ [NSMutableSet setWithArray:[nodeIdToView() allKeys]];
1941
+
1942
+ for (NSUInteger i = 0; i < paneCount; i++) {
1943
+ NSView *paneView = paneViews[i];
1944
+ const int rootNodeId = rootNodeIds[i];
1945
+ if (rootNodeId < 0 || rootNodeId >= tree.nodeCount()) continue;
1946
+ ensureRootClickBridge(paneView);
1947
+ const int parentHeight = static_cast<int>(paneView.bounds.size.height);
1948
+ // Each pane node's own layout.x/y act as its document origin, so the
1949
+ // pane's children land relative to the pane container's top-left.
1950
+ const Node &paneNode = tree.node(rootNodeId);
1951
+ syncRecursive(rootNodeId, paneView, paneNode.layout.x, paneNode.layout.y, parentHeight, unseen);
1952
+ }
1953
+
1954
+ for (NSNumber *gone in unseen) {
1955
+ NSView *v = nodeIdToView()[gone];
1956
+ [v removeFromSuperview];
1957
+ [nodeIdToView() removeObjectForKey:gone];
1958
+ }
1959
+ }
1960
+
1961
+ void MacosRenderer::teardown()
1962
+ {
1963
+ for (NSView *v in [nodeIdToView() allValues]) {
1964
+ [v removeFromSuperview];
1965
+ }
1966
+ [nodeIdToView() removeAllObjects];
1967
+ }
1968
+
1969
+ } // namespace gea::macos