agentix-cli 0.94.1 → 0.96.0
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/apps/mac-voice/Sources/AgentXVoice/Character.swift +4 -0
- package/apps/mac-voice/Sources/AgentXVoice/Config.swift +26 -9
- package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +56 -7
- package/apps/mac-voice/Sources/AgentXVoice/PanelAnswer.swift +2 -0
- package/apps/mac-voice/Sources/AgentXVoice/PillMenu.swift +9 -0
- package/apps/mac-voice/Sources/AgentXVoice/PillPlacement.swift +15 -4
- package/apps/mac-voice/Sources/AgentXVoice/PlayMath.swift +250 -0
- package/apps/mac-voice/Sources/AgentXVoice/PlayRead.swift +98 -0
- package/apps/mac-voice/Sources/AgentXVoice/PlayStage.swift +164 -0
- package/apps/mac-voice/Sources/AgentXVoice/SettingsLook.swift +6 -0
- package/apps/mac-voice/Sources/AgentXVoice/SettingsWindow.swift +4 -1
- package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +23 -0
- package/apps/mac-voice/Sources/AgentXVoice/Surface.swift +19 -0
- package/apps/mac-voice/Sources/AgentXVoice/VoiceSettings.swift +8 -2
- package/apps/mac-voice/Sources/AgentXVoice/main.swift +73 -0
- package/apps/mac-voice/build.sh +1 -1
- package/dist/{App-Y62EOIB4.js → App-UHSIO3UE.js} +2 -2
- package/dist/{ChatApp-VIGYSGG4.js → ChatApp-LLTFBLJY.js} +2 -2
- package/dist/{agent-AGBADNWK.js → agent-6RJ633MQ.js} +2 -2
- package/dist/{board-dashboard-MHMYOBKA.js → board-dashboard-A3ZEI4F6.js} +4 -4
- package/dist/{chunk-IWEN3YNQ.js → chunk-44UGVUN7.js} +2 -2
- package/dist/{chunk-73NSSC6K.js → chunk-4XHKJZLJ.js} +2 -2
- package/dist/{chunk-EWE2TJMN.js → chunk-6DCY2JCB.js} +5 -5
- package/dist/{chunk-BP52VVCW.js → chunk-6NECC6YS.js} +2 -2
- package/dist/{chunk-IBXHC3ZV.js → chunk-76IOF3DK.js} +171 -171
- package/dist/chunk-76IOF3DK.js.map +1 -0
- package/dist/{chunk-LKBUTSYP.js → chunk-ALWMYA2B.js} +2 -2
- package/dist/{chunk-HJUBCA7O.js → chunk-DU6536CC.js} +2 -2
- package/dist/{chunk-2MMC5LHF.js → chunk-EBVB727L.js} +2 -2
- package/dist/{chunk-Q4R4OS5S.js → chunk-GHXSBJZS.js} +2 -2
- package/dist/{chunk-KA6K25T4.js → chunk-IPDEWVVT.js} +2 -2
- package/dist/{chunk-C6PWAEVX.js → chunk-J2MHITWR.js} +2 -2
- package/dist/{chunk-PS3UB5PH.js → chunk-KEKO4S45.js} +2 -2
- package/dist/{chunk-MIRBKQHA.js → chunk-KIGFWUKP.js} +2 -2
- package/dist/{chunk-5EOFTPQB.js → chunk-MKF6HSBA.js} +2 -2
- package/dist/{chunk-74S7PKIX.js → chunk-QZIEYDIY.js} +2 -2
- package/dist/{chunk-DS7CDASP.js → chunk-TE44E733.js} +2 -2
- package/dist/{chunk-5PFTUGP4.js → chunk-VO2GNAKB.js} +2 -2
- package/dist/{chunk-VUSDWZ6L.js → chunk-XEKZKH3W.js} +2 -2
- package/dist/{chunk-VUSDWZ6L.js.map → chunk-XEKZKH3W.js.map} +1 -1
- package/dist/{chunk-M56MKNAG.js → chunk-ZVW645NU.js} +2 -2
- package/dist/{chunk-T2IDDOSX.js → chunk-ZYJDAT3E.js} +2 -2
- package/dist/{chunk-OQHC7TJL.js → chunk-ZZSNM2RI.js} +2 -2
- package/dist/cli.js +1 -1
- package/dist/{client-RMUEJMV5.js → client-ARS6ZFT2.js} +2 -2
- package/dist/{config-M2Z4HTZI.js → config-XO5Z3BO5.js} +2 -2
- package/dist/config-mutator-MJX4EIYV.js +2 -0
- package/dist/{doctor-W6OQ7JI2.js → doctor-VUK3JE6Z.js} +2 -2
- package/dist/index.d.ts +14 -0
- package/dist/index.js +1 -1
- package/dist/{program-6G2IS3WF.js → program-QO72D2EL.js} +166 -166
- package/dist/program-QO72D2EL.js.map +1 -0
- package/dist/{promote-YH3MNZFF.js → promote-UGQXA4KV.js} +2 -2
- package/dist/{query-BJC5VVS5.js → query-DL7RHPMA.js} +2 -2
- package/dist/{registry-EJTSQEBJ.js → registry-YIPKBRBH.js} +2 -2
- package/dist/{request-planner-MKVEPQGA.js → request-planner-L2FAVNKP.js} +2 -2
- package/dist/{review-candidates-OAG3DMQR.js → review-candidates-RPUP4XBU.js} +2 -2
- package/dist/{routing-U4VI4PAH.js → routing-G66SVTRO.js} +2 -2
- package/dist/schedule-tool-FOWPIWKP.js +2 -0
- package/dist/scheduler-GFVUPAD6.js +2 -0
- package/dist/{seat-CVOTWLEN.js → seat-ZPH7DA5L.js} +2 -2
- package/dist/{select-FNERAHZI.js → select-2WTV2BI2.js} +2 -2
- package/dist/{set-enabled-3C7AVZ65.js → set-enabled-KI2FINQD.js} +2 -2
- package/dist/{tool-KR4MDO2R.js → tool-2P4LRWN4.js} +2 -2
- package/dist/{tool-H7CDGZJ6.js → tool-ZFQ46KU3.js} +2 -2
- package/dist/wiki-XATGT3DI.js +2 -0
- package/package.json +1 -1
- package/dist/chunk-IBXHC3ZV.js.map +0 -1
- package/dist/config-mutator-L2LCW5ES.js +0 -2
- package/dist/program-6G2IS3WF.js.map +0 -1
- package/dist/schedule-tool-ZTTZBTXA.js +0 -2
- package/dist/scheduler-4RLL7YOW.js +0 -2
- package/dist/wiki-HA3U33SO.js +0 -2
- /package/dist/{App-Y62EOIB4.js.map → App-UHSIO3UE.js.map} +0 -0
- /package/dist/{ChatApp-VIGYSGG4.js.map → ChatApp-LLTFBLJY.js.map} +0 -0
- /package/dist/{agent-AGBADNWK.js.map → agent-6RJ633MQ.js.map} +0 -0
- /package/dist/{board-dashboard-MHMYOBKA.js.map → board-dashboard-A3ZEI4F6.js.map} +0 -0
- /package/dist/{chunk-IWEN3YNQ.js.map → chunk-44UGVUN7.js.map} +0 -0
- /package/dist/{chunk-73NSSC6K.js.map → chunk-4XHKJZLJ.js.map} +0 -0
- /package/dist/{chunk-EWE2TJMN.js.map → chunk-6DCY2JCB.js.map} +0 -0
- /package/dist/{chunk-BP52VVCW.js.map → chunk-6NECC6YS.js.map} +0 -0
- /package/dist/{chunk-LKBUTSYP.js.map → chunk-ALWMYA2B.js.map} +0 -0
- /package/dist/{chunk-HJUBCA7O.js.map → chunk-DU6536CC.js.map} +0 -0
- /package/dist/{chunk-2MMC5LHF.js.map → chunk-EBVB727L.js.map} +0 -0
- /package/dist/{chunk-Q4R4OS5S.js.map → chunk-GHXSBJZS.js.map} +0 -0
- /package/dist/{chunk-KA6K25T4.js.map → chunk-IPDEWVVT.js.map} +0 -0
- /package/dist/{chunk-C6PWAEVX.js.map → chunk-J2MHITWR.js.map} +0 -0
- /package/dist/{chunk-PS3UB5PH.js.map → chunk-KEKO4S45.js.map} +0 -0
- /package/dist/{chunk-MIRBKQHA.js.map → chunk-KIGFWUKP.js.map} +0 -0
- /package/dist/{chunk-5EOFTPQB.js.map → chunk-MKF6HSBA.js.map} +0 -0
- /package/dist/{chunk-74S7PKIX.js.map → chunk-QZIEYDIY.js.map} +0 -0
- /package/dist/{chunk-DS7CDASP.js.map → chunk-TE44E733.js.map} +0 -0
- /package/dist/{chunk-5PFTUGP4.js.map → chunk-VO2GNAKB.js.map} +0 -0
- /package/dist/{chunk-M56MKNAG.js.map → chunk-ZVW645NU.js.map} +0 -0
- /package/dist/{chunk-T2IDDOSX.js.map → chunk-ZYJDAT3E.js.map} +0 -0
- /package/dist/{chunk-OQHC7TJL.js.map → chunk-ZZSNM2RI.js.map} +0 -0
- /package/dist/{client-RMUEJMV5.js.map → client-ARS6ZFT2.js.map} +0 -0
- /package/dist/{config-M2Z4HTZI.js.map → config-XO5Z3BO5.js.map} +0 -0
- /package/dist/{config-mutator-L2LCW5ES.js.map → config-mutator-MJX4EIYV.js.map} +0 -0
- /package/dist/{doctor-W6OQ7JI2.js.map → doctor-VUK3JE6Z.js.map} +0 -0
- /package/dist/{promote-YH3MNZFF.js.map → promote-UGQXA4KV.js.map} +0 -0
- /package/dist/{query-BJC5VVS5.js.map → query-DL7RHPMA.js.map} +0 -0
- /package/dist/{registry-EJTSQEBJ.js.map → registry-YIPKBRBH.js.map} +0 -0
- /package/dist/{request-planner-MKVEPQGA.js.map → request-planner-L2FAVNKP.js.map} +0 -0
- /package/dist/{review-candidates-OAG3DMQR.js.map → review-candidates-RPUP4XBU.js.map} +0 -0
- /package/dist/{routing-U4VI4PAH.js.map → routing-G66SVTRO.js.map} +0 -0
- /package/dist/{schedule-tool-ZTTZBTXA.js.map → schedule-tool-FOWPIWKP.js.map} +0 -0
- /package/dist/{scheduler-4RLL7YOW.js.map → scheduler-GFVUPAD6.js.map} +0 -0
- /package/dist/{seat-CVOTWLEN.js.map → seat-ZPH7DA5L.js.map} +0 -0
- /package/dist/{select-FNERAHZI.js.map → select-2WTV2BI2.js.map} +0 -0
- /package/dist/{set-enabled-3C7AVZ65.js.map → set-enabled-KI2FINQD.js.map} +0 -0
- /package/dist/{tool-KR4MDO2R.js.map → tool-2P4LRWN4.js.map} +0 -0
- /package/dist/{tool-H7CDGZJ6.js.map → tool-ZFQ46KU3.js.map} +0 -0
- /package/dist/{wiki-HA3U33SO.js.map → wiki-XATGT3DI.js.map} +0 -0
|
@@ -51,6 +51,10 @@ final class CharacterHost {
|
|
|
51
51
|
private var rest = CGPoint.zero
|
|
52
52
|
/// Its body on screen: where it can be taken hold of.
|
|
53
53
|
private var body = NSRect.zero
|
|
54
|
+
/// Its colours, and the point under it in screen points: play mode
|
|
55
|
+
/// (#505) takes it from there.
|
|
56
|
+
var stops: [NSColor] { view.stops }
|
|
57
|
+
var foot: NSPoint { NSPoint(x: CGFloat(sim.x), y: rest.y) }
|
|
54
58
|
/// Carried by the pointer: where the pointer took hold, and where it
|
|
55
59
|
/// stood then, which is not where it rests once it has stepped aside.
|
|
56
60
|
private var carried: (from: NSPoint, rest: CGPoint)?
|
|
@@ -35,18 +35,35 @@ enum Config {
|
|
|
35
35
|
set { UserDefaults.standard.set(newValue, forKey: "animatedOrb") }
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/// "Play mode" in the menu (#505): the character may play on a frozen
|
|
39
|
+
/// picture of the screen when asked. Off by default.
|
|
40
|
+
static var playMode: Bool {
|
|
41
|
+
get { UserDefaults.standard.bool(forKey: "playMode") }
|
|
42
|
+
set { UserDefaults.standard.set(newValue, forKey: "playMode") }
|
|
43
|
+
}
|
|
44
|
+
|
|
38
45
|
/// Where the pill was last dragged to (its bottom-left corner, in
|
|
39
46
|
/// screen points). Nil: the default corner.
|
|
40
47
|
static var pillOrigin: CGPoint? {
|
|
41
|
-
get {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
48
|
+
get { point("pillOrigin") }
|
|
49
|
+
set { setPoint(newValue, "pillOrigin") }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Where the pill reduced to its orb was last dragged to. Nil: the
|
|
53
|
+
/// middle of the bottom edge. Kept apart from the pill's own place.
|
|
54
|
+
static var orbOrigin: CGPoint? {
|
|
55
|
+
get { point("orbOrigin") }
|
|
56
|
+
set { setPoint(newValue, "orbOrigin") }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private static func point(_ key: String) -> CGPoint? {
|
|
60
|
+
guard let xy = UserDefaults.standard.array(forKey: key) as? [Double], xy.count == 2 else { return nil }
|
|
61
|
+
return CGPoint(x: xy[0], y: xy[1])
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private static func setPoint(_ p: CGPoint?, _ key: String) {
|
|
65
|
+
if let p { UserDefaults.standard.set([Double(p.x), Double(p.y)], forKey: key) }
|
|
66
|
+
else { UserDefaults.standard.removeObject(forKey: key) }
|
|
50
67
|
}
|
|
51
68
|
|
|
52
69
|
/// Where the character was last dragged to (the point under it, in
|
|
@@ -36,6 +36,12 @@ final class Panel: NSPanel {
|
|
|
36
36
|
let separator = NSBox()
|
|
37
37
|
/// Hidden by close, Esc or the menu until the next talk key.
|
|
38
38
|
var dismissed = false
|
|
39
|
+
/// Reduced to the orb alone (#457): a small circle with no words and no
|
|
40
|
+
/// buttons, dragged and remembered apart from the pill. A click, a
|
|
41
|
+
/// call or an answer to read opens the full pill again.
|
|
42
|
+
private(set) var reduced = false
|
|
43
|
+
/// Told when the pill reduces or opens.
|
|
44
|
+
var onReduced: ((Bool) -> Void)?
|
|
39
45
|
/// Above zero while the app moves or resizes the pill, so only a drag
|
|
40
46
|
/// is remembered.
|
|
41
47
|
var placing = 0
|
|
@@ -136,7 +142,10 @@ final class Panel: NSPanel {
|
|
|
136
142
|
let now = NSEvent.mouseLocation
|
|
137
143
|
let moved = hypot(now.x - start.x, now.y - start.y)
|
|
138
144
|
let onRow = convertToScreen(row.convert(row.bounds, to: nil)).contains(start)
|
|
139
|
-
|
|
145
|
+
// Reduced, a click opens the pill; it does not start talking.
|
|
146
|
+
if moved < 4 && onRow {
|
|
147
|
+
if reduced { MainActor.assumeIsolated { setReduced(false) } } else { onClick?() }
|
|
148
|
+
}
|
|
140
149
|
}
|
|
141
150
|
super.mouseUp(with: event)
|
|
142
151
|
}
|
|
@@ -201,6 +210,14 @@ final class Panel: NSPanel {
|
|
|
201
210
|
}
|
|
202
211
|
}
|
|
203
212
|
|
|
213
|
+
/// A call must not be missed behind an orb: it opens the full pill.
|
|
214
|
+
var opensFullPill: Bool {
|
|
215
|
+
switch self {
|
|
216
|
+
case .ringing, .onCall: return true
|
|
217
|
+
default: return false
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
204
221
|
/// Idle is an invitation and belongs in meta type; everything else
|
|
205
222
|
/// is a running commentary and belongs in body type.
|
|
206
223
|
var isMeta: Bool { if case .idle = self { return true }; return false }
|
|
@@ -312,6 +329,9 @@ final class Panel: NSPanel {
|
|
|
312
329
|
let answerHeight = max(0, bounds.height - h)
|
|
313
330
|
let above = growth.above
|
|
314
331
|
row.frame = NSRect(x: 0, y: above ? 0 : answerHeight, width: bounds.width, height: h)
|
|
332
|
+
// Reduced, the orb is all there is: in the middle of its circle.
|
|
333
|
+
orb.frame.origin.x = reduced ? (bounds.width - Self.orbFrame) / 2 : 28 - Self.orbFrame / 2
|
|
334
|
+
if reduced { return }
|
|
315
335
|
closeButton.frame.origin.x = bounds.width - 28
|
|
316
336
|
// The mini orbs sit between the words and the close button, and
|
|
317
337
|
// the words give up that room while they show.
|
|
@@ -345,17 +365,41 @@ final class Panel: NSPanel {
|
|
|
345
365
|
}
|
|
346
366
|
let screens = NSScreen.screens.map(\.visibleFrame)
|
|
347
367
|
let fallback = (NSScreen.main ?? NSScreen.screens.first)?.visibleFrame ?? .zero
|
|
348
|
-
place(PillPlacement.clamp(saved: Config.pillOrigin, size: frame.size,
|
|
368
|
+
place(PillPlacement.clamp(saved: reduced ? Config.orbOrigin : Config.pillOrigin, size: frame.size,
|
|
369
|
+
screens: screens, fallback: fallback,
|
|
370
|
+
rest: reduced ? PillPlacement.orbOrigin : PillPlacement.defaultOrigin))
|
|
349
371
|
}
|
|
350
372
|
|
|
351
|
-
/// Back to the bottom-right corner
|
|
373
|
+
/// Back to where it first sat (the pill: the bottom-right corner; the
|
|
374
|
+
/// orb: the middle of the bottom edge), forgetting where it was dragged.
|
|
352
375
|
/// Not while it is the character's bubble: nothing would move, and
|
|
353
376
|
/// the place kept for the orb look would be lost.
|
|
354
377
|
@MainActor
|
|
355
378
|
func resetPosition() {
|
|
356
379
|
guard bubble == nil else { return }
|
|
357
|
-
Config.pillOrigin = nil
|
|
380
|
+
if reduced { Config.orbOrigin = nil } else { Config.pillOrigin = nil }
|
|
381
|
+
restorePosition()
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/// Reduce the pill to its orb, or open it again. The two forms keep
|
|
385
|
+
/// their own places. Nothing to reduce to while the character stands
|
|
386
|
+
/// in for the orb.
|
|
387
|
+
@MainActor
|
|
388
|
+
func setReduced(_ on: Bool) {
|
|
389
|
+
guard reduced != on, !on || PillMenu.canReduce(showsOrb: showsOrb) else { return }
|
|
390
|
+
collapse(animated: false)
|
|
391
|
+
reduced = on
|
|
392
|
+
clip?.isHidden = on
|
|
393
|
+
closeButton.isHidden = true
|
|
394
|
+
miniOrbs.isHidden = on || busyCount == 0
|
|
395
|
+
(contentView as? Surface)?.shape(circle: on ? PillPlacement.orbSize.height : nil)
|
|
396
|
+
placing += 1
|
|
397
|
+
setContentSize(on ? PillPlacement.orbSize : Self.size)
|
|
398
|
+
placing -= 1
|
|
358
399
|
restorePosition()
|
|
400
|
+
invalidateShadow()
|
|
401
|
+
render(current)
|
|
402
|
+
onReduced?(on)
|
|
359
403
|
}
|
|
360
404
|
|
|
361
405
|
/// The character's speech bubble (#491): the pill sits above `head`,
|
|
@@ -396,6 +440,7 @@ final class Panel: NSPanel {
|
|
|
396
440
|
@objc private func moved() {
|
|
397
441
|
MainActor.assumeIsolated {
|
|
398
442
|
guard placing == 0 else { return }
|
|
443
|
+
if reduced { Config.orbOrigin = frame.origin; return }
|
|
399
444
|
Config.pillOrigin = expanded ? collapsedFrame().origin : frame.origin
|
|
400
445
|
}
|
|
401
446
|
}
|
|
@@ -427,6 +472,7 @@ final class Panel: NSPanel {
|
|
|
427
472
|
@MainActor
|
|
428
473
|
func setShowsOrb(_ on: Bool) {
|
|
429
474
|
guard showsOrb != on else { return }
|
|
475
|
+
if !on { setReduced(false) }
|
|
430
476
|
showsOrb = on
|
|
431
477
|
orb.isHidden = !on
|
|
432
478
|
orb.setOnScreen(on && isVisible)
|
|
@@ -451,7 +497,7 @@ final class Panel: NSPanel {
|
|
|
451
497
|
@objc private func closeClicked() { onDismiss?() }
|
|
452
498
|
|
|
453
499
|
override func mouseEntered(with event: NSEvent) {
|
|
454
|
-
closeButton.isHidden =
|
|
500
|
+
closeButton.isHidden = reduced
|
|
455
501
|
hovering = true
|
|
456
502
|
MainActor.assumeIsolated { armCollapse() }
|
|
457
503
|
}
|
|
@@ -484,6 +530,7 @@ final class Panel: NSPanel {
|
|
|
484
530
|
/// corruption that traps somewhere unrelated an hour later.
|
|
485
531
|
@MainActor
|
|
486
532
|
func render(_ state: State) {
|
|
533
|
+
if reduced && state.opensFullPill { setReduced(false) }
|
|
487
534
|
current = state
|
|
488
535
|
// The agent's colour, except where the state is the message: an
|
|
489
536
|
// error, or notifications held.
|
|
@@ -514,7 +561,8 @@ final class Panel: NSPanel {
|
|
|
514
561
|
|
|
515
562
|
// Grown into an answer, it stays until it collapses, idle or not;
|
|
516
563
|
// so does a pill with agents still busy in its mini orbs.
|
|
517
|
-
|
|
564
|
+
// Reduced, the orb is the assistant's place on screen: it stays, idle or not.
|
|
565
|
+
if dismissed || (state.isMeta && !alwaysVisible && !expanded && busyCount == 0 && !reduced) { hide() } else { show() }
|
|
518
566
|
// After showing or hiding: a still character draws its bubble's
|
|
519
567
|
// tail only while the bubble is on screen.
|
|
520
568
|
onLook?(state, tint, colors)
|
|
@@ -526,6 +574,7 @@ final class Panel: NSPanel {
|
|
|
526
574
|
@MainActor
|
|
527
575
|
func showCall(_ mode: CallBar.Mode) {
|
|
528
576
|
guard mode != callBar.mode else { return }
|
|
577
|
+
if mode != .hidden { setReduced(false) }
|
|
529
578
|
callBar.show(mode)
|
|
530
579
|
if let content = contentView { layoutContent(content.bounds) }
|
|
531
580
|
}
|
|
@@ -536,7 +585,7 @@ final class Panel: NSPanel {
|
|
|
536
585
|
func showBusy(_ orbs: [MiniOrbsModel.Orb], more: Int) {
|
|
537
586
|
let count = orbs.isEmpty ? 0 : orbs.count + more
|
|
538
587
|
miniOrbs.show(orbs, more: more)
|
|
539
|
-
miniOrbs.isHidden = count == 0
|
|
588
|
+
miniOrbs.isHidden = count == 0 || reduced
|
|
540
589
|
if count != busyCount {
|
|
541
590
|
busyCount = count
|
|
542
591
|
if let content = contentView { layoutContent(content.bounds) }
|
|
@@ -23,6 +23,8 @@ extension Panel {
|
|
|
23
23
|
@MainActor
|
|
24
24
|
func grow(answerHeight: CGFloat) {
|
|
25
25
|
guard !dismissed else { return }
|
|
26
|
+
// An answer to read, or one that asks: the full pill opens for it.
|
|
27
|
+
setReduced(false)
|
|
26
28
|
let pill = collapsedFrame()
|
|
27
29
|
guard let visible = visibleFrame(for: pill) else { return }
|
|
28
30
|
let height = min(Self.size.height + min(answerHeight, cardMaxHeight), visible.height)
|
|
@@ -13,4 +13,13 @@ enum PillMenu {
|
|
|
13
13
|
static func click(showPill: Bool, visible: Bool) -> Bool {
|
|
14
14
|
!isChecked(showPill: showPill, visible: visible)
|
|
15
15
|
}
|
|
16
|
+
|
|
17
|
+
/// The item that reduces the pill to its orb, or brings the pill back.
|
|
18
|
+
static func reduceTitle(reduced: Bool) -> String {
|
|
19
|
+
reduced ? "Show full pill" : "Reduce to orb"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// Only a pill that has its orb can be reduced to it: while the
|
|
23
|
+
/// character stands in for the orb, there is nothing to reduce to.
|
|
24
|
+
static func canReduce(showsOrb: Bool) -> Bool { showsOrb }
|
|
16
25
|
}
|
|
@@ -13,13 +13,24 @@ enum PillPlacement {
|
|
|
13
13
|
CGPoint(x: visible.maxX - size.width - inset, y: visible.minY + inset)
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/// The pill reduced to its orb: a circle as tall as the pill.
|
|
17
|
+
static let orbSize = CGSize(width: 54, height: 54)
|
|
18
|
+
|
|
19
|
+
/// Where the reduced orb first sits: the middle of the bottom edge of
|
|
20
|
+
/// `visible`, clear of the Dock.
|
|
21
|
+
static func orbOrigin(size: CGSize, visible: CGRect) -> CGPoint {
|
|
22
|
+
CGPoint(x: (visible.midX - size.width / 2).rounded(), y: visible.minY + inset)
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
/// A saved position made safe: the pill lands fully on the screen it
|
|
17
26
|
/// overlaps most. When it overlaps none (the monitor it was on has been
|
|
18
27
|
/// unplugged, or the saved value is nonsense) it goes back to the
|
|
19
|
-
/// default
|
|
20
|
-
|
|
28
|
+
/// default place on `fallback`. Nil `saved`: the default place, which
|
|
29
|
+
/// is the bottom-right corner unless `rest` says otherwise.
|
|
30
|
+
static func clamp(saved: CGPoint?, size: CGSize, screens: [CGRect], fallback: CGRect,
|
|
31
|
+
rest defaultOrigin: (CGSize, CGRect) -> CGPoint = PillPlacement.defaultOrigin) -> CGPoint {
|
|
21
32
|
guard let saved, saved.x.isFinite, saved.y.isFinite else {
|
|
22
|
-
return defaultOrigin(size
|
|
33
|
+
return defaultOrigin(size, fallback)
|
|
23
34
|
}
|
|
24
35
|
let frame = CGRect(origin: saved, size: size)
|
|
25
36
|
var best: CGRect?
|
|
@@ -31,7 +42,7 @@ enum PillPlacement {
|
|
|
31
42
|
if area > bestArea { bestArea = area; best = screen }
|
|
32
43
|
}
|
|
33
44
|
guard let screen = best, bestArea > 0 else {
|
|
34
|
-
return defaultOrigin(size
|
|
45
|
+
return defaultOrigin(size, fallback)
|
|
35
46
|
}
|
|
36
47
|
return inside(frame, screen).origin
|
|
37
48
|
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Play mode (#505): the character leaves the bottom edge and plays on a
|
|
4
|
+
/// frozen picture of the screen. It walks along a line of text as on a
|
|
5
|
+
/// floor, jumps to another line, eats words and wipes a line. These are
|
|
6
|
+
/// the numbers only: where the words are, what it does and when, where it
|
|
7
|
+
/// is at a given moment and which letters are gone. Foundation only, so
|
|
8
|
+
/// the tests can run it without a window.
|
|
9
|
+
///
|
|
10
|
+
/// Everything is in the picture's points, origin top-left, y downwards.
|
|
11
|
+
enum PlayMath {
|
|
12
|
+
typealias M = CharacterMath
|
|
13
|
+
|
|
14
|
+
struct Point: Equatable { var x = 0.0, y = 0.0 }
|
|
15
|
+
|
|
16
|
+
struct Rect: Equatable {
|
|
17
|
+
var x = 0.0, y = 0.0, w = 0.0, h = 0.0
|
|
18
|
+
var maxX: Double { x + w }
|
|
19
|
+
var maxY: Double { y + h }
|
|
20
|
+
var midX: Double { x + w / 2 }
|
|
21
|
+
var midY: Double { y + h / 2 }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
struct Word: Equatable {
|
|
25
|
+
var text: String
|
|
26
|
+
var rect: Rect
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// One line of text as it was read, left to right, and the colour of
|
|
30
|
+
/// the page around it (0xRRGGBB): what shows where a word is gone.
|
|
31
|
+
struct Line: Equatable {
|
|
32
|
+
var words: [Word]
|
|
33
|
+
var rect: Rect
|
|
34
|
+
var paper: UInt32 = 0xFFFFFF
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// One move of a script.
|
|
38
|
+
enum Step: Equatable {
|
|
39
|
+
/// Jump onto the start of a line.
|
|
40
|
+
case jump(line: Int)
|
|
41
|
+
/// Walk along the top of a line to its end.
|
|
42
|
+
case walk(line: Int)
|
|
43
|
+
/// Go through a line and eat these words, letter by letter.
|
|
44
|
+
case eat(line: Int, words: Range<Int>)
|
|
45
|
+
/// Wipe a whole line with a cloth.
|
|
46
|
+
case wipe(line: Int)
|
|
47
|
+
/// Stand still, facing you, for so many seconds.
|
|
48
|
+
case rest(Double)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Points per drawing unit on the page: a body 40 points across,
|
|
52
|
+
/// smaller than on the bottom edge so it fits between lines.
|
|
53
|
+
static let unit = 0.4
|
|
54
|
+
/// The middle of its body above its feet, and its top, in points.
|
|
55
|
+
static let middle = (M.Pose().lift + 50) * unit
|
|
56
|
+
static let top = (M.Pose().lift + 100) * unit + 8
|
|
57
|
+
/// Points a second, and seconds a letter.
|
|
58
|
+
static let walkSpeed = 220.0
|
|
59
|
+
static let wipeSpeed = 260.0
|
|
60
|
+
static let perLetter = 0.09
|
|
61
|
+
/// Its mouth, ahead of its middle.
|
|
62
|
+
static let mouth = 10.0
|
|
63
|
+
|
|
64
|
+
/// A word's letters: the word's rectangle cut into equal parts, one a
|
|
65
|
+
/// character. An estimate, close enough to take them one at a time.
|
|
66
|
+
static func letters(of word: Word) -> [Rect] {
|
|
67
|
+
let n = max(word.text.count, 1), w = word.rect.w / Double(n)
|
|
68
|
+
return (0..<n).map { Rect(x: word.rect.x + Double($0) * w, y: word.rect.y, w: w, h: word.rect.h) }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// The colour most of `samples` share: the page around a line. Close
|
|
72
|
+
/// shades count as one, so the soft edge of a letter does not win.
|
|
73
|
+
static func common(_ samples: [UInt32]) -> UInt32? {
|
|
74
|
+
var count: [UInt32: Int] = [:]
|
|
75
|
+
for s in samples { count[s & 0xF8F8F8, default: 0] += 1 }
|
|
76
|
+
guard let best = count.max(by: { ($0.value, $0.key) < ($1.value, $1.key) })?.key else { return nil }
|
|
77
|
+
return samples.first { $0 & 0xF8F8F8 == best }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// The built-in script: the line nearest the middle of the page and
|
|
81
|
+
/// the two under it in the same column. It walks the first, eats the
|
|
82
|
+
/// start of the second and wipes the third. Lines too close to the top
|
|
83
|
+
/// to stand on, too short or of one word are left alone.
|
|
84
|
+
static func demo(_ lines: [Line], width: Double, height: Double) -> [Step] {
|
|
85
|
+
let fit = lines.indices.filter { lines[$0].words.count >= 2 && lines[$0].rect.w >= 120 && lines[$0].rect.y >= top + 40 }
|
|
86
|
+
func far(_ i: Int) -> Double { hypot(lines[i].rect.midX - width / 2, lines[i].rect.midY - height / 2) }
|
|
87
|
+
guard let first = fit.min(by: { far($0) < far($1) }) else { return [] }
|
|
88
|
+
let a = lines[first].rect
|
|
89
|
+
let under = fit.filter { lines[$0].rect.y > a.y + a.h / 2 && lines[$0].rect.x < a.maxX && lines[$0].rect.maxX > a.x }
|
|
90
|
+
.sorted { lines[$0].rect.y < lines[$1].rect.y }
|
|
91
|
+
let picked = [first] + under.prefix(2)
|
|
92
|
+
var steps: [Step] = [.jump(line: first), .walk(line: first)]
|
|
93
|
+
if picked.count > 1 { steps += [.eat(line: picked[1], words: 0..<min(3, lines[picked[1]].words.count))] }
|
|
94
|
+
else { steps += [.eat(line: first, words: 0..<min(2, lines[first].words.count))] }
|
|
95
|
+
if picked.count > 2 { steps += [.wipe(line: picked[2])] }
|
|
96
|
+
return steps + [.rest(1.2)]
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// A script laid out in time: every move has its start and its end, so
|
|
101
|
+
/// where the character is, and what is gone, depends on the time alone.
|
|
102
|
+
/// The same script gives the same frames on every run.
|
|
103
|
+
struct Play {
|
|
104
|
+
typealias P = PlayMath
|
|
105
|
+
typealias M = CharacterMath
|
|
106
|
+
|
|
107
|
+
enum Kind { case jump, walk, eat, wipe, rest }
|
|
108
|
+
|
|
109
|
+
struct Move {
|
|
110
|
+
var kind: Kind
|
|
111
|
+
var from, to: P.Point
|
|
112
|
+
var start, duration: Double
|
|
113
|
+
var line = 0
|
|
114
|
+
var words = 0..<0
|
|
115
|
+
var end: Double { start + duration }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
struct Frame {
|
|
119
|
+
/// Its feet.
|
|
120
|
+
var at = P.Point()
|
|
121
|
+
/// -1 turned left, 0 facing you, 1 turned right.
|
|
122
|
+
var face = 0.0
|
|
123
|
+
var pose = M.Pose()
|
|
124
|
+
var t = 0.0
|
|
125
|
+
/// What is covered with the page's colour, and which colour.
|
|
126
|
+
var gone: [(rect: P.Rect, paper: UInt32)] = []
|
|
127
|
+
/// The cloth, while it wipes.
|
|
128
|
+
var cloth: P.Rect?
|
|
129
|
+
var done = false
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let lines: [P.Line]
|
|
133
|
+
private(set) var moves: [Move] = []
|
|
134
|
+
var duration: Double { moves.last?.end ?? 0 }
|
|
135
|
+
|
|
136
|
+
/// `start` is where its feet are when play begins. Steps that name a
|
|
137
|
+
/// line or a word that is not there are skipped.
|
|
138
|
+
init(lines: [P.Line], steps: [P.Step], start: P.Point) {
|
|
139
|
+
self.lines = lines
|
|
140
|
+
var at = start, now = 0.0
|
|
141
|
+
func add(_ kind: Kind, to: P.Point, _ seconds: Double, line: Int = 0, words: Range<Int> = 0..<0) -> Move {
|
|
142
|
+
Move(kind: kind, from: at, to: to, start: now, duration: seconds, line: line, words: words)
|
|
143
|
+
}
|
|
144
|
+
/// Get to `to` first: a jump, unless it is already there.
|
|
145
|
+
func reach(_ to: P.Point, into list: inout [Move]) {
|
|
146
|
+
guard at != to else { return }
|
|
147
|
+
let m = add(.jump, to: to, 0.5 + hypot(to.x - at.x, to.y - at.y) / 900)
|
|
148
|
+
list.append(m); at = to; now = m.end
|
|
149
|
+
}
|
|
150
|
+
var list: [Move] = []
|
|
151
|
+
for step in steps {
|
|
152
|
+
switch step {
|
|
153
|
+
case .jump(let i):
|
|
154
|
+
guard lines.indices.contains(i) else { continue }
|
|
155
|
+
reach(P.Point(x: lines[i].rect.x, y: lines[i].rect.y), into: &list)
|
|
156
|
+
case .walk(let i):
|
|
157
|
+
guard lines.indices.contains(i) else { continue }
|
|
158
|
+
let r = lines[i].rect
|
|
159
|
+
if at.y != r.y || at.x < r.x || at.x > r.maxX { reach(P.Point(x: r.x, y: r.y), into: &list) }
|
|
160
|
+
let m = add(.walk, to: P.Point(x: r.maxX, y: r.y), (r.maxX - at.x) / P.walkSpeed, line: i)
|
|
161
|
+
list.append(m); at = m.to; now = m.end
|
|
162
|
+
case .eat(let i, let words):
|
|
163
|
+
guard lines.indices.contains(i), !words.isEmpty, words.upperBound <= lines[i].words.count else { continue }
|
|
164
|
+
// In the line, its middle at the middle of the letters.
|
|
165
|
+
let y = lines[i].rect.midY + P.middle
|
|
166
|
+
let first = lines[i].words[words.lowerBound].rect, last = lines[i].words[words.upperBound - 1].rect
|
|
167
|
+
reach(P.Point(x: first.x - P.mouth - 4, y: y), into: &list)
|
|
168
|
+
let count = lines[i].words[words].reduce(0) { $0 + max($1.text.count, 1) }
|
|
169
|
+
let m = add(.eat, to: P.Point(x: last.maxX, y: y), Double(count) * P.perLetter, line: i, words: words)
|
|
170
|
+
list.append(m); at = m.to; now = m.end
|
|
171
|
+
case .wipe(let i):
|
|
172
|
+
guard lines.indices.contains(i) else { continue }
|
|
173
|
+
let r = lines[i].rect
|
|
174
|
+
reach(P.Point(x: r.x, y: r.y), into: &list)
|
|
175
|
+
let m = add(.wipe, to: P.Point(x: r.maxX, y: r.y), r.w / P.wipeSpeed, line: i)
|
|
176
|
+
list.append(m); at = m.to; now = m.end
|
|
177
|
+
case .rest(let seconds):
|
|
178
|
+
let m = add(.rest, to: at, max(seconds, 0))
|
|
179
|
+
list.append(m); now = m.end
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
moves = list
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
func frame(at t: Double) -> Frame {
|
|
186
|
+
guard let i = moves.lastIndex(where: { $0.start <= t }) else { return Frame(done: true) }
|
|
187
|
+
let m = moves[i], p = M.span(t, m.start, m.end)
|
|
188
|
+
var f = Frame(t: t, done: t >= duration)
|
|
189
|
+
|
|
190
|
+
f.at = P.Point(x: m.from.x + (m.to.x - m.from.x) * p, y: m.from.y + (m.to.y - m.from.y) * p)
|
|
191
|
+
if m.kind == .jump {
|
|
192
|
+
// An arc over both ends, kept inside the picture.
|
|
193
|
+
let rise = 30 + hypot(m.to.x - m.from.x, m.to.y - m.from.y) * 0.12
|
|
194
|
+
f.at.y -= min(rise, max(min(m.from.y, m.to.y) - P.top, 0)) * 4 * p * (1 - p)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The look of this move, reached from the look of the one before
|
|
198
|
+
// in a fifth of a second: the eyes and the body do not snap.
|
|
199
|
+
let into = M.ease(M.span(t, m.start, m.start + 0.2))
|
|
200
|
+
let before = i > 0 ? look(moves[i - 1]) : look(m)
|
|
201
|
+
let now = look(m)
|
|
202
|
+
f.face = before.face + (now.face - before.face) * into
|
|
203
|
+
f.pose = M.blend(before.pose, now.pose, eyes: into, body: into, marks: into)
|
|
204
|
+
switch m.kind {
|
|
205
|
+
case .walk: f.pose.lift += 3 * into * abs(sin(2 * .pi * t * 2.2))
|
|
206
|
+
case .jump:
|
|
207
|
+
let air = sin(.pi * p)
|
|
208
|
+
f.pose.sy *= 1 + 0.12 * air; f.pose.sx *= 1 - 0.08 * air
|
|
209
|
+
case .eat:
|
|
210
|
+
let bite = into * (0.5 + 0.5 * sin(2 * .pi * t / P.perLetter))
|
|
211
|
+
f.pose.sy *= 1 - 0.08 * bite; f.pose.sx *= 1 + 0.05 * bite
|
|
212
|
+
case .wipe: f.pose.tilt += 5 * into * sin(2 * .pi * t * 5)
|
|
213
|
+
case .rest: break
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for done in moves[...i] {
|
|
217
|
+
let x = done.end <= t ? done.to.x : f.at.x
|
|
218
|
+
switch done.kind {
|
|
219
|
+
case .eat:
|
|
220
|
+
for word in lines[done.line].words[done.words] {
|
|
221
|
+
let gone = P.letters(of: word).filter { $0.maxX <= x + P.mouth }
|
|
222
|
+
guard let last = gone.last else { continue }
|
|
223
|
+
f.gone.append((cover(P.Rect(x: word.rect.x, y: word.rect.y, w: last.maxX - word.rect.x, h: word.rect.h)),
|
|
224
|
+
lines[done.line].paper))
|
|
225
|
+
}
|
|
226
|
+
case .wipe:
|
|
227
|
+
let r = lines[done.line].rect
|
|
228
|
+
if x > r.x { f.gone.append((cover(P.Rect(x: r.x, y: r.y, w: min(x, r.maxX) - r.x, h: r.h)), lines[done.line].paper)) }
|
|
229
|
+
if done.end > t { f.cloth = P.Rect(x: x - 9, y: r.y - 2, w: 18, h: r.h + 4) }
|
|
230
|
+
default: break
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return f
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/// A little more than the letters, so nothing of them shows at the edges.
|
|
237
|
+
private func cover(_ r: P.Rect) -> P.Rect { P.Rect(x: r.x - 1, y: r.y - 2, w: r.w + 2, h: r.h + 4) }
|
|
238
|
+
|
|
239
|
+
/// Which way it faces during a move, and its pose without the motion.
|
|
240
|
+
private func look(_ m: Move) -> (face: Double, pose: M.Pose) {
|
|
241
|
+
let way: Double = m.to.x >= m.from.x ? 1 : -1
|
|
242
|
+
switch m.kind {
|
|
243
|
+
case .jump: return (way, M.Pose(tilt: 6 * way, gy: -2))
|
|
244
|
+
case .walk: return (way, M.Pose(tilt: 7 * way))
|
|
245
|
+
case .eat: return (way, M.Pose(size: 1.12))
|
|
246
|
+
case .wipe: return (way, M.Pose(tilt: 12 * way, gy: 4, lid: 0.3))
|
|
247
|
+
case .rest: return (0, M.Pose())
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import Vision
|
|
3
|
+
|
|
4
|
+
/// Play mode's stage (#505): one picture of the screen and the lines of
|
|
5
|
+
/// text on it. The picture is read here, on this Mac, by Apple's Vision
|
|
6
|
+
/// framework; it is kept in memory only and nothing is uploaded.
|
|
7
|
+
///
|
|
8
|
+
/// Taking the picture needs the Screen Recording permission. Without it
|
|
9
|
+
/// macOS gives a picture of the desktop alone rather than an error, so the
|
|
10
|
+
/// permission is checked first.
|
|
11
|
+
enum PlayRead {
|
|
12
|
+
static var allowed: Bool { CGPreflightScreenCaptureAccess() }
|
|
13
|
+
|
|
14
|
+
/// Shows the system's request, once; after that macOS only answers no.
|
|
15
|
+
static func ask() { CGRequestScreenCaptureAccess() }
|
|
16
|
+
|
|
17
|
+
/// What is on `screen` now.
|
|
18
|
+
static func picture(of screen: NSScreen) -> CGImage? {
|
|
19
|
+
// Screens are measured from the bottom of the first one; the
|
|
20
|
+
// picture is asked for from its top.
|
|
21
|
+
guard let first = NSScreen.screens.first else { return nil }
|
|
22
|
+
let f = screen.frame
|
|
23
|
+
let rect = CGRect(x: f.minX, y: first.frame.maxY - f.maxY, width: f.width, height: f.height)
|
|
24
|
+
return CGWindowListCreateImage(rect, .optionOnScreenOnly, kCGNullWindowID, [.bestResolution])
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// The lines of text in `image`, top to bottom, in points of a picture
|
|
28
|
+
/// `size` across with its origin top-left. Slow (a second or so for a
|
|
29
|
+
/// whole screen): call it off the main thread.
|
|
30
|
+
static func lines(in image: CGImage, size: CGSize) -> [PlayMath.Line] {
|
|
31
|
+
let request = VNRecognizeTextRequest()
|
|
32
|
+
request.recognitionLevel = .accurate
|
|
33
|
+
request.recognitionLanguages = ["fr-FR", "en-US"]
|
|
34
|
+
// Where the letters are matters here, not what they spell.
|
|
35
|
+
request.usesLanguageCorrection = false
|
|
36
|
+
guard (try? VNImageRequestHandler(cgImage: image, options: [:]).perform([request])) != nil,
|
|
37
|
+
let found = request.results else { return [] }
|
|
38
|
+
let pixels = Pixels(image, size: size)
|
|
39
|
+
|
|
40
|
+
var lines: [PlayMath.Line] = []
|
|
41
|
+
for observation in found {
|
|
42
|
+
guard let text = observation.topCandidates(1).first else { continue }
|
|
43
|
+
let s = text.string
|
|
44
|
+
var words: [PlayMath.Word] = []
|
|
45
|
+
var i = s.startIndex
|
|
46
|
+
while i < s.endIndex {
|
|
47
|
+
guard !s[i].isWhitespace else { i = s.index(after: i); continue }
|
|
48
|
+
let end = s[i...].firstIndex(where: \.isWhitespace) ?? s.endIndex
|
|
49
|
+
// Vision's boxes are 0…1 from the bottom-left of the image.
|
|
50
|
+
if let b = (try? text.boundingBox(for: i..<end))?.boundingBox {
|
|
51
|
+
words.append(PlayMath.Word(text: String(s[i..<end]),
|
|
52
|
+
rect: PlayMath.Rect(x: b.minX * size.width, y: (1 - b.maxY) * size.height,
|
|
53
|
+
w: b.width * size.width, h: b.height * size.height)))
|
|
54
|
+
}
|
|
55
|
+
i = end
|
|
56
|
+
}
|
|
57
|
+
guard let first = words.first, let last = words.last else { continue }
|
|
58
|
+
let top = words.map(\.rect.y).min()!, bottom = words.map(\.rect.maxY).max()!
|
|
59
|
+
let rect = PlayMath.Rect(x: first.rect.x, y: top, w: last.rect.maxX - first.rect.x, h: bottom - top)
|
|
60
|
+
lines.append(PlayMath.Line(words: words, rect: rect, paper: pixels?.paper(around: rect) ?? 0xFFFFFF))
|
|
61
|
+
}
|
|
62
|
+
return lines.sorted { abs($0.rect.y - $1.rect.y) > 6 ? $0.rect.y < $1.rect.y : $0.rect.x < $1.rect.x }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// The picture at one pixel a point, to read colours from.
|
|
66
|
+
private struct Pixels {
|
|
67
|
+
let data: [UInt8]
|
|
68
|
+
let width, height: Int
|
|
69
|
+
|
|
70
|
+
init?(_ image: CGImage, size: CGSize) {
|
|
71
|
+
let w = Int(size.width), h = Int(size.height)
|
|
72
|
+
guard w > 0, h > 0 else { return nil }
|
|
73
|
+
var bytes = [UInt8](repeating: 0, count: w * h * 4)
|
|
74
|
+
let drawn = bytes.withUnsafeMutableBytes { buffer -> Bool in
|
|
75
|
+
guard let ctx = CGContext(data: buffer.baseAddress, width: w, height: h, bitsPerComponent: 8,
|
|
76
|
+
bytesPerRow: w * 4, space: CGColorSpace(name: CGColorSpace.sRGB)!,
|
|
77
|
+
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue) else { return false }
|
|
78
|
+
ctx.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h))
|
|
79
|
+
return true
|
|
80
|
+
}
|
|
81
|
+
guard drawn else { return nil }
|
|
82
|
+
data = bytes; width = w; height = h
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// The page's colour around a line: what most points just above
|
|
86
|
+
/// and just under it share.
|
|
87
|
+
func paper(around r: PlayMath.Rect) -> UInt32? {
|
|
88
|
+
var samples: [UInt32] = []
|
|
89
|
+
for y in [Int(r.y) - 3, Int(r.maxY) + 3] where y >= 0 && y < height {
|
|
90
|
+
for x in stride(from: max(Int(r.x), 0), to: min(Int(r.maxX), width), by: 3) {
|
|
91
|
+
let i = (y * width + x) * 4
|
|
92
|
+
samples.append(UInt32(data[i]) << 16 | UInt32(data[i + 1]) << 8 | UInt32(data[i + 2]))
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return PlayMath.common(samples)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|