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.
- package/README.md +7 -0
- package/dist/android/templates/CraftBridge.kt.template +2 -0
- package/dist/android/templates/CraftNative.kt.template +7 -0
- package/dist/android/templates/CraftNativeServices.kt.template +2 -1
- package/dist/android/templates/CraftNativeShell.kt.template +12 -2
- package/dist/android/templates/LocationRecordingService.kt.template +125 -6
- package/dist/android/templates/MainActivityNative.kt.template +21 -1
- package/dist/api/mobile.d.ts +59 -2
- package/dist/cli.js +1 -1
- package/dist/index.cjs +54 -7
- package/dist/index.js +54 -7
- package/dist/ios/src/index.d.ts +7 -0
- package/dist/ios/templates/CraftApp.swift +268 -12
- package/dist/ios/templates/CraftHybrid.swift +1 -0
- package/dist/ios/templates/CraftNativeActions.swift +3 -1
- package/dist/ios/templates/CraftNativeScreen.swift +1 -0
- package/dist/ios/templates/CraftNativeUI.swift +89 -0
- package/dist/ios/templates/testing/DriverTests.swift.template +17 -1
- package/dist/macos/src/index.d.ts +14 -3
- package/dist/macos/src/index.js +124 -27
- package/dist/macos/src/toolchain.d.ts +56 -0
- package/dist/macos/templates/CraftMacApplication.swift +54 -9
- package/dist/macos/templates/CraftMacRenderer.swift +376 -18
- package/dist/macos/templates/CraftMacRuntime.swift +114 -1
- package/dist/macos/templates/CraftMacWebView.swift +416 -0
- package/dist/mobile.js +53 -6
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
348
|
-
if let
|
|
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) +
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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:
|
|
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:
|
|
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 {
|