dsh-notch 0.3.1 → 0.3.6
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.en.md +58 -47
- package/README.md +60 -55
- package/cordis.patch.yml +3 -0
- package/desktop/README.md +29 -0
- package/desktop/notch-lifecycle.mjs +120 -0
- package/desktop/restart-probe.mjs +61 -0
- package/docs/desktop-017rc2.md +23 -0
- package/docs/elastic-edge-dock.md +127 -0
- package/docs/motion-audit.md +1 -1
- package/docs/releases/v0.3.2.md +38 -0
- package/docs/releases/v0.3.3.md +9 -0
- package/docs/subagent-compatibility.md +51 -0
- package/docs/verification/elastic-algorithm-timing.json +337 -0
- package/docs/verification/elastic-attention.json +44 -0
- package/docs/verification/elastic-frame-timing.json +240 -0
- package/docs/verification/elastic-tension.json +59 -0
- package/docs/verification/elastic-tip-taper.json +181 -0
- package/dshx.yml +2 -0
- package/lib/client.js +112 -0
- package/lib/client.js.map +1 -0
- package/lib/index.mjs +990 -0
- package/lib/index.mjs.map +1 -0
- package/package.json +66 -10
- package/src/board.ts +178 -35
- package/src/client/index.ts +104 -0
- package/src/dsh-notch.ts +19 -2
- package/src/http.ts +17 -2
- package/src/session-state.ts +15 -1
- package/src/store.ts +3 -3
- package/src/types.ts +3 -0
- package/BASELINE.md +0 -13
- package/DESIGN.md +0 -34
- package/cordis.yml +0 -5
- package/macos/Package.swift +0 -17
- package/macos/STATUS-MOTION.md +0 -49
- package/macos/Sources/Client.swift +0 -139
- package/macos/Sources/IdleRobot.swift +0 -423
- package/macos/Sources/NotchMarkdown.swift +0 -266
- package/macos/Sources/Panel.swift +0 -153
- package/macos/Sources/Resources/Idle/balance.json +0 -1
- package/macos/Sources/Resources/Idle/blink.json +0 -1
- package/macos/Sources/Resources/Idle/cube-in.json +0 -1
- package/macos/Sources/Resources/Idle/dance.json +0 -1
- package/macos/Sources/Resources/Idle/hop.json +0 -1
- package/macos/Sources/Resources/Idle/nod.json +0 -1
- package/macos/Sources/Resources/Idle/satellite-in.json +0 -1
- package/macos/Sources/Resources/Idle/satellite-out.json +0 -1
- package/macos/Sources/Resources/Idle/scan.json +0 -1
- package/macos/Sources/Resources/Idle/sleep.json +0 -1
- package/macos/Sources/Resources/Idle/sneeze.json +0 -1
- package/macos/Sources/Resources/Idle/stretch.json +0 -1
- package/macos/Sources/Resources/Idle/tilt.json +0 -1
- package/macos/Sources/ReviewStudio.swift +0 -337
- package/macos/Sources/RootView.swift +0 -1093
- package/macos/Sources/StatusOrbit.swift +0 -642
- package/macos/Sources/main.swift +0 -153
- package/macos/Tests/ExpandedHeightProbe.swift +0 -108
- package/macos/Tests/GeometryProbe.swift +0 -98
- package/macos/Tests/IdleProbe.swift +0 -294
- package/macos/Tests/MotionProbe.swift +0 -106
- package/macos/Tests/OutcomeProbe.swift +0 -133
- package/macos/Tests/ScrollbarProbe.swift +0 -91
- package/macos/Tests/expanded-height.sh +0 -13
- package/macos/Tests/geometry.sh +0 -14
- package/macos/Tests/idle.sh +0 -14
- package/macos/Tests/motion.sh +0 -14
- package/macos/Tests/outcome.sh +0 -14
- package/macos/Tests/scrollbar.sh +0 -13
- package/tests/answer-lifecycle.test.mjs +0 -23
- package/tests/board.test.mjs +0 -7
- package/tests/browse-sync.test.mjs +0 -20
- package/tests/mirror.test.mjs +0 -4
- package/tests/peer-range.test.mjs +0 -21
- package/tests/session-state.test.mjs +0 -54
- package/tests/unread-hold.test.mjs +0 -90
- package/tools/desktop-shell/README.md +0 -56
- package/tools/desktop-shell/child-log.mjs +0 -6
- package/tools/desktop-shell/child-log.test.mjs +0 -14
- package/tools/desktop-shell/package-recovery.py +0 -78
- package/tools/desktop-shell/recovery-probe.cjs +0 -50
- package/tools/desktop-shell/renderer-recovery.mjs +0 -95
- package/tools/desktop-shell/renderer-recovery.test.mjs +0 -24
- package/tools/diagnostics/completion-watch.py +0 -101
- package/tools/diagnostics/completion-watch.test.py +0 -17
- package/tools/diagnostics/renderer-diagnostics.mjs +0 -56
- package/tools/diagnostics/renderer-diagnostics.test.mjs +0 -32
- package/tools/idle/LICENSE.open-bot-motion +0 -21
- package/tools/idle/export.mjs +0 -40
- package/tools/idle/index.html +0 -4
- package/tools/idle/motions.js +0 -83
- package/tools/idle/original.js +0 -1673
- package/tools/idle/renderer.js +0 -1673
- package/tools/recording/Client.swift +0 -76
- package/tools/recording/DemoCatalog.swift +0 -174
- package/tools/recording/README.md +0 -23
- package/tools/recording/build.sh +0 -41
- package/tools/recording/main.swift +0 -100
- package/tools/review/ReviewStudio.swift +0 -236
- package/tools/review/audit.sh +0 -19
- package/tools/review/build.sh +0 -7
- package/tools/review/main.swift +0 -158
- package/tools/review/render-audit.swift +0 -32
|
@@ -1,423 +0,0 @@
|
|
|
1
|
-
import AppKit
|
|
2
|
-
import SwiftUI
|
|
3
|
-
|
|
4
|
-
struct IdleEye: Decodable {
|
|
5
|
-
var x: Double; var y: Double; var w: Double; var h: Double
|
|
6
|
-
var r: Double; var angle: Double; var opacity: Double
|
|
7
|
-
}
|
|
8
|
-
struct IdleFrame: Decodable {
|
|
9
|
-
var points: [[Double]]; var eyes: [IdleEye]; var body: Int; var eye: Int
|
|
10
|
-
}
|
|
11
|
-
struct IdleClip: Decodable {
|
|
12
|
-
var fps: Double; var duration: Double; var frames: [IdleFrame]
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
enum IdleInterpolation {
|
|
16
|
-
static func mix(_ a: IdleFrame, _ b: IdleFrame, _ t: Double) -> IdleFrame {
|
|
17
|
-
let w = max(0, min(1, t))
|
|
18
|
-
func n(_ x: Double, _ y: Double) -> Double { x + (y-x)*w }
|
|
19
|
-
func color(_ x: Int, _ y: Int) -> Int {
|
|
20
|
-
[16,8,0].reduce(0) { $0 | (Int(n(Double((x >> $1)&255), Double((y >> $1)&255)).rounded()) << $1) }
|
|
21
|
-
}
|
|
22
|
-
let points = zip(a.points,b.points).map { [n($0[0],$1[0]),n($0[1],$1[1])] }
|
|
23
|
-
let eyes = zip(a.eyes,b.eyes).map { e,f in
|
|
24
|
-
IdleEye(x:n(e.x,f.x),y:n(e.y,f.y),w:n(e.w,f.w),h:n(e.h,f.h),r:n(e.r,f.r),angle:n(e.angle,f.angle),opacity:n(e.opacity,f.opacity))
|
|
25
|
-
}
|
|
26
|
-
return IdleFrame(points:points,eyes:eyes,body:color(a.body,b.body),eye:color(a.eye,b.eye))
|
|
27
|
-
}
|
|
28
|
-
// Shape-preserving cubic Hermite interpolation: continuous velocity between
|
|
29
|
-
// authored samples, without overshoot or changing the clip's choreography.
|
|
30
|
-
static func sample(_ clip: IdleClip, at position: Double) -> IdleFrame {
|
|
31
|
-
let p = min(Double(clip.frames.count-1), max(0, position)), i = Int(p), t = p-Double(i)
|
|
32
|
-
let a=clip.frames[max(0,i-1)], b=clip.frames[i]
|
|
33
|
-
let c=clip.frames[min(i+1,clip.frames.count-1)], d=clip.frames[min(i+2,clip.frames.count-1)]
|
|
34
|
-
func curve(_ a:Double,_ b:Double,_ c:Double,_ d:Double) -> Double {
|
|
35
|
-
func slope(_ x:Double,_ y:Double)->Double { x*y > 0 ? 2*x*y/(x+y) : 0 }
|
|
36
|
-
let m=slope(b-a,c-b), n=slope(c-b,d-c), t2=t*t, t3=t2*t
|
|
37
|
-
return (2*t3-3*t2+1)*b+(t3-2*t2+t)*m+(-2*t3+3*t2)*c+(t3-t2)*n
|
|
38
|
-
}
|
|
39
|
-
var result=mix(b,c,t)
|
|
40
|
-
result.points=b.points.indices.map { j in (0...1).map { k in curve(a.points[j][k],b.points[j][k],c.points[j][k],d.points[j][k]) } }
|
|
41
|
-
result.eyes=b.eyes.indices.map { j in
|
|
42
|
-
let a=a.eyes[j],b=b.eyes[j],c=c.eyes[j],d=d.eyes[j]
|
|
43
|
-
return IdleEye(x:curve(a.x,b.x,c.x,d.x),y:curve(a.y,b.y,c.y,d.y),w:curve(a.w,b.w,c.w,d.w),h:curve(a.h,b.h,c.h,d.h),r:curve(a.r,b.r,c.r,d.r),angle:curve(a.angle,b.angle,c.angle,d.angle),opacity:curve(a.opacity,b.opacity,c.opacity,d.opacity))
|
|
44
|
-
}
|
|
45
|
-
return result
|
|
46
|
-
}
|
|
47
|
-
static func carry(_ source:IdleFrame, previous:IdleFrame?, seconds:Double) -> IdleFrame {
|
|
48
|
-
guard let previous else { return source }
|
|
49
|
-
var result=source
|
|
50
|
-
let distance=seconds*240
|
|
51
|
-
result.points=zip(source.points,previous.points).map { a,b in [a[0]+(a[0]-b[0])*distance,a[1]+(a[1]-b[1])*distance] }
|
|
52
|
-
result.eyes=zip(source.eyes,previous.eyes).map { a,b in
|
|
53
|
-
func n(_ x:Double,_ y:Double)->Double { x+(x-y)*distance }
|
|
54
|
-
return IdleEye(x:n(a.x,b.x),y:n(a.y,b.y),w:max(0,n(a.w,b.w)),h:max(0,n(a.h,b.h)),r:max(0,n(a.r,b.r)),angle:n(a.angle,b.angle),opacity:min(1,max(0,n(a.opacity,b.opacity))))
|
|
55
|
-
}
|
|
56
|
-
return result
|
|
57
|
-
}
|
|
58
|
-
static func smooth(_ value: Double) -> Double {
|
|
59
|
-
let t=max(0,min(1,value));return t*t*t*(t*(t*6-15)+10)
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
@MainActor
|
|
64
|
-
final class IdleLibrary {
|
|
65
|
-
static let shared = IdleLibrary()
|
|
66
|
-
private var clips: [String: IdleClip] = [:]
|
|
67
|
-
func clip(_ id: String) -> IdleClip? {
|
|
68
|
-
if let clip = clips[id] { return clip }
|
|
69
|
-
guard let url = Bundle.module.url(forResource: id, withExtension: "json", subdirectory: "Idle"),
|
|
70
|
-
let data = try? Data(contentsOf: url), let clip = try? JSONDecoder().decode(IdleClip.self, from: data),
|
|
71
|
-
!clip.frames.isEmpty else { return nil }
|
|
72
|
-
clips[id] = clip
|
|
73
|
-
return clip
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/// Only advances while an idle robot is visible. Screen sleep never queues overdue gags.
|
|
78
|
-
@MainActor
|
|
79
|
-
final class IdleDirector: ObservableObject {
|
|
80
|
-
static weak var previewInstance: IdleDirector?
|
|
81
|
-
static let basics = ["blink", "scan", "tilt", "nod", "stretch", "hop", "balance", "sneeze", "sleep"]
|
|
82
|
-
@Published var action: String? = "blink"
|
|
83
|
-
@Published var began = Date()
|
|
84
|
-
@Published var asleep = false
|
|
85
|
-
private(set) var currentDuration: Double = 7
|
|
86
|
-
static func restDuration() -> Double { Double.random(in: 5...10) }
|
|
87
|
-
private var timer: Timer?
|
|
88
|
-
private var nextBasic = Date()
|
|
89
|
-
private var nextRare = Date()
|
|
90
|
-
private var previous = "blink"
|
|
91
|
-
private var blendFrom: IdleFrame?
|
|
92
|
-
private var blendPrevious: IdleFrame?
|
|
93
|
-
private var blendBegan = Date()
|
|
94
|
-
private let blinkEpoch=Date()
|
|
95
|
-
var animating: Bool { timer != nil || action != nil || blendFrom != nil }
|
|
96
|
-
static func blinkClosure(at elapsed:Double) -> Double {
|
|
97
|
-
let durations=[3.7,4.9,3.2,4.6,4.1,3.5]
|
|
98
|
-
let cycle=durations.reduce(0,+)
|
|
99
|
-
var phase=max(0,elapsed).truncatingRemainder(dividingBy:cycle)
|
|
100
|
-
for duration in durations {
|
|
101
|
-
if phase < duration {
|
|
102
|
-
let t=phase-(duration-0.28)
|
|
103
|
-
guard t >= 0 else { return 0 }
|
|
104
|
-
return t < 0.09 ? IdleInterpolation.smooth(t/0.09) : 1-IdleInterpolation.smooth((t-0.09)/0.19)
|
|
105
|
-
}
|
|
106
|
-
phase-=duration
|
|
107
|
-
}
|
|
108
|
-
return 0
|
|
109
|
-
}
|
|
110
|
-
func displayFrame(at now:Date)->IdleFrame? {
|
|
111
|
-
guard var f=frame(at:now) else { return nil }
|
|
112
|
-
guard !reduceMotion,action != "sleep",action != "blink",action != "sneeze" else { return f }
|
|
113
|
-
let close=Self.blinkClosure(at:now.timeIntervalSince(blinkEpoch))
|
|
114
|
-
f.eyes=f.eyes.map { eye in var e=eye;e.h=max(0.5,e.h*(1-0.95*close));e.r=min(e.r,e.h/2);return e }
|
|
115
|
-
return f
|
|
116
|
-
}
|
|
117
|
-
func frame(at now: Date) -> IdleFrame? {
|
|
118
|
-
guard let neutral=IdleLibrary.shared.clip("blink")?.frames.first else { return nil }
|
|
119
|
-
var target=neutral
|
|
120
|
-
if let id=action,let clip=IdleLibrary.shared.clip(id) {
|
|
121
|
-
let elapsed=max(0,now.timeIntervalSince(began))
|
|
122
|
-
let position=min(elapsed*clip.fps,Double(clip.frames.count-1))
|
|
123
|
-
target=IdleInterpolation.sample(clip,at:position)
|
|
124
|
-
if id == "dance" {
|
|
125
|
-
let weight=IdleInterpolation.smooth(min(elapsed/0.35,(currentDuration-elapsed)/0.45))
|
|
126
|
-
target=IdleInterpolation.mix(neutral,target,weight)
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
if let source=blendFrom {
|
|
130
|
-
let elapsed=max(0,now.timeIntervalSince(blendBegan))
|
|
131
|
-
let moving=IdleInterpolation.carry(source,previous:blendPrevious,seconds:0.06*(1-exp(-elapsed/0.06)))
|
|
132
|
-
return IdleInterpolation.mix(moving,target,IdleInterpolation.smooth(elapsed/0.35))
|
|
133
|
-
}
|
|
134
|
-
return target
|
|
135
|
-
}
|
|
136
|
-
private func beginBlend(from source: IdleFrame?, at now: Date) {
|
|
137
|
-
let previous=frame(at:now.addingTimeInterval(-1.0/240))
|
|
138
|
-
blendPrevious=previous;blendFrom=source;blendBegan=now
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
var automaticActions = true
|
|
142
|
-
private var sequence = 0
|
|
143
|
-
private var observers: [NSObjectProtocol] = []
|
|
144
|
-
var reduceMotion: Bool { NSWorkspace.shared.accessibilityDisplayShouldReduceMotion }
|
|
145
|
-
func start(playImmediately: Bool = true) {
|
|
146
|
-
Self.previewInstance = self
|
|
147
|
-
guard timer == nil else { return }
|
|
148
|
-
blendFrom = nil
|
|
149
|
-
currentDuration = IdleLibrary.shared.clip("blink")?.duration ?? 7
|
|
150
|
-
began = Date(); action = reduceMotion || !playImmediately ? nil : "blink"
|
|
151
|
-
schedule(from: began)
|
|
152
|
-
let nc = NSWorkspace.shared.notificationCenter
|
|
153
|
-
observers = [nc.addObserver(forName: NSWorkspace.screensDidSleepNotification, object: nil, queue: .main) { [weak self] _ in
|
|
154
|
-
Task { @MainActor in self?.asleep = true; self?.action = nil }
|
|
155
|
-
}, nc.addObserver(forName: NSWorkspace.screensDidWakeNotification, object: nil, queue: .main) { [weak self] _ in
|
|
156
|
-
Task { @MainActor in guard let self else { return }; self.asleep = false; self.schedule(from: Date()) }
|
|
157
|
-
}]
|
|
158
|
-
timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
|
159
|
-
Task { @MainActor in self?.tick(Date()) }
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
private func schedule(from date: Date) {
|
|
163
|
-
nextBasic = date.addingTimeInterval(Self.restDuration())
|
|
164
|
-
nextRare = date.addingTimeInterval(Double.random(in: 1200...2400))
|
|
165
|
-
}
|
|
166
|
-
func tick(_ now: Date) {
|
|
167
|
-
guard !asleep, !reduceMotion else { blendFrom = nil; action = nil; return }
|
|
168
|
-
if blendFrom != nil && now.timeIntervalSince(blendBegan) >= 0.35 { blendFrom = nil; objectWillChange.send() }
|
|
169
|
-
if action != nil {
|
|
170
|
-
let duration = currentDuration
|
|
171
|
-
if now.timeIntervalSince(began) >= duration { let source=frame(at:now); beginBlend(from:source,at:now); self.action = nil; nextBasic = now.addingTimeInterval(Self.restDuration()) }
|
|
172
|
-
return
|
|
173
|
-
}
|
|
174
|
-
guard automaticActions else { return }
|
|
175
|
-
if now >= nextRare {
|
|
176
|
-
play("dance"); nextRare = now.addingTimeInterval(Double.random(in: 1200...2400))
|
|
177
|
-
} else if now >= nextBasic {
|
|
178
|
-
play(Self.basics.filter { $0 != previous }.randomElement() ?? "blink")
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
func play(_ id: String, at now: Date = Date()) {
|
|
182
|
-
guard !asleep, !reduceMotion else { return }
|
|
183
|
-
guard IdleLibrary.shared.clip(id) != nil else { return }
|
|
184
|
-
let source=frame(at:now)
|
|
185
|
-
beginBlend(from:source,at:now)
|
|
186
|
-
currentDuration = id == "dance" ? Double.random(in: 3...5) : (IdleLibrary.shared.clip(id)?.duration ?? 7)
|
|
187
|
-
previous = id; began = now; action = id
|
|
188
|
-
if id == "dance" { nextRare = began.addingTimeInterval(Double.random(in: 1200...2400)) }
|
|
189
|
-
}
|
|
190
|
-
func resume(_ id: String, elapsed: Double, from source: IdleFrame? = nil) {
|
|
191
|
-
guard !reduceMotion, !asleep else { return }
|
|
192
|
-
beginBlend(from:source,at:Date()); blendPrevious=source
|
|
193
|
-
currentDuration = id == "dance" ? Double.random(in: 3...5) : (IdleLibrary.shared.clip(id)?.duration ?? 7)
|
|
194
|
-
previous = id; action = id; began = Date().addingTimeInterval(-elapsed)
|
|
195
|
-
}
|
|
196
|
-
func tryNext() { play(Self.basics[sequence % Self.basics.count]); sequence += 1 }
|
|
197
|
-
func stop() {
|
|
198
|
-
if Self.previewInstance === self { Self.previewInstance = nil }
|
|
199
|
-
timer?.invalidate(); timer = nil; action = nil; blendFrom = nil
|
|
200
|
-
for observer in observers { NSWorkspace.shared.notificationCenter.removeObserver(observer) }
|
|
201
|
-
observers.removeAll()
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
struct IdleRobotCanvas: View {
|
|
206
|
-
let clip: IdleClip?
|
|
207
|
-
let elapsed: Double
|
|
208
|
-
var visibility: Double = 1
|
|
209
|
-
var entering: Bool = false
|
|
210
|
-
var entryColor: Int? = nil
|
|
211
|
-
var exitColor: Int = 0x4d6bfe
|
|
212
|
-
private func color(_ rgb: Int) -> Color {
|
|
213
|
-
Color(red: Double((rgb >> 16) & 255) / 255, green: Double((rgb >> 8) & 255) / 255, blue: Double(rgb & 255) / 255)
|
|
214
|
-
}
|
|
215
|
-
var body: some View {
|
|
216
|
-
let neutralFrame = IdleLibrary.shared.clip("blink")?.frames.first
|
|
217
|
-
return Canvas { context, size in
|
|
218
|
-
guard let clip, !clip.frames.isEmpty else { return }
|
|
219
|
-
// Sampled vector paths are interpolated at display cadence, never enlarged bitmaps.
|
|
220
|
-
let position = min(max(0, elapsed * clip.fps), Double(clip.frames.count - 1))
|
|
221
|
-
let index = Int(position), amount = position - Double(index)
|
|
222
|
-
let a = clip.frames[index], b = clip.frames[min(index + 1, clip.frames.count - 1)]
|
|
223
|
-
let edge = clip.duration > 10 ? min(1, max(0, min(elapsed / 0.35, (clip.duration - elapsed) / 0.45))) : 1
|
|
224
|
-
let danceBlend = edge * edge * (3 - 2 * edge)
|
|
225
|
-
func mix(_ x: Double, _ y: Double) -> Double { x + (y - x) * amount }
|
|
226
|
-
func mixedColor(_ x: Int, _ y: Int) -> Color {
|
|
227
|
-
Color(red: mix(Double((x >> 16) & 255), Double((y >> 16) & 255)) / 255,
|
|
228
|
-
green: mix(Double((x >> 8) & 255), Double((y >> 8) & 255)) / 255,
|
|
229
|
-
blue: mix(Double(x & 255), Double(y & 255)) / 255)
|
|
230
|
-
}
|
|
231
|
-
context.translateBy(x: size.width / 2, y: size.height / 2)
|
|
232
|
-
context.scaleBy(x: 40.0 / 280.0, y: 40.0 / 280.0)
|
|
233
|
-
var centerX = 0.0, centerY = 0.0
|
|
234
|
-
for i in a.points.indices {
|
|
235
|
-
let n = neutralFrame?.points[i] ?? a.points[i]
|
|
236
|
-
centerX += mix(a.points[i][0], b.points[i][0]) * danceBlend + n[0] * (1-danceBlend)
|
|
237
|
-
centerY += mix(a.points[i][1], b.points[i][1]) * danceBlend + n[1] * (1-danceBlend)
|
|
238
|
-
}
|
|
239
|
-
centerX /= Double(a.points.count); centerY /= Double(a.points.count)
|
|
240
|
-
var bodyPath = Path()
|
|
241
|
-
for i in a.points.indices {
|
|
242
|
-
let q = a.points[i], r = b.points[i]
|
|
243
|
-
let n = neutralFrame?.points[i] ?? q
|
|
244
|
-
let x = mix(q[0], r[0]) * danceBlend + n[0] * (1 - danceBlend)
|
|
245
|
-
let y = mix(q[1], r[1]) * danceBlend + n[1] * (1 - danceBlend)
|
|
246
|
-
let angle = atan2(y - centerY, x - centerX)
|
|
247
|
-
let radius = (entering ? 9.5:5.7) * 280.0 / 40.0
|
|
248
|
-
let point = CGPoint(x: x * visibility + cos(angle) * radius * (1 - visibility),
|
|
249
|
-
y: y * visibility + sin(angle) * radius * (1 - visibility))
|
|
250
|
-
if i == 0 { bodyPath.move(to: point) } else { bodyPath.addLine(to: point) }
|
|
251
|
-
}
|
|
252
|
-
bodyPath.closeSubpath()
|
|
253
|
-
// Dance begins and ends through the neutral body, retaining original bounce speed.
|
|
254
|
-
let pigment = mixedColor(a.body, b.body)
|
|
255
|
-
var filled = context
|
|
256
|
-
filled.opacity = entering ? 1 : max(0, (visibility - 0.30) / 0.70)
|
|
257
|
-
let entryBlend=visibility
|
|
258
|
-
let origin=entryColor ?? 0x4d6bfe
|
|
259
|
-
func entryChannel(_ shift:Int,_ end:Double)->Double { (Double((origin >> shift)&255)*(1-entryBlend)+end*entryBlend)/255 }
|
|
260
|
-
let entryInk=Color(red:entryChannel(16,229),green:entryChannel(8,229),blue:entryChannel(0,231))
|
|
261
|
-
filled.fill(bodyPath, with: .color(entering ? entryInk : color(0xe5e5e7)))
|
|
262
|
-
var painted = filled; painted.opacity *= danceBlend * (entering ? entryBlend:1); painted.fill(bodyPath, with: .color(pigment))
|
|
263
|
-
if !entering && visibility < 1 {
|
|
264
|
-
var outline = context; outline.opacity = min(1, (1 - visibility) * 3) * min(1, visibility * 4)
|
|
265
|
-
func inkChannel(_ shift: Int, _ neutral: Double, _ blue: Double) -> Double {
|
|
266
|
-
let current = mix(Double((a.body >> shift) & 255), Double((b.body >> shift) & 255)) * danceBlend + neutral * (1-danceBlend)
|
|
267
|
-
return (current * visibility + Double((exitColor >> shift)&255) * (1-visibility))/255
|
|
268
|
-
}
|
|
269
|
-
let ink = Color(red: inkChannel(16,229,77), green: inkChannel(8,229,107), blue: inkChannel(0,231,254))
|
|
270
|
-
outline.stroke(bodyPath, with: .color(ink), lineWidth: 1.7 * 280/40)
|
|
271
|
-
}
|
|
272
|
-
context.clip(to: bodyPath)
|
|
273
|
-
for i in a.eyes.indices where i < b.eyes.count {
|
|
274
|
-
let rawE = a.eyes[i], rawF = b.eyes[i]
|
|
275
|
-
let neutral = neutralFrame?.eyes[i] ?? rawE
|
|
276
|
-
func blendEye(_ v: IdleEye) -> IdleEye {
|
|
277
|
-
func blend(_ x: Double, _ n: Double) -> Double { x*danceBlend+n*(1-danceBlend) }
|
|
278
|
-
return IdleEye(x:blend(v.x,neutral.x),y:blend(v.y,neutral.y),w:blend(v.w,neutral.w),h:blend(v.h,neutral.h),r:blend(v.r,neutral.r),angle:blend(v.angle,neutral.angle),opacity:blend(v.opacity,neutral.opacity))
|
|
279
|
-
}
|
|
280
|
-
let e = blendEye(rawE), f = blendEye(rawF)
|
|
281
|
-
var eyeContext = context
|
|
282
|
-
eyeContext.translateBy(x: mix(e.x, f.x) * visibility, y: mix(e.y, f.y) * visibility)
|
|
283
|
-
eyeContext.rotate(by: .degrees(mix(e.angle, f.angle)))
|
|
284
|
-
eyeContext.opacity = mix(e.opacity, f.opacity) * max(0, min(1, (visibility - 0.60) / 0.40))
|
|
285
|
-
let w = mix(e.w, f.w), h = mix(e.h, f.h), r = mix(e.r, f.r)
|
|
286
|
-
eyeContext.fill(Path(roundedRect: CGRect(x: -w/2, y: -h/2, width: w, height: h), cornerRadius: r), with: .color(mixedColor(a.eye, b.eye)))
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
.allowsHitTesting(false)
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
struct RobotDeparture {
|
|
294
|
-
let progress:Double
|
|
295
|
-
static let duration=1.22
|
|
296
|
-
private var robotProgress:Double { progress*Self.duration/0.9 }
|
|
297
|
-
var scale:Double { 1-0.94*IdleInterpolation.smooth((robotProgress-0.48)/0.20) }
|
|
298
|
-
var opacity:Double { 1-IdleInterpolation.smooth((robotProgress-0.68)/0.06) }
|
|
299
|
-
var statusOpacity:Double { IdleInterpolation.smooth((robotProgress-0.68)/0.06) }
|
|
300
|
-
var statusScale:Double { min(1,max(0,(progress*Self.duration-0.666)/(Self.duration-0.666))) }
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
@MainActor
|
|
304
|
-
final class IdlePresence: ObservableObject {
|
|
305
|
-
@Published var visibility: Double = 1
|
|
306
|
-
var returnColor: Int = 0x4d6bfe
|
|
307
|
-
@Published var entering = true
|
|
308
|
-
@Published var transitioning = false
|
|
309
|
-
@Published var departureProgress = 1.0
|
|
310
|
-
private var transitionAt=Date()
|
|
311
|
-
private var transitionIdle=true
|
|
312
|
-
private var blendReversal=false
|
|
313
|
-
func presentedFrame(at now:Date)->IdleFrame? {
|
|
314
|
-
guard transitioning,let clip=IdleLibrary.shared.clip(transitionIdle ? "cube-in":"satellite-out") else { return coastFrame(at:now) }
|
|
315
|
-
let elapsed=max(0,now.timeIntervalSince(transitionAt))
|
|
316
|
-
let target=IdleInterpolation.sample(clip,at:elapsed*clip.fps)
|
|
317
|
-
guard (!transitionIdle || blendReversal),let source=coastFrame(at:now) else { return target }
|
|
318
|
-
return IdleInterpolation.mix(source,target,IdleInterpolation.smooth(elapsed/0.22))
|
|
319
|
-
}
|
|
320
|
-
var frozenFrame: IdleFrame?
|
|
321
|
-
var frozenID = "blink"
|
|
322
|
-
var frozenElapsed = 0.0
|
|
323
|
-
private var frozenPrevious: IdleFrame?
|
|
324
|
-
private var frozenAt=Date()
|
|
325
|
-
func coastFrame(at now:Date)->IdleFrame? {
|
|
326
|
-
guard let frozenFrame else { return nil }
|
|
327
|
-
let t=max(0,now.timeIntervalSince(frozenAt))
|
|
328
|
-
return IdleInterpolation.carry(frozenFrame,previous:frozenPrevious,seconds:0.06*(1-exp(-t/0.06)))
|
|
329
|
-
}
|
|
330
|
-
private var timer: Timer?
|
|
331
|
-
private var generation = 0
|
|
332
|
-
func set(_ idle: Bool, director: IdleDirector, animated: Bool = true) {
|
|
333
|
-
let reversing=transitioning
|
|
334
|
-
if reversing { frozenFrame=presentedFrame(at:Date());frozenPrevious=nil;frozenAt=Date() }
|
|
335
|
-
blendReversal=reversing
|
|
336
|
-
timer?.invalidate(); timer = nil
|
|
337
|
-
generation += 1; let current = generation
|
|
338
|
-
if !idle {
|
|
339
|
-
if visibility >= 1 && !blendReversal {
|
|
340
|
-
frozenAt=Date()
|
|
341
|
-
frozenPrevious=director.displayFrame(at:frozenAt.addingTimeInterval(-1.0/240))
|
|
342
|
-
frozenFrame = director.displayFrame(at:frozenAt)
|
|
343
|
-
frozenID = director.action ?? "blink"
|
|
344
|
-
frozenElapsed = director.action == nil ? 0 : max(0, Date().timeIntervalSince(director.began))
|
|
345
|
-
}
|
|
346
|
-
director.stop()
|
|
347
|
-
} else {
|
|
348
|
-
frozenFrame=coastFrame(at:Date());frozenPrevious=nil
|
|
349
|
-
director.start(playImmediately: false)
|
|
350
|
-
// Continue the same geometry when a partially completed transition reverses.
|
|
351
|
-
if visibility == 0 { frozenID = "blink"; frozenElapsed = 0; frozenFrame = IdleLibrary.shared.clip("blink")?.frames.first }
|
|
352
|
-
}
|
|
353
|
-
// Preserve circle geometry when reversing mid-flight.
|
|
354
|
-
if !blendReversal { entering = idle }
|
|
355
|
-
let start = visibility, end = idle ? 1.0 : 0.0
|
|
356
|
-
transitionAt=Date();transitionIdle=idle;transitioning=animated
|
|
357
|
-
if !idle { departureProgress=animated ? 0:1 }
|
|
358
|
-
|
|
359
|
-
guard animated, !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else { transitioning=false;visibility = end; return }
|
|
360
|
-
let began = ProcessInfo.processInfo.systemUptime
|
|
361
|
-
timer = Timer.scheduledTimer(withTimeInterval: 1/60.0, repeats: true) { [weak self] _ in
|
|
362
|
-
Task { @MainActor in
|
|
363
|
-
guard let self, self.generation == current else { return }
|
|
364
|
-
let t = min(1, (ProcessInfo.processInfo.systemUptime - began) / (idle ? 1.05 : RobotDeparture.duration))
|
|
365
|
-
if !idle { self.departureProgress=t }
|
|
366
|
-
let smooth = idle ? IdleInterpolation.smooth(t/0.32) : IdleInterpolation.smooth((t-0.68)/0.32)
|
|
367
|
-
self.visibility = start + (end-start)*smooth
|
|
368
|
-
if t >= 1 {
|
|
369
|
-
self.timer?.invalidate(); self.timer = nil
|
|
370
|
-
let final=self.presentedFrame(at:Date())
|
|
371
|
-
self.transitioning=false
|
|
372
|
-
if idle { director.resume("blink", elapsed:0, from:final) }
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
func stop() { generation += 1; timer?.invalidate(); timer = nil }
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
struct IdleStatusSlot: View {
|
|
381
|
-
@ObservedObject var model: BoardModel
|
|
382
|
-
@StateObject private var director = IdleDirector()
|
|
383
|
-
@StateObject private var presence = IdlePresence()
|
|
384
|
-
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
385
|
-
private var idle: Bool { model.showsIdleRobot }
|
|
386
|
-
private var hasStatus: Bool { model.orbitLayout.total > 0.0001 || model.anyFailed || model.completedUnreadCount > 0 || model.busyCount > 0 || model.statusFlight != nil }
|
|
387
|
-
var body: some View {
|
|
388
|
-
let departing = presence.transitioning && !presence.entering
|
|
389
|
-
let departure = RobotDeparture(progress:presence.departureProgress)
|
|
390
|
-
ZStack {
|
|
391
|
-
if hasStatus {
|
|
392
|
-
StatusOrbitView(model: model,workReveal:departing ? departure.statusScale:1)
|
|
393
|
-
.opacity(departing ? departure.statusOpacity:1-presence.visibility)
|
|
394
|
-
}
|
|
395
|
-
if presence.visibility > 0 || idle {
|
|
396
|
-
TimelineView(.animation(minimumInterval: 1/60.0, paused: (!director.animating && !presence.transitioning && presence.visibility >= 1) || director.asleep || reduceMotion)) { timeline in
|
|
397
|
-
let stable = idle && !presence.transitioning
|
|
398
|
-
let frame = stable ? director.displayFrame(at: timeline.date) : presence.presentedFrame(at:timeline.date)
|
|
399
|
-
let sampled = frame.map { IdleClip(fps:1,duration:7,frames:[$0]) }
|
|
400
|
-
IdleRobotCanvas(clip: sampled, elapsed: 0, visibility: departing ? 1:presence.visibility, entering: presence.entering, entryColor:presence.returnColor,exitColor:model.needsAction ? 0xf2ff14:0x4d6bfe)
|
|
401
|
-
}
|
|
402
|
-
.frame(width: 30, height: 42)
|
|
403
|
-
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
|
404
|
-
.scaleEffect(departing ? departure.scale:1)
|
|
405
|
-
.opacity(departing ? departure.opacity:1)
|
|
406
|
-
.accessibilityLabel("待机机器人")
|
|
407
|
-
.contextMenu {
|
|
408
|
-
if idle {
|
|
409
|
-
Button("试试下一个待机动作") { director.tryNext() }
|
|
410
|
-
Button("播放变色跳舞彩蛋") { director.play("dance") }
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
.frame(height: !hasStatus && !idle ? 0 : nil)
|
|
416
|
-
.onAppear { presence.set(idle, director: director, animated: false) }
|
|
417
|
-
.onChange(of: idle) { _, value in
|
|
418
|
-
if value { presence.returnColor=model.orbitLayout.decision > 0.1 ? 0xf2ff14:model.retainedFailureCount > 0 ? 0xff4000:model.retainedSuccessCount > 0 ? 0x34c759:0x4d6bfe }
|
|
419
|
-
presence.set(value, director: director)
|
|
420
|
-
}
|
|
421
|
-
.onDisappear { presence.stop(); director.stop() }
|
|
422
|
-
}
|
|
423
|
-
}
|
|
@@ -1,266 +0,0 @@
|
|
|
1
|
-
import AppKit
|
|
2
|
-
import SwiftUI
|
|
3
|
-
|
|
4
|
-
/// Block Markdown using DSH body type: 14pt system, 12pt SF Mono.
|
|
5
|
-
enum NotchMarkdown {
|
|
6
|
-
static let bodySize: CGFloat = 14
|
|
7
|
-
static let headingSize: CGFloat = 17
|
|
8
|
-
static let codeSize: CGFloat = 12
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
struct NotchMarkdownView: View {
|
|
12
|
-
let source: String
|
|
13
|
-
|
|
14
|
-
var body: some View {
|
|
15
|
-
VStack(alignment: .leading, spacing: 10) {
|
|
16
|
-
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
|
|
17
|
-
blockView(block)
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
.frame(maxWidth: .infinity, alignment: .leading)
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
private var blocks: [Block] { Block.parse(source) }
|
|
24
|
-
|
|
25
|
-
@ViewBuilder
|
|
26
|
-
private func blockView(_ block: Block) -> some View {
|
|
27
|
-
switch block {
|
|
28
|
-
case .heading(let level, let text):
|
|
29
|
-
inline(text)
|
|
30
|
-
.font(.system(size: level <= 1 ? 18 : level == 2 ? 16 : 15, weight: .semibold))
|
|
31
|
-
.foregroundStyle(.white)
|
|
32
|
-
.padding(.top, level <= 2 ? 8 : 4)
|
|
33
|
-
case .paragraph(let text):
|
|
34
|
-
inline(text)
|
|
35
|
-
.font(.system(size: NotchMarkdown.bodySize))
|
|
36
|
-
.foregroundStyle(Color.white.opacity(0.9))
|
|
37
|
-
.lineSpacing(6)
|
|
38
|
-
case .list(let items, let ordered):
|
|
39
|
-
VStack(alignment: .leading, spacing: 4) {
|
|
40
|
-
ForEach(Array(items.enumerated()), id: \.offset) { i, item in
|
|
41
|
-
HStack(alignment: .top, spacing: 8) {
|
|
42
|
-
Text(ordered ? "\(i + 1)." : "•")
|
|
43
|
-
.font(.system(size: NotchMarkdown.bodySize, weight: .medium))
|
|
44
|
-
.foregroundStyle(Color.white.opacity(0.55))
|
|
45
|
-
.frame(width: 18, alignment: .trailing)
|
|
46
|
-
inline(item)
|
|
47
|
-
.font(.system(size: NotchMarkdown.bodySize))
|
|
48
|
-
.foregroundStyle(Color.white.opacity(0.9))
|
|
49
|
-
.lineSpacing(5)
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
case .table(let headers, let rows):
|
|
54
|
-
table(headers: headers, rows: rows)
|
|
55
|
-
case .code(let text):
|
|
56
|
-
Text(text)
|
|
57
|
-
.font(.system(size: NotchMarkdown.codeSize, design: .monospaced))
|
|
58
|
-
.foregroundStyle(Color.white.opacity(0.92))
|
|
59
|
-
.frame(maxWidth: .infinity, alignment: .leading)
|
|
60
|
-
.padding(10)
|
|
61
|
-
.background(
|
|
62
|
-
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
63
|
-
.fill(Color.white.opacity(0.07))
|
|
64
|
-
)
|
|
65
|
-
case .quote(let text):
|
|
66
|
-
inline(text)
|
|
67
|
-
.font(.system(size: NotchMarkdown.bodySize))
|
|
68
|
-
.foregroundStyle(Color.white.opacity(0.7))
|
|
69
|
-
.padding(.leading, 10)
|
|
70
|
-
.overlay(alignment: .leading) {
|
|
71
|
-
Rectangle().fill(Color.white.opacity(0.28)).frame(width: 2)
|
|
72
|
-
}
|
|
73
|
-
case .rule:
|
|
74
|
-
Divider().overlay(Color.white.opacity(0.18))
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
private func inline(_ markdown: String) -> Text {
|
|
79
|
-
var options = AttributedString.MarkdownParsingOptions()
|
|
80
|
-
options.interpretedSyntax = .inlineOnlyPreservingWhitespace
|
|
81
|
-
options.failurePolicy = .returnPartiallyParsedIfPossible
|
|
82
|
-
if let parsed = try? AttributedString(markdown: markdown, options: options) {
|
|
83
|
-
var styled = parsed
|
|
84
|
-
for run in styled.runs {
|
|
85
|
-
if run.inlinePresentationIntent?.contains(.code) == true {
|
|
86
|
-
styled[run.range].font = .system(size: NotchMarkdown.codeSize, design: .monospaced)
|
|
87
|
-
styled[run.range].backgroundColor = NSColor.white.withAlphaComponent(0.1)
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return Text(styled)
|
|
91
|
-
}
|
|
92
|
-
return Text(markdown)
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
private func table(headers: [String], rows: [[String]]) -> some View {
|
|
96
|
-
let columns = max(headers.count, rows.map(\.count).max() ?? 0)
|
|
97
|
-
return VStack(alignment: .leading, spacing: 0) {
|
|
98
|
-
if !headers.isEmpty {
|
|
99
|
-
tableRow(cells: headers, header: true, columns: columns)
|
|
100
|
-
Rectangle().fill(Color.white.opacity(0.2)).frame(height: 1)
|
|
101
|
-
}
|
|
102
|
-
ForEach(Array(rows.enumerated()), id: \.offset) { i, row in
|
|
103
|
-
tableRow(cells: row, header: false, columns: columns)
|
|
104
|
-
if i < rows.count - 1 {
|
|
105
|
-
Rectangle().fill(Color.white.opacity(0.08)).frame(height: 1)
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
.padding(8)
|
|
110
|
-
.background(
|
|
111
|
-
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
112
|
-
.stroke(Color.white.opacity(0.16), lineWidth: 1)
|
|
113
|
-
)
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
private func tableRow(cells: [String], header: Bool, columns: Int) -> some View {
|
|
117
|
-
HStack(alignment: .top, spacing: 0) {
|
|
118
|
-
ForEach(0..<columns, id: \.self) { i in
|
|
119
|
-
let cell = i < cells.count ? cells[i] : ""
|
|
120
|
-
Text(cell)
|
|
121
|
-
.font(.system(size: 12, weight: header ? .semibold : .regular))
|
|
122
|
-
.foregroundStyle(Color.white.opacity(header ? 0.95 : 0.82))
|
|
123
|
-
.frame(maxWidth: .infinity, alignment: .leading)
|
|
124
|
-
.padding(.vertical, 5)
|
|
125
|
-
.padding(.horizontal, 6)
|
|
126
|
-
if i < columns - 1 {
|
|
127
|
-
Rectangle().fill(Color.white.opacity(0.1)).frame(width: 1)
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
private enum Block {
|
|
135
|
-
case heading(Int, String)
|
|
136
|
-
case paragraph(String)
|
|
137
|
-
case list([String], Bool)
|
|
138
|
-
case table([String], [[String]])
|
|
139
|
-
case code(String)
|
|
140
|
-
case quote(String)
|
|
141
|
-
case rule
|
|
142
|
-
|
|
143
|
-
static func parse(_ source: String) -> [Block] {
|
|
144
|
-
let lines = source.replacingOccurrences(of: "\r\n", with: "\n").split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
|
|
145
|
-
var blocks: [Block] = []
|
|
146
|
-
var i = 0
|
|
147
|
-
while i < lines.count {
|
|
148
|
-
let raw = lines[i]
|
|
149
|
-
let line = raw.trimmingCharacters(in: .whitespaces)
|
|
150
|
-
if line.isEmpty { i += 1; continue }
|
|
151
|
-
if line.hasPrefix("```") {
|
|
152
|
-
i += 1
|
|
153
|
-
var body: [String] = []
|
|
154
|
-
while i < lines.count, !lines[i].trimmingCharacters(in: .whitespaces).hasPrefix("```") {
|
|
155
|
-
body.append(lines[i])
|
|
156
|
-
i += 1
|
|
157
|
-
}
|
|
158
|
-
if i < lines.count { i += 1 }
|
|
159
|
-
blocks.append(.code(body.joined(separator: "\n")))
|
|
160
|
-
continue
|
|
161
|
-
}
|
|
162
|
-
if line == "---" || line == "***" || line == "___" {
|
|
163
|
-
blocks.append(.rule)
|
|
164
|
-
i += 1
|
|
165
|
-
continue
|
|
166
|
-
}
|
|
167
|
-
if let heading = heading(line) {
|
|
168
|
-
blocks.append(heading)
|
|
169
|
-
i += 1
|
|
170
|
-
continue
|
|
171
|
-
}
|
|
172
|
-
if line.hasPrefix("|") && i + 1 < lines.count, isTableDivider(lines[i + 1]) {
|
|
173
|
-
let headers = cells(line)
|
|
174
|
-
i += 2
|
|
175
|
-
var rows: [[String]] = []
|
|
176
|
-
while i < lines.count, lines[i].trimmingCharacters(in: .whitespaces).hasPrefix("|") {
|
|
177
|
-
rows.append(cells(lines[i]))
|
|
178
|
-
i += 1
|
|
179
|
-
}
|
|
180
|
-
blocks.append(.table(headers, rows))
|
|
181
|
-
continue
|
|
182
|
-
}
|
|
183
|
-
if line.hasPrefix("> ") || line == ">" {
|
|
184
|
-
var quoted: [String] = []
|
|
185
|
-
while i < lines.count {
|
|
186
|
-
let t = lines[i].trimmingCharacters(in: .whitespaces)
|
|
187
|
-
if t.hasPrefix("> ") { quoted.append(String(t.dropFirst(2))) }
|
|
188
|
-
else if t == ">" { quoted.append("") }
|
|
189
|
-
else { break }
|
|
190
|
-
i += 1
|
|
191
|
-
}
|
|
192
|
-
blocks.append(.quote(quoted.joined(separator: "\n")))
|
|
193
|
-
continue
|
|
194
|
-
}
|
|
195
|
-
if isBullet(line) || isOrdered(line) {
|
|
196
|
-
let ordered = isOrdered(line)
|
|
197
|
-
var items: [String] = []
|
|
198
|
-
while i < lines.count {
|
|
199
|
-
let t = lines[i].trimmingCharacters(in: .whitespaces)
|
|
200
|
-
if ordered, let item = orderedItem(t) { items.append(item) }
|
|
201
|
-
else if !ordered, let item = bulletItem(t) { items.append(item) }
|
|
202
|
-
else { break }
|
|
203
|
-
i += 1
|
|
204
|
-
}
|
|
205
|
-
blocks.append(.list(items, ordered))
|
|
206
|
-
continue
|
|
207
|
-
}
|
|
208
|
-
var para: [String] = [line]
|
|
209
|
-
i += 1
|
|
210
|
-
while i < lines.count {
|
|
211
|
-
let t = lines[i].trimmingCharacters(in: .whitespaces)
|
|
212
|
-
if t.isEmpty || t.hasPrefix("#") || t.hasPrefix("|") || t.hasPrefix("```") || t.hasPrefix("> ") || isBullet(t) || isOrdered(t) || t == "---" { break }
|
|
213
|
-
para.append(t)
|
|
214
|
-
i += 1
|
|
215
|
-
}
|
|
216
|
-
blocks.append(.paragraph(para.joined(separator: " ")))
|
|
217
|
-
}
|
|
218
|
-
return blocks
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
private static func heading(_ line: String) -> Block? {
|
|
222
|
-
guard line.hasPrefix("#") else { return nil }
|
|
223
|
-
var level = 0
|
|
224
|
-
for ch in line {
|
|
225
|
-
if ch == "#" { level += 1 } else { break }
|
|
226
|
-
}
|
|
227
|
-
guard level > 0, level <= 6, line.count > level, line[line.index(line.startIndex, offsetBy: level)] == " " else { return nil }
|
|
228
|
-
return .heading(level, String(line.dropFirst(level + 1)))
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
private static func isTableDivider(_ line: String) -> Bool {
|
|
232
|
-
let t = line.trimmingCharacters(in: .whitespaces)
|
|
233
|
-
guard t.hasPrefix("|") else { return false }
|
|
234
|
-
return t.replacingOccurrences(of: "|", with: "").replacingOccurrences(of: ":", with: "").replacingOccurrences(of: "-", with: "").replacingOccurrences(of: " ", with: "").isEmpty
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
private static func cells(_ line: String) -> [String] {
|
|
238
|
-
var parts = line.split(separator: "|", omittingEmptySubsequences: false).map { $0.trimmingCharacters(in: .whitespaces) }
|
|
239
|
-
if parts.first == "" { parts.removeFirst() }
|
|
240
|
-
if parts.last == "" { parts.removeLast() }
|
|
241
|
-
return parts
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
private static func isBullet(_ line: String) -> Bool {
|
|
245
|
-
line.hasPrefix("- ") || line.hasPrefix("* ")
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
private static func bulletItem(_ line: String) -> String? {
|
|
249
|
-
if line.hasPrefix("- ") { return String(line.dropFirst(2)) }
|
|
250
|
-
if line.hasPrefix("* ") { return String(line.dropFirst(2)) }
|
|
251
|
-
return nil
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
private static func isOrdered(_ line: String) -> Bool {
|
|
255
|
-
orderedItem(line) != nil
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
private static func orderedItem(_ line: String) -> String? {
|
|
259
|
-
guard let dot = line.firstIndex(of: ".") else { return nil }
|
|
260
|
-
let n = line[..<dot]
|
|
261
|
-
guard !n.isEmpty, n.allSatisfy(\.isNumber) else { return nil }
|
|
262
|
-
let rest = line[line.index(after: dot)...]
|
|
263
|
-
guard rest.first == " " else { return nil }
|
|
264
|
-
return String(rest.dropFirst())
|
|
265
|
-
}
|
|
266
|
-
}
|