diff --git a/App/Blurt/Blurt.xcodeproj/project.pbxproj b/App/Blurt/Blurt.xcodeproj/project.pbxproj index b9cb501..3f36dde 100644 --- a/App/Blurt/Blurt.xcodeproj/project.pbxproj +++ b/App/Blurt/Blurt.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ 0F6D746DF48BB048827A358F /* rom1b-29-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 786672855B44AB80CAF4D782 /* rom1b-29-stop.m4a */; }; 1088FECB82D84AD0951CA1E2 /* juno-83-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 920F5A55960A00E2AECF1362 /* juno-83-stop.m4a */; }; 10EA68562CE70506895CB2FE /* rom1b-31-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 1D5C52A673686FB041595227 /* rom1b-31-stop.m4a */; }; + 121DC6CD82DEAC7CC0F5AC24 /* BoundTriggerKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8999B37D9418B7A6923BD358 /* BoundTriggerKey.swift */; }; 1275CFCDBF1D593B545C8A84 /* rom1b-15-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 6D89280137DAC07403FB60FC /* rom1b-15-start.m4a */; }; 15210F6BE968D3E2EF771DE0 /* rom1a-5-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 2CA6F4E6A10AE93F169DF19B /* rom1a-5-stop.m4a */; }; 152452FE264E453808327885 /* juno-122-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 9FE15281EA6DE5C2C1978C44 /* juno-122-stop.m4a */; }; @@ -120,6 +121,7 @@ 409F9B76AE0CB9FD2D78EA3D /* rom1b-22-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = DAA2E01D418EA3ABA8A9DD5F /* rom1b-22-start.m4a */; }; 40CFC003124E4E9E13CFDB76 /* rom1a-11-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = CDCD486F98020360173EBEFA /* rom1a-11-start.m4a */; }; 418B5EABF6FB429CB94F6B0C /* juno-66-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 4E414413BCDA4C613782919D /* juno-66-stop.m4a */; }; + 4290831AB516CD38229449D8 /* DeveloperSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7C95B4C80443E2A6EFE05E7 /* DeveloperSection.swift */; }; 43223BEF5DB80A497D743186 /* juno-102-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 9308FFA344AB1B73A960BD15 /* juno-102-stop.m4a */; }; 4372C0AD932309335C25FF8D /* juno-123-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 96D2FE2DE3A2177A80764E1B /* juno-123-start.m4a */; }; 438A2882ACACB5153CCA1D21 /* juno-127-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 88CC243509230D21563FDF89 /* juno-127-start.m4a */; }; @@ -178,6 +180,7 @@ 697C21589BFDE5A66DD168A1 /* juno-67-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 5D1E0161AF60ADB6D903535E /* juno-67-start.m4a */; }; 69C396E4080D37117F2AEB01 /* juno-87-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 9D02AECDAE6B146F11B7A721 /* juno-87-stop.m4a */; }; 69E27229D8C64D7119918B7A /* SoundStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27E18FF6880EADB94F98C7C4 /* SoundStepView.swift */; }; + 6A1402BFED3758AAD9C84ADF /* SettingRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 254D4D3CAC1C114EC51C96C8 /* SettingRow.swift */; }; 6A498D9EE4EDDA7B47837FB6 /* juno-73-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = D6CA551786B56B10B45B9BF3 /* juno-73-start.m4a */; }; 6B3FF5991DA85C563D97B83C /* juno-109-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 47B0B08D890A1A7CB4577410 /* juno-109-start.m4a */; }; 6B5EAA66BF23484BBB1F5640 /* rom1b-7-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = EA933A670288BEAFD43D468E /* rom1b-7-start.m4a */; }; @@ -193,6 +196,7 @@ 738A8EF4AB672CD2B7F13319 /* juno-86-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 40703D977839CB4F625C0284 /* juno-86-stop.m4a */; }; 73E75F74DC2F7984F50E184A /* juno-110-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 09CD5D88E1A2BE5D0095F167 /* juno-110-start.m4a */; }; 73FA46BD466212E9AE09C847 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C151FD5B49E5D7E61BDDEF0 /* App.swift */; }; + 74BD28AA54484635D62FA797 /* OverlayPillContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41AD30574EA478D2BD0E87ED /* OverlayPillContent.swift */; }; 750BE46A52E2E794D4FD3366 /* rom1b-26-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = DD0650160548287F62C1FA77 /* rom1b-26-start.m4a */; }; 7661BD55883B6F3B03AA3905 /* juno-40-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = BCE9C2C9715E2C1C96683AE0 /* juno-40-stop.m4a */; }; 76C1876DB538F8E190EB509E /* BlurtCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = D643CB1D491E00A2182907F8 /* BlurtCommands.swift */; }; @@ -301,6 +305,7 @@ B3FC1877229B7510E656EEFC /* rom1a-7-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = D79CA18872ADF342DE5426CA /* rom1a-7-stop.m4a */; }; B56AB7FEAEC9E9ECE36B400E /* juno-28-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 8A319EF914358A938C87389D /* juno-28-stop.m4a */; }; B5A7CEA6517E5A1E4D77623F /* FloatingPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE2598E3B8BA1D650EEEEF4B /* FloatingPanel.swift */; }; + B5ADC67B78CA3EA70D6BAEB3 /* RecentDictationsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0EC4E3D73862F8E4DB6F47D /* RecentDictationsSection.swift */; }; B6ACE56CCFCAE22FC67833AF /* juno-76-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 94546743017BF75E0DC24ACC /* juno-76-start.m4a */; }; B6B9598209C21737FC80D9FC /* MenuBarUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63869ED0BF971F732668349B /* MenuBarUITests.swift */; }; B844779F8427A1225C45A9FB /* WizardController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30E57A9C9B934A2120381005 /* WizardController.swift */; }; @@ -504,6 +509,7 @@ 22FF5E0C4681C483B4828FB2 /* juno-38-start.m4a */ = {isa = PBXFileReference; path = "juno-38-start.m4a"; sourceTree = ""; }; 240939D503F3CE03A0697A5F /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; 24B8138546D9CD21F6D55F54 /* juno-60-start.m4a */ = {isa = PBXFileReference; path = "juno-60-start.m4a"; sourceTree = ""; }; + 254D4D3CAC1C114EC51C96C8 /* SettingRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingRow.swift; sourceTree = ""; }; 258681056CA44359083EDC9B /* rom1b-12-stop.m4a */ = {isa = PBXFileReference; path = "rom1b-12-stop.m4a"; sourceTree = ""; }; 27E18FF6880EADB94F98C7C4 /* SoundStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundStepView.swift; sourceTree = ""; }; 281E6ED71E7F613E6E6689D0 /* juno-25-start.m4a */ = {isa = PBXFileReference; path = "juno-25-start.m4a"; sourceTree = ""; }; @@ -571,6 +577,7 @@ 40A711B3442D9CBE0189BEF3 /* juno-2-stop.m4a */ = {isa = PBXFileReference; path = "juno-2-stop.m4a"; sourceTree = ""; }; 41587FEDA3BE5310D3FA6CDA /* juno-127-stop.m4a */ = {isa = PBXFileReference; path = "juno-127-stop.m4a"; sourceTree = ""; }; 41736827B0D4E7E1D024DCF1 /* juno-30-start.m4a */ = {isa = PBXFileReference; path = "juno-30-start.m4a"; sourceTree = ""; }; + 41AD30574EA478D2BD0E87ED /* OverlayPillContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayPillContent.swift; sourceTree = ""; }; 41CCE59FAFC731DAAFB8B09A /* juno-103-start.m4a */ = {isa = PBXFileReference; path = "juno-103-start.m4a"; sourceTree = ""; }; 429B975FA7FFA777106D0A49 /* rom1b-13-stop.m4a */ = {isa = PBXFileReference; path = "rom1b-13-stop.m4a"; sourceTree = ""; }; 44DD60B425DF100431946DAB /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; @@ -690,6 +697,7 @@ 886848A89BE3512619263E54 /* juno-94-start.m4a */ = {isa = PBXFileReference; path = "juno-94-start.m4a"; sourceTree = ""; }; 8887DCE124B065F8313AC59F /* juno-9-start.m4a */ = {isa = PBXFileReference; path = "juno-9-start.m4a"; sourceTree = ""; }; 88CC243509230D21563FDF89 /* juno-127-start.m4a */ = {isa = PBXFileReference; path = "juno-127-start.m4a"; sourceTree = ""; }; + 8999B37D9418B7A6923BD358 /* BoundTriggerKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoundTriggerKey.swift; sourceTree = ""; }; 899D9AD955B936F6BFC4E293 /* juno-52-start.m4a */ = {isa = PBXFileReference; path = "juno-52-start.m4a"; sourceTree = ""; }; 89F61F7352130DAAA530D836 /* juno-107-stop.m4a */ = {isa = PBXFileReference; path = "juno-107-stop.m4a"; sourceTree = ""; }; 8A319EF914358A938C87389D /* juno-28-stop.m4a */ = {isa = PBXFileReference; path = "juno-28-stop.m4a"; sourceTree = ""; }; @@ -727,6 +735,7 @@ A01872064CB7D739DE6F9952 /* rom1a-25-stop.m4a */ = {isa = PBXFileReference; path = "rom1a-25-stop.m4a"; sourceTree = ""; }; A0C585C5F805520ADA502A8F /* UITestIdentifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestIdentifiers.swift; sourceTree = ""; }; A0C78659208520BB93EAB372 /* juno-12-start.m4a */ = {isa = PBXFileReference; path = "juno-12-start.m4a"; sourceTree = ""; }; + A0EC4E3D73862F8E4DB6F47D /* RecentDictationsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentDictationsSection.swift; sourceTree = ""; }; A27D7F43295DE2A81EFFDDBC /* OverlayWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayWindowController.swift; sourceTree = ""; }; A2C5021E8F72182F331DA397 /* juno-3-start.m4a */ = {isa = PBXFileReference; path = "juno-3-start.m4a"; sourceTree = ""; }; A4AFAD52CAC3825D7A603DC5 /* juno-100-start.m4a */ = {isa = PBXFileReference; path = "juno-100-start.m4a"; sourceTree = ""; }; @@ -773,6 +782,7 @@ C59092F9220BBE7465068325 /* KeyTermsStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyTermsStepView.swift; sourceTree = ""; }; C6EFB0086ED63DB182AFFC8F /* SettingsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsUITests.swift; sourceTree = ""; }; C7714523A92F4753092CA67C /* juno-81-start.m4a */ = {isa = PBXFileReference; path = "juno-81-start.m4a"; sourceTree = ""; }; + C7C95B4C80443E2A6EFE05E7 /* DeveloperSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSection.swift; sourceTree = ""; }; C7DAE8C9E70F6D2232F409DE /* juno-101-start.m4a */ = {isa = PBXFileReference; path = "juno-101-start.m4a"; sourceTree = ""; }; CB487BE3C821FF18E414CF4B /* juno-117-stop.m4a */ = {isa = PBXFileReference; path = "juno-117-stop.m4a"; sourceTree = ""; }; CBBA2376BDBD82E7474C9D84 /* juno-46-start.m4a */ = {isa = PBXFileReference; path = "juno-46-start.m4a"; sourceTree = ""; }; @@ -1349,8 +1359,12 @@ isa = PBXGroup; children = ( D643CB1D491E00A2182907F8 /* BlurtCommands.swift */, + 8999B37D9418B7A6923BD358 /* BoundTriggerKey.swift */, + C7C95B4C80443E2A6EFE05E7 /* DeveloperSection.swift */, 01F10B3261EA8F8616B8595A /* MainWindowRoot.swift */, 383E03AB60BA21AAD92655D5 /* ReadyView.swift */, + A0EC4E3D73862F8E4DB6F47D /* RecentDictationsSection.swift */, + 254D4D3CAC1C114EC51C96C8 /* SettingRow.swift */, 37CBB4580F251B182A8B1937 /* SettingsWindowRoot.swift */, 30E57A9C9B934A2120381005 /* WizardController.swift */, 7F646CD07DBBA47F44CE0AD1 /* WizardView.swift */, @@ -1378,6 +1392,7 @@ 912505E1D0FBD5D5CA591BA2 /* Overlay */ = { isa = PBXGroup; children = ( + 41AD30574EA478D2BD0E87ED /* OverlayPillContent.swift */, 2E79CB809D285C0C27E8D43D /* OverlayView.swift */, A27D7F43295DE2A81EFFDDBC /* OverlayWindowController.swift */, ); @@ -1977,7 +1992,9 @@ 3407E8759D61D569A998EE78 /* AppCoordinator.swift in Sources */, 8D2BA6E9FAC605D678A73164 /* AppDelegate.swift in Sources */, 76C1876DB538F8E190EB509E /* BlurtCommands.swift in Sources */, + 121DC6CD82DEAC7CC0F5AC24 /* BoundTriggerKey.swift in Sources */, A5D51CD762229A68757463C8 /* CueSoundPlayer.swift in Sources */, + 4290831AB516CD38229449D8 /* DeveloperSection.swift in Sources */, 0F1317CCFA2A3245DAA96141 /* DictationComposition.swift in Sources */, 1C40B5DB92262E05808ECCEA /* DictationKeyTap.swift in Sources */, B5A7CEA6517E5A1E4D77623F /* FloatingPanel.swift in Sources */, @@ -1986,10 +2003,13 @@ E8AE03AA4DCD807D2BB8D660 /* LiquidGlass.swift in Sources */, 7F838018EA0ACE7170FC32C4 /* MainWindowRoot.swift in Sources */, AD32046355138298E7FC40D0 /* MenuBarScene.swift in Sources */, + 74BD28AA54484635D62FA797 /* OverlayPillContent.swift in Sources */, 9437C292A6D7FE533338073D /* OverlayView.swift in Sources */, 8FE4B9B139BB9557FBC5589B /* OverlayWindowController.swift in Sources */, D750C1BF16502EEC92182ECD /* PermissionsStepView.swift in Sources */, A13335FE97FA2E60A4F5CABC /* ReadyView.swift in Sources */, + B5ADC67B78CA3EA70D6BAEB3 /* RecentDictationsSection.swift in Sources */, + 6A1402BFED3758AAD9C84ADF /* SettingRow.swift in Sources */, 1D1100AB3D47EFC80D47B04B /* SettingsWindowRoot.swift in Sources */, 69E27229D8C64D7119918B7A /* SoundStepView.swift in Sources */, 1817ACD2F7D8F7AF3C9B1C7C /* UITestIdentifiers.swift in Sources */, diff --git a/App/Blurt/Blurt/Overlay/OverlayPillContent.swift b/App/Blurt/Blurt/Overlay/OverlayPillContent.swift new file mode 100644 index 0000000..32c596e --- /dev/null +++ b/App/Blurt/Blurt/Overlay/OverlayPillContent.swift @@ -0,0 +1,190 @@ +import BlurtEngine +import SwiftUI + +enum OverlayBrandPalette { + static let cyan = Color(red: 0.20, green: 0.88, blue: 0.96) + static let magenta = Color(red: 0.98, green: 0.12, blue: 0.73) +} + +/// The shared type, tracking, and cyan color for the overlay's status-line +/// text ("Transcribing…", "Pasted", and "Copied") so they can't drift out of +/// sync. +struct StatusLineText: View { + let text: String + + init(_ text: String) { + self.text = text + } + + var body: some View { + Text(text) + .font(.system(size: 10, weight: .semibold)) + .tracking(0.8) + .lineLimit(1) + .minimumScaleFactor(0.7) + .foregroundStyle(OverlayBrandPalette.cyan) + } +} + +/// Redraw cap for the pill's continuous animations — the REC dot's pulse, the +/// "Transcribing…" breath, and the waveform's idle wave. +/// +/// Read from the engine rather than restated: the cap exists *because* of the mic +/// meter's cadence (the level feed and these slow sines can't show anything +/// faster, so rendering at the display's full refresh rate — up to 120 Hz on +/// ProMotion — would only burn energy), so it tracks that one number instead of +/// duplicating it behind a comment that could go stale. +private let overlayAnimationInterval = MicCapture.meterIntervalSeconds + +extension View { + /// Raised-cosine opacity breathing over `period`: 1 → `minOpacity` → 1, so the + /// view eases through the dim point instead of bouncing off it. Shared by the + /// REC dot and the "Transcribing…" label so the pill's two heartbeats stay one + /// curve. With `animated` false (Reduce Motion) the view renders untouched. + @ViewBuilder + func pulsingOpacity(period: Double, minOpacity: Double, animated: Bool) -> some View { + if animated { + TimelineView(.animation(minimumInterval: overlayAnimationInterval)) { timeline in + self.opacity( + MeterBarGeometry.breathingOpacity( + time: timeline.date.timeIntervalSinceReferenceDate, + period: period, minOpacity: minOpacity)) + } + } else { + self + } + } +} + +/// The "Transcribing…" status line with a slow breathing pulse — the processing +/// counterpart of the recording bars' idle shimmer, so the pill keeps visibly +/// working while the app waits on the Sync API and pastes the result. Driven by +/// the same continuous-clock `TimelineView` pattern as `WaveformBars` (never a +/// one-shot state toggle). Under Reduce Motion the label holds steady at full +/// opacity — exactly the pre-animation rendering. +struct TranscribingLabel: View { + /// Whether to run the breathing motion (off under Reduce Motion). + let animated: Bool + + // One breath every ~1.8 s, dimming to ~55% and back: slow and shallow enough + // to read as a calm heartbeat rather than an alert blink. The floor keeps the + // 10 pt cyan legible against the dark tint at the trough, and limits the + // brightness pop if the cross-fade to "Pasted" (rendered at full opacity) + // starts mid-breath. + private let breathPeriod: Double = 1.8 + private let minOpacity: Double = 0.55 + + var body: some View { + label.pulsingOpacity(period: breathPeriod, minOpacity: minOpacity, animated: animated) + } + + // Shared with the "Pasted" notice (OverlayView's `.pasted` case) so the + // processing → pasted hand-off reads as one status line. + private var label: some View { + StatusLineText("Transcribing…") + } +} + +/// The "● REC" recording tag: a pulsing magenta dot + "REC" caption, sitting to +/// the left of the waveform — the native echo of the site demo's magenta pixel +/// tag. Magenta (the brand --hot) stands in for the conventional red record dot; +/// its slow pulse (see `dot`) carries the live-capture affordance while the cyan +/// bars carry the level. +struct RecordingTag: View { + /// Whether to pulse the dot (off under Reduce Motion). + let animated: Bool + + // One pulse every ~1.2 s, dimming to 40% and back: the universal "recording, + // right now" heartbeat. Since magenta stands in for the conventional red dot, + // the pulse — not the hue — carries the live-capture cue. Driven by the same + // continuous-clock TimelineView as the waveform and TranscribingLabel (never a + // one-shot repeatForever toggle). + private let pulsePeriod: Double = 1.2 + private let minOpacity: Double = 0.4 + + var body: some View { + HStack(spacing: 4) { + dot + Text("REC") + .font(.system(size: 9, weight: .semibold)) + .tracking(1.2) + .foregroundStyle(OverlayBrandPalette.magenta) + } + .fixedSize() + } + + /// The magenta record dot, breathing while recording. + private var dot: some View { + circle.pulsingOpacity(period: pulsePeriod, minOpacity: minOpacity, animated: animated) + } + + private var circle: some View { + Circle() + .fill(OverlayBrandPalette.magenta) + .frame(width: 5, height: 5) + } +} + +/// The only view that reads `bridge.level`, so @Observable scopes the ~20 Hz +/// meter invalidation to this leaf (and its `WaveformBars` child) instead of the +/// enclosing `OverlayView`. `WaveformBars` stays a pure value view — easy to +/// reason about and drive from a fixed level — with the observation isolated +/// here. See `OverlayView.bridge` for why the level isn't threaded as a value. +struct WaveformBarsLevel: View { + let bridge: OverlayBridge + let animated: Bool + let color: Color + + var body: some View { + WaveformBars(level: bridge.level, animated: animated, color: color) + } +} + +/// A row of bars filling the whole pill that track the *current* mic level — no +/// scrolling history. Bars span the full width (count derived from the available +/// width) and grow from the vertical center; a symmetric envelope keeps the +/// middle tallest so the field reads as one voice "blob". When you're between +/// words a gentle wave travels across the bars; the breathing fades out +/// as your voice gets louder. Under Reduce Motion the breathing is dropped and +/// heights simply reflect the level. +private struct WaveformBars: View { + /// Current loudness, 0...1 (MicCapture.linearLevel). + let level: Float + /// Whether to run the idle breathing motion (off under Reduce Motion). + let animated: Bool + let color: Color + + var body: some View { + GeometryReader { geo in + // Bar count, heights, the envelope, and the idle wave are all engine + // geometry (unit-tested there); this view owns the frame, the color, and the + // redraw cadence. Resolved once per layout, then shared by every bar. + let layout = MeterBarRow(availableSize: geo.size) + Group { + if animated { + // Continuous clock so the idle breathing is smooth and never depends + // on a one-shot state toggle; capped at `overlayAnimationInterval`. + TimelineView(.animation(minimumInterval: overlayAnimationInterval)) { timeline in + bars(layout, time: timeline.date.timeIntervalSinceReferenceDate) + } + } else { + bars(layout, time: 0) + } + } + .frame(width: geo.size.width, height: geo.size.height) + } + } + + private func bars(_ layout: MeterBarRow, time: TimeInterval) -> some View { + HStack(spacing: MeterBarGeometry.barSpacing) { + ForEach(0.. some View { - if animated { - TimelineView(.animation(minimumInterval: overlayAnimationInterval)) { timeline in - self.opacity( - MeterBarGeometry.breathingOpacity( - time: timeline.date.timeIntervalSinceReferenceDate, - period: period, minOpacity: minOpacity)) - } - } else { - self - } - } -} - -/// The "Transcribing…" status line with a slow breathing pulse — the processing -/// counterpart of the recording bars' idle shimmer, so the pill keeps visibly -/// working while the app waits on the Sync API and pastes the result. Driven by -/// the same continuous-clock `TimelineView` pattern as `WaveformBars` (never a -/// one-shot state toggle). Under Reduce Motion the label holds steady at full -/// opacity — exactly the pre-animation rendering. -private struct TranscribingLabel: View { - /// Whether to run the breathing motion (off under Reduce Motion). - let animated: Bool - - // One breath every ~1.8 s, dimming to ~55% and back: slow and shallow enough - // to read as a calm heartbeat rather than an alert blink. The floor keeps the - // 10 pt cyan legible against the dark tint at the trough, and limits the - // brightness pop if the cross-fade to "Pasted" (rendered at full opacity) - // starts mid-breath. - private let breathPeriod: Double = 1.8 - private let minOpacity: Double = 0.55 - - var body: some View { - label.pulsingOpacity(period: breathPeriod, minOpacity: minOpacity, animated: animated) - } - - // Shared with the "Pasted" notice (OverlayView's `.pasted` case) so the - // processing → pasted hand-off reads as one status line. - private var label: some View { - StatusLineText("Transcribing…") - } -} - -/// The "● REC" recording tag: a pulsing magenta dot + "REC" caption, sitting to -/// the left of the waveform — the native echo of the site demo's magenta pixel -/// tag. Magenta (the brand --hot) stands in for the conventional red record dot; -/// its slow pulse (see `dot`) carries the live-capture affordance while the cyan -/// bars carry the level. -private struct RecordingTag: View { - /// Whether to pulse the dot (off under Reduce Motion). - let animated: Bool - - // One pulse every ~1.2 s, dimming to 40% and back: the universal "recording, - // right now" heartbeat. Since magenta stands in for the conventional red dot, - // the pulse — not the hue — carries the live-capture cue. Driven by the same - // continuous-clock TimelineView as the waveform and TranscribingLabel (never a - // one-shot repeatForever toggle). - private let pulsePeriod: Double = 1.2 - private let minOpacity: Double = 0.4 - - var body: some View { - HStack(spacing: 4) { - dot - Text("REC") - .font(.system(size: 9, weight: .semibold)) - .tracking(1.2) - .foregroundStyle(OverlayBrandPalette.magenta) - } - .fixedSize() - } - - /// The magenta record dot, breathing while recording. - private var dot: some View { - circle.pulsingOpacity(period: pulsePeriod, minOpacity: minOpacity, animated: animated) - } - - private var circle: some View { - Circle() - .fill(OverlayBrandPalette.magenta) - .frame(width: 5, height: 5) - } -} - -/// The only view that reads `bridge.level`, so @Observable scopes the ~20 Hz -/// meter invalidation to this leaf (and its `WaveformBars` child) instead of the -/// enclosing `OverlayView`. `WaveformBars` stays a pure value view — easy to -/// reason about and drive from a fixed level — with the observation isolated -/// here. See `OverlayView.bridge` for why the level isn't threaded as a value. -private struct WaveformBarsLevel: View { - let bridge: OverlayBridge - let animated: Bool - let color: Color - - var body: some View { - WaveformBars(level: bridge.level, animated: animated, color: color) - } -} - -/// A row of bars filling the whole pill that track the *current* mic level — no -/// scrolling history. Bars span the full width (count derived from the available -/// width) and grow from the vertical center; a symmetric envelope keeps the -/// middle tallest so the field reads as one voice "blob". When you're between -/// words a gentle wave travels across the bars; the breathing fades out -/// as your voice gets louder. Under Reduce Motion the breathing is dropped and -/// heights simply reflect the level. -private struct WaveformBars: View { - /// Current loudness, 0...1 (MicCapture.linearLevel). - let level: Float - /// Whether to run the idle breathing motion (off under Reduce Motion). - let animated: Bool - let color: Color - - var body: some View { - GeometryReader { geo in - // Bar count, heights, the envelope, and the idle wave are all engine - // geometry (unit-tested there); this view owns the frame, the color, and the - // redraw cadence. Resolved once per layout, then shared by every bar. - let layout = MeterBarRow(availableSize: geo.size) - Group { - if animated { - // Continuous clock so the idle breathing is smooth and never depends - // on a one-shot state toggle; capped at `overlayAnimationInterval`. - TimelineView(.animation(minimumInterval: overlayAnimationInterval)) { timeline in - bars(layout, time: timeline.date.timeIntervalSinceReferenceDate) - } - } else { - bars(layout, time: 0) - } - } - .frame(width: geo.size.width, height: geo.size.height) - } - } - - private func bars(_ layout: MeterBarRow, time: TimeInterval) -> some View { - HStack(spacing: MeterBarGeometry.barSpacing) { - ForEach(0.. 0 else { return } - guard (try? await Task.sleep(for: .seconds(1.5))) != nil else { return } - showsCopyConfirmation = false - } - } - - /// The row's trailing slot: the relative timestamp, swapped for the "Copy" - /// button on hover/focus and a transient "Copied" confirmation after a copy. - /// All three are layered (faded, not swapped out of the hierarchy) so the - /// button never loses keyboard focus mid-confirmation, and the slot sizes to - /// the widest so nothing shifts as they trade places. - private func trailingAccessory(timestamp: String) -> some View { - ZStack(alignment: .trailing) { - Text(timestamp) - .opacity(trailingSlot == .timestamp ? 1 : 0) - Button(action: copyTranscript) { - // Hand-rolled label: `Label`'s default icon–title gap reads as two - // separate items at this size; pull the glyph in tight. - HStack(spacing: 3) { - Image(systemName: "doc.on.doc") - Text("Copy") - } - } - .buttonStyle(RecentCopyButtonStyle()) - .focused($copyButtonFocused) - .opacity(trailingSlot == .copyButton ? 1 : 0) - // Opacity-0 views still hit-test; only take clicks while visible (this - // gates pointer input without breaking keyboard focus/activation). - .allowsHitTesting(trailingSlot == .copyButton) - Label("Copied", systemImage: "checkmark") - .opacity(trailingSlot == .copiedConfirmation ? 1 : 0) - // Let clicks fall through rather than swallowing them while the - // confirmation sits above the (hidden) copy button. - .allowsHitTesting(false) - } - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize() - .animation(.easeOut(duration: 0.12), value: trailingSlot) - } - - private func copyTranscript() { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(entry.text, forType: .string) - - // The invisible pasteboard write gets audible + visible confirmation: - AccessibilityNotification.Announcement("Copied").post() - showsCopyConfirmation = true - copyCount += 1 - } - -} - private struct ReadyBrandingView: View { /// Loaded once for the process rather than per `body` evaluation: this view /// sits in `ReadyView`, whose body re-runs on every new dictation @@ -317,35 +122,3 @@ private struct KeyCap: View { ) } } - -/// The Recent row's Copy control: accent-tinted (marking it clickable, vs. the -/// secondary timestamp it replaces) with an accent highlight on hover/press, -/// painted outside the layout bounds so the trailing alignment doesn't shift. -private struct RecentCopyButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - RecentCopyButton(configuration: configuration) - } -} - -private struct RecentCopyButton: View { - let configuration: ButtonStyleConfiguration - @State private var isHovered = false - - var body: some View { - configuration.label - .foregroundStyle(Color.accentColor) - .background { - RoundedRectangle(cornerRadius: 5, style: .continuous) - .fill(Color.accentColor.opacity(highlightOpacity)) - .padding(.horizontal, -5) - .padding(.vertical, -3) - } - .animation(.easeOut(duration: 0.12), value: isHovered) - .animation(.easeOut(duration: 0.12), value: configuration.isPressed) - .onHover { isHovered = $0 } - } - - private var highlightOpacity: Double { - configuration.isPressed ? 0.2 : isHovered ? 0.12 : 0 - } -} diff --git a/App/Blurt/Blurt/Wizard/RecentDictationsSection.swift b/App/Blurt/Blurt/Wizard/RecentDictationsSection.swift new file mode 100644 index 0000000..34b4d95 --- /dev/null +++ b/App/Blurt/Blurt/Wizard/RecentDictationsSection.swift @@ -0,0 +1,229 @@ +import Accessibility +import BlurtEngine +import SwiftUI + +/// The "Recent" list under the shortcut readout: the last few dictations, newest +/// first, each a single truncated line with a live relative timestamp. The list +/// area reserves a fixed height for `RecentDictations.capacity` rows so the +/// window never resizes and nothing above it moves as dictations arrive; unused +/// slots are held open (empty → a muted placeholder fills the whole area). +struct RecentDictationsSection: View { + let entries: [RecentDictations.Entry] + + private static let rowHeight: CGFloat = 28 + private static let separatorThickness: CGFloat = 1 + + /// How often the relative timestamps re-render. Half the engine's "just now" + /// window, so a row can't read as stale for longer than that window lasts — + /// derived from the threshold rather than a bare `30` in case it changes. + private static let timestampRefresh = RecentDictations.Entry.justNowThreshold / 2 + /// Height of a full `capacity`-row list; the container is pinned to this whether + /// it holds 0, 1, or `capacity` rows. The row-count arithmetic is the engine's, + /// next to the `capacity` it depends on. + private var reservedHeight: CGFloat { + RecentDictations.reservedHeight( + rowHeight: Self.rowHeight, separatorThickness: Self.separatorThickness) + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Recent") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityAddTraits(.isHeader) + + listBody + .frame(height: reservedHeight, alignment: .top) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(.quinary) + ) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + } + + @ViewBuilder + private var listBody: some View { + if entries.isEmpty { + Text("Your recent blurts will appear here") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + // Live relative timestamps ("2 minutes ago") without a stored clock: the + // TimelineView re-renders on a coarse cadence (`timestampRefresh`) and each + // row formats against its current date. + TimelineView(.periodic(from: .now, by: Self.timestampRefresh)) { timeline in + VStack(spacing: 0) { + ForEach(entries) { entry in + RecentDictationRow(entry: entry, now: timeline.date) + .frame(height: Self.rowHeight) + if entry.id != entries.last?.id { + // Semantic separator (adapts to light/dark + Increase Contrast), + // full-bleed across the grouped container — the rows carry no + // leading icon to inset past, so an edge-to-edge rule reads cleaner. + Divider() + } + } + } + } + } + } +} + +/// A single recent-dictation row: the transcript (one truncated line) with a +/// relative timestamp trailing it, formatted against `now` (supplied by the +/// enclosing `TimelineView` so it advances over time), and a copy affordance. +/// +/// Copy follows the standard macOS list-row shape: on hover (or keyboard focus, +/// for Full Keyboard Access) a "Copy" button takes the timestamp's place; the +/// same command is in the row's contextual menu and a VoiceOver custom action, +/// so it's never reachable through hover alone. Copying briefly shows "Copied" +/// (and announces it), since a pasteboard write has no visible effect. +private struct RecentDictationRow: View { + let entry: RecentDictations.Entry + let now: Date + + @State private var isHovered = false + @State private var showsCopyConfirmation = false + /// Counts copies of this row; the confirmation-reset `.task(id:)` keys off + /// it, so each copy cancels the running timer and starts a fresh one. + @State private var copyCount = 0 + @FocusState private var copyButtonFocused: Bool + + /// The three things the trailing slot can show. Deriving the visible one + /// from a single value keeps the exclusivity structural rather than spread + /// across per-layer boolean conditions. + private enum TrailingSlot { case timestamp, copyButton, copiedConfirmation } + + /// Keyboard focus counts as well as hover for revealing the copy button, so + /// Full Keyboard Access users tabbing to the (otherwise invisible) button + /// can see what they're on. + private var trailingSlot: TrailingSlot { + if showsCopyConfirmation { return .copiedConfirmation } + if isHovered || copyButtonFocused { return .copyButton } + return .timestamp + } + + var body: some View { + // Formatted once per render; feeds the trailing text and VoiceOver label. + // The "just now"/relative-phrasing rule is the engine's (unit-tested there). + let timestamp = entry.relativeLabel(now: now) + HStack(spacing: 10) { + Text(entry.text) + .font(.callout) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + trailingAccessory(timestamp: timestamp) + } + .padding(.horizontal, 12) + .frame(maxHeight: .infinity) + // Hover tooltip with the full transcript, so a pointer user can read what + // the single truncated line cuts off (VoiceOver already gets it via the + // label below). + .help(entry.text) + .contentShape(Rectangle()) + .onHover { isHovered = $0 } + .contextMenu { + Button("Copy") { copyTranscript() } + } + // One VoiceOver element per row; the explicit label controls the phrasing, + // so ignore the children rather than merge. Copy is re-exposed as a custom + // action since the hover button is ignored with the rest of the children. + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(entry.text), \(timestamp)") + .accessibilityAction(named: "Copy") { copyTranscript() } + // Reverts the "Copied" confirmation after a beat. The cancelled-sleep guard + // keeps a superseded timer from clearing the newer copy's confirmation. + .task(id: copyCount) { + guard copyCount > 0 else { return } + guard (try? await Task.sleep(for: .seconds(1.5))) != nil else { return } + showsCopyConfirmation = false + } + } + + /// The row's trailing slot: the relative timestamp, swapped for the "Copy" + /// button on hover/focus and a transient "Copied" confirmation after a copy. + /// All three are layered (faded, not swapped out of the hierarchy) so the + /// button never loses keyboard focus mid-confirmation, and the slot sizes to + /// the widest so nothing shifts as they trade places. + private func trailingAccessory(timestamp: String) -> some View { + ZStack(alignment: .trailing) { + Text(timestamp) + .opacity(trailingSlot == .timestamp ? 1 : 0) + Button(action: copyTranscript) { + // Hand-rolled label: `Label`'s default icon–title gap reads as two + // separate items at this size; pull the glyph in tight. + HStack(spacing: 3) { + Image(systemName: "doc.on.doc") + Text("Copy") + } + } + .buttonStyle(RecentCopyButtonStyle()) + .focused($copyButtonFocused) + .opacity(trailingSlot == .copyButton ? 1 : 0) + // Opacity-0 views still hit-test; only take clicks while visible (this + // gates pointer input without breaking keyboard focus/activation). + .allowsHitTesting(trailingSlot == .copyButton) + Label("Copied", systemImage: "checkmark") + .opacity(trailingSlot == .copiedConfirmation ? 1 : 0) + // Let clicks fall through rather than swallowing them while the + // confirmation sits above the (hidden) copy button. + .allowsHitTesting(false) + } + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize() + .animation(.easeOut(duration: 0.12), value: trailingSlot) + } + + private func copyTranscript() { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(entry.text, forType: .string) + + // The invisible pasteboard write gets audible + visible confirmation: + AccessibilityNotification.Announcement("Copied").post() + showsCopyConfirmation = true + copyCount += 1 + } + +} + +/// The Recent row's Copy control: accent-tinted (marking it clickable, vs. the +/// secondary timestamp it replaces) with an accent highlight on hover/press, +/// painted outside the layout bounds so the trailing alignment doesn't shift. +private struct RecentCopyButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + RecentCopyButton(configuration: configuration) + } +} + +private struct RecentCopyButton: View { + let configuration: ButtonStyleConfiguration + @State private var isHovered = false + + var body: some View { + configuration.label + .foregroundStyle(Color.accentColor) + .background { + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(Color.accentColor.opacity(highlightOpacity)) + .padding(.horizontal, -5) + .padding(.vertical, -3) + } + .animation(.easeOut(duration: 0.12), value: isHovered) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + .onHover { isHovered = $0 } + } + + private var highlightOpacity: Double { + configuration.isPressed ? 0.2 : isHovered ? 0.12 : 0 + } +} diff --git a/App/Blurt/Blurt/Wizard/SettingRow.swift b/App/Blurt/Blurt/Wizard/SettingRow.swift new file mode 100644 index 0000000..811bc98 --- /dev/null +++ b/App/Blurt/Blurt/Wizard/SettingRow.swift @@ -0,0 +1,43 @@ +import SwiftUI + +/// A settings/setup-form row: an icon-and-title label on the leading edge, with +/// trailing content pushed to the trailing edge and vertically centered against +/// the label. A plain `HStack` (default `.center` alignment) rather than +/// `LabeledContent`, which baseline-aligns the control to the label and leaves +/// it reading slightly high. Shared so every settings/setup row stays visually +/// identical (see `APIKeyStepView`, `PermissionsStepView`, `PickerSettingRow`). +struct SettingRow: View { + var title: String + var systemImage: String + @ViewBuilder var trailing: () -> Trailing + + var body: some View { + HStack { + Label(title, systemImage: systemImage) + Spacer(minLength: 12) + trailing() + } + } +} + +/// A `SettingRow` whose trailing content is a compact menu picker. Shared by the +/// shortcut and sound sections so the two rows stay identical. +struct PickerSettingRow: View { + var title: String + var systemImage: String + var accessibilityID: String + @Binding var selection: Value + @ViewBuilder var options: () -> Options + + var body: some View { + SettingRow(title: title, systemImage: systemImage) { + // The picker keeps its own (hidden) title so VoiceOver reads a meaningful + // name for the pop-up button rather than an empty string. + Picker(title, selection: $selection, content: options) + .labelsHidden() + .pickerStyle(.menu) + .fixedSize() + .accessibilityIdentifier(accessibilityID) + } + } +} diff --git a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift index 4003516..5aced8b 100644 --- a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift +++ b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift @@ -1,4 +1,3 @@ -import BlurtEngine import SwiftUI /// Root view of the `Settings` scene. A `TabView` at the root of a `Settings` @@ -113,31 +112,3 @@ private struct UpdateSection: View { } } } - -/// The Developer section of the Settings window: an opt-in switch for developer -/// mode. While on, every completed dictation is appended to the local JSONL log -/// (see `DictationLog` — its gate reads the same default this toggle writes), -/// and the footer shows where that log lives so it's easy to find. Settings-only -/// — not a wizard step, since it never gates setup. (Housed here rather than in -/// its own file so the committed XcodeGen project doesn't need regenerating; -/// move it to its own file next time `xcodegen generate` runs anyway.) -private struct DeveloperSection: View { - @AppStorage(DeveloperModeStore.defaultsKey) private var developerMode = false - - var body: some View { - Section { - Toggle(isOn: $developerMode) { - Label("Developer mode", systemImage: "hammer") - } - .accessibilityIdentifier(UITestIdentifiers.developerToggle) - } header: { - Text("Developer") - } footer: { - // The home-abbreviated path is derived in the engine next to the URL the - // writer appends to, so this label can never drift from where the log - // actually lands. - Text("Logs each dictation to \(DictationLog.defaultDisplayPath).") - .textSelection(.enabled) - } - } -} diff --git a/App/Blurt/Blurt/Wizard/Steps/HotkeyStepView.swift b/App/Blurt/Blurt/Wizard/Steps/HotkeyStepView.swift index 1312987..9c57a9b 100644 --- a/App/Blurt/Blurt/Wizard/Steps/HotkeyStepView.swift +++ b/App/Blurt/Blurt/Wizard/Steps/HotkeyStepView.swift @@ -44,71 +44,3 @@ struct HotkeyStepView: View { } } } - -/// Live-updating read of the bound dictation trigger key, for the views that -/// only *display* it ("Tap or hold right ⌘ to blurt"). -/// -/// Wraps the `@AppStorage` + `TriggerKey.fromPersisted` pair that three views — -/// the ready screen, the menu bar menu, and the permissions footer — each spelled -/// out, along with a restated `TriggerKey.rightCommand.rawValue` default that -/// `fromPersisted` already owns (an unset default reads as 0, which it maps to -/// right ⌘). Reading the raw keycode through `@AppStorage`, rather than calling -/// `TriggerKeyStore()` once, is what makes these views re-render when the key is -/// rebound in the separate Settings window. -/// -/// A `DynamicProperty` rather than a `View` because two of the three call sites -/// need the value inside a larger sentence, not a `Text` of its own. (Housed in -/// this file — the trigger-key view file — rather than its own, so the committed -/// XcodeGen project doesn't need regenerating; move it out next time -/// `xcodegen generate` runs anyway.) -@propertyWrapper -struct BoundTriggerKey: DynamicProperty { - @AppStorage(TriggerKeyStore.defaultsKey) private var keyCode = 0 - - var wrappedValue: TriggerKey { TriggerKey.fromPersisted(keyCode) } -} - -/// A settings/setup-form row: an icon-and-title label on the leading edge, with -/// trailing content pushed to the trailing edge and vertically centered against -/// the label. A plain `HStack` (default `.center` alignment) rather than -/// `LabeledContent`, which baseline-aligns the control to the label and leaves -/// it reading slightly high. Shared so every settings/setup row stays visually -/// identical (see `APIKeyStepView`, `PermissionsStepView`, -/// `PickerSettingRow`). (Housed here rather than in its own file so the -/// committed XcodeGen project doesn't need regenerating; move it to its own file -/// next time `xcodegen generate` runs anyway.) -struct SettingRow: View { - var title: String - var systemImage: String - @ViewBuilder var trailing: () -> Trailing - - var body: some View { - HStack { - Label(title, systemImage: systemImage) - Spacer(minLength: 12) - trailing() - } - } -} - -/// A `SettingRow` whose trailing content is a compact menu picker. Shared by the -/// shortcut and sound sections so the two rows stay identical. -struct PickerSettingRow: View { - var title: String - var systemImage: String - var accessibilityID: String - @Binding var selection: Value - @ViewBuilder var options: () -> Options - - var body: some View { - SettingRow(title: title, systemImage: systemImage) { - // The picker keeps its own (hidden) title so VoiceOver reads a meaningful - // name for the pop-up button rather than an empty string. - Picker(title, selection: $selection, content: options) - .labelsHidden() - .pickerStyle(.menu) - .fixedSize() - .accessibilityIdentifier(accessibilityID) - } - } -} diff --git a/Sources/BlurtEngine/Injection/KeyInjector+SystemActions.swift b/Sources/BlurtEngine/Injection/KeyInjector+SystemActions.swift new file mode 100644 index 0000000..21354e0 --- /dev/null +++ b/Sources/BlurtEngine/Injection/KeyInjector+SystemActions.swift @@ -0,0 +1,67 @@ +import AppKit +import ApplicationServices +import CoreGraphics + +// The system side effects behind `KeyInjector`'s injectable seams: the default +// implementations of app activation, the frontmost-wait, the Accessibility-trust +// check, and the synthesized ⌘V. Split out from the actor's paste orchestration +// (`KeyInjector.swift`) because these are pure AppKit/CoreGraphics glue with no +// actor state — the only place `KeyInjector` needs CoreGraphics or +// ApplicationServices at all. `static`, not `private`, so the initializers in +// `KeyInjector.swift` can wire them as the defaults across the file boundary. +extension KeyInjector { + static func activate(_ app: NSRunningApplication) -> Bool { + app.activate() + } + + static func waitUntilFrontmost(_ app: NSRunningApplication) async -> Bool { + let pid = app.processIdentifier + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .milliseconds(350)) + while clock.now < deadline { + if await isFrontmost(pid) { return true } + try? await Task.sleep(for: .milliseconds(10)) + } + // One last read: the final sleep may have carried us past the deadline just + // before the activation landed. + return await isFrontmost(pid) + } + + /// Whether `pid` owns the frontmost application right now. Named rather than + /// inlined so the poll and the post-deadline check share one expression — and so + /// neither needs `MainActor.run`'s explicit `body:` label, which a trailing + /// closure in an `if` condition can't use. + private static func isFrontmost(_ pid: pid_t) async -> Bool { + await MainActor.run { + NSWorkspace.shared.frontmostApplication?.processIdentifier == pid + } + } + + static func accessibilityTrusted() -> Bool { + AXIsProcessTrusted() + } + + /// Posts Cmd-V. Returns `false` if the events couldn't be built. The real side + /// effect (a keystroke into the focused app) is why this is the injectable seam + /// tests replace. + static func postCmdV() -> Bool { + let vKey: CGKeyCode = 0x09 // kVK_ANSI_V + guard let source = CGEventSource(stateID: .combinedSessionState), + let down = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: true), + let up = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: false) + else { return false } + down.flags = .maskCommand + up.flags = .maskCommand + // Post to the annotated session tap rather than the HID tap: the session tap + // honors exactly the flags set above instead of OR-ing in the live hardware + // modifier state, so a still-held hotkey modifier can't corrupt Cmd-V into a + // combo the target app ignores. (We deliberately don't suppress local events + // during the post: `setLocalEventsFilterDuringSuppressionState` lingers for + // the source's ~0.25s suppression interval and would swallow the user's next + // dictation keypress right after a paste — the annotated tap already prevents + // the modifier merge that suppression was guarding against.) + down.post(tap: .cgAnnotatedSessionEventTap) + up.post(tap: .cgAnnotatedSessionEventTap) + return true + } +} diff --git a/Sources/BlurtEngine/Injection/KeyInjector.swift b/Sources/BlurtEngine/Injection/KeyInjector.swift index 7d39dfa..8eba297 100644 --- a/Sources/BlurtEngine/Injection/KeyInjector.swift +++ b/Sources/BlurtEngine/Injection/KeyInjector.swift @@ -1,6 +1,4 @@ import AppKit -import ApplicationServices -import CoreGraphics public actor KeyInjector: InjectorProtocol { private var targetApp: NSRunningApplication? @@ -264,58 +262,4 @@ public actor KeyInjector: InjectorProtocol { return restore } - private static func activate(_ app: NSRunningApplication) -> Bool { - app.activate() - } - - private static func waitUntilFrontmost(_ app: NSRunningApplication) async -> Bool { - let pid = app.processIdentifier - let clock = ContinuousClock() - let deadline = clock.now.advanced(by: .milliseconds(350)) - while clock.now < deadline { - if await isFrontmost(pid) { return true } - try? await Task.sleep(for: .milliseconds(10)) - } - // One last read: the final sleep may have carried us past the deadline just - // before the activation landed. - return await isFrontmost(pid) - } - - /// Whether `pid` owns the frontmost application right now. Named rather than - /// inlined so the poll and the post-deadline check share one expression — and so - /// neither needs `MainActor.run`'s explicit `body:` label, which a trailing - /// closure in an `if` condition can't use. - private static func isFrontmost(_ pid: pid_t) async -> Bool { - await MainActor.run { - NSWorkspace.shared.frontmostApplication?.processIdentifier == pid - } - } - - private static func accessibilityTrusted() -> Bool { - AXIsProcessTrusted() - } - - /// Posts Cmd-V. Returns `false` if the events couldn't be built. The real side - /// effect (a keystroke into the focused app) is why this is the injectable seam - /// tests replace. - private static func postCmdV() -> Bool { - let vKey: CGKeyCode = 0x09 // kVK_ANSI_V - guard let source = CGEventSource(stateID: .combinedSessionState), - let down = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: true), - let up = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: false) - else { return false } - down.flags = .maskCommand - up.flags = .maskCommand - // Post to the annotated session tap rather than the HID tap: the session tap - // honors exactly the flags set above instead of OR-ing in the live hardware - // modifier state, so a still-held hotkey modifier can't corrupt Cmd-V into a - // combo the target app ignores. (We deliberately don't suppress local events - // during the post: `setLocalEventsFilterDuringSuppressionState` lingers for - // the source's ~0.25s suppression interval and would swallow the user's next - // dictation keypress right after a paste — the annotated tap already prevents - // the modifier merge that suppression was guarding against.) - down.post(tap: .cgAnnotatedSessionEventTap) - up.post(tap: .cgAnnotatedSessionEventTap) - return true - } }