craft-native 0.0.172 → 0.0.174
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/src/index.d.ts +2 -0
- package/dist/android/src/index.js +4 -1
- package/dist/android/templates/AndroidManifest.xml.template +2 -0
- package/dist/android/templates/CraftNativeShell.kt.template +50 -0
- package/dist/android/templates/MainActivityNative.kt.template +441 -27
- package/dist/android/templates/build.gradle.kts.app.template +3 -0
- 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 +1 -0
- package/dist/ios/templates/CraftApp.swift +11 -0
- package/dist/ios/templates/CraftNativeActions.swift +174 -0
- package/dist/ios/templates/CraftNativeFlatList.swift +1 -1
- package/dist/ios/templates/CraftNativeScreen.swift +443 -38
- package/dist/macos/src/index.d.ts +16 -0
- package/dist/macos/src/index.js +75 -0
- package/dist/macos/templates/CraftMacApplication.swift +342 -0
- package/dist/macos/templates/CraftMacRenderer.swift +481 -0
- package/dist/macos/templates/CraftMacRuntime.swift +266 -0
- package/dist/macos/templates/CraftNativeMutation.swift +390 -0
- package/dist/macos/templates/native_layout.zig +1651 -0
- package/package.json +9 -1
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import AVKit
|
|
3
|
+
import Darwin
|
|
4
|
+
|
|
5
|
+
enum CraftMacColor {
|
|
6
|
+
static var isDark: Bool { NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua }
|
|
7
|
+
static func parse(_ value: Any?, fallback: NSColor = .labelColor) -> NSColor {
|
|
8
|
+
guard let text = value as? String else { return fallback }
|
|
9
|
+
if text == "transparent" { return .clear }
|
|
10
|
+
let named: [String: NSColor] = ["label": .labelColor, "secondaryLabel": .secondaryLabelColor, "separator": .separatorColor,
|
|
11
|
+
"controlBackground": .controlBackgroundColor, "windowBackground": .windowBackgroundColor, "textBackground": .textBackgroundColor,
|
|
12
|
+
"accent": .controlAccentColor, "white": .white, "black": .black]
|
|
13
|
+
if let color = named[text] { return color }
|
|
14
|
+
let hex = text.replacingOccurrences(of: "#", with: "")
|
|
15
|
+
if let n = UInt64(hex, radix: 16), hex.count == 6 || hex.count == 8 {
|
|
16
|
+
let rgb = hex.count == 8 ? n >> 8 : n
|
|
17
|
+
return NSColor(srgbRed: CGFloat((rgb >> 16) & 255) / 255, green: CGFloat((rgb >> 8) & 255) / 255, blue: CGFloat(rgb & 255) / 255,
|
|
18
|
+
alpha: hex.count == 8 ? CGFloat(n & 255) / 255 : 1)
|
|
19
|
+
}
|
|
20
|
+
return fallback
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/// The same C ABI used by Craft's mobile hosts. AppKit supplies text metrics;
|
|
25
|
+
/// Zig supplies flex sizing and justification, without a second layout rule.
|
|
26
|
+
enum CraftMacFlex {
|
|
27
|
+
struct Item { var basis: Float; var grow: Float; var shrink: Float; var min: Float; var max: Float; var start: Float; var end: Float }
|
|
28
|
+
typealias Function = @convention(c) (Float, Float, UInt32, UnsafeRawPointer, Int, UnsafeMutableRawPointer, UnsafeMutableRawPointer) -> Int32
|
|
29
|
+
static let function: Function = {
|
|
30
|
+
guard let address = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_flex_row_justified") else { fatalError("Craft's shared native layout library is missing") }
|
|
31
|
+
return unsafeBitCast(address, to: Function.self)
|
|
32
|
+
}()
|
|
33
|
+
static func layout(_ items: [Item], available: CGFloat, gap: CGFloat, justify: String) -> [(CGFloat, CGFloat)] {
|
|
34
|
+
guard !items.isEmpty else { return [] }
|
|
35
|
+
let code: UInt32 = ["flex-start": 0, "center": 1, "flex-end": 2, "space-between": 3, "space-around": 4, "space-evenly": 5][justify] ?? 0
|
|
36
|
+
var widths = [Float](repeating: 0, count: items.count), positions = widths
|
|
37
|
+
let status = items.withUnsafeBufferPointer { input in widths.withUnsafeMutableBufferPointer { output in positions.withUnsafeMutableBufferPointer { offsets in
|
|
38
|
+
function(Float(available), Float(gap), code, UnsafeRawPointer(input.baseAddress!), items.count, UnsafeMutableRawPointer(output.baseAddress!), UnsafeMutableRawPointer(offsets.baseAddress!))
|
|
39
|
+
} } }
|
|
40
|
+
precondition(status == 0, "Shared native layout rejected finite dimensions")
|
|
41
|
+
return zip(widths, positions).map { (CGFloat($0), CGFloat($1)) }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
final class CraftMacFlow: NSView {
|
|
46
|
+
var boxes: [CraftMacBox] = []
|
|
47
|
+
var style: [String: Any] = [:]
|
|
48
|
+
override var isFlipped: Bool { true }
|
|
49
|
+
override func layout() {
|
|
50
|
+
super.layout()
|
|
51
|
+
layoutBoxes(in: bounds.size)
|
|
52
|
+
}
|
|
53
|
+
func layoutBoxes(in size: CGSize) {
|
|
54
|
+
let horizontal = style["flexDirection"] as? String == "row"
|
|
55
|
+
let padding = dimension(style["padding"], base: size.width) ?? 0
|
|
56
|
+
let left = dimension(style["paddingLeft"], base: size.width) ?? dimension(style["paddingHorizontal"], base: size.width) ?? padding
|
|
57
|
+
let right = dimension(style["paddingRight"], base: size.width) ?? dimension(style["paddingHorizontal"], base: size.width) ?? padding
|
|
58
|
+
let top = dimension(style["paddingTop"], base: size.height) ?? dimension(style["paddingVertical"], base: size.height) ?? padding
|
|
59
|
+
let bottom = dimension(style["paddingBottom"], base: size.height) ?? dimension(style["paddingVertical"], base: size.height) ?? padding
|
|
60
|
+
let inner = CGSize(width: max(0, size.width - left - right), height: max(0, size.height - top - bottom))
|
|
61
|
+
let visible = boxes.filter { !$0.isHidden && $0.kind != "Modal" }
|
|
62
|
+
let gap = dimension(style["gap"], base: inner.width) ?? 0
|
|
63
|
+
let sizes = visible.map { $0.measure(width: inner.width) }
|
|
64
|
+
let items = visible.enumerated().map { index, box -> CraftMacFlex.Item in
|
|
65
|
+
let main = horizontal ? sizes[index].width : sizes[index].height
|
|
66
|
+
let basis = dimension(box.style["flexBasis"], base: horizontal ? inner.width : inner.height) ?? main
|
|
67
|
+
let grow = number(box.style["flexGrow"]) ?? number(box.style["flex"]) ?? 0
|
|
68
|
+
let shrink = number(box.style["flexShrink"]) ?? (grow > 0 ? 1 : 0)
|
|
69
|
+
let minimum = dimension(box.style[horizontal ? "minWidth" : "minHeight"], base: horizontal ? inner.width : inner.height)
|
|
70
|
+
let maximum = dimension(box.style[horizontal ? "maxWidth" : "maxHeight"], base: horizontal ? inner.width : inner.height)
|
|
71
|
+
return .init(basis: Float(basis), grow: Float(grow), shrink: Float(shrink), min: minimum.map(Float.init) ?? .nan, max: maximum.map(Float.init) ?? .nan, start: 0, end: 0)
|
|
72
|
+
}
|
|
73
|
+
let frames = CraftMacFlex.layout(items, available: horizontal ? inner.width : inner.height, gap: gap, justify: style["justifyContent"] as? String ?? "flex-start")
|
|
74
|
+
for (index, box) in visible.enumerated() {
|
|
75
|
+
let crossAvailable = horizontal ? inner.height : inner.width
|
|
76
|
+
let crossExplicit = dimension(box.style[horizontal ? "height" : "width"], base: crossAvailable)
|
|
77
|
+
let alignment = box.style["alignSelf"] as? String ?? style["alignItems"] as? String ?? "stretch"
|
|
78
|
+
let cross = min(crossAvailable, crossExplicit ?? (alignment == "stretch" ? crossAvailable : (horizontal ? sizes[index].height : sizes[index].width)))
|
|
79
|
+
let offset = alignment == "center" ? (crossAvailable - cross) / 2 : alignment == "flex-end" ? crossAvailable - cross : 0
|
|
80
|
+
box.frame = horizontal ? CGRect(x: left + frames[index].1, y: top + offset, width: frames[index].0, height: cross)
|
|
81
|
+
: CGRect(x: left + offset, y: top + frames[index].1, width: cross, height: frames[index].0)
|
|
82
|
+
box.layoutSubtreeIfNeeded()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
func fitting(width: CGFloat) -> CGSize {
|
|
86
|
+
let padding = number(style["padding"]) ?? 0
|
|
87
|
+
let horizontalPadding = (number(style["paddingLeft"]) ?? number(style["paddingHorizontal"]) ?? padding) + (number(style["paddingRight"]) ?? number(style["paddingHorizontal"]) ?? padding)
|
|
88
|
+
let verticalPadding = (number(style["paddingTop"]) ?? number(style["paddingVertical"]) ?? padding) + (number(style["paddingBottom"]) ?? number(style["paddingVertical"]) ?? padding)
|
|
89
|
+
let visible = boxes.filter { !$0.isHidden && $0.kind != "Modal" }
|
|
90
|
+
let sizes = visible.map { $0.measure(width: max(0, width - horizontalPadding)) }
|
|
91
|
+
let gaps = CGFloat(max(0, sizes.count - 1)) * (number(style["gap"]) ?? 0)
|
|
92
|
+
if style["flexDirection"] as? String == "row" {
|
|
93
|
+
return CGSize(width: sizes.reduce(0) { $0 + $1.width } + horizontalPadding + gaps, height: (sizes.map(\.height).max() ?? 0) + verticalPadding)
|
|
94
|
+
}
|
|
95
|
+
return CGSize(width: (sizes.map(\.width).max() ?? 0) + horizontalPadding, height: sizes.reduce(0) { $0 + $1.height } + verticalPadding + gaps)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func number(_ value: Any?) -> CGFloat? { (value as? NSNumber).map { CGFloat(truncating: $0) } }
|
|
100
|
+
func dimension(_ value: Any?, base: CGFloat) -> CGFloat? {
|
|
101
|
+
if let number = number(value), number.isFinite { return max(0, number) }
|
|
102
|
+
if let text = value as? String, text.hasSuffix("%"), let percent = Double(text.dropLast()), percent.isFinite { return max(0, base * CGFloat(percent) / 100) }
|
|
103
|
+
return nil
|
|
104
|
+
}
|
|
105
|
+
func nativeText(_ node: [String: Any]) -> String {
|
|
106
|
+
(node["children"] as? [Any] ?? []).map { child -> String in
|
|
107
|
+
if let text = child as? String { return text }
|
|
108
|
+
if let node = child as? [String: Any] { return nativeText(node) }
|
|
109
|
+
return ""
|
|
110
|
+
}.joined()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/// Each node owns an AppKit control. IDs survive STX mutations, so editing,
|
|
114
|
+
/// selection, and scroll position survive updates instead of being recreated.
|
|
115
|
+
final class CraftMacTextView: NSTextView {
|
|
116
|
+
var placeholder = ""
|
|
117
|
+
override func draw(_ dirtyRect: NSRect) {
|
|
118
|
+
super.draw(dirtyRect)
|
|
119
|
+
if string.isEmpty && !placeholder.isEmpty {
|
|
120
|
+
(placeholder as NSString).draw(at: CGPoint(x: textContainerInset.width + (textContainer?.lineFragmentPadding ?? 5), y: textContainerInset.height),
|
|
121
|
+
withAttributes: [.font: font ?? NSFont.systemFont(ofSize: 13), .foregroundColor: NSColor.placeholderTextColor])
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate {
|
|
127
|
+
let id: String
|
|
128
|
+
let kind: String
|
|
129
|
+
weak var renderer: CraftMacRenderer?
|
|
130
|
+
var props: [String: Any] = [:], style: [String: Any] = [:], events: [String: String] = [:]
|
|
131
|
+
var flow: CraftMacFlow?
|
|
132
|
+
var control: NSView?
|
|
133
|
+
var imageTask: URLSessionDataTask?
|
|
134
|
+
var imageSource = ""
|
|
135
|
+
var scrollKey = ""
|
|
136
|
+
var modal: NSPanel?
|
|
137
|
+
override var isFlipped: Bool { true }
|
|
138
|
+
|
|
139
|
+
init(id: String, kind: String, renderer: CraftMacRenderer) {
|
|
140
|
+
self.id = id; self.kind = kind; self.renderer = renderer
|
|
141
|
+
super.init(frame: .zero)
|
|
142
|
+
wantsLayer = true
|
|
143
|
+
setAccessibilityElement(false)
|
|
144
|
+
switch kind {
|
|
145
|
+
case "Text":
|
|
146
|
+
let text = NSTextField(wrappingLabelWithString: "")
|
|
147
|
+
text.isSelectable = true; text.maximumNumberOfLines = 0
|
|
148
|
+
control = text
|
|
149
|
+
case "Button", "Link":
|
|
150
|
+
let button = NSButton(title: "", target: self, action: #selector(pressed))
|
|
151
|
+
button.bezelStyle = .rounded; control = button
|
|
152
|
+
case "TextInput":
|
|
153
|
+
let input = NSTextField(); input.delegate = self; control = input
|
|
154
|
+
case "Picker":
|
|
155
|
+
let picker = NSPopUpButton(frame: .zero, pullsDown: false)
|
|
156
|
+
picker.target = self; picker.action = #selector(changed); control = picker
|
|
157
|
+
case "Switch", "Checkbox":
|
|
158
|
+
let button = NSButton(checkboxWithTitle: "", target: self, action: #selector(changed)); control = button
|
|
159
|
+
case "Icon", "Image":
|
|
160
|
+
let image = NSImageView(); image.imageScaling = .scaleProportionallyUpOrDown; control = image
|
|
161
|
+
case "Video": control = AVPlayerView()
|
|
162
|
+
case "ActivityIndicator": let indicator = NSProgressIndicator(); indicator.style = .spinning; indicator.startAnimation(nil); control = indicator
|
|
163
|
+
case "ScrollView", "FlatList", "SectionList":
|
|
164
|
+
let scroll = NSScrollView(); scroll.hasVerticalScroller = true; scroll.drawsBackground = false; scroll.borderType = .noBorder
|
|
165
|
+
let content = CraftMacFlow(); content.style = ["flexDirection": "column"]
|
|
166
|
+
scroll.documentView = content; flow = content; control = scroll
|
|
167
|
+
case "Sidebar": control = CraftMacSidebar(box: self)
|
|
168
|
+
case "View", "SafeAreaView", "Pressable", "TouchableOpacity", "TouchableHighlight", "KeyboardAvoidingView", "Modal", "SplitView":
|
|
169
|
+
let container = CraftMacFlow(); flow = container; control = container
|
|
170
|
+
default:
|
|
171
|
+
let text = NSTextField(labelWithString: "Unsupported native control: \(kind)"); control = text
|
|
172
|
+
renderer.runtime.errors.append("Unsupported native control: \(kind)")
|
|
173
|
+
}
|
|
174
|
+
if let control { addSubview(control) }
|
|
175
|
+
}
|
|
176
|
+
required init?(coder: NSCoder) { fatalError("Native controls are created from STX") }
|
|
177
|
+
|
|
178
|
+
func configure(_ node: [String: Any]) {
|
|
179
|
+
props = node["props"] as? [String: Any] ?? [:]
|
|
180
|
+
style = node["style"] as? [String: Any] ?? [:]
|
|
181
|
+
events = node["events"] as? [String: String] ?? [:]
|
|
182
|
+
isHidden = style["display"] as? String == "none" || props["hidden"] as? Bool == true
|
|
183
|
+
NSApp.effectiveAppearance.performAsCurrentDrawingAppearance {
|
|
184
|
+
layer?.backgroundColor = CraftMacColor.parse(style["backgroundColor"], fallback: .clear).cgColor
|
|
185
|
+
layer?.borderColor = CraftMacColor.parse(style["borderColor"], fallback: .separatorColor).cgColor
|
|
186
|
+
}
|
|
187
|
+
layer?.cornerRadius = number(style["borderRadius"]) ?? 0
|
|
188
|
+
layer?.borderWidth = number(style["borderWidth"]) ?? 0
|
|
189
|
+
alphaValue = number(style["opacity"]) ?? 1
|
|
190
|
+
let identifier = props["testID"] as? String ?? props["testId"] as? String ?? props["accessibilityIdentifier"] as? String ?? id
|
|
191
|
+
control?.setAccessibilityIdentifier(identifier)
|
|
192
|
+
let label = props["accessibilityLabel"] as? String ?? props["aria-label"] as? String ?? props["title"] as? String
|
|
193
|
+
if let label { control?.setAccessibilityLabel(label) }
|
|
194
|
+
control?.toolTip = props["title"] as? String
|
|
195
|
+
let fontSize = number(style["fontSize"]) ?? 13
|
|
196
|
+
let weightValue = number(style["fontWeight"]) ?? CGFloat(Double(style["fontWeight"] as? String ?? "") ?? 0)
|
|
197
|
+
let weight: NSFont.Weight = (style["fontWeight"] as? String == "bold" || weightValue >= 600) ? .semibold : .regular
|
|
198
|
+
let font = NSFont.systemFont(ofSize: fontSize, weight: weight)
|
|
199
|
+
if let text = control as? NSTextField, kind == "Text" {
|
|
200
|
+
text.stringValue = props["text"] as? String ?? nativeText(node)
|
|
201
|
+
text.font = font; text.textColor = CraftMacColor.parse(style["color"])
|
|
202
|
+
text.alignment = style["textAlign"] as? String == "center" ? .center : style["textAlign"] as? String == "right" ? .right : .left
|
|
203
|
+
text.maximumNumberOfLines = (props["numberOfLines"] as? NSNumber)?.intValue ?? 0
|
|
204
|
+
text.lineBreakMode = text.maximumNumberOfLines == 1 ? .byTruncatingTail : .byWordWrapping
|
|
205
|
+
}
|
|
206
|
+
if let button = control as? NSButton {
|
|
207
|
+
button.title = props["title"] as? String ?? nativeText(node)
|
|
208
|
+
button.font = font
|
|
209
|
+
button.isEnabled = props["disabled"] as? Bool != true
|
|
210
|
+
if kind == "Switch" || kind == "Checkbox" { button.state = (props["value"] as? Bool ?? props["checked"] as? Bool ?? false) ? .on : .off }
|
|
211
|
+
button.bezelStyle = props["variant"] as? String == "toolbar" ? .texturedRounded : .rounded
|
|
212
|
+
if let symbol = props["symbol"] as? String { button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: button.title); button.imagePosition = button.title.isEmpty ? .imageOnly : .imageLeading }
|
|
213
|
+
if props["variant"] as? String == "primary" { button.bezelColor = .controlAccentColor }
|
|
214
|
+
if props["variant"] as? String == "danger" { button.contentTintColor = .systemRed }
|
|
215
|
+
}
|
|
216
|
+
if kind == "TextInput" { configureInput(font: font) }
|
|
217
|
+
if let picker = control as? NSPopUpButton {
|
|
218
|
+
let rawOptions = props["options"] as? [[String: Any]] ?? (node["children"] as? [[String: Any]] ?? []).map { child in
|
|
219
|
+
["value": (child["props"] as? [String: Any])?["value"] ?? nativeText(child), "label": nativeText(child)]
|
|
220
|
+
}
|
|
221
|
+
let old = picker.itemArray.map { $0.representedObject as? String ?? "" }
|
|
222
|
+
let new = rawOptions.map { String(describing: $0["value"] ?? "") }
|
|
223
|
+
if old != new { picker.removeAllItems(); for option in rawOptions { picker.addItem(withTitle: option["label"] as? String ?? ""); picker.lastItem?.representedObject = String(describing: option["value"] ?? "") } }
|
|
224
|
+
if let value = props["value"] ?? props["selectedValue"], let index = new.firstIndex(of: String(describing: value)) { picker.selectItem(at: index) }
|
|
225
|
+
picker.isEnabled = props["disabled"] as? Bool != true
|
|
226
|
+
}
|
|
227
|
+
if let image = control as? NSImageView {
|
|
228
|
+
if kind == "Icon" { image.image = NSImage(systemSymbolName: props["symbol"] as? String ?? props["name"] as? String ?? "questionmark", accessibilityDescription: label); image.contentTintColor = CraftMacColor.parse(style["color"]) }
|
|
229
|
+
else { configureImage(image) }
|
|
230
|
+
}
|
|
231
|
+
if let video = control as? AVPlayerView, let source = (props["source"] as? String ?? props["src"] as? String), source != imageSource {
|
|
232
|
+
imageSource = source
|
|
233
|
+
if let url = renderer?.runtime.network.request(["url": source])?.url {
|
|
234
|
+
video.player = AVPlayer(url: url); video.controlsStyle = .inline
|
|
235
|
+
if props["autoplay"] as? Bool == true { video.player?.play() }
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if let sidebar = control as? CraftMacSidebar { sidebar.configure(props) }
|
|
239
|
+
flow?.style = style
|
|
240
|
+
needsLayout = true
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private func configureInput(font: NSFont) {
|
|
244
|
+
let multiline = props["multiline"] as? Bool == true
|
|
245
|
+
if multiline, !(control is NSScrollView) {
|
|
246
|
+
control?.removeFromSuperview()
|
|
247
|
+
let scroll = NSScrollView(); scroll.drawsBackground = true; scroll.backgroundColor = .textBackgroundColor; scroll.hasVerticalScroller = true
|
|
248
|
+
scroll.borderType = .bezelBorder
|
|
249
|
+
let text = CraftMacTextView(); text.isRichText = false; text.delegate = self; text.font = font; text.isVerticallyResizable = true; text.isHorizontallyResizable = false
|
|
250
|
+
text.textContainerInset = CGSize(width: 5, height: 6)
|
|
251
|
+
text.autoresizingMask = [.width]; text.textContainer?.widthTracksTextView = true
|
|
252
|
+
scroll.documentView = text; control = scroll; addSubview(scroll)
|
|
253
|
+
} else if !multiline, let current = control as? NSTextField {
|
|
254
|
+
let search = props["type"] as? String == "search"
|
|
255
|
+
let secure = props["secureTextEntry"] as? Bool == true || props["type"] as? String == "password"
|
|
256
|
+
if (search && !(current is NSSearchField)) || (secure && !(current is NSSecureTextField)) {
|
|
257
|
+
current.removeFromSuperview()
|
|
258
|
+
let field: NSTextField = search ? NSSearchField() : NSSecureTextField()
|
|
259
|
+
field.delegate = self; control = field; addSubview(field)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
let value = props["value"] as? String ?? ""
|
|
263
|
+
if let text = (control as? NSScrollView)?.documentView as? NSTextView {
|
|
264
|
+
if text.string != value { text.string = value }
|
|
265
|
+
text.isEditable = props["disabled"] as? Bool != true; text.font = font
|
|
266
|
+
if let editor = text as? CraftMacTextView { editor.placeholder = props["placeholder"] as? String ?? ""; editor.needsDisplay = true }
|
|
267
|
+
text.setAccessibilityIdentifier(props["testID"] as? String ?? props["testId"] as? String ?? id)
|
|
268
|
+
text.setAccessibilityLabel(props["placeholder"] as? String ?? "Message")
|
|
269
|
+
} else if let field = control as? NSTextField {
|
|
270
|
+
if field.stringValue != value { field.stringValue = value }
|
|
271
|
+
field.placeholderString = props["placeholder"] as? String
|
|
272
|
+
field.font = font; field.isEnabled = props["disabled"] as? Bool != true
|
|
273
|
+
field.setAccessibilityIdentifier(props["testID"] as? String ?? props["testId"] as? String ?? id)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private func configureImage(_ image: NSImageView) {
|
|
278
|
+
let source = props["source"] as? String ?? props["src"] as? String ?? (props["source"] as? [String: Any])?["uri"] as? String ?? ""
|
|
279
|
+
guard source != imageSource else { return }
|
|
280
|
+
imageSource = source; imageTask?.cancel(); image.image = nil
|
|
281
|
+
if source.hasPrefix("data:"), let encoded = source.split(separator: ",", maxSplits: 1).last, let data = Data(base64Encoded: String(encoded)) { image.image = NSImage(data: data); return }
|
|
282
|
+
guard !source.isEmpty, let request = renderer?.runtime.network.request(["url": source]), let session = renderer?.runtime.network.session else { return }
|
|
283
|
+
imageTask = session.dataTask(with: request) { [weak self, weak image] data, _, _ in
|
|
284
|
+
DispatchQueue.main.async {
|
|
285
|
+
guard let self, self.imageSource == source else { return }
|
|
286
|
+
image?.image = data.flatMap(NSImage.init(data:))
|
|
287
|
+
self.fire(image?.image == nil ? "onError" : "onLoad")
|
|
288
|
+
}
|
|
289
|
+
}; imageTask?.resume()
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
func measure(width: CGFloat) -> CGSize {
|
|
293
|
+
if isHidden { return .zero }
|
|
294
|
+
let specifiedWidth = dimension(style["width"], base: width)
|
|
295
|
+
let limit = min(specifiedWidth ?? width, dimension(style["maxWidth"], base: width) ?? width)
|
|
296
|
+
var size = CGSize(width: limit, height: 0)
|
|
297
|
+
if let text = control as? NSTextField, kind == "Text" {
|
|
298
|
+
let rect = (text.stringValue as NSString).boundingRect(with: CGSize(width: max(1, limit - 6), height: 1_000_000), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: text.font ?? NSFont.systemFont(ofSize: 13)])
|
|
299
|
+
size = CGSize(width: min(limit, ceil(rect.width) + 6), height: max(18, ceil(rect.height) + 2))
|
|
300
|
+
if text.maximumNumberOfLines == 1 { size.height = max(18, (text.font?.pointSize ?? 13) + 5) }
|
|
301
|
+
} else if kind == "ScrollView" || kind == "FlatList" || kind == "Sidebar" { size = CGSize(width: limit, height: 100) }
|
|
302
|
+
else if kind == "Icon" { size = CGSize(width: 18, height: 18) }
|
|
303
|
+
else if kind == "Image" || kind == "Video" { size = CGSize(width: limit, height: 160) }
|
|
304
|
+
else if kind == "TextInput" { size = CGSize(width: limit, height: props["multiline"] as? Bool == true ? 48 : 28) }
|
|
305
|
+
else if let control = control as? NSControl { size = control.intrinsicContentSize; size.width = max(24, size.width); size.height = max(26, size.height) }
|
|
306
|
+
else if let flow { size = flow.fitting(width: limit) }
|
|
307
|
+
size.width = specifiedWidth ?? min(limit, size.width)
|
|
308
|
+
if let maximum = dimension(style["maxWidth"], base: width) { size.width = min(size.width, maximum) }
|
|
309
|
+
// A naturally sized column can be narrower than its measurement limit.
|
|
310
|
+
// Measure its wrapped children again at the width it will actually get.
|
|
311
|
+
if let flow, style["flexDirection"] as? String != "row" {
|
|
312
|
+
size.height = flow.fitting(width: size.width).height
|
|
313
|
+
}
|
|
314
|
+
size.height = dimension(style["height"], base: 1000) ?? size.height
|
|
315
|
+
size.width = max(size.width, dimension(style["minWidth"], base: width) ?? 0)
|
|
316
|
+
size.height = max(size.height, dimension(style["minHeight"], base: 1000) ?? 0)
|
|
317
|
+
if let maxHeight = dimension(style["maxHeight"], base: 1000) { size.height = min(size.height, maxHeight) }
|
|
318
|
+
return size
|
|
319
|
+
}
|
|
320
|
+
override func layout() {
|
|
321
|
+
super.layout()
|
|
322
|
+
control?.frame = bounds
|
|
323
|
+
if let scroll = control as? NSScrollView {
|
|
324
|
+
if let flow {
|
|
325
|
+
let width = scroll.contentSize.width
|
|
326
|
+
flow.frame = CGRect(x: 0, y: 0, width: width, height: max(scroll.contentSize.height, flow.fitting(width: width).height))
|
|
327
|
+
flow.layoutBoxes(in: flow.bounds.size)
|
|
328
|
+
}
|
|
329
|
+
if let text = scroll.documentView as? NSTextView { text.frame.size.width = scroll.contentSize.width; text.textContainer?.containerSize = CGSize(width: scroll.contentSize.width, height: .greatestFiniteMagnitude) }
|
|
330
|
+
} else if let flow { flow.layoutBoxes(in: bounds.size) }
|
|
331
|
+
}
|
|
332
|
+
func fire(_ name: String, _ payload: [String: Any] = [:]) { renderer?.runtime.event(events[name], payload: payload) }
|
|
333
|
+
@objc func pressed() { fire("onPress") }
|
|
334
|
+
@objc func changed() {
|
|
335
|
+
let value: Any = (control as? NSPopUpButton)?.selectedItem?.representedObject ?? ((control as? NSButton)?.state == .on)
|
|
336
|
+
fire("onValueChange", ["value": value, "nativeEvent": ["value": value]])
|
|
337
|
+
}
|
|
338
|
+
func controlTextDidChange(_ notification: Notification) { if let field = notification.object as? NSTextField { input(field.stringValue) } }
|
|
339
|
+
func textDidChange(_ notification: Notification) { if let text = notification.object as? NSTextView { input(text.string) } }
|
|
340
|
+
func input(_ value: String) { fire("onChangeText", ["text": value, "value": value, "nativeEvent": ["text": value, "value": value]]) }
|
|
341
|
+
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
|
342
|
+
if commandSelector == #selector(NSResponder.insertNewline(_:)), !NSEvent.modifierFlags.contains(.shift), events["onSubmitEditing"] != nil { fire("onSubmitEditing", ["text": textView.string]); return true }
|
|
343
|
+
return false
|
|
344
|
+
}
|
|
345
|
+
override func mouseDown(with event: NSEvent) { if events["onPress"] != nil { fire("onPress") } else { super.mouseDown(with: event) } }
|
|
346
|
+
override func rightMouseDown(with event: NSEvent) { fire("onLongPress", ["x": event.locationInWindow.x, "y": event.locationInWindow.y]) }
|
|
347
|
+
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
348
|
+
guard !isHidden, bounds.contains(convert(point, from: superview)) else { return nil }
|
|
349
|
+
// Rows handle the whole click. Transcript text keeps AppKit selection,
|
|
350
|
+
// while a right click is routed to the STX conversation/message menu.
|
|
351
|
+
if kind == "Pressable", events["onPress"] != nil || (events["onLongPress"] != nil && NSApp.currentEvent?.type == .rightMouseDown) { return self }
|
|
352
|
+
return super.hitTest(point)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
final class CraftMacSidebarRow: NSTableRowView {
|
|
357
|
+
override func drawSelection(in dirtyRect: NSRect) {
|
|
358
|
+
guard isSelected else { return }
|
|
359
|
+
effectiveAppearance.performAsCurrentDrawingAppearance {
|
|
360
|
+
NSColor.unemphasizedSelectedContentBackgroundColor.setFill()
|
|
361
|
+
NSBezierPath(roundedRect: bounds.insetBy(dx: 4, dy: 1), xRadius: 7, yRadius: 7).fill()
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
final class CraftMacSidebar: NSScrollView, NSOutlineViewDataSource, NSOutlineViewDelegate {
|
|
367
|
+
final class Item { let id: String; let label: String; let icon: String; let badge: String; let children: [Item]
|
|
368
|
+
init(_ raw: [String: Any]) { id = raw["id"] as? String ?? ""; label = raw["label"] as? String ?? raw["header"] as? String ?? ""; icon = raw["icon"] as? String ?? ""; badge = raw["badge"].map { $0 is NSNull ? "" : String(describing: $0) } ?? ""; children = (raw["items"] as? [[String: Any]] ?? []).map(Item.init) }
|
|
369
|
+
}
|
|
370
|
+
weak var box: CraftMacBox?
|
|
371
|
+
let outline = NSOutlineView()
|
|
372
|
+
var sections: [Item] = []
|
|
373
|
+
var fingerprint = ""
|
|
374
|
+
init(box: CraftMacBox) {
|
|
375
|
+
self.box = box; super.init(frame: .zero)
|
|
376
|
+
hasVerticalScroller = true; drawsBackground = false
|
|
377
|
+
let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("label")); outline.addTableColumn(column); outline.outlineTableColumn = column
|
|
378
|
+
outline.headerView = nil; outline.style = .plain; outline.backgroundColor = .windowBackgroundColor
|
|
379
|
+
outline.rowSizeStyle = .default; outline.indentationPerLevel = 0
|
|
380
|
+
outline.dataSource = self; outline.delegate = self; outline.autoresizesOutlineColumn = true; documentView = outline
|
|
381
|
+
}
|
|
382
|
+
required init?(coder: NSCoder) { fatalError("Native sidebar is created from STX") }
|
|
383
|
+
func configure(_ props: [String: Any]) {
|
|
384
|
+
let raw = props["sections"] as? [[String: Any]] ?? []
|
|
385
|
+
let data = (try? JSONSerialization.data(withJSONObject: raw, options: .sortedKeys)) ?? Data()
|
|
386
|
+
let next = String(decoding: data, as: UTF8.self)
|
|
387
|
+
if next != fingerprint { fingerprint = next; sections = raw.map(Item.init); outline.reloadData(); outline.expandItem(nil, expandChildren: true) }
|
|
388
|
+
if let selected = props["selected"] as? String { for index in 0..<outline.numberOfRows { if (outline.item(atRow: index) as? Item)?.id == selected { outline.selectRowIndexes(IndexSet(integer: index), byExtendingSelection: false); break } } }
|
|
389
|
+
}
|
|
390
|
+
func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { (item as? Item)?.children.count ?? sections.count }
|
|
391
|
+
func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { (item as? Item)?.children[index] ?? sections[index] }
|
|
392
|
+
func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { !(item as! Item).children.isEmpty }
|
|
393
|
+
func outlineView(_ outlineView: NSOutlineView, isGroupItem item: Any) -> Bool { !(item as! Item).children.isEmpty }
|
|
394
|
+
func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool { (item as! Item).children.isEmpty }
|
|
395
|
+
func outlineView(_ outlineView: NSOutlineView, shouldShowOutlineCellForItem item: Any) -> Bool { false }
|
|
396
|
+
func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat {
|
|
397
|
+
let item = item as! Item
|
|
398
|
+
return item.children.isEmpty ? 32 : item.label.isEmpty ? 12 : 28
|
|
399
|
+
}
|
|
400
|
+
func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? {
|
|
401
|
+
let row = CraftMacSidebarRow(); row.isEmphasized = false; return row
|
|
402
|
+
}
|
|
403
|
+
func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
|
|
404
|
+
let item = item as! Item
|
|
405
|
+
let cell = NSTableCellView(); let label = NSTextField(labelWithString: item.label + (item.badge.isEmpty ? "" : " \(item.badge)"))
|
|
406
|
+
label.font = NSFont.systemFont(ofSize: 13); label.lineBreakMode = .byTruncatingTail; cell.textField = label; cell.addSubview(label)
|
|
407
|
+
if !item.children.isEmpty { label.font = NSFont.systemFont(ofSize: 11, weight: .semibold); label.textColor = .secondaryLabelColor }
|
|
408
|
+
label.translatesAutoresizingMaskIntoConstraints = false
|
|
409
|
+
NSLayoutConstraint.activate([label.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: item.icon.isEmpty ? 4 : 27), label.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -6), label.centerYAnchor.constraint(equalTo: cell.centerYAnchor)])
|
|
410
|
+
if !item.icon.isEmpty { let image = NSImageView(image: NSImage(systemSymbolName: item.icon, accessibilityDescription: nil) ?? NSImage()); image.frame = CGRect(x: 4, y: 7, width: 18, height: 18); cell.imageView = image; cell.addSubview(image) }
|
|
411
|
+
cell.setAccessibilityIdentifier(item.id); cell.setAccessibilityLabel(item.label)
|
|
412
|
+
return cell
|
|
413
|
+
}
|
|
414
|
+
func outlineViewSelectionDidChange(_ notification: Notification) {
|
|
415
|
+
guard let item = outline.item(atRow: outline.selectedRow) as? Item, item.children.isEmpty, item.id != box?.props["selected"] as? String else { return }
|
|
416
|
+
box?.fire("onValueChange", ["value": item.id, "nativeEvent": ["value": item.id]])
|
|
417
|
+
}
|
|
418
|
+
override func layout() { super.layout(); outline.frame.size.width = contentSize.width }
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
final class CraftMacRenderer {
|
|
422
|
+
let runtime: CraftMacRuntime
|
|
423
|
+
let root = CraftMacFlow()
|
|
424
|
+
var boxes: [String: CraftMacBox] = [:]
|
|
425
|
+
weak var window: NSWindow?
|
|
426
|
+
var sheets: [String: NSPanel] = [:]
|
|
427
|
+
init(runtime: CraftMacRuntime) { self.runtime = runtime }
|
|
428
|
+
func render(_ document: [String: Any]) {
|
|
429
|
+
var retained = Set<String>()
|
|
430
|
+
var scrolls: [(CraftMacBox, CGPoint, Bool)] = []
|
|
431
|
+
for box in boxes.values { if let scroll = box.control as? NSScrollView, box.kind != "Sidebar" {
|
|
432
|
+
let bounds = scroll.contentView.bounds
|
|
433
|
+
let bottom = bounds.maxY >= (scroll.documentView?.bounds.height ?? 0) - 8
|
|
434
|
+
scrolls.append((box, bounds.origin, bottom))
|
|
435
|
+
} }
|
|
436
|
+
func reconcile(_ node: [String: Any]) -> CraftMacBox {
|
|
437
|
+
let id = node["id"] as? String ?? "root", kind = node["type"] as? String ?? "View"
|
|
438
|
+
retained.insert(id)
|
|
439
|
+
let box = boxes[id] ?? CraftMacBox(id: id, kind: kind, renderer: self)
|
|
440
|
+
boxes[id] = box; box.configure(node)
|
|
441
|
+
if let flow = box.flow, kind != "Button" {
|
|
442
|
+
let children = (node["children"] as? [Any] ?? []).compactMap { $0 as? [String: Any] }.map(reconcile)
|
|
443
|
+
for child in flow.boxes where !children.contains(where: { $0 === child }) { child.removeFromSuperview() }
|
|
444
|
+
flow.boxes = children
|
|
445
|
+
for child in children where child.superview !== flow && sheets[child.id] == nil { flow.addSubview(child) }
|
|
446
|
+
}
|
|
447
|
+
return box
|
|
448
|
+
}
|
|
449
|
+
let box = reconcile(document)
|
|
450
|
+
for (id, box) in boxes where !retained.contains(id) {
|
|
451
|
+
box.imageTask?.cancel(); box.removeFromSuperview(); boxes.removeValue(forKey: id)
|
|
452
|
+
if let sheet = sheets.removeValue(forKey: id) { window?.endSheet(sheet); sheet.close() }
|
|
453
|
+
}
|
|
454
|
+
root.style = ["flexDirection": "column"]
|
|
455
|
+
root.boxes = [box]; if box.superview !== root { root.addSubview(box) }
|
|
456
|
+
box.frame = root.bounds; box.layoutSubtreeIfNeeded(); root.layoutBoxes(in: root.bounds.size)
|
|
457
|
+
for modal in boxes.values where modal.kind == "Modal" {
|
|
458
|
+
guard let window else { continue }
|
|
459
|
+
if modal.props["visible"] as? Bool == false {
|
|
460
|
+
if let sheet = sheets.removeValue(forKey: modal.id) { window.endSheet(sheet); sheet.close() }
|
|
461
|
+
continue
|
|
462
|
+
}
|
|
463
|
+
if sheets[modal.id] == nil {
|
|
464
|
+
let size = modal.measure(width: 480)
|
|
465
|
+
let panel = NSPanel(contentRect: CGRect(x: 0, y: 0, width: 480, height: min(650, max(200, size.height))), styleMask: [.titled], backing: .buffered, defer: false)
|
|
466
|
+
panel.title = modal.props["title"] as? String ?? ""; panel.isReleasedWhenClosed = false
|
|
467
|
+
modal.removeFromSuperview(); panel.contentView = modal; sheets[modal.id] = panel
|
|
468
|
+
window.beginSheet(panel)
|
|
469
|
+
}
|
|
470
|
+
modal.frame = sheets[modal.id]!.contentView!.bounds; modal.needsLayout = true; modal.layoutSubtreeIfNeeded()
|
|
471
|
+
}
|
|
472
|
+
for (box, origin, bottom) in scrolls {
|
|
473
|
+
guard let scroll = box.control as? NSScrollView, box.kind != "TextInput" else { continue }
|
|
474
|
+
let key = box.props["scrollKey"] as? String ?? ""
|
|
475
|
+
let changed = key != box.scrollKey; box.scrollKey = key
|
|
476
|
+
let y = (bottom || changed) && box.props["autoScrollBottom"] as? Bool == true ? max(0, (scroll.documentView?.frame.height ?? 0) - scroll.contentSize.height) : origin.y
|
|
477
|
+
scroll.contentView.scroll(to: CGPoint(x: origin.x, y: y)); scroll.reflectScrolledClipView(scroll.contentView)
|
|
478
|
+
}
|
|
479
|
+
root.needsLayout = true
|
|
480
|
+
}
|
|
481
|
+
}
|