@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.
- package/LICENSE +202 -0
- package/README.md +54 -0
- package/dist/index.d.ts +127 -0
- package/dist/index.js +3076 -0
- package/generated/AVFoundation.d.ts +112 -0
- package/generated/ActivityKit.d.ts +7 -0
- package/generated/AppKit.d.ts +268 -0
- package/generated/CoreGraphics.d.ts +18 -0
- package/generated/CoreLocation.d.ts +28 -0
- package/generated/CoreMedia.d.ts +8 -0
- package/generated/Dispatch.d.ts +2 -0
- package/generated/Foundation.d.ts +13 -0
- package/generated/MapKit.d.ts +28 -0
- package/generated/Metal.d.ts +91 -0
- package/generated/MetalKit.d.ts +22 -0
- package/generated/Photos.d.ts +5 -0
- package/generated/QuartzCore.d.ts +12 -0
- package/generated/SwiftData.d.ts +11 -0
- package/generated/UIKit.d.ts +187 -0
- package/package.json +123 -0
- package/runtime/AVFoundation.js +136 -0
- package/runtime/ActivityKit.js +18 -0
- package/runtime/AppKit.js +313 -0
- package/runtime/CoreGraphics.js +15 -0
- package/runtime/CoreLocation.js +30 -0
- package/runtime/CoreMedia.js +7 -0
- package/runtime/Dispatch.js +11 -0
- package/runtime/Foundation.js +21 -0
- package/runtime/MapKit.js +22 -0
- package/runtime/Metal.js +126 -0
- package/runtime/MetalKit.js +15 -0
- package/runtime/Photos.js +9 -0
- package/runtime/QuartzCore.js +9 -0
- package/runtime/SwiftData.js +29 -0
- package/runtime/UIKit.js +242 -0
- package/runtime/empty.js +1 -0
- package/targets/ios/Info.plist.in +25 -0
- package/targets/ios/README.md +46 -0
- package/targets/ios/build-ios.sh +395 -0
- package/targets/ios/detect-development-team.mjs +109 -0
- package/targets/ios/generate-resident-entry.mjs +67 -0
- package/targets/ios/generate-resident-registry.mjs +162 -0
- package/targets/ios/generate-xcode-project.mjs +520 -0
- package/targets/ios/main/canvas_view.h +7 -0
- package/targets/ios/main/canvas_view.mm +94 -0
- package/targets/ios/main/font_registry.h +17 -0
- package/targets/ios/main/font_registry.mm +129 -0
- package/targets/ios/main/image_bridge.h +13 -0
- package/targets/ios/main/image_bridge.mm +53 -0
- package/targets/ios/main/ios_apps.c +28 -0
- package/targets/ios/main/ios_camera.mm +446 -0
- package/targets/ios/main/ios_display.mm +344 -0
- package/targets/ios/main/ios_main.mm +811 -0
- package/targets/ios/main/ios_memory.cpp +55 -0
- package/targets/ios/main/ios_renderer.h +20 -0
- package/targets/ios/main/ios_renderer.mm +27 -0
- package/targets/ios/main/ios_root_background.cpp +20 -0
- package/targets/ios/main/ios_root_background.h +11 -0
- package/targets/ios/main/ios_sensors.mm +244 -0
- package/targets/ios/main/ios_timers.mm +53 -0
- package/targets/ios/main/renderer/ios_renderer_internal.h +65 -0
- package/targets/ios/main/renderer/ios_renderer_support.mm +111 -0
- package/targets/ios/main/renderer/native_button.mm +179 -0
- package/targets/ios/main/renderer/native_label.mm +161 -0
- package/targets/ios/main/renderer/native_scroll_container.mm +279 -0
- package/targets/ios/main/renderer/view_reconciler.mm +274 -0
- package/targets/macos/Info.plist.in +26 -0
- package/targets/macos/README.md +131 -0
- package/targets/macos/build-child-lifecycle.sh +119 -0
- package/targets/macos/build-macos.sh +2156 -0
- package/targets/macos/check-module-graph-freshness.mjs +82 -0
- package/targets/macos/compiler-input-fingerprint.mjs +32 -0
- package/targets/macos/finalize-apple-native-output.mjs +77 -0
- package/targets/macos/generate-resident-entry.mjs +80 -0
- package/targets/macos/generate-resident-registry.mjs +141 -0
- package/targets/macos/heavy-build-lock.sh +176 -0
- package/targets/macos/main/canvas_view.h +9 -0
- package/targets/macos/main/canvas_view.mm +176 -0
- package/targets/macos/main/color_convert.h +18 -0
- package/targets/macos/main/color_convert.mm +39 -0
- package/targets/macos/main/font_registry.h +35 -0
- package/targets/macos/main/font_registry.mm +350 -0
- package/targets/macos/main/image_bridge.h +16 -0
- package/targets/macos/main/image_bridge.mm +52 -0
- package/targets/macos/main/macos_app_platform.mm +95 -0
- package/targets/macos/main/macos_apps.c +31 -0
- package/targets/macos/main/macos_device_control.mm +56 -0
- package/targets/macos/main/macos_display.mm +484 -0
- package/targets/macos/main/macos_host_device_control.mm +95 -0
- package/targets/macos/main/macos_main.mm +839 -0
- package/targets/macos/main/macos_memory.cpp +31 -0
- package/targets/macos/main/macos_native_shell.h +31 -0
- package/targets/macos/main/macos_native_shell.mm +396 -0
- package/targets/macos/main/macos_network.mm +253 -0
- package/targets/macos/main/macos_renderer.h +39 -0
- package/targets/macos/main/macos_renderer.mm +1969 -0
- package/targets/macos/main/macos_sensors.cpp +148 -0
- package/targets/macos/main/macos_smoke_app.cpp +65 -0
- package/targets/macos/main/macos_timers.mm +52 -0
- package/targets/macos/main/press_bridge.h +21 -0
- package/targets/macos/main/press_bridge.mm +72 -0
- package/targets/macos/main/thermalright_hid_display.h +14 -0
- package/targets/macos/main/thermalright_hid_display.mm +995 -0
- package/targets/macos/native-artifact-provenance.mjs +421 -0
- package/targets/macos/order-compile-edges.mjs +64 -0
- package/targets/macos/preflight-hard-muted-artifact.mjs +93 -0
- package/targets/macos/prune-generated-modules.mjs +147 -0
- package/targets/macos/sync-resources.mjs +153 -0
- package/targets/shared/analyze-audio-capture.mjs +131 -0
- package/targets/shared/apple_audio.mm +1194 -0
- package/targets/shared/apple_audio_selftest.mm +602 -0
- package/targets/shared/apple_audio_testing.h +34 -0
- 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
|