diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index 2074994f..3cbb2afc 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 7C5AF14C2F15041600DE21B0 /* MediaRemoteAdapter in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 7C5AF14A2F15041600DE21B0 /* MediaRemoteAdapter */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 7C9A71022F58B00000FB7CAF /* TranscribeCpp in Frameworks */ = {isa = PBXBuildFile; productRef = 7C9A71012F58B00000FB7CAF /* TranscribeCpp */; }; 7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */; }; + C0DE89000000000000000002 /* SettingsNavigationStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE89000000000000000001 /* SettingsNavigationStateTests.swift */; }; 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 */; }; @@ -77,6 +78,7 @@ C0DE63700000000000000001 /* WhisperLanguageSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhisperLanguageSelectionTests.swift; sourceTree = ""; }; 7C078D8F2E3B339200FB7CAC /* FluidVoice Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FluidVoice Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyShortcutTests.swift; sourceTree = ""; }; + C0DE89000000000000000001 /* SettingsNavigationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsNavigationStateTests.swift; sourceTree = ""; }; 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 = ""; }; @@ -155,6 +157,7 @@ 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */, CD1C7A0000000000000000B1 /* CustomDictionaryManualEntryTests.swift */, 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */, + C0DE89000000000000000001 /* SettingsNavigationStateTests.swift */, 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */, 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */, A11A00000000000000000001 /* AnalyticsDatabaseTests.swift */, @@ -330,6 +333,7 @@ B52000000000000000000002 /* PrivateAIDictationTokenBudgetTests.swift in Sources */, CD1C7A0000000000000000B2 /* CustomDictionaryManualEntryTests.swift in Sources */, 7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */, + C0DE89000000000000000002 /* SettingsNavigationStateTests.swift in Sources */, 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */, 272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */, A11A00000000000000000002 /* AnalyticsDatabaseTests.swift in Sources */, diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 7eda4157..da723835 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -74,23 +74,6 @@ private final class DictationAIStreamPreviewBuffer { } } -// MARK: - Sidebar Item Enum - -enum SidebarItem: Hashable { - case welcome - case voiceEngine - case aiEnhancements - case preferences - case meetingTools - case customDictionary - case stats - case history - case changelog - case feedback - case commandMode - case rewriteMode -} - enum PrimaryDictationShortcutEdit: Hashable { case add case replace(Int) @@ -210,6 +193,7 @@ struct ContentView: View { } @Environment(\.theme) private var theme + @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion @State private var hotkeyManager: GlobalHotkeyManager? = nil @State private var hotkeyManagerInitialized: Bool = false @@ -243,6 +227,13 @@ struct ContentView: View { @State private var selectedSidebarItem: SidebarItem? @State private var previousSidebarItem: SidebarItem? = nil // Track previous for mode transitions + @State private var settingsNavigation = SettingsNavigationState() + @State private var settingsSearchQuery = "" + @State private var settingsSearchScrollRequest = 0 + + @State private var isHelpEntryHovered = false + @State private var isSettingsEntryHovered = false + @State private var isSettingsBackHovered = false @State private var playgroundUsed: Bool = SettingsStore.shared.playgroundUsed @State private var recordingAppInfo: (name: String, bundleId: String, windowTitle: String)? = nil @State private var recordingPrecedingText: String = "" @@ -318,7 +309,6 @@ struct ContentView: View { @State private var savedProviders: [SettingsStore.SavedProvider] = [] @State private var selectedProviderID: String = SettingsStore.shared.selectedProviderID @State private var columnVisibility: NavigationSplitViewVisibility = .all - @State private var microphoneSettingsScrollRequest = 0 var body: some View { let layout = AnyView( @@ -327,7 +317,7 @@ struct ContentView: View { self.onboardingOnlyView } else { NavigationSplitView(columnVisibility: self.$columnVisibility) { - self.sidebarView + self.sidebarContent .navigationSplitViewColumnWidth(min: 220, ideal: 250, max: 300) } detail: { self.detailView @@ -351,7 +341,7 @@ struct ContentView: View { self.refreshAccessibilityPermissionState() } .onReceive(NotificationCenter.default.publisher(for: .openCustomDictionaryFromVoiceEngine)) { _ in - self.selectedSidebarItem = .customDictionary + self.navigateToApp(.customDictionary) } .onReceive(NotificationCenter.default.publisher(for: .appNavigationRequested)) { _ in self.handlePendingAppNavigation() @@ -578,9 +568,8 @@ struct ContentView: View { self.finishAccessibilityPermissionFlow() } - if self.selectedSidebarItem == nil { - let isOnboarded = self.asr.isAsrReady || self.asr.modelsExistOnDisk - self.selectedSidebarItem = isOnboarded ? .preferences : .welcome + if self.selectedSidebarItem == nil, !self.settingsNavigation.isPresented { + self.selectedSidebarItem = .welcome } self.handlePendingAppNavigation() @@ -968,12 +957,11 @@ struct ContentView: View { switch destination { case .customDictionary: - self.selectedSidebarItem = .customDictionary + self.navigateToApp(.customDictionary) case .microphoneSettings: - self.selectedSidebarItem = .preferences - self.microphoneSettingsScrollRequest &+= 1 - case .preferences: - self.selectedSidebarItem = .preferences + self.openSettings(.audio) + case .settings: + self.openSettings(.general) } } @@ -982,12 +970,33 @@ struct ContentView: View { switch destination { case .aiEnhancements: - self.selectedSidebarItem = .aiEnhancements + self.navigateToApp(.aiEnhancements) case .history: - self.selectedSidebarItem = .history + self.navigateToApp(.history) } } + private func navigateToApp(_ destination: SidebarItem) { + self.resetSettingsSearch() + self.settingsNavigation.leaveForApp() + self.selectedSidebarItem = destination + } + + private func openSettings(_ section: SettingsSection) { + self.resetSettingsSearch() + self.settingsNavigation.present(section, returningTo: self.selectedSidebarItem) + } + + private func closeSettings() { + self.resetSettingsSearch() + self.selectedSidebarItem = self.settingsNavigation.dismiss() + } + + private func resetSettingsSearch() { + self.settingsSearchQuery = "" + self.settingsSearchScrollRequest += 1 + } + private func resetPendingShortcutState() { self.currentRecordingModifierKeyCodes = [] self.pendingModifierKeyCodes = [] @@ -1167,10 +1176,36 @@ struct ContentView: View { NSWorkspace.shared.open(url) } - private var sidebarView: some View { + private func openHelpDocumentation() { + guard let url = URL(string: "https://docs.altic.dev/") else { return } + NSWorkspace.shared.open(url) + } + + private var sidebarContent: some View { + ZStack { + // Keep both sidebars mounted so navigation feedback never waits on view construction. + self.appSidebarView + .opacity(self.settingsNavigation.isPresented ? 0 : 1) + .offset(x: self.settingsNavigation.isPresented ? -self.sidebarTransitionDistance : 0) + .allowsHitTesting(!self.settingsNavigation.isPresented) + .accessibilityHidden(self.settingsNavigation.isPresented) + + self.settingsSidebarView + .background(self.theme.palette.sidebarBackground) + .opacity(self.settingsNavigation.isPresented ? 1 : 0) + .offset(x: self.settingsNavigation.isPresented ? 0 : self.sidebarTransitionDistance) + .allowsHitTesting(self.settingsNavigation.isPresented) + .accessibilityHidden(!self.settingsNavigation.isPresented) + } + .clipped() + .navigationTitle(self.settingsNavigation.isPresented ? "Settings" : "FluidVoice") + .tint(self.theme.palette.accent) + .animation(self.modeTransitionAnimation, value: self.settingsNavigation.isPresented) + } + + private var appSidebarView: some View { List(selection: self.$selectedSidebarItem) { Section { - self.sidebarNavigationLink(.preferences, title: "Settings", systemImage: "gearshape.fill") self.sidebarNavigationLink(.voiceEngine, title: "Voice Engine", systemImage: "waveform") self.sidebarNavigationLink(.aiEnhancements, title: "AI Enhancement", systemImage: "brain") self.sidebarNavigationLink(.customDictionary, title: "Custom Dictionary", systemImage: "text.book.closed.fill") @@ -1201,9 +1236,211 @@ struct ContentView: View { } } .listStyle(.sidebar) + .accentColor(self.theme.palette.accent) .animation(nil, value: self.selectedSidebarItem) - .navigationTitle("FluidVoice") - .tint(self.theme.palette.accent) + .safeAreaInset(edge: .bottom, spacing: 0) { + VStack(spacing: 0) { + self.helpEntryButton + self.settingsEntryButton + } + } + } + + private var settingsSidebarView: some View { + VStack(spacing: 0) { + Button { + self.closeSettings() + } label: { + HStack(spacing: self.theme.metrics.spacing.sm) { + Image(systemName: "chevron.left") + .font(.system(size: 11, weight: .semibold)) + .frame(width: 18, height: 28) + + Text("Back to app") + .font(self.theme.typography.sidebarItem) + + Spacer(minLength: 0) + } + .foregroundStyle(.secondary) + .padding(.horizontal, self.theme.metrics.spacing.md) + .padding(.top, self.theme.metrics.spacing.sm) + .padding(.bottom, self.theme.metrics.spacing.xs) + .frame(maxWidth: .infinity, minHeight: 36, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(SidebarChromeButtonStyle( + isHovered: self.isSettingsBackHovered, + reduceMotion: self.accessibilityReduceMotion + )) + .onHover { self.isSettingsBackHovered = $0 } + .help("Back to FluidVoice") + .accessibilityLabel("Back to FluidVoice") + + SettingsSearchField(text: Binding( + get: { self.settingsSearchQuery }, + set: { self.updateSettingsSearchQuery($0) } + )) + .frame(height: 24) + .padding(.horizontal, self.theme.metrics.spacing.md) + .padding(.top, self.theme.metrics.spacing.xs) + .padding(.bottom, self.theme.metrics.spacing.sm) + + List(selection: Binding( + get: { self.settingsNavigation.selectedSection }, + set: { newValue in + if let newValue { + self.settingsNavigation.selectedSection = newValue + self.settingsSearchScrollRequest += 1 + } + } + )) { + ForEach(self.filteredSettingsSections) { section in + let isSelected = self.settingsNavigation.selectedSection == section + NavigationLink(value: section) { + HStack(spacing: self.theme.metrics.spacing.sm) { + Image(systemName: section.systemImage) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(isSelected ? Color.white.opacity(0.9) : Color.secondary) + .frame(width: 18) + + Text(section.title) + .foregroundStyle(isSelected ? Color.white : Color.primary) + } + .font(self.theme.typography.sidebarItem) + } + .sidebarOptionHover( + isSelected: isSelected, + reduceMotion: self.accessibilityReduceMotion + ) + } + } + .listStyle(.sidebar) + .accentColor(self.theme.palette.accent) + .animation(nil, value: self.settingsNavigation.selectedSection) + } + } + + private var isSettingsSearchActive: Bool { + !self.settingsSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var settingsSearchResults: [SettingsSearchResult] { + self.availableSettingsSearchResults(for: self.settingsSearchQuery) + } + + private var filteredSettingsSections: [SettingsSection] { + guard self.isSettingsSearchActive else { return SettingsSection.allCases } + let matchingSections = Set(self.settingsSearchResults.map(\.section)) + return SettingsSection.allCases.filter(matchingSections.contains) + } + + private func updateSettingsSearchQuery(_ query: String) { + self.settingsSearchQuery = query + self.settingsSearchScrollRequest += 1 + + guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + let results = self.availableSettingsSearchResults(for: query) + self.settingsNavigation.selectedSection = SettingsSearchIndex.preferredSection( + current: self.settingsNavigation.selectedSection, + results: results + ) + } + + private func availableSettingsSearchResults(for query: String) -> [SettingsSearchResult] { + SettingsSearchIndex.results(for: query) + .filter { self.isSettingsSearchTargetAvailable($0.target) } + } + + private func isSettingsSearchTargetAvailable(_ target: SettingsSearchTarget) -> Bool { + switch target { + case .microphonePermission: + return self.asr.micStatus != .authorized + case .accessibilityPermission: + return !self.accessibilityEnabled + case .audioStorage: + return SettingsStore.shared.saveTranscriptionHistory && + SettingsStore.shared.saveAudioWithTranscriptionHistory + case .bottomOffset: + return self.settings.overlayPosition == .bottom + default: + return true + } + } + + private var settingsEntryButton: some View { + Button { + self.openSettings(.general) + } label: { + HStack(spacing: self.theme.metrics.spacing.sm) { + Image(systemName: "gearshape") + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + .frame(width: 18) + + Text("Settings") + + Spacer(minLength: self.theme.metrics.spacing.sm) + + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.tertiary) + } + .font(self.theme.typography.sidebarItem) + .padding(.horizontal, self.theme.metrics.spacing.md) + .frame(maxWidth: .infinity, minHeight: 48, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(SidebarChromeButtonStyle( + isHovered: self.isSettingsEntryHovered, + reduceMotion: self.accessibilityReduceMotion + )) + .onHover { self.isSettingsEntryHovered = $0 } + .help("Settings") + .accessibilityLabel("Settings") + } + + private var helpEntryButton: some View { + Button { + self.openHelpDocumentation() + } label: { + HStack(spacing: self.theme.metrics.spacing.sm) { + Image(systemName: "questionmark.circle") + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + .frame(width: 18) + + Text("Help") + + Spacer(minLength: self.theme.metrics.spacing.sm) + + Image(systemName: "arrow.up.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.tertiary) + } + .font(self.theme.typography.sidebarItem) + .padding(.horizontal, self.theme.metrics.spacing.md) + .frame(maxWidth: .infinity, minHeight: 40, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(SidebarChromeButtonStyle( + isHovered: self.isHelpEntryHovered, + reduceMotion: self.accessibilityReduceMotion + )) + .onHover { self.isHelpEntryHovered = $0 } + .help("Open FluidVoice Help") + .accessibilityLabel("Help") + .accessibilityHint("Opens FluidVoice documentation in your default browser") + } + + private var modeTransitionAnimation: Animation { + let duration = self.settingsNavigation.isPresented ? 0.16 : 0.1 + return self.accessibilityReduceMotion + ? .easeOut(duration: 0.08) + : .snappy(duration: duration, extraBounce: 0) + } + + private var sidebarTransitionDistance: CGFloat { + self.accessibilityReduceMotion ? 0 : 8 } private func sidebarSectionHeader(_ title: String) -> some View { @@ -1216,12 +1453,27 @@ struct ContentView: View { } private func sidebarNavigationLink(_ item: SidebarItem, title: String, systemImage: String) -> some View { - NavigationLink(value: item) { - Label(title, systemImage: systemImage) - .font(self.theme.typography.sidebarItem) - .frame(minHeight: 24, alignment: .leading) - .padding(.vertical, self.theme.metrics.spacing.xs / 2) + let isSelected = self.selectedSidebarItem == item + return NavigationLink(value: item) { + HStack(spacing: self.theme.metrics.spacing.sm) { + Image(nsImage: SidebarSymbolCache.image(named: systemImage)) + .renderingMode(.template) + .resizable() + .scaledToFit() + .foregroundStyle(isSelected ? Color.white.opacity(0.9) : Color.secondary) + .frame(width: 16, height: 16) + .accessibilityHidden(true) + + Text(title) + .foregroundStyle(isSelected ? Color.white : Color.primary) + } + .font(self.theme.typography.sidebarItem) + .padding(.vertical, self.theme.metrics.spacing.xs / 2) } + .sidebarOptionHover( + isSelected: isSelected, + reduceMotion: self.accessibilityReduceMotion + ) } private var themePreferenceButton: some View { @@ -1244,7 +1496,7 @@ struct ContentView: View { private var todayStatsButton: some View { TodayStatsToolbarButton(typingWPM: self.settings.userTypingWPM) { - self.selectedSidebarItem = .stats + self.navigateToApp(.stats) } } @@ -1253,14 +1505,29 @@ struct ContentView: View { Color(nsColor: .windowBackgroundColor) .ignoresSafeArea() - self.detailContent - .transaction { transaction in - transaction.animation = nil - } + // Preserve the app destination so Back never waits on expensive detail initialization. + self.appDetailContent + .opacity(self.settingsNavigation.isPresented ? 0 : 1) + .offset(x: self.settingsNavigation.isPresented ? -6 : 0) + .allowsHitTesting(!self.settingsNavigation.isPresented) + .accessibilityHidden(self.settingsNavigation.isPresented) + + if self.settingsNavigation.isPresented { + self.preferencesView + .transition(self.settingsDetailTransition) + } + } + .animation(self.modeTransitionAnimation, value: self.settingsNavigation.isPresented) + } + + private var settingsDetailTransition: AnyTransition { + if self.accessibilityReduceMotion { + return .opacity } + return .offset(x: 8).combined(with: .opacity) } - private var detailContent: AnyView { + private var appDetailContent: AnyView { switch self.selectedSidebarItem ?? .welcome { case .welcome: return AnyView(self.welcomeView) @@ -1276,8 +1543,6 @@ struct ContentView: View { activeShortcutRecordingTarget: self.$activeShortcutRecordingTarget, shortcutRecordingMessage: self.$shortcutRecordingMessage )) - case .preferences: - return AnyView(self.preferencesView) case .meetingTools: return AnyView(self.meetingToolsView) case .customDictionary: @@ -1458,40 +1723,53 @@ struct ContentView: View { // MARK: - Preferences View + @ViewBuilder private var preferencesView: some View { - SettingsView( - microphonePreferenceCoordinator: self.appServices.microphonePreferenceCoordinator, - appear: self.$appear, - visualizerNoiseThreshold: self.$visualizerNoiseThreshold, - selectedInputUID: self.$selectedInputUID, - selectedOutputUID: self.$selectedOutputUID, - inputDevices: self.$inputDevices, - outputDevices: self.$outputDevices, - accessibilityEnabled: self.$accessibilityEnabled, - primaryDictationShortcuts: self.$primaryDictationShortcuts, - activeShortcutRecordingTarget: self.$activeShortcutRecordingTarget, - shortcutRecordingMessage: self.$shortcutRecordingMessage, - commandModeShortcut: self.$commandModeHotkeyShortcut, - rewriteShortcut: self.$rewriteModeHotkeyShortcut, - cancelRecordingShortcut: self.$cancelRecordingHotkeyShortcut, - pasteLastTranscriptionShortcut: self.$pasteLastTranscriptionHotkeyShortcut, - commandModeShortcutEnabled: self.$isCommandModeShortcutEnabled, - rewriteShortcutEnabled: self.$isRewriteModeShortcutEnabled, - pasteLastTranscriptionShortcutEnabled: self.$isPasteLastTranscriptionShortcutEnabled, - hotkeyManagerInitialized: self.$hotkeyManagerInitialized, - hotkeyMode: self.$hotkeyMode, - enableStreamingPreview: self.$enableStreamingPreview, - copyToClipboard: self.$copyToClipboard, - hotkeyManager: self.hotkeyManager, - menuBarManager: self.menuBarManager, - startRecording: self.startRecording, - refreshDevices: self.refreshDevices, - openAccessibilitySettings: self.openAccessibilitySettings, - restartApp: self.restartApp, - revealAppInFinder: self.revealAppInFinder, - openApplicationsFolder: self.openApplicationsFolder, - microphoneSettingsScrollRequest: self.microphoneSettingsScrollRequest - ) + if self.isSettingsSearchActive, self.settingsSearchResults.isEmpty { + ContentUnavailableView { + Label("No Settings Found", systemImage: "magnifyingglass") + } description: { + Text("No settings match “\(self.settingsSearchQuery)”.") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityElement(children: .combine) + } else { + SettingsView( + selectedSection: self.settingsNavigation.selectedSection ?? .general, + searchResults: self.settingsSearchResults, + searchScrollRequest: self.settingsSearchScrollRequest, + microphonePreferenceCoordinator: self.appServices.microphonePreferenceCoordinator, + appear: self.$appear, + visualizerNoiseThreshold: self.$visualizerNoiseThreshold, + selectedInputUID: self.$selectedInputUID, + selectedOutputUID: self.$selectedOutputUID, + inputDevices: self.$inputDevices, + outputDevices: self.$outputDevices, + accessibilityEnabled: self.$accessibilityEnabled, + primaryDictationShortcuts: self.$primaryDictationShortcuts, + activeShortcutRecordingTarget: self.$activeShortcutRecordingTarget, + shortcutRecordingMessage: self.$shortcutRecordingMessage, + commandModeShortcut: self.$commandModeHotkeyShortcut, + rewriteShortcut: self.$rewriteModeHotkeyShortcut, + cancelRecordingShortcut: self.$cancelRecordingHotkeyShortcut, + pasteLastTranscriptionShortcut: self.$pasteLastTranscriptionHotkeyShortcut, + commandModeShortcutEnabled: self.$isCommandModeShortcutEnabled, + rewriteShortcutEnabled: self.$isRewriteModeShortcutEnabled, + pasteLastTranscriptionShortcutEnabled: self.$isPasteLastTranscriptionShortcutEnabled, + hotkeyManagerInitialized: self.$hotkeyManagerInitialized, + hotkeyMode: self.$hotkeyMode, + enableStreamingPreview: self.$enableStreamingPreview, + copyToClipboard: self.$copyToClipboard, + hotkeyManager: self.hotkeyManager, + menuBarManager: self.menuBarManager, + startRecording: self.startRecording, + refreshDevices: self.refreshDevices, + openAccessibilitySettings: self.openAccessibilitySettings, + restartApp: self.restartApp, + revealAppInFinder: self.revealAppInFinder, + openApplicationsFolder: self.openApplicationsFolder + ) + } } private var recordingView: some View { @@ -1504,15 +1782,13 @@ struct ContentView: View { private var commandModeView: some View { CommandModeView(service: self.commandModeService, onClose: { - let isOnboarded = self.asr.isAsrReady || self.asr.modelsExistOnDisk - self.selectedSidebarItem = isOnboarded ? .preferences : .welcome + self.navigateToApp(.welcome) }) } private var rewriteModeView: some View { RewriteModeView(service: self.rewriteModeService, onClose: { - let isOnboarded = self.asr.isAsrReady || self.asr.modelsExistOnDisk - self.selectedSidebarItem = isOnboarded ? .preferences : .welcome + self.navigateToApp(.welcome) }) } @@ -3625,7 +3901,7 @@ struct ContentView: View { if self.selectedSidebarItem == .rewriteMode { DebugLogger.shared.debug("Cancel callback: closing mode view", source: "ContentView") DispatchQueue.main.async { - self.selectedSidebarItem = .welcome + self.navigateToApp(.welcome) } handled = true } @@ -3700,8 +3976,7 @@ struct ContentView: View { if self.selectedSidebarItem == .rewriteMode { DebugLogger.shared.debug("Cancel shortcut: closing mode view", source: "ContentView") - let isOnboarded = self.asr.isAsrReady || self.asr.modelsExistOnDisk - self.selectedSidebarItem = isOnboarded ? .preferences : .welcome + self.navigateToApp(.welcome) handled = true } @@ -4042,9 +4317,7 @@ extension ContentView { private func completeOnboarding(selecting target: SidebarItem? = nil) { self.settings.onboardingCompleted = true - - let isOnboarded = self.asr.isAsrReady || self.asr.modelsExistOnDisk - self.selectedSidebarItem = target ?? (isOnboarded ? .preferences : .welcome) + self.navigateToApp(target ?? .welcome) } private func missingOnboardingCompletionRequirements(allowsAIConfiguration: Bool = false) -> [String] { @@ -4427,6 +4700,107 @@ extension ContentView { // swiftlint:enable type_body_length +@MainActor +private enum SidebarSymbolCache { + private static let symbolNames = [ + "waveform", + "brain", + "text.book.closed.fill", + "terminal.fill", + "doc.text.fill", + "clock.arrow.circlepath", + "chart.bar.fill", + "house.fill", + "doc.text.magnifyingglass", + "envelope.fill", + ] + + private static let images: [String: NSImage] = { + let configuration = NSImage.SymbolConfiguration(pointSize: 14, weight: .regular) + .applying(.preferringHierarchical()) + var images: [String: NSImage] = [:] + + for name in symbolNames { + guard let image = NSImage(systemSymbolName: name, accessibilityDescription: nil)? + .withSymbolConfiguration(configuration) + else { continue } + + image.isTemplate = true + image.cacheMode = .always + images[name] = image + } + + return images + }() + + static func image(named name: String) -> NSImage { + self.images[name] ?? NSImage(systemSymbolName: "questionmark", accessibilityDescription: nil) ?? NSImage() + } +} + +private struct SidebarChromeButtonStyle: ButtonStyle { + let isHovered: Bool + let reduceMotion: Bool + + func makeBody(configuration: Configuration) -> some View { + let scale = self.reduceMotion || !configuration.isPressed ? 1 : 0.985 + + configuration.label + .foregroundStyle(.primary) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(self.backgroundOpacity(isPressed: configuration.isPressed))) + ) + .scaleEffect(scale) + .animation(.easeOut(duration: self.reduceMotion ? 0.08 : 0.1), value: configuration.isPressed) + .animation(.easeOut(duration: 0.1), value: self.isHovered) + } + + private func backgroundOpacity(isPressed: Bool) -> Double { + if isPressed { + return 0.12 + } + return self.isHovered ? 0.08 : 0 + } +} + +private extension View { + func sidebarOptionHover(isSelected: Bool, reduceMotion: Bool) -> some View { + modifier(SidebarOptionHoverModifier(isSelected: isSelected, reduceMotion: reduceMotion)) + } +} + +private struct SidebarOptionHoverModifier: ViewModifier { + let isSelected: Bool + let reduceMotion: Bool + + @Environment(\.theme) private var theme + @State private var isHovered = false + + func body(content: Content) -> some View { + content + .frame(maxWidth: .infinity, minHeight: 28, alignment: .leading) + .padding(.horizontal, 5) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(self.backgroundColor) + ) + .padding(.horizontal, -5) + .padding(.vertical, -3) + .contentShape(Rectangle()) + .onHover { self.isHovered = $0 } + .animation(.easeOut(duration: self.reduceMotion ? 0.08 : 0.12), value: self.isHovered) + } + + private var backgroundColor: Color { + if self.isSelected { + return self.theme.palette.accent + } + return Color.primary.opacity(self.isHovered ? 0.08 : 0) + } +} + private struct AccessibilitySettingsFloatingGuideView: View { let appURL: URL let appName: String diff --git a/Sources/Fluid/Services/MenuBarManager.swift b/Sources/Fluid/Services/MenuBarManager.swift index f56fa565..f50fd358 100644 --- a/Sources/Fluid/Services/MenuBarManager.swift +++ b/Sources/Fluid/Services/MenuBarManager.swift @@ -6,7 +6,7 @@ import SwiftUI enum MenuBarNavigationDestination: String { case customDictionary case microphoneSettings - case preferences + case settings } @MainActor @@ -911,7 +911,7 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { } @objc private func openPreferences() { - self.openNavigationDestination(.preferences) + self.openNavigationDestination(.settings) } @objc private func openCustomDictionary() { diff --git a/Sources/Fluid/UI/AppNavigationState.swift b/Sources/Fluid/UI/AppNavigationState.swift new file mode 100644 index 00000000..ad93b133 --- /dev/null +++ b/Sources/Fluid/UI/AppNavigationState.swift @@ -0,0 +1,82 @@ +// +// AppNavigationState.swift +// fluid +// +// Navigation state shared by the main app and settings sidebars. +// + +import Foundation + +enum SidebarItem: Hashable { + case welcome + case voiceEngine + case aiEnhancements + case meetingTools + case customDictionary + case stats + case history + case changelog + case feedback + case commandMode + case rewriteMode +} + +enum SettingsSection: String, CaseIterable, Identifiable, Hashable { + case general + case dictation + case notifications + case audio + case overlay + case dataAndDiagnostics + + var id: Self { + self + } + + var title: String { + switch self { + case .general: return "General" + case .dictation: return "Dictation" + case .notifications: return "Notifications" + case .audio: return "Audio" + case .overlay: return "Overlay" + case .dataAndDiagnostics: return "Data & Diagnostics" + } + } + + var systemImage: String { + switch self { + case .general: return "gearshape" + case .dictation: return "keyboard" + case .notifications: return "bell" + case .audio: return "speaker.wave.2" + case .overlay: return "rectangle.on.rectangle" + case .dataAndDiagnostics: return "wrench.and.screwdriver" + } + } +} + +struct SettingsNavigationState: Equatable { + var selectedSection: SettingsSection? + private(set) var returnDestination: SidebarItem = .welcome + + var isPresented: Bool { + self.selectedSection != nil + } + + mutating func present(_ section: SettingsSection, returningTo currentDestination: SidebarItem?) { + if !self.isPresented { + self.returnDestination = currentDestination ?? .welcome + } + self.selectedSection = section + } + + mutating func dismiss() -> SidebarItem { + self.selectedSection = nil + return self.returnDestination + } + + mutating func leaveForApp() { + self.selectedSection = nil + } +} diff --git a/Sources/Fluid/UI/SettingsSearch.swift b/Sources/Fluid/UI/SettingsSearch.swift new file mode 100644 index 00000000..64edd0ee --- /dev/null +++ b/Sources/Fluid/UI/SettingsSearch.swift @@ -0,0 +1,576 @@ +// +// SettingsSearch.swift +// fluid +// +// Search metadata and fuzzy matching for the Settings sidebar. +// + +import AppKit +import Foundation +import SwiftUI + +enum SettingsSearchTarget: Hashable { + case general + case launchAtStartup + case showWindowAtLogin + case dockVisibility + case accentColor + case transcriptionSounds + case automaticUpdates + case analyticsPrivacy + + case dictation + case microphonePermission + case globalHotkey + case primaryDictationShortcuts + case commandModeShortcut + case editModeShortcut + case cancelRecordingShortcut + case pasteLastTranscriptionShortcut + case activationMode + case copyToClipboard + case textInsertionMode + case spokenSend + case transcriptionHistory + case audioHistory + case usageStreak + case skipSilentRecordings + case pauseMedia + case accessibilityPermission + case textFormatting + + case notifications + case aiEnhancementFailures + case microphoneChanges + + case audio + case inputDevicePriority + case outputDevice + + case overlay + case overlaySensitivity + case overlayPosition + case transcriptionPreviewLength + case overlayStyle + case livePreview + case bottomOffset + + case dataAndDiagnostics + case backupAndRestore + case audioStorage + case debugLogs + case fasterLongDictation + + var section: SettingsSection { + switch self { + case .general, + .launchAtStartup, + .showWindowAtLogin, + .dockVisibility, + .accentColor, + .transcriptionSounds, + .automaticUpdates: + return .general + + case .dictation, + .analyticsPrivacy, + .microphonePermission, + .globalHotkey, + .primaryDictationShortcuts, + .commandModeShortcut, + .editModeShortcut, + .cancelRecordingShortcut, + .pasteLastTranscriptionShortcut, + .activationMode, + .copyToClipboard, + .textInsertionMode, + .spokenSend, + .transcriptionHistory, + .audioHistory, + .audioStorage, + .usageStreak, + .skipSilentRecordings, + .pauseMedia, + .accessibilityPermission, + .textFormatting: + return .dictation + + case .notifications, .aiEnhancementFailures, .microphoneChanges: + return .notifications + + case .audio, .inputDevicePriority, .outputDevice: + return .audio + + case .overlay, + .overlaySensitivity, + .overlayPosition, + .transcriptionPreviewLength, + .overlayStyle, + .livePreview, + .bottomOffset: + return .overlay + + case .dataAndDiagnostics, .backupAndRestore, .debugLogs, .fasterLongDictation: + return .dataAndDiagnostics + } + } +} + +extension SettingsSection { + var searchTarget: SettingsSearchTarget { + switch self { + case .general: return .general + case .dictation: return .dictation + case .notifications: return .notifications + case .audio: return .audio + case .overlay: return .overlay + case .dataAndDiagnostics: return .dataAndDiagnostics + } + } +} + +struct SettingsSearchResult: Identifiable, Equatable { + let target: SettingsSearchTarget + let score: Int + + var id: SettingsSearchTarget { + self.target + } + + var section: SettingsSection { + self.target.section + } +} + +enum SettingsSearchIndex { + private static let queryStopWords: Set = ["and", "for", "from", "in", "of", "or", "the", "to", "with"] + + private struct Entry { + let target: SettingsSearchTarget + let title: String + let terms: [String] + } + + private struct WeightedField { + let text: String + let weight: Int + } + + private static let entries: [Entry] = [ + .init(target: .general, title: "General", terms: ["app settings preferences"]), + .init( + target: .launchAtStartup, + title: "Launch at startup", + terms: ["Automatically start FluidVoice when you log in", "login startup boot open"] + ), + .init( + target: .showWindowAtLogin, + title: "Show window when launched at login", + terms: ["starts silently in the menu bar", "login window visibility"] + ), + .init( + target: .dockVisibility, + title: "Hide from Dock & App Switcher", + terms: ["menu bar only", "dock icon command tab cmd tab app switcher"] + ), + .init(target: .accentColor, title: "Accent Color", terms: ["appearance theme tint colour preset"]), + .init( + target: .transcriptionSounds, + title: "Transcription Sounds", + terms: ["recording sound cue volume independent mute start end audio"] + ), + .init( + target: .automaticUpdates, + title: "Automatic Updates", + terms: ["beta releases check for updates release notes rollback previous builds version"] + ), + .init( + target: .analyticsPrivacy, + title: "Share Detailed Anonymous Analytics", + terms: ["Analytics Privacy what we collect telemetry data active use weekly opt out"] + ), + + .init(target: .dictation, title: "Dictation", terms: ["typing transcription keyboard preferences"]), + .init( + target: .microphonePermission, + title: "Microphone Permission", + terms: ["mic access authorization privacy grant denied system settings"] + ), + .init(target: .globalHotkey, title: "Global Hotkey", terms: ["keyboard shortcut activation accessibility"]), + .init( + target: .primaryDictationShortcuts, + title: "Primary Dictation Shortcuts", + terms: ["keyboard shortcut mouse button record hotkey"] + ), + .init(target: .commandModeShortcut, title: "Command Mode", terms: ["voice commands terminal hotkey shortcut"]), + .init(target: .editModeShortcut, title: "Edit Mode", terms: ["rewrite selected text hotkey shortcut"]), + .init( + target: .cancelRecordingShortcut, + title: "Cancel Recording", + terms: ["dismiss overlay stop hotkey shortcut"] + ), + .init( + target: .pasteLastTranscriptionShortcut, + title: "Paste Last Transcription", + terms: ["reinsert recent text hotkey shortcut clipboard"] + ), + .init(target: .activationMode, title: "Activation Mode", terms: ["hold toggle hotkey behavior"]), + .init( + target: .copyToClipboard, + title: "Copy to Clipboard", + terms: ["automatically copy transcribed text pasteboard backup"] + ), + .init( + target: .textInsertionMode, + title: "Text Insertion Mode", + terms: ["type text copy paste clipboard reliable insertion delivery"] + ), + .init( + target: .spokenSend, + title: "Spoken Send", + terms: ["send immediately phrase command enter return submit voice"] + ), + .init( + target: .transcriptionHistory, + title: "Save Transcription History", + terms: ["stats tracking privacy remember transcript"] + ), + .init( + target: .audioHistory, + title: "Save Audio With History", + terms: ["store microphone recording locally dictation audio"] + ), + .init( + target: .usageStreak, + title: "Weekends Don't Break Streak", + terms: ["Saturday Sunday stats usage streak weekday"] + ), + .init( + target: .skipSilentRecordings, + title: "Skip Silent Recordings", + terms: ["silence quiet speech avoid transcription"] + ), + .init( + target: .pauseMedia, + title: "Pause Media During Transcription", + terms: ["resume music audio video playback recording"] + ), + .init( + target: .accessibilityPermission, + title: "Accessibility Permission", + terms: ["global hotkey access macOS system settings authorize"] + ), + .init( + target: .textFormatting, + title: "Text Formatting", + terms: [ + "Lowercase First Letter Remove Trailing Period Slash Commands @ Formatting", + "Space Between Dictations Smart Capitalization punctuation uppercase symbols mentions", + ] + ), + + .init(target: .notifications, title: "Notifications", terms: ["alerts warnings"]), + .init( + target: .aiEnhancementFailures, + title: "AI Enhancement Failures", + terms: ["notify processing error raw transcription alert"] + ), + .init( + target: .microphoneChanges, + title: "Microphone Changes", + terms: ["mic device lost changed alert notification"] + ), + + .init(target: .audio, title: "Audio", terms: ["sound devices microphone speaker"]), + .init( + target: .inputDevicePriority, + title: "Input Device Priority", + terms: ["microphone mic order drag reorder default restore removed unavailable active"] + ), + .init( + target: .outputDevice, + title: "Output Device", + terms: ["speaker headphones audio system default refresh loading"] + ), + + .init(target: .overlay, title: "Overlay", terms: ["recording indicator notch pill visualizer"]), + .init( + target: .overlaySensitivity, + title: "Sensitivity", + terms: ["audio visualizer sound input more less threshold"] + ), + .init( + target: .overlayPosition, + title: "Overlay Position", + terms: ["recording indicator screen notch pill bottom location"] + ), + .init( + target: .transcriptionPreviewLength, + title: "Transcription Preview Length", + terms: ["recent characters notch pill preview size"] + ), + .init( + target: .overlayStyle, + title: "Overlay Size & Notch Style", + terms: ["large compact regular recording indicator presentation layout"] + ), + .init( + target: .livePreview, + title: "Live Preview", + terms: ["streaming transcription text while speak overlay"] + ), + .init(target: .bottomOffset, title: "Bottom Offset", terms: ["distance from bottom screen pixels position"]), + + .init( + target: .dataAndDiagnostics, + title: "Data & Diagnostics", + terms: ["backup restore debug logs experimental storage troubleshooting"] + ), + .init( + target: .backupAndRestore, + title: "Backup & Restore", + terms: ["export import settings prompt profiles history stats API keys"] + ), + .init( + target: .audioStorage, + title: "Audio Storage", + terms: ["history budget GB export ZIP delete prune WAV manifest"] + ), + .init( + target: .debugLogs, + title: "Debug Settings", + terms: ["Show Debug Logs in App Reveal Log File crash diagnostics troubleshooting"] + ), + .init( + target: .fasterLongDictation, + title: "Faster Long Dictation", + terms: ["experimental Parakeet reuse live windows remaining tail transcription"] + ), + ] + + static func results(for query: String) -> [SettingsSearchResult] { + let normalizedQuery = self.normalize(query) + guard !normalizedQuery.isEmpty else { return [] } + + let rankedResults: [(index: Int, result: SettingsSearchResult)] = self.entries.enumerated().compactMap { index, entry in + guard let score = self.score(entry, for: normalizedQuery) else { return nil } + return (index, SettingsSearchResult(target: entry.target, score: score)) + } + + return rankedResults.sorted { lhs, rhs in + if lhs.result.score == rhs.result.score { + return lhs.index < rhs.index + } + return lhs.result.score > rhs.result.score + } + .map(\.result) + } + + static func matchingSections(for query: String) -> [SettingsSection] { + let matchingSections = Set(self.results(for: query).map(\.section)) + return SettingsSection.allCases.filter(matchingSections.contains) + } + + static func preferredSection( + current: SettingsSection?, + results: [SettingsSearchResult] + ) -> SettingsSection? { + guard !results.isEmpty else { return current } + if let current, results.contains(where: { $0.section == current }) { + return current + } + return results.first?.section + } + + private static func score(_ entry: Entry, for query: String) -> Int? { + let fields = [ + WeightedField(text: entry.title, weight: 100), + WeightedField(text: entry.target.section.title, weight: 45), + ] + entry.terms.map { WeightedField(text: $0, weight: 55) } + + let allQueryTokens = self.tokens(in: query) + let meaningfulQueryTokens = allQueryTokens.filter { !self.queryStopWords.contains($0) } + let queryTokens = meaningfulQueryTokens.isEmpty ? allQueryTokens : meaningfulQueryTokens + let compactQuery = self.compact(query) + let compactCandidates = fields.flatMap { field in + var candidates = [self.compact(self.normalize(field.text))] + for token in self.tokens(in: field.text) { + candidates.append(self.compact(token)) + } + return candidates + } + let hasCompactFuzzyMatch = compactQuery.count >= 4 && + compactCandidates.contains(where: { self.isFuzzyMatch(query: compactQuery, candidate: $0) }) + var score = 0 + + for queryToken in queryTokens { + let bestTokenScore = fields.compactMap { field -> Int? in + self.tokens(in: field.text) + .compactMap { self.tokenScore(query: queryToken, candidate: $0) } + .max() + .map { $0 + field.weight } + }.max() + + if let bestTokenScore { + score += bestTokenScore + } else if !hasCompactFuzzyMatch { + return nil + } + } + + let normalizedTitle = self.normalize(entry.title) + let normalizedSection = self.normalize(entry.target.section.title) + score += self.phraseBonus(query: query, candidate: normalizedTitle, exactBonus: 1000) + score += self.phraseBonus(query: query, candidate: normalizedSection, exactBonus: 400) + + for term in entry.terms { + score += self.phraseBonus(query: query, candidate: self.normalize(term), exactBonus: 220) + } + + if hasCompactFuzzyMatch { + score += 180 + } + + return score + } + + private static func phraseBonus(query: String, candidate: String, exactBonus: Int) -> Int { + if candidate == query { + return exactBonus + } + if candidate.hasPrefix(query) { + return exactBonus * 3 / 5 + } + if query.count >= 3, candidate.contains(query) { + return exactBonus * 2 / 5 + } + return 0 + } + + private static func tokenScore(query: String, candidate: String) -> Int? { + if candidate == query { + return 120 + } + if candidate.hasPrefix(query) { + return 105 + } + if query.count >= 3, candidate.contains(query) { + return 90 + } + if self.isFuzzyMatch(query: query, candidate: candidate) { + return 70 - (self.editDistance(query, candidate) * 8) + } + return nil + } + + private static func isFuzzyMatch(query: String, candidate: String) -> Bool { + let tolerance: Int + switch query.count { + case 0...3: + return false + case 4...6: + tolerance = 1 + default: + tolerance = 2 + } + + guard abs(query.count - candidate.count) <= tolerance else { return false } + return self.editDistance(query, candidate) <= tolerance + } + + private static func editDistance(_ lhs: String, _ rhs: String) -> Int { + let left = Array(lhs) + let right = Array(rhs) + guard !left.isEmpty else { return right.count } + guard !right.isEmpty else { return left.count } + + var previous = Array(0...right.count) + for (leftIndex, leftCharacter) in left.enumerated() { + var current = [leftIndex + 1] + current.reserveCapacity(right.count + 1) + + for (rightIndex, rightCharacter) in right.enumerated() { + current.append(min( + current[rightIndex] + 1, + previous[rightIndex + 1] + 1, + previous[rightIndex] + (leftCharacter == rightCharacter ? 0 : 1) + )) + } + previous = current + } + return previous[right.count] + } + + private static func normalize(_ value: String) -> String { + let folded = value.folding( + options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], + locale: .current + ) + let separated = folded.map { character in + character.isLetter || character.isNumber ? character : " " + } + return String(separated) + .split(whereSeparator: \.isWhitespace) + .joined(separator: " ") + } + + private static func tokens(in value: String) -> [String] { + self.normalize(value).split(separator: " ").map(String.init) + } + + private static func compact(_ value: String) -> String { + value.filter { !$0.isWhitespace } + } +} + +struct SettingsSearchField: NSViewRepresentable { + @Binding var text: String + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeNSView(context: Context) -> NSSearchField { + let searchField = NSSearchField() + searchField.delegate = context.coordinator + searchField.placeholderString = "Search Settings" + searchField.sendsSearchStringImmediately = true + searchField.sendsWholeSearchString = false + searchField.controlSize = .regular + searchField.focusRingType = .default + searchField.setAccessibilityLabel("Search Settings") + return searchField + } + + func updateNSView(_ searchField: NSSearchField, context: Context) { + context.coordinator.parent = self + if searchField.stringValue != self.text { + searchField.stringValue = self.text + } + } + + final class Coordinator: NSObject, NSSearchFieldDelegate { + var parent: SettingsSearchField + + init(_ parent: SettingsSearchField) { + self.parent = parent + } + + func controlTextDidChange(_ notification: Notification) { + guard let searchField = notification.object as? NSSearchField else { return } + self.parent.text = searchField.stringValue + } + + func control( + _ control: NSControl, + textView: NSTextView, + doCommandBy commandSelector: Selector + ) -> Bool { + guard commandSelector == #selector(NSResponder.cancelOperation(_:)) else { return false } + textView.string = "" + control.stringValue = "" + self.parent.text = "" + return true + } + } +} diff --git a/Sources/Fluid/UI/SettingsSearchPresentation.swift b/Sources/Fluid/UI/SettingsSearchPresentation.swift new file mode 100644 index 00000000..6e6fef53 --- /dev/null +++ b/Sources/Fluid/UI/SettingsSearchPresentation.swift @@ -0,0 +1,243 @@ +// +// SettingsSearchPresentation.swift +// fluid +// +// Search highlighting and programmatic reveal support for Settings. +// + +import AppKit +import SwiftUI + +final class SettingsSearchScrollCoordinator { + private final class WeakAnchor { + weak var view: NSView? + + init(_ view: NSView) { + self.view = view + } + } + + private weak var scrollView: NSScrollView? + private var anchors: [SettingsSearchTarget: WeakAnchor] = [:] + private var requestedTarget: SettingsSearchTarget? + private var requestedID: Int? + + func attach(_ scrollView: NSScrollView) { + self.scrollView = scrollView + self.scheduleRequestedScroll() + } + + func register(_ view: NSView, for target: SettingsSearchTarget) { + self.anchors[target] = WeakAnchor(view) + if target == self.requestedTarget { + self.scheduleRequestedScroll() + } + } + + func requestScroll(to target: SettingsSearchTarget?, requestID: Int) { + guard self.requestedTarget != target || self.requestedID != requestID else { return } + self.requestedTarget = target + self.requestedID = requestID + self.scheduleRequestedScroll() + } + + private func scheduleRequestedScroll() { + DispatchQueue.main.async { [weak self] in + self?.performRequestedScroll() + } + } + + private func performRequestedScroll() { + guard let target = self.requestedTarget, + let scrollView = self.scrollView, + let documentView = scrollView.documentView, + let anchorView = self.anchors[target]?.view, + anchorView.window != nil + else { return } + + let targetRect = anchorView.convert(anchorView.bounds, to: documentView) + .insetBy(dx: 0, dy: -16) + documentView.scrollToVisible(targetRect) + scrollView.reflectScrolledClipView(scrollView.contentView) + } +} + +struct SettingsSearchPresentation { + let matchedTargets: Set + let primaryTarget: SettingsSearchTarget + let scrollCoordinator: SettingsSearchScrollCoordinator +} + +private struct SettingsSearchPresentationKey: EnvironmentKey { + static let defaultValue: SettingsSearchPresentation? = nil +} + +extension EnvironmentValues { + var settingsSearchPresentation: SettingsSearchPresentation? { + get { self[SettingsSearchPresentationKey.self] } + set { self[SettingsSearchPresentationKey.self] = newValue } + } +} + +private struct SettingsSearchAnchor: NSViewRepresentable { + let target: SettingsSearchTarget + let scrollCoordinator: SettingsSearchScrollCoordinator + + func makeNSView(context _: Context) -> NSView { + let view = NSView() + self.scrollCoordinator.register(view, for: self.target) + return view + } + + func updateNSView(_ view: NSView, context _: Context) { + self.scrollCoordinator.register(view, for: self.target) + } +} + +private struct SettingsSearchTargetModifier: ViewModifier { + let target: SettingsSearchTarget + + @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion + @Environment(\.settingsSearchPresentation) private var presentation + + private var isMatched: Bool { + self.presentation?.matchedTargets.contains(self.target) == true + } + + private var isPrimary: Bool { + self.presentation?.primaryTarget == self.target + } + + func body(content: Content) -> some View { + content + .background { + ZStack { + if self.isMatched { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.accentColor.opacity(self.isPrimary ? 0.12 : 0.07)) + .padding(-6) + } + + if let presentation { + SettingsSearchAnchor( + target: self.target, + scrollCoordinator: presentation.scrollCoordinator + ) + } + } + } + .overlay { + if self.isMatched { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke( + Color.accentColor.opacity(self.isPrimary ? 0.72 : 0.38), + lineWidth: self.isPrimary ? 1.5 : 1 + ) + .padding(-6) + .allowsHitTesting(false) + } + } + .animation( + self.accessibilityReduceMotion ? nil : .easeOut(duration: 0.16), + value: self.isMatched + ) + .accessibilityHint(self.isMatched ? "Matches Settings search" : "") + } +} + +extension View { + func settingsSearchTarget(_ target: SettingsSearchTarget) -> some View { + modifier(SettingsSearchTargetModifier(target: target)) + } +} + +private final class SettingsPersistentScroller: NSScroller { + override static var isCompatibleWithOverlayScrollers: Bool { + false + } +} + +struct SettingsPersistentScrollView: NSViewRepresentable { + private let theme: AppTheme + private let colorScheme: ColorScheme + private let searchScrollCoordinator: SettingsSearchScrollCoordinator + private let searchScrollTarget: SettingsSearchTarget? + private let searchScrollRequest: Int + private let content: Content + + init( + theme: AppTheme, + colorScheme: ColorScheme, + searchScrollCoordinator: SettingsSearchScrollCoordinator, + searchScrollTarget: SettingsSearchTarget?, + searchScrollRequest: Int, + @ViewBuilder content: () -> Content + ) { + self.theme = theme + self.colorScheme = colorScheme + self.searchScrollCoordinator = searchScrollCoordinator + self.searchScrollTarget = searchScrollTarget + self.searchScrollRequest = searchScrollRequest + self.content = content() + } + + private var hostedContent: AnyView { + AnyView( + self.content + .appTheme(self.theme) + .environment(\.colorScheme, self.colorScheme) + ) + } + + func makeNSView(context _: Context) -> NSScrollView { + let scrollView = NSScrollView() + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = false + scrollView.scrollerStyle = .legacy + scrollView.verticalScroller = SettingsPersistentScroller() + scrollView.verticalScroller?.isHidden = false + scrollView.verticalScroller?.alphaValue = 1 + scrollView.verticalScrollElasticity = .allowed + scrollView.horizontalScrollElasticity = .none + + let hostingView = NSHostingView(rootView: self.hostedContent) + hostingView.translatesAutoresizingMaskIntoConstraints = false + hostingView.setContentHuggingPriority(.defaultLow, for: .horizontal) + hostingView.setContentHuggingPriority(.required, for: .vertical) + + scrollView.documentView = hostingView + NSLayoutConstraint.activate([ + hostingView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: scrollView.contentView.trailingAnchor), + hostingView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor), + hostingView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor), + ]) + + self.updateSearchScrolling(for: scrollView) + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context _: Context) { + (scrollView.documentView as? NSHostingView)?.rootView = self.hostedContent + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = false + scrollView.scrollerStyle = .legacy + if !(scrollView.verticalScroller is SettingsPersistentScroller) { + scrollView.verticalScroller = SettingsPersistentScroller() + } + scrollView.verticalScroller?.isHidden = false + scrollView.verticalScroller?.alphaValue = 1 + self.updateSearchScrolling(for: scrollView) + } + + private func updateSearchScrolling(for scrollView: NSScrollView) { + self.searchScrollCoordinator.attach(scrollView) + self.searchScrollCoordinator.requestScroll( + to: self.searchScrollTarget, + requestID: self.searchScrollRequest + ) + } +} diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index ac19d790..79e1580f 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -28,6 +28,9 @@ struct SettingsView: View { @Environment(\.theme) private var theme @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion @ObservedObject private var settings = SettingsStore.shared + let selectedSection: SettingsSection + let searchResults: [SettingsSearchResult] + let searchScrollRequest: Int @ObservedObject var microphonePreferenceCoordinator: MicrophonePreferenceCoordinator @Binding var appear: Bool @Binding var visualizerNoiseThreshold: Double @@ -65,9 +68,10 @@ struct SettingsView: View { @State private var rollbackVersion: String = "" @State private var isRollingBack: Bool = false @State private var audioHistoryBudgetText: String = Self.audioBudgetText(for: SettingsStore.shared.audioHistoryBudgetGB) - @State private var audioHistoryUsageBytes: Int64 = DictationAudioHistoryStore.shared.audioUsageBytes() + @State private var audioHistoryUsageBytes: Int64 = 0 @State private var draggedMicrophoneUID: String? @State private var hoveredMicrophoneUID: String? + @State private var searchScrollCoordinator = SettingsSearchScrollCoordinator() let hotkeyManager: GlobalHotkeyManager? let menuBarManager: MenuBarManager @@ -77,23 +81,6 @@ struct SettingsView: View { let restartApp: () -> Void let revealAppInFinder: () -> Void let openApplicationsFolder: () -> Void - let microphoneSettingsScrollRequest: Int - - private var isRecordingAnyShortcut: Bool { - self.activeShortcutRecordingTarget != nil - } - - private var settingsTitleText: Color { - Color(nsColor: .labelColor) - } - - private var settingsSecondaryText: Color { - self.colorScheme == .light ? Color(nsColor: .labelColor).opacity(0.90) : self.theme.palette.primaryText.opacity(0.82) - } - - private var settingsTertiaryText: Color { - self.colorScheme == .light ? Color(nsColor: .labelColor).opacity(0.85) : self.theme.palette.secondaryText - } private func isRecording(_ target: ShortcutRecordingTarget) -> Bool { self.activeShortcutRecordingTarget == target @@ -218,9 +205,17 @@ struct SettingsView: View { SettingsPersistentScrollView( theme: self.theme, colorScheme: self.colorScheme, - microphoneSettingsScrollRequest: self.microphoneSettingsScrollRequest + searchScrollCoordinator: self.searchScrollCoordinator, + searchScrollTarget: self.selectedSectionSearchResults.first?.target, + searchScrollRequest: self.searchScrollRequest ) { VStack(spacing: 16) { + Text(self.selectedSection.title) + .font(.title2.weight(.semibold)) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + .settingsSearchTarget(self.selectedSection.searchTarget) + // App Settings Card ThemedCard(style: .standard) { VStack(alignment: .leading, spacing: 14) { @@ -238,6 +233,7 @@ struct SettingsView: View { errorMessage: self.settings.launchAtStartupErrorMessage, isOn: self.launchAtStartupBinding ) + .settingsSearchTarget(.launchAtStartup) Divider().opacity(0.2) // Show window when launched at login @@ -249,6 +245,7 @@ struct SettingsView: View { set: { SettingsStore.shared.showMainWindowAtLoginLaunch = $0 } ) ) + .settingsSearchTarget(.showWindowAtLogin) Divider().opacity(0.2) // Hide from Dock & App Switcher @@ -261,6 +258,7 @@ struct SettingsView: View { set: { SettingsStore.shared.hideFromDockAndAppSwitcher = $0 } ) ) + .settingsSearchTarget(.dockVisibility) Divider().opacity(0.2) // Accent Color @@ -312,6 +310,7 @@ struct SettingsView: View { ) } } + .settingsSearchTarget(.accentColor) Divider().opacity(0.2) HStack { @@ -340,6 +339,7 @@ struct SettingsView: View { .pickerStyle(.menu) .frame(width: 170, alignment: .trailing) } + .settingsSearchTarget(.transcriptionSounds) if SettingsStore.shared.transcriptionStartSound != .none { HStack { @@ -444,6 +444,7 @@ struct SettingsView: View { .font(self.theme.typography.bodySmall) .foregroundStyle(self.settingsSecondaryText) } + .settingsSearchTarget(.automaticUpdates) // Update Buttons HStack(spacing: 10) { @@ -565,58 +566,57 @@ struct SettingsView: View { } .padding(16) } + .shownInSettingsSection(.general, selectedSection: self.selectedSection) - // Microphone Permission Card - ThemedCard(style: .standard) { - VStack(alignment: .leading, spacing: 14) { - Label("Microphone Permission", systemImage: "mic.fill") - .font(.headline) - .foregroundStyle(.primary) + if self.asr.micStatus != .authorized { + // Only surface microphone permission when the user needs to act. + ThemedCard(style: .standard) { + VStack(alignment: .leading, spacing: 14) { + Label("Microphone Permission", systemImage: "mic.fill") + .font(.headline) + .foregroundStyle(.primary) - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 10) { - Circle() - .fill(self.asr.micStatus == .authorized ? self.theme.palette.success : self.theme.palette.warning) - .frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Circle() + .fill(self.theme.palette.warning) + .frame(width: 8, height: 8) - VStack(alignment: .leading, spacing: 2) { - Text( - self.asr.micStatus == .authorized ? "Microphone access granted" : - self.asr.micStatus == .denied ? "Microphone access denied" : - "Microphone access not determined" - ) - .font(self.theme.typography.bodyStrong) - .foregroundStyle(self.asr.micStatus == .authorized ? .primary : self.theme.palette.warning) + VStack(alignment: .leading, spacing: 2) { + Text( + self.asr.micStatus == .denied + ? "Microphone access denied" + : "Microphone access not determined" + ) + .font(self.theme.typography.bodyStrong) + .foregroundStyle(self.theme.palette.warning) - if self.asr.micStatus != .authorized { Text("Microphone access is required for voice recording") .font(self.theme.typography.bodySmall) .foregroundStyle(self.settingsSecondaryText) } - } - Spacer() + Spacer() - if self.asr.micStatus == .notDetermined { - Button { - self.asr.requestMicAccess() - } label: { - Label("Grant Access", systemImage: "mic.fill") - } - .buttonStyle(.borderedProminent) - .tint(self.theme.palette.accent) - .controlSize(.regular) - } else if self.asr.micStatus == .denied { - Button { - self.asr.openSystemSettingsForMic() - } label: { - Label("Open Settings", systemImage: "gear") + if self.asr.micStatus == .notDetermined { + Button { + self.asr.requestMicAccess() + } label: { + Label("Grant Access", systemImage: "mic.fill") + } + .buttonStyle(.borderedProminent) + .tint(self.theme.palette.accent) + .controlSize(.regular) + } else if self.asr.micStatus == .denied { + Button { + self.asr.openSystemSettingsForMic() + } label: { + Label("Open Settings", systemImage: "gear") + } + .buttonStyle(.bordered) + .controlSize(.regular) } - .buttonStyle(.bordered) - .controlSize(.regular) } - } - if self.asr.micStatus != .authorized { self.instructionsBox( title: "How to enable microphone access:", steps: self.asr.micStatus == .notDetermined @@ -629,8 +629,10 @@ struct SettingsView: View { ) } } + .padding(16) } - .padding(16) + .settingsSearchTarget(.microphonePermission) + .shownInSettingsSection(.dictation, selectedSection: self.selectedSection) } // Global Hotkey Card @@ -664,6 +666,7 @@ struct SettingsView: View { } } } + .settingsSearchTarget(.globalHotkey) if self.accessibilityEnabled { VStack(alignment: .leading, spacing: 12) { @@ -698,6 +701,7 @@ struct SettingsView: View { .foregroundStyle(self.settingsTertiaryText) self.primaryDictationShortcutsList() + .settingsSearchTarget(.primaryDictationShortcuts) self.dictationPromptPicker(for: .primary) Divider().opacity(0.2).padding(.vertical, 4) @@ -728,6 +732,7 @@ struct SettingsView: View { self.commandModeShortcutEnabled = false } ) + .settingsSearchTarget(.commandModeShortcut) Divider().opacity(0.2).padding(.vertical, 4) self.shortcutRow( @@ -748,6 +753,7 @@ struct SettingsView: View { self.activeShortcutRecordingTarget = .edit } ) + .settingsSearchTarget(.editModeShortcut) Divider().opacity(0.2).padding(.vertical, 4) self.shortcutRow( @@ -767,6 +773,7 @@ struct SettingsView: View { self.activeShortcutRecordingTarget = .cancel } ) + .settingsSearchTarget(.cancelRecordingShortcut) Divider().opacity(0.2).padding(.vertical, 4) self.shortcutRow( @@ -796,6 +803,7 @@ struct SettingsView: View { self.pasteLastTranscriptionShortcutEnabled = false } ) + .settingsSearchTarget(.pasteLastTranscriptionShortcut) } .padding(12) .background( @@ -834,6 +842,7 @@ struct SettingsView: View { SettingsStore.shared.hotkeyMode = newValue self.hotkeyManager?.setHotkeyMode(newValue) } + .settingsSearchTarget(.activationMode) Divider().opacity(0.2) self.optionToggleRow( @@ -844,6 +853,7 @@ struct SettingsView: View { .onChange(of: self.copyToClipboard) { _, newValue in SettingsStore.shared.copyTranscriptionToClipboard = newValue } + .settingsSearchTarget(.copyToClipboard) Divider().opacity(0.2) HStack(alignment: .center) { @@ -869,9 +879,11 @@ struct SettingsView: View { .pickerStyle(.menu) .frame(width: 170, alignment: .trailing) } + .settingsSearchTarget(.textInsertionMode) Divider().opacity(0.2) self.spokenSendSettings + .settingsSearchTarget(.spokenSend) Divider().opacity(0.2) self.optionToggleRow( @@ -885,6 +897,7 @@ struct SettingsView: View { } ) ) + .settingsSearchTarget(.transcriptionHistory) Divider().opacity(0.2) self.optionToggleRow( @@ -899,12 +912,14 @@ struct SettingsView: View { ) ) .disabled(!SettingsStore.shared.saveTranscriptionHistory) + .settingsSearchTarget(.audioHistory) if SettingsStore.shared.saveTranscriptionHistory, SettingsStore.shared.saveAudioWithTranscriptionHistory { self.audioHistoryControls() .padding(.top, 2) + .settingsSearchTarget(.audioStorage) Divider().opacity(0.2) } else { Divider().opacity(0.2) @@ -918,6 +933,7 @@ struct SettingsView: View { set: { SettingsStore.shared.weekendsDontBreakStreak = $0 } ) ) + .settingsSearchTarget(.usageStreak) Divider().opacity(0.2) self.optionToggleRow( @@ -929,6 +945,7 @@ struct SettingsView: View { ), allowsDescriptionWrapping: true ) + .settingsSearchTarget(.skipSilentRecordings) Divider().opacity(0.2) self.optionToggleRow( @@ -939,6 +956,7 @@ struct SettingsView: View { set: { SettingsStore.shared.pauseMediaDuringTranscription = $0 } ) ) + .settingsSearchTarget(.pauseMedia) Divider().opacity(0.2) self.optionToggleRow( @@ -948,6 +966,7 @@ struct SettingsView: View { "Never includes transcription text or prompts.", isOn: self.detailedAnalyticsToggleBinding ) + .settingsSearchTarget(.analyticsPrivacy) HStack { Button("What we collect") { @@ -1016,10 +1035,12 @@ struct SettingsView: View { .controlSize(.small) } } + .settingsSearchTarget(.accessibilityPermission) } } .padding(16) } + .shownInSettingsSection(.dictation, selectedSection: self.selectedSection) ThemedCard(style: .standard) { VStack(alignment: .leading, spacing: 14) { @@ -1080,6 +1101,8 @@ struct SettingsView: View { } .padding(16) } + .settingsSearchTarget(.textFormatting) + .shownInSettingsSection(.dictation, selectedSection: self.selectedSection) // Notification Settings Card ThemedCard(style: .standard) { @@ -1097,6 +1120,7 @@ struct SettingsView: View { set: { SettingsStore.shared.notifyAIProcessingFailures = $0 } ) ) + .settingsSearchTarget(.aiEnhancementFailures) Divider().opacity(0.2) @@ -1113,10 +1137,12 @@ struct SettingsView: View { } ) ) + .settingsSearchTarget(.microphoneChanges) } } .padding(16) } + .shownInSettingsSection(.notifications, selectedSection: self.selectedSection) // Audio Devices Card ThemedCard(style: .standard) { @@ -1143,6 +1169,7 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 12) { self.microphonePrioritySection + .settingsSearchTarget(.inputDevicePriority) .onChange(of: self.inputDevices) { _, newDevices in let defaultInput = AudioDevice.getDefaultInputDevice() self.cachedDefaultInputUID = defaultInput?.uid ?? "" @@ -1214,18 +1241,19 @@ struct SettingsView: View { } } } + .settingsSearchTarget(.outputDevice) self.microphoneQualityGuidance } } .padding(16) } - .background(MicrophoneSettingsScrollAnchor()) + .shownInSettingsSection(.audio, selectedSection: self.selectedSection) // Overlay Settings Card ThemedCard(style: .standard) { VStack(alignment: .leading, spacing: 14) { - Label("Overlay", systemImage: "waveform") + Label("Overlay", systemImage: "rectangle.on.rectangle") .font(.headline) .foregroundStyle(.primary) @@ -1249,6 +1277,7 @@ struct SettingsView: View { .buttonStyle(.bordered) .controlSize(.small) } + .settingsSearchTarget(.overlaySensitivity) HStack(spacing: 10) { Text("More") @@ -1293,6 +1322,7 @@ struct SettingsView: View { .pickerStyle(.menu) .frame(width: 170, alignment: .trailing) } + .settingsSearchTarget(.overlayPosition) Divider().padding(.vertical, 8) @@ -1336,6 +1366,7 @@ struct SettingsView: View { .frame(width: 36, alignment: .leading) } } + .settingsSearchTarget(.transcriptionPreviewLength) Divider().padding(.vertical, 4) @@ -1373,6 +1404,7 @@ struct SettingsView: View { .frame(width: 170, alignment: .trailing) } } + .settingsSearchTarget(.overlayStyle) HStack { VStack(alignment: .leading, spacing: 2) { @@ -1392,6 +1424,7 @@ struct SettingsView: View { SettingsStore.shared.enableStreamingPreview = newValue } } + .settingsSearchTarget(.livePreview) // Bottom overlay specific settings (only show when bottom is selected) if self.settings.overlayPosition == .bottom { @@ -1422,6 +1455,7 @@ struct SettingsView: View { } .frame(width: 170, alignment: .trailing) } + .settingsSearchTarget(.bottomOffset) } if self.asr.isRunning { @@ -1436,12 +1470,15 @@ struct SettingsView: View { } .padding(16) } + .shownInSettingsSection(.overlay, selectedSection: self.selectedSection) // Backup & Restore Card ThemedCard(style: .standard) { self.backupUtilityRow() .padding(16) } + .settingsSearchTarget(.backupAndRestore) + .shownInSettingsSection(.dataAndDiagnostics, selectedSection: self.selectedSection) // Debug Settings Card ThemedCard(style: .standard) { @@ -1485,6 +1522,8 @@ struct SettingsView: View { } .padding(16) } + .settingsSearchTarget(.debugLogs) + .shownInSettingsSection(.dataAndDiagnostics, selectedSection: self.selectedSection) ThemedCard(style: .standard) { VStack(alignment: .leading, spacing: 14) { @@ -1506,9 +1545,13 @@ struct SettingsView: View { } .padding(16) } + .settingsSearchTarget(.fasterLongDictation) + .shownInSettingsSection(.dataAndDiagnostics, selectedSection: self.selectedSection) } .padding(16) + .environment(\.settingsSearchPresentation, self.settingsSearchPresentation) } + .id(self.selectedSection) .sheet(isPresented: self.$showAnalyticsPrivacy) { AnalyticsPrivacyView() .frame(minWidth: 520, minHeight: 520) @@ -1530,55 +1573,8 @@ struct SettingsView: View { } ) } - .onAppear { - Task { @MainActor in - // Ensure the shared audio startup gate is scheduled. Safe to call repeatedly. - await AudioStartupGate.shared.scheduleOpenAfterInitialUISettled() - await AudioStartupGate.shared.waitUntilOpen() - - self.refreshDevices() - - // Sync input device selection after refresh - if !self.inputDevices.isEmpty { - let defaultInput = AudioDevice.getDefaultInputDevice() - self.cachedDefaultInputUID = defaultInput?.uid ?? "" - if let selectedInput = self.appServices.microphonePreferenceCoordinator - .reconcileMicrophoneSelection( - availableInputs: self.inputDevices, - defaultInputUID: self.cachedDefaultInputUID - ) - { - self.selectedInputUID = selectedInput.uid - } - } - - // Sync output device selection after refresh - if !self.outputDevices.isEmpty { - let outputValid = self.outputDevices.contains { $0.uid == self.selectedOutputUID } - if !outputValid || self.selectedOutputUID.isEmpty { - if let prefUID = SettingsStore.shared.preferredOutputDeviceUID, - self.outputDevices.contains(where: { $0.uid == prefUID }) - { - self.selectedOutputUID = prefUID - } else if let defaultUID = AudioDevice.getDefaultOutputDevice()?.uid, - self.outputDevices.contains(where: { $0.uid == defaultUID }) - { - self.selectedOutputUID = defaultUID - } else { - self.selectedOutputUID = self.outputDevices.first?.uid ?? "" - } - } - } - - // CRITICAL FIX: Populate cached default device names after onAppear, not during view body evaluation. - // This avoids the CoreAudio/SwiftUI AttributeGraph race condition that causes EXC_BAD_ACCESS. - let defaultInput = AudioDevice.getDefaultInputDevice() - self.cachedDefaultInputUID = defaultInput?.uid ?? "" - self.cachedDefaultOutputName = AudioDevice.getDefaultOutputDevice()?.name ?? "" - self.refreshRollbackState() - self.settings.refreshLaunchAtStartupStatus(clearError: true, logMismatch: false) - self.refreshAudioHistoryUsage() - } + .task(id: self.selectedSection) { + await self.prepareSelectedSection() } .onChange(of: self.visualizerNoiseThreshold) { _, newValue in SettingsStore.shared.visualizerNoiseThreshold = newValue @@ -2289,6 +2285,35 @@ struct SettingsView: View { } private extension SettingsView { + var isRecordingAnyShortcut: Bool { + self.activeShortcutRecordingTarget != nil + } + + var selectedSectionSearchResults: [SettingsSearchResult] { + self.searchResults.filter { $0.section == self.selectedSection } + } + + var settingsSearchPresentation: SettingsSearchPresentation? { + guard let primaryTarget = self.selectedSectionSearchResults.first?.target else { return nil } + return SettingsSearchPresentation( + matchedTargets: Set(self.selectedSectionSearchResults.map(\.target)), + primaryTarget: primaryTarget, + scrollCoordinator: self.searchScrollCoordinator + ) + } + + var settingsTitleText: Color { + Color(nsColor: .labelColor) + } + + var settingsSecondaryText: Color { + self.colorScheme == .light ? Color(nsColor: .labelColor).opacity(0.90) : self.theme.palette.primaryText.opacity(0.82) + } + + var settingsTertiaryText: Color { + self.colorScheme == .light ? Color(nsColor: .labelColor).opacity(0.85) : self.theme.palette.secondaryText + } + var microphonePrioritySection: some View { VStack(alignment: .leading, spacing: 8) { HStack { @@ -2553,6 +2578,83 @@ private extension SettingsView { } } +private extension SettingsView { + func prepareSelectedSection() async { + do { + try await Task.sleep(nanoseconds: self.accessibilityReduceMotion ? 120_000_000 : 240_000_000) + } catch { + return + } + + guard !Task.isCancelled else { return } + switch self.selectedSection { + case .general: + self.refreshRollbackState() + self.settings.refreshLaunchAtStartupStatus(clearError: true, logMismatch: false) + case .audio: + await self.prepareAudioSettings() + case .dictation: + await self.refreshAudioHistoryUsageInBackground() + case .notifications, .overlay, .dataAndDiagnostics: + break + } + } + + func prepareAudioSettings() async { + // Keep Core Audio initialization out of the navigation transaction. + await AudioStartupGate.shared.scheduleOpenAfterInitialUISettled() + await AudioStartupGate.shared.waitUntilOpen() + guard !Task.isCancelled else { return } + + self.refreshDevices() + + if !self.inputDevices.isEmpty { + let defaultInput = AudioDevice.getDefaultInputDevice() + self.cachedDefaultInputUID = defaultInput?.uid ?? "" + if let selectedInput = self.appServices.microphonePreferenceCoordinator + .reconcileMicrophoneSelection( + availableInputs: self.inputDevices, + defaultInputUID: self.cachedDefaultInputUID + ) + { + self.selectedInputUID = selectedInput.uid + } + } + + if !self.outputDevices.isEmpty { + let outputValid = self.outputDevices.contains { $0.uid == self.selectedOutputUID } + if !outputValid || self.selectedOutputUID.isEmpty { + if let prefUID = SettingsStore.shared.preferredOutputDeviceUID, + self.outputDevices.contains(where: { $0.uid == prefUID }) + { + self.selectedOutputUID = prefUID + } else if let defaultUID = AudioDevice.getDefaultOutputDevice()?.uid, + self.outputDevices.contains(where: { $0.uid == defaultUID }) + { + self.selectedOutputUID = defaultUID + } else { + self.selectedOutputUID = self.outputDevices.first?.uid ?? "" + } + } + } + + // Cache hardware names outside body evaluation to avoid the Core Audio/AttributeGraph race. + let defaultInput = AudioDevice.getDefaultInputDevice() + self.cachedDefaultInputUID = defaultInput?.uid ?? "" + self.cachedDefaultOutputName = AudioDevice.getDefaultOutputDevice()?.name ?? "" + } + + func refreshAudioHistoryUsageInBackground() async { + let usageBytes = await Task.detached(priority: .utility) { + DictationAudioHistoryStore.shared.audioUsageBytes() + }.value + guard !Task.isCancelled else { return } + + self.audioHistoryUsageBytes = usageBytes + self.audioHistoryBudgetText = Self.audioBudgetText(for: SettingsStore.shared.audioHistoryBudgetGB) + } +} + private struct MicrophonePriorityDropDelegate: DropDelegate { let targetUID: String let settings: SettingsStore @@ -2593,132 +2695,12 @@ private struct MicrophonePriorityDropDelegate: DropDelegate { } } -private final class SettingsPersistentScroller: NSScroller { - override static var isCompatibleWithOverlayScrollers: Bool { - false - } -} - -private final class SettingsPersistentScrollCoordinator { - var lastMicrophoneSettingsScrollRequest = 0 -} - -private struct SettingsPersistentScrollView: NSViewRepresentable { - private let theme: AppTheme - private let colorScheme: ColorScheme - private let microphoneSettingsScrollRequest: Int - private let content: Content - - init( - theme: AppTheme, - colorScheme: ColorScheme, - microphoneSettingsScrollRequest: Int, - @ViewBuilder content: () -> Content - ) { - self.theme = theme - self.colorScheme = colorScheme - self.microphoneSettingsScrollRequest = microphoneSettingsScrollRequest - self.content = content() - } - - func makeCoordinator() -> SettingsPersistentScrollCoordinator { - SettingsPersistentScrollCoordinator() - } - - private var hostedContent: AnyView { - AnyView( - self.content - .appTheme(self.theme) - .environment(\.colorScheme, self.colorScheme) - ) - } - - func makeNSView(context _: Context) -> NSScrollView { - let scrollView = NSScrollView() - scrollView.drawsBackground = false - scrollView.hasVerticalScroller = true - scrollView.hasHorizontalScroller = false - scrollView.autohidesScrollers = false - scrollView.scrollerStyle = .legacy - scrollView.verticalScroller = SettingsPersistentScroller() - scrollView.verticalScroller?.isHidden = false - scrollView.verticalScroller?.alphaValue = 1 - scrollView.verticalScrollElasticity = .allowed - scrollView.horizontalScrollElasticity = .none - - let hostingView = NSHostingView(rootView: self.hostedContent) - hostingView.translatesAutoresizingMaskIntoConstraints = false - hostingView.setContentHuggingPriority(.defaultLow, for: .horizontal) - hostingView.setContentHuggingPriority(.required, for: .vertical) - - scrollView.documentView = hostingView - NSLayoutConstraint.activate([ - hostingView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor), - hostingView.trailingAnchor.constraint(equalTo: scrollView.contentView.trailingAnchor), - hostingView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor), - hostingView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor), - ]) - - return scrollView - } - - func updateNSView(_ scrollView: NSScrollView, context: Context) { - (scrollView.documentView as? NSHostingView)?.rootView = self.hostedContent - scrollView.hasVerticalScroller = true - scrollView.hasHorizontalScroller = false - scrollView.autohidesScrollers = false - scrollView.scrollerStyle = .legacy - if !(scrollView.verticalScroller is SettingsPersistentScroller) { - scrollView.verticalScroller = SettingsPersistentScroller() - } - scrollView.verticalScroller?.isHidden = false - scrollView.verticalScroller?.alphaValue = 1 - - guard self.microphoneSettingsScrollRequest > 0, - context.coordinator.lastMicrophoneSettingsScrollRequest != self.microphoneSettingsScrollRequest - else { return } - context.coordinator.lastMicrophoneSettingsScrollRequest = self.microphoneSettingsScrollRequest - DispatchQueue.main.async { - Self.scrollToMicrophoneSettings(in: scrollView) - } - } - - private static func scrollToMicrophoneSettings(in scrollView: NSScrollView) { - guard let documentView = scrollView.documentView else { return } - documentView.layoutSubtreeIfNeeded() - guard let anchor = documentView.descendant(withIdentifier: MicrophoneSettingsScrollAnchor.identifier) else { - return - } - - let targetRect = anchor.convert(anchor.bounds, to: documentView) - let maximumY = max(0, documentView.bounds.height - scrollView.contentView.bounds.height) - let targetY = min(maximumY, max(0, targetRect.minY - 12)) - scrollView.contentView.scroll(to: NSPoint(x: 0, y: targetY)) - scrollView.reflectScrolledClipView(scrollView.contentView) - } -} - -private struct MicrophoneSettingsScrollAnchor: NSViewRepresentable { - static let identifier = NSUserInterfaceItemIdentifier("FluidVoice.MicrophoneSettingsScrollAnchor") - - func makeNSView(context _: Context) -> NSView { - let view = NSView() - view.identifier = Self.identifier - return view - } - - func updateNSView(_: NSView, context _: Context) {} -} - -private extension NSView { - func descendant(withIdentifier identifier: NSUserInterfaceItemIdentifier) -> NSView? { - if self.identifier == identifier { return self } - for subview in self.subviews { - if let match = subview.descendant(withIdentifier: identifier) { - return match - } +private extension View { + @ViewBuilder + func shownInSettingsSection(_ section: SettingsSection, selectedSection: SettingsSection) -> some View { + if section == selectedSection { + self } - return nil } } diff --git a/Tests/FluidDictationIntegrationTests/SettingsNavigationStateTests.swift b/Tests/FluidDictationIntegrationTests/SettingsNavigationStateTests.swift new file mode 100644 index 00000000..b04434a4 --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/SettingsNavigationStateTests.swift @@ -0,0 +1,104 @@ +@testable import FluidVoice_Debug +import XCTest + +@MainActor +final class SettingsNavigationStateTests: XCTestCase { + func testPresentAndDismissRestoresPreviousAppDestination() { + var state = SettingsNavigationState() + + state.present(.general, returningTo: .history) + + XCTAssertTrue(state.isPresented) + XCTAssertEqual(state.selectedSection, .general) + XCTAssertEqual(state.returnDestination, .history) + XCTAssertEqual(state.dismiss(), .history) + XCTAssertFalse(state.isPresented) + } + + func testSpecificDeepLinkChangesSectionWithoutReplacingReturnDestination() { + var state = SettingsNavigationState() + state.present(.general, returningTo: .stats) + + state.present(.audio, returningTo: .customDictionary) + + XCTAssertEqual(state.selectedSection, .audio) + XCTAssertEqual(state.returnDestination, .stats) + } + + func testMissingReturnDestinationFallsBackToGettingStarted() { + var state = SettingsNavigationState() + + state.present(.general, returningTo: nil) + + XCTAssertEqual(state.dismiss(), .welcome) + } + + func testLeavingForAppDismissesSettings() { + var state = SettingsNavigationState() + state.present(.overlay, returningTo: .voiceEngine) + + state.leaveForApp() + + XCTAssertFalse(state.isPresented) + XCTAssertNil(state.selectedSection) + } + + func testSettingsSectionsHaveStableTitlesAndIcons() { + XCTAssertEqual( + SettingsSection.allCases.map(\.title), + ["General", "Dictation", "Notifications", "Audio", "Overlay", "Data & Diagnostics"] + ) + XCTAssertTrue(SettingsSection.allCases.allSatisfy { !$0.systemImage.isEmpty }) + XCTAssertEqual(SettingsSection.overlay.systemImage, "rectangle.on.rectangle") + } + + func testSettingsSearchRanksExactTitleAheadOfRelatedTerms() { + let results = SettingsSearchIndex.results(for: "Copy to Clipboard") + + XCTAssertEqual(results.first?.target, .copyToClipboard) + XCTAssertTrue(results.contains { $0.target == .textInsertionMode }) + } + + func testSettingsSearchNormalizesCaseAndDiacritics() { + let results = SettingsSearchIndex.results(for: "ACCÉNT COLOR") + + XCTAssertEqual(results.first?.target, .accentColor) + } + + func testSettingsSearchMatchesPrefixesAliasesAndMultipleWords() { + XCTAssertEqual(SettingsSearchIndex.results(for: "start").first?.target, .launchAtStartup) + XCTAssertTrue(SettingsSearchIndex.results(for: "mic").contains { $0.target == .inputDevicePriority }) + XCTAssertEqual(SettingsSearchIndex.results(for: "audio device").first?.section, .audio) + } + + func testSettingsSearchToleratesRepresentativeTypos() { + XCTAssertTrue(SettingsSearchIndex.results(for: "microfone").contains { $0.target == .microphonePermission }) + XCTAssertEqual(SettingsSearchIndex.results(for: "clipbord").first?.target, .copyToClipboard) + XCTAssertEqual(SettingsSearchIndex.results(for: "hot ky").first?.target, .globalHotkey) + } + + func testSettingsSearchRejectsUnrelatedShortQuery() { + XCTAssertTrue(SettingsSearchIndex.results(for: "zz").isEmpty) + } + + func testSettingsSearchKeepsSectionsInNavigationOrder() { + XCTAssertEqual( + SettingsSearchIndex.matchingSections(for: "mic"), + [.dictation, .notifications, .audio] + ) + } + + func testSettingsSearchPreservesMatchingSectionAndFallsBackToBestResult() { + let results = SettingsSearchIndex.results(for: "mic") + + XCTAssertEqual( + SettingsSearchIndex.preferredSection(current: .audio, results: results), + .audio + ) + XCTAssertEqual( + SettingsSearchIndex.preferredSection(current: .general, results: results), + results.first?.section + ) + XCTAssertEqual(SettingsSearchIndex.preferredSection(current: .audio, results: []), .audio) + } +}