agentix-cli 0.94.0 → 0.95.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 (107) hide show
  1. package/apps/mac-voice/Sources/AgentXVoice/Character.swift +4 -0
  2. package/apps/mac-voice/Sources/AgentXVoice/Config.swift +7 -0
  3. package/apps/mac-voice/Sources/AgentXVoice/Hotkey.swift +8 -5
  4. package/apps/mac-voice/Sources/AgentXVoice/PlayMath.swift +250 -0
  5. package/apps/mac-voice/Sources/AgentXVoice/PlayRead.swift +98 -0
  6. package/apps/mac-voice/Sources/AgentXVoice/PlayStage.swift +164 -0
  7. package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +14 -0
  8. package/apps/mac-voice/Sources/AgentXVoice/main.swift +59 -1
  9. package/apps/mac-voice/build.sh +1 -1
  10. package/dist/{App-Y62EOIB4.js → App-6PNLFYAK.js} +2 -2
  11. package/dist/{ChatApp-VIGYSGG4.js → ChatApp-YYBPNTIA.js} +2 -2
  12. package/dist/{agent-AGBADNWK.js → agent-THOKXUGD.js} +2 -2
  13. package/dist/{board-dashboard-MHMYOBKA.js → board-dashboard-URZW2L6F.js} +4 -4
  14. package/dist/{chunk-IWEN3YNQ.js → chunk-24EKSYR7.js} +2 -2
  15. package/dist/{chunk-VPCW4477.js → chunk-3BD54ASS.js} +171 -171
  16. package/dist/chunk-3BD54ASS.js.map +1 -0
  17. package/dist/{chunk-5EOFTPQB.js → chunk-3DB2X334.js} +2 -2
  18. package/dist/{chunk-DS7CDASP.js → chunk-7V7GH57E.js} +2 -2
  19. package/dist/{chunk-Q4R4OS5S.js → chunk-AP4HOAUV.js} +2 -2
  20. package/dist/{chunk-C6PWAEVX.js → chunk-DE4SOF37.js} +2 -2
  21. package/dist/{chunk-KA6K25T4.js → chunk-FQHY4A5H.js} +2 -2
  22. package/dist/{chunk-T2IDDOSX.js → chunk-GE3ISITN.js} +2 -2
  23. package/dist/{chunk-EWE2TJMN.js → chunk-HZJ4S2LT.js} +5 -5
  24. package/dist/{chunk-HJUBCA7O.js → chunk-IEEZ3HJW.js} +2 -2
  25. package/dist/{chunk-MIRBKQHA.js → chunk-KE3FB3SK.js} +2 -2
  26. package/dist/{chunk-VUSDWZ6L.js → chunk-KFMGQ6S7.js} +2 -2
  27. package/dist/{chunk-VUSDWZ6L.js.map → chunk-KFMGQ6S7.js.map} +1 -1
  28. package/dist/{chunk-PS3UB5PH.js → chunk-NARH4ZMY.js} +2 -2
  29. package/dist/{chunk-73NSSC6K.js → chunk-PNSAL5NB.js} +2 -2
  30. package/dist/{chunk-OQHC7TJL.js → chunk-RQVRKBLH.js} +2 -2
  31. package/dist/{chunk-BP52VVCW.js → chunk-VJEO7EOS.js} +2 -2
  32. package/dist/{chunk-5PFTUGP4.js → chunk-VNVKNVGF.js} +2 -2
  33. package/dist/{chunk-LKBUTSYP.js → chunk-VUUDZFRR.js} +2 -2
  34. package/dist/{chunk-2MMC5LHF.js → chunk-WFZHADE2.js} +2 -2
  35. package/dist/{chunk-M56MKNAG.js → chunk-ZGUC3EG6.js} +2 -2
  36. package/dist/{chunk-74S7PKIX.js → chunk-ZGWDZ7X7.js} +2 -2
  37. package/dist/cli.js +1 -1
  38. package/dist/{client-RMUEJMV5.js → client-EKGV6UYH.js} +2 -2
  39. package/dist/{config-M2Z4HTZI.js → config-SF2U4WSJ.js} +2 -2
  40. package/dist/config-mutator-XTYVB2LB.js +2 -0
  41. package/dist/{doctor-W6OQ7JI2.js → doctor-3BOJJYBX.js} +2 -2
  42. package/dist/index.d.ts +7 -0
  43. package/dist/index.js +1 -1
  44. package/dist/{program-BRC2SSDI.js → program-T4RVQKES.js} +152 -152
  45. package/dist/program-T4RVQKES.js.map +1 -0
  46. package/dist/{promote-YH3MNZFF.js → promote-L2VBS3X2.js} +2 -2
  47. package/dist/{query-BJC5VVS5.js → query-Y5XOM7X6.js} +2 -2
  48. package/dist/{registry-EJTSQEBJ.js → registry-ID2KMJCU.js} +2 -2
  49. package/dist/{request-planner-MKVEPQGA.js → request-planner-LHVZFBYR.js} +2 -2
  50. package/dist/{review-candidates-OAG3DMQR.js → review-candidates-ZNCQQKQS.js} +2 -2
  51. package/dist/{routing-U4VI4PAH.js → routing-OAYXRJ7K.js} +2 -2
  52. package/dist/schedule-tool-K7UHGVQ2.js +2 -0
  53. package/dist/scheduler-VQFLXG4Y.js +2 -0
  54. package/dist/{seat-CVOTWLEN.js → seat-Y7QTFQDR.js} +2 -2
  55. package/dist/{select-FNERAHZI.js → select-OC4FXEIP.js} +2 -2
  56. package/dist/{set-enabled-3C7AVZ65.js → set-enabled-QBPTP6I4.js} +2 -2
  57. package/dist/{tool-H7CDGZJ6.js → tool-MEAHEWD2.js} +2 -2
  58. package/dist/{tool-KR4MDO2R.js → tool-P257FZXB.js} +2 -2
  59. package/dist/wiki-SZH2KSLQ.js +2 -0
  60. package/package.json +1 -1
  61. package/dist/chunk-VPCW4477.js.map +0 -1
  62. package/dist/config-mutator-L2LCW5ES.js +0 -2
  63. package/dist/program-BRC2SSDI.js.map +0 -1
  64. package/dist/schedule-tool-ZTTZBTXA.js +0 -2
  65. package/dist/scheduler-4RLL7YOW.js +0 -2
  66. package/dist/wiki-HA3U33SO.js +0 -2
  67. /package/dist/{App-Y62EOIB4.js.map → App-6PNLFYAK.js.map} +0 -0
  68. /package/dist/{ChatApp-VIGYSGG4.js.map → ChatApp-YYBPNTIA.js.map} +0 -0
  69. /package/dist/{agent-AGBADNWK.js.map → agent-THOKXUGD.js.map} +0 -0
  70. /package/dist/{board-dashboard-MHMYOBKA.js.map → board-dashboard-URZW2L6F.js.map} +0 -0
  71. /package/dist/{chunk-IWEN3YNQ.js.map → chunk-24EKSYR7.js.map} +0 -0
  72. /package/dist/{chunk-5EOFTPQB.js.map → chunk-3DB2X334.js.map} +0 -0
  73. /package/dist/{chunk-DS7CDASP.js.map → chunk-7V7GH57E.js.map} +0 -0
  74. /package/dist/{chunk-Q4R4OS5S.js.map → chunk-AP4HOAUV.js.map} +0 -0
  75. /package/dist/{chunk-C6PWAEVX.js.map → chunk-DE4SOF37.js.map} +0 -0
  76. /package/dist/{chunk-KA6K25T4.js.map → chunk-FQHY4A5H.js.map} +0 -0
  77. /package/dist/{chunk-T2IDDOSX.js.map → chunk-GE3ISITN.js.map} +0 -0
  78. /package/dist/{chunk-EWE2TJMN.js.map → chunk-HZJ4S2LT.js.map} +0 -0
  79. /package/dist/{chunk-HJUBCA7O.js.map → chunk-IEEZ3HJW.js.map} +0 -0
  80. /package/dist/{chunk-MIRBKQHA.js.map → chunk-KE3FB3SK.js.map} +0 -0
  81. /package/dist/{chunk-PS3UB5PH.js.map → chunk-NARH4ZMY.js.map} +0 -0
  82. /package/dist/{chunk-73NSSC6K.js.map → chunk-PNSAL5NB.js.map} +0 -0
  83. /package/dist/{chunk-OQHC7TJL.js.map → chunk-RQVRKBLH.js.map} +0 -0
  84. /package/dist/{chunk-BP52VVCW.js.map → chunk-VJEO7EOS.js.map} +0 -0
  85. /package/dist/{chunk-5PFTUGP4.js.map → chunk-VNVKNVGF.js.map} +0 -0
  86. /package/dist/{chunk-LKBUTSYP.js.map → chunk-VUUDZFRR.js.map} +0 -0
  87. /package/dist/{chunk-2MMC5LHF.js.map → chunk-WFZHADE2.js.map} +0 -0
  88. /package/dist/{chunk-M56MKNAG.js.map → chunk-ZGUC3EG6.js.map} +0 -0
  89. /package/dist/{chunk-74S7PKIX.js.map → chunk-ZGWDZ7X7.js.map} +0 -0
  90. /package/dist/{client-RMUEJMV5.js.map → client-EKGV6UYH.js.map} +0 -0
  91. /package/dist/{config-M2Z4HTZI.js.map → config-SF2U4WSJ.js.map} +0 -0
  92. /package/dist/{config-mutator-L2LCW5ES.js.map → config-mutator-XTYVB2LB.js.map} +0 -0
  93. /package/dist/{doctor-W6OQ7JI2.js.map → doctor-3BOJJYBX.js.map} +0 -0
  94. /package/dist/{promote-YH3MNZFF.js.map → promote-L2VBS3X2.js.map} +0 -0
  95. /package/dist/{query-BJC5VVS5.js.map → query-Y5XOM7X6.js.map} +0 -0
  96. /package/dist/{registry-EJTSQEBJ.js.map → registry-ID2KMJCU.js.map} +0 -0
  97. /package/dist/{request-planner-MKVEPQGA.js.map → request-planner-LHVZFBYR.js.map} +0 -0
  98. /package/dist/{review-candidates-OAG3DMQR.js.map → review-candidates-ZNCQQKQS.js.map} +0 -0
  99. /package/dist/{routing-U4VI4PAH.js.map → routing-OAYXRJ7K.js.map} +0 -0
  100. /package/dist/{schedule-tool-ZTTZBTXA.js.map → schedule-tool-K7UHGVQ2.js.map} +0 -0
  101. /package/dist/{scheduler-4RLL7YOW.js.map → scheduler-VQFLXG4Y.js.map} +0 -0
  102. /package/dist/{seat-CVOTWLEN.js.map → seat-Y7QTFQDR.js.map} +0 -0
  103. /package/dist/{select-FNERAHZI.js.map → select-OC4FXEIP.js.map} +0 -0
  104. /package/dist/{set-enabled-3C7AVZ65.js.map → set-enabled-QBPTP6I4.js.map} +0 -0
  105. /package/dist/{tool-H7CDGZJ6.js.map → tool-MEAHEWD2.js.map} +0 -0
  106. /package/dist/{tool-KR4MDO2R.js.map → tool-P257FZXB.js.map} +0 -0
  107. /package/dist/{wiki-HA3U33SO.js.map → wiki-SZH2KSLQ.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,6 +35,13 @@ 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? {
@@ -11,10 +11,10 @@ import Carbon.HIToolbox
11
11
  /// ⌥Space and not ⌃Space: control-space is bound to input-source switching
12
12
  /// on many Macs, and silently stealing it is a bad first impression.
13
13
  ///
14
- /// Each instance carries its own `id` and IGNORES events for any other.
15
- /// That is not decoration: every installed handler is called for EVERY
16
- /// registered hotkey, so a second binding without this filter makes both
17
- /// actions fire on either key.
14
+ /// Each instance carries its own `id` and PASSES ON events for any other.
15
+ /// That is not decoration: a press of any registered hotkey goes to the
16
+ /// handler installed last, and reaches the one before it only when that
17
+ /// handler says the event was not its own.
18
18
  final class Hotkey {
19
19
  private var ref: EventHotKeyRef?
20
20
  private var handler: EventHandlerRef?
@@ -62,7 +62,10 @@ final class Hotkey {
62
62
  event, EventParamName(kEventParamDirectObject),
63
63
  EventParamType(typeEventHotKeyID), nil,
64
64
  MemoryLayout<EventHotKeyID>.size, nil, &fired)
65
- if status == noErr, fired.id != me.id { return noErr }
65
+ // Not ours: say so. `noErr` means "handled" and ends the event
66
+ // there, so the key registered last answered and every key
67
+ // before it, the talk key first, was dead (#511).
68
+ if status == noErr, fired.id != me.id { return OSStatus(eventNotHandledErr) }
66
69
 
67
70
  let kind = GetEventKind(event)
68
71
  DispatchQueue.main.async {
@@ -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
+ }
@@ -0,0 +1,164 @@
1
+ import AppKit
2
+
3
+ /// What play mode (#505) draws over the picture: the page's colour where
4
+ /// words are gone, the cloth, and the character. The context is flipped,
5
+ /// in the picture's points.
6
+ enum PlayDraw {
7
+ static func draw(_ frame: Play.Frame, in ctx: CGContext, stops: [NSColor]) {
8
+ for gone in frame.gone {
9
+ let c = gone.paper
10
+ ctx.setFillColor(CGColor(srgbRed: CGFloat(c >> 16 & 0xFF) / 255, green: CGFloat(c >> 8 & 0xFF) / 255,
11
+ blue: CGFloat(c & 0xFF) / 255, alpha: 1))
12
+ ctx.fill(cg(gone.rect))
13
+ }
14
+ if let cloth = frame.cloth {
15
+ let path = CGPath(roundedRect: cg(cloth), cornerWidth: 4, cornerHeight: 4, transform: nil)
16
+ ctx.addPath(path)
17
+ ctx.setFillColor(stops[4].cgColor)
18
+ ctx.fillPath()
19
+ ctx.addPath(path)
20
+ ctx.setStrokeColor(stops[1].cgColor)
21
+ ctx.setLineWidth(1)
22
+ ctx.strokePath()
23
+ }
24
+ let shown = CharacterSim.Frame(pose: frame.pose, x: frame.at.x, face: frame.face, t: frame.t)
25
+ CharacterDraw.draw(shown, in: ctx, edge: CGPoint(x: frame.at.x, y: frame.at.y), origin: 0,
26
+ unit: CGFloat(PlayMath.unit), stops: stops)
27
+ }
28
+
29
+ private static func cg(_ r: PlayMath.Rect) -> CGRect { CGRect(x: r.x, y: r.y, width: r.w, height: r.h) }
30
+ }
31
+
32
+ /// The stage: a frozen picture of the screen laid over everything, with
33
+ /// the character playing on it. The real page is never touched: nothing
34
+ /// is clicked, typed or changed, and what looks eaten or wiped is only
35
+ /// painted over on the picture.
36
+ ///
37
+ /// The page behind goes stale while the picture is up, so play ends on
38
+ /// the first real input: any key, a click, a scroll, another app or Space
39
+ /// coming forward. Unlike the character on its edge, this window does
40
+ /// take keys and clicks, for that one purpose.
41
+ @MainActor
42
+ final class PlayHost {
43
+ private var window: NSPanel?
44
+ private var timer: Timer?
45
+ private var observers: [(NotificationCenter, NSObjectProtocol)] = []
46
+ private var script: Play?
47
+ private var began = 0.0
48
+ /// Raised by each `start`, so a text read that outlives its play can
49
+ /// tell that the picture now up is not the one it read.
50
+ private(set) var number = 0
51
+ /// Told each time a play is over.
52
+ var onEnd: (() -> Void)?
53
+
54
+ var running: Bool { window != nil }
55
+
56
+ /// Lay `picture` over `screen`, the character waiting at `foot` (the
57
+ /// picture's points) until `play(_:)` gives it a script: reading the
58
+ /// text takes a moment, and the page must not move meanwhile.
59
+ func start(picture: CGImage, on screen: NSScreen, stops: [NSColor], at foot: PlayMath.Point) {
60
+ guard !running else { return }
61
+ script = nil
62
+ number += 1
63
+ let panel = PlayWindow(contentRect: screen.frame, styleMask: [.borderless, .nonactivatingPanel],
64
+ backing: .buffered, defer: false)
65
+ panel.level = .screenSaver
66
+ panel.collectionBehavior = [.fullScreenAuxiliary]
67
+ panel.hasShadow = false
68
+ panel.hidesOnDeactivate = false
69
+
70
+ let bounds = NSRect(origin: .zero, size: screen.frame.size)
71
+ let backdrop = NSView(frame: bounds)
72
+ backdrop.wantsLayer = true
73
+ backdrop.layer?.contents = picture
74
+ let view = PlayView(frame: bounds)
75
+ view.stops = stops
76
+ view.shown = Play.Frame(at: foot, pose: CharacterMath.pose(.working))
77
+ view.onInput = { [weak self] in self?.end("a key or a click") }
78
+ backdrop.addSubview(view)
79
+ panel.contentView = backdrop
80
+ panel.setFrame(screen.frame, display: true)
81
+ window = panel
82
+ panel.makeKeyAndOrderFront(nil)
83
+ panel.makeFirstResponder(view)
84
+
85
+ func watch(_ center: NotificationCenter, _ name: Notification.Name, _ object: Any?, _ why: String) {
86
+ observers.append((center, center.addObserver(forName: name, object: object, queue: .main) { [weak self] _ in
87
+ MainActor.assumeIsolated { self?.end(why) }
88
+ }))
89
+ }
90
+ watch(.default, NSWindow.didResignKeyNotification, panel, "another window came forward")
91
+ watch(NSWorkspace.shared.notificationCenter, NSWorkspace.activeSpaceDidChangeNotification, nil, "the Space changed")
92
+ watch(.default, NSApplication.didChangeScreenParametersNotification, nil, "the screens changed")
93
+
94
+ let shownAt = ProcessInfo.processInfo.systemUptime
95
+ let frames = Timer(timeInterval: 1.0 / 30, repeats: true) { [weak self, weak view] _ in
96
+ MainActor.assumeIsolated {
97
+ guard let self, let view else { return }
98
+ let now = ProcessInfo.processInfo.systemUptime
99
+ if let script = self.script {
100
+ view.shown = script.frame(at: now - self.began)
101
+ } else {
102
+ // Its thinking dots hop while it waits.
103
+ view.shown.t = now - shownAt
104
+ }
105
+ view.needsDisplay = true
106
+ if view.shown.done { self.end("the script ended") }
107
+ }
108
+ }
109
+ RunLoop.main.add(frames, forMode: .common)
110
+ timer = frames
111
+ }
112
+
113
+ /// The script to play on the picture that is up. Nothing happens if
114
+ /// the play has ended meanwhile.
115
+ func play(_ script: Play) {
116
+ guard running, self.script == nil else { return }
117
+ self.script = script
118
+ began = ProcessInfo.processInfo.systemUptime
119
+ Log.info("play: started, \(script.lines.count) lines read, \(String(format: "%.1f", script.duration)) s")
120
+ }
121
+
122
+ /// Take the picture away and drop it. Safe to call when no play runs.
123
+ func end(_ why: String) {
124
+ guard let panel = window else { return }
125
+ window = nil
126
+ timer?.invalidate()
127
+ timer = nil
128
+ for (center, observer) in observers { center.removeObserver(observer) }
129
+ observers = []
130
+ panel.orderOut(nil)
131
+ panel.contentView?.layer?.contents = nil
132
+ panel.contentView = nil
133
+ Log.info("play: ended, \(why)")
134
+ onEnd?()
135
+ }
136
+ }
137
+
138
+ /// Borderless, and still able to take the keys that end the play.
139
+ private final class PlayWindow: NSPanel {
140
+ override var canBecomeKey: Bool { true }
141
+ }
142
+
143
+ /// The drawing over the picture. Every key, click and scroll ends the play.
144
+ private final class PlayView: NSView {
145
+ var shown = Play.Frame()
146
+ var stops = CharacterDraw.stops(tint: Brand.accent, colors: nil)
147
+ var onInput: (() -> Void)?
148
+
149
+ override var isFlipped: Bool { true }
150
+ override var acceptsFirstResponder: Bool { true }
151
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
152
+
153
+ override func draw(_ dirtyRect: NSRect) {
154
+ guard let ctx = NSGraphicsContext.current?.cgContext else { return }
155
+ PlayDraw.draw(shown, in: ctx, stops: stops)
156
+ }
157
+
158
+ override func keyDown(with event: NSEvent) { onInput?() }
159
+ override func performKeyEquivalent(with event: NSEvent) -> Bool { onInput?(); return true }
160
+ override func mouseDown(with event: NSEvent) { onInput?() }
161
+ override func rightMouseDown(with event: NSEvent) { onInput?() }
162
+ override func otherMouseDown(with event: NSEvent) { onInput?() }
163
+ override func scrollWheel(with event: NSEvent) { onInput?() }
164
+ }
@@ -55,6 +55,9 @@ final class StatusMenu: NSObject, NSMenuDelegate {
55
55
  /// Bring a hidden character back, and let its bubble show again.
56
56
  var onShowCharacter: (() -> Void)?
57
57
  var onSettings: (() -> Void)?
58
+ /// Play mode (#505): whether a play can start now, and start one.
59
+ var playReady: () -> Bool = { false }
60
+ var onPlay: (() -> Void)?
58
61
 
59
62
  override init() {
60
63
  super.init()
@@ -248,6 +251,14 @@ final class StatusMenu: NSObject, NSMenuDelegate {
248
251
  let orb = action("Animated orb", #selector(toggleOrb), key: "")
249
252
  orb.state = Config.animatedOrb ? .on : .off
250
253
  menu.addItem(orb)
254
+ let mode = action("Play mode", #selector(togglePlayMode), key: "")
255
+ mode.state = Config.playMode ? .on : .off
256
+ menu.addItem(mode)
257
+ if Config.playMode {
258
+ let play = action("Play on this page", #selector(startPlay), key: "")
259
+ play.isEnabled = playReady()
260
+ menu.addItem(play)
261
+ }
251
262
  // The character hides with its bubble, and has no bubble at rest.
252
263
  switch characterVisible() {
253
264
  case true?: menu.addItem(action("Hide character", #selector(hidePill), key: ""))
@@ -313,6 +324,9 @@ final class StatusMenu: NSObject, NSMenuDelegate {
313
324
  onAnimatedOrbChanged?(Config.animatedOrb)
314
325
  }
315
326
 
327
+ @objc private func togglePlayMode() { Config.playMode.toggle() }
328
+ @objc private func startPlay() { onPlay?() }
329
+
316
330
  @objc private func hidePill() { onHidePill?() }
317
331
  @objc private func showCharacter() { onShowCharacter?() }
318
332
  @objc private func resetPosition() { onResetPosition?() }