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.
Files changed (114) hide show
  1. package/apps/mac-voice/Sources/AgentXVoice/Character.swift +4 -0
  2. package/apps/mac-voice/Sources/AgentXVoice/Config.swift +26 -9
  3. package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +56 -7
  4. package/apps/mac-voice/Sources/AgentXVoice/PanelAnswer.swift +2 -0
  5. package/apps/mac-voice/Sources/AgentXVoice/PillMenu.swift +9 -0
  6. package/apps/mac-voice/Sources/AgentXVoice/PillPlacement.swift +15 -4
  7. package/apps/mac-voice/Sources/AgentXVoice/PlayMath.swift +250 -0
  8. package/apps/mac-voice/Sources/AgentXVoice/PlayRead.swift +98 -0
  9. package/apps/mac-voice/Sources/AgentXVoice/PlayStage.swift +164 -0
  10. package/apps/mac-voice/Sources/AgentXVoice/SettingsLook.swift +6 -0
  11. package/apps/mac-voice/Sources/AgentXVoice/SettingsWindow.swift +4 -1
  12. package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +23 -0
  13. package/apps/mac-voice/Sources/AgentXVoice/Surface.swift +19 -0
  14. package/apps/mac-voice/Sources/AgentXVoice/VoiceSettings.swift +8 -2
  15. package/apps/mac-voice/Sources/AgentXVoice/main.swift +73 -0
  16. package/apps/mac-voice/build.sh +1 -1
  17. package/dist/{App-Y62EOIB4.js → App-UHSIO3UE.js} +2 -2
  18. package/dist/{ChatApp-VIGYSGG4.js → ChatApp-LLTFBLJY.js} +2 -2
  19. package/dist/{agent-AGBADNWK.js → agent-6RJ633MQ.js} +2 -2
  20. package/dist/{board-dashboard-MHMYOBKA.js → board-dashboard-A3ZEI4F6.js} +4 -4
  21. package/dist/{chunk-IWEN3YNQ.js → chunk-44UGVUN7.js} +2 -2
  22. package/dist/{chunk-73NSSC6K.js → chunk-4XHKJZLJ.js} +2 -2
  23. package/dist/{chunk-EWE2TJMN.js → chunk-6DCY2JCB.js} +5 -5
  24. package/dist/{chunk-BP52VVCW.js → chunk-6NECC6YS.js} +2 -2
  25. package/dist/{chunk-IBXHC3ZV.js → chunk-76IOF3DK.js} +171 -171
  26. package/dist/chunk-76IOF3DK.js.map +1 -0
  27. package/dist/{chunk-LKBUTSYP.js → chunk-ALWMYA2B.js} +2 -2
  28. package/dist/{chunk-HJUBCA7O.js → chunk-DU6536CC.js} +2 -2
  29. package/dist/{chunk-2MMC5LHF.js → chunk-EBVB727L.js} +2 -2
  30. package/dist/{chunk-Q4R4OS5S.js → chunk-GHXSBJZS.js} +2 -2
  31. package/dist/{chunk-KA6K25T4.js → chunk-IPDEWVVT.js} +2 -2
  32. package/dist/{chunk-C6PWAEVX.js → chunk-J2MHITWR.js} +2 -2
  33. package/dist/{chunk-PS3UB5PH.js → chunk-KEKO4S45.js} +2 -2
  34. package/dist/{chunk-MIRBKQHA.js → chunk-KIGFWUKP.js} +2 -2
  35. package/dist/{chunk-5EOFTPQB.js → chunk-MKF6HSBA.js} +2 -2
  36. package/dist/{chunk-74S7PKIX.js → chunk-QZIEYDIY.js} +2 -2
  37. package/dist/{chunk-DS7CDASP.js → chunk-TE44E733.js} +2 -2
  38. package/dist/{chunk-5PFTUGP4.js → chunk-VO2GNAKB.js} +2 -2
  39. package/dist/{chunk-VUSDWZ6L.js → chunk-XEKZKH3W.js} +2 -2
  40. package/dist/{chunk-VUSDWZ6L.js.map → chunk-XEKZKH3W.js.map} +1 -1
  41. package/dist/{chunk-M56MKNAG.js → chunk-ZVW645NU.js} +2 -2
  42. package/dist/{chunk-T2IDDOSX.js → chunk-ZYJDAT3E.js} +2 -2
  43. package/dist/{chunk-OQHC7TJL.js → chunk-ZZSNM2RI.js} +2 -2
  44. package/dist/cli.js +1 -1
  45. package/dist/{client-RMUEJMV5.js → client-ARS6ZFT2.js} +2 -2
  46. package/dist/{config-M2Z4HTZI.js → config-XO5Z3BO5.js} +2 -2
  47. package/dist/config-mutator-MJX4EIYV.js +2 -0
  48. package/dist/{doctor-W6OQ7JI2.js → doctor-VUK3JE6Z.js} +2 -2
  49. package/dist/index.d.ts +14 -0
  50. package/dist/index.js +1 -1
  51. package/dist/{program-6G2IS3WF.js → program-QO72D2EL.js} +166 -166
  52. package/dist/program-QO72D2EL.js.map +1 -0
  53. package/dist/{promote-YH3MNZFF.js → promote-UGQXA4KV.js} +2 -2
  54. package/dist/{query-BJC5VVS5.js → query-DL7RHPMA.js} +2 -2
  55. package/dist/{registry-EJTSQEBJ.js → registry-YIPKBRBH.js} +2 -2
  56. package/dist/{request-planner-MKVEPQGA.js → request-planner-L2FAVNKP.js} +2 -2
  57. package/dist/{review-candidates-OAG3DMQR.js → review-candidates-RPUP4XBU.js} +2 -2
  58. package/dist/{routing-U4VI4PAH.js → routing-G66SVTRO.js} +2 -2
  59. package/dist/schedule-tool-FOWPIWKP.js +2 -0
  60. package/dist/scheduler-GFVUPAD6.js +2 -0
  61. package/dist/{seat-CVOTWLEN.js → seat-ZPH7DA5L.js} +2 -2
  62. package/dist/{select-FNERAHZI.js → select-2WTV2BI2.js} +2 -2
  63. package/dist/{set-enabled-3C7AVZ65.js → set-enabled-KI2FINQD.js} +2 -2
  64. package/dist/{tool-KR4MDO2R.js → tool-2P4LRWN4.js} +2 -2
  65. package/dist/{tool-H7CDGZJ6.js → tool-ZFQ46KU3.js} +2 -2
  66. package/dist/wiki-XATGT3DI.js +2 -0
  67. package/package.json +1 -1
  68. package/dist/chunk-IBXHC3ZV.js.map +0 -1
  69. package/dist/config-mutator-L2LCW5ES.js +0 -2
  70. package/dist/program-6G2IS3WF.js.map +0 -1
  71. package/dist/schedule-tool-ZTTZBTXA.js +0 -2
  72. package/dist/scheduler-4RLL7YOW.js +0 -2
  73. package/dist/wiki-HA3U33SO.js +0 -2
  74. /package/dist/{App-Y62EOIB4.js.map → App-UHSIO3UE.js.map} +0 -0
  75. /package/dist/{ChatApp-VIGYSGG4.js.map → ChatApp-LLTFBLJY.js.map} +0 -0
  76. /package/dist/{agent-AGBADNWK.js.map → agent-6RJ633MQ.js.map} +0 -0
  77. /package/dist/{board-dashboard-MHMYOBKA.js.map → board-dashboard-A3ZEI4F6.js.map} +0 -0
  78. /package/dist/{chunk-IWEN3YNQ.js.map → chunk-44UGVUN7.js.map} +0 -0
  79. /package/dist/{chunk-73NSSC6K.js.map → chunk-4XHKJZLJ.js.map} +0 -0
  80. /package/dist/{chunk-EWE2TJMN.js.map → chunk-6DCY2JCB.js.map} +0 -0
  81. /package/dist/{chunk-BP52VVCW.js.map → chunk-6NECC6YS.js.map} +0 -0
  82. /package/dist/{chunk-LKBUTSYP.js.map → chunk-ALWMYA2B.js.map} +0 -0
  83. /package/dist/{chunk-HJUBCA7O.js.map → chunk-DU6536CC.js.map} +0 -0
  84. /package/dist/{chunk-2MMC5LHF.js.map → chunk-EBVB727L.js.map} +0 -0
  85. /package/dist/{chunk-Q4R4OS5S.js.map → chunk-GHXSBJZS.js.map} +0 -0
  86. /package/dist/{chunk-KA6K25T4.js.map → chunk-IPDEWVVT.js.map} +0 -0
  87. /package/dist/{chunk-C6PWAEVX.js.map → chunk-J2MHITWR.js.map} +0 -0
  88. /package/dist/{chunk-PS3UB5PH.js.map → chunk-KEKO4S45.js.map} +0 -0
  89. /package/dist/{chunk-MIRBKQHA.js.map → chunk-KIGFWUKP.js.map} +0 -0
  90. /package/dist/{chunk-5EOFTPQB.js.map → chunk-MKF6HSBA.js.map} +0 -0
  91. /package/dist/{chunk-74S7PKIX.js.map → chunk-QZIEYDIY.js.map} +0 -0
  92. /package/dist/{chunk-DS7CDASP.js.map → chunk-TE44E733.js.map} +0 -0
  93. /package/dist/{chunk-5PFTUGP4.js.map → chunk-VO2GNAKB.js.map} +0 -0
  94. /package/dist/{chunk-M56MKNAG.js.map → chunk-ZVW645NU.js.map} +0 -0
  95. /package/dist/{chunk-T2IDDOSX.js.map → chunk-ZYJDAT3E.js.map} +0 -0
  96. /package/dist/{chunk-OQHC7TJL.js.map → chunk-ZZSNM2RI.js.map} +0 -0
  97. /package/dist/{client-RMUEJMV5.js.map → client-ARS6ZFT2.js.map} +0 -0
  98. /package/dist/{config-M2Z4HTZI.js.map → config-XO5Z3BO5.js.map} +0 -0
  99. /package/dist/{config-mutator-L2LCW5ES.js.map → config-mutator-MJX4EIYV.js.map} +0 -0
  100. /package/dist/{doctor-W6OQ7JI2.js.map → doctor-VUK3JE6Z.js.map} +0 -0
  101. /package/dist/{promote-YH3MNZFF.js.map → promote-UGQXA4KV.js.map} +0 -0
  102. /package/dist/{query-BJC5VVS5.js.map → query-DL7RHPMA.js.map} +0 -0
  103. /package/dist/{registry-EJTSQEBJ.js.map → registry-YIPKBRBH.js.map} +0 -0
  104. /package/dist/{request-planner-MKVEPQGA.js.map → request-planner-L2FAVNKP.js.map} +0 -0
  105. /package/dist/{review-candidates-OAG3DMQR.js.map → review-candidates-RPUP4XBU.js.map} +0 -0
  106. /package/dist/{routing-U4VI4PAH.js.map → routing-G66SVTRO.js.map} +0 -0
  107. /package/dist/{schedule-tool-ZTTZBTXA.js.map → schedule-tool-FOWPIWKP.js.map} +0 -0
  108. /package/dist/{scheduler-4RLL7YOW.js.map → scheduler-GFVUPAD6.js.map} +0 -0
  109. /package/dist/{seat-CVOTWLEN.js.map → seat-ZPH7DA5L.js.map} +0 -0
  110. /package/dist/{select-FNERAHZI.js.map → select-2WTV2BI2.js.map} +0 -0
  111. /package/dist/{set-enabled-3C7AVZ65.js.map → set-enabled-KI2FINQD.js.map} +0 -0
  112. /package/dist/{tool-KR4MDO2R.js.map → tool-2P4LRWN4.js.map} +0 -0
  113. /package/dist/{tool-H7CDGZJ6.js.map → tool-ZFQ46KU3.js.map} +0 -0
  114. /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
- guard let xy = UserDefaults.standard.array(forKey: "pillOrigin") as? [Double], xy.count == 2
43
- else { return nil }
44
- return CGPoint(x: xy[0], y: xy[1])
45
- }
46
- set {
47
- if let p = newValue { UserDefaults.standard.set([Double(p.x), Double(p.y)], forKey: "pillOrigin") }
48
- else { UserDefaults.standard.removeObject(forKey: "pillOrigin") }
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
- if moved < 4 && onRow { onClick?() }
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, screens: screens, fallback: fallback))
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, forgetting where it was dragged.
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 = false
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
- if dismissed || (state.isMeta && !alwaysVisible && !expanded && busyCount == 0) { hide() } else { show() }
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 corner of `fallback`. Nil `saved`: the default corner.
20
- static func clamp(saved: CGPoint?, size: CGSize, screens: [CGRect], fallback: CGRect) -> CGPoint {
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: size, visible: fallback)
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: size, visible: fallback)
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
+ }