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") + } +}