craft-native 0.0.424 → 0.0.436
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/dist/android/templates/CraftNativeCapabilities.kt.template +15 -3
- package/dist/android/templates/CraftNativeSounds.kt.template +3 -2
- package/dist/android/templates/MainActivityNative.kt.template +61 -12
- package/dist/browser.js +9 -2
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +2 -0
- package/dist/ios/src/index.js +2 -1
- package/dist/ios/templates/CraftApp.swift +63 -0
- package/dist/ios/templates/CraftNativeMutation.swift +18 -3
- package/dist/ios/templates/CraftNativeScreen.swift +82 -39
- package/dist/ios/templates/CraftVoiceRecording.swift +62 -5
- package/dist/ios/templates/testing/CraftTimedInput.m +90 -1
- package/dist/ios/templates/testing/DriverTests.swift.template +73 -14
- package/dist/macos/src/index.js +2 -2
- package/dist/macos/src/toolchain.d.ts +2 -2
- package/dist/macos/templates/CraftMacApplication.swift +23 -5
- package/dist/macos/templates/CraftMacRenderer.swift +85 -22
- package/dist/macos/templates/CraftMacRuntime.swift +34 -2
- package/dist/macos/templates/CraftNativeMutation.swift +18 -3
- package/package.json +1 -1
|
@@ -42,7 +42,7 @@ import Darwin
|
|
|
42
42
|
/// tabbar shown|hidden the native tab bar (Today, Calendar, …) can be tapped, or not
|
|
43
43
|
/// selected <tab> the tab bar's selected button is this one
|
|
44
44
|
/// tab <name> tap a tab bar button
|
|
45
|
-
/// scrollto <text>
|
|
45
|
+
/// scrollto <text> at most 12 drags until the target is hittable and visible
|
|
46
46
|
/// swipe up|down|left|right
|
|
47
47
|
/// drag x1 y1 x2 y2 slow app-relative drag, held 0.3s before release
|
|
48
48
|
/// audit <name> accessibility audit (element descriptions, Dynamic Type, contrast); logs each issue
|
|
@@ -143,13 +143,29 @@ final class DriverTests: XCTestCase {
|
|
|
143
143
|
return any.exists ? any : nil
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/// Where a label is looked for, in order: the topmost presented alert,
|
|
147
|
+
/// then the topmost sheet, then the whole app. A sheet's "Sign out" and
|
|
148
|
+
/// the row that opened it share a label, and the row behind the sheet
|
|
149
|
+
/// is still reported hittable, so the presented one has to win.
|
|
150
|
+
func searchScopes<Scope>(alerts: [Scope], sheets: [Scope], app: Scope) -> [Scope] {
|
|
151
|
+
[alerts.last, sheets.last].compactMap { $0 } + [app]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func presentedScopes() -> [XCUIElement] {
|
|
155
|
+
let alerts = app.alerts.allElementsBoundByIndex.filter { $0.exists }
|
|
156
|
+
let sheets = app.sheets.allElementsBoundByIndex.filter { $0.exists }
|
|
157
|
+
return searchScopes(alerts: alerts, sheets: sheets, app: app as XCUIElement)
|
|
158
|
+
}
|
|
159
|
+
|
|
146
160
|
func exact(_ label: String) -> XCUIElement? {
|
|
147
161
|
let predicate = NSPredicate(format: "label == %@", label)
|
|
148
|
-
for
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
162
|
+
for scope in presentedScopes() {
|
|
163
|
+
for query in [scope.buttons, scope.links, scope.staticTexts] {
|
|
164
|
+
let matches = query.matching(predicate)
|
|
165
|
+
for index in 0..<min(matches.count, 25) {
|
|
166
|
+
let candidate = matches.element(boundBy: index)
|
|
167
|
+
if candidate.exists && candidate.isHittable { return candidate }
|
|
168
|
+
}
|
|
153
169
|
}
|
|
154
170
|
}
|
|
155
171
|
return nil
|
|
@@ -164,6 +180,27 @@ final class DriverTests: XCTestCase {
|
|
|
164
180
|
return nil
|
|
165
181
|
}
|
|
166
182
|
|
|
183
|
+
/// A short target fits entirely; an oversized target fills the viewport.
|
|
184
|
+
func visibleScrollFrame(_ target: CGRect, in viewport: CGRect) -> Bool {
|
|
185
|
+
let visible = target.intersection(viewport)
|
|
186
|
+
return !target.isEmpty && !viewport.isEmpty && !visible.isNull
|
|
187
|
+
&& visible.width >= min(target.width, viewport.width)
|
|
188
|
+
&& visible.height >= min(target.height, viewport.height)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
func scrollTargetVisible(_ target: XCUIElement?) -> Bool {
|
|
192
|
+
guard let target, target.exists, target.isHittable,
|
|
193
|
+
visibleScrollFrame(target.frame, in: app.frame) else { return false }
|
|
194
|
+
// Hittable alone accepts controls clipped by the scroll view's edge.
|
|
195
|
+
// Only an ancestor scroll view clips this target, not its siblings.
|
|
196
|
+
let identity = NSPredicate(format: "identifier == %@ AND label == %@", target.identifier, target.label)
|
|
197
|
+
for scroll in app.scrollViews.allElementsBoundByIndex {
|
|
198
|
+
if scroll.descendants(matching: .any).matching(identity).firstMatch.exists,
|
|
199
|
+
!visibleScrollFrame(target.frame, in: scroll.frame.intersection(app.frame)) { return false }
|
|
200
|
+
}
|
|
201
|
+
return true
|
|
202
|
+
}
|
|
203
|
+
|
|
167
204
|
/// About a third of a screen of scrolling, as a held drag.
|
|
168
205
|
///
|
|
169
206
|
/// `app.swipeUp()` is a flick, and how far a flick throws a scroll view
|
|
@@ -248,8 +285,15 @@ final class DriverTests: XCTestCase {
|
|
|
248
285
|
return (parts[0], parts.count > 1 ? Double(parts[1]) ?? 10 : 10)
|
|
249
286
|
}
|
|
250
287
|
|
|
251
|
-
/// A
|
|
252
|
-
///
|
|
288
|
+
/// A switch reports "0" or "1". Any other value is not a switch state to
|
|
289
|
+
/// wait on, so the tap stands as it would for any other control.
|
|
290
|
+
func switchFlipped(from before: String?, to after: String?) -> Bool {
|
|
291
|
+
let states: Set<String> = ["0", "1"]
|
|
292
|
+
guard let before, let after, states.contains(before), states.contains(after) else { return true }
|
|
293
|
+
return before != after
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/// Deliver one gesture; the command's observation determines success.
|
|
253
297
|
func press(_ element: XCUIElement) {
|
|
254
298
|
if element.isHittable { element.tap() }
|
|
255
299
|
else { element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() }
|
|
@@ -401,8 +445,21 @@ final class DriverTests: XCTestCase {
|
|
|
401
445
|
else { XCTFail("Element not found"); note("FAIL tapexact \(arg): NOT FOUND") }
|
|
402
446
|
case "tapid":
|
|
403
447
|
let target = identified(arg)
|
|
404
|
-
if target.waitForExistence(timeout: 10) {
|
|
405
|
-
else
|
|
448
|
+
if !target.waitForExistence(timeout: 10) { XCTFail("Element not found"); note("FAIL tapid \(arg): NOT FOUND") }
|
|
449
|
+
else if target.elementType == .switch {
|
|
450
|
+
// Observe one physical gesture. Retapping could conceal a
|
|
451
|
+
// renderer or hit-testing failure (HQ agreement job 7570).
|
|
452
|
+
let before = target.value as? String
|
|
453
|
+
press(target)
|
|
454
|
+
let settle = Date().addingTimeInterval(1.5)
|
|
455
|
+
var flipped = false
|
|
456
|
+
repeat {
|
|
457
|
+
flipped = switchFlipped(from: before, to: target.value as? String)
|
|
458
|
+
if !flipped { Thread.sleep(forTimeInterval: 0.25) }
|
|
459
|
+
} while !flipped && Date() < settle
|
|
460
|
+
check(flipped, "tapid \(arg) flips the switch")
|
|
461
|
+
}
|
|
462
|
+
else { press(target); note("tapid \(arg): ok") }
|
|
406
463
|
case "longpressid":
|
|
407
464
|
let target = identified(arg)
|
|
408
465
|
guard target.waitForExistence(timeout: 10), target.isHittable else {
|
|
@@ -590,13 +647,13 @@ final class DriverTests: XCTestCase {
|
|
|
590
647
|
case "scrollto":
|
|
591
648
|
var found = element(containing: arg)
|
|
592
649
|
var drags = 0
|
|
593
|
-
while
|
|
650
|
+
while !scrollTargetVisible(found) && drags < 12 {
|
|
594
651
|
let towardTop = found.map { !$0.frame.isEmpty && $0.frame.midY < app.frame.midY } ?? false
|
|
595
652
|
scrollPage(towardTop: towardTop)
|
|
596
653
|
drags += 1
|
|
597
654
|
found = element(containing: arg)
|
|
598
655
|
}
|
|
599
|
-
check(found
|
|
656
|
+
check(scrollTargetVisible(found), "scrollto \(arg)")
|
|
600
657
|
case "swipe":
|
|
601
658
|
switch arg {
|
|
602
659
|
case "up": app.swipeUp()
|
|
@@ -644,7 +701,9 @@ final class DriverTests: XCTestCase {
|
|
|
644
701
|
// A system prompt over the app ("Save Password?" → Not Now), wherever iOS shows it.
|
|
645
702
|
let label = arg.isEmpty ? "Not Now" : arg
|
|
646
703
|
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
|
647
|
-
|
|
704
|
+
// The app's own alert or sheet first (its button can share a
|
|
705
|
+
// label with the control behind it), then the app, then iOS.
|
|
706
|
+
let candidates = { self.presentedScopes().map { $0.buttons[label] } + [springboard.buttons[label]] }
|
|
648
707
|
let end = Date().addingTimeInterval(6)
|
|
649
708
|
// A tap is not a dismissal. iOS 26 draws "Save Password?" as a
|
|
650
709
|
// sheet that is in the tree, and hittable, while it still slides
|
|
@@ -655,7 +714,7 @@ final class DriverTests: XCTestCase {
|
|
|
655
714
|
var tapped = false
|
|
656
715
|
var gone = false
|
|
657
716
|
while !gone && Date() < end {
|
|
658
|
-
guard let button = candidates.first(where: { $0.exists && $0.isHittable }) else {
|
|
717
|
+
guard let button = candidates().first(where: { $0.exists && $0.isHittable }) else {
|
|
659
718
|
if tapped { gone = true } else { Thread.sleep(forTimeInterval: 0.5) }
|
|
660
719
|
continue
|
|
661
720
|
}
|
package/dist/macos/src/index.js
CHANGED
|
@@ -43,9 +43,9 @@ import process from "process";
|
|
|
43
43
|
var macosToolchain = {
|
|
44
44
|
zig: "0.18.0-dev.252+86dc5ce25",
|
|
45
45
|
sources: {
|
|
46
|
-
"zig-js": { repository: "zig-utils/zig-js", revision: "
|
|
46
|
+
"zig-js": { repository: "zig-utils/zig-js", revision: "44a0a2ba814a911ac701c44073bd8d7d8ff6a22d", paths: ["src", "include", "tests", "tools", "docs", "bench", "conformance"] },
|
|
47
47
|
"zig-regex": { repository: "zig-utils/zig-regex", revision: "fdd9c2e333ea6ac2fd06d080d939ef7f11d61b1b", paths: ["src"] },
|
|
48
|
-
"zig-gc": { repository: "zig-utils/zig-gc", revision: "
|
|
48
|
+
"zig-gc": { repository: "zig-utils/zig-gc", revision: "73c802e05768a729614e9c4296abb118aa06faf9", paths: ["src"] }
|
|
49
49
|
}
|
|
50
50
|
};
|
|
51
51
|
var defaultRun = async (args, cwd) => {
|
|
@@ -9,7 +9,7 @@ export declare const macosToolchain: {
|
|
|
9
9
|
readonly sources: {
|
|
10
10
|
readonly 'zig-js': {
|
|
11
11
|
readonly repository: 'zig-utils/zig-js';
|
|
12
|
-
readonly revision: '
|
|
12
|
+
readonly revision: '44a0a2ba814a911ac701c44073bd8d7d8ff6a22d';
|
|
13
13
|
readonly paths: readonly ['src', 'include', 'tests', 'tools', 'docs', 'bench', 'conformance'];
|
|
14
14
|
};
|
|
15
15
|
readonly 'zig-regex': {
|
|
@@ -19,7 +19,7 @@ export declare const macosToolchain: {
|
|
|
19
19
|
};
|
|
20
20
|
readonly 'zig-gc': {
|
|
21
21
|
readonly repository: 'zig-utils/zig-gc';
|
|
22
|
-
readonly revision: '
|
|
22
|
+
readonly revision: '73c802e05768a729614e9c4296abb118aa06faf9';
|
|
23
23
|
readonly paths: readonly ['src'];
|
|
24
24
|
};
|
|
25
25
|
};
|
|
@@ -21,6 +21,8 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
|
|
|
21
21
|
let trafficLights: CGPoint?
|
|
22
22
|
/// The screen has work to finish before the app quits (`craft.desktop.onQuit`).
|
|
23
23
|
var holdsQuit = false
|
|
24
|
+
/// The content width the screen last heard, so a resize that changes nothing sends nothing.
|
|
25
|
+
var reportedViewportWidth: CGFloat = -1
|
|
24
26
|
init(name: String, route: String, options: [String: Any], application: CraftMacApplication) {
|
|
25
27
|
self.name = name
|
|
26
28
|
runtime = CraftMacRuntime(route: route, params: options["params"] as? [String: Any] ?? [:], network: application.network)
|
|
@@ -49,9 +51,22 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
|
|
|
49
51
|
if name == "main" && application.testDirectory == nil { window.setFrameAutosaveName("\(application.appName)NativeMain") }
|
|
50
52
|
window.makeKeyAndOrderFront(nil)
|
|
51
53
|
placeTrafficLights()
|
|
54
|
+
// The screen starts knowing its width, so `md:`/`lg:` classes and `matchMedia` are right on the first frame.
|
|
55
|
+
let size = window.contentLayoutRect.size
|
|
56
|
+
reportedViewportWidth = size.width
|
|
57
|
+
runtime.context.evaluateScript("__stxNativeBridge.viewportWidth = \(Double(size.width)); __stxNativeBridge.viewportHeight = \(Double(size.height));")
|
|
52
58
|
runtime.load(application.bundle)
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
/// Tells the screen its new width (VIEWPORT, as a phone does on rotation): only when the width changed,
|
|
62
|
+
/// since a live resize lays out every frame.
|
|
63
|
+
func reportViewport() {
|
|
64
|
+
let size = window.contentLayoutRect.size
|
|
65
|
+
guard size.width > 0, abs(size.width - reportedViewportWidth) >= 0.5 else { return }
|
|
66
|
+
reportedViewportWidth = size.width
|
|
67
|
+
runtime.send("VIEWPORT", ["width": Double(size.width), "height": Double(size.height)])
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
/// AppKit lays the title bar out again on resize, full screen and appearance changes, so this runs after each.
|
|
56
71
|
func placeTrafficLights() {
|
|
57
72
|
guard let inset = trafficLights, let close = window.standardWindowButton(.closeButton), let titlebar = close.superview?.superview else { return }
|
|
@@ -64,8 +79,8 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
|
|
|
64
79
|
window.standardWindowButton(type)?.setFrameOrigin(CGPoint(x: inset.x + CGFloat(index) * (size.width + 6), y: (height - size.height) / 2))
|
|
65
80
|
}
|
|
66
81
|
}
|
|
67
|
-
func windowDidResize(_ notification: Notification) { placeTrafficLights() }
|
|
68
|
-
func windowDidExitFullScreen(_ notification: Notification) { placeTrafficLights() }
|
|
82
|
+
func windowDidResize(_ notification: Notification) { placeTrafficLights(); reportViewport() }
|
|
83
|
+
func windowDidExitFullScreen(_ notification: Notification) { placeTrafficLights(); reportViewport() }
|
|
69
84
|
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
|
70
85
|
if name == "main" { sender.orderOut(nil); return false }
|
|
71
86
|
return true
|
|
@@ -213,6 +228,9 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
213
228
|
defer { CATransaction.commit() }
|
|
214
229
|
let scheme = CraftMacColor.isDark ? "dark" : "light"
|
|
215
230
|
for scene in scenes.values {
|
|
231
|
+
// AppKit lays the title bar out again for the new appearance, after this returns.
|
|
232
|
+
scene.placeTrafficLights()
|
|
233
|
+
DispatchQueue.main.async { [weak scene] in scene?.placeTrafficLights() }
|
|
216
234
|
scene.runtime.context.evaluateScript("__stxNativeBridge.colorScheme = '\(scheme)';")
|
|
217
235
|
// The protocol's own message: the screen restyles its `dark:` classes and tells `craft.appearance.onChange`.
|
|
218
236
|
scene.runtime.send("APPEARANCE", ["colorScheme": scheme])
|
|
@@ -455,7 +473,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
455
473
|
let box = scene.renderer.boxes.values.first(where: { ($0.props["testID"] as? String ?? $0.props["testId"] as? String ?? $0.id) == target }) else { return ["ok": false, "error": "No control \(action["target"] ?? "")"] }
|
|
456
474
|
let text = action["text"] as? String ?? ""
|
|
457
475
|
switch kind {
|
|
458
|
-
case "press": if let button = box.control as? NSButton { button.performClick(nil) } else { box.fire("onPress") }
|
|
476
|
+
case "press": if let button = box.control as? NSButton { button.performClick(nil) } else if let toggle = box.control as? NSSwitch { toggle.performClick(nil) } else { box.fire("onPress") }
|
|
459
477
|
case "type":
|
|
460
478
|
if let field = box.control as? NSTextField { field.stringValue = text }
|
|
461
479
|
else if let view = (box.control as? NSScrollView)?.documentView as? NSTextView { view.string = text }
|
|
@@ -500,7 +518,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
500
518
|
let editor = (target as? NSTextField)?.currentEditor() as? NSTextView ?? target as? NSTextView
|
|
501
519
|
guard let editor else { return ["ok": false, "error": "Not a text input"] }
|
|
502
520
|
editor.doCommand(by: command)
|
|
503
|
-
case "click": if let button = box.control as? NSButton { button.performClick(nil) } else { box.pressed() }
|
|
521
|
+
case "click": if let button = box.control as? NSButton { button.performClick(nil) } else if let toggle = box.control as? NSSwitch { toggle.performClick(nil) } else { box.pressed() }
|
|
504
522
|
case "fill":
|
|
505
523
|
let value = action["value"] as? String ?? ""
|
|
506
524
|
if let field = box.control as? NSTextField { field.stringValue = value; box.input(value) }
|
|
@@ -536,7 +554,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
|
|
|
536
554
|
return node
|
|
537
555
|
}
|
|
538
556
|
func classes(_ view: NSView) -> [String] { [String(describing: type(of: view))] + view.subviews.flatMap(classes) }
|
|
539
|
-
return ["name": scene.name, "title": scene.window.title, "route": scene.runtime.route, "contextClass": String(describing: type(of: scene.runtime.context)), "nodes": nodes, "viewClasses": classes(scene.renderer.root), "errors": scene.runtime.errors]
|
|
557
|
+
return ["name": scene.name, "title": scene.window.title, "route": scene.runtime.route, "contextClass": String(describing: type(of: scene.runtime.context)), "nodes": nodes, "viewClasses": classes(scene.renderer.root), "viewportWidth": Double(scene.reportedViewportWidth), "errors": scene.runtime.errors]
|
|
540
558
|
}
|
|
541
559
|
let webViews = windows.reduce(0) { count, window in count + ((window["viewClasses"] as? [String] ?? []).filter { $0.contains("WKWebView") || $0 == "WebView" }.count) }
|
|
542
560
|
return ["renderer": "appkit", "scriptEngine": "zig-js", "appearance": CraftMacColor.isDark ? "dark" : "light", "windows": windows, "webViews": webViews, "menu": testMenu]
|
|
@@ -288,6 +288,8 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
|
|
|
288
288
|
static var measuredCount = 0
|
|
289
289
|
private struct MeasureKey: Hashable { let width: CGFloat, stretch: Bool }
|
|
290
290
|
private var measured: [MeasureKey: CGSize] = [:]
|
|
291
|
+
/// What this box measured is stale: it or something inside it changed (CraftMacRenderer.update).
|
|
292
|
+
func forgetMeasurements() { measured.removeAll(keepingCapacity: true) }
|
|
291
293
|
private var measuredGeneration = -1
|
|
292
294
|
var componentRoot: CraftMacBox? { props["data-stx-component-wrapper"] != nil && flow?.boxes.count == 1 && flow?.boxes.first?.kind != "Modal" ? flow?.boxes.first : nil }
|
|
293
295
|
var layoutStyle: [String: Any] {
|
|
@@ -318,7 +320,10 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
|
|
|
318
320
|
case "Picker":
|
|
319
321
|
let picker = NSPopUpButton(frame: .zero, pullsDown: false)
|
|
320
322
|
picker.target = self; picker.action = #selector(changed); control = picker
|
|
321
|
-
case "Switch"
|
|
323
|
+
case "Switch":
|
|
324
|
+
// A toggle, as System Settings draws one; a checkbox is the Checkbox primitive's.
|
|
325
|
+
let toggle = NSSwitch(); toggle.target = self; toggle.action = #selector(changed); control = toggle
|
|
326
|
+
case "Checkbox":
|
|
322
327
|
let button = NSButton(checkboxWithTitle: "", target: self, action: #selector(changed)); control = button
|
|
323
328
|
case "Icon", "Image":
|
|
324
329
|
let image = NSImageView(); image.imageScaling = .scaleProportionallyUpOrDown; control = image
|
|
@@ -407,6 +412,11 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
|
|
|
407
412
|
text.maximumNumberOfLines = (props["numberOfLines"] as? NSNumber)?.intValue ?? 0
|
|
408
413
|
text.lineBreakMode = text.maximumNumberOfLines == 1 ? .byTruncatingTail : .byWordWrapping
|
|
409
414
|
}
|
|
415
|
+
if let toggle = control as? NSSwitch {
|
|
416
|
+
toggle.state = (props["value"] as? Bool ?? props["checked"] as? Bool ?? false) ? .on : .off
|
|
417
|
+
toggle.isEnabled = props["disabled"] as? Bool != true
|
|
418
|
+
if let label { toggle.setAccessibilityLabel(label) }
|
|
419
|
+
}
|
|
410
420
|
if let button = control as? NSButton {
|
|
411
421
|
let title = props["title"] as? String ?? nativeText(node)
|
|
412
422
|
button.title = props["iconOnly"] as? Bool == true ? "" : title
|
|
@@ -811,7 +821,8 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
|
|
|
811
821
|
func fire(_ name: String, _ payload: [String: Any] = [:]) { renderer?.runtime.event(events[name], payload: payload) }
|
|
812
822
|
@objc func pressed() { fire("onPress") }
|
|
813
823
|
@objc func changed() {
|
|
814
|
-
let
|
|
824
|
+
let state: NSControl.StateValue? = (control as? NSSwitch)?.state ?? (control as? NSButton)?.state
|
|
825
|
+
let value: Any = (control as? NSPopUpButton)?.selectedItem?.representedObject ?? (state == .on)
|
|
815
826
|
fire("onValueChange", ["value": value, "nativeEvent": ["value": value]])
|
|
816
827
|
}
|
|
817
828
|
func controlTextDidChange(_ notification: Notification) { if let field = notification.object as? NSTextField { input(field.stringValue) } }
|
|
@@ -957,6 +968,77 @@ final class CraftMacRenderer {
|
|
|
957
968
|
weak var window: NSWindow?
|
|
958
969
|
var sheets: [String: NSPanel] = [:]
|
|
959
970
|
init(runtime: CraftMacRuntime) { self.runtime = runtime }
|
|
971
|
+
/// The box for `node`, made or reconfigured, and its children's in order; every id drawn goes into `retained`.
|
|
972
|
+
func reconcile(_ node: [String: Any], retained: inout Set<String>) -> CraftMacBox {
|
|
973
|
+
let id = node["id"] as? String ?? "root", kind = node["type"] as? String ?? "View"
|
|
974
|
+
retained.insert(id)
|
|
975
|
+
let box = boxes[id] ?? CraftMacBox(id: id, kind: kind, renderer: self)
|
|
976
|
+
boxes[id] = box; box.configure(node)
|
|
977
|
+
if let flow = box.flow, kind != "Button" {
|
|
978
|
+
let children = (node["children"] as? [Any] ?? []).compactMap { $0 as? [String: Any] }.map { reconcile($0, retained: &retained) }
|
|
979
|
+
for child in flow.boxes where !children.contains(where: { $0 === child }) { child.removeFromSuperview() }
|
|
980
|
+
flow.boxes = children
|
|
981
|
+
for child in children where child.superview !== flow && sheets[child.id] == nil { flow.addSubview(child) }
|
|
982
|
+
// `zIndex` draws a child above its siblings (an overlay over a web page), highest last.
|
|
983
|
+
let raised = children.filter { (number($0.layoutStyle["zIndex"]) ?? 0) > 0 && $0.superview === flow }
|
|
984
|
+
.sorted { (number($0.layoutStyle["zIndex"]) ?? 0) < (number($1.layoutStyle["zIndex"]) ?? 0) }
|
|
985
|
+
if !raised.isEmpty, Array(flow.subviews.suffix(raised.count)) != raised {
|
|
986
|
+
for child in raised { flow.addSubview(child, positioned: .above, relativeTo: nil) }
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
return box
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/// A batch drawn where it happened rather than across the window: each changed node's subtree
|
|
993
|
+
/// (or, for a node the batch created, its nearest drawn ancestor's) is reconciled again, and only
|
|
994
|
+
/// those boxes and the boxes above them measure and lay out again. A hover on one row used to
|
|
995
|
+
/// reconfigure every view in the window and forget every measurement. False when it cannot be
|
|
996
|
+
/// done in place (nothing drawn yet, a sheet, a node changing kind): the caller renders the tree.
|
|
997
|
+
func update(_ ids: [String], parentOf: (String) -> String?, node nodeFor: (String) -> [String: Any]?) -> Bool {
|
|
998
|
+
guard !boxes.isEmpty else { return false }
|
|
999
|
+
var targets: [String] = []
|
|
1000
|
+
for id in ids {
|
|
1001
|
+
var current: String? = id
|
|
1002
|
+
while let candidate = current, boxes[candidate] == nil { current = parentOf(candidate) }
|
|
1003
|
+
guard let target = current else { return false }
|
|
1004
|
+
if !targets.contains(target) { targets.append(target) }
|
|
1005
|
+
}
|
|
1006
|
+
// A target inside another is drawn with it.
|
|
1007
|
+
let chosen = targets.filter { target in
|
|
1008
|
+
guard let box = boxes[target] else { return false }
|
|
1009
|
+
return !targets.contains { other in other != target && boxes[other].map { box.isDescendant(of: $0) } == true }
|
|
1010
|
+
}
|
|
1011
|
+
CATransaction.begin(); CATransaction.setDisableActions(true)
|
|
1012
|
+
defer { CATransaction.commit() }
|
|
1013
|
+
for target in chosen {
|
|
1014
|
+
guard let box = boxes[target], box.window === window, sheets[target] == nil, box.kind != "Modal",
|
|
1015
|
+
let node = nodeFor(target), (node["type"] as? String ?? "View") == box.kind else { return false }
|
|
1016
|
+
var before = Set<String>()
|
|
1017
|
+
collectIds(box, into: &before)
|
|
1018
|
+
var retained = Set<String>()
|
|
1019
|
+
_ = reconcile(node, retained: &retained)
|
|
1020
|
+
for id in before where !retained.contains(id) {
|
|
1021
|
+
guard let gone = boxes.removeValue(forKey: id) else { continue }
|
|
1022
|
+
gone.imageTask?.cancel(); gone.removeFromSuperview()
|
|
1023
|
+
}
|
|
1024
|
+
for id in retained { if let drawn = boxes[id] { drawn.forgetMeasurements(); drawn.needsLayout = true } }
|
|
1025
|
+
var above: NSView? = box.superview
|
|
1026
|
+
while let view = above {
|
|
1027
|
+
(view as? CraftMacBox)?.forgetMeasurements()
|
|
1028
|
+
view.needsLayout = true
|
|
1029
|
+
above = view.superview
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
root.needsLayout = true
|
|
1033
|
+
root.layoutSubtreeIfNeeded()
|
|
1034
|
+
return true
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
private func collectIds(_ box: CraftMacBox, into ids: inout Set<String>) {
|
|
1038
|
+
ids.insert(box.id)
|
|
1039
|
+
for child in box.flow?.boxes ?? [] { collectIds(child, into: &ids) }
|
|
1040
|
+
}
|
|
1041
|
+
|
|
960
1042
|
func render(_ document: [String: Any]) {
|
|
961
1043
|
CATransaction.begin(); CATransaction.setDisableActions(true)
|
|
962
1044
|
defer { CATransaction.commit() }
|
|
@@ -967,26 +1049,7 @@ final class CraftMacRenderer {
|
|
|
967
1049
|
let bottom = bounds.maxY >= (scroll.documentView?.bounds.height ?? 0) - 8
|
|
968
1050
|
scrolls.append((box, bounds.origin, bottom))
|
|
969
1051
|
} }
|
|
970
|
-
|
|
971
|
-
let id = node["id"] as? String ?? "root", kind = node["type"] as? String ?? "View"
|
|
972
|
-
retained.insert(id)
|
|
973
|
-
let box = boxes[id] ?? CraftMacBox(id: id, kind: kind, renderer: self)
|
|
974
|
-
boxes[id] = box; box.configure(node)
|
|
975
|
-
if let flow = box.flow, kind != "Button" {
|
|
976
|
-
let children = (node["children"] as? [Any] ?? []).compactMap { $0 as? [String: Any] }.map(reconcile)
|
|
977
|
-
for child in flow.boxes where !children.contains(where: { $0 === child }) { child.removeFromSuperview() }
|
|
978
|
-
flow.boxes = children
|
|
979
|
-
for child in children where child.superview !== flow && sheets[child.id] == nil { flow.addSubview(child) }
|
|
980
|
-
// `zIndex` draws a child above its siblings (an overlay over a web page), highest last.
|
|
981
|
-
let raised = children.filter { (number($0.layoutStyle["zIndex"]) ?? 0) > 0 && $0.superview === flow }
|
|
982
|
-
.sorted { (number($0.layoutStyle["zIndex"]) ?? 0) < (number($1.layoutStyle["zIndex"]) ?? 0) }
|
|
983
|
-
if !raised.isEmpty, Array(flow.subviews.suffix(raised.count)) != raised {
|
|
984
|
-
for child in raised { flow.addSubview(child, positioned: .above, relativeTo: nil) }
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
return box
|
|
988
|
-
}
|
|
989
|
-
let box = reconcile(document)
|
|
1052
|
+
let box = reconcile(document, retained: &retained)
|
|
990
1053
|
// Boxes were reconfigured and children replaced: nothing measured before now holds.
|
|
991
1054
|
CraftMacBox.measureGeneration &+= 1
|
|
992
1055
|
for (id, box) in boxes where !retained.contains(id) {
|
|
@@ -173,7 +173,37 @@ final class CraftMacRuntime {
|
|
|
173
173
|
""")
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
func load(_ bundle: String) {
|
|
176
|
+
func load(_ bundle: String) {
|
|
177
|
+
startProfileIfAsked()
|
|
178
|
+
context.evaluateScript(bundle); ZJSContextRunMicrotasks(context)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/// `CRAFT_JS_PROFILE=<file>`: sample this screen's JavaScript from the moment its bundle loads,
|
|
182
|
+
/// for `CRAFT_JS_PROFILE_MS` milliseconds (5000, every `CRAFT_JS_PROFILE_INTERVAL_US`, 1000),
|
|
183
|
+
/// and write it as Chrome's `.cpuprofile` (DevTools and speedscope open it). A window other
|
|
184
|
+
/// than Main writes beside it, its route in the name.
|
|
185
|
+
private func startProfileIfAsked() {
|
|
186
|
+
let env = ProcessInfo.processInfo.environment
|
|
187
|
+
guard let path = env["CRAFT_JS_PROFILE"], !path.isEmpty, let ref = context.jsGlobalContextRef else { return }
|
|
188
|
+
guard ZJSContextStartCPUProfile(ref, UInt32(env["CRAFT_JS_PROFILE_INTERVAL_US"] ?? "") ?? 0) else { return }
|
|
189
|
+
let seconds = (Double(env["CRAFT_JS_PROFILE_MS"] ?? "") ?? 5000) / 1000
|
|
190
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { [weak self] in self?.writeProfile(to: path) }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private func writeProfile(to path: String) {
|
|
194
|
+
guard !disposed, let ref = context.jsGlobalContextRef, let json = ZJSContextStopCPUProfile(ref) else { return }
|
|
195
|
+
defer { JSStringRelease(json) }
|
|
196
|
+
let size = JSStringGetMaximumUTF8CStringSize(json)
|
|
197
|
+
var bytes = [CChar](repeating: 0, count: size)
|
|
198
|
+
JSStringGetUTF8CString(json, &bytes, size)
|
|
199
|
+
let url = URL(fileURLWithPath: path)
|
|
200
|
+
let target = route == "Main" ? url : url.deletingPathExtension().appendingPathExtension("\(route).cpuprofile")
|
|
201
|
+
do {
|
|
202
|
+
try String(cString: bytes).write(to: target, atomically: true, encoding: .utf8)
|
|
203
|
+
fputs("[craft] JavaScript profile written to \(target.path)\n", stderr)
|
|
204
|
+
}
|
|
205
|
+
catch { fputs("[craft] could not write the JavaScript profile to \(target.path): \(error)\n", stderr) }
|
|
206
|
+
}
|
|
177
207
|
|
|
178
208
|
func dispose() {
|
|
179
209
|
guard !disposed else { return }
|
|
@@ -215,7 +245,9 @@ final class CraftMacRuntime {
|
|
|
215
245
|
case "MUTATE":
|
|
216
246
|
do {
|
|
217
247
|
let result = try document.apply(payload)
|
|
218
|
-
|
|
248
|
+
// Drawn where it changed; the whole tree only when the batch reshaped it.
|
|
249
|
+
if result.requiresFullRender || scene?.renderer.update(result.affectedNodeIds, parentOf: document.parentId(of:), node: document.node) != true,
|
|
250
|
+
let tree = result.document { scene?.renderer.render(tree) }
|
|
219
251
|
send("MUTATION_ACK", ["version": 1, "batchId": result.batchId, "revision": result.revision])
|
|
220
252
|
} catch let error as CraftNativeMutationFailure {
|
|
221
253
|
send("MUTATION_ERROR", ["version": 1, "batchId": payload["batchId"] ?? "", "code": error.code, "message": error.message])
|
|
@@ -18,10 +18,22 @@ struct CraftNativeMutationFailure: Error, Equatable {
|
|
|
18
18
|
struct CraftNativeMutationResult {
|
|
19
19
|
let batchId: String
|
|
20
20
|
let revision: Int
|
|
21
|
-
let document: [String: Any]?
|
|
22
21
|
let updatedNodeIds: [String]
|
|
23
22
|
let affectedNodeIds: [String]
|
|
24
23
|
let requiresFullRender: Bool
|
|
24
|
+
private let materialize: () -> [String: Any]?
|
|
25
|
+
/// The committed tree, built when asked for: a host that redraws only the
|
|
26
|
+
/// nodes a batch touched never needs the whole tree.
|
|
27
|
+
var document: [String: Any]? { materialize() }
|
|
28
|
+
|
|
29
|
+
init(batchId: String, revision: Int, document: @escaping () -> [String: Any]?, updatedNodeIds: [String], affectedNodeIds: [String], requiresFullRender: Bool) {
|
|
30
|
+
self.batchId = batchId
|
|
31
|
+
self.revision = revision
|
|
32
|
+
self.materialize = document
|
|
33
|
+
self.updatedNodeIds = updatedNodeIds
|
|
34
|
+
self.affectedNodeIds = affectedNodeIds
|
|
35
|
+
self.requiresFullRender = requiresFullRender
|
|
36
|
+
}
|
|
25
37
|
}
|
|
26
38
|
|
|
27
39
|
/// Validates and applies versioned native-tree mutations without touching UIKit.
|
|
@@ -91,6 +103,8 @@ final class CraftNativeMutationDocument {
|
|
|
91
103
|
var updatedNodeIds: [String] = []
|
|
92
104
|
var affectedNodeIds: [String] = []
|
|
93
105
|
var requiresFullRender = false
|
|
106
|
+
// Only an operation that adds, moves or removes nodes can leave one unreachable.
|
|
107
|
+
var reshaped = false
|
|
94
108
|
for (index, value) in operations.enumerated() {
|
|
95
109
|
guard let operation = value as? [String: Any], let name = operation["op"] as? String else {
|
|
96
110
|
throw CraftNativeMutationFailure(
|
|
@@ -99,6 +113,7 @@ final class CraftNativeMutationDocument {
|
|
|
99
113
|
operationIndex: index
|
|
100
114
|
)
|
|
101
115
|
}
|
|
116
|
+
if name != "updateNode" { reshaped = true }
|
|
102
117
|
do {
|
|
103
118
|
switch name {
|
|
104
119
|
case "createNode":
|
|
@@ -147,7 +162,7 @@ final class CraftNativeMutationDocument {
|
|
|
147
162
|
guard let root = candidateNodes[candidateRoot], root.parent == nil else {
|
|
148
163
|
throw CraftNativeMutationFailure("INVALID_TREE", "root node must not have a parent")
|
|
149
164
|
}
|
|
150
|
-
guard descendants(of: candidateRoot, nodes: candidateNodes).count + 1 == candidateNodes.count else {
|
|
165
|
+
guard !reshaped || descendants(of: candidateRoot, nodes: candidateNodes).count + 1 == candidateNodes.count else {
|
|
151
166
|
throw CraftNativeMutationFailure("INVALID_TREE", "all nodes must be reachable from the root")
|
|
152
167
|
}
|
|
153
168
|
}
|
|
@@ -158,7 +173,7 @@ final class CraftNativeMutationDocument {
|
|
|
158
173
|
return CraftNativeMutationResult(
|
|
159
174
|
batchId: batchId,
|
|
160
175
|
revision: nextRevision,
|
|
161
|
-
document: candidateRoot.flatMap { materialize($0, nodes: candidateNodes) },
|
|
176
|
+
document: { [candidateRoot, candidateNodes, weak self] in candidateRoot.flatMap { self?.materialize($0, nodes: candidateNodes) } },
|
|
162
177
|
updatedNodeIds: updatedNodeIds,
|
|
163
178
|
affectedNodeIds: affectedNodeIds,
|
|
164
179
|
requiresFullRender: requiresFullRender
|