From 1bafbdce5c40981d2ee4f250dfc0e81ce3d3a933 Mon Sep 17 00:00:00 2001 From: Senad Date: Fri, 2 Oct 2026 16:07:46 +0200 Subject: [PATCH 1/4] Add augmented translation for incoming messages and replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Translation (off by default) translates chats in apps the user picks (WhatsApp, Teams, Telegram, Slack, Outlook, Mail, Messages): - Incoming: every 2 s, and right after scrolling, TranslationCoordinator captures the frontmost chat window (new captureActiveWindow), reads it with Vision OCR using language hints, groups lines into messages (MessageBlockGrouper: conversation area above the reply field, timestamps and low-confidence lines dropped), and draws each translation under its message in a click-through overlay. Unchanged windows are skipped by a pixel fingerprint. - Replies: when the user writes in their reading language in a chat whose language is known, the reply is translated after a 700 ms pause and shown in a card under the field; ⌃⌥T (recordable) replaces the draft via select-all + paste with the clipboard restored. Never sends. - Engines: Apple Translation (TranslationSession(installedSource:target:), on-device) for its ~20 languages; for pairs it lacks, such as Macedonian, the selected Open Source model with a few-shot prompt, as the bundled models are base models. Results are cached in memory only. - TranslationLanguagePolicy is conservative (≥12 letters, ≥0.8 confidence, no links or numbers) and recognizes Macedonian by its alphabet, since Natural Language reports it as Bulgarian. Respects Cotabby's own gates (globally on, not paused, app not disabled, Low Power Mode), never reads secure fields, and stores nothing. Co-Authored-By: Claude Opus 5.5 --- Cotabby.xcodeproj/project.pbxproj | 116 +++++++ .../Coordinators/SettingsCoordinator.swift | 10 +- .../Translation/TranslationCoordinator.swift | 304 ++++++++++++++++++ Cotabby/App/Core/AppDelegate.swift | 2 + Cotabby/App/Core/CotabbyAppEnvironment.swift | 36 ++- .../Translation/TranslationModels.swift | 96 ++++++ .../TranslationOverlayController.swift | 162 ++++++++++ .../Suggestion/SuggestionInserter.swift | 30 ++ .../Translation/TranslationEngines.swift | 149 +++++++++ .../Translation/TranslationHotkeyTap.swift | 84 +++++ .../TranslationPreferencesStore.swift | 88 +++++ .../Services/Visual/ScreenTextExtractor.swift | 11 +- .../Visual/WindowScreenshotService.swift | 41 +++ .../ConversationLanguageTracker.swift | 40 +++ .../FewShotTranslationPrompt.swift | 70 ++++ .../Translation/MessageBlockGrouper.swift | 99 ++++++ .../Translation/TranslationCache.swift | 49 +++ .../TranslationLanguagePolicy.swift | 96 ++++++ .../Settings/Panes/TranslationPaneView.swift | 211 ++++++++++++ Cotabby/UI/Settings/SettingsCategory.swift | 7 +- .../UI/Settings/SettingsContainerView.swift | 4 + Cotabby/UI/Settings/SettingsIndex.swift | 21 ++ .../Translation/TranslationCoreTests.swift | 214 ++++++++++++ 23 files changed, 1936 insertions(+), 4 deletions(-) create mode 100644 Cotabby/App/Coordinators/Translation/TranslationCoordinator.swift create mode 100644 Cotabby/Models/Translation/TranslationModels.swift create mode 100644 Cotabby/Services/Presentation/TranslationOverlayController.swift create mode 100644 Cotabby/Services/Translation/TranslationEngines.swift create mode 100644 Cotabby/Services/Translation/TranslationHotkeyTap.swift create mode 100644 Cotabby/Services/Translation/TranslationPreferencesStore.swift create mode 100644 Cotabby/Support/Translation/ConversationLanguageTracker.swift create mode 100644 Cotabby/Support/Translation/FewShotTranslationPrompt.swift create mode 100644 Cotabby/Support/Translation/MessageBlockGrouper.swift create mode 100644 Cotabby/Support/Translation/TranslationCache.swift create mode 100644 Cotabby/Support/Translation/TranslationLanguagePolicy.swift create mode 100644 Cotabby/UI/Settings/Panes/TranslationPaneView.swift create mode 100644 CotabbyTests/Support/Translation/TranslationCoreTests.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index ddd90ac8..b5c2d9de 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 0209B7FDA016D12F91C1D197 /* BlockBreakAlignment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82420F9505E69AE2ADE9F583 /* BlockBreakAlignment.swift */; }; 023A1A2A26B192E244192D8D /* TrailingDuplicationFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E824A5614BC7E7FCA05AD29 /* TrailingDuplicationFilterTests.swift */; }; 02A837BD3861FD72B3E99D0F /* BaseCompletionPromptRendererTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 612CA2988BF19FAE0114165A /* BaseCompletionPromptRendererTests.swift */; }; + 02BD1E581B8CB51A8912D93A /* TranslationLanguagePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C793AD0B4DC99E072D292EDB /* TranslationLanguagePolicy.swift */; }; 02C80969B8D6A0DBDC619FCA /* FocusSessionScopedCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 487D8C5521411FC6845D4A51 /* FocusSessionScopedCacheTests.swift */; }; 02DA43985CDAE6859014F14F /* SuggestionOverlayPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D598CC3134879999D567455 /* SuggestionOverlayPresenter.swift */; }; 02DABCA8259C5C811D245239 /* OpenAICompatibleCredentialStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B23950F38B1B3C8A5CC5BE5 /* OpenAICompatibleCredentialStore.swift */; }; @@ -70,7 +71,9 @@ 0E6692D2A7AEEDF72FCF1AE2 /* ExtendedContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0447A27B293394D9379C48 /* ExtendedContextTests.swift */; }; 1010A070B4E0F0A2FDF45112 /* CompletionContentPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA5F007F5185BD597550607 /* CompletionContentPolicyTests.swift */; }; 102E1EDC04636AFB607A8249 /* ActiveSuggestionSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F95814016D80E0A23974F0 /* ActiveSuggestionSession.swift */; }; + 1038CF642829E77824F2B25D /* TranslationLanguagePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C793AD0B4DC99E072D292EDB /* TranslationLanguagePolicy.swift */; }; 105187F185A579BC67CA2A93 /* AXHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B5D1A7D938F633C6EE094F7 /* AXHelper.swift */; }; + 107DF2BD1B2616BD36E3C1EC /* TranslationCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F4F9870325E80C563192E40 /* TranslationCache.swift */; }; 1092E77D9D92DAC4808ABF9A /* GhostFontResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BB26526298A85454330591 /* GhostFontResolver.swift */; }; 109F9BD7E1AE3E7FB0C712AA /* GhostTextLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EB202C5AD6079174A14330C /* GhostTextLayoutTests.swift */; }; 1104ABD01283774EDF249EF0 /* LLMIOFileHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56A2F75C66BECA61A722D3FB /* LLMIOFileHandler.swift */; }; @@ -158,6 +161,7 @@ 24A641C63FB5DB47C2116A5C /* OpenAICompatibleEndpointConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40BA54CDA6F5887BEC6C668 /* OpenAICompatibleEndpointConfigurationTests.swift */; }; 24D5BE34BE7B7B72A93C6A9F /* BundledRuntimeLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1AB5EBB44EE64EB3EB1426F /* BundledRuntimeLocator.swift */; }; 24E0F4F0EC9148FB409FD112 /* InlinePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BC705A82669AEFBC704F59 /* InlinePreviewView.swift */; }; + 24EE74F6BE91FC7AF04AB8A7 /* TranslationOverlayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 683945CBE9B88F1A2D28A254 /* TranslationOverlayController.swift */; }; 257302D78C5AE9951C63FCEE /* SuggestionAnchorCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE7DB9EE77511823EDA7B52E /* SuggestionAnchorCacheTests.swift */; }; 25F7E6EC713F8F71DEEEAAA3 /* SystemUIFocusShadowPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC384A90F3D8B71584B3449 /* SystemUIFocusShadowPolicy.swift */; }; 26067524E60D738791E983CD /* SOURCES.md in Resources */ = {isa = PBXBuildFile; fileRef = 054987E76CA9D1FA4F81EA8F /* SOURCES.md */; }; @@ -189,6 +193,7 @@ 2DF5A3826AAB99C279EBB8DE /* InputMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B81DD30EB657368AACE9625A /* InputMonitor.swift */; }; 2E0AEA2199864F46F86FC0C0 /* TextDirectionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD33A18BECBD7292ACD9F8B8 /* TextDirectionDetector.swift */; }; 2E3DEB7E89D0146274596F2E /* SettingsContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0CE9AB1286367BA2E82392 /* SettingsContainerView.swift */; }; + 2E5F38501661C60DE7EFD980 /* FewShotTranslationPrompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F20E1AB04686FB097239D77 /* FewShotTranslationPrompt.swift */; }; 2EDFE6F33018D2D43D5CB813 /* MacroEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 974A8708D2006767BD76862A /* MacroEngine.swift */; }; 2EF09816678369E9CB1B63B8 /* ShortcutModifierMaskNSEventFlagsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C354F219D47B96E53D4DE99F /* ShortcutModifierMaskNSEventFlagsTests.swift */; }; 2F7C69992620D669D794A6F0 /* CaretWordContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D274F82A32C7FB404964428 /* CaretWordContextTests.swift */; }; @@ -206,6 +211,7 @@ 32F8F271C53D0F3782C7375D /* ChromiumAccessibilityEnabler.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB13342992EEF2BDEEAB1236 /* ChromiumAccessibilityEnabler.swift */; }; 332F2AE6277FB6E871589E4D /* MacroControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C64BFD7273D1B637AD880AA /* MacroControllerTests.swift */; }; 3368DFBAA36527B7BB09EA67 /* ApplicationBundleMetadataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C151BF4D39485E5CFACFECB /* ApplicationBundleMetadataTests.swift */; }; + 33765A26561DCD843A03205E /* TranslationPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B85ECBA936DAC06F98552271 /* TranslationPaneView.swift */; }; 33EC776945F8CE612A8E85A3 /* CustomRulesEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E7D1184F0BC707F0A9148DD /* CustomRulesEditor.swift */; }; 35173B2E2394D942043FB086 /* CompletionContentPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 384D85534F3C2F40E2FD8781 /* CompletionContentPolicy.swift */; }; 35F1681148A324CBEC797BAF /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DE038A6DF4A16B3EB5CEBD3 /* MenuBarView.swift */; }; @@ -287,6 +293,7 @@ 477488B0FDAD0A9B76DA9848 /* OpenAICompatibleAPIClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA17364BAFFA56E7DDF682D /* OpenAICompatibleAPIClientTests.swift */; }; 47D099DE9608A174BB0C3C3F /* OnboardingTemplateFeatureListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 771EAA74003DAD7D4F3FD199 /* OnboardingTemplateFeatureListTests.swift */; }; 47EFFC4AEBA2848C933BB77C /* PromptSectionBudget.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE89BEAF57DF3A287DF56F1 /* PromptSectionBudget.swift */; }; + 4849A63A5DF120418DC4AA1A /* TranslationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1992990B3729259C031994B3 /* TranslationCoordinator.swift */; }; 4882DF865737ECDC4925F44B /* GeneralPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07480CE96ED0EBD94817C6B1 /* GeneralPaneView.swift */; }; 48A2F371756723299597F5A1 /* TypoGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCE0E0C38A776383B11809 /* TypoGate.swift */; }; 494DA4B28C173F2B3E18DBF9 /* EmojiPickerControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD02052E51AFE56BECB92E4 /* EmojiPickerControllerTests.swift */; }; @@ -301,11 +308,13 @@ 4D2C1A0B4FBEF95CBFB9F481 /* ModelFileValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0259095D0A3E9B8DC26CB4B0 /* ModelFileValidatorTests.swift */; }; 4D32F003F78EAAA984E77D83 /* LLMIOFileHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56A2F75C66BECA61A722D3FB /* LLMIOFileHandler.swift */; }; 4D4F02A7187C324A4B43A974 /* MenuBarStatusLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB051B5B72986C03AB1EE428 /* MenuBarStatusLabelView.swift */; }; + 4D8EAA5D687A54D5428258F6 /* TranslationPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B85ECBA936DAC06F98552271 /* TranslationPaneView.swift */; }; 4E2DEFF3CA51E8B160B802CA /* SettingsIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34EF2406E7A384F3325AAF9A /* SettingsIndex.swift */; }; 4E7F611941736F526C3B9C1B /* CotabbyBrand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3C1C9A9BF1F74C16E4401EC /* CotabbyBrand.swift */; }; 4E8268A5C44B9E5945EBE59D /* AppUpdateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A7A9C39A0713BD3B442C9AC /* AppUpdateManager.swift */; }; 4ED862FE30A8DAE69C9EC7EF /* AcknowledgementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7024D32C43EAD2C5F8689B2D /* AcknowledgementsView.swift */; }; 4F3CE71B97663B8025779F9C /* fr.txt in Resources */ = {isa = PBXBuildFile; fileRef = 5DCA3E46621815A19300365F /* fr.txt */; }; + 4FEF448AF18494F11C816D80 /* TranslationModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB05DAD47115DAEF0BFE11AF /* TranslationModels.swift */; }; 507E7BCCD189A64C3F8ECB79 /* HomePaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1123AB515110BD0CBA39490 /* HomePaneView.swift */; }; 50CDF4E6CD754A74DDCF67FC /* MacroTriggerStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CDB11D96F343194B390321 /* MacroTriggerStateMachine.swift */; }; 50D12741A639F8A367239FBE /* TypefaceEvidenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B45E958CF9BE63A728E273 /* TypefaceEvidenceTests.swift */; }; @@ -327,6 +336,7 @@ 53F946FF09038C1C751C6E53 /* FoundationModelPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A051152D3C503A2339BF0FA /* FoundationModelPromptRenderer.swift */; }; 54091F97C3F64ABB737DC76B /* LanguageCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6EA89178F52F6D5B90CBFE /* LanguageCatalog.swift */; }; 540C1F9A70A5C7DB988FBDD4 /* VisualContextExcerptSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47457FED99065966328047EB /* VisualContextExcerptSelector.swift */; }; + 54199DD4D6FEABFBA4ED1AED /* TranslationEngines.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143AB19919CAE76F0F1B6DCB /* TranslationEngines.swift */; }; 54290B1569A7568809014A23 /* SystemSettingsWindowLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177B69917649DAD33BC90388 /* SystemSettingsWindowLocator.swift */; }; 543B45F8FF4A6AFCB9286521 /* Sparkle-LICENSE.txt in Resources */ = {isa = PBXBuildFile; fileRef = 70C489EC7EA50C2B103A6B09 /* Sparkle-LICENSE.txt */; }; 5461DD0133FE632B2EB4936A /* BrowserAppDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 589029534F91D2BE26A65263 /* BrowserAppDetectorTests.swift */; }; @@ -335,6 +345,7 @@ 55243B4055E8067FE3483164 /* FieldStyleCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 544C25CEF5C997F920C7BF0F /* FieldStyleCache.swift */; }; 555501CEB124DFDF657EACA4 /* TypingCadence.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7014E6E969FD4579028BAC4 /* TypingCadence.swift */; }; 556197D64680237CBCCCDD9F /* OCRTextHygiene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7736E6B390C822F91CC1DA99 /* OCRTextHygiene.swift */; }; + 559451AFAA8CF6AE2A350552 /* TranslationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1992990B3729259C031994B3 /* TranslationCoordinator.swift */; }; 55DAFC9D06629C23A6868565 /* WelcomeTemplateStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B53ED6F2F3354FB3E6065804 /* WelcomeTemplateStepView.swift */; }; 56471188E5F78EE0785D7BF7 /* CotabbyInference-MIT.txt in Resources */ = {isa = PBXBuildFile; fileRef = 2472C76E96009300EBF038D4 /* CotabbyInference-MIT.txt */; }; 564BA8F230F45E378D26B36C /* GhostSpaceBoundary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D99C3973D57C7F53416CFBF6 /* GhostSpaceBoundary.swift */; }; @@ -434,6 +445,7 @@ 6F2FE689BCA50BEAE80AC6F4 /* ShortcutsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB630F9814388203DD1CA2EC /* ShortcutsPaneView.swift */; }; 6F3B43828817C2C303E8D16E /* InlineCommandCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65AF0FF1CE97A0761ADAFEB8 /* InlineCommandCoordinatorTests.swift */; }; 6FA8CBA189210C9821B03656 /* TokenHealingBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50AECDF57800BBB6617620D5 /* TokenHealingBuffer.swift */; }; + 6FEB82EB34BB3C89EF656F9E /* MessageBlockGrouper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B77C56D304F76441BD79ED08 /* MessageBlockGrouper.swift */; }; 7008657841ECD4F06A46A9DE /* PromptPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC4D7129517D678D95D0BC2 /* PromptPolicyTests.swift */; }; 7097797640547007DF552F4E /* SuggestionOverlayStabilityGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49DB247AACE1B28D45DB52B0 /* SuggestionOverlayStabilityGate.swift */; }; 70A40CE02EF0AF361263BE7E /* EmojiPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871C7A27BAB5A510273D0A19 /* EmojiPickerView.swift */; }; @@ -453,6 +465,7 @@ 735C2E64CA51F58098B30A0D /* it.txt in Resources */ = {isa = PBXBuildFile; fileRef = 0397F1DACB094A0F6A66BC0E /* it.txt */; }; 73A1C073056E07657992AE5A /* CaretTokenPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EBE00782EE6813AF3224CD5 /* CaretTokenPosition.swift */; }; 73B7C4D5143F7AEC321BC873 /* PermissionAndContextModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AEBBBAB9588AA8EDBA41520 /* PermissionAndContextModelTests.swift */; }; + 7418E40356268EA32FF7E2A7 /* TranslationPreferencesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D257852738F461DC1049FF5 /* TranslationPreferencesStore.swift */; }; 744B06C2488156B178675615 /* PermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BF316556FDA64CB8AD07B6 /* PermissionManager.swift */; }; 749C4953D3AFE86AFABA968C /* OnboardingTemplateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C5E3A714F5A7818337101C /* OnboardingTemplateTests.swift */; }; 74C07F4568C4FAE1C344E789 /* TextDirectionDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BD4DE5D9D8C9BB66B78633 /* TextDirectionDetectorTests.swift */; }; @@ -463,6 +476,7 @@ 757696BA01AE112E3B1241EC /* CaretLagPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40016262B83C6C55B6593C7 /* CaretLagPolicy.swift */; }; 764F157C87F3155FBE698A3A /* SuggestionConversationIsolationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022734DBBC384802C7CDB97D /* SuggestionConversationIsolationTests.swift */; }; 765A4E8212962910AA3FBA87 /* InputSuppressionControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DBD75CAF6CAC849D5ED9FA4 /* InputSuppressionControllerTests.swift */; }; + 765CA66166EBB88BA38E33E0 /* ConversationLanguageTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09F14F4E5FCCFC79ACC22934 /* ConversationLanguageTracker.swift */; }; 76933444A4EF1F1CE5039E9E /* ContextLivePreviewField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8368940C6C15CC3F932B8DFC /* ContextLivePreviewField.swift */; }; 76B2F9D2C93958F10F65D26F /* SuggestionSessionReconciliationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EEDCE7B9BC5B22080B3A8DF /* SuggestionSessionReconciliationTests.swift */; }; 76D834C8C72F8AFD737D8433 /* TerminalAppDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52C557203A5A5F08E68BB11B /* TerminalAppDetectorTests.swift */; }; @@ -500,6 +514,7 @@ 81073963BC57B5CA9151B0EC /* CustomRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADC3A68EF5FD9A1E6B5D249 /* CustomRulesTests.swift */; }; 8129ED85DFCF718BFF855D7C /* PixelCaretLocatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52263602D578987A7ADC776 /* PixelCaretLocatorTests.swift */; }; 81870F2D46C12CA1E6B19523 /* CurrentWordExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1AAC6367483952AB7D59A62 /* CurrentWordExtractor.swift */; }; + 81AEE27758546FC4F77BCE77 /* TranslationCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D600F38FBA77948D07934173 /* TranslationCoreTests.swift */; }; 81FCE906A26ED284E8DD46D5 /* ModelDownloadSessionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEFBEB715AF043D3315B6656 /* ModelDownloadSessionDelegate.swift */; }; 82330263D4919D89C293F737 /* DebouncePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B61134664FF616732705A89B /* DebouncePolicyTests.swift */; }; 82AE1426097F86C73537D6CF /* SuggestionResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8543EBA09E11A5066880CC91 /* SuggestionResult.swift */; }; @@ -524,12 +539,14 @@ 85DE2B1A62BA24DA615A628E /* HostBundledFontRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA2CFC322EE56151646E745 /* HostBundledFontRegistryTests.swift */; }; 85EDFFAB94CB8577CDBAF49B /* SuggestionDismissalMemoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6809474A7C1B57F1D13AA6F /* SuggestionDismissalMemoryTests.swift */; }; 85FCDC35AC29F4A89E30F559 /* BundledRuntimeLocatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBD7B94FF42D8C538C328E80 /* BundledRuntimeLocatorTests.swift */; }; + 86C1D546D6DE1777DBE6CCC4 /* TranslationHotkeyTap.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBF09B3CEB692504AE84F41E /* TranslationHotkeyTap.swift */; }; 8756E3050C45106D7AD188AA /* MacroTriggerStateMachineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C9DBC2A94B6E805EDAFEA5 /* MacroTriggerStateMachineTests.swift */; }; 87C0A4F8038F912CDFF5C071 /* supported-model-regressions.json in Resources */ = {isa = PBXBuildFile; fileRef = 409BB6D0152A1A9B37DE12F3 /* supported-model-regressions.json */; }; 87CE40ADBFCDBFD266BA9E3F /* InlinePreviewPanelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 924CAA5E25C596A9FAB7602B /* InlinePreviewPanelController.swift */; }; 888FF8B27F8545F1EE9BBA93 /* OpenAICompatibleSuggestionEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B241105A4F9C29DD8C8D4A91 /* OpenAICompatibleSuggestionEngine.swift */; }; 88AAFB21F06EE37A392081E1 /* SuggestionSessionReconciler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CADFBB03C10309B9048C8E9 /* SuggestionSessionReconciler.swift */; }; 88B5413CB02F1C53E81DC683 /* HostFaceMemoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 623D8B3C927836D7C4002AB8 /* HostFaceMemoryTests.swift */; }; + 88E36607074D2607730360C9 /* TranslationPreferencesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D257852738F461DC1049FF5 /* TranslationPreferencesStore.swift */; }; 89329024F050602EFBC7CC6B /* FocusTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FDF029F7828CAF3FE8850 /* FocusTracker.swift */; }; 89B0199B226B7B6FCEBBBAC3 /* SuggestionCoordinatorRigTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2471F967A05EB4C8BF7CD242 /* SuggestionCoordinatorRigTestCase.swift */; }; 8AE7B4DE4650D0DA8F6EFFE7 /* GhostTextLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C0792BF971B702DE0A095E /* GhostTextLayout.swift */; }; @@ -619,6 +636,7 @@ A16DEE62FB45155A10E5DF74 /* SuggestionCoordinator+HostMarkedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DAD5637347E83DDCF515568 /* SuggestionCoordinator+HostMarkedText.swift */; }; A1712224D1E7273EDA047544 /* SuggestionCoordinatorWordCompletionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 867188F95EB41AB11AA8862B /* SuggestionCoordinatorWordCompletionTests.swift */; }; A18051D870C0BEF082053D2C /* GhostFontResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BB26526298A85454330591 /* GhostFontResolver.swift */; }; + A1807CDAA7D835525D7B7870 /* TranslationHotkeyTap.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBF09B3CEB692504AE84F41E /* TranslationHotkeyTap.swift */; }; A1A612C90221E0FE1195754A /* SettingsCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0AEFF86F8210CBE7CFCBAD /* SettingsCategory.swift */; }; A1C4151AAE1CC55282C136BB /* GhostSpaceBoundary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D99C3973D57C7F53416CFBF6 /* GhostSpaceBoundary.swift */; }; A1D47F271E70B4FF24FA9E77 /* PermissionHostAppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB3DF187FA7AE5A61B035F6 /* PermissionHostAppTests.swift */; }; @@ -672,6 +690,7 @@ AD1BCDC0809B413FEC5F115B /* GhostTextPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29163A3D0BB0F11EEAC2985A /* GhostTextPanelView.swift */; }; AD7940C54015532D2E8973DD /* CompositionInputModeClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9316289DD11A72D3EAC0A929 /* CompositionInputModeClassifier.swift */; }; ADB10BCA76295920FE4A9FE1 /* SuggestionFadeInPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2B351B5FDF077F8FF49A9A7 /* SuggestionFadeInPolicy.swift */; }; + ADDA3DD1B4DD52B898774D5E /* TranslationModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB05DAD47115DAEF0BFE11AF /* TranslationModels.swift */; }; AE5D7E6E784F22EC71060E7E /* HostZoomLadder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B09274BE9500E3C41C18B2 /* HostZoomLadder.swift */; }; AE739989AC401113B99018BC /* SuggestionCoordinator+Input.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FFA31F5FA8D164CB6C30D06 /* SuggestionCoordinator+Input.swift */; }; AE79541055252CA69CFD5DD7 /* CaretWordContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 976E3D6790D2A3594BCEEA23 /* CaretWordContext.swift */; }; @@ -734,6 +753,7 @@ C059BDAD7E8F82B058201A87 /* OnboardingTemplateRecommenderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FFD3AFACAAC963DAF59D153 /* OnboardingTemplateRecommenderTests.swift */; }; C080D50FA0ED1F32C92BCC36 /* HuggingFaceModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D2E806C1915514651343055 /* HuggingFaceModelsTests.swift */; }; C0B833234748E82D3382631A /* emoji.json in Resources */ = {isa = PBXBuildFile; fileRef = C379D77029D6E88C8C1B9AF7 /* emoji.json */; }; + C0FA647FFD677D577859D0FC /* TranslationEngines.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143AB19919CAE76F0F1B6DCB /* TranslationEngines.swift */; }; C10A7F3968C1A0B24F1C479D /* SwiftLog-NOTICE.txt in Resources */ = {isa = PBXBuildFile; fileRef = 1788B4543337A9E8388B8F6F /* SwiftLog-NOTICE.txt */; }; C14049A1A7C33F8CADDB3675 /* EmojiUsageStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3D991D3536ED989C89AF44D /* EmojiUsageStoreTests.swift */; }; C1ADF50B2D3A3A05CD0FFA68 /* LlamaEvalScoring.swift in Sources */ = {isa = PBXBuildFile; fileRef = 202086735E28B96BBCE55E6B /* LlamaEvalScoring.swift */; }; @@ -778,6 +798,7 @@ C8682DF05F0B3C3950FB64B1 /* SystemMetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B966EA885CB107161FC3006 /* SystemMetricsStore.swift */; }; C8A3B724BB54A1209583B78B /* VisualContextStartCoalescer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF8F1A40EAA82773C07F8B9 /* VisualContextStartCoalescer.swift */; }; C8A48355F3821EBD3D964749 /* SignOffCueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F19230243E3B5DE5A76244C /* SignOffCueTests.swift */; }; + C8A90B93D5BD4B7CFF175670 /* FewShotTranslationPrompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F20E1AB04686FB097239D77 /* FewShotTranslationPrompt.swift */; }; C8B5E45A6F45B307C067CF61 /* SurfaceContextComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27B03589D82D0ADCF41B9CD0 /* SurfaceContextComposer.swift */; }; C95343A5A8496627F44233F0 /* InsertedTextAdvance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8C1A4EC935BB837432BAF2 /* InsertedTextAdvance.swift */; }; CA959F082DB2C6E95C5743C8 /* GhostBaselinePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25782BF21045F20B9721DB90 /* GhostBaselinePolicyTests.swift */; }; @@ -789,6 +810,7 @@ CCA8847AD15933EC1EB2C238 /* SuggestionFadeInPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2B351B5FDF077F8FF49A9A7 /* SuggestionFadeInPolicy.swift */; }; CCE3A8C0CDB6F493D61131F6 /* BrowserAppDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 266C8BF167118855C56FC7F4 /* BrowserAppDetector.swift */; }; CCFE98D5CB3EEFE56B0704C7 /* StaticTextRunWalkThrottleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15AEB98CEE34847D9A7B2BCA /* StaticTextRunWalkThrottleTests.swift */; }; + CD0C872DDB22D31CC6A169E9 /* ConversationLanguageTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09F14F4E5FCCFC79ACC22934 /* ConversationLanguageTracker.swift */; }; CD5740A7732F4ACA73CB3F64 /* SystemSettingsWindowLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177B69917649DAD33BC90388 /* SystemSettingsWindowLocator.swift */; }; CDDEF3C29AEACA986034C01C /* SuggestionEngineModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB1A3F7D6C0FF28717E6D4C /* SuggestionEngineModels.swift */; }; CE218D30F69D215095E68456 /* AXHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2AA5BD865E0BCFB53DC699 /* AXHelperTests.swift */; }; @@ -797,6 +819,7 @@ CED430DD80E4769920E3C0E5 /* FocusModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F7E1691BC93836AE2FA8931 /* FocusModelsTests.swift */; }; CEDE9C238AD33258B36DB509 /* WelcomeHeroDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = F155556C751FB074B31CCC64 /* WelcomeHeroDemo.swift */; }; CF04C28598AF9A437990C8A4 /* SurfaceContextComposerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 652973AF18C20D51F81F76C5 /* SurfaceContextComposerTests.swift */; }; + CF2868D25EBBCE9CAE8A9DD3 /* MessageBlockGrouper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B77C56D304F76441BD79ED08 /* MessageBlockGrouper.swift */; }; CF2EADEEEF5AA63FB9B9EA8E /* SuggestionInserter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A3D1125B962CBE0269EEDDB /* SuggestionInserter.swift */; }; CF391BC4A3C80C41048D64D1 /* TypoCaseTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B293615FC411551C3FC15528 /* TypoCaseTransfer.swift */; }; CFCBA5D14335960CF994F40D /* GhostFontSizeLimits.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC61CA8FEFB45FB1CCA453A /* GhostFontSizeLimits.swift */; }; @@ -938,6 +961,7 @@ F32559EB4BE478324CA18B96 /* EmojiQueryRunTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626861231138509BCD7BB6EA /* EmojiQueryRunTests.swift */; }; F35738A5EFCE7C2A737F08C9 /* EmojiPickerModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68ADAFFD5031EBFA70CD62BB /* EmojiPickerModelsTests.swift */; }; F38776739B0987C0E3F2D343 /* SettingsSearchRankerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95C62A4403E4815004C6FCB8 /* SettingsSearchRankerTests.swift */; }; + F399998A7FBAB7115932D7AA /* TranslationOverlayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 683945CBE9B88F1A2D28A254 /* TranslationOverlayController.swift */; }; F3B72E0E3C700E57BC3B48F8 /* EmojiTriggerStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8159F4DD2F03935DD17BD907 /* EmojiTriggerStateMachine.swift */; }; F3DA1B242F30332AA91C00C5 /* FoundationModelSuggestionEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377A0BBB59988043005A138A /* FoundationModelSuggestionEngineTests.swift */; }; F3FC8AEB8504F7D1BB1C6443 /* DisplayCoordinateConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECC1416A95346CB72BE036FE /* DisplayCoordinateConverter.swift */; }; @@ -974,6 +998,7 @@ FDF71F83A24FBE17F5B63C68 /* ApplicationBundleMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1E28CF46BF59ABDC3056BF /* ApplicationBundleMetadata.swift */; }; FE0922970524121DEC4EF2D9 /* OpenAICompatibleEndpointModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ABCE733783332BE52E43D67 /* OpenAICompatibleEndpointModels.swift */; }; FE4F4A0778E7D2ABC9EEC155 /* EngineAndModelPaneView+Power.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF5872EC7795CC1EFF6D0D04 /* EngineAndModelPaneView+Power.swift */; }; + FE7EF795922DDAFDDD62F5C2 /* TranslationCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F4F9870325E80C563192E40 /* TranslationCache.swift */; }; FECBB40B75788B449719170B /* DeepGeometryWalkThrottle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CCBD29CF7B0BA5A9CFDEEA3 /* DeepGeometryWalkThrottle.swift */; }; FECD5A1F9317317983B49D34 /* SuggestionDismissalMemory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17F3333CFD174AB34060A0BD /* SuggestionDismissalMemory.swift */; }; FEF2CF888D8709D1FB0D2B20 /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 6F27073D2818C0218C3F4370 /* Logging */; }; @@ -1015,6 +1040,7 @@ 07480CE96ED0EBD94817C6B1 /* GeneralPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralPaneView.swift; sourceTree = ""; }; 081E05039D4CC2BE2EFCEAD4 /* SuggestionStateHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionStateHelperTests.swift; sourceTree = ""; }; 0854896090B568FFB1926D24 /* WordCountFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordCountFormatterTests.swift; sourceTree = ""; }; + 09F14F4E5FCCFC79ACC22934 /* ConversationLanguageTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationLanguageTracker.swift; sourceTree = ""; }; 0A184538FD926947EBB88D9E /* EngineAndModelPaneView+Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EngineAndModelPaneView+Endpoint.swift"; sourceTree = ""; }; 0A3D1125B962CBE0269EEDDB /* SuggestionInserter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionInserter.swift; sourceTree = ""; }; 0B6816DF5D33863F966240B4 /* es.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = es.txt; sourceTree = ""; }; @@ -1032,6 +1058,7 @@ 12E1E72C09460390583D98D1 /* SymSpellCorrector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SymSpellCorrector.swift; sourceTree = ""; }; 12F6FDB6D09851CC5C6649EF /* MenuBarSections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarSections.swift; sourceTree = ""; }; 13838E1372956FF2CFC5ECCC /* SelfCaptureGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfCaptureGate.swift; sourceTree = ""; }; + 143AB19919CAE76F0F1B6DCB /* TranslationEngines.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationEngines.swift; sourceTree = ""; }; 147D8EAD035A66FCA1D07E37 /* HostBaselineCalibratorTypefaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostBaselineCalibratorTypefaceTests.swift; sourceTree = ""; }; 14D5E77EB08030489DAFA4DC /* DeviceInfoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoTests.swift; sourceTree = ""; }; 14E22DCC1852B8E4B235D256 /* WordCountFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordCountFormatter.swift; sourceTree = ""; }; @@ -1048,6 +1075,7 @@ 17B5B1AE537B78F54A32BC63 /* VisualContextExcerptSelectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualContextExcerptSelectorTests.swift; sourceTree = ""; }; 17D41C0E6B52554439D51D99 /* TypingSessionEvalScoring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingSessionEvalScoring.swift; sourceTree = ""; }; 17F3333CFD174AB34060A0BD /* SuggestionDismissalMemory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionDismissalMemory.swift; sourceTree = ""; }; + 1992990B3729259C031994B3 /* TranslationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationCoordinator.swift; sourceTree = ""; }; 19B257134CCCD0D4370E5287 /* SwiftLog-Apache-2.0.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "SwiftLog-Apache-2.0.txt"; sourceTree = ""; }; 1A951C04DF783B1F1EFDD1E5 /* BundleVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleVersion.swift; sourceTree = ""; }; 1ABCE733783332BE52E43D67 /* OpenAICompatibleEndpointModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAICompatibleEndpointModels.swift; sourceTree = ""; }; @@ -1108,6 +1136,7 @@ 2E7C883990AE384602BA9AF6 /* SuggestionSettingsDomainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsDomainTests.swift; sourceTree = ""; }; 2EB3DF187FA7AE5A61B035F6 /* PermissionHostAppTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionHostAppTests.swift; sourceTree = ""; }; 2EC384A90F3D8B71584B3449 /* SystemUIFocusShadowPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemUIFocusShadowPolicy.swift; sourceTree = ""; }; + 2F20E1AB04686FB097239D77 /* FewShotTranslationPrompt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FewShotTranslationPrompt.swift; sourceTree = ""; }; 2F93E4EC64A8773A1A7F3E24 /* CompletionRenderMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionRenderMode.swift; sourceTree = ""; }; 2FCD2572B33EB6DDF1F2110A /* EmojiCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCatalogTests.swift; sourceTree = ""; }; 3003832CAC19ED7AE371E845 /* SuggestionPresentationModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionPresentationModels.swift; sourceTree = ""; }; @@ -1242,6 +1271,7 @@ 67332497BB5448AEDA700BA7 /* CurrencyEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyEvaluator.swift; sourceTree = ""; }; 67A9F3C0F7B53B7F0D3BA82A /* PerAppShortcutOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerAppShortcutOverride.swift; sourceTree = ""; }; 67EA130AF8073D90CA89AAC6 /* InsertionSafetyGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertionSafetyGateTests.swift; sourceTree = ""; }; + 683945CBE9B88F1A2D28A254 /* TranslationOverlayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationOverlayController.swift; sourceTree = ""; }; 68ADAFFD5031EBFA70CD62BB /* EmojiPickerModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerModelsTests.swift; sourceTree = ""; }; 68C4773B9316036F7E594EE1 /* EmojiUsageModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiUsageModels.swift; sourceTree = ""; }; 694D2DD5826C379BA4A6C775 /* SuggestionCoordinatorInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionCoordinatorInputTests.swift; sourceTree = ""; }; @@ -1256,6 +1286,7 @@ 6C896CEDC632FB738D3A42BF /* SignOffCue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignOffCue.swift; sourceTree = ""; }; 6C8C1A4EC935BB837432BAF2 /* InsertedTextAdvance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertedTextAdvance.swift; sourceTree = ""; }; 6CF1FBAABEF545B620AF8D78 /* ru-100k.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "ru-100k.txt"; sourceTree = ""; }; + 6D257852738F461DC1049FF5 /* TranslationPreferencesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationPreferencesStore.swift; sourceTree = ""; }; 6D8FE1B9BEA0B35C6910E534 /* SuggestionContinuationPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionContinuationPlan.swift; sourceTree = ""; }; 6DB982BF30B3601F57277776 /* fr-100k.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "fr-100k.txt"; sourceTree = ""; }; 6E2AA5BD865E0BCFB53DC699 /* AXHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AXHelperTests.swift; sourceTree = ""; }; @@ -1306,6 +1337,7 @@ 7D2F4ED8C7493EF4B967BFF9 /* Aria2Provisioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Aria2Provisioner.swift; sourceTree = ""; }; 7DE038A6DF4A16B3EB5CEBD3 /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = ""; }; 7F19230243E3B5DE5A76244C /* SignOffCueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignOffCueTests.swift; sourceTree = ""; }; + 7F4F9870325E80C563192E40 /* TranslationCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationCache.swift; sourceTree = ""; }; 7F7E66CFA5AB733CE1F793A8 /* MarkerSelectionSynthesizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkerSelectionSynthesizerTests.swift; sourceTree = ""; }; 80588CA72AB588807BE1C499 /* TokenHealingBufferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenHealingBufferTests.swift; sourceTree = ""; }; 8159F4DD2F03935DD17BD907 /* EmojiTriggerStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiTriggerStateMachine.swift; sourceTree = ""; }; @@ -1407,6 +1439,7 @@ AAD02052E51AFE56BECB92E4 /* EmojiPickerControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerControllerTests.swift; sourceTree = ""; }; AAE4180FC6B2E085CEE93F23 /* HostTextMetricsProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostTextMetricsProbe.swift; sourceTree = ""; }; AAE89BEAF57DF3A287DF56F1 /* PromptSectionBudget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptSectionBudget.swift; sourceTree = ""; }; + AB05DAD47115DAEF0BFE11AF /* TranslationModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationModels.swift; sourceTree = ""; }; AB84C175EA5B59E118F58C49 /* llama-eval-cases.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "llama-eval-cases.json"; sourceTree = ""; }; AC03B99F8F0C435522D44238 /* LICENSE */ = {isa = PBXFileReference; path = LICENSE; sourceTree = ""; }; ACD93D7DE258C8E5367A433D /* EmojiCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCatalog.swift; sourceTree = ""; }; @@ -1441,13 +1474,16 @@ B630D1F95FD08BC30E624672 /* DebouncePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebouncePolicy.swift; sourceTree = ""; }; B68F9EDFE2903996F0E5524E /* MailHeaderFieldDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailHeaderFieldDetectorTests.swift; sourceTree = ""; }; B753DA96CA6BC168E675E61E /* GhostWrapBandPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostWrapBandPolicy.swift; sourceTree = ""; }; + B77C56D304F76441BD79ED08 /* MessageBlockGrouper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBlockGrouper.swift; sourceTree = ""; }; B81DD30EB657368AACE9625A /* InputMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputMonitor.swift; sourceTree = ""; }; + B85ECBA936DAC06F98552271 /* TranslationPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationPaneView.swift; sourceTree = ""; }; B8EBC9F1890DD2FFC4884A5C /* SecureFieldDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureFieldDetectorTests.swift; sourceTree = ""; }; B9F5D16600EE9D4B76D04A26 /* CaretGeometrySelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaretGeometrySelector.swift; sourceTree = ""; }; BA2BBA3187237680274BF80E /* HostTextMetricsCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostTextMetricsCacheTests.swift; sourceTree = ""; }; BA991392A72658B758551BBA /* PerformanceMetricsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceMetricsStoreTests.swift; sourceTree = ""; }; BADB38D0160B47637572FC5E /* SettingsSidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSidebarView.swift; sourceTree = ""; }; BB36177FC6C26F5E4CD1AD6E /* InputMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputMonitorTests.swift; sourceTree = ""; }; + BBF09B3CEB692504AE84F41E /* TranslationHotkeyTap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationHotkeyTap.swift; sourceTree = ""; }; BC07B7CE715C69A63E6123DD /* SuggestionSettingsModelDisabledAppsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsModelDisabledAppsTests.swift; sourceTree = ""; }; BC4F887528AE74AC0DD30314 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; BC7FA0146AC6B78725105BA4 /* InkBaselineAnalyzerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InkBaselineAnalyzerTests.swift; sourceTree = ""; }; @@ -1484,6 +1520,7 @@ C696204AAEBA357129F77646 /* HuggingFaceModelBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuggingFaceModelBrowserView.swift; sourceTree = ""; }; C6A19E3F68A42D2FAC95E000 /* SuggestionTextNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionTextNormalizer.swift; sourceTree = ""; }; C736C539D4C97B5664E498BE /* HuggingFaceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuggingFaceModels.swift; sourceTree = ""; }; + C793AD0B4DC99E072D292EDB /* TranslationLanguagePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationLanguagePolicy.swift; sourceTree = ""; }; C7B8727111208CB506C25C4F /* ContextBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextBuffer.swift; sourceTree = ""; }; C8B2BA5E0AD5E3F1D89531B9 /* FocusedInputPollingSignatureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusedInputPollingSignatureTests.swift; sourceTree = ""; }; C8F55E55BBA67E4BF500832D /* SuggestionConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionConfigurationTests.swift; sourceTree = ""; }; @@ -1519,6 +1556,7 @@ D4DB988251D2B907F316A3B8 /* EmojiSynonymCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiSynonymCatalogTests.swift; sourceTree = ""; }; D52263602D578987A7ADC776 /* PixelCaretLocatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PixelCaretLocatorTests.swift; sourceTree = ""; }; D5E16CB478D3F015EE0C5D8F /* LlamaPromptCacheHintTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaPromptCacheHintTrackerTests.swift; sourceTree = ""; }; + D600F38FBA77948D07934173 /* TranslationCoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationCoreTests.swift; sourceTree = ""; }; D681DDF8E81F868C1BC92CB4 /* BlockBreakAlignmentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockBreakAlignmentTests.swift; sourceTree = ""; }; D71686BC29B7E0FACFF0B19C /* SpellingLanguageResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpellingLanguageResolverTests.swift; sourceTree = ""; }; D72CC98CFDA795F8210198E8 /* RuntimeBootstrapModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeBootstrapModelTests.swift; sourceTree = ""; }; @@ -1983,6 +2021,7 @@ DEBD6113A3C1038BECC99245 /* PerformancePaneView.swift */, 7113D3373525113CA69E7597 /* PermissionsPaneView.swift */, EB630F9814388203DD1CA2EC /* ShortcutsPaneView.swift */, + B85ECBA936DAC06F98552271 /* TranslationPaneView.swift */, D48B95B6665109B6C6A63B42 /* WritingPaneView.swift */, ); path = Panes; @@ -2457,6 +2496,7 @@ children = ( 342079C94E7FF7A20AE6FC57 /* InlineFeatures */, AF372CAE8AE04CE493E0ED18 /* Suggestion */, + D635A9BB8F3370AC767BBF23 /* Translation */, 3350EDE01ED5125520C79D53 /* SettingsCoordinator.swift */, 21CB3008986BE7FD2A4D9132 /* WelcomeCoordinator.swift */, ); @@ -2664,6 +2704,7 @@ 5154668C834FF09343FD3BE6 /* Settings */, 50D6C35B203AC5C0BF4BB1A8 /* Spelling */, 2D8E398BA5F2ED675A25AAFD /* Suggestion */, + B83C01707055589407F5338B /* Translation */, ); path = Models; sourceTree = ""; @@ -2749,6 +2790,14 @@ path = Evaluators; sourceTree = ""; }; + 9463CB8FA51F378E0DA4396E /* Translation */ = { + isa = PBXGroup; + children = ( + D600F38FBA77948D07934173 /* TranslationCoreTests.swift */, + ); + path = Translation; + sourceTree = ""; + }; 987BC94D9D28389C8CE0406E /* Visual */ = { isa = PBXGroup; children = ( @@ -2776,6 +2825,7 @@ 31F80159F918D5DBF9E39915 /* Settings */, F96313138F50BA4CB6236726 /* Spelling */, C33F213B45FF359E6176B987 /* Suggestion */, + 9463CB8FA51F378E0DA4396E /* Translation */, 0AF20FCEC932776FDD999FD6 /* Utilities */, ); path = Support; @@ -3065,6 +3115,7 @@ 924CAA5E25C596A9FAB7602B /* InlinePreviewPanelController.swift */, 9A3C5A66AA93E50E4A64ED46 /* OverlayController.swift */, 4A65BCABF4B23690AE3DBE93 /* PixelCaretLocator.swift */, + 683945CBE9B88F1A2D28A254 /* TranslationOverlayController.swift */, ); path = Presentation; sourceTree = ""; @@ -3078,6 +3129,14 @@ path = TestSupport; sourceTree = ""; }; + B83C01707055589407F5338B /* Translation */ = { + isa = PBXGroup; + children = ( + AB05DAD47115DAEF0BFE11AF /* TranslationModels.swift */, + ); + path = Translation; + sourceTree = ""; + }; B9BD4D94E361394D2D4852F2 /* Caching */ = { isa = PBXGroup; children = ( @@ -3268,6 +3327,16 @@ path = Focus; sourceTree = ""; }; + D3CA6AE5C4E79858A3A07282 /* Translation */ = { + isa = PBXGroup; + children = ( + 143AB19919CAE76F0F1B6DCB /* TranslationEngines.swift */, + BBF09B3CEB692504AE84F41E /* TranslationHotkeyTap.swift */, + 6D257852738F461DC1049FF5 /* TranslationPreferencesStore.swift */, + ); + path = Translation; + sourceTree = ""; + }; D55AC5A7C15AD5E2917B168A /* Llama */ = { isa = PBXGroup; children = ( @@ -3291,6 +3360,14 @@ path = Input; sourceTree = ""; }; + D635A9BB8F3370AC767BBF23 /* Translation */ = { + isa = PBXGroup; + children = ( + 1992990B3729259C031994B3 /* TranslationCoordinator.swift */, + ); + path = Translation; + sourceTree = ""; + }; D816DC011F025BC315F10052 /* Focus */ = { isa = PBXGroup; children = ( @@ -3399,6 +3476,7 @@ FFF55D3CE9FF3B16845823F7 /* Runtime */, 6495533696D4461386265D05 /* Spelling */, 3B1B0D8E3400BE4FC26AF6B5 /* Suggestion */, + D3CA6AE5C4E79858A3A07282 /* Translation */, 7CE2AB7F84E13EB0A98D263D /* Updates */, 2574DDA4AFE2DE99E608EF95 /* Visual */, ); @@ -3444,6 +3522,18 @@ path = Settings; sourceTree = ""; }; + EAD863059119DF530FF056B3 /* Translation */ = { + isa = PBXGroup; + children = ( + 09F14F4E5FCCFC79ACC22934 /* ConversationLanguageTracker.swift */, + 2F20E1AB04686FB097239D77 /* FewShotTranslationPrompt.swift */, + B77C56D304F76441BD79ED08 /* MessageBlockGrouper.swift */, + 7F4F9870325E80C563192E40 /* TranslationCache.swift */, + C793AD0B4DC99E072D292EDB /* TranslationLanguagePolicy.swift */, + ); + path = Translation; + sourceTree = ""; + }; F02B51FBFE03C6BF20497A70 /* Input */ = { isa = PBXGroup; children = ( @@ -3545,6 +3635,7 @@ 22548CAEF237D0D8CFCBD171 /* Settings */, 643C6D3D4745CED7E4A6417B /* Spelling */, 81E6FF5F2E0AE29DA29FF3DC /* Suggestion */, + EAD863059119DF530FF056B3 /* Translation */, 9ED3FC5CD8FA7C322F2AC166 /* Utilities */, ); path = Support; @@ -3888,6 +3979,7 @@ DA80B804C506B427A663302D /* ContextLivePreviewField.swift in Sources */, 930BA578E742D96FD9D340ED /* ContextPaneView.swift in Sources */, BFE1811C699B5B87530C3248 /* ControlTokenMarkers.swift in Sources */, + CD0C872DDB22D31CC6A169E9 /* ConversationLanguageTracker.swift in Sources */, B55B160E0534AE23BAC1C3DA /* CotabbyApp.swift in Sources */, AF55F1ABEDEA10C76C307CEC /* CotabbyAppEnvironment.swift in Sources */, 4E7F611941736F526C3B9C1B /* CotabbyBrand.swift in Sources */, @@ -3929,6 +4021,7 @@ 19EB4D1C2D18C0ADC70BAD29 /* EngineAndModelPaneView+OpenSource.swift in Sources */, C6F2116D9256622BA879E2EE /* EngineAndModelPaneView+Power.swift in Sources */, E6650273045E6F1976AF23CA /* EngineAndModelPaneView.swift in Sources */, + 2E5F38501661C60DE7EFD980 /* FewShotTranslationPrompt.swift in Sources */, D5929DF79BA02EE9B170B4D5 /* FieldEdgeIconIndicatorView.swift in Sources */, 146BD035FE0200D06EE1D7AE /* FieldStyleCache.swift in Sources */, 11D3A5C8DA17BCDB79969FC4 /* FileLogHandler.swift in Sources */, @@ -4010,6 +4103,7 @@ 66E7FB51A0DF5916ABBA15D0 /* MenuBarSections.swift in Sources */, 4D4F02A7187C324A4B43A974 /* MenuBarStatusLabelView.swift in Sources */, 35F1681148A324CBEC797BAF /* MenuBarView.swift in Sources */, + 6FEB82EB34BB3C89EF656F9E /* MessageBlockGrouper.swift in Sources */, 8D83B47447409074AAD1A423 /* MidWordContinuationPolicy.swift in Sources */, 222A03738679FC530E5B19B5 /* MirrorOverlayLayout.swift in Sources */, E6C99AB3E1A56E71F3A518FB /* ModelDownloadManager.swift in Sources */, @@ -4136,6 +4230,15 @@ A62575C4326C9C961A82436F /* TokenHealingBuffer.swift in Sources */, E6A062DEDF942C57A77A1AB7 /* TokenHealingPlan.swift in Sources */, A275EDDC0B43865C587C33AA /* TrailingDuplicationFilter.swift in Sources */, + 107DF2BD1B2616BD36E3C1EC /* TranslationCache.swift in Sources */, + 4849A63A5DF120418DC4AA1A /* TranslationCoordinator.swift in Sources */, + 54199DD4D6FEABFBA4ED1AED /* TranslationEngines.swift in Sources */, + 86C1D546D6DE1777DBE6CCC4 /* TranslationHotkeyTap.swift in Sources */, + 02BD1E581B8CB51A8912D93A /* TranslationLanguagePolicy.swift in Sources */, + ADDA3DD1B4DD52B898774D5E /* TranslationModels.swift in Sources */, + 24EE74F6BE91FC7AF04AB8A7 /* TranslationOverlayController.swift in Sources */, + 33765A26561DCD843A03205E /* TranslationPaneView.swift in Sources */, + 7418E40356268EA32FF7E2A7 /* TranslationPreferencesStore.swift in Sources */, F60A94747732F706FD666925 /* TypefaceEvidence.swift in Sources */, DA4693E09A0790D12F531CCA /* TypefaceMatcher.swift in Sources */, 555501CEB124DFDF657EACA4 /* TypingCadence.swift in Sources */, @@ -4215,6 +4318,7 @@ 76933444A4EF1F1CE5039E9E /* ContextLivePreviewField.swift in Sources */, 2089E4C64C1BFDCDA9F2F721 /* ContextPaneView.swift in Sources */, 1415DED5362F6F343AF63123 /* ControlTokenMarkers.swift in Sources */, + 765CA66166EBB88BA38E33E0 /* ConversationLanguageTracker.swift in Sources */, AA2E09FF7E430D66ECA8ECD5 /* CotabbyApp.swift in Sources */, FCC571EC239846F06007BFCA /* CotabbyAppEnvironment.swift in Sources */, 085BB87581DFFA260A630E24 /* CotabbyBrand.swift in Sources */, @@ -4256,6 +4360,7 @@ F45DC37FC763FB932B9AB3FB /* EngineAndModelPaneView+OpenSource.swift in Sources */, FE4F4A0778E7D2ABC9EEC155 /* EngineAndModelPaneView+Power.swift in Sources */, 8B8EA667506EB67DBCA35D57 /* EngineAndModelPaneView.swift in Sources */, + C8A90B93D5BD4B7CFF175670 /* FewShotTranslationPrompt.swift in Sources */, A3ABF1872257F40B14CBA3C2 /* FieldEdgeIconIndicatorView.swift in Sources */, 55243B4055E8067FE3483164 /* FieldStyleCache.swift in Sources */, AC716016F8E26C1711C39F6D /* FileLogHandler.swift in Sources */, @@ -4337,6 +4442,7 @@ 832FB4EEA935CA4D67F4478D /* MenuBarSections.swift in Sources */, C52E6F9FECD2FB173386F836 /* MenuBarStatusLabelView.swift in Sources */, 7807D6217618D982CD301875 /* MenuBarView.swift in Sources */, + CF2868D25EBBCE9CAE8A9DD3 /* MessageBlockGrouper.swift in Sources */, 800D96A3889A915779826D11 /* MidWordContinuationPolicy.swift in Sources */, 2CF9087AE6FDB2A182BA9C85 /* MirrorOverlayLayout.swift in Sources */, 0CF057842A15F79FB3C69462 /* ModelDownloadManager.swift in Sources */, @@ -4463,6 +4569,15 @@ 6FA8CBA189210C9821B03656 /* TokenHealingBuffer.swift in Sources */, F61CFA7A600E266D5A05E26E /* TokenHealingPlan.swift in Sources */, 407D80367A0243FBCC9865D6 /* TrailingDuplicationFilter.swift in Sources */, + FE7EF795922DDAFDDD62F5C2 /* TranslationCache.swift in Sources */, + 559451AFAA8CF6AE2A350552 /* TranslationCoordinator.swift in Sources */, + C0FA647FFD677D577859D0FC /* TranslationEngines.swift in Sources */, + A1807CDAA7D835525D7B7870 /* TranslationHotkeyTap.swift in Sources */, + 1038CF642829E77824F2B25D /* TranslationLanguagePolicy.swift in Sources */, + 4FEF448AF18494F11C816D80 /* TranslationModels.swift in Sources */, + F399998A7FBAB7115932D7AA /* TranslationOverlayController.swift in Sources */, + 4D8EAA5D687A54D5428258F6 /* TranslationPaneView.swift in Sources */, + 88E36607074D2607730360C9 /* TranslationPreferencesStore.swift in Sources */, 2740742B866BC12043B81268 /* TypefaceEvidence.swift in Sources */, 9F8656C10B45DA6699A43787 /* TypefaceMatcher.swift in Sources */, F6A88AAC1A3FE7FB284BFC34 /* TypingCadence.swift in Sources */, @@ -4737,6 +4852,7 @@ C2085C2B2C6F0926A1266867 /* TokenHealingBufferTests.swift in Sources */, 82BEAFC0C5DCE607D45335D3 /* TokenHealingPlanTests.swift in Sources */, 023A1A2A26B192E244192D8D /* TrailingDuplicationFilterTests.swift in Sources */, + 81AEE27758546FC4F77BCE77 /* TranslationCoreTests.swift in Sources */, 50D12741A639F8A367239FBE /* TypefaceEvidenceTests.swift in Sources */, 01527F11AEDBE20935A254B6 /* TypefaceMatcherTests.swift in Sources */, FDA59446E91261744C6DDFDA /* TypingCadenceTests.swift in Sources */, diff --git a/Cotabby/App/Coordinators/SettingsCoordinator.swift b/Cotabby/App/Coordinators/SettingsCoordinator.swift index 5535fec0..37be444d 100644 --- a/Cotabby/App/Coordinators/SettingsCoordinator.swift +++ b/Cotabby/App/Coordinators/SettingsCoordinator.swift @@ -25,6 +25,8 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { private let systemMetricsStore: SystemMetricsStore private let onShowWelcome: () -> Void private let clearEmojiHistory: () -> Void + private let translationPreferences: TranslationPreferencesStore + private let translationService: TranslationService private var settingsWindowController: NSWindowController? @@ -49,7 +51,9 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { qualityMetricsStore: SuggestionQualityMetricsStore, systemMetricsStore: SystemMetricsStore, onShowWelcome: @escaping () -> Void, - clearEmojiHistory: @escaping () -> Void + clearEmojiHistory: @escaping () -> Void, + translationPreferences: TranslationPreferencesStore, + translationService: TranslationService ) { self.appUpdateManager = appUpdateManager self.permissionManager = permissionManager @@ -65,6 +69,8 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { self.systemMetricsStore = systemMetricsStore self.onShowWelcome = onShowWelcome self.clearEmojiHistory = clearEmojiHistory + self.translationPreferences = translationPreferences + self.translationService = translationService } /// Shows the settings window, reusing the existing instance if it is already open. @@ -94,6 +100,8 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { systemMetricsStore: systemMetricsStore, onShowWelcome: onShowWelcome, clearEmojiHistory: clearEmojiHistory, + translationPreferences: translationPreferences, + translationService: translationService, onQuit: { NSApplication.shared.terminate(nil) } ) ) diff --git a/Cotabby/App/Coordinators/Translation/TranslationCoordinator.swift b/Cotabby/App/Coordinators/Translation/TranslationCoordinator.swift new file mode 100644 index 00000000..ecc91246 --- /dev/null +++ b/Cotabby/App/Coordinators/Translation/TranslationCoordinator.swift @@ -0,0 +1,304 @@ +import AppKit +import Combine +import CoreGraphics +import Foundation +import Logging + +/// Runs augmented translation in the apps the user chose: +/// +/// - **Incoming:** every 2 seconds (and shortly after scrolling) it captures the frontmost chat +/// window, reads its text, groups it into messages (`MessageBlockGrouper`), translates those not in +/// the reading language (`TranslationService`), and draws each translation under its message. +/// - **Reply:** when the user types a reply in their reading language in a chat whose language is +/// known, it shows the reply translated into that language under the field and installs a +/// shortcut that replaces the draft with it. It never sends anything. +/// +/// Ownership: built once by `CotabbyAppEnvironment`, started and stopped by `AppDelegate`. It reads +/// the focus snapshot but never changes autocomplete state. Nothing it reads or translates is +/// stored: captures are dropped after OCR, translations live in an in-memory cache. +@MainActor +final class TranslationCoordinator { + private let preferences: TranslationPreferencesStore + private let service: TranslationService + private let capture: WindowScreenshotService + private let overlay: TranslationOverlayController + private let focusModel: FocusTrackingModel + private let inserter: SuggestionInserter + private let hotkey: TranslationHotkeyTap + /// Cotabby's own gates for an app: globally on, not paused, app not disabled, not Low Power Mode. + private let isAllowed: @MainActor (String) -> Bool + + private var conversations = ConversationLanguageTracker() + private var timer: Timer? + private var scrollMonitor: Any? + private var cancellables = Set() + private var incomingTask: Task? + private var lastCaptureSignature: (windowID: CGWindowID, frame: CGRect, hash: Int)? + private var replyTask: Task? + private var offeredReply: (draft: String, translation: String)? + /// The draft currently being translated. Focus updates arrive every ~50 ms; without this, each + /// one would cancel the pending translation of a draft that has not changed. + private var pendingReplyDraft: String? + + /// Most blocks translated per capture; the local model (Macedonian) is limited further because + /// each translation takes about a second. + private static let maximumBlocks = 30 + private static let maximumLocalModelBlocks = 5 + private static let replyDebounceNanoseconds: UInt64 = 700_000_000 + + init( + preferences: TranslationPreferencesStore, + service: TranslationService, + capture: WindowScreenshotService, + overlay: TranslationOverlayController, + focusModel: FocusTrackingModel, + inserter: SuggestionInserter, + hotkey: TranslationHotkeyTap, + isAllowed: @escaping @MainActor (String) -> Bool + ) { + self.preferences = preferences + self.service = service + self.capture = capture + self.overlay = overlay + self.focusModel = focusModel + self.inserter = inserter + self.hotkey = hotkey + self.isAllowed = isAllowed + } + + func start() { + guard timer == nil else { return } + timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { self?.scheduleIncomingPass() } + } + // Scrolling moves every message; hide stale labels at once and re-read once it settles. + scrollMonitor = NSEvent.addGlobalMonitorForEvents(matching: .scrollWheel) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + self.overlay.hideIncoming() + self.lastCaptureSignature = nil + } + } + focusModel.$snapshot + .sink { [weak self] snapshot in self?.observeReply(snapshot) } + .store(in: &cancellables) + preferences.$preferences + .map(\.isEnabled) + .removeDuplicates() + .sink { [weak self] enabled in + guard let self, !enabled else { return } + self.overlay.hideAll() + self.hotkey.remove() + self.service.clearCache() + self.conversations.removeAll() + } + .store(in: &cancellables) + } + + func stop() { + timer?.invalidate() + timer = nil + if let scrollMonitor { NSEvent.removeMonitor(scrollMonitor) } + scrollMonitor = nil + cancellables.removeAll() + incomingTask?.cancel() + replyTask?.cancel() + hotkey.remove() + overlay.hideAll() + } + + // MARK: - Incoming + + private func scheduleIncomingPass() { + guard incomingTask == nil else { return } + incomingTask = Task { [weak self] in + await self?.runIncomingPass() + self?.incomingTask = nil + } + } + + private func runIncomingPass() async { + let prefs = preferences.preferences + guard prefs.isEnabled, prefs.translatesIncoming, + let app = NSWorkspace.shared.frontmostApplication, + let bundleIdentifier = app.bundleIdentifier, + preferences.isActive(forBundleIdentifier: bundleIdentifier), + isAllowed(bundleIdentifier), + CGPreflightScreenCaptureAccess() + else { + overlay.hideIncoming() + lastCaptureSignature = nil + return + } + + do { + let window = try await capture.captureActiveWindow(processIdentifier: app.processIdentifier) + let hash = Self.sampleHash(window.image) + if let last = lastCaptureSignature, last.windowID == window.windowID, last.frame == window.windowFrame, + last.hash == hash { + return + } + overlay.hideIncoming() + + let extractor = ScreenTextExtractor( + maxImageDimension: 2400, maxRecognizedCharacters: 20_000, + recognitionLanguages: Self.recognitionLanguages(reading: prefs.readingLanguage) + ) + let text = try await extractor.extractText(from: window.image) + let lines = text.lines.compactMap { line -> MessageBlockGrouper.Line? in + guard let box = line.boundingBox else { return nil } + return MessageBlockGrouper.Line(text: line.text, confidence: line.confidence, boundingBox: box) + } + let blocks = MessageBlockGrouper.blocks( + from: lines, windowFrame: window.windowFrame, composeFrame: composeFrame(forPID: app.processIdentifier) + ) + let labels = await translate(blocks, readingLanguage: prefs.readingLanguage, + conversationKey: ConversationLanguageTracker.key( + bundleIdentifier: bundleIdentifier, windowTitle: window.windowTitle)) + + // The user may have switched apps or chats while this ran; never draw over the wrong window. + guard NSWorkspace.shared.frontmostApplication?.processIdentifier == app.processIdentifier else { return } + lastCaptureSignature = (window.windowID, window.windowFrame, hash) + overlay.showIncoming(labels, windowFrame: window.windowFrame) + } catch { + overlay.hideIncoming() + } + } + + /// Translates the most recent messages first (lowest on screen) and records the language of the + /// newest one as the conversation's language. + private func translate(_ blocks: [MessageBlock], readingLanguage: String, conversationKey: String) async -> [TranslationLabel] { + var labels: [TranslationLabel] = [] + var localModelBudget = Self.maximumLocalModelBlocks + var recordedConversation = false + for (index, block) in blocks.reversed().prefix(Self.maximumBlocks).enumerated() { + if Task.isCancelled { break } + guard let detection = TranslationLanguagePolicy.needsTranslation(block.text, readingLanguage: readingLanguage) + else { continue } + if !recordedConversation { + conversations.record(language: detection.language, for: conversationKey) + recordedConversation = true + } + let availability = await service.availability(from: detection.language, to: readingLanguage) + if availability == .localModel { + guard localModelBudget > 0 else { continue } + localModelBudget -= 1 + } else if availability != .ready { + continue + } + guard let result = try? await service.translate(block.text, from: detection.language, to: readingLanguage), + result.translatedText.caseInsensitiveCompare(block.text) != .orderedSame + else { continue } + labels.append(TranslationLabel(id: index, text: result.translatedText, messageFrame: block.frame)) + } + return labels + } + + /// The reply field's frame in the same app, in global top-left points, so its text (the draft) + /// and the chat list beside it are not read as messages. + private func composeFrame(forPID pid: pid_t) -> CGRect? { + guard let input = focusModel.snapshot.context, input.processIdentifier == pid, + let frame = input.elementFrameRect ?? input.inputFrameRect else { return nil } + return ScreenSpace.globalRect(fromCocoa: frame) + } + + /// Vision hints: the reading language plus the scripts the user's chats are likely in. Vision has + /// no Macedonian model; Russian and Ukrainian cover its Cyrillic letters best. + private static func recognitionLanguages(reading: String) -> [String] { + var languages = ["en-US", "tr-TR", "ru-RU", "uk-UA", "de-DE", "fr-FR", "es-ES", "it-IT", "nl-NL"] + if let match = languages.firstIndex(where: { TranslationLanguagePolicy.sameLanguage($0, reading) }) { + languages.insert(languages.remove(at: match), at: 0) + } + return languages + } + + /// A cheap fingerprint of the captured pixels, so an unchanged chat is not re-read every 2 s. + private static func sampleHash(_ image: CGImage) -> Int { + guard let data = image.dataProvider?.data, let bytes = CFDataGetBytePtr(data) else { return 0 } + let length = CFDataGetLength(data) + var hasher = Hasher() + hasher.combine(image.width) + hasher.combine(image.height) + var offset = 0 + while offset < length { + hasher.combine(bytes[offset]) + offset += 997 + } + return hasher.finalize() + } + + // MARK: - Reply + + private func observeReply(_ snapshot: FocusSnapshot) { + let prefs = preferences.preferences + guard prefs.isEnabled, prefs.offersReplyTranslation, + case .supported = snapshot.capability, + let input = snapshot.context, !input.isSecure, + preferences.isActive(forBundleIdentifier: input.bundleIdentifier), + isAllowed(input.bundleIdentifier), + let conversationLanguage = conversations.language( + for: ConversationLanguageTracker.key(bundleIdentifier: input.bundleIdentifier, windowTitle: input.windowTitle)), + !TranslationLanguagePolicy.sameLanguage(conversationLanguage, prefs.readingLanguage) + else { + withdrawReplyOffer() + return + } + + let draft = (input.precedingText + input.trailingText).trimmingCharacters(in: .whitespacesAndNewlines) + if offeredReply?.draft == draft || pendingReplyDraft == draft { return } + withdrawReplyOffer() + guard draft.split(whereSeparator: \.isWhitespace).count >= 2, + TranslationLanguagePolicy.isWritten(in: prefs.readingLanguage, draft) + else { return } + + let fieldFrame = input.elementFrameRect ?? input.inputFrameRect ?? input.caretRect + pendingReplyDraft = draft + replyTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: Self.replyDebounceNanoseconds) + guard let self, !Task.isCancelled else { return } + let result = try? await self.service.translate(draft, from: prefs.readingLanguage, to: conversationLanguage) + guard !Task.isCancelled, self.pendingReplyDraft == draft else { return } + self.pendingReplyDraft = nil + guard let result else { return } + self.offerReply(draft: draft, translation: result.translatedText, language: conversationLanguage, fieldFrame: fieldFrame) + } + } + + private func offerReply(draft: String, translation: String, language: String, fieldFrame: CGRect) { + let prefs = preferences.preferences + offeredReply = (draft, translation) + overlay.showReply( + translation: translation, + languageName: Locale.current.localizedString(forLanguageCode: language) ?? language, + shortcutLabel: prefs.replaceKeyLabel, + below: fieldFrame + ) + hotkey.install(keyCode: prefs.replaceKeyCode, modifiers: prefs.replaceKeyModifiers) { [weak self] in + self?.replaceDraft() + } + } + + /// Replaces the draft only if the field still holds exactly the text that was translated. + private func replaceDraft() { + guard let offeredReply, let input = focusModel.snapshot.context, + (input.precedingText + input.trailingText).trimmingCharacters(in: .whitespacesAndNewlines) == offeredReply.draft + else { + withdrawReplyOffer() + return + } + let translation = offeredReply.translation + withdrawReplyOffer() + if !inserter.replaceFieldText(with: translation) { + CotabbyLogger.app.error("Reply translation could not replace the draft") + } + } + + private func withdrawReplyOffer() { + replyTask?.cancel() + replyTask = nil + pendingReplyDraft = nil + offeredReply = nil + hotkey.remove() + overlay.hideReply() + } +} diff --git a/Cotabby/App/Core/AppDelegate.swift b/Cotabby/App/Core/AppDelegate.swift index e29c9e63..5d97e3d4 100644 --- a/Cotabby/App/Core/AppDelegate.swift +++ b/Cotabby/App/Core/AppDelegate.swift @@ -166,6 +166,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { appUpdateManager.start() suggestionCoordinator.start() inlineCommandCoordinator.start() + environment.translationCoordinator.start() welcomeCoordinator.presentIfNeeded() welcomeCoordinator.presentPermissionReminderIfNeeded() didStartServices = true @@ -248,6 +249,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { focusDebugOverlayController?.hide() suggestionCoordinator.stop() inlineCommandCoordinator.stop() + environment.translationCoordinator.stop() inputMonitor.stop() focusModel.stop() diff --git a/Cotabby/App/Core/CotabbyAppEnvironment.swift b/Cotabby/App/Core/CotabbyAppEnvironment.swift index ee4a1618..9644a334 100644 --- a/Cotabby/App/Core/CotabbyAppEnvironment.swift +++ b/Cotabby/App/Core/CotabbyAppEnvironment.swift @@ -40,6 +40,8 @@ final class CotabbyAppEnvironment { let huggingFaceSearchService: HuggingFaceSearchService let performanceMetricsStore: PerformanceMetricsStore let qualityMetricsStore: SuggestionQualityMetricsStore + let translationPreferences: TranslationPreferencesStore + let translationCoordinator: TranslationCoordinator let settingsCoordinator: SettingsCoordinator let activationIndicatorController: ActivationIndicatorController let focusDebugOverlayController: FocusDebugOverlayController? @@ -157,6 +159,34 @@ final class CotabbyAppEnvironment { // to start sampling, so constructing it eagerly here costs nothing. let systemMetricsStore = SystemMetricsStore() let suggestionInserter = SuggestionInserter(suppressionController: suppressionController) + + // Augmented translation: Apple Translation first, the selected Open Source model for pairs + // Apple does not cover. Off until the user turns it on in Settings → Translation. + let translationPreferences = TranslationPreferencesStore() + let translationService = TranslationService( + apple: AppleTranslationEngine(), + local: LocalModelTranslationEngine( + runtimeManager: runtimeManager, + hasModel: { [weak runtimeModel] in runtimeModel?.selectedModelFilename != nil } + ) + ) + let translationCoordinator = TranslationCoordinator( + preferences: translationPreferences, + service: translationService, + capture: WindowScreenshotService(), + overlay: TranslationOverlayController(), + focusModel: focusModel, + inserter: suggestionInserter, + hotkey: TranslationHotkeyTap(suppressionController: suppressionController), + isAllowed: { [weak suggestionSettings, weak lowPowerModeMonitor] bundleIdentifier in + guard let suggestionSettings else { return false } + let settings = suggestionSettings.snapshot + let lowPower = (lowPowerModeMonitor?.isLowPowerModeEnabled ?? false) + && settings.isLowPowerModeAutoDisableEnabled + return settings.isGloballyEnabled && !settings.isTemporarilyPaused && !lowPower + && !settings.disabledAppBundleIdentifiers.contains(bundleIdentifier) + } + ) // Commit accepted text through an IME-safe path (Accessibility / paste) while a composing IME // is active; a synthetic keystroke would be re-absorbed into composition and the accept would // silently fail. @@ -247,7 +277,9 @@ final class CotabbyAppEnvironment { onShowWelcome: { [weak welcomeCoordinator] in welcomeCoordinator?.showWelcome() }, - clearEmojiHistory: { emojiUsageStore.clear() } + clearEmojiHistory: { emojiUsageStore.clear() }, + translationPreferences: translationPreferences, + translationService: translationService ) let interactionState = SuggestionInteractionState() @@ -352,6 +384,8 @@ final class CotabbyAppEnvironment { self.huggingFaceSearchService = huggingFaceSearchService self.performanceMetricsStore = performanceMetricsStore self.qualityMetricsStore = qualityMetricsStore + self.translationPreferences = translationPreferences + self.translationCoordinator = translationCoordinator self.settingsCoordinator = settingsCoordinator self.activationIndicatorController = activationIndicatorController self.focusDebugOverlayController = CotabbyDebugOptions.areOverlaysAvailable diff --git a/Cotabby/Models/Translation/TranslationModels.swift b/Cotabby/Models/Translation/TranslationModels.swift new file mode 100644 index 00000000..4b2ade4b --- /dev/null +++ b/Cotabby/Models/Translation/TranslationModels.swift @@ -0,0 +1,96 @@ +import CoreGraphics +import Foundation + +/// File overview: +/// Values for Cotabby's translation feature: translating incoming messages into the user's reading +/// language (shown in an overlay under each message) and offering the user's reply translated into +/// the conversation's language before they send it. +/// +/// Languages are carried as minimal BCP-47 codes ("en", "tr", "mk") rather than an enum, because +/// the set comes from Apple's Translation framework (which varies by macOS version) plus the +/// languages Cotabby can translate with its local model. + +/// How well one language pair can be translated on this Mac. +nonisolated enum TranslationPairAvailability: Equatable, Sendable { + /// Apple Translation has the pair installed; translation is fast and offline. + case ready + /// Apple Translation supports the pair but its language files are not downloaded yet. + case needsDownload + /// Apple Translation does not support the pair; the local model translates it instead. + case localModel + /// Neither engine can translate the pair. + case unavailable +} + +/// The user's translation preferences, persisted by `TranslationPreferencesStore`. +nonisolated struct TranslationPreferences: Equatable, Sendable { + /// Master switch. Off by default: translation reads other people's messages from the screen, + /// which is a choice the user makes, not one Cotabby makes for them. + var isEnabled: Bool + /// The language the user reads; incoming messages are translated into it, and replies written + /// in it are offered in the conversation's language. + var readingLanguage: String + /// Apps whose windows are translated, by bundle identifier. + var appBundleIdentifiers: [String] + var translatesIncoming: Bool + var offersReplyTranslation: Bool + /// The key that replaces the user's draft with its translation. + var replaceKeyCode: CGKeyCode + var replaceKeyModifiers: UInt32 + var replaceKeyLabel: String + + /// Messaging and mail apps offered when the user first turns translation on. + static let suggestedAppBundleIdentifiers = [ + "net.whatsapp.WhatsApp", + "com.microsoft.teams2", + "ru.keepcoder.Telegram", + "com.tinyspeck.slackmacgap", + "com.microsoft.Outlook", + "com.apple.mail", + "com.apple.MobileSMS" + ] + + /// ⌃⌥T: free in the suggested apps, and unlike Tab or Return it cannot send a message. + static let defaultReplaceKeyCode: CGKeyCode = 17 + static let defaultReplaceKeyModifiers: UInt32 = (1 << 2) | (1 << 3) + static let defaultReplaceKeyLabel = "⌃ ⌥ T" + + static let defaults = TranslationPreferences( + isEnabled: false, + readingLanguage: "en", + appBundleIdentifiers: suggestedAppBundleIdentifiers, + translatesIncoming: true, + offersReplyTranslation: true, + replaceKeyCode: defaultReplaceKeyCode, + replaceKeyModifiers: defaultReplaceKeyModifiers, + replaceKeyLabel: defaultReplaceKeyLabel + ) +} + +/// One translated piece of text, with the language it was detected in. +nonisolated struct TranslationResult: Equatable, Sendable { + let sourceText: String + let sourceLanguage: String + let targetLanguage: String + let translatedText: String +} + +nonisolated enum TranslationError: Error, Equatable, LocalizedError { + case unsupportedPair(source: String, target: String) + case needsDownload(source: String, target: String) + case noLocalModel + case emptyResult + + var errorDescription: String? { + switch self { + case let .unsupportedPair(source, target): + return "Translation from \(source) to \(target) isn't available on this Mac." + case let .needsDownload(source, target): + return "Download \(source) and \(target) in System Settings › General › Language & Region › Translation Languages." + case .noLocalModel: + return "This language needs a downloaded Open Source model, and none is selected." + case .emptyResult: + return "The translation came back empty." + } + } +} diff --git a/Cotabby/Services/Presentation/TranslationOverlayController.swift b/Cotabby/Services/Presentation/TranslationOverlayController.swift new file mode 100644 index 00000000..3bda2cd6 --- /dev/null +++ b/Cotabby/Services/Presentation/TranslationOverlayController.swift @@ -0,0 +1,162 @@ +import AppKit +import SwiftUI + +/// One translation drawn under a message. +struct TranslationLabel: Equatable, Identifiable { + let id: Int + let text: String + /// The message it translates, in global points with a top-left origin. + let messageFrame: CGRect +} + +/// Draws translations on screen: labels under incoming messages, and the reply-translation card +/// under the field being typed in. +/// +/// Both panels are borderless, non-activating, and click-through (`ignoresMouseEvents`), like the +/// ghost-text panel (`OverlayController`) and the focus debug overlay, so they never take focus or +/// block clicks in the chat app. The incoming panel covers the chat window and draws every label in +/// one SwiftUI view; it is excluded from screen capture because Cotabby captures the window by +/// itself (`SCContentFilter(desktopIndependentWindow:)`), never the composited screen. +@MainActor +final class TranslationOverlayController { + private var incomingPanel: TranslationPanel? + private var replyPanel: TranslationPanel? + + // MARK: - Incoming messages + + func showIncoming(_ labels: [TranslationLabel], windowFrame: CGRect) { + guard !labels.isEmpty else { + hideIncoming() + return + } + let panel = incomingPanel ?? Self.makePanel() + incomingPanel = panel + let cocoaFrame = ScreenSpace.cocoaRect(fromGlobal: windowFrame) + panel.setFrame(cocoaFrame, display: false) + panel.contentView = NSHostingView(rootView: IncomingTranslationLabels(labels: labels, windowFrame: windowFrame)) + panel.orderFrontRegardless() + } + + func hideIncoming() { + incomingPanel?.orderOut(nil) + } + + // MARK: - Reply card + + /// Shows `translation` under the field at `fieldFrame` (Cocoa coordinates, as Accessibility + /// snapshots report them), or above it when the field sits at the bottom of the screen. + func showReply(translation: String, languageName: String, shortcutLabel: String, below fieldFrame: CGRect) { + let panel = replyPanel ?? Self.makePanel() + replyPanel = panel + let view = NSHostingView(rootView: ReplyTranslationCard( + translation: translation, languageName: languageName, shortcutLabel: shortcutLabel + )) + let width = min(max(fieldFrame.width, 320), 640) + view.frame.size.width = width + let height = view.fittingSize.height + let screen = NSScreen.screens.first { $0.frame.intersects(fieldFrame) } ?? NSScreen.main + let visible = screen?.visibleFrame ?? fieldFrame + var origin = CGPoint(x: fieldFrame.minX, y: fieldFrame.minY - height - 6) + if origin.y < visible.minY { origin.y = fieldFrame.maxY + 6 } + origin.x = min(max(origin.x, visible.minX + 8), visible.maxX - width - 8) + panel.contentView = view + panel.setFrame(CGRect(origin: origin, size: CGSize(width: width, height: height)), display: true) + panel.orderFrontRegardless() + } + + func hideReply() { + replyPanel?.orderOut(nil) + } + + func hideAll() { + hideIncoming() + hideReply() + } + + private static func makePanel() -> TranslationPanel { + let panel = TranslationPanel( + contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: true + ) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false + panel.level = .statusBar + panel.ignoresMouseEvents = true + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle] + panel.animationBehavior = .none + return panel + } +} + +/// Never key or main: the panels must not steal focus from the chat app. +private final class TranslationPanel: NSPanel { + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } +} + +/// Converts between global display points with a top-left origin (ScreenCaptureKit, Accessibility) +/// and AppKit's bottom-left Cocoa coordinates, across all screens. +enum ScreenSpace { + @MainActor + static func cocoaRect(fromGlobal rect: CGRect) -> CGRect { + let desktop = NSScreen.screens.map(\.frame).reduce(CGRect.null) { $0.union($1) } + guard !desktop.isNull else { return rect } + return CGRect(x: rect.minX, y: desktop.maxY - rect.maxY, width: rect.width, height: rect.height) + } + + @MainActor + static func globalRect(fromCocoa rect: CGRect) -> CGRect { + cocoaRect(fromGlobal: rect) + } +} + +private struct IncomingTranslationLabels: View { + let labels: [TranslationLabel] + let windowFrame: CGRect + + var body: some View { + ZStack(alignment: .topLeading) { + Color.clear + ForEach(labels) { label in + Text(label.text) + .font(.system(size: 12)) + .foregroundStyle(.primary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 7, style: .continuous).stroke(Color.accentColor.opacity(0.5))) + .frame(maxWidth: max(label.messageFrame.width, 220), alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .offset(x: label.messageFrame.minX - windowFrame.minX, + y: label.messageFrame.maxY - windowFrame.minY + 2) + } + } + .frame(width: windowFrame.width, height: windowFrame.height, alignment: .topLeading) + } +} + +private struct ReplyTranslationCard: View { + let translation: String + let languageName: String + let shortcutLabel: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "character.bubble") + Text(languageName).font(.caption.weight(.semibold)) + Spacer(minLength: 8) + Text("\(shortcutLabel) to replace your draft") + .font(.caption) + .foregroundStyle(.secondary) + } + Text(translation) + .font(.system(size: 13)) + .fixedSize(horizontal: false, vertical: true) + } + .padding(10) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous).stroke(Color.accentColor.opacity(0.5))) + .padding(2) + } +} diff --git a/Cotabby/Services/Suggestion/SuggestionInserter.swift b/Cotabby/Services/Suggestion/SuggestionInserter.swift index 1c0af40d..91e45189 100644 --- a/Cotabby/Services/Suggestion/SuggestionInserter.swift +++ b/Cotabby/Services/Suggestion/SuggestionInserter.swift @@ -186,6 +186,36 @@ final class SuggestionInserter { return true } + /// Replaces everything in the focused field with `text`: selects all with a synthetic Cmd-A, then + /// pastes. Used by reply translation, where the whole draft becomes its translation. Paste (not + /// keystrokes) because a translation can be long or multi-line and Electron chat fields drop or + /// reorder long synthetic keystroke bursts; the user's clipboard is restored as for any paste. + /// The paste waits a moment so the Cmd-A has been processed before the menu Paste runs (the + /// AX Paste press is immediate, while the keystroke is queued). Never presses Return. + func replaceFieldText(with text: String) -> Bool { + guard let down = CGEvent(keyboardEventSource: nil, virtualKey: Self.aKeyCode, keyDown: true), + let up = CGEvent(keyboardEventSource: nil, virtualKey: Self.aKeyCode, keyDown: false) else { + lastErrorMessage = "Unable to create a synthetic select-all event." + return false + } + down.flags = .maskCommand + up.flags = .maskCommand + suppressionController.markSynthetic(down) + suppressionController.markSynthetic(up) + down.post(tap: .cghidEventTap) + up.post(tap: .cghidEventTap) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in + guard let self else { return } + if !self.insertViaPaste(text) { + CotabbyLogger.suggestion.error("Reply translation paste failed") + } + } + lastErrorMessage = nil + return true + } + + private static let aKeyCode: CGKeyCode = 0x00 + /// Commits `text` by placing it on the pasteboard and synthesizing Cmd-V, then restoring the /// user's clipboard shortly after. Returns false (having already restored the clipboard) if any /// synthetic event could not be created, so the caller falls back to keystroke insertion. The diff --git a/Cotabby/Services/Translation/TranslationEngines.swift b/Cotabby/Services/Translation/TranslationEngines.swift new file mode 100644 index 00000000..91e81589 --- /dev/null +++ b/Cotabby/Services/Translation/TranslationEngines.swift @@ -0,0 +1,149 @@ +import Foundation +import Logging +import Translation + +/// One way to translate text on this Mac. +@MainActor +protocol TranslationEngine: AnyObject { + func availability(from source: String, to target: String) async -> TranslationPairAvailability + func translate(_ text: String, from source: String, to target: String) async throws -> String +} + +/// Apple's on-device Translation framework: fast (tens of milliseconds once warm), offline, and the +/// best quality for the ~20 languages it covers. Sessions are created per language pair from the +/// installed language files (`init(installedSource:target:)`, macOS 26) and reused, because the first +/// translation on a session loads its model. +@MainActor +final class AppleTranslationEngine: TranslationEngine { + private var sessions: [String: AnyObject] = [:] + + func availability(from source: String, to target: String) async -> TranslationPairAvailability { + guard #available(macOS 26.0, *) else { return .unavailable } + let status = await LanguageAvailability().status( + from: Locale.Language(identifier: source), + to: Locale.Language(identifier: target) + ) + switch status { + case .installed: return .ready + case .supported: return .needsDownload + case .unsupported: return .unavailable + @unknown default: return .unavailable + } + } + + func translate(_ text: String, from source: String, to target: String) async throws -> String { + guard #available(macOS 26.0, *) else { throw TranslationError.unsupportedPair(source: source, target: target) } + switch await availability(from: source, to: target) { + case .ready: break + case .needsDownload: throw TranslationError.needsDownload(source: source, target: target) + case .localModel, .unavailable: throw TranslationError.unsupportedPair(source: source, target: target) + } + let key = "\(source)>\(target)" + let session: TranslationSession + if let existing = sessions[key] as? TranslationSession { + session = existing + } else { + session = TranslationSession( + installedSource: Locale.Language(identifier: source), + target: Locale.Language(identifier: target) + ) + sessions[key] = session + } + let response = try await session.translate(text) + return response.targetText + } +} + +/// Translates with the selected Open Source model for pairs Apple Translation does not cover +/// (Macedonian). The bundled models are base models, so this uses a few-shot prompt +/// (`FewShotTranslationPrompt`) rather than an instruction. It shares the local runtime, and its +/// lock, with autocomplete; a translation waits for an in-flight suggestion and vice versa. +@MainActor +final class LocalModelTranslationEngine: TranslationEngine { + private let runtimeManager: LlamaRuntimeManager + private let hasModel: @MainActor () -> Bool + + init(runtimeManager: LlamaRuntimeManager, hasModel: @escaping @MainActor () -> Bool) { + self.runtimeManager = runtimeManager + self.hasModel = hasModel + } + + func availability(from source: String, to target: String) async -> TranslationPairAvailability { + guard FewShotTranslationPrompt.supports(source: source, target: target) else { return .unavailable } + return hasModel() ? .localModel : .unavailable + } + + func translate(_ text: String, from source: String, to target: String) async throws -> String { + guard hasModel() else { throw TranslationError.noLocalModel } + guard let prompt = FewShotTranslationPrompt.prompt(for: text, source: source, target: target) else { + throw TranslationError.unsupportedPair(source: source, target: target) + } + let wordCount = text.split(whereSeparator: \.isWhitespace).count + var options = LlamaGenerationOptions( + // Translations run a little longer than their source in tokens; cap runaway output. + maxPredictionTokens: min(256, wordCount * 4 + 16), + temperature: 0.2, + topK: 20, + topP: 0.9, + minP: 0.05, + repetitionPenalty: 1.05, + seed: nil + ) + // One line per translation: decoding stops at the line break the examples end with. + options.singleLine = true + // The autocomplete stop at the first sentence end would cut a two-sentence message in half. + options.sentenceStopMinimumTokens = .max + let output = try await runtimeManager.generate(prompt: prompt, options: options) + let cleaned = FewShotTranslationPrompt.cleanedOutput(output.text) + guard !cleaned.isEmpty else { throw TranslationError.emptyResult } + return cleaned + } +} + +/// The app's single translation entry point: detects nothing itself, picks the engine for a pair +/// (Apple first, the local model only where Apple cannot), and caches every result in memory. +/// +/// Owned by `CotabbyAppEnvironment`; the incoming-message coordinator and the reply controller use +/// it, and the Translation settings pane reads `availability` for its per-language status. +@MainActor +final class TranslationService { + private let apple: any TranslationEngine + private let local: any TranslationEngine + private var cache = TranslationCache() + + init(apple: any TranslationEngine, local: any TranslationEngine) { + self.apple = apple + self.local = local + } + + func availability(from source: String, to target: String) async -> TranslationPairAvailability { + let appleStatus = await apple.availability(from: source, to: target) + if appleStatus == .ready || appleStatus == .needsDownload { return appleStatus } + return await local.availability(from: source, to: target) + } + + func translate(_ text: String, from source: String, to target: String) async throws -> TranslationResult { + if let cached = cache.value(for: text, target: target) { return cached } + let engine: any TranslationEngine + switch await apple.availability(from: source, to: target) { + case .ready: + engine = apple + case .needsDownload: + // The local model is a weaker stand-in; prefer it only when it can actually run. + engine = await local.availability(from: source, to: target) == .localModel ? local : apple + case .localModel, .unavailable: + engine = local + } + let translated = try await engine.translate(text, from: source, to: target) + let result = TranslationResult( + sourceText: text, sourceLanguage: source, targetLanguage: target, translatedText: translated + ) + cache.store(result) + return result + } + + /// Drops every cached translation, e.g. when translation is turned off. + func clearCache() { + cache.removeAll() + } +} diff --git a/Cotabby/Services/Translation/TranslationHotkeyTap.swift b/Cotabby/Services/Translation/TranslationHotkeyTap.swift new file mode 100644 index 00000000..1ff94c71 --- /dev/null +++ b/Cotabby/Services/Translation/TranslationHotkeyTap.swift @@ -0,0 +1,84 @@ +import CoreGraphics +import Foundation +import Logging + +/// The "replace my draft with its translation" shortcut. +/// +/// A consuming event tap, so the key never reaches the chat app (⌥T would otherwise type "†"). It +/// is installed only while a reply translation is on offer and removed as soon as it is not, so +/// Cotabby sits in the keystroke path for this feature only while the card is visible. Synthetic +/// events Cotabby posts itself are ignored, matching the other taps (`InputMonitor`). +@MainActor +final class TranslationHotkeyTap { + private let suppressionController: InputSuppressionController + private var tap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + private var keyCode: CGKeyCode = 0 + private var modifiers: UInt32 = 0 + private var onPress: (() -> Void)? + + init(suppressionController: InputSuppressionController) { + self.suppressionController = suppressionController + } + + var isInstalled: Bool { tap != nil } + + func install(keyCode: CGKeyCode, modifiers: UInt32, onPress: @escaping () -> Void) { + self.keyCode = keyCode + self.modifiers = modifiers + self.onPress = onPress + guard tap == nil else { return } + + let callback: CGEventTapCallBack = { _, type, event, userInfo in + guard let userInfo else { return Unmanaged.passUnretained(event) } + let owner = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() + return MainActor.assumeIsolated { owner.handle(type: type, event: event) } + } + guard let created = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: CGEventMask(1 << CGEventType.keyDown.rawValue), + callback: callback, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) else { + CotabbyLogger.app.warning("Failed to create the translation shortcut tap") + return + } + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, created, 0) + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + CGEvent.tapEnable(tap: created, enable: true) + tap = created + runLoopSource = source + } + + func remove() { + onPress = nil + guard let tap else { return } + CGEvent.tapEnable(tap: tap, enable: false) + if let runLoopSource { + CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, .commonModes) + } + CFMachPortInvalidate(tap) + self.tap = nil + runLoopSource = nil + } + + private func handle(type: CGEventType, event: CGEvent) -> Unmanaged? { + switch type { + case .tapDisabledByTimeout, .tapDisabledByUserInput: + if let tap { CGEvent.tapEnable(tap: tap, enable: true) } + return Unmanaged.passUnretained(event) + case .keyDown: + guard !suppressionController.isSynthetic(event), + CGKeyCode(event.getIntegerValueField(.keyboardEventKeycode)) == keyCode, + ShortcutModifierMask(eventFlags: event.flags).rawValue == modifiers, + let onPress + else { return Unmanaged.passUnretained(event) } + onPress() + return nil + default: + return Unmanaged.passUnretained(event) + } + } +} diff --git a/Cotabby/Services/Translation/TranslationPreferencesStore.swift b/Cotabby/Services/Translation/TranslationPreferencesStore.swift new file mode 100644 index 00000000..487f74c3 --- /dev/null +++ b/Cotabby/Services/Translation/TranslationPreferencesStore.swift @@ -0,0 +1,88 @@ +import Combine +import CoreGraphics +import Foundation + +/// Owns the translation preferences and persists them in `UserDefaults`. +/// +/// Kept out of `SuggestionSettingsModel` because translation is its own subsystem: nothing in the +/// suggestion pipeline reads these values, and the incoming/outgoing translation coordinators and +/// the Translation settings pane are their only readers. Built once by `CotabbyAppEnvironment`. +@MainActor +final class TranslationPreferencesStore: ObservableObject { + @Published private(set) var preferences: TranslationPreferences + + private let userDefaults: UserDefaults + + private enum Key { + static let isEnabled = "cotabbyTranslationEnabled" + static let readingLanguage = "cotabbyTranslationReadingLanguage" + static let apps = "cotabbyTranslationApps" + static let translatesIncoming = "cotabbyTranslationIncoming" + static let offersReply = "cotabbyTranslationReplies" + static let replaceKeyCode = "cotabbyTranslationReplaceKeyCode" + static let replaceKeyModifiers = "cotabbyTranslationReplaceKeyModifiers" + static let replaceKeyLabel = "cotabbyTranslationReplaceKeyLabel" + } + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + let defaults = TranslationPreferences.defaults + preferences = TranslationPreferences( + isEnabled: userDefaults.object(forKey: Key.isEnabled) as? Bool ?? defaults.isEnabled, + readingLanguage: userDefaults.string(forKey: Key.readingLanguage) ?? defaults.readingLanguage, + appBundleIdentifiers: userDefaults.stringArray(forKey: Key.apps) ?? defaults.appBundleIdentifiers, + translatesIncoming: userDefaults.object(forKey: Key.translatesIncoming) as? Bool ?? defaults.translatesIncoming, + offersReplyTranslation: userDefaults.object(forKey: Key.offersReply) as? Bool ?? defaults.offersReplyTranslation, + replaceKeyCode: (userDefaults.object(forKey: Key.replaceKeyCode) as? Int).map { CGKeyCode($0) } + ?? defaults.replaceKeyCode, + replaceKeyModifiers: (userDefaults.object(forKey: Key.replaceKeyModifiers) as? Int).map { UInt32($0) } + ?? defaults.replaceKeyModifiers, + replaceKeyLabel: userDefaults.string(forKey: Key.replaceKeyLabel) ?? defaults.replaceKeyLabel + ) + } + + /// Whether translation applies to windows of this app right now. + func isActive(forBundleIdentifier bundleIdentifier: String?) -> Bool { + guard preferences.isEnabled, let bundleIdentifier else { return false } + return preferences.appBundleIdentifiers.contains(bundleIdentifier) + } + + func setEnabled(_ enabled: Bool) { + update(\.isEnabled, enabled, key: Key.isEnabled) + } + + func setReadingLanguage(_ language: String) { + update(\.readingLanguage, language, key: Key.readingLanguage) + } + + func setTranslatesIncoming(_ enabled: Bool) { + update(\.translatesIncoming, enabled, key: Key.translatesIncoming) + } + + func setOffersReplyTranslation(_ enabled: Bool) { + update(\.offersReplyTranslation, enabled, key: Key.offersReply) + } + + func setApp(_ bundleIdentifier: String, included: Bool) { + var apps = preferences.appBundleIdentifiers.filter { $0 != bundleIdentifier } + if included { apps.append(bundleIdentifier) } + update(\.appBundleIdentifiers, apps, key: Key.apps) + } + + func setReplaceKey(keyCode: CGKeyCode, modifiers: UInt32, label: String) { + guard preferences.replaceKeyCode != keyCode || preferences.replaceKeyModifiers != modifiers + || preferences.replaceKeyLabel != label else { return } + preferences.replaceKeyCode = keyCode + preferences.replaceKeyModifiers = modifiers + preferences.replaceKeyLabel = label + userDefaults.set(Int(keyCode), forKey: Key.replaceKeyCode) + userDefaults.set(Int(modifiers), forKey: Key.replaceKeyModifiers) + userDefaults.set(label, forKey: Key.replaceKeyLabel) + } + + private func update(_ keyPath: WritableKeyPath, _ value: Value, key: String) { + guard preferences[keyPath: keyPath] != value else { return } + preferences[keyPath: keyPath] = value + userDefaults.set(value, forKey: key) + } +} diff --git a/Cotabby/Services/Visual/ScreenTextExtractor.swift b/Cotabby/Services/Visual/ScreenTextExtractor.swift index 39ed30b4..09e12db6 100644 --- a/Cotabby/Services/Visual/ScreenTextExtractor.swift +++ b/Cotabby/Services/Visual/ScreenTextExtractor.swift @@ -78,13 +78,19 @@ struct ScreenTextExtractor: ScreenTextExtracting { let maxImageDimension: Int let maxRecognizedCharacters: Int + /// Vision language hints, in priority order ("tr-TR", "ru-RU"). Nil keeps Vision's default, which + /// is what screen context has always used; translation passes the chat's languages so accented + /// and Cyrillic letters are read correctly. + let recognitionLanguages: [String]? init( maxImageDimension: Int = VisualContextConfiguration.default.maxImageDimension, - maxRecognizedCharacters: Int = VisualContextConfiguration.default.maxRecognizedCharacters + maxRecognizedCharacters: Int = VisualContextConfiguration.default.maxRecognizedCharacters, + recognitionLanguages: [String]? = nil ) { self.maxImageDimension = maxImageDimension self.maxRecognizedCharacters = maxRecognizedCharacters + self.recognitionLanguages = recognitionLanguages } /// Performs OCR asynchronously so the main actor is not blocked by Vision processing. @@ -185,6 +191,9 @@ struct ScreenTextExtractor: ScreenTextExtracting { request.recognitionLevel = .accurate request.usesLanguageCorrection = true request.minimumTextHeight = 0.008 + if let recognitionLanguages = self.recognitionLanguages { + request.recognitionLanguages = recognitionLanguages + } do { let handler = VNImageRequestHandler(cgImage: preparedImage, options: [:]) diff --git a/Cotabby/Services/Visual/WindowScreenshotService.swift b/Cotabby/Services/Visual/WindowScreenshotService.swift index 517aaa1d..e4f92341 100644 --- a/Cotabby/Services/Visual/WindowScreenshotService.swift +++ b/Cotabby/Services/Visual/WindowScreenshotService.swift @@ -23,6 +23,15 @@ struct CapturedWindowScreenshot { /// ScreenCaptureKit is permissioned, asynchronous, and window-manager dependent. Keeping this /// protocol narrow lets `ScreenshotContextGenerator` tests focus on context policy instead of /// requiring a live macOS desktop capture. +/// One whole app window, captured for reading its text. `windowFrame` is in global display points +/// with a top-left origin (ScreenCaptureKit's space), the space OCR boxes are mapped into. +struct CapturedAppWindow { + let image: CGImage + let windowID: CGWindowID + let windowFrame: CGRect + let windowTitle: String? +} + protocol WindowScreenshotCapturing { func captureSnapshot( around context: FocusedInputSnapshot, @@ -138,6 +147,38 @@ struct WindowScreenshotService: WindowScreenshotCapturing { return CapturedWindowScreenshot(image: image, windowTitle: matchingWindow.title, focusBounds: focusBounds) } + /// Captures the whole active window of an app, for reading what is on screen (incoming + /// messages to translate) rather than the area around a text field. Returns the window's frame + /// in global display points (top-left origin) so text positions found in the image can be mapped + /// back onto the screen. The capture contains only that window, never Cotabby's own overlays. + func captureActiveWindow(processIdentifier: pid_t) async throws -> CapturedAppWindow { + guard CGPreflightScreenCaptureAccess() else { + throw WindowScreenshotError.screenRecordingPermissionMissing + } + let shareableContent = try await currentShareableContent() + try Task.checkCancellation() + let candidates = shareableContent.windows.filter { + $0.owningApplication?.processID == processIdentifier && $0.isOnScreen && $0.windowLayer == 0 + } + guard let window = candidates.first(where: \.isActive) ?? candidates.max(by: { + $0.frame.width * $0.frame.height < $1.frame.width * $1.frame.height + }) else { + throw WindowScreenshotError.noVisibleWindowForProcess(processIdentifier) + } + let scale = backingScaleFactor(for: window.frame) + let configuration = SCStreamConfiguration() + configuration.sourceRect = CGRect(origin: .zero, size: window.frame.size) + configuration.width = max(Int((window.frame.width * scale).rounded(.up)), 1) + configuration.height = max(Int((window.frame.height * scale).rounded(.up)), 1) + configuration.showsCursor = false + let image = try await captureImage( + filter: SCContentFilter(desktopIndependentWindow: window), + configuration: configuration + ) + try Task.checkCancellation() + return CapturedAppWindow(image: image, windowID: window.windowID, windowFrame: window.frame, windowTitle: window.title) + } + private func snapshotRect( around context: FocusedInputSnapshot, windowFrame: CGRect, diff --git a/Cotabby/Support/Translation/ConversationLanguageTracker.swift b/Cotabby/Support/Translation/ConversationLanguageTracker.swift new file mode 100644 index 00000000..a2b4020f --- /dev/null +++ b/Cotabby/Support/Translation/ConversationLanguageTracker.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Remembers which language each conversation is in, so a reply can be offered in it. +/// +/// The incoming-message reader reports the language of the lowest (most recent) translated message +/// in a chat window; the reply controller asks for it when the user starts typing in that window. +/// Conversations are keyed by app plus window title, which in chat apps is the contact or channel +/// name. Kept in memory only and capped, like the translation cache. +nonisolated struct ConversationLanguageTracker: Sendable { + private struct Entry: Sendable { + let language: String + let updatedAt: Date + } + + private let capacity: Int + private var entries: [String: Entry] = [:] + + init(capacity: Int = 100) { + self.capacity = max(1, capacity) + } + + static func key(bundleIdentifier: String, windowTitle: String?) -> String { + "\(bundleIdentifier)|\(windowTitle ?? "")" + } + + mutating func record(language: String, for key: String, at date: Date = Date()) { + entries[key] = Entry(language: language, updatedAt: date) + if entries.count > capacity, let oldest = entries.min(by: { $0.value.updatedAt < $1.value.updatedAt })?.key { + entries[oldest] = nil + } + } + + func language(for key: String) -> String? { + entries[key]?.language + } + + mutating func removeAll() { + entries = [:] + } +} diff --git a/Cotabby/Support/Translation/FewShotTranslationPrompt.swift b/Cotabby/Support/Translation/FewShotTranslationPrompt.swift new file mode 100644 index 00000000..a97d1bbc --- /dev/null +++ b/Cotabby/Support/Translation/FewShotTranslationPrompt.swift @@ -0,0 +1,70 @@ +import Foundation + +/// Builds the prompt that lets a base (non-chat) language model translate, and cleans its output. +/// +/// A base model cannot follow "translate this"; it continues text. Showing it a few labeled +/// sentence pairs and ending with the source sentence plus the target label makes the most likely +/// continuation the translation itself. The examples are everyday chat lines so the register +/// matches messages, and each pair is one line so decoding stops at the line break. +nonisolated enum FewShotTranslationPrompt { + /// Parallel example sentences, keyed by language code. Every language listed here has the + /// same sentences in the same order. + private static let examples: [String: [String]] = [ + "en": [ + "I will call you after the meeting.", + "Can you send me the document today?", + "Thank you, see you tomorrow." + ], + "mk": [ + "Ќе ти се јавам после состанокот.", + "Можеш ли да ми го испратиш документот денес?", + "Ти благодарам, се гледаме утре." + ], + "tr": [ + "Toplantıdan sonra seni arayacağım.", + "Belgeyi bana bugün gönderebilir misin?", + "Teşekkürler, yarın görüşürüz." + ] + ] + + /// Languages this prompt can translate between (both sides need examples). + static func supports(source: String, target: String) -> Bool { + examples[TranslationLanguagePolicy.baseCode(source)] != nil + && examples[TranslationLanguagePolicy.baseCode(target)] != nil + } + + static func prompt(for text: String, source: String, target: String) -> String? { + let sourceCode = TranslationLanguagePolicy.baseCode(source) + let targetCode = TranslationLanguagePolicy.baseCode(target) + guard let sourceExamples = examples[sourceCode], let targetExamples = examples[targetCode] else { return nil } + let sourceLabel = label(sourceCode) + let targetLabel = label(targetCode) + var lines: [String] = [] + for (source, target) in zip(sourceExamples, targetExamples) { + lines.append("\(sourceLabel): \(source)") + lines.append("\(targetLabel): \(target)") + lines.append("") + } + // One line per message: the model stops at the line break, and a multi-line message would + // otherwise end the translation after its first line. + let singleLine = text.split(whereSeparator: \.isNewline).joined(separator: " ") + lines.append("\(sourceLabel): \(singleLine)") + lines.append("\(targetLabel):") + return lines.joined(separator: "\n") + } + + /// The translation from the model's raw continuation: the first line, without any label the + /// model started repeating. + static func cleanedOutput(_ raw: String) -> String { + var line = raw.split(whereSeparator: \.isNewline).first.map(String.init) ?? "" + for code in examples.keys { + let marker = " \(label(code)):" + if let range = line.range(of: marker) { line = String(line[.. String { + Locale(identifier: "en").localizedString(forLanguageCode: code) ?? code + } +} diff --git a/Cotabby/Support/Translation/MessageBlockGrouper.swift b/Cotabby/Support/Translation/MessageBlockGrouper.swift new file mode 100644 index 00000000..a21f9b64 --- /dev/null +++ b/Cotabby/Support/Translation/MessageBlockGrouper.swift @@ -0,0 +1,99 @@ +import CoreGraphics +import Foundation + +/// One message as read from the screen: its text and where it sits, in global display points with a +/// top-left origin (ScreenCaptureKit / Accessibility space). +nonisolated struct MessageBlock: Equatable, Sendable { + let text: String + let frame: CGRect +} + +/// Turns recognized text lines from a chat window into message blocks. +/// +/// Pure geometry and text rules, so it is tested on recorded OCR output. It is conservative: when +/// in doubt it splits rather than merges, because a translation spanning two people's messages is +/// worse than two separate ones. +nonisolated enum MessageBlockGrouper { + struct Line: Equatable, Sendable { + let text: String + let confidence: Float + /// Vision's normalized box: unit square, bottom-left origin, relative to the captured image. + let boundingBox: CGRect + } + + /// Lines below this OCR confidence are usually icons, avatars, or reactions read as text. + static let minimumConfidence: Float = 0.5 + + /// - Parameters: + /// - windowFrame: the captured window, global points, top-left origin. + /// - composeFrame: the focused reply field, if any. Messages are read only above it and from + /// its left edge rightwards, which excludes the chat list and the reply being typed. + static func blocks(from lines: [Line], windowFrame: CGRect, composeFrame: CGRect?) -> [MessageBlock] { + let region = conversationRegion(windowFrame: windowFrame, composeFrame: composeFrame) + let placed: [(text: String, frame: CGRect)] = lines.compactMap { line in + let text = line.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard line.confidence >= minimumConfidence, !isChrome(text) else { return nil } + let frame = globalFrame(of: line.boundingBox, in: windowFrame) + guard region.contains(CGPoint(x: frame.midX, y: frame.midY)) else { return nil } + return (text, frame) + } + .sorted { $0.frame.minY == $1.frame.minY ? $0.frame.minX < $1.frame.minX : $0.frame.minY < $1.frame.minY } + + var blocks: [(texts: [String], frame: CGRect, lineHeight: CGFloat)] = [] + for line in placed { + if let last = blocks.last, continues(last.frame, lineHeight: last.lineHeight, with: line.frame) { + blocks[blocks.count - 1].texts.append(line.text) + blocks[blocks.count - 1].frame = last.frame.union(line.frame) + } else { + blocks.append(([line.text], line.frame, line.frame.height)) + } + } + return blocks.map { MessageBlock(text: $0.texts.joined(separator: " "), frame: $0.frame) } + } + + /// Maps a Vision box into global points (top-left origin) for a capture of `windowFrame`. + static func globalFrame(of box: CGRect, in windowFrame: CGRect) -> CGRect { + CGRect( + x: windowFrame.minX + box.minX * windowFrame.width, + y: windowFrame.minY + (1 - box.maxY) * windowFrame.height, + width: box.width * windowFrame.width, + height: box.height * windowFrame.height + ) + } + + // MARK: - Rules + + /// The part of the window that holds the conversation. With a reply field, that is everything + /// above it from its left edge (minus a small margin) to the window's right edge; without one, + /// the window below its title bar. + private static func conversationRegion(windowFrame: CGRect, composeFrame: CGRect?) -> CGRect { + let titleBar: CGFloat = 52 + guard let composeFrame, windowFrame.intersects(composeFrame) else { + return CGRect(x: windowFrame.minX, y: windowFrame.minY + titleBar, + width: windowFrame.width, height: max(0, windowFrame.height - titleBar)) + } + let left = max(windowFrame.minX, composeFrame.minX - 24) + let top = windowFrame.minY + titleBar + return CGRect(x: left, y: top, width: windowFrame.maxX - left, height: max(0, composeFrame.minY - top)) + } + + /// The next line belongs to the same message when it starts right below (within 0.8 line + /// heights) and lines up with the block's left edge or overlaps it horizontally. + private static func continues(_ block: CGRect, lineHeight: CGFloat, with line: CGRect) -> Bool { + let gap = line.minY - block.maxY + guard gap >= -lineHeight * 0.3, gap <= lineHeight * 0.8 else { return false } + let alignedLeft = abs(line.minX - block.minX) <= lineHeight * 1.5 + let overlaps = line.minX < block.maxX && line.maxX > block.minX + return alignedLeft || overlaps + } + + /// Timestamps, read receipts, and other non-message text chat apps draw. + private static func isChrome(_ text: String) -> Bool { + guard text.count >= 2 else { return true } + let letters = text.filter(\.isLetter).count + if letters == 0 { return true } + let time = #"^\d{1,2}[:.]\d{2}(\s?[AaPp][Mm])?$"# + if text.range(of: time, options: .regularExpression) != nil { return true } + return false + } +} diff --git a/Cotabby/Support/Translation/TranslationCache.swift b/Cotabby/Support/Translation/TranslationCache.swift new file mode 100644 index 00000000..c7dd8402 --- /dev/null +++ b/Cotabby/Support/Translation/TranslationCache.swift @@ -0,0 +1,49 @@ +import Foundation + +/// In-memory least-recently-used cache of translations. +/// +/// Chats re-render the same messages constantly (every scroll, every capture), so translating each +/// one once and reusing it is what keeps the overlay cheap. Nothing here is ever written to disk: +/// translations are other people's messages, and they vanish when Cotabby quits. +nonisolated struct TranslationCache: Sendable { + private struct Key: Hashable, Sendable { + let text: String + let target: String + } + + private let capacity: Int + private var values: [Key: TranslationResult] = [:] + private var order: [Key] = [] + + init(capacity: Int = 500) { + self.capacity = max(1, capacity) + } + + var count: Int { values.count } + + mutating func value(for text: String, target: String) -> TranslationResult? { + let key = Key(text: text, target: target) + guard let value = values[key] else { return nil } + touch(key) + return value + } + + mutating func store(_ result: TranslationResult) { + let key = Key(text: result.sourceText, target: result.targetLanguage) + values[key] = result + touch(key) + while order.count > capacity { + values[order.removeFirst()] = nil + } + } + + mutating func removeAll() { + values = [:] + order = [] + } + + private mutating func touch(_ key: Key) { + if let index = order.firstIndex(of: key) { order.remove(at: index) } + order.append(key) + } +} diff --git a/Cotabby/Support/Translation/TranslationLanguagePolicy.swift b/Cotabby/Support/Translation/TranslationLanguagePolicy.swift new file mode 100644 index 00000000..a507b410 --- /dev/null +++ b/Cotabby/Support/Translation/TranslationLanguagePolicy.swift @@ -0,0 +1,96 @@ +import Foundation +import NaturalLanguage + +/// Decides which language a piece of text is in and whether it needs translating. +/// +/// Pure apart from Apple's `NLLanguageRecognizer`, whose decision is wrapped by a deterministic +/// rule (`confidentLanguage`) so tests do not depend on Natural Language's model probabilities. +/// +/// It is conservative on purpose: a wrong "this is Turkish" guess would put a nonsense +/// translation under a message, so short text, links, numbers, and low-confidence guesses are +/// left alone. +nonisolated enum TranslationLanguagePolicy { + struct Detection: Equatable, Sendable { + let language: String + let confidence: Double + } + + /// Below this many letters, language identification is guesswork ("ok", "haha", "👍"). + static let minimumLetters = 12 + static let minimumConfidence = 0.8 + + /// The detected language of `text`, or nil when it is too short or ambiguous. + static func detect(_ text: String) -> Detection? { + let letters = text.filter(\.isLetter) + guard letters.count >= minimumLetters, !looksLikeLinkOrCode(text) else { return nil } + + if let macedonian = macedonianByAlphabet(text) { return macedonian } + + let recognizer = NLLanguageRecognizer() + recognizer.processString(text) + let hypotheses = recognizer.languageHypotheses(withMaximum: 3) + .map { (code: $0.key.rawValue, score: $0.value) } + guard let best = confidentLanguage(from: hypotheses) else { return nil } + // Natural Language has no Macedonian model and labels Macedonian text Bulgarian. Text that + // uses only letters the Macedonian alphabet has is far more likely Macedonian for a user + // who writes it; genuine Bulgarian almost always uses ъ, щ, ь, ю or я. + if best.language == "bg", usesOnlyMacedonianCyrillic(text) { + return Detection(language: "mk", confidence: best.confidence) + } + return best + } + + /// The detection when `text` should be translated into `readingLanguage`, else nil. + static func needsTranslation(_ text: String, readingLanguage: String) -> Detection? { + guard let detection = detect(text), !sameLanguage(detection.language, readingLanguage) else { return nil } + return detection + } + + /// Whether the user's draft is written in their reading language (the reply case). + static func isWritten(in language: String, _ text: String) -> Bool { + guard let detection = detect(text) else { return false } + return sameLanguage(detection.language, language) + } + + static func confidentLanguage(from hypotheses: [(code: String, score: Double)]) -> Detection? { + guard let best = hypotheses.max(by: { $0.score < $1.score }), best.score >= minimumConfidence else { return nil } + return Detection(language: best.code, confidence: best.score) + } + + /// "en-US" and "en" are the same reading language. + static func sameLanguage(_ lhs: String, _ rhs: String) -> Bool { + baseCode(lhs) == baseCode(rhs) + } + + static func baseCode(_ code: String) -> String { + String(code.lowercased().split(whereSeparator: { $0 == "-" || $0 == "_" }).first ?? "") + } + + // MARK: - Macedonian + + /// Letters only the Macedonian alphabet has (Serbian shares ј љ њ џ but not ѓ ќ ѕ). + private static let macedonianOnlyLetters: Set = ["ѓ", "ќ", "ѕ"] + /// Cyrillic letters absent from Macedonian (Russian, Bulgarian, Ukrainian). + private static let nonMacedonianCyrillic: Set = ["ъ", "щ", "ь", "ю", "я", "ы", "э", "ё", "й", "і", "ї", "є"] + + private static func macedonianByAlphabet(_ text: String) -> Detection? { + let lowered = text.lowercased() + guard lowered.contains(where: macedonianOnlyLetters.contains), + !lowered.contains(where: nonMacedonianCyrillic.contains) else { return nil } + return Detection(language: "mk", confidence: 0.95) + } + + private static func usesOnlyMacedonianCyrillic(_ text: String) -> Bool { + !text.lowercased().contains(where: nonMacedonianCyrillic.contains) + } + + // MARK: - Noise + + private static func looksLikeLinkOrCode(_ text: String) -> Bool { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") || trimmed.hasPrefix("www.") { return true } + // Mostly symbols and digits (codes, amounts, phone numbers): nothing to translate. + let letters = trimmed.filter(\.isLetter).count + return Double(letters) / Double(max(trimmed.count, 1)) < 0.5 + } +} diff --git a/Cotabby/UI/Settings/Panes/TranslationPaneView.swift b/Cotabby/UI/Settings/Panes/TranslationPaneView.swift new file mode 100644 index 00000000..caabc5bf --- /dev/null +++ b/Cotabby/UI/Settings/Panes/TranslationPaneView.swift @@ -0,0 +1,211 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +/// Settings → Translation: augmented translation of incoming messages and replies. +/// +/// Presentation only. Preferences live in `TranslationPreferencesStore`; the per-language status +/// asks `TranslationService`, which knows what Apple Translation has installed and whether the +/// local model can cover the rest. +struct TranslationPaneView: View { + @ObservedObject var preferences: TranslationPreferencesStore + let service: TranslationService + + @State private var statuses: [String: TranslationPairAvailability] = [:] + @State private var isRecordingShortcut = false + + /// Chat languages whose status is shown; the user's own from their Writing languages are + /// common ones, and Macedonian shows how the local-model fallback is reported. + private static let statusLanguages = ["tr", "mk", "de", "fr", "es", "it", "ru", "nl", "ar", "zh"] + + var body: some View { + SettingsPaneScaffold { + Section("Translation") { + Toggle(isOn: binding(\.isEnabled, preferences.setEnabled)) { + SettingsRowLabel( + title: "Translate Messages", + description: "In the apps below, show translations of messages in other languages, " + + "and offer your replies in the chat's language. Everything runs on this Mac.", + systemImage: "character.bubble" + ) + } + .settingsItem(.translationEnabled) + + Picker(selection: binding(\.readingLanguage, preferences.setReadingLanguage)) { + ForEach(Self.readingLanguageChoices, id: \.self) { code in + Text(Self.name(code)).tag(code) + } + } label: { + SettingsRowLabel( + title: "My Language", + description: "Messages are translated into this language, and replies you write in it are offered in the chat's language.", + systemImage: "person.wave.2" + ) + } + .disabled(!preferences.preferences.isEnabled) + + Toggle(isOn: binding(\.translatesIncoming, preferences.setTranslatesIncoming)) { + SettingsRowLabel( + title: "Translate Incoming Messages", + description: "Reads the chat window every few seconds and shows each translation under its message. Needs Screen Recording.", + systemImage: "text.bubble" + ) + } + .disabled(!preferences.preferences.isEnabled) + + Toggle(isOn: binding(\.offersReplyTranslation, preferences.setOffersReplyTranslation)) { + SettingsRowLabel( + title: "Offer Reply Translation", + description: "When you write in your language, show your reply in the chat's language. " + + "Press \(preferences.preferences.replaceKeyLabel) to replace your draft; you still press Return to send.", + systemImage: "arrowshape.turn.up.left" + ) + } + .disabled(!preferences.preferences.isEnabled) + + LabeledContent { + KeybindRow( + label: preferences.preferences.replaceKeyLabel, + keyCode: preferences.preferences.replaceKeyCode, + isRecording: $isRecordingShortcut, + onRecord: { keyCode, modifiers, label in + preferences.setReplaceKey(keyCode: keyCode, modifiers: modifiers.rawValue, label: label) + }, + onReset: { + preferences.setReplaceKey( + keyCode: TranslationPreferences.defaultReplaceKeyCode, + modifiers: TranslationPreferences.defaultReplaceKeyModifiers, + label: TranslationPreferences.defaultReplaceKeyLabel + ) + }, + resetLabel: "Reset", + shouldShowReset: preferences.preferences.replaceKeyCode != TranslationPreferences.defaultReplaceKeyCode + || preferences.preferences.replaceKeyModifiers != TranslationPreferences.defaultReplaceKeyModifiers, + onClear: { + preferences.setReplaceKey( + keyCode: TranslationPreferences.defaultReplaceKeyCode, + modifiers: TranslationPreferences.defaultReplaceKeyModifiers, + label: TranslationPreferences.defaultReplaceKeyLabel + ) + }, + clearLabel: "Use Default", + clearHelp: "Go back to \(TranslationPreferences.defaultReplaceKeyLabel).", + conflictChecker: { _, _ in nil } + ) + } label: { + SettingsRowLabel( + title: "Replace Draft Shortcut", + description: "Replaces your draft with the offered translation. It never sends the message.", + systemImage: "keyboard" + ) + } + .disabled(!preferences.preferences.isEnabled || !preferences.preferences.offersReplyTranslation) + } + + Section("Apps") { + ForEach(preferences.preferences.appBundleIdentifiers, id: \.self) { identifier in + LabeledContent { + Button("Remove") { preferences.setApp(identifier, included: false) } + } label: { + HStack(spacing: 10) { + Image(nsImage: Self.icon(for: identifier)) + .resizable() + .frame(width: 20, height: 20) + .accessibilityHidden(true) + Text(Self.appName(for: identifier)) + } + } + } + Button("Add App…") { addApp() } + .settingsItem(.translationApps) + } + + Section("Languages") { + ForEach(Self.statusLanguages, id: \.self) { code in + LabeledContent(Self.name(code)) { + Text(statusLabel(statuses[code])) + .foregroundStyle(statusColor(statuses[code])) + } + } + Text("Apple Translation runs on this Mac for about 20 languages; download missing ones in " + + "System Settings › General › Language & Region › Translation Languages. Other languages, " + + "like Macedonian, use your Open Source model, which is slower and less accurate.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .settingsItem(.translationLanguages) + } + + Section("Privacy") { + Text("Translation reads text from the chat window with Screen Recording, translates it on this Mac, " + + "and keeps translations in memory only. Nothing is saved, logged, or sent to a server. " + + "Password fields and apps where Cotabby is off are never read.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .task(id: preferences.preferences.readingLanguage) { await refreshStatuses() } + } + + private static func icon(for bundleIdentifier: String) -> NSImage { + NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) + .map { NSWorkspace.shared.icon(forFile: $0.path) } ?? NSWorkspace.shared.icon(for: .applicationBundle) + } + + private static func appName(for bundleIdentifier: String) -> String { + NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) + .map { FileManager.default.displayName(atPath: $0.path).replacingOccurrences(of: ".app", with: "") } + ?? bundleIdentifier + } + + private static let readingLanguageChoices = ["en", "tr", "mk", "de", "fr", "es", "it", "nl", "ru"] + + private static func name(_ code: String) -> String { + Locale.current.localizedString(forLanguageCode: code) ?? code + } + + private func refreshStatuses() async { + var result: [String: TranslationPairAvailability] = [:] + let reading = preferences.preferences.readingLanguage + for code in Self.statusLanguages where !TranslationLanguagePolicy.sameLanguage(code, reading) { + result[code] = await service.availability(from: code, to: reading) + } + statuses = result + } + + private func statusLabel(_ status: TranslationPairAvailability?) -> String { + switch status { + case .ready: return "Apple Translation" + case .needsDownload: return "Download in System Settings" + case .localModel: return "Open Source model" + case .unavailable: return "Not available" + case nil: return "Your language" + } + } + + private func statusColor(_ status: TranslationPairAvailability?) -> Color { + switch status { + case .ready: return .green + case .needsDownload, .localModel: return .orange + case .unavailable: return .red + case nil: return .secondary + } + } + + private func binding( + _ keyPath: KeyPath, + _ setter: @escaping (Value) -> Void + ) -> Binding { + Binding(get: { preferences.preferences[keyPath: keyPath] }, set: setter) + } + + private func addApp() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.application] + panel.directoryURL = URL(fileURLWithPath: "/Applications") + panel.prompt = "Add" + guard panel.runModal() == .OK, let url = panel.url, let metadata = ApplicationBundleMetadata(appURL: url) else { return } + preferences.setApp(metadata.bundleIdentifier, included: true) + } +} diff --git a/Cotabby/UI/Settings/SettingsCategory.swift b/Cotabby/UI/Settings/SettingsCategory.swift index a71af600..ba9a524b 100644 --- a/Cotabby/UI/Settings/SettingsCategory.swift +++ b/Cotabby/UI/Settings/SettingsCategory.swift @@ -17,6 +17,7 @@ enum SettingsCategory: String, CaseIterable, Hashable, Identifiable { case emoji case writing case context + case translation case engineAndModel case shortcuts case apps @@ -34,6 +35,7 @@ enum SettingsCategory: String, CaseIterable, Hashable, Identifiable { case .emoji: return "Emoji" case .writing: return "Writing" case .context: return "Context" + case .translation: return "Translation" case .engineAndModel: return "Engine & Model" case .shortcuts: return "Shortcuts" case .apps: return "Apps" @@ -52,6 +54,7 @@ enum SettingsCategory: String, CaseIterable, Hashable, Identifiable { case .emoji: return "face.smiling" case .writing: return "square.and.pencil" case .context: return "doc.text" + case .translation: return "character.bubble.fill" case .engineAndModel: return "cpu.fill" case .shortcuts: return "keyboard.fill" case .apps: return "app.badge.fill" @@ -72,6 +75,7 @@ enum SettingsCategory: String, CaseIterable, Hashable, Identifiable { case .emoji: return .yellow case .writing: return .indigo case .context: return .teal + case .translation: return .blue case .engineAndModel: return .orange case .shortcuts: return .pink case .apps: return .red @@ -90,6 +94,7 @@ enum SettingsCategory: String, CaseIterable, Hashable, Identifiable { case .emoji: return "The inline emoji picker" case .writing: return "Length, profile, and corrections" case .context: return "What the model can reference" + case .translation: return "Translate messages and replies" case .engineAndModel: return "Choose the engine and models" case .shortcuts: return "Keys that accept suggestions" case .apps: return "Where Cotabby stays quiet" @@ -106,7 +111,7 @@ enum SettingsCategory: String, CaseIterable, Hashable, Identifiable { static let sidebarGroups: [[SettingsCategory]] = [ [.home], [.general, .appearance, .emoji], - [.writing, .context, .engineAndModel], + [.writing, .context, .translation, .engineAndModel], [.shortcuts, .apps, .permissions], [.performance, .about] ] diff --git a/Cotabby/UI/Settings/SettingsContainerView.swift b/Cotabby/UI/Settings/SettingsContainerView.swift index e65917cc..81101cb4 100644 --- a/Cotabby/UI/Settings/SettingsContainerView.swift +++ b/Cotabby/UI/Settings/SettingsContainerView.swift @@ -28,6 +28,8 @@ struct SettingsContainerView: View { let onShowWelcome: () -> Void let clearEmojiHistory: () -> Void + @ObservedObject var translationPreferences: TranslationPreferencesStore + let translationService: TranslationService let onQuit: () -> Void @AppStorage("cotabbySettingsSelectedCategoryV2") @@ -142,6 +144,8 @@ struct SettingsContainerView: View { ) case .writing: WritingPaneView(suggestionSettings: suggestionSettings) + case .translation: + TranslationPaneView(preferences: translationPreferences, service: translationService) case .context: ContextPaneView(suggestionSettings: suggestionSettings) case .shortcuts: diff --git a/Cotabby/UI/Settings/SettingsIndex.swift b/Cotabby/UI/Settings/SettingsIndex.swift index 29ce5545..ebe17cfa 100644 --- a/Cotabby/UI/Settings/SettingsIndex.swift +++ b/Cotabby/UI/Settings/SettingsIndex.swift @@ -59,6 +59,9 @@ enum SettingsItem: String, CaseIterable, Identifiable { case automaticallyFixTypos // Context case extendedContext + case translationEnabled + case translationApps + case translationLanguages case contextLivePreview // Engine & Model case engine @@ -149,6 +152,9 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .spellingDictionaries: return "Spelling Dictionaries" case .automaticallyFixTypos: return "Automatically Fix Typos" case .extendedContext: return "Extended Context" + case .translationEnabled: return "Translate Messages" + case .translationApps: return "Translation Apps" + case .translationLanguages: return "Translation Languages" case .contextLivePreview: return "Live Preview" case .engine: return "Engine" case .appleIntelligenceAvailability: return "Apple Intelligence Availability" @@ -233,6 +239,9 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .spellingDictionaries: return "character.book.closed" case .automaticallyFixTypos: return "checkmark.circle" case .extendedContext: return "doc.text" + case .translationEnabled: return "character.bubble" + case .translationApps: return "app.badge" + case .translationLanguages: return "globe" case .contextLivePreview: return "text.cursor" case .engine: return "cpu" case .appleIntelligenceAvailability: return "apple.logo" @@ -293,6 +302,8 @@ enum SettingsItem: String, CaseIterable, Identifiable { return .writing case .extendedContext, .contextLivePreview: return .context + case .translationEnabled, .translationApps, .translationLanguages: + return .translation case .engine, .appleIntelligenceAvailability, .modelStatus, .selectedModel, .lowPowerModeAutoDisable, .powerBasedModelSwitching, .batteryModel, .pluggedInModel, .downloadModels, .huggingFaceBrowser, .modelsFolder, .lmStudio, @@ -360,6 +371,9 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .spellingDictionaries: return "Dictionaries used to detect typos." case .automaticallyFixTypos: return "Replace a misspelled word right after you press Space." case .extendedContext: return "A glossary or notes sent with every suggestion." + case .translationEnabled: return "Translate incoming messages and offer replies in the chat's language." + case .translationApps: return "Apps whose chats are translated." + case .translationLanguages: return "Which languages translate on this Mac, and how." case .contextLivePreview: return "A real field that exercises the full pipeline." case .engine: return "Apple Intelligence, bundled Open Source, or a local endpoint." case .appleIntelligenceAvailability: return "Whether this Mac can run Apple Intelligence." @@ -526,6 +540,13 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .automaticallyFixTypos: return ["typo", "automatic", "automatically", "autocorrect", "fix", "spelling", "replace", "space", "instant", "without accepting"] + case .translationEnabled: + return ["translate", "translation", "translator", "augmented", "incoming", "reply", "foreign language", + "turkish", "macedonian", "messages", "chat"] + case .translationApps: + return ["translate", "translation apps", "whatsapp", "teams", "telegram", "slack", "outlook", "mail"] + case .translationLanguages: + return ["translation languages", "apple translation", "download languages", "offline translation"] case .extendedContext: return ["context", "glossary", "reference", "notes", "jargon", "instructions", "memory", "background", "system prompt", "vocabulary"] diff --git a/CotabbyTests/Support/Translation/TranslationCoreTests.swift b/CotabbyTests/Support/Translation/TranslationCoreTests.swift new file mode 100644 index 00000000..93037ed5 --- /dev/null +++ b/CotabbyTests/Support/Translation/TranslationCoreTests.swift @@ -0,0 +1,214 @@ +import CoreGraphics +import XCTest +@testable import Cotabby + +final class TranslationLanguagePolicyTests: XCTestCase { + func test_turkishMessageNeedsTranslationIntoEnglish() { + let detection = TranslationLanguagePolicy.needsTranslation( + "Yarın sabah toplantıdan sonra seni arayacağım, tamam mı?", readingLanguage: "en" + ) + XCTAssertEqual(detection?.language, "tr") + } + + func test_englishMessageIsLeftAlone() { + XCTAssertNil(TranslationLanguagePolicy.needsTranslation( + "I will call you tomorrow morning after the meeting.", readingLanguage: "en-US" + )) + } + + func test_macedonianIsRecognizedByItsAlphabet() { + XCTAssertEqual(TranslationLanguagePolicy.detect("Ќе ти се јавам после состанокот утре.")?.language, "mk") + } + + func test_russianIsNotMistakenForMacedonian() { + XCTAssertEqual(TranslationLanguagePolicy.detect("Я позвоню тебе завтра утром после встречи.")?.language, "ru") + } + + func test_shortTextLinksAndNumbersAreSkipped() { + XCTAssertNil(TranslationLanguagePolicy.detect("tamam")) + XCTAssertNil(TranslationLanguagePolicy.detect("https://imperum.io/docs/getting-started")) + XCTAssertNil(TranslationLanguagePolicy.detect("+31 6 1234 5678 / 11:20 / €4.500,00")) + } + + func test_lowConfidenceHypothesesAreRejected() { + XCTAssertNil(TranslationLanguagePolicy.confidentLanguage(from: [("tr", 0.55), ("az", 0.4)])) + XCTAssertEqual(TranslationLanguagePolicy.confidentLanguage(from: [("tr", 0.92)])?.language, "tr") + } + + func test_draftLanguageCheck() { + XCTAssertTrue(TranslationLanguagePolicy.isWritten(in: "en", "Thanks, I will send the report tonight.")) + XCTAssertFalse(TranslationLanguagePolicy.isWritten(in: "en", "Teşekkürler, raporu bu akşam göndereceğim.")) + } +} + +final class TranslationCacheTests: XCTestCase { + private func result(_ text: String) -> TranslationResult { + TranslationResult(sourceText: text, sourceLanguage: "tr", targetLanguage: "en", translatedText: text.uppercased()) + } + + func test_storesAndEvictsLeastRecentlyUsed() { + var cache = TranslationCache(capacity: 2) + cache.store(result("bir")) + cache.store(result("iki")) + _ = cache.value(for: "bir", target: "en") + cache.store(result("üç")) + + XCTAssertNotNil(cache.value(for: "bir", target: "en")) + XCTAssertNil(cache.value(for: "iki", target: "en")) + XCTAssertEqual(cache.count, 2) + } +} + +final class FewShotTranslationPromptTests: XCTestCase { + func test_promptEndsWithTheTargetLabelAndKeepsOneLinePerMessage() throws { + let prompt = try XCTUnwrap(FewShotTranslationPrompt.prompt(for: "Здраво,\nкако си?", source: "mk", target: "en")) + + XCTAssertTrue(prompt.hasSuffix("Macedonian: Здраво, како си?\nEnglish:")) + XCTAssertTrue(prompt.contains("English: I will call you after the meeting.")) + } + + func test_outputStopsAtTheFirstLineOrRepeatedLabel() { + XCTAssertEqual(FewShotTranslationPrompt.cleanedOutput(" Hello, how are you?\n\nMacedonian: ..."), "Hello, how are you?") + XCTAssertEqual(FewShotTranslationPrompt.cleanedOutput(" Hello there. Macedonian: Здраво"), "Hello there.") + } + + func test_unsupportedPairsHaveNoPrompt() { + XCTAssertFalse(FewShotTranslationPrompt.supports(source: "ja", target: "en")) + XCTAssertNil(FewShotTranslationPrompt.prompt(for: "こんにちは", source: "ja", target: "en")) + } +} + +final class MessageBlockGrouperTests: XCTestCase { + private let window = CGRect(x: 100, y: 100, width: 1000, height: 800) + + /// A Vision box for a line at the given window-relative top-left point. + private func line(_ text: String, x: CGFloat, y: CGFloat, width: CGFloat = 400, height: CGFloat = 20, + confidence: Float = 0.95) -> MessageBlockGrouper.Line { + MessageBlockGrouper.Line( + text: text, confidence: confidence, + boundingBox: CGRect(x: x / 1000, y: 1 - (y + height) / 800, width: width / 1000, height: height / 800) + ) + } + + func test_wrappedLinesOfOneMessageMergeAndSeparateMessagesStaySplit() { + let lines = [ + line("Yarın sabah toplantıdan sonra", x: 400, y: 200), + line("seni arayacağım.", x: 400, y: 222), + line("Tamam, bekliyorum o zaman!", x: 400, y: 300) + ] + + let blocks = MessageBlockGrouper.blocks(from: lines, windowFrame: window, composeFrame: nil) + + XCTAssertEqual(blocks.map(\.text), ["Yarın sabah toplantıdan sonra seni arayacağım.", "Tamam, bekliyorum o zaman!"]) + XCTAssertEqual(blocks[0].frame.minY, 300, accuracy: 0.5, "Mapped into global points") + } + + func test_chatListLeftOfTheReplyFieldAndTheDraftAreIgnored() { + let compose = CGRect(x: 400, y: 820, width: 600, height: 40) + let lines = [ + line("Ahmet: Merhaba nasılsın bugün", x: 20, y: 200), + line("Merhaba, toplantı saat kaçta?", x: 420, y: 200), + line("draft I am typing right now", x: 320, y: 725) + ] + + let blocks = MessageBlockGrouper.blocks(from: lines, windowFrame: window, composeFrame: compose) + + XCTAssertEqual(blocks.map(\.text), ["Merhaba, toplantı saat kaçta?"]) + } + + func test_timestampsAndLowConfidenceLinesAreDropped() { + let lines = [ + line("11:20", x: 400, y: 200, width: 40), + line("Iİ!", x: 400, y: 260, confidence: 0.2), + line("Görüşürüz yarın akşam", x: 400, y: 320) + ] + + XCTAssertEqual(MessageBlockGrouper.blocks(from: lines, windowFrame: window, composeFrame: nil).map(\.text), + ["Görüşürüz yarın akşam"]) + } +} + +final class ConversationLanguageTrackerTests: XCTestCase { + func test_remembersTheLatestLanguagePerConversationAndCaps() { + var tracker = ConversationLanguageTracker(capacity: 2) + let ahmet = ConversationLanguageTracker.key(bundleIdentifier: "net.whatsapp.WhatsApp", windowTitle: "Ahmet") + tracker.record(language: "tr", for: ahmet, at: Date(timeIntervalSince1970: 1)) + tracker.record(language: "mk", for: "b", at: Date(timeIntervalSince1970: 2)) + tracker.record(language: "de", for: "c", at: Date(timeIntervalSince1970: 3)) + + XCTAssertNil(tracker.language(for: ahmet), "The oldest conversation was evicted") + XCTAssertEqual(tracker.language(for: "c"), "de") + } +} + +@MainActor +final class TranslationServiceTests: XCTestCase { + private static var retained: [AnyObject] = [] + + private final class FakeEngine: TranslationEngine { + var status: TranslationPairAvailability + private(set) var calls = 0 + let prefix: String + init(status: TranslationPairAvailability, prefix: String) { self.status = status; self.prefix = prefix } + func availability(from source: String, to target: String) async -> TranslationPairAvailability { status } + func translate(_ text: String, from source: String, to target: String) async throws -> String { + calls += 1 + return "\(prefix):\(text)" + } + } + + private func make(apple: TranslationPairAvailability, local: TranslationPairAvailability) + -> (TranslationService, FakeEngine, FakeEngine) { + let appleEngine = FakeEngine(status: apple, prefix: "apple") + let localEngine = FakeEngine(status: local, prefix: "local") + let service = TranslationService(apple: appleEngine, local: localEngine) + Self.retained.append(contentsOf: [appleEngine, localEngine, service] as [AnyObject]) + return (service, appleEngine, localEngine) + } + + func test_appleIsUsedWhenItHasThePair() async throws { + let (service, apple, _) = make(apple: .ready, local: .localModel) + + let result = try await service.translate("Merhaba dünya", from: "tr", to: "en") + + XCTAssertEqual(result.translatedText, "apple:Merhaba dünya") + _ = try await service.translate("Merhaba dünya", from: "tr", to: "en") + XCTAssertEqual(apple.calls, 1, "The second request comes from the cache") + } + + func test_localModelCoversPairsAppleDoesNotSupport() async throws { + let (service, _, local) = make(apple: .unavailable, local: .localModel) + + let result = try await service.translate("Здраво", from: "mk", to: "en") + + XCTAssertEqual(result.translatedText, "local:Здраво") + XCTAssertEqual(local.calls, 1) + let status = await service.availability(from: "mk", to: "en") + XCTAssertEqual(status, .localModel) + } +} + +@MainActor +final class TranslationPreferencesStoreTests: XCTestCase { + private static var retained: [AnyObject] = [] + + func test_defaultsAreOffWithSuggestedAppsAndPersist() { + let suite = "cotabby.test.translation.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let store = TranslationPreferencesStore(userDefaults: defaults) + Self.retained.append(store) + XCTAssertFalse(store.preferences.isEnabled) + XCTAssertTrue(store.preferences.appBundleIdentifiers.contains("net.whatsapp.WhatsApp")) + XCTAssertFalse(store.isActive(forBundleIdentifier: "net.whatsapp.WhatsApp"), "Off until enabled") + + store.setEnabled(true) + store.setReadingLanguage("tr") + store.setApp("com.example.Chat", included: true) + + let reloaded = TranslationPreferencesStore(userDefaults: defaults) + Self.retained.append(reloaded) + XCTAssertTrue(reloaded.isActive(forBundleIdentifier: "com.example.Chat")) + XCTAssertEqual(reloaded.preferences.readingLanguage, "tr") + } +} From 0e7d27b9f5feeb15223d34e36176da00260993f5 Mon Sep 17 00:00:00 2001 From: Senad Date: Fri, 2 Oct 2026 18:34:03 +0200 Subject: [PATCH 2/4] Turn Autocomplete and Translate on or off per app or per window from the field icon Clicking Cotabby's field-edge icon opens a small popup for the focused app and window. It has an app switch and a window choice (same as app, on, off) for Autocomplete and for Translate. A window is its app plus its normalized title (unread badges like "(3) " dropped), so a Teams or WhatsApp chat can be configured on its own. - WindowFeatureScope (pure): window keys, "window choice wins over the app", and the disabled-apps set adjusted for the focused window, which every existing autocomplete gate now reads. - WindowFeatureOverrideStore: window choices in UserDefaults, bounded to 500 windows per feature. - App switches write to the existing owners (disabled-apps list, the translation app list), so Settings and the popup agree. - The icon is clickable, and stays visible but dimmed where autocomplete is off, since it is the way back on. The popup is a non-activating panel, so the host app keeps keyboard focus. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LptEE4YbaWL55YwQRnTt74 --- Cotabby.xcodeproj/project.pbxproj | 28 +++ .../SuggestionCoordinator+Input.swift | 8 +- .../SuggestionCoordinator+Lifecycle.swift | 32 +++- .../SuggestionCoordinator+Prediction.swift | 2 +- .../Suggestion/SuggestionCoordinator.swift | 4 + .../Translation/TranslationCoordinator.swift | 43 ++++- Cotabby/App/Core/AppDelegate.swift | 80 +++++++- Cotabby/App/Core/CotabbyAppEnvironment.swift | 18 ++ .../ActivationIndicatorController.swift | 40 +++- .../FieldScopeMenuController.swift | 95 +++++++++ .../WindowFeatureOverrideStore.swift | 81 ++++++++ .../Support/Settings/WindowFeatureScope.swift | 91 +++++++++ .../Overlays/FieldEdgeIconIndicatorView.swift | 4 + Cotabby/UI/Overlays/FieldScopeMenuView.swift | 180 ++++++++++++++++++ .../Settings/WindowFeatureScopeTests.swift | 93 +++++++++ 15 files changed, 780 insertions(+), 19 deletions(-) create mode 100644 Cotabby/Services/Presentation/FieldScopeMenuController.swift create mode 100644 Cotabby/Services/Suggestion/WindowFeatureOverrideStore.swift create mode 100644 Cotabby/Support/Settings/WindowFeatureScope.swift create mode 100644 Cotabby/UI/Overlays/FieldScopeMenuView.swift create mode 100644 CotabbyTests/Support/Settings/WindowFeatureScopeTests.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index b5c2d9de..3ef8431e 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -332,6 +332,7 @@ 52E6B0B61DA8AC33EF60BA04 /* FoundationModelSuggestionEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17794AE5A5D5A93A7919E6BA /* FoundationModelSuggestionEngine.swift */; }; 5313CEB1330FED9569FF55EF /* GhostBaselinePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C08EC70547439E840417F5A5 /* GhostBaselinePolicy.swift */; }; 5392CB32A4E8BD0A4EC0B7AB /* SuggestionPauseModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0ACD725A16F26E9F29124E4 /* SuggestionPauseModels.swift */; }; + 539D78979912915B640E0699 /* FieldScopeMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09186F1F67F2F029FE0335E2 /* FieldScopeMenuView.swift */; }; 53BA549ABCA25E9F44DF6341 /* DisplayCoordinateConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D881FED12A85FFC20F2C9D7 /* DisplayCoordinateConverterTests.swift */; }; 53F946FF09038C1C751C6E53 /* FoundationModelPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A051152D3C503A2339BF0FA /* FoundationModelPromptRenderer.swift */; }; 54091F97C3F64ABB737DC76B /* LanguageCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6EA89178F52F6D5B90CBFE /* LanguageCatalog.swift */; }; @@ -605,6 +606,7 @@ 980E02175E2D83D4B60FE519 /* SuggestionQualityMetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50119F67E7BB9FBC8EA94BF2 /* SuggestionQualityMetricsStore.swift */; }; 985C583A41DEEE040F48D661 /* FocusModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B778713FE1C9481CE0DF065 /* FocusModels.swift */; }; 986C1339A8FBA3569FC129D6 /* PopupChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87C6335EED41343595F17A08 /* PopupChrome.swift */; }; + 98EB9B7C171AD7B37EB1E5AD /* WindowFeatureScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 658E962BA04C47C4AF377D11 /* WindowFeatureScopeTests.swift */; }; 992D34699B695EFE0EBA8A1C /* KeybindRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5F6D44A9B4EC51505A0A363 /* KeybindRow.swift */; }; 99334CDC1399D03019202E85 /* CurrentWordExtractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9331B12568E717FCF9A5086 /* CurrentWordExtractorTests.swift */; }; 996786038E31C671B650DFE4 /* SuggestionOverlayViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9720913FECB22A9A28C96333 /* SuggestionOverlayViews.swift */; }; @@ -736,6 +738,7 @@ BBC7F32FC99DBC41B0102E54 /* OpenAICompatibleEndpointModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ABCE733783332BE52E43D67 /* OpenAICompatibleEndpointModels.swift */; }; BBCF59701C5E1E77E7A3F62D /* AXTreeDumpWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB07C5F0319D124B6266BCF3 /* AXTreeDumpWriter.swift */; }; BC0E4576CEA12EA3600D5A0A /* SuggestionCoordinatorAcceptanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E1EBD88A434C325CC6EFBDF /* SuggestionCoordinatorAcceptanceTests.swift */; }; + BC29ED1D254CFB650B6C199C /* WindowFeatureOverrideStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 318A4A2DEB3665B84EB2C6CE /* WindowFeatureOverrideStore.swift */; }; BC973BC6939231E0DB1836F1 /* SelfCaptureGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13838E1372956FF2CFC5ECCC /* SelfCaptureGate.swift */; }; BC9C2F30AF33F820256E4FBB /* PermissionModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D47E1471C110614F0D07E399 /* PermissionModels.swift */; }; BCD54829D3DD33D8B034AA73 /* SuggestionSettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFFEDED24AED136A9C83F730 /* SuggestionSettingsModel.swift */; }; @@ -765,6 +768,7 @@ C2D5D224A3B1954E198A9F9C /* FieldStyleCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C8D2853B43F01B285FB44C /* FieldStyleCacheTests.swift */; }; C304E4802C31CD1BCD2B8743 /* HostFaceMemory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7103021AEC287186CE25415D /* HostFaceMemory.swift */; }; C317134A15FC4388B3163632 /* Aria2DownloadServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA0A1D64275BC85AEBBB852 /* Aria2DownloadServiceTests.swift */; }; + C3B27AD6DEE3BC4B14308B08 /* FieldScopeMenuController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2F79734224739C7D611F5D0 /* FieldScopeMenuController.swift */; }; C3B79350EEAAC688F89A4E6D /* WelcomePersonalizeStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F26F50467C83B202FB4BAC2A /* WelcomePersonalizeStepView.swift */; }; C3C347E9485B70C59BF5EFC8 /* SuggestionQualityMetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50119F67E7BB9FBC8EA94BF2 /* SuggestionQualityMetricsStore.swift */; }; C3F63C89E82ED82DF24BD603 /* TypoGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE5F414704A8264C2946A50 /* TypoGateTests.swift */; }; @@ -792,6 +796,7 @@ C72537496242A0A90F4442B3 /* SpeculativeAcceptanceContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4B98B1ABB36E600D7ECA185 /* SpeculativeAcceptanceContextTests.swift */; }; C73BCA5544F526106FE162AE /* ModelDownloadSessionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEFBEB715AF043D3315B6656 /* ModelDownloadSessionDelegate.swift */; }; C7492A81906AD943E286F4B5 /* SuggestionContinuationPlanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 790082EFCDEF94454A3B805F /* SuggestionContinuationPlanTests.swift */; }; + C756B61C97A83986B4D924D3 /* WindowFeatureScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0BCDEA8C81F16BA8FDF45CB /* WindowFeatureScope.swift */; }; C7A48F93691943EF2EEBD775 /* DownloadOutcomeClassifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B3AD6D4125ED99669F4EC32 /* DownloadOutcomeClassifierTests.swift */; }; C82AD8930B5C3EB06D51A75A /* SuggestionRequestFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD834BCFF812935889E16D5A /* SuggestionRequestFactory.swift */; }; C8434622BFCE324E7044E581 /* TickMarkSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BB42779285A1BA40B7848C8 /* TickMarkSlider.swift */; }; @@ -842,6 +847,7 @@ D2DB4238276DD73A8D5F588C /* ClipboardRelevanceFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BF37C1A0F734BB940E371C2 /* ClipboardRelevanceFilter.swift */; }; D3044E73646472B70D754F3D /* BlockBreakAlignment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82420F9505E69AE2ADE9F583 /* BlockBreakAlignment.swift */; }; D338A02B462BCE3A9BA978ED /* SuggestionAvailabilityEvaluatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED76F12EBF9C7E7B9AA95142 /* SuggestionAvailabilityEvaluatorTests.swift */; }; + D356A74F896ACDF8D2970852 /* WindowFeatureOverrideStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 318A4A2DEB3665B84EB2C6CE /* WindowFeatureOverrideStore.swift */; }; D38D295DB3155EFB001B3F5C /* Aria2Provisioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D2F4ED8C7493EF4B967BFF9 /* Aria2Provisioner.swift */; }; D3909BBB80781B0CE1982A8E /* DeviceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6586D6D735F287F43D42520A /* DeviceInfo.swift */; }; D42BBBD704B21898047615E0 /* LlamaRuntimeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16967F782C95CEB09B9C8D49 /* LlamaRuntimeModels.swift */; }; @@ -933,6 +939,7 @@ EC4975F707689CEA77A636BB /* PromptSectionBudgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A73D95F2BF5CA59705653931 /* PromptSectionBudgetTests.swift */; }; ED560EA132AF1EEA0FE66DAA /* WordPrefixIndexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D549854AD86485DF61F3B75 /* WordPrefixIndexTests.swift */; }; ED5E57512F532DDA48DF3360 /* SurfaceContextComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27B03589D82D0ADCF41B9CD0 /* SurfaceContextComposer.swift */; }; + EDD49B25B85F1DF5938EA176 /* FieldScopeMenuController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2F79734224739C7D611F5D0 /* FieldScopeMenuController.swift */; }; EDD9AE89E16AB1EF47D06269 /* DownloadableModelCatalogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 415E80FEA7C1330D502CE97F /* DownloadableModelCatalogView.swift */; }; EDE18BDC23BED1559A750653 /* SuggestionCoordinator+WordCompletion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D2BDE0DF26393F326F7C44A /* SuggestionCoordinator+WordCompletion.swift */; }; EE2C9177CE615298595215A8 /* SuggestionOverlayPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D598CC3134879999D567455 /* SuggestionOverlayPresenter.swift */; }; @@ -959,6 +966,7 @@ F28293E59121DBD6241BA551 /* EmojiPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871C7A27BAB5A510273D0A19 /* EmojiPickerView.swift */; }; F2919788D0F55970FCCCA8BD /* FocusSessionScopedCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43EBAC4CC8B8B8266F3E4DA1 /* FocusSessionScopedCache.swift */; }; F32559EB4BE478324CA18B96 /* EmojiQueryRunTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626861231138509BCD7BB6EA /* EmojiQueryRunTests.swift */; }; + F326E0438140A7417365CCAD /* FieldScopeMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09186F1F67F2F029FE0335E2 /* FieldScopeMenuView.swift */; }; F35738A5EFCE7C2A737F08C9 /* EmojiPickerModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68ADAFFD5031EBFA70CD62BB /* EmojiPickerModelsTests.swift */; }; F38776739B0987C0E3F2D343 /* SettingsSearchRankerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95C62A4403E4815004C6FCB8 /* SettingsSearchRankerTests.swift */; }; F399998A7FBAB7115932D7AA /* TranslationOverlayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 683945CBE9B88F1A2D28A254 /* TranslationOverlayController.swift */; }; @@ -976,6 +984,7 @@ F619A3C5BD770B230A78530D /* ChromiumAccessibilityEnabler.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB13342992EEF2BDEEAB1236 /* ChromiumAccessibilityEnabler.swift */; }; F61CFA7A600E266D5A05E26E /* TokenHealingPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA9ADF6C0BD9D16016AA9B /* TokenHealingPlan.swift */; }; F6A88AAC1A3FE7FB284BFC34 /* TypingCadence.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7014E6E969FD4579028BAC4 /* TypingCadence.swift */; }; + F6CBEA62200866A9D9504A79 /* WindowFeatureScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0BCDEA8C81F16BA8FDF45CB /* WindowFeatureScope.swift */; }; F76ECB9663E46AA806E2C7DE /* SuggestionEngineRouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C034C1C9E51031A845D1184 /* SuggestionEngineRouterTests.swift */; }; F845522F1927C10BBB8792FD /* SettingsIconTile.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6A4CC16A6970E3E7086B5C /* SettingsIconTile.swift */; }; F894B9A7730EE6EEEB4C81BE /* VisualContextStartCoalescer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF8F1A40EAA82773C07F8B9 /* VisualContextStartCoalescer.swift */; }; @@ -1040,6 +1049,7 @@ 07480CE96ED0EBD94817C6B1 /* GeneralPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralPaneView.swift; sourceTree = ""; }; 081E05039D4CC2BE2EFCEAD4 /* SuggestionStateHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionStateHelperTests.swift; sourceTree = ""; }; 0854896090B568FFB1926D24 /* WordCountFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordCountFormatterTests.swift; sourceTree = ""; }; + 09186F1F67F2F029FE0335E2 /* FieldScopeMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldScopeMenuView.swift; sourceTree = ""; }; 09F14F4E5FCCFC79ACC22934 /* ConversationLanguageTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationLanguageTracker.swift; sourceTree = ""; }; 0A184538FD926947EBB88D9E /* EngineAndModelPaneView+Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EngineAndModelPaneView+Endpoint.swift"; sourceTree = ""; }; 0A3D1125B962CBE0269EEDDB /* SuggestionInserter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionInserter.swift; sourceTree = ""; }; @@ -1144,6 +1154,7 @@ 308F8660F70ED64511BDB59F /* RandomMacroEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RandomMacroEvaluator.swift; sourceTree = ""; }; 30AB3A99BEB2EA978298FC6F /* MidWordContinuationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MidWordContinuationPolicyTests.swift; sourceTree = ""; }; 3153FB925190B70E0502C43D /* InkCaretAnalyzerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InkCaretAnalyzerTests.swift; sourceTree = ""; }; + 318A4A2DEB3665B84EB2C6CE /* WindowFeatureOverrideStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowFeatureOverrideStore.swift; sourceTree = ""; }; 31A62CE4DA3E55B5E117DBD8 /* LlamaSuggestionEvalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaSuggestionEvalTests.swift; sourceTree = ""; }; 3350EDE01ED5125520C79D53 /* SettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsCoordinator.swift; sourceTree = ""; }; 338EE4D92F8BE01CF43FE876 /* OllamaPreloadWorkControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OllamaPreloadWorkControllerTests.swift; sourceTree = ""; }; @@ -1262,6 +1273,7 @@ 652973AF18C20D51F81F76C5 /* SurfaceContextComposerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceContextComposerTests.swift; sourceTree = ""; }; 655F56C230F45CAC13EEE59C /* SuggestionTextColorCodecTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionTextColorCodecTests.swift; sourceTree = ""; }; 6586D6D735F287F43D42520A /* DeviceInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfo.swift; sourceTree = ""; }; + 658E962BA04C47C4AF377D11 /* WindowFeatureScopeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowFeatureScopeTests.swift; sourceTree = ""; }; 65AF0FF1CE97A0761ADAFEB8 /* InlineCommandCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineCommandCoordinatorTests.swift; sourceTree = ""; }; 65E97C4BFAF5EAF6792170AC /* FocusSnapshotExternalApplicationIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSnapshotExternalApplicationIdentityTests.swift; sourceTree = ""; }; 665A55CA2E7975CBFC63B4E2 /* InkBaselineAnalyzer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InkBaselineAnalyzer.swift; sourceTree = ""; }; @@ -1506,6 +1518,7 @@ C1D68B5ABA427D3E87000E78 /* InlineCommandCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineCommandCoordinator.swift; sourceTree = ""; }; C2AD7D366EC4344D332EE6A3 /* Signing.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Signing.xcconfig; sourceTree = ""; }; C2B351B5FDF077F8FF49A9A7 /* SuggestionFadeInPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionFadeInPolicy.swift; sourceTree = ""; }; + C2F79734224739C7D611F5D0 /* FieldScopeMenuController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldScopeMenuController.swift; sourceTree = ""; }; C319B4FF652512A69377DD5C /* DecodeStopPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecodeStopPolicy.swift; sourceTree = ""; }; C32F6A7622BB9D537D157FA2 /* PermissionHostApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionHostApp.swift; sourceTree = ""; }; C354F219D47B96E53D4DE99F /* ShortcutModifierMaskNSEventFlagsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutModifierMaskNSEventFlagsTests.swift; sourceTree = ""; }; @@ -1624,6 +1637,7 @@ EF4D6CEDF032A25EF72D7228 /* KeyCodeLabels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCodeLabels.swift; sourceTree = ""; }; F000304686E4573BC351AD3F /* PermissionDragSourceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionDragSourceView.swift; sourceTree = ""; }; F0A61DC16ED76D694A59BF14 /* SuggestionSessionTypingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSessionTypingTests.swift; sourceTree = ""; }; + F0BCDEA8C81F16BA8FDF45CB /* WindowFeatureScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowFeatureScope.swift; sourceTree = ""; }; F155556C751FB074B31CCC64 /* WelcomeHeroDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeHeroDemo.swift; sourceTree = ""; }; F1A4C5DEA32868F99E953D99 /* CompletionSeamGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSeamGuard.swift; sourceTree = ""; }; F1B95D6456663C72DE3A6AF1 /* SuggestionEngineSelectionPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionEngineSelectionPolicyTests.swift; sourceTree = ""; }; @@ -1933,6 +1947,7 @@ 57B29DAC600626783649475A /* SettingsSearchRanker.swift */, 83256754ED5DD82BD749D6CE /* SuggestionEngineSelectionPolicy.swift */, 8AB17696CE79982039061F29 /* SuggestionSettingsStore.swift */, + F0BCDEA8C81F16BA8FDF45CB /* WindowFeatureScope.swift */, ); path = Settings; sourceTree = ""; @@ -2062,6 +2077,7 @@ 95C62A4403E4815004C6FCB8 /* SettingsSearchRankerTests.swift */, F1B95D6456663C72DE3A6AF1 /* SuggestionEngineSelectionPolicyTests.swift */, 35C0B587D81D87ACB952C95E /* SuggestionSettingsStoreTests.swift */, + 658E962BA04C47C4AF377D11 /* WindowFeatureScopeTests.swift */, ); path = Settings; sourceTree = ""; @@ -2113,6 +2129,7 @@ 003594B09C83EF2DF35577D5 /* SuggestionDebugLogger.swift */, 0A3D1125B962CBE0269EEDDB /* SuggestionInserter.swift */, 9D598CC3134879999D567455 /* SuggestionOverlayPresenter.swift */, + 318A4A2DEB3665B84EB2C6CE /* WindowFeatureOverrideStore.swift */, ); path = Suggestion; sourceTree = ""; @@ -2161,6 +2178,7 @@ isa = PBXGroup; children = ( F35CD1B715FCBB12BEB6CE62 /* FieldEdgeIconIndicatorView.swift */, + 09186F1F67F2F029FE0335E2 /* FieldScopeMenuView.swift */, 76BC705A82669AEFBC704F59 /* InlinePreviewView.swift */, 9720913FECB22A9A28C96333 /* SuggestionOverlayViews.swift */, ); @@ -3106,6 +3124,7 @@ children = ( 2E379AF08CDFF65D6EFC565E /* ActivationIndicatorController.swift */, 872C8DDC2E86A1C4C4BBD99F /* EmojiPickerPanelController.swift */, + C2F79734224739C7D611F5D0 /* FieldScopeMenuController.swift */, 8B4F6E70B8A242F7BDE5361A /* FocusDebugOverlayController.swift */, 29163A3D0BB0F11EEAC2985A /* GhostTextPanelView.swift */, DDC1ECBE9D1EDE855B57A913 /* HostBaselineCalibrator.swift */, @@ -4023,6 +4042,8 @@ E6650273045E6F1976AF23CA /* EngineAndModelPaneView.swift in Sources */, 2E5F38501661C60DE7EFD980 /* FewShotTranslationPrompt.swift in Sources */, D5929DF79BA02EE9B170B4D5 /* FieldEdgeIconIndicatorView.swift in Sources */, + C3B27AD6DEE3BC4B14308B08 /* FieldScopeMenuController.swift in Sources */, + 539D78979912915B640E0699 /* FieldScopeMenuView.swift in Sources */, 146BD035FE0200D06EE1D7AE /* FieldStyleCache.swift in Sources */, 11D3A5C8DA17BCDB79969FC4 /* FileLogHandler.swift in Sources */, 01EA87DB89817609EC02BD93 /* FocusCapabilityFlickerGate.swift in Sources */, @@ -4258,6 +4279,8 @@ 0901FA0B0FF3593645189449 /* WelcomePersonalizeStepView.swift in Sources */, 55DAFC9D06629C23A6868565 /* WelcomeTemplateStepView.swift in Sources */, A8580B09E21C2B44A1D9B0BA /* WelcomeView.swift in Sources */, + D356A74F896ACDF8D2970852 /* WindowFeatureOverrideStore.swift in Sources */, + C756B61C97A83986B4D924D3 /* WindowFeatureScope.swift in Sources */, A6DAD9EB9AE88A319EADAC7B /* WindowScreenshotService.swift in Sources */, D79C2A5DCDAFB9741E208103 /* WordCountFormatter.swift in Sources */, 840B11AFD82E64D5452CC7A3 /* WordPrefixIndex.swift in Sources */, @@ -4362,6 +4385,8 @@ 8B8EA667506EB67DBCA35D57 /* EngineAndModelPaneView.swift in Sources */, C8A90B93D5BD4B7CFF175670 /* FewShotTranslationPrompt.swift in Sources */, A3ABF1872257F40B14CBA3C2 /* FieldEdgeIconIndicatorView.swift in Sources */, + EDD49B25B85F1DF5938EA176 /* FieldScopeMenuController.swift in Sources */, + F326E0438140A7417365CCAD /* FieldScopeMenuView.swift in Sources */, 55243B4055E8067FE3483164 /* FieldStyleCache.swift in Sources */, AC716016F8E26C1711C39F6D /* FileLogHandler.swift in Sources */, 917E9217724C62C8C8E4E1C9 /* FocusCapabilityFlickerGate.swift in Sources */, @@ -4597,6 +4622,8 @@ C3B79350EEAAC688F89A4E6D /* WelcomePersonalizeStepView.swift in Sources */, 471F4800C22DACDA60BF54CD /* WelcomeTemplateStepView.swift in Sources */, 0DA822FDD75309CA14FCB915 /* WelcomeView.swift in Sources */, + BC29ED1D254CFB650B6C199C /* WindowFeatureOverrideStore.swift in Sources */, + F6CBEA62200866A9D9504A79 /* WindowFeatureScope.swift in Sources */, 1F8CC88AFFE67C08944CF506 /* WindowScreenshotService.swift in Sources */, F22FAA3449506ABBACB12BA0 /* WordCountFormatter.swift in Sources */, 1A0EEE893EA683AE79EA76C3 /* WordPrefixIndex.swift in Sources */, @@ -4867,6 +4894,7 @@ 9259C21BDBE17A814F0C6431 /* VisualContextExcerptSelectorTests.swift in Sources */, DC6FEE1602B6891CA4D0CDCA /* VisualContextStartCoalescerTests.swift in Sources */, 1536BBBDFC274C414DEB1420 /* WebContentFieldDetectorTests.swift in Sources */, + 98EB9B7C171AD7B37EB1E5AD /* WindowFeatureScopeTests.swift in Sources */, D8E0E5FA644F62103D18645B /* WordCountFormatterTests.swift in Sources */, ED560EA132AF1EEA0FE66DAA /* WordPrefixIndexTests.swift in Sources */, ); diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift index c0730e75..b8f62206 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift @@ -25,7 +25,7 @@ extension SuggestionCoordinator { temporarilyPaused: settingsSnapshot.isTemporarilyPaused, isLowPowerModeActive: lowPowerModeProvider.isLowPowerModeEnabled, isLowPowerModeAutoDisableEnabled: settingsSnapshot.isLowPowerModeAutoDisableEnabled, - disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, + disabledAppBundleIdentifiers: disabledApps(for: focusModel.snapshot), disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, inputMonitoringGranted: permissionManager.inputMonitoringGranted, @@ -45,7 +45,7 @@ extension SuggestionCoordinator { temporarilyPaused: settingsSnapshot.isTemporarilyPaused, isLowPowerModeActive: lowPowerModeProvider.isLowPowerModeEnabled, isLowPowerModeAutoDisableEnabled: settingsSnapshot.isLowPowerModeAutoDisableEnabled, - disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, + disabledAppBundleIdentifiers: disabledApps(for: focusModel.snapshot), disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, inputMonitoringGranted: permissionManager.inputMonitoringGranted, @@ -84,7 +84,7 @@ extension SuggestionCoordinator { temporarilyPaused: settingsSnapshot.isTemporarilyPaused, isLowPowerModeActive: lowPowerModeProvider.isLowPowerModeEnabled, isLowPowerModeAutoDisableEnabled: settingsSnapshot.isLowPowerModeAutoDisableEnabled, - disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, + disabledAppBundleIdentifiers: disabledApps(for: snapshot), disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, inputMonitoringGranted: permissionManager.inputMonitoringGranted, @@ -132,7 +132,7 @@ extension SuggestionCoordinator { temporarilyPaused: settingsSnapshot.isTemporarilyPaused, isLowPowerModeActive: lowPowerModeProvider.isLowPowerModeEnabled, isLowPowerModeAutoDisableEnabled: settingsSnapshot.isLowPowerModeAutoDisableEnabled, - disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, + disabledAppBundleIdentifiers: disabledApps(for: snapshot), disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, inputMonitoringGranted: permissionManager.inputMonitoringGranted, diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Lifecycle.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Lifecycle.swift index ef774b5b..6713f47c 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Lifecycle.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Lifecycle.swift @@ -54,6 +54,20 @@ extension SuggestionCoordinator { CotabbyLogger.suggestion.info("Settings changed, resetting suggestion state") settingsSnapshot = snapshot + resetAfterAvailabilityChange() + } + + /// The field icon changed a window's autocomplete choice. That is an availability change the + /// settings snapshot cannot see (window choices live in `WindowFeatureOverrideStore`), so it + /// takes the same reset-and-maybe-restart path a settings change does. + func handleWindowFeatureOverrideChange() { + CotabbyLogger.suggestion.info("Window feature choice changed, resetting suggestion state") + resetAfterAvailabilityChange() + } + + /// Drops in-flight work and the visible suggestion, then restarts context capture and + /// prediction only where the current settings and window choices still allow them. + private func resetAfterAvailabilityChange() { cancelPredictionWork() resetCachedGenerationContext() clearSuggestion(clearDiagnostics: true) @@ -68,7 +82,7 @@ extension SuggestionCoordinator { temporarilyPaused: settingsSnapshot.isTemporarilyPaused, isLowPowerModeActive: lowPowerModeProvider.isLowPowerModeEnabled, isLowPowerModeAutoDisableEnabled: settingsSnapshot.isLowPowerModeAutoDisableEnabled, - disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, + disabledAppBundleIdentifiers: disabledApps(for: focusModel.snapshot), disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, inputMonitoringGranted: permissionManager.inputMonitoringGranted, @@ -86,7 +100,7 @@ extension SuggestionCoordinator { temporarilyPaused: settingsSnapshot.isTemporarilyPaused, isLowPowerModeActive: lowPowerModeProvider.isLowPowerModeEnabled, isLowPowerModeAutoDisableEnabled: settingsSnapshot.isLowPowerModeAutoDisableEnabled, - disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, + disabledAppBundleIdentifiers: disabledApps(for: focusModel.snapshot), disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, inputMonitoringGranted: permissionManager.inputMonitoringGranted, @@ -109,7 +123,7 @@ extension SuggestionCoordinator { temporarilyPaused: settingsSnapshot.isTemporarilyPaused, isLowPowerModeActive: lowPowerModeProvider.isLowPowerModeEnabled, isLowPowerModeAutoDisableEnabled: settingsSnapshot.isLowPowerModeAutoDisableEnabled, - disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, + disabledAppBundleIdentifiers: disabledApps(for: snapshot), disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, inputMonitoringGranted: permissionManager.inputMonitoringGranted, @@ -120,3 +134,15 @@ extension SuggestionCoordinator { return context } } + +extension SuggestionCoordinator { + /// The disabled-apps set adjusted for the focused window's own choice, so every availability + /// gate honors "off in this chat" and "on in this chat" without learning about windows. + func disabledApps(for focusSnapshot: FocusSnapshot) -> Set { + WindowFeatureScope.effectiveDisabledApps( + settingsSnapshot.disabledAppBundleIdentifiers, + bundleIdentifier: focusSnapshot.bundleIdentifier, + windowOverride: windowAutocompleteOverride(focusSnapshot) + ) + } +} diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift index 3df7bcc2..3329b70f 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift @@ -1277,7 +1277,7 @@ extension SuggestionCoordinator { temporarilyPaused: settingsSnapshot.isTemporarilyPaused, isLowPowerModeActive: lowPowerModeProvider.isLowPowerModeEnabled, isLowPowerModeAutoDisableEnabled: settingsSnapshot.isLowPowerModeAutoDisableEnabled, - disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, + disabledAppBundleIdentifiers: disabledApps(for: focusSnapshot), disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, inputMonitoringGranted: permissionManager.inputMonitoringGranted, diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift index 227073e9..2cb81fe8 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift @@ -38,6 +38,10 @@ final class SuggestionCoordinator: ObservableObject { let suggestionEngine: any SuggestionGenerating let suggestionSettings: any SuggestionSettingsProviding let clipboardContextProvider: any ClipboardContextProviding + /// The focused window's own autocomplete choice from the field icon (nil: follow the app). + /// A closure rather than the concrete store so the coordinator stays testable without + /// `UserDefaults`; `CotabbyAppEnvironment` points it at `WindowFeatureOverrideStore`. + var windowAutocompleteOverride: @MainActor (FocusSnapshot) -> Bool? = { _ in nil } let clipboardRelevanceFilter: any ClipboardRelevanceFiltering let visualContextCoordinator: any VisualContextCoordinating let interactionState: SuggestionInteractionState diff --git a/Cotabby/App/Coordinators/Translation/TranslationCoordinator.swift b/Cotabby/App/Coordinators/Translation/TranslationCoordinator.swift index ecc91246..670d9441 100644 --- a/Cotabby/App/Coordinators/Translation/TranslationCoordinator.swift +++ b/Cotabby/App/Coordinators/Translation/TranslationCoordinator.swift @@ -27,6 +27,8 @@ final class TranslationCoordinator { private let hotkey: TranslationHotkeyTap /// Cotabby's own gates for an app: globally on, not paused, app not disabled, not Low Power Mode. private let isAllowed: @MainActor (String) -> Bool + /// Per-window choices made from the field icon; set by `CotabbyAppEnvironment`. + var windowOverrides: WindowFeatureOverrideStore? private var conversations = ConversationLanguageTracker() private var timer: Timer? @@ -122,7 +124,7 @@ final class TranslationCoordinator { guard prefs.isEnabled, prefs.translatesIncoming, let app = NSWorkspace.shared.frontmostApplication, let bundleIdentifier = app.bundleIdentifier, - preferences.isActive(forBundleIdentifier: bundleIdentifier), + mayTranslate(inApp: bundleIdentifier), isAllowed(bundleIdentifier), CGPreflightScreenCaptureAccess() else { @@ -133,6 +135,13 @@ final class TranslationCoordinator { do { let window = try await capture.captureActiveWindow(processIdentifier: app.processIdentifier) + // The window title is only known now; a chat turned off from the field icon stays off + // even though its app is on the translation list (and vice versa). + guard isTranslationActive(bundleIdentifier: bundleIdentifier, windowTitle: window.windowTitle) else { + overlay.hideIncoming() + lastCaptureSignature = nil + return + } let hash = Self.sampleHash(window.image) if let last = lastCaptureSignature, last.windowID == window.windowID, last.frame == window.windowFrame, last.hash == hash { @@ -227,6 +236,36 @@ final class TranslationCoordinator { return hasher.finalize() } + // MARK: - Scope + + /// Whether translation applies in this window: the window's own choice from the field icon + /// when it has one, otherwise whether its app is on the translation list. + func isTranslationActive(bundleIdentifier: String?, windowTitle: String?) -> Bool { + guard preferences.preferences.isEnabled, let bundleIdentifier else { return false } + let windowKey = WindowFeatureScope.windowKey(bundleIdentifier: bundleIdentifier, windowTitle: windowTitle) + return WindowFeatureScope.resolve( + appEnabled: preferences.isActive(forBundleIdentifier: bundleIdentifier), + windowOverride: windowOverrides?.override(for: .translation, windowKey: windowKey) + ) + } + + /// Cheap pre-check before a capture, when the front window's title is not known yet: the app + /// is on the list, or one of its windows was turned on from the field icon. + private func mayTranslate(inApp bundleIdentifier: String) -> Bool { + preferences.isActive(forBundleIdentifier: bundleIdentifier) + || (preferences.preferences.isEnabled + && windowOverrides?.hasEnabledWindow(for: .translation, bundleIdentifier: bundleIdentifier) == true) + } + + /// Re-run both directions now that a window or app choice changed, instead of waiting for the + /// next 2-second tick, so the popup's switch takes effect immediately. + func handleScopeChange() { + lastCaptureSignature = nil + overlay.hideIncoming() + scheduleIncomingPass() + observeReply(focusModel.snapshot) + } + // MARK: - Reply private func observeReply(_ snapshot: FocusSnapshot) { @@ -234,7 +273,7 @@ final class TranslationCoordinator { guard prefs.isEnabled, prefs.offersReplyTranslation, case .supported = snapshot.capability, let input = snapshot.context, !input.isSecure, - preferences.isActive(forBundleIdentifier: input.bundleIdentifier), + isTranslationActive(bundleIdentifier: input.bundleIdentifier, windowTitle: input.windowTitle), isAllowed(input.bundleIdentifier), let conversationLanguage = conversations.language( for: ConversationLanguageTracker.key(bundleIdentifier: input.bundleIdentifier, windowTitle: input.windowTitle)), diff --git a/Cotabby/App/Core/AppDelegate.swift b/Cotabby/App/Core/AppDelegate.swift index 5d97e3d4..d856d880 100644 --- a/Cotabby/App/Core/AppDelegate.swift +++ b/Cotabby/App/Core/AppDelegate.swift @@ -31,6 +31,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let settingsCoordinator: SettingsCoordinator private let activationIndicatorController: ActivationIndicatorController + private let fieldScopeMenuController: FieldScopeMenuController private let focusDebugOverlayController: FocusDebugOverlayController? /// Retained for the app's lifetime because the environment owns its own `cancellables` (the only /// subscriptions wiring the focus-poll-interval setting and the global-toggle hotkey rebind to the @@ -62,6 +63,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { welcomeCoordinator = environment.welcomeCoordinator settingsCoordinator = environment.settingsCoordinator activationIndicatorController = environment.activationIndicatorController + fieldScopeMenuController = environment.fieldScopeMenuController focusDebugOverlayController = environment.focusDebugOverlayController super.init() @@ -91,8 +93,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } .store(in: &cancellables) + activationIndicatorController.onClick = { [weak self] iconFrame in + self?.toggleFieldScopeMenu(anchorRect: iconFrame) + } + focusModel.$snapshot .sink { [weak self] snapshot in + self?.dismissFieldScopeMenuIfFocusLeftTarget(snapshot) self?.updateActivationIndicator(for: snapshot) self?.focusDebugOverlayController?.update(for: snapshot) } @@ -256,8 +263,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { runtimeModel.shutdownSync(timeoutSeconds: 1.5) } - /// Shows or hides the field-edge Cotabby icon based on focus state, global enable, per-app - /// disable rules, and the user's indicator toggle. + /// Shows or hides the field-edge Cotabby icon based on focus state, global enable, and the + /// user's indicator toggle. Where autocomplete is off for the app or window the icon stays, + /// dimmed, because clicking it is how the user turns autocomplete back on there. private func updateActivationIndicator( for snapshot: FocusSnapshot, settings: SuggestionSettingsSnapshot? = nil @@ -265,7 +273,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let settings = settings ?? suggestionSettings.snapshot guard settings.isGloballyEnabled, !settings.isTemporarilyPaused, - !settings.disabledAppBundleIdentifiers.contains(snapshot.bundleIdentifier ?? ""), case .supported = snapshot.capability, let context = snapshot.context else { @@ -273,11 +280,76 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return } + let windowKey = WindowFeatureScope.windowKey( + bundleIdentifier: snapshot.bundleIdentifier, windowTitle: context.windowTitle + ) + let disabledApps = WindowFeatureScope.effectiveDisabledApps( + settings.disabledAppBundleIdentifiers, + bundleIdentifier: snapshot.bundleIdentifier, + windowOverride: environment.windowFeatureOverrides.override(for: .autocomplete, windowKey: windowKey) + ) activationIndicatorController.show( enabled: suggestionSettings.showIndicator, caretRect: context.caretRect, - inputFrameRect: context.inputFrameRect + inputFrameRect: context.inputFrameRect, + dimmed: disabledApps.contains(snapshot.bundleIdentifier ?? "") + ) + } + + // MARK: - Field icon popup + + /// Opens (or, on a second click, closes) the Autocomplete/Translate popup for the focused + /// app and window. The target is captured now so the popup keeps editing this window. + private func toggleFieldScopeMenu(anchorRect: CGRect) { + if fieldScopeMenuController.isShown { + fieldScopeMenuController.dismiss() + return + } + let snapshot = focusModel.snapshot + guard let bundleIdentifier = snapshot.bundleIdentifier else { return } + let target = FieldScopeTarget( + bundleIdentifier: bundleIdentifier, + applicationName: snapshot.applicationName, + windowTitle: snapshot.context?.windowTitle ) + let menu = FieldScopeMenuView( + target: target, + suggestionSettings: suggestionSettings, + translationPreferences: environment.translationPreferences, + windowOverrides: environment.windowFeatureOverrides, + onChange: { [weak self] feature in self?.handleFieldScopeChange(feature) }, + onOpenSettings: { [weak self] in + self?.fieldScopeMenuController.dismiss() + self?.settingsCoordinator.showSettings() + } + ) + fieldScopeMenuController.show(menu, for: target, anchorRect: anchorRect) + } + + /// Applies a popup change right away instead of waiting for the next keystroke or tick. + private func handleFieldScopeChange(_ feature: ScopedFeature) { + switch feature { + case .autocomplete: + suggestionCoordinator.handleWindowFeatureOverrideChange() + updateActivationIndicator(for: focusModel.snapshot) + case .translation: + environment.translationCoordinator.handleScopeChange() + } + } + + /// Closes the popup once the user is typing in a different app or window, so it never edits a + /// window other than the one it names. A missing bundle or title is AX flicker, not a move. + private func dismissFieldScopeMenuIfFocusLeftTarget(_ snapshot: FocusSnapshot) { + guard let target = fieldScopeMenuController.target, + let bundleIdentifier = snapshot.bundleIdentifier + else { return } + let windowKey = WindowFeatureScope.windowKey( + bundleIdentifier: bundleIdentifier, windowTitle: snapshot.context?.windowTitle + ) + if bundleIdentifier != target.bundleIdentifier + || (windowKey != nil && target.windowKey != nil && windowKey != target.windowKey) { + fieldScopeMenuController.dismiss() + } } /// Warm the local runtime only when the user is actually on a local engine path. diff --git a/Cotabby/App/Core/CotabbyAppEnvironment.swift b/Cotabby/App/Core/CotabbyAppEnvironment.swift index 9644a334..c75cb316 100644 --- a/Cotabby/App/Core/CotabbyAppEnvironment.swift +++ b/Cotabby/App/Core/CotabbyAppEnvironment.swift @@ -44,6 +44,10 @@ final class CotabbyAppEnvironment { let translationCoordinator: TranslationCoordinator let settingsCoordinator: SettingsCoordinator let activationIndicatorController: ActivationIndicatorController + /// Per-window Autocomplete/Translate choices made from the field icon. + let windowFeatureOverrides: WindowFeatureOverrideStore + /// The popup the field icon opens. + let fieldScopeMenuController: FieldScopeMenuController let focusDebugOverlayController: FocusDebugOverlayController? private var cancellables = Set() @@ -199,6 +203,8 @@ final class CotabbyAppEnvironment { faceMemoryDefaults: .standard ) let activationIndicatorController = ActivationIndicatorController() + let windowFeatureOverrides = WindowFeatureOverrideStore() + translationCoordinator.windowOverrides = windowFeatureOverrides let clipboardContextProvider = ClipboardContextProvider() let clipboardRelevanceFilter = ClipboardRelevanceFilter() let screenshotContextGenerator = ScreenshotContextGenerator() @@ -356,6 +362,16 @@ final class CotabbyAppEnvironment { macro: macroController, inputMonitor: inputMonitor ) + // The coordinator asks for the focused window's own choice through a closure, so it does not + // depend on the concrete store (and its tests need no UserDefaults). + suggestionCoordinator.windowAutocompleteOverride = { [weak windowFeatureOverrides] snapshot in + windowFeatureOverrides?.override( + for: .autocomplete, + windowKey: WindowFeatureScope.windowKey( + bundleIdentifier: snapshot.bundleIdentifier, windowTitle: snapshot.context?.windowTitle + ) + ) + } suggestionCoordinator.emojiInputObserver = { [weak inlineCommandCoordinator] event in inlineCommandCoordinator?.observe(event) ?? false } @@ -388,6 +404,8 @@ final class CotabbyAppEnvironment { self.translationCoordinator = translationCoordinator self.settingsCoordinator = settingsCoordinator self.activationIndicatorController = activationIndicatorController + self.windowFeatureOverrides = windowFeatureOverrides + self.fieldScopeMenuController = FieldScopeMenuController() self.focusDebugOverlayController = CotabbyDebugOptions.areOverlaysAvailable ? FocusDebugOverlayController() : nil diff --git a/Cotabby/Services/Presentation/ActivationIndicatorController.swift b/Cotabby/Services/Presentation/ActivationIndicatorController.swift index 63464702..9c0d5fc3 100644 --- a/Cotabby/Services/Presentation/ActivationIndicatorController.swift +++ b/Cotabby/Services/Presentation/ActivationIndicatorController.swift @@ -15,10 +15,19 @@ final class ActivationIndicatorController { private let fieldEdgeGap: CGFloat = 0 private let screenInset: CGFloat = 2 - private lazy var contentView: NSHostingView = { - NSHostingView(rootView: AnyView(EmptyView())) + private lazy var contentView: ClickableHostingView = { + let view = ClickableHostingView(rootView: AnyView(EmptyView())) + view.onClick = { [weak self] in + guard let self else { return } + self.onClick?(self.panel.frame) + } + return view }() + /// Called with the icon's screen frame when the user clicks it; `AppDelegate` opens the + /// field-scope popup from here. + var onClick: ((CGRect) -> Void)? + private lazy var panel: ActivationIndicatorPanel = { let panel = ActivationIndicatorPanel( contentRect: CGRect(x: 0, y: 0, width: 10, height: 10), @@ -29,7 +38,9 @@ final class ActivationIndicatorController { panel.isReleasedWhenClosed = false panel.backgroundColor = .clear panel.isOpaque = false - panel.ignoresMouseEvents = true + // Clickable: the icon opens the field-scope popup. It sits just outside the field's edge, + // so taking its 14pt of clicks never covers the host's own text. + panel.ignoresMouseEvents = false panel.hasShadow = false panel.animationBehavior = .none panel.level = .statusBar @@ -44,7 +55,8 @@ final class ActivationIndicatorController { func show( enabled: Bool, caretRect: CGRect, - inputFrameRect: CGRect? + inputFrameRect: CGRect?, + dimmed: Bool = false ) { guard enabled else { hide(reason: "Activation indicator hidden because it is disabled.") @@ -56,7 +68,7 @@ final class ActivationIndicatorController { return } - contentView.rootView = AnyView(FieldEdgeIconIndicatorView()) + contentView.rootView = AnyView(FieldEdgeIconIndicatorView(dimmed: dimmed)) contentView.layoutSubtreeIfNeeded() let contentSize = contentView.fittingSize let origin = fieldEdgeIconOrigin( @@ -146,3 +158,21 @@ private final class ActivationIndicatorPanel: NSPanel { override var canBecomeKey: Bool { false } override var canBecomeMain: Bool { false } } + +/// Hosting view for the icon that turns a click into `onClick`. +/// +/// `acceptsFirstMouse` returns true because the panel is never key (it is non-activating), and +/// without it AppKit would spend the first click making the window key instead of delivering it. +final class ClickableHostingView: NSHostingView { + var onClick: (() -> Void)? + + override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true } + + override func mouseDown(with _: NSEvent) { + onClick?() + } + + override func resetCursorRects() { + addCursorRect(bounds, cursor: .pointingHand) + } +} diff --git a/Cotabby/Services/Presentation/FieldScopeMenuController.swift b/Cotabby/Services/Presentation/FieldScopeMenuController.swift new file mode 100644 index 00000000..80f3703c --- /dev/null +++ b/Cotabby/Services/Presentation/FieldScopeMenuController.swift @@ -0,0 +1,95 @@ +import AppKit +import SwiftUI + +/// Owns the small floating panel that holds `FieldScopeMenuView` next to the field-edge icon. +/// +/// Why a non-activating `NSPanel` and not an `NSPopover` or menu: the user opens this mid-sentence +/// in another app (Teams, WhatsApp). Activating Cotabby would move keyboard focus out of the host's +/// compose field, and the host would see a focus change. A `.nonactivatingPanel` takes clicks +/// without becoming the active app, so the host keeps focus while the switches are flipped. +/// +/// Lifetime: built once by `CotabbyAppEnvironment`; `AppDelegate` calls `show` when the icon is +/// clicked and `dismiss` when focus leaves the target window. The panel itself dismisses on a click +/// anywhere else or on Escape, through global event monitors installed only while it is open. +@MainActor +final class FieldScopeMenuController { + private let panel: NSPanel + private let hostingView = NSHostingView(rootView: AnyView(EmptyView())) + /// Opaque tokens returned by `NSEvent.addGlobalMonitorForEvents`; removed on dismiss so no + /// monitor outlives the panel. + private var eventMonitors: [Any] = [] + private(set) var target: FieldScopeTarget? + + var isShown: Bool { panel.isVisible } + + init() { + panel = NSPanel( + contentRect: CGRect(x: 0, y: 0, width: 10, height: 10), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: true + ) + panel.isReleasedWhenClosed = false + panel.backgroundColor = .clear + panel.isOpaque = false + // The window is clear, so the window server shapes this shadow from the rounded content. + panel.hasShadow = true + panel.becomesKeyOnlyIfNeeded = true + panel.hidesOnDeactivate = false + panel.animationBehavior = .utilityWindow + // Above the field-edge icon (`.statusBar`) so the popup is never covered by it. + panel.level = .popUpMenu + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle] + panel.contentView = hostingView + } + + /// Shows `content` for `target` beside `anchorRect` (the icon's frame, AppKit screen coords). + func show(_ content: some View, for target: FieldScopeTarget, anchorRect: CGRect) { + self.target = target + hostingView.rootView = AnyView(content) + hostingView.layoutSubtreeIfNeeded() + let size = hostingView.fittingSize + panel.setFrame(CGRect(origin: Self.origin(for: size, anchorRect: anchorRect), size: size).integral, display: true) + panel.orderFrontRegardless() + installDismissMonitors() + } + + func dismiss() { + guard panel.isVisible || !eventMonitors.isEmpty else { return } + panel.orderOut(nil) + target = nil + eventMonitors.forEach(NSEvent.removeMonitor) + eventMonitors.removeAll() + } + + /// Global monitors see events delivered to *other* apps, which is exactly "a click outside the + /// panel" (clicks inside it are local events and never reach these). + private func installDismissMonitors() { + guard eventMonitors.isEmpty else { return } + if let click = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown], handler: { [weak self] _ in + MainActor.assumeIsolated { self?.dismiss() } + }) { + eventMonitors.append(click) + } + if let escape = NSEvent.addGlobalMonitorForEvents(matching: .keyDown, handler: { [weak self] event in + guard event.keyCode == 53 else { return } // Escape + MainActor.assumeIsolated { self?.dismiss() } + }) { + eventMonitors.append(escape) + } + } + + /// Below the icon, left edges aligned, flipped above it when there is no room below; clamped to + /// the screen so the popup is always fully visible. + private static func origin(for size: CGSize, anchorRect: CGRect) -> CGPoint { + let gap: CGFloat = 6 + let midpoint = CGPoint(x: anchorRect.midX, y: anchorRect.midY) + let visible = (NSScreen.screens.first { $0.frame.contains(midpoint) } ?? NSScreen.main)?.visibleFrame + ?? CGRect(origin: .zero, size: size) + var y = anchorRect.minY - gap - size.height + if y < visible.minY { y = anchorRect.maxY + gap } + let x = min(max(anchorRect.minX, visible.minX + 4), visible.maxX - size.width - 4) + y = min(max(y, visible.minY + 4), visible.maxY - size.height - 4) + return CGPoint(x: x, y: y) + } +} diff --git a/Cotabby/Services/Suggestion/WindowFeatureOverrideStore.swift b/Cotabby/Services/Suggestion/WindowFeatureOverrideStore.swift new file mode 100644 index 00000000..a97c03d8 --- /dev/null +++ b/Cotabby/Services/Suggestion/WindowFeatureOverrideStore.swift @@ -0,0 +1,81 @@ +import Combine +import Foundation + +/// Owns the per-window feature choices made from the field icon and persists them in +/// `UserDefaults`. +/// +/// Why its own store: app-level choices already live with their features (the disabled-apps list in +/// `SuggestionSettingsModel`, the app list in `TranslationPreferencesStore`), and both are read far +/// more widely than window choices. Window choices are a thin override layer that only the field +/// icon writes, so they stay in one small store instead of widening either settings model. Built +/// once by `CotabbyAppEnvironment`; the suggestion coordinator, translation coordinator, indicator, +/// and popup read it through `override(for:windowKey:)`. +@MainActor +final class WindowFeatureOverrideStore: ObservableObject { + /// Window key (from `WindowFeatureScope.windowKey`) to on/off, per feature. A missing entry + /// means the window follows its app. + @Published private(set) var overrides: [ScopedFeature: [String: Bool]] + + /// Oldest entries are dropped beyond this many windows per feature, so a long-lived install that + /// visits thousands of chats keeps a bounded defaults payload. + static let maximumWindowsPerFeature = 500 + + private let userDefaults: UserDefaults + private static let defaultsKeyPrefix = "cotabbyWindowFeatureOverrides." + /// Insertion order per feature, newest last, persisted beside the map so trimming is stable. + private var order: [ScopedFeature: [String]] + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + var overrides: [ScopedFeature: [String: Bool]] = [:] + var order: [ScopedFeature: [String]] = [:] + for feature in ScopedFeature.allCases { + let stored = userDefaults.array(forKey: Self.defaultsKeyPrefix + feature.rawValue) as? [[String: Any]] ?? [] + var map: [String: Bool] = [:] + var keys: [String] = [] + for entry in stored { + guard let key = entry["window"] as? String, let enabled = entry["enabled"] as? Bool else { continue } + if map.updateValue(enabled, forKey: key) == nil { keys.append(key) } + } + overrides[feature] = map + order[feature] = keys + } + self.overrides = overrides + self.order = order + } + + /// The window's own choice for a feature, or nil when it follows the app. + func override(for feature: ScopedFeature, windowKey: String?) -> Bool? { + guard let windowKey else { return nil } + return overrides[feature]?[windowKey] + } + + /// Whether any window of this app has the feature forced on. Translation uses it to decide + /// whether a capture is worth taking before it knows which window is in front. + func hasEnabledWindow(for feature: ScopedFeature, bundleIdentifier: String) -> Bool { + let prefix = bundleIdentifier + "\u{1F}" + return overrides[feature]?.contains { $0.key.hasPrefix(prefix) && $0.value } ?? false + } + + /// Sets (or with nil, clears) a window's choice for a feature. + func setOverride(_ enabled: Bool?, for feature: ScopedFeature, windowKey: String) { + var map = overrides[feature] ?? [:] + var keys = (order[feature] ?? []).filter { $0 != windowKey } + if let enabled { + map[windowKey] = enabled + keys.append(windowKey) + } else { + map.removeValue(forKey: windowKey) + } + while keys.count > Self.maximumWindowsPerFeature { + map.removeValue(forKey: keys.removeFirst()) + } + guard map != overrides[feature] else { return } + overrides[feature] = map + order[feature] = keys + userDefaults.set( + keys.compactMap { key in map[key].map { ["window": key, "enabled": $0] as [String: Any] } }, + forKey: Self.defaultsKeyPrefix + feature.rawValue + ) + } +} diff --git a/Cotabby/Support/Settings/WindowFeatureScope.swift b/Cotabby/Support/Settings/WindowFeatureScope.swift new file mode 100644 index 00000000..6200433f --- /dev/null +++ b/Cotabby/Support/Settings/WindowFeatureScope.swift @@ -0,0 +1,91 @@ +import Foundation + +/// A Cotabby feature the field icon can turn on or off for one app or one window. +nonisolated enum ScopedFeature: String, Codable, CaseIterable, Sendable { + case autocomplete + case translation +} + +/// File overview: +/// Pure rules for "this app" versus "this window" feature choices made from the field icon. +/// +/// The app-level answer already has an owner per feature: the disabled-apps list for autocomplete +/// and the translation app list for translation. This type only adds the window layer on top: a +/// window is identified by its app plus its normalized title (a Teams or WhatsApp chat title names +/// the conversation), and a window choice, when one exists, wins over the app's. Keeping the rule +/// here, with no storage or AppKit, means the suggestion gate, the translation gate, the indicator, +/// and the popup can never disagree about what a window choice means. +nonisolated enum WindowFeatureScope { + /// Longest title kept in a key. Titles longer than this are document paths or page titles that + /// change constantly; truncating keeps keys bounded without losing the chat-name prefix. + static let maximumTitleCharacters = 160 + + /// The storage key for one window, or nil when the window cannot be told apart from the app's + /// other windows (no bundle identifier, or an empty title). A nil key means "app scope only". + static func windowKey(bundleIdentifier: String?, windowTitle: String?) -> String? { + guard let bundleIdentifier = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), + !bundleIdentifier.isEmpty, + let title = normalizedTitle(windowTitle) + else { return nil } + // U+001F (unit separator) cannot appear in a bundle identifier, so the split is unambiguous. + return bundleIdentifier + "\u{1F}" + title + } + + /// The window title as a stable identity: whitespace collapsed, and a leading unread badge such + /// as "(3) " dropped, because chat apps prefix the count and would otherwise turn one chat into + /// a new window every time a message arrives. + static func normalizedTitle(_ title: String?) -> String? { + guard var title = title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty else { return nil } + if let badge = title.range(of: #"^\(\d+\+?\)\s*"#, options: .regularExpression) { + title.removeSubrange(badge) + } + let collapsed = title.split(whereSeparator: \.isWhitespace).joined(separator: " ") + guard !collapsed.isEmpty else { return nil } + return String(collapsed.prefix(maximumTitleCharacters)) + } + + /// The feature's effective state in a window: its own choice when it has one, else the app's. + static func resolve(appEnabled: Bool, windowOverride: Bool?) -> Bool { + windowOverride ?? appEnabled + } + + /// The disabled-apps set the suggestion availability gate should use for the focused window. + /// + /// Every autocomplete gate already asks "is this bundle in the disabled set?". Adjusting the set + /// for the focused window (adding the app when the window is off, removing it when the window is + /// forced on) lets all of those gates honor window choices without each one learning about + /// windows, which is the regression-safe way to add a scope to a rule that has many readers. + static func effectiveDisabledApps( + _ disabledApps: Set, + bundleIdentifier: String?, + windowOverride: Bool? + ) -> Set { + guard let bundleIdentifier, let windowOverride else { return disabledApps } + var adjusted = disabledApps + if windowOverride { + adjusted.remove(bundleIdentifier) + } else { + adjusted.insert(bundleIdentifier) + } + return adjusted + } +} + +/// The app and window the field icon's popup acts on, captured when the icon is clicked so the +/// popup keeps editing that window even if focus moves while it is open. +nonisolated struct FieldScopeTarget: Equatable, Sendable { + let bundleIdentifier: String + let applicationName: String + /// The window's title as shown to the user (nil when the app exposes none). + let windowTitle: String? + /// Storage key for window choices; nil means the window cannot be told apart, so the popup + /// offers app-level switches only. + let windowKey: String? + + init(bundleIdentifier: String, applicationName: String, windowTitle: String?) { + self.bundleIdentifier = bundleIdentifier + self.applicationName = applicationName + self.windowTitle = WindowFeatureScope.normalizedTitle(windowTitle) + self.windowKey = WindowFeatureScope.windowKey(bundleIdentifier: bundleIdentifier, windowTitle: windowTitle) + } +} diff --git a/Cotabby/UI/Overlays/FieldEdgeIconIndicatorView.swift b/Cotabby/UI/Overlays/FieldEdgeIconIndicatorView.swift index 64273d52..caae6ec4 100644 --- a/Cotabby/UI/Overlays/FieldEdgeIconIndicatorView.swift +++ b/Cotabby/UI/Overlays/FieldEdgeIconIndicatorView.swift @@ -7,6 +7,9 @@ struct FieldEdgeIconIndicatorView: View { // Sized at 0.7 of the original chip so the affordance sits more discreetly beside the input. private let side: CGFloat = 14 private let cornerRadius: CGFloat = 3.5 + /// Autocomplete is off in this app or window. The icon stays (it is how the user turns it back + /// on) but fades so it reads as "Cotabby is here, but paused". + var dimmed = false var body: some View { ZStack { @@ -22,6 +25,7 @@ struct FieldEdgeIconIndicatorView: View { .frame(width: side, height: side) .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) .shadow(color: .black.opacity(0.12), radius: 2, y: 1) + .opacity(dimmed ? 0.45 : 1) .fixedSize() } } diff --git a/Cotabby/UI/Overlays/FieldScopeMenuView.swift b/Cotabby/UI/Overlays/FieldScopeMenuView.swift new file mode 100644 index 00000000..511662c3 --- /dev/null +++ b/Cotabby/UI/Overlays/FieldScopeMenuView.swift @@ -0,0 +1,180 @@ +import AppKit +import SwiftUI + +/// The popup shown when the user clicks Cotabby's field-edge icon: turn Autocomplete and Translate +/// on or off for the whole app, or for just this window (a chat, document, or page). +/// +/// Presentation only. App-level answers are written to their existing owners (the disabled-apps +/// list in `SuggestionSettingsModel`, the app list in `TranslationPreferencesStore`), so Settings +/// and the popup always show the same thing; window answers go to `WindowFeatureOverrideStore`. +/// `onChange` tells `AppDelegate` which feature changed so it can refresh that coordinator right +/// away. Hosted by `FieldScopeMenuController` in a non-activating panel, which is why the controls +/// are plain switches and segmented pickers: they work on the first click without making Cotabby +/// the active app, so the host keeps keyboard focus. +struct FieldScopeMenuView: View { + let target: FieldScopeTarget + /// `@ObservedObject` (not `@StateObject`): these long-lived models belong to the app + /// environment; the view only watches them so its switches update when they change. + @ObservedObject var suggestionSettings: SuggestionSettingsModel + @ObservedObject var translationPreferences: TranslationPreferencesStore + @ObservedObject var windowOverrides: WindowFeatureOverrideStore + let onChange: (ScopedFeature) -> Void + let onOpenSettings: () -> Void + + /// A window's choice as a three-way picker: follow the app, or force on or off. + private enum WindowChoice: Hashable { + case sameAsApp, on, off + + init(_ override: Bool?) { + switch override { + case nil: self = .sameAsApp + case true?: self = .on + case false?: self = .off + } + } + + var override: Bool? { + switch self { + case .sameAsApp: return nil + case .on: return true + case .off: return false + } + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(target.applicationName) + .font(.headline) + if let title = target.windowTitle { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + + Divider() + + featureSection( + title: "Autocomplete", + feature: .autocomplete, + appEnabled: Binding( + get: { !suggestionSettings.isApplicationDisabled(bundleIdentifier: target.bundleIdentifier) }, + set: { enabled in + suggestionSettings.setApplicationDisabled( + bundleIdentifier: target.bundleIdentifier, + displayName: target.applicationName, + disabled: !enabled + ) + onChange(.autocomplete) + } + ), + isAvailable: true + ) + + Divider() + + featureSection( + title: "Translate", + feature: .translation, + appEnabled: Binding( + get: { translationPreferences.preferences.appBundleIdentifiers.contains(target.bundleIdentifier) }, + set: { enabled in + translationPreferences.setApp(target.bundleIdentifier, included: enabled) + onChange(.translation) + } + ), + isAvailable: translationPreferences.preferences.isEnabled + ) + if !translationPreferences.preferences.isEnabled { + HStack { + Text("Translation is off.") + .font(.caption) + .foregroundStyle(.secondary) + Button("Turn On") { + translationPreferences.setEnabled(true) + onChange(.translation) + } + .buttonStyle(.link) + .font(.caption) + } + } + + Divider() + + Button("Settings…", action: onOpenSettings) + .buttonStyle(.borderless) + .font(.subheadline) + } + .padding(14) + .frame(width: 272) + .background { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(nsColor: .windowBackgroundColor)) + } + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.7), lineWidth: 1) + } + } + + @ViewBuilder + private func featureSection( + title: String, + feature: ScopedFeature, + appEnabled: Binding, + isAvailable: Bool + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(title) + .font(.subheadline.weight(.semibold)) + Spacer() + Text(effectiveLabel(feature: feature, appEnabled: appEnabled.wrappedValue, isAvailable: isAvailable)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Toggle("In \(target.applicationName)", isOn: appEnabled) + .toggleStyle(.switch) + .controlSize(.small) + + if let windowKey = target.windowKey { + HStack { + Text("This window") + Spacer(minLength: 8) + Picker("This window", selection: Binding( + get: { WindowChoice(windowOverrides.override(for: feature, windowKey: windowKey)) }, + set: { choice in + windowOverrides.setOverride(choice.override, for: feature, windowKey: windowKey) + onChange(feature) + } + )) { + Text("Same as app").tag(WindowChoice.sameAsApp) + Text("On").tag(WindowChoice.on) + Text("Off").tag(WindowChoice.off) + } + .pickerStyle(.segmented) + .labelsHidden() + .controlSize(.small) + .fixedSize() + } + } + } + .disabled(!isAvailable) + } + + /// "On here" / "Off here": the result of the app switch and the window choice together, so the + /// user does not have to work out which of the two wins. + private func effectiveLabel(feature: ScopedFeature, appEnabled: Bool, isAvailable: Bool) -> String { + guard isAvailable else { return "Off" } + let on = WindowFeatureScope.resolve( + appEnabled: appEnabled, + windowOverride: windowOverrides.override(for: feature, windowKey: target.windowKey) + ) + return on ? "On here" : "Off here" + } +} diff --git a/CotabbyTests/Support/Settings/WindowFeatureScopeTests.swift b/CotabbyTests/Support/Settings/WindowFeatureScopeTests.swift new file mode 100644 index 00000000..c94570a8 --- /dev/null +++ b/CotabbyTests/Support/Settings/WindowFeatureScopeTests.swift @@ -0,0 +1,93 @@ +import XCTest +@testable import Cotabby + +final class WindowFeatureScopeTests: XCTestCase { + func test_windowKeyCombinesBundleAndNormalizedTitle() { + XCTAssertEqual( + WindowFeatureScope.windowKey(bundleIdentifier: "com.microsoft.teams2", windowTitle: " Chat | Simeon "), + "com.microsoft.teams2\u{1F}Chat | Simeon" + ) + } + + func test_unreadBadgeDoesNotMakeANewWindow() { + let plain = WindowFeatureScope.windowKey(bundleIdentifier: "net.whatsapp.WhatsApp", windowTitle: "WhatsApp") + XCTAssertEqual(WindowFeatureScope.windowKey(bundleIdentifier: "net.whatsapp.WhatsApp", windowTitle: "(3) WhatsApp"), plain) + XCTAssertEqual(WindowFeatureScope.windowKey(bundleIdentifier: "net.whatsapp.WhatsApp", windowTitle: "(99+) WhatsApp"), plain) + } + + func test_noKeyWithoutBundleOrTitle() { + XCTAssertNil(WindowFeatureScope.windowKey(bundleIdentifier: nil, windowTitle: "Chat")) + XCTAssertNil(WindowFeatureScope.windowKey(bundleIdentifier: "com.apple.TextEdit", windowTitle: " ")) + XCTAssertNil(WindowFeatureScope.windowKey(bundleIdentifier: "com.apple.TextEdit", windowTitle: nil)) + } + + func test_longTitlesAreBounded() { + let title = String(repeating: "a", count: 1_000) + XCTAssertEqual(WindowFeatureScope.normalizedTitle(title)?.count, WindowFeatureScope.maximumTitleCharacters) + } + + func test_windowChoiceWinsOverApp() { + XCTAssertTrue(WindowFeatureScope.resolve(appEnabled: false, windowOverride: true)) + XCTAssertFalse(WindowFeatureScope.resolve(appEnabled: true, windowOverride: false)) + XCTAssertTrue(WindowFeatureScope.resolve(appEnabled: true, windowOverride: nil)) + } + + func test_effectiveDisabledAppsFollowsWindowChoice() { + let disabled: Set = ["com.other"] + XCTAssertEqual( + WindowFeatureScope.effectiveDisabledApps(disabled, bundleIdentifier: "com.app", windowOverride: false), + ["com.other", "com.app"] + ) + XCTAssertEqual( + WindowFeatureScope.effectiveDisabledApps(["com.app"], bundleIdentifier: "com.app", windowOverride: true), + [] + ) + XCTAssertEqual( + WindowFeatureScope.effectiveDisabledApps(disabled, bundleIdentifier: "com.app", windowOverride: nil), + disabled + ) + } +} + +@MainActor +final class WindowFeatureOverrideStoreTests: XCTestCase { + private var defaults: UserDefaults! + private let suite = "WindowFeatureOverrideStoreTests" + + override func setUp() { + super.setUp() + defaults = UserDefaults(suiteName: suite) + defaults.removePersistentDomain(forName: suite) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suite) + super.tearDown() + } + + func test_setClearAndPersist() { + let store = WindowFeatureOverrideStore(userDefaults: defaults) + store.setOverride(false, for: .autocomplete, windowKey: "app\u{1F}A") + store.setOverride(true, for: .translation, windowKey: "app\u{1F}A") + XCTAssertEqual(store.override(for: .autocomplete, windowKey: "app\u{1F}A"), false) + XCTAssertTrue(store.hasEnabledWindow(for: .translation, bundleIdentifier: "app")) + XCTAssertFalse(store.hasEnabledWindow(for: .translation, bundleIdentifier: "ap")) + + let reloaded = WindowFeatureOverrideStore(userDefaults: defaults) + XCTAssertEqual(reloaded.override(for: .autocomplete, windowKey: "app\u{1F}A"), false) + XCTAssertEqual(reloaded.override(for: .translation, windowKey: "app\u{1F}A"), true) + + reloaded.setOverride(nil, for: .autocomplete, windowKey: "app\u{1F}A") + XCTAssertNil(WindowFeatureOverrideStore(userDefaults: defaults).override(for: .autocomplete, windowKey: "app\u{1F}A")) + } + + func test_oldestWindowsAreTrimmed() { + let store = WindowFeatureOverrideStore(userDefaults: defaults) + for index in 0...WindowFeatureOverrideStore.maximumWindowsPerFeature { + store.setOverride(true, for: .autocomplete, windowKey: "w\(index)") + } + XCTAssertNil(store.override(for: .autocomplete, windowKey: "w0")) + XCTAssertEqual(store.override(for: .autocomplete, windowKey: "w1"), true) + XCTAssertEqual(store.overrides[.autocomplete]?.count, WindowFeatureOverrideStore.maximumWindowsPerFeature) + } +} From b6e6fa03c69bc8404bf273c2d55f76ea3c116349 Mon Sep 17 00:00:00 2001 From: Senad Date: Fri, 2 Oct 2026 18:56:38 +0200 Subject: [PATCH 3/4] Compact field-icon popup; find window titles in Electron apps - Smaller popup (216pt): one row per feature with a compact switch, a mini "This window: App / On / Off" picker, Settings as a gear icon. - A SwiftUI switch instead of the system one: the popup panel is never key (the host keeps focus), and AppKit drew inactive-window switches gray, so "on" looked "off". The hosting view accepts the first click. - Teams, Slack and WhatsApp (Chromium/Electron) do not link the web field to its window in the AX tree, so no window title was found and the popup offered app switches only. FocusedInputSnapshot gains appFocusedWindowTitle (the app's focused-window title, read only when the field-relative read fails) and featureScopeWindowTitle. It is used only for window choices and the translation conversation key, never for prompts or session identity. This also fixes reply translation in Teams, whose conversation key never matched the incoming one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LptEE4YbaWL55YwQRnTt74 --- .../Translation/TranslationCoordinator.swift | 4 +- Cotabby/App/Core/AppDelegate.swift | 6 +- Cotabby/App/Core/CotabbyAppEnvironment.swift | 2 +- Cotabby/Models/Focus/FocusModels.swift | 14 ++ .../Resolution/FocusSnapshotResolver.swift | 4 + .../FieldScopeMenuController.swift | 8 +- Cotabby/Support/Accessibility/AXHelper.swift | 13 ++ Cotabby/UI/Overlays/FieldScopeMenuView.swift | 123 +++++++++++------- 8 files changed, 117 insertions(+), 57 deletions(-) diff --git a/Cotabby/App/Coordinators/Translation/TranslationCoordinator.swift b/Cotabby/App/Coordinators/Translation/TranslationCoordinator.swift index 670d9441..817ece6e 100644 --- a/Cotabby/App/Coordinators/Translation/TranslationCoordinator.swift +++ b/Cotabby/App/Coordinators/Translation/TranslationCoordinator.swift @@ -273,10 +273,10 @@ final class TranslationCoordinator { guard prefs.isEnabled, prefs.offersReplyTranslation, case .supported = snapshot.capability, let input = snapshot.context, !input.isSecure, - isTranslationActive(bundleIdentifier: input.bundleIdentifier, windowTitle: input.windowTitle), + isTranslationActive(bundleIdentifier: input.bundleIdentifier, windowTitle: input.featureScopeWindowTitle), isAllowed(input.bundleIdentifier), let conversationLanguage = conversations.language( - for: ConversationLanguageTracker.key(bundleIdentifier: input.bundleIdentifier, windowTitle: input.windowTitle)), + for: ConversationLanguageTracker.key(bundleIdentifier: input.bundleIdentifier, windowTitle: input.featureScopeWindowTitle)), !TranslationLanguagePolicy.sameLanguage(conversationLanguage, prefs.readingLanguage) else { withdrawReplyOffer() diff --git a/Cotabby/App/Core/AppDelegate.swift b/Cotabby/App/Core/AppDelegate.swift index d856d880..13532c0b 100644 --- a/Cotabby/App/Core/AppDelegate.swift +++ b/Cotabby/App/Core/AppDelegate.swift @@ -281,7 +281,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } let windowKey = WindowFeatureScope.windowKey( - bundleIdentifier: snapshot.bundleIdentifier, windowTitle: context.windowTitle + bundleIdentifier: snapshot.bundleIdentifier, windowTitle: context.featureScopeWindowTitle ) let disabledApps = WindowFeatureScope.effectiveDisabledApps( settings.disabledAppBundleIdentifiers, @@ -310,7 +310,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let target = FieldScopeTarget( bundleIdentifier: bundleIdentifier, applicationName: snapshot.applicationName, - windowTitle: snapshot.context?.windowTitle + windowTitle: snapshot.context?.featureScopeWindowTitle ) let menu = FieldScopeMenuView( target: target, @@ -344,7 +344,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let bundleIdentifier = snapshot.bundleIdentifier else { return } let windowKey = WindowFeatureScope.windowKey( - bundleIdentifier: bundleIdentifier, windowTitle: snapshot.context?.windowTitle + bundleIdentifier: bundleIdentifier, windowTitle: snapshot.context?.featureScopeWindowTitle ) if bundleIdentifier != target.bundleIdentifier || (windowKey != nil && target.windowKey != nil && windowKey != target.windowKey) { diff --git a/Cotabby/App/Core/CotabbyAppEnvironment.swift b/Cotabby/App/Core/CotabbyAppEnvironment.swift index c75cb316..56e17d33 100644 --- a/Cotabby/App/Core/CotabbyAppEnvironment.swift +++ b/Cotabby/App/Core/CotabbyAppEnvironment.swift @@ -368,7 +368,7 @@ final class CotabbyAppEnvironment { windowFeatureOverrides?.override( for: .autocomplete, windowKey: WindowFeatureScope.windowKey( - bundleIdentifier: snapshot.bundleIdentifier, windowTitle: snapshot.context?.windowTitle + bundleIdentifier: snapshot.bundleIdentifier, windowTitle: snapshot.context?.featureScopeWindowTitle ) ) } diff --git a/Cotabby/Models/Focus/FocusModels.swift b/Cotabby/Models/Focus/FocusModels.swift index 0ac67ebf..5fad904f 100644 --- a/Cotabby/Models/Focus/FocusModels.swift +++ b/Cotabby/Models/Focus/FocusModels.swift @@ -408,6 +408,18 @@ nonisolated struct FocusedInputSnapshot: Equatable { /// compiling unchanged. let fieldPlaceholder: String? + /// The app's focused-window title, read only when `windowTitle` is nil. Chromium and Electron + /// hosts (Teams, Slack, WhatsApp) often do not link a web field to its window in the AX tree, so + /// the field-relative read fails even though the window has a title. Kept separate from + /// `windowTitle` on purpose: that one feeds prompts and session identity, and this fallback + /// is only for per-window feature choices (`featureScopeWindowTitle`). + let appFocusedWindowTitle: String? + + /// The title that identifies this window for the field icon's per-window choices. + var featureScopeWindowTitle: String? { + windowTitle ?? appFocusedWindowTitle + } + /// How the host renders text near the caret (measured widths, line box, line pitch), resolved /// once per field so the ghost can match the host's typeface and wrap geometry. Nil when the /// host exposes no measurable text geometry. The initializer default keeps existing call sites @@ -444,6 +456,7 @@ nonisolated struct FocusedInputSnapshot: Equatable { resolvedFieldStyle: ResolvedFieldStyle? = nil, windowTitle: String? = nil, fieldPlaceholder: String? = nil, + appFocusedWindowTitle: String? = nil, hostTextMetrics: HostTextMetrics? = nil, elementFrameRect: CGRect? = nil, hostMarkedTextRange: NSRange? = nil @@ -471,6 +484,7 @@ nonisolated struct FocusedInputSnapshot: Equatable { self.resolvedFieldStyle = resolvedFieldStyle self.windowTitle = windowTitle self.fieldPlaceholder = fieldPlaceholder + self.appFocusedWindowTitle = appFocusedWindowTitle self.hostTextMetrics = hostTextMetrics self.elementFrameRect = elementFrameRect self.hostMarkedTextRange = hostMarkedTextRange diff --git a/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift b/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift index 63f5c035..1d201f3f 100644 --- a/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift +++ b/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift @@ -262,6 +262,9 @@ struct FocusSnapshotResolver { || BrowserAppDetector.isBrowser(bundleIdentifier: bundleIdentifier) ) let windowTitle = resolvedCandidate.isSecure ? nil : AXHelper.windowTitle(near: focusedElement) + // Two extra attribute reads, and only when the field-relative read came back empty. + let appFocusedWindowTitle = (resolvedCandidate.isSecure || windowTitle != nil) + ? nil : AXHelper.focusedWindowTitle(processIdentifier: application.processIdentifier) let fieldPlaceholder = resolvedCandidate.isSecure ? nil : AXHelper.stringValue( for: kAXPlaceholderValueAttribute as CFString, on: resolvedCandidate.element ) @@ -331,6 +334,7 @@ struct FocusSnapshotResolver { resolvedFieldStyle: resolvedFieldStyle, windowTitle: windowTitle, fieldPlaceholder: fieldPlaceholder, + appFocusedWindowTitle: appFocusedWindowTitle, hostTextMetrics: Self.mergingRunLinePitch(hostTextMetrics, edges: observedContentEdges), elementFrameRect: resolvedCandidate.elementFrameRect, hostMarkedTextRange: resolvedCandidate.markedTextRange ?? chromiumCompletionRange ?? smartComposeRange diff --git a/Cotabby/Services/Presentation/FieldScopeMenuController.swift b/Cotabby/Services/Presentation/FieldScopeMenuController.swift index 80f3703c..21046b49 100644 --- a/Cotabby/Services/Presentation/FieldScopeMenuController.swift +++ b/Cotabby/Services/Presentation/FieldScopeMenuController.swift @@ -14,7 +14,7 @@ import SwiftUI @MainActor final class FieldScopeMenuController { private let panel: NSPanel - private let hostingView = NSHostingView(rootView: AnyView(EmptyView())) + private let hostingView = FirstMouseHostingView(rootView: AnyView(EmptyView())) /// Opaque tokens returned by `NSEvent.addGlobalMonitorForEvents`; removed on dismiss so no /// monitor outlives the panel. private var eventMonitors: [Any] = [] @@ -93,3 +93,9 @@ final class FieldScopeMenuController { return CGPoint(x: x, y: y) } } + +/// The panel is never key, so without `acceptsFirstMouse` AppKit would spend the first click on a +/// switch trying to make the window key instead of delivering it to the control. +private final class FirstMouseHostingView: NSHostingView { + override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true } +} diff --git a/Cotabby/Support/Accessibility/AXHelper.swift b/Cotabby/Support/Accessibility/AXHelper.swift index 9a25e04c..d6a8e4d4 100644 --- a/Cotabby/Support/Accessibility/AXHelper.swift +++ b/Cotabby/Support/Accessibility/AXHelper.swift @@ -1060,6 +1060,19 @@ enum AXHelper { return stringValue(for: kAXTitleAttribute as CFString, on: window) } + /// Title of the app's focused window, for hosts whose fields do not expose `kAXWindowAttribute` + /// (Chromium/Electron web content). Reads the application element's `kAXFocusedWindowAttribute` + /// and that window's title; nil on any miss. + static func focusedWindowTitle(processIdentifier: pid_t) -> String? { + let application = AXUIElementCreateApplication(processIdentifier) + guard let value = copyAttributeValue(kAXFocusedWindowAttribute as CFString, on: application), + CFGetTypeID(value) == AXUIElementGetTypeID() + else { return nil } + // Same Core Foundation bridging rule as `windowTitle(near:)`: the type ID was checked above. + let window = unsafeBitCast(value, to: AXUIElement.self) + return stringValue(for: kAXTitleAttribute as CFString, on: window) + } + /// Best-effort read of the page URL for local navigation identity and per-site rules. /// Browsers expose `kAXURLAttribute` on the web area or window rather than the focused field, so /// this walks up a bounded number of ancestors. It returns nil on any miss (non-browser focus, an diff --git a/Cotabby/UI/Overlays/FieldScopeMenuView.swift b/Cotabby/UI/Overlays/FieldScopeMenuView.swift index 511662c3..e6ba2b84 100644 --- a/Cotabby/UI/Overlays/FieldScopeMenuView.swift +++ b/Cotabby/UI/Overlays/FieldScopeMenuView.swift @@ -43,22 +43,32 @@ struct FieldScopeMenuView: View { } var body: some View { - VStack(alignment: .leading, spacing: 10) { - VStack(alignment: .leading, spacing: 2) { - Text(target.applicationName) - .font(.headline) - if let title = target.windowTitle { - Text(title) - .font(.caption) + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + VStack(alignment: .leading, spacing: 1) { + Text(target.applicationName) + .font(.system(size: 12, weight: .semibold)) + if let title = target.windowTitle { + Text(title) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + Spacer(minLength: 4) + Button(action: onOpenSettings) { + Image(systemName: "gearshape") + .font(.system(size: 11)) .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) } + .buttonStyle(.borderless) + .help("Open Cotabby Settings") } Divider() - featureSection( + featureRow( title: "Autocomplete", feature: .autocomplete, appEnabled: Binding( @@ -75,9 +85,7 @@ struct FieldScopeMenuView: View { isAvailable: true ) - Divider() - - featureSection( + featureRow( title: "Translate", feature: .translation, appEnabled: Binding( @@ -90,62 +98,56 @@ struct FieldScopeMenuView: View { isAvailable: translationPreferences.preferences.isEnabled ) if !translationPreferences.preferences.isEnabled { - HStack { + HStack(spacing: 4) { Text("Translation is off.") - .font(.caption) .foregroundStyle(.secondary) Button("Turn On") { translationPreferences.setEnabled(true) onChange(.translation) } .buttonStyle(.link) - .font(.caption) } + .font(.system(size: 10)) } - - Divider() - - Button("Settings…", action: onOpenSettings) - .buttonStyle(.borderless) - .font(.subheadline) } - .padding(14) - .frame(width: 272) + .padding(10) + .frame(width: 216) .background { - RoundedRectangle(cornerRadius: 12, style: .continuous) + RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(Color(nsColor: .windowBackgroundColor)) } .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) + RoundedRectangle(cornerRadius: 10, style: .continuous) .stroke(Color(nsColor: .separatorColor).opacity(0.7), lineWidth: 1) } } + /// One feature: its name, the app switch, and (when the window can be told apart) a compact + /// window picker. The switch is the app's setting; the picker overrides it for this window. @ViewBuilder - private func featureSection( + private func featureRow( title: String, feature: ScopedFeature, appEnabled: Binding, isAvailable: Bool ) -> some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 4) { HStack { Text(title) - .font(.subheadline.weight(.semibold)) - Spacer() - Text(effectiveLabel(feature: feature, appEnabled: appEnabled.wrappedValue, isAvailable: isAvailable)) - .font(.caption) - .foregroundStyle(.secondary) + .font(.system(size: 12)) + Spacer(minLength: 6) + Toggle(title, isOn: appEnabled) + .toggleStyle(CompactSwitchStyle()) + .labelsHidden() + .help("\(title) in all of \(target.applicationName)") } - Toggle("In \(target.applicationName)", isOn: appEnabled) - .toggleStyle(.switch) - .controlSize(.small) - if let windowKey = target.windowKey { - HStack { + HStack(spacing: 6) { Text("This window") - Spacer(minLength: 8) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + Spacer(minLength: 4) Picker("This window", selection: Binding( get: { WindowChoice(windowOverrides.override(for: feature, windowKey: windowKey)) }, set: { choice in @@ -153,28 +155,49 @@ struct FieldScopeMenuView: View { onChange(feature) } )) { - Text("Same as app").tag(WindowChoice.sameAsApp) + Text("App").tag(WindowChoice.sameAsApp) Text("On").tag(WindowChoice.on) Text("Off").tag(WindowChoice.off) } .pickerStyle(.segmented) .labelsHidden() - .controlSize(.small) + .controlSize(.mini) .fixedSize() + .help("App follows the switch above; On or Off applies to this window only.") } } } .disabled(!isAvailable) + .opacity(isAvailable ? 1 : 0.5) } +} - /// "On here" / "Off here": the result of the app switch and the window choice together, so the - /// user does not have to work out which of the two wins. - private func effectiveLabel(feature: ScopedFeature, appEnabled: Bool, isAvailable: Bool) -> String { - guard isAvailable else { return "Off" } - let on = WindowFeatureScope.resolve( - appEnabled: appEnabled, - windowOverride: windowOverrides.override(for: feature, windowKey: target.windowKey) - ) - return on ? "On here" : "Off here" +/// A small switch drawn in SwiftUI. +/// +/// Why not the system switch: this popup lives in a non-activating panel that is never the key +/// window (so the host keeps focus), and AppKit draws its controls in an inactive window gray, +/// which made "on" look "off". This style draws the accent color from the value alone. +private struct CompactSwitchStyle: ToggleStyle { + @Environment(\.isEnabled) private var isEnabled + + func makeBody(configuration: Configuration) -> some View { + Capsule() + .fill(configuration.isOn ? Color.accentColor : Color(nsColor: .tertiaryLabelColor)) + .frame(width: 26, height: 15) + .overlay(alignment: configuration.isOn ? .trailing : .leading) { + Circle() + .fill(.white) + .padding(1.5) + .shadow(color: .black.opacity(0.15), radius: 0.5, y: 0.5) + } + .animation(.easeOut(duration: 0.12), value: configuration.isOn) + .contentShape(Capsule()) + .onTapGesture { + guard isEnabled else { return } + configuration.isOn.toggle() + } + .accessibilityElement() + .accessibilityAddTraits(.isButton) + .accessibilityValue(configuration.isOn ? "On" : "Off") } } From 76a7711d58e0a7020e7828f514f913472721f12e Mon Sep 17 00:00:00 2001 From: Senad Date: Fri, 2 Oct 2026 19:12:18 +0200 Subject: [PATCH 4/4] Treat an empty window title as missing for per-window choices Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LptEE4YbaWL55YwQRnTt74 --- Cotabby/App/Core/AppDelegate.swift | 10 ++++++++++ Cotabby/Models/Focus/FocusModels.swift | 6 +++++- .../Focus/Resolution/FocusSnapshotResolver.swift | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/Cotabby/App/Core/AppDelegate.swift b/Cotabby/App/Core/AppDelegate.swift index 13532c0b..af29f5d4 100644 --- a/Cotabby/App/Core/AppDelegate.swift +++ b/Cotabby/App/Core/AppDelegate.swift @@ -307,6 +307,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } let snapshot = focusModel.snapshot guard let bundleIdentifier = snapshot.bundleIdentifier else { return } + // Presence and length only: titles name chats and documents, so the text is not logged. + CotabbyLogger.focus.info( + "Field scope popup opened", + metadata: [ + "stage": .string("field-scope-menu"), + "field_window_title_length": .stringConvertible(snapshot.context?.windowTitle?.count ?? -1), + "app_window_title_length": .stringConvertible(snapshot.context?.appFocusedWindowTitle?.count ?? -1), + "has_context": .string(snapshot.context == nil ? "no" : "yes") + ] + ) let target = FieldScopeTarget( bundleIdentifier: bundleIdentifier, applicationName: snapshot.applicationName, diff --git a/Cotabby/Models/Focus/FocusModels.swift b/Cotabby/Models/Focus/FocusModels.swift index 5fad904f..5a3b9638 100644 --- a/Cotabby/Models/Focus/FocusModels.swift +++ b/Cotabby/Models/Focus/FocusModels.swift @@ -417,7 +417,11 @@ nonisolated struct FocusedInputSnapshot: Equatable { /// The title that identifies this window for the field icon's per-window choices. var featureScopeWindowTitle: String? { - windowTitle ?? appFocusedWindowTitle + // Some hosts answer the title read with "" rather than failing; treat that as missing so + // the app-window fallback still applies. + [windowTitle, appFocusedWindowTitle].lazy + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } } /// How the host renders text near the caret (measured widths, line box, line pitch), resolved diff --git a/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift b/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift index 1d201f3f..ec5055f0 100644 --- a/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift +++ b/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift @@ -263,7 +263,7 @@ struct FocusSnapshotResolver { ) let windowTitle = resolvedCandidate.isSecure ? nil : AXHelper.windowTitle(near: focusedElement) // Two extra attribute reads, and only when the field-relative read came back empty. - let appFocusedWindowTitle = (resolvedCandidate.isSecure || windowTitle != nil) + let appFocusedWindowTitle = (resolvedCandidate.isSecure || !(windowTitle ?? "").isEmpty) ? nil : AXHelper.focusedWindowTitle(processIdentifier: application.processIdentifier) let fieldPlaceholder = resolvedCandidate.isSecure ? nil : AXHelper.stringValue( for: kAXPlaceholderValueAttribute as CFString, on: resolvedCandidate.element