diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..60573f9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,17 @@ +name: Test + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: macos-15 + steps: + - uses: actions/checkout@v5 + - name: Run unit tests + run: swift test diff --git a/README.md b/README.md index 3ff9e92..d63a0e0 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ swift test If tests fail because `XCTest` is unavailable, install the full Xcode app instead of using only Command Line Tools. -Check all UI languages with `scripts/check-localization.sh`. Add `--snapshots` to generate a local HTML gallery of synthetic native UI fixtures under `.build/localization-audit/`; the command prints its path. Pull requests run the localization checks automatically. See [localization coverage and remaining manual checks](docs/localization-audit.md). +Check all UI languages with `scripts/check-localization.sh`. Add `--snapshots` to generate a local HTML gallery of synthetic native UI fixtures under `.build/localization-audit/`; the command prints its path. Pull requests and pushes to `main` run the full unit test suite and the localization checks automatically. See [localization coverage and remaining manual checks](docs/localization-audit.md). ## Keyboard Flow diff --git a/README.zh-CN.md b/README.zh-CN.md index 681b49c..3960878 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -131,7 +131,7 @@ swift test 如果测试因为 `XCTest` 不可用而失败,请安装完整 Xcode,而不是只安装 Command Line Tools。 -使用 `scripts/check-localization.sh` 检查所有界面语言。加上 `--snapshots` 可在 `.build/localization-audit/` 下生成使用模拟数据的原生界面 HTML 截图集,命令会输出文件路径。Pull request 会自动运行本地化检查。检查范围和仍需手动验证的项目见[本地化检查说明](docs/localization-audit.md)。 +使用 `scripts/check-localization.sh` 检查所有界面语言。加上 `--snapshots` 可在 `.build/localization-audit/` 下生成使用模拟数据的原生界面 HTML 截图集,命令会输出文件路径。Pull request 和推送到 `main` 的提交会自动运行全部单元测试和本地化检查。检查范围和仍需手动验证的项目见[本地化检查说明](docs/localization-audit.md)。 ## 快捷键 diff --git a/Sources/InkletApp/AppCoordinator.swift b/Sources/InkletApp/AppCoordinator.swift index 4fe0d4e..86e4f96 100644 --- a/Sources/InkletApp/AppCoordinator.swift +++ b/Sources/InkletApp/AppCoordinator.swift @@ -36,23 +36,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } -@MainActor -private enum SelectionPronunciationReturnState: Equatable { - case menu - case translationResult -} - -private struct PendingSelectionRead { - let sourceProcessIdentifier: pid_t - let location: SelectionPoint -} - -private struct PendingUserCopyRead { - let sourceProcessIdentifier: pid_t - let location: SelectionPoint - let clipboardHandoff: SelectionClipboardUserCopyHandoff -} - @MainActor enum UpdateCheckMenuConfiguration { static func apply(to menu: NSMenu) { @@ -74,14 +57,7 @@ final class AppCoordinator: NSObject, NSMenuDelegate { private let configStore: UserDefaultsConfigStore private let accessibilityPermissionService: AccessibilityPermissionService private let apiKeyStore: LocalAPIKeyStore - private let selectionActionMonitor: SelectionActionMonitor - private let selectionActionWindowController: SelectionActionWindowController - private let selectionSourceValidator: SelectionSourceValidator - private let selectionClipboardReader: SelectionClipboardReader - private let selectionUserCopyReader: SelectionUserCopyReader - private let selectionReadPipeline: SelectionReadPipeline - private let selectionTranslationCache: JSONSelectionTranslationCache - private let speechPlaybackService: SpeechPlaybackService + private let selectionActions: SelectionActionsController private let historyStore: JSONLHistoryStore private var configObserver: NSObjectProtocol? private var accessibilityObserver: NSObjectProtocol? @@ -92,23 +68,8 @@ final class AppCoordinator: NSObject, NSMenuDelegate { private var settingsShortcutMonitor: Any? private var lastTargetApplication: NSRunningApplication? private var isRecordingHotkey = false - private var selectionActionCoordinator: SelectionActionCoordinator - private let selectionTaskRegistry = SelectionTaskRegistry() - private var selectionReadTask: Task? - private var selectionTranslationTask: Task? - private var selectionTTSTask: Task? - private var selectionCopyFeedbackTask: Task? - private var selectionReadTaskID: UUID? - private var selectionTranslationTaskID: UUID? - private var selectionTTSTaskID: UUID? - private var selectionCopyFeedbackTaskID: UUID? - private var isSelectionSpeechPlaying = false private var isMigrationMaintenanceActive = false private var isStopping = false - private var currentSelectionText = "" - private var currentTranslationText = "" - private var selectionPronunciationReturnState = SelectionPronunciationReturnState.menu - private var panelDismissalPolicy = SelectionPanelDismissalPolicy() private var mainUpdateCheckMenuItem: NSMenuItem? private var statusUpdateCheckMenuItem: NSMenuItem? private var trackedMenus: Set = [] @@ -143,17 +104,7 @@ final class AppCoordinator: NSObject, NSMenuDelegate { let configStore = UserDefaultsConfigStore() let apiKeyStore = LocalAPIKeyStore() let migrationPresentationModel = LegacyMigrationPresentationModel(outcome: migrationOutcome) - let selectionSourceValidator = SelectionSourceValidator() - let sourceValidator: SelectionReadPipeline.SourceValidator = { - selectionSourceValidator.isCurrent($0) - } - let selectedTextReader = SelectedTextReader() - let selectionClipboardReader = SelectionClipboardReader( - sourceProcessValidator: sourceValidator - ) - let selectionUserCopyReader = SelectionUserCopyReader( - sourceProcessValidator: sourceValidator - ) + let accessibilityPermissionService = AccessibilityPermissionService() self.migrationOutcome = migrationOutcome self.migrator = migrator @@ -173,34 +124,16 @@ final class AppCoordinator: NSObject, NSMenuDelegate { self.aboutController = AboutWindowController() self.hotkeyManager = GlobalHotkeyManager() self.configStore = configStore - self.accessibilityPermissionService = AccessibilityPermissionService() + self.accessibilityPermissionService = accessibilityPermissionService self.apiKeyStore = apiKeyStore - self.selectionActionMonitor = SelectionActionMonitor() - self.selectionActionWindowController = SelectionActionWindowController() - self.selectionSourceValidator = selectionSourceValidator - self.selectionClipboardReader = selectionClipboardReader - self.selectionUserCopyReader = selectionUserCopyReader - self.selectionReadPipeline = SelectionReadPipeline( - sourceValidator: sourceValidator, - accessibilityReader: { sourceProcessIdentifier, mouseLocation in - selectedTextReader.readSelectedText( - sourceProcessIdentifier: sourceProcessIdentifier, - mouseLocation: mouseLocation - ) - }, - clipboardReader: { sourceProcessIdentifier, forceSelectionMode in - await selectionClipboardReader.readSelectedText( - sourceProcessIdentifier: sourceProcessIdentifier, - forceSelectionMode: forceSelectionMode - ) - } - ) - self.selectionTranslationCache = JSONSelectionTranslationCache( - fileURL: storagePaths.translationCacheFileURL - ) - self.speechPlaybackService = SpeechPlaybackService() - self.selectionActionCoordinator = SelectionActionCoordinator( - config: ((try? configStore.load()) ?? AppConfig.defaultConfig()).selectionActions + self.selectionActions = SelectionActionsController( + configStore: configStore, + apiKeyStore: apiKeyStore, + historyStore: historyStore, + accessibilityPermissionService: accessibilityPermissionService, + translationCache: JSONSelectionTranslationCache( + fileURL: storagePaths.translationCacheFileURL + ) ) super.init() @@ -227,77 +160,13 @@ final class AppCoordinator: NSObject, NSMenuDelegate { self.settingsController.onMigrationWorkflowIdleChange = { [weak self] _ in self?.refreshMigrationImportEligibility() } - self.selectionActionMonitor.onCandidateSelection = { [weak self] point in - guard let self, !self.isStopping else { return } - self.handleSelectionActionCandidate(at: point) - } - self.selectionActionMonitor.onCopyTrigger = { [weak self] trigger in - guard let self, - !self.isStopping, - !isMigrationMaintenanceActive, - trigger.sourceProcessIdentifier > 0, - trigger.sourceProcessIdentifier != NSRunningApplication.current.processIdentifier, - selectionSourceValidator.isCurrent(trigger.sourceProcessIdentifier) - else { - return - } - let clipboardHandoff = selectionClipboardReader.beginUserCopyHandoff() - let pendingUserCopyRead = PendingUserCopyRead( - sourceProcessIdentifier: trigger.sourceProcessIdentifier, - location: trigger.point, - clipboardHandoff: clipboardHandoff - ) - self.handleSelectionActionCopyTrigger(pendingUserCopyRead) + self.selectionActions.onWorkStateChange = { [weak self] in + self?.refreshMigrationImportEligibility() } - self.selectionActionMonitor.onDismiss = { [weak self] reason in + self.selectionActions.onInteractionEnded = { [weak self] in guard let self, !self.isStopping else { return } - self.handleSelectionDismissRequest( - reason: String(describing: reason), - bypassingPanelGrace: reason.bypassesPanelGrace - ) - } - self.selectionActionMonitor.onInteractionStateChange = { [weak self] isActive in - guard let self, !self.isStopping, !isActive else { return } self.automaticUpdatePresentationGate.schedule() } - self.selectionActionWindowController.onTranslate = { [weak self] in - Task { @MainActor in - self?.translateCurrentSelection() - } - } - self.selectionActionWindowController.onPronounce = { [weak self] in - Task { @MainActor in - self?.pronounceCurrentSelection() - } - } - self.selectionActionWindowController.onPronounceOriginal = { [weak self] in - Task { @MainActor in - self?.pronounceOriginalFromTranslation() - } - } - self.selectionActionWindowController.onPronounceTranslation = { [weak self] in - Task { @MainActor in - self?.pronounceCurrentTranslation() - } - } - self.selectionActionWindowController.onCopyTranslation = { [weak self] in - self?.copyCurrentTranslation() - } - self.selectionActionWindowController.onRetryTranslation = { [weak self] in - Task { @MainActor in - self?.translateCurrentSelection() - } - } - self.selectionActionWindowController.onDismiss = { [weak self] in - guard let self, !self.isStopping else { return } - self.forceDismissSelectionActions(reason: "selectionPanelEscape") - } - self.speechPlaybackService.onFinish = { [weak self] in - guard let self, !self.isStopping else { return } - self.isSelectionSpeechPlaying = false - self.restoreSelectionPronunciationReturnState() - self.refreshMigrationImportEligibility() - } } private func makeUpdateCheckCoordinator() -> UpdateCheckCoordinator { @@ -350,7 +219,7 @@ final class AppCoordinator: NSObject, NSMenuDelegate { } self.registerConfiguredHotkey() self.windowController.reloadDictationConfiguration() - self.configureSelectionActions() + self.selectionActions.configureSelectionActions() } } @@ -361,7 +230,7 @@ final class AppCoordinator: NSObject, NSMenuDelegate { ) { [weak self] _ in Task { @MainActor in guard let self, !self.isStopping else { return } - self.configureSelectionActions() + self.selectionActions.configureSelectionActions() } } @@ -402,7 +271,7 @@ final class AppCoordinator: NSObject, NSMenuDelegate { registerConfiguredHotkey() windowController.reloadDictationConfiguration() - configureSelectionActions() + selectionActions.configureSelectionActions() showPermissionSettingsIfNeeded() installSettingsShortcutMonitor() refreshMigrationImportEligibility() @@ -443,23 +312,10 @@ final class AppCoordinator: NSObject, NSMenuDelegate { self.settingsShortcutMonitor = nil } hotkeyManager.unregister() - selectionActionMonitor.stop() - let selectionTasks = selectionTaskRegistry.snapshotAndClear() - selectionReadTask = nil - selectionTranslationTask = nil - selectionTTSTask = nil - selectionCopyFeedbackTask = nil - selectionReadTaskID = nil - selectionTranslationTaskID = nil - selectionTTSTaskID = nil - selectionCopyFeedbackTaskID = nil - selectionTasks.cancel() - speechPlaybackService.stop() - isSelectionSpeechPlaying = false + selectionActions.beginStopping() await windowController.cancelDictationAndWait() - await selectionClipboardReader.cancelActiveRead() - await selectionTasks.waitForCompletion() + await selectionActions.finishStopping() } private func configureMainMenu() { @@ -544,10 +400,7 @@ final class AppCoordinator: NSObject, NSMenuDelegate { private var migrationWorkflowsAreIdle: Bool { !windowController.isBusy && settingsController.isMigrationWorkflowIdle - && selectionReadTask == nil - && selectionTranslationTask == nil - && selectionTTSTask == nil - && !isSelectionSpeechPlaying + && selectionActions.isIdle } private var canRequestAssistedMigration: Bool { @@ -561,8 +414,8 @@ final class AppCoordinator: NSObject, NSMenuDelegate { migrationWorkflowsAreIdle: migrationWorkflowsAreIdle, isSelectingMigrationSource: migrationPresentationModel.phase == .selecting, hasModalWindow: NSApp.modalWindow != nil, - isSelectionPanelVisible: selectionActionWindowController.isPanelVisible, - isSelectionInteractionActive: selectionActionMonitor.isInteractionActive, + isSelectionPanelVisible: selectionActions.isPanelVisible, + isSelectionInteractionActive: selectionActions.isInteractionActive, isMenuTracking: !trackedMenus.isEmpty, isUpdateAlertPresenting: updateCheckAlertPresenter.isPresentingAlert, isStopping: isStopping @@ -651,23 +504,7 @@ final class AppCoordinator: NSObject, NSMenuDelegate { private func enterMigrationMaintenance() async { isMigrationMaintenanceActive = true hotkeyManager.unregister() - selectionActionMonitor.stop() - selectionReadTask?.cancel() - selectionReadTask = nil - selectionReadTaskID = nil - selectionTranslationTask?.cancel() - selectionTranslationTask = nil - selectionTranslationTaskID = nil - selectionTTSTask?.cancel() - selectionTTSTask = nil - selectionTTSTaskID = nil - selectionCopyFeedbackTask?.cancel() - selectionCopyFeedbackTask = nil - selectionCopyFeedbackTaskID = nil - forceDismissSelectionActions(reason: "migrationMaintenance") - selectionActionWindowController.hidePanel() - speechPlaybackService.stop() - isSelectionSpeechPlaying = false + selectionActions.enterMigrationMaintenance() await windowController.cancelForMigrationMaintenance() await settingsController.waitForMigrationMaintenanceQuiescence() refreshMigrationImportEligibility() @@ -678,7 +515,7 @@ final class AppCoordinator: NSObject, NSMenuDelegate { isMigrationMaintenanceActive = false registerConfiguredHotkey() windowController.reloadDictationConfiguration() - configureSelectionActions() + selectionActions.leaveMigrationMaintenance() refreshMigrationImportEligibility() } @@ -720,17 +557,7 @@ final class AppCoordinator: NSObject, NSMenuDelegate { private func handleActivatedApplication(_ application: NSRunningApplication?) { guard !isStopping else { return } rememberTargetApplication(application) - if SelectionActivationDismissalPolicy.shouldDismiss( - activatedProcessIdentifier: application?.processIdentifier, - currentProcessIdentifier: NSRunningApplication.current.processIdentifier - ) { - SelectionActionDiagnostics.log( - "activated external app dismiss pid=\(application?.processIdentifier ?? -1)" - ) - handleSelectionDismissRequest(reason: "externalActivation", bypassingPanelGrace: true) - } else { - SelectionActionDiagnostics.log("activated current app ignored for selection dismiss") - } + selectionActions.handleActivatedApplication(application) } private func registerConfiguredHotkey() { @@ -759,504 +586,6 @@ final class AppCoordinator: NSObject, NSMenuDelegate { } } - private func configureSelectionActions() { - guard !isStopping, !isMigrationMaintenanceActive else { - selectionActionMonitor.stop() - return - } - let config = (try? configStore.load()) ?? AppConfig.defaultConfig() - handleSelectionActionEffects(selectionActionCoordinator.handle(.updateConfig(config.selectionActions))) - let isAccessibilityTrusted = accessibilityPermissionService.isTrusted - SelectionActionDiagnostics.log( - "configure enabled=\(config.selectionActions.isEnabled) accessibilityTrusted=\(isAccessibilityTrusted)" - ) - if config.selectionActions.isEnabled, isAccessibilityTrusted { - if !selectionActionMonitor.start() { - SelectionActionDiagnostics.log("selection action monitor installation unavailable") - } - } else { - selectionActionMonitor.stop() - } - } - - private func handleSelectionActionCandidate(at point: SelectionPoint) { - guard !isStopping, !isMigrationMaintenanceActive else { return } - guard let sourceApp = NSWorkspace.shared.frontmostApplication, - sourceApp.processIdentifier != NSRunningApplication.current.processIdentifier - else { - return - } - - let bundleID = sourceApp.bundleIdentifier ?? "pid-\(sourceApp.processIdentifier)" - let request = PendingSelectionRead( - sourceProcessIdentifier: sourceApp.processIdentifier, - location: point - ) - SelectionActionDiagnostics.logRateLimited("candidate sourceApp=\(bundleID)") - handleSelectionActionEffects(selectionActionCoordinator.handle( - .candidateSelection(sourceAppBundleID: bundleID, mouseLocation: point) - ), pendingSelectionRead: request) - } - - private func handleSelectionActionCopyTrigger(_ pendingUserCopyRead: PendingUserCopyRead) { - guard !isStopping, !isMigrationMaintenanceActive else { return } - - handleSelectionActionEffects(selectionActionCoordinator.handle(.dismiss)) - let taskID = UUID() - selectionReadTaskID = taskID - let task = Task { [weak self] in - guard let self else { return } - defer { - self.selectionTaskRegistry.remove(id: taskID) - if self.selectionReadTaskID == taskID { - self.selectionReadTask = nil - self.selectionReadTaskID = nil - self.refreshMigrationImportEligibility() - } - } - let handoffOutcome = await selectionClipboardReader.finishUserCopyHandoff( - pendingUserCopyRead.clipboardHandoff - ) - guard !Task.isCancelled, - !isStopping, - !isMigrationMaintenanceActive, - selectionReadTaskID == taskID - else { - return - } - switch handoffOutcome { - case .unobservedSyntheticAction: - SelectionActionDiagnostics.log("copy trigger ignored unobserved synthetic action") - return - case .noActiveRead, .restorationRelinquished, .completedWithoutPasteboardMutation: - break - } - let result = await selectionUserCopyReader.readCopiedText( - sourceProcessIdentifier: pendingUserCopyRead.sourceProcessIdentifier, - after: pendingUserCopyRead.clipboardHandoff.boundaryChangeCount - ) - guard !Task.isCancelled, - !isStopping, - !isMigrationMaintenanceActive, - selectionReadTaskID == taskID, - selectionSourceValidator.isCurrent(pendingUserCopyRead.sourceProcessIdentifier) - else { - return - } - guard case .success(let text) = result, !text.isEmpty else { - SelectionActionDiagnostics.log("copy trigger empty clipboard") - return - } - - SelectionActionDiagnostics.log("copy trigger showPanel length=\(text.count)") - panelDismissalPolicy.recordPanelShown(at: Date().timeIntervalSinceReferenceDate) - selectionActionMonitor.recordPanelShown() - currentSelectionText = text - currentTranslationText = "" - selectionPronunciationReturnState = .menu - selectionCopyFeedbackTask?.cancel() - selectionActionWindowController.showMenu(at: pendingUserCopyRead.location) - } - selectionReadTask = task - selectionTaskRegistry.register(task, id: taskID) - refreshMigrationImportEligibility() - } - - private func handleSelectionActionEffects( - _ effects: [SelectionActionEffect], - pendingSelectionRead: PendingSelectionRead? = nil - ) { - guard !isStopping else { return } - for effect in effects { - switch effect { - case .scheduleRead(let delayMilliseconds): - guard let pendingSelectionRead else { continue } - SelectionActionDiagnostics.logRateLimited("effect scheduleRead delayMs=\(delayMilliseconds)") - selectionReadTask?.cancel() - let taskID = UUID() - selectionReadTaskID = taskID - let task = Task { [weak self] in - guard let self else { return } - defer { - self.selectionTaskRegistry.remove(id: taskID) - if self.selectionReadTaskID == taskID { - self.selectionReadTask = nil - self.selectionReadTaskID = nil - self.refreshMigrationImportEligibility() - } - } - do { - try await Task.sleep(nanoseconds: UInt64(delayMilliseconds) * 1_000_000) - } catch { - return - } - guard !Task.isCancelled, !isStopping else { return } - await self.completeScheduledSelectionRead(pendingSelectionRead) - } - selectionReadTask = task - selectionTaskRegistry.register(task, id: taskID) - refreshMigrationImportEligibility() - case .cancelRead: - SelectionActionDiagnostics.log("effect cancelRead") - selectionReadTask?.cancel() - selectionReadTask = nil - selectionReadTaskID = nil - refreshMigrationImportEligibility() - case .hidePanel: - SelectionActionDiagnostics.log("effect hidePanel") - selectionActionWindowController.hidePanel() - refreshMigrationImportEligibility() - case .cancelWork: - SelectionActionDiagnostics.log("effect cancelWork") - selectionTranslationTask?.cancel() - selectionTranslationTask = nil - selectionTranslationTaskID = nil - selectionTTSTask?.cancel() - selectionTTSTask = nil - selectionTTSTaskID = nil - selectionCopyFeedbackTask?.cancel() - speechPlaybackService.stop() - isSelectionSpeechPlaying = false - refreshMigrationImportEligibility() - case .showPanel(let text, let location): - SelectionActionDiagnostics.log("effect showPanel length=\(text.count)") - panelDismissalPolicy.recordPanelShown(at: Date().timeIntervalSinceReferenceDate) - selectionActionMonitor.recordPanelShown() - currentSelectionText = text - currentTranslationText = "" - selectionPronunciationReturnState = .menu - selectionCopyFeedbackTask?.cancel() - selectionActionWindowController.showMenu(at: location) - case .showUnsupportedNotice: - SelectionActionDiagnostics.log("effect showUnsupportedNotice") - panelDismissalPolicy.recordPanelShown(at: Date().timeIntervalSinceReferenceDate) - selectionActionMonitor.recordPanelShown() - showSelectionUnsupportedNotice() - } - } - } - - private func handleSelectionDismissRequest(reason: String = "unknown", bypassingPanelGrace: Bool = false) { - guard !isStopping else { return } - guard panelDismissalPolicy.shouldDismiss( - at: Date().timeIntervalSinceReferenceDate, - bypassingGrace: bypassingPanelGrace - ) else { - SelectionActionDiagnostics.log("panel dismiss suppressed during visibility grace reason=\(reason)") - return - } - - forceDismissSelectionActions(reason: reason) - } - - private func forceDismissSelectionActions(reason: String = "force") { - guard !isStopping else { return } - SelectionActionDiagnostics.log("force dismiss selection actions reason=\(reason)") - handleSelectionActionEffects(selectionActionCoordinator.handle(.dismiss)) - } - - private func diagnosticSummary(for result: SelectedTextReadResult) -> String { - switch result { - case .success(let text): - "success length=\(text.count)" - case .permissionDenied: - "permissionDenied" - case .emptySelection: - "emptySelection" - case .unsupported: - "unsupported" - case .missingFocusedElement: - "missingFocusedElement" - case .failed(let message): - "failed \(message)" - } - } - - private func completeScheduledSelectionRead(_ pendingSelectionRead: PendingSelectionRead) async { - guard !isStopping, !isMigrationMaintenanceActive else { return } - let config = (try? configStore.load()) ?? AppConfig.defaultConfig() - let configuredForceSelectionMode = config.selectionActions.forceSelectionMode - let forceSelectionMode: SelectionForceSelectionMode = if configuredForceSelectionMode == .menuCopyOnly, - config.selectionActions.allowsSimulatedCopyFallback { - .menuCopyThenShortcut - } else { - configuredForceSelectionMode - } - let readSelectedTextForAutomaticSelection = { - await self.readSelectedTextForAutomaticSelection( - pendingSelectionRead, - forceSelectionMode: forceSelectionMode - ) - } - let result = await readSelectedTextForAutomaticSelection() - guard !Task.isCancelled, !isStopping, !isMigrationMaintenanceActive else { return } - SelectionActionDiagnostics.logRateLimited("read result \(diagnosticSummary(for: result))") - handleSelectionActionEffects(selectionActionCoordinator.handle(.readCompleted(result))) - } - - private func readSelectedTextForAutomaticSelection( - _ pendingSelectionRead: PendingSelectionRead, - forceSelectionMode: SelectionForceSelectionMode - ) async -> SelectedTextReadResult { - await selectionReadPipeline.readSelectedText( - sourceProcessIdentifier: pendingSelectionRead.sourceProcessIdentifier, - mouseLocation: pendingSelectionRead.location, - forceSelectionMode: forceSelectionMode - ) - } - - private func showSelectionUnsupportedNotice() { - let point = SelectionPoint(x: NSEvent.mouseLocation.x, y: NSEvent.mouseLocation.y) - currentSelectionText = "" - currentTranslationText = "" - selectionActionWindowController.showNotice(L10n.text("selection.action.unsupportedMessage"), at: point) - } - - private func translateCurrentSelection() { - guard !isStopping, !isMigrationMaintenanceActive else { return } - let sourceText = currentSelectionText - guard !sourceText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return - } - - selectionTranslationTask?.cancel() - let taskID = UUID() - selectionTranslationTaskID = taskID - selectionActionWindowController.showTranslating() - let task = Task { [weak self] in - guard let self else { return } - defer { - self.selectionTaskRegistry.remove(id: taskID) - if self.selectionTranslationTaskID == taskID { - self.selectionTranslationTask = nil - self.selectionTranslationTaskID = nil - self.refreshMigrationImportEligibility() - } - } - do { - let config = try self.configStore.load() - let providerPreset = config.resolvedProviderPreset - let providerID = config.providerID - let apiKeyStore = self.apiKeyStore - let provider = LLMProviderFactory.provider(for: providerPreset) { - guard let apiKey = apiKeyStore.loadAPIKey(forProviderID: providerID), - !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { - throw TransformationError.provider(L10n.format("popover.error.missingAPIKey", providerPreset.name)) - } - return apiKey - } - let targetLanguageName = config.selectionActions.translationLanguage.resolvedPromptTargetName( - interfaceLanguageCode: L10n.resolvedLanguage.localeIdentifier - ) - let systemPrompt = config.selectionActions.effectiveTranslationPrompt( - targetLanguageName: targetLanguageName - ) - let service = CachedSelectionTranslationService( - service: SelectionTranslationService(provider: provider), - cache: selectionTranslationCache - ) - let translated = try await service.translate( - sourceText: sourceText, - targetLanguageName: targetLanguageName, - systemPrompt: systemPrompt, - model: config.model, - providerID: providerID, - timeoutSeconds: config.timeoutSeconds - ) - guard !Task.isCancelled, !isStopping, !isMigrationMaintenanceActive else { return } - try? self.historyStore.append(HistoryItem( - source: .selection, - inputText: sourceText, - outputText: translated, - modeName: nil, - targetLanguageName: targetLanguageName, - model: config.model, - metadata: ["providerID": providerID] - )) - await MainActor.run { - guard !self.isStopping else { return } - self.currentTranslationText = translated - self.selectionActionWindowController.showTranslation(translated) - } - } catch is CancellationError { - } catch { - if !self.isStopping, !self.isMigrationMaintenanceActive { - self.selectionActionWindowController.showTranslationError( - L10n.text("selection.action.translationFailed") - ) - } - } - } - selectionTranslationTask = task - selectionTaskRegistry.register(task, id: taskID) - refreshMigrationImportEligibility() - } - - private func pronounceCurrentSelection() { - pronounceSelectionText(currentSelectionText, returnState: .menu) - } - - private func pronounceOriginalFromTranslation() { - pronounceSelectionText( - currentSelectionText, - returnState: .translationResult, - loadingFeedback: .loadingOriginalPronunciation, - playingFeedback: .playingOriginalPronunciation - ) - } - - private func pronounceCurrentTranslation() { - pronounceSelectionText( - currentTranslationText, - returnState: .translationResult, - loadingFeedback: .loadingTranslationPronunciation, - playingFeedback: .playingTranslationPronunciation - ) - } - - private func pronounceSelectionText( - _ text: String, - returnState: SelectionPronunciationReturnState, - loadingFeedback: SelectionActionFeedback? = nil, - playingFeedback: SelectionActionFeedback? = nil - ) { - guard !isStopping, !isMigrationMaintenanceActive else { return } - let sourceText = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !sourceText.isEmpty else { return } - - selectionPronunciationReturnState = returnState - selectionTTSTask?.cancel() - let taskID = UUID() - selectionTTSTaskID = taskID - if returnState == .translationResult, !currentTranslationText.isEmpty { - selectionActionWindowController.showTranslation(currentTranslationText, feedback: loadingFeedback) - } else { - selectionActionWindowController.showPreparingPronunciation() - } - let task = Task { [weak self] in - guard let self else { return } - defer { - self.selectionTaskRegistry.remove(id: taskID) - if self.selectionTTSTaskID == taskID { - self.selectionTTSTask = nil - self.selectionTTSTaskID = nil - self.refreshMigrationImportEligibility() - } - } - do { - let provider = OpenAITTSProvider(apiKeyProvider: { [apiKeyStore] in - guard let apiKey = apiKeyStore.loadAPIKey(forProviderID: LLMProviderPreset.openAI.id), - !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { - throw OpenAITTSError.provider(L10n.text("selection.action.missingOpenAIKey")) - } - return apiKey - }) - let config = (try? self.configStore.load()) ?? AppConfig.defaultConfig() - let audioData = try await provider.speechAudio(OpenAITTSRequest( - input: sourceText, - voice: config.selectionActions.pronunciationVoice.rawValue, - speed: config.selectionActions.pronunciationSpeed, - timeoutSeconds: config.timeoutSeconds - )) - guard !Task.isCancelled, !isStopping, !isMigrationMaintenanceActive else { return } - await MainActor.run { - guard !self.isStopping else { return } - do { - try self.speechPlaybackService.play(audioData: audioData) - self.isSelectionSpeechPlaying = true - self.refreshMigrationImportEligibility() - if returnState == .menu { - self.selectionActionWindowController.showPlayingPronunciation() - } else if !self.currentTranslationText.isEmpty { - self.selectionActionWindowController.showTranslation( - self.currentTranslationText, - feedback: playingFeedback - ) - } - } catch { - self.isSelectionSpeechPlaying = false - self.showSelectionPronunciationError(returnState: returnState) - } - } - } catch is CancellationError { - } catch { - if !self.isStopping, !self.isMigrationMaintenanceActive { - self.showSelectionPronunciationError(returnState: returnState) - } - } - } - selectionTTSTask = task - selectionTaskRegistry.register(task, id: taskID) - refreshMigrationImportEligibility() - } - - private func restoreSelectionPronunciationReturnState() { - guard !isStopping, !isMigrationMaintenanceActive else { return } - switch selectionPronunciationReturnState { - case .menu: - selectionActionWindowController.restoreMenu() - case .translationResult: - if currentTranslationText.isEmpty { - selectionActionWindowController.restoreMenu() - } else { - selectionActionWindowController.showTranslation(currentTranslationText) - } - } - } - - private func showSelectionPronunciationError(returnState: SelectionPronunciationReturnState) { - guard !isStopping else { return } - let message = L10n.text("selection.action.pronunciationFailed") - switch returnState { - case .menu: - selectionActionWindowController.showPronunciationError(message) - case .translationResult: - if currentTranslationText.isEmpty { - selectionActionWindowController.showPronunciationError(message) - } else { - selectionActionWindowController.showTranslation(currentTranslationText, errorMessage: message) - } - } - } - - private func copyCurrentTranslation() { - guard !isStopping, !isMigrationMaintenanceActive, !currentTranslationText.isEmpty else { - return - } - - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(currentTranslationText, forType: .string) - selectionCopyFeedbackTask?.cancel() - selectionActionWindowController.showTranslation(currentTranslationText, feedback: .copiedTranslation) - let taskID = UUID() - selectionCopyFeedbackTaskID = taskID - let task = Task { [weak self, copiedText = currentTranslationText] in - defer { - if let self { - self.selectionTaskRegistry.remove(id: taskID) - if self.selectionCopyFeedbackTaskID == taskID { - self.selectionCopyFeedbackTask = nil - self.selectionCopyFeedbackTaskID = nil - } - } - } - try? await Task.sleep(for: .milliseconds(900)) - await MainActor.run { - guard let self, - !self.isStopping, - self.currentTranslationText == copiedText - else { - return - } - self.selectionActionWindowController.showTranslation(self.currentTranslationText) - } - } - selectionCopyFeedbackTask = task - selectionTaskRegistry.register(task, id: taskID) - } - private func setHotkeyRecording(_ isRecording: Bool) { guard !isStopping, isRecordingHotkey != isRecording else { return @@ -1410,19 +739,19 @@ final class AppCoordinator: NSObject, NSMenuDelegate { @objc func openPopover() { guard !isStopping, !isMigrationMaintenanceActive else { return } - forceDismissSelectionActions(reason: "openPopover") + selectionActions.forceDismissSelectionActions(reason: "openPopover") windowController.show(fallbackApplication: lastTargetApplication) } @objc func openSettings() { guard !isStopping else { return } - forceDismissSelectionActions(reason: "openSettings") + selectionActions.forceDismissSelectionActions(reason: "openSettings") showSettings(section: .general) } @objc func openAbout() { guard !isStopping, !isMigrationMaintenanceActive else { return } - forceDismissSelectionActions(reason: "openAbout") + selectionActions.forceDismissSelectionActions(reason: "openAbout") aboutController.show() } diff --git a/Sources/InkletApp/InkletPopoverView.swift b/Sources/InkletApp/InkletPopoverView.swift index 1bdeba9..6e3c871 100644 --- a/Sources/InkletApp/InkletPopoverView.swift +++ b/Sources/InkletApp/InkletPopoverView.swift @@ -1,848 +1,7 @@ -import Combine import AppKit import SwiftUI import InkletCore -@MainActor -final class InkletPopoverViewModel: ObservableObject { - @Published var sourceText = "" - @Published var resultText = "" - @Published var errorMessage: String? - @Published var isTransforming = false - @Published var isInserting = false - @Published private(set) var modePickerState: WritingModePickerState - @Published private(set) var popoverSession: WritingPopoverSessionState - @Published private(set) var modeSearchFocusRevision = 0 - @Published private(set) var modes: [PromptMode] - @Published var preferredPopoverHeight: CGFloat = 168 - @Published var appearance: AppAppearance - @Published var voiceShortcutHint: VoiceInputConfig.Shortcut? - @Published private(set) var dictationPhase: WritingDictationCoordinator.Phase = .idle - - var onHidePopover: (() -> Void)? - var onFocusPopover: (() -> Void)? - var onFocusSourceInput: ((FocusRequestGeneration.Request) -> Void)? - var onOpenSettings: (() -> Void)? - var onCancelDictation: (() -> Void)? - - var route: WritingPopoverSessionState.Route { - popoverSession.route - } - - var selectedModeID: String { - popoverSession.selectedModeID - } - - var isResultStale: Bool { - popoverSession.isResultStale && !resultText.isEmpty - } - - var resultModeDisplayName: String? { - guard let resultModeID = popoverSession.resultModeID else { - return nil - } - - return modes.first(where: { $0.id == resultModeID })?.localizedName ?? resultModeID - } - - var currentProviderName: String { - LLMProviderPreset.preset(id: config.providerID).name - } - - var currentModelName: String { - config.model - } - - var currentVoiceInputConfig: VoiceInputConfig { - config.voiceInput - } - - var shouldShowDictationStatus: Bool { - voiceShortcutHint != nil || dictationPhase.isActive - } - - var dictationStatusText: String { - switch dictationPhase { - case .connecting: - L10n.text("dictation.status.connecting") - case .listening: - L10n.text("dictation.status.listening") - case .recordingForFallback: - L10n.text("dictation.status.recordingFallback") - case .finalizing: - L10n.text("dictation.status.finalizing") - case .recovering: - L10n.text("dictation.status.recovering") - case .idle, .complete, .failed: - L10n.format("dictation.hint.hold", voiceShortcutHint?.localizedName ?? "") - } - } - - var dictationStatusAccessibilityLabel: String { - switch dictationPhase { - case .listening: - L10n.text("dictation.accessibility.listening") - case .recordingForFallback: - L10n.text("dictation.accessibility.recordingFallback") - case .connecting, .finalizing, .recovering: - dictationStatusText - case .idle, .complete, .failed: - L10n.text("dictation.accessibility.ready") - } - } - - private struct SourceDictationPresentationSnapshot { - let sourceText: String - let resultText: String - let errorMessage: String? - let popoverSession: WritingPopoverSessionState - let stateMachineState: PopoverStateMachine.State - let draftSourceText: String - let hasTransformedInSession: Bool - } - - private var stateMachine: PopoverStateMachine - private let configStore: UserDefaultsConfigStore - private let apiKeyStore: LocalAPIKeyStore - private let insertionService: InsertionService - private let transformationServiceFactory: (any LLMProvider) -> TransformationService - private let historyStore: any HistoryStore - private let writingModePreferenceStore: WritingModePreferenceStore - private var config: AppConfig - private var previousApplication: NSRunningApplication? - private var transformationTask: Task? - private var insertionTask: Task? - private var sessionID = 0 - private var draftSourceText = "" - private var hasTransformedInSession = false - private var sourceFocusGeneration = FocusRequestGeneration() - private var sourceDictationPresentationSnapshot: SourceDictationPresentationSnapshot? - private var isRestoringSourceDictationPresentation = false - private var languageCancellable: AnyCancellable? - private var dictationErrorMessage: String? - - init( - stateMachine: PopoverStateMachine = PopoverStateMachine(), - configStore: UserDefaultsConfigStore = UserDefaultsConfigStore(), - apiKeyStore: LocalAPIKeyStore = LocalAPIKeyStore(), - transformationServiceFactory: @escaping (any LLMProvider) -> TransformationService = { TransformationService(provider: $0) }, - insertionService: InsertionService = InsertionService(), - historyStore: any HistoryStore = JSONLHistoryStore(), - writingModePreferenceStore: WritingModePreferenceStore = WritingModePreferenceStore() - ) { - self.stateMachine = stateMachine - self.configStore = configStore - self.apiKeyStore = apiKeyStore - self.transformationServiceFactory = transformationServiceFactory - self.insertionService = insertionService - self.historyStore = historyStore - self.writingModePreferenceStore = writingModePreferenceStore - - let loadedConfig = (try? configStore.load()) ?? AppConfig.defaultConfig() - let selectedModeID = Self.resolvedModeID( - preferredModeID: writingModePreferenceStore.loadLastModeID(), - config: loadedConfig - ) - let visibleModes = Self.resolvedVisibleModes(from: loadedConfig) - self.config = loadedConfig - self.modes = visibleModes - self.modePickerState = WritingModePickerState( - items: Self.modePickerItems(for: visibleModes), - preferredModeID: selectedModeID - ) - self.popoverSession = WritingPopoverSessionState(selectedModeID: selectedModeID) - self.appearance = loadedConfig.appearance - self.voiceShortcutHint = nil - languageCancellable = NotificationCenter.default.publisher(for: .inkletLanguageDidChange) - .receive(on: RunLoop.main) - .sink { [weak self] _ in - self?.refreshLocalizedContent() - } - } - - private func refreshLocalizedContent() { - modePickerState = WritingModePickerState( - items: Self.modePickerItems(for: modes), - preferredModeID: modePickerState.highlightedModeID, - query: modePickerState.query - ) - if case .failed(let errorKey) = dictationPhase, errorMessage == dictationErrorMessage { - errorMessage = L10n.text(errorKey) - dictationErrorMessage = errorMessage - } - } - - func resetForOpen(previousApplication: NSRunningApplication?) { - invalidateSourceInputFocusRequests() - transformationTask?.cancel() - transformationTask = nil - insertionTask?.cancel() - insertionTask = nil - sessionID += 1 - self.previousApplication = previousApplication - stateMachine = PopoverStateMachine() - - config = (try? configStore.load()) ?? AppConfig.defaultConfig() - let selectedModeID = Self.resolvedModeID( - preferredModeID: writingModePreferenceStore.loadLastModeID(), - config: config - ) - modes = Self.resolvedVisibleModes(from: config) - popoverSession = WritingPopoverSessionState(selectedModeID: selectedModeID) - modePickerState = WritingModePickerState( - items: Self.modePickerItems(for: modes), - preferredModeID: selectedModeID - ) - modeSearchFocusRevision += 1 - appearance = config.appearance - refreshVoiceShortcutHint() - sourceText = draftSourceText - resultText = "" - errorMessage = nil - isTransforming = false - isInserting = false - hasTransformedInSession = false - sourceDictationPresentationSnapshot = nil - dictationPhase = .idle - preferredPopoverHeight = WritingModePickerView.preferredHeight( - resultCount: modePickerState.filteredItems.count - ) - - handle(actions: stateMachine.send(.open)) - if !sourceText.isEmpty { - _ = stateMachine.send(.sourceChanged(sourceText)) - } - } - - var isBusy: Bool { - isTransforming || isInserting || dictationPhase.isActive - } - - func cancelForMigrationMaintenance() { - sessionID += 1 - transformationTask?.cancel() - transformationTask = nil - insertionTask?.cancel() - insertionTask = nil - isTransforming = false - isInserting = false - } - - private func refreshVoiceShortcutHint() { - let shortcut = config.voiceInput.shortcut - voiceShortcutHint = shortcut == .disabled ? nil : shortcut - } - - func beginSourceDictationPresentation() -> Bool { - guard route == .editor, - !isBusy, - !isRestoringSourceDictationPresentation, - sourceDictationPresentationSnapshot == nil - else { - return false - } - - sourceDictationPresentationSnapshot = SourceDictationPresentationSnapshot( - sourceText: sourceText, - resultText: resultText, - errorMessage: errorMessage, - popoverSession: popoverSession, - stateMachineState: stateMachine.state, - draftSourceText: draftSourceText, - hasTransformedInSession: hasTransformedInSession - ) - errorMessage = nil - return true - } - - func synchronizeSourceTextDuringDictation(_ text: String) { - guard sourceDictationPresentationSnapshot != nil else { - return - } - sourceText = text - } - - func commitSourceDictationPresentation() { - guard sourceDictationPresentationSnapshot != nil else { - return - } - sourceDictationPresentationSnapshot = nil - resultText = "" - errorMessage = nil - mutatePopoverSession { $0.clearResult() } - draftSourceText = sourceText - hasTransformedInSession = false - stateMachine = PopoverStateMachine( - state: .editingSource(source: sourceText, errorMessage: nil) - ) - } - - func synchronizeSourceTextAfterDictation(_ text: String) { - sourceText = text - resultText = "" - errorMessage = nil - mutatePopoverSession { $0.clearResult() } - draftSourceText = text - hasTransformedInSession = false - stateMachine = PopoverStateMachine( - state: .editingSource(source: text, errorMessage: nil) - ) - } - - func restoreSourceDictationPresentation() { - guard let snapshot = sourceDictationPresentationSnapshot, - !isRestoringSourceDictationPresentation - else { - return - } - isRestoringSourceDictationPresentation = true - defer { - sourceDictationPresentationSnapshot = nil - isRestoringSourceDictationPresentation = false - } - sourceText = snapshot.sourceText - resultText = snapshot.resultText - errorMessage = snapshot.errorMessage - popoverSession = snapshot.popoverSession - stateMachine = PopoverStateMachine(state: snapshot.stateMachineState) - draftSourceText = snapshot.draftSourceText - hasTransformedInSession = snapshot.hasTransformedInSession - } - - func setDictationPhase(_ phase: WritingDictationCoordinator.Phase) { - dictationPhase = phase - if case .failed(let errorKey) = phase { - errorMessage = L10n.text(errorKey) - dictationErrorMessage = errorMessage - } - } - - func updateSourceText(_ text: String) { - guard !isBusy else { - return - } - - sourceText = text - if !resultText.isEmpty { - resultText = "" - mutatePopoverSession { $0.clearResult() } - } - - _ = stateMachine.send(.sourceChanged(text)) - } - - func updateResultText(_ text: String) { - guard !isBusy else { - return - } - - resultText = text - guard !text.isEmpty else { - mutatePopoverSession { $0.clearResult() } - stateMachine = PopoverStateMachine( - state: .editingSource(source: sourceText, errorMessage: nil) - ) - return - } - - _ = stateMachine.send(.resultChanged(text)) - } - - func updateModeSearchQuery(_ query: String) { - guard !isBusy else { - return - } - mutateModePickerState { $0.setQuery(query) } - } - - func moveModeHighlight(by offset: Int) { - guard !isBusy else { - return - } - mutateModePickerState { $0.moveHighlight(by: offset) } - } - - func highlightMode(modeID: String) { - guard !isBusy else { - return - } - mutateModePickerState { _ = $0.highlight(modeID: modeID) } - } - - func commitHighlightedMode() { - guard let highlightedModeID = modePickerState.highlightedModeID else { - return - } - - commitMode(modeID: highlightedModeID) - } - - func commitMode(modeID: String) { - guard !isBusy, - modes.contains(where: { $0.id == modeID }) - else { - return - } - - mutatePopoverSession { $0.enterEditor(modeID: modeID) } - writingModePreferenceStore.saveLastModeID(modeID) - requestSourceInputFocus() - } - - func returnToModePicker() { - guard !isBusy else { - return - } - - invalidateSourceInputFocusRequests() - mutatePopoverSession { $0.showModePicker() } - modePickerState = WritingModePickerState( - items: Self.modePickerItems(for: modes), - preferredModeID: selectedModeID - ) - modeSearchFocusRevision += 1 - } - - func cyclePromptMode(direction: Int) { - guard !modes.isEmpty else { - return - } - - let currentIndex = modes.firstIndex { $0.id == selectedModeID } ?? 0 - let nextIndex = (currentIndex + direction + modes.count) % modes.count - commitMode(modeID: modes[nextIndex].id) - } - - func submit() { - guard !isBusy else { - return - } - - errorMessage = nil - if !resultText.isEmpty, isResultStale { - removeSingleTrailingNewlineFromSource() - _ = stateMachine.send(.sourceChanged(sourceText)) - handle(actions: stateMachine.send(.submit)) - return - } - - if !resultText.isEmpty { - removeSingleTrailingNewlineFromResult() - let currentResult = resultText - _ = stateMachine.send(.resultChanged(currentResult)) - let actions = stateMachine.send(.submit) - if actions.isEmpty { - insert( - text: currentResult, - fallbackState: .previewingResult(source: sourceText, result: currentResult) - ) - } else { - handle(actions: actions) - } - return - } - - removeSingleTrailingNewlineFromSource() - _ = stateMachine.send(.sourceChanged(sourceText)) - handle(actions: stateMachine.send(.submit)) - } - - private func removeSingleTrailingNewlineFromSource() { - if sourceText.hasSuffix("\r\n") { - sourceText.removeLast(2) - } else if sourceText.hasSuffix("\n") || sourceText.hasSuffix("\r") { - sourceText.removeLast() - } - } - - private func removeSingleTrailingNewlineFromResult() { - if resultText.hasSuffix("\r\n") { - resultText.removeLast(2) - } else if resultText.hasSuffix("\n") || resultText.hasSuffix("\r") { - resultText.removeLast() - } - } - - func insertOriginal() { - guard !isBusy else { - return - } - - let trimmedSource = sourceText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedSource.isEmpty else { - errorMessage = L10n.text("popover.error.emptyOriginal") - return - } - - errorMessage = nil - let fallbackState: PopoverStateMachine.State = resultText.isEmpty - ? .editingSource(source: sourceText, errorMessage: nil) - : .previewingResult(source: sourceText, result: resultText) - - if resultText.isEmpty { - _ = stateMachine.send(.sourceChanged(sourceText)) - let actions = stateMachine.send(.insertOriginal) - if !actions.isEmpty { - handle(actions: actions) - return - } - } - - insert(text: sourceText, fallbackState: fallbackState) - } - - func escape() { - if dictationPhase.isActive { - onCancelDictation?() - return - } - - if isTransforming { - cancelTransformationAndStayInEditor() - return - } - - guard !isInserting else { - return - } - - if route == .modePicker { - draftSourceText = hasTransformedInSession ? "" : sourceText - handle(actions: stateMachine.send(.close)) - return - } - - if !resultText.isEmpty { - resultText = "" - errorMessage = nil - mutatePopoverSession { $0.clearResult() } - stateMachine = PopoverStateMachine( - state: .editingSource(source: sourceText, errorMessage: nil) - ) - requestSourceInputFocus() - return - } - - returnToModePicker() - } - - func openSettings() { - guard !isBusy else { - return - } - onHidePopover?() - onOpenSettings?() - } - - func isCurrentSourceFocusRequest(_ request: FocusRequestGeneration.Request) -> Bool { - sourceFocusGeneration.isCurrent(request) - } - - private func requestSourceInputFocus() { - guard route == .editor else { - return - } - - let request = sourceFocusGeneration.issue() - onFocusSourceInput?(request) - } - - private func invalidateSourceInputFocusRequests() { - sourceFocusGeneration.invalidate() - } - - private func handle(actions: [PopoverStateMachine.Action]) { - for action in actions { - switch action { - case .showPopover: - onFocusPopover?() - case .hidePopover: - onHidePopover?() - case .focusSourceInput: - requestSourceInputFocus() - case .startTransformation(let source): - startTransformation(source: source) - case .showResult(let result): - hasTransformedInSession = true - draftSourceText = "" - resultText = result - case .showError(let message): - errorMessage = localizedStateMachineMessage(message) - case .insertText(let text): - let fallbackState: PopoverStateMachine.State - if !resultText.isEmpty { - fallbackState = .previewingResult(source: sourceText, result: resultText) - } else { - fallbackState = .editingSource(source: sourceText, errorMessage: nil) - } - insert(text: text, fallbackState: fallbackState) - } - } - } - - private func startTransformation(source: String) { - transformationTask?.cancel() - let preservesExistingResult = isResultStale - if !preservesExistingResult { - resultText = "" - mutatePopoverSession { $0.clearResult() } - } - errorMessage = nil - isTransforming = true - - let resolvedModeID = Self.resolvedModeID( - preferredModeID: selectedModeID, - visibleModes: modes - ) - let mode = PromptModeStore(modes: modes).resolve( - modeID: resolvedModeID, - sourceText: source - ) - let transformationModeID = mode.id - let model = config.model - let timeoutSeconds = config.timeoutSeconds - let providerPreset = config.resolvedProviderPreset - let providerID = config.providerID - let apiKeyStore = self.apiKeyStore - let provider = LLMProviderFactory.provider(for: providerPreset) { - try LocalAPIKeyProvider( - apiKeyStore: apiKeyStore, - providerID: providerID, - providerName: providerPreset.name - ).loadAPIKey() - } - let transformationService = transformationServiceFactory(provider) - - transformationTask = Task { [weak self] in - guard let self else { return } - do { - let result = try await transformationService.transform( - sourceText: source, - mode: mode, - model: model, - timeoutSeconds: timeoutSeconds - ) - guard !Task.isCancelled else { return } - try? historyStore.append(HistoryItem( - source: .write, - inputText: source, - outputText: result.outputText, - modeName: mode.localizedName, - targetLanguageName: nil, - model: model, - metadata: [ - "modeID": mode.id, - "providerID": providerID - ] - )) - transformationTask = nil - isTransforming = false - mutatePopoverSession { $0.recordResult(modeID: transformationModeID) } - handle(actions: stateMachine.send(.transformationSucceeded(result: result.outputText))) - } catch { - guard !Task.isCancelled else { return } - transformationTask = nil - isTransforming = false - if !preservesExistingResult { - resultText = "" - mutatePopoverSession { $0.clearResult() } - } - handle(actions: stateMachine.send(.transformationFailed(message: error.userFacingMessage))) - if preservesExistingResult { - synchronizeStateMachineWithVisibleContent() - } - } - } - } - - private func cancelTransformationAndStayInEditor() { - transformationTask?.cancel() - transformationTask = nil - isTransforming = false - errorMessage = nil - mutatePopoverSession { $0.enterEditor(modeID: selectedModeID) } - synchronizeStateMachineWithVisibleContent() - } - - private func synchronizeStateMachineWithVisibleContent() { - let visibleState: PopoverStateMachine.State = resultText.isEmpty - ? .editingSource(source: sourceText, errorMessage: nil) - : .previewingResult(source: sourceText, result: resultText) - stateMachine = PopoverStateMachine(state: visibleState) - } - - private func mutateModePickerState(_ mutation: (inout WritingModePickerState) -> Void) { - var updatedState = modePickerState - mutation(&updatedState) - modePickerState = updatedState - } - - private func mutatePopoverSession(_ mutation: (inout WritingPopoverSessionState) -> Void) { - var updatedSession = popoverSession - mutation(&updatedSession) - popoverSession = updatedSession - } - - private static func resolvedModeID( - preferredModeID: String?, - config: AppConfig - ) -> String { - guard let preferredModeID else { - return config.defaultVisibleModeID - } - - return config.visibleModeID(preferredModeID: preferredModeID) - } - - private static func resolvedVisibleModes(from config: AppConfig) -> [PromptMode] { - let visibleModes = config.visiblePromptModes - if !visibleModes.isEmpty { - return visibleModes - } - - return AppConfig.defaultConfig().visiblePromptModes - } - - private static func resolvedModeID( - preferredModeID: String?, - visibleModes: [PromptMode] - ) -> String { - if let preferredModeID, - visibleModes.contains(where: { $0.id == preferredModeID }) { - return preferredModeID - } - - return visibleModes.first?.id ?? PromptMode.translateToEnglishID - } - - private static func modePickerItems(for modes: [PromptMode]) -> [WritingModePickerItem] { - modes.map { WritingModePickerItem(id: $0.id, title: $0.localizedName) } - } - - private func localizedStateMachineMessage(_ message: String) -> String { - switch message { - case "请输入要转换的文本": - L10n.text("error.emptySource") - default: - message - } - } - - private func insert(text: String, fallbackState: PopoverStateMachine.State) { - guard let previousApplication else { - stateMachine = PopoverStateMachine(state: fallbackState) - errorMessage = L10n.text("popover.error.missingTarget") - return - } - - errorMessage = nil - onHidePopover?() - isInserting = true - - let insertionSessionID = sessionID - insertionTask?.cancel() - insertionTask = Task { [weak self] in - guard let self else { return } - do { - try await insertionService.insert(text: text, into: previousApplication) - guard sessionID == insertionSessionID else { - return - } - isInserting = false - insertionTask = nil - draftSourceText = "" - sourceText = "" - resultText = "" - mutatePopoverSession { $0.clearResult() } - handle(actions: stateMachine.send(.insertionFinished)) - } catch { - guard sessionID == insertionSessionID else { - return - } - isInserting = false - insertionTask = nil - stateMachine = PopoverStateMachine(state: fallbackState) - errorMessage = error.userFacingMessage - onFocusPopover?() - } - } - } -} - -private struct LocalAPIKeyProvider: @unchecked Sendable { - let apiKeyStore: LocalAPIKeyStore - let providerID: String - let providerName: String - - func loadAPIKey() throws -> String { - guard let apiKey = apiKeyStore.loadAPIKey(forProviderID: providerID), - !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { - throw TransformationError.provider(L10n.format("popover.error.missingAPIKey", providerName)) - } - return apiKey - } -} - -private extension Error { - var userFacingMessage: String { - if let transformationError = self as? TransformationError { - switch transformationError { - case .emptySource: - return L10n.text("error.emptySource") - case .emptyResponse: - return L10n.text("error.emptyResponse") - case .timeout: - return L10n.text("error.timeout") - case .networkConnectionLost: - return L10n.text("error.networkConnectionLost") - case .provider(let message): - return localizedProviderMessage(message) - } - } - - if let localizedError = self as? LocalizedError, - let description = localizedError.errorDescription { - return description - } - - if let insertionError = self as? InsertionError { - switch insertionError { - case .accessibilityPermissionMissing: - return L10n.text("insertion.error.accessibility") - case .activationFailed: - return L10n.text("insertion.error.activation") - case .cannotCreatePasteEvent: - return L10n.text("insertion.error.pasteEvent") - case .clipboardRestoreFailed: - return L10n.text("insertion.error.clipboardRestore") - } - } - - return String(describing: self) - } - - private func localizedProviderMessage(_ message: String) -> String { - for providerName in LLMProviderPreset.all.map(\.name) { - let prefix = "\(providerName) 请求失败:" - guard message.hasPrefix(prefix) else { - continue - } - - let detail = String(message.dropFirst(prefix.count)) - if detail == "URL 无效" { - return L10n.format("error.provider.urlInvalid", providerName) - } - if detail == "HTTP unknown" { - return L10n.format("error.provider.httpUnknown", providerName) - } - return L10n.format("error.provider.prefix", providerName, detail) - } - - return message - } -} - enum InkletTextViewAttachmentEvent { case attach(NSTextView) case detach(NSTextView) @@ -1452,446 +611,3 @@ private struct ResultEditorHeightPreferenceKey: PreferenceKey { value = nextValue() } } - -private final class InkletTextContainerView: NSView { - let scrollView = NSScrollView() - let placeholderLabel = NSTextField(labelWithString: "") - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - - scrollView.translatesAutoresizingMaskIntoConstraints = false - placeholderLabel.translatesAutoresizingMaskIntoConstraints = false - placeholderLabel.font = .systemFont(ofSize: 14) - placeholderLabel.textColor = .placeholderTextColor - placeholderLabel.lineBreakMode = .byTruncatingTail - placeholderLabel.maximumNumberOfLines = 1 - placeholderLabel.isEditable = false - placeholderLabel.isSelectable = false - placeholderLabel.backgroundColor = .clear - placeholderLabel.drawsBackground = false - - addSubview(scrollView) - addSubview(placeholderLabel) - - NSLayoutConstraint.activate([ - scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), - scrollView.topAnchor.constraint(equalTo: topAnchor), - scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), - placeholderLabel.leadingAnchor.constraint(equalTo: leadingAnchor), - placeholderLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), - placeholderLabel.topAnchor.constraint(equalTo: topAnchor), - ]) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - @MainActor - func updatePlaceholderVisibility() { - let textView = scrollView.documentView as? NSTextView - placeholderLabel.isHidden = placeholderLabel.stringValue.isEmpty - || textView?.string.isEmpty == false - || textView?.hasMarkedText() == true - } -} - -private final class InkletNativeTextView: NSTextView { - var onInputStateChange: (() -> Void)? - var onEscapeKeyDown: (() -> Void)? - - override func keyDown(with event: NSEvent) { - guard event.keyCode == 53, !hasMarkedText() else { - super.keyDown(with: event) - return - } - - onEscapeKeyDown?() - } - - override func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { - super.setMarkedText(string, selectedRange: selectedRange, replacementRange: replacementRange) - onInputStateChange?() - } - - override func unmarkText() { - super.unmarkText() - onInputStateChange?() - } - - override func insertText(_ insertString: Any, replacementRange: NSRange) { - super.insertText(insertString, replacementRange: replacementRange) - onInputStateChange?() - } -} - -private struct InkletTextView: NSViewRepresentable { - @Binding var text: String - var placeholder: String? - var isEditable: Bool - var onSubmit: (() -> Void)? - var onInsertOriginal: (() -> Void)? - var onEscape: (() -> Void)? - var onTextViewAttachment: ((InkletTextViewAttachmentEvent) -> Void)? - - func makeCoordinator() -> Coordinator { - Coordinator( - text: $text, - onSubmit: onSubmit, - onInsertOriginal: onInsertOriginal, - onEscape: onEscape, - onTextViewAttachment: onTextViewAttachment - ) - } - - func makeNSView(context: Context) -> InkletTextContainerView { - let container = InkletTextContainerView() - let scrollView = container.scrollView - scrollView.drawsBackground = false - scrollView.borderType = .noBorder - scrollView.hasVerticalScroller = false - scrollView.hasHorizontalScroller = false - scrollView.autohidesScrollers = true - scrollView.scrollerStyle = .overlay - scrollView.contentInsets = NSEdgeInsetsZero - scrollView.automaticallyAdjustsContentInsets = false - scrollView.horizontalScrollElasticity = .none - - let textView = InkletNativeTextView() - textView.string = text - textView.delegate = context.coordinator - textView.onInputStateChange = { [weak coordinator = context.coordinator, weak textView, weak container] in - guard let textView else { - return - } - coordinator?.syncText(from: textView) - container?.updatePlaceholderVisibility() - } - textView.onEscapeKeyDown = { [weak coordinator = context.coordinator] in - coordinator?.onEscape?() - } - textView.isEditable = isEditable - textView.isSelectable = true - textView.isRichText = false - textView.importsGraphics = false - textView.allowsUndo = true - textView.usesFindBar = false - textView.drawsBackground = false - textView.backgroundColor = .clear - textView.font = .systemFont(ofSize: 14) - textView.textColor = .labelColor - textView.insertionPointColor = .controlAccentColor - textView.textContainerInset = .zero - textView.textContainer?.lineFragmentPadding = 0 - textView.textContainer?.widthTracksTextView = true - textView.textContainer?.containerSize = NSSize(width: scrollView.contentSize.width, height: CGFloat.greatestFiniteMagnitude) - textView.minSize = NSSize(width: 0, height: 0) - textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) - textView.isHorizontallyResizable = false - textView.isVerticallyResizable = true - textView.autoresizingMask = [.width] - textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - - scrollView.documentView = textView - context.coordinator.textView = textView - onTextViewAttachment?(.attach(textView)) - container.placeholderLabel.stringValue = placeholder ?? "" - container.updatePlaceholderVisibility() - return container - } - - func updateNSView(_ container: InkletTextContainerView, context: Context) { - let scrollView = container.scrollView - guard let textView = scrollView.documentView as? NSTextView else { - return - } - - context.coordinator.text = $text - context.coordinator.onSubmit = onSubmit - context.coordinator.onInsertOriginal = onInsertOriginal - context.coordinator.onEscape = onEscape - context.coordinator.onTextViewAttachment = onTextViewAttachment - context.coordinator.textView = textView - onTextViewAttachment?(.attach(textView)) - - textView.isEditable = isEditable - textView.font = .systemFont(ofSize: 14) - textView.textColor = .labelColor - textView.insertionPointColor = .controlAccentColor - container.placeholderLabel.stringValue = placeholder ?? "" - - if textView.isEditable, !textView.hasMarkedText(), textView.string != text { - textView.string = text - } - container.updatePlaceholderVisibility() - } - - static func dismantleNSView( - _ container: InkletTextContainerView, - coordinator: Coordinator - ) { - if let textView = container.scrollView.documentView as? NSTextView { - coordinator.onTextViewAttachment?(.detach(textView)) - } - coordinator.textView = nil - } - - final class Coordinator: NSObject, NSTextViewDelegate, @unchecked Sendable { - var text: Binding - var onSubmit: (() -> Void)? - var onInsertOriginal: (() -> Void)? - var onEscape: (() -> Void)? - var onTextViewAttachment: ((InkletTextViewAttachmentEvent) -> Void)? - weak var textView: NSTextView? - - init( - text: Binding, - onSubmit: (() -> Void)?, - onInsertOriginal: (() -> Void)?, - onEscape: (() -> Void)?, - onTextViewAttachment: ((InkletTextViewAttachmentEvent) -> Void)? - ) { - self.text = text - self.onSubmit = onSubmit - self.onInsertOriginal = onInsertOriginal - self.onEscape = onEscape - self.onTextViewAttachment = onTextViewAttachment - super.init() - } - - @MainActor - func textDidChange(_ notification: Notification) { - guard let textView = notification.object as? NSTextView else { - return - } - - syncText(from: textView) - textView.enclosingScrollView?.superview - .flatMap { $0 as? InkletTextContainerView }? - .updatePlaceholderVisibility() - } - - @MainActor - func textViewDidChangeSelection(_ notification: Notification) { - guard let textView = notification.object as? NSTextView else { - return - } - - textView.enclosingScrollView?.superview - .flatMap { $0 as? InkletTextContainerView }? - .updatePlaceholderVisibility() - } - - @MainActor - func syncText(from textView: NSTextView) { - text.wrappedValue = textView.string - } - - @MainActor - func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { - guard !textView.hasMarkedText() else { - return false - } - - if commandSelector == #selector(NSResponder.cancelOperation(_:)) { - onEscape?() - return true - } - - guard commandSelector == #selector(NSResponder.insertNewline(_:)) - || commandSelector == #selector(NSResponder.insertNewlineIgnoringFieldEditor(_:)) - else { - return false - } - - let modifiers = NSApp.currentEvent?.modifierFlags.intersection(.deviceIndependentFlagsMask) ?? [] - if modifiers.contains(.command) { - onInsertOriginal?() - return true - } - - if modifiers.contains(.shift) || modifiers.contains(.option) { - return false - } - - onSubmit?() - return true - } - } -} - -private extension NSView { - var descendantTextViews: [NSTextView] { - var textViews: [NSTextView] = [] - if let textView = self as? NSTextView { - textViews.append(textView) - } - - for subview in subviews { - textViews.append(contentsOf: subview.descendantTextViews) - } - - return textViews - } -} - -private struct PopoverKeyEventHandler: NSViewRepresentable { - let route: WritingPopoverSessionState.Route - let onSubmit: () -> Void - let onInsertOriginal: () -> Void - let onEscape: () -> Void - let onCycleMode: (Int) -> Void - let onMoveModeHighlight: (Int) -> Void - let onCommitMode: () -> Void - - func makeNSView(context: Context) -> NSView { - let view = NSView(frame: .zero) - context.coordinator.attach(to: view) - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - context.coordinator.route = route - context.coordinator.onSubmit = onSubmit - context.coordinator.onInsertOriginal = onInsertOriginal - context.coordinator.onEscape = onEscape - context.coordinator.onCycleMode = onCycleMode - context.coordinator.onMoveModeHighlight = onMoveModeHighlight - context.coordinator.onCommitMode = onCommitMode - context.coordinator.attach(to: nsView) - } - - static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { - coordinator.detach() - } - - func makeCoordinator() -> Coordinator { - Coordinator( - route: route, - onSubmit: onSubmit, - onInsertOriginal: onInsertOriginal, - onEscape: onEscape, - onCycleMode: onCycleMode, - onMoveModeHighlight: onMoveModeHighlight, - onCommitMode: onCommitMode - ) - } - - @MainActor - final class Coordinator { - var route: WritingPopoverSessionState.Route - var onSubmit: () -> Void - var onInsertOriginal: () -> Void - var onEscape: () -> Void - var onCycleMode: (Int) -> Void - var onMoveModeHighlight: (Int) -> Void - var onCommitMode: () -> Void - private weak var view: NSView? - private var monitor: Any? - - init( - route: WritingPopoverSessionState.Route, - onSubmit: @escaping () -> Void, - onInsertOriginal: @escaping () -> Void, - onEscape: @escaping () -> Void, - onCycleMode: @escaping (Int) -> Void, - onMoveModeHighlight: @escaping (Int) -> Void, - onCommitMode: @escaping () -> Void - ) { - self.route = route - self.onSubmit = onSubmit - self.onInsertOriginal = onInsertOriginal - self.onEscape = onEscape - self.onCycleMode = onCycleMode - self.onMoveModeHighlight = onMoveModeHighlight - self.onCommitMode = onCommitMode - } - - func detach() { - if let monitor { - NSEvent.removeMonitor(monitor) - self.monitor = nil - } - } - - func attach(to view: NSView) { - self.view = view - guard monitor == nil else { return } - - monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - self?.handle(event) ?? event - } - } - - private func handle(_ event: NSEvent) -> NSEvent? { - guard let window = view?.window, - event.window === window, - window.isKeyWindow - else { - return event - } - - let action = WritingPopoverKeyboardPolicy.action( - route: route, - keyCode: event.keyCode, - modifiers: keyboardModifiers(from: event.modifierFlags), - isComposingText: isComposingText - ) - - switch action { - case .passThrough: - return event - case .consume: - return nil - case .escape: - onEscape() - return nil - case .moveHighlight(let offset): - onMoveModeHighlight(offset) - return nil - case .commitMode: - onCommitMode() - return nil - case .cycleMode(let direction): - onCycleMode(direction) - return nil - case .submit: - onSubmit() - return nil - case .insertOriginal: - onInsertOriginal() - return nil - } - } - - private var isComposingText: Bool { - guard let responder = view?.window?.firstResponder as? NSTextInputClient else { - return false - } - - return responder.hasMarkedText() - } - - private func keyboardModifiers( - from modifiers: NSEvent.ModifierFlags - ) -> WritingPopoverKeyboardModifiers { - var keyboardModifiers: WritingPopoverKeyboardModifiers = [] - if modifiers.contains(.command) { - keyboardModifiers.insert(.command) - } - if modifiers.contains(.shift) { - keyboardModifiers.insert(.shift) - } - if modifiers.contains(.option) { - keyboardModifiers.insert(.option) - } - if modifiers.contains(.control) { - keyboardModifiers.insert(.control) - } - return keyboardModifiers - } - } -} diff --git a/Sources/InkletApp/InkletPopoverViewModel.swift b/Sources/InkletApp/InkletPopoverViewModel.swift new file mode 100644 index 0000000..fc800c5 --- /dev/null +++ b/Sources/InkletApp/InkletPopoverViewModel.swift @@ -0,0 +1,844 @@ +import Combine +import AppKit +import SwiftUI +import InkletCore + +@MainActor +final class InkletPopoverViewModel: ObservableObject { + @Published var sourceText = "" + @Published var resultText = "" + @Published var errorMessage: String? + @Published var isTransforming = false + @Published var isInserting = false + @Published private(set) var modePickerState: WritingModePickerState + @Published private(set) var popoverSession: WritingPopoverSessionState + @Published private(set) var modeSearchFocusRevision = 0 + @Published private(set) var modes: [PromptMode] + @Published var preferredPopoverHeight: CGFloat = 168 + @Published var appearance: AppAppearance + @Published var voiceShortcutHint: VoiceInputConfig.Shortcut? + @Published private(set) var dictationPhase: WritingDictationCoordinator.Phase = .idle + + var onHidePopover: (() -> Void)? + var onFocusPopover: (() -> Void)? + var onFocusSourceInput: ((FocusRequestGeneration.Request) -> Void)? + var onOpenSettings: (() -> Void)? + var onCancelDictation: (() -> Void)? + + var route: WritingPopoverSessionState.Route { + popoverSession.route + } + + var selectedModeID: String { + popoverSession.selectedModeID + } + + var isResultStale: Bool { + popoverSession.isResultStale && !resultText.isEmpty + } + + var resultModeDisplayName: String? { + guard let resultModeID = popoverSession.resultModeID else { + return nil + } + + return modes.first(where: { $0.id == resultModeID })?.localizedName ?? resultModeID + } + + var currentProviderName: String { + LLMProviderPreset.preset(id: config.providerID).name + } + + var currentModelName: String { + config.model + } + + var currentVoiceInputConfig: VoiceInputConfig { + config.voiceInput + } + + var shouldShowDictationStatus: Bool { + voiceShortcutHint != nil || dictationPhase.isActive + } + + var dictationStatusText: String { + switch dictationPhase { + case .connecting: + L10n.text("dictation.status.connecting") + case .listening: + L10n.text("dictation.status.listening") + case .recordingForFallback: + L10n.text("dictation.status.recordingFallback") + case .finalizing: + L10n.text("dictation.status.finalizing") + case .recovering: + L10n.text("dictation.status.recovering") + case .idle, .complete, .failed: + L10n.format("dictation.hint.hold", voiceShortcutHint?.localizedName ?? "") + } + } + + var dictationStatusAccessibilityLabel: String { + switch dictationPhase { + case .listening: + L10n.text("dictation.accessibility.listening") + case .recordingForFallback: + L10n.text("dictation.accessibility.recordingFallback") + case .connecting, .finalizing, .recovering: + dictationStatusText + case .idle, .complete, .failed: + L10n.text("dictation.accessibility.ready") + } + } + + private struct SourceDictationPresentationSnapshot { + let sourceText: String + let resultText: String + let errorMessage: String? + let popoverSession: WritingPopoverSessionState + let stateMachineState: PopoverStateMachine.State + let draftSourceText: String + let hasTransformedInSession: Bool + } + + private var stateMachine: PopoverStateMachine + private let configStore: UserDefaultsConfigStore + private let apiKeyStore: LocalAPIKeyStore + private let insertionService: InsertionService + private let transformationServiceFactory: (any LLMProvider) -> TransformationService + private let historyStore: any HistoryStore + private let writingModePreferenceStore: WritingModePreferenceStore + private var config: AppConfig + private var previousApplication: NSRunningApplication? + private var transformationTask: Task? + private var insertionTask: Task? + private var sessionID = 0 + private var draftSourceText = "" + private var hasTransformedInSession = false + private var sourceFocusGeneration = FocusRequestGeneration() + private var sourceDictationPresentationSnapshot: SourceDictationPresentationSnapshot? + private var isRestoringSourceDictationPresentation = false + private var languageCancellable: AnyCancellable? + private var dictationErrorMessage: String? + + init( + stateMachine: PopoverStateMachine = PopoverStateMachine(), + configStore: UserDefaultsConfigStore = UserDefaultsConfigStore(), + apiKeyStore: LocalAPIKeyStore = LocalAPIKeyStore(), + transformationServiceFactory: @escaping (any LLMProvider) -> TransformationService = { TransformationService(provider: $0) }, + insertionService: InsertionService = InsertionService(), + historyStore: any HistoryStore = JSONLHistoryStore(), + writingModePreferenceStore: WritingModePreferenceStore = WritingModePreferenceStore() + ) { + self.stateMachine = stateMachine + self.configStore = configStore + self.apiKeyStore = apiKeyStore + self.transformationServiceFactory = transformationServiceFactory + self.insertionService = insertionService + self.historyStore = historyStore + self.writingModePreferenceStore = writingModePreferenceStore + + let loadedConfig = (try? configStore.load()) ?? AppConfig.defaultConfig() + let selectedModeID = Self.resolvedModeID( + preferredModeID: writingModePreferenceStore.loadLastModeID(), + config: loadedConfig + ) + let visibleModes = Self.resolvedVisibleModes(from: loadedConfig) + self.config = loadedConfig + self.modes = visibleModes + self.modePickerState = WritingModePickerState( + items: Self.modePickerItems(for: visibleModes), + preferredModeID: selectedModeID + ) + self.popoverSession = WritingPopoverSessionState(selectedModeID: selectedModeID) + self.appearance = loadedConfig.appearance + self.voiceShortcutHint = nil + languageCancellable = NotificationCenter.default.publisher(for: .inkletLanguageDidChange) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.refreshLocalizedContent() + } + } + + private func refreshLocalizedContent() { + modePickerState = WritingModePickerState( + items: Self.modePickerItems(for: modes), + preferredModeID: modePickerState.highlightedModeID, + query: modePickerState.query + ) + if case .failed(let errorKey) = dictationPhase, errorMessage == dictationErrorMessage { + errorMessage = L10n.text(errorKey) + dictationErrorMessage = errorMessage + } + } + + func resetForOpen(previousApplication: NSRunningApplication?) { + invalidateSourceInputFocusRequests() + transformationTask?.cancel() + transformationTask = nil + insertionTask?.cancel() + insertionTask = nil + sessionID += 1 + self.previousApplication = previousApplication + stateMachine = PopoverStateMachine() + + config = (try? configStore.load()) ?? AppConfig.defaultConfig() + let selectedModeID = Self.resolvedModeID( + preferredModeID: writingModePreferenceStore.loadLastModeID(), + config: config + ) + modes = Self.resolvedVisibleModes(from: config) + popoverSession = WritingPopoverSessionState(selectedModeID: selectedModeID) + modePickerState = WritingModePickerState( + items: Self.modePickerItems(for: modes), + preferredModeID: selectedModeID + ) + modeSearchFocusRevision += 1 + appearance = config.appearance + refreshVoiceShortcutHint() + sourceText = draftSourceText + resultText = "" + errorMessage = nil + isTransforming = false + isInserting = false + hasTransformedInSession = false + sourceDictationPresentationSnapshot = nil + dictationPhase = .idle + preferredPopoverHeight = WritingModePickerView.preferredHeight( + resultCount: modePickerState.filteredItems.count + ) + + handle(actions: stateMachine.send(.open)) + if !sourceText.isEmpty { + _ = stateMachine.send(.sourceChanged(sourceText)) + } + } + + var isBusy: Bool { + isTransforming || isInserting || dictationPhase.isActive + } + + func cancelForMigrationMaintenance() { + sessionID += 1 + transformationTask?.cancel() + transformationTask = nil + insertionTask?.cancel() + insertionTask = nil + isTransforming = false + isInserting = false + } + + private func refreshVoiceShortcutHint() { + let shortcut = config.voiceInput.shortcut + voiceShortcutHint = shortcut == .disabled ? nil : shortcut + } + + func beginSourceDictationPresentation() -> Bool { + guard route == .editor, + !isBusy, + !isRestoringSourceDictationPresentation, + sourceDictationPresentationSnapshot == nil + else { + return false + } + + sourceDictationPresentationSnapshot = SourceDictationPresentationSnapshot( + sourceText: sourceText, + resultText: resultText, + errorMessage: errorMessage, + popoverSession: popoverSession, + stateMachineState: stateMachine.state, + draftSourceText: draftSourceText, + hasTransformedInSession: hasTransformedInSession + ) + errorMessage = nil + return true + } + + func synchronizeSourceTextDuringDictation(_ text: String) { + guard sourceDictationPresentationSnapshot != nil else { + return + } + sourceText = text + } + + func commitSourceDictationPresentation() { + guard sourceDictationPresentationSnapshot != nil else { + return + } + sourceDictationPresentationSnapshot = nil + resultText = "" + errorMessage = nil + mutatePopoverSession { $0.clearResult() } + draftSourceText = sourceText + hasTransformedInSession = false + stateMachine = PopoverStateMachine( + state: .editingSource(source: sourceText, errorMessage: nil) + ) + } + + func synchronizeSourceTextAfterDictation(_ text: String) { + sourceText = text + resultText = "" + errorMessage = nil + mutatePopoverSession { $0.clearResult() } + draftSourceText = text + hasTransformedInSession = false + stateMachine = PopoverStateMachine( + state: .editingSource(source: text, errorMessage: nil) + ) + } + + func restoreSourceDictationPresentation() { + guard let snapshot = sourceDictationPresentationSnapshot, + !isRestoringSourceDictationPresentation + else { + return + } + isRestoringSourceDictationPresentation = true + defer { + sourceDictationPresentationSnapshot = nil + isRestoringSourceDictationPresentation = false + } + sourceText = snapshot.sourceText + resultText = snapshot.resultText + errorMessage = snapshot.errorMessage + popoverSession = snapshot.popoverSession + stateMachine = PopoverStateMachine(state: snapshot.stateMachineState) + draftSourceText = snapshot.draftSourceText + hasTransformedInSession = snapshot.hasTransformedInSession + } + + func setDictationPhase(_ phase: WritingDictationCoordinator.Phase) { + dictationPhase = phase + if case .failed(let errorKey) = phase { + errorMessage = L10n.text(errorKey) + dictationErrorMessage = errorMessage + } + } + + func updateSourceText(_ text: String) { + guard !isBusy else { + return + } + + sourceText = text + if !resultText.isEmpty { + resultText = "" + mutatePopoverSession { $0.clearResult() } + } + + _ = stateMachine.send(.sourceChanged(text)) + } + + func updateResultText(_ text: String) { + guard !isBusy else { + return + } + + resultText = text + guard !text.isEmpty else { + mutatePopoverSession { $0.clearResult() } + stateMachine = PopoverStateMachine( + state: .editingSource(source: sourceText, errorMessage: nil) + ) + return + } + + _ = stateMachine.send(.resultChanged(text)) + } + + func updateModeSearchQuery(_ query: String) { + guard !isBusy else { + return + } + mutateModePickerState { $0.setQuery(query) } + } + + func moveModeHighlight(by offset: Int) { + guard !isBusy else { + return + } + mutateModePickerState { $0.moveHighlight(by: offset) } + } + + func highlightMode(modeID: String) { + guard !isBusy else { + return + } + mutateModePickerState { _ = $0.highlight(modeID: modeID) } + } + + func commitHighlightedMode() { + guard let highlightedModeID = modePickerState.highlightedModeID else { + return + } + + commitMode(modeID: highlightedModeID) + } + + func commitMode(modeID: String) { + guard !isBusy, + modes.contains(where: { $0.id == modeID }) + else { + return + } + + mutatePopoverSession { $0.enterEditor(modeID: modeID) } + writingModePreferenceStore.saveLastModeID(modeID) + requestSourceInputFocus() + } + + func returnToModePicker() { + guard !isBusy else { + return + } + + invalidateSourceInputFocusRequests() + mutatePopoverSession { $0.showModePicker() } + modePickerState = WritingModePickerState( + items: Self.modePickerItems(for: modes), + preferredModeID: selectedModeID + ) + modeSearchFocusRevision += 1 + } + + func cyclePromptMode(direction: Int) { + guard !modes.isEmpty else { + return + } + + let currentIndex = modes.firstIndex { $0.id == selectedModeID } ?? 0 + let nextIndex = (currentIndex + direction + modes.count) % modes.count + commitMode(modeID: modes[nextIndex].id) + } + + func submit() { + guard !isBusy else { + return + } + + errorMessage = nil + if !resultText.isEmpty, isResultStale { + removeSingleTrailingNewlineFromSource() + _ = stateMachine.send(.sourceChanged(sourceText)) + handle(actions: stateMachine.send(.submit)) + return + } + + if !resultText.isEmpty { + removeSingleTrailingNewlineFromResult() + let currentResult = resultText + _ = stateMachine.send(.resultChanged(currentResult)) + let actions = stateMachine.send(.submit) + if actions.isEmpty { + insert( + text: currentResult, + fallbackState: .previewingResult(source: sourceText, result: currentResult) + ) + } else { + handle(actions: actions) + } + return + } + + removeSingleTrailingNewlineFromSource() + _ = stateMachine.send(.sourceChanged(sourceText)) + handle(actions: stateMachine.send(.submit)) + } + + private func removeSingleTrailingNewlineFromSource() { + if sourceText.hasSuffix("\r\n") { + sourceText.removeLast(2) + } else if sourceText.hasSuffix("\n") || sourceText.hasSuffix("\r") { + sourceText.removeLast() + } + } + + private func removeSingleTrailingNewlineFromResult() { + if resultText.hasSuffix("\r\n") { + resultText.removeLast(2) + } else if resultText.hasSuffix("\n") || resultText.hasSuffix("\r") { + resultText.removeLast() + } + } + + func insertOriginal() { + guard !isBusy else { + return + } + + let trimmedSource = sourceText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSource.isEmpty else { + errorMessage = L10n.text("popover.error.emptyOriginal") + return + } + + errorMessage = nil + let fallbackState: PopoverStateMachine.State = resultText.isEmpty + ? .editingSource(source: sourceText, errorMessage: nil) + : .previewingResult(source: sourceText, result: resultText) + + if resultText.isEmpty { + _ = stateMachine.send(.sourceChanged(sourceText)) + let actions = stateMachine.send(.insertOriginal) + if !actions.isEmpty { + handle(actions: actions) + return + } + } + + insert(text: sourceText, fallbackState: fallbackState) + } + + func escape() { + if dictationPhase.isActive { + onCancelDictation?() + return + } + + if isTransforming { + cancelTransformationAndStayInEditor() + return + } + + guard !isInserting else { + return + } + + if route == .modePicker { + draftSourceText = hasTransformedInSession ? "" : sourceText + handle(actions: stateMachine.send(.close)) + return + } + + if !resultText.isEmpty { + resultText = "" + errorMessage = nil + mutatePopoverSession { $0.clearResult() } + stateMachine = PopoverStateMachine( + state: .editingSource(source: sourceText, errorMessage: nil) + ) + requestSourceInputFocus() + return + } + + returnToModePicker() + } + + func openSettings() { + guard !isBusy else { + return + } + onHidePopover?() + onOpenSettings?() + } + + func isCurrentSourceFocusRequest(_ request: FocusRequestGeneration.Request) -> Bool { + sourceFocusGeneration.isCurrent(request) + } + + private func requestSourceInputFocus() { + guard route == .editor else { + return + } + + let request = sourceFocusGeneration.issue() + onFocusSourceInput?(request) + } + + private func invalidateSourceInputFocusRequests() { + sourceFocusGeneration.invalidate() + } + + private func handle(actions: [PopoverStateMachine.Action]) { + for action in actions { + switch action { + case .showPopover: + onFocusPopover?() + case .hidePopover: + onHidePopover?() + case .focusSourceInput: + requestSourceInputFocus() + case .startTransformation(let source): + startTransformation(source: source) + case .showResult(let result): + hasTransformedInSession = true + draftSourceText = "" + resultText = result + case .showError(let message): + errorMessage = localizedStateMachineMessage(message) + case .insertText(let text): + let fallbackState: PopoverStateMachine.State + if !resultText.isEmpty { + fallbackState = .previewingResult(source: sourceText, result: resultText) + } else { + fallbackState = .editingSource(source: sourceText, errorMessage: nil) + } + insert(text: text, fallbackState: fallbackState) + } + } + } + + private func startTransformation(source: String) { + transformationTask?.cancel() + let preservesExistingResult = isResultStale + if !preservesExistingResult { + resultText = "" + mutatePopoverSession { $0.clearResult() } + } + errorMessage = nil + isTransforming = true + + let resolvedModeID = Self.resolvedModeID( + preferredModeID: selectedModeID, + visibleModes: modes + ) + let mode = PromptModeStore(modes: modes).resolve( + modeID: resolvedModeID, + sourceText: source + ) + let transformationModeID = mode.id + let model = config.model + let timeoutSeconds = config.timeoutSeconds + let providerPreset = config.resolvedProviderPreset + let providerID = config.providerID + let apiKeyStore = self.apiKeyStore + let provider = LLMProviderFactory.provider(for: providerPreset) { + try LocalAPIKeyProvider( + apiKeyStore: apiKeyStore, + providerID: providerID, + providerName: providerPreset.name + ).loadAPIKey() + } + let transformationService = transformationServiceFactory(provider) + + transformationTask = Task { [weak self] in + guard let self else { return } + do { + let result = try await transformationService.transform( + sourceText: source, + mode: mode, + model: model, + timeoutSeconds: timeoutSeconds + ) + guard !Task.isCancelled else { return } + try? historyStore.append(HistoryItem( + source: .write, + inputText: source, + outputText: result.outputText, + modeName: mode.localizedName, + targetLanguageName: nil, + model: model, + metadata: [ + "modeID": mode.id, + "providerID": providerID + ] + )) + transformationTask = nil + isTransforming = false + mutatePopoverSession { $0.recordResult(modeID: transformationModeID) } + handle(actions: stateMachine.send(.transformationSucceeded(result: result.outputText))) + } catch { + guard !Task.isCancelled else { return } + transformationTask = nil + isTransforming = false + if !preservesExistingResult { + resultText = "" + mutatePopoverSession { $0.clearResult() } + } + handle(actions: stateMachine.send(.transformationFailed(message: error.userFacingMessage))) + if preservesExistingResult { + synchronizeStateMachineWithVisibleContent() + } + } + } + } + + private func cancelTransformationAndStayInEditor() { + transformationTask?.cancel() + transformationTask = nil + isTransforming = false + errorMessage = nil + mutatePopoverSession { $0.enterEditor(modeID: selectedModeID) } + synchronizeStateMachineWithVisibleContent() + } + + private func synchronizeStateMachineWithVisibleContent() { + let visibleState: PopoverStateMachine.State = resultText.isEmpty + ? .editingSource(source: sourceText, errorMessage: nil) + : .previewingResult(source: sourceText, result: resultText) + stateMachine = PopoverStateMachine(state: visibleState) + } + + private func mutateModePickerState(_ mutation: (inout WritingModePickerState) -> Void) { + var updatedState = modePickerState + mutation(&updatedState) + modePickerState = updatedState + } + + private func mutatePopoverSession(_ mutation: (inout WritingPopoverSessionState) -> Void) { + var updatedSession = popoverSession + mutation(&updatedSession) + popoverSession = updatedSession + } + + private static func resolvedModeID( + preferredModeID: String?, + config: AppConfig + ) -> String { + guard let preferredModeID else { + return config.defaultVisibleModeID + } + + return config.visibleModeID(preferredModeID: preferredModeID) + } + + private static func resolvedVisibleModes(from config: AppConfig) -> [PromptMode] { + let visibleModes = config.visiblePromptModes + if !visibleModes.isEmpty { + return visibleModes + } + + return AppConfig.defaultConfig().visiblePromptModes + } + + private static func resolvedModeID( + preferredModeID: String?, + visibleModes: [PromptMode] + ) -> String { + if let preferredModeID, + visibleModes.contains(where: { $0.id == preferredModeID }) { + return preferredModeID + } + + return visibleModes.first?.id ?? PromptMode.translateToEnglishID + } + + private static func modePickerItems(for modes: [PromptMode]) -> [WritingModePickerItem] { + modes.map { WritingModePickerItem(id: $0.id, title: $0.localizedName) } + } + + private func localizedStateMachineMessage(_ message: String) -> String { + switch message { + case "请输入要转换的文本": + L10n.text("error.emptySource") + default: + message + } + } + + private func insert(text: String, fallbackState: PopoverStateMachine.State) { + guard let previousApplication else { + stateMachine = PopoverStateMachine(state: fallbackState) + errorMessage = L10n.text("popover.error.missingTarget") + return + } + + errorMessage = nil + onHidePopover?() + isInserting = true + + let insertionSessionID = sessionID + insertionTask?.cancel() + insertionTask = Task { [weak self] in + guard let self else { return } + do { + try await insertionService.insert(text: text, into: previousApplication) + guard sessionID == insertionSessionID else { + return + } + isInserting = false + insertionTask = nil + draftSourceText = "" + sourceText = "" + resultText = "" + mutatePopoverSession { $0.clearResult() } + handle(actions: stateMachine.send(.insertionFinished)) + } catch { + guard sessionID == insertionSessionID else { + return + } + isInserting = false + insertionTask = nil + stateMachine = PopoverStateMachine(state: fallbackState) + errorMessage = error.userFacingMessage + onFocusPopover?() + } + } + } +} + +private struct LocalAPIKeyProvider: @unchecked Sendable { + let apiKeyStore: LocalAPIKeyStore + let providerID: String + let providerName: String + + func loadAPIKey() throws -> String { + guard let apiKey = apiKeyStore.loadAPIKey(forProviderID: providerID), + !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + throw TransformationError.provider(L10n.format("popover.error.missingAPIKey", providerName)) + } + return apiKey + } +} + +private extension Error { + var userFacingMessage: String { + if let transformationError = self as? TransformationError { + switch transformationError { + case .emptySource: + return L10n.text("error.emptySource") + case .emptyResponse: + return L10n.text("error.emptyResponse") + case .timeout: + return L10n.text("error.timeout") + case .networkConnectionLost: + return L10n.text("error.networkConnectionLost") + case .provider(let message): + return localizedProviderMessage(message) + } + } + + if let localizedError = self as? LocalizedError, + let description = localizedError.errorDescription { + return description + } + + if let insertionError = self as? InsertionError { + switch insertionError { + case .accessibilityPermissionMissing: + return L10n.text("insertion.error.accessibility") + case .activationFailed: + return L10n.text("insertion.error.activation") + case .cannotCreatePasteEvent: + return L10n.text("insertion.error.pasteEvent") + case .clipboardRestoreFailed: + return L10n.text("insertion.error.clipboardRestore") + } + } + + return String(describing: self) + } + + private func localizedProviderMessage(_ message: String) -> String { + for providerName in LLMProviderPreset.all.map(\.name) { + let prefix = "\(providerName) 请求失败:" + guard message.hasPrefix(prefix) else { + continue + } + + let detail = String(message.dropFirst(prefix.count)) + if detail == "URL 无效" { + return L10n.format("error.provider.urlInvalid", providerName) + } + if detail == "HTTP unknown" { + return L10n.format("error.provider.httpUnknown", providerName) + } + return L10n.format("error.provider.prefix", providerName, detail) + } + + return message + } +} diff --git a/Sources/InkletApp/InkletTextView.swift b/Sources/InkletApp/InkletTextView.swift new file mode 100644 index 0000000..d599c05 --- /dev/null +++ b/Sources/InkletApp/InkletTextView.swift @@ -0,0 +1,446 @@ +import AppKit +import SwiftUI +import InkletCore + +final class InkletTextContainerView: NSView { + let scrollView = NSScrollView() + let placeholderLabel = NSTextField(labelWithString: "") + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + scrollView.translatesAutoresizingMaskIntoConstraints = false + placeholderLabel.translatesAutoresizingMaskIntoConstraints = false + placeholderLabel.font = .systemFont(ofSize: 14) + placeholderLabel.textColor = .placeholderTextColor + placeholderLabel.lineBreakMode = .byTruncatingTail + placeholderLabel.maximumNumberOfLines = 1 + placeholderLabel.isEditable = false + placeholderLabel.isSelectable = false + placeholderLabel.backgroundColor = .clear + placeholderLabel.drawsBackground = false + + addSubview(scrollView) + addSubview(placeholderLabel) + + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + scrollView.topAnchor.constraint(equalTo: topAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + placeholderLabel.leadingAnchor.constraint(equalTo: leadingAnchor), + placeholderLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), + placeholderLabel.topAnchor.constraint(equalTo: topAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @MainActor + func updatePlaceholderVisibility() { + let textView = scrollView.documentView as? NSTextView + placeholderLabel.isHidden = placeholderLabel.stringValue.isEmpty + || textView?.string.isEmpty == false + || textView?.hasMarkedText() == true + } +} + +private final class InkletNativeTextView: NSTextView { + var onInputStateChange: (() -> Void)? + var onEscapeKeyDown: (() -> Void)? + + override func keyDown(with event: NSEvent) { + guard event.keyCode == 53, !hasMarkedText() else { + super.keyDown(with: event) + return + } + + onEscapeKeyDown?() + } + + override func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { + super.setMarkedText(string, selectedRange: selectedRange, replacementRange: replacementRange) + onInputStateChange?() + } + + override func unmarkText() { + super.unmarkText() + onInputStateChange?() + } + + override func insertText(_ insertString: Any, replacementRange: NSRange) { + super.insertText(insertString, replacementRange: replacementRange) + onInputStateChange?() + } +} + +struct InkletTextView: NSViewRepresentable { + @Binding var text: String + var placeholder: String? + var isEditable: Bool + var onSubmit: (() -> Void)? + var onInsertOriginal: (() -> Void)? + var onEscape: (() -> Void)? + var onTextViewAttachment: ((InkletTextViewAttachmentEvent) -> Void)? + + func makeCoordinator() -> Coordinator { + Coordinator( + text: $text, + onSubmit: onSubmit, + onInsertOriginal: onInsertOriginal, + onEscape: onEscape, + onTextViewAttachment: onTextViewAttachment + ) + } + + func makeNSView(context: Context) -> InkletTextContainerView { + let container = InkletTextContainerView() + let scrollView = container.scrollView + scrollView.drawsBackground = false + scrollView.borderType = .noBorder + scrollView.hasVerticalScroller = false + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.scrollerStyle = .overlay + scrollView.contentInsets = NSEdgeInsetsZero + scrollView.automaticallyAdjustsContentInsets = false + scrollView.horizontalScrollElasticity = .none + + let textView = InkletNativeTextView() + textView.string = text + textView.delegate = context.coordinator + textView.onInputStateChange = { [weak coordinator = context.coordinator, weak textView, weak container] in + guard let textView else { + return + } + coordinator?.syncText(from: textView) + container?.updatePlaceholderVisibility() + } + textView.onEscapeKeyDown = { [weak coordinator = context.coordinator] in + coordinator?.onEscape?() + } + textView.isEditable = isEditable + textView.isSelectable = true + textView.isRichText = false + textView.importsGraphics = false + textView.allowsUndo = true + textView.usesFindBar = false + textView.drawsBackground = false + textView.backgroundColor = .clear + textView.font = .systemFont(ofSize: 14) + textView.textColor = .labelColor + textView.insertionPointColor = .controlAccentColor + textView.textContainerInset = .zero + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.containerSize = NSSize(width: scrollView.contentSize.width, height: CGFloat.greatestFiniteMagnitude) + textView.minSize = NSSize(width: 0, height: 0) + textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.autoresizingMask = [.width] + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + scrollView.documentView = textView + context.coordinator.textView = textView + onTextViewAttachment?(.attach(textView)) + container.placeholderLabel.stringValue = placeholder ?? "" + container.updatePlaceholderVisibility() + return container + } + + func updateNSView(_ container: InkletTextContainerView, context: Context) { + let scrollView = container.scrollView + guard let textView = scrollView.documentView as? NSTextView else { + return + } + + context.coordinator.text = $text + context.coordinator.onSubmit = onSubmit + context.coordinator.onInsertOriginal = onInsertOriginal + context.coordinator.onEscape = onEscape + context.coordinator.onTextViewAttachment = onTextViewAttachment + context.coordinator.textView = textView + onTextViewAttachment?(.attach(textView)) + + textView.isEditable = isEditable + textView.font = .systemFont(ofSize: 14) + textView.textColor = .labelColor + textView.insertionPointColor = .controlAccentColor + container.placeholderLabel.stringValue = placeholder ?? "" + + if textView.isEditable, !textView.hasMarkedText(), textView.string != text { + textView.string = text + } + container.updatePlaceholderVisibility() + } + + static func dismantleNSView( + _ container: InkletTextContainerView, + coordinator: Coordinator + ) { + if let textView = container.scrollView.documentView as? NSTextView { + coordinator.onTextViewAttachment?(.detach(textView)) + } + coordinator.textView = nil + } + + final class Coordinator: NSObject, NSTextViewDelegate, @unchecked Sendable { + var text: Binding + var onSubmit: (() -> Void)? + var onInsertOriginal: (() -> Void)? + var onEscape: (() -> Void)? + var onTextViewAttachment: ((InkletTextViewAttachmentEvent) -> Void)? + weak var textView: NSTextView? + + init( + text: Binding, + onSubmit: (() -> Void)?, + onInsertOriginal: (() -> Void)?, + onEscape: (() -> Void)?, + onTextViewAttachment: ((InkletTextViewAttachmentEvent) -> Void)? + ) { + self.text = text + self.onSubmit = onSubmit + self.onInsertOriginal = onInsertOriginal + self.onEscape = onEscape + self.onTextViewAttachment = onTextViewAttachment + super.init() + } + + @MainActor + func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { + return + } + + syncText(from: textView) + textView.enclosingScrollView?.superview + .flatMap { $0 as? InkletTextContainerView }? + .updatePlaceholderVisibility() + } + + @MainActor + func textViewDidChangeSelection(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { + return + } + + textView.enclosingScrollView?.superview + .flatMap { $0 as? InkletTextContainerView }? + .updatePlaceholderVisibility() + } + + @MainActor + func syncText(from textView: NSTextView) { + text.wrappedValue = textView.string + } + + @MainActor + func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + guard !textView.hasMarkedText() else { + return false + } + + if commandSelector == #selector(NSResponder.cancelOperation(_:)) { + onEscape?() + return true + } + + guard commandSelector == #selector(NSResponder.insertNewline(_:)) + || commandSelector == #selector(NSResponder.insertNewlineIgnoringFieldEditor(_:)) + else { + return false + } + + let modifiers = NSApp.currentEvent?.modifierFlags.intersection(.deviceIndependentFlagsMask) ?? [] + if modifiers.contains(.command) { + onInsertOriginal?() + return true + } + + if modifiers.contains(.shift) || modifiers.contains(.option) { + return false + } + + onSubmit?() + return true + } + } +} + +private extension NSView { + var descendantTextViews: [NSTextView] { + var textViews: [NSTextView] = [] + if let textView = self as? NSTextView { + textViews.append(textView) + } + + for subview in subviews { + textViews.append(contentsOf: subview.descendantTextViews) + } + + return textViews + } +} + +struct PopoverKeyEventHandler: NSViewRepresentable { + let route: WritingPopoverSessionState.Route + let onSubmit: () -> Void + let onInsertOriginal: () -> Void + let onEscape: () -> Void + let onCycleMode: (Int) -> Void + let onMoveModeHighlight: (Int) -> Void + let onCommitMode: () -> Void + + func makeNSView(context: Context) -> NSView { + let view = NSView(frame: .zero) + context.coordinator.attach(to: view) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + context.coordinator.route = route + context.coordinator.onSubmit = onSubmit + context.coordinator.onInsertOriginal = onInsertOriginal + context.coordinator.onEscape = onEscape + context.coordinator.onCycleMode = onCycleMode + context.coordinator.onMoveModeHighlight = onMoveModeHighlight + context.coordinator.onCommitMode = onCommitMode + context.coordinator.attach(to: nsView) + } + + static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { + coordinator.detach() + } + + func makeCoordinator() -> Coordinator { + Coordinator( + route: route, + onSubmit: onSubmit, + onInsertOriginal: onInsertOriginal, + onEscape: onEscape, + onCycleMode: onCycleMode, + onMoveModeHighlight: onMoveModeHighlight, + onCommitMode: onCommitMode + ) + } + + @MainActor + final class Coordinator { + var route: WritingPopoverSessionState.Route + var onSubmit: () -> Void + var onInsertOriginal: () -> Void + var onEscape: () -> Void + var onCycleMode: (Int) -> Void + var onMoveModeHighlight: (Int) -> Void + var onCommitMode: () -> Void + private weak var view: NSView? + private var monitor: Any? + + init( + route: WritingPopoverSessionState.Route, + onSubmit: @escaping () -> Void, + onInsertOriginal: @escaping () -> Void, + onEscape: @escaping () -> Void, + onCycleMode: @escaping (Int) -> Void, + onMoveModeHighlight: @escaping (Int) -> Void, + onCommitMode: @escaping () -> Void + ) { + self.route = route + self.onSubmit = onSubmit + self.onInsertOriginal = onInsertOriginal + self.onEscape = onEscape + self.onCycleMode = onCycleMode + self.onMoveModeHighlight = onMoveModeHighlight + self.onCommitMode = onCommitMode + } + + func detach() { + if let monitor { + NSEvent.removeMonitor(monitor) + self.monitor = nil + } + } + + func attach(to view: NSView) { + self.view = view + guard monitor == nil else { return } + + monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + self?.handle(event) ?? event + } + } + + private func handle(_ event: NSEvent) -> NSEvent? { + guard let window = view?.window, + event.window === window, + window.isKeyWindow + else { + return event + } + + let action = WritingPopoverKeyboardPolicy.action( + route: route, + keyCode: event.keyCode, + modifiers: keyboardModifiers(from: event.modifierFlags), + isComposingText: isComposingText + ) + + switch action { + case .passThrough: + return event + case .consume: + return nil + case .escape: + onEscape() + return nil + case .moveHighlight(let offset): + onMoveModeHighlight(offset) + return nil + case .commitMode: + onCommitMode() + return nil + case .cycleMode(let direction): + onCycleMode(direction) + return nil + case .submit: + onSubmit() + return nil + case .insertOriginal: + onInsertOriginal() + return nil + } + } + + private var isComposingText: Bool { + guard let responder = view?.window?.firstResponder as? NSTextInputClient else { + return false + } + + return responder.hasMarkedText() + } + + private func keyboardModifiers( + from modifiers: NSEvent.ModifierFlags + ) -> WritingPopoverKeyboardModifiers { + var keyboardModifiers: WritingPopoverKeyboardModifiers = [] + if modifiers.contains(.command) { + keyboardModifiers.insert(.command) + } + if modifiers.contains(.shift) { + keyboardModifiers.insert(.shift) + } + if modifiers.contains(.option) { + keyboardModifiers.insert(.option) + } + if modifiers.contains(.control) { + keyboardModifiers.insert(.control) + } + return keyboardModifiers + } + } +} diff --git a/Sources/InkletApp/SelectionActionsController.swift b/Sources/InkletApp/SelectionActionsController.swift new file mode 100644 index 0000000..2e80484 --- /dev/null +++ b/Sources/InkletApp/SelectionActionsController.swift @@ -0,0 +1,763 @@ +import AppKit +import InkletCore + +@MainActor +private enum SelectionPronunciationReturnState: Equatable { + case menu + case translationResult +} + +private struct PendingSelectionRead { + let sourceProcessIdentifier: pid_t + let location: SelectionPoint +} + +private struct PendingUserCopyRead { + let sourceProcessIdentifier: pid_t + let location: SelectionPoint + let clipboardHandoff: SelectionClipboardUserCopyHandoff +} + +@MainActor +final class SelectionActionsController { + var onWorkStateChange: (() -> Void)? + var onInteractionEnded: (() -> Void)? + + private let configStore: UserDefaultsConfigStore + private let apiKeyStore: LocalAPIKeyStore + private let historyStore: JSONLHistoryStore + private let accessibilityPermissionService: AccessibilityPermissionService + private let selectionActionMonitor: SelectionActionMonitor + private let selectionActionWindowController: SelectionActionWindowController + private let selectionSourceValidator: SelectionSourceValidator + private let selectionClipboardReader: SelectionClipboardReader + private let selectionUserCopyReader: SelectionUserCopyReader + private let selectionReadPipeline: SelectionReadPipeline + private let selectionTranslationCache: JSONSelectionTranslationCache + private let speechPlaybackService: SpeechPlaybackService + private var selectionActionCoordinator: SelectionActionCoordinator + private let selectionTaskRegistry = SelectionTaskRegistry() + private var stoppedSelectionTasks: SelectionShutdownTaskSnapshot? + private var selectionReadTask: Task? + private var selectionTranslationTask: Task? + private var selectionTTSTask: Task? + private var selectionCopyFeedbackTask: Task? + private var selectionReadTaskID: UUID? + private var selectionTranslationTaskID: UUID? + private var selectionTTSTaskID: UUID? + private var selectionCopyFeedbackTaskID: UUID? + private var isSelectionSpeechPlaying = false + private var isMigrationMaintenanceActive = false + private var isStopping = false + private var currentSelectionText = "" + private var currentTranslationText = "" + private var selectionPronunciationReturnState = SelectionPronunciationReturnState.menu + private var panelDismissalPolicy = SelectionPanelDismissalPolicy() + + init( + configStore: UserDefaultsConfigStore, + apiKeyStore: LocalAPIKeyStore, + historyStore: JSONLHistoryStore, + accessibilityPermissionService: AccessibilityPermissionService, + translationCache: JSONSelectionTranslationCache + ) { + let selectionSourceValidator = SelectionSourceValidator() + let sourceValidator: SelectionReadPipeline.SourceValidator = { + selectionSourceValidator.isCurrent($0) + } + let selectedTextReader = SelectedTextReader() + let selectionClipboardReader = SelectionClipboardReader( + sourceProcessValidator: sourceValidator + ) + let selectionUserCopyReader = SelectionUserCopyReader( + sourceProcessValidator: sourceValidator + ) + + self.configStore = configStore + self.apiKeyStore = apiKeyStore + self.historyStore = historyStore + self.accessibilityPermissionService = accessibilityPermissionService + self.selectionActionMonitor = SelectionActionMonitor() + self.selectionActionWindowController = SelectionActionWindowController() + self.selectionSourceValidator = selectionSourceValidator + self.selectionClipboardReader = selectionClipboardReader + self.selectionUserCopyReader = selectionUserCopyReader + self.selectionReadPipeline = SelectionReadPipeline( + sourceValidator: sourceValidator, + accessibilityReader: { sourceProcessIdentifier, mouseLocation in + selectedTextReader.readSelectedText( + sourceProcessIdentifier: sourceProcessIdentifier, + mouseLocation: mouseLocation + ) + }, + clipboardReader: { sourceProcessIdentifier, forceSelectionMode in + await selectionClipboardReader.readSelectedText( + sourceProcessIdentifier: sourceProcessIdentifier, + forceSelectionMode: forceSelectionMode + ) + } + ) + self.selectionTranslationCache = translationCache + self.speechPlaybackService = SpeechPlaybackService() + self.selectionActionCoordinator = SelectionActionCoordinator( + config: ((try? configStore.load()) ?? AppConfig.defaultConfig()).selectionActions + ) + + self.selectionActionMonitor.onCandidateSelection = { [weak self] point in + guard let self, !self.isStopping else { return } + self.handleSelectionActionCandidate(at: point) + } + self.selectionActionMonitor.onCopyTrigger = { [weak self] trigger in + guard let self, + !self.isStopping, + !isMigrationMaintenanceActive, + trigger.sourceProcessIdentifier > 0, + trigger.sourceProcessIdentifier != NSRunningApplication.current.processIdentifier, + selectionSourceValidator.isCurrent(trigger.sourceProcessIdentifier) + else { + return + } + let clipboardHandoff = selectionClipboardReader.beginUserCopyHandoff() + let pendingUserCopyRead = PendingUserCopyRead( + sourceProcessIdentifier: trigger.sourceProcessIdentifier, + location: trigger.point, + clipboardHandoff: clipboardHandoff + ) + self.handleSelectionActionCopyTrigger(pendingUserCopyRead) + } + self.selectionActionMonitor.onDismiss = { [weak self] reason in + guard let self, !self.isStopping else { return } + self.handleSelectionDismissRequest( + reason: String(describing: reason), + bypassingPanelGrace: reason.bypassesPanelGrace + ) + } + self.selectionActionMonitor.onInteractionStateChange = { [weak self] isActive in + guard let self, !self.isStopping, !isActive else { return } + self.onInteractionEnded?() + } + self.selectionActionWindowController.onTranslate = { [weak self] in + Task { @MainActor in + self?.translateCurrentSelection() + } + } + self.selectionActionWindowController.onPronounce = { [weak self] in + Task { @MainActor in + self?.pronounceCurrentSelection() + } + } + self.selectionActionWindowController.onPronounceOriginal = { [weak self] in + Task { @MainActor in + self?.pronounceOriginalFromTranslation() + } + } + self.selectionActionWindowController.onPronounceTranslation = { [weak self] in + Task { @MainActor in + self?.pronounceCurrentTranslation() + } + } + self.selectionActionWindowController.onCopyTranslation = { [weak self] in + self?.copyCurrentTranslation() + } + self.selectionActionWindowController.onRetryTranslation = { [weak self] in + Task { @MainActor in + self?.translateCurrentSelection() + } + } + self.selectionActionWindowController.onDismiss = { [weak self] in + guard let self, !self.isStopping else { return } + self.forceDismissSelectionActions(reason: "selectionPanelEscape") + } + self.speechPlaybackService.onFinish = { [weak self] in + guard let self, !self.isStopping else { return } + self.isSelectionSpeechPlaying = false + self.restoreSelectionPronunciationReturnState() + self.notifyWorkStateChange() + } + } + + /// True when no selection read, translation, or pronunciation is running. + var isIdle: Bool { + selectionReadTask == nil + && selectionTranslationTask == nil + && selectionTTSTask == nil + && !isSelectionSpeechPlaying + } + + var isPanelVisible: Bool { + selectionActionWindowController.isPanelVisible + } + + var isInteractionActive: Bool { + selectionActionMonitor.isInteractionActive + } + + /// Stops new selection work and cancels running tasks without suspending. + func beginStopping() { + isStopping = true + selectionActionMonitor.stop() + let selectionTasks = selectionTaskRegistry.snapshotAndClear() + stoppedSelectionTasks = selectionTasks + selectionReadTask = nil + selectionTranslationTask = nil + selectionTTSTask = nil + selectionCopyFeedbackTask = nil + selectionReadTaskID = nil + selectionTranslationTaskID = nil + selectionTTSTaskID = nil + selectionCopyFeedbackTaskID = nil + selectionTasks.cancel() + speechPlaybackService.stop() + isSelectionSpeechPlaying = false + } + + /// Joins the clipboard read and the tasks captured by `beginStopping()`. + func finishStopping() async { + await selectionClipboardReader.cancelActiveRead() + await stoppedSelectionTasks?.waitForCompletion() + stoppedSelectionTasks = nil + } + + func enterMigrationMaintenance() { + isMigrationMaintenanceActive = true + selectionActionMonitor.stop() + selectionReadTask?.cancel() + selectionReadTask = nil + selectionReadTaskID = nil + selectionTranslationTask?.cancel() + selectionTranslationTask = nil + selectionTranslationTaskID = nil + selectionTTSTask?.cancel() + selectionTTSTask = nil + selectionTTSTaskID = nil + selectionCopyFeedbackTask?.cancel() + selectionCopyFeedbackTask = nil + selectionCopyFeedbackTaskID = nil + forceDismissSelectionActions(reason: "migrationMaintenance") + selectionActionWindowController.hidePanel() + speechPlaybackService.stop() + isSelectionSpeechPlaying = false + } + + func leaveMigrationMaintenance() { + isMigrationMaintenanceActive = false + configureSelectionActions() + } + + func handleActivatedApplication(_ application: NSRunningApplication?) { + guard !isStopping else { return } + if SelectionActivationDismissalPolicy.shouldDismiss( + activatedProcessIdentifier: application?.processIdentifier, + currentProcessIdentifier: NSRunningApplication.current.processIdentifier + ) { + SelectionActionDiagnostics.log( + "activated external app dismiss pid=\(application?.processIdentifier ?? -1)" + ) + handleSelectionDismissRequest(reason: "externalActivation", bypassingPanelGrace: true) + } else { + SelectionActionDiagnostics.log("activated current app ignored for selection dismiss") + } + } + + private func notifyWorkStateChange() { + onWorkStateChange?() + } + + func configureSelectionActions() { + guard !isStopping, !isMigrationMaintenanceActive else { + selectionActionMonitor.stop() + return + } + let config = (try? configStore.load()) ?? AppConfig.defaultConfig() + handleSelectionActionEffects(selectionActionCoordinator.handle(.updateConfig(config.selectionActions))) + let isAccessibilityTrusted = accessibilityPermissionService.isTrusted + SelectionActionDiagnostics.log( + "configure enabled=\(config.selectionActions.isEnabled) accessibilityTrusted=\(isAccessibilityTrusted)" + ) + if config.selectionActions.isEnabled, isAccessibilityTrusted { + if !selectionActionMonitor.start() { + SelectionActionDiagnostics.log("selection action monitor installation unavailable") + } + } else { + selectionActionMonitor.stop() + } + } + + private func handleSelectionActionCandidate(at point: SelectionPoint) { + guard !isStopping, !isMigrationMaintenanceActive else { return } + guard let sourceApp = NSWorkspace.shared.frontmostApplication, + sourceApp.processIdentifier != NSRunningApplication.current.processIdentifier + else { + return + } + + let bundleID = sourceApp.bundleIdentifier ?? "pid-\(sourceApp.processIdentifier)" + let request = PendingSelectionRead( + sourceProcessIdentifier: sourceApp.processIdentifier, + location: point + ) + SelectionActionDiagnostics.logRateLimited("candidate sourceApp=\(bundleID)") + handleSelectionActionEffects(selectionActionCoordinator.handle( + .candidateSelection(sourceAppBundleID: bundleID, mouseLocation: point) + ), pendingSelectionRead: request) + } + + private func handleSelectionActionCopyTrigger(_ pendingUserCopyRead: PendingUserCopyRead) { + guard !isStopping, !isMigrationMaintenanceActive else { return } + + handleSelectionActionEffects(selectionActionCoordinator.handle(.dismiss)) + let taskID = UUID() + selectionReadTaskID = taskID + let task = Task { [weak self] in + guard let self else { return } + defer { + self.selectionTaskRegistry.remove(id: taskID) + if self.selectionReadTaskID == taskID { + self.selectionReadTask = nil + self.selectionReadTaskID = nil + self.notifyWorkStateChange() + } + } + let handoffOutcome = await selectionClipboardReader.finishUserCopyHandoff( + pendingUserCopyRead.clipboardHandoff + ) + guard !Task.isCancelled, + !isStopping, + !isMigrationMaintenanceActive, + selectionReadTaskID == taskID + else { + return + } + switch handoffOutcome { + case .unobservedSyntheticAction: + SelectionActionDiagnostics.log("copy trigger ignored unobserved synthetic action") + return + case .noActiveRead, .restorationRelinquished, .completedWithoutPasteboardMutation: + break + } + let result = await selectionUserCopyReader.readCopiedText( + sourceProcessIdentifier: pendingUserCopyRead.sourceProcessIdentifier, + after: pendingUserCopyRead.clipboardHandoff.boundaryChangeCount + ) + guard !Task.isCancelled, + !isStopping, + !isMigrationMaintenanceActive, + selectionReadTaskID == taskID, + selectionSourceValidator.isCurrent(pendingUserCopyRead.sourceProcessIdentifier) + else { + return + } + guard case .success(let text) = result, !text.isEmpty else { + SelectionActionDiagnostics.log("copy trigger empty clipboard") + return + } + + SelectionActionDiagnostics.log("copy trigger showPanel length=\(text.count)") + panelDismissalPolicy.recordPanelShown(at: Date().timeIntervalSinceReferenceDate) + selectionActionMonitor.recordPanelShown() + currentSelectionText = text + currentTranslationText = "" + selectionPronunciationReturnState = .menu + selectionCopyFeedbackTask?.cancel() + selectionActionWindowController.showMenu(at: pendingUserCopyRead.location) + } + selectionReadTask = task + selectionTaskRegistry.register(task, id: taskID) + notifyWorkStateChange() + } + + private func handleSelectionActionEffects( + _ effects: [SelectionActionEffect], + pendingSelectionRead: PendingSelectionRead? = nil + ) { + guard !isStopping else { return } + for effect in effects { + switch effect { + case .scheduleRead(let delayMilliseconds): + guard let pendingSelectionRead else { continue } + SelectionActionDiagnostics.logRateLimited("effect scheduleRead delayMs=\(delayMilliseconds)") + selectionReadTask?.cancel() + let taskID = UUID() + selectionReadTaskID = taskID + let task = Task { [weak self] in + guard let self else { return } + defer { + self.selectionTaskRegistry.remove(id: taskID) + if self.selectionReadTaskID == taskID { + self.selectionReadTask = nil + self.selectionReadTaskID = nil + self.notifyWorkStateChange() + } + } + do { + try await Task.sleep(nanoseconds: UInt64(delayMilliseconds) * 1_000_000) + } catch { + return + } + guard !Task.isCancelled, !isStopping else { return } + await self.completeScheduledSelectionRead(pendingSelectionRead) + } + selectionReadTask = task + selectionTaskRegistry.register(task, id: taskID) + notifyWorkStateChange() + case .cancelRead: + SelectionActionDiagnostics.log("effect cancelRead") + selectionReadTask?.cancel() + selectionReadTask = nil + selectionReadTaskID = nil + notifyWorkStateChange() + case .hidePanel: + SelectionActionDiagnostics.log("effect hidePanel") + selectionActionWindowController.hidePanel() + notifyWorkStateChange() + case .cancelWork: + SelectionActionDiagnostics.log("effect cancelWork") + selectionTranslationTask?.cancel() + selectionTranslationTask = nil + selectionTranslationTaskID = nil + selectionTTSTask?.cancel() + selectionTTSTask = nil + selectionTTSTaskID = nil + selectionCopyFeedbackTask?.cancel() + speechPlaybackService.stop() + isSelectionSpeechPlaying = false + notifyWorkStateChange() + case .showPanel(let text, let location): + SelectionActionDiagnostics.log("effect showPanel length=\(text.count)") + panelDismissalPolicy.recordPanelShown(at: Date().timeIntervalSinceReferenceDate) + selectionActionMonitor.recordPanelShown() + currentSelectionText = text + currentTranslationText = "" + selectionPronunciationReturnState = .menu + selectionCopyFeedbackTask?.cancel() + selectionActionWindowController.showMenu(at: location) + case .showUnsupportedNotice: + SelectionActionDiagnostics.log("effect showUnsupportedNotice") + panelDismissalPolicy.recordPanelShown(at: Date().timeIntervalSinceReferenceDate) + selectionActionMonitor.recordPanelShown() + showSelectionUnsupportedNotice() + } + } + } + + private func handleSelectionDismissRequest(reason: String = "unknown", bypassingPanelGrace: Bool = false) { + guard !isStopping else { return } + guard panelDismissalPolicy.shouldDismiss( + at: Date().timeIntervalSinceReferenceDate, + bypassingGrace: bypassingPanelGrace + ) else { + SelectionActionDiagnostics.log("panel dismiss suppressed during visibility grace reason=\(reason)") + return + } + + forceDismissSelectionActions(reason: reason) + } + + func forceDismissSelectionActions(reason: String = "force") { + guard !isStopping else { return } + SelectionActionDiagnostics.log("force dismiss selection actions reason=\(reason)") + handleSelectionActionEffects(selectionActionCoordinator.handle(.dismiss)) + } + + private func diagnosticSummary(for result: SelectedTextReadResult) -> String { + switch result { + case .success(let text): + "success length=\(text.count)" + case .permissionDenied: + "permissionDenied" + case .emptySelection: + "emptySelection" + case .unsupported: + "unsupported" + case .missingFocusedElement: + "missingFocusedElement" + case .failed(let message): + "failed \(message)" + } + } + + private func completeScheduledSelectionRead(_ pendingSelectionRead: PendingSelectionRead) async { + guard !isStopping, !isMigrationMaintenanceActive else { return } + let config = (try? configStore.load()) ?? AppConfig.defaultConfig() + let configuredForceSelectionMode = config.selectionActions.forceSelectionMode + let forceSelectionMode: SelectionForceSelectionMode = if configuredForceSelectionMode == .menuCopyOnly, + config.selectionActions.allowsSimulatedCopyFallback { + .menuCopyThenShortcut + } else { + configuredForceSelectionMode + } + let readSelectedTextForAutomaticSelection = { + await self.readSelectedTextForAutomaticSelection( + pendingSelectionRead, + forceSelectionMode: forceSelectionMode + ) + } + let result = await readSelectedTextForAutomaticSelection() + guard !Task.isCancelled, !isStopping, !isMigrationMaintenanceActive else { return } + SelectionActionDiagnostics.logRateLimited("read result \(diagnosticSummary(for: result))") + handleSelectionActionEffects(selectionActionCoordinator.handle(.readCompleted(result))) + } + + private func readSelectedTextForAutomaticSelection( + _ pendingSelectionRead: PendingSelectionRead, + forceSelectionMode: SelectionForceSelectionMode + ) async -> SelectedTextReadResult { + await selectionReadPipeline.readSelectedText( + sourceProcessIdentifier: pendingSelectionRead.sourceProcessIdentifier, + mouseLocation: pendingSelectionRead.location, + forceSelectionMode: forceSelectionMode + ) + } + + private func showSelectionUnsupportedNotice() { + let point = SelectionPoint(x: NSEvent.mouseLocation.x, y: NSEvent.mouseLocation.y) + currentSelectionText = "" + currentTranslationText = "" + selectionActionWindowController.showNotice(L10n.text("selection.action.unsupportedMessage"), at: point) + } + + private func translateCurrentSelection() { + guard !isStopping, !isMigrationMaintenanceActive else { return } + let sourceText = currentSelectionText + guard !sourceText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return + } + + selectionTranslationTask?.cancel() + let taskID = UUID() + selectionTranslationTaskID = taskID + selectionActionWindowController.showTranslating() + let task = Task { [weak self] in + guard let self else { return } + defer { + self.selectionTaskRegistry.remove(id: taskID) + if self.selectionTranslationTaskID == taskID { + self.selectionTranslationTask = nil + self.selectionTranslationTaskID = nil + self.notifyWorkStateChange() + } + } + do { + let config = try self.configStore.load() + let providerPreset = config.resolvedProviderPreset + let providerID = config.providerID + let apiKeyStore = self.apiKeyStore + let provider = LLMProviderFactory.provider(for: providerPreset) { + guard let apiKey = apiKeyStore.loadAPIKey(forProviderID: providerID), + !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + throw TransformationError.provider(L10n.format("popover.error.missingAPIKey", providerPreset.name)) + } + return apiKey + } + let targetLanguageName = config.selectionActions.translationLanguage.resolvedPromptTargetName( + interfaceLanguageCode: L10n.resolvedLanguage.localeIdentifier + ) + let systemPrompt = config.selectionActions.effectiveTranslationPrompt( + targetLanguageName: targetLanguageName + ) + let service = CachedSelectionTranslationService( + service: SelectionTranslationService(provider: provider), + cache: selectionTranslationCache + ) + let translated = try await service.translate( + sourceText: sourceText, + targetLanguageName: targetLanguageName, + systemPrompt: systemPrompt, + model: config.model, + providerID: providerID, + timeoutSeconds: config.timeoutSeconds + ) + guard !Task.isCancelled, !isStopping, !isMigrationMaintenanceActive else { return } + try? self.historyStore.append(HistoryItem( + source: .selection, + inputText: sourceText, + outputText: translated, + modeName: nil, + targetLanguageName: targetLanguageName, + model: config.model, + metadata: ["providerID": providerID] + )) + await MainActor.run { + guard !self.isStopping else { return } + self.currentTranslationText = translated + self.selectionActionWindowController.showTranslation(translated) + } + } catch is CancellationError { + } catch { + if !self.isStopping, !self.isMigrationMaintenanceActive { + self.selectionActionWindowController.showTranslationError( + L10n.text("selection.action.translationFailed") + ) + } + } + } + selectionTranslationTask = task + selectionTaskRegistry.register(task, id: taskID) + notifyWorkStateChange() + } + + private func pronounceCurrentSelection() { + pronounceSelectionText(currentSelectionText, returnState: .menu) + } + + private func pronounceOriginalFromTranslation() { + pronounceSelectionText( + currentSelectionText, + returnState: .translationResult, + loadingFeedback: .loadingOriginalPronunciation, + playingFeedback: .playingOriginalPronunciation + ) + } + + private func pronounceCurrentTranslation() { + pronounceSelectionText( + currentTranslationText, + returnState: .translationResult, + loadingFeedback: .loadingTranslationPronunciation, + playingFeedback: .playingTranslationPronunciation + ) + } + + private func pronounceSelectionText( + _ text: String, + returnState: SelectionPronunciationReturnState, + loadingFeedback: SelectionActionFeedback? = nil, + playingFeedback: SelectionActionFeedback? = nil + ) { + guard !isStopping, !isMigrationMaintenanceActive else { return } + let sourceText = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !sourceText.isEmpty else { return } + + selectionPronunciationReturnState = returnState + selectionTTSTask?.cancel() + let taskID = UUID() + selectionTTSTaskID = taskID + if returnState == .translationResult, !currentTranslationText.isEmpty { + selectionActionWindowController.showTranslation(currentTranslationText, feedback: loadingFeedback) + } else { + selectionActionWindowController.showPreparingPronunciation() + } + let task = Task { [weak self] in + guard let self else { return } + defer { + self.selectionTaskRegistry.remove(id: taskID) + if self.selectionTTSTaskID == taskID { + self.selectionTTSTask = nil + self.selectionTTSTaskID = nil + self.notifyWorkStateChange() + } + } + do { + let provider = OpenAITTSProvider(apiKeyProvider: { [apiKeyStore] in + guard let apiKey = apiKeyStore.loadAPIKey(forProviderID: LLMProviderPreset.openAI.id), + !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + throw OpenAITTSError.provider(L10n.text("selection.action.missingOpenAIKey")) + } + return apiKey + }) + let config = (try? self.configStore.load()) ?? AppConfig.defaultConfig() + let audioData = try await provider.speechAudio(OpenAITTSRequest( + input: sourceText, + voice: config.selectionActions.pronunciationVoice.rawValue, + speed: config.selectionActions.pronunciationSpeed, + timeoutSeconds: config.timeoutSeconds + )) + guard !Task.isCancelled, !isStopping, !isMigrationMaintenanceActive else { return } + await MainActor.run { + guard !self.isStopping else { return } + do { + try self.speechPlaybackService.play(audioData: audioData) + self.isSelectionSpeechPlaying = true + self.notifyWorkStateChange() + if returnState == .menu { + self.selectionActionWindowController.showPlayingPronunciation() + } else if !self.currentTranslationText.isEmpty { + self.selectionActionWindowController.showTranslation( + self.currentTranslationText, + feedback: playingFeedback + ) + } + } catch { + self.isSelectionSpeechPlaying = false + self.showSelectionPronunciationError(returnState: returnState) + } + } + } catch is CancellationError { + } catch { + if !self.isStopping, !self.isMigrationMaintenanceActive { + self.showSelectionPronunciationError(returnState: returnState) + } + } + } + selectionTTSTask = task + selectionTaskRegistry.register(task, id: taskID) + notifyWorkStateChange() + } + + private func restoreSelectionPronunciationReturnState() { + guard !isStopping, !isMigrationMaintenanceActive else { return } + switch selectionPronunciationReturnState { + case .menu: + selectionActionWindowController.restoreMenu() + case .translationResult: + if currentTranslationText.isEmpty { + selectionActionWindowController.restoreMenu() + } else { + selectionActionWindowController.showTranslation(currentTranslationText) + } + } + } + + private func showSelectionPronunciationError(returnState: SelectionPronunciationReturnState) { + guard !isStopping else { return } + let message = L10n.text("selection.action.pronunciationFailed") + switch returnState { + case .menu: + selectionActionWindowController.showPronunciationError(message) + case .translationResult: + if currentTranslationText.isEmpty { + selectionActionWindowController.showPronunciationError(message) + } else { + selectionActionWindowController.showTranslation(currentTranslationText, errorMessage: message) + } + } + } + + private func copyCurrentTranslation() { + guard !isStopping, !isMigrationMaintenanceActive, !currentTranslationText.isEmpty else { + return + } + + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(currentTranslationText, forType: .string) + selectionCopyFeedbackTask?.cancel() + selectionActionWindowController.showTranslation(currentTranslationText, feedback: .copiedTranslation) + let taskID = UUID() + selectionCopyFeedbackTaskID = taskID + let task = Task { [weak self, copiedText = currentTranslationText] in + defer { + if let self { + self.selectionTaskRegistry.remove(id: taskID) + if self.selectionCopyFeedbackTaskID == taskID { + self.selectionCopyFeedbackTask = nil + self.selectionCopyFeedbackTaskID = nil + } + } + } + try? await Task.sleep(for: .milliseconds(900)) + await MainActor.run { + guard let self, + !self.isStopping, + self.currentTranslationText == copiedText + else { + return + } + self.selectionActionWindowController.showTranslation(self.currentTranslationText) + } + } + selectionCopyFeedbackTask = task + selectionTaskRegistry.register(task, id: taskID) + } +} diff --git a/Sources/InkletApp/SettingsControls.swift b/Sources/InkletApp/SettingsControls.swift new file mode 100644 index 0000000..b543f64 --- /dev/null +++ b/Sources/InkletApp/SettingsControls.swift @@ -0,0 +1,664 @@ +import AppKit +import Carbon +import SwiftUI +import InkletCore + +struct PromptModeTableView: NSViewRepresentable { + let modes: [PromptMode] + @Binding var selectedModeID: String + let canDelete: Bool + let onMove: (Int, Int) -> Void + let onToggleVisibility: (String) -> Void + let onDelete: (String) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator( + modes: modes, + selectedModeID: $selectedModeID, + canDelete: canDelete, + onMove: onMove, + onToggleVisibility: onToggleVisibility, + onDelete: onDelete + ) + } + + func makeNSView(context: Context) -> NSScrollView { + let tableView = NSTableView() + let column = NSTableColumn(identifier: Coordinator.columnIdentifier) + column.resizingMask = .autoresizingMask + tableView.addTableColumn(column) + tableView.headerView = nil + tableView.rowHeight = 38 + tableView.intercellSpacing = NSSize(width: 0, height: 2) + tableView.backgroundColor = .clear + tableView.selectionHighlightStyle = .none + tableView.usesAlternatingRowBackgroundColors = false + tableView.delegate = context.coordinator + tableView.dataSource = context.coordinator + tableView.registerForDraggedTypes([Coordinator.dragPasteboardType]) + + let scrollView = PromptModeTableScrollView() + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = false + scrollView.hasHorizontalScroller = false + scrollView.horizontalScrollElasticity = .none + scrollView.autohidesScrollers = true + scrollView.scrollerStyle = .overlay + scrollView.documentView = tableView + context.coordinator.tableView = tableView + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + context.coordinator.modes = modes + context.coordinator.selectedModeID = $selectedModeID + context.coordinator.canDelete = canDelete + context.coordinator.onMove = onMove + context.coordinator.onToggleVisibility = onToggleVisibility + context.coordinator.onDelete = onDelete + + guard let tableView = scrollView.documentView as? NSTableView else { + return + } + + PromptModeTableScrollView.syncTableWidth(tableView, to: scrollView.contentView.bounds.width) + + tableView.reloadData() + context.coordinator.syncSelection() + } + + final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate { + static let columnIdentifier = NSUserInterfaceItemIdentifier("PromptModeColumn") + static let rowIdentifier = NSUserInterfaceItemIdentifier("PromptModeRow") + static let dragPasteboardType = NSPasteboard.PasteboardType("com.inklet.prompt-mode") + + var modes: [PromptMode] + var selectedModeID: Binding + var canDelete: Bool + var onMove: (Int, Int) -> Void + var onToggleVisibility: (String) -> Void + var onDelete: (String) -> Void + weak var tableView: NSTableView? + private var isSyncingSelection = false + + init( + modes: [PromptMode], + selectedModeID: Binding, + canDelete: Bool, + onMove: @escaping (Int, Int) -> Void, + onToggleVisibility: @escaping (String) -> Void, + onDelete: @escaping (String) -> Void + ) { + self.modes = modes + self.selectedModeID = selectedModeID + self.canDelete = canDelete + self.onMove = onMove + self.onToggleVisibility = onToggleVisibility + self.onDelete = onDelete + } + + func numberOfRows(in tableView: NSTableView) -> Int { + modes.count + } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + guard modes.indices.contains(row) else { + return nil + } + + let cell = (tableView.makeView(withIdentifier: Self.rowIdentifier, owner: self) as? PromptModeTableCellView) + ?? PromptModeTableCellView(identifier: Self.rowIdentifier) + let mode = modes[row] + cell.configure( + mode: mode, + isSelected: mode.id == selectedModeID.wrappedValue, + canDelete: canDelete, + target: self + ) + return cell + } + + func tableViewSelectionDidChange(_ notification: Notification) { + guard !isSyncingSelection, + let tableView, + modes.indices.contains(tableView.selectedRow) + else { + return + } + + selectedModeID.wrappedValue = modes[tableView.selectedRow].id + } + + func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? { + guard modes.indices.contains(row) else { + return nil + } + + let item = NSPasteboardItem() + item.setString(modes[row].id, forType: Self.dragPasteboardType) + return item + } + + func tableView( + _ tableView: NSTableView, + validateDrop info: NSDraggingInfo, + proposedRow row: Int, + proposedDropOperation dropOperation: NSTableView.DropOperation + ) -> NSDragOperation { + tableView.setDropRow(row, dropOperation: .above) + return .move + } + + func tableView( + _ tableView: NSTableView, + acceptDrop info: NSDraggingInfo, + row: Int, + dropOperation: NSTableView.DropOperation + ) -> Bool { + guard let draggedModeID = info.draggingPasteboard.string(forType: Self.dragPasteboardType), + let sourceIndex = modes.firstIndex(where: { $0.id == draggedModeID }) + else { + return false + } + + let destination = max(0, min(row, modes.count)) + guard sourceIndex != destination, sourceIndex + 1 != destination else { + return false + } + + onMove(sourceIndex, destination) + selectedModeID.wrappedValue = draggedModeID + return true + } + + @MainActor + func syncSelection() { + guard let tableView else { + return + } + + isSyncingSelection = true + defer { isSyncingSelection = false } + + if let selectedIndex = modes.firstIndex(where: { $0.id == selectedModeID.wrappedValue }) { + tableView.selectRowIndexes(IndexSet(integer: selectedIndex), byExtendingSelection: false) + } else { + tableView.deselectAll(nil) + } + } + + @MainActor + @objc func toggleVisibility(_ sender: NSButton) { + guard let modeID = sender.identifier?.rawValue else { + return + } + onToggleVisibility(modeID) + } + + @MainActor + @objc func deleteMode(_ sender: NSButton) { + guard let modeID = sender.identifier?.rawValue else { + return + } + onDelete(modeID) + } + } +} + +private final class PromptModeTableScrollView: NSScrollView { + override func layout() { + super.layout() + guard let tableView = documentView as? NSTableView else { + return + } + Self.syncTableWidth(tableView, to: contentView.bounds.width) + } + + static func syncTableWidth(_ tableView: NSTableView, to width: CGFloat) { + let width = max(width, 1) + guard tableView.tableColumns.first?.width != width else { + return + } + + tableView.tableColumns.first?.width = width + var frame = tableView.frame + frame.size.width = width + tableView.frame = frame + } +} + +private final class PromptModeTableCellView: NSTableCellView { + private static let rowWidth: CGFloat = 190 + private let rowContainer = NSView() + private let selectionBackground = NSView() + private let dragHandle = NSImageView() + private let titleField = NSTextField(labelWithString: "") + private let visibilityButton = NSButton() + private let deleteButton = NSButton() + private var trackingArea: NSTrackingArea? + private var isHovered = false + private var isCellSelected = false + private var isModeVisible = true + private var canDeleteMode = true + + init(identifier: NSUserInterfaceItemIdentifier) { + super.init(frame: .zero) + self.identifier = identifier + buildView() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure( + mode: PromptMode, + isSelected: Bool, + canDelete: Bool, + target: PromptModeTableView.Coordinator + ) { + let symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .regular) + isCellSelected = isSelected + isModeVisible = mode.isVisible + canDeleteMode = canDelete + titleField.stringValue = mode.name.isEmpty ? L10n.text("settings.mode.untitled") : mode.localizedName + titleField.font = .systemFont(ofSize: 13, weight: isSelected ? .semibold : .regular) + visibilityButton.image = NSImage(systemSymbolName: mode.isVisible ? "eye" : "eye.slash", accessibilityDescription: nil)? + .withSymbolConfiguration(symbolConfiguration) + visibilityButton.toolTip = mode.isVisible ? L10n.text("settings.mode.visible") : L10n.text("settings.mode.hidden") + visibilityButton.identifier = NSUserInterfaceItemIdentifier(mode.id) + visibilityButton.target = target + visibilityButton.action = #selector(PromptModeTableView.Coordinator.toggleVisibility(_:)) + + deleteButton.identifier = NSUserInterfaceItemIdentifier(mode.id) + deleteButton.target = target + deleteButton.action = #selector(PromptModeTableView.Coordinator.deleteMode(_:)) + deleteButton.isEnabled = canDelete + deleteButton.toolTip = L10n.text("settings.mode.delete") + applyAppearance() + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let trackingArea { + removeTrackingArea(trackingArea) + } + let area = NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], + owner: self + ) + trackingArea = area + addTrackingArea(area) + } + + override func mouseEntered(with event: NSEvent) { + isHovered = true + applyAppearance() + } + + override func mouseExited(with event: NSEvent) { + isHovered = false + applyAppearance() + } + + private func buildView() { + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + rowContainer.translatesAutoresizingMaskIntoConstraints = false + addSubview(rowContainer) + + selectionBackground.wantsLayer = true + selectionBackground.layer?.cornerRadius = 12 + selectionBackground.layer?.cornerCurve = .continuous + selectionBackground.layer?.backgroundColor = NSColor.clear.cgColor + selectionBackground.translatesAutoresizingMaskIntoConstraints = false + rowContainer.addSubview(selectionBackground) + + let symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .regular) + dragHandle.image = NSImage(systemSymbolName: "line.3.horizontal", accessibilityDescription: nil)? + .withSymbolConfiguration(symbolConfiguration) + dragHandle.contentTintColor = NSColor(white: 1, alpha: 0.18) + dragHandle.setContentHuggingPriority(.required, for: .horizontal) + dragHandle.setContentCompressionResistancePriority(.required, for: .horizontal) + dragHandle.toolTip = L10n.text("settings.mode.dragToSort") + dragHandle.translatesAutoresizingMaskIntoConstraints = false + + titleField.lineBreakMode = .byTruncatingTail + titleField.textColor = NSColor(white: 0.74, alpha: 1) + titleField.setContentHuggingPriority(.defaultLow, for: .horizontal) + titleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + titleField.translatesAutoresizingMaskIntoConstraints = false + + configureIconButton(visibilityButton) + configureIconButton(deleteButton) + deleteButton.image = NSImage(systemSymbolName: "trash", accessibilityDescription: nil)? + .withSymbolConfiguration(symbolConfiguration) + deleteButton.contentTintColor = NSColor(white: 1, alpha: 0.32) + + rowContainer.addSubview(dragHandle) + rowContainer.addSubview(titleField) + rowContainer.addSubview(visibilityButton) + rowContainer.addSubview(deleteButton) + + NSLayoutConstraint.activate([ + rowContainer.widthAnchor.constraint(equalToConstant: Self.rowWidth), + rowContainer.centerXAnchor.constraint(equalTo: centerXAnchor, constant: -16), + rowContainer.topAnchor.constraint(equalTo: topAnchor, constant: 3), + rowContainer.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -3), + selectionBackground.leadingAnchor.constraint(equalTo: rowContainer.leadingAnchor), + selectionBackground.trailingAnchor.constraint(equalTo: rowContainer.trailingAnchor), + selectionBackground.topAnchor.constraint(equalTo: rowContainer.topAnchor), + selectionBackground.bottomAnchor.constraint(equalTo: rowContainer.bottomAnchor), + dragHandle.widthAnchor.constraint(equalToConstant: 18), + visibilityButton.widthAnchor.constraint(equalToConstant: 24), + visibilityButton.heightAnchor.constraint(equalToConstant: 24), + deleteButton.widthAnchor.constraint(equalToConstant: 24), + deleteButton.heightAnchor.constraint(equalToConstant: 24), + dragHandle.leadingAnchor.constraint(equalTo: rowContainer.leadingAnchor, constant: 14), + dragHandle.centerYAnchor.constraint(equalTo: rowContainer.centerYAnchor), + titleField.leadingAnchor.constraint(equalTo: dragHandle.trailingAnchor, constant: 10), + titleField.trailingAnchor.constraint(equalTo: visibilityButton.leadingAnchor, constant: -8), + titleField.centerYAnchor.constraint(equalTo: rowContainer.centerYAnchor), + visibilityButton.trailingAnchor.constraint(equalTo: deleteButton.leadingAnchor, constant: -4), + visibilityButton.centerYAnchor.constraint(equalTo: rowContainer.centerYAnchor), + deleteButton.trailingAnchor.constraint(equalTo: rowContainer.trailingAnchor, constant: -10), + deleteButton.centerYAnchor.constraint(equalTo: rowContainer.centerYAnchor) + ]) + } + + private func applyAppearance() { + let isDark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + selectionBackground.layer?.backgroundColor = isCellSelected + ? NSColor.controlAccentColor.withAlphaComponent(isDark ? 0.20 : 0.14).cgColor + : (isHovered ? NSColor.labelColor.withAlphaComponent(isDark ? 0.04 : 0.045).cgColor : NSColor.clear.cgColor) + titleField.textColor = isCellSelected + ? NSColor.controlAccentColor + : (isHovered ? .labelColor : .secondaryLabelColor) + dragHandle.contentTintColor = isHovered || isCellSelected + ? .secondaryLabelColor + : .tertiaryLabelColor + visibilityButton.contentTintColor = isModeVisible + ? .secondaryLabelColor + : .tertiaryLabelColor + visibilityButton.alphaValue = isHovered || isCellSelected ? 1 : 0.78 + deleteButton.contentTintColor = canDeleteMode + ? .secondaryLabelColor + : .tertiaryLabelColor + deleteButton.alphaValue = isHovered || isCellSelected ? 1 : 0.64 + } + + private func configureIconButton(_ button: NSButton) { + button.translatesAutoresizingMaskIntoConstraints = false + button.isBordered = false + button.bezelStyle = .regularSquare + button.setButtonType(.momentaryChange) + button.imagePosition = .imageOnly + button.focusRingType = .none + button.setContentHuggingPriority(.required, for: .horizontal) + button.setContentCompressionResistancePriority(.required, for: .horizontal) + } +} + +struct SettingsPromptTextView: NSViewRepresentable { + @Binding var text: String + + func makeCoordinator() -> Coordinator { + Coordinator(text: $text) + } + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSScrollView() + scrollView.drawsBackground = false + scrollView.borderType = .noBorder + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.scrollerStyle = .overlay + scrollView.contentInsets = NSEdgeInsetsZero + scrollView.automaticallyAdjustsContentInsets = false + scrollView.horizontalScrollElasticity = .none + + let textView = NSTextView() + textView.string = text + textView.delegate = context.coordinator + textView.isEditable = true + textView.isSelectable = true + textView.isRichText = false + textView.importsGraphics = false + textView.allowsUndo = true + textView.usesFindBar = false + textView.drawsBackground = false + textView.backgroundColor = .clear + textView.font = .systemFont(ofSize: 13) + textView.textColor = .labelColor + textView.insertionPointColor = .controlAccentColor + textView.textContainerInset = .zero + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.containerSize = NSSize( + width: scrollView.contentSize.width, + height: CGFloat.greatestFiniteMagnitude + ) + textView.minSize = NSSize(width: 0, height: 0) + textView.maxSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.autoresizingMask = [.width] + + scrollView.documentView = textView + context.coordinator.textView = textView + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? NSTextView else { + return + } + + context.coordinator.text = $text + context.coordinator.textView = textView + textView.font = .systemFont(ofSize: 13) + textView.textColor = .labelColor + textView.insertionPointColor = .controlAccentColor + + if !textView.hasMarkedText(), textView.string != text { + textView.string = text + } + } + + final class Coordinator: NSObject, NSTextViewDelegate, @unchecked Sendable { + var text: Binding + weak var textView: NSTextView? + + init(text: Binding) { + self.text = text + super.init() + } + + @MainActor + func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { + return + } + + text.wrappedValue = textView.string + } + } +} + +struct HotkeyRecorderField: NSViewRepresentable { + @Binding var hotkey: String + + func makeCoordinator() -> Coordinator { + Coordinator(hotkey: $hotkey) + } + + func makeNSView(context: Context) -> RecorderView { + let view = RecorderView() + view.onChange = { context.coordinator.hotkey.wrappedValue = $0 } + view.hotkey = hotkey + return view + } + + func updateNSView(_ nsView: RecorderView, context: Context) { + nsView.hotkey = hotkey + nsView.onChange = { context.coordinator.hotkey.wrappedValue = $0 } + nsView.updateDisplay() + } + + final class Coordinator { + var hotkey: Binding + + init(hotkey: Binding) { + self.hotkey = hotkey + } + } + + final class RecorderView: NSView { + var hotkey = "" + var onChange: ((String) -> Void)? + private var isRecording = false + private let label = NSTextField(labelWithString: "") + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.cornerRadius = 8 + layer?.borderWidth = 1 + label.translatesAutoresizingMaskIntoConstraints = false + label.alignment = .center + label.font = .monospacedSystemFont(ofSize: 13, weight: .medium) + addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), + label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), + label.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + updateDisplay() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var acceptsFirstResponder: Bool { true } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + updateDisplay() + } + + override func mouseDown(with event: NSEvent) { + isRecording = true + publishRecordingState() + window?.makeFirstResponder(self) + updateDisplay() + } + + override func resignFirstResponder() -> Bool { + isRecording = false + publishRecordingState() + updateDisplay() + return super.resignFirstResponder() + } + + override func keyDown(with event: NSEvent) { + guard isRecording else { + super.keyDown(with: event) + return + } + + if event.keyCode == UInt16(kVK_Escape) { + isRecording = false + publishRecordingState() + window?.makeFirstResponder(nil) + updateDisplay() + return + } + + guard let recordedHotkey = recordedHotkey(from: event) else { + NSSound.beep() + return + } + + hotkey = recordedHotkey.displayString + onChange?(hotkey) + isRecording = false + publishRecordingState() + window?.makeFirstResponder(nil) + updateDisplay() + } + + override func flagsChanged(with event: NSEvent) { + if isRecording { + updateDisplay(pressedModifiers: modifierDisplayString(from: event.modifierFlags)) + } else { + super.flagsChanged(with: event) + } + } + + func updateDisplay(pressedModifiers: String = "") { + let backgroundColor = isRecording ? NSColor.controlAccentColor.withAlphaComponent(0.16) : NSColor.controlBackgroundColor + layer?.backgroundColor = resolvedCGColor(backgroundColor) + layer?.borderColor = resolvedCGColor(isRecording ? .controlAccentColor : .separatorColor) + label.textColor = isRecording ? .controlAccentColor : .labelColor + if isRecording { + label.stringValue = pressedModifiers.isEmpty + ? L10n.text("settings.hotkey.recording") + : "\(pressedModifiers)\(L10n.text("settings.hotkey.pressKey"))" + } else { + label.stringValue = hotkey.isEmpty ? L10n.text("settings.hotkey.record") : hotkey + } + } + + private func resolvedCGColor(_ color: NSColor) -> CGColor { + var resolvedColor = color.cgColor + effectiveAppearance.performAsCurrentDrawingAppearance { + resolvedColor = color.cgColor + } + return resolvedColor + } + + private func recordedHotkey(from event: NSEvent) -> Hotkey? { + var modifiers: Hotkey.Modifier = [] + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if flags.contains(.command) { modifiers.insert(.command) } + if flags.contains(.option) { modifiers.insert(.option) } + if flags.contains(.control) { modifiers.insert(.control) } + if flags.contains(.shift) { modifiers.insert(.shift) } + + guard !modifiers.isEmpty, + modifiers != [.shift], + Hotkey.displayName(for: UInt32(event.keyCode)) != nil + else { + return nil + } + + return Hotkey(keyCode: UInt32(event.keyCode), modifiers: modifiers) + } + + private func modifierDisplayString(from flags: NSEvent.ModifierFlags) -> String { + var modifiers: Hotkey.Modifier = [] + let filteredFlags = flags.intersection(.deviceIndependentFlagsMask) + if filteredFlags.contains(.command) { modifiers.insert(.command) } + if filteredFlags.contains(.option) { modifiers.insert(.option) } + if filteredFlags.contains(.control) { modifiers.insert(.control) } + if filteredFlags.contains(.shift) { modifiers.insert(.shift) } + return modifiers.displayString + } + + private func publishRecordingState() { + NotificationCenter.default.post( + name: .hotkeyRecordingDidChange, + object: nil, + userInfo: ["isRecording": isRecording] + ) + } + } +} diff --git a/Sources/InkletApp/SettingsView.swift b/Sources/InkletApp/SettingsView.swift index a61f692..12652dc 100644 --- a/Sources/InkletApp/SettingsView.swift +++ b/Sources/InkletApp/SettingsView.swift @@ -1,620 +1,7 @@ import AppKit -import Carbon -import Combine import SwiftUI import InkletCore -private enum PronunciationPreviewState: Equatable { - case loading(SelectionPronunciationVoice) - case playing(SelectionPronunciationVoice) - - func matches(_ voice: SelectionPronunciationVoice) -> Bool { - switch self { - case .loading(let activeVoice), .playing(let activeVoice): - activeVoice == voice - } - } -} - -@MainActor -final class SettingsViewModel: ObservableObject { - @Published var config: AppConfig - @Published var message: String - @Published var providerAPIKey: String - @Published var selectedPromptModeID: String - @Published var interfaceLanguage: InterfaceLanguage - @Published var cachedProviderModels: [String: [String]] - @Published var isRefreshingModelCatalog: Bool - @Published var isEditingCustomModel: Bool - @Published var microphoneOptions: [MicrophoneDeviceOption] - @Published fileprivate var pronunciationPreviewState: PronunciationPreviewState? - @Published var permissionRefreshID: UUID - @Published var historyItems: [HistoryItem] - @Published var historyFilter: HistorySource? - @Published private(set) var isMigrationMaintenanceActive = false - @Published private(set) var isMigrationWorkflowIdle = true - - private let configStore: UserDefaultsConfigStore - private let apiKeyStore: LocalAPIKeyStore - private let modelCatalogService: ModelCatalogService - private let historyStore: any HistoryStore - private let microphoneDeviceCatalog: MicrophoneDeviceCatalog - private let pronunciationPreviewPlaybackService: SpeechPlaybackService - private var autoSaveCancellable: AnyCancellable? - private var pronunciationPreviewTasks: [UUID: Task] = [:] - private var pronunciationPreviewTaskID: UUID? - private var modelCatalogRefreshTask: Task? - private var modelCatalogRefreshTaskID: UUID? - private var savedConfig: AppConfig - private var savedProviderAPIKey: String - private var savedInterfaceLanguage: InterfaceLanguage - - static let customModelMenuID = "__custom_model__" - - init( - configStore: UserDefaultsConfigStore = UserDefaultsConfigStore(), - apiKeyStore: LocalAPIKeyStore = LocalAPIKeyStore(), - modelCatalogService: ModelCatalogService = ModelCatalogService(), - historyStore: any HistoryStore = JSONLHistoryStore(), - microphoneDeviceCatalog: MicrophoneDeviceCatalog = MicrophoneDeviceCatalog() - ) { - var loadedConfig = (try? configStore.load()) ?? AppConfig.defaultConfig() - self.configStore = configStore - self.apiKeyStore = apiKeyStore - self.modelCatalogService = modelCatalogService - self.historyStore = historyStore - self.microphoneDeviceCatalog = microphoneDeviceCatalog - self.pronunciationPreviewPlaybackService = SpeechPlaybackService() - loadedConfig.providerID = LLMProviderPreset.openAI.id - self.config = loadedConfig - self.savedConfig = loadedConfig - self.message = "" - self.interfaceLanguage = InkletLanguageStore.selectedLanguage - self.savedInterfaceLanguage = InkletLanguageStore.selectedLanguage - let loadedProviderAPIKey = apiKeyStore.loadAPIKey(forProviderID: LLMProviderPreset.openAI.id) ?? "" - self.providerAPIKey = loadedProviderAPIKey - self.savedProviderAPIKey = loadedProviderAPIKey - self.selectedPromptModeID = loadedConfig.promptModes.sorted { $0.sortOrder < $1.sortOrder }.first?.id - ?? PromptMode.translateToEnglishID - self.cachedProviderModels = Dictionary( - uniqueKeysWithValues: LLMProviderPreset.all.compactMap { preset in - guard let modelIDs = modelCatalogService.cachedModelIDs(for: preset.id) else { - return nil - } - return (preset.id, modelIDs) - } - ) - self.isRefreshingModelCatalog = false - self.isEditingCustomModel = false - self.microphoneOptions = microphoneDeviceCatalog.options() - self.pronunciationPreviewState = nil - self.permissionRefreshID = UUID() - self.historyItems = (try? historyStore.load()).map { Array($0.reversed()) } ?? [] - self.historyFilter = nil - - self.pronunciationPreviewPlaybackService.onFinish = { [weak self] in - self?.pronunciationPreviewState = nil - self?.refreshMigrationWorkflowIdle() - } - installAutoSave() - } - - var selectedProvider: LLMProviderPreset { - LLMProviderPreset.openAI - } - - var filteredHistoryItems: [HistoryItem] { - guard let historyFilter else { - return historyItems - } - return historyItems.filter { $0.source == historyFilter } - } - - var isCustomOpenAICompatibleProvider: Bool { - false - } - - var selectedProviderModelOptions: [String] { - guard !isCustomOpenAICompatibleProvider else { - return [] - } - - var seen = Set() - var options: [String] = [] - - for modelID in cachedProviderModels[LLMProviderPreset.openAI.id] ?? [] { - if seen.insert(modelID).inserted { - options.append(modelID) - } - } - - if seen.insert(selectedProvider.defaultModel).inserted { - options.insert(selectedProvider.defaultModel, at: 0) - } - - return options - } - - var selectedModelMenuValue: String { - if isEditingCustomModel { - return Self.customModelMenuID - } - - return selectedProviderModelOptions.contains(config.model) ? config.model : Self.customModelMenuID - } - - var shouldShowCustomModelField: Bool { - isCustomOpenAICompatibleProvider || selectedModelMenuValue == Self.customModelMenuID - } - - var selectedModelIsDefault: Bool { - config.model.trimmingCharacters(in: .whitespacesAndNewlines) == selectedProvider.defaultModel - } - - var selectedPromptModeIndex: Int? { - config.promptModes.firstIndex { $0.id == selectedPromptModeID } - } - - var orderedPromptModes: [PromptMode] { - config.promptModes.sorted { lhs, rhs in - if lhs.sortOrder == rhs.sortOrder { - return lhs.name < rhs.name - } - return lhs.sortOrder < rhs.sortOrder - } - } - - var isAccessibilityTrusted: Bool { - AccessibilityPermissionService().isTrusted - } - - func refreshPermissions() { - permissionRefreshID = UUID() - } - - func reloadHistory() { - do { - historyItems = Array(try historyStore.load().reversed()) - } catch { - historyItems = [] - message = L10n.text("settings.history.error.loadFailed") - } - } - - func clearHistory() { - guard !isMigrationMaintenanceActive else { return } - do { - try historyStore.clear() - historyItems = [] - message = L10n.text("settings.history.cleared") - } catch { - message = L10n.text("settings.history.error.clearFailed") - } - } - - func refreshMicrophoneOptions() { - microphoneOptions = microphoneDeviceCatalog.options() - if let microphoneDeviceID = config.voiceInput.microphoneDeviceID, - !microphoneOptions.contains(where: { $0.deviceID == microphoneDeviceID }) { - config.voiceInput.microphoneDeviceID = nil - } - } - - var selectedMicrophoneMenuID: String { - get { - guard let microphoneDeviceID = config.voiceInput.microphoneDeviceID, - microphoneOptions.contains(where: { $0.deviceID == microphoneDeviceID }) - else { - return MicrophoneDeviceOption.systemDefaultID - } - return microphoneDeviceID - } - set { - config.voiceInput.microphoneDeviceID = newValue == MicrophoneDeviceOption.systemDefaultID ? nil : newValue - } - } - - func modelMenuTitle(for modelID: String) -> String { - if modelID == config.model, !selectedModelIsDefault { - return "\(modelID) *" - } - return modelID - } - - func selectModelMenuValue(_ value: String) { - if value == Self.customModelMenuID { - isEditingCustomModel = true - return - } - - isEditingCustomModel = false - config.model = value - save() - } - - func previewPronunciationVoice() { - guard !isMigrationMaintenanceActive else { return } - let voice = config.selectionActions.pronunciationVoice - let speed = config.selectionActions.pronunciationSpeed - if let pronunciationPreviewTaskID { - pronunciationPreviewTasks[pronunciationPreviewTaskID]?.cancel() - } - pronunciationPreviewPlaybackService.stop() - pronunciationPreviewState = .loading(voice) - let taskID = UUID() - pronunciationPreviewTaskID = taskID - - let task = Task { [weak self] in - defer { - self?.pronunciationPreviewTasks[taskID] = nil - if self?.pronunciationPreviewTaskID == taskID { - self?.pronunciationPreviewTaskID = nil - } - self?.refreshMigrationWorkflowIdle() - } - do { - guard let self else { return } - let provider = OpenAITTSProvider(apiKeyProvider: { [apiKeyStore] in - guard let apiKey = apiKeyStore.loadAPIKey(forProviderID: LLMProviderPreset.openAI.id), - !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { - throw OpenAITTSError.provider(L10n.text("selection.action.missingOpenAIKey")) - } - return apiKey - }) - let audioData = try await provider.speechAudio(OpenAITTSRequest( - input: SelectionPronunciationVoice.previewText( - interfaceLanguageCode: L10n.resolvedLanguage.localeIdentifier - ), - voice: voice.rawValue, - speed: speed, - timeoutSeconds: config.timeoutSeconds - )) - guard !Task.isCancelled, - pronunciationPreviewTaskID == taskID, - !isMigrationMaintenanceActive - else { - return - } - await MainActor.run { - do { - try self.pronunciationPreviewPlaybackService.play(audioData: audioData) - self.pronunciationPreviewState = .playing(voice) - } catch { - self.pronunciationPreviewState = nil - self.message = L10n.text("selection.action.pronunciationFailed") - } - } - } catch is CancellationError { - } catch { - guard !Task.isCancelled, self?.pronunciationPreviewTaskID == taskID else { return } - await MainActor.run { - self?.pronunciationPreviewState = nil - self?.message = L10n.text("selection.action.pronunciationFailed") - } - } - } - pronunciationPreviewTasks[taskID] = task - refreshMigrationWorkflowIdle() - } - - func resetSelectionTranslationPrompt() { - config.selectionActions.translationPrompt = SelectionActionsConfig.defaultTranslationPrompt - save() - } - - func refreshModelCatalogIfNeeded() async { - guard !isMigrationMaintenanceActive else { return } - if let modelCatalogRefreshTask { - await modelCatalogRefreshTask.value - return - } - - isRefreshingModelCatalog = true - let taskID = UUID() - modelCatalogRefreshTaskID = taskID - let modelCatalogService = self.modelCatalogService - let task = Task { - do { - try await modelCatalogService.refreshIfNeeded() - } catch { - // The model picker still works with saved/default/custom values when refresh fails. - } - } - modelCatalogRefreshTask = task - refreshMigrationWorkflowIdle() - await task.value - - guard modelCatalogRefreshTaskID == taskID else { return } - modelCatalogRefreshTask = nil - modelCatalogRefreshTaskID = nil - isRefreshingModelCatalog = false - refreshMigrationWorkflowIdle() - guard !isMigrationMaintenanceActive else { return } - cachedProviderModels = Dictionary( - uniqueKeysWithValues: LLMProviderPreset.all.compactMap { preset in - guard let modelIDs = modelCatalogService.cachedModelIDs(for: preset.id) else { - return nil - } - return (preset.id, modelIDs) - } - ) - } - - func addPromptMode() { - let mode = PromptMode( - id: "custom-\(Int(Date().timeIntervalSince1970))", - name: L10n.text("settings.mode.newName"), - description: "", - systemPrompt: "", - shortcut: nil, - participatesInAuto: false, - autoRule: .none, - sortOrder: (config.promptModes.map(\.sortOrder).max() ?? 0) + 1, - isVisible: true - ) - config.promptModes.append(mode) - selectedPromptModeID = mode.id - normalizePromptModeSortOrder() - save() - } - - func deleteSelectedPromptMode() { - deletePromptMode(modeID: selectedPromptModeID) - } - - func deletePromptMode(modeID: String) { - guard config.promptModes.count > 1 else { - message = L10n.text("settings.error.promptModeRequired") - return - } - - config.promptModes.removeAll { $0.id == modeID } - normalizePromptModeSortOrder() - - if !config.promptModes.contains(where: \.isVisible), - let firstIndex = config.promptModes.firstIndex(where: { _ in true }) { - config.promptModes[firstIndex].isVisible = true - } - - if selectedPromptModeID == modeID { - selectedPromptModeID = config.promptModes.first?.id ?? PromptMode.translateToEnglishID - } - save() - } - - func movePromptModes(from source: IndexSet, to destination: Int) { - var orderedModes = orderedPromptModes - orderedModes.move(fromOffsets: source, toOffset: destination) - - for (sortOrder, mode) in orderedModes.enumerated() { - guard let configIndex = config.promptModes.firstIndex(where: { $0.id == mode.id }) else { - continue - } - config.promptModes[configIndex].sortOrder = sortOrder - } - normalizePromptModeSortOrder() - save() - } - - func promptModeName(modeID: String) -> String { - guard let mode = config.promptModes.first(where: { $0.id == modeID }) else { - return L10n.text("settings.mode.untitled") - } - return mode.name.isEmpty ? L10n.text("settings.mode.untitled") : mode.localizedName - } - - func togglePromptModeVisibility(modeID: String) { - guard let index = config.promptModes.firstIndex(where: { $0.id == modeID }) else { - return - } - - if config.promptModes[index].isVisible, - config.promptModes.filter(\.isVisible).count <= 1 { - message = L10n.text("settings.error.visibleModeRequired") - return - } - - config.promptModes[index].isVisible.toggle() - save() - } - - func promptModeVisibilityBinding(modeID: String) -> Binding { - Binding( - get: { [weak self] in - self?.config.promptModes.first(where: { $0.id == modeID })?.isVisible ?? false - }, - set: { [weak self] newValue in - guard let self, - let mode = self.config.promptModes.first(where: { $0.id == modeID }), - mode.isVisible != newValue - else { - return - } - self.togglePromptModeVisibility(modeID: modeID) - } - ) - } - - func canMovePromptMode(modeID: String, direction: Int) -> Bool { - guard let currentIndex = orderedPromptModes.firstIndex(where: { $0.id == modeID }) else { - return false - } - - return orderedPromptModes.indices.contains(currentIndex + direction) - } - - private func normalizePromptModeSortOrder() { - let orderedModes = orderedPromptModes - for (sortOrder, mode) in orderedModes.enumerated() { - guard let index = config.promptModes.firstIndex(where: { $0.id == mode.id }) else { - continue - } - config.promptModes[index].sortOrder = sortOrder - } - config.promptModes.sort { lhs, rhs in - if lhs.sortOrder == rhs.sortOrder { - return lhs.name < rhs.name - } - return lhs.sortOrder < rhs.sortOrder - } - } - - @discardableResult - func save() -> Bool { - guard !isMigrationMaintenanceActive else { return false } - let trimmedKey = providerAPIKey.trimmingCharacters(in: .whitespacesAndNewlines) - let shouldSaveConfig = config != savedConfig - let shouldSaveProviderAPIKey = providerAPIKey != savedProviderAPIKey - let shouldSaveInterfaceLanguage = interfaceLanguage != savedInterfaceLanguage - guard shouldSaveConfig || shouldSaveProviderAPIKey || shouldSaveInterfaceLanguage else { - return true - } - - do { - if shouldSaveConfig { - guard config.promptModes.contains(where: \.isVisible) else { - message = L10n.text("settings.error.visibleModeRequired") - return false - } - guard !config.model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - message = L10n.text("settings.error.modelRequired") - return false - } - config.providerID = LLMProviderPreset.openAI.id - - _ = try Hotkey.parse(config.hotkey) - try configStore.save(config) - savedConfig = config - } - - if shouldSaveInterfaceLanguage { - InkletLanguageStore.selectedLanguage = interfaceLanguage - savedInterfaceLanguage = interfaceLanguage - } - - if shouldSaveProviderAPIKey { - if trimmedKey.isEmpty { - try apiKeyStore.deleteAPIKey(forProviderID: LLMProviderPreset.openAI.id) - } else { - try apiKeyStore.saveAPIKey(trimmedKey, forProviderID: LLMProviderPreset.openAI.id) - } - providerAPIKey = trimmedKey - savedProviderAPIKey = trimmedKey - } - message = L10n.text("settings.saved") - NotificationCenter.default.post(name: .appConfigDidSave, object: nil) - return true - } catch let error as HotkeyError { - message = error.userFacingMessage - return false - } catch { - message = L10n.format("settings.error.saveFailed", String(describing: error)) - return false - } - } - - private func installAutoSave() { - guard !isMigrationMaintenanceActive, autoSaveCancellable == nil else { - return - } - autoSaveCancellable = Publishers.CombineLatest3($config, $providerAPIKey, $interfaceLanguage) - .dropFirst() - .debounce(for: .milliseconds(450), scheduler: RunLoop.main) - .sink { [weak self] _, _, _ in - guard let self else { return } - self.save() - } - } - - func flushPendingEdits() -> Bool { - autoSaveCancellable?.cancel() - autoSaveCancellable = nil - let didSave = hasPendingEdits ? save() : true - installAutoSave() - return didSave - } - - private var hasPendingEdits: Bool { - config != savedConfig - || providerAPIKey != savedProviderAPIKey - || interfaceLanguage != savedInterfaceLanguage - } - - func setMigrationMaintenanceActive(_ isActive: Bool) { - guard isMigrationMaintenanceActive != isActive else { return } - isMigrationMaintenanceActive = isActive - if isActive { - autoSaveCancellable?.cancel() - autoSaveCancellable = nil - pronunciationPreviewTasks.values.forEach { $0.cancel() } - modelCatalogRefreshTask?.cancel() - pronunciationPreviewPlaybackService.stop() - pronunciationPreviewState = nil - refreshMigrationWorkflowIdle() - } else { - installAutoSave() - refreshMigrationWorkflowIdle() - } - } - - func waitForMigrationMaintenanceQuiescence() async { - let previewTasks = Array(pronunciationPreviewTasks.values) - for task in previewTasks { - await task.value - } - - let catalogTaskID = modelCatalogRefreshTaskID - await modelCatalogRefreshTask?.value - if modelCatalogRefreshTaskID == catalogTaskID { - modelCatalogRefreshTask = nil - modelCatalogRefreshTaskID = nil - isRefreshingModelCatalog = false - } - refreshMigrationWorkflowIdle() - } - - private func refreshMigrationWorkflowIdle() { - isMigrationWorkflowIdle = pronunciationPreviewState == nil - && pronunciationPreviewTasks.isEmpty - && modelCatalogRefreshTask == nil - && !isRefreshingModelCatalog - } - - func openAccessibilitySettings() { - guard let url = URL( - string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" - ) else { - message = L10n.text("settings.error.openAccessibility") - return - } - - NotificationCenter.default.post( - name: .inkletDidOpenPermissionSettings, - object: nil, - userInfo: ["permission": PermissionSettingsDestination.accessibility.rawValue] - ) - NSWorkspace.shared.open(url) - } - -} - -extension Notification.Name { - static let appConfigDidSave = Notification.Name("InkletAppConfigDidSave") - static let hotkeyRecordingDidChange = Notification.Name("InkletHotkeyRecordingDidChange") - static let inkletDidOpenPermissionSettings = Notification.Name("InkletDidOpenPermissionSettings") - static let inkletAccessibilityDidBecomeTrusted = Notification.Name("InkletAccessibilityDidBecomeTrusted") - static let inkletDidCompleteOnboarding = Notification.Name("InkletDidCompleteOnboarding") -} - -private extension PromptMode { - static let builtInIDs: Set = [ - PromptMode.translateToEnglishID, - PromptMode.chineseSummaryID, - PromptMode.voiceCleanupID - ] -} - private extension SelectionTranslationLanguage { var localizedDisplayName: String { switch self { @@ -1988,663 +1375,3 @@ enum SettingsLayoutMetrics { return max(168, ceil(textWidth) + 22) } } - -private struct PromptModeTableView: NSViewRepresentable { - let modes: [PromptMode] - @Binding var selectedModeID: String - let canDelete: Bool - let onMove: (Int, Int) -> Void - let onToggleVisibility: (String) -> Void - let onDelete: (String) -> Void - - func makeCoordinator() -> Coordinator { - Coordinator( - modes: modes, - selectedModeID: $selectedModeID, - canDelete: canDelete, - onMove: onMove, - onToggleVisibility: onToggleVisibility, - onDelete: onDelete - ) - } - - func makeNSView(context: Context) -> NSScrollView { - let tableView = NSTableView() - let column = NSTableColumn(identifier: Coordinator.columnIdentifier) - column.resizingMask = .autoresizingMask - tableView.addTableColumn(column) - tableView.headerView = nil - tableView.rowHeight = 38 - tableView.intercellSpacing = NSSize(width: 0, height: 2) - tableView.backgroundColor = .clear - tableView.selectionHighlightStyle = .none - tableView.usesAlternatingRowBackgroundColors = false - tableView.delegate = context.coordinator - tableView.dataSource = context.coordinator - tableView.registerForDraggedTypes([Coordinator.dragPasteboardType]) - - let scrollView = PromptModeTableScrollView() - scrollView.drawsBackground = false - scrollView.hasVerticalScroller = false - scrollView.hasHorizontalScroller = false - scrollView.horizontalScrollElasticity = .none - scrollView.autohidesScrollers = true - scrollView.scrollerStyle = .overlay - scrollView.documentView = tableView - context.coordinator.tableView = tableView - return scrollView - } - - func updateNSView(_ scrollView: NSScrollView, context: Context) { - context.coordinator.modes = modes - context.coordinator.selectedModeID = $selectedModeID - context.coordinator.canDelete = canDelete - context.coordinator.onMove = onMove - context.coordinator.onToggleVisibility = onToggleVisibility - context.coordinator.onDelete = onDelete - - guard let tableView = scrollView.documentView as? NSTableView else { - return - } - - PromptModeTableScrollView.syncTableWidth(tableView, to: scrollView.contentView.bounds.width) - - tableView.reloadData() - context.coordinator.syncSelection() - } - - final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate { - static let columnIdentifier = NSUserInterfaceItemIdentifier("PromptModeColumn") - static let rowIdentifier = NSUserInterfaceItemIdentifier("PromptModeRow") - static let dragPasteboardType = NSPasteboard.PasteboardType("com.inklet.prompt-mode") - - var modes: [PromptMode] - var selectedModeID: Binding - var canDelete: Bool - var onMove: (Int, Int) -> Void - var onToggleVisibility: (String) -> Void - var onDelete: (String) -> Void - weak var tableView: NSTableView? - private var isSyncingSelection = false - - init( - modes: [PromptMode], - selectedModeID: Binding, - canDelete: Bool, - onMove: @escaping (Int, Int) -> Void, - onToggleVisibility: @escaping (String) -> Void, - onDelete: @escaping (String) -> Void - ) { - self.modes = modes - self.selectedModeID = selectedModeID - self.canDelete = canDelete - self.onMove = onMove - self.onToggleVisibility = onToggleVisibility - self.onDelete = onDelete - } - - func numberOfRows(in tableView: NSTableView) -> Int { - modes.count - } - - func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { - guard modes.indices.contains(row) else { - return nil - } - - let cell = (tableView.makeView(withIdentifier: Self.rowIdentifier, owner: self) as? PromptModeTableCellView) - ?? PromptModeTableCellView(identifier: Self.rowIdentifier) - let mode = modes[row] - cell.configure( - mode: mode, - isSelected: mode.id == selectedModeID.wrappedValue, - canDelete: canDelete, - target: self - ) - return cell - } - - func tableViewSelectionDidChange(_ notification: Notification) { - guard !isSyncingSelection, - let tableView, - modes.indices.contains(tableView.selectedRow) - else { - return - } - - selectedModeID.wrappedValue = modes[tableView.selectedRow].id - } - - func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? { - guard modes.indices.contains(row) else { - return nil - } - - let item = NSPasteboardItem() - item.setString(modes[row].id, forType: Self.dragPasteboardType) - return item - } - - func tableView( - _ tableView: NSTableView, - validateDrop info: NSDraggingInfo, - proposedRow row: Int, - proposedDropOperation dropOperation: NSTableView.DropOperation - ) -> NSDragOperation { - tableView.setDropRow(row, dropOperation: .above) - return .move - } - - func tableView( - _ tableView: NSTableView, - acceptDrop info: NSDraggingInfo, - row: Int, - dropOperation: NSTableView.DropOperation - ) -> Bool { - guard let draggedModeID = info.draggingPasteboard.string(forType: Self.dragPasteboardType), - let sourceIndex = modes.firstIndex(where: { $0.id == draggedModeID }) - else { - return false - } - - let destination = max(0, min(row, modes.count)) - guard sourceIndex != destination, sourceIndex + 1 != destination else { - return false - } - - onMove(sourceIndex, destination) - selectedModeID.wrappedValue = draggedModeID - return true - } - - @MainActor - func syncSelection() { - guard let tableView else { - return - } - - isSyncingSelection = true - defer { isSyncingSelection = false } - - if let selectedIndex = modes.firstIndex(where: { $0.id == selectedModeID.wrappedValue }) { - tableView.selectRowIndexes(IndexSet(integer: selectedIndex), byExtendingSelection: false) - } else { - tableView.deselectAll(nil) - } - } - - @MainActor - @objc func toggleVisibility(_ sender: NSButton) { - guard let modeID = sender.identifier?.rawValue else { - return - } - onToggleVisibility(modeID) - } - - @MainActor - @objc func deleteMode(_ sender: NSButton) { - guard let modeID = sender.identifier?.rawValue else { - return - } - onDelete(modeID) - } - } -} - -private final class PromptModeTableScrollView: NSScrollView { - override func layout() { - super.layout() - guard let tableView = documentView as? NSTableView else { - return - } - Self.syncTableWidth(tableView, to: contentView.bounds.width) - } - - static func syncTableWidth(_ tableView: NSTableView, to width: CGFloat) { - let width = max(width, 1) - guard tableView.tableColumns.first?.width != width else { - return - } - - tableView.tableColumns.first?.width = width - var frame = tableView.frame - frame.size.width = width - tableView.frame = frame - } -} - -private final class PromptModeTableCellView: NSTableCellView { - private static let rowWidth: CGFloat = 190 - private let rowContainer = NSView() - private let selectionBackground = NSView() - private let dragHandle = NSImageView() - private let titleField = NSTextField(labelWithString: "") - private let visibilityButton = NSButton() - private let deleteButton = NSButton() - private var trackingArea: NSTrackingArea? - private var isHovered = false - private var isCellSelected = false - private var isModeVisible = true - private var canDeleteMode = true - - init(identifier: NSUserInterfaceItemIdentifier) { - super.init(frame: .zero) - self.identifier = identifier - buildView() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func configure( - mode: PromptMode, - isSelected: Bool, - canDelete: Bool, - target: PromptModeTableView.Coordinator - ) { - let symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .regular) - isCellSelected = isSelected - isModeVisible = mode.isVisible - canDeleteMode = canDelete - titleField.stringValue = mode.name.isEmpty ? L10n.text("settings.mode.untitled") : mode.localizedName - titleField.font = .systemFont(ofSize: 13, weight: isSelected ? .semibold : .regular) - visibilityButton.image = NSImage(systemSymbolName: mode.isVisible ? "eye" : "eye.slash", accessibilityDescription: nil)? - .withSymbolConfiguration(symbolConfiguration) - visibilityButton.toolTip = mode.isVisible ? L10n.text("settings.mode.visible") : L10n.text("settings.mode.hidden") - visibilityButton.identifier = NSUserInterfaceItemIdentifier(mode.id) - visibilityButton.target = target - visibilityButton.action = #selector(PromptModeTableView.Coordinator.toggleVisibility(_:)) - - deleteButton.identifier = NSUserInterfaceItemIdentifier(mode.id) - deleteButton.target = target - deleteButton.action = #selector(PromptModeTableView.Coordinator.deleteMode(_:)) - deleteButton.isEnabled = canDelete - deleteButton.toolTip = L10n.text("settings.mode.delete") - applyAppearance() - } - - override func updateTrackingAreas() { - super.updateTrackingAreas() - if let trackingArea { - removeTrackingArea(trackingArea) - } - let area = NSTrackingArea( - rect: bounds, - options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], - owner: self - ) - trackingArea = area - addTrackingArea(area) - } - - override func mouseEntered(with event: NSEvent) { - isHovered = true - applyAppearance() - } - - override func mouseExited(with event: NSEvent) { - isHovered = false - applyAppearance() - } - - private func buildView() { - wantsLayer = true - layer?.backgroundColor = NSColor.clear.cgColor - rowContainer.translatesAutoresizingMaskIntoConstraints = false - addSubview(rowContainer) - - selectionBackground.wantsLayer = true - selectionBackground.layer?.cornerRadius = 12 - selectionBackground.layer?.cornerCurve = .continuous - selectionBackground.layer?.backgroundColor = NSColor.clear.cgColor - selectionBackground.translatesAutoresizingMaskIntoConstraints = false - rowContainer.addSubview(selectionBackground) - - let symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .regular) - dragHandle.image = NSImage(systemSymbolName: "line.3.horizontal", accessibilityDescription: nil)? - .withSymbolConfiguration(symbolConfiguration) - dragHandle.contentTintColor = NSColor(white: 1, alpha: 0.18) - dragHandle.setContentHuggingPriority(.required, for: .horizontal) - dragHandle.setContentCompressionResistancePriority(.required, for: .horizontal) - dragHandle.toolTip = L10n.text("settings.mode.dragToSort") - dragHandle.translatesAutoresizingMaskIntoConstraints = false - - titleField.lineBreakMode = .byTruncatingTail - titleField.textColor = NSColor(white: 0.74, alpha: 1) - titleField.setContentHuggingPriority(.defaultLow, for: .horizontal) - titleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - titleField.translatesAutoresizingMaskIntoConstraints = false - - configureIconButton(visibilityButton) - configureIconButton(deleteButton) - deleteButton.image = NSImage(systemSymbolName: "trash", accessibilityDescription: nil)? - .withSymbolConfiguration(symbolConfiguration) - deleteButton.contentTintColor = NSColor(white: 1, alpha: 0.32) - - rowContainer.addSubview(dragHandle) - rowContainer.addSubview(titleField) - rowContainer.addSubview(visibilityButton) - rowContainer.addSubview(deleteButton) - - NSLayoutConstraint.activate([ - rowContainer.widthAnchor.constraint(equalToConstant: Self.rowWidth), - rowContainer.centerXAnchor.constraint(equalTo: centerXAnchor, constant: -16), - rowContainer.topAnchor.constraint(equalTo: topAnchor, constant: 3), - rowContainer.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -3), - selectionBackground.leadingAnchor.constraint(equalTo: rowContainer.leadingAnchor), - selectionBackground.trailingAnchor.constraint(equalTo: rowContainer.trailingAnchor), - selectionBackground.topAnchor.constraint(equalTo: rowContainer.topAnchor), - selectionBackground.bottomAnchor.constraint(equalTo: rowContainer.bottomAnchor), - dragHandle.widthAnchor.constraint(equalToConstant: 18), - visibilityButton.widthAnchor.constraint(equalToConstant: 24), - visibilityButton.heightAnchor.constraint(equalToConstant: 24), - deleteButton.widthAnchor.constraint(equalToConstant: 24), - deleteButton.heightAnchor.constraint(equalToConstant: 24), - dragHandle.leadingAnchor.constraint(equalTo: rowContainer.leadingAnchor, constant: 14), - dragHandle.centerYAnchor.constraint(equalTo: rowContainer.centerYAnchor), - titleField.leadingAnchor.constraint(equalTo: dragHandle.trailingAnchor, constant: 10), - titleField.trailingAnchor.constraint(equalTo: visibilityButton.leadingAnchor, constant: -8), - titleField.centerYAnchor.constraint(equalTo: rowContainer.centerYAnchor), - visibilityButton.trailingAnchor.constraint(equalTo: deleteButton.leadingAnchor, constant: -4), - visibilityButton.centerYAnchor.constraint(equalTo: rowContainer.centerYAnchor), - deleteButton.trailingAnchor.constraint(equalTo: rowContainer.trailingAnchor, constant: -10), - deleteButton.centerYAnchor.constraint(equalTo: rowContainer.centerYAnchor) - ]) - } - - private func applyAppearance() { - let isDark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua - selectionBackground.layer?.backgroundColor = isCellSelected - ? NSColor.controlAccentColor.withAlphaComponent(isDark ? 0.20 : 0.14).cgColor - : (isHovered ? NSColor.labelColor.withAlphaComponent(isDark ? 0.04 : 0.045).cgColor : NSColor.clear.cgColor) - titleField.textColor = isCellSelected - ? NSColor.controlAccentColor - : (isHovered ? .labelColor : .secondaryLabelColor) - dragHandle.contentTintColor = isHovered || isCellSelected - ? .secondaryLabelColor - : .tertiaryLabelColor - visibilityButton.contentTintColor = isModeVisible - ? .secondaryLabelColor - : .tertiaryLabelColor - visibilityButton.alphaValue = isHovered || isCellSelected ? 1 : 0.78 - deleteButton.contentTintColor = canDeleteMode - ? .secondaryLabelColor - : .tertiaryLabelColor - deleteButton.alphaValue = isHovered || isCellSelected ? 1 : 0.64 - } - - private func configureIconButton(_ button: NSButton) { - button.translatesAutoresizingMaskIntoConstraints = false - button.isBordered = false - button.bezelStyle = .regularSquare - button.setButtonType(.momentaryChange) - button.imagePosition = .imageOnly - button.focusRingType = .none - button.setContentHuggingPriority(.required, for: .horizontal) - button.setContentCompressionResistancePriority(.required, for: .horizontal) - } -} - -private struct SettingsPromptTextView: NSViewRepresentable { - @Binding var text: String - - func makeCoordinator() -> Coordinator { - Coordinator(text: $text) - } - - func makeNSView(context: Context) -> NSScrollView { - let scrollView = NSScrollView() - scrollView.drawsBackground = false - scrollView.borderType = .noBorder - scrollView.hasVerticalScroller = true - scrollView.hasHorizontalScroller = false - scrollView.autohidesScrollers = true - scrollView.scrollerStyle = .overlay - scrollView.contentInsets = NSEdgeInsetsZero - scrollView.automaticallyAdjustsContentInsets = false - scrollView.horizontalScrollElasticity = .none - - let textView = NSTextView() - textView.string = text - textView.delegate = context.coordinator - textView.isEditable = true - textView.isSelectable = true - textView.isRichText = false - textView.importsGraphics = false - textView.allowsUndo = true - textView.usesFindBar = false - textView.drawsBackground = false - textView.backgroundColor = .clear - textView.font = .systemFont(ofSize: 13) - textView.textColor = .labelColor - textView.insertionPointColor = .controlAccentColor - textView.textContainerInset = .zero - textView.textContainer?.lineFragmentPadding = 0 - textView.textContainer?.widthTracksTextView = true - textView.textContainer?.containerSize = NSSize( - width: scrollView.contentSize.width, - height: CGFloat.greatestFiniteMagnitude - ) - textView.minSize = NSSize(width: 0, height: 0) - textView.maxSize = NSSize( - width: CGFloat.greatestFiniteMagnitude, - height: CGFloat.greatestFiniteMagnitude - ) - textView.isHorizontallyResizable = false - textView.isVerticallyResizable = true - textView.autoresizingMask = [.width] - - scrollView.documentView = textView - context.coordinator.textView = textView - return scrollView - } - - func updateNSView(_ scrollView: NSScrollView, context: Context) { - guard let textView = scrollView.documentView as? NSTextView else { - return - } - - context.coordinator.text = $text - context.coordinator.textView = textView - textView.font = .systemFont(ofSize: 13) - textView.textColor = .labelColor - textView.insertionPointColor = .controlAccentColor - - if !textView.hasMarkedText(), textView.string != text { - textView.string = text - } - } - - final class Coordinator: NSObject, NSTextViewDelegate, @unchecked Sendable { - var text: Binding - weak var textView: NSTextView? - - init(text: Binding) { - self.text = text - super.init() - } - - @MainActor - func textDidChange(_ notification: Notification) { - guard let textView = notification.object as? NSTextView else { - return - } - - text.wrappedValue = textView.string - } - } -} - -private struct HotkeyRecorderField: NSViewRepresentable { - @Binding var hotkey: String - - func makeCoordinator() -> Coordinator { - Coordinator(hotkey: $hotkey) - } - - func makeNSView(context: Context) -> RecorderView { - let view = RecorderView() - view.onChange = { context.coordinator.hotkey.wrappedValue = $0 } - view.hotkey = hotkey - return view - } - - func updateNSView(_ nsView: RecorderView, context: Context) { - nsView.hotkey = hotkey - nsView.onChange = { context.coordinator.hotkey.wrappedValue = $0 } - nsView.updateDisplay() - } - - final class Coordinator { - var hotkey: Binding - - init(hotkey: Binding) { - self.hotkey = hotkey - } - } - - final class RecorderView: NSView { - var hotkey = "" - var onChange: ((String) -> Void)? - private var isRecording = false - private let label = NSTextField(labelWithString: "") - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - wantsLayer = true - layer?.cornerRadius = 8 - layer?.borderWidth = 1 - label.translatesAutoresizingMaskIntoConstraints = false - label.alignment = .center - label.font = .monospacedSystemFont(ofSize: 13, weight: .medium) - addSubview(label) - NSLayoutConstraint.activate([ - label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), - label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), - label.centerYAnchor.constraint(equalTo: centerYAnchor) - ]) - updateDisplay() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override var acceptsFirstResponder: Bool { true } - - override func viewDidChangeEffectiveAppearance() { - super.viewDidChangeEffectiveAppearance() - updateDisplay() - } - - override func mouseDown(with event: NSEvent) { - isRecording = true - publishRecordingState() - window?.makeFirstResponder(self) - updateDisplay() - } - - override func resignFirstResponder() -> Bool { - isRecording = false - publishRecordingState() - updateDisplay() - return super.resignFirstResponder() - } - - override func keyDown(with event: NSEvent) { - guard isRecording else { - super.keyDown(with: event) - return - } - - if event.keyCode == UInt16(kVK_Escape) { - isRecording = false - publishRecordingState() - window?.makeFirstResponder(nil) - updateDisplay() - return - } - - guard let recordedHotkey = recordedHotkey(from: event) else { - NSSound.beep() - return - } - - hotkey = recordedHotkey.displayString - onChange?(hotkey) - isRecording = false - publishRecordingState() - window?.makeFirstResponder(nil) - updateDisplay() - } - - override func flagsChanged(with event: NSEvent) { - if isRecording { - updateDisplay(pressedModifiers: modifierDisplayString(from: event.modifierFlags)) - } else { - super.flagsChanged(with: event) - } - } - - func updateDisplay(pressedModifiers: String = "") { - let backgroundColor = isRecording ? NSColor.controlAccentColor.withAlphaComponent(0.16) : NSColor.controlBackgroundColor - layer?.backgroundColor = resolvedCGColor(backgroundColor) - layer?.borderColor = resolvedCGColor(isRecording ? .controlAccentColor : .separatorColor) - label.textColor = isRecording ? .controlAccentColor : .labelColor - if isRecording { - label.stringValue = pressedModifiers.isEmpty - ? L10n.text("settings.hotkey.recording") - : "\(pressedModifiers)\(L10n.text("settings.hotkey.pressKey"))" - } else { - label.stringValue = hotkey.isEmpty ? L10n.text("settings.hotkey.record") : hotkey - } - } - - private func resolvedCGColor(_ color: NSColor) -> CGColor { - var resolvedColor = color.cgColor - effectiveAppearance.performAsCurrentDrawingAppearance { - resolvedColor = color.cgColor - } - return resolvedColor - } - - private func recordedHotkey(from event: NSEvent) -> Hotkey? { - var modifiers: Hotkey.Modifier = [] - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if flags.contains(.command) { modifiers.insert(.command) } - if flags.contains(.option) { modifiers.insert(.option) } - if flags.contains(.control) { modifiers.insert(.control) } - if flags.contains(.shift) { modifiers.insert(.shift) } - - guard !modifiers.isEmpty, - modifiers != [.shift], - Hotkey.displayName(for: UInt32(event.keyCode)) != nil - else { - return nil - } - - return Hotkey(keyCode: UInt32(event.keyCode), modifiers: modifiers) - } - - private func modifierDisplayString(from flags: NSEvent.ModifierFlags) -> String { - var modifiers: Hotkey.Modifier = [] - let filteredFlags = flags.intersection(.deviceIndependentFlagsMask) - if filteredFlags.contains(.command) { modifiers.insert(.command) } - if filteredFlags.contains(.option) { modifiers.insert(.option) } - if filteredFlags.contains(.control) { modifiers.insert(.control) } - if filteredFlags.contains(.shift) { modifiers.insert(.shift) } - return modifiers.displayString - } - - private func publishRecordingState() { - NotificationCenter.default.post( - name: .hotkeyRecordingDidChange, - object: nil, - userInfo: ["isRecording": isRecording] - ) - } - } -} diff --git a/Sources/InkletApp/SettingsViewModel.swift b/Sources/InkletApp/SettingsViewModel.swift new file mode 100644 index 0000000..18f7ef7 --- /dev/null +++ b/Sources/InkletApp/SettingsViewModel.swift @@ -0,0 +1,606 @@ +import AppKit +import Combine +import SwiftUI +import InkletCore + +enum PronunciationPreviewState: Equatable { + case loading(SelectionPronunciationVoice) + case playing(SelectionPronunciationVoice) + + func matches(_ voice: SelectionPronunciationVoice) -> Bool { + switch self { + case .loading(let activeVoice), .playing(let activeVoice): + activeVoice == voice + } + } +} + +@MainActor +final class SettingsViewModel: ObservableObject { + @Published var config: AppConfig + @Published var message: String + @Published var providerAPIKey: String + @Published var selectedPromptModeID: String + @Published var interfaceLanguage: InterfaceLanguage + @Published var cachedProviderModels: [String: [String]] + @Published var isRefreshingModelCatalog: Bool + @Published var isEditingCustomModel: Bool + @Published var microphoneOptions: [MicrophoneDeviceOption] + @Published private(set) var pronunciationPreviewState: PronunciationPreviewState? + @Published var permissionRefreshID: UUID + @Published var historyItems: [HistoryItem] + @Published var historyFilter: HistorySource? + @Published private(set) var isMigrationMaintenanceActive = false + @Published private(set) var isMigrationWorkflowIdle = true + + private let configStore: UserDefaultsConfigStore + private let apiKeyStore: LocalAPIKeyStore + private let modelCatalogService: ModelCatalogService + private let historyStore: any HistoryStore + private let microphoneDeviceCatalog: MicrophoneDeviceCatalog + private let pronunciationPreviewPlaybackService: SpeechPlaybackService + private var autoSaveCancellable: AnyCancellable? + private var pronunciationPreviewTasks: [UUID: Task] = [:] + private var pronunciationPreviewTaskID: UUID? + private var modelCatalogRefreshTask: Task? + private var modelCatalogRefreshTaskID: UUID? + private var savedConfig: AppConfig + private var savedProviderAPIKey: String + private var savedInterfaceLanguage: InterfaceLanguage + + static let customModelMenuID = "__custom_model__" + + init( + configStore: UserDefaultsConfigStore = UserDefaultsConfigStore(), + apiKeyStore: LocalAPIKeyStore = LocalAPIKeyStore(), + modelCatalogService: ModelCatalogService = ModelCatalogService(), + historyStore: any HistoryStore = JSONLHistoryStore(), + microphoneDeviceCatalog: MicrophoneDeviceCatalog = MicrophoneDeviceCatalog() + ) { + var loadedConfig = (try? configStore.load()) ?? AppConfig.defaultConfig() + self.configStore = configStore + self.apiKeyStore = apiKeyStore + self.modelCatalogService = modelCatalogService + self.historyStore = historyStore + self.microphoneDeviceCatalog = microphoneDeviceCatalog + self.pronunciationPreviewPlaybackService = SpeechPlaybackService() + loadedConfig.providerID = LLMProviderPreset.openAI.id + self.config = loadedConfig + self.savedConfig = loadedConfig + self.message = "" + self.interfaceLanguage = InkletLanguageStore.selectedLanguage + self.savedInterfaceLanguage = InkletLanguageStore.selectedLanguage + let loadedProviderAPIKey = apiKeyStore.loadAPIKey(forProviderID: LLMProviderPreset.openAI.id) ?? "" + self.providerAPIKey = loadedProviderAPIKey + self.savedProviderAPIKey = loadedProviderAPIKey + self.selectedPromptModeID = loadedConfig.promptModes.sorted { $0.sortOrder < $1.sortOrder }.first?.id + ?? PromptMode.translateToEnglishID + self.cachedProviderModels = Dictionary( + uniqueKeysWithValues: LLMProviderPreset.all.compactMap { preset in + guard let modelIDs = modelCatalogService.cachedModelIDs(for: preset.id) else { + return nil + } + return (preset.id, modelIDs) + } + ) + self.isRefreshingModelCatalog = false + self.isEditingCustomModel = false + self.microphoneOptions = microphoneDeviceCatalog.options() + self.pronunciationPreviewState = nil + self.permissionRefreshID = UUID() + self.historyItems = (try? historyStore.load()).map { Array($0.reversed()) } ?? [] + self.historyFilter = nil + + self.pronunciationPreviewPlaybackService.onFinish = { [weak self] in + self?.pronunciationPreviewState = nil + self?.refreshMigrationWorkflowIdle() + } + installAutoSave() + } + + var selectedProvider: LLMProviderPreset { + LLMProviderPreset.openAI + } + + var filteredHistoryItems: [HistoryItem] { + guard let historyFilter else { + return historyItems + } + return historyItems.filter { $0.source == historyFilter } + } + + var isCustomOpenAICompatibleProvider: Bool { + false + } + + var selectedProviderModelOptions: [String] { + guard !isCustomOpenAICompatibleProvider else { + return [] + } + + var seen = Set() + var options: [String] = [] + + for modelID in cachedProviderModels[LLMProviderPreset.openAI.id] ?? [] { + if seen.insert(modelID).inserted { + options.append(modelID) + } + } + + if seen.insert(selectedProvider.defaultModel).inserted { + options.insert(selectedProvider.defaultModel, at: 0) + } + + return options + } + + var selectedModelMenuValue: String { + if isEditingCustomModel { + return Self.customModelMenuID + } + + return selectedProviderModelOptions.contains(config.model) ? config.model : Self.customModelMenuID + } + + var shouldShowCustomModelField: Bool { + isCustomOpenAICompatibleProvider || selectedModelMenuValue == Self.customModelMenuID + } + + var selectedModelIsDefault: Bool { + config.model.trimmingCharacters(in: .whitespacesAndNewlines) == selectedProvider.defaultModel + } + + var selectedPromptModeIndex: Int? { + config.promptModes.firstIndex { $0.id == selectedPromptModeID } + } + + var orderedPromptModes: [PromptMode] { + config.promptModes.sorted { lhs, rhs in + if lhs.sortOrder == rhs.sortOrder { + return lhs.name < rhs.name + } + return lhs.sortOrder < rhs.sortOrder + } + } + + var isAccessibilityTrusted: Bool { + AccessibilityPermissionService().isTrusted + } + + func refreshPermissions() { + permissionRefreshID = UUID() + } + + func reloadHistory() { + do { + historyItems = Array(try historyStore.load().reversed()) + } catch { + historyItems = [] + message = L10n.text("settings.history.error.loadFailed") + } + } + + func clearHistory() { + guard !isMigrationMaintenanceActive else { return } + do { + try historyStore.clear() + historyItems = [] + message = L10n.text("settings.history.cleared") + } catch { + message = L10n.text("settings.history.error.clearFailed") + } + } + + func refreshMicrophoneOptions() { + microphoneOptions = microphoneDeviceCatalog.options() + if let microphoneDeviceID = config.voiceInput.microphoneDeviceID, + !microphoneOptions.contains(where: { $0.deviceID == microphoneDeviceID }) { + config.voiceInput.microphoneDeviceID = nil + } + } + + var selectedMicrophoneMenuID: String { + get { + guard let microphoneDeviceID = config.voiceInput.microphoneDeviceID, + microphoneOptions.contains(where: { $0.deviceID == microphoneDeviceID }) + else { + return MicrophoneDeviceOption.systemDefaultID + } + return microphoneDeviceID + } + set { + config.voiceInput.microphoneDeviceID = newValue == MicrophoneDeviceOption.systemDefaultID ? nil : newValue + } + } + + func modelMenuTitle(for modelID: String) -> String { + if modelID == config.model, !selectedModelIsDefault { + return "\(modelID) *" + } + return modelID + } + + func selectModelMenuValue(_ value: String) { + if value == Self.customModelMenuID { + isEditingCustomModel = true + return + } + + isEditingCustomModel = false + config.model = value + save() + } + + func previewPronunciationVoice() { + guard !isMigrationMaintenanceActive else { return } + let voice = config.selectionActions.pronunciationVoice + let speed = config.selectionActions.pronunciationSpeed + if let pronunciationPreviewTaskID { + pronunciationPreviewTasks[pronunciationPreviewTaskID]?.cancel() + } + pronunciationPreviewPlaybackService.stop() + pronunciationPreviewState = .loading(voice) + let taskID = UUID() + pronunciationPreviewTaskID = taskID + + let task = Task { [weak self] in + defer { + self?.pronunciationPreviewTasks[taskID] = nil + if self?.pronunciationPreviewTaskID == taskID { + self?.pronunciationPreviewTaskID = nil + } + self?.refreshMigrationWorkflowIdle() + } + do { + guard let self else { return } + let provider = OpenAITTSProvider(apiKeyProvider: { [apiKeyStore] in + guard let apiKey = apiKeyStore.loadAPIKey(forProviderID: LLMProviderPreset.openAI.id), + !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + throw OpenAITTSError.provider(L10n.text("selection.action.missingOpenAIKey")) + } + return apiKey + }) + let audioData = try await provider.speechAudio(OpenAITTSRequest( + input: SelectionPronunciationVoice.previewText( + interfaceLanguageCode: L10n.resolvedLanguage.localeIdentifier + ), + voice: voice.rawValue, + speed: speed, + timeoutSeconds: config.timeoutSeconds + )) + guard !Task.isCancelled, + pronunciationPreviewTaskID == taskID, + !isMigrationMaintenanceActive + else { + return + } + await MainActor.run { + do { + try self.pronunciationPreviewPlaybackService.play(audioData: audioData) + self.pronunciationPreviewState = .playing(voice) + } catch { + self.pronunciationPreviewState = nil + self.message = L10n.text("selection.action.pronunciationFailed") + } + } + } catch is CancellationError { + } catch { + guard !Task.isCancelled, self?.pronunciationPreviewTaskID == taskID else { return } + await MainActor.run { + self?.pronunciationPreviewState = nil + self?.message = L10n.text("selection.action.pronunciationFailed") + } + } + } + pronunciationPreviewTasks[taskID] = task + refreshMigrationWorkflowIdle() + } + + func resetSelectionTranslationPrompt() { + config.selectionActions.translationPrompt = SelectionActionsConfig.defaultTranslationPrompt + save() + } + + func refreshModelCatalogIfNeeded() async { + guard !isMigrationMaintenanceActive else { return } + if let modelCatalogRefreshTask { + await modelCatalogRefreshTask.value + return + } + + isRefreshingModelCatalog = true + let taskID = UUID() + modelCatalogRefreshTaskID = taskID + let modelCatalogService = self.modelCatalogService + let task = Task { + do { + try await modelCatalogService.refreshIfNeeded() + } catch { + // The model picker still works with saved/default/custom values when refresh fails. + } + } + modelCatalogRefreshTask = task + refreshMigrationWorkflowIdle() + await task.value + + guard modelCatalogRefreshTaskID == taskID else { return } + modelCatalogRefreshTask = nil + modelCatalogRefreshTaskID = nil + isRefreshingModelCatalog = false + refreshMigrationWorkflowIdle() + guard !isMigrationMaintenanceActive else { return } + cachedProviderModels = Dictionary( + uniqueKeysWithValues: LLMProviderPreset.all.compactMap { preset in + guard let modelIDs = modelCatalogService.cachedModelIDs(for: preset.id) else { + return nil + } + return (preset.id, modelIDs) + } + ) + } + + func addPromptMode() { + let mode = PromptMode( + id: "custom-\(Int(Date().timeIntervalSince1970))", + name: L10n.text("settings.mode.newName"), + description: "", + systemPrompt: "", + shortcut: nil, + participatesInAuto: false, + autoRule: .none, + sortOrder: (config.promptModes.map(\.sortOrder).max() ?? 0) + 1, + isVisible: true + ) + config.promptModes.append(mode) + selectedPromptModeID = mode.id + normalizePromptModeSortOrder() + save() + } + + func deleteSelectedPromptMode() { + deletePromptMode(modeID: selectedPromptModeID) + } + + func deletePromptMode(modeID: String) { + guard config.promptModes.count > 1 else { + message = L10n.text("settings.error.promptModeRequired") + return + } + + config.promptModes.removeAll { $0.id == modeID } + normalizePromptModeSortOrder() + + if !config.promptModes.contains(where: \.isVisible), + let firstIndex = config.promptModes.firstIndex(where: { _ in true }) { + config.promptModes[firstIndex].isVisible = true + } + + if selectedPromptModeID == modeID { + selectedPromptModeID = config.promptModes.first?.id ?? PromptMode.translateToEnglishID + } + save() + } + + func movePromptModes(from source: IndexSet, to destination: Int) { + var orderedModes = orderedPromptModes + orderedModes.move(fromOffsets: source, toOffset: destination) + + for (sortOrder, mode) in orderedModes.enumerated() { + guard let configIndex = config.promptModes.firstIndex(where: { $0.id == mode.id }) else { + continue + } + config.promptModes[configIndex].sortOrder = sortOrder + } + normalizePromptModeSortOrder() + save() + } + + func promptModeName(modeID: String) -> String { + guard let mode = config.promptModes.first(where: { $0.id == modeID }) else { + return L10n.text("settings.mode.untitled") + } + return mode.name.isEmpty ? L10n.text("settings.mode.untitled") : mode.localizedName + } + + func togglePromptModeVisibility(modeID: String) { + guard let index = config.promptModes.firstIndex(where: { $0.id == modeID }) else { + return + } + + if config.promptModes[index].isVisible, + config.promptModes.filter(\.isVisible).count <= 1 { + message = L10n.text("settings.error.visibleModeRequired") + return + } + + config.promptModes[index].isVisible.toggle() + save() + } + + func promptModeVisibilityBinding(modeID: String) -> Binding { + Binding( + get: { [weak self] in + self?.config.promptModes.first(where: { $0.id == modeID })?.isVisible ?? false + }, + set: { [weak self] newValue in + guard let self, + let mode = self.config.promptModes.first(where: { $0.id == modeID }), + mode.isVisible != newValue + else { + return + } + self.togglePromptModeVisibility(modeID: modeID) + } + ) + } + + func canMovePromptMode(modeID: String, direction: Int) -> Bool { + guard let currentIndex = orderedPromptModes.firstIndex(where: { $0.id == modeID }) else { + return false + } + + return orderedPromptModes.indices.contains(currentIndex + direction) + } + + private func normalizePromptModeSortOrder() { + let orderedModes = orderedPromptModes + for (sortOrder, mode) in orderedModes.enumerated() { + guard let index = config.promptModes.firstIndex(where: { $0.id == mode.id }) else { + continue + } + config.promptModes[index].sortOrder = sortOrder + } + config.promptModes.sort { lhs, rhs in + if lhs.sortOrder == rhs.sortOrder { + return lhs.name < rhs.name + } + return lhs.sortOrder < rhs.sortOrder + } + } + + @discardableResult + func save() -> Bool { + guard !isMigrationMaintenanceActive else { return false } + let trimmedKey = providerAPIKey.trimmingCharacters(in: .whitespacesAndNewlines) + let shouldSaveConfig = config != savedConfig + let shouldSaveProviderAPIKey = providerAPIKey != savedProviderAPIKey + let shouldSaveInterfaceLanguage = interfaceLanguage != savedInterfaceLanguage + guard shouldSaveConfig || shouldSaveProviderAPIKey || shouldSaveInterfaceLanguage else { + return true + } + + do { + if shouldSaveConfig { + guard config.promptModes.contains(where: \.isVisible) else { + message = L10n.text("settings.error.visibleModeRequired") + return false + } + guard !config.model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + message = L10n.text("settings.error.modelRequired") + return false + } + config.providerID = LLMProviderPreset.openAI.id + + _ = try Hotkey.parse(config.hotkey) + try configStore.save(config) + savedConfig = config + } + + if shouldSaveInterfaceLanguage { + InkletLanguageStore.selectedLanguage = interfaceLanguage + savedInterfaceLanguage = interfaceLanguage + } + + if shouldSaveProviderAPIKey { + if trimmedKey.isEmpty { + try apiKeyStore.deleteAPIKey(forProviderID: LLMProviderPreset.openAI.id) + } else { + try apiKeyStore.saveAPIKey(trimmedKey, forProviderID: LLMProviderPreset.openAI.id) + } + providerAPIKey = trimmedKey + savedProviderAPIKey = trimmedKey + } + message = L10n.text("settings.saved") + NotificationCenter.default.post(name: .appConfigDidSave, object: nil) + return true + } catch let error as HotkeyError { + message = error.userFacingMessage + return false + } catch { + message = L10n.format("settings.error.saveFailed", String(describing: error)) + return false + } + } + + private func installAutoSave() { + guard !isMigrationMaintenanceActive, autoSaveCancellable == nil else { + return + } + autoSaveCancellable = Publishers.CombineLatest3($config, $providerAPIKey, $interfaceLanguage) + .dropFirst() + .debounce(for: .milliseconds(450), scheduler: RunLoop.main) + .sink { [weak self] _, _, _ in + guard let self else { return } + self.save() + } + } + + func flushPendingEdits() -> Bool { + autoSaveCancellable?.cancel() + autoSaveCancellable = nil + let didSave = hasPendingEdits ? save() : true + installAutoSave() + return didSave + } + + private var hasPendingEdits: Bool { + config != savedConfig + || providerAPIKey != savedProviderAPIKey + || interfaceLanguage != savedInterfaceLanguage + } + + func setMigrationMaintenanceActive(_ isActive: Bool) { + guard isMigrationMaintenanceActive != isActive else { return } + isMigrationMaintenanceActive = isActive + if isActive { + autoSaveCancellable?.cancel() + autoSaveCancellable = nil + pronunciationPreviewTasks.values.forEach { $0.cancel() } + modelCatalogRefreshTask?.cancel() + pronunciationPreviewPlaybackService.stop() + pronunciationPreviewState = nil + refreshMigrationWorkflowIdle() + } else { + installAutoSave() + refreshMigrationWorkflowIdle() + } + } + + func waitForMigrationMaintenanceQuiescence() async { + let previewTasks = Array(pronunciationPreviewTasks.values) + for task in previewTasks { + await task.value + } + + let catalogTaskID = modelCatalogRefreshTaskID + await modelCatalogRefreshTask?.value + if modelCatalogRefreshTaskID == catalogTaskID { + modelCatalogRefreshTask = nil + modelCatalogRefreshTaskID = nil + isRefreshingModelCatalog = false + } + refreshMigrationWorkflowIdle() + } + + private func refreshMigrationWorkflowIdle() { + isMigrationWorkflowIdle = pronunciationPreviewState == nil + && pronunciationPreviewTasks.isEmpty + && modelCatalogRefreshTask == nil + && !isRefreshingModelCatalog + } + + func openAccessibilitySettings() { + guard let url = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" + ) else { + message = L10n.text("settings.error.openAccessibility") + return + } + + NotificationCenter.default.post( + name: .inkletDidOpenPermissionSettings, + object: nil, + userInfo: ["permission": PermissionSettingsDestination.accessibility.rawValue] + ) + NSWorkspace.shared.open(url) + } +} + +extension Notification.Name { + static let appConfigDidSave = Notification.Name("InkletAppConfigDidSave") + static let hotkeyRecordingDidChange = Notification.Name("InkletHotkeyRecordingDidChange") + static let inkletDidOpenPermissionSettings = Notification.Name("InkletDidOpenPermissionSettings") + static let inkletAccessibilityDidBecomeTrusted = Notification.Name("InkletAccessibilityDidBecomeTrusted") + static let inkletDidCompleteOnboarding = Notification.Name("InkletDidCompleteOnboarding") +} diff --git a/Tests/InkletCoreTests/AppCoordinatorSourceTests.swift b/Tests/InkletCoreTests/AppCoordinatorSourceTests.swift index ef5073a..9afcac1 100644 --- a/Tests/InkletCoreTests/AppCoordinatorSourceTests.swift +++ b/Tests/InkletCoreTests/AppCoordinatorSourceTests.swift @@ -264,8 +264,8 @@ final class AppCoordinatorSourceTests: XCTestCase { XCTAssertTrue(automaticGateBlock.contains("migrationWorkflowsAreIdle: migrationWorkflowsAreIdle")) XCTAssertTrue(automaticGateBlock.contains("isSelectingMigrationSource: migrationPresentationModel.phase == .selecting")) XCTAssertTrue(automaticGateBlock.contains("hasModalWindow: NSApp.modalWindow != nil")) - XCTAssertTrue(automaticGateBlock.contains("isSelectionPanelVisible: selectionActionWindowController.isPanelVisible")) - XCTAssertTrue(automaticGateBlock.contains("isSelectionInteractionActive: selectionActionMonitor.isInteractionActive")) + XCTAssertTrue(automaticGateBlock.contains("isSelectionPanelVisible: selectionActions.isPanelVisible")) + XCTAssertTrue(automaticGateBlock.contains("isSelectionInteractionActive: selectionActions.isInteractionActive")) XCTAssertTrue(automaticGateBlock.contains("isMenuTracking: !trackedMenus.isEmpty")) XCTAssertTrue(automaticGateBlock.contains("isUpdateAlertPresenting: updateCheckAlertPresenter.isPresentingAlert")) XCTAssertTrue(automaticGateBlock.contains("isStopping: isStopping")) @@ -349,7 +349,7 @@ final class AppCoordinatorSourceTests: XCTestCase { } func testSelectionCallbacksRegisterWorkSynchronouslyAndIdleSchedulesUpdateGate() throws { - let source = try appCoordinatorSource() + let source = try selectionActionsControllerSource() let candidateStart = try XCTUnwrap(source.range( of: "self.selectionActionMonitor.onCandidateSelection =" )) @@ -381,7 +381,22 @@ final class AppCoordinatorSourceTests: XCTestCase { XCTAssertTrue(interactionBlock.contains("!self.isStopping")) XCTAssertTrue(interactionBlock.contains("!isActive")) XCTAssertEqual( - interactionBlock.components(separatedBy: "automaticUpdatePresentationGate.schedule()").count - 1, + interactionBlock.components(separatedBy: "onInteractionEnded?()").count - 1, + 1 + ) + + let coordinatorSource = try appCoordinatorSource() + let interactionEnded = try sourceBlock( + startingAt: "self.selectionActions.onInteractionEnded =", + endingBefore: "private func makeUpdateCheckCoordinator()", + in: coordinatorSource + ) + try assertTokensAppearInOrder( + ["!self.isStopping", "automaticUpdatePresentationGate.schedule()"], + in: String(interactionEnded) + ) + XCTAssertEqual( + interactionEnded.components(separatedBy: "automaticUpdatePresentationGate.schedule()").count - 1, 1 ) @@ -424,13 +439,20 @@ final class AppCoordinatorSourceTests: XCTestCase { let panelBegin = try XCTUnwrap(migrationBlock.range(of: "await panel.begin()")) XCTAssertLessThan(beginSelecting.lowerBound, finalRefresh.lowerBound) XCTAssertLessThan(finalRefresh.lowerBound, panelBegin.lowerBound) + let workStateCallback = try sourceBlock( + startingAt: "self.selectionActions.onWorkStateChange =", + endingBefore: "self.selectionActions.onInteractionEnded =", + in: source + ) + XCTAssertTrue(workStateCallback.contains("self?.refreshMigrationImportEligibility()")) - let effectsStart = try XCTUnwrap(source.range(of: "private func handleSelectionActionEffects")) - let dismissStart = try XCTUnwrap(source.range( + let selectionSource = try selectionActionsControllerSource() + let effectsStart = try XCTUnwrap(selectionSource.range(of: "private func handleSelectionActionEffects")) + let dismissStart = try XCTUnwrap(selectionSource.range( of: "\n private func handleSelectionDismissRequest", - range: effectsStart.upperBound.. String { + try appSource(named: "AppCoordinator.swift") + } + + private func selectionActionsControllerSource() throws -> String { + try appSource(named: "SelectionActionsController.swift") + } + + private func appSource(named fileName: String) throws -> String { let packageRoot = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - let sourceURL = packageRoot.appendingPathComponent("Sources/InkletApp/AppCoordinator.swift") + let sourceURL = packageRoot.appendingPathComponent("Sources/InkletApp").appendingPathComponent(fileName) return try String(contentsOf: sourceURL, encoding: .utf8) } } diff --git a/Tests/InkletCoreTests/LegacyMigrationAppSourceTests.swift b/Tests/InkletCoreTests/LegacyMigrationAppSourceTests.swift index 0d475ee..3f835c1 100644 --- a/Tests/InkletCoreTests/LegacyMigrationAppSourceTests.swift +++ b/Tests/InkletCoreTests/LegacyMigrationAppSourceTests.swift @@ -33,7 +33,9 @@ final class LegacyMigrationAppSourceTests: XCTestCase { func testSettingsRetainsOneModelAndFreezesAutosaveDuringMigration() throws { let controller = try appSource(named: "SettingsWindowController.swift") - let view = try appSource(named: "SettingsView.swift") + let view = try ["SettingsViewModel.swift", "SettingsView.swift"] + .map { try appSource(named: $0) } + .joined(separator: "\n") let compactController = controller.filter { !$0.isWhitespace } let pronunciationPreview = try sourceScope( startingAt: "func previewPronunciationVoice()", @@ -154,6 +156,7 @@ final class LegacyMigrationAppSourceTests: XCTestCase { func testCoordinatorFreezesAllMutationSurfacesAndRechecksBusyState() throws { let source = try appSource(named: "AppCoordinator.swift") + let selectionSource = try appSource(named: "SelectionActionsController.swift") let importFlow = try sourceScope( startingAt: "private func requestAssistedMigrationImport() async", endingBefore: "private func enterMigrationMaintenance() async", @@ -167,51 +170,54 @@ final class LegacyMigrationAppSourceTests: XCTestCase { let copyTrigger = try sourceScope( startingAt: "private func handleSelectionActionCopyTrigger", endingBefore: "private func handleSelectionActionEffects", - in: source + in: selectionSource ) let scheduledRead = try sourceScope( startingAt: "private func completeScheduledSelectionRead", endingBefore: "private func readSelectedTextForAutomaticSelection", - in: source + in: selectionSource ) let translation = try sourceScope( startingAt: "private func translateCurrentSelection", endingBefore: "private func pronounceCurrentSelection", - in: source + in: selectionSource ) let pronunciation = try sourceScope( startingAt: "private func pronounceSelectionText", endingBefore: "private func restoreSelectionPronunciationReturnState", - in: source + in: selectionSource ) let pronunciationRestore = try sourceScope( startingAt: "private func restoreSelectionPronunciationReturnState", endingBefore: "private func showSelectionPronunciationError", - in: source + in: selectionSource ) XCTAssertTrue(source.contains("private var isMigrationMaintenanceActive")) XCTAssertTrue(source.contains("windowController.isBusy")) XCTAssertTrue(source.contains("settingsController.isMigrationWorkflowIdle")) XCTAssertTrue(source.contains("settingsController.onMigrationWorkflowIdleChange")) - XCTAssertTrue(source.contains("selectionReadTask == nil")) - XCTAssertTrue(source.contains("selectionTranslationTask == nil")) - XCTAssertTrue(source.contains("selectionTTSTask == nil")) - XCTAssertTrue(source.contains("!isSelectionSpeechPlaying")) + XCTAssertTrue(source.contains("selectionActions.isIdle")) + XCTAssertTrue(selectionSource.contains("selectionReadTask == nil")) + XCTAssertTrue(selectionSource.contains("selectionTranslationTask == nil")) + XCTAssertTrue(selectionSource.contains("selectionTTSTask == nil")) + XCTAssertTrue(selectionSource.contains("!isSelectionSpeechPlaying")) XCTAssertTrue(source.contains("guard canRequestAssistedMigration")) XCTAssertGreaterThanOrEqual( source.components(separatedBy: "guard canRequestAssistedMigration").count - 1, 2 ) XCTAssertTrue(source.contains("hotkeyManager.unregister()")) - XCTAssertTrue(source.contains("selectionActionMonitor.stop()")) + XCTAssertTrue(source.contains("selectionActions.enterMigrationMaintenance()")) + XCTAssertTrue(selectionSource.contains("selectionActionMonitor.stop()")) XCTAssertTrue(source.contains("await settingsController.waitForMigrationMaintenanceQuiescence()")) XCTAssertTrue(source.contains("await windowController.cancelForMigrationMaintenance()")) - XCTAssertTrue(source.contains("selectionReadTask?.cancel()")) - XCTAssertTrue(source.contains("selectionTranslationTask?.cancel()")) - XCTAssertTrue(source.contains("selectionTTSTask?.cancel()")) - XCTAssertTrue(source.contains("speechPlaybackService.stop()")) + XCTAssertTrue(selectionSource.contains("selectionReadTask?.cancel()")) + XCTAssertTrue(selectionSource.contains("selectionTranslationTask?.cancel()")) + XCTAssertTrue(selectionSource.contains("selectionTTSTask?.cancel()")) + XCTAssertTrue(selectionSource.contains("speechPlaybackService.stop()")) XCTAssertTrue(source.contains("guard !isStopping, !isMigrationMaintenanceActive")) + XCTAssertTrue(selectionSource.contains("guard !isStopping, !isMigrationMaintenanceActive")) XCTAssertTrue(copyTrigger.contains("let task = Task")) XCTAssertTrue(copyTrigger.contains("selectionTaskRegistry.register(task, id: taskID)")) try assertTokensAppearInOrder( @@ -225,6 +231,7 @@ final class LegacyMigrationAppSourceTests: XCTestCase { try assertTokensAppearInOrder( [ "isMigrationMaintenanceActive = true", + "selectionActions.enterMigrationMaintenance()", "await windowController.cancelForMigrationMaintenance()", "await settingsController.waitForMigrationMaintenanceQuiescence()", ], @@ -260,7 +267,9 @@ final class LegacyMigrationAppSourceTests: XCTestCase { } func testPopoverExposesBusyAndMaintenanceCancellation() throws { - let view = try appSource(named: "InkletPopoverView.swift") + let view = try ["InkletPopoverViewModel.swift", "InkletPopoverView.swift"] + .map { try appSource(named: $0) } + .joined(separator: "\n") let controller = try appSource(named: "InkletPopoverWindowController.swift") XCTAssertTrue(view.contains("var isBusy: Bool")) @@ -332,7 +341,7 @@ final class LegacyMigrationAppSourceTests: XCTestCase { let source = try appSource(named: "AppCoordinator.swift") let delegate = try sourceScope( startingAt: "final class AppDelegate", - endingBefore: "private enum SelectionPronunciationReturnState", + endingBefore: "enum UpdateCheckMenuConfiguration", in: source ) diff --git a/Tests/InkletCoreTests/SelectionActionMonitorSourceTests.swift b/Tests/InkletCoreTests/SelectionActionMonitorSourceTests.swift index 1735318..2c3f27e 100644 --- a/Tests/InkletCoreTests/SelectionActionMonitorSourceTests.swift +++ b/Tests/InkletCoreTests/SelectionActionMonitorSourceTests.swift @@ -231,7 +231,7 @@ final class SelectionActionMonitorSourceTests: XCTestCase { func testSelectionEventDiagnosticsAreRateLimitedWithoutReadingContent() throws { let monitorSource = try monitorSource() let diagnosticsSource = try appSource(named: "SelectionActionDiagnostics.swift") - let coordinatorSource = try appSource(named: "AppCoordinator.swift") + let coordinatorSource = try appSource(named: "SelectionActionsController.swift") XCTAssertTrue(diagnosticsSource.contains("SelectionActionDiagnosticRateLimiter")) XCTAssertTrue(diagnosticsSource.contains("static func logRateLimited")) diff --git a/Tests/InkletCoreTests/SettingsViewSourceTests.swift b/Tests/InkletCoreTests/SettingsViewSourceTests.swift index 9654a13..d4e6819 100644 --- a/Tests/InkletCoreTests/SettingsViewSourceTests.swift +++ b/Tests/InkletCoreTests/SettingsViewSourceTests.swift @@ -5,9 +5,7 @@ import Security final class SettingsViewSourceTests: XCTestCase { func testTemperatureSettingIsRemoved() throws { - let packageRoot = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - let sourceURL = packageRoot.appendingPathComponent("Sources/InkletApp/SettingsView.swift") - let source = try String(contentsOf: sourceURL, encoding: .utf8) + let source = try settingsViewSource() XCTAssertFalse(source.contains("settings.row.temperature")) XCTAssertFalse(source.contains("settings.help.temperature")) @@ -228,10 +226,15 @@ final class SettingsViewSourceTests: XCTestCase { XCTAssertFalse(rowBlock.contains("settings.history.copyOriginal")) } + /// The settings sources in their original single-file order: view model, view, controls. private func settingsViewSource() throws -> String { let packageRoot = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - let sourceURL = packageRoot.appendingPathComponent("Sources/InkletApp/SettingsView.swift") - return try String(contentsOf: sourceURL, encoding: .utf8) + return try ["SettingsViewModel.swift", "SettingsView.swift", "SettingsControls.swift"].map { + try String( + contentsOf: packageRoot.appendingPathComponent("Sources/InkletApp").appendingPathComponent($0), + encoding: .utf8 + ) + }.joined(separator: "\n") } private func sourceScope( diff --git a/Tests/InkletCoreTests/WritingDictationLocalizationTests.swift b/Tests/InkletCoreTests/WritingDictationLocalizationTests.swift index 9b329e1..7f97a18 100644 --- a/Tests/InkletCoreTests/WritingDictationLocalizationTests.swift +++ b/Tests/InkletCoreTests/WritingDictationLocalizationTests.swift @@ -187,7 +187,9 @@ final class WritingDictationLocalizationTests: XCTestCase { } func testDictationAccessibilityUndoAndErrorKeysAreWiredToProductionSurfaces() throws { - let popover = try sourceFile("Sources/InkletApp/InkletPopoverView.swift") + let popover = try ["InkletPopoverViewModel.swift", "InkletPopoverView.swift", "InkletTextView.swift"] + .map { try sourceFile("Sources/InkletApp/\($0)") } + .joined(separator: "\n") let transaction = try sourceFile("Sources/InkletApp/DictationEditorTransaction.swift") let controller = try sourceFile("Sources/InkletApp/InkletPopoverWindowController.swift") let recorder = try sourceFile("Sources/InkletApp/AudioRecorder.swift") diff --git a/Tests/InkletCoreTests/WritingModeLauncherSourceTests.swift b/Tests/InkletCoreTests/WritingModeLauncherSourceTests.swift index 528763d..2031ce9 100644 --- a/Tests/InkletCoreTests/WritingModeLauncherSourceTests.swift +++ b/Tests/InkletCoreTests/WritingModeLauncherSourceTests.swift @@ -35,7 +35,7 @@ final class WritingModeLauncherSourceTests: XCTestCase { of: "private final class InkletNativeTextView" )) let representableStart = try XCTUnwrap(source.range( - of: "private struct InkletTextView", + of: "struct InkletTextView", range: nativeTextViewStart.upperBound.. String { let packageRoot = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - let sourceURL = packageRoot.appendingPathComponent("Sources/InkletApp/InkletPopoverView.swift") - return try String(contentsOf: sourceURL, encoding: .utf8) + return try ["InkletPopoverViewModel.swift", "InkletPopoverView.swift", "InkletTextView.swift"].map { + try String( + contentsOf: packageRoot.appendingPathComponent("Sources/InkletApp").appendingPathComponent($0), + encoding: .utf8 + ) + }.joined(separator: "\n") } private func pickerSource() throws -> String { diff --git a/Tests/InkletCoreTests/WritingPopoverKeyboardPolicyTests.swift b/Tests/InkletCoreTests/WritingPopoverKeyboardPolicyTests.swift index d5eb20b..9a679a0 100644 --- a/Tests/InkletCoreTests/WritingPopoverKeyboardPolicyTests.swift +++ b/Tests/InkletCoreTests/WritingPopoverKeyboardPolicyTests.swift @@ -106,7 +106,7 @@ final class WritingPopoverKeyboardPolicyTests: XCTestCase { let source = try popoverSource() let nativeStart = try XCTUnwrap(source.range(of: "private final class InkletNativeTextView")) let representableStart = try XCTUnwrap(source.range( - of: "private struct InkletTextView", + of: "struct InkletTextView", range: nativeStart.upperBound.. String { let packageRoot = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - return try String( - contentsOf: packageRoot.appendingPathComponent("Sources/InkletApp/InkletPopoverView.swift"), - encoding: .utf8 - ) + return try ["InkletPopoverViewModel.swift", "InkletPopoverView.swift", "InkletTextView.swift"].map { + try String( + contentsOf: packageRoot.appendingPathComponent("Sources/InkletApp").appendingPathComponent($0), + encoding: .utf8 + ) + }.joined(separator: "\n") } private func windowControllerSource() throws -> String {