craft-native 0.0.172 → 0.0.173

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.
@@ -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
+ }