craft-native 0.0.435 → 0.0.442
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.md +30 -0
- package/README.md +35 -0
- package/dist/android/src/index.d.ts +8 -0
- package/dist/android/src/index.js +17 -2
- package/dist/android/templates/CraftHealthServices.kt.template +2 -0
- package/dist/android/templates/CraftNativeCapabilities.kt.template +42 -1
- package/dist/android/templates/CraftNativeMotion.kt.template +269 -0
- package/dist/android/templates/CraftNativeServices.kt.template +6 -1
- package/dist/android/templates/CraftPassiveSteps.kt.template +159 -0
- package/dist/android/templates/CraftRecordingPressure.kt.template +156 -0
- package/dist/android/templates/LocationRecordingService.kt.template +77 -9
- package/dist/android/templates/MainActivityNative.kt.template +129 -1
- package/dist/cli.js +289 -108
- package/dist/components/index.d.ts +25 -0
- package/dist/index.cjs +207 -27
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +321 -140
- package/dist/installer-background.d.ts +2 -0
- package/dist/installer-layout.d.ts +1 -0
- package/dist/ios/src/index.d.ts +10 -0
- package/dist/ios/src/index.js +28 -2
- package/dist/ios/templates/CraftApp.swift +14 -0
- package/dist/ios/templates/CraftHealth.swift +2 -1
- package/dist/ios/templates/CraftHealthBackground.swift +308 -0
- package/dist/ios/templates/CraftNativeActions.swift +153 -0
- package/dist/ios/templates/CraftNativeMutation.swift +18 -3
- package/dist/ios/templates/CraftNativePedometer.swift +181 -0
- package/dist/ios/templates/CraftNativeScreen.swift +76 -0
- package/dist/ios/templates/testing/DriverTests.swift.template +24 -4
- package/dist/macos/src/index.js +1 -1
- package/dist/macos/src/toolchain.d.ts +1 -1
- package/dist/macos/templates/CraftMacApplication.swift +124 -39
- package/dist/macos/templates/CraftMacRenderer.swift +169 -37
- package/dist/macos/templates/CraftMacRuntime.swift +298 -77
- package/dist/macos/templates/CraftNativeMutation.swift +18 -3
- package/dist/package.d.ts +40 -0
- package/dist/types.d.ts +26 -0
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import AppKit
|
|
2
2
|
import AVKit
|
|
3
|
+
import ZigJS
|
|
3
4
|
import CoreImage
|
|
4
5
|
import UserNotifications
|
|
5
6
|
import UniformTypeIdentifiers
|
|
@@ -23,19 +24,31 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
|
|
|
23
24
|
var holdsQuit = false
|
|
24
25
|
/// The content width the screen last heard, so a resize that changes nothing sends nothing.
|
|
25
26
|
var reportedViewportWidth: CGFloat = -1
|
|
26
|
-
|
|
27
|
+
/// The content size the screen started with, which the window corrects once it shows.
|
|
28
|
+
let startedViewport: CGSize
|
|
29
|
+
private var shown = false
|
|
30
|
+
/// How long a window waits for its screen's first tree before it opens without it.
|
|
31
|
+
static let firstTreeGrace: TimeInterval = 0.5
|
|
32
|
+
/// `runtime` is the screen already started for this window (the main one starts before AppKit has
|
|
33
|
+
/// launched); without one the screen starts here, and evaluates its bundle while the window is made.
|
|
34
|
+
init(name: String, route: String, options: [String: Any], application: CraftMacApplication, runtime prepared: CraftMacRuntime? = nil) {
|
|
35
|
+
CraftTrace.mark("scene \(name)")
|
|
27
36
|
self.name = name
|
|
28
|
-
runtime = CraftMacRuntime(route: route, params: options["params"] as? [String: Any] ?? [:], network: application.network)
|
|
29
|
-
renderer = CraftMacRenderer(runtime: runtime)
|
|
30
37
|
let width = number(options["width"]) ?? 1120, height = number(options["height"]) ?? 740
|
|
31
38
|
let transparent = (options["titlebarStyle"] as? String ?? application.titlebarStyle) == "transparent"
|
|
39
|
+
let viewport = Self.contentSize(options: options, application: application, name: name)
|
|
40
|
+
runtime = prepared ?? application.startScreen(route: route, options: options, viewport: viewport, colorScheme: CraftMacColor.isDark ? "dark" : "light")
|
|
41
|
+
reportedViewportWidth = viewport.width
|
|
42
|
+
startedViewport = viewport
|
|
43
|
+
renderer = CraftMacRenderer(runtime: runtime)
|
|
32
44
|
let lights = options["trafficLights"] as? [Any] ?? (name == "main" ? application.trafficLights : nil)
|
|
33
45
|
trafficLights = lights.flatMap { values in values.count == 2 ? number(values[0]).flatMap { x in number(values[1]).map { CGPoint(x: x, y: $0) } } : nil }
|
|
34
|
-
let panel = CraftMacWindow(contentRect: CGRect(x: 0, y: 0, width: width, height: height), styleMask: transparent ? [.titled, .closable, .resizable, .miniaturizable, .fullSizeContentView] : [.titled, .closable, .resizable, .miniaturizable], backing: .buffered, defer:
|
|
46
|
+
let panel = CraftMacWindow(contentRect: CGRect(x: 0, y: 0, width: width, height: height), styleMask: transparent ? [.titled, .closable, .resizable, .miniaturizable, .fullSizeContentView] : [.titled, .closable, .resizable, .miniaturizable], backing: .buffered, defer: true)
|
|
35
47
|
panel.runtime = runtime
|
|
36
48
|
window = panel
|
|
49
|
+
CraftTrace.mark("window created")
|
|
37
50
|
super.init()
|
|
38
|
-
|
|
51
|
+
renderer.window = window
|
|
39
52
|
window.title = options["title"] as? String ?? application.appName
|
|
40
53
|
if (options["titlebarStyle"] as? String ?? application.titlebarStyle) == "transparent" {
|
|
41
54
|
window.titlebarAppearsTransparent = true
|
|
@@ -48,14 +61,54 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
|
|
|
48
61
|
renderer.root.autoresizingMask = [.width, .height]
|
|
49
62
|
window.contentView = renderer.root
|
|
50
63
|
window.delegate = self; window.center()
|
|
51
|
-
if
|
|
52
|
-
window.
|
|
64
|
+
if let autosave = Self.autosaveName(name: name, application: application) { window.setFrameAutosaveName(autosave) }
|
|
65
|
+
// Whatever the screen drew while the window was made is in it before it shows. A screen that has not
|
|
66
|
+
// drawn yet gets a moment to, so the window opens with its content rather than blank.
|
|
67
|
+
runtime.attach(self)
|
|
68
|
+
if runtime.hasDrawn { show() } else {
|
|
69
|
+
runtime.whenDrawn { [weak self] in self?.show() }
|
|
70
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + Self.firstTreeGrace) { [weak self] in self?.show() }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Orders the window front the first time. The window is made deferred, so the window server's side
|
|
75
|
+
/// of it is made here, once, rather than updated for each property set while it was configured; and
|
|
76
|
+
/// it opens without the zoom AppKit plays for a new window, which runs a display link and draws it in
|
|
77
|
+
/// over several frames. Later showings animate as usual.
|
|
78
|
+
func show() {
|
|
79
|
+
guard !shown else { return }
|
|
80
|
+
shown = true
|
|
81
|
+
let behavior = window.animationBehavior
|
|
82
|
+
window.animationBehavior = .none
|
|
83
|
+
CraftTrace.span("window ordered front") { window.makeKeyAndOrderFront(nil) }
|
|
84
|
+
window.animationBehavior = behavior
|
|
53
85
|
placeTrafficLights()
|
|
54
|
-
// The screen
|
|
86
|
+
// The screen started with the size this window was expected to open at; a saved frame the
|
|
87
|
+
// screen cannot fit, or a smaller display, makes it another, which the screen now hears.
|
|
55
88
|
let size = window.contentLayoutRect.size
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
89
|
+
if abs(size.width - startedViewport.width) >= 0.5 || abs(size.height - startedViewport.height) >= 0.5 {
|
|
90
|
+
reportedViewportWidth = size.width
|
|
91
|
+
runtime.send("VIEWPORT", ["width": Double(size.width), "height": Double(size.height)])
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
static func autosaveName(name: String, application: CraftMacApplication) -> String? {
|
|
96
|
+
name == "main" && application.testDirectory == nil ? "\(application.appName)NativeMain" : nil
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// The content size (below the title bar) the window will open at, known before it exists: the size
|
|
100
|
+
/// asked for, or the frame AppKit saved for it last time. Its screen starts with it.
|
|
101
|
+
static func contentSize(options: [String: Any], application: CraftMacApplication, name: String) -> CGSize {
|
|
102
|
+
let transparent = (options["titlebarStyle"] as? String ?? application.titlebarStyle) == "transparent"
|
|
103
|
+
let titlebar = NSWindow.frameRect(forContentRect: CGRect(x: 0, y: 0, width: 100, height: 100), styleMask: [.titled]).height - 100
|
|
104
|
+
let width = number(options["width"]) ?? 1120, height = number(options["height"]) ?? 740
|
|
105
|
+
// `x y width height` of the frame, then the screen's.
|
|
106
|
+
if let autosave = autosaveName(name: name, application: application),
|
|
107
|
+
let saved = UserDefaults.standard.string(forKey: "NSWindow Frame \(autosave)")?.split(separator: " ").compactMap({ Double($0) }), saved.count >= 4 {
|
|
108
|
+
return CGSize(width: saved[2], height: max(0, saved[3] - titlebar))
|
|
109
|
+
}
|
|
110
|
+
// A full-size content view runs under the title bar, which its layout rect leaves out.
|
|
111
|
+
return CGSize(width: width, height: transparent ? max(0, height - titlebar) : height)
|
|
59
112
|
}
|
|
60
113
|
|
|
61
114
|
/// Tells the screen its new width (VIEWPORT, as a phone does on rotation): only when the width changed,
|
|
@@ -109,8 +162,8 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
109
162
|
var titlebarStyle = "standard"
|
|
110
163
|
var trafficLights: [Any]?
|
|
111
164
|
/// Apps that ship their own agent (Attic) hand out a session cookie at
|
|
112
|
-
/// `/native/session
|
|
113
|
-
/// endpoint and starts with `--no-session`.
|
|
165
|
+
/// `/native/session` (`authorize`). An app that talks to a hosted API has
|
|
166
|
+
/// no such endpoint and starts with `--no-session`.
|
|
114
167
|
var requiresSession = true
|
|
115
168
|
var mainWindowOptions: [String: Any] = [:]
|
|
116
169
|
/// The Dock icon's menu (`craft-app.json` `dockMenu`): each item sends its action as a desktop command.
|
|
@@ -128,34 +181,46 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
128
181
|
var testMenu: [[String: Any]] = []
|
|
129
182
|
var testMenuChoice: String?
|
|
130
183
|
var appearanceObservation: NSKeyValueObservation?
|
|
184
|
+
/// The main window's screen, started before AppKit launched, waiting for its window.
|
|
185
|
+
var preparedMain: CraftMacRuntime?
|
|
186
|
+
/// The appearance it started with, which AppKit could not be asked yet.
|
|
187
|
+
var preparedColorScheme = ""
|
|
188
|
+
var mainOptions: [String: Any] { mainWindowOptions.merging(["title": appName]) { _, new in new } }
|
|
189
|
+
/// Launch finished, and why the agent issued no session, if it did not (shown once both are known).
|
|
190
|
+
var launched = false
|
|
191
|
+
var sessionFailure: String?
|
|
131
192
|
/// A quit waiting for screens to finish (`craft.desktop.onQuit`), for at most `quitGrace` seconds.
|
|
132
193
|
private var pendingQuit: (token: String, waiting: Set<String>, timeout: Timer)?
|
|
133
194
|
static let quitGrace: TimeInterval = 3
|
|
134
195
|
|
|
196
|
+
func applicationWillFinishLaunching(_ notification: Notification) { CraftTrace.mark("will finish launching") }
|
|
135
197
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
136
|
-
|
|
198
|
+
CraftTrace.mark("did finish launching")
|
|
199
|
+
CraftTrace.span("menus installed") { installMenus() }
|
|
137
200
|
appearanceObservation = NSApp.observe(\.effectiveAppearance, options: [.new]) { [weak self] _, _ in
|
|
138
201
|
DispatchQueue.main.async { self?.appearanceChanged() }
|
|
139
202
|
}
|
|
140
203
|
NSApp.activate(ignoringOtherApps: true)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
204
|
+
open(name: "main", route: initialRoute, options: mainOptions)
|
|
205
|
+
if preparedColorScheme != (CraftMacColor.isDark ? "dark" : "light") { appearanceChanged() }
|
|
206
|
+
startTestDriver()
|
|
207
|
+
launched = true
|
|
208
|
+
if let failure = sessionFailure { sessionFailed(failure) }
|
|
209
|
+
}
|
|
210
|
+
/// Asks the agent for the session cookie (`CraftMacNetwork.authorize`) as the app starts, before
|
|
211
|
+
/// AppKit has launched; the screen's requests wait for it. Without one the app says so and quits.
|
|
212
|
+
func authorize() {
|
|
213
|
+
network.authorize { failure in
|
|
150
214
|
DispatchQueue.main.async {
|
|
151
|
-
|
|
152
|
-
guard
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
self.open(name: "main", route: self.initialRoute, options: self.mainWindowOptions.merging(["title": self.appName]) { _, new in new })
|
|
156
|
-
self.startTestDriver()
|
|
215
|
+
CraftTrace.mark("native session answered")
|
|
216
|
+
guard let failure else { return }
|
|
217
|
+
self.sessionFailure = failure
|
|
218
|
+
if self.launched { self.sessionFailed(failure) }
|
|
157
219
|
}
|
|
158
|
-
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
private func sessionFailed(_ message: String) {
|
|
223
|
+
let alert = NSAlert(); alert.messageText = "Could not connect to the app"; alert.informativeText = message; alert.runModal(); NSApp.terminate(nil)
|
|
159
224
|
}
|
|
160
225
|
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { scenes["main"]?.window.makeKeyAndOrderFront(nil); return true }
|
|
161
226
|
/// Links from other apps (this app as the default browser) and files dropped on the Dock icon.
|
|
@@ -212,7 +277,24 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
212
277
|
}
|
|
213
278
|
func open(name: String, route: String, options: [String: Any]) {
|
|
214
279
|
if let scene = scenes[name] { scene.window.makeKeyAndOrderFront(nil); return }
|
|
215
|
-
|
|
280
|
+
let prepared = name == "main" ? preparedMain : nil
|
|
281
|
+
if prepared != nil { preparedMain = nil }
|
|
282
|
+
scenes[name] = CraftMacScene(name: name, route: route, options: options, application: self, runtime: prepared)
|
|
283
|
+
}
|
|
284
|
+
/// A screen's thread and context, its bundle evaluated as soon as the thread can.
|
|
285
|
+
func startScreen(route: String, options: [String: Any], viewport: CGSize, colorScheme: String) -> CraftMacRuntime {
|
|
286
|
+
let runtime = CraftMacRuntime(route: route, params: options["params"] as? [String: Any] ?? [:], network: network, colorScheme: colorScheme)
|
|
287
|
+
runtime.load(bundle, viewport: viewport)
|
|
288
|
+
return runtime
|
|
289
|
+
}
|
|
290
|
+
/// Starts the main window's screen before AppKit launches: connecting to the window server and
|
|
291
|
+
/// launching take a few hundred milliseconds, most of them waiting, and the screen evaluates its
|
|
292
|
+
/// bundle, mounts and sends its first requests meanwhile. Its tree is drawn when the window opens.
|
|
293
|
+
func prepareMainScreen() {
|
|
294
|
+
// NSApp does not exist yet, so the system's setting stands in for AppKit's appearance;
|
|
295
|
+
// launching checks it against AppKit's.
|
|
296
|
+
preparedColorScheme = UserDefaults.standard.string(forKey: "AppleInterfaceStyle") == "Dark" ? "dark" : "light"
|
|
297
|
+
preparedMain = startScreen(route: initialRoute, options: mainOptions, viewport: CraftMacScene.contentSize(options: mainOptions, application: self, name: "main"), colorScheme: preparedColorScheme)
|
|
216
298
|
}
|
|
217
299
|
/// A menu command goes to the window it was chosen in (the key window), else the main one: with
|
|
218
300
|
/// two windows of the same screen open, cmd-T opens one tab, not one in each.
|
|
@@ -231,7 +313,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
231
313
|
// AppKit lays the title bar out again for the new appearance, after this returns.
|
|
232
314
|
scene.placeTrafficLights()
|
|
233
315
|
DispatchQueue.main.async { [weak scene] in scene?.placeTrafficLights() }
|
|
234
|
-
scene.runtime.
|
|
316
|
+
scene.runtime.evaluate("__stxNativeBridge.colorScheme = '\(scheme)';")
|
|
235
317
|
// The protocol's own message: the screen restyles its `dark:` classes and tells `craft.appearance.onChange`.
|
|
236
318
|
scene.runtime.send("APPEARANCE", ["colorScheme": scheme])
|
|
237
319
|
scene.window.effectiveAppearance.performAsCurrentDrawingAppearance {
|
|
@@ -293,7 +375,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
293
375
|
if parts.contains("alt") { item.keyEquivalentModifierMask.insert(.option) }
|
|
294
376
|
item.isEnabled = raw["disabled"] as? Bool != true
|
|
295
377
|
item.state = raw["checked"] as? Bool == true ? .on : .off
|
|
296
|
-
if let symbol = raw["icon"] as? String { item.image =
|
|
378
|
+
if let symbol = raw["icon"] as? String { item.image = CraftMacSymbols.image(symbol) }
|
|
297
379
|
return item
|
|
298
380
|
}
|
|
299
381
|
func perform(_ method: String, args: [Any], scene: CraftMacScene?, reply: @escaping (Any) -> Void) {
|
|
@@ -373,10 +455,10 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
373
455
|
guard let path = file["path"], chosenFiles.contains(path), let data = try? Data(contentsOf: URL(fileURLWithPath: path)), data.count <= 256 * 1024 * 1024,
|
|
374
456
|
var request = network.request(["url": endpoint, "method": "POST", "headers": ["x-filename": file["name"] ?? "Attachment", "content-type": file["type"] ?? "application/octet-stream"]]) else { reply(["error": "Could not read a picked attachment"]); return }
|
|
375
457
|
request.httpBody = data
|
|
376
|
-
network.session.dataTask(with: request) { data, response, error in DispatchQueue.main.async {
|
|
458
|
+
network.resume(network.session.dataTask(with: request) { data, response, error in DispatchQueue.main.async {
|
|
377
459
|
guard error == nil, (response as? HTTPURLResponse)?.statusCode == 200, let data, let value = try? JSONSerialization.jsonObject(with: data) else { reply(["error": error?.localizedDescription ?? "Attachment upload failed"]); return }
|
|
378
460
|
results.append(value); next(index + 1)
|
|
379
|
-
} }
|
|
461
|
+
} })
|
|
380
462
|
}; next(0)
|
|
381
463
|
}
|
|
382
464
|
private func preview(_ options: [String: Any], scene: CraftMacScene?) {
|
|
@@ -390,18 +472,18 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
390
472
|
player.player?.play()
|
|
391
473
|
} else {
|
|
392
474
|
let image = NSImageView(); image.imageScaling = .scaleProportionallyUpOrDown; panel.contentView = image
|
|
393
|
-
network.session.dataTask(with: request) { data, _, _ in DispatchQueue.main.async { image.image = data.flatMap(NSImage.init(data:)) } }
|
|
475
|
+
network.resume(network.session.dataTask(with: request) { data, _, _ in DispatchQueue.main.async { image.image = data.flatMap(NSImage.init(data:)) } })
|
|
394
476
|
}
|
|
395
477
|
panel.center(); panel.makeKeyAndOrderFront(nil)
|
|
396
478
|
}
|
|
397
479
|
private func export(_ options: [String: Any], reply: @escaping (Any) -> Void) {
|
|
398
480
|
let panel = NSSavePanel(); panel.nameFieldStringValue = options["name"] as? String ?? "Attachment"
|
|
399
481
|
guard panel.runModal() == .OK, let destination = panel.url, let request = network.request(["url": options["url"] ?? ""]) else { reply(false); return }
|
|
400
|
-
network.session.downloadTask(with: request) { location, response, error in
|
|
482
|
+
network.resume(network.session.downloadTask(with: request) { location, response, error in
|
|
401
483
|
var saved = false
|
|
402
484
|
if error == nil, (response as? HTTPURLResponse)?.statusCode == 200, let location { do { if FileManager.default.fileExists(atPath: destination.path) { try FileManager.default.removeItem(at: destination) }; try FileManager.default.moveItem(at: location, to: destination); saved = true } catch {} }
|
|
403
485
|
DispatchQueue.main.async { reply(saved) }
|
|
404
|
-
}
|
|
486
|
+
})
|
|
405
487
|
}
|
|
406
488
|
func notification(_ method: String, options: [String: Any], reply: @escaping (Any) -> Void) {
|
|
407
489
|
guard method == "schedule" else { reply(false); return }
|
|
@@ -554,7 +636,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
554
636
|
return node
|
|
555
637
|
}
|
|
556
638
|
func classes(_ view: NSView) -> [String] { [String(describing: type(of: view))] + view.subviews.flatMap(classes) }
|
|
557
|
-
return ["name": scene.name, "title": scene.window.title, "route": scene.runtime.route, "contextClass": String(describing:
|
|
639
|
+
return ["name": scene.name, "title": scene.window.title, "route": scene.runtime.route, "contextClass": String(describing: ZigJSContext.self), "nodes": nodes, "viewClasses": classes(scene.renderer.root), "viewportWidth": Double(scene.reportedViewportWidth), "errors": scene.runtime.errors]
|
|
558
640
|
}
|
|
559
641
|
let webViews = windows.reduce(0) { count, window in count + ((window["viewClasses"] as? [String] ?? []).filter { $0.contains("WKWebView") || $0 == "WebView" }.count) }
|
|
560
642
|
return ["renderer": "appkit", "scriptEngine": "zig-js", "appearance": CraftMacColor.isDark ? "dark" : "light", "windows": windows, "webViews": webViews, "menu": testMenu]
|
|
@@ -563,6 +645,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
563
645
|
|
|
564
646
|
@main struct CraftMacMain {
|
|
565
647
|
static func main() throws {
|
|
648
|
+
CraftTrace.mark("main")
|
|
566
649
|
// A bundled app is launched by Finder with no arguments: its settings are
|
|
567
650
|
// `Contents/Resources/craft-app.json`, written by the build. A flag on the
|
|
568
651
|
// command line still wins, so a development run can point elsewhere.
|
|
@@ -595,6 +678,8 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
595
678
|
application.network = CraftMacNetwork(base: api)
|
|
596
679
|
let testDirectory = argument("--test-dir")
|
|
597
680
|
if !testDirectory.isEmpty { application.testDirectory = URL(fileURLWithPath: testDirectory, isDirectory: true) }
|
|
681
|
+
if application.requiresSession { application.authorize() }
|
|
682
|
+
application.prepareMainScreen()
|
|
598
683
|
let app = NSApplication.shared
|
|
599
684
|
app.setActivationPolicy(.regular); app.delegate = application; app.run()
|
|
600
685
|
}
|
|
@@ -21,6 +21,22 @@ enum CraftMacColor {
|
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/// SF Symbols, one image per name and description. An image keeps what it rasterized, so views that
|
|
25
|
+
/// share one draw it once rather than each looking the glyph up in the catalog and rendering it again.
|
|
26
|
+
enum CraftMacSymbols {
|
|
27
|
+
private struct Key: Hashable { let name: String, description: String? }
|
|
28
|
+
private static var images: [Key: NSImage?] = [:]
|
|
29
|
+
static func image(_ name: String, description: String? = nil) -> NSImage? {
|
|
30
|
+
let key = Key(name: name, description: description)
|
|
31
|
+
if let image = images[key] { return image }
|
|
32
|
+
// Descriptions can carry changing text (a button's title), so the cache starts over rather than grow without end.
|
|
33
|
+
if images.count >= 512 { images.removeAll(keepingCapacity: true) }
|
|
34
|
+
let image = NSImage(systemSymbolName: name, accessibilityDescription: description)
|
|
35
|
+
images[key] = image
|
|
36
|
+
return image
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
24
40
|
/// The same C ABI used by Craft's mobile hosts. AppKit supplies text metrics;
|
|
25
41
|
/// Zig supplies flex sizing and justification, without a second layout rule.
|
|
26
42
|
enum CraftMacFlex {
|
|
@@ -56,7 +72,6 @@ final class CraftMacFlow: NSView {
|
|
|
56
72
|
// scopes must preserve the root's allocation on that later pass too.
|
|
57
73
|
if componentScope, boxes.count == 1, boxes[0].kind != "Modal", boxes[0].superview === self {
|
|
58
74
|
boxes[0].frame = CGRect(origin: .zero, size: size)
|
|
59
|
-
boxes[0].layoutSubtreeIfNeeded()
|
|
60
75
|
return
|
|
61
76
|
}
|
|
62
77
|
let horizontal = style["flexDirection"] as? String == "row"
|
|
@@ -102,9 +117,10 @@ final class CraftMacFlow: NSView {
|
|
|
102
117
|
let measured = horizontal ? box.measure(width: frames[index].0, stretch: true) : sizes[index]
|
|
103
118
|
let cross = min(crossAvailable, crossExplicit ?? (alignment == "stretch" ? crossAvailable : (horizontal ? measured.height : measured.width)))
|
|
104
119
|
let offset = margins.start + (alignment == "center" ? (crossAvailable - cross) / 2 : alignment == "flex-end" ? crossAvailable - cross : 0)
|
|
120
|
+
// No layout pass of its own: a child whose size changed is marked for layout, and AppKit lays it out
|
|
121
|
+
// after this one returns, in the same pass. Asking here walked its subtree again for every ancestor.
|
|
105
122
|
box.frame = horizontal ? CGRect(x: left + frames[index].1, y: top + offset, width: frames[index].0, height: cross)
|
|
106
123
|
: CGRect(x: left + offset, y: top + frames[index].1, width: cross, height: frames[index].0)
|
|
107
|
-
box.layoutSubtreeIfNeeded()
|
|
108
124
|
}
|
|
109
125
|
for box in boxes where !box.isHidden && box.layoutStyle["position"] as? String == "absolute" {
|
|
110
126
|
let measured = box.measure(width: size.width)
|
|
@@ -113,7 +129,6 @@ final class CraftMacFlow: NSView {
|
|
|
113
129
|
let width = dimension(box.layoutStyle["width"], base: size.width) ?? (leading != nil && trailing != nil ? max(0, size.width - leading! - trailing!) : measured.width)
|
|
114
130
|
let height = dimension(box.layoutStyle["height"], base: size.height) ?? (above != nil && below != nil ? max(0, size.height - above! - below!) : measured.height)
|
|
115
131
|
box.frame = CGRect(x: (leading ?? (trailing.map { size.width - $0 - width } ?? 0)), y: (above ?? (below.map { size.height - $0 - height } ?? 0)), width: width, height: height)
|
|
116
|
-
box.layoutSubtreeIfNeeded()
|
|
117
132
|
}
|
|
118
133
|
}
|
|
119
134
|
func fitting(width: CGFloat, stretchChildren: Bool = true) -> CGSize {
|
|
@@ -288,7 +303,12 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
|
|
|
288
303
|
static var measuredCount = 0
|
|
289
304
|
private struct MeasureKey: Hashable { let width: CGFloat, stretch: Bool }
|
|
290
305
|
private var measured: [MeasureKey: CGSize] = [:]
|
|
306
|
+
/// What this box measured is stale: it or something inside it changed (CraftMacRenderer.update).
|
|
307
|
+
func forgetMeasurements() { measured.removeAll(keepingCapacity: true); controlSize = nil }
|
|
291
308
|
private var measuredGeneration = -1
|
|
309
|
+
/// A control's own size (a button, a switch, a picker), asked once rather than at every width the box is
|
|
310
|
+
/// measured at: a button answers through SwiftUI's layout on current macOS. Forgotten with the measurements.
|
|
311
|
+
private var controlSize: CGSize?
|
|
292
312
|
var componentRoot: CraftMacBox? { props["data-stx-component-wrapper"] != nil && flow?.boxes.count == 1 && flow?.boxes.first?.kind != "Modal" ? flow?.boxes.first : nil }
|
|
293
313
|
var layoutStyle: [String: Any] {
|
|
294
314
|
guard let child = componentRoot else { return style }
|
|
@@ -311,8 +331,13 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
|
|
|
311
331
|
text.isSelectable = true; text.maximumNumberOfLines = 0
|
|
312
332
|
control = text
|
|
313
333
|
case "Button", "Link":
|
|
314
|
-
|
|
315
|
-
|
|
334
|
+
// What `NSButton(title:target:action:)` sets up, without the `sizeToFit` it ends with: a button's
|
|
335
|
+
// size goes through SwiftUI's layout on current macOS, and the box sets its frame anyway.
|
|
336
|
+
let button = NSButton(frame: .zero)
|
|
337
|
+
button.setButtonType(.momentaryPushIn); button.bezelStyle = .rounded
|
|
338
|
+
button.lineBreakMode = .byTruncatingTail; button.imageScaling = .scaleProportionallyDown
|
|
339
|
+
button.title = ""; button.target = self; button.action = #selector(pressed)
|
|
340
|
+
control = button
|
|
316
341
|
case "TextInput":
|
|
317
342
|
let input = NSTextField(); input.delegate = self; control = input
|
|
318
343
|
case "Picker":
|
|
@@ -346,6 +371,7 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
|
|
|
346
371
|
required init?(coder: NSCoder) { fatalError("Native controls are created from STX") }
|
|
347
372
|
|
|
348
373
|
func configure(_ node: [String: Any]) {
|
|
374
|
+
controlSize = nil
|
|
349
375
|
props = node["props"] as? [String: Any] ?? [:]
|
|
350
376
|
flow?.componentScope = props["data-stx-component-wrapper"] != nil
|
|
351
377
|
style = node["style"] as? [String: Any] ?? [:]
|
|
@@ -376,10 +402,15 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
|
|
|
376
402
|
} else { layer?.shadowOpacity = 0; layer?.shadowPath = nil; control?.layer?.cornerRadius = 0 }
|
|
377
403
|
layer?.cornerCurve = .continuous
|
|
378
404
|
for edge in ["Top", "Right", "Bottom", "Left"] {
|
|
405
|
+
let hidden = (number(style["border\(edge)Width"]) ?? 0) <= 0
|
|
406
|
+
// An edge's layer is made the first time it shows: most boxes never draw one, and four hidden
|
|
407
|
+
// layers on every box still cost Core Animation a commit each. An input makes its four at once,
|
|
408
|
+
// as it always did, since swapping its control later puts the new one above layers made before.
|
|
409
|
+
if edgeBorders[edge] == nil, hidden, kind != "TextInput" { continue }
|
|
379
410
|
let border = edgeBorders[edge] ?? CALayer()
|
|
380
411
|
if edgeBorders[edge] == nil { edgeBorders[edge] = border; layer?.addSublayer(border) }
|
|
381
412
|
border.backgroundColor = CraftMacColor.parse(style["borderColor"], fallback: .separatorColor).cgColor
|
|
382
|
-
border.isHidden =
|
|
413
|
+
border.isHidden = hidden
|
|
383
414
|
}
|
|
384
415
|
if let name = props["material"] as? String {
|
|
385
416
|
if material == nil { let effect = NSVisualEffectView(); effect.state = .followsWindowActiveState; addSubview(effect, positioned: .below, relativeTo: control); material = effect }
|
|
@@ -426,7 +457,7 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
|
|
|
426
457
|
button.bezelStyle = props["variant"] as? String == "toolbar" ? .texturedRounded : .rounded
|
|
427
458
|
button.isBordered = !["toolbar", "ghost"].contains(props["variant"] as? String ?? "")
|
|
428
459
|
button.contentTintColor = CraftMacColor.parse(style["color"], fallback: .controlAccentColor)
|
|
429
|
-
if let symbol = props["symbol"] as? String { button.image =
|
|
460
|
+
if let symbol = props["symbol"] as? String { button.image = CraftMacSymbols.image(symbol, description: button.title); button.imagePosition = button.title.isEmpty ? .imageOnly : .imageLeading }
|
|
430
461
|
if button.image != nil { button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: number(style["fontSize"]) ?? 16, weight: .regular) }
|
|
431
462
|
if props["variant"] as? String == "primary" { button.bezelColor = .controlAccentColor }
|
|
432
463
|
if props["variant"] as? String == "danger" { button.contentTintColor = .systemRed }
|
|
@@ -455,7 +486,7 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
|
|
|
455
486
|
picker.isEnabled = props["disabled"] as? Bool != true
|
|
456
487
|
}
|
|
457
488
|
if let image = control as? NSImageView {
|
|
458
|
-
if kind == "Icon" { image.image =
|
|
489
|
+
if kind == "Icon" { image.image = CraftMacSymbols.image(props["symbol"] as? String ?? props["name"] as? String ?? "questionmark", description: label); image.contentTintColor = CraftMacColor.parse(style["color"]) }
|
|
459
490
|
else { configureImage(image) }
|
|
460
491
|
}
|
|
461
492
|
if let video = control as? AVPlayerView, let source = (props["source"] as? String ?? props["src"] as? String), source != imageSource {
|
|
@@ -554,7 +585,7 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
|
|
|
554
585
|
image?.image = data.flatMap(NSImage.init(data:))
|
|
555
586
|
self.fire(image?.image == nil ? "onError" : "onLoad")
|
|
556
587
|
}
|
|
557
|
-
}; imageTask?.resume()
|
|
588
|
+
}; imageTask.map { renderer?.runtime.network.resume($0) }
|
|
558
589
|
}
|
|
559
590
|
|
|
560
591
|
/// `style` with the pointer's layers on top: `hoverStyle` while hovered, `pressStyle` while pressed.
|
|
@@ -725,8 +756,9 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
|
|
|
725
756
|
return NSEdgeInsets(top: number(style["paddingTop"]) ?? vertical, left: number(style["paddingLeft"]) ?? horizontal, bottom: number(style["paddingBottom"]) ?? vertical, right: number(style["paddingRight"]) ?? horizontal)
|
|
726
757
|
}
|
|
727
758
|
func measure(width: CGFloat, stretch: Bool = false) -> CGSize {
|
|
728
|
-
if measuredGeneration != CraftMacBox.measureGeneration || measured.count > 64 {
|
|
729
|
-
|
|
759
|
+
if measuredGeneration != CraftMacBox.measureGeneration || measured.count > 64 { forgetMeasurements(); measuredGeneration = CraftMacBox.measureGeneration }
|
|
760
|
+
// Only a box with children can stretch to the width offered; a leaf measures the same either way.
|
|
761
|
+
let key = MeasureKey(width: width, stretch: stretch && flow != nil)
|
|
730
762
|
if let size = measured[key] { return size }
|
|
731
763
|
let size = measureSubtree(width: width, stretch: stretch)
|
|
732
764
|
measured[key] = size
|
|
@@ -754,7 +786,15 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
|
|
|
754
786
|
else if kind == "Icon" { size = CGSize(width: 18, height: 18) }
|
|
755
787
|
else if kind == "Image" || kind == "Video" { size = CGSize(width: limit, height: 160) }
|
|
756
788
|
else if kind == "TextInput" { size = CGSize(width: limit, height: props["multiline"] as? Bool == true ? 48 : 28) }
|
|
757
|
-
else if let control = control as? NSControl {
|
|
789
|
+
else if let control = control as? NSControl {
|
|
790
|
+
// A control its style sizes in full is not asked at all: the width and height below replace its answer.
|
|
791
|
+
if specifiedWidth != nil, dimension(style["height"], base: 1000) != nil { size = CGSize(width: 24, height: 26) }
|
|
792
|
+
else {
|
|
793
|
+
let natural = controlSize ?? control.intrinsicContentSize
|
|
794
|
+
controlSize = natural
|
|
795
|
+
size = natural; size.width = max(24, size.width); size.height = max(26, size.height)
|
|
796
|
+
}
|
|
797
|
+
}
|
|
758
798
|
else if let flow { size = flow.fitting(width: limit, stretchChildren: false) }
|
|
759
799
|
size.width = specifiedWidth ?? (flow != nil && stretch ? limit : min(limit, size.width))
|
|
760
800
|
if let maximum = dimension(style["maxWidth"], base: width) { size.width = min(size.width, maximum) }
|
|
@@ -806,7 +846,7 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
|
|
|
806
846
|
}
|
|
807
847
|
}
|
|
808
848
|
if let child = componentRoot {
|
|
809
|
-
child.frame = bounds
|
|
849
|
+
child.frame = bounds
|
|
810
850
|
} else if let scroll = control as? NSScrollView {
|
|
811
851
|
if let flow {
|
|
812
852
|
let width = scroll.contentSize.width
|
|
@@ -948,7 +988,7 @@ final class CraftMacSidebar: NSScrollView, NSOutlineViewDataSource, NSOutlineVie
|
|
|
948
988
|
let badge = NSTextField(labelWithString: item.badge); badge.font = NSFont.systemFont(ofSize: 11); badge.textColor = .secondaryLabelColor
|
|
949
989
|
badge.translatesAutoresizingMaskIntoConstraints = false; badge.setContentHuggingPriority(.required, for: .horizontal); cell.addSubview(badge)
|
|
950
990
|
NSLayoutConstraint.activate([label.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: item.icon.isEmpty ? 16 : 39), label.trailingAnchor.constraint(lessThanOrEqualTo: badge.leadingAnchor, constant: -8), label.centerYAnchor.constraint(equalTo: cell.centerYAnchor), badge.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -16), badge.centerYAnchor.constraint(equalTo: cell.centerYAnchor)])
|
|
951
|
-
if !item.icon.isEmpty { let image = NSImageView(image:
|
|
991
|
+
if !item.icon.isEmpty { let image = NSImageView(image: CraftMacSymbols.image(item.icon) ?? NSImage()); image.contentTintColor = item.color; image.frame = CGRect(x: 16, y: (rowHeight - 16) / 2, width: 16, height: 16); cell.imageView = image; cell.addSubview(image) }
|
|
952
992
|
cell.setAccessibilityIdentifier(item.id); cell.setAccessibilityLabel(item.label)
|
|
953
993
|
return cell
|
|
954
994
|
}
|
|
@@ -965,7 +1005,116 @@ final class CraftMacRenderer {
|
|
|
965
1005
|
var boxes: [String: CraftMacBox] = [:]
|
|
966
1006
|
weak var window: NSWindow?
|
|
967
1007
|
var sheets: [String: NSPanel] = [:]
|
|
1008
|
+
/// Boxes whose nodes went away: hidden at once, and taken out of the window once the batch is drawn.
|
|
1009
|
+
/// Leaving a window walks a view's whole subtree, every control in it hearing so, and a batch that
|
|
1010
|
+
/// replaced a list's rows spent most of its time on the rows it dropped, before any new row showed.
|
|
1011
|
+
private var leaving: [CraftMacBox] = []
|
|
968
1012
|
init(runtime: CraftMacRuntime) { self.runtime = runtime }
|
|
1013
|
+
|
|
1014
|
+
private func discard(_ box: CraftMacBox) {
|
|
1015
|
+
box.imageTask?.cancel()
|
|
1016
|
+
guard box.superview != nil else { return }
|
|
1017
|
+
box.isHidden = true
|
|
1018
|
+
if leaving.isEmpty { DispatchQueue.main.async { [weak self] in self?.sweep() } }
|
|
1019
|
+
leaving.append(box)
|
|
1020
|
+
}
|
|
1021
|
+
private func sweep() {
|
|
1022
|
+
let gone = leaving; leaving.removeAll()
|
|
1023
|
+
CATransaction.begin(); CATransaction.setDisableActions(true)
|
|
1024
|
+
gone.forEach { $0.removeFromSuperview() }
|
|
1025
|
+
CATransaction.commit()
|
|
1026
|
+
}
|
|
1027
|
+
/// The box for `node`, made or reconfigured, and its children's in order; every id drawn goes into `retained`.
|
|
1028
|
+
func reconcile(_ node: [String: Any], retained: inout Set<String>) -> CraftMacBox {
|
|
1029
|
+
let box = configured(node, retained: &retained)
|
|
1030
|
+
reconcileChildren(of: box, node, retained: &retained)
|
|
1031
|
+
return box
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/// The box for `node`, made or reconfigured, without its children.
|
|
1035
|
+
private func configured(_ node: [String: Any], retained: inout Set<String>) -> CraftMacBox {
|
|
1036
|
+
let id = node["id"] as? String ?? "root", kind = node["type"] as? String ?? "View"
|
|
1037
|
+
retained.insert(id)
|
|
1038
|
+
let box = boxes[id] ?? CraftMacBox(id: id, kind: kind, renderer: self)
|
|
1039
|
+
boxes[id] = box; box.configure(node)
|
|
1040
|
+
return box
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/// A box's children in order, then theirs. Each new child joins its parent before its own children are
|
|
1044
|
+
/// made, so views join the window one at a time: AppKit takes about twice as long over a whole new
|
|
1045
|
+
/// subtree joining at once.
|
|
1046
|
+
private func reconcileChildren(of box: CraftMacBox, _ node: [String: Any], retained: inout Set<String>) {
|
|
1047
|
+
guard let flow = box.flow, box.kind != "Button" else { return }
|
|
1048
|
+
let nodes = (node["children"] as? [Any] ?? []).compactMap { $0 as? [String: Any] }
|
|
1049
|
+
let children = nodes.map { configured($0, retained: &retained) }
|
|
1050
|
+
// A child dropped here either moves to another parent in this pass (adding it there takes it from
|
|
1051
|
+
// here) or is gone, and the caller discards it: it is not taken out of the window twice.
|
|
1052
|
+
flow.boxes = children
|
|
1053
|
+
for child in children where child.superview !== flow && sheets[child.id] == nil { flow.addSubview(child) }
|
|
1054
|
+
for (child, childNode) in zip(children, nodes) { reconcileChildren(of: child, childNode, retained: &retained) }
|
|
1055
|
+
// `zIndex` draws a child above its siblings (an overlay over a web page), highest last. After the
|
|
1056
|
+
// children's own children: a component's zIndex is its root's.
|
|
1057
|
+
let raised = children.filter { (number($0.layoutStyle["zIndex"]) ?? 0) > 0 && $0.superview === flow }
|
|
1058
|
+
.sorted { (number($0.layoutStyle["zIndex"]) ?? 0) < (number($1.layoutStyle["zIndex"]) ?? 0) }
|
|
1059
|
+
if !raised.isEmpty, Array(flow.subviews.suffix(raised.count)) != raised {
|
|
1060
|
+
for child in raised { flow.addSubview(child, positioned: .above, relativeTo: nil) }
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/// A batch drawn where it happened rather than across the window: each changed node's subtree
|
|
1065
|
+
/// (or, for a node the batch created, its nearest drawn ancestor's) is reconciled again, and only
|
|
1066
|
+
/// those boxes and the boxes above them measure and lay out again. A hover on one row used to
|
|
1067
|
+
/// reconfigure every view in the window and forget every measurement. False when it cannot be
|
|
1068
|
+
/// done in place (nothing drawn yet, a sheet, a node changing kind): the caller renders the tree.
|
|
1069
|
+
func update(_ ids: [String], parentOf: (String) -> String?, node nodeFor: (String) -> [String: Any]?) -> Bool {
|
|
1070
|
+
guard !boxes.isEmpty else { return false }
|
|
1071
|
+
var targets: [String] = []
|
|
1072
|
+
let top = root.boxes.first?.id
|
|
1073
|
+
for id in ids {
|
|
1074
|
+
var current: String? = id
|
|
1075
|
+
while let candidate = current, boxes[candidate] == nil { current = parentOf(candidate) }
|
|
1076
|
+
// A node the batch removed has no parent left (one it made and removed never had a box): the
|
|
1077
|
+
// removal marked the parent it left, which is drawn instead, discarding its box. Declining here
|
|
1078
|
+
// redrew the whole window for any batch that dropped a row it had also updated.
|
|
1079
|
+
guard let target = current, target == top || parentOf(target) != nil else { continue }
|
|
1080
|
+
if !targets.contains(target) { targets.append(target) }
|
|
1081
|
+
}
|
|
1082
|
+
// A target inside another is drawn with it.
|
|
1083
|
+
let chosen = targets.filter { target in
|
|
1084
|
+
guard let box = boxes[target] else { return false }
|
|
1085
|
+
return !targets.contains { other in other != target && boxes[other].map { box.isDescendant(of: $0) } == true }
|
|
1086
|
+
}
|
|
1087
|
+
CATransaction.begin(); CATransaction.setDisableActions(true)
|
|
1088
|
+
defer { CATransaction.commit() }
|
|
1089
|
+
for target in chosen {
|
|
1090
|
+
guard let box = boxes[target], box.window === window, sheets[target] == nil, box.kind != "Modal",
|
|
1091
|
+
let node = nodeFor(target), (node["type"] as? String ?? "View") == box.kind else { return false }
|
|
1092
|
+
var before = Set<String>()
|
|
1093
|
+
collectIds(box, into: &before)
|
|
1094
|
+
var retained = Set<String>()
|
|
1095
|
+
_ = reconcile(node, retained: &retained)
|
|
1096
|
+
for id in before where !retained.contains(id) {
|
|
1097
|
+
guard let gone = boxes.removeValue(forKey: id) else { continue }
|
|
1098
|
+
discard(gone)
|
|
1099
|
+
}
|
|
1100
|
+
for id in retained { if let drawn = boxes[id] { drawn.forgetMeasurements(); drawn.needsLayout = true } }
|
|
1101
|
+
var above: NSView? = box.superview
|
|
1102
|
+
while let view = above {
|
|
1103
|
+
(view as? CraftMacBox)?.forgetMeasurements()
|
|
1104
|
+
view.needsLayout = true
|
|
1105
|
+
above = view.superview
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
root.needsLayout = true
|
|
1109
|
+
root.layoutSubtreeIfNeeded()
|
|
1110
|
+
return true
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
private func collectIds(_ box: CraftMacBox, into ids: inout Set<String>) {
|
|
1114
|
+
ids.insert(box.id)
|
|
1115
|
+
for child in box.flow?.boxes ?? [] { collectIds(child, into: &ids) }
|
|
1116
|
+
}
|
|
1117
|
+
|
|
969
1118
|
func render(_ document: [String: Any]) {
|
|
970
1119
|
CATransaction.begin(); CATransaction.setDisableActions(true)
|
|
971
1120
|
defer { CATransaction.commit() }
|
|
@@ -976,34 +1125,17 @@ final class CraftMacRenderer {
|
|
|
976
1125
|
let bottom = bounds.maxY >= (scroll.documentView?.bounds.height ?? 0) - 8
|
|
977
1126
|
scrolls.append((box, bounds.origin, bottom))
|
|
978
1127
|
} }
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
if let flow = box.flow, kind != "Button" {
|
|
985
|
-
let children = (node["children"] as? [Any] ?? []).compactMap { $0 as? [String: Any] }.map(reconcile)
|
|
986
|
-
for child in flow.boxes where !children.contains(where: { $0 === child }) { child.removeFromSuperview() }
|
|
987
|
-
flow.boxes = children
|
|
988
|
-
for child in children where child.superview !== flow && sheets[child.id] == nil { flow.addSubview(child) }
|
|
989
|
-
// `zIndex` draws a child above its siblings (an overlay over a web page), highest last.
|
|
990
|
-
let raised = children.filter { (number($0.layoutStyle["zIndex"]) ?? 0) > 0 && $0.superview === flow }
|
|
991
|
-
.sorted { (number($0.layoutStyle["zIndex"]) ?? 0) < (number($1.layoutStyle["zIndex"]) ?? 0) }
|
|
992
|
-
if !raised.isEmpty, Array(flow.subviews.suffix(raised.count)) != raised {
|
|
993
|
-
for child in raised { flow.addSubview(child, positioned: .above, relativeTo: nil) }
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
return box
|
|
997
|
-
}
|
|
998
|
-
let box = reconcile(document)
|
|
1128
|
+
// The top box joins the window before its children are made (see reconcileChildren).
|
|
1129
|
+
let box = configured(document, retained: &retained)
|
|
1130
|
+
root.style = ["flexDirection": "column"]
|
|
1131
|
+
root.boxes = [box]; if box.superview !== root { root.addSubview(box) }
|
|
1132
|
+
reconcileChildren(of: box, document, retained: &retained)
|
|
999
1133
|
// Boxes were reconfigured and children replaced: nothing measured before now holds.
|
|
1000
1134
|
CraftMacBox.measureGeneration &+= 1
|
|
1001
1135
|
for (id, box) in boxes where !retained.contains(id) {
|
|
1002
|
-
|
|
1136
|
+
discard(box); boxes.removeValue(forKey: id)
|
|
1003
1137
|
if let sheet = sheets.removeValue(forKey: id) { window?.endSheet(sheet); sheet.close() }
|
|
1004
1138
|
}
|
|
1005
|
-
root.style = ["flexDirection": "column"]
|
|
1006
|
-
root.boxes = [box]; if box.superview !== root { root.addSubview(box) }
|
|
1007
1139
|
box.frame = root.bounds; box.layoutSubtreeIfNeeded(); root.layoutBoxes(in: root.bounds.size)
|
|
1008
1140
|
for modal in boxes.values where modal.kind == "Modal" {
|
|
1009
1141
|
guard let window else { continue }
|