craft-native 0.0.396 → 0.0.408

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.
@@ -68,9 +68,17 @@ final class CraftMacFlow: NSView {
68
68
  let inner = CGSize(width: max(0, size.width - left - right), height: max(0, size.height - top - bottom))
69
69
  let visible = boxes.filter { !$0.isHidden && $0.kind != "Modal" && $0.layoutStyle["position"] as? String != "absolute" }
70
70
  let gap = dimension(style["gap"], base: inner.width) ?? 0
71
+ // Margins on the cross axis (left and right in a column, top and bottom in a row) shrink what a child may fill and offset it.
72
+ func crossMargins(_ box: CraftMacBox) -> (start: CGFloat, end: CGFloat) {
73
+ let layout = box.layoutStyle
74
+ let margin = number(layout["margin"]) ?? 0
75
+ let axis = number(layout[horizontal ? "marginVertical" : "marginHorizontal"]) ?? margin
76
+ return (number(layout[horizontal ? "marginTop" : "marginLeft"]) ?? axis, number(layout[horizontal ? "marginBottom" : "marginRight"]) ?? axis)
77
+ }
71
78
  let sizes = visible.map { box in
72
79
  let alignment = box.layoutStyle["alignSelf"] as? String ?? style["alignItems"] as? String ?? "stretch"
73
- return box.measure(width: inner.width, stretch: !horizontal && alignment == "stretch")
80
+ let margins = crossMargins(box)
81
+ return box.measure(width: horizontal ? inner.width : max(0, inner.width - margins.start - margins.end), stretch: !horizontal && alignment == "stretch")
74
82
  }
75
83
  let items = visible.enumerated().map { index, box -> CraftMacFlex.Item in
76
84
  let main = horizontal ? sizes[index].width : sizes[index].height
@@ -87,12 +95,13 @@ final class CraftMacFlow: NSView {
87
95
  }
88
96
  let frames = CraftMacFlex.layout(items, available: horizontal ? inner.width : inner.height, gap: gap, justify: style["justifyContent"] as? String ?? "flex-start")
89
97
  for (index, box) in visible.enumerated() {
90
- let crossAvailable = horizontal ? inner.height : inner.width
98
+ let margins = crossMargins(box)
99
+ let crossAvailable = max(0, (horizontal ? inner.height : inner.width) - margins.start - margins.end)
91
100
  let crossExplicit = dimension(box.layoutStyle[horizontal ? "height" : "width"], base: crossAvailable)
92
101
  let alignment = box.layoutStyle["alignSelf"] as? String ?? style["alignItems"] as? String ?? "stretch"
93
102
  let measured = horizontal ? box.measure(width: frames[index].0, stretch: true) : sizes[index]
94
103
  let cross = min(crossAvailable, crossExplicit ?? (alignment == "stretch" ? crossAvailable : (horizontal ? measured.height : measured.width)))
95
- let offset = alignment == "center" ? (crossAvailable - cross) / 2 : alignment == "flex-end" ? crossAvailable - cross : 0
104
+ let offset = margins.start + (alignment == "center" ? (crossAvailable - cross) / 2 : alignment == "flex-end" ? crossAvailable - cross : 0)
96
105
  box.frame = horizontal ? CGRect(x: left + frames[index].1, y: top + offset, width: frames[index].0, height: cross)
97
106
  : CGRect(x: left + offset, y: top + frames[index].1, width: cross, height: frames[index].0)
98
107
  box.layoutSubtreeIfNeeded()
@@ -134,7 +143,12 @@ final class CraftMacFlow: NSView {
134
143
  let margin = number(layout["marginVertical"]) ?? number(layout["margin"]) ?? 0
135
144
  return total + (number(layout["marginTop"]) ?? margin) + (number(layout["marginBottom"]) ?? margin)
136
145
  }
137
- return CGSize(width: (sizes.map(\.width).max() ?? 0) + horizontalPadding, height: sizes.reduce(0) { $0 + $1.height } + verticalPadding + gaps + margins)
146
+ let crossMargins = zip(visible, sizes).map { box, size -> CGFloat in
147
+ let layout = box.layoutStyle
148
+ let margin = number(layout["marginHorizontal"]) ?? number(layout["margin"]) ?? 0
149
+ return size.width + (number(layout["marginLeft"]) ?? margin) + (number(layout["marginRight"]) ?? margin)
150
+ }
151
+ return CGSize(width: (crossMargins.max() ?? 0) + horizontalPadding, height: sizes.reduce(0) { $0 + $1.height } + verticalPadding + gaps + margins)
138
152
  }
139
153
  }
140
154
 
@@ -156,8 +170,81 @@ func nativeText(_ node: [String: Any]) -> String {
156
170
  /// selection, and scroll position survive updates instead of being recreated.
157
171
  final class CraftMacTextView: NSTextView {
158
172
  var placeholder = ""
173
+ /// An inline completion drawn after the caret in the placeholder color
174
+ /// (`ghostText`); Tab accepts it (`onAcceptCompletion`), any edit drops it.
175
+ var ghost = ""
176
+ /// `markdown` prop: the text stays plain markdown, drawn the way Notes would show it (headings, bold, italic,
177
+ /// code, quotes, checklists with the done ones struck through), restyled after every edit.
178
+ var markdown = false
179
+ var lastStyled = ""
180
+ /// The font the markdown styling is built from. NSTextView's own `font` answers the font at the caret, which a heading changes.
181
+ var baseFont: NSFont?
182
+ override func didChangeText() { ghost = ""; super.didChangeText(); restyle() }
183
+ func restyle() {
184
+ guard markdown, let storage = textStorage else { return }
185
+ let base = baseFont ?? font ?? NSFont.systemFont(ofSize: 13)
186
+ let text = string as NSString
187
+ let full = NSRange(location: 0, length: text.length)
188
+ // The dynamic system font does not answer NSFontManager's trait conversion, so weight and slant are built directly.
189
+ enum Trait { case bold, italic }
190
+ func traits(_ font: NSFont, _ trait: Trait) -> NSFont {
191
+ switch trait {
192
+ case .bold:
193
+ let heavier = NSFont.systemFont(ofSize: font.pointSize, weight: .bold)
194
+ return font.fontDescriptor.symbolicTraits.contains(.italic) ? (NSFont(descriptor: heavier.fontDescriptor.withSymbolicTraits(.italic), size: font.pointSize) ?? heavier) : heavier
195
+ case .italic:
196
+ return NSFont(descriptor: font.fontDescriptor.withSymbolicTraits([.italic]), size: font.pointSize) ?? font
197
+ }
198
+ }
199
+ let dim: [NSAttributedString.Key: Any] = [.foregroundColor: NSColor.tertiaryLabelColor]
200
+ storage.beginEditing()
201
+ storage.setAttributes([.font: base, .foregroundColor: NSColor.labelColor], range: full)
202
+ text.enumerateSubstrings(in: full, options: [.byLines, .substringNotRequired]) { _, range, _, _ in
203
+ let line = text.substring(with: range)
204
+ if let hashes = line.prefix(while: { $0 == "#" }) as Substring?, (1...3).contains(hashes.count), line.dropFirst(hashes.count).hasPrefix(" ") {
205
+ let size: CGFloat = [1: base.pointSize + 11, 2: base.pointSize + 6, 3: base.pointSize + 3][hashes.count]!
206
+ storage.addAttribute(.font, value: traits(NSFont.systemFont(ofSize: size), .bold), range: range)
207
+ storage.addAttributes(dim, range: NSRange(location: range.location, length: hashes.count + 1))
208
+ }
209
+ else if line.hasPrefix("- [x] ") || line.hasPrefix("- [X] ") {
210
+ storage.addAttributes(dim, range: NSRange(location: range.location, length: 6))
211
+ storage.addAttributes([.strikethroughStyle: NSUnderlineStyle.single.rawValue, .foregroundColor: NSColor.secondaryLabelColor], range: NSRange(location: range.location + 6, length: range.length - 6))
212
+ }
213
+ else if line.hasPrefix("- [ ] ") { storage.addAttributes([.foregroundColor: NSColor.controlAccentColor], range: NSRange(location: range.location, length: 5)) }
214
+ else if line.hasPrefix("> ") {
215
+ storage.addAttributes(dim, range: NSRange(location: range.location, length: 2))
216
+ storage.addAttribute(.foregroundColor, value: NSColor.secondaryLabelColor, range: NSRange(location: range.location + 2, length: range.length - 2))
217
+ }
218
+ else if line.hasPrefix("- ") || line.hasPrefix("* ") { storage.addAttributes([.foregroundColor: NSColor.secondaryLabelColor], range: NSRange(location: range.location, length: 2)) }
219
+ }
220
+ func inline(_ pattern: String, _ apply: (NSRange, NSRange) -> Void) {
221
+ guard let expression = try? NSRegularExpression(pattern: pattern) else { return }
222
+ for match in expression.matches(in: string, range: full) where match.numberOfRanges > 1 { apply(match.range, match.range(at: 1)) }
223
+ }
224
+ inline("\\*\\*(?!\\s)(.+?)(?<!\\s)\\*\\*") { whole, inner in
225
+ storage.enumerateAttribute(.font, in: inner) { value, range, _ in storage.addAttribute(.font, value: traits(value as? NSFont ?? base, .bold), range: range) }
226
+ storage.addAttributes(dim, range: NSRange(location: whole.location, length: 2)); storage.addAttributes(dim, range: NSRange(location: whole.location + whole.length - 2, length: 2))
227
+ }
228
+ inline("(?<![\\*\\w])\\*(?![\\*\\s])(.+?)(?<![\\*\\s])\\*(?![\\*\\w])") { whole, inner in
229
+ storage.enumerateAttribute(.font, in: inner) { value, range, _ in storage.addAttribute(.font, value: traits(value as? NSFont ?? base, .italic), range: range) }
230
+ storage.addAttributes(dim, range: NSRange(location: whole.location, length: 1)); storage.addAttributes(dim, range: NSRange(location: whole.location + whole.length - 1, length: 1))
231
+ }
232
+ inline("`([^`\\n]+)`") { whole, inner in
233
+ storage.addAttributes([.font: NSFont.monospacedSystemFont(ofSize: base.pointSize - 1, weight: .regular), .backgroundColor: NSColor.quaternaryLabelColor], range: inner)
234
+ storage.addAttributes(dim, range: NSRange(location: whole.location, length: 1)); storage.addAttributes(dim, range: NSRange(location: whole.location + whole.length - 1, length: 1))
235
+ }
236
+ storage.endEditing()
237
+ typingAttributes = [.font: base, .foregroundColor: NSColor.labelColor]
238
+ }
159
239
  override func draw(_ dirtyRect: NSRect) {
160
240
  super.draw(dirtyRect)
241
+ if !ghost.isEmpty, selectedRange().length == 0, let window {
242
+ let screen = firstRect(forCharacterRange: NSRange(location: selectedRange().location, length: 0), actualRange: nil)
243
+ let point = convert(window.convertFromScreen(screen), from: nil)
244
+ let left = textContainerInset.width + (textContainer?.lineFragmentPadding ?? 5)
245
+ (ghost as NSString).draw(with: CGRect(x: point.minX, y: point.minY, width: max(20, bounds.width - point.minX - left), height: 100_000), options: [.usesLineFragmentOrigin],
246
+ attributes: [.font: font ?? NSFont.systemFont(ofSize: 13), .foregroundColor: NSColor.tertiaryLabelColor])
247
+ }
161
248
  if string.isEmpty && !placeholder.isEmpty {
162
249
  (placeholder as NSString).draw(at: CGPoint(x: textContainerInset.width + (textContainer?.lineFragmentPadding ?? 5), y: textContainerInset.height),
163
250
  withAttributes: [.font: font ?? NSFont.systemFont(ofSize: 13), .foregroundColor: NSColor.placeholderTextColor])
@@ -165,7 +252,7 @@ final class CraftMacTextView: NSTextView {
165
252
  }
166
253
  }
167
254
 
168
- final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate {
255
+ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDraggingSource {
169
256
  let id: String
170
257
  let kind: String
171
258
  weak var renderer: CraftMacRenderer?
@@ -178,6 +265,17 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate {
178
265
  var modal: NSPanel?
179
266
  var material: NSVisualEffectView?
180
267
  var edgeBorders: [String: CALayer] = [:]
268
+ var lastSelection = ""
269
+ /// The pointer is over the box, or pressing it: `hoverStyle` and `pressStyle` apply on top of `style`.
270
+ var hovering = false, pressing = false
271
+ var hoverArea: NSTrackingArea?
272
+ var gradient: CAGradientLayer?
273
+ var configuredOnce = false
274
+ /// Drag and drop: where a press started (a drag begins 3pt away), and the last position reported to a drop target.
275
+ static let dragType = NSPasteboard.PasteboardType("org.craft-native.drag")
276
+ var downPoint: NSPoint?
277
+ var lastDropPosition = ""
278
+ var acceptsDrops = false
181
279
  var componentRoot: CraftMacBox? { props["data-stx-component-wrapper"] != nil && flow?.boxes.count == 1 && flow?.boxes.first?.kind != "Modal" ? flow?.boxes.first : nil }
182
280
  var layoutStyle: [String: Any] {
183
281
  guard let child = componentRoot else { return style }
@@ -218,6 +316,9 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate {
218
316
  let content = CraftMacFlow(); content.style = ["flexDirection": "column"]
219
317
  scroll.documentView = content; flow = content; control = scroll
220
318
  case "Sidebar": control = CraftMacSidebar(box: self)
319
+ #if CRAFT_WEBKIT
320
+ case "WebView": control = CraftMacWebHost(box: self)
321
+ #endif
221
322
  case "View", "SafeAreaView", "Pressable", "TouchableOpacity", "TouchableHighlight", "KeyboardAvoidingView", "Modal", "SplitView":
222
323
  let container = CraftMacFlow(); flow = container; control = container
223
324
  default:
@@ -234,10 +335,6 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate {
234
335
  style = node["style"] as? [String: Any] ?? [:]
235
336
  events = node["events"] as? [String: String] ?? [:]
236
337
  isHidden = style["display"] as? String == "none" || props["hidden"] as? Bool == true
237
- NSApp.effectiveAppearance.performAsCurrentDrawingAppearance {
238
- layer?.backgroundColor = CraftMacColor.parse(style["backgroundColor"], fallback: .clear).cgColor
239
- layer?.borderColor = CraftMacColor.parse(style["borderColor"], fallback: .separatorColor).cgColor
240
- }
241
338
  layer?.cornerRadius = number(style["borderRadius"]) ?? 0
242
339
  layer?.borderWidth = number(style["borderWidth"]) ?? 0
243
340
  // AppKit views may draw their entire visibleRect, which can extend
@@ -246,6 +343,22 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate {
246
343
  clipsToBounds = style["overflow"] as? String != "visible"
247
344
  if let container = control as? CraftMacFlow { container.clipsToBounds = clipsToBounds }
248
345
  layer?.masksToBounds = clipsToBounds || (number(style["borderRadius"]) ?? 0) > 0
346
+ // `shadow-*` classes: a box with a shadow cannot clip its own layer, or the shadow is cut off.
347
+ if let opacity = number(style["shadowOpacity"]), opacity > 0 {
348
+ let offset = style["shadowOffset"] as? [String: Any] ?? [:]
349
+ // The shadow needs the box's own layer unclipped, so a rounded box that also clips
350
+ // (`rounded-xl overflow-hidden shadow-lg`, a card holding a web page) clips one level in.
351
+ let clipsContent = style["overflow"] as? String != "visible" && (number(style["borderRadius"]) ?? 0) > 0
352
+ layer?.masksToBounds = false; clipsToBounds = false
353
+ if let inner = control, clipsContent {
354
+ inner.wantsLayer = true; inner.layer?.masksToBounds = true; inner.layer?.cornerCurve = .continuous
355
+ inner.layer?.cornerRadius = number(style["borderRadius"]) ?? 0
356
+ }
357
+ layer?.shadowOpacity = Float(opacity); layer?.shadowRadius = number(style["shadowRadius"]) ?? 3
358
+ layer?.shadowOffset = CGSize(width: number(offset["width"]) ?? 0, height: -(number(offset["height"]) ?? 1))
359
+ layer?.shadowColor = CraftMacColor.parse(style["shadowColor"], fallback: .black).cgColor
360
+ } else { layer?.shadowOpacity = 0; layer?.shadowPath = nil; control?.layer?.cornerRadius = 0 }
361
+ layer?.cornerCurve = .continuous
249
362
  for edge in ["Top", "Right", "Bottom", "Left"] {
250
363
  let border = edgeBorders[edge] ?? CALayer()
251
364
  if edgeBorders[edge] == nil { edgeBorders[edge] = border; layer?.addSublayer(border) }
@@ -256,7 +369,6 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate {
256
369
  if material == nil { let effect = NSVisualEffectView(); effect.blendingMode = .behindWindow; effect.state = .followsWindowActiveState; addSubview(effect, positioned: .below, relativeTo: control); material = effect }
257
370
  material?.material = name == "sidebar" ? .sidebar : .headerView
258
371
  } else { material?.removeFromSuperview(); material = nil }
259
- alphaValue = number(style["opacity"]) ?? 1
260
372
  let identifier = props["testID"] as? String ?? props["testId"] as? String ?? props["accessibilityIdentifier"] as? String ?? id
261
373
  control?.setAccessibilityIdentifier(identifier)
262
374
  let label = props["accessibilityLabel"] as? String ?? props["aria-label"] as? String ?? props["title"] as? String
@@ -269,6 +381,10 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate {
269
381
  if let text = control as? NSTextField, kind == "Text" {
270
382
  text.stringValue = props["text"] as? String ?? nativeText(node)
271
383
  text.font = font; text.textColor = CraftMacColor.parse(style["color"])
384
+ if let line = style["textDecorationLine"] as? String, line == "line-through" || line == "underline" {
385
+ let attributed = NSMutableAttributedString(string: text.stringValue, attributes: [.font: font, .foregroundColor: CraftMacColor.parse(style["color"]), line == "underline" ? .underlineStyle : .strikethroughStyle: NSUnderlineStyle.single.rawValue])
386
+ text.attributedStringValue = attributed
387
+ }
272
388
  text.alignment = style["textAlign"] as? String == "center" ? .center : style["textAlign"] as? String == "right" ? .right : .left
273
389
  text.maximumNumberOfLines = (props["numberOfLines"] as? NSNumber)?.intValue ?? 0
274
390
  text.lineBreakMode = text.maximumNumberOfLines == 1 ? .byTruncatingTail : .byWordWrapping
@@ -312,6 +428,17 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate {
312
428
  }
313
429
  }
314
430
  if let sidebar = control as? CraftMacSidebar { sidebar.configure(props) }
431
+ let dropTarget = events["onDrop"] != nil || events["onDragOver"] != nil
432
+ if dropTarget != acceptsDrops {
433
+ acceptsDrops = dropTarget
434
+ if dropTarget { registerForDraggedTypes([Self.dragType, .URL, .fileURL]) } else { unregisterDraggedTypes() }
435
+ }
436
+ updateTrackingAreas()
437
+ applyAppearance(animated: configuredOnce)
438
+ configuredOnce = true
439
+ #if CRAFT_WEBKIT
440
+ if let web = control as? CraftMacWebHost { web.show(props["tab"] as? String) }
441
+ #endif
315
442
  flow?.style = style
316
443
  needsLayout = true
317
444
  }
@@ -344,8 +471,20 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate {
344
471
  text.drawsBackground = bordered
345
472
  }
346
473
  if text.string != value { text.string = value }
347
- text.isEditable = props["disabled"] as? Bool != true; text.font = font
348
- if let editor = text as? CraftMacTextView { editor.placeholder = props["placeholder"] as? String ?? ""; editor.needsDisplay = true }
474
+ // `selection` ({ start, end }) moves the caret, e.g. after a formatting action changed the text.
475
+ if let want = props["selection"] as? [String: Any], let start = number(want["start"]), let end = number(want["end"]) {
476
+ let length = (text.string as NSString).length
477
+ let range = NSRange(location: min(Int(start), length), length: max(0, min(Int(end), length) - min(Int(start), length)))
478
+ if text.selectedRange() != range { lastSelection = "\(range.location):\(range.length)"; text.setSelectedRange(range) }
479
+ }
480
+ text.isEditable = props["disabled"] as? Bool != true
481
+ // Assigning `font` restyles the whole text, so a markdown editor only does it when the base font changes.
482
+ if let editor = text as? CraftMacTextView, props["markdown"] as? Bool == true {
483
+ if editor.baseFont != font { editor.baseFont = font; editor.font = font; editor.lastStyled = "\u{0}" }
484
+ } else { (text as? CraftMacTextView)?.baseFont = nil; text.font = font }
485
+ if let editor = text as? CraftMacTextView { let markdown = props["markdown"] as? Bool == true
486
+ if editor.markdown != markdown || (markdown && editor.string != editor.lastStyled) { editor.markdown = markdown; editor.restyle(); editor.lastStyled = editor.string }
487
+ editor.placeholder = props["placeholder"] as? String ?? ""; editor.ghost = props["ghostText"] as? String ?? ""; editor.needsDisplay = true }
349
488
  text.setAccessibilityIdentifier(props["testID"] as? String ?? props["testId"] as? String ?? id)
350
489
  text.setAccessibilityLabel(props["placeholder"] as? String ?? "Message")
351
490
  } else if let field = control as? NSTextField {
@@ -372,6 +511,167 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate {
372
511
  }; imageTask?.resume()
373
512
  }
374
513
 
514
+ /// `style` with the pointer's layers on top: `hoverStyle` while hovered, `pressStyle` while pressed.
515
+ var appearanceStyle: [String: Any] {
516
+ var look = style
517
+ if hovering, let hover = style["hoverStyle"] as? [String: Any] { hover.forEach { look[$0.key] = $0.value } }
518
+ if pressing, let press = style["pressStyle"] as? [String: Any] { press.forEach { look[$0.key] = $0.value } }
519
+ return look
520
+ }
521
+
522
+ /// Background, border, opacity, gradient and text colour, eased over `transitionDuration` (ms) when it changes.
523
+ func applyAppearance(animated: Bool) {
524
+ let look = appearanceStyle
525
+ let duration = animated && !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? (number(style["transitionDuration"]) ?? 0) / 1000 : 0
526
+ NSApp.effectiveAppearance.performAsCurrentDrawingAppearance {
527
+ ease("backgroundColor", to: CraftMacColor.parse(look["backgroundColor"], fallback: .clear).cgColor, duration)
528
+ ease("borderColor", to: CraftMacColor.parse(look["borderColor"], fallback: .separatorColor).cgColor, duration)
529
+ applyGradient(look, duration)
530
+ if kind == "Text", let text = control as? NSTextField, style["hoverStyle"] != nil || style["pressStyle"] != nil {
531
+ text.textColor = CraftMacColor.parse(look["color"])
532
+ }
533
+ }
534
+ let opacity = number(look["opacity"]) ?? 1
535
+ if duration > 0, abs(alphaValue - opacity) > 0.001 {
536
+ NSAnimationContext.runAnimationGroup { context in context.duration = duration; animator().alphaValue = opacity }
537
+ } else { alphaValue = opacity }
538
+ }
539
+
540
+ private func ease(_ key: String, to value: CGColor, _ duration: CFTimeInterval) {
541
+ guard let layer else { return }
542
+ if duration > 0, let from = (layer.presentation() ?? layer).value(forKey: key) {
543
+ let animation = CABasicAnimation(keyPath: key)
544
+ animation.fromValue = from; animation.toValue = value; animation.duration = duration
545
+ animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
546
+ layer.add(animation, forKey: key)
547
+ }
548
+ layer.setValue(value, forKey: key)
549
+ }
550
+
551
+ /// `bg-gradient-to-*` with `from-`, `via-` and `to-` colours, under the box's content as on the phone hosts.
552
+ private func applyGradient(_ look: [String: Any], _ duration: CFTimeInterval) {
553
+ guard let direction = look["gradientDirection"] as? String, look["gradientFrom"] != nil, look["gradientTo"] != nil else {
554
+ gradient?.removeFromSuperlayer(); gradient = nil; return
555
+ }
556
+ let layer = gradient ?? CAGradientLayer()
557
+ if gradient == nil { self.layer?.insertSublayer(layer, at: 0); gradient = layer; layer.frame = bounds }
558
+ let colors = [look["gradientFrom"], look["gradientVia"], look["gradientTo"]].compactMap { $0 }.map { CraftMacColor.parse($0, fallback: .clear).cgColor }
559
+ if duration > 0, let from = (layer.presentation() ?? layer).colors {
560
+ let animation = CABasicAnimation(keyPath: "colors")
561
+ animation.fromValue = from; animation.toValue = colors; animation.duration = duration
562
+ layer.add(animation, forKey: "colors")
563
+ }
564
+ layer.colors = colors
565
+ // The box is flipped (y grows downward), as UIKit is, so the phone hosts' points carry over.
566
+ let points: (CGPoint, CGPoint)
567
+ switch direction {
568
+ case "to-t": points = (CGPoint(x: 0.5, y: 1), CGPoint(x: 0.5, y: 0))
569
+ case "to-tr": points = (CGPoint(x: 0, y: 1), CGPoint(x: 1, y: 0))
570
+ case "to-r": points = (CGPoint(x: 0, y: 0.5), CGPoint(x: 1, y: 0.5))
571
+ case "to-br": points = (CGPoint(x: 0, y: 0), CGPoint(x: 1, y: 1))
572
+ case "to-bl": points = (CGPoint(x: 1, y: 0), CGPoint(x: 0, y: 1))
573
+ case "to-l": points = (CGPoint(x: 1, y: 0.5), CGPoint(x: 0, y: 0.5))
574
+ case "to-tl": points = (CGPoint(x: 1, y: 1), CGPoint(x: 0, y: 0))
575
+ default: points = (CGPoint(x: 0.5, y: 0), CGPoint(x: 0.5, y: 1))
576
+ }
577
+ layer.startPoint = points.0; layer.endPoint = points.1
578
+ }
579
+
580
+ override func updateTrackingAreas() {
581
+ super.updateTrackingAreas()
582
+ let wanted = style["hoverStyle"] is [String: Any] || style["pressStyle"] is [String: Any] || events["onHoverChange"] != nil
583
+ if !wanted, let area = hoverArea { removeTrackingArea(area); hoverArea = nil; hovering = false; pressing = false }
584
+ if wanted, hoverArea == nil {
585
+ let area = NSTrackingArea(rect: .zero, options: [.mouseEnteredAndExited, .activeInActiveApp, .inVisibleRect], owner: self)
586
+ addTrackingArea(area); hoverArea = area
587
+ }
588
+ }
589
+ override func mouseEntered(with event: NSEvent) {
590
+ guard hoverArea != nil, !hovering else { return }
591
+ hovering = true; applyAppearance(animated: true)
592
+ fire("onHoverChange", ["hovered": true])
593
+ }
594
+ override func mouseExited(with event: NSEvent) {
595
+ guard hovering || pressing else { return }
596
+ let was = hovering
597
+ hovering = false; pressing = false; applyAppearance(animated: true)
598
+ if was { fire("onHoverChange", ["hovered": false]) }
599
+ }
600
+
601
+ // MARK: drag and drop
602
+ // A `draggable` box starts a drag 3pt from where it was pressed, carrying `dragData` (and `dragURL`
603
+ // for other apps). A box with `onDrop`/`onDragOver` hears drags pass over it as `before`/`after`
604
+ // (or `inside`, with `dropInside`), with the data, links or files they carry.
605
+
606
+ override func mouseDragged(with event: NSEvent) {
607
+ guard props["draggable"] as? Bool == true, let start = downPoint,
608
+ hypot(event.locationInWindow.x - start.x, event.locationInWindow.y - start.y) > 3 else { super.mouseDragged(with: event); return }
609
+ downPoint = nil
610
+ if pressing { pressing = false; applyAppearance(animated: false) }
611
+ let item = NSPasteboardItem()
612
+ let data = props["dragData"] ?? NSNull()
613
+ if let encoded = try? JSONSerialization.data(withJSONObject: ["data": data], options: [.fragmentsAllowed]) { item.setData(encoded, forType: Self.dragType) }
614
+ if let url = props["dragURL"] as? String, !url.isEmpty { item.setString(url, forType: .URL); item.setString(url, forType: .string) }
615
+ let dragging = NSDraggingItem(pasteboardWriter: item)
616
+ dragging.setDraggingFrame(bounds, contents: snapshot())
617
+ beginDraggingSession(with: [dragging], event: event, source: self)
618
+ fire("onDragStart", ["data": data])
619
+ }
620
+
621
+ private func snapshot() -> NSImage {
622
+ guard bounds.width > 0, bounds.height > 0, let rep = bitmapImageRepForCachingDisplay(in: bounds) else { return NSImage() }
623
+ cacheDisplay(in: bounds, to: rep)
624
+ let image = NSImage(size: bounds.size); image.addRepresentation(rep)
625
+ return image
626
+ }
627
+
628
+ func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation {
629
+ context == .withinApplication ? .move : .copy
630
+ }
631
+ func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) {
632
+ fire("onDragEnd", ["dropped": operation != []])
633
+ }
634
+
635
+ override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { dragMoved(sender) }
636
+ override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { dragMoved(sender) }
637
+ override func draggingExited(_ sender: NSDraggingInfo?) { lastDropPosition = ""; fire("onDragLeave") }
638
+ override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
639
+ let position = dropPosition(at: convert(sender.draggingLocation, from: nil))
640
+ lastDropPosition = ""
641
+ fire("onDrop", dropPayload(sender.draggingPasteboard, position: position))
642
+ return true
643
+ }
644
+
645
+ /// Reported once per change of position, not on every pointer move.
646
+ private func dragMoved(_ sender: NSDraggingInfo) -> NSDragOperation {
647
+ let position = dropPosition(at: convert(sender.draggingLocation, from: nil))
648
+ if position != lastDropPosition { lastDropPosition = position; fire("onDragOver", dropPayload(sender.draggingPasteboard, position: position)) }
649
+ return sender.draggingPasteboard.types?.contains(Self.dragType) == true ? .move : .copy
650
+ }
651
+
652
+ /// The box is flipped, so y runs down: the top half is `before`; with `dropInside`, the middle half is `inside`.
653
+ func dropPosition(at point: NSPoint) -> String {
654
+ let fraction = bounds.height > 0 ? point.y / bounds.height : 0.5
655
+ if props["dropInside"] as? Bool == true { return fraction < 0.25 ? "before" : fraction > 0.75 ? "after" : "inside" }
656
+ return fraction < 0.5 ? "before" : "after"
657
+ }
658
+
659
+ func dropPayload(_ board: NSPasteboard, position: String) -> [String: Any] {
660
+ var payload: [String: Any] = ["position": position]
661
+ if let raw = board.data(forType: Self.dragType), let object = try? JSONSerialization.jsonObject(with: raw, options: [.fragmentsAllowed]) as? [String: Any] {
662
+ payload["data"] = object["data"] ?? NSNull(); payload["internal"] = true
663
+ }
664
+ let urls = board.readObjects(forClasses: [NSURL.self], options: nil) as? [URL] ?? []
665
+ let files = urls.filter(\.isFileURL).map(\.path), links = urls.filter { !$0.isFileURL }.map(\.absoluteString)
666
+ if !files.isEmpty { payload["files"] = files }
667
+ if !links.isEmpty { payload["urls"] = links }
668
+ return payload
669
+ }
670
+ override func mouseUp(with event: NSEvent) {
671
+ if pressing { pressing = false; applyAppearance(animated: true) }
672
+ super.mouseUp(with: event)
673
+ }
674
+
375
675
  var textPadding: NSEdgeInsets {
376
676
  let all = number(style["padding"]) ?? 0
377
677
  let horizontal = number(style["paddingHorizontal"]) ?? all
@@ -386,11 +686,12 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate {
386
686
  var size = CGSize(width: limit, height: 0)
387
687
  if let text = control as? NSTextField, kind == "Text" {
388
688
  let rect = (text.stringValue as NSString).boundingRect(with: CGSize(width: max(1, limit - 6 - textPadding.left - textPadding.right), height: 1_000_000), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: text.font ?? NSFont.systemFont(ofSize: 13)])
389
- size = CGSize(width: min(limit, ceil(rect.width) + 6 + textPadding.left + textPadding.right), height: max(18, ceil(rect.height) + 2))
689
+ size = CGSize(width: min(limit, ceil(rect.width) + 9 + textPadding.left + textPadding.right), height: max(18, ceil(rect.height) + 2))
390
690
  if text.maximumNumberOfLines > 0 { size.height = min(size.height, ceil((text.font?.ascender ?? 13) - (text.font?.descender ?? -3) + (text.font?.leading ?? 0)) * CGFloat(text.maximumNumberOfLines) + 2) }
391
691
  if text.maximumNumberOfLines == 1 { size.height = max(18, (text.font?.pointSize ?? 13) + 5) }
392
692
  size.height += textPadding.top + textPadding.bottom
393
693
  } else if kind == "ScrollView" || kind == "FlatList" || kind == "Sidebar" { size = CGSize(width: limit, height: 100) }
694
+ else if kind == "WebView" { size = CGSize(width: limit, height: 300) }
394
695
  else if kind == "Icon" { size = CGSize(width: 18, height: 18) }
395
696
  else if kind == "Image" || kind == "Video" { size = CGSize(width: limit, height: 160) }
396
697
  else if kind == "TextInput" { size = CGSize(width: limit, height: props["multiline"] as? Bool == true ? 48 : 28) }
@@ -419,6 +720,13 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate {
419
720
  control?.frame = CGRect(x: padding.left, y: padding.top, width: max(0, bounds.width - padding.left - padding.right), height: max(0, bounds.height - padding.top - padding.bottom))
420
721
  } else { control?.frame = bounds }
421
722
  material?.frame = bounds
723
+ gradient?.frame = bounds
724
+ gradient?.cornerRadius = layer?.cornerRadius ?? 0
725
+ // An explicit path spares Core Animation an offscreen pass to find the shadow's shape, every frame.
726
+ if let layer, layer.shadowOpacity > 0 {
727
+ layer.shadowPath = CGPath(roundedRect: bounds, cornerWidth: layer.cornerRadius, cornerHeight: layer.cornerRadius, transform: nil)
728
+ if let inner = control?.layer, inner.masksToBounds { inner.cornerRadius = layer.cornerRadius }
729
+ }
422
730
  for (edge, border) in edgeBorders {
423
731
  let thickness = number(style["border\(edge)Width"]) ?? 0
424
732
  switch edge {
@@ -447,18 +755,56 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate {
447
755
  }
448
756
  func controlTextDidChange(_ notification: Notification) { if let field = notification.object as? NSTextField { input(field.stringValue) } }
449
757
  func textDidChange(_ notification: Notification) { if let text = notification.object as? NSTextView { input(text.string) } }
758
+ /// The caret and selection, as UTF-16 offsets (JavaScript string indices): `onSelectionChange`.
759
+ func textViewDidChangeSelection(_ notification: Notification) {
760
+ guard let text = notification.object as? NSTextView, events["onSelectionChange"] != nil else { return }
761
+ let range = text.selectedRange()
762
+ let key = "\(range.location):\(range.length)"
763
+ guard key != lastSelection else { return }
764
+ lastSelection = key
765
+ fire("onSelectionChange", ["start": range.location, "end": range.location + range.length, "selection": ["start": range.location, "end": range.location + range.length], "nativeEvent": ["selection": ["start": range.location, "end": range.location + range.length]]])
766
+ }
450
767
  func input(_ value: String) { fire("onChangeText", ["text": value, "value": value, "nativeEvent": ["text": value, "value": value]]) }
451
768
  func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
769
+ if commandSelector == #selector(NSResponder.cancelOperation(_:)), (textView as? CraftMacTextView)?.ghost.isEmpty ?? true {
770
+ // Escape in an editor is the screen's too (it closes the open to-do), unless a completion is showing.
771
+ renderer?.runtime.send("CRAFT_EVENT", ["event": "craftDesktopCommand", "detail": ["id": "escape"]]); return true
772
+ }
773
+ if commandSelector == #selector(NSResponder.insertTab(_:)), let editor = textView as? CraftMacTextView, !editor.ghost.isEmpty, events["onAcceptCompletion"] != nil {
774
+ let accepted = editor.ghost; editor.ghost = ""; fire("onAcceptCompletion", ["text": accepted, "value": accepted]); return true
775
+ }
452
776
  if commandSelector == #selector(NSResponder.insertNewline(_:)), !NSEvent.modifierFlags.contains(.shift), events["onSubmitEditing"] != nil { fire("onSubmitEditing", ["text": textView.string]); return true }
453
777
  return false
454
778
  }
455
- override func mouseDown(with event: NSEvent) { if events["onPress"] != nil { fire("onPress") } else { super.mouseDown(with: event) } }
779
+ /// Single-line fields: Return submits, and leaving the field blurs it, as on the web.
780
+ func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
781
+ if commandSelector == #selector(NSResponder.cancelOperation(_:)) {
782
+ renderer?.runtime.send("CRAFT_EVENT", ["event": "craftDesktopCommand", "detail": ["id": "escape"]]); return true
783
+ }
784
+ if commandSelector == #selector(NSResponder.insertNewline(_:)), events["onSubmitEditing"] != nil { fire("onSubmitEditing", ["text": textView.string, "value": textView.string, "nativeEvent": ["text": textView.string]]); return true }
785
+ return false
786
+ }
787
+ func controlTextDidEndEditing(_ notification: Notification) {
788
+ guard let field = notification.object as? NSTextField else { return }
789
+ fire("onEndEditing", ["text": field.stringValue, "value": field.stringValue]); fire("onBlur", ["text": field.stringValue, "value": field.stringValue])
790
+ }
791
+ func textDidEndEditing(_ notification: Notification) {
792
+ guard let text = notification.object as? NSTextView else { return }
793
+ fire("onEndEditing", ["text": text.string, "value": text.string]); fire("onBlur", ["text": text.string, "value": text.string])
794
+ }
795
+ override func mouseDown(with event: NSEvent) {
796
+ downPoint = event.locationInWindow
797
+ if style["pressStyle"] is [String: Any] { pressing = true; applyAppearance(animated: false) }
798
+ if events["onPress"] != nil { fire("onPress") } else { super.mouseDown(with: event) }
799
+ }
456
800
  override func rightMouseDown(with event: NSEvent) { fire("onLongPress", ["x": event.locationInWindow.x, "y": event.locationInWindow.y]) }
457
801
  override func hitTest(_ point: NSPoint) -> NSView? {
458
802
  guard !isHidden, bounds.contains(convert(point, from: superview)) else { return nil }
459
803
  // Rows handle the whole click. Transcript text keeps AppKit selection,
460
804
  // while a right click is routed to the STX conversation/message menu.
461
805
  if kind == "Pressable", events["onPress"] != nil || (events["onLongPress"] != nil && NSApp.currentEvent?.type == .rightMouseDown) { return self }
806
+ // A draggable row takes the whole press, or its label would start a text selection instead of a drag.
807
+ if props["draggable"] as? Bool == true, NSApp.currentEvent?.type == .leftMouseDown { return self }
462
808
  return super.hitTest(point)
463
809
  }
464
810
  }
@@ -481,6 +827,9 @@ final class CraftMacSidebar: NSScrollView, NSOutlineViewDataSource, NSOutlineVie
481
827
  let outline = NSOutlineView()
482
828
  var sections: [Item] = []
483
829
  var fingerprint = ""
830
+ /// `rowHeight` and `labelFontSize` props: a Things-like sidebar is 24 points tall at 15.
831
+ var rowHeight: CGFloat = 32
832
+ var labelSize: CGFloat = 13
484
833
  init(box: CraftMacBox) {
485
834
  self.box = box; super.init(frame: .zero)
486
835
  hasVerticalScroller = true; drawsBackground = false
@@ -493,7 +842,11 @@ final class CraftMacSidebar: NSScrollView, NSOutlineViewDataSource, NSOutlineVie
493
842
  func configure(_ props: [String: Any]) {
494
843
  let raw = props["sections"] as? [[String: Any]] ?? []
495
844
  let data = (try? JSONSerialization.data(withJSONObject: raw, options: .sortedKeys)) ?? Data()
496
- let next = String(decoding: data, as: UTF8.self)
845
+ // Written as attributes (`rowHeight="24"`) they arrive as text.
846
+ func size(_ key: String) -> CGFloat? { number(props[key]) ?? (props[key] as? String).flatMap(Double.init).map { CGFloat($0) } }
847
+ rowHeight = size("rowHeight") ?? 32
848
+ labelSize = size("labelFontSize") ?? 13
849
+ let next = String(decoding: data, as: UTF8.self) + "|\(rowHeight)|\(labelSize)"
497
850
  if next != fingerprint { fingerprint = next; sections = raw.map(Item.init); outline.reloadData(); outline.expandItem(nil, expandChildren: true) }
498
851
  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 } } }
499
852
  }
@@ -505,7 +858,7 @@ final class CraftMacSidebar: NSScrollView, NSOutlineViewDataSource, NSOutlineVie
505
858
  func outlineView(_ outlineView: NSOutlineView, shouldShowOutlineCellForItem item: Any) -> Bool { false }
506
859
  func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat {
507
860
  let item = item as! Item
508
- return item.children.isEmpty ? 32 : item.label.isEmpty ? 12 : 28
861
+ return item.children.isEmpty ? rowHeight : item.label.isEmpty ? 12 : max(24, rowHeight - 4)
509
862
  }
510
863
  func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? {
511
864
  let row = CraftMacSidebarRow(); row.isEmphasized = false; return row
@@ -513,13 +866,13 @@ final class CraftMacSidebar: NSScrollView, NSOutlineViewDataSource, NSOutlineVie
513
866
  func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
514
867
  let item = item as! Item
515
868
  let cell = NSTableCellView(); let label = NSTextField(labelWithString: item.label)
516
- label.font = NSFont.systemFont(ofSize: 13); label.lineBreakMode = .byTruncatingTail; cell.textField = label; cell.addSubview(label)
869
+ label.font = NSFont.systemFont(ofSize: labelSize); label.lineBreakMode = .byTruncatingTail; cell.textField = label; cell.addSubview(label)
517
870
  if !item.children.isEmpty { label.font = NSFont.systemFont(ofSize: 11, weight: .semibold); label.textColor = .secondaryLabelColor }
518
871
  label.translatesAutoresizingMaskIntoConstraints = false
519
872
  let badge = NSTextField(labelWithString: item.badge); badge.font = NSFont.systemFont(ofSize: 11); badge.textColor = .secondaryLabelColor
520
873
  badge.translatesAutoresizingMaskIntoConstraints = false; badge.setContentHuggingPriority(.required, for: .horizontal); cell.addSubview(badge)
521
874
  NSLayoutConstraint.activate([label.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: item.icon.isEmpty ? 16 : 39), label.trailingAnchor.constraint(lessThanOrEqualTo: badge.leadingAnchor, constant: -8), label.centerYAnchor.constraint(equalTo: cell.centerYAnchor), badge.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -16), badge.centerYAnchor.constraint(equalTo: cell.centerYAnchor)])
522
- if !item.icon.isEmpty { let image = NSImageView(image: NSImage(systemSymbolName: item.icon, accessibilityDescription: nil) ?? NSImage()); image.contentTintColor = item.color; image.frame = CGRect(x: 16, y: 8, width: 16, height: 16); cell.imageView = image; cell.addSubview(image) }
875
+ if !item.icon.isEmpty { let image = NSImageView(image: NSImage(systemSymbolName: item.icon, accessibilityDescription: nil) ?? NSImage()); image.contentTintColor = item.color; image.frame = CGRect(x: 16, y: (rowHeight - 16) / 2, width: 16, height: 16); cell.imageView = image; cell.addSubview(image) }
523
876
  cell.setAccessibilityIdentifier(item.id); cell.setAccessibilityLabel(item.label)
524
877
  return cell
525
878
  }
@@ -581,6 +934,11 @@ final class CraftMacRenderer {
581
934
  modal.removeFromSuperview(); panel.contentView = modal; sheets[modal.id] = panel
582
935
  window.beginSheet(panel)
583
936
  }
937
+ if let panel = sheets[modal.id] {
938
+ // A sheet follows its content: rows loaded after it opened grow it, up to the screen's comfort.
939
+ let wanted = min(650, max(200, modal.measure(width: 480).height))
940
+ if abs(panel.contentRect(forFrameRect: panel.frame).height - wanted) > 1 { panel.setContentSize(CGSize(width: 480, height: wanted)) }
941
+ }
584
942
  modal.frame = sheets[modal.id]!.contentView!.bounds; modal.needsLayout = true; modal.layoutSubtreeIfNeeded()
585
943
  }
586
944
  for (box, origin, bottom) in scrolls {