From 4788c2eb4d79ab864adb1f70d3c15bc4e9fb9c62 Mon Sep 17 00:00:00 2001 From: kevinmokai007-alt Date: Wed, 16 Sep 2026 02:27:01 +0200 Subject: [PATCH 1/2] feat: premium overlays companion and experimental live typing --- Fluid.xcodeproj/project.pbxproj | 8 + Sources/Fluid/ContentView.swift | 2 +- Sources/Fluid/Persistence/BackupService.swift | 21 + Sources/Fluid/Persistence/SettingsStore.swift | 386 +++++- Sources/Fluid/Services/ASRService.swift | 17 + .../LiveTyping/LiveTypingAXTarget.swift | 235 ++++ .../LiveTyping/LiveTypingController.swift | 230 +++ .../LiveTyping/LiveTypingSession.swift | 228 +++ Sources/Fluid/Services/MenuBarManager.swift | 13 +- .../Fluid/Services/NotchOverlayManager.swift | 44 +- .../UI/Companion/CompanionGeometry.swift | 217 +++ .../Fluid/UI/Companion/CompanionModel.swift | 253 ++++ .../UI/Companion/CompanionVisualizer.swift | 446 ++++++ Sources/Fluid/UI/LiveTypingStatusRow.swift | 29 + .../UI/Overlay/AudioEnvelopeFollower.swift | 87 ++ .../Fluid/UI/Overlay/AuroraVisualizer.swift | 499 +++++++ .../Fluid/UI/Overlay/MinimalVisualizer.swift | 176 +++ Sources/Fluid/UI/Overlay/NotchHaloRim.swift | 131 ++ .../UI/Overlay/NotchStyleVisualizer.swift | 95 ++ Sources/Fluid/UI/Overlay/OrbitalGlow.swift | 118 ++ .../UI/Overlay/OverlayAnchorGeometry.swift | 103 ++ .../Fluid/UI/Overlay/OverlayColorTheme.swift | 366 +++++ .../UI/Overlay/OverlayLifecycleState.swift | 62 + .../UI/Overlay/OverlayProcessingRing.swift | 74 + .../UI/Overlay/OverlaySettingsControls.swift | 244 ++++ .../UI/Overlay/OverlayStyleThumbnail.swift | 153 ++ Sources/Fluid/UI/Overlay/OverlaySurface.swift | 179 +++ .../Fluid/UI/Overlay/OverlayVisualStyle.swift | 50 + .../UI/Overlay/OverlayVisualizerView.swift | 187 +++ .../Fluid/UI/Overlay/PillPreviewSizing.swift | 39 + .../Fluid/UI/Overlay/PulseVisualizer.swift | 196 +++ Sources/Fluid/UI/Overlay/WaveVisualizer.swift | 134 ++ Sources/Fluid/UI/SettingsSearch.swift | 47 +- Sources/Fluid/UI/SettingsView.swift | 443 +++++- Sources/Fluid/Views/BottomOverlayView.swift | 683 +++++++-- Sources/Fluid/Views/NotchContentViews.swift | 40 +- .../LiveTypingSessionTests.swift | 267 ++++ .../OverlayPremiumSettingsTests.swift | 1231 +++++++++++++++++ 38 files changed, 7558 insertions(+), 175 deletions(-) create mode 100644 Sources/Fluid/Services/LiveTyping/LiveTypingAXTarget.swift create mode 100644 Sources/Fluid/Services/LiveTyping/LiveTypingController.swift create mode 100644 Sources/Fluid/Services/LiveTyping/LiveTypingSession.swift create mode 100644 Sources/Fluid/UI/Companion/CompanionGeometry.swift create mode 100644 Sources/Fluid/UI/Companion/CompanionModel.swift create mode 100644 Sources/Fluid/UI/Companion/CompanionVisualizer.swift create mode 100644 Sources/Fluid/UI/LiveTypingStatusRow.swift create mode 100644 Sources/Fluid/UI/Overlay/AudioEnvelopeFollower.swift create mode 100644 Sources/Fluid/UI/Overlay/AuroraVisualizer.swift create mode 100644 Sources/Fluid/UI/Overlay/MinimalVisualizer.swift create mode 100644 Sources/Fluid/UI/Overlay/NotchHaloRim.swift create mode 100644 Sources/Fluid/UI/Overlay/NotchStyleVisualizer.swift create mode 100644 Sources/Fluid/UI/Overlay/OrbitalGlow.swift create mode 100644 Sources/Fluid/UI/Overlay/OverlayAnchorGeometry.swift create mode 100644 Sources/Fluid/UI/Overlay/OverlayColorTheme.swift create mode 100644 Sources/Fluid/UI/Overlay/OverlayLifecycleState.swift create mode 100644 Sources/Fluid/UI/Overlay/OverlayProcessingRing.swift create mode 100644 Sources/Fluid/UI/Overlay/OverlaySettingsControls.swift create mode 100644 Sources/Fluid/UI/Overlay/OverlayStyleThumbnail.swift create mode 100644 Sources/Fluid/UI/Overlay/OverlaySurface.swift create mode 100644 Sources/Fluid/UI/Overlay/OverlayVisualStyle.swift create mode 100644 Sources/Fluid/UI/Overlay/OverlayVisualizerView.swift create mode 100644 Sources/Fluid/UI/Overlay/PillPreviewSizing.swift create mode 100644 Sources/Fluid/UI/Overlay/PulseVisualizer.swift create mode 100644 Sources/Fluid/UI/Overlay/WaveVisualizer.swift create mode 100644 Tests/FluidDictationIntegrationTests/LiveTypingSessionTests.swift create mode 100644 Tests/FluidDictationIntegrationTests/OverlayPremiumSettingsTests.swift diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index 8bf0e82fb..4f25d0ce4 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -17,6 +17,8 @@ 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */; }; B52000000000000000000002 /* PrivateAIDictationTokenBudgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52000000000000000000001 /* PrivateAIDictationTokenBudgetTests.swift */; }; CD1C7A0000000000000000B2 /* CustomDictionaryManualEntryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1C7A0000000000000000B1 /* CustomDictionaryManualEntryTests.swift */; }; + CD1C7A0000000000000000C2 /* OverlayPremiumSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1C7A0000000000000000C1 /* OverlayPremiumSettingsTests.swift */; }; + CD1C7A0000000000000000D2 /* LiveTypingSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1C7A0000000000000000D1 /* LiveTypingSessionTests.swift */; }; 7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */; }; 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */; }; 272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */; }; @@ -84,6 +86,8 @@ 7CDB0A202F3C4D5600FB7CAD /* FluidDictationIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FluidDictationIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = ""; }; CD1C7A0000000000000000B1 /* CustomDictionaryManualEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDictionaryManualEntryTests.swift; sourceTree = ""; }; + CD1C7A0000000000000000C1 /* OverlayPremiumSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayPremiumSettingsTests.swift; sourceTree = ""; }; + CD1C7A0000000000000000D1 /* LiveTypingSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTypingSessionTests.swift; sourceTree = ""; }; 7CFA1D0B2F500000C0DEF002 /* TypingServiceTransientPasteboardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingServiceTransientPasteboardTests.swift; sourceTree = ""; }; 803000000000000000000001 /* MediaPlaybackServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlaybackServiceTests.swift; sourceTree = ""; }; 0B05F11E000000000000A001 /* SupportedFileExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedFileExtensionsTests.swift; sourceTree = ""; }; @@ -158,6 +162,8 @@ 7CDB0A272F3C4D5600FB7CAD /* Resources */, 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */, CD1C7A0000000000000000B1 /* CustomDictionaryManualEntryTests.swift */, + CD1C7A0000000000000000C1 /* OverlayPremiumSettingsTests.swift */, + CD1C7A0000000000000000D1 /* LiveTypingSessionTests.swift */, 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */, C0DE89000000000000000001 /* SettingsNavigationStateTests.swift */, 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */, @@ -335,6 +341,8 @@ 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */, B52000000000000000000002 /* PrivateAIDictationTokenBudgetTests.swift in Sources */, CD1C7A0000000000000000B2 /* CustomDictionaryManualEntryTests.swift in Sources */, + CD1C7A0000000000000000C2 /* OverlayPremiumSettingsTests.swift in Sources */, + CD1C7A0000000000000000D2 /* LiveTypingSessionTests.swift in Sources */, 7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */, C0DE89000000000000000002 /* SettingsNavigationStateTests.swift in Sources */, 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */, diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 79700192c..173a0f064 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -1464,7 +1464,7 @@ struct ContentView: View { return SettingsStore.shared.saveTranscriptionHistory && SettingsStore.shared.saveAudioWithTranscriptionHistory case .bottomOffset: - return self.settings.overlayPosition == .bottom + return self.settings.overlayPosition.isBottomAnchored default: return true } diff --git a/Sources/Fluid/Persistence/BackupService.swift b/Sources/Fluid/Persistence/BackupService.swift index 0dfdd165c..8ea7bd23b 100644 --- a/Sources/Fluid/Persistence/BackupService.swift +++ b/Sources/Fluid/Persistence/BackupService.swift @@ -84,6 +84,27 @@ struct SettingsBackupPayload: Codable, Equatable { let overlayPosition: SettingsStore.OverlayPosition let overlayBottomOffset: Double let overlaySize: SettingsStore.OverlaySize + // Optional so backups created before the premium overlay still decode. + let overlayVisualStyle: OverlayVisualStyle? + let overlayColorTheme: OverlayColorTheme? + let overlayGlowIntensity: OverlayGlowIntensity? + let showTargetAppIcon: Bool? + /// Optional so backups written before the user scale still decode. + let overlayScale: Double? + /// Optional so backups written before the surface options still decode. + let overlaySurfaceAppearance: OverlaySurfaceAppearance? + let overlayCustomThemeHex: String? + /// Optional so backups written before the advanced glow tuning still decode. + let overlayGlowStrength: Double? + /// Optional so backups written before the Companion still decode. + let companionScale: Double? + let companionVariant: CompanionVariant? + // Optional so backups written before accessories still decode. + // swiftlint:disable:next discouraged_optional_collection + let companionAccessories: [String]? + let overlayMotionIntensity: MotionIntensity? + /// Optional so backups written before Live Typing still decode. + let liveTypingExperimental: Bool? let transcriptionPreviewCharLimit: Int let userTypingWPM: Int let saveTranscriptionHistory: Bool diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 111f4e84d..da4f125ea 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -1828,7 +1828,10 @@ final class SettingsStore: ObservableObject { var enableStreamingPreview: Bool { get { let value = self.defaults.object(forKey: Keys.enableStreamingPreview) - return value as? Bool ?? true // Default to true (enabled) + // Off unless the user turned it on: the compact pill is the product + // default, and an explicit choice is still preserved because the key + // is only absent when it was never written. + return value as? Bool ?? false } set { objectWillChange.send() @@ -2091,27 +2094,81 @@ final class SettingsStore: ObservableObject { case pill case small case medium + /// Very small circular voice orb. + case round + /// The animation alone: no surface, no border, no shadow. + case svg case large + /// The formats offered in Settings. + /// + /// `.large` stays in the enum - and therefore in persisted preferences - + /// so an install that already selected it keeps working, but the modern + /// formats are Pill / Small / Medium and Large is no longer offered. + static let selectable: [OverlaySize] = [.pill, .small, .medium, .round, .svg] + + /// True for the retired extended format. Its layout is still built, it is + /// simply no longer reachable from the picker. + var isLegacy: Bool { self == .large } + var displayName: String { switch self { case .pill: return "Pill" case .small: return "Small" case .medium: return "Medium" + case .round: return "Round" + case .svg: return "SVG" case .large: return "Large" } } } - /// Position options for the recording overlay + /// Position options for the recording overlay. + /// + /// The raw values for `topCenter` and `bottomCenter` deliberately keep the + /// historical "top" and "bottom" spellings, so preferences written by older + /// builds keep decoding with no migration step. `topCenter` also keeps the + /// legacy top-notch presentation; every other anchor uses the floating pill. enum OverlayPosition: String, CaseIterable, Codable { - case top // Top of screen (notch area or floating) - case bottom // Bottom of screen + case topCenter = "top" + case bottomCenter = "bottom" + case topLeft = "topLeft" + case topRight = "topRight" + case bottomLeft = "bottomLeft" + case bottomRight = "bottomRight" var displayName: String { switch self { - case .top: return "Top of Screen" - case .bottom: return "Bottom of Screen" + case .topCenter: return "Top Center" + case .bottomCenter: return "Bottom Center" + case .topLeft: return "Top Left" + case .topRight: return "Top Right" + case .bottomLeft: return "Bottom Left" + case .bottomRight: return "Bottom Right" + } + } + + /// The legacy top-notch presentation, preserved for existing preferences. + var usesNotchPresentation: Bool { + self == .topCenter + } + + /// True when the floating pill panel owns this anchor. + var usesFloatingOverlay: Bool { + !self.usesNotchPresentation + } + + var isBottomAnchored: Bool { + switch self { + case .bottomCenter, .bottomLeft, .bottomRight: return true + case .topCenter, .topLeft, .topRight: return false + } + } + + var isSideAnchored: Bool { + switch self { + case .topLeft, .topRight, .bottomLeft, .bottomRight: return true + case .topCenter, .bottomCenter: return false } } } @@ -2121,6 +2178,9 @@ final class SettingsStore: ObservableObject { enum NotchPresentationMode: String, CaseIterable, Codable { case standard case minimal + /// The light lives under the cutout: no icon, no text, no controls, just + /// the premium style animation at a larger size inside the halo. + case ambient var displayName: String { switch self { @@ -2128,6 +2188,8 @@ final class SettingsStore: ObservableObject { return "Standard Notch" case .minimal: return "Compact" + case .ambient: + return "Ambient Glow (Beta)" } } } @@ -2138,7 +2200,7 @@ final class SettingsStore: ObservableObject { guard let raw = self.defaults.string(forKey: Keys.overlayPosition), let position = OverlayPosition(rawValue: raw) else { - return .bottom // Default to bottom (menu overlay) + return .bottomCenter // Default to the floating bottom pill } return position } @@ -2202,6 +2264,245 @@ final class SettingsStore: ObservableObject { } } + /// Supported band and neutral value for `overlayScale`. + /// + /// The band runs from quarter size to triple size, so a format can be a + /// glanceable sliver or a real object on screen. + static let overlayScaleRange: ClosedRange = 0.25...3.0 + static let overlayScaleDefault: Double = 1.0 + + /// User scale applied on top of the chosen overlay format. + /// + /// Persisted as a Double. Reads are clamped, so a hand-edited or corrupt + /// preference can never produce an unusable overlay. + var overlayScale: Double { + get { + guard let value = self.defaults.object(forKey: Keys.overlayScale) as? Double else { + return Self.overlayScaleDefault + } + return min(max(value, Self.overlayScaleRange.lowerBound), Self.overlayScaleRange.upperBound) + } + set { + let clamped = min(max(newValue, Self.overlayScaleRange.lowerBound), Self.overlayScaleRange.upperBound) + objectWillChange.send() + self.defaults.set(clamped, forKey: Keys.overlayScale) + + // Reuse the existing live-resize notification so a visible overlay + // re-measures and re-anchors instead of only updating next launch. + NotificationCenter.default.post(name: NSNotification.Name("OverlaySizeChanged"), object: nil) + } + } + + /// Visual style used to draw the live audio level inside the overlay. + /// + /// Defaults to Aurora, the premium style, without touching any preference an + /// existing install already wrote. + var overlayVisualStyle: OverlayVisualStyle { + get { + guard let raw = self.defaults.string(forKey: Keys.overlayVisualStyle), + let style = OverlayVisualStyle(rawValue: raw) + else { + return .fallback + } + return style + } + set { + objectWillChange.send() + self.defaults.set(newValue.rawValue, forKey: Keys.overlayVisualStyle) + } + } + + /// Color theme shared by the visualizer and the orbital aura. + var overlayColorTheme: OverlayColorTheme { + get { + guard let raw = self.defaults.string(forKey: Keys.overlayColorTheme), + let theme = OverlayColorTheme(rawValue: raw) + else { + return .fallback + } + return theme + } + set { + objectWillChange.send() + self.defaults.set(newValue.rawValue, forKey: Keys.overlayColorTheme) + } + } + + /// Strength of the border, aura and visualizer glow. + var overlayGlowIntensity: OverlayGlowIntensity { + get { + guard let raw = self.defaults.string(forKey: Keys.overlayGlowIntensity), + let intensity = OverlayGlowIntensity(rawValue: raw) + else { + return .fallback + } + return intensity + } + set { + objectWillChange.send() + self.defaults.set(newValue.rawValue, forKey: Keys.overlayGlowIntensity) + } + } + + /// Whether the overlay shows the icon of the app receiving the dictation. + /// Defaults to true, which matches the behaviour of existing installs. + /// Advanced fine tuning applied on top of the glow preset. + /// + /// The getter republishes the value into `OverlayGlowTuning` so the overlay + /// components resolve the same strength without seeing the settings store. + var overlayGlowStrength: Double { + get { + let stored = self.defaults.object(forKey: Keys.overlayGlowStrength) as? Double + ?? OverlayGlowTuning.neutral + OverlayGlowTuning.strength = stored + return min(max(stored, OverlayGlowTuning.range.lowerBound), OverlayGlowTuning.range.upperBound) + } + set { + let clamped = min(max(newValue, OverlayGlowTuning.range.lowerBound), OverlayGlowTuning.range.upperBound) + objectWillChange.send() + self.defaults.set(clamped, forKey: Keys.overlayGlowStrength) + OverlayGlowTuning.strength = clamped + } + } + + /// Surface the overlay is drawn on. Automatic follows the system. + var overlaySurfaceAppearance: OverlaySurfaceAppearance { + get { + guard let raw = self.defaults.string(forKey: Keys.overlaySurfaceAppearance), + let value = OverlaySurfaceAppearance(rawValue: raw) + else { + return .fallback + } + return value + } + set { + objectWillChange.send() + self.defaults.set(newValue.rawValue, forKey: Keys.overlaySurfaceAppearance) + } + } + + /// Primary colour of the Custom theme. + /// + /// The getter republishes the value into `OverlayCustomTheme` so the overlay + /// components, which do not see the settings store, resolve the same palette. + var overlayCustomThemeHex: String { + get { + let stored = self.defaults.string(forKey: Keys.overlayCustomThemeHex) ?? OverlayCustomTheme.defaultHex + OverlayCustomTheme.hex = stored + return stored + } + set { + let resolved = newValue.isEmpty ? OverlayCustomTheme.defaultHex : newValue + objectWillChange.send() + self.defaults.set(resolved, forKey: Keys.overlayCustomThemeHex) + OverlayCustomTheme.hex = resolved + } + } + + // MARK: - Companion style options + + /// Supported band and neutral value for the Companion's own size. + /// + /// 100% is the Companion's reference size; the band lets it shrink back to + /// the old footprint or grow well past the reference. + static let companionScaleRange: ClosedRange = 0.5...3.0 + static let companionScaleDefault: Double = 1.0 + + /// Size multiplier for the Companion style. Reads are clamped, so a + /// hand-edited preference can never make the character absurd. + var companionScale: Double { + get { + guard let value = self.defaults.object(forKey: Keys.companionScale) as? Double else { + return Self.companionScaleDefault + } + return min(max(value, Self.companionScaleRange.lowerBound), Self.companionScaleRange.upperBound) + } + set { + let clamped = min(max(newValue, Self.companionScaleRange.lowerBound), Self.companionScaleRange.upperBound) + objectWillChange.send() + self.defaults.set(clamped, forKey: Keys.companionScale) + Self.postCompanionSettingsChanged() + } + } + + var companionVariant: CompanionVariant { + get { + guard let raw = self.defaults.string(forKey: Keys.companionVariant), + let value = CompanionVariant(rawValue: raw) else { return CompanionVariant.fallback } + return value + } + set { + objectWillChange.send() + self.defaults.set(newValue.rawValue, forKey: Keys.companionVariant) + Self.postCompanionSettingsChanged() + } + } + + /// Accessories are stored as raw strings. An unknown value from a future + /// build is simply ignored rather than failing the read. + var companionAccessories: Set { + get { + guard let raw = self.defaults.array(forKey: Keys.companionAccessories) as? [String] else { + return CompanionAccessory.fallback + } + return Set(raw.compactMap(CompanionAccessory.init(rawValue:))) + } + set { + objectWillChange.send() + self.defaults.set(newValue.map(\.rawValue).sorted(), forKey: Keys.companionAccessories) + Self.postCompanionSettingsChanged() + } + } + + /// One global movement budget shared by Aurora and the Companion. + var overlayMotionIntensity: MotionIntensity { + get { + guard let raw = self.defaults.string(forKey: Keys.overlayMotionIntensity), + let value = MotionIntensity(rawValue: raw) else { return MotionIntensity.fallback } + return value + } + set { + objectWillChange.send() + self.defaults.set(newValue.rawValue, forKey: Keys.overlayMotionIntensity) + Self.postCompanionSettingsChanged() + } + } + + private static func postCompanionSettingsChanged() { + NotificationCenter.default.post(name: NSNotification.Name("CompanionSettingsChanged"), object: nil) + } + + /// Experimental Live Typing. Off by default. + /// + /// When on, FluidVoice streams the revised partial into the focused text + /// field while the user is still speaking - but only while it can read the + /// field back and prove it still owns the exact range it wrote. Any doubt + /// downgrades to the normal final paste. + var liveTypingExperimental: Bool { + get { + guard self.defaults.object(forKey: Keys.liveTypingExperimental) != nil else { return false } + return self.defaults.bool(forKey: Keys.liveTypingExperimental) + } + set { + objectWillChange.send() + self.defaults.set(newValue, forKey: Keys.liveTypingExperimental) + if !newValue { + LiveTypingController.shared.reset() + } + } + } + + var showTargetAppIcon: Bool { + get { + guard self.defaults.object(forKey: Keys.showTargetAppIcon) != nil else { return true } + return self.defaults.bool(forKey: Keys.showTargetAppIcon) + } + set { + objectWillChange.send() + self.defaults.set(newValue, forKey: Keys.showTargetAppIcon) + } + } + /// How many recent transcription characters show in overlays (default: 150) var transcriptionPreviewCharLimit: Int { get { @@ -3317,6 +3618,19 @@ final class SettingsStore: ObservableObject { overlayPosition: self.overlayPosition, overlayBottomOffset: self.overlayBottomOffset, overlaySize: self.overlaySize, + overlayVisualStyle: self.overlayVisualStyle, + overlayColorTheme: self.overlayColorTheme, + overlayGlowIntensity: self.overlayGlowIntensity, + showTargetAppIcon: self.showTargetAppIcon, + overlayScale: self.overlayScale, + overlaySurfaceAppearance: self.overlaySurfaceAppearance, + overlayCustomThemeHex: self.overlayCustomThemeHex, + overlayGlowStrength: self.overlayGlowStrength, + companionScale: self.companionScale, + companionVariant: self.companionVariant, + companionAccessories: self.companionAccessories.map(\.rawValue).sorted(), + overlayMotionIntensity: self.overlayMotionIntensity, + liveTypingExperimental: self.liveTypingExperimental, transcriptionPreviewCharLimit: self.transcriptionPreviewCharLimit, userTypingWPM: self.userTypingWPM, saveTranscriptionHistory: self.saveTranscriptionHistory, @@ -3474,6 +3788,47 @@ final class SettingsStore: ObservableObject { self.overlayPosition = payload.overlayPosition self.overlayBottomOffset = payload.overlayBottomOffset self.overlaySize = payload.overlaySize + // Optional in the payload so backups written before the premium overlay + // still restore; missing values keep whatever the user already had. + if let overlayVisualStyle = payload.overlayVisualStyle { + self.overlayVisualStyle = overlayVisualStyle + } + if let overlayColorTheme = payload.overlayColorTheme { + self.overlayColorTheme = overlayColorTheme + } + if let overlayGlowIntensity = payload.overlayGlowIntensity { + self.overlayGlowIntensity = overlayGlowIntensity + } + if let showTargetAppIcon = payload.showTargetAppIcon { + self.showTargetAppIcon = showTargetAppIcon + } + if let overlayScale = payload.overlayScale { + self.overlayScale = overlayScale + } + if let overlaySurfaceAppearance = payload.overlaySurfaceAppearance { + self.overlaySurfaceAppearance = overlaySurfaceAppearance + } + if let overlayCustomThemeHex = payload.overlayCustomThemeHex { + self.overlayCustomThemeHex = overlayCustomThemeHex + } + if let overlayGlowStrength = payload.overlayGlowStrength { + self.overlayGlowStrength = overlayGlowStrength + } + if let companionScale = payload.companionScale { + self.companionScale = companionScale + } + if let companionVariant = payload.companionVariant { + self.companionVariant = companionVariant + } + if let companionAccessories = payload.companionAccessories { + self.companionAccessories = Set(companionAccessories.compactMap(CompanionAccessory.init(rawValue:))) + } + if let overlayMotionIntensity = payload.overlayMotionIntensity { + self.overlayMotionIntensity = overlayMotionIntensity + } + if let liveTypingExperimental = payload.liveTypingExperimental { + self.liveTypingExperimental = liveTypingExperimental + } self.transcriptionPreviewCharLimit = payload.transcriptionPreviewCharLimit self.userTypingWPM = payload.userTypingWPM self.saveTranscriptionHistory = payload.saveTranscriptionHistory @@ -5555,8 +5910,25 @@ private extension SettingsStore { static let overlayBottomOffset = "OverlayBottomOffset" static let overlayBottomOffsetMigratedTo50 = "OverlayBottomOffsetMigratedTo50" static let overlaySize = "OverlaySize" + static let overlayScale = "OverlayScale" + static let overlayVisualStyle = "OverlayVisualStyle" + static let overlayColorTheme = "OverlayColorTheme" + static let overlayGlowIntensity = "OverlayGlowIntensity" + static let showTargetAppIcon = "ShowTargetAppIcon" + static let overlaySurfaceAppearance = "OverlaySurfaceAppearance" + static let overlayGlowStrength = "OverlayGlowStrength" + static let overlayCustomThemeHex = "OverlayCustomThemeHex" + static let overlayMotionIntensity = "OverlayMotionIntensity" static let transcriptionPreviewCharLimit = "TranscriptionPreviewCharLimit" + // Companion style options + static let companionScale = "CompanionScale" + static let companionVariant = "CompanionVariant" + static let companionAccessories = "CompanionAccessories" + + // Live Typing (experimental) + static let liveTypingExperimental = "LiveTypingExperimental" + /// Media Playback Control static let pauseMediaDuringTranscription = "PauseMediaDuringTranscription" diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 6a930f0a6..d3eb631f0 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -2129,6 +2129,7 @@ final class ASRService: ObservableObject { self.audioBuffer.clear(keepingCapacity: true) // specific optimization for restart self.partialTranscription.removeAll() self.previousFullTranscription.removeAll() + LiveTypingController.shared.reset() self.lastBoostHitTerm = nil self.lastProcessedSampleCount = 0 self.isProcessingChunk = false @@ -3338,6 +3339,7 @@ final class ASRService: ObservableObject { completedBufferHandoff = true self.partialTranscription.removeAll() self.previousFullTranscription.removeAll() + LiveTypingController.shared.reset() self.lastBoostHitTerm = nil self.lastProcessedSampleCount = 0 self.isProcessingChunk = false @@ -5456,6 +5458,10 @@ final class ASRService: ObservableObject { self.partialTranscription = updatedText self.previousFullTranscription = newText + // Experimental Live Typing: streams the revised partial into the + // target field when the user opted in. Does nothing otherwise. + LiveTypingController.shared.observePartial(updatedText) + DebugLogger.shared.debug("✅ Streaming: '\(updatedText)' (\(String(format: "%.2f", duration))s)", source: "ASRService") } let rtf = duration / (Double(currentSampleCount) / 16_000.0) @@ -5538,6 +5544,11 @@ final class ASRService: ObservableObject { tracksDictionaryCorrections: Bool = false, completion: (@MainActor (TypingService.DeliveryOutcome) -> Void)? = nil ) { + // Live Typing may already own the text in the field. When it does, the + // normal insertion must not run again. + if LiveTypingController.shared.consumeFinalDelivery(plainText: plan.plainText) { + return + } let requestedAt = ProcessInfo.processInfo.systemUptime let textReadyAge = textReadyAt.map { Int(((requestedAt - $0) * 1000).rounded()) } let text = plan.plainText @@ -5572,6 +5583,11 @@ final class ASRService: ObservableObject { postInsertionKey: SettingsStore.SpokenSendKey? = nil, requiredFocusTarget: TypingService.CapturedFocusTarget? = nil ) async -> TypingService.DeliveryOutcome { + // Same hand-off as the fire-and-forget path: Live Typing owns the text, + // so report the insertion as already done. + if LiveTypingController.shared.consumeFinalDelivery(plainText: plan.plainText) { + return .inserted + } let requestedAt = ProcessInfo.processInfo.systemUptime let textReadyAge = textReadyAt.map { Int(((requestedAt - $0) * 1000).rounded()) } let text = plan.plainText @@ -5918,6 +5934,7 @@ private extension ASRService { self.streamingWorkState.endSession(sessionID) self.partialTranscription = "" self.previousFullTranscription = "" + LiveTypingController.shared.reset() self.isProcessingChunk = false self.skipNextChunk = false } diff --git a/Sources/Fluid/Services/LiveTyping/LiveTypingAXTarget.swift b/Sources/Fluid/Services/LiveTyping/LiveTypingAXTarget.swift new file mode 100644 index 000000000..485993533 --- /dev/null +++ b/Sources/Fluid/Services/LiveTyping/LiveTypingAXTarget.swift @@ -0,0 +1,235 @@ +// +// LiveTypingAXTarget.swift +// Fluid +// +// Accessibility read/write primitives for the experimental Live Typing path. +// + +import ApplicationServices +import AppKit +import Foundation + +/// What the focused field actually, verifiably supports. +/// +/// Produced by a strictly read-only probe: it reads attributes and asks the +/// Accessibility server whether they are settable. It never writes, so running +/// it can not modify the user's text. +nonisolated struct LiveTypingCapabilities: Equatable { + let role: String? + let subrole: String? + let canReadValue: Bool + let canReadSelection: Bool + let valueIsSettable: Bool + let selectedTextIsSettable: Bool + let isSecure: Bool + + /// The level the target can be expected to support, before any write. + /// + /// Level 1 needs a settable value (read, revise, verify). Level 2 only needs + /// a settable selection, because it appends stabilised chunks at the caret. + /// Anything else is Level 3, the behaviour the app has always had. + var predictedLevel: LiveTypingLevel { + if self.isSecure || !self.canReadValue || !self.canReadSelection { + return .finalOnly + } + if self.valueIsSettable { + return .full + } + if self.selectedTextIsSettable { + return .committedChunks + } + return .finalOnly + } + + /// One-line report shown in Settings, so a user can certify a given app. + var summary: String { + let target = self.role ?? "unknown field" + let level = self.predictedLevel.rawValue + if self.isSecure { + return "\(target): secure field, never streamed into. Level 3 (final paste)." + } + if self.predictedLevel == .finalOnly { + return "\(target): value not readable/writable. Level 3 (final paste)." + } + return "\(target): readable=\(self.canReadValue) range=\(self.canReadSelection) " + + "valueSettable=\(self.valueIsSettable) selectionSettable=\(self.selectedTextIsSettable) " + + "-> Level \(level)" + } +} + +/// Thin Accessibility wrapper around the focused text field. +/// +/// It reuses the same primitives the existing cursor-insertion path already +/// relies on (`kAXValueAttribute` + `kAXSelectedTextRangeAttribute`), but it only +/// ever replaces a range it is explicitly handed. It never deletes anything on +/// its own, and it never assumes an attribute is available: every read can fail +/// and every caller must handle that. +struct LiveTypingAXTarget { + let pid: pid_t + let element: AXUIElement + let bundleIdentifier: String? + + /// Resolves the focused element, reusing the same system-wide lookup the + /// typing service uses. Returns nil when Accessibility is not granted or when + /// the focused element is not a text field. + static func capture(preferredPID: pid_t?) -> LiveTypingAXTarget? { + guard AXIsProcessTrusted() else { return nil } + + let systemWide = AXUIElementCreateSystemWide() + var focusedRef: CFTypeRef? + let result = AXUIElementCopyAttributeValue( + systemWide, + kAXFocusedUIElementAttribute as CFString, + &focusedRef + ) + guard result == .success, let focusedRef, + CFGetTypeID(focusedRef) == AXUIElementGetTypeID() + else { return nil } + + let element = unsafeBitCast(focusedRef, to: AXUIElement.self) + var pid: pid_t = 0 + AXUIElementGetPid(element, &pid) + guard pid > 0 else { return nil } + // A session may only continue in the process it started in. + if let preferredPID, preferredPID > 0, pid != preferredPID { return nil } + + let bundle = NSRunningApplication(processIdentifier: pid)?.bundleIdentifier + return LiveTypingAXTarget(pid: pid, element: element, bundleIdentifier: bundle) + } + + /// Secure fields must never be streamed into. + func isSecure() -> Bool { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue( + self.element, + kAXSubroleAttribute as CFString, + &value + ) == .success, let subrole = value as? String else { + // Unknown subrole: treat it as unavailable rather than risk a secure + // field. The caller falls back to final-only delivery. + return true + } + return subrole == (kAXSecureTextFieldSubrole as String) + || subrole.localizedCaseInsensitiveContains("secure") + } + + func value() -> String? { + var value: CFTypeRef? + let result = AXUIElementCopyAttributeValue( + self.element, + kAXValueAttribute as CFString, + &value + ) + guard result == .success, let text = value as? String else { return nil } + return text + } + + func selection() -> CFRange? { + var value: CFTypeRef? + let result = AXUIElementCopyAttributeValue( + self.element, + kAXSelectedTextRangeAttribute as CFString, + &value + ) + guard result == .success, let value, CFGetTypeID(value) == AXValueGetTypeID() else { + return nil + } + var range = CFRange() + guard AXValueGetValue(unsafeBitCast(value, to: AXValue.self), .cfRange, &range) else { + return nil + } + return range + } + + /// Replaces exactly `range` with `text` and puts the caret after the new + /// text. Returns false without touching the field whenever the value cannot + /// be read or the range does not fit. + func replace(range: NSRange, with text: String) -> Bool { + guard let current = self.value() else { return false } + let currentText = current as NSString + guard range.location >= 0, + range.length >= 0, + NSMaxRange(range) <= currentText.length + else { return false } + + let mutable = NSMutableString(string: current) + mutable.replaceCharacters(in: range, with: text) + let updated = mutable as String + + if AXUIElementSetAttributeValue( + self.element, + kAXValueAttribute as CFString, + updated as CFString + ) == .success { + self.moveCaret(to: range.location + (text as NSString).length) + return true + } + + // Level 2 fallback: select the owned range and replace the selection. + // Used by fields that expose a settable selection but not a settable + // value. + var selection = CFRange(location: range.location, length: range.length) + guard let axRange = AXValueCreate(.cfRange, &selection), + AXUIElementSetAttributeValue( + self.element, + kAXSelectedTextRangeAttribute as CFString, + axRange + ) == .success, + AXUIElementSetAttributeValue( + self.element, + kAXSelectedTextAttribute as CFString, + text as CFString + ) == .success + else { return false } + return true + } + + private func moveCaret(to location: Int) { + var caret = CFRange(location: location, length: 0) + if let axRange = AXValueCreate(.cfRange, &caret) { + _ = AXUIElementSetAttributeValue( + self.element, + kAXSelectedTextRangeAttribute as CFString, + axRange + ) + } + } + + private func stringAttribute(_ attribute: CFString) -> String? { + var value: CFTypeRef? + let result = AXUIElementCopyAttributeValue(self.element, attribute, &value) + guard result == .success, let text = value as? String else { return nil } + return text + } + + private func isSettable(_ attribute: CFString) -> Bool { + var settable: DarwinBoolean = false + let result = AXUIElementIsAttributeSettable(self.element, attribute, &settable) + return result == .success && settable.boolValue + } + + /// Read-only capability probe. Never writes. + func capabilities() -> LiveTypingCapabilities { + let role = self.stringAttribute(kAXRoleAttribute as CFString) + let subrole = self.stringAttribute(kAXSubroleAttribute as CFString) + let isSecure = subrole == (kAXSecureTextFieldSubrole as String) + || (subrole ?? "").localizedCaseInsensitiveContains("secure") + return LiveTypingCapabilities( + role: role, + subrole: subrole, + canReadValue: self.value() != nil, + canReadSelection: self.selection() != nil, + valueIsSettable: self.isSettable(kAXValueAttribute as CFString), + selectedTextIsSettable: self.isSettable(kAXSelectedTextAttribute as CFString), + isSecure: isSecure + ) + } + + /// Reads the substring at a range, used to verify a write after the fact. + func substring(at range: NSRange) -> String? { + guard let current = self.value() else { return nil } + let text = current as NSString + guard range.location >= 0, NSMaxRange(range) <= text.length else { return nil } + return text.substring(with: range) + } +} diff --git a/Sources/Fluid/Services/LiveTyping/LiveTypingController.swift b/Sources/Fluid/Services/LiveTyping/LiveTypingController.swift new file mode 100644 index 000000000..7d8a2f66f --- /dev/null +++ b/Sources/Fluid/Services/LiveTyping/LiveTypingController.swift @@ -0,0 +1,230 @@ +// +// LiveTypingController.swift +// Fluid +// +// Orchestration for the experimental Live Typing path. +// + +import AppKit +import ApplicationServices +import Combine +import Foundation + +/// Streams revised partials into the target field, but only while it can prove +/// it still owns the exact range it wrote. +/// +/// The controller is intentionally conservative: +/// - it never writes until the focused field's value and selection are readable; +/// - it verifies every write against the field; +/// - it walks Level 1 -> 2 -> 3 on any doubt instead of guessing; +/// - if it ever wrote text it can no longer track, it suppresses the normal +/// paste and leaves the final transcript on the pasteboard rather than risk a +/// duplicate or delete the user's text. +/// +/// Default OFF. This path has not been certified against any specific +/// application, so it stays experimental. +@MainActor +final class LiveTypingController: ObservableObject { + static let shared = LiveTypingController() + + /// Human-readable result of the last session, shown in Settings so the user + /// can certify which level a given application actually supported. + @Published private(set) var lastReport: String = "No dictation yet." + + private var session: LiveTypingSession? + private var target: LiveTypingAXTarget? + /// Highest level the current session is still allowed to use. + private(set) var effectiveLevel: LiveTypingLevel = .finalOnly + + private init() {} + + var isStreaming: Bool { + self.session?.hasWritten == true + } + + /// Feeds one revised partial. Wired to the existing partial subscription, so + /// it adds no new ASR work. + func observePartial(_ rawText: String) { + guard SettingsStore.shared.liveTypingExperimental else { + self.reset() + return + } + let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + + if self.session == nil { + self.beginSession() + } + guard var session = self.session, let target = self.target else { return } + guard let value = target.value() else { + self.downgrade(&session) + self.session = session + return + } + + switch session.partial(text, currentValue: value) { + case .none: + session.noteObservedValue(value) + self.session = session + + case .write(let range, let newText): + let previous = session.ownedText + if self.write(range: range, text: newText, target: target) { + session.noteWrite(newText) + self.session = session + return + } + // The write did not verify. Try to put the field back exactly as it + // was; if that also fails, the session can no longer be trusted. + if !previous.isEmpty { + let rollback = NSRange(location: range.location, length: (newText as NSString).length) + _ = target.replace(range: rollback, with: previous) + } + DebugLogger.shared.info( + "Live typing write failed; downgrading from \(session.level)", + source: "LiveTyping" + ) + self.downgrade(&session) + self.session = session + + case .abort(let reason): + DebugLogger.shared.info( + "Live typing stopped: \(reason.rawValue)", + source: "LiveTyping" + ) + self.reset() + } + } + + /// Called just before the normal insertion. Returns true when Live Typing + /// already owns the text and the caller must not insert it again. + func consumeFinalDelivery(plainText: String) -> Bool { + guard var session = self.session, let target = self.target else { + self.reset() + return false + } + defer { self.reset() } + + guard session.hasWritten else { return false } + + // Focus must still be exactly where the session opened. Writing the final + // text into a background field (or a different field of the same app) + // would surprise the user, so the transcript goes to the pasteboard + // instead of being inserted somewhere they are not looking. + guard let focused = LiveTypingAXTarget.capture(preferredPID: nil), + focused.pid == target.pid, + CFEqual(focused.element, target.element) + else { + DebugLogger.shared.info( + "Live typing focus moved; keeping the transcript on the pasteboard", + source: "LiveTyping" + ) + self.preserveToPasteboard(plainText) + return true + } + + guard let value = target.value() else { + self.preserveToPasteboard(plainText) + return true + } + + let outcome = session.final(plainText, currentValue: value) + switch outcome { + case .fallbackToDelivery: + return false + + case .nothingToDo: + self.lastReport += " - final text already in place, no second insertion." + return true + + case .replace(let range, let text): + if self.write(range: range, text: text, target: target) { + session.noteWrite(text) + self.lastReport += " - final transcript written once." + return true + } + DebugLogger.shared.info( + "Live typing final replace failed; keeping the transcript on the pasteboard", + source: "LiveTyping" + ) + self.preserveToPasteboard(plainText) + return true + + case .abort(let reason): + DebugLogger.shared.info( + "Live typing lost ownership (\(reason.rawValue)); not pasting over the user's edit", + source: "LiveTyping" + ) + self.preserveToPasteboard(plainText) + return true + } + } + + func reset() { + self.session = nil + self.target = nil + self.effectiveLevel = .finalOnly + } + + // MARK: - Private + + private func beginSession() { + guard let target = LiveTypingAXTarget.capture(preferredPID: nil) else { + // No readable field: stay at Level 3, which is the behaviour the app + // has always had. + self.effectiveLevel = .finalOnly + self.lastReport = "No focused text field (or Accessibility not granted). Level 3 (final paste)." + return + } + let capabilities = target.capabilities() + let level = capabilities.predictedLevel + self.effectiveLevel = level + self.lastReport = capabilities.summary + guard level != .finalOnly else { + self.target = nil + self.session = nil + return + } + guard let value = target.value(), let selection = target.selection() else { + self.effectiveLevel = .finalOnly + self.lastReport += " (value or range became unavailable)" + self.target = nil + self.session = nil + return + } + self.target = target + self.session = LiveTypingSession( + targetPID: target.pid, + anchor: selection.location, + initialValue: value, + level: level + ) + } + + /// Level 1 -> 2 -> 3. A downgrade never loses what was already written. + private func downgrade(_ session: inout LiveTypingSession) { + switch session.level { + case .full: + session.downgrade(to: .committedChunks) + case .committedChunks: + session.downgrade(to: .finalOnly) + case .finalOnly: + break + } + self.effectiveLevel = session.level + } + + private func write(range: NSRange, text: String, target: LiveTypingAXTarget) -> Bool { + guard target.replace(range: range, with: text) else { return false } + // Verify against the field, not against our own bookkeeping. + return target.substring( + at: NSRange(location: range.location, length: (text as NSString).length) + ) == text + } + + private func preserveToPasteboard(_ text: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + } +} diff --git a/Sources/Fluid/Services/LiveTyping/LiveTypingSession.swift b/Sources/Fluid/Services/LiveTyping/LiveTypingSession.swift new file mode 100644 index 000000000..c62fc5cf8 --- /dev/null +++ b/Sources/Fluid/Services/LiveTyping/LiveTypingSession.swift @@ -0,0 +1,228 @@ +// +// LiveTypingSession.swift +// Fluid +// +// Ownership bookkeeping for the experimental Live Typing path. +// + +import CoreGraphics +import Foundation + +/// How aggressively Live Typing is allowed to write into the target field. +/// +/// The controller starts at the highest level the target can support and +/// downgrades automatically - never upgrades mid-session - so a failure can only +/// ever fall back to the behaviour the app already had. +nonisolated enum LiveTypingLevel: Int, Codable, Equatable, Comparable { + /// Read the field, own a range, rewrite the partial as it is revised. + case full = 1 + /// Only append the stabilised prefix; never rewrite what was already written. + case committedChunks = 2 + /// Never touch the field while speaking. The existing final paste runs. + case finalOnly = 3 + + static func < (lhs: LiveTypingLevel, rhs: LiveTypingLevel) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +/// Why a session stopped rewriting. Every case is a *safe* stop: the session has +/// either written nothing, or it refuses to touch a field it no longer owns. +nonisolated enum LiveTypingAbortReason: String, Equatable { + case targetChanged + case valueChangedExternally + case verificationFailed + case writeFailed +} + +/// What the controller must do for one partial or final transcript. +nonisolated enum LiveTypingAction: Equatable { + case none + /// Replace exactly this range with this text. The range is always the range + /// FluidVoice previously wrote, never "the last N characters". + case write(range: NSRange, text: String) + case abort(LiveTypingAbortReason) +} + +/// What to do once the final transcript is known. +nonisolated enum LiveTypingFinalPlan: Equatable { + /// The field already contains exactly the final text: do not insert again. + case nothingToDo + /// Replace the owned range with the final transcript. + case replace(range: NSRange, text: String) + /// Nothing was ever written (or the level is final-only): run the normal + /// delivery path exactly as before. + case fallbackToDelivery + /// Ownership was lost. The caller must not paste on top; it should keep the + /// final text on the pasteboard instead of risking a duplicate. + case abort(LiveTypingAbortReason) +} + +/// One live-typing session. +/// +/// Deliberately a value type with no Accessibility or AppKit dependency: every +/// safety rule of the brief is expressed here and can be tested without a live +/// application. +/// +/// Invariants: +/// - `ownedRange` is always `anchor ..< anchor + ownedText.length`; +/// - FluidVoice only ever writes to `ownedRange`; +/// - it never writes until it has verified that the field still contains exactly +/// `ownedText` at that range; +/// - any doubt aborts instead of guessing. +nonisolated struct LiveTypingSession: Equatable { + let sessionID: UUID + /// The process FluidVoice started writing into. A different PID means a + /// different field, so the session is over. + let targetPID: pid_t + /// Caret location where FluidVoice started writing, in UTF-16 units. + let anchor: Int + /// Text that sat before the caret when the session opened. It must never + /// change; if it does, the user edited the field and the session is over. + private let prefixText: String + /// Text that sat after the caret when the session opened. It must never + /// change either, which is what stops FluidVoice from writing into a field + /// the user has typed into. + private let trailingText: String + /// The exact text FluidVoice wrote and still owns. + private(set) var ownedText: String + /// The last field value FluidVoice observed. + private(set) var lastKnownValue: String + private(set) var level: LiveTypingLevel + /// True once at least one write reached the field. + private(set) var hasWritten: Bool + + init( + sessionID: UUID = UUID(), + targetPID: pid_t, + anchor: Int, + initialValue: String, + level: LiveTypingLevel + ) { + let text = initialValue as NSString + let clampedAnchor = max(0, min(anchor, text.length)) + self.sessionID = sessionID + self.targetPID = targetPID + self.anchor = clampedAnchor + self.prefixText = text.substring(to: clampedAnchor) + self.trailingText = text.substring(from: clampedAnchor) + self.ownedText = "" + self.lastKnownValue = initialValue + self.level = level + self.hasWritten = false + } + + /// The exact range FluidVoice owns, in UTF-16 units. + var ownedRange: NSRange { + NSRange(location: self.anchor, length: (self.ownedText as NSString).length) + } + + /// True when the field still contains exactly what FluidVoice wrote, at the + /// range it wrote it. This is the ownership proof the safety rule demands. + func owns(_ value: String) -> Bool { + let text = value as NSString + let range = self.ownedRange + guard range.location >= 0, range.length >= 0, NSMaxRange(range) <= text.length else { + return false + } + return text.substring(with: range) == self.ownedText + } + + /// The strongest ownership proof available: the text before the caret, the + /// text inside the owned range and the text after it must all still be + /// exactly what FluidVoice last saw. A change anywhere aborts the session - + /// FluidVoice never guesses what the user meant. + func contextIsIntact(_ value: String) -> Bool { + let text = value as NSString + guard text.length >= self.anchor + (self.ownedText as NSString).length else { return false } + guard text.substring(to: self.anchor) == self.prefixText else { return false } + guard text.substring(with: self.ownedRange) == self.ownedText else { return false } + guard text.substring(from: NSMaxRange(self.ownedRange)) == self.trailingText else { return false } + return true + } + + /// The caret is expected to sit just after the owned text while streaming. + func caretIsAtOwnedEnd(_ selection: CFRange?) -> Bool { + guard let selection else { return false } + return selection.location == NSMaxRange(self.ownedRange) + } + + /// What to do for a revised partial. + mutating func partial(_ text: String, currentValue: String) -> LiveTypingAction { + switch self.level { + case .finalOnly: + return .none + + case .full: + guard self.contextIsIntact(currentValue) else { return .abort(.valueChangedExternally) } + guard text != self.ownedText else { return .none } + return .write(range: self.ownedRange, text: text) + + case .committedChunks: + guard self.contextIsIntact(currentValue) else { return .abort(.valueChangedExternally) } + // Commit everything but the last word: the tail is still provisional, + // and this level is not allowed to rewrite it. + let words = text.split(separator: " ").map(String.init) + guard words.count > 1 else { return .none } + let committed = words.dropLast().joined(separator: " ") + guard committed.count > self.ownedText.count, committed.hasPrefix(self.ownedText) else { + return committed == self.ownedText ? .none : .abort(.verificationFailed) + } + return .write(range: self.ownedRange, text: committed) + } + } + + /// Records that a write of `text` reached the field. + mutating func noteWrite(_ text: String) { + self.ownedText = text + self.hasWritten = true + } + + /// Records a field value FluidVoice observed without writing to it. + mutating func noteObservedValue(_ value: String) { + self.lastKnownValue = value + } + + /// Level 2 to 3 never loses text: the session simply stops rewriting and the + /// final delivery takes over. + mutating func downgrade(to newLevel: LiveTypingLevel) { + if newLevel > self.level { + self.level = newLevel + } + } + + /// What to do with the final transcript. + mutating func final(_ transcript: String, currentValue: String) -> LiveTypingFinalPlan { + // The level only gates *new* writes. Once something was written the field + // holds our text whatever the level is now, so it still has to be + // reconciled - falling back to the paste path here would duplicate it. + guard self.hasWritten else { + return .fallbackToDelivery + } + guard self.contextIsIntact(currentValue) else { + return .abort(.valueChangedExternally) + } + if self.ownedText == transcript { + return .nothingToDo + } + return .replace(range: self.ownedRange, text: transcript) + } + + /// Longest word prefix common to two transcripts, ignoring case and + /// punctuation. Mirrors the streaming diff so the two never disagree. + static func stablePrefix(previous: String, current: String) -> String { + let previousWords = previous.split(separator: " ").map(String.init) + let currentWords = current.split(separator: " ").map(String.init) + guard !currentWords.isEmpty else { return "" } + + var matchCount = 0 + for index in 0.. 0 else { return "" } + return currentWords[0.. Self { @@ -808,6 +834,8 @@ private extension NotchOverlayManager.NotchPresentationPolicy { return .standard case .minimal: return supportsCompactPresentation ? .minimal : .standard + case .ambient: + return .ambient } } } diff --git a/Sources/Fluid/UI/Companion/CompanionGeometry.swift b/Sources/Fluid/UI/Companion/CompanionGeometry.swift new file mode 100644 index 000000000..30f157476 --- /dev/null +++ b/Sources/Fluid/UI/Companion/CompanionGeometry.swift @@ -0,0 +1,217 @@ +// +// CompanionGeometry.swift +// Fluid +// +// Pure geometry for one Companion frame. +// + +import SwiftUI + +/// Everything the Companion draws is derived from this value. +/// +/// It is a function of the phase, the audio level and the variant tuning only, +/// so the companion moves because the slow drift or the voice moved - never +/// because of a random source. That is the same rule the overlay visualizers +/// follow, and it is what keeps the character calm instead of twitchy. +struct CompanionGeometry { + let rect: CGRect + let phase: CGFloat + let level: CGFloat + let tuning: CompanionTuning + /// 0.55 subtle ... 1.5 expressive. Multiplies the travel, never the voice. + let motion: CGFloat + + static let coreSteps = 44 + static let membraneSteps = 48 + + // MARK: - Core + + /// Radius of the dark body. A little under a third of the box, so the + /// membranes can wrap around it without leaving the canvas. + var coreRadius: CGFloat { + min(self.rect.width, self.rect.height) * 0.25 + } + + var center: CGPoint { + CGPoint(x: self.rect.midX, y: self.rect.midY) + } + + /// Closed silhouette of the dark core. + func corePath() -> Path { + var points: [CGPoint] = [] + for step in 0.. CGFloat { + let breath = 1 + 0.026 * sin(self.phase * 0.7) + 0.10 * self.level * self.motion + let body = 1 + + 0.062 * cos(2 * angle + 0.35) + + 0.030 * cos(3 * angle + self.phase * 0.42) + + 0.016 * cos(5 * angle - self.phase * 0.30) + return breath * body + } + + // MARK: - Membranes + + func membraneRadius(index: Int) -> CGFloat { + let side = min(self.rect.width, self.rect.height) + return side * (0.30 + 0.050 * CGFloat(index)) + } + + /// Angular coverage of a membrane, in radians. + /// + /// Always under a full turn: that opening is the difference between a draped + /// ribbon and a ring around the body. + func membraneSpan(index: Int) -> CGFloat { + let seed = CGFloat(index) * 1.9 + 0.6 + let speed = self.tuning.speed * self.motion + return 1.55 * CGFloat.pi + 0.22 * sin(seed + self.phase * speed * 0.3) + } + + /// A tapered ribbon that wraps most of the body, then opens. + /// + /// The band never closes into a ring: it covers roughly three quarters of the + /// circumference and fades to a point at both ends, and its radius carries a + /// strong two and three fold modulation, so it drapes around the body instead + /// of orbiting it. Consecutive membranes spin in opposite directions. This is + /// the difference between a membrane and a Saturn ring. + func membranePath(index: Int) -> Path { + let side = min(self.rect.width, self.rect.height) + let base = self.membraneRadius(index: index) + let seed = CGFloat(index) * 1.9 + 0.6 + let direction: CGFloat = index % 2 == 0 ? 1 : -1 + let speed = self.tuning.speed * self.motion + let spin = self.phase * speed * 0.35 * direction + // Outer ribbons travel a little less, so they cannot leave the canvas. + let damp = 1 - 0.06 * CGFloat(index) + + let span = self.membraneSpan(index: index) + let start = spin + seed + CGFloat(index) * 0.65 + let ribbonWidth = side * (0.085 - 0.010 * CGFloat(index)) + let offsetX = side * 0.020 * CGFloat(index - 1) + let offsetY = -side * 0.012 * CGFloat(index - 1) + let center = CGPoint(x: self.center.x + offsetX, y: self.center.y + offsetY) + + let steps = Self.membraneSteps + var angles: [CGFloat] = [] + var radii: [CGFloat] = [] + var halfWidths: [CGFloat] = [] + for step in 0.. limit ? limit / maxOuter : 1 + + var outer: [CGPoint] = [] + var inner: [CGPoint] = [] + for index in 0.. [CGPoint] { + let side = min(self.rect.width, self.rect.height) + return (0.. CGPoint { + let offset = self.coreRadius * 0.42 * CGFloat(sign) + return CGPoint(x: self.center.x + offset, y: self.center.y - self.coreRadius * 0.16) + } + + func eyeSize() -> CGSize { + let width = max(self.coreRadius * 0.30, 2) + let height = max(self.coreRadius * 0.34 * (0.55 + 0.65 * self.level * self.motion), 1.4) + return CGSize(width: width, height: height) + } + + // MARK: - Helpers + + /// Closed Catmull-Rom style outline through the points, using the shared + /// midpoint technique so every organic shape in the app is smoothed the same + /// way. + static func smoothClosedPath(_ points: [CGPoint]) -> Path { + guard points.count > 2 else { return Path() } + var path = Path() + path.move(to: midpoint(points[points.count - 1], points[0])) + for index in 0.. CGPoint { + CGPoint(x: (first.x + second.x) / 2, y: (first.y + second.y) / 2) + } + + /// One point on the (slightly vertically squashed) ring around the core. + private static func point(center: CGPoint, angle: CGFloat, radius: CGFloat) -> CGPoint { + CGPoint(x: center.x + cos(angle) * radius, y: center.y + sin(angle) * radius * 0.94) + } +} diff --git a/Sources/Fluid/UI/Companion/CompanionModel.swift b/Sources/Fluid/UI/Companion/CompanionModel.swift new file mode 100644 index 000000000..abc78695a --- /dev/null +++ b/Sources/Fluid/UI/Companion/CompanionModel.swift @@ -0,0 +1,253 @@ +// +// CompanionModel.swift +// Fluid +// +// Settings model for the optional Companion: size, corner, presence, the +// elemental variants and the accessory slots. +// + +import SwiftUI + +/// Fixed metrics shared by the Companion style and its settings. +nonisolated enum CompanionMetrics { + /// Side of the Companion canvas at scale 1.0. + /// + /// This is the size the Companion used to reach at 200%: the reference look + /// is now what "reset to 100%" gives, and the user can only grow from there. + static let baseSide: CGFloat = 96 +} + +/// The elemental variants. Same soul, different material. +enum CompanionVariant: String, CaseIterable, Codable, Identifiable { + case standard = "default" + case fire + case water + case wind + case earth + case aurora + case gothic + + static let fallback: CompanionVariant = .standard + + var id: String { self.rawValue } + + var displayName: String { + switch self { + case .standard: return "Default" + case .fire: return "Fire" + case .water: return "Water" + case .wind: return "Wind" + case .earth: return "Earth" + case .aurora: return "Aurora" + case .gothic: return "Gothic" + } + } + + var summary: String { + switch self { + case .standard: return "Balanced membranes in your theme colours" + case .fire: return "Warm palette, pointed rising ribbons, rare embers" + case .water: return "Cool fluid membranes, slower and rounder" + case .wind: return "Thin quick ribbons and small fragments" + case .earth: return "Stable green mass, calm and grounded" + case .aurora: return "Flagship multilayer light" + case .gothic: return "Dark plum body with crimson accents" + } + } + + /// Fixed palette for variants with their own identity. Default, Aurora and + /// the theme-driven variants return nil and use the overlay theme palette, so + /// the Companion reuses the colour engine instead of duplicating it. + var fixedPalette: OverlayPalette? { + switch self { + case .standard, .aurora: + return nil + case .fire: + return OverlayPalette( + primary: Color(hex: "#FF4D2E") ?? .red, + secondary: Color(hex: "#FF8A3D") ?? .orange, + tertiary: Color(hex: "#FFC15E") ?? .yellow, + highlight: Color(hex: "#FF3D7F") ?? .pink, + accent: Color(hex: "#FFE0A3") ?? .yellow + ) + case .water: + return OverlayPalette( + primary: Color(hex: "#1D4ED8") ?? .blue, + secondary: Color(hex: "#38BDF8") ?? .cyan, + tertiary: Color(hex: "#22D3EE") ?? .cyan, + highlight: Color(hex: "#A5F3FC") ?? .cyan, + accent: Color(hex: "#60A5FA") ?? .blue + ) + case .wind: + return OverlayPalette( + primary: Color(hex: "#67E8F9") ?? .cyan, + secondary: Color(hex: "#A5F3FC") ?? .cyan, + tertiary: Color(hex: "#E0F7FF") ?? .white, + highlight: Color(hex: "#FFFFFF") ?? .white, + accent: Color(hex: "#BAE6FD") ?? .cyan + ) + case .earth: + return OverlayPalette( + primary: Color(hex: "#166534") ?? .green, + secondary: Color(hex: "#22C55E") ?? .green, + tertiary: Color(hex: "#86EFAC") ?? .green, + highlight: Color(hex: "#BEF264") ?? .green, + accent: Color(hex: "#A3E635") ?? .green + ) + case .gothic: + return OverlayPalette( + primary: Color(hex: "#6D28D9") ?? .purple, + secondary: Color(hex: "#A21CAF") ?? .purple, + tertiary: Color(hex: "#E11D48") ?? .red, + highlight: Color(hex: "#FB7185") ?? .pink, + accent: Color(hex: "#F43F5E") ?? .pink + ) + } + } + + var tuning: CompanionTuning { + switch self { + case .standard: + return CompanionTuning(membraneCount: 3, speed: 1.0, sharpness: 0.50, rise: 0.00, + flutter: 0.16, wobble: 0.34, coreGlow: 0.32, emberCount: 0, emberWarmth: 0.5) + case .fire: + return CompanionTuning(membraneCount: 3, speed: 1.45, sharpness: 0.86, rise: 0.34, + flutter: 0.34, wobble: 0.44, coreGlow: 0.46, emberCount: 5, emberWarmth: 1.0) + case .water: + return CompanionTuning(membraneCount: 3, speed: 0.70, sharpness: 0.24, rise: -0.05, + flutter: 0.18, wobble: 0.56, coreGlow: 0.36, emberCount: 2, emberWarmth: 0.15) + case .wind: + return CompanionTuning(membraneCount: 4, speed: 1.60, sharpness: 0.95, rise: 0.10, + flutter: 0.30, wobble: 0.60, coreGlow: 0.20, emberCount: 0, emberWarmth: 0.0) + case .earth: + return CompanionTuning(membraneCount: 2, speed: 0.50, sharpness: 0.16, rise: -0.10, + flutter: 0.10, wobble: 0.28, coreGlow: 0.26, emberCount: 0, emberWarmth: 0.3) + case .aurora: + return CompanionTuning(membraneCount: 4, speed: 0.95, sharpness: 0.55, rise: 0.05, + flutter: 0.22, wobble: 0.50, coreGlow: 0.50, emberCount: 2, emberWarmth: 0.7) + case .gothic: + return CompanionTuning(membraneCount: 3, speed: 0.85, sharpness: 0.68, rise: 0.02, + flutter: 0.18, wobble: 0.40, coreGlow: 0.52, emberCount: 3, emberWarmth: 0.9) + } + } + + func palette(themePalette: OverlayPalette) -> OverlayPalette { + self.fixedPalette ?? themePalette + } +} + +/// How the membranes and the core behave for one variant. +/// +/// Kept as plain values on purpose: the seven variants share one parametric +/// engine, so there is no separate drawing path per element. +struct CompanionTuning: Equatable { + var membraneCount: Int + var speed: CGFloat + /// Lower values are rounder, higher values narrower and more pointed. + var sharpness: CGFloat + /// Positive values lift the ribbons; Fire uses this for rising energy. + var rise: CGFloat + /// High frequency trembling. + var flutter: CGFloat + /// Low frequency radius modulation. + var wobble: CGFloat + var coreGlow: CGFloat + var emberCount: Int + /// 0 = cool sparks, 1 = warm sparks. + var emberWarmth: CGFloat +} + +/// Where an accessory is attached. +enum CompanionAccessorySlot: String, CaseIterable, Codable, Identifiable { + case head + case face + case body + case aura + + var id: String { self.rawValue } +} + +/// The small free local customisation set. +enum CompanionAccessory: String, CaseIterable, Codable, Identifiable { + case hat + case glasses + case scarf + case halo + + static let fallback: Set = [] + + var id: String { self.rawValue } + + var displayName: String { + switch self { + case .hat: return "Hat" + case .glasses: return "Glasses" + case .scarf: return "Scarf" + case .halo: return "Halo" + } + } + + var slot: CompanionAccessorySlot { + switch self { + case .hat: return .head + case .glasses: return .face + case .scarf: return .body + case .halo: return .aura + } + } +} + +/// How much movement the overlays and the companion are allowed. +enum MotionIntensity: String, CaseIterable, Codable, Identifiable { + case subtle + case normal + case expressive + + static let fallback: MotionIntensity = .normal + + var id: String { self.rawValue } + + var displayName: String { + switch self { + case .subtle: return "Subtle" + case .normal: return "Normal" + case .expressive: return "Expressive" + } + } + + /// Multiplier applied to membrane travel and to the audio reaction. + var scale: CGFloat { + switch self { + case .subtle: return 0.55 + case .normal: return 1.0 + case .expressive: return 1.5 + } + } +} + +/// The state the companion is expressing. +/// +/// Derived only from signals the dictation pipeline already publishes, so the +/// companion can never add a new source of truth (or a new audio path). +enum CompanionState: String, CaseIterable, Codable { + case idle + case listening + case thinking + case typing + case completed + case error + + static func resolve( + isPresented: Bool, + isProcessing: Bool, + didComplete: Bool, + hasFailure: Bool, + hasTranscription: Bool + ) -> CompanionState { + if hasFailure { return .error } + if didComplete { return .completed } + if isProcessing { return .thinking } + if isPresented { return hasTranscription ? .typing : .listening } + return .idle + } +} diff --git a/Sources/Fluid/UI/Companion/CompanionVisualizer.swift b/Sources/Fluid/UI/Companion/CompanionVisualizer.swift new file mode 100644 index 000000000..640c92aa8 --- /dev/null +++ b/Sources/Fluid/UI/Companion/CompanionVisualizer.swift @@ -0,0 +1,446 @@ +// +// CompanionVisualizer.swift +// Fluid +// +// The optional Companion: an abstract dark core wrapped in fluid membranes. +// + +import SwiftUI + +/// Draws the Companion for one instant. +/// +/// Pure consumer of state the dictation pipeline already publishes: the +/// microphone level and the lifecycle phase. It opens no audio path, owns no +/// timer of its own, and stops completely when it is not on screen. +struct CompanionVisualizer: View { + let state: CompanionState + let variant: CompanionVariant + let themePalette: OverlayPalette + let glow: OverlayGlowIntensity + let accessories: Set + let level: CGFloat + let isActive: Bool + let reduceMotion: Bool + let motion: MotionIntensity + + /// Frozen phase for offscreen previews and tests. Inert at runtime. + var previewPhase: CGFloat? = nil + + var body: some View { + let palette = self.variant.palette(themePalette: self.themePalette) + let tuning = self.variant.tuning + Group { + if let phase = self.previewPhase { + self.canvas(phase: phase, palette: palette, tuning: tuning) + } else if self.reduceMotion { + // Reduce Motion: keep the silhouette, the palette and the audio + // reaction, drop the continuous drift. + self.canvas(phase: 1.1, palette: palette, tuning: tuning) + } else { + TimelineView(.animation(minimumInterval: self.frameInterval, paused: !self.isActive)) { timeline in + self.canvas( + phase: Self.phase(for: timeline.date), + palette: palette, + tuning: tuning + ) + } + } + } + .compositingGroup() + .allowsHitTesting(false) + } + + /// Idle is the cheapest state; listening and thinking carry the audio + /// reaction and get the full rate. + private var frameInterval: TimeInterval { + switch self.state { + case .listening, .thinking, .typing: return 1.0 / 30.0 + case .idle, .completed, .error: return 1.0 / 18.0 + } + } + + private static func phase(for date: Date) -> CGFloat { + date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 240) + } + + private func canvas(phase: CGFloat, palette: OverlayPalette, tuning: CompanionTuning) -> some View { + Canvas(rendersAsynchronously: false) { context, size in + self.draw(into: &context, size: size, phase: phase, palette: palette, tuning: tuning) + } + } + + // MARK: - Drawing + + private func draw( + into context: inout GraphicsContext, + size: CGSize, + phase: CGFloat, + palette: OverlayPalette, + tuning: CompanionTuning + ) { + let rect = CGRect(origin: .zero, size: size) + let geometry = CompanionGeometry( + rect: rect, + phase: phase, + level: min(max(self.level, 0), 1), + tuning: tuning, + motion: self.motion.scale + ) + let glowScale = CGFloat(self.glow.visualizerGlowScale) + let colors = [palette.tertiary, palette.primary, palette.highlight, palette.secondary, palette.accent] + + self.drawAura(into: &context, geometry: geometry, palette: palette, tuning: tuning, glowScale: glowScale) + + // Membranes: a blurred light pass, then the ribbon itself. + for index in 0.. 0.05 else { return } + + let side = min(geometry.rect.width, geometry.rect.height) + let baseRadius = geometry.coreRadius * 1.15 + for index in 0..<2 { + let progress = (geometry.phase * 0.30 + CGFloat(index) * 0.5) + .truncatingRemainder(dividingBy: 1) + let radius = baseRadius + side * 0.26 * progress + let alpha = (1 - progress) * 0.28 * Double(energy) + guard alpha > 0.01 else { continue } + let rect = CGRect( + x: geometry.center.x - radius, + y: geometry.center.y - radius, + width: radius * 2, + height: radius * 2 + ) + let color = index == 0 ? palette.tertiary : palette.highlight + context.stroke( + Path(ellipseIn: rect), + with: .color(color.opacity(alpha)), + lineWidth: max(side * 0.012 * (1 - progress), 0.6) + ) + } + } + + private func drawCore( + into context: inout GraphicsContext, + geometry: CompanionGeometry, + palette: OverlayPalette, + glowScale: CGFloat + ) { + let core = geometry.corePath() + // Dark body. Never pure black, so the rim has something to sit against. + context.fill( + core, + with: .radialGradient( + Gradient(colors: [ + Color(hex: "#181B26") ?? Color.black, + Color(hex: "#05060A") ?? Color.black, + ]), + center: CGPoint(x: geometry.center.x, y: geometry.center.y - geometry.coreRadius * 0.45), + startRadius: 0, + endRadius: geometry.coreRadius * 1.65 + ) + ) + // Light reflected from below, so the body reads as a volume rather than a + // flat sticker. + context.fill( + core, + with: .linearGradient( + Gradient(colors: [ + Color.clear, + palette.secondary.opacity(min(0.24 * Double(glowScale), 0.34)), + ]), + startPoint: CGPoint(x: geometry.center.x, y: geometry.center.y), + endPoint: CGPoint(x: geometry.center.x, y: geometry.center.y + geometry.coreRadius) + ) + ) + context.stroke( + core, + with: .linearGradient( + Gradient(colors: [ + palette.tertiary.opacity(0.85), + palette.primary.opacity(0.70), + palette.highlight.opacity(0.80), + ]), + startPoint: CGPoint(x: geometry.rect.minX, y: geometry.center.y), + endPoint: CGPoint(x: geometry.rect.maxX, y: geometry.center.y) + ), + lineWidth: max(1.0 * glowScale, 0.8) + ) + } + + private func drawFace( + into context: inout GraphicsContext, + geometry: CompanionGeometry, + palette: OverlayPalette + ) { + let face = Color.white.opacity(0.92) + let size = geometry.eyeSize() + let stroke = StrokeStyle(lineWidth: max(size.width * 0.28, 1.3), lineCap: .round) + for sign in [-1, 1] { + let center = geometry.eyeCenter(sign: sign) + switch self.state { + case .idle: + context.stroke( + Self.eyeArc(center: center, width: size.width, curvingUp: false), + with: .color(face), + style: stroke + ) + case .completed: + context.stroke( + Self.eyeArc(center: center, width: size.width, curvingUp: true), + with: .color(face), + style: stroke + ) + case .listening: + let radius = size.width * 0.44 + let eye = Path(roundedRect: CGRect( + x: center.x - radius, + y: center.y - size.height * 0.5, + width: radius * 2, + height: size.height + ), cornerRadius: radius) + // A soft glow so "I am listening" is unmistakable at a glance. + context.drawLayer { layer in + layer.addFilter(.blur(radius: max(size.width * 0.9, 1.5))) + layer.opacity = 0.55 + layer.fill(eye, with: .color(palette.tertiary)) + } + context.fill(eye, with: .color(face)) + case .thinking: + // Two small pupils that glance sideways, so the state reads as + // "working on it" without any text. + let shift = size.width * 0.34 * sin(geometry.phase * 0.9 + CGFloat(sign)) + let radius = size.width * 0.30 + context.fill( + Path(ellipseIn: CGRect( + x: center.x - radius + shift, + y: center.y - radius, + width: radius * 2, + height: radius * 2 + )), + with: .color(face) + ) + case .typing: + let bar = CGRect( + x: center.x - size.width * 0.46, + y: center.y - max(size.width * 0.10, 0.7), + width: size.width * 0.92, + height: max(size.width * 0.20, 1.4) + ) + context.fill(Path(roundedRect: bar, cornerRadius: bar.height / 2), with: .color(face)) + case .error: + context.stroke( + Self.crossPath(center: center, size: size.width * 0.55), + with: .color(face), + style: stroke + ) + } + } + // A tiny neutral mouth keeps the face readable without making it human. + let mouthWidth = size.width * 0.9 + let mouthY = geometry.center.y + geometry.coreRadius * 0.34 + var mouth = Path() + mouth.move(to: CGPoint(x: geometry.center.x - mouthWidth / 2, y: mouthY)) + mouth.addQuadCurve( + to: CGPoint(x: geometry.center.x + mouthWidth / 2, y: mouthY), + control: CGPoint( + x: geometry.center.x, + y: mouthY + (self.state == .error ? -size.width * 0.30 : size.width * 0.34) + ) + ) + context.stroke( + mouth, + with: .color(palette.tertiary.opacity(0.55)), + style: StrokeStyle(lineWidth: max(size.width * 0.16, 1.0), lineCap: .round) + ) + } + + private func drawAccessories( + into context: inout GraphicsContext, + geometry: CompanionGeometry, + palette: OverlayPalette + ) { + let radius = geometry.coreRadius + let stroke = max(radius * 0.16, 1.2) + + if self.accessories.contains(.halo) { + let rect = CGRect( + x: geometry.center.x - radius * 0.80, + y: geometry.center.y - radius * 1.78, + width: radius * 1.60, + height: radius * 0.42 + ) + context.drawLayer { layer in + layer.addFilter(.blur(radius: radius * 0.5)) + layer.opacity = 0.55 + layer.stroke(Path(ellipseIn: rect), with: .color(palette.accent), lineWidth: stroke) + } + context.stroke(Path(ellipseIn: rect), with: .color(palette.accent.opacity(0.95)), lineWidth: stroke) + } + + if self.accessories.contains(.hat) { + let crownWidth = radius * 1.10 + let crownHeight = radius * 0.62 + let top = geometry.center.y - radius * 1.32 + var crown = Path() + crown.move(to: CGPoint(x: geometry.center.x - crownWidth / 2, y: top + crownHeight)) + crown.addLine(to: CGPoint(x: geometry.center.x - crownWidth * 0.36, y: top)) + crown.addLine(to: CGPoint(x: geometry.center.x + crownWidth * 0.36, y: top)) + crown.addLine(to: CGPoint(x: geometry.center.x + crownWidth / 2, y: top + crownHeight)) + crown.closeSubpath() + context.fill(crown, with: .color(palette.primary.opacity(0.94))) + let brim = CGRect( + x: geometry.center.x - crownWidth * 0.74, + y: top + crownHeight - stroke * 0.5, + width: crownWidth * 1.48, + height: stroke * 1.6 + ) + context.fill( + Path(roundedRect: brim, cornerRadius: brim.height / 2), + with: .color(palette.highlight.opacity(0.95)) + ) + } + + if self.accessories.contains(.glasses) { + let lensRadius = radius * 0.38 + for sign in [-1, 1] { + let center = geometry.eyeCenter(sign: sign) + let rect = CGRect( + x: center.x - lensRadius, + y: center.y - lensRadius, + width: lensRadius * 2, + height: lensRadius * 2 + ) + context.stroke(Path(ellipseIn: rect), with: .color(palette.tertiary.opacity(0.95)), lineWidth: stroke) + } + var bridge = Path() + bridge.move(to: CGPoint(x: geometry.center.x - radius * 0.12, y: geometry.center.y - radius * 0.16)) + bridge.addLine(to: CGPoint(x: geometry.center.x + radius * 0.12, y: geometry.center.y - radius * 0.16)) + context.stroke(bridge, with: .color(palette.tertiary.opacity(0.95)), lineWidth: stroke * 0.8) + } + + if self.accessories.contains(.scarf) { + let band = CGRect( + x: geometry.center.x - radius * 0.80, + y: geometry.center.y + radius * 0.60, + width: radius * 1.60, + height: radius * 0.34 + ) + context.fill( + Path(roundedRect: band, cornerRadius: band.height / 2), + with: .color(palette.highlight.opacity(0.95)) + ) + let tail = CGRect( + x: geometry.center.x + radius * 0.30, + y: band.maxY - radius * 0.06, + width: radius * 0.26, + height: radius * 0.48 + ) + context.fill( + Path(roundedRect: tail, cornerRadius: tail.width / 2), + with: .color(palette.accent.opacity(0.9)) + ) + } + } + + private func drawEmbers( + into context: inout GraphicsContext, + geometry: CompanionGeometry, + palette: OverlayPalette + ) { + guard geometry.tuning.emberCount > 0 else { return } + let side = min(geometry.rect.width, geometry.rect.height) + let radius = max(side * 0.018, 0.8) + let warm = geometry.tuning.emberWarmth > 0.5 ? palette.accent : palette.tertiary + for point in geometry.emberPoints() { + context.fill( + Path(ellipseIn: CGRect( + x: point.x - radius, + y: point.y - radius, + width: radius * 2, + height: radius * 2 + )), + with: .color(warm.opacity(0.85)) + ) + } + } + + // MARK: - Face helpers + + private static func eyeArc(center: CGPoint, width: CGFloat, curvingUp: Bool) -> Path { + let half = width * 0.55 + let depth = width * 0.40 * (curvingUp ? -1 : 1) + var path = Path() + path.move(to: CGPoint(x: center.x - half, y: center.y)) + path.addQuadCurve( + to: CGPoint(x: center.x + half, y: center.y), + control: CGPoint(x: center.x, y: center.y + depth) + ) + return path + } + + private static func crossPath(center: CGPoint, size: CGFloat) -> Path { + let half = size / 2 + var path = Path() + path.move(to: CGPoint(x: center.x - half, y: center.y - half)) + path.addLine(to: CGPoint(x: center.x + half, y: center.y + half)) + path.move(to: CGPoint(x: center.x + half, y: center.y - half)) + path.addLine(to: CGPoint(x: center.x - half, y: center.y + half)) + return path + } +} diff --git a/Sources/Fluid/UI/LiveTypingStatusRow.swift b/Sources/Fluid/UI/LiveTypingStatusRow.swift new file mode 100644 index 000000000..6178fb04a --- /dev/null +++ b/Sources/Fluid/UI/LiveTypingStatusRow.swift @@ -0,0 +1,29 @@ +// +// LiveTypingStatusRow.swift +// Fluid +// +// Settings readout for the experimental Live Typing path. +// + +import SwiftUI + +/// Shows what the last Live Typing session actually did. +/// +/// This is how a given application gets certified: dictate once with the option +/// on, then read the level it resolved to here. It is a convenience for a +/// feature that is deliberately not enabled by default. +struct LiveTypingStatusRow: View { + @ObservedObject private var controller = LiveTypingController.shared + + var body: some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "info.circle") + .font(.caption) + .foregroundStyle(.secondary) + Text(self.controller.lastReport) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } +} diff --git a/Sources/Fluid/UI/Overlay/AudioEnvelopeFollower.swift b/Sources/Fluid/UI/Overlay/AudioEnvelopeFollower.swift new file mode 100644 index 000000000..fda1fac0e --- /dev/null +++ b/Sources/Fluid/UI/Overlay/AudioEnvelopeFollower.swift @@ -0,0 +1,87 @@ +// +// AudioEnvelopeFollower.swift +// Fluid +// +// Damping for the raw microphone level that the ASR engine already publishes. +// + +import Foundation + +/// Converts the raw, jumpy level published by the audio engine into the damped +/// envelope the premium visualizers draw. +/// +/// This is pure value logic: it reads no audio hardware, opens no second capture +/// path, and only ever owns one fixed-size history ring. +nonisolated struct AudioEnvelopeFollower { + /// Number of delayed samples kept for the travelling-light effect. + static let historyLength = 18 + + /// Most recent sample last. + private(set) var history: [CGFloat] + /// Current smoothed level in 0...1. + private(set) var smoothed: CGFloat = 0 + + /// Fraction of the gap closed per update while the level is rising. + let attack: CGFloat + /// Fraction of the gap closed per update while the level is falling. + let release: CGFloat + + init(attack: CGFloat = 0.6, release: CGFloat = 0.14) { + self.attack = attack + self.release = release + self.history = Array(repeating: 0, count: Self.historyLength) + } + + /// Maps a raw 0...1 level through the user's visualizer sensitivity. + static func normalized(_ level: CGFloat, noiseThreshold: CGFloat) -> CGFloat { + let clampedLevel = min(max(level, 0), 1) + let clampedThreshold = min(max(noiseThreshold, 0), 0.95) + let denominator = max(1 - clampedThreshold, 0.001) + let gated = max(min((clampedLevel - clampedThreshold) / denominator, 1), 0) + return pow(gated, 0.62) + } + + /// Feeds one normalized sample and returns the new smoothed level. + @discardableResult + mutating func update(with level: CGFloat) -> CGFloat { + let target = min(max(level, 0), 1) + let coefficient = target > self.smoothed ? self.attack : self.release + self.smoothed += (target - self.smoothed) * coefficient + if self.history.isEmpty { + self.history = Array(repeating: 0, count: Self.historyLength) + } + self.history.removeFirst() + self.history.append(self.smoothed) + return self.smoothed + } + + /// Offset 0 is the newest sample; higher offsets are older. + func delayedSample(at offset: Int) -> CGFloat { + let index = self.history.count - 1 - offset + guard index >= 0, index < self.history.count else { return 0 } + return self.history[index] + } + + mutating func reset() { + self.smoothed = 0 + for index in self.history.indices { + self.history[index] = 0 + } + } + + /// Follower pre-filled with a smooth speech envelope. + /// + /// Used only by offscreen previews and tests, so the audio-reactive styles + /// can be rendered at a known level without a live microphone. The production + /// path never calls it: at runtime the follower is fed by the real level. + static func preview(level: CGFloat) -> AudioEnvelopeFollower { + var follower = AudioEnvelopeFollower() + let span = CGFloat(max(AudioEnvelopeFollower.historyLength - 1, 1)) + for index in 0..1 is faster, <1 is slower. + var speed: CGFloat + /// Direction of the lobe drift. The accent layer drifts against the mass. + var driftDirection: CGFloat + /// Fraction of the canvas width the layer spans. + var widthScale: CGFloat + /// Fraction of the canvas height the layer's envelope spans. + var heightScale: CGFloat + /// Multiplies the thickness profile. + var thicknessScale: CGFloat + /// Vertical centre of the layer, as a fraction of the canvas height. + var verticalOffset: CGFloat + /// How far the lobe centres may wander, as a fraction of the width. + var lobeDrift: CGFloat + /// Base centre, width and weight of each Gaussian lobe. + var lobeCenters: [CGFloat] + var lobeWidths: [CGFloat] + var lobeWeights: [CGFloat] + /// Symmetric vertical bow of the centreline, as a fraction of layer height. + /// Zero at both ends, maximum in the middle: the opposite of a global slope, + /// so the composition never looks tilted. + var bowAmount: CGFloat + /// Zero-mean weave of the centreline along the layer, as a fraction of the + /// layer height. This is what makes the band travel up and down around a + /// horizontal axis - the __/\____ read - instead of drawing one flat lens. + var weaveAmount: CGFloat + /// Roughly how many times the centreline crosses its axis. Each layer picks a + /// different count so the three never collapse into one thick sine wave. + var weaveFrequency: CGFloat + /// 0 = follow the smoothed level, 1 = follow the delayed history. + var historyMix: CGFloat + /// Fraction of the layer height that survives in silence. + var idleAmplitude: CGFloat +} + +extension AuroraLayerSpec { + /// Layer A - atmosphere. Wide, cool, slow and highly transparent. It gives + /// the mass depth and is allowed to grow more on one side than the other. + static let atmosphere = AuroraLayerSpec( + phaseOffset: 0.0, + speed: 0.34, + driftDirection: 1, + widthScale: 1.06, + heightScale: 1.00, + thicknessScale: 0.78, + verticalOffset: -0.14, + lobeDrift: 0.058, + lobeCenters: [0.18, 0.50, 0.82], + lobeWidths: [0.20, 0.15, 0.18], + lobeWeights: [0.70, 0.95, 0.62], + bowAmount: 0.10, + weaveAmount: 0.40, + weaveFrequency: 1.35, + historyMix: 0.22, + idleAmplitude: 0.38 + ) + + /// Layer B - the voice body. Brightest, organic, and the layer that follows + /// the microphone level most directly. + static let mass = AuroraLayerSpec( + phaseOffset: 1.7, + speed: 1.0, + driftDirection: 1, + widthScale: 1.0, + heightScale: 1.00, + thicknessScale: 1.20, + verticalOffset: 0.0, + lobeDrift: 0.05, + lobeCenters: [0.25, 0.52, 0.79], + lobeWidths: [0.105, 0.095, 0.11], + lobeWeights: [0.88, 1.0, 0.82], + bowAmount: 0.0, + weaveAmount: 0.22, + weaveFrequency: 2.15, + historyMix: 0.72, + idleAmplitude: 0.44 + ) + + /// Layer C - the accent. Thin, offset, drifting the other way. It reveals the + /// depth of the motion; it is never a progress line. + static let accent = AuroraLayerSpec( + phaseOffset: 3.4, + speed: 1.38, + driftDirection: -1, + widthScale: 0.88, + heightScale: 0.70, + thicknessScale: 0.58, + verticalOffset: 0.15, + lobeDrift: 0.072, + lobeCenters: [0.28, 0.55, 0.76], + lobeWidths: [0.09, 0.07, 0.10], + lobeWeights: [0.75, 1.0, 0.68], + bowAmount: 0.12, + weaveAmount: 0.55, + weaveFrequency: 3.10, + historyMix: 0.90, + idleAmplitude: 0.26 + ) +} + +/// Pure geometry for one Aurora layer at one phase. +/// +/// Everything is a function of the samples and the phase, so the shape only ever +/// moves because the voice or the slow drift moved. No random source anywhere. +struct AuroraLayerGeometry { + let rect: CGRect + let spec: AuroraLayerSpec + let samples: [CGFloat] + let level: CGFloat + let phase: CGFloat + /// Global movement budget. Multiplies displacement only, never the voice. + var motion: CGFloat = 1 + + func upperPoint(at step: Int) -> CGPoint { + let frame = self.frame(at: step) + return CGPoint(x: frame.x, y: frame.y - frame.upper) + } + + func lowerPoint(at step: Int) -> CGPoint { + let frame = self.frame(at: step) + return CGPoint(x: frame.x, y: frame.y + frame.lower) + } + + /// Horizontal centre of each lobe, used by the localised bloom. + func lobeCenters() -> [CGFloat] { + let drift = self.spec.lobeDrift * self.spec.driftDirection * self.motion + let phase = self.phase * self.spec.speed + return self.spec.lobeCenters.enumerated().map { index, base in + let offset = drift * sin( + phase * (0.30 + 0.06 * CGFloat(index)) + + self.spec.phaseOffset + + CGFloat(index) * 1.9 + ) + return min(max(base + offset, 0.06), 0.94) + } + } + + private func layerRect() -> CGRect { + let width = self.rect.width * self.spec.widthScale + let height = self.rect.height * self.spec.heightScale + return CGRect( + x: self.rect.midX - width / 2, + y: self.rect.midY + self.rect.height * self.spec.verticalOffset - height / 2, + width: width, + height: height + ) + } + + private func frame(at step: Int) -> (x: CGFloat, y: CGFloat, upper: CGFloat, lower: CGFloat) { + let t = CGFloat(step) / CGFloat(max(AuroraLayerSpec.steps - 1, 1)) + let layer = self.layerRect() + let halfHeight = layer.height / 2 + + // Blunt-ended window. A window that reaches zero pinches the outline into + // the two points of a lens, which is exactly the wavy-line read we are + // moving away from; keeping a floor makes the silhouette a rounded mass. + let window = 0.42 + 0.58 * pow(sin(t * CGFloat.pi), 0.55) + + let lobes = self.lobeProfile(at: t) + + // Newest speech sits at the head of the shape, older samples trail away. + let delayed = self.sample(t: t) + let voice = self.level * (1 - self.spec.historyMix) + delayed * self.spec.historyMix + let energy = min(max(voice * 1.12, 0), 1) + + // Idle keeps a recognisable silhouette at a whisper; the lobes then vary + // the thickness instead of flattening it. The lobe term stays deep so the + // outline keeps visible valleys rather than one convex blob. + let thickness = halfHeight * self.spec.thicknessScale + * window + * (self.spec.idleAmplitude + (1 - self.spec.idleAmplitude) * energy) + * (0.55 + 0.45 * lobes) + + // A symmetric bow keeps the centre of the mass exactly where it is, so + // the composition can never look globally tilted: it is zero at both ends + // and at its strongest in the middle, the opposite of a global slope. + let bow = self.spec.bowAmount * halfHeight * self.motion * sin(t * CGFloat.pi) + * sin(self.phase * self.spec.speed * 0.55 + self.spec.phaseOffset) + + // Zero-mean weave: the band travels above and below its centreline, so it + // reads as a ribbon crossing a horizontal axis rather than as one globally + // sloped line. The accent layer weaves the other way on purpose. + let weavePhase = self.phase * self.spec.speed + let weave = (self.spec.weaveAmount / 1.45) * halfHeight * self.motion * self.spec.driftDirection * ( + sin(t * CGFloat.pi * self.spec.weaveFrequency + weavePhase * 0.8 + self.spec.phaseOffset) + + 0.45 * sin( + t * CGFloat.pi * self.spec.weaveFrequency * 2.37 + + weavePhase * 0.5 + + self.spec.phaseOffset * 1.7 + ) + ) + + // A small, slowly reversing lean. Enough for the mass to feel alive, + // never enough for one side of the outline to read as heavier. + let lean = 0.5 + 0.06 * sin( + self.phase * self.spec.speed * 0.43 + t * 2.4 + self.spec.phaseOffset + ) + + return ( + x: layer.minX + layer.width * t, + y: layer.midY + bow + weave, + upper: thickness * lean, + lower: thickness * (1 - lean) + ) + } + + /// Sum of the Gaussian lobes, clamped to 0 ... 1. + private func lobeProfile(at t: CGFloat) -> CGFloat { + let centers = self.lobeCenters() + var total: CGFloat = 0 + for index in 0.. CGFloat { + guard self.samples.count > 1 else { return self.level } + let position = t * CGFloat(self.samples.count - 1) + let lower = Int(position.rounded(.down)) + let upper = min(lower + 1, self.samples.count - 1) + let fraction = position - CGFloat(lower) + let start = self.samples[max(lower, 0)] + let end = self.samples[upper] + return start + (end - start) * fraction + } +} + +/// One closed organic layer, drawn from AuroraLayerGeometry. +struct AuroraLayerShape: Shape { + var spec: AuroraLayerSpec + var samples: [CGFloat] + var level: CGFloat + var phase: CGFloat + var motion: CGFloat = 1 + + func path(in rect: CGRect) -> Path { + let geometry = AuroraLayerGeometry( + rect: rect, + spec: self.spec, + samples: self.samples, + level: self.level, + phase: self.phase, + motion: self.motion + ) + let steps = AuroraLayerSpec.steps + var path = Path() + var previous: CGPoint? + + for step in 0.. CGPoint { + CGPoint(x: (first.x + second.x) / 2, y: (first.y + second.y) / 2) + } +} + +/// The full Aurora composition for one phase. +/// +/// Split out of AuroraVisualizer so the Settings miniature can render exactly the +/// same stack - no timeline, no audio - and therefore cannot drift away from what +/// the overlay actually draws. +struct AuroraComposition: View { + let palette: OverlayPalette + let glow: OverlayGlowIntensity + let samples: [CGFloat] + let level: CGFloat + let phase: CGFloat + /// Global movement budget. Multiplies displacement only, never the voice. + var motion: CGFloat = 1 + /// The bloom is skipped by the static Settings miniature, where it would be + /// pure cost on a 76 x 34 card. + var showsBloom: Bool = true + + var body: some View { + let energy = min(max(self.level, 0), 1) + let glowScale = CGFloat(self.glow.visualizerGlowScale) + let bloom = min(0.60 * self.glow.visualizerGlowScale, 0.85) + + ZStack { + // Layer A - atmosphere. A cool, wide ribbon above the mass, seen + // through it rather than painted behind it. + AuroraLayerShape(spec: .atmosphere, samples: self.samples, level: energy, phase: self.phase, motion: self.motion) + .fill(self.palette.atmosphereGradient) + .blur(radius: max(2.6 * glowScale, 0.4)) + .opacity(0.34 + 0.24 * Double(energy)) + + // Layer B - soft bloom of the voice body. + AuroraLayerShape(spec: .mass, samples: self.samples, level: energy, phase: self.phase, motion: self.motion) + .fill(self.palette.ribbonGradient) + .blur(radius: max(2.2 * glowScale, 0.3)) + .opacity(0.30 + 0.28 * Double(energy)) + + // Layer B - the mass itself. Held a little under full opacity so the + // cool atmosphere still shows through it: that overlap is what reads + // as translucent layers rather than one painted band. + AuroraLayerShape(spec: .mass, samples: self.samples, level: energy, phase: self.phase, motion: self.motion) + .fill(self.palette.ribbonGradient) + .opacity(0.86) + + // Brighter core, so the middle reads as a light source rather than a + // uniformly filled band. + AuroraLayerShape(spec: .mass, samples: self.samples, level: energy, phase: self.phase, motion: self.motion) + .fill(Color.white.opacity(0.16)) + .scaleEffect(y: 0.56) + .blur(radius: 1.5) + .opacity(0.26 + 0.58 * Double(energy)) + + // Layer C - thin warm accent below the mass, drifting against it. + AuroraLayerShape(spec: .accent, samples: self.samples, level: energy, phase: self.phase, motion: self.motion) + .fill(self.palette.accentLayerGradient) + .blur(radius: 1.0) + .opacity(0.38 + 0.45 * Double(energy)) + + if self.showsBloom { + self.lobeBloom(phase: self.phase, energy: energy, opacity: bloom) + } + } + } + + /// Localised light where the lobes are - not a uniform halo around + /// everything. Without it the glow follows the whole silhouette equally, + /// which is the flat look the reference sheet avoids. + private func lobeBloom(phase: CGFloat, energy: CGFloat, opacity: Double) -> some View { + Canvas(rendersAsynchronously: false) { context, size in + guard opacity > 0.001, size.width > 1, size.height > 1 else { return } + let geometry = AuroraLayerGeometry( + rect: CGRect(origin: .zero, size: size), + spec: .mass, + samples: [], + level: energy, + phase: phase + ) + let centers = geometry.lobeCenters() + let radius = max(size.width * 0.22, 6) + for index in centers.indices { + let weight = AuroraLayerSpec.mass.lobeWeights[index] + let alpha = min(opacity * Double(weight) * (0.35 + 0.65 * Double(energy)), 0.80) + guard alpha > 0.005 else { continue } + let center = CGPoint(x: centers[index] * size.width, y: size.height / 2) + let rect = CGRect( + x: center.x - radius, + y: center.y - radius, + width: radius * 2, + height: radius * 2 + ) + let shading = GraphicsContext.Shading.radialGradient( + Gradient(colors: [ + self.palette.highlight.opacity(alpha), + self.palette.highlight.opacity(0), + ]), + center: center, + startRadius: 0, + endRadius: radius + ) + context.fill(Path(ellipseIn: rect), with: shading) + } + } + .blur(radius: 3.0) + .allowsHitTesting(false) + } +} + +/// Aurora visualizer: a soft multilayer mass of light with a brighter core and a +/// warm ember at its head. +struct AuroraVisualizer: View { + let level: CGFloat + let isActive: Bool + let palette: OverlayPalette + let glow: OverlayGlowIntensity + let reduceMotion: Bool + let noiseThreshold: CGFloat + /// Global movement budget from Settings. + let motion: MotionIntensity + + @State private var follower: AudioEnvelopeFollower + + /// Optional seeded energy for offscreen previews and tests. Production call + /// sites never pass it, so the live path is unchanged: at runtime the + /// follower is fed by the microphone level the engine already publishes. + init( + level: CGFloat, + isActive: Bool, + palette: OverlayPalette, + glow: OverlayGlowIntensity, + reduceMotion: Bool, + noiseThreshold: CGFloat, + motion: MotionIntensity = .normal, + previewLevel: CGFloat? = nil + ) { + self.level = level + self.isActive = isActive + self.palette = palette + self.glow = glow + self.reduceMotion = reduceMotion + self.noiseThreshold = noiseThreshold + self.motion = motion + self._follower = State( + initialValue: previewLevel.map(AudioEnvelopeFollower.preview) ?? AudioEnvelopeFollower() + ) + } + + var body: some View { + Group { + if self.reduceMotion { + // Reduce Motion: keep the gradient and the audio response, drop + // the continuous drift entirely. + AuroraComposition( + palette: self.palette, + glow: self.glow, + samples: self.follower.history, + level: self.follower.smoothed, + phase: 0.8, + motion: self.motion.scale + ) + } else { + TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: !self.isActive)) { timeline in + AuroraComposition( + palette: self.palette, + glow: self.glow, + samples: self.follower.history, + level: self.follower.smoothed, + phase: Self.phase(for: timeline.date), + motion: self.motion.scale + ) + } + } + } + .onChange(of: self.level) { _, newLevel in + self.follower.update( + with: AudioEnvelopeFollower.normalized(newLevel, noiseThreshold: self.noiseThreshold) + ) + } + .onChange(of: self.isActive) { _, active in + guard !active else { return } + self.follower.reset() + } + } + + /// Slow drift so the mass never looks like a static graphic. It is far + /// slower than the audio response, so the voice always drives the motion the + /// user actually reads. + private static func phase(for date: Date) -> CGFloat { + let seconds = date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 240) + return CGFloat(seconds) * 1.05 + } +} diff --git a/Sources/Fluid/UI/Overlay/MinimalVisualizer.swift b/Sources/Fluid/UI/Overlay/MinimalVisualizer.swift new file mode 100644 index 000000000..1dbe1bde2 --- /dev/null +++ b/Sources/Fluid/UI/Overlay/MinimalVisualizer.swift @@ -0,0 +1,176 @@ +// +// MinimalVisualizer.swift +// Fluid +// +// Minimal style: a restrained monochrome dot matrix. +// + +import SwiftUI + +/// Shared geometry for the Minimal dot row. +/// +/// The live visualizer and the Settings miniature both read their dot count, +/// radius bounds and taper from here, so the picture shown in Settings can +/// never drift away from what the overlay actually draws. +enum MinimalDotGeometry { + static let dotCount = 9 + static let minimumRadius: CGFloat = 1.1 + static let maximumRadius: CGFloat = 3.4 + /// How strongly the ends of the row are damped relative to the centre. + static let endTaperScale: CGFloat = 0.45 + static let minimumTaper: CGFloat = 0.35 + + /// Fewest dots worth drawing, used by the smallest canvases. + static let minimumDotCount = 5 + /// Roughly how much width one dot plus its gap needs. + private static let preferredDotPitch: CGFloat = 5.6 + + /// Dots that suit a canvas of the given width. + /// + /// The row stays the same graphic in every format; only its density adapts, + /// so the circular orb gets a few calm dots instead of nine cramped ones. + static func dotCount(forWidth width: CGFloat) -> Int { + guard width > 0 else { return MinimalDotGeometry.dotCount } + let fitted = Int((width / MinimalDotGeometry.preferredDotPitch).rounded(.down)) + return min(max(fitted, MinimalDotGeometry.minimumDotCount), MinimalDotGeometry.dotCount) + } + + /// 0 ... 1 weight that softens the outer dots. + static func taper(at index: Int, count: Int = MinimalDotGeometry.dotCount) -> CGFloat { + let centerDistance = abs(CGFloat(index) - CGFloat(count - 1) / 2) + let maxDistance = max(CGFloat(count - 1) / 2, 1) + return max(MinimalDotGeometry.minimumTaper, 1 - (centerDistance / maxDistance) * MinimalDotGeometry.endTaperScale) + } + + /// Dot radius for a 0 ... 1 energy sample. + static func radius(energy: CGFloat, at index: Int, count: Int = MinimalDotGeometry.dotCount) -> CGFloat { + let tapered = min(max(energy * MinimalDotGeometry.taper(at: index, count: count), 0), 1) + return MinimalDotGeometry.minimumRadius + + (MinimalDotGeometry.maximumRadius - MinimalDotGeometry.minimumRadius) * tapered + } + + /// Static stand-in envelope used by the Settings miniature: a calm hump, as + /// when the user is speaking at a normal level. + static let previewEnergies: [CGFloat] = (0.. CGPoint { + let slot = size.width / CGFloat(max(count, 1)) + return CGPoint(x: slot * (CGFloat(index) + 0.5), y: size.height / 2) + } + + private func dotRadius(at index: Int, count: Int) -> CGFloat { + MinimalDotGeometry.radius(energy: self.dotEnergy(at: index, count: count), at: index, count: count) + } + + /// Reads the shared delay line so the dots ripple with the voice instead of + /// flickering. The taper towards the ends of the row lives in + /// `MinimalDotGeometry` so the Settings miniature shares it. + private func dotEnergy(at index: Int, count: Int) -> CGFloat { + let historySpan = max(AudioEnvelopeFollower.historyLength - 1, 1) + let step = max(count - 1, 1) + let sample = self.follower.delayedSample(at: index * historySpan / step) + return min(max(sample, 0), 1) + } +} diff --git a/Sources/Fluid/UI/Overlay/NotchHaloRim.swift b/Sources/Fluid/UI/Overlay/NotchHaloRim.swift new file mode 100644 index 000000000..86a450a21 --- /dev/null +++ b/Sources/Fluid/UI/Overlay/NotchHaloRim.swift @@ -0,0 +1,131 @@ +// +// NotchHaloRim.swift +// Fluid +// +// Light that lives under the notch cutout. +// + +import SwiftUI + +/// The themed light under the notch. +/// +/// Three stacked pieces, all drawn inside the notch content area, so the +/// DynamicNotchKit presentation shape is never touched: +/// +/// 1. a wide, very soft diffuse glow that spills left and right of the cutout's +/// underside - the impression that the notch itself emits a little light; +/// 2. a short warm ember in the middle, the minority hue of the reference; +/// 3. the hairline rim that hugs the cutout, faded towards the top so it reads as +/// light coming out from under the black shape rather than a ring around it. +/// +/// Nothing here animates on its own: every opacity follows the audio level the +/// notch already publishes. +struct NotchHaloRim: View { + let palette: OverlayPalette + let glow: OverlayGlowIntensity + let level: CGFloat + + /// Corner radius of the content it hugs. + var cornerRadius: CGFloat = 9 + + var body: some View { + ZStack { + // Wide cool glow under the cutout. + Ellipse() + .fill( + RadialGradient( + colors: [ + self.palette.secondary.opacity(self.underGlowOpacity), + self.palette.secondary.opacity(0), + ], + center: .center, + startRadius: 0, + endRadius: 44 + ) + ) + .frame(height: 16) + .offset(y: 5) + .blur(radius: max(5.0 * CGFloat(self.glow.visualizerGlowScale), 1.5)) + .allowsHitTesting(false) + + // Warm ember, kept a minority of the composition. + Ellipse() + .fill( + RadialGradient( + colors: [ + self.palette.accent.opacity(self.underGlowOpacity * 0.85), + self.palette.accent.opacity(0), + ], + center: .center, + startRadius: 0, + endRadius: 20 + ) + ) + .frame(width: 52, height: 11) + .offset(y: 6) + .blur(radius: 4) + .allowsHitTesting(false) + + // The hairline that hugs the cutout. Cyan at both ends, the voice + // hues through the middle, so it belongs to the same palette as the + // premium styles. + RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous) + .strokeBorder( + LinearGradient( + colors: [ + self.palette.tertiary.opacity(self.rimOpacity), + self.palette.primary.opacity(self.rimOpacity), + self.palette.highlight.opacity(self.rimOpacity), + self.palette.tertiary.opacity(self.rimOpacity), + ], + startPoint: .leading, + endPoint: .trailing + ), + lineWidth: self.rimWidth + ) + .blur(radius: self.blurRadius) + .mask( + // Fade the rim out towards the top: only the underside reads. + LinearGradient( + stops: [ + .init(color: .clear, location: 0.00), + .init(color: .clear, location: 0.42), + .init(color: .white.opacity(0.55), location: 0.78), + .init(color: .white, location: 1.00), + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .allowsHitTesting(false) + } + } + + /// Bright enough to actually read on the black cutout, still bounded so it + /// never turns into a glowing ring around the notch. + static func rimOpacity(glow: OverlayGlowIntensity, level: CGFloat) -> Double { + let base = 0.55 * glow.visualizerGlowScale + let voiced = 0.80 + 0.55 * Double(min(max(level, 0), 1)) + return min(base * voiced, 0.85) + } + + private var rimOpacity: Double { + Self.rimOpacity(glow: self.glow, level: self.level) + } + + /// The wide diffuse glow stays fainter than the rim: it is atmosphere, not a + /// second light source. It still follows the voice so the notch feels alive. + private var underGlowOpacity: Double { + let base = 0.30 * self.glow.visualizerGlowScale + let voiced = 0.70 + 0.60 * Double(min(max(self.level, 0), 1)) + return min(base * voiced, 0.55) + } + + private var rimWidth: CGFloat { + max(self.glow.borderWidth * 1.15, 0.9) + } + + private var blurRadius: CGFloat { + max(self.glow.auraBlurRadius * 0.28, 1.2) + } +} diff --git a/Sources/Fluid/UI/Overlay/NotchStyleVisualizer.swift b/Sources/Fluid/UI/Overlay/NotchStyleVisualizer.swift new file mode 100644 index 000000000..3c97f0ca0 --- /dev/null +++ b/Sources/Fluid/UI/Overlay/NotchStyleVisualizer.swift @@ -0,0 +1,95 @@ +// +// NotchStyleVisualizer.swift +// Fluid +// +// Bridges the notch presentation to the premium visual styles. +// + +import Combine +import SwiftUI + +/// Draws the selected overlay style inside the notch. +/// +/// The notch has its own audio publisher and never feeds +/// `NotchContentState.bottomOverlayAudioLevel` (that value belongs to the +/// floating pill), so this small adapter subscribes to the publisher the notch +/// already receives. No second capture path is created: it is the same stream +/// `CompactNotchWaveformView` has always consumed. +/// +/// The surface is always dark - the area around a hardware notch is black +/// whatever the system appearance is - so the styles keep their contrast. +struct NotchStyleVisualizer: View { + let audioPublisher: AnyPublisher + let canvasSize: CGSize + + @StateObject private var data: AudioVisualizationData + @ObservedObject private var settings = SettingsStore.shared + @ObservedObject private var contentState = NotchContentState.shared + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + init(audioPublisher: AnyPublisher, canvasSize: CGSize) { + self.audioPublisher = audioPublisher + self.canvasSize = canvasSize + _data = StateObject(wrappedValue: AudioVisualizationData(audioLevelPublisher: audioPublisher)) + } + + var body: some View { + OverlayVisualizerView( + style: self.settings.overlayVisualStyle, + palette: self.settings.overlayColorTheme.palette, + glow: self.settings.overlayGlowIntensity, + level: self.data.audioLevel, + lifecycle: self.lifecycle, + isActive: true, + reduceMotion: self.reduceMotion, + noiseThreshold: 0.05, + barCount: Self.barCount(forWidth: self.canvasSize.width), + barWidth: Self.barWidth, + barSpacing: Self.barSpacing, + canvasSize: self.canvasSize, + surface: .dark, + motion: self.settings.overlayMotionIntensity, + companionVariant: self.settings.companionVariant, + companionAccessories: self.settings.companionAccessories, + hasTranscription: !self.contentState.cachedPreviewText.isEmpty + ) + .frame(width: self.canvasSize.width, height: self.canvasSize.height) + .background( + NotchHaloRim( + palette: self.settings.overlayColorTheme.palette, + glow: self.settings.overlayGlowIntensity, + level: self.data.audioLevel + ) + .padding(.horizontal, -3) + .padding(.bottom, -2) + ) + } + + /// The notch is only on screen while a session runs, so it shows the + /// recording styles and, once the microphone stops, the same processing ring + /// the floating pill uses. + private var lifecycle: OverlayLifecycleState { + self.contentState.isProcessing ? .processing : .recording + } + + /// Notch canvas the compact body reserves for the visualizer. + static let canvasWidth: CGFloat = 48 + static let canvasHeight: CGFloat = 18 + /// Eight bars at this width and spacing stay inside the canvas above; a wider + /// row would overflow the notch body. + static let barCount = 8 + + /// Bars that suit a canvas, so the compact row and the wider Ambient Glow + /// body both read as a full row instead of a lonely cluster. + static func barCount(forWidth width: CGFloat) -> Int { + let fitted = Int((width / 4.5).rounded(.down)) + return min(max(fitted, 6), 16) + } + static let barWidth: CGFloat = 2.5 + static let barSpacing: CGFloat = 2.0 + + /// Width the Wave style needs for the parameters above. + static var waveRowWidth: CGFloat { + CGFloat(Self.barCount) * Self.barWidth + CGFloat(max(Self.barCount - 1, 0)) * Self.barSpacing + } +} diff --git a/Sources/Fluid/UI/Overlay/OrbitalGlow.swift b/Sources/Fluid/UI/Overlay/OrbitalGlow.swift new file mode 100644 index 000000000..73c4abd91 --- /dev/null +++ b/Sources/Fluid/UI/Overlay/OrbitalGlow.swift @@ -0,0 +1,118 @@ +// +// OrbitalGlow.swift +// Fluid +// +// Subtle orbital aura that traces the pill outline. +// + +import SwiftUI + +/// A hairline themed border with one slow highlight travelling around it. +/// +/// This is deliberately not a rainbow outline. The gradient is dominated by the +/// theme palette with a single brighter zone, the orbit speed is fixed, and the +/// audio level modulates opacity only - never the rotation. When Reduce Motion +/// is on, or the overlay is off screen, the angle is pinned and the timeline +/// stops entirely. +struct OrbitalGlow: View { + let cornerRadius: CGFloat + let palette: OverlayPalette + let glow: OverlayGlowIntensity + let level: CGFloat + let isActive: Bool + let reduceMotion: Bool + /// Minimal style keeps the ring monochrome to stay sober. + var isMonochrome: Bool = false + /// Briefly brightens the aura on the completion flash. + var isCompleting: Bool = false + + /// Seconds per revolution. Slow enough to read as ambient light. + static let revolutionSeconds: Double = 5.5 + + var body: some View { + Group { + if self.reduceMotion || !self.isActive { + self.ring(angle: 0) + } else { + TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: !self.isActive)) { timeline in + self.ring(angle: Self.angle(for: timeline.date)) + } + } + } + .allowsHitTesting(false) + } + + /// Deterministic angle in 0..<360 for a point in time. + /// + /// Wrapping every revolution keeps the value small over long uptimes and is + /// visually seamless: 0 degrees and 360 degrees are the same rotation. + static func angle(for date: Date) -> Double { + let seconds = date.timeIntervalSinceReferenceDate + let withinRevolution = seconds.truncatingRemainder(dividingBy: Self.revolutionSeconds) + return withinRevolution / Self.revolutionSeconds * 360 + } + + private func ring(angle: Double) -> some View { + let shape = RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous) + let base = self.glow.auraOpacity(forLevel: self.level) + // "Brief brightening of the halo" on completion, still bounded so it can + // never flare. + let opacity = self.isCompleting ? min(base * 1.9 + 0.10, 0.60) : base + + return ZStack { + shape + .strokeBorder(self.borderGradient(angle: angle), lineWidth: self.glow.auraStrokeWidth) + .blur(radius: self.glow.auraBlurRadius) + .opacity(opacity) + + shape + .strokeBorder(self.borderGradient(angle: angle), lineWidth: self.glow.borderWidth) + .opacity(min(opacity * 3.2, 0.9)) + } + } + + private func borderGradient(angle: Double) -> AngularGradient { + if self.isCompleting { + return AngularGradient( + gradient: Gradient(stops: [ + .init(color: OverlayPalette.success.opacity(0.35), location: 0.00), + .init(color: OverlayPalette.success.opacity(1.00), location: 0.20), + .init(color: self.palette.tertiary.opacity(0.75), location: 0.50), + .init(color: OverlayPalette.success.opacity(0.90), location: 0.78), + .init(color: OverlayPalette.success.opacity(0.35), location: 1.00), + ]), + center: .center, + angle: .degrees(angle) + ) + } + + if self.isMonochrome { + return AngularGradient( + gradient: Gradient(stops: [ + .init(color: Color.white.opacity(0.04), location: 0.00), + .init(color: Color.white.opacity(0.62), location: 0.12), + .init(color: Color.white.opacity(0.30), location: 0.34), + .init(color: Color.white.opacity(0.06), location: 0.62), + .init(color: Color.white.opacity(0.22), location: 0.82), + .init(color: Color.white.opacity(0.04), location: 1.00), + ]), + center: .center, + angle: .degrees(angle) + ) + } + + return AngularGradient( + gradient: Gradient(stops: [ + .init(color: self.palette.primary.opacity(0.04), location: 0.00), + .init(color: self.palette.primary.opacity(0.85), location: 0.10), + .init(color: self.palette.secondary.opacity(0.95), location: 0.26), + .init(color: self.palette.tertiary.opacity(0.80), location: 0.42), + .init(color: self.palette.primary.opacity(0.20), location: 0.62), + .init(color: self.palette.secondary.opacity(0.30), location: 0.80), + .init(color: self.palette.primary.opacity(0.04), location: 1.00), + ]), + center: .center, + angle: .degrees(angle) + ) + } +} diff --git a/Sources/Fluid/UI/Overlay/OverlayAnchorGeometry.swift b/Sources/Fluid/UI/Overlay/OverlayAnchorGeometry.swift new file mode 100644 index 000000000..2e2425f01 --- /dev/null +++ b/Sources/Fluid/UI/Overlay/OverlayAnchorGeometry.swift @@ -0,0 +1,103 @@ +// +// OverlayAnchorGeometry.swift +// Fluid +// +// Pure placement math for the floating recording pill. +// + +import CoreGraphics + +extension SettingsStore.OverlayPosition { + /// Transparent margin the pill view reserves around itself for its own drop + /// shadow and aura. Keep in sync with the padding applied in + /// BottomOverlayView.body and with PillShadowMetrics.hitTestInset. + static let pillCanvasPadding: CGFloat = 26 + + /// Gap between the visible pill and the top or bottom screen edge. + static let verticalEdgeInset: CGFloat = 10 + /// Gap between the visible pill and the left or right screen edge. + static let horizontalEdgeInset: CGFloat = 16 + /// Legacy buffers kept from the original bottom-overlay clamping. + static let lowerSafetyBuffer: CGFloat = 10 + static let upperSafetyBuffer: CGFloat = 40 + + /// Screen origin for the panel that carries this anchor. + /// + /// - Parameters: + /// - windowSize: full panel size, including the transparent canvas padding. + /// - screenFrame: the target screen's full frame, used for centering. + /// - visibleFrame: the target screen's visible frame, which already + /// excludes the menu bar, the Dock and the notch area. + /// - bottomOffset: the user's bottom offset preference. It drives the + /// bottom-centered anchor exactly as it did before. + /// - canvasPadding: transparent margin included in `windowSize`. + func windowOrigin( + windowSize: CGSize, + screenFrame: CGRect, + visibleFrame: CGRect, + bottomOffset: CGFloat, + canvasPadding: CGFloat + ) -> CGPoint { + let x = self.horizontalOrigin( + windowSize: windowSize, + screenFrame: screenFrame, + visibleFrame: visibleFrame, + canvasPadding: canvasPadding + ) + let y = self.verticalOrigin( + windowSize: windowSize, + visibleFrame: visibleFrame, + bottomOffset: bottomOffset, + canvasPadding: canvasPadding + ) + return CGPoint(x: x, y: y) + } + + private func horizontalOrigin( + windowSize: CGSize, + screenFrame: CGRect, + visibleFrame: CGRect, + canvasPadding: CGFloat + ) -> CGFloat { + switch self { + case .topCenter, .bottomCenter: + // Preserved verbatim from the original bottom overlay placement. + return screenFrame.midX - windowSize.width / 2 + case .topLeft, .bottomLeft: + return visibleFrame.minX + Self.horizontalEdgeInset - canvasPadding + case .topRight, .bottomRight: + return visibleFrame.maxX - Self.horizontalEdgeInset + canvasPadding - windowSize.width + } + } + + private func verticalOrigin( + windowSize: CGSize, + visibleFrame: CGRect, + bottomOffset: CGFloat, + canvasPadding: CGFloat + ) -> CGFloat { + if self.isBottomAnchored { + // Pin the bottom edge of the *visible* overlay, not the transparent + // shadow canvas around it. That canvas padding scales with the user + // scale, so anchoring the window instead of the content made the pill + // float away from the bottom edge whenever the scale slider moved - + // the diagonal glide the polish pass reported. + let contentHeight = max(windowSize.height - canvasPadding * 2, 0) + let minimumBottom = visibleFrame.minY + Self.lowerSafetyBuffer + let maximumBottom = max( + visibleFrame.maxY - Self.upperSafetyBuffer - contentHeight, + minimumBottom + ) + let contentBottom = min(max(visibleFrame.minY + bottomOffset, minimumBottom), maximumBottom) + return contentBottom - canvasPadding + } + + // Top anchors pin the top edge, so the overlay grows downwards exactly as + // it always did: land the visible pill a few points below the menu bar, + // the way a small macOS status indicator sits. + let maximumY = visibleFrame.maxY - Self.verticalEdgeInset + canvasPadding - windowSize.height + let minimumY = visibleFrame.minY + Self.lowerSafetyBuffer + let preferredY = maximumY + return min(max(preferredY, minimumY), max(maximumY, minimumY)) + } +} diff --git a/Sources/Fluid/UI/Overlay/OverlayColorTheme.swift b/Sources/Fluid/UI/Overlay/OverlayColorTheme.swift new file mode 100644 index 000000000..797ab4017 --- /dev/null +++ b/Sources/Fluid/UI/Overlay/OverlayColorTheme.swift @@ -0,0 +1,366 @@ +// +// OverlayColorTheme.swift +// Fluid +// +// Centralized color, glow and appearance definitions for the recording overlay. +// +// Every overlay surface (visualizer, orbital aura, hairline border, accent) +// resolves its colors from a single palette so the themes never drift apart. +// + +import SwiftUI + +/// Resolved colors for one overlay theme. +struct OverlayPalette: Equatable { + let primary: Color + let secondary: Color + let tertiary: Color + /// Bright second highlight in the middle of the spectrum (magenta/pink for + /// Aurora). It is what keeps the flagship theme from collapsing into "Blue": + /// the layered Aurora draws it between the violet body and the warm accent. + let highlight: Color + /// Warm highlight used sparingly, never as a dominant hue. + let accent: Color + /// Stored so per-frame drawing never rebuilds the array. + let gradientColors: [Color] + + init(primary: Color, secondary: Color, tertiary: Color, highlight: Color, accent: Color) { + self.primary = primary + self.secondary = secondary + self.tertiary = tertiary + self.highlight = highlight + self.accent = accent + self.gradientColors = [primary, secondary, tertiary] + } + + /// Fixed completion tint. Deliberately not themed: a successful delivery + /// reads as green in the reference concept sheet whatever the color theme. + static let success = Color(hex: "#34C759") ?? Color.green + + /// Gradient that runs left to right, used by the ribbon and the rings. + var horizontalGradient: LinearGradient { + LinearGradient(colors: self.gradientColors, startPoint: .leading, endPoint: .trailing) + } + + /// Gradient that runs bottom to top, used by the bar visualizers. + var verticalGradient: LinearGradient { + LinearGradient(colors: self.gradientColors, startPoint: .bottom, endPoint: .top) + } + + /// Aurora mass gradient: cyan, blue, violet, magenta, a narrow warm band, + /// then back to cyan. Every hue the reference sheet shows is present, and the + /// transparency of the layers lets them mix on screen instead of reading as + /// one flat colour ramp. + var ribbonGradient: LinearGradient { + LinearGradient( + gradient: Gradient(stops: [ + .init(color: self.tertiary, location: 0.00), + .init(color: self.secondary, location: 0.22), + .init(color: self.primary, location: 0.44), + .init(color: self.highlight, location: 0.62), + .init(color: self.accent, location: 0.80), + .init(color: self.tertiary, location: 1.00), + ]), + startPoint: .leading, + endPoint: .trailing + ) + } + + /// Atmosphere gradient for the outermost Aurora layer: cool hues only, so the + /// wide background mass reads as depth rather than as a second voice body. + var atmosphereGradient: LinearGradient { + LinearGradient( + gradient: Gradient(stops: [ + .init(color: self.secondary.opacity(0.85), location: 0.00), + .init(color: self.tertiary.opacity(0.95), location: 0.38), + .init(color: self.primary.opacity(0.90), location: 0.70), + .init(color: self.secondary.opacity(0.85), location: 1.00), + ]), + startPoint: .leading, + endPoint: .trailing + ) + } + + /// Ordered hues used by the Wave row, so the theme's whole spectrum is + /// visible across the bars instead of one flat hue. The first and last entry + /// match, which lets the row close back on itself in cyan. + var spectrumColors: [Color] { + [self.tertiary, self.secondary, self.primary, self.highlight, self.accent, self.tertiary] + } + + /// Nearest spectrum hue for a 0 ... 1 position. Deliberately cheap: it runs + /// once per bar on every frame. + func spectrumColor(at fraction: CGFloat) -> Color { + let colors = self.spectrumColors + guard colors.count > 1 else { return colors.first ?? self.primary } + let clamped = min(max(fraction, 0), 1) + let index = Int((clamped * CGFloat(colors.count - 1)).rounded()) + return colors[min(max(index, 0), colors.count - 1)] + } + + /// Thin accent-layer gradient: magenta and warm light over a cyan tail. It is + /// deliberately a minority of the composition. + var accentLayerGradient: LinearGradient { + LinearGradient( + gradient: Gradient(stops: [ + .init(color: self.tertiary.opacity(0.70), location: 0.00), + .init(color: self.highlight, location: 0.40), + .init(color: self.accent, location: 0.68), + .init(color: self.highlight.opacity(0.70), location: 1.00), + ]), + startPoint: .leading, + endPoint: .trailing + ) + } +} + +/// Color themes shared by the visualizers and the orbital aura. +enum OverlayColorTheme: String, CaseIterable, Codable, Identifiable { + case aurora + case blue + case purple + case green + case orange + /// One user-chosen colour expanded into a full palette. + case custom + + /// Used whenever a persisted or imported value cannot be resolved. + static let fallback: OverlayColorTheme = .aurora + + /// Near-black pill surface. Deliberately not pure black so the border reads. + static let surfaceHex = "#07080A" + + var id: String { + self.rawValue + } + + var displayName: String { + switch self { + case .aurora: return "Aurora" + case .blue: return "Blue" + case .purple: return "Purple" + case .green: return "Green" + case .orange: return "Orange" + case .custom: return "Custom" + } + } + + /// Resolved palette. Backed by a cache because it is read on every audio + /// tick while the overlay is drawn. + var palette: OverlayPalette { + if self == .custom { + return OverlayPalette.derive(fromHex: OverlayCustomTheme.hex) + ?? Self.cachedPalettes[.aurora] + ?? Self.makePalette(for: .aurora) + } + return Self.cachedPalettes[self] ?? Self.makePalette(for: self) + } + + /// Small swatch rendered in the settings chips. + var swatchColors: [Color] { + self.palette.gradientColors + } + + private static let cachedPalettes: [OverlayColorTheme: OverlayPalette] = Dictionary( + uniqueKeysWithValues: OverlayColorTheme.allCases + .filter { $0 != .custom } + .map { theme in + (theme, OverlayColorTheme.makePalette(for: theme)) + } + ) + + private static func makePalette(for theme: OverlayColorTheme) -> OverlayPalette { + switch theme { + case .aurora: + // The flagship theme is the only one that intentionally crosses the + // colour wheel: cyan, blue, violet, magenta and a warm ember. + return OverlayPalette( + primary: Self.color("#8B5CF6", fallback: .purple), + secondary: Self.color("#4D7CFE", fallback: .blue), + tertiary: Self.color("#38D6E8", fallback: .cyan), + highlight: Self.color("#FF5FA2", fallback: .pink), + accent: Self.color("#FF9A5B", fallback: .orange) + ) + case .blue: + return OverlayPalette( + primary: Self.color("#1E40AF", fallback: .blue), + secondary: Self.color("#3B82F6", fallback: .blue), + tertiary: Self.color("#22D3EE", fallback: .cyan), + highlight: Self.color("#7DD3FC", fallback: .cyan), + accent: Self.color("#93C5FD", fallback: .blue) + ) + case .purple: + return OverlayPalette( + primary: Self.color("#6D28D9", fallback: .purple), + secondary: Self.color("#A855F7", fallback: .purple), + tertiary: Self.color("#C084FC", fallback: .purple), + highlight: Self.color("#E879F9", fallback: .pink), + accent: Self.color("#F5A9DC", fallback: .pink) + ) + case .green: + return OverlayPalette( + primary: Self.color("#0F766E", fallback: .teal), + secondary: Self.color("#10B981", fallback: .green), + tertiary: Self.color("#34D399", fallback: .green), + highlight: Self.color("#A7F3D0", fallback: .mint), + accent: Self.color("#6EE7A8", fallback: .green) + ) + case .orange: + return OverlayPalette( + primary: Self.color("#C2410C", fallback: .orange), + secondary: Self.color("#F59E0B", fallback: .orange), + tertiary: Self.color("#FDBA74", fallback: .orange), + highlight: Self.color("#FCD34D", fallback: .yellow), + accent: Self.color("#FFE0B2", fallback: .yellow) + ) + case .custom: + // Never reached through the cache; the resolved palette is derived + // from the user colour so the fallback stays the branded default. + return OverlayPalette.derive(fromHex: OverlayCustomTheme.hex) + ?? OverlayPalette( + primary: Self.color("#8B5CF6", fallback: .purple), + secondary: Self.color("#4D7CFE", fallback: .blue), + tertiary: Self.color("#38D6E8", fallback: .cyan), + highlight: Self.color("#FF5FA2", fallback: .pink), + accent: Self.color("#FF9A5B", fallback: .orange) + ) + } + } + + private static func color(_ hex: String, fallback: Color) -> Color { + Color(hex: hex) ?? fallback + } +} + +/// How strongly the overlay glows. Subtle is genuinely restrained; Vivid stays +/// premium instead of saturating into a gaming look. +/// Advanced glow fine tuning applied on top of the Subtle / Normal / Vivid +/// preset, exactly like `OverlayCustomTheme` mirrors the custom colour: the +/// overlay components read the resolved values without depending on the +/// settings store. `SettingsStore` keeps it in sync on read and write. +enum OverlayGlowTuning { + static let range: ClosedRange = 0.5...1.5 + static let neutral: Double = 1.0 + nonisolated(unsafe) static var strength: Double = OverlayGlowTuning.neutral + + /// Reads are clamped so a corrupt preference cannot flare the overlay. + static var clampedStrength: Double { + min(max(strength, range.lowerBound), range.upperBound) + } +} + +enum OverlayGlowIntensity: String, CaseIterable, Codable, Identifiable { + case subtle + case normal + case vivid + + /// Used whenever a persisted or imported value cannot be resolved. + static let fallback: OverlayGlowIntensity = .normal + + var id: String { + self.rawValue + } + + var displayName: String { + switch self { + case .subtle: return "Subtle" + case .normal: return "Normal" + case .vivid: return "Vivid" + } + } + + /// Opacity of the orbital aura while the user is silent. + var auraBaseOpacity: Double { + let base: Double + switch self { + case .subtle: base = 0.10 + case .normal: base = 0.16 + case .vivid: base = 0.19 + } + // Bounded so even Vivid at 150% stays a gentle light rather than a flare. + return min(base * OverlayGlowTuning.clampedStrength, 0.30) + } + + /// The hairline border stays close to one point at every intensity. + /// + /// The advanced strength moves it far less than the aura: a hairline that + /// scales linearly stops reading as a hairline. + var borderWidth: CGFloat { + let base: CGFloat + switch self { + case .subtle: base = 0.9 + case .normal: base = 1.0 + case .vivid: base = 1.15 + } + let adjustment = (CGFloat(OverlayGlowTuning.clampedStrength) - 1) * 0.35 + return max(base + adjustment, 0.5) + } + + var auraStrokeWidth: CGFloat { + let base: CGFloat + switch self { + case .subtle: base = 2.4 + case .normal: base = 3.2 + case .vivid: base = 4.2 + } + return base * CGFloat(OverlayGlowTuning.clampedStrength) + } + + var auraBlurRadius: CGFloat { + let base: CGFloat + switch self { + case .subtle: base = 4.0 + case .normal: base = 6.5 + case .vivid: base = 9.0 + } + return base * CGFloat(OverlayGlowTuning.clampedStrength) + } + + /// Multiplier applied to the soft glow behind the visualizers. + var visualizerGlowScale: Double { + let base: Double + switch self { + case .subtle: base = 0.5 + case .normal: base = 1.0 + case .vivid: base = 1.5 + } + return base * OverlayGlowTuning.clampedStrength + } + + /// Aura opacity for a normalized 0...1 audio level. + /// + /// The target is roughly 10-15% in silence, 18-22% at normal speech and + /// 25-30% at a vocal peak. Volume modulates intensity only - never the + /// speed at which the highlight travels around the capsule. + func auraOpacity(forLevel level: CGFloat) -> Double { + let clamped = Double(min(max(level, 0), 1)) + let modulated = self.auraBaseOpacity * (0.85 + 0.85 * clamped) + return min(max(modulated, 0), 0.42) + } +} + +/// The overlay appearance settings resolved into one value. +struct OverlayAppearance: Equatable { + let style: OverlayVisualStyle + let theme: OverlayColorTheme + let glow: OverlayGlowIntensity + let showsTargetAppIcon: Bool + let surface: OverlaySurfaceAppearance + + static let fallback = OverlayAppearance( + style: .fallback, + theme: .fallback, + glow: .fallback, + showsTargetAppIcon: true, + surface: .fallback + ) + + var palette: OverlayPalette { + self.theme.palette + } + + /// Surface weights for the current system appearance. + func surfaceStyle(systemIsDark: Bool) -> OverlaySurfaceStyle { + OverlaySurfaceStyle.resolve(isLight: self.surface.resolvesToLight(systemIsDark: systemIsDark)) + } +} diff --git a/Sources/Fluid/UI/Overlay/OverlayLifecycleState.swift b/Sources/Fluid/UI/Overlay/OverlayLifecycleState.swift new file mode 100644 index 000000000..5831a3952 --- /dev/null +++ b/Sources/Fluid/UI/Overlay/OverlayLifecycleState.swift @@ -0,0 +1,62 @@ +// +// OverlayLifecycleState.swift +// Fluid +// +// Overlay lifecycle derived from signals the dictation pipeline already publishes. +// + +import Foundation + +/// Visual phase of the floating overlay. +/// +/// Every case maps onto a state the pipeline genuinely reports. Nothing here is +/// driven by an artificial timer, so the overlay can never claim to be +/// "processing" while the engine is idle, or "done" before text was injected. +enum OverlayLifecycleState: Equatable { + /// No overlay on screen. + case hidden + /// The microphone is live and the visualizer tracks the real input level. + case recording + /// Capture stopped and the engine is still refining or delivering the text. + case processing + /// The existing error surface is showing; the accent turns warm red. + case error + /// The pipeline confirmed delivery and a short completion flash is playing. + case completed + + /// Resolves the phase from the published overlay state. + /// + /// - Parameters: + /// - isPresented: the overlay panel is on screen. + /// - isReleaseTransitioning: the hotkey was released and the final pass is running. + /// - isProcessing: the pipeline reports AI or transcription post-processing. + /// - hasProcessingFailure: the existing AI failure surface is visible. + /// - isDeliveryCompleted: the pipeline confirmed the text was delivered. + static func resolve( + isPresented: Bool, + isReleaseTransitioning: Bool, + isProcessing: Bool, + hasProcessingFailure: Bool, + isDeliveryCompleted: Bool = false + ) -> OverlayLifecycleState { + guard isPresented else { return .hidden } + if isDeliveryCompleted { + return .completed + } + if hasProcessingFailure, !isProcessing { + return .error + } + if isProcessing || isReleaseTransitioning { + return .processing + } + return .recording + } + + /// True while the audio-reactive visualizer should be drawn. + var showsVisualizer: Bool { + switch self { + case .recording, .hidden: return true + case .processing, .error, .completed: return false + } + } +} diff --git a/Sources/Fluid/UI/Overlay/OverlayProcessingRing.swift b/Sources/Fluid/UI/Overlay/OverlayProcessingRing.swift new file mode 100644 index 000000000..8376ef86e --- /dev/null +++ b/Sources/Fluid/UI/Overlay/OverlayProcessingRing.swift @@ -0,0 +1,74 @@ +// +// OverlayProcessingRing.swift +// Fluid +// +// Themed ring shown while the engine is still refining or delivering text. +// + +import SwiftUI + +/// Replaces the generic system spinner with a themed gradient arc. +/// +/// It only exists while the overlay is on screen and Reduce Motion is off, so +/// nothing keeps redrawing once the pill has been parked. +struct OverlayProcessingRing: View { + let palette: OverlayPalette + let glow: OverlayGlowIntensity + let isActive: Bool + let reduceMotion: Bool + /// Minimal style keeps the ring monochrome to stay sober. + var isMonochrome: Bool = false + + /// Seconds per revolution for the working arc. + static let revolutionSeconds: Double = 1.2 + + var body: some View { + Group { + if self.reduceMotion || !self.isActive { + self.ring(angle: 0) + } else { + TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: !self.isActive)) { timeline in + self.ring(angle: Self.angle(for: timeline.date)) + } + } + } + .allowsHitTesting(false) + } + + /// Deterministic angle in 0..<360, wrapped every revolution. + static func angle(for date: Date) -> Double { + let seconds = date.timeIntervalSinceReferenceDate + let withinRevolution = seconds.truncatingRemainder(dividingBy: Self.revolutionSeconds) + return withinRevolution / Self.revolutionSeconds * 360 + } + + private func ring(angle: Double) -> some View { + ZStack { + Circle() + .strokeBorder(self.trackColor.opacity(0.18), lineWidth: 1.6) + + Circle() + .trim(from: 0, to: 0.34) + .stroke(self.track, style: StrokeStyle(lineWidth: 1.8, lineCap: .round)) + .rotationEffect(.degrees(angle)) + + Circle() + .trim(from: 0, to: 0.34) + .stroke(self.track, style: StrokeStyle(lineWidth: 2.6, lineCap: .round)) + .rotationEffect(.degrees(angle)) + .blur(radius: 2.6 * CGFloat(self.glow.visualizerGlowScale)) + .opacity(0.55 * self.glow.visualizerGlowScale) + } + } + + private var trackColor: Color { + self.isMonochrome ? Color.white : self.palette.primary + } + + private var track: AngularGradient { + let colors = self.isMonochrome + ? [Color.white.opacity(0.25), Color.white.opacity(0.95)] + : [self.palette.primary, self.palette.secondary, self.palette.tertiary] + return AngularGradient(gradient: Gradient(colors: colors), center: .center) + } +} diff --git a/Sources/Fluid/UI/Overlay/OverlaySettingsControls.swift b/Sources/Fluid/UI/Overlay/OverlaySettingsControls.swift new file mode 100644 index 000000000..0febc2b27 --- /dev/null +++ b/Sources/Fluid/UI/Overlay/OverlaySettingsControls.swift @@ -0,0 +1,244 @@ +// +// OverlaySettingsControls.swift +// Fluid +// +// Self-contained controls for the Overlay settings section. +// + +import SwiftUI + +/// One selectable style card with a live miniature of the style. +struct OverlayStyleCard: View { + let style: OverlayVisualStyle + let theme: OverlayColorTheme + let glow: OverlayGlowIntensity + let surface: OverlaySurfaceAppearance + let isSelected: Bool + let action: () -> Void + + @Environment(\.colorScheme) private var colorScheme + @State private var isHovered = false + + var body: some View { + Button(action: self.action) { + VStack(alignment: .leading, spacing: 8) { + OverlayStyleThumbnail( + style: self.style, + palette: self.theme.palette, + glow: self.glow, + surface: OverlaySurfaceStyle.resolve( + isLight: self.surface.resolvesToLight(systemIsDark: self.colorScheme == .dark) + ) + ) + + VStack(alignment: .leading, spacing: 1) { + Text(self.style.displayName) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.primary) + Text(self.style.summary) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(self.fillColor) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(self.borderColor, lineWidth: self.isSelected ? 1.5 : 1) + ) + .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + .buttonStyle(.plain) + .onHover { self.isHovered = $0 } + .accessibilityLabel(self.style.displayName) + .accessibilityAddTraits(self.isSelected ? [.isSelected] : []) + } + + private var fillColor: Color { + if self.isSelected { + return Color.accentColor.opacity(0.14) + } + return Color.primary.opacity(self.isHovered ? 0.06 : 0.03) + } + + private var borderColor: Color { + self.isSelected ? Color.accentColor.opacity(0.65) : Color.primary.opacity(0.10) + } +} + +/// One color theme chip with a gradient swatch. +struct OverlayThemeChip: View { + let theme: OverlayColorTheme + let isSelected: Bool + let action: () -> Void + + @State private var isHovered = false + + var body: some View { + Button(action: self.action) { + VStack(spacing: 6) { + ZStack { + Circle() + .fill( + LinearGradient( + colors: self.theme.swatchColors, + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 20, height: 20) + + if self.isSelected { + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .shadow(color: .black.opacity(0.55), radius: 1) + } + } + .padding(3) + .overlay( + Circle() + .strokeBorder(self.borderColor, lineWidth: self.isSelected ? 2 : 1) + ) + + Text(self.theme.displayName) + .font(.system(size: 10, weight: self.isSelected ? .semibold : .regular)) + .foregroundStyle(self.isSelected ? Color.primary : Color.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(self.isSelected ? Color.accentColor.opacity(0.10) : Color.primary.opacity(self.isHovered ? 0.05 : 0)) + ) + .contentShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) + } + .buttonStyle(.plain) + .onHover { self.isHovered = $0 } + .accessibilityLabel(self.theme.displayName) + .accessibilityAddTraits(self.isSelected ? [.isSelected] : []) + } + + private var borderColor: Color { + self.isSelected ? Color.accentColor.opacity(0.85) : Color.primary.opacity(0.12) + } +} + +/// Segmented Subtle / Normal / Vivid control. +struct OverlayGlowPicker: View { + @Binding var selection: OverlayGlowIntensity + + var body: some View { + HStack(spacing: 4) { + ForEach(OverlayGlowIntensity.allCases, id: \.self) { intensity in + OverlayGlowChip(intensity: intensity, isSelected: self.selection == intensity) { + self.selection = intensity + } + } + } + .padding(3) + .background( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(Color.primary.opacity(0.05)) + ) + } +} + +private struct OverlayGlowChip: View { + let intensity: OverlayGlowIntensity + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: self.action) { + Text(self.intensity.displayName) + .font(.system(size: 11, weight: self.isSelected ? .semibold : .regular)) + .foregroundStyle(self.isSelected ? Color.primary : Color.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(self.isSelected ? Color.primary.opacity(0.14) : Color.clear) + ) + .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityLabel(self.intensity.displayName) + .accessibilityAddTraits(self.isSelected ? [.isSelected] : []) + } +} + +/// 2 x 3 anchor grid drawn as miniature screens. +struct OverlayAnchorGrid: View { + @Binding var selection: SettingsStore.OverlayPosition + + private static let rows: [[SettingsStore.OverlayPosition]] = [ + [.topLeft, .topCenter, .topRight], + [.bottomLeft, .bottomCenter, .bottomRight], + ] + + var body: some View { + VStack(spacing: 6) { + ForEach(Self.rows.indices, id: \.self) { rowIndex in + HStack(spacing: 6) { + ForEach(Self.rows[rowIndex], id: \.self) { anchor in + OverlayAnchorButton(anchor: anchor, isSelected: self.selection == anchor) { + self.selection = anchor + } + } + } + } + } + } +} + +/// One anchor button, drawn as a tiny screen with the pill at that corner. +private struct OverlayAnchorButton: View { + let anchor: SettingsStore.OverlayPosition + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: self.action) { + ZStack { + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(Color.primary.opacity(0.05)) + + Capsule() + .fill(self.isSelected ? Color.accentColor : Color.primary.opacity(0.35)) + .frame(width: 20, height: 6) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: self.alignment) + .padding(4) + + RoundedRectangle(cornerRadius: 5, style: .continuous) + .strokeBorder( + self.isSelected ? Color.accentColor.opacity(0.8) : Color.primary.opacity(0.14), + lineWidth: self.isSelected ? 1.6 : 1 + ) + } + .frame(height: 34) + .contentShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) + } + .buttonStyle(.plain) + .help(self.anchor.displayName) + .accessibilityLabel(self.anchor.displayName) + .accessibilityAddTraits(self.isSelected ? [.isSelected] : []) + } + + private var alignment: Alignment { + switch self.anchor { + case .topLeft: return .topLeading + case .topCenter: return .top + case .topRight: return .topTrailing + case .bottomLeft: return .bottomLeading + case .bottomCenter: return .bottom + case .bottomRight: return .bottomTrailing + } + } +} diff --git a/Sources/Fluid/UI/Overlay/OverlayStyleThumbnail.swift b/Sources/Fluid/UI/Overlay/OverlayStyleThumbnail.swift new file mode 100644 index 000000000..d9b0f5725 --- /dev/null +++ b/Sources/Fluid/UI/Overlay/OverlayStyleThumbnail.swift @@ -0,0 +1,153 @@ +// +// OverlayStyleThumbnail.swift +// Fluid +// +// Static miniature used by the Overlay settings style picker. +// + +import SwiftUI + +/// Frozen preview of a style so the settings picker reads at a glance. +/// +/// Everything here is static: no audio subscription, no timeline and no state, +/// so a grid of four thumbnails costs nothing while Settings is open. +struct OverlayStyleThumbnail: View { + let style: OverlayVisualStyle + let palette: OverlayPalette + let glow: OverlayGlowIntensity + /// Surface the miniature is drawn on, so the card matches the real overlay. + var surface: OverlaySurfaceStyle = .dark + + static let size = CGSize(width: 76, height: 34) + + /// Smooth hump used as a stand-in envelope for the Aurora ribbon. + private static let auroraSamples: [CGFloat] = (0.. some View { + Canvas(rendersAsynchronously: false) { context, size in + let neutral = GraphicsContext.Shading.color(self.surface.primaryText) + let tint = GraphicsContext.Shading.color(self.palette.primary.opacity(0.20)) + let slot = size.width / CGFloat(max(radii.count, 1)) + for (index, radius) in radii.enumerated() { + let center = CGPoint(x: slot * (CGFloat(index) + 0.5), y: size.height / 2) + let rect = CGRect( + x: center.x - radius, + y: center.y - radius, + width: radius * 2, + height: radius * 2 + ) + context.fill(Path(ellipseIn: rect), with: neutral) + context.fill(Path(ellipseIn: rect), with: tint) + } + } + } + + /// Themed bar row, shared by the Wave preview. + private func bars(heights: [CGFloat], width: CGFloat, spacing: CGFloat) -> some View { + Canvas(rendersAsynchronously: false) { context, size in + let fill: GraphicsContext.Shading = .linearGradient( + Gradient(colors: self.palette.gradientColors), + startPoint: CGPoint(x: 0, y: size.height), + endPoint: CGPoint(x: 0, y: 0) + ) + let totalWidth = CGFloat(heights.count) * width + CGFloat(max(heights.count - 1, 0)) * spacing + let originX = (size.width - totalWidth) / 2 + for (index, value) in heights.enumerated() { + let barHeight = max(2, size.height * 0.78 * value) + let rect = CGRect( + x: originX + CGFloat(index) * (width + spacing), + y: size.height / 2 - barHeight / 2, + width: width, + height: barHeight + ) + context.fill(Path(roundedRect: rect, cornerRadius: width / 2), with: fill) + } + } + } +} diff --git a/Sources/Fluid/UI/Overlay/OverlaySurface.swift b/Sources/Fluid/UI/Overlay/OverlaySurface.swift new file mode 100644 index 000000000..cc233cbfc --- /dev/null +++ b/Sources/Fluid/UI/Overlay/OverlaySurface.swift @@ -0,0 +1,179 @@ +// +// OverlaySurface.swift +// Fluid +// +// Surface appearance (dark / light) and the custom theme derivation. +// + +import AppKit +import SwiftUI + +/// Which surface the overlay is drawn on. +/// +/// Automatic follows the system appearance; Dark and Light force one. The +/// visualizer keeps its saturated palette in both cases - only the surface and +/// the foreground weights move, so a light overlay stays a light *glass*, never +/// a clinical white card. +enum OverlaySurfaceAppearance: String, CaseIterable, Codable, Identifiable { + case automatic + case dark + case light + + static let fallback: OverlaySurfaceAppearance = .automatic + + var id: String { self.rawValue } + + var displayName: String { + switch self { + case .automatic: return "Automatic" + case .dark: return "Dark" + case .light: return "Light" + } + } + + /// Resolves the effective surface for the current system appearance. + func resolvesToLight(systemIsDark: Bool) -> Bool { + switch self { + case .automatic: return !systemIsDark + case .dark: return false + case .light: return true + } + } +} + +/// Resolved surface colours and weights. +/// +/// Kept separate from `OverlayPalette` on purpose: the palette describes the +/// light the overlay *emits*, this describes the surface it sits on. +struct OverlaySurfaceStyle: Equatable { + let isLight: Bool + /// Opaque-ish surface colour. The caller applies its own translucency. + let fill: Color + let primaryText: Color + let secondaryText: Color + let shadowOpacity: Double + let sheenOpacity: Double + /// Border and hairline opacity multiplier: hairlines need more contrast on a + /// light surface to stay visible. + let borderScale: Double + /// True when there is no surface at all and the desktop shows through. + /// + /// Monochrome styles cannot pick a neutral that works on both a light and a + /// dark desktop, so in this case they fall back to the theme's brightest hue + /// instead of chasing the system appearance. + var isChromeless: Bool = false + + static let dark = OverlaySurfaceStyle( + isLight: false, + fill: Color(hex: "#07080A") ?? Color.black, + primaryText: Color.white.opacity(0.90), + secondaryText: Color.white.opacity(0.62), + shadowOpacity: 0.32, + sheenOpacity: 0.055, + borderScale: 1.0 + ) + + static let light = OverlaySurfaceStyle( + isLight: true, + // Very light grey rather than white: the pill should read as frosted + // glass over the user's window, not as a blank card. + fill: Color(hex: "#E7E9EE") ?? Color(white: 0.91), + primaryText: Color.black.opacity(0.86), + secondaryText: Color.black.opacity(0.55), + shadowOpacity: 0.18, + sheenOpacity: 0.42, + borderScale: 1.45 + ) + + /// Used by the chromeless format, where the underlying pixels are unknown. + static let chromeless = OverlaySurfaceStyle( + isLight: false, + fill: Color.clear, + primaryText: Color.white.opacity(0.92), + secondaryText: Color.white.opacity(0.62), + shadowOpacity: 0, + sheenOpacity: 0, + borderScale: 1.0, + isChromeless: true + ) + + static func resolve(isLight: Bool) -> OverlaySurfaceStyle { + isLight ? .light : .dark + } +} + +/// Hex of the user-chosen primary colour for the Custom theme. +/// +/// Held as a small mirror so the overlay components can resolve a palette +/// without depending on the full settings store. `SettingsStore` keeps it in +/// sync whenever the preference is read or written. +enum OverlayCustomTheme { + static let defaultHex = "#8B5CF6" + nonisolated(unsafe) static var hex: String = OverlayCustomTheme.defaultHex +} + +extension OverlayPalette { + /// Builds a harmonious four-colour palette from a single primary colour. + /// + /// The user picks one colour and the variations are derived in HSB, so the + /// result keeps the same hue family instead of asking for four choices. + static func derive(fromHex hex: String) -> OverlayPalette? { + guard let base = NSColor(hex: hex) else { return nil } + var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + base.usingColorSpace(.sRGB)?.getHue(&h, saturation: &s, brightness: &b, alpha: &a) + guard s > 0 || b > 0 else { return nil } + + func shifted(hue: CGFloat, saturation: CGFloat, brightness: CGFloat) -> Color { + var newHue = h + hue + newHue -= floor(newHue) // wrap into 0 ..< 1 + let color = NSColor( + hue: newHue, + saturation: min(max(s * saturation, 0), 1), + brightness: min(max(b * brightness, 0), 1), + alpha: 1 + ) + return Color(nsColor: color) + } + + return OverlayPalette( + primary: shifted(hue: 0.00, saturation: 1.00, brightness: 0.82), + secondary: shifted(hue: 0.06, saturation: 0.98, brightness: 1.06), + tertiary: shifted(hue: 0.14, saturation: 0.82, brightness: 1.20), + // A brighter neighbour on the warm side of the wheel. Without it a + // Custom theme would render every Aurora layer from the same narrow + // hue band and lose the multilayer read. + highlight: shifted(hue: 0.10, saturation: 1.02, brightness: 1.16), + // Opposite side of the wheel for a warm counterpoint, kept saturated + // so it stays a minority accent rather than a muddy tint. + accent: shifted(hue: -0.09, saturation: 1.05, brightness: 1.12) + ) + } +} + +extension Color { + /// `#RRGGBB` for the current colour, used to persist the Custom theme. + var overlayHexString: String { + guard let rgb = NSColor(self).usingColorSpace(.sRGB) else { return OverlayCustomTheme.defaultHex } + let r = Int((rgb.redComponent * 255).rounded()) + let g = Int((rgb.greenComponent * 255).rounded()) + let b = Int((rgb.blueComponent * 255).rounded()) + return String(format: "#%02X%02X%02X", r, g, b) + } +} + +private extension NSColor { + /// sRGB initialiser for a `#RRGGBB` string. + convenience init?(hex: String) { + var value = hex.trimmingCharacters(in: .whitespacesAndNewlines) + value = value.replacingOccurrences(of: "#", with: "") + guard value.count == 6 else { return nil } + var rgb: UInt64 = 0 + guard Scanner(string: value).scanHexInt64(&rgb) else { return nil } + self.init( + srgbRed: CGFloat((rgb & 0xFF0000) >> 16) / 255, + green: CGFloat((rgb & 0x00FF00) >> 8) / 255, + blue: CGFloat(rgb & 0x0000FF) / 255, + alpha: 1 + ) + } +} diff --git a/Sources/Fluid/UI/Overlay/OverlayVisualStyle.swift b/Sources/Fluid/UI/Overlay/OverlayVisualStyle.swift new file mode 100644 index 000000000..f61ac04dd --- /dev/null +++ b/Sources/Fluid/UI/Overlay/OverlayVisualStyle.swift @@ -0,0 +1,50 @@ +// +// OverlayVisualStyle.swift +// Fluid +// +// Visual styles available to the floating recording overlay. +// + +import SwiftUI + +/// The visual treatment used to draw live audio feedback inside the recording overlay. +/// +/// A style only changes how the microphone level the ASR engine already publishes +/// is *rendered*. It never changes when audio is captured, when the engine starts +/// or stops, or when transcribed text is injected. +enum OverlayVisualStyle: String, CaseIterable, Codable, Identifiable { + case minimal + case aurora + case wave + case pulse + /// The animated Companion character. It is a style, so it works in every + /// format and follows the theme colours like the others. + case companion + + /// Used whenever a persisted or imported value cannot be resolved. + static let fallback: OverlayVisualStyle = .aurora + + var id: String { + self.rawValue + } + + var displayName: String { + switch self { + case .minimal: return "Minimal" + case .aurora: return "Aurora" + case .wave: return "Wave" + case .pulse: return "Pulse" + case .companion: return "Companion" + } + } + + var summary: String { + switch self { + case .minimal: return "Sober monochrome level meter" + case .aurora: return "Organic light ribbon with an orbital aura" + case .wave: return "Symmetric waveform around the center line" + case .pulse: return "Calm ring that breathes with your voice" + case .companion: return "Animated companion that reacts to your voice" + } + } +} diff --git a/Sources/Fluid/UI/Overlay/OverlayVisualizerView.swift b/Sources/Fluid/UI/Overlay/OverlayVisualizerView.swift new file mode 100644 index 000000000..45d814f1d --- /dev/null +++ b/Sources/Fluid/UI/Overlay/OverlayVisualizerView.swift @@ -0,0 +1,187 @@ +// +// OverlayVisualizerView.swift +// Fluid +// +// Dispatches the live level to the component that draws the selected style. +// + +import SwiftUI + +/// Renders the selected overlay style inside a fixed canvas. +/// +/// The view is a pure consumer of state the dictation pipeline already owns: the +/// damped microphone level and the resolved lifecycle phase. It never starts +/// audio, never waits for the engine and never delays the pill. +struct OverlayVisualizerView: View { + let style: OverlayVisualStyle + let palette: OverlayPalette + let glow: OverlayGlowIntensity + let level: CGFloat + let lifecycle: OverlayLifecycleState + let isActive: Bool + let reduceMotion: Bool + let noiseThreshold: CGFloat + let barCount: Int + let barWidth: CGFloat + let barSpacing: CGFloat + let canvasSize: CGSize + /// Resolved surface, so monochrome styles stay legible in Light mode. + var surface: OverlaySurfaceStyle = .dark + /// Seeded energy for offscreen previews and tests. Inert at runtime. + var previewLevel: CGFloat? = nil + /// Global movement budget, forwarded to the Aurora style. + var motion: MotionIntensity = .normal + /// Companion element (material/palette). Only read by the Companion style. + var companionVariant: CompanionVariant = .standard + /// Companion accessories. Only read by the Companion style. + var companionAccessories: Set = [] + /// Whether a partial already exists. Feeds the Companion "typing" face. + var hasTranscription: Bool = false + + var body: some View { + Group { + if self.style == .companion { + // The Companion expresses the phase itself (listening, thinking, + // completed, error), so it is drawn in every phase instead of + // being replaced by the completion check or the processing ring. + self.companionBody + .transition(.opacity) + } else if self.lifecycle == .completed { + OverlayCompletionCheck(glow: self.glow) + .frame(width: self.ringSide, height: self.ringSide) + .transition(.opacity) + } else if self.lifecycle == .processing { + OverlayProcessingRing( + palette: self.palette, + glow: self.glow, + isActive: self.isActive, + reduceMotion: self.reduceMotion, + isMonochrome: self.style == .minimal + ) + .frame(width: self.ringSide, height: self.ringSide) + .transition(.opacity) + } else { + self.styleBody + .transition(.opacity) + } + } + .frame(width: self.canvasSize.width, height: self.canvasSize.height) + .animation(self.reduceMotion ? nil : .easeInOut(duration: 0.22), value: self.lifecycle) + } + + private var ringSide: CGFloat { + max(self.canvasSize.height * 0.74, 12) + } + + @ViewBuilder + private var styleBody: some View { + switch self.style { + case .minimal: + MinimalVisualizer( + level: self.level, + isActive: self.isActive, + palette: self.palette, + glow: self.glow, + noiseThreshold: self.noiseThreshold, + surface: self.surface, + previewLevel: self.previewLevel + ) + case .aurora: + AuroraVisualizer( + level: self.level, + isActive: self.isActive, + palette: self.palette, + glow: self.glow, + reduceMotion: self.reduceMotion, + noiseThreshold: self.noiseThreshold, + motion: self.motion, + previewLevel: self.previewLevel + ) + case .wave: + WaveVisualizer( + level: self.level, + isActive: self.isActive, + palette: self.palette, + glow: self.glow, + noiseThreshold: self.noiseThreshold, + barCount: self.barCount, + barWidth: self.barWidth, + barSpacing: self.barSpacing, + previewLevel: self.previewLevel + ) + case .pulse: + PulseVisualizer( + level: self.level, + isActive: self.isActive, + palette: self.palette, + glow: self.glow, + noiseThreshold: self.noiseThreshold, + reduceMotion: self.reduceMotion, + previewLevel: self.previewLevel + ) + case .companion: + self.companionBody + } + } + + @ViewBuilder + private var companionBody: some View { + CompanionVisualizer( + state: Self.companionState(for: self.lifecycle, hasTranscription: self.hasTranscription), + variant: self.companionVariant, + themePalette: self.palette, + glow: self.glow, + accessories: self.companionAccessories, + level: self.level, + isActive: self.isActive, + reduceMotion: self.reduceMotion, + motion: self.motion, + // A seeded preview freezes the Companion's phase; at runtime it runs + // from its own timeline. + previewPhase: self.previewLevel == nil ? nil : 1.2 + ) + } + + /// Lifecycle to expression. The pipeline already publishes these phases, so + /// the Companion needs no state of its own. + static func companionState( + for lifecycle: OverlayLifecycleState, + hasTranscription: Bool + ) -> CompanionState { + switch lifecycle { + case .hidden: return .idle + case .recording: return hasTranscription ? .typing : .listening + case .processing: return .thinking + case .completed: return .completed + case .error: return .error + } + } +} + +/// Brief completion flash: a small white check inside a softly glowing disc. +/// +/// It only appears on the success path, and the caller keeps the dwell bounded +/// and cancellable so it can never delay dictation or text delivery. +struct OverlayCompletionCheck: View { + let glow: OverlayGlowIntensity + + var body: some View { + GeometryReader { proxy in + let side = min(proxy.size.width, proxy.size.height) + ZStack { + Circle() + .fill(OverlayPalette.success) + .shadow( + color: OverlayPalette.success.opacity(0.5), + radius: 3.5 * CGFloat(self.glow.visualizerGlowScale) + ) + + Image(systemName: "checkmark") + .font(.system(size: max(side * 0.54, 7), weight: .bold)) + .foregroundStyle(.white) + } + .frame(width: side, height: side) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} diff --git a/Sources/Fluid/UI/Overlay/PillPreviewSizing.swift b/Sources/Fluid/UI/Overlay/PillPreviewSizing.swift new file mode 100644 index 000000000..4eb3d824f --- /dev/null +++ b/Sources/Fluid/UI/Overlay/PillPreviewSizing.swift @@ -0,0 +1,39 @@ +// +// PillPreviewSizing.swift +// Fluid +// +// Pure width math for the live preview that grows the pill while dictating. +// + +import Foundation + +/// Estimates how wide the compact pill must become to show a live preview. +/// +/// The pill has to grow smoothly rather than jump, and it must stay small, so the +/// width is derived from the text with a pure function instead of a layout +/// measurement pass. That keeps the value testable and avoids a feedback loop +/// between SwiftUI layout and the window resize path. +enum PillPreviewSizing { + /// Hard cap so the preview never turns the pill into a text box. + static let maxWidth: CGFloat = 150 + /// Below this the preview is not worth the extra width. + static let minUsefulWidth: CGFloat = 34 + /// Horizontal breathing room added around the text. + static let horizontalPadding: CGFloat = 14 + + /// Estimated width of a single character for the system font size in use. + static func characterWidth(forFontSize fontSize: CGFloat) -> CGFloat { + max(fontSize * 0.54, 1) + } + + /// Width to reserve for the preview, or 0 when it should stay hidden. + static func width(for text: String, fontSize: CGFloat, characterLimit: Int) -> CGFloat { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, characterLimit > 0 else { return 0 } + + let visibleCount = min(trimmed.count, characterLimit) + let estimated = CGFloat(visibleCount) * self.characterWidth(forFontSize: fontSize) + self.horizontalPadding + guard estimated >= self.minUsefulWidth else { return 0 } + return min(estimated, self.maxWidth) + } +} diff --git a/Sources/Fluid/UI/Overlay/PulseVisualizer.swift b/Sources/Fluid/UI/Overlay/PulseVisualizer.swift new file mode 100644 index 000000000..f30c9ebb2 --- /dev/null +++ b/Sources/Fluid/UI/Overlay/PulseVisualizer.swift @@ -0,0 +1,196 @@ +// +// PulseVisualizer.swift +// Fluid +// +// Pulse style: a calm ring that breathes with the voice, wrapped in a slow +// circular Aurora wave. +// + +import SwiftUI + +/// Circular level indicator. +/// +/// Two ideas stacked: the breathing ring Pulse always had, and a thin wavy ring +/// that turns around it like a small circular Aurora. Both follow the damped +/// envelope, and the wave is drawn with the theme spectrum and rotated by its +/// own phase, so it genuinely changes colour as it travels. +struct PulseVisualizer: View { + let level: CGFloat + let isActive: Bool + let palette: OverlayPalette + let glow: OverlayGlowIntensity + let noiseThreshold: CGFloat + let reduceMotion: Bool + + @State private var follower: AudioEnvelopeFollower + + /// Seeded energy for offscreen previews and tests. Inert at runtime. + init( + level: CGFloat, + isActive: Bool, + palette: OverlayPalette, + glow: OverlayGlowIntensity, + noiseThreshold: CGFloat, + reduceMotion: Bool = false, + previewLevel: CGFloat? = nil + ) { + self.level = level + self.isActive = isActive + self.palette = palette + self.glow = glow + self.noiseThreshold = noiseThreshold + self.reduceMotion = reduceMotion + self._follower = State( + initialValue: previewLevel.map(AudioEnvelopeFollower.preview) ?? AudioEnvelopeFollower() + ) + } + + private var energy: CGFloat { + min(max(self.follower.smoothed, 0), 1) + } + + var body: some View { + GeometryReader { proxy in + let energy = self.energy + let glowScale = CGFloat(self.glow.visualizerGlowScale) + // Every dimension is a fraction of the shortest side, so the style + // fits the pill, the orb and the chromeless canvas alike. + let unit = max(min(proxy.size.width, proxy.size.height), 8) + + ZStack { + self.rings(unit: unit, energy: energy, glowScale: glowScale) + self.wave(unit: unit, energy: energy, glowScale: glowScale) + } + .frame(width: proxy.size.width, height: proxy.size.height) + } + .animation(.easeOut(duration: 0.18), value: self.energy) + .onChange(of: self.level) { _, newLevel in + self.follower.update( + with: AudioEnvelopeFollower.normalized(newLevel, noiseThreshold: self.noiseThreshold) + ) + } + .onChange(of: self.isActive) { _, active in + guard !active else { return } + self.follower.reset() + } + } + + // MARK: - Breathing rings + + @ViewBuilder + private func rings(unit: CGFloat, energy: CGFloat, glowScale: CGFloat) -> some View { + ZStack { + // Outer diffuse layer: the ring's light bleeding into the surface. + Circle() + .strokeBorder( + self.palette.secondary.opacity(0.14 + 0.30 * Double(energy)), + lineWidth: unit * (0.05 + 0.07 * energy) + ) + .frame(width: unit * (0.74 + 0.42 * energy), height: unit * (0.74 + 0.42 * energy)) + .blur(radius: max(3.0 * glowScale, 0.6)) + + // Sharp core. + Circle() + .strokeBorder( + self.palette.horizontalGradient, + lineWidth: unit * (0.045 + 0.05 * energy) + ) + .frame(width: unit * (0.46 + 0.30 * energy), height: unit * (0.46 + 0.30 * energy)) + .shadow( + color: self.palette.tertiary.opacity(0.18 + 0.34 * Double(energy)), + radius: 3 * glowScale + ) + + // One localised highlight, lit from one side. + Circle() + .trim(from: 0.04, to: 0.28) + .stroke( + self.palette.highlight.opacity(0.30 + 0.50 * Double(energy)), + style: StrokeStyle(lineWidth: unit * (0.06 + 0.05 * energy), lineCap: .round) + ) + .frame(width: unit * (0.46 + 0.30 * energy), height: unit * (0.46 + 0.30 * energy)) + .rotationEffect(.degrees(-118)) + + // Null point. + Circle() + .fill(self.palette.primary.opacity(0.85)) + .frame( + width: max(unit * (0.13 + 0.11 * energy), 2.5), + height: max(unit * (0.13 + 0.11 * energy), 2.5) + ) + } + } + + // MARK: - Circular Aurora wave + + @ViewBuilder + private func wave(unit: CGFloat, energy: CGFloat, glowScale: CGFloat) -> some View { + if self.reduceMotion { + // Reduce Motion: keep the coloured wave, drop the travel. + self.waveRing(unit: unit, energy: energy, glowScale: glowScale, phase: 0.8) + } else { + TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: !self.isActive)) { timeline in + self.waveRing( + unit: unit, + energy: energy, + glowScale: glowScale, + phase: Self.phase(for: timeline.date) + ) + } + } + } + + /// A closed wave that travels around the ring. + /// + /// The whole layer is rotated by the phase as well, so the spectrum colours + /// travel with the wave: that is what makes the ring change colour instead of + /// merely changing shape. + private func waveRing(unit: CGFloat, energy: CGFloat, glowScale: CGFloat, phase: CGFloat) -> some View { + Canvas(rendersAsynchronously: false) { context, size in + let center = CGPoint(x: size.width / 2, y: size.height / 2) + let baseRadius = unit * (0.40 + 0.05 * energy) + let amplitude = baseRadius * (0.035 + 0.075 * energy) + let steps = 84 + var path = Path() + for step in 0...steps { + let theta = CGFloat(step) / CGFloat(steps) * 2 * .pi + let radius = baseRadius + + amplitude * sin(3 * theta + phase) + + amplitude * 0.55 * sin(5 * theta - phase * 0.7) + let point = CGPoint( + x: center.x + cos(theta) * radius, + y: center.y + sin(theta) * radius + ) + if step == 0 { + path.move(to: point) + } else { + path.addLine(to: point) + } + } + path.closeSubpath() + + let shading = GraphicsContext.Shading.linearGradient( + Gradient(colors: self.palette.spectrumColors), + startPoint: CGPoint(x: 0, y: 0), + endPoint: CGPoint(x: size.width, y: size.height) + ) + let lineWidth = max(unit * (0.028 + 0.022 * energy), 0.8) + + if self.glow != .subtle { + context.drawLayer { layer in + layer.addFilter(.blur(radius: 3.0 * glowScale)) + layer.opacity = 0.55 * glowScale + layer.stroke(path, with: shading, lineWidth: lineWidth * 2.2) + } + } + context.stroke(path, with: shading, lineWidth: lineWidth) + } + .rotationEffect(.degrees(Double(phase) * 26)) + .allowsHitTesting(false) + } + + private static func phase(for date: Date) -> CGFloat { + let seconds = date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 120) + return CGFloat(seconds) * 0.9 + } +} diff --git a/Sources/Fluid/UI/Overlay/WaveVisualizer.swift b/Sources/Fluid/UI/Overlay/WaveVisualizer.swift new file mode 100644 index 000000000..9bc8eab50 --- /dev/null +++ b/Sources/Fluid/UI/Overlay/WaveVisualizer.swift @@ -0,0 +1,134 @@ +// +// WaveVisualizer.swift +// Fluid +// +// Wave style: symmetric bars around the center line, fed by the delay line. +// + +import SwiftUI + +/// Vertical bars mirrored around the center line. +/// +/// Bar heights are read from the shared delay line instead of an independent +/// random source, so the waveform is genuinely correlated with the microphone +/// level, stays smooth and never jitters. +/// +/// Each bar takes its colour from the theme's ordered spectrum, so the Aurora +/// theme shows cyan - blue - violet - magenta - warm across the row instead of +/// collapsing into Blue. +struct WaveVisualizer: View { + let level: CGFloat + let isActive: Bool + let palette: OverlayPalette + let glow: OverlayGlowIntensity + let noiseThreshold: CGFloat + let barCount: Int + let barWidth: CGFloat + let barSpacing: CGFloat + + @State private var follower: AudioEnvelopeFollower + + /// Optional seeded energy for offscreen previews and tests. Production call + /// sites never pass it, so the live path is unchanged: at runtime the + /// follower is fed by the microphone level the engine already publishes. + init( + level: CGFloat, + isActive: Bool, + palette: OverlayPalette, + glow: OverlayGlowIntensity, + noiseThreshold: CGFloat, + barCount: Int, + barWidth: CGFloat, + barSpacing: CGFloat, + previewLevel: CGFloat? = nil + ) { + self.level = level + self.isActive = isActive + self.palette = palette + self.glow = glow + self.noiseThreshold = noiseThreshold + self.barCount = barCount + self.barWidth = barWidth + self.barSpacing = barSpacing + self._follower = State( + initialValue: previewLevel.map(AudioEnvelopeFollower.preview) ?? AudioEnvelopeFollower() + ) + } + + var body: some View { + Canvas(rendersAsynchronously: false) { context, size in + if self.glow != .subtle { + context.drawLayer { layer in + layer.addFilter(.blur(radius: 2.6 * CGFloat(self.glow.visualizerGlowScale))) + layer.opacity = 0.5 * self.glow.visualizerGlowScale + self.drawBars(into: &layer, size: size) + } + } + + self.drawBars(into: &context, size: size) + } + .onChange(of: self.level) { _, newLevel in + self.follower.update( + with: AudioEnvelopeFollower.normalized(newLevel, noiseThreshold: self.noiseThreshold) + ) + } + .onChange(of: self.isActive) { _, active in + guard !active else { return } + self.follower.reset() + } + } + + private func drawBars(into context: inout GraphicsContext, size: CGSize) { + // Light from above: the top of the canvas is brighter than the bottom, so + // each bar keeps some vertical relief even though its colour is flat. + let sheen = GraphicsContext.Shading.linearGradient( + Gradient(colors: [Color.white.opacity(0.22), Color.white.opacity(0.0)]), + startPoint: CGPoint(x: 0, y: 0), + endPoint: CGPoint(x: 0, y: size.height) + ) + for index in 0.. 1 ? CGFloat(index) / CGFloat(self.barCount - 1) : 0.5 + context.fill(path, with: .color(self.palette.spectrumColor(at: fraction))) + context.fill(path, with: sheen) + } + } + + private func barRect(at index: Int, in size: CGSize) -> CGRect { + let totalWidth = CGFloat(self.barCount) * self.barWidth + + CGFloat(max(self.barCount - 1, 0)) * self.barSpacing + let originX = (size.width - totalWidth) / 2 + CGFloat(index) * (self.barWidth + self.barSpacing) + let minimumHeight: CGFloat = 2 + let availableHeight = max(size.height - minimumHeight, 0) + let energy = self.barEnergy(at: index) + let height = minimumHeight + availableHeight * energy + return CGRect( + x: originX, + y: size.height / 2 - height / 2, + width: self.barWidth, + height: height + ) + } + + private func barEnergy(at index: Int) -> CGFloat { + let historySpan = max(AudioEnvelopeFollower.historyLength - 1, 1) + let step = max(self.barCount - 1, 1) + let offset = index * historySpan / step + let sample = self.follower.delayedSample(at: offset) + + // Lift quiet and normal speech so a change in volume is unmistakable, + // while leaving the top of the range uncompressed so loud speech still + // reads clearly taller than normal speech. + let boosted = pow(min(max(sample, 0), 1), 0.74) + + let centerDistance = abs(CGFloat(index) - CGFloat(self.barCount - 1) / 2) + let maxDistance = max(CGFloat(self.barCount - 1) / 2, 1) + let normalized = min(centerDistance / maxDistance, 1) + // A shallower taper than before: the outer bars stay part of the row + // instead of shrinking into it. + let taper = max(0.38, 1.0 - normalized * 0.56) + let variation = 0.92 + 0.08 * cos(CGFloat(index) * 1.45) + return min(max(boosted * taper * variation, 0), 1) + } +} diff --git a/Sources/Fluid/UI/SettingsSearch.swift b/Sources/Fluid/UI/SettingsSearch.swift index 83767ca77..ec39fd150 100644 --- a/Sources/Fluid/UI/SettingsSearch.swift +++ b/Sources/Fluid/UI/SettingsSearch.swift @@ -50,11 +50,17 @@ enum SettingsSearchTarget: Hashable { case overlay case overlaySensitivity + case overlayVisualStyle + case overlayColorTheme + case overlayGlowIntensity + case showTargetAppIcon case overlayPosition case transcriptionPreviewLength case overlayStyle case livePreview case bottomOffset + case companion + case liveTyping case dataAndDiagnostics case backupAndRestore @@ -107,11 +113,17 @@ enum SettingsSearchTarget: Hashable { case .overlay, .overlaySensitivity, + .overlayVisualStyle, + .overlayColorTheme, + .overlayGlowIntensity, + .showTargetAppIcon, .overlayPosition, .transcriptionPreviewLength, .overlayStyle, .livePreview, - .bottomOffset: + .bottomOffset, + .companion, + .liveTyping: return .overlay case .dataAndDiagnostics, .backupAndRestore, .debugLogs: @@ -307,6 +319,26 @@ enum SettingsSearchIndex { ), .init(target: .overlay, title: "Overlay", terms: ["recording indicator notch pill visualizer"]), + .init( + target: .overlayVisualStyle, + title: "Overlay Style", + terms: ["minimal aurora wave pulse visualizer animation look ribbon"] + ), + .init( + target: .overlayColorTheme, + title: "Color Theme", + terms: ["aurora blue purple green orange palette gradient tint color"] + ), + .init( + target: .overlayGlowIntensity, + title: "Glow Intensity", + terms: ["subtle normal vivid brightness glow aura border shine"] + ), + .init( + target: .showTargetAppIcon, + title: "Show Target App Icon", + terms: ["app icon target application logo hide avatar"] + ), .init( target: .overlaySensitivity, title: "Sensitivity", @@ -333,6 +365,19 @@ enum SettingsSearchIndex { terms: ["streaming transcription text while speak overlay"] ), .init(target: .bottomOffset, title: "Bottom Offset", terms: ["distance from bottom screen pixels position"]), + .init( + target: .liveTyping, + title: "Live Typing", + terms: ["experimental write while speaking field streaming partial accessibility"] + ), + .init( + target: .companion, + title: "Companion", + terms: [ + "mascot presence corner character elemental variant accessory", + "hat glasses scarf halo fire water wind earth aurora gothic motion intensity", + ] + ), .init( target: .dataAndDiagnostics, diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index 49ccd8574..1a877bb43 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -1252,7 +1252,20 @@ struct SettingsView: View { // Overlay Settings Card ThemedCard(style: .standard) { - VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 16) { + self.overlayStylePicker + Divider().opacity(0.25) + self.overlayThemePicker + Divider().opacity(0.25) + self.overlaySurfacePicker + Divider().opacity(0.25) + self.overlayGlowPicker + Divider().opacity(0.25) + self.overlayAnchorPicker + Divider().opacity(0.25) + self.overlayTargetAppIconToggle + Divider().opacity(0.25) + VStack(alignment: .leading, spacing: 12) { HStack { VStack(alignment: .leading, spacing: 2) { @@ -1297,31 +1310,6 @@ struct SettingsView: View { Divider().padding(.vertical, 8) - // Overlay Position - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("Overlay Position") - .font(self.theme.typography.bodyStrong) - .foregroundStyle(self.settingsTitleText) - Text("Where the recording indicator appears on screen") - .font(self.theme.typography.bodySmall) - .foregroundStyle(self.settingsSecondaryText) - } - - Spacer() - - Picker("", selection: self.$settings.overlayPosition) { - ForEach(SettingsStore.OverlayPosition.allCases, id: \.self) { position in - Text(position.displayName).tag(position) - } - } - .pickerStyle(.menu) - .frame(width: 170, alignment: .trailing) - } - .settingsSearchTarget(.overlayPosition) - - Divider().padding(.vertical, 8) - VStack(alignment: .leading, spacing: 10) { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 2) { @@ -1368,13 +1356,13 @@ struct SettingsView: View { HStack { VStack(alignment: .leading, spacing: 2) { - Text(self.settings.overlayPosition == .bottom ? "Overlay Size" : "Notch Style") + Text(self.settings.overlayPosition.usesNotchPresentation ? "Notch Style" : "Overlay Size") .font(self.theme.typography.bodyStrong) .foregroundStyle(self.settingsTitleText) Text( - self.settings.overlayPosition == .bottom - ? "How large the recording indicator appears" - : "Choose the regular notch or the compact layout" + self.settings.overlayPosition.usesNotchPresentation + ? "Choose the regular notch or the compact layout" + : "How large the recording indicator appears" ) .font(self.theme.typography.bodySmall) .foregroundStyle(self.settingsSecondaryText) @@ -1382,9 +1370,9 @@ struct SettingsView: View { Spacer() - if self.settings.overlayPosition == .bottom { + if self.settings.overlayPosition.usesFloatingOverlay { Picker("", selection: self.$settings.overlaySize) { - ForEach(SettingsStore.OverlaySize.allCases, id: \.self) { size in + ForEach(SettingsStore.OverlaySize.selectable, id: \.self) { size in Text(size.displayName).tag(size) } } @@ -1402,6 +1390,55 @@ struct SettingsView: View { } .settingsSearchTarget(.overlayStyle) + // Scale only affects the floating pill: the notch + // presentation is docked to the hardware cutout. + if self.settings.overlayPosition.usesFloatingOverlay { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Scale") + .font(self.theme.typography.bodyStrong) + .foregroundStyle(self.settingsTitleText) + Text("Grow or shrink the overlay around the chosen format") + .font(self.theme.typography.bodySmall) + .foregroundStyle(self.settingsSecondaryText) + } + + Spacer() + + Text("\(Int((self.settings.overlayScale * 100).rounded()))%") + .font(.caption.monospaced()) + .foregroundStyle(self.settingsSecondaryText) + + Button("Reset") { + self.settings.overlayScale = SettingsStore.overlayScaleDefault + } + .buttonStyle(.link) + .disabled(abs(self.settings.overlayScale - SettingsStore.overlayScaleDefault) < 0.0001) + } + + HStack(spacing: 10) { + Text("50%") + .font(.caption) + .foregroundStyle(self.settingsSecondaryText) + .frame(width: 36, alignment: .trailing) + + Slider( + value: Binding( + get: { self.settings.overlayScale }, + set: { self.settings.overlayScale = $0 } + ), + in: SettingsStore.overlayScaleRange, + step: 0.05 + ) + .controlSize(.regular) + + Text("200%") + .font(.caption) + .foregroundStyle(self.settingsSecondaryText) + .frame(width: 36, alignment: .leading) + } + } + HStack { VStack(alignment: .leading, spacing: 2) { Text("Live Preview") @@ -1422,8 +1459,38 @@ struct SettingsView: View { } .settingsSearchTarget(.livePreview) - // Bottom overlay specific settings (only show when bottom is selected) - if self.settings.overlayPosition == .bottom { + VStack(alignment: .leading, spacing: 6) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Live Typing (Experimental)") + .font(self.theme.typography.bodyStrong) + .foregroundStyle(self.settingsTitleText) + Text("Write the transcription into the field while you speak, then revise it") + .font(self.theme.typography.bodySmall) + .foregroundStyle(self.settingsSecondaryText) + } + + Spacer() + + Toggle("", isOn: Binding( + get: { self.settings.liveTypingExperimental }, + set: { self.settings.liveTypingExperimental = $0 } + )) + .labelsHidden() + } + + Text("Off by default. FluidVoice only writes while it can read the field back and prove it owns the range it wrote; otherwise it falls back to the normal final paste. Not certified against any specific application yet.") + .font(self.theme.typography.bodySmall) + .foregroundStyle(self.settingsSecondaryText) + + if self.settings.liveTypingExperimental { + LiveTypingStatusRow() + } + } + .settingsSearchTarget(.liveTyping) + + // Bottom-anchored overlays only. + if self.settings.overlayPosition.isBottomAnchored { Divider().padding(.vertical, 4) // Bottom Offset @@ -1832,6 +1899,30 @@ struct SettingsView: View { // MARK: - Helper Views + private func companionPickerRow( + _ title: String, + selection: Binding, + cases: [T], + label: @escaping (T) -> String + ) -> some View { + HStack { + Text(title) + .font(self.theme.typography.bodyStrong) + .foregroundStyle(self.settingsTitleText) + + Spacer() + + Picker("", selection: selection) { + ForEach(cases) { value in + Text(label(value)).tag(value) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(width: 190, alignment: .trailing) + } + } + private func settingsToggleRow( title: String, description: String, @@ -2299,6 +2390,290 @@ private extension SettingsView { ) } + // MARK: - Overlay Appearance Rows + + func overlaySettingHeader(_ title: String, _ subtitle: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(self.theme.typography.bodyStrong) + .foregroundStyle(self.settingsTitleText) + Text(subtitle) + .font(self.theme.typography.bodySmall) + .foregroundStyle(self.settingsSecondaryText) + } + } + + var overlayStylePicker: some View { + VStack(alignment: .leading, spacing: 10) { + self.overlaySettingHeader( + "Overlay Style", + "How the live audio level is drawn while you dictate" + ) + + LazyVGrid( + columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)], + spacing: 10 + ) { + ForEach(OverlayVisualStyle.allCases, id: \.self) { style in + OverlayStyleCard( + style: style, + theme: self.settings.overlayColorTheme, + glow: self.settings.overlayGlowIntensity, + surface: self.settings.overlaySurfaceAppearance, + isSelected: self.settings.overlayVisualStyle == style + ) { + self.settings.overlayVisualStyle = style + } + } + } + + if self.settings.overlayVisualStyle == .companion { + Divider().padding(.vertical, 4) + self.companionStyleOptions + } + } + .settingsSearchTarget(.overlayVisualStyle) + } + + /// Companion-only options, inside the Style section because the Companion is + /// a *style*: the element changes its material and its palette, the + /// accessories change its silhouette. Colours come from the shared theme. + var companionStyleOptions: some View { + VStack(alignment: .leading, spacing: 10) { + self.companionPickerRow( + "Element", + selection: self.$settings.companionVariant, + cases: CompanionVariant.allCases, + label: { $0.displayName } + ) + + Text(self.settings.companionVariant.summary) + .font(self.theme.typography.bodySmall) + .foregroundStyle(self.settingsSecondaryText) + + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Size") + .font(self.theme.typography.bodyStrong) + .foregroundStyle(self.settingsTitleText) + Spacer() + Text("\(Int((self.settings.companionScale * 100).rounded()))%") + .font(.caption.monospaced()) + .foregroundStyle(self.settingsSecondaryText) + Button("Reset") { + self.settings.companionScale = SettingsStore.companionScaleDefault + } + .buttonStyle(.link) + .disabled(abs(self.settings.companionScale - SettingsStore.companionScaleDefault) < 0.0001) + } + Slider( + value: Binding( + get: { self.settings.companionScale }, + set: { self.settings.companionScale = $0 } + ), + in: SettingsStore.companionScaleRange, + step: 0.05 + ) + .controlSize(.regular) + } + + HStack(spacing: 8) { + ForEach(CompanionAccessory.allCases, id: \.self) { accessory in + Toggle(accessory.displayName, isOn: Binding( + get: { self.settings.companionAccessories.contains(accessory) }, + set: { isOn in + var selection = self.settings.companionAccessories + if isOn { + selection.insert(accessory) + } else { + selection.remove(accessory) + } + self.settings.companionAccessories = selection + } + )) + .toggleStyle(.button) + .controlSize(.small) + } + } + } + .settingsSearchTarget(.companion) + } + + var overlayThemePicker: some View { + VStack(alignment: .leading, spacing: 10) { + self.overlaySettingHeader( + "Color Theme", + "Shared by the visualizer and the orbital aura" + ) + + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8), + ], + spacing: 8 + ) { + ForEach(OverlayColorTheme.allCases, id: \.self) { theme in + OverlayThemeChip( + theme: theme, + isSelected: self.settings.overlayColorTheme == theme + ) { + self.settings.overlayColorTheme = theme + } + } + } + + // One primary colour is enough: the rest of the palette is derived. + if self.settings.overlayColorTheme == .custom { + HStack(spacing: 10) { + ColorPicker( + "", + selection: Binding( + get: { Color(hex: self.settings.overlayCustomThemeHex) ?? .purple }, + set: { self.settings.overlayCustomThemeHex = $0.overlayHexString } + ), + supportsOpacity: false + ) + .labelsHidden() + + Text("Primary color") + .font(self.theme.typography.bodySmall) + .foregroundStyle(self.settingsSecondaryText) + + Spacer() + + Button("Reset") { + self.settings.overlayCustomThemeHex = OverlayCustomTheme.defaultHex + } + .buttonStyle(.link) + .disabled(self.settings.overlayCustomThemeHex.uppercased() == OverlayCustomTheme.defaultHex.uppercased()) + } + } + } + .settingsSearchTarget(.overlayColorTheme) + } + + var overlaySurfacePicker: some View { + VStack(alignment: .leading, spacing: 10) { + self.overlaySettingHeader( + "Surface", + "Dark keeps the near-black pill; Light follows macOS Light Mode" + ) + + Picker("", selection: self.$settings.overlaySurfaceAppearance) { + ForEach(OverlaySurfaceAppearance.allCases, id: \.self) { appearance in + Text(appearance.displayName).tag(appearance) + } + } + .pickerStyle(.segmented) + .labelsHidden() + } + .settingsSearchTarget(.overlayColorTheme) + } + + var overlayGlowPicker: some View { + VStack(alignment: .leading, spacing: 10) { + self.overlaySettingHeader( + "Glow Intensity", + "Strength of the border, aura and visualizer glow" + ) + + OverlayGlowPicker(selection: self.$settings.overlayGlowIntensity) + + // Secondary control: the preset above sets the base, this corrects it. + DisclosureGroup { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Text("50%") + .font(.caption) + .foregroundStyle(self.settingsSecondaryText) + .frame(width: 36, alignment: .trailing) + + Slider( + value: Binding( + get: { self.settings.overlayGlowStrength }, + set: { self.settings.overlayGlowStrength = $0 } + ), + in: OverlayGlowTuning.range, + step: 0.05 + ) + .controlSize(.regular) + + Text("150%") + .font(.caption) + .foregroundStyle(self.settingsSecondaryText) + .frame(width: 36, alignment: .leading) + + Text("\(Int((self.settings.overlayGlowStrength * 100).rounded()))%") + .font(.caption.monospaced()) + .foregroundStyle(self.settingsSecondaryText) + .frame(width: 44, alignment: .trailing) + } + + Text("Fine tuning applied on top of the preset.") + .font(self.theme.typography.bodySmall) + .foregroundStyle(self.settingsSecondaryText) + + Divider().padding(.vertical, 2) + + Text("Motion Intensity") + .font(self.theme.typography.bodyStrong) + .foregroundStyle(self.settingsTitleText) + Picker("", selection: self.$settings.overlayMotionIntensity) { + ForEach(MotionIntensity.allCases, id: \.self) { intensity in + Text(intensity.displayName).tag(intensity) + } + } + .pickerStyle(.segmented) + .labelsHidden() + Text("Modulates how far Aurora and the Companion travel.") + .font(self.theme.typography.bodySmall) + .foregroundStyle(self.settingsSecondaryText) + } + .padding(.top, 6) + } label: { + Text("Advanced") + .font(self.theme.typography.bodyStrong) + .foregroundStyle(self.settingsTitleText) + } + } + .settingsSearchTarget(.overlayGlowIntensity) + } + + var overlayAnchorPicker: some View { + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 2) { + Text("Position") + .font(self.theme.typography.bodyStrong) + .foregroundStyle(self.settingsTitleText) + Text( + self.settings.overlayPosition.usesNotchPresentation + ? "Top Center keeps the notch presentation. Any other anchor uses the floating pill." + : "Where the floating recording pill appears on the active screen" + ) + .font(self.theme.typography.bodySmall) + .foregroundStyle(self.settingsSecondaryText) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 12) + + OverlayAnchorGrid(selection: self.$settings.overlayPosition) + .frame(width: 190) + } + .settingsSearchTarget(.overlayPosition) + } + + var overlayTargetAppIconToggle: some View { + self.settingsToggleRow( + title: "Show Target App Icon", + description: "Show the icon of the app that will receive the dictation.", + isOn: self.$settings.showTargetAppIcon + ) + .settingsSearchTarget(.showTargetAppIcon) + } + var settingsTitleText: Color { Color(nsColor: .labelColor) } diff --git a/Sources/Fluid/Views/BottomOverlayView.swift b/Sources/Fluid/Views/BottomOverlayView.swift index f7463aa44..7b8d0ecb5 100644 --- a/Sources/Fluid/Views/BottomOverlayView.swift +++ b/Sources/Fluid/Views/BottomOverlayView.swift @@ -53,6 +53,10 @@ final class BottomOverlayWindowController { private var deferredResizePending = false private var presentationGeneration: UInt64 = 0 private let dismissalDuration: TimeInterval = 0.02 + /// Bounded dwell that lets the completion check land before the panel is + /// parked. Text delivery has already finished when this runs, so it can + /// never delay dictation, the paste or the next recording. + private static let completionFlashDuration: TimeInterval = 0.30 private var isHideInProgress = false private var activeHideGeneration: UInt64? private var hideWaiters: [CheckedContinuation] = [] @@ -126,6 +130,7 @@ final class BottomOverlayWindowController { // offscreen. Revealing the neutral shell first causes a visible flash // that reads as the overlay appearing twice. NotchContentState.shared.setBottomOverlayPresented(true) + NotchContentState.shared.clearDeliveryCompletion() NotchContentState.shared.setSpokenSendIndicatorState(.hidden) NotchContentState.shared.mode = mode switch mode { @@ -284,7 +289,15 @@ final class BottomOverlayWindowController { } self.clearPresentationResources() - try? await Task.sleep(nanoseconds: UInt64(self.dismissalDuration * 1_000_000_000)) + if self.isCompletionFlashPending { + // Success path only: let the check land, then run the normal + // dismissal. Bounded, and superseded instantly by a new recording. + NotchContentState.shared.clearDeliveryCompletion() + self.startDismissalVisual() + try? await Task.sleep(nanoseconds: UInt64(Self.completionFlashDuration * 1_000_000_000)) + } else { + try? await Task.sleep(nanoseconds: UInt64(self.dismissalDuration * 1_000_000_000)) + } guard self.presentationGeneration == currentGeneration else { Self.overlayBench("bottom_hide_return reason=stale_generation") @@ -307,12 +320,30 @@ final class BottomOverlayWindowController { NotchContentState.shared.isBottomOverlayPresented else { return } + // The completion flash owns the visuals until its bounded dwell expires, + // so the dismissal animation is started by performHideAndWait instead. + guard !self.isCompletionFlashPending else { + Self.overlayBench("bottom_hide_visual_deferred reason=completion_flash") + return + } + + self.startDismissalVisual() + } + + private func startDismissalVisual() { NotchContentState.shared.setBottomOverlayReleaseTransitioning(true) NotchContentState.shared.setBottomOverlayDismissOffsetY(8) NotchContentState.shared.setBottomOverlayDismissing(true) Self.overlayBench("bottom_hide_visual_requested") } + /// True while a confirmed delivery should flash before the panel is parked. + /// Reduce Motion skips the flash entirely and hides immediately. + private var isCompletionFlashPending: Bool { + NotchContentState.shared.didCompleteDelivery + && !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + } + private func clearPresentationResources() { self.audioSubscription?.cancel() self.audioSubscription = nil @@ -534,29 +565,21 @@ final class BottomOverlayWindowController { let screen = self.targetScreen ?? window.screen ?? OverlayScreenResolver.screenForCurrentPointer() guard let screen = screen else { return } - let fullFrame = screen.frame - let visibleFrame = screen.visibleFrame - let windowSize = window.frame.size - - // Horizontal centering - let x = fullFrame.midX - windowSize.width / 2 - - // Vertical positioning with safety clamping - let offset = SettingsStore.shared.overlayBottomOffset - - // Calculate raw position - var y = visibleFrame.minY + CGFloat(offset) - - // Safety Clamping: - // 1. Min: Ensure it's at least visibleFrame.minY (not below the dock/visible area) - // 2. Max: Ensure it doesn't cross the top of the visible frame minus its own height - let minY = visibleFrame.minY + 10 // Small buffer from absolute bottom - let maxY = visibleFrame.maxY - windowSize.height - 40 // Buffer from top - - y = max(min(y, maxY), minY) + let size = SettingsStore.shared.overlaySize + let origin = SettingsStore.shared.overlayPosition.windowOrigin( + windowSize: window.frame.size, + screenFrame: screen.frame, + visibleFrame: screen.visibleFrame, + bottomOffset: CGFloat(SettingsStore.shared.overlayBottomOffset), + // Must mirror BottomOverlayView.usesCanvasPadding exactly. + canvasPadding: (SettingsStore.shared.overlayVisualStyle != .companion + && (size == .pill || size == .round)) + ? SettingsStore.OverlayPosition.pillCanvasPadding * CGFloat(SettingsStore.shared.overlayScale) + : 0 + ) // Apply position directly to avoid implicit frame animations during hover-driven resizes. - window.setFrameOrigin(NSPoint(x: x, y: y)) + window.setFrameOrigin(origin) } private func parkWindowOffscreen() { @@ -2104,6 +2127,14 @@ private enum PillShadowMetrics { private final class BottomOverlayHostingView: NSHostingView { override func hitTest(_ point: NSPoint) -> NSView? { + // Chromeless format: there is nothing to click, and the graphic covers + // only part of its window, so the whole window must stay click-through + // instead of swallowing clicks aimed at whatever is behind it. + if SettingsStore.shared.overlaySize == .svg + || SettingsStore.shared.overlayVisualStyle == .companion + { + return nil + } if SettingsStore.shared.overlaySize == .pill { let visibleOverlayBounds = self.bounds.insetBy( dx: PillShadowMetrics.hitTestInset, @@ -2135,7 +2166,9 @@ struct BottomOverlayView: View { @ObservedObject private var historyStore = TranscriptionHistoryStore.shared @ObservedObject private var settings = SettingsStore.shared @Environment(\.theme) private var theme + @Environment(\.colorScheme) private var colorScheme @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency @State private var isHoveringModeChip = false @State private var isHoveringPromptChip = false @State private var isHoveringActionsChip = false @@ -2152,7 +2185,9 @@ struct BottomOverlayView: View { @State private var processingStatusVisible = false @State private var processingStatusCycleID = 0 @State private var lastResolvedAppIcon: NSImage? - @State private var borderAnimationStartedAt: Date? + /// 0...1 entrance progress for the pill. Kept at 0 while the panel is parked + /// offscreen so a presentation can never flash at full opacity first. + @State private var entryProgress: CGFloat = 0 struct LayoutConstants { let hPadding: CGFloat @@ -2180,23 +2215,25 @@ struct BottomOverlayView: View { static func get(for size: SettingsStore.OverlaySize) -> LayoutConstants { switch size { case .pill: + // Premium pill: 38 pt tall, ~108 pt wide, capsule radius, with the + // app icon at the 20 pt optical size used across macOS. return LayoutConstants( hPadding: 12, - vPadding: 8, - waveformWidth: 46, - waveformHeight: 30, - iconSize: 18, + vPadding: 6, + waveformWidth: 52, + waveformHeight: 26, + iconSize: 20, transFontSize: 10, modeFontSize: 9, - cornerRadius: 23, + cornerRadius: 19, barCount: 8, barWidth: 3.0, barSpacing: 2.5, - minBarHeight: 4, - maxBarHeight: 28, - containerWidth: 100, - overlayWidth: 100, - overlayHeight: 46, + minBarHeight: 3, + maxBarHeight: 24, + containerWidth: 108, + overlayWidth: 108, + overlayHeight: 38, previewBoxHeight: 0, usesFixedCanvas: false, showsTopControls: false, @@ -2204,23 +2241,29 @@ struct BottomOverlayView: View { showsModeLabel: false ) case .small: + // Compact object: the app icon plus the animation, nothing else. + // The surface is an *ecrin* for the light, so it hugs the row + // instead of reserving a wide empty capsule, and the visualizer is + // given that width back. No mode label is drawn for this format + // (see `showsTextModeLabel`): the word "Dictate" spent exactly the + // space the animation was meant to fill. return LayoutConstants( hPadding: 10, vPadding: 6, - waveformWidth: 90, - waveformHeight: 20, + waveformWidth: 108, + waveformHeight: 24, iconSize: 16, transFontSize: 11, modeFontSize: 10, cornerRadius: 14, - barCount: 7, + barCount: 11, barWidth: 3.0, - barSpacing: 3.5, + barSpacing: 3.4, minBarHeight: 5, - maxBarHeight: 16, - containerWidth: 200, - overlayWidth: 300, - overlayHeight: 124, + maxBarHeight: 18, + containerWidth: 152, + overlayWidth: 152, + overlayHeight: 44, previewBoxHeight: 0, usesFixedCanvas: false, showsTopControls: false, @@ -2251,6 +2294,65 @@ struct BottomOverlayView: View { showsPreview: true, showsModeLabel: true ) + case .svg: + // The animation alone. + // + // Nothing is reserved for a surface, a border or a shadow: the + // canvas *is* the graphic, so the visualizer gets every point of + // it and no canvas padding is applied around the window. + return LayoutConstants( + hPadding: 0, + vPadding: 0, + waveformWidth: 104, + waveformHeight: 52, + iconSize: 0, + transFontSize: 10, + modeFontSize: 9, + cornerRadius: 0, + barCount: 14, + barWidth: 3.5, + barSpacing: 3.0, + minBarHeight: 3, + maxBarHeight: 30, + containerWidth: 104, + overlayWidth: 104, + overlayHeight: 52, + // Denser than the pill: the chromeless canvas is twice as + // wide, and the bar count has to follow the container or the + // Wave style would read as a small lonely row next to Aurora. + previewBoxHeight: 0, + usesFixedCanvas: false, + showsTopControls: false, + showsPreview: false, + showsModeLabel: false + ) + case .round: + // Square canvas at 46 pt. The corner radius is half the side, so + // the shared rounded-rectangle surface and rim resolve to a circle + // without any format-specific drawing code. + return LayoutConstants( + hPadding: 6, + vPadding: 6, + waveformWidth: 34, + waveformHeight: 34, + iconSize: 0, + transFontSize: 10, + modeFontSize: 9, + cornerRadius: 23, + barCount: 6, + barWidth: 2.5, + barSpacing: 2.5, + minBarHeight: 3, + maxBarHeight: 22, + containerWidth: 46, + overlayWidth: 46, + overlayHeight: 46, + previewBoxHeight: 0, + usesFixedCanvas: false, + showsTopControls: false, + showsPreview: false, + showsModeLabel: false + ) case .large: return LayoutConstants( hPadding: 18, @@ -2277,16 +2379,66 @@ struct BottomOverlayView: View { ) } } + + /// The same layout with every visual dimension multiplied by `scale`. + /// + /// Counts and behaviour flags are deliberately untouched: the scale + /// changes how large the overlay is, not what it draws. One multiplier + /// here keeps Pill / Small / Medium on exactly the same design language + /// instead of growing into a separate, unrelated layout. + func scaled(by scale: CGFloat) -> LayoutConstants { + guard scale != 1 else { return self } + return LayoutConstants( + hPadding: self.hPadding * scale, + vPadding: self.vPadding * scale, + waveformWidth: self.waveformWidth * scale, + waveformHeight: self.waveformHeight * scale, + iconSize: self.iconSize * scale, + transFontSize: self.transFontSize * scale, + modeFontSize: self.modeFontSize * scale, + cornerRadius: self.cornerRadius * scale, + barCount: self.barCount, + barWidth: self.barWidth * scale, + barSpacing: self.barSpacing * scale, + minBarHeight: self.minBarHeight * scale, + maxBarHeight: self.maxBarHeight * scale, + containerWidth: self.containerWidth * scale, + overlayWidth: self.overlayWidth * scale, + overlayHeight: self.overlayHeight * scale, + previewBoxHeight: self.previewBoxHeight * scale, + usesFixedCanvas: self.usesFixedCanvas, + showsTopControls: self.showsTopControls, + showsPreview: self.showsPreview, + showsModeLabel: self.showsModeLabel + ) + } } private var layout: LayoutConstants { LayoutConstants.get(for: self.settings.overlaySize) + .scaled(by: CGFloat(self.settings.overlayScale)) + } + + /// Canvas padding that keeps the drawn shadow inside the window. It has to + /// follow the user scale, otherwise a large pill would clip its own shadow. + private var scaledCanvasPadding: CGFloat { + SettingsStore.OverlayPosition.pillCanvasPadding * CGFloat(self.settings.overlayScale) } private var isCompactControls: Bool { self.settings.overlaySize == .medium } + /// Whether the compact row draws the text mode label. + /// + /// Small keeps the app icon and the visualizer only: the word "Dictate" spent + /// exactly the width the animation should occupy. The underlying layout flag + /// stays true so the icon slot and the model-loading indicator keep their + /// existing behaviour. + private var showsTextModeLabel: Bool { + self.layout.showsModeLabel && self.settings.overlaySize != .small + } + private var waveformHorizontalOffset: CGFloat { self.settings.overlaySize == .medium ? -28 : 0 } @@ -2295,6 +2447,145 @@ struct BottomOverlayView: View { self.settings.overlaySize == .pill } + /// The Companion is a style, but a *chromeless* one: the character is the + /// whole overlay. It therefore gets its own square canvas instead of the + /// format capsule, and the window stays fully click-through. + private var isCompanionStyle: Bool { + self.settings.overlayVisualStyle == .companion + } + + /// Side of the Companion canvas: the base size, the global overlay scale and + /// the Companion's own size control. + private var companionSide: CGFloat { + CompanionMetrics.baseSide + * CGFloat(self.settings.overlayScale) + * CGFloat(self.settings.companionScale) + } + + private var isRoundSize: Bool { + self.settings.overlaySize == .round + } + + /// Chromeless format: the visualizer is the entire overlay. + private var isSVGSize: Bool { + self.settings.overlaySize == .svg + } + + + + /// Formats drawn as a self-contained floating capsule or orb, which reserve + /// canvas padding for the drawn shadow. + private var usesCanvasPadding: Bool { + // The Companion draws its own canvas, so it never reserves the format's + // shadow padding even when that format is Pill or Round. + !self.isCompanionStyle && (self.isPillSize || self.isRoundSize) + } + + /// Resolved overlay appearance. Cheap: palettes are cached per theme. + private var overlayAppearance: OverlayAppearance { + OverlayAppearance( + style: self.settings.overlayVisualStyle, + theme: self.settings.overlayColorTheme, + glow: self.settings.overlayGlowIntensity, + showsTargetAppIcon: self.settings.showTargetAppIcon, + surface: self.settings.overlaySurfaceAppearance + ) + } + + private var overlayPalette: OverlayPalette { + self.overlayAppearance.palette + } + + /// Lifecycle phase derived only from signals the dictation pipeline already publishes. + private var overlayLifecycleState: OverlayLifecycleState { + OverlayLifecycleState.resolve( + isPresented: self.contentState.isBottomOverlayPresented, + isReleaseTransitioning: self.contentState.isBottomOverlayReleaseTransitioning, + isProcessing: self.contentState.isProcessing, + hasProcessingFailure: self.contentState.isAIProcessingFailureVisible, + isDeliveryCompleted: self.contentState.didCompleteDelivery + ) + } + + /// True while the pill is on screen and not on its way out. Gates every + /// continuous animation in the premium overlay. + private var isPillAnimationActive: Bool { + self.contentState.isBottomOverlayPresented && !self.contentState.isBottomOverlayDismissing + } + + private var visualizerThreshold: CGFloat { + CGFloat(self.settings.visualizerNoiseThreshold) + } + + private var isModelLoadingForIcon: Bool { + self.layout.showsModeLabel && !self.appServices.asr.isAsrReady && + (self.appServices.asr.isLoadingModel || self.appServices.asr.isDownloadingModel) + } + + /// Width reserved for the live preview that grows the pill while dictating. + private var pillPreviewWidth: CGFloat { + guard self.isPillSize, self.settings.enableStreamingPreview else { return 0 } + return PillPreviewSizing.width( + for: self.transcriptionPreviewText, + fontSize: self.layout.transFontSize, + characterLimit: self.settings.transcriptionPreviewCharLimit + ) + } + + /// Width reclaimed when the user hides the target app icon in the pill. + private var hiddenIconWidth: CGFloat { + guard self.isPillSize, !self.settings.showTargetAppIcon, !self.isModelLoadingForIcon else { return 0 } + return self.layout.iconSize + 4 + } + + /// Total canvas width, including any live preview growth. + private var overlayCanvasWidth: CGFloat { + if self.isCompanionStyle { + return self.companionSide + } + if self.layout.usesFixedCanvas { + return self.layout.overlayWidth + } + // The orb is exactly as wide as it is tall. + if self.isRoundSize { + return self.layout.containerWidth + } + return max(self.layout.containerWidth + self.pillPreviewWidth - self.hiddenIconWidth, 72) + } + + private var pillVisualizerWidth: CGFloat { + self.showsSpokenSendIndicator ? 32 : self.layout.waveformWidth + } + + /// Resolved surface weights for the current system appearance. + private var overlaySurface: OverlaySurfaceStyle { + self.overlayAppearance.surfaceStyle(systemIsDark: self.colorScheme == .dark) + } + + /// Pill surface. Stays opaque under Reduce Transparency. + private var overlaySurfaceFill: Color { + let surface = self.overlaySurface.fill + guard self.usesCanvasPadding, !self.reduceTransparency else { return surface } + return surface.opacity(0.94) + } + + /// Single-line live preview that grows the pill instead of resizing abruptly. + private var pillPreviewView: some View { + Text(self.transcriptionPreviewText) + .font(.system(size: self.layout.transFontSize, weight: .medium)) + .foregroundStyle(self.overlaySurface.primaryText) + .lineLimit(1) + .truncationMode(.head) + .frame( + width: max(self.pillPreviewWidth - PillPreviewSizing.horizontalPadding, 1), + alignment: .leading + ) + .animation( + self.reduceMotion ? nil : .easeOut(duration: 0.18), + value: self.pillPreviewWidth + ) + } + private var modeColor: Color { self.contentState.mode.notchColor } @@ -2508,6 +2799,9 @@ struct BottomOverlayView: View { } private var overlayFrameHeight: CGFloat? { + if self.isCompanionStyle { + return self.companionSide + } guard self.layout.usesFixedCanvas else { return nil } return self.shouldReservePreviewArea ? self.layout.overlayHeight : nil } @@ -2517,7 +2811,10 @@ struct BottomOverlayView: View { return self.layout.waveformWidth * 2.2 } - return max(self.layout.waveformWidth * 2.2, self.layout.containerWidth - self.layout.hPadding * 2) + // Dynamic formats size their pill to `containerWidth`, so the preview has + // to fit inside it. The old "2.2 x visualizer" floor could exceed a narrow + // Small capsule and let the preview overflow past the surface. + return max(self.layout.containerWidth - self.layout.hPadding * 2, self.layout.waveformWidth) } private var dynamicPreviewBaseMinHeight: CGFloat { @@ -2583,6 +2880,18 @@ struct BottomOverlayView: View { return min(max(estimatedWrappedLines + newlineCount, 1), maxVisibleLines) } + /// Entrance transition: opacity plus a very light scale over 150 ms, no bounce. + /// + /// The panel is revealed only after this state has already been reset, so the + /// first composited frame can never flash at full opacity. + private func beginEntryAnimation() { + DispatchQueue.main.async { + withAnimation(.timingCurve(0.22, 0.0, 0.2, 1.0, duration: 0.15)) { + self.entryProgress = 1 + } + } + } + private func refreshDynamicPreviewSizeIfNeeded(for previewText: String) { guard self.shouldReservePreviewArea else { return } guard !self.layout.usesFixedCanvas else { return } @@ -2647,11 +2956,17 @@ struct BottomOverlayView: View { } private var overlayAnimatedScale: CGFloat { - self.contentState.isBottomOverlayDismissing ? 0.985 : 1.0 + var scale: CGFloat = self.contentState.isBottomOverlayDismissing ? 0.985 : 1.0 + // Small contraction as the completion check lands. + if self.contentState.didCompleteDelivery { + scale *= 0.972 + } + guard !self.reduceMotion else { return scale } + return scale * (0.972 + 0.028 * self.entryProgress) } private var overlayAnimatedOpacity: Double { - 1.0 + Double(self.entryProgress) } private func chipBackground(isHovered: Bool, disabled: Bool) -> some View { @@ -3067,27 +3382,28 @@ struct BottomOverlayView: View { private var targetAppIconView: some View { let appIcon = self.displayedAppIcon - let showModelLoading = self.layout.showsModeLabel && !self.appServices.asr.isAsrReady && - (self.appServices.asr.isLoadingModel || self.appServices.asr.isDownloadingModel) + let showModelLoading = self.isModelLoadingForIcon + let showsAppIcon = self.settings.showTargetAppIcon && appIcon != nil + let showsPlaceholder = self.settings.showTargetAppIcon && !self.layout.showsModeLabel return VStack(spacing: 2) { if showModelLoading { ProgressView() .controlSize(.mini) } - if let appIcon = appIcon { + if showsAppIcon, let appIcon = appIcon { Image(nsImage: appIcon) .resizable() .aspectRatio(contentMode: .fit) .frame(width: self.layout.iconSize, height: self.layout.iconSize) .clipShape(RoundedRectangle(cornerRadius: self.layout.iconSize / 4)) - } else if !self.layout.showsModeLabel { + } else if showsPlaceholder { Circle() .fill(self.modeColor.opacity(0.9)) .frame(width: max(self.layout.iconSize * 0.45, 7), height: max(self.layout.iconSize * 0.45, 7)) } } .frame(width: self.layout.iconSize, height: self.layout.iconSize) - .opacity((appIcon != nil || showModelLoading || !self.layout.showsModeLabel) ? 1 : 0) + .opacity((showsAppIcon || showModelLoading || showsPlaceholder) ? 1 : 0) } private var leadingAppContextView: some View { @@ -3102,7 +3418,9 @@ struct BottomOverlayView: View { .transition(.scale(scale: 0.8).combined(with: .opacity)) } - self.targetAppIconView + if self.settings.showTargetAppIcon || self.isModelLoadingForIcon { + self.targetAppIconView + } } .animation( self.reduceMotion ? nil : .easeOut(duration: 0.14), @@ -3154,6 +3472,125 @@ struct BottomOverlayView: View { } var body: some View { + if self.isCompanionStyle { + self.companionStyleBody + } else if self.isSVGSize { + self.svgBody + } else if self.isRoundSize { + self.roundBody + } else { + self.standardBody + } + } + + /// The Companion as a chromeless overlay: the character alone, no surface, + /// no rim, no shadow. Its expression comes from the phases the pipeline + /// already publishes. + private var companionStyleBody: some View { + CompanionVisualizer( + state: OverlayVisualizerView.companionState( + for: self.overlayLifecycleState, + hasTranscription: self.hasTranscription + ), + variant: self.settings.companionVariant, + themePalette: self.overlayPalette, + glow: self.overlayAppearance.glow, + accessories: self.settings.companionAccessories, + level: self.contentState.bottomOverlayAudioLevel, + isActive: self.isPillAnimationActive, + reduceMotion: self.reduceMotion, + motion: self.settings.overlayMotionIntensity + ) + .frame(width: self.companionSide, height: self.companionSide) + } + + /// The animation on its own. + /// + /// No surface, no hairline rim, no shadow - the desktop is the background. + /// That also means the window reserves no canvas padding, so there is no + /// invisible frame catching clicks around the graphic. + private var svgBody: some View { + OverlayVisualizerView( + style: self.overlayAppearance.style, + palette: self.overlayPalette, + glow: self.overlayAppearance.glow, + level: self.contentState.bottomOverlayAudioLevel, + lifecycle: self.overlayLifecycleState, + isActive: self.isPillAnimationActive, + reduceMotion: self.reduceMotion, + noiseThreshold: self.visualizerThreshold, + barCount: self.layout.barCount, + barWidth: self.layout.barWidth, + barSpacing: self.layout.barSpacing, + canvasSize: CGSize( + width: self.layout.waveformWidth, + height: self.layout.waveformHeight + ), + surface: .chromeless, + motion: self.settings.overlayMotionIntensity, + companionVariant: self.settings.companionVariant, + companionAccessories: self.settings.companionAccessories, + hasTranscription: self.hasTranscription + ) + .frame(width: self.layout.containerWidth, height: self.layout.overlayHeight) + } + + /// Circular voice object: a very small orb that keeps the chosen style. + /// + /// Deliberately minimal - the orb exists to be glanced at, so it carries the + /// visualizer and nothing else: no preview, no mode label, no controls. + private var roundBody: some View { + OverlayVisualizerView( + style: self.overlayAppearance.style, + palette: self.overlayPalette, + glow: self.overlayAppearance.glow, + level: self.contentState.bottomOverlayAudioLevel, + lifecycle: self.overlayLifecycleState, + isActive: self.isPillAnimationActive, + reduceMotion: self.reduceMotion, + noiseThreshold: self.visualizerThreshold, + barCount: self.layout.barCount, + barWidth: self.layout.barWidth, + barSpacing: self.layout.barSpacing, + canvasSize: CGSize( + width: self.layout.waveformWidth, + height: self.layout.waveformHeight + ), + surface: self.overlaySurface, + motion: self.settings.overlayMotionIntensity, + companionVariant: self.settings.companionVariant, + companionAccessories: self.settings.companionAccessories, + hasTranscription: self.hasTranscription + ) + .frame(width: self.layout.containerWidth, height: self.layout.overlayHeight) + .background( + ZStack { + Circle() + .fill(self.overlaySurfaceFill) + .shadow( + color: Color.black.opacity(self.overlaySurface.shadowOpacity), + radius: PillShadowMetrics.radius, + x: 0, + y: PillShadowMetrics.yOffset + ) + + // Hairline themed rim, brightest under the thumb of the voice. + OrbitalGlow( + cornerRadius: self.layout.cornerRadius, + palette: self.overlayPalette, + glow: self.overlayAppearance.glow, + level: self.contentState.bottomOverlayAudioLevel, + isActive: self.isPillAnimationActive, + reduceMotion: self.reduceMotion, + isMonochrome: self.overlayAppearance.style == .minimal, + isCompleting: self.contentState.didCompleteDelivery + ) + } + ) + .padding(self.scaledCanvasPadding) + } + + var standardBody: some View { VStack(spacing: max(4, self.layout.vPadding / 2)) { if self.layout.showsTopControls, !self.isCompactControls { HStack { @@ -3240,7 +3677,7 @@ struct BottomOverlayView: View { if self.settings.overlaySize == .small { Text(previewText) .font(.system(size: self.layout.transFontSize, weight: .medium)) - .foregroundStyle(.white.opacity(0.9)) + .foregroundStyle(self.overlaySurface.primaryText) .multilineTextAlignment(.leading) .lineLimit(1) .truncationMode(.head) @@ -3249,7 +3686,7 @@ struct BottomOverlayView: View { } else { Text(previewText) .font(.system(size: self.layout.transFontSize, weight: .medium)) - .foregroundStyle(.white.opacity(0.9)) + .foregroundStyle(self.overlaySurface.primaryText) .multilineTextAlignment(.leading) .lineLimit(Int(self.previewMaxHeight / max(self.estimatedPreviewLineHeight, 1))) .truncationMode(.head) @@ -3293,21 +3730,41 @@ struct BottomOverlayView: View { self.leadingAppContextView } - // Waveform visualization - BottomWaveformView( - color: self.modeColor, - layout: self.layout, - visibleBarCount: self.isPillSize && self.showsSpokenSendIndicator ? 6 : nil - ) - .frame( - width: self.isPillSize && self.showsSpokenSendIndicator - ? 32 - : self.layout.waveformWidth, - height: self.layout.waveformHeight + // Every format draws the same premium styles. Only the canvas + // handed to the visualizer changes, so Small and Medium keep + // Aurora / Wave / Pulse / Minimal instead of falling back to a + // generic waveform that ignores the chosen style. + // The component only reads the level and the lifecycle the + // dictation pipeline already publishes. + OverlayVisualizerView( + style: self.overlayAppearance.style, + palette: self.overlayPalette, + glow: self.overlayAppearance.glow, + level: self.contentState.bottomOverlayAudioLevel, + lifecycle: self.overlayLifecycleState, + isActive: self.isPillAnimationActive, + reduceMotion: self.reduceMotion, + noiseThreshold: self.visualizerThreshold, + barCount: self.layout.barCount, + barWidth: self.layout.barWidth, + barSpacing: self.layout.barSpacing, + canvasSize: CGSize( + width: self.isPillSize ? self.pillVisualizerWidth : self.layout.waveformWidth, + height: self.layout.waveformHeight + ), + surface: self.overlaySurface, + motion: self.settings.overlayMotionIntensity, + companionVariant: self.settings.companionVariant, + companionAccessories: self.settings.companionAccessories, + hasTranscription: self.hasTranscription ) + if self.isPillSize, self.pillPreviewWidth > 0 { + self.pillPreviewView + } + // Compact overlays still need a visible mode because they have no selector. - if self.layout.showsModeLabel, !self.layout.showsTopControls { + if self.showsTextModeLabel, !self.layout.showsTopControls { VStack(alignment: .leading, spacing: 2) { Text(self.modeLabel) .font(.system(size: self.layout.modeFontSize, weight: .semibold)) @@ -3352,70 +3809,56 @@ struct BottomOverlayView: View { .frame(maxWidth: .infinity, alignment: .center) .background( ZStack { - // Solid pitch black background, with a soft drop shadow so the pill lifts + // Near-black surface, with a soft drop shadow so the pill lifts // off whatever is behind it (pill size only; outer padding reserves room). RoundedRectangle(cornerRadius: self.layout.cornerRadius) - .fill(Color.black) + .fill(self.overlaySurfaceFill) .shadow( - color: Color.black.opacity(self.isPillSize ? 0.32 : 0), + color: Color.black.opacity(self.isPillSize ? self.overlaySurface.shadowOpacity : 0), radius: self.isPillSize ? PillShadowMetrics.radius : 0, x: 0, y: self.isPillSize ? PillShadowMetrics.yOffset : 0 ) if self.isPillSize { - // Glossy border: a bright highlight that slowly rotates around the edge. - // Paused under reduce-motion to avoid continuous redraws on low-resource Macs. - if self.reduceMotion || !self.contentState.isBottomOverlayPresented { - RoundedRectangle(cornerRadius: self.layout.cornerRadius) - .strokeBorder( - AngularGradient( - gradient: Gradient(stops: [ - .init(color: .white.opacity(0.06), location: 0.00), - .init(color: .white.opacity(0.55), location: 0.13), - .init(color: .white.opacity(0.10), location: 0.30), - .init(color: .white.opacity(0.03), location: 0.55), - .init(color: .white.opacity(0.22), location: 0.80), - .init(color: .white.opacity(0.06), location: 1.00), - ]), - center: .center, - angle: .degrees(0) - ), - lineWidth: 1.2 - ) - } else { - TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in - let seconds = max( - 0, - timeline.date.timeIntervalSince(self.borderAnimationStartedAt ?? timeline.date) - ) - let angle = (seconds.truncatingRemainder(dividingBy: 6.0) / 6.0) * 360.0 - RoundedRectangle(cornerRadius: self.layout.cornerRadius) - .strokeBorder( - AngularGradient( - gradient: Gradient(stops: [ - .init(color: .white.opacity(0.06), location: 0.00), - .init(color: .white.opacity(0.55), location: 0.13), - .init(color: .white.opacity(0.10), location: 0.30), - .init(color: .white.opacity(0.03), location: 0.55), - .init(color: .white.opacity(0.22), location: 0.80), - .init(color: .white.opacity(0.06), location: 1.00), - ]), - center: .center, - angle: .degrees(angle) + // Barely visible top sheen: enough to give the capsule + // depth without turning it into a glossy surface. + RoundedRectangle(cornerRadius: self.layout.cornerRadius, style: .continuous) + .fill( + LinearGradient( + gradient: Gradient(stops: [ + .init( + color: Color.white.opacity(self.reduceTransparency ? 0.02 : self.overlaySurface.sheenOpacity), + location: 0.00 ), - lineWidth: 1.2 - ) - } - } + .init(color: Color.white.opacity(0.0), location: 0.55), + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + .allowsHitTesting(false) + + // Hairline themed border with one slow highlight travelling + // around the capsule. Stops completely while the pill is hidden. + OrbitalGlow( + cornerRadius: self.layout.cornerRadius, + palette: self.overlayPalette, + glow: self.overlayAppearance.glow, + level: self.contentState.bottomOverlayAudioLevel, + isActive: self.isPillAnimationActive, + reduceMotion: self.reduceMotion, + isMonochrome: self.overlayAppearance.style == .minimal, + isCompleting: self.contentState.didCompleteDelivery + ) } else { // Inner border RoundedRectangle(cornerRadius: self.layout.cornerRadius) .strokeBorder( LinearGradient( colors: [ - Color.white.opacity(self.overlayBorderTopOpacity), - Color.white.opacity(self.overlayBorderBottomOpacity), + self.overlayPalette.secondary.opacity(self.overlayBorderTopOpacity), + self.overlayPalette.secondary.opacity(self.overlayBorderBottomOpacity), ], startPoint: .top, endPoint: .bottom @@ -3433,12 +3876,13 @@ struct BottomOverlayView: View { } } .frame( - width: self.layout.usesFixedCanvas ? self.layout.overlayWidth : self.layout.containerWidth, + width: self.overlayCanvasWidth, height: self.overlayFrameHeight, alignment: .top ) - // Reserve space around the pill so its drop shadow isn't clipped by the (content-sized) window. - .padding(self.isPillSize ? 26 : 0) + // Reserve space around the pill so its drop shadow and aura are not clipped + // by the content-sized window. Keep in sync with OverlayAnchorGeometry. + .padding(self.usesCanvasPadding ? self.scaledCanvasPadding : 0) .frame(maxHeight: .infinity, alignment: .top) .scaleEffect(self.overlayAnimatedScale, anchor: .center) .offset(y: self.overlayAnimatedOffsetY) @@ -3450,7 +3894,14 @@ struct BottomOverlayView: View { BottomOverlayWindowController.shared.refreshSizeForContent() } .onChange(of: self.contentState.isBottomOverlayPresented) { _, presented in - self.borderAnimationStartedAt = presented ? Date() : nil + if presented { + self.beginEntryAnimation() + } else { + // The panel is already parked offscreen here, so resetting the + // entrance progress cannot be seen. It guarantees the next + // presentation starts from a fully transparent frame. + self.entryProgress = 0 + } } .onChange(of: self.settings.enableStreamingPreview) { _, _ in self.dynamicPreviewResizeBucket = self.previewResizeBucket(for: self.currentPreviewSizingText) diff --git a/Sources/Fluid/Views/NotchContentViews.swift b/Sources/Fluid/Views/NotchContentViews.swift index 1fd4cc501..c3f7809d5 100644 --- a/Sources/Fluid/Views/NotchContentViews.swift +++ b/Sources/Fluid/Views/NotchContentViews.swift @@ -302,6 +302,23 @@ class NotchContentState: ObservableObject { self.bottomOverlayDismissOffsetY = normalizedOffset } + // MARK: - Completion Flash + + /// True between a confirmed text delivery and the end of the completion + /// flash. Only ever set from the success path of the dictation pipeline, so + /// the overlay can never claim a delivery that did not happen. + @Published private(set) var didCompleteDelivery: Bool = false + + func markDeliveryCompleted() { + guard !self.didCompleteDelivery else { return } + self.didCompleteDelivery = true + } + + func clearDeliveryCompletion() { + guard self.didCompleteDelivery else { return } + self.didCompleteDelivery = false + } + // MARK: - Command Output Methods /// Show expanded output view with content @@ -991,13 +1008,26 @@ struct NotchExpandedView: View { private var notchBodyContent: some View { VStack(alignment: .center, spacing: 6) { HStack(spacing: 4) { - self.appIconView + if self.presentationPolicy.showsAppIcon { + self.appIconView + } - CompactNotchWaveformView( + // Same premium styles as the floating pill: the notch already + // publishes notch-presence, so Top Center on a notched Mac shows + // Aurora / Wave / Pulse / Minimal / Companion instead of a + // generic waveform. Ambient Glow gives the animation the whole + // cutout and drops the icon. + NotchStyleVisualizer( audioPublisher: self.audioPublisher, - color: self.modeColor + canvasSize: CGSize( + width: self.presentationPolicy.visualizerWidth, + height: self.presentationPolicy.visualizerHeight + ) + ) + .frame( + width: self.presentationPolicy.visualizerWidth, + height: self.presentationPolicy.visualizerHeight ) - .frame(width: 48, height: 18) self.promptSelectorControl @@ -1012,7 +1042,7 @@ struct NotchExpandedView: View { } } .frame(maxWidth: .infinity, alignment: .center) - .offset(x: 4, y: 0) + .offset(x: self.presentationPolicy.showsAppIcon ? 4 : 0, y: 0) .animation(.easeOut(duration: 0.14), value: self.contentState.spokenSendIndicatorState) self.promptHoverMenuRow diff --git a/Tests/FluidDictationIntegrationTests/LiveTypingSessionTests.swift b/Tests/FluidDictationIntegrationTests/LiveTypingSessionTests.swift new file mode 100644 index 000000000..c32e3c895 --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/LiveTypingSessionTests.swift @@ -0,0 +1,267 @@ +import CoreGraphics +@testable import FluidVoice_Debug +import Foundation +import XCTest + +/// Pure-logic coverage for the experimental Live Typing ownership session. +/// +/// These tests never touch Accessibility, a window server or the network: they +/// assert the safety rules the feature depends on, which is the only part of +/// Live Typing that can be verified without a live target application. +final class LiveTypingSessionTests: XCTestCase { + private func session( + anchor: Int = 0, + value: String = "", + level: LiveTypingLevel = .full + ) -> LiveTypingSession { + LiveTypingSession(targetPID: 4242, anchor: anchor, initialValue: value, level: level) + } + + // MARK: - Level 1 + + func testFullLevelWritesThenReplacesTheOwnedRange() { + var session = self.session() + XCTAssertEqual( + session.partial("Je pense que", currentValue: ""), + .write(range: NSRange(location: 0, length: 0), text: "Je pense que") + ) + session.noteWrite("Je pense que") + XCTAssertEqual(session.ownedRange, NSRange(location: 0, length: 12)) + XCTAssertTrue(session.contextIsIntact("Je pense que")) + + XCTAssertEqual( + session.partial("Je pense que demain", currentValue: "Je pense que"), + .write(range: NSRange(location: 0, length: 12), text: "Je pense que demain") + ) + } + + func testUnchangedPartialIsANoOp() { + var session = self.session() + session.noteWrite("Bonjour") + XCTAssertEqual(session.partial("Bonjour", currentValue: "Bonjour"), .none) + } + + // MARK: - Safety + + func testEditingBeforeTheOwnedRangeAbortsTheSession() { + var session = self.session(anchor: 5, value: "Hello world") + session.noteWrite(" big") + XCTAssertEqual( + session.partial(" big day", currentValue: "Heyo big world"), + .abort(.valueChangedExternally) + ) + } + + func testTypingAfterTheOwnedTextAbortsTheSession() { + var session = self.session(anchor: 5, value: "Hello world") + session.noteWrite(" big") + XCTAssertEqual( + session.partial(" big day", currentValue: "Hello bigX world"), + .abort(.valueChangedExternally) + ) + } + + func testTrailingTextIsPreservedWhileStreaming() { + var session = self.session(anchor: 5, value: "Hello world") + session.noteWrite(" beautiful") + // The " world" tail is still exactly where the session opened. + XCTAssertTrue(session.contextIsIntact("Hello beautiful world")) + XCTAssertFalse(session.contextIsIntact("Hello beautifulthere world")) + } + + func testRangeOutsideTheFieldIsNeverOwned() { + var session = self.session(anchor: 100, value: "short") + // The anchor is clamped into the field, so the range can never escape it. + XCTAssertEqual(session.anchor, 5) + session.noteWrite("!") + XCTAssertTrue(session.contextIsIntact("short!")) + } + + // MARK: - Level 2 + + func testCommittedChunksOnlyAppendAndNeverRewrite() { + var session = self.session(anchor: 0, value: "", level: .committedChunks) + XCTAssertEqual( + session.partial("Je pense que demain", currentValue: ""), + .write(range: NSRange(location: 0, length: 0), text: "Je pense que") + ) + session.noteWrite("Je pense que") + + // A revision that diverges from what is already written must not rewrite + // the committed prefix; the session stops instead. + XCTAssertEqual( + session.partial("Je pense demain matin", currentValue: "Je pense que"), + .abort(.verificationFailed) + ) + } + + func testCommittedChunksGrowMonotonically() { + var session = self.session(anchor: 0, value: "", level: .committedChunks) + session.noteWrite("Je pense") + XCTAssertEqual( + session.partial("Je pense que demain", currentValue: "Je pense"), + .write(range: NSRange(location: 0, length: 8), text: "Je pense que") + ) + } + + func testSingleWordPartialIsNotCommitted() { + var session = self.session(anchor: 0, value: "", level: .committedChunks) + XCTAssertEqual(session.partial("Bonjour", currentValue: ""), .none) + } + + // MARK: - Finalisation + + func testFinalFallsBackWhenNothingWasWritten() { + var session = self.session() + XCTAssertEqual(session.final("Bonjour", currentValue: ""), .fallbackToDelivery) + } + + func testFinalIsANoOpWhenTheFieldAlreadyMatches() { + var session = self.session() + session.noteWrite("Bonjour") + XCTAssertEqual(session.final("Bonjour", currentValue: "Bonjour"), .nothingToDo) + } + + func testFinalReplacesARevisedTranscript() { + var session = self.session() + session.noteWrite("Je pense que") + XCTAssertEqual( + session.final("Je pense que demain", currentValue: "Je pense que"), + .replace(range: NSRange(location: 0, length: 12), text: "Je pense que demain") + ) + } + + func testFinalAbortsWhenOwnershipWasLost() { + var session = self.session() + session.noteWrite("Je pense que") + XCTAssertEqual( + session.final("Je pense que demain", currentValue: "L'utilisateur a tapé autre chose"), + .abort(.valueChangedExternally) + ) + } + + /// A session that already wrote must still reconcile even after it + /// downgraded, otherwise the final paste would duplicate the text. + func testFinalStillReconcilesAfterADowngradeToFinalOnly() { + var session = self.session() + session.noteWrite("Bonjour") + session.downgrade(to: .finalOnly) + XCTAssertEqual( + session.final("Bonjour", currentValue: "Bonjour"), + .nothingToDo + ) + // But no new partial is ever written at that level. + XCTAssertEqual(session.partial("Bonjour tout le monde", currentValue: "Bonjour"), .none) + } + + // MARK: - Levels + + func testDowngradeOnlyMovesDownwards() { + var session = self.session(level: .full) + session.downgrade(to: .committedChunks) + XCTAssertEqual(session.level, .committedChunks) + session.downgrade(to: .full) + XCTAssertEqual(session.level, .committedChunks, "a session never upgrades mid-flight") + session.downgrade(to: .finalOnly) + XCTAssertEqual(session.level, .finalOnly) + } + + func testLevelsAreOrderedForTheAutomaticDowngrade() { + XCTAssertLessThan(LiveTypingLevel.full, .committedChunks) + XCTAssertLessThan(LiveTypingLevel.committedChunks, .finalOnly) + XCTAssertEqual(LiveTypingLevel.full.rawValue, 1) + XCTAssertEqual(LiveTypingLevel.finalOnly.rawValue, 3) + } + + func testFinalOnlyNeverWritesAPartial() { + var session = self.session(level: .finalOnly) + XCTAssertEqual(session.partial("Bonjour", currentValue: ""), .none) + XCTAssertFalse(session.hasWritten) + } + + // MARK: - Helpers + + func testOwnedRangeIsMeasuredInUTF16Units() { + var session = self.session(anchor: 2, value: "ab😀") + XCTAssertEqual(session.anchor, 2) + session.noteWrite("XY") + // Two ASCII characters, whatever the surrounding emoji costs. + XCTAssertEqual(session.ownedRange, NSRange(location: 2, length: 2)) + XCTAssertTrue(session.contextIsIntact("abXY😀")) + } + + func testStablePrefixIgnoresCaseAndPunctuation() { + XCTAssertEqual( + LiveTypingSession.stablePrefix(previous: "je pense que", current: "Je pense que demain"), + "Je pense que" + ) + XCTAssertEqual(LiveTypingSession.stablePrefix(previous: "bonjour", current: "Bonsoir"), "") + XCTAssertEqual(LiveTypingSession.stablePrefix(previous: "", current: "Bonjour"), "") + } + + func testCaretIsExpectedAtTheEndOfTheOwnedText() { + var session = self.session() + session.noteWrite("Bonjour") + XCTAssertTrue(session.caretIsAtOwnedEnd(CFRange(location: 7, length: 0))) + XCTAssertFalse(session.caretIsAtOwnedEnd(CFRange(location: 2, length: 0))) + XCTAssertFalse(session.caretIsAtOwnedEnd(nil)) + } + + func testObservedValueDoesNotFakeOwnership() { + var session = self.session() + session.noteObservedValue("Bonjour") + XCTAssertFalse(session.hasWritten) + XCTAssertEqual(session.final("Bonjour", currentValue: "Bonjour"), .fallbackToDelivery) + } + + // MARK: - Accessibilty capability probe + + private func capabilities( + value: Bool = true, + selection: Bool = true, + valueSettable: Bool = true, + selectionSettable: Bool = false, + secure: Bool = false + ) -> LiveTypingCapabilities { + LiveTypingCapabilities( + role: "AXTextArea", + subrole: secure ? "AXSecureTextField" : "AXStandardWindow", + canReadValue: value, + canReadSelection: selection, + valueIsSettable: valueSettable, + selectedTextIsSettable: selectionSettable, + isSecure: secure + ) + } + + func testCapabilitiesPredictLevelOneWhenTheValueIsSettable() { + XCTAssertEqual(self.capabilities().predictedLevel, .full) + } + + /// A settable selection is enough for committed chunk streaming. + func testCapabilitiesPredictLevelTwoWhenOnlyTheSelectionIsSettable() { + XCTAssertEqual( + self.capabilities(valueSettable: false, selectionSettable: true).predictedLevel, + .committedChunks + ) + } + + func testCapabilitiesFallBackToFinalOnlyWhenNothingIsUsable() { + XCTAssertEqual(self.capabilities(value: false).predictedLevel, .finalOnly) + XCTAssertEqual(self.capabilities(selection: false).predictedLevel, .finalOnly) + XCTAssertEqual( + self.capabilities(valueSettable: false, selectionSettable: false).predictedLevel, + .finalOnly + ) + } + + /// A secure field is never streamed into, whatever else it exposes. + func testSecureFieldsAreAlwaysFinalOnly() { + XCTAssertEqual(self.capabilities(secure: true).predictedLevel, .finalOnly) + } + + func testCapabilitySummaryNamesTheResolvedLevel() { + XCTAssertTrue(self.capabilities().summary.contains("Level 1")) + XCTAssertTrue(self.capabilities(secure: true).summary.contains("Level 3")) + } +} diff --git a/Tests/FluidDictationIntegrationTests/OverlayPremiumSettingsTests.swift b/Tests/FluidDictationIntegrationTests/OverlayPremiumSettingsTests.swift new file mode 100644 index 000000000..26a41ba97 --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/OverlayPremiumSettingsTests.swift @@ -0,0 +1,1231 @@ +import CoreGraphics +@testable import FluidVoice_Debug +import Foundation +import XCTest + +/// Pure-logic coverage for the premium overlay: persisted-value compatibility, +/// anchor math, glow modulation, envelope damping and preview sizing. +/// +/// Everything here is side-effect free on purpose. These tests never write to +/// UserDefaults, the Keychain or the audio cache, so they are safe to run on a +/// machine that is actively using FluidVoice. +@MainActor +final class OverlayPremiumSettingsTests: XCTestCase { + private let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982) + private let visibleFrame = CGRect(x: 0, y: 0, width: 1512, height: 950) + /// 108 pt pill + 2 x 26 pt canvas padding, 38 pt tall. + private let pillWindowSize = CGSize(width: 160, height: 90) + private let canvasPadding = SettingsStore.OverlayPosition.pillCanvasPadding + + // MARK: - OverlayPosition compatibility + + func testLegacyOverlayPositionRawValuesStillDecode() { + XCTAssertEqual(SettingsStore.OverlayPosition(rawValue: "top"), .topCenter) + XCTAssertEqual(SettingsStore.OverlayPosition(rawValue: "bottom"), .bottomCenter) + } + + func testPremiumAnchorRawValuesDecode() { + XCTAssertEqual(SettingsStore.OverlayPosition(rawValue: "topLeft"), .topLeft) + XCTAssertEqual(SettingsStore.OverlayPosition(rawValue: "topRight"), .topRight) + XCTAssertEqual(SettingsStore.OverlayPosition(rawValue: "bottomLeft"), .bottomLeft) + XCTAssertEqual(SettingsStore.OverlayPosition(rawValue: "bottomRight"), .bottomRight) + } + + func testUnknownPersistedPositionDoesNotDecode() { + XCTAssertNil(SettingsStore.OverlayPosition(rawValue: "middleLeft")) + } + + func testLegacyTopPreferenceKeepsTheNotchPresentation() { + let legacyTop = SettingsStore.OverlayPosition(rawValue: "top") + XCTAssertEqual(legacyTop?.usesNotchPresentation, true) + XCTAssertEqual(legacyTop?.usesFloatingOverlay, false) + } + + func testEveryOtherAnchorUsesTheFloatingPill() { + let floating: [SettingsStore.OverlayPosition] = [ + .bottomCenter, .topLeft, .topRight, .bottomLeft, .bottomRight, + ] + for anchor in floating { + XCTAssertTrue(anchor.usesFloatingOverlay, "\(anchor.rawValue) should use the floating pill") + XCTAssertFalse(anchor.usesNotchPresentation, "\(anchor.rawValue) should not use the notch") + } + } + + func testVerticalAndSidePredicates() { + XCTAssertTrue(SettingsStore.OverlayPosition.bottomLeft.isBottomAnchored) + XCTAssertTrue(SettingsStore.OverlayPosition.bottomCenter.isBottomAnchored) + XCTAssertTrue(SettingsStore.OverlayPosition.bottomRight.isBottomAnchored) + XCTAssertFalse(SettingsStore.OverlayPosition.topCenter.isBottomAnchored) + XCTAssertFalse(SettingsStore.OverlayPosition.topLeft.isBottomAnchored) + + XCTAssertTrue(SettingsStore.OverlayPosition.topRight.isSideAnchored) + XCTAssertTrue(SettingsStore.OverlayPosition.bottomLeft.isSideAnchored) + XCTAssertFalse(SettingsStore.OverlayPosition.topCenter.isSideAnchored) + XCTAssertFalse(SettingsStore.OverlayPosition.bottomCenter.isSideAnchored) + } + + func testOverlayPositionSurvivesCodableRoundTrip() throws { + for anchor in SettingsStore.OverlayPosition.allCases { + let data = try JSONEncoder().encode(anchor) + XCTAssertEqual(try JSONDecoder().decode(SettingsStore.OverlayPosition.self, from: data), anchor) + } + } + + // MARK: - Anchor geometry + + private func origin(for anchor: SettingsStore.OverlayPosition, bottomOffset: CGFloat = 50) -> CGPoint { + anchor.windowOrigin( + windowSize: self.pillWindowSize, + screenFrame: self.screenFrame, + visibleFrame: self.visibleFrame, + bottomOffset: bottomOffset, + canvasPadding: self.canvasPadding + ) + } + + /// The bottom offset positions the *visible* pill, not the transparent shadow + /// canvas around it. Anchoring the window instead made the pill float upwards + /// as the scale grew - the diagonal glide the polish pass reported. + func testBottomCenterPinsTheVisiblePillToTheBottomOffset() { + let origin = self.origin(for: .bottomCenter) + XCTAssertEqual(origin.x, self.screenFrame.midX - self.pillWindowSize.width / 2, accuracy: 0.001) + XCTAssertEqual(origin.y + self.canvasPadding, self.visibleFrame.minY + 50, accuracy: 0.001) + } + + func testBottomOffsetStillMovesTheBottomCenterAnchor() { + XCTAssertEqual( + self.origin(for: .bottomCenter, bottomOffset: 120).y + self.canvasPadding, + self.visibleFrame.minY + 120, + accuracy: 0.001 + ) + } + + /// P4: the anchored edge is invariant across the whole 50 - 200 % band, for + /// every anchor. This is the "no diagonal glide" guarantee, measured. + func testChangingTheScaleNeverMovesTheAnchoredEdge() { + let scales: [CGFloat] = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] + for anchor in SettingsStore.OverlayPosition.allCases { + var bottomEdge: [CGFloat] = [] + var topEdge: [CGFloat] = [] + var centreX: [CGFloat] = [] + var leftEdge: [CGFloat] = [] + var rightEdge: [CGFloat] = [] + for scale in scales { + let padding = self.canvasPadding * scale + // The pill is 108 x 38 with 26 pt of canvas padding on all sides, + // so a scale multiplies both the content and that padding. + let windowSize = CGSize( + width: (108 + self.canvasPadding * 2) * scale, + height: (38 + self.canvasPadding * 2) * scale + ) + let origin = anchor.windowOrigin( + windowSize: windowSize, + screenFrame: self.screenFrame, + visibleFrame: self.visibleFrame, + bottomOffset: 160, + canvasPadding: padding + ) + bottomEdge.append(origin.y + padding) + topEdge.append(origin.y + windowSize.height - padding) + centreX.append(origin.x + windowSize.width / 2) + leftEdge.append(origin.x + padding) + rightEdge.append(origin.x + windowSize.width - padding) + } + let spread = { (values: [CGFloat]) in (values.max() ?? 0) - (values.min() ?? 0) } + + switch anchor { + case .bottomCenter, .bottomLeft, .bottomRight: + XCTAssertLessThanOrEqual(spread(bottomEdge), 0.01, "\(anchor.rawValue) bottom edge drifted") + case .topCenter, .topLeft, .topRight: + XCTAssertLessThanOrEqual(spread(topEdge), 0.01, "\(anchor.rawValue) top edge drifted") + } + + switch anchor { + case .topCenter, .bottomCenter: + XCTAssertLessThanOrEqual(spread(centreX), 0.01, "\(anchor.rawValue) centre drifted") + case .topLeft, .bottomLeft: + XCTAssertLessThanOrEqual(spread(leftEdge), 0.01, "\(anchor.rawValue) left edge drifted") + case .topRight, .bottomRight: + XCTAssertLessThanOrEqual(spread(rightEdge), 0.01, "\(anchor.rawValue) right edge drifted") + } + } + } + + func testTopAnchorsSitJustBelowTheMenuBar() { + let anchors: [SettingsStore.OverlayPosition] = [.topCenter, .topLeft, .topRight] + for anchor in anchors { + let origin = self.origin(for: anchor) + let visibleTop = origin.y + self.pillWindowSize.height - self.canvasPadding + XCTAssertEqual( + visibleTop, + self.visibleFrame.maxY - SettingsStore.OverlayPosition.verticalEdgeInset, + accuracy: 0.001, + "\(anchor.rawValue) should clear the menu bar" + ) + } + } + + func testSideAnchorsRespectTheHorizontalEdgeInset() { + let left = self.origin(for: .topLeft) + XCTAssertEqual( + left.x + self.canvasPadding, + self.visibleFrame.minX + SettingsStore.OverlayPosition.horizontalEdgeInset, + accuracy: 0.001 + ) + let leftBottom = self.origin(for: .bottomLeft) + XCTAssertEqual(leftBottom.x, left.x, accuracy: 0.001) + + let right = self.origin(for: .topRight) + XCTAssertEqual( + right.x + self.pillWindowSize.width - self.canvasPadding, + self.visibleFrame.maxX - SettingsStore.OverlayPosition.horizontalEdgeInset, + accuracy: 0.001 + ) + let rightBottom = self.origin(for: .bottomRight) + XCTAssertEqual(rightBottom.x, right.x, accuracy: 0.001) + } + + func testVisiblePillNeverLeavesTheVisibleFrame() { + for anchor in SettingsStore.OverlayPosition.allCases { + let origin = self.origin(for: anchor) + let visibleMinX = origin.x + self.canvasPadding + let visibleMaxX = origin.x + self.pillWindowSize.width - self.canvasPadding + let visibleMinY = origin.y + self.canvasPadding + let visibleMaxY = origin.y + self.pillWindowSize.height - self.canvasPadding + + XCTAssertGreaterThanOrEqual(visibleMinX, self.visibleFrame.minX - 0.001, anchor.rawValue) + XCTAssertLessThanOrEqual(visibleMaxX, self.visibleFrame.maxX + 0.001, anchor.rawValue) + XCTAssertGreaterThanOrEqual(visibleMinY, self.visibleFrame.minY - 0.001, anchor.rawValue) + XCTAssertLessThanOrEqual(visibleMaxY, self.visibleFrame.maxY + 0.001, anchor.rawValue) + } + } + + func testOffsetsAreClampedIntoTheVisibleFrame() { + let huge = self.origin(for: .bottomCenter, bottomOffset: 100_000) + XCTAssertLessThanOrEqual( + huge.y + self.pillWindowSize.height, + self.visibleFrame.maxY + 0.001 + ) + } + + // MARK: - Glow intensity + + func testAuraOpacityMatchesTheDesignTargets() { + let normal = OverlayGlowIntensity.normal + XCTAssertEqual(normal.auraOpacity(forLevel: 0), 0.136, accuracy: 0.005) + XCTAssertEqual(normal.auraOpacity(forLevel: 0.5), 0.204, accuracy: 0.005) + XCTAssertEqual(normal.auraOpacity(forLevel: 1), 0.272, accuracy: 0.005) + } + + func testAuraOpacityIsMonotonicAndBounded() { + for intensity in OverlayGlowIntensity.allCases { + var previous = -1.0 + for step in 0...20 { + let value = intensity.auraOpacity(forLevel: CGFloat(step) / 20) + XCTAssertGreaterThanOrEqual(value, previous) + XCTAssertGreaterThanOrEqual(value, 0) + XCTAssertLessThanOrEqual(value, 0.42) + previous = value + } + XCTAssertEqual(intensity.auraOpacity(forLevel: -5), intensity.auraOpacity(forLevel: 0)) + XCTAssertEqual(intensity.auraOpacity(forLevel: 9), intensity.auraOpacity(forLevel: 1)) + } + } + + func testSubtleStaysCalmerThanVivid() { + for step in 0...10 { + let level = CGFloat(step) / 10 + XCTAssertLessThan( + OverlayGlowIntensity.subtle.auraOpacity(forLevel: level), + OverlayGlowIntensity.vivid.auraOpacity(forLevel: level) + ) + } + } + + // MARK: - Envelope follower + + func testFollowerRisesFasterThanItFalls() { + var follower = AudioEnvelopeFollower() + follower.update(with: 1) + let afterAttack = follower.smoothed + follower.update(with: 0) + let afterRelease = follower.smoothed + + XCTAssertGreaterThan(afterAttack, 0.5) + XCTAssertGreaterThan(afterRelease, 1 - afterAttack) + XCTAssertLessThan(afterRelease, afterAttack) + } + + func testDelayedSamplesAreOrderedNewestFirst() { + var follower = AudioEnvelopeFollower() + follower.update(with: 1) + + XCTAssertEqual(follower.delayedSample(at: 0), follower.smoothed, accuracy: 0.0001) + XCTAssertEqual(follower.delayedSample(at: AudioEnvelopeFollower.historyLength - 1), 0, accuracy: 0.0001) + XCTAssertEqual(follower.delayedSample(at: 999), 0, accuracy: 0.0001) + XCTAssertEqual(follower.delayedSample(at: -1), 0, accuracy: 0.0001) + } + + func testHistoryLengthIsStableAcrossManyUpdates() { + var follower = AudioEnvelopeFollower() + for step in 0..<200 { + follower.update(with: CGFloat(step % 10) / 10) + } + XCTAssertEqual(follower.history.count, AudioEnvelopeFollower.historyLength) + } + + func testResetReturnsTheFollowerToSilence() { + var follower = AudioEnvelopeFollower() + follower.update(with: 1) + follower.reset() + XCTAssertEqual(follower.smoothed, 0, accuracy: 0.0001) + XCTAssertEqual(follower.history.max() ?? -1, 0, accuracy: 0.0001) + } + + func testNormalizationGatesBackgroundNoiseAndClamps() { + XCTAssertEqual(AudioEnvelopeFollower.normalized(0.2, noiseThreshold: 0.4), 0, accuracy: 0.0001) + XCTAssertEqual(AudioEnvelopeFollower.normalized(1, noiseThreshold: 0.4), 1, accuracy: 0.0001) + XCTAssertEqual(AudioEnvelopeFollower.normalized(-3, noiseThreshold: 0.4), 0, accuracy: 0.0001) + XCTAssertEqual(AudioEnvelopeFollower.normalized(9, noiseThreshold: 0.4), 1, accuracy: 0.0001) + XCTAssertLessThan( + AudioEnvelopeFollower.normalized(0.6, noiseThreshold: 0.4), + AudioEnvelopeFollower.normalized(0.8, noiseThreshold: 0.4) + ) + } + + // MARK: - Pill preview sizing + + func testPreviewWidthIsZeroWithoutUsableText() { + XCTAssertEqual(PillPreviewSizing.width(for: "", fontSize: 10, characterLimit: 150), 0) + XCTAssertEqual(PillPreviewSizing.width(for: " \n ", fontSize: 10, characterLimit: 150), 0) + XCTAssertEqual(PillPreviewSizing.width(for: "hello", fontSize: 10, characterLimit: 0), 0) + } + + func testPreviewWidthIsCappedAndGrowsWithText() { + let longText = String(repeating: "a", count: 500) + XCTAssertEqual( + PillPreviewSizing.width(for: longText, fontSize: 10, characterLimit: 500), + PillPreviewSizing.maxWidth + ) + + let short = PillPreviewSizing.width(for: "bonjour", fontSize: 10, characterLimit: 150) + let longer = PillPreviewSizing.width(for: String(repeating: "a", count: 20), fontSize: 10, characterLimit: 150) + XCTAssertGreaterThan(longer, short) + XCTAssertLessThanOrEqual(longer, PillPreviewSizing.maxWidth) + } + + func testPreviewWidthHonoursTheCharacterLimit() { + let limit = 10 + let capped = PillPreviewSizing.width(for: String(repeating: "a", count: 400), fontSize: 10, characterLimit: limit) + let equivalent = PillPreviewSizing.width(for: String(repeating: "a", count: limit), fontSize: 10, characterLimit: 150) + XCTAssertEqual(capped, equivalent, accuracy: 0.001) + } + + func testVeryShortPreviewIsNotWorthTheExtraWidth() { + XCTAssertEqual(PillPreviewSizing.width(for: "a", fontSize: 2, characterLimit: 150), 0) + } + + // MARK: - Lifecycle + + func testLifecycleResolvesFromPublishedSignalsOnly() { + XCTAssertEqual( + OverlayLifecycleState.resolve( + isPresented: false, + isReleaseTransitioning: true, + isProcessing: true, + hasProcessingFailure: true + ), + .hidden + ) + XCTAssertEqual( + OverlayLifecycleState.resolve( + isPresented: true, + isReleaseTransitioning: false, + isProcessing: false, + hasProcessingFailure: false + ), + .recording + ) + XCTAssertEqual( + OverlayLifecycleState.resolve( + isPresented: true, + isReleaseTransitioning: true, + isProcessing: false, + hasProcessingFailure: false + ), + .processing + ) + XCTAssertEqual( + OverlayLifecycleState.resolve( + isPresented: true, + isReleaseTransitioning: false, + isProcessing: true, + hasProcessingFailure: false + ), + .processing + ) + XCTAssertEqual( + OverlayLifecycleState.resolve( + isPresented: true, + isReleaseTransitioning: false, + isProcessing: false, + hasProcessingFailure: true + ), + .error + ) + XCTAssertEqual( + OverlayLifecycleState.resolve( + isPresented: true, + isReleaseTransitioning: false, + isProcessing: true, + hasProcessingFailure: true + ), + .processing + ) + XCTAssertEqual( + OverlayLifecycleState.resolve( + isPresented: true, + isReleaseTransitioning: false, + isProcessing: false, + hasProcessingFailure: false, + isDeliveryCompleted: true + ), + .completed + ) + // A parked panel never reports completion. + XCTAssertEqual( + OverlayLifecycleState.resolve( + isPresented: false, + isReleaseTransitioning: false, + isProcessing: false, + hasProcessingFailure: false, + isDeliveryCompleted: true + ), + .hidden + ) + } + + func testOnlyRecordingShowsTheAudioVisualizer() { + XCTAssertTrue(OverlayLifecycleState.recording.showsVisualizer) + XCTAssertTrue(OverlayLifecycleState.hidden.showsVisualizer) + XCTAssertFalse(OverlayLifecycleState.processing.showsVisualizer) + XCTAssertFalse(OverlayLifecycleState.error.showsVisualizer) + XCTAssertFalse(OverlayLifecycleState.completed.showsVisualizer) + } + + // MARK: - Styles and themes + + func testFallbacksAreTheDocumentedDefaults() { + XCTAssertEqual(OverlayVisualStyle.fallback, .aurora) + XCTAssertEqual(OverlayColorTheme.fallback, .aurora) + XCTAssertEqual(OverlayGlowIntensity.fallback, .normal) + XCTAssertTrue(OverlayAppearance.fallback.showsTargetAppIcon) + } + + func testUnknownPersistedAppearanceValuesAreRejected() { + XCTAssertNil(OverlayVisualStyle(rawValue: "hologram")) + XCTAssertNil(OverlayColorTheme(rawValue: "neon")) + XCTAssertNil(OverlayGlowIntensity(rawValue: "blinding")) + } + + func testEveryStyleAndThemeIsPresentable() { + XCTAssertEqual( + Set(OverlayVisualStyle.allCases.map(\.rawValue)).count, + OverlayVisualStyle.allCases.count + ) + XCTAssertEqual( + Set(OverlayColorTheme.allCases.map(\.rawValue)).count, + OverlayColorTheme.allCases.count + ) + for style in OverlayVisualStyle.allCases { + XCTAssertFalse(style.displayName.isEmpty) + XCTAssertFalse(style.summary.isEmpty) + } + for theme in OverlayColorTheme.allCases { + XCTAssertGreaterThanOrEqual(theme.palette.gradientColors.count, 3) + XCTAssertGreaterThanOrEqual(theme.swatchColors.count, 3) + } + for intensity in OverlayGlowIntensity.allCases { + XCTAssertFalse(intensity.displayName.isEmpty) + XCTAssertGreaterThan(intensity.auraStrokeWidth, intensity.borderWidth) + XCTAssertGreaterThan(intensity.visualizerGlowScale, 0) + } + } + + func testAppearanceEnumsSurviveCodableRoundTrip() throws { + for style in OverlayVisualStyle.allCases { + let data = try JSONEncoder().encode(style) + XCTAssertEqual(try JSONDecoder().decode(OverlayVisualStyle.self, from: data), style) + } + for theme in OverlayColorTheme.allCases { + let data = try JSONEncoder().encode(theme) + XCTAssertEqual(try JSONDecoder().decode(OverlayColorTheme.self, from: data), theme) + } + for intensity in OverlayGlowIntensity.allCases { + let data = try JSONEncoder().encode(intensity) + XCTAssertEqual(try JSONDecoder().decode(OverlayGlowIntensity.self, from: data), intensity) + } + } + + // MARK: - Orbit timing + + func testOrbitCompletesOneTurnPerRevolution() { + let start = Date(timeIntervalSinceReferenceDate: 0) + XCTAssertEqual(OrbitalGlow.angle(for: start), 0, accuracy: 0.001) + XCTAssertEqual( + OrbitalGlow.angle(for: start.addingTimeInterval(OrbitalGlow.revolutionSeconds / 4)), + 90, + accuracy: 0.001 + ) + XCTAssertEqual( + OrbitalGlow.angle(for: start.addingTimeInterval(OrbitalGlow.revolutionSeconds * 0.75)), + 270, + accuracy: 0.001 + ) + XCTAssertLessThanOrEqual( + OrbitalGlow.angle(for: start.addingTimeInterval(123_456)), + 360 + ) + } + + func testOrbitSpeedStaysInTheAmbientRange() { + XCTAssertGreaterThanOrEqual(OrbitalGlow.revolutionSeconds, 4) + XCTAssertLessThanOrEqual(OrbitalGlow.revolutionSeconds, 7) + } + + // MARK: - Minimal dot geometry + + /// Regression guard: the Minimal style is a row of dots, and the Settings + /// miniature must be drawn from this same geometry instead of its own copy + /// of the numbers (it used to render bars, which no longer matched). + func testMinimalStyleIsANineDotRow() { + XCTAssertEqual(MinimalDotGeometry.dotCount, 9) + XCTAssertEqual(MinimalDotGeometry.previewRadii.count, MinimalDotGeometry.dotCount) + } + + func testMinimalDotRadiiStayWithinTheirBounds() { + for (index, radius) in MinimalDotGeometry.previewRadii.enumerated() { + XCTAssertGreaterThanOrEqual(radius, MinimalDotGeometry.minimumRadius, "dot \(index)") + XCTAssertLessThanOrEqual(radius, MinimalDotGeometry.maximumRadius, "dot \(index)") + } + } + + func testMinimalDotRowIsSymmetricAndBrightestInTheMiddle() { + let radii = MinimalDotGeometry.previewRadii + let lastIndex = radii.count - 1 + for index in 0...(lastIndex / 2) { + XCTAssertEqual(radii[index], radii[lastIndex - index], accuracy: 0.0001, "dot \(index)") + } + XCTAssertGreaterThan(radii[lastIndex / 2], radii[0]) + } + + func testMinimalTaperDampsTheEndsOfTheRow() { + let first = MinimalDotGeometry.taper(at: 0) + let middle = MinimalDotGeometry.taper(at: MinimalDotGeometry.dotCount / 2) + XCTAssertLessThan(first, middle) + XCTAssertEqual(middle, 1, accuracy: 0.0001) + XCTAssertGreaterThanOrEqual(first, MinimalDotGeometry.minimumTaper) + } + + func testMinimalPreviewRadiiUseTheLiveRadiusFormula() { + for (index, energy) in MinimalDotGeometry.previewEnergies.enumerated() { + XCTAssertEqual( + MinimalDotGeometry.previewRadii[index], + MinimalDotGeometry.radius(energy: energy, at: index), + accuracy: 0.0001 + ) + } + } + + func testMinimalRadiusFormulaIsMonotonicInEnergy() { + for index in 0.. Void) { + let previous = OverlayGlowTuning.strength + OverlayGlowTuning.strength = value + body() + OverlayGlowTuning.strength = previous + } + + func testGlowStrengthBandIsHalfToHundredFiftyPercent() { + XCTAssertEqual(OverlayGlowTuning.range.lowerBound, 0.5, accuracy: 0.0001) + XCTAssertEqual(OverlayGlowTuning.range.upperBound, 1.5, accuracy: 0.0001) + XCTAssertEqual(OverlayGlowTuning.neutral, 1.0, accuracy: 0.0001) + XCTAssertTrue(OverlayGlowTuning.range.contains(OverlayGlowTuning.neutral)) + } + + func testAdvancedStrengthScalesTheAuraAndTheVisualizerGlow() { + let preset = OverlayGlowIntensity.vivid + withGlowStrength(1.0) { + let base = preset.auraBaseOpacity + withGlowStrength(1.5) { + XCTAssertGreaterThan(preset.auraBaseOpacity, base) + XCTAssertGreaterThan(preset.auraBlurRadius, 6.5) + } + withGlowStrength(0.5) { + XCTAssertLessThan(preset.auraBaseOpacity, base) + } + } + } + + /// A hairline that grows linearly stops reading as a hairline, so the + /// advanced control deliberately moves it far less than the aura. + func testAdvancedStrengthBarelyMovesTheHairline() { + let preset = OverlayGlowIntensity.normal + withGlowStrength(1.0) { + let baseBorder = preset.borderWidth + let baseAura = preset.auraStrokeWidth + withGlowStrength(1.5) { + let borderRatio = preset.borderWidth / baseBorder + let auraRatio = preset.auraStrokeWidth / baseAura + XCTAssertLessThan(borderRatio, auraRatio) + XCTAssertLessThan(borderRatio, 1.2) + } + } + } + + func testExtremeStoredStrengthIsClampedWhenRead() { + withGlowStrength(12) { + XCTAssertEqual(OverlayGlowTuning.clampedStrength, 1.5, accuracy: 0.0001) + // Even at the ceiling the aura stays a gentle light. + XCTAssertLessThanOrEqual(OverlayGlowIntensity.vivid.auraBaseOpacity, 0.30) + } + withGlowStrength(-3) { + XCTAssertEqual(OverlayGlowTuning.clampedStrength, 0.5, accuracy: 0.0001) + XCTAssertGreaterThanOrEqual(OverlayGlowIntensity.subtle.borderWidth, 0.5) + } + } + + func testPresetOrderSurvivesTheAdvancedStrength() { + withGlowStrength(1.4) { + XCTAssertLessThan(OverlayGlowIntensity.subtle.visualizerGlowScale, OverlayGlowIntensity.normal.visualizerGlowScale) + XCTAssertLessThan(OverlayGlowIntensity.normal.visualizerGlowScale, OverlayGlowIntensity.vivid.visualizerGlowScale) + } + } + + // MARK: - Surface and custom theme + + func testSurfaceAppearanceResolvesAgainstTheSystemAppearance() { + XCTAssertFalse(OverlaySurfaceAppearance.dark.resolvesToLight(systemIsDark: false)) + XCTAssertFalse(OverlaySurfaceAppearance.dark.resolvesToLight(systemIsDark: true)) + XCTAssertTrue(OverlaySurfaceAppearance.light.resolvesToLight(systemIsDark: false)) + XCTAssertTrue(OverlaySurfaceAppearance.light.resolvesToLight(systemIsDark: true)) + // Automatic simply follows the system. + XCTAssertTrue(OverlaySurfaceAppearance.automatic.resolvesToLight(systemIsDark: false)) + XCTAssertFalse(OverlaySurfaceAppearance.automatic.resolvesToLight(systemIsDark: true)) + } + + func testLightSurfaceSwapsTheForegroundInsteadOfTheLight() { + let dark = OverlaySurfaceStyle.dark + let light = OverlaySurfaceStyle.light + XCTAssertFalse(dark.isLight) + XCTAssertTrue(light.isLight) + XCTAssertNotEqual(dark.primaryText, light.primaryText) + XCTAssertNotEqual(dark.fill, light.fill) + // A light surface needs stronger hairlines to stay visible. + XCTAssertGreaterThan(light.borderScale, dark.borderScale) + } + + func testCustomThemeDerivesAFullPaletteFromOneColor() { + for hex in ["#2FA8F0", "#E0533D", "#37C46B"] { + guard let palette = OverlayPalette.derive(fromHex: hex) else { + XCTFail("derivation failed for \(hex)") + continue + } + XCTAssertEqual(palette.gradientColors.count, 3) + XCTAssertNotEqual(palette.primary, palette.accent, hex) + XCTAssertNotEqual(palette.primary, palette.tertiary, hex) + } + } + + func testCustomThemeRejectsUnusableInput() { + XCTAssertNil(OverlayPalette.derive(fromHex: "nope")) + XCTAssertNil(OverlayPalette.derive(fromHex: "#12345")) + } + + func testCustomThemeIsARealThemeCase() { + XCTAssertTrue(OverlayColorTheme.allCases.contains(.custom)) + XCTAssertEqual(OverlayColorTheme.custom.rawValue, "custom") + XCTAssertEqual(OverlayColorTheme.custom.displayName, "Custom") + } + + /// Every modern format has to be buildable, so no size can end up without a + /// layout while the picker still offers it. + func testEverySelectableFormatHasAPositiveLayout() { + for size in SettingsStore.OverlaySize.selectable { + let layout = BottomOverlayView.LayoutConstants.get(for: size) + XCTAssertGreaterThan(layout.overlayWidth, 0, size.rawValue) + XCTAssertGreaterThan(layout.waveformWidth, 0, size.rawValue) + // Chromeless formats deliberately draw no surface, so they carry no + // corner radius; every format that draws one must round it. + if size == .svg { + XCTAssertEqual(layout.cornerRadius, 0, "the animation alone has no surface") + XCTAssertEqual(layout.iconSize, 0, "the animation alone has no icon") + XCTAssertFalse(layout.showsPreview, size.rawValue) + XCTAssertFalse(layout.showsModeLabel, size.rawValue) + } else { + XCTAssertGreaterThan(layout.cornerRadius, 0, size.rawValue) + } + // The orb is deliberately icon-less: it carries the visualizer only. + if size == .round { + XCTAssertEqual(layout.iconSize, 0, "the orb has no icon") + } else if size != .svg { + XCTAssertGreaterThan(layout.iconSize, 0, size.rawValue) + XCTAssertGreaterThanOrEqual(layout.iconSize, layout.cornerRadius, size.rawValue) + } + } + } + + func testNotchPresentationOffersAnAmbientGlowMode() { + XCTAssertTrue(SettingsStore.NotchPresentationMode.allCases.contains(.ambient)) + XCTAssertTrue(SettingsStore.NotchPresentationMode.ambient.displayName.contains("Ambient Glow")) + XCTAssertTrue(SettingsStore.NotchPresentationMode.ambient.displayName.contains("Beta")) + } + + /// Ambient Glow is the reference look: no icon, no text, a wider canvas + /// for the premium style. It must not depend on the hardware compact areas. + func testAmbientNotchPolicyDropsTheChromeAndEnlargesTheAnimation() { + let policy = NotchOverlayManager.NotchPresentationPolicy.forMode( + .ambient, + supportsCompactPresentation: false + ) + XCTAssertFalse(policy.showsAppIcon) + XCTAssertFalse(policy.showsPromptSelector) + XCTAssertFalse(policy.showsStreamingPreview) + XCTAssertFalse(policy.showsModeLabel) + XCTAssertFalse(policy.allowsCommandExpansion) + XCTAssertGreaterThan(policy.visualizerWidth, NotchStyleVisualizer.canvasWidth) + XCTAssertGreaterThanOrEqual(policy.visualizerHeight, NotchStyleVisualizer.canvasHeight) + // The compact and standard rows keep their own geometry. + let compact = NotchOverlayManager.NotchPresentationPolicy.forMode( + .minimal, + supportsCompactPresentation: true + ) + XCTAssertTrue(compact.showsAppIcon) + XCTAssertEqual(compact.visualizerWidth, NotchStyleVisualizer.canvasWidth, accuracy: 0.0001) + } + + func testNotchBarCountAdaptsToTheCanvasWidth() { + XCTAssertEqual(NotchStyleVisualizer.barCount(forWidth: NotchStyleVisualizer.canvasWidth), 10) + XCTAssertEqual(NotchStyleVisualizer.barCount(forWidth: 104), 16) + XCTAssertEqual(NotchStyleVisualizer.barCount(forWidth: 20), 6) + // The ambient row still fits inside its canvas. + let ambientBars = NotchStyleVisualizer.barCount(forWidth: 104) + let row = CGFloat(ambientBars) * NotchStyleVisualizer.barWidth + + CGFloat(ambientBars - 1) * NotchStyleVisualizer.barSpacing + XCTAssertLessThanOrEqual(row, 104) + } + + func testCompanionScaleStartsAtTheReferenceSizeAndCanGrow() { + XCTAssertEqual(SettingsStore.companionScaleRange.lowerBound, 0.5, accuracy: 0.0001) + XCTAssertEqual(SettingsStore.companionScaleRange.upperBound, 3.0, accuracy: 0.0001) + XCTAssertEqual(SettingsStore.companionScaleDefault, 1.0, accuracy: 0.0001) + XCTAssertTrue(SettingsStore.companionScaleRange.contains(SettingsStore.companionScaleDefault)) + XCTAssertGreaterThan(CompanionMetrics.baseSide, 0) + } + + /// The Companion is a style, so its expression must come from the phases the + /// pipeline already publishes - never from a state of its own. + func testCompanionStyleMapsTheLifecycleToAnExpression() { + XCTAssertEqual(OverlayVisualizerView.companionState(for: .hidden, hasTranscription: false), .idle) + XCTAssertEqual(OverlayVisualizerView.companionState(for: .recording, hasTranscription: false), .listening) + XCTAssertEqual(OverlayVisualizerView.companionState(for: .recording, hasTranscription: true), .typing) + XCTAssertEqual(OverlayVisualizerView.companionState(for: .processing, hasTranscription: false), .thinking) + XCTAssertEqual(OverlayVisualizerView.companionState(for: .completed, hasTranscription: false), .completed) + XCTAssertEqual(OverlayVisualizerView.companionState(for: .error, hasTranscription: false), .error) + } + + // MARK: - Aurora multilayer geometry (P1) + + /// `__/\____`, not `/`. The centreline may weave, but it must stay centred on + /// its own axis, otherwise the whole composition reads as a tilted object. + func testAuroraLayerCentrelinesStayOnTheirAxis() { + let rect = CGRect(x: 0, y: 0, width: 104, height: 44) + for spec in [AuroraLayerSpec.atmosphere, .mass, .accent] { + for phase in [CGFloat(0), 1.3, 2.7, 4.1] { + let geometry = AuroraLayerGeometry( + rect: rect, + spec: spec, + samples: [], + level: 0.7, + phase: phase + ) + var sum: CGFloat = 0 + for step in 0.. CGFloat { + let geometry = AuroraLayerGeometry(rect: rect, spec: .mass, samples: [], level: level, phase: 1.1) + var peak: CGFloat = 0 + for step in 0.. CompanionVariant.water.tuning.rise) + XCTAssertTrue(CompanionVariant.wind.tuning.membraneCount > CompanionVariant.earth.tuning.membraneCount) + XCTAssertGreaterThan(CompanionVariant.earth.tuning.membraneCount, 0) + } + + /// Default and Aurora follow the user's overlay theme (including Custom); + /// the elemental variants carry their own identity. + func testCompanionPaletteFollowsTheThemeUnlessTheVariantIsElemental() { + let themePalette = OverlayColorTheme.blue.palette + XCTAssertEqual(CompanionVariant.standard.palette(themePalette: themePalette), themePalette) + XCTAssertEqual(CompanionVariant.aurora.palette(themePalette: themePalette), themePalette) + XCTAssertNotEqual(CompanionVariant.fire.palette(themePalette: themePalette), themePalette) + XCTAssertNotEqual(CompanionVariant.gothic.palette(themePalette: themePalette), themePalette) + } + + func testMotionIntensityIsOrderedAndHasASafeFallback() { + XCTAssertLessThan(MotionIntensity.subtle.scale, MotionIntensity.normal.scale) + XCTAssertLessThan(MotionIntensity.normal.scale, MotionIntensity.expressive.scale) + XCTAssertEqual(MotionIntensity.fallback, .normal) + XCTAssertEqual(CompanionVariant.fallback, .standard) + } + +} + + From 1870780ef40d11477ec3a2805754c81d905fccb4 Mon Sep 17 00:00:00 2001 From: kevinmokai007-alt Date: Wed, 16 Sep 2026 03:46:52 +0200 Subject: [PATCH 2/2] fix: address review feedback on live typing, settings and overlay Live Typing safety (Codex P1 / Greptile P1): - Bind the session to the field that was focused when recording started (bindRecordingFocus) instead of resolving "whatever is focused now" when the first partial arrives, and re-check that exact element before every write. A partial can no longer land in a field the user moved to. - Keep a tombstone when a session aborts after it already wrote text, so a later partial can not restart a session over it and the final transcript is not inserted a second time. - Treat an absent or empty AX subrole as secure, matching isSecure(), so an unreadable field is never streamed into. Extracted as a pure, tested helper. - Stop clobbering the clipboard on fallback: the transcript is written as a transient, auto-generated item and the previous contents are restored a moment later unless the user changed the clipboard. Spoken Send (Codex P1 / Greptile P1): - When Live Typing already owns the text, still dispatch the configured post-insertion key with an empty plan and report the real outcome, instead of returning .inserted and leaving the send undone. Settings / overlay: - Restore the upstream default for enableStreamingPreview so upgrading never silently disables the live preview. - Post OverlaySizeChanged when the visual style or Companion size changes, so a visible panel re-measures instead of clipping the new style. - Clear the completion flag after the flash dwell, not before it. - Correct the scale slider endpoint labels to the real 25%-300% band. Tests: 560 total, 1 pre-existing AZERTY failure on the baseline. --- Sources/Fluid/ContentView.swift | 3 + Sources/Fluid/Persistence/SettingsStore.swift | 13 +- Sources/Fluid/Services/ASRService.swift | 45 ++++++- .../LiveTyping/LiveTypingAXTarget.swift | 58 ++++----- .../LiveTyping/LiveTypingController.swift | 120 ++++++++++++++++-- .../LiveTyping/LiveTypingSession.swift | 14 ++ Sources/Fluid/UI/SettingsView.swift | 4 +- Sources/Fluid/Views/BottomOverlayView.swift | 9 +- .../LiveTypingSessionTests.swift | 63 +++++++++ 9 files changed, 275 insertions(+), 54 deletions(-) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 173a0f064..9f3a3cdf8 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -2082,6 +2082,9 @@ struct ContentView: View { // Used to restore focus when the user interacts with overlay dropdowns. let focusTarget = TypingService.captureSystemFocusTarget() self.recordingFocusTarget = focusTarget + // Live Typing may only ever write into this exact field, so it is bound + // here rather than resolved again when the first partial arrives. + LiveTypingController.shared.bindRecordingFocus(focusTarget) let focusedPID = focusTarget?.pid ?? NSWorkspace.shared.frontmostApplication?.processIdentifier NotchContentState.shared.recordingTargetPID = focusedPID diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index da4f125ea..0b1514708 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -1828,10 +1828,10 @@ final class SettingsStore: ObservableObject { var enableStreamingPreview: Bool { get { let value = self.defaults.object(forKey: Keys.enableStreamingPreview) - // Off unless the user turned it on: the compact pill is the product - // default, and an explicit choice is still preserved because the key - // is only absent when it was never written. - return value as? Bool ?? false + // Unchanged upstream default: an install that never wrote the key + // keeps the live preview it already had. Only an explicit choice is + // read here, so upgrading never silently disables it. + return value as? Bool ?? true } set { objectWillChange.send() @@ -2309,6 +2309,10 @@ final class SettingsStore: ObservableObject { set { objectWillChange.send() self.defaults.set(newValue.rawValue, forKey: Keys.overlayVisualStyle) + // The styles do not share a footprint (Companion is chromeless and + // bigger), so a visible panel must re-measure instead of keeping the + // previous frame until an unrelated update arrives. + NotificationCenter.default.post(name: NSNotification.Name("OverlaySizeChanged"), object: nil) } } @@ -2422,6 +2426,7 @@ final class SettingsStore: ObservableObject { objectWillChange.send() self.defaults.set(clamped, forKey: Keys.companionScale) Self.postCompanionSettingsChanged() + NotificationCenter.default.post(name: NSNotification.Name("OverlaySizeChanged"), object: nil) } } diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index d3eb631f0..1a5e4bfd0 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -5545,8 +5545,10 @@ final class ASRService: ObservableObject { completion: (@MainActor (TypingService.DeliveryOutcome) -> Void)? = nil ) { // Live Typing may already own the text in the field. When it does, the - // normal insertion must not run again. + // normal insertion must not run again - but the caller still needs its + // completion so overlay dismissal and the pipeline summary are not lost. if LiveTypingController.shared.consumeFinalDelivery(plainText: plan.plainText) { + completion?(.inserted) return } let requestedAt = ProcessInfo.processInfo.systemUptime @@ -5583,10 +5585,23 @@ final class ASRService: ObservableObject { postInsertionKey: SettingsStore.SpokenSendKey? = nil, requiredFocusTarget: TypingService.CapturedFocusTarget? = nil ) async -> TypingService.DeliveryOutcome { - // Same hand-off as the fire-and-forget path: Live Typing owns the text, - // so report the insertion as already done. + // Same hand-off as the fire-and-forget path, except that this one may + // still owe a post-insertion action (Spoken Send). The text is already + // in the field, so only the key is dispatched - and its real outcome is + // reported, so a spoken send is not reported as failed. if LiveTypingController.shared.consumeFinalDelivery(plainText: plan.plainText) { - return .inserted + guard let postInsertionKey else { return .inserted } + let actionOutcome = await self.dispatchPostInsertionActionOnly( + preferredTargetPID: preferredTargetPID, + textReadyAt: textReadyAt, + postInsertionKey: postInsertionKey, + requiredFocusTarget: requiredFocusTarget + ) + switch actionOutcome { + case .actionDispatched: return .insertedAndActionDispatched + case .actionSuppressed: return .insertedActionSuppressed + default: return .inserted + } } let requestedAt = ProcessInfo.processInfo.systemUptime let textReadyAge = textReadyAt.map { Int(((requestedAt - $0) * 1000).rounded()) } @@ -5620,6 +5635,28 @@ final class ASRService: ObservableObject { return outcome } + /// Runs the delivery pipeline with an empty plan, which dispatches only the + /// configured post-insertion action. Used when Live Typing already owns the + /// text: the insertion is done, the Spoken Send key is still due. + private func dispatchPostInsertionActionOnly( + preferredTargetPID: pid_t?, + textReadyAt: TimeInterval?, + postInsertionKey: SettingsStore.SpokenSendKey, + requiredFocusTarget: TypingService.CapturedFocusTarget? + ) async -> TypingService.DeliveryOutcome { + await withCheckedContinuation { continuation in + self.typingService.typeOutputPlanInstantly( + .plain(""), + preferredTargetPID: preferredTargetPID, + textReadyAt: textReadyAt, + postInsertionKey: postInsertionKey, + requiredFocusTarget: requiredFocusTarget + ) { outcome in + continuation.resume(returning: outcome) + } + } + } + /// Removes filler sounds from transcribed text static func removeFillerWords(_ text: String) -> String { guard SettingsStore.shared.removeFillerWordsEnabled else { return text } diff --git a/Sources/Fluid/Services/LiveTyping/LiveTypingAXTarget.swift b/Sources/Fluid/Services/LiveTyping/LiveTypingAXTarget.swift index 485993533..b26cf19bd 100644 --- a/Sources/Fluid/Services/LiveTyping/LiveTypingAXTarget.swift +++ b/Sources/Fluid/Services/LiveTyping/LiveTypingAXTarget.swift @@ -69,32 +69,19 @@ struct LiveTypingAXTarget { let element: AXUIElement let bundleIdentifier: String? - /// Resolves the focused element, reusing the same system-wide lookup the - /// typing service uses. Returns nil when Accessibility is not granted or when - /// the focused element is not a text field. - static func capture(preferredPID: pid_t?) -> LiveTypingAXTarget? { - guard AXIsProcessTrusted() else { return nil } - - let systemWide = AXUIElementCreateSystemWide() - var focusedRef: CFTypeRef? - let result = AXUIElementCopyAttributeValue( - systemWide, - kAXFocusedUIElementAttribute as CFString, - &focusedRef - ) - guard result == .success, let focusedRef, - CFGetTypeID(focusedRef) == AXUIElementGetTypeID() - else { return nil } - - let element = unsafeBitCast(focusedRef, to: AXUIElement.self) - var pid: pid_t = 0 - AXUIElementGetPid(element, &pid) - guard pid > 0 else { return nil } - // A session may only continue in the process it started in. - if let preferredPID, preferredPID > 0, pid != preferredPID { return nil } - - let bundle = NSRunningApplication(processIdentifier: pid)?.bundleIdentifier - return LiveTypingAXTarget(pid: pid, element: element, bundleIdentifier: bundle) + /// Whether a subrole identifies a field that must never be streamed into. + /// + /// A subrole we cannot read is *not* proof that the field is safe, so an + /// unknown or absent value is treated as secure. The caller then falls back + /// to the final-only paste it has always used. Pure and nonisolated so the + /// conservative rule is testable without a live Accessibility element. + nonisolated static func isSecureSubrole(_ subrole: String?) -> Bool { + // An absent *or empty* subrole is just as unreadable as a failed lookup. + guard let subrole, + !subrole.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return true } + return subrole == (kAXSecureTextFieldSubrole as String) + || subrole.localizedCaseInsensitiveContains("secure") } /// Secure fields must never be streamed into. @@ -109,8 +96,18 @@ struct LiveTypingAXTarget { // field. The caller falls back to final-only delivery. return true } - return subrole == (kAXSecureTextFieldSubrole as String) - || subrole.localizedCaseInsensitiveContains("secure") + return Self.isSecureSubrole(subrole) + } + + /// True when this exact element is still the system-wide focused element. + /// + /// Live Typing re-checks this before every write, so a revised partial can + /// never land in a field the user moved to after recording started. Reuses + /// the same exact-element comparison the final delivery path already uses. + func isStillFocused() -> Bool { + TypingService.isExactFocusTargetActive( + TypingService.CapturedFocusTarget(pid: self.pid, window: nil, element: self.element) + ) } func value() -> String? { @@ -212,8 +209,9 @@ struct LiveTypingAXTarget { func capabilities() -> LiveTypingCapabilities { let role = self.stringAttribute(kAXRoleAttribute as CFString) let subrole = self.stringAttribute(kAXSubroleAttribute as CFString) - let isSecure = subrole == (kAXSecureTextFieldSubrole as String) - || (subrole ?? "").localizedCaseInsensitiveContains("secure") + // Same conservative rule as isSecure(): an unreadable subrole is not + // proof that the field is safe, so it must not be streamed into. + let isSecure = Self.isSecureSubrole(subrole) return LiveTypingCapabilities( role: role, subrole: subrole, diff --git a/Sources/Fluid/Services/LiveTyping/LiveTypingController.swift b/Sources/Fluid/Services/LiveTyping/LiveTypingController.swift index 7d8a2f66f..07b60f655 100644 --- a/Sources/Fluid/Services/LiveTyping/LiveTypingController.swift +++ b/Sources/Fluid/Services/LiveTyping/LiveTypingController.swift @@ -33,11 +33,29 @@ final class LiveTypingController: ObservableObject { private var session: LiveTypingSession? private var target: LiveTypingAXTarget? + /// The exact field that was focused when the recording started. Live Typing + /// never resolves "whatever is focused now" on its own: a partial may only + /// be written into the field the user began dictating into. + private var recordingTarget: LiveTypingAXTarget? /// Highest level the current session is still allowed to use. private(set) var effectiveLevel: LiveTypingLevel = .finalOnly private init() {} + /// Records the field the recording started in. Called once per dictation, + /// before any partial can arrive. + func bindRecordingFocus(_ focusTarget: TypingService.CapturedFocusTarget?) { + guard let focusTarget else { + self.recordingTarget = nil + return + } + self.recordingTarget = LiveTypingAXTarget( + pid: focusTarget.pid, + element: focusTarget.element, + bundleIdentifier: NSRunningApplication(processIdentifier: focusTarget.pid)?.bundleIdentifier + ) + } + var isStreaming: Bool { self.session?.hasWritten == true } @@ -56,6 +74,24 @@ final class LiveTypingController: ObservableObject { self.beginSession() } guard var session = self.session, let target = self.target else { return } + // A session that already wrote text and then lost ownership leaves a + // tombstone: the partial is still in the field, so nothing may start a + // second session on top of it. + guard !session.abortedAfterWrite else { return } + + // The field the recording started in must still be the focused one. + // Otherwise this partial would be written into whatever the user moved + // to, so the session stops and the final paste is suppressed instead. + guard target.isStillFocused() else { + session.markAborted() + self.session = session + DebugLogger.shared.info( + "Live typing stopped: focus moved to another field", + source: "LiveTyping" + ) + return + } + guard let value = target.value() else { self.downgrade(&session) self.session = session @@ -92,29 +128,43 @@ final class LiveTypingController: ObservableObject { "Live typing stopped: \(reason.rawValue)", source: "LiveTyping" ) - self.reset() + // Keep the session as a tombstone when it already wrote text, so a + // later partial can not restart it and the final transcript is not + // pasted a second time. + session.markAborted() + self.session = session } } /// Called just before the normal insertion. Returns true when Live Typing /// already owns the text and the caller must not insert it again. func consumeFinalDelivery(plainText: String) -> Bool { - guard var session = self.session, let target = self.target else { + guard var session = self.session else { self.reset() return false } defer { self.reset() } + // A session that wrote text and then had to stop still owns the outcome: + // its partial is in the field, so running the normal paste here would + // insert the transcript a second time. + if session.abortedAfterWrite { + DebugLogger.shared.info( + "Live typing stopped after writing; keeping the transcript off the field", + source: "LiveTyping" + ) + self.preserveToPasteboard(plainText) + return true + } + + guard let target = self.target else { return false } guard session.hasWritten else { return false } // Focus must still be exactly where the session opened. Writing the final // text into a background field (or a different field of the same app) // would surprise the user, so the transcript goes to the pasteboard // instead of being inserted somewhere they are not looking. - guard let focused = LiveTypingAXTarget.capture(preferredPID: nil), - focused.pid == target.pid, - CFEqual(focused.element, target.element) - else { + guard target.isStillFocused() else { DebugLogger.shared.info( "Live typing focus moved; keeping the transcript on the pasteboard", source: "LiveTyping" @@ -169,11 +219,15 @@ final class LiveTypingController: ObservableObject { // MARK: - Private private func beginSession() { - guard let target = LiveTypingAXTarget.capture(preferredPID: nil) else { - // No readable field: stay at Level 3, which is the behaviour the app - // has always had. + // Only ever the field the recording started in, and only while it is + // still the focused one. + guard let target = self.recordingTarget, target.isStillFocused() else { + // No captured target, or the user already moved on: stay at Level 3, + // which is the behaviour the app has always had. self.effectiveLevel = .finalOnly - self.lastReport = "No focused text field (or Accessibility not granted). Level 3 (final paste)." + self.lastReport = + "Live Typing only writes into the field that was focused when recording started. " + + "Level 3 (final paste)." return } let capabilities = target.capabilities() @@ -222,9 +276,53 @@ final class LiveTypingController: ObservableObject { ) == text } + /// A restorative copy of the user's clipboard. + private struct PasteboardSnapshot { + let items: [[NSPasteboard.PasteboardType: Data]] + } + + private static func capturePasteboard(_ pasteboard: NSPasteboard) -> PasteboardSnapshot { + let items = pasteboard.pasteboardItems?.map { item -> [NSPasteboard.PasteboardType: Data] in + var data: [NSPasteboard.PasteboardType: Data] = [:] + for type in item.types { + if let value = item.data(forType: type) { data[type] = value } + } + return data + } ?? [] + return PasteboardSnapshot(items: items) + } + + private static func restorePasteboard(_ snapshot: PasteboardSnapshot, to pasteboard: NSPasteboard) { + pasteboard.clearContents() + guard !snapshot.items.isEmpty else { return } + let restored = snapshot.items.map { entry -> NSPasteboardItem in + let item = NSPasteboardItem() + for (type, data) in entry { item.setData(data, forType: type) } + return item + } + _ = pasteboard.writeObjects(restored) + } + + /// Makes the final transcript available without destroying what the user had + /// copied: it is written as a transient, auto-generated item (so clipboard + /// managers stay out of it) and the previous contents are put back a moment + /// later unless the user moved on. The transcript is also in the dictation + /// history, so nothing is lost either way. private func preserveToPasteboard(_ text: String) { let pasteboard = NSPasteboard.general + let snapshot = Self.capturePasteboard(pasteboard) + pasteboard.clearContents() - pasteboard.setString(text, forType: .string) + guard pasteboard.writeObjects([TypingService.makeTransientPasteboardItem(text)]) else { + Self.restorePasteboard(snapshot, to: pasteboard) + return + } + let ourChangeCount = pasteboard.changeCount + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + // Only restore when the clipboard is still exactly what we wrote, so + // a copy the user made in the meantime is never clobbered. + guard pasteboard.changeCount == ourChangeCount else { return } + Self.restorePasteboard(snapshot, to: pasteboard) + } } } diff --git a/Sources/Fluid/Services/LiveTyping/LiveTypingSession.swift b/Sources/Fluid/Services/LiveTyping/LiveTypingSession.swift index c62fc5cf8..4472628e2 100644 --- a/Sources/Fluid/Services/LiveTyping/LiveTypingSession.swift +++ b/Sources/Fluid/Services/LiveTyping/LiveTypingSession.swift @@ -91,6 +91,10 @@ nonisolated struct LiveTypingSession: Equatable { private(set) var level: LiveTypingLevel /// True once at least one write reached the field. private(set) var hasWritten: Bool + /// True once the session stopped rewriting *after* writing. The field still + /// holds our text, so the normal paste must stay suppressed for the rest of + /// this dictation - nothing may start a second session on top of it. + private(set) var abortedAfterWrite: Bool init( sessionID: UUID = UUID(), @@ -110,6 +114,7 @@ nonisolated struct LiveTypingSession: Equatable { self.lastKnownValue = initialValue self.level = level self.hasWritten = false + self.abortedAfterWrite = false } /// The exact range FluidVoice owns, in UTF-16 units. @@ -183,6 +188,15 @@ nonisolated struct LiveTypingSession: Equatable { self.lastKnownValue = value } + /// Marks the session as stopped. A session that never wrote anything is + /// simply over - the normal paste still runs - but one that did write leaves + /// a tombstone so its text is reconciled instead of duplicated. + mutating func markAborted() { + if self.hasWritten { + self.abortedAfterWrite = true + } + } + /// Level 2 to 3 never loses text: the session simply stops rewriting and the /// final delivery takes over. mutating func downgrade(to newLevel: LiveTypingLevel) { diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index 1a877bb43..9b41911bf 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -1417,7 +1417,7 @@ struct SettingsView: View { } HStack(spacing: 10) { - Text("50%") + Text("25%") .font(.caption) .foregroundStyle(self.settingsSecondaryText) .frame(width: 36, alignment: .trailing) @@ -1432,7 +1432,7 @@ struct SettingsView: View { ) .controlSize(.regular) - Text("200%") + Text("300%") .font(.caption) .foregroundStyle(self.settingsSecondaryText) .frame(width: 36, alignment: .leading) diff --git a/Sources/Fluid/Views/BottomOverlayView.swift b/Sources/Fluid/Views/BottomOverlayView.swift index 7b8d0ecb5..728fbce09 100644 --- a/Sources/Fluid/Views/BottomOverlayView.swift +++ b/Sources/Fluid/Views/BottomOverlayView.swift @@ -290,9 +290,9 @@ final class BottomOverlayWindowController { self.clearPresentationResources() if self.isCompletionFlashPending { - // Success path only: let the check land, then run the normal - // dismissal. Bounded, and superseded instantly by a new recording. - NotchContentState.shared.clearDeliveryCompletion() + // Success path only: the completion visual owns this bounded dwell, + // then the normal dismissal runs. Superseded instantly by a new + // recording; text delivery has already finished either way. self.startDismissalVisual() try? await Task.sleep(nanoseconds: UInt64(Self.completionFlashDuration * 1_000_000_000)) } else { @@ -304,6 +304,9 @@ final class BottomOverlayWindowController { return .superseded } + // Cleared only after the dwell: clearing it first left the overlay + // dismissing without ever showing the completion it was flashing. + NotchContentState.shared.clearDeliveryCompletion() self.parkWindowOffscreen() window.alphaValue = 1 NotchContentState.shared.setBottomOverlayPresented(false) diff --git a/Tests/FluidDictationIntegrationTests/LiveTypingSessionTests.swift b/Tests/FluidDictationIntegrationTests/LiveTypingSessionTests.swift index c32e3c895..60e3ef877 100644 --- a/Tests/FluidDictationIntegrationTests/LiveTypingSessionTests.swift +++ b/Tests/FluidDictationIntegrationTests/LiveTypingSessionTests.swift @@ -154,6 +154,69 @@ final class LiveTypingSessionTests: XCTestCase { XCTAssertEqual(session.partial("Bonjour tout le monde", currentValue: "Bonjour"), .none) } + // MARK: - Abort tombstone + + /// Once a session has written text and then stopped, the field still holds + /// that text: the final delivery must keep being suppressed instead of + /// pasting the transcript a second time. + func testAbortAfterAWriteLeavesATombstone() { + var session = self.session() + session.noteWrite("Je pense que") + session.markAborted() + XCTAssertTrue(session.hasWritten) + XCTAssertTrue(session.abortedAfterWrite) + // The tombstone survives further bookkeeping, so a later partial can + // never start a fresh session over the same field. + session.noteObservedValue("Je pense que demain") + XCTAssertTrue(session.abortedAfterWrite) + } + + /// A session that never wrote anything is simply over: the normal paste + /// still has to run, so no tombstone is left behind. + func testAbortWithoutAWriteLeavesNoTombstone() { + var session = self.session() + session.markAborted() + XCTAssertFalse(session.abortedAfterWrite) + XCTAssertEqual(session.final("Bonjour", currentValue: ""), .fallbackToDelivery) + } + + func testFreshSessionHasNoTombstone() { + XCTAssertFalse(self.session().abortedAfterWrite) + } + + // MARK: - Secure fields + + /// An unreadable subrole is not proof that a field is safe, so it must be + /// treated exactly like a secure one. + func testUnknownSubroleIsTreatedAsSecure() { + XCTAssertTrue(LiveTypingAXTarget.isSecureSubrole(nil)) + XCTAssertTrue(LiveTypingAXTarget.isSecureSubrole("")) + XCTAssertTrue(LiveTypingAXTarget.isSecureSubrole("AXSecureTextField")) + XCTAssertTrue(LiveTypingAXTarget.isSecureSubrole("AXSecureTextArea")) + XCTAssertTrue(LiveTypingAXTarget.isSecureSubrole("AXSecureTextFieldSubrole")) + } + + func testOrdinarySubrolesAreNotSecure() { + XCTAssertFalse(LiveTypingAXTarget.isSecureSubrole("AXTextField")) + XCTAssertFalse(LiveTypingAXTarget.isSecureSubrole("AXTextArea")) + XCTAssertFalse(LiveTypingAXTarget.isSecureSubrole("AXSearchField")) + } + + /// A nil subrole is reported as secure by the probe, so the predicted level + /// can never be Level 1. + func testCapabilitiesWithAnUnreadableSubroleStayFinalOnly() { + let unknown = LiveTypingCapabilities( + role: "AXTextField", + subrole: nil, + canReadValue: true, + canReadSelection: true, + valueIsSettable: true, + selectedTextIsSettable: true, + isSecure: LiveTypingAXTarget.isSecureSubrole(nil) + ) + XCTAssertEqual(unknown.predictedLevel, .finalOnly) + } + // MARK: - Levels func testDowngradeOnlyMovesDownwards() {