diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index e0240674..42fde1f9 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ 057CEA7858012C1501F1785C /* MarkerSelectionSynthesizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A863F41C0C03D7B4AC5DC002 /* MarkerSelectionSynthesizer.swift */; }; 05CC25E51682528CE2E73446 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BC4F887528AE74AC0DD30314 /* Assets.xcassets */; }; 0609E4F29F537530A49C6A50 /* FocusSessionScopedCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E504DABB83411B3FA0B8DC5 /* FocusSessionScopedCache.swift */; }; + 06195E20701221AE3B2AB371 /* ShortcutResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 565F28C13FD26BF174532228 /* ShortcutResolverTests.swift */; }; 06A310C087B460289B5ACCFE /* BundleVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D84EED1A6A711F39DEA18F /* BundleVersion.swift */; }; 06B7E7339877B334B28BE2D3 /* TypoGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8412FE2BAC406421248A03B /* TypoGate.swift */; }; 06CFA03207FF92EB272A66F2 /* CaretLinePosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77364C1AF183EF1C0A4074D /* CaretLinePosition.swift */; }; @@ -215,6 +216,7 @@ 46F341472191BC451B6BF6B5 /* SuggestionRequestFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE858CB1E687E3CEB8FDD5B /* SuggestionRequestFactory.swift */; }; 4701AB8E93FA78B808824AAD /* CotabbyBrand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9B27E3D3FD8008CE2C1B616 /* CotabbyBrand.swift */; }; 47364583344BEA8FDC7178D8 /* DownloadFileRescuer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4B4A2E2DD6733658EC05BD8 /* DownloadFileRescuer.swift */; }; + 475259B5A87A3FA07475136F /* KeybindRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5F6D44A9B4EC51505A0A363 /* KeybindRow.swift */; }; 475FB7450EEC3C1B16E66CC4 /* LLMIOFileHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9030FAAB468119A0236284A6 /* LLMIOFileHandlerTests.swift */; }; 47654BDCFD2DE6D4DE85D7FE /* LanguageTagsEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A7CDA90E128350BFF1A9D66 /* LanguageTagsEditor.swift */; }; 4767E0C7B4997069EA7ADBD7 /* GhostFontSizeStabilizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9458F0820B3161FE9CF1DDAF /* GhostFontSizeStabilizer.swift */; }; @@ -410,9 +412,12 @@ 91ADE463EE72D77E0D3EBBCA /* TickMarkSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67586807ACE8EB13C9014535 /* TickMarkSlider.swift */; }; 91C27021750AC03AA4A0115A /* HuggingFaceAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110CB0B53016644EF7840301 /* HuggingFaceAPIClient.swift */; }; 91D1F16B8C5DA281D4B7F699 /* CustomRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD752451330486FE270018B0 /* CustomRulesTests.swift */; }; + 91D6204A26046A21A29F2C18 /* PerAppShortcutOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7599A7A2B95DF7F6BD4363C7 /* PerAppShortcutOverride.swift */; }; 91D8189EFCD1BA992EA6F038 /* ConfidenceSuppressionPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06FF2B0A3094A952A8EBA9B5 /* ConfidenceSuppressionPolicyTests.swift */; }; 924489CEE8171F7AD8579D71 /* FocusDebugOverlayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F5E263AB69029D5E13D5EE8 /* FocusDebugOverlayController.swift */; }; 924E6C74380F9289AA721518 /* he.txt in Resources */ = {isa = PBXBuildFile; fileRef = C9C000E46A1E404932F89C81 /* he.txt */; }; + 925769DDA8D4D420BB096EAD /* PerAppShortcutOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7599A7A2B95DF7F6BD4363C7 /* PerAppShortcutOverride.swift */; }; + 92C7A90AF8A8117BBAFD71AB /* PerAppShortcutOverrideStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452AEA995B5C7DAD5E596DE7 /* PerAppShortcutOverrideStoreTests.swift */; }; 9301B309462E4CF7F554578F /* OnboardingStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ABC808DE01AF9AD04E38943 /* OnboardingStyle.swift */; }; 930BA578E742D96FD9D340ED /* ContextPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89497C35D1825BAE9625EE06 /* ContextPaneView.swift */; }; 934885ACC2DEA20B27F10948 /* PromptContextSanitizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D80CC2CCAAFE3F23FB8C37A /* PromptContextSanitizerTests.swift */; }; @@ -431,6 +436,7 @@ 97EF76E6B7A1AFB3FA4879D1 /* LGPL-3.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = 7513810E78F3C94FE972EB07 /* LGPL-3.0.txt */; }; 982BA68A53CEE0F45D41F3D3 /* PromptSectionBudget.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFCFCCCB69C29A86E726B10A /* PromptSectionBudget.swift */; }; 98E2E14A069384C1088CDB44 /* PromptContextSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4B45B91D4DEAC979C3113E /* PromptContextSanitizer.swift */; }; + 992D34699B695EFE0EBA8A1C /* KeybindRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5F6D44A9B4EC51505A0A363 /* KeybindRow.swift */; }; 9938DE59D9E05BC51A5BA5B8 /* SuggestionDebugLoggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD60968AEA8A5843F4E24618 /* SuggestionDebugLoggerTests.swift */; }; 9973763A9F4EA4D8B4AE59EB /* SuggestionEngineRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 384FBCF5D7A3A446C5BE2B8D /* SuggestionEngineRouter.swift */; }; 9A419D0704C95920CB71D3B1 /* RandomMacroEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3EC87078D3A4C21DB3252C /* RandomMacroEvaluator.swift */; }; @@ -491,6 +497,7 @@ B00FDD3DEE0B73FF5136C91C /* FocusTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FDF029F7828CAF3FE8850 /* FocusTracker.swift */; }; B02F46E94F74BAEBB90E165A /* PerformanceMetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 979A7867966180A545BB44C4 /* PerformanceMetricsStore.swift */; }; B0B115C6EBAC37FF6115B4BE /* SuggestionCoordinator+Lifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78E280F4F39A9D86840800D2 /* SuggestionCoordinator+Lifecycle.swift */; }; + B111A46CE1BA84C88EE472DD /* ShortcutResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D8A108E922306B7BA09AD7F /* ShortcutResolver.swift */; }; B2BDCFF0824EE41FC1C0451A /* FocusSnapshotResolverLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67D57F248880978A09DD28A6 /* FocusSnapshotResolverLiveTests.swift */; }; B2F7589B8D32ACF97BB642AB /* HuggingFaceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A520809E71697E3BB9A8139C /* HuggingFaceModels.swift */; }; B335B04A3EB50E51FF9C8C0F /* PerDomainDisableSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25C3087D4A9F4DC52FD5A69 /* PerDomainDisableSettings.swift */; }; @@ -551,6 +558,7 @@ C56ABA04AE27A9943368035C /* CurrentWordSpellChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 733BF6287BDE599B02A12271 /* CurrentWordSpellChecker.swift */; }; C5ADE88B2504F252278E3DD5 /* OverlayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F308F6E274CC645E27CB651F /* OverlayController.swift */; }; C607A624A0FB697486C56B8E /* PowerSourceMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB235F0DEA53295DAF8B4FA0 /* PowerSourceMonitor.swift */; }; + C617F829F7E0EE8EC8259873 /* ShortcutResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D8A108E922306B7BA09AD7F /* ShortcutResolver.swift */; }; C618C5595DA9C57C806A3E03 /* SettingsAttentionEvaluatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BC293F6125E2B14DCF05AD9 /* SettingsAttentionEvaluatorTests.swift */; }; C63F95C324C29940FAC6B973 /* de-100k.txt in Resources */ = {isa = PBXBuildFile; fileRef = 4B8665A5495891F9E3DDA48B /* de-100k.txt */; }; C6925440737F37F537622F35 /* StreamedGhostTextPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 299BD7B741DA4AAE6A061BAD /* StreamedGhostTextPolicy.swift */; }; @@ -802,6 +810,7 @@ 386C98FFCF76EC1C8C7E82BB /* SuggestionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionModels.swift; sourceTree = ""; }; 38931C165873B50B405CC602 /* SystemMetricsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMetricsStoreTests.swift; sourceTree = ""; }; 3BDA36955CCCFA87C1F67268 /* SuggestionEngineRouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionEngineRouterTests.swift; sourceTree = ""; }; + 3D8A108E922306B7BA09AD7F /* ShortcutResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutResolver.swift; sourceTree = ""; }; 3DE1975F3B5F4A70478DBF41 /* DownloadOutcomeClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadOutcomeClassifier.swift; sourceTree = ""; }; 41BBD5A4BA08CABE77860886 /* HardwareCapabilityProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareCapabilityProbe.swift; sourceTree = ""; }; 421FD16E18622824E038DFB4 /* CaretRunPlacementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaretRunPlacementTests.swift; sourceTree = ""; }; @@ -810,6 +819,7 @@ 442507A5399ACD81D1DB4936 /* SuggestionFadeInPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionFadeInPolicyTests.swift; sourceTree = ""; }; 4451D6673112575DF24C4A48 /* OnboardingTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTemplate.swift; sourceTree = ""; }; 44595B534DD7323F0AD60825 /* MenuBarPopoverDismisser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarPopoverDismisser.swift; sourceTree = ""; }; + 452AEA995B5C7DAD5E596DE7 /* PerAppShortcutOverrideStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerAppShortcutOverrideStoreTests.swift; sourceTree = ""; }; 45A896811745673061AF3612 /* SuggestionFocusFreshnessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionFocusFreshnessTests.swift; sourceTree = ""; }; 4638C74239D1DE2DC4D87975 /* MacroController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroController.swift; sourceTree = ""; }; 4696A84D17890B154533A08F /* PromptPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptPolicyTests.swift; sourceTree = ""; }; @@ -830,6 +840,7 @@ 5484C8A04B9C00CF79D589EB /* ScreenFrameReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenFrameReader.swift; sourceTree = ""; }; 54BC85605541E913EE57B258 /* ExtendedContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedContextTests.swift; sourceTree = ""; }; 54EF3C7F5D9D6F3FA50FD51C /* ContextBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextBuffer.swift; sourceTree = ""; }; + 565F28C13FD26BF174532228 /* ShortcutResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutResolverTests.swift; sourceTree = ""; }; 5664E34B23FBDF69292FEF43 /* FoundationModelSuggestionEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelSuggestionEngine.swift; sourceTree = ""; }; 5976600F428C1265121D4C0C /* SystemSettingsWindowLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemSettingsWindowLocator.swift; sourceTree = ""; }; 59E299BE2E9D42A33D5D2F5D /* ScreenTextExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenTextExtractor.swift; sourceTree = ""; }; @@ -878,6 +889,7 @@ 74BD1D4DB27D5D96D1E06096 /* DisplayCoordinateConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayCoordinateConverter.swift; sourceTree = ""; }; 7513810E78F3C94FE972EB07 /* LGPL-3.0.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "LGPL-3.0.txt"; sourceTree = ""; }; 75396860978E81EFAA506CD4 /* EmojiQueryRunTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiQueryRunTests.swift; sourceTree = ""; }; + 7599A7A2B95DF7F6BD4363C7 /* PerAppShortcutOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerAppShortcutOverride.swift; sourceTree = ""; }; 764659D09C3F0E8FBD267102 /* EmojiPickerPanelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerPanelController.swift; sourceTree = ""; }; 764EE0693994E2E126E7FC77 /* SuggestionFadeInPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionFadeInPolicy.swift; sourceTree = ""; }; 77B0121E7BB173F8A2B0B108 /* WindowScreenshotService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowScreenshotService.swift; sourceTree = ""; }; @@ -1084,6 +1096,7 @@ E3C84377F352140759B448C9 /* CaretGeometrySelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaretGeometrySelector.swift; sourceTree = ""; }; E43E587E421AF544A8300CE4 /* CustomRulesCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomRulesCatalog.swift; sourceTree = ""; }; E5DAF68AEBFE334F68A65E82 /* AcceptanceModePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcceptanceModePickerView.swift; sourceTree = ""; }; + E5F6D44A9B4EC51505A0A363 /* KeybindRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeybindRow.swift; sourceTree = ""; }; E6423D6CC8CC371D2DA899DE /* PermissionOverlayTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionOverlayTracker.swift; sourceTree = ""; }; E68BE6A22BA0D42C8DD9868C /* SelfCaptureGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfCaptureGate.swift; sourceTree = ""; }; E7F42112F14026E6253BB865 /* PermissionAndContextModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionAndContextModelTests.swift; sourceTree = ""; }; @@ -1331,6 +1344,7 @@ E5DAF68AEBFE334F68A65E82 /* AcceptanceModePickerView.swift */, E1FAD890FBC2D0351C0E3C60 /* ContextLivePreviewField.swift */, A594D02B0EF3C2DD62EFE69A /* GhostTextPreview.swift */, + E5F6D44A9B4EC51505A0A363 /* KeybindRow.swift */, E17AAA3C022A8AE39FACAAD5 /* SettingsIconTile.swift */, 19BE12C28A4AB8A4A58C2FF7 /* SettingsPaneScaffold.swift */, BE0A565A2AD007EBE9D70697 /* SettingsQuickLinkCard.swift */, @@ -1382,6 +1396,7 @@ BF4BB93056F291FD24EFAD22 /* LanguageCatalog.swift */, A804F4DB6FD9BC8C27B2B65F /* LlamaRuntimeModels.swift */, 4451D6673112575DF24C4A48 /* OnboardingTemplate.swift */, + 7599A7A2B95DF7F6BD4363C7 /* PerAppShortcutOverride.swift */, 979A7867966180A545BB44C4 /* PerformanceMetricsStore.swift */, 9D82FFC568527700EC17C07D /* PermissionModels.swift */, 60629DFE309C1A4BD8A7FB3B /* RuntimeBootstrapModel.swift */, @@ -1529,6 +1544,7 @@ D814BBA41CF29E8DD9954651 /* OnboardingTemplateFeatureListTests.swift */, 01B72736E416910878E8E493 /* OnboardingTemplateRecommenderTests.swift */, 28B7EB84781C0ED57844585E /* OnboardingTemplateTests.swift */, + 452AEA995B5C7DAD5E596DE7 /* PerAppShortcutOverrideStoreTests.swift */, EC582636750B78D497119845 /* PerDomainDisableSettingsTests.swift */, 7C0FCC5CCF6AE528E3C4DDA7 /* PerformanceMetricsStoreTests.swift */, E7F42112F14026E6253BB865 /* PermissionAndContextModelTests.swift */, @@ -1547,6 +1563,7 @@ 5A2FFC2055C52FB837DEEB8F /* SettingsIndexTests.swift */, C533B73CDDA3685135C460FB /* SettingsSearchRankerTests.swift */, 0850B07CCDBA67C756C6EC59 /* ShortcutConflictTests.swift */, + 565F28C13FD26BF174532228 /* ShortcutResolverTests.swift */, CA5B101C75C5D3972E33E8E0 /* SpeculativeAcceptanceContextTests.swift */, D562A73C7C680F2AA65F9F7F /* SpellingDictionaryResourceTests.swift */, E0871985CB1F877EC422E18C /* SpellingLanguageResolverTests.swift */, @@ -1773,6 +1790,7 @@ D4B56C250DDEF3E81F9DCBD7 /* SentenceBoundaryClassifier.swift */, 2A02336442BB735EE2E8D064 /* SettingsAttentionEvaluator.swift */, 866D74711A35E0085D2A4BB3 /* SettingsSearchRanker.swift */, + 3D8A108E922306B7BA09AD7F /* ShortcutResolver.swift */, B7EB66904C35A7D8BEF5D2A5 /* SpeculativeAcceptanceContext.swift */, 0348A7053E5683C68879A71A /* SpellingLanguageResolver.swift */, 299BD7B741DA4AAE6A061BAD /* StreamedGhostTextPolicy.swift */, @@ -2112,6 +2130,7 @@ ADBCB725707ED11B19C7F08D /* InsertionStrategySelector.swift in Sources */, 1C267B67EA61527B74C9D051 /* KeyCodeLabels.swift in Sources */, BA74281E2DDE659C5CACBF24 /* KeyRecorderView.swift in Sources */, + 992D34699B695EFE0EBA8A1C /* KeybindRow.swift in Sources */, 4086A1B07488C4D3D43D86C9 /* KeyboardInputSourceMonitor.swift in Sources */, 64F20D51F7905DC290AE0351 /* KeycapView.swift in Sources */, E3CAAEFAAB5BB24CEE16445B /* LLMIOFileHandler.swift in Sources */, @@ -2144,6 +2163,7 @@ 087EAFD68591401E870EFEC3 /* OnboardingTemplateFeatureList.swift in Sources */, B7A98BC225304E4DFED9E622 /* OnboardingTemplateRecommender.swift in Sources */, C5ADE88B2504F252278E3DD5 /* OverlayController.swift in Sources */, + 91D6204A26046A21A29F2C18 /* PerAppShortcutOverride.swift in Sources */, B335B04A3EB50E51FF9C8C0F /* PerDomainDisableSettings.swift in Sources */, B02F46E94F74BAEBB90E165A /* PerformanceMetricsStore.swift in Sources */, ACC1D8C50007AA193271F977 /* PerformancePaneView.swift in Sources */, @@ -2181,6 +2201,7 @@ BC27A2F336857345642A30E5 /* SettingsSearchRanker.swift in Sources */, B95C928082728C49D851E40D /* SettingsSearchResultRow.swift in Sources */, 753DC144B9394A35A3F395DA /* SettingsSidebarView.swift in Sources */, + B111A46CE1BA84C88EE472DD /* ShortcutResolver.swift in Sources */, 6F2FE689BCA50BEAE80AC6F4 /* ShortcutsPaneView.swift in Sources */, B909A118616C0C47AAB6039A /* SpeculativeAcceptanceContext.swift in Sources */, AD361AA6F90A5E5F6F5005BF /* SpellingDictionaryCatalog.swift in Sources */, @@ -2359,6 +2380,7 @@ 9D0F4829D11BCD4DB1290410 /* InsertionStrategySelector.swift in Sources */, F78F594F77C26C233377E71F /* KeyCodeLabels.swift in Sources */, F8E86FA4D6CEEBFA7FB55F8D /* KeyRecorderView.swift in Sources */, + 475259B5A87A3FA07475136F /* KeybindRow.swift in Sources */, 3124AD2340D4B58AF48A22F3 /* KeyboardInputSourceMonitor.swift in Sources */, 9C5B1ED23126B60E3BBF3A5B /* KeycapView.swift in Sources */, 046C133967B32BBF9205EBB1 /* LLMIOFileHandler.swift in Sources */, @@ -2391,6 +2413,7 @@ 64DA031AEAC20AC6C852A24A /* OnboardingTemplateFeatureList.swift in Sources */, DF8794793110A8ED234CBA96 /* OnboardingTemplateRecommender.swift in Sources */, 0DDC0CFF5558A8F4355836B2 /* OverlayController.swift in Sources */, + 925769DDA8D4D420BB096EAD /* PerAppShortcutOverride.swift in Sources */, A67B3F97DAEBEC7CA9B1215E /* PerDomainDisableSettings.swift in Sources */, 0E4009CFC86608F055909967 /* PerformanceMetricsStore.swift in Sources */, 60636D92D12FED132250D8D2 /* PerformancePaneView.swift in Sources */, @@ -2428,6 +2451,7 @@ D3BC4EA192B234EB22361186 /* SettingsSearchRanker.swift in Sources */, E98161E9592F825F9CB7D32D /* SettingsSearchResultRow.swift in Sources */, 27D4F5CACADE171F142178B4 /* SettingsSidebarView.swift in Sources */, + C617F829F7E0EE8EC8259873 /* ShortcutResolver.swift in Sources */, 12995E5DDB11E3395E6AF82F /* ShortcutsPaneView.swift in Sources */, 4B2C52C714D04D17ACB70B99 /* SpeculativeAcceptanceContext.swift in Sources */, 2E972FB7E0CF14EE03AA55A3 /* SpellingDictionaryCatalog.swift in Sources */, @@ -2587,6 +2611,7 @@ DA23422A2CF77CFD3B1283C8 /* OnboardingTemplateFeatureListTests.swift in Sources */, D648DD70AD847F67B77CE052 /* OnboardingTemplateRecommenderTests.swift in Sources */, 0160F9D9929465E6B6A3385F /* OnboardingTemplateTests.swift in Sources */, + 92C7A90AF8A8117BBAFD71AB /* PerAppShortcutOverrideStoreTests.swift in Sources */, C9426ADFCA2EC1D11D841A2A /* PerDomainDisableSettingsTests.swift in Sources */, 8B26F7B26358438D6EB88C2E /* PerformanceMetricsStoreTests.swift in Sources */, 15FA56CEF6FB5FF54C2FBA6F /* PermissionAndContextModelTests.swift in Sources */, @@ -2605,6 +2630,7 @@ F71FD79FAC8B59C1CBD9E2E0 /* SettingsIndexTests.swift in Sources */, F0556D369F809445D0AC4E9C /* SettingsSearchRankerTests.swift in Sources */, 8441299082E6B68F7F88911B /* ShortcutConflictTests.swift in Sources */, + 06195E20701221AE3B2AB371 /* ShortcutResolverTests.swift in Sources */, 14B2492F1208888C0C3F8804 /* SpeculativeAcceptanceContextTests.swift in Sources */, 303652F15C0FE55595669D81 /* SpellingDictionaryResourceTests.swift in Sources */, 66D0D9F605AF462F569A5CFD /* SpellingLanguageResolverTests.swift in Sources */, diff --git a/Cotabby/App/Core/CotabbyAppEnvironment.swift b/Cotabby/App/Core/CotabbyAppEnvironment.swift index 06d4d389..8474b655 100644 --- a/Cotabby/App/Core/CotabbyAppEnvironment.swift +++ b/Cotabby/App/Core/CotabbyAppEnvironment.swift @@ -60,10 +60,6 @@ final class CotabbyAppEnvironment { permissionProvider: { permissionManager.inputMonitoringGranted }, suppressionController: suppressionController ) - inputMonitor.acceptanceKeyCodeProvider = { suggestionSettings.acceptanceKeyCode } - inputMonitor.acceptanceKeyModifiersProvider = { suggestionSettings.acceptanceKeyModifiers } - inputMonitor.fullAcceptanceKeyCodeProvider = { suggestionSettings.fullAcceptanceKeyCode } - inputMonitor.fullAcceptanceKeyModifiersProvider = { suggestionSettings.fullAcceptanceKeyModifiers } inputMonitor.globalToggleKeyCodeProvider = { suggestionSettings.globalToggleKeyCode } inputMonitor.globalToggleKeyModifiersProvider = { suggestionSettings.globalToggleKeyModifiers } inputMonitor.onGlobalToggleHotkey = { [weak suggestionSettings] in @@ -103,6 +99,33 @@ final class CotabbyAppEnvironment { } return true } + // Accept-key resolution runs at event time through `ShortcutResolver` so a per-app override + // (keyed by the live frontmost bundle id) wins over the global binding, falling back to the + // global when no override matches. Installed here — after `focusModel` exists — because the + // closures capture it weakly to read the fresh snapshot per keystroke. The poll-based + // snapshot can briefly lag a fast app switch, the same race the evaluator above already has. + // Each binding resolves once, as a unit, so the key code and its modifiers always come from + // the same per-app/global resolution and the overrides are scanned at most once per binding. + inputMonitor.acceptanceBindingProvider = { [weak focusModel] in + let binding = ShortcutResolver.acceptBinding( + frontmostBundleIdentifier: focusModel?.snapshot.bundleIdentifier, + overrides: suggestionSettings.perAppShortcutOverrides, + globalKeyCode: suggestionSettings.acceptanceKeyCode, + globalModifiers: suggestionSettings.acceptanceKeyModifiers, + globalLabel: suggestionSettings.acceptanceKeyLabel + ) + return (binding.keyCode, binding.modifiers) + } + inputMonitor.fullAcceptanceBindingProvider = { [weak focusModel] in + let binding = ShortcutResolver.fullAcceptBinding( + frontmostBundleIdentifier: focusModel?.snapshot.bundleIdentifier, + overrides: suggestionSettings.perAppShortcutOverrides, + globalKeyCode: suggestionSettings.fullAcceptanceKeyCode, + globalModifiers: suggestionSettings.fullAcceptanceKeyModifiers, + globalLabel: suggestionSettings.fullAcceptanceKeyLabel + ) + return (binding.keyCode, binding.modifiers) + } let appUpdateManager = AppUpdateManager() let welcomeCoordinator = WelcomeCoordinator( permissionManager: permissionManager, diff --git a/Cotabby/Models/InputModels.swift b/Cotabby/Models/InputModels.swift index d2cd3536..037b0ced 100644 --- a/Cotabby/Models/InputModels.swift +++ b/Cotabby/Models/InputModels.swift @@ -13,7 +13,7 @@ import Foundation /// We don't reuse `CGEventFlags` directly because it carries unrelated bits — caps lock, /// numeric pad, secondary fn, device-specific flags — that we don't want to participate in /// shortcut equality. Reducing to a 4-bit mask gives unambiguous storage and comparison. -struct ShortcutModifierMask: OptionSet, Hashable { +struct ShortcutModifierMask: OptionSet, Hashable, Sendable, Codable { let rawValue: UInt32 init(rawValue: UInt32) { @@ -35,6 +35,19 @@ struct ShortcutModifierMask: OptionSet, Hashable { if eventFlags.contains(.maskControl) { mask.insert(.control) } self = mask } + + // Single-value coding lets persisted per-app overrides store the modifier set as a plain + // integer in JSON — same on-disk shape the standalone `acceptanceKeyModifiers` UserDefault + // uses, so the two are debuggable side by side and migrate cleanly if we ever consolidate. + init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(UInt32.self) + self.init(rawValue: raw) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } } struct CapturedInputEvent: Equatable { diff --git a/Cotabby/Models/PerAppShortcutOverride.swift b/Cotabby/Models/PerAppShortcutOverride.swift new file mode 100644 index 00000000..342ce6a7 --- /dev/null +++ b/Cotabby/Models/PerAppShortcutOverride.swift @@ -0,0 +1,61 @@ +import CoreGraphics +import Foundation + +/// File overview: +/// One application's per-app shortcut customization. Each field is **optional** so the +/// "no custom shortcut → fall back to the global binding" state is first-class instead of a +/// sentinel value: if `acceptKeyCode == nil`, the accept binding for this app is inherited +/// from `SuggestionSettingsModel.acceptanceKeyCode`. +/// +/// The bundle identifier is the durable identity used by the suggestion pipeline and the input +/// monitor's event-time provider closures. The display name is saved alongside so Settings can +/// render a readable list without having to resolve installed applications on every launch. +/// +/// `ShortcutResolver` is the only place that should consume these overrides; rely on it so the +/// precedence rule (per-app → global) stays in a single, testable spot. +struct PerAppShortcutOverride: Codable, Equatable, Identifiable, Sendable { + let bundleIdentifier: String + var displayName: String + /// `nil` for any field means "inherit the global binding for that action". The three accept + /// fields move as a unit — UI and resolver both treat (keyCode, modifiers, label) as one + /// binding — so cleared overrides set all three to nil. + var acceptKeyCode: CGKeyCode? + var acceptKeyModifiers: ShortcutModifierMask? + var acceptKeyLabel: String? + var fullAcceptKeyCode: CGKeyCode? + var fullAcceptKeyModifiers: ShortcutModifierMask? + var fullAcceptKeyLabel: String? + + var id: String { bundleIdentifier } + + /// True when there is no override left to persist — the row should be removed from the + /// settings store instead of sitting around as a no-op alongside the global bindings. + var isEmpty: Bool { + acceptKeyCode == nil && fullAcceptKeyCode == nil + } + + /// A copy with any *partially*-specified binding collapsed back to "inherit global". Each binding + /// is a unit of (keyCode, modifiers, label) and `ShortcutResolver` only fires when all three are + /// present, so a row persisted with — say — a key code but no label would otherwise survive + /// `isEmpty`, appear in Settings, yet never fire at event time (a phantom override). Normalizing + /// on load keeps the stored shape matching exactly what the resolver honors. + var bindingsNormalized: PerAppShortcutOverride { + var normalized = self + if acceptKeyCode == nil || acceptKeyModifiers == nil || acceptKeyLabel == nil { + normalized.acceptKeyCode = nil + normalized.acceptKeyModifiers = nil + normalized.acceptKeyLabel = nil + } + if fullAcceptKeyCode == nil || fullAcceptKeyModifiers == nil || fullAcceptKeyLabel == nil { + normalized.fullAcceptKeyCode = nil + normalized.fullAcceptKeyModifiers = nil + normalized.fullAcceptKeyLabel = nil + } + return normalized + } + + /// Whether this row pins an accept key (i.e. the user explicitly chose one, including the + /// "no key" sentinel for "this app should never accept word-by-word"). + var hasAcceptOverride: Bool { acceptKeyCode != nil } + var hasFullAcceptOverride: Bool { fullAcceptKeyCode != nil } +} diff --git a/Cotabby/Models/SuggestionSettingsData.swift b/Cotabby/Models/SuggestionSettingsData.swift index 2284a8ad..4c23cccb 100644 --- a/Cotabby/Models/SuggestionSettingsData.swift +++ b/Cotabby/Models/SuggestionSettingsData.swift @@ -92,6 +92,9 @@ struct SuggestionSettingsData: Equatable { var globalToggleKeyCode: CGKeyCode var globalToggleKeyModifiers: ShortcutModifierMask var globalToggleKeyLabel: String + /// Per-app accept/full-accept overrides keyed by bundle id. Empty by default; a row is present + /// only for apps the user explicitly customized. `ShortcutResolver` consumes this at event time. + var perAppShortcutOverrides: [PerAppShortcutOverride] var acceptanceGranularity: AcceptanceGranularity var isPowerBasedModelSwitchingEnabled: Bool var batteryEngine: SuggestionEngineKind diff --git a/Cotabby/Models/SuggestionSettingsModel.swift b/Cotabby/Models/SuggestionSettingsModel.swift index c3b7c76a..e6d6c251 100644 --- a/Cotabby/Models/SuggestionSettingsModel.swift +++ b/Cotabby/Models/SuggestionSettingsModel.swift @@ -123,6 +123,9 @@ final class SuggestionSettingsModel: ObservableObject { @Published private(set) var globalToggleKeyCode: CGKeyCode @Published private(set) var globalToggleKeyModifiers: ShortcutModifierMask @Published private(set) var globalToggleKeyLabel: String + /// Per-app accept/full-accept overrides. Published so the input monitor's event-time provider + /// closures (via `ShortcutResolver`) and the Apps settings pane both observe the live list. + @Published private(set) var perAppShortcutOverrides: [PerAppShortcutOverride] @Published private(set) var acceptanceGranularity: AcceptanceGranularity @Published private(set) var isPowerBasedModelSwitchingEnabled: Bool @Published private(set) var batteryEngine: SuggestionEngineKind @@ -206,6 +209,8 @@ final class SuggestionSettingsModel: ObservableObject { autoAcceptTrailingPunctuation = data.autoAcceptTrailingPunctuation addSpaceAfterAccept = data.addSpaceAfterAccept streamSuggestionsWhileGenerating = data.streamSuggestionsWhileGenerating + fadeInSuggestions = data.fadeInSuggestions + fadeInDurationSeconds = data.fadeInDurationSeconds acceptanceKeyCode = data.acceptanceKeyCode acceptanceKeyModifiers = data.acceptanceKeyModifiers acceptanceKeyLabel = data.acceptanceKeyLabel @@ -215,6 +220,7 @@ final class SuggestionSettingsModel: ObservableObject { globalToggleKeyCode = data.globalToggleKeyCode globalToggleKeyModifiers = data.globalToggleKeyModifiers globalToggleKeyLabel = data.globalToggleKeyLabel + perAppShortcutOverrides = data.perAppShortcutOverrides acceptanceGranularity = data.acceptanceGranularity isPowerBasedModelSwitchingEnabled = data.isPowerBasedModelSwitchingEnabled batteryEngine = data.batteryEngine @@ -282,6 +288,7 @@ final class SuggestionSettingsModel: ObservableObject { globalToggleKeyCode = data.globalToggleKeyCode globalToggleKeyModifiers = data.globalToggleKeyModifiers globalToggleKeyLabel = data.globalToggleKeyLabel + perAppShortcutOverrides = data.perAppShortcutOverrides acceptanceGranularity = data.acceptanceGranularity isPowerBasedModelSwitchingEnabled = data.isPowerBasedModelSwitchingEnabled batteryEngine = data.batteryEngine @@ -973,6 +980,130 @@ final class SuggestionSettingsModel: ObservableObject { setGlobalToggleKey(keyCode: Self.disabledKeyCode, modifiers: [], label: Self.disabledKeyLabel) } + // MARK: - Per-app shortcut overrides + + /// Fast lookup used by `ShortcutResolver` at event time. The array is small (one row per app + /// the user customized) so a linear scan is fine; we avoid materializing a dictionary on every + /// access because the published array is replaced on every mutation. + func perAppShortcutOverride(forBundleIdentifier bundleIdentifier: String?) -> PerAppShortcutOverride? { + guard let normalized = SuggestionSettingsStore.normalizedBundleIdentifier(bundleIdentifier) else { + return nil + } + return perAppShortcutOverrides.first { $0.bundleIdentifier == normalized } + } + + /// Replaces (or inserts) the accept-word binding for one app. Pass the disabled sentinel + /// `(SuggestionSettingsModel.disabledKeyCode, [], "None")` to bind "no key accepts in this app"; + /// pass anything else for a real combo. To restore the global fallback, call + /// `clearPerAppAcceptKey` instead — that nils out the override so the resolver re-inherits. + func setPerAppAcceptKey( + bundleIdentifier: String, + displayName: String, + keyCode: CGKeyCode, + modifiers: ShortcutModifierMask, + label: String + ) { + guard let normalizedBundleIdentifier = SuggestionSettingsStore.normalizedBundleIdentifier(bundleIdentifier) else { + return + } + let normalizedModifiers = keyCode == Self.disabledKeyCode ? [] : modifiers + let normalizedDisplayName = SuggestionSettingsStore.normalizedDisplayName( + displayName, + fallbackBundleIdentifier: normalizedBundleIdentifier + ) + + var override = existingPerAppOverride(bundleIdentifier: normalizedBundleIdentifier) + ?? PerAppShortcutOverride(bundleIdentifier: normalizedBundleIdentifier, displayName: normalizedDisplayName) + override.displayName = normalizedDisplayName + override.acceptKeyCode = keyCode + override.acceptKeyModifiers = normalizedModifiers + override.acceptKeyLabel = label + + upsertPerAppOverride(override) + } + + /// Clears just the accept-word override for one app. If the row also has no full-accept + /// override left, the row itself is removed so the resolver re-inherits the global pair. + func clearPerAppAcceptKey(bundleIdentifier: String) { + guard let normalizedBundleIdentifier = SuggestionSettingsStore.normalizedBundleIdentifier(bundleIdentifier), + var override = existingPerAppOverride(bundleIdentifier: normalizedBundleIdentifier) else { + return + } + override.acceptKeyCode = nil + override.acceptKeyModifiers = nil + override.acceptKeyLabel = nil + upsertPerAppOverride(override) + } + + func setPerAppFullAcceptKey( + bundleIdentifier: String, + displayName: String, + keyCode: CGKeyCode, + modifiers: ShortcutModifierMask, + label: String + ) { + guard let normalizedBundleIdentifier = SuggestionSettingsStore.normalizedBundleIdentifier(bundleIdentifier) else { + return + } + let normalizedModifiers = keyCode == Self.disabledKeyCode ? [] : modifiers + let normalizedDisplayName = SuggestionSettingsStore.normalizedDisplayName( + displayName, + fallbackBundleIdentifier: normalizedBundleIdentifier + ) + + var override = existingPerAppOverride(bundleIdentifier: normalizedBundleIdentifier) + ?? PerAppShortcutOverride(bundleIdentifier: normalizedBundleIdentifier, displayName: normalizedDisplayName) + override.displayName = normalizedDisplayName + override.fullAcceptKeyCode = keyCode + override.fullAcceptKeyModifiers = normalizedModifiers + override.fullAcceptKeyLabel = label + + upsertPerAppOverride(override) + } + + func clearPerAppFullAcceptKey(bundleIdentifier: String) { + guard let normalizedBundleIdentifier = SuggestionSettingsStore.normalizedBundleIdentifier(bundleIdentifier), + var override = existingPerAppOverride(bundleIdentifier: normalizedBundleIdentifier) else { + return + } + override.fullAcceptKeyCode = nil + override.fullAcceptKeyModifiers = nil + override.fullAcceptKeyLabel = nil + upsertPerAppOverride(override) + } + + /// Drops the row for `bundleIdentifier` entirely — both accept fields revert to the global + /// binding. UI exposes this as "Reset to global" so the fallback path is a first-class action. + func removePerAppOverride(bundleIdentifier: String) { + guard let normalizedBundleIdentifier = SuggestionSettingsStore.normalizedBundleIdentifier(bundleIdentifier) else { + return + } + let updated = perAppShortcutOverrides.filter { $0.bundleIdentifier != normalizedBundleIdentifier } + guard perAppShortcutOverrides != updated else { return } + perAppShortcutOverrides = updated + store.savePerAppShortcutOverrides(updated) + } + + private func existingPerAppOverride(bundleIdentifier: String) -> PerAppShortcutOverride? { + perAppShortcutOverrides.first { $0.bundleIdentifier == bundleIdentifier } + } + + /// Upserts `override` into the sorted, deduped list. An override that has nilled out both + /// accept fields is removed from the store so the resolver naturally falls back to global — + /// the array never holds rows that decode to a no-op. + private func upsertPerAppOverride(_ override: PerAppShortcutOverride) { + var byBundle = Dictionary(uniqueKeysWithValues: perAppShortcutOverrides.map { ($0.bundleIdentifier, $0) }) + if override.isEmpty { + byBundle.removeValue(forKey: override.bundleIdentifier) + } else { + byBundle[override.bundleIdentifier] = override + } + let updated = SuggestionSettingsStore.sortedPerAppShortcutOverrides(Array(byBundle.values)) + guard perAppShortcutOverrides != updated else { return } + perAppShortcutOverrides = updated + store.savePerAppShortcutOverrides(updated) + } + // All stored state is thread-safe to release (Combine subjects, the value-typed store). The // nonisolated deinit prevents Swift from scheduling the teardown through the // back-deployment main-actor executor shim, which has a StopLookupScope bug on macOS 26. @@ -1009,6 +1140,51 @@ final class SuggestionSettingsModel: ObservableObject { return nil } + /// Per-app conflict scoping. A per-app override is only checked against the **same app's** + /// other binding (accept-word vs accept-entire) and against the **global** toggle key, never + /// against unrelated apps. Two different apps may legitimately bind the same combo: the + /// resolver picks the right one at event time based on the frontmost bundle id, so there is no + /// ambiguity at the tap layer. + /// + /// `excluding` is the action the user is currently re-recording in *this* app, so we never + /// flag an in-place edit as colliding with its own existing binding. + func conflictingPerAppShortcutName( + forBundleIdentifier bundleIdentifier: String, + keyCode: CGKeyCode, + modifiers: ShortcutModifierMask, + excluding action: ShortcutAction + ) -> String? { + guard keyCode != Self.disabledKeyCode else { return nil } + + // Same-app check: only consult the other accept binding on the same row. The full set of + // ShortcutAction cases includes the global toggle, which intentionally falls through to + // the global-only check below. + if let override = perAppShortcutOverride(forBundleIdentifier: bundleIdentifier) { + if action != .acceptWord, + let overrideKey = override.acceptKeyCode, + let overrideModifiers = override.acceptKeyModifiers, + overrideKey == keyCode, + overrideModifiers == modifiers { + return ShortcutAction.acceptWord.displayName + } + if action != .acceptEntireSuggestion, + let overrideKey = override.fullAcceptKeyCode, + let overrideModifiers = override.fullAcceptKeyModifiers, + overrideKey == keyCode, + overrideModifiers == modifiers { + return ShortcutAction.acceptEntireSuggestion.displayName + } + } + + // The global toggle is an app-spanning binding — a per-app accept key that collides with + // it would still get eaten by the toggle tap, so we refuse the combo even though it isn't + // in `ShortcutAction` for per-app rows. + if globalToggleKeyCode == keyCode, globalToggleKeyModifiers == modifiers { + return ShortcutAction.toggleTabby.displayName + } + return nil + } + private func shortcutBinding(for action: ShortcutAction) -> (keyCode: CGKeyCode, modifiers: ShortcutModifierMask) { switch action { case .acceptWord: diff --git a/Cotabby/Services/Input/InputMonitor.swift b/Cotabby/Services/Input/InputMonitor.swift index e76d06bf..f8775789 100644 --- a/Cotabby/Services/Input/InputMonitor.swift +++ b/Cotabby/Services/Input/InputMonitor.swift @@ -59,18 +59,18 @@ final class InputMonitor { /// event, so this closure is a fast, side-effect-free read. var emojiCaptureKeyDecider: (@MainActor (InputMonitorKeyEvent) -> InputMonitorAcceptTapDecision)? - /// Reads the current word-accept key code from the model at event time, avoiding - /// Combine delivery lag between settings changes and the event classifier. - var acceptanceKeyCodeProvider: @MainActor () -> CGKeyCode = { 48 } - - /// Modifier mask required alongside the word-accept key code. Empty means the bare key. - var acceptanceKeyModifiersProvider: @MainActor () -> ShortcutModifierMask = { [] } - - /// Reads the current full-accept key code from the model at event time. - var fullAcceptanceKeyCodeProvider: @MainActor () -> CGKeyCode = { CGKeyCode(UInt16.max) } + /// Reads the current word-accept binding (key code + required modifiers) from the model at event + /// time, avoiding Combine delivery lag between settings changes and the event classifier. The two + /// fields are returned together so a per-app override is scanned at most once per binding and the + /// key code and its modifiers can never come from two different resolutions. + var acceptanceBindingProvider: @MainActor () -> (keyCode: CGKeyCode, modifiers: ShortcutModifierMask) = { + (48, []) + } - /// Modifier mask required alongside the full-accept key code. Empty means the bare key. - var fullAcceptanceKeyModifiersProvider: @MainActor () -> ShortcutModifierMask = { [] } + /// Reads the current full-accept binding (key code + required modifiers) from the model at event time. + var fullAcceptanceBindingProvider: @MainActor () -> (keyCode: CGKeyCode, modifiers: ShortcutModifierMask) = { + (CGKeyCode(UInt16.max), []) + } /// Reads the global-toggle hotkey at event time. `disabledKeyCode` (UInt16.max) means unbound; /// the dedicated toggle tap is torn down whenever the provider returns that sentinel so users @@ -702,20 +702,20 @@ final class InputMonitor { private func acceptanceKind(for keyEvent: InputMonitorKeyEvent) -> CapturedInputEvent.Kind? { let eventModifiers = ShortcutModifierMask(eventFlags: keyEvent.flags) - // Read shortcut state from the model at event time so changes are always current. - let fullAcceptKey = fullAcceptanceKeyCodeProvider() - let fullAcceptModifiers = fullAcceptanceKeyModifiersProvider() - let acceptKey = acceptanceKeyCodeProvider() - let acceptModifiers = acceptanceKeyModifiersProvider() + // Read shortcut state from the model at event time so changes are always current. Each binding + // resolves once as a unit, so the key code and modifiers can never disagree and a per-app + // override is scanned at most once per binding per keystroke. + let fullAccept = fullAcceptanceBindingProvider() + let accept = acceptanceBindingProvider() // Full-suggestion acceptance takes priority so pressing the full-accept key doesn't // silently fall through to word-accept when both are assigned. The bound modifier set must // match exactly after normalization, so `Tab` and `Shift+Tab` remain distinct bindings. - if keyEvent.keyCode == fullAcceptKey, eventModifiers == fullAcceptModifiers { + if keyEvent.keyCode == fullAccept.keyCode, eventModifiers == fullAccept.modifiers { return .fullAcceptance } - if keyEvent.keyCode == acceptKey, eventModifiers == acceptModifiers { + if keyEvent.keyCode == accept.keyCode, eventModifiers == accept.modifiers { return .acceptance } diff --git a/Cotabby/Support/ShortcutResolver.swift b/Cotabby/Support/ShortcutResolver.swift new file mode 100644 index 00000000..ca24ae9a --- /dev/null +++ b/Cotabby/Support/ShortcutResolver.swift @@ -0,0 +1,82 @@ +import CoreGraphics +import Foundation + +/// File overview: +/// Pure, event-time resolution of which `(keyCode, modifiers)` pair should be treated as the +/// accept-word (or accept-full) shortcut for the currently-focused app. +/// +/// **Precedence (highest first):** +/// 1. **Per-app override** — `PerAppShortcutOverride` matching the frontmost bundle id. +/// 2. **Global binding** — `SuggestionSettingsModel.acceptanceKey*` / +/// `SuggestionSettingsModel.fullAcceptanceKey*`. +/// +/// This file is the single source of truth for that ordering. The resolver is intentionally a +/// pure free function (no event tap, no AX) so the matrix can be unit-tested without launching +/// the app — the input-monitor providers in `CotabbyAppEnvironment` call into it once per +/// keystroke with the current frontmost bundle id from `FocusTrackingModel.snapshot`. +enum ShortcutResolver { + struct ResolvedBinding: Equatable { + let keyCode: CGKeyCode + let modifiers: ShortcutModifierMask + /// User-facing label that should appear in the keycap hint, recorder badge, etc. When + /// the resolved binding came from an override this is the per-app label; otherwise it is + /// the global label so the pill still teaches the key that will actually fire. + let label: String + } + + /// Resolve the accept-word binding for `frontmostBundleIdentifier`. + /// + /// - Parameters: + /// - frontmostBundleIdentifier: bundle id from `FocusTrackingModel.snapshot`. Resolution + /// runs at event time on the closure path, so this is intentionally read fresh per call + /// rather than captured. + /// - overrides: live list from `SuggestionSettingsModel.perAppShortcutOverrides`. Same + /// event-time-fresh requirement. + /// - globalKeyCode/Modifiers/Label: the global accept-word binding. + /// - Returns: the override binding when one is present for the frontmost app, else the + /// global binding. Disabled-sentinel keys (`SuggestionSettingsModel.disabledKeyCode`) are + /// returned verbatim — choosing "no key" for one app is a legitimate override, not a + /// reason to inherit. + static func acceptBinding( + frontmostBundleIdentifier: String?, + overrides: [PerAppShortcutOverride], + globalKeyCode: CGKeyCode, + globalModifiers: ShortcutModifierMask, + globalLabel: String + ) -> ResolvedBinding { + if let override = override(for: frontmostBundleIdentifier, in: overrides), + let keyCode = override.acceptKeyCode, + let modifiers = override.acceptKeyModifiers, + let label = override.acceptKeyLabel { + return ResolvedBinding(keyCode: keyCode, modifiers: modifiers, label: label) + } + return ResolvedBinding(keyCode: globalKeyCode, modifiers: globalModifiers, label: globalLabel) + } + + /// Mirror of `acceptBinding` for the full-accept (accept-entire-suggestion) action. + static func fullAcceptBinding( + frontmostBundleIdentifier: String?, + overrides: [PerAppShortcutOverride], + globalKeyCode: CGKeyCode, + globalModifiers: ShortcutModifierMask, + globalLabel: String + ) -> ResolvedBinding { + if let override = override(for: frontmostBundleIdentifier, in: overrides), + let keyCode = override.fullAcceptKeyCode, + let modifiers = override.fullAcceptKeyModifiers, + let label = override.fullAcceptKeyLabel { + return ResolvedBinding(keyCode: keyCode, modifiers: modifiers, label: label) + } + return ResolvedBinding(keyCode: globalKeyCode, modifiers: globalModifiers, label: globalLabel) + } + + /// Linear lookup. `overrides` is small (one row per customized app), so a Dictionary is + /// not worth the per-mutation allocation; a fresh array is published on every change. + private static func override( + for bundleIdentifier: String?, + in overrides: [PerAppShortcutOverride] + ) -> PerAppShortcutOverride? { + guard let bundleIdentifier, !bundleIdentifier.isEmpty else { return nil } + return overrides.first { $0.bundleIdentifier == bundleIdentifier } + } +} diff --git a/Cotabby/Support/SuggestionSettingsStore.swift b/Cotabby/Support/SuggestionSettingsStore.swift index fe5c559e..24ae9c40 100644 --- a/Cotabby/Support/SuggestionSettingsStore.swift +++ b/Cotabby/Support/SuggestionSettingsStore.swift @@ -71,6 +71,7 @@ struct SuggestionSettingsStore { private static let isGloballyEnabledDefaultsKey = "cotabbyGloballyEnabled" private static let disabledAppRulesDefaultsKey = "cotabbyDisabledAppRules" + private static let perAppShortcutOverridesDefaultsKey = "cotabbyPerAppShortcutOverrides" private static let suggestInIntegratedTerminalsDefaultsKey = "cotabbySuggestInIntegratedTerminals" private static let showCaretIndicatorDefaultsKey = "cotabbyShowCaretIndicator" private static let selectedIndicatorModeDefaultsKey = "cotabbySelectedIndicatorMode" @@ -146,6 +147,7 @@ struct SuggestionSettingsStore { private static let allPreferenceDefaultsKeys: [String] = [ isGloballyEnabledDefaultsKey, disabledAppRulesDefaultsKey, + perAppShortcutOverridesDefaultsKey, suggestInIntegratedTerminalsDefaultsKey, showCaretIndicatorDefaultsKey, selectedIndicatorModeDefaultsKey, @@ -210,6 +212,7 @@ struct SuggestionSettingsStore { func load(configuration: SuggestionConfiguration) -> SuggestionSettingsData { let resolvedGloballyEnabled = userDefaults.object(forKey: Self.isGloballyEnabledDefaultsKey) as? Bool ?? true let resolvedDisabledAppRules = loadDisabledAppRules() + let resolvedPerAppShortcutOverrides = loadPerAppShortcutOverrides() let resolvedShowIndicator: Bool = if let modeString = userDefaults.string( forKey: Self.selectedIndicatorModeDefaultsKey ) { @@ -486,6 +489,7 @@ struct SuggestionSettingsStore { globalToggleKeyCode: resolvedGlobalToggleKeyCode, globalToggleKeyModifiers: resolvedGlobalToggleKeyModifiers, globalToggleKeyLabel: resolvedGlobalToggleKeyLabel, + perAppShortcutOverrides: resolvedPerAppShortcutOverrides, acceptanceGranularity: resolvedAcceptanceGranularity, isPowerBasedModelSwitchingEnabled: resolvedPowerBasedModelSwitchingEnabled, batteryEngine: resolvedBatteryEngine, @@ -549,6 +553,7 @@ struct SuggestionSettingsStore { modifiers: data.globalToggleKeyModifiers, label: data.globalToggleKeyLabel ) + savePerAppShortcutOverrides(data.perAppShortcutOverrides) saveAcceptanceGranularity(data.acceptanceGranularity) savePowerBasedModelSwitchingEnabled(data.isPowerBasedModelSwitchingEnabled) saveBatteryEngine(data.batteryEngine) @@ -599,6 +604,20 @@ struct SuggestionSettingsStore { } } + /// An empty list removes the key entirely (mirroring `saveDisabledAppRules`) so a fresh + /// install and a mutated-back-to-empty store are indistinguishable on disk, and the absent vs + /// present distinction in `load` only ever fires on the very first launch. + func savePerAppShortcutOverrides(_ overrides: [PerAppShortcutOverride]) { + guard !overrides.isEmpty else { + userDefaults.removeObject(forKey: Self.perAppShortcutOverridesDefaultsKey) + return + } + + if let data = try? JSONEncoder().encode(overrides) { + userDefaults.set(data, forKey: Self.perAppShortcutOverridesDefaultsKey) + } + } + func saveShowIndicator(_ show: Bool) { let mode: ActivationIndicatorMode = show ? .fieldEdgeIcon : .hidden userDefaults.set(mode.rawValue, forKey: Self.selectedIndicatorModeDefaultsKey) @@ -831,6 +850,55 @@ struct SuggestionSettingsStore { return sortedDisabledAppRules(Array(rulesByBundleIdentifier.values)) } + // MARK: - Per-app shortcut override decoding + + private func loadPerAppShortcutOverrides() -> [PerAppShortcutOverride] { + guard let data = userDefaults.data(forKey: Self.perAppShortcutOverridesDefaultsKey), + let decoded = try? JSONDecoder().decode([PerAppShortcutOverride].self, from: data) + else { + return [] + } + + return Self.sanitizedPerAppShortcutOverrides(decoded) + } + + /// Trim, dedupe by bundle id, collapse partial bindings to "inherit global", drop empty (no + /// accept and no full-accept) entries, and normalize each row's display name. Mirrors + /// `sanitizedDisabledAppRules` so both stores have the same "absent vs empty UserDefault" + /// discipline and one decode-time hardening pass. + private static func sanitizedPerAppShortcutOverrides( + _ overrides: [PerAppShortcutOverride] + ) -> [PerAppShortcutOverride] { + var byBundle: [String: PerAppShortcutOverride] = [:] + + for override in overrides { + guard let normalizedBundleIdentifier = normalizedBundleIdentifier(override.bundleIdentifier) else { + continue + } + // Collapse any partial binding (e.g. a key code with no label) to "inherit global" before + // the empty check, so a phantom row that survives load but never fires in `ShortcutResolver` + // can't linger in Settings. + let normalized = override.bindingsNormalized + guard !normalized.isEmpty else { continue } + + byBundle[normalizedBundleIdentifier] = PerAppShortcutOverride( + bundleIdentifier: normalizedBundleIdentifier, + displayName: normalizedDisplayName( + normalized.displayName, + fallbackBundleIdentifier: normalizedBundleIdentifier + ), + acceptKeyCode: normalized.acceptKeyCode, + acceptKeyModifiers: normalized.acceptKeyModifiers, + acceptKeyLabel: normalized.acceptKeyLabel, + fullAcceptKeyCode: normalized.fullAcceptKeyCode, + fullAcceptKeyModifiers: normalized.fullAcceptKeyModifiers, + fullAcceptKeyLabel: normalized.fullAcceptKeyLabel + ) + } + + return sortedPerAppShortcutOverrides(Array(byBundle.values)) + } + // MARK: - Pure value normalizers (shared with the facade's setters) static func sortedDisabledAppRules( @@ -845,6 +913,18 @@ struct SuggestionSettingsStore { } } + static func sortedPerAppShortcutOverrides( + _ overrides: [PerAppShortcutOverride] + ) -> [PerAppShortcutOverride] { + overrides.sorted { + if $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedSame { + return $0.bundleIdentifier < $1.bundleIdentifier + } + + return $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + } + static func normalizedBundleIdentifier(_ bundleIdentifier: String?) -> String? { guard let bundleIdentifier else { return nil diff --git a/Cotabby/UI/Settings/Components/KeybindRow.swift b/Cotabby/UI/Settings/Components/KeybindRow.swift new file mode 100644 index 00000000..7dd4ed07 --- /dev/null +++ b/Cotabby/UI/Settings/Components/KeybindRow.swift @@ -0,0 +1,60 @@ +import SwiftUI + +/// Shared row chrome for one keybinding. Owns the keycap / Change / Reset / Clear layout and the +/// `KeyRecorderView` recording state hand-off so callers stay focused on what each binding does +/// rather than how it is rendered. Extracted from `ShortcutsPaneView` so the per-app shortcuts +/// section in `AppsPaneView` can reuse the identical chrome (and so the two panes can't drift). +struct KeybindRow: View { + let label: String + let keyCode: CGKeyCode + let modifiers: ShortcutModifierMask + let defaultKeyCode: CGKeyCode + @Binding var isRecording: Bool + let onRecord: (CGKeyCode, ShortcutModifierMask, String) -> Void + /// `nil` hides the Reset button — used by bindings whose only sensible "reset" is unbind, which + /// the Clear button already covers (e.g. the opt-in global-toggle hotkey, and per-app overrides + /// where "reset to global" is a different gesture than the recorder's factory default). + let onReset: (() -> Void)? + let onClear: () -> Void + let clearHelp: String + /// Names the action that already owns a proposed combo so the recorder can block duplicates. + let conflictChecker: (CGKeyCode, ShortcutModifierMask) -> String? + + var body: some View { + HStack(spacing: 8) { + // The same physical-keycap chrome the onboarding keys step renders, so a binding looks + // like the same object on both surfaces. + KeycapView(label: label, fontSize: 12, minWidth: 36) + + if isRecording { + KeyRecorderView( + onKeyRecorded: { keyCode, modifiers, label in + onRecord(keyCode, modifiers, label) + isRecording = false + }, + onCancelled: { isRecording = false }, + conflictChecker: conflictChecker + ) + } else { + Button("Change") { + isRecording = true + } + } + + if let onReset, keyCode != defaultKeyCode || !modifiers.isEmpty { + Button("Reset") { + onReset() + isRecording = false + } + } + + if keyCode != SuggestionSettingsModel.disabledKeyCode { + Button("Clear") { + onClear() + isRecording = false + } + .help(clearHelp) + } + } + } +} diff --git a/Cotabby/UI/Settings/Panes/AppsPaneView.swift b/Cotabby/UI/Settings/Panes/AppsPaneView.swift index 6cf02411..c36d18c5 100644 --- a/Cotabby/UI/Settings/Panes/AppsPaneView.swift +++ b/Cotabby/UI/Settings/Panes/AppsPaneView.swift @@ -14,9 +14,34 @@ struct AppsPaneView: View { /// notifications: the panel is not a live process inspector, and re-rendering as random apps /// open and close would make the chips flicker while the user is mid-task. @State private var runningAppSuggestions: [RunningAppSuggestion] = [] + /// Which (bundle identifier, action) the user is currently re-recording. Single-value state + /// rather than a per-row binding so only one recorder can be active at a time — pressing + /// "Change" on a second row dismisses the first, matching the global Shortcuts pane. + @State private var recordingTarget: RecordingTarget? var body: some View { SettingsPaneScaffold { + Section("Per-App Shortcuts") { + Text("Give a specific app its own accept key. Apps without an override use the " + + "global shortcut from the Shortcuts pane.") + .font(.caption) + .foregroundStyle(.secondary) + + if suggestionSettings.perAppShortcutOverrides.isEmpty { + Text("No per-app shortcuts. Cotabby uses the global accept key everywhere.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + ForEach(suggestionSettings.perAppShortcutOverrides) { override in + perAppOverrideRow(override) + } + } + + Button("Add App…") { + presentPerAppOverridePicker() + } + } + Section("Disabled Apps") { Text("Cotabby won't autocomplete in these apps. Add an app you can't disable from the " + "menu bar, like a launcher that closes the moment it loses focus.") @@ -111,6 +136,281 @@ struct AppsPaneView: View { .buttonStyle(.plain) } + @ViewBuilder + private func perAppOverrideRow(_ override: PerAppShortcutOverride) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + Image(nsImage: icon(forBundleIdentifier: override.bundleIdentifier)) + .resizable() + .frame(width: 28, height: 28) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + Text(override.displayName) + Text(override.bundleIdentifier) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + + Spacer(minLength: 0) + + Button { + suggestionSettings.removePerAppOverride(bundleIdentifier: override.bundleIdentifier) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + .help("Remove this app's overrides. Cotabby will use the global accept keys here.") + } + + perAppBindingRow( + override: override, + action: .acceptWord, + title: "Accept Word", + inheritsHelp: "Uses the global shortcut (\(suggestionSettings.acceptanceKeyLabel)). " + + "Click Change to set a custom key for \(override.displayName)." + ) + perAppBindingRow( + override: override, + action: .acceptEntireSuggestion, + title: "Accept Entire Suggestion", + inheritsHelp: "Uses the global shortcut (\(suggestionSettings.fullAcceptanceKeyLabel)). " + + "Click Change to set a custom key for \(override.displayName)." + ) + } + .padding(.vertical, 4) + } + + /// One (action) row inside one per-app override. Renders the resolved keycap (override or + /// global), then either a Change button (no override yet) or the full KeybindRow chrome + /// (override set, with Reset-to-global as the affordance for clearing back to inheritance). + @ViewBuilder + private func perAppBindingRow( + override: PerAppShortcutOverride, + action: ShortcutAction, + title: String, + inheritsHelp: String + ) -> some View { + let inherits = (action == .acceptWord && !override.hasAcceptOverride) + || (action == .acceptEntireSuggestion && !override.hasFullAcceptOverride) + let recordingBinding = recordingBinding(forBundleIdentifier: override.bundleIdentifier, action: action) + let label = perAppBindingLabel(override: override, action: action) + let keyCode = perAppBindingKeyCode(override: override, action: action) + let modifiers = perAppBindingModifiers(override: override, action: action) + + HStack(alignment: .center, spacing: 12) { + Text(title) + .font(.callout) + .frame(width: 180, alignment: .leading) + + if inherits { + Text("Uses global (\(label))") + .font(.caption) + .foregroundStyle(.secondary) + .help(inheritsHelp) + + if recordingBinding.wrappedValue { + KeyRecorderView( + onKeyRecorded: { keyCode, modifiers, recordedLabel in + applyPerAppBinding( + override: override, + action: action, + keyCode: keyCode, + modifiers: modifiers, + label: recordedLabel + ) + recordingTarget = nil + }, + onCancelled: { recordingTarget = nil }, + conflictChecker: perAppConflictChecker( + bundleIdentifier: override.bundleIdentifier, + action: action + ) + ) + } else { + Button("Change") { + recordingTarget = RecordingTarget( + bundleIdentifier: override.bundleIdentifier, + action: action + ) + } + } + } else { + KeybindRow( + label: label, + keyCode: keyCode, + modifiers: modifiers, + // No factory default for per-app rows — "Reset to global" is the meaningful + // gesture and is wired through `onClear` below. Passing the disabled sentinel + // here ensures the recorder's built-in Reset button never appears. + defaultKeyCode: SuggestionSettingsModel.disabledKeyCode, + isRecording: recordingBinding, + onRecord: { keyCode, modifiers, recordedLabel in + applyPerAppBinding( + override: override, + action: action, + keyCode: keyCode, + modifiers: modifiers, + label: recordedLabel + ) + }, + onReset: nil, + onClear: { clearPerAppBinding(override: override, action: action) }, + clearHelp: "Reset to global — \(override.displayName) will use the global " + + "shortcut again.", + conflictChecker: perAppConflictChecker( + bundleIdentifier: override.bundleIdentifier, + action: action + ) + ) + } + } + } + + private func recordingBinding(forBundleIdentifier bundleIdentifier: String, action: ShortcutAction) -> Binding { + Binding( + get: { + recordingTarget == RecordingTarget(bundleIdentifier: bundleIdentifier, action: action) + }, + set: { isRecording in + if isRecording { + recordingTarget = RecordingTarget(bundleIdentifier: bundleIdentifier, action: action) + } else if recordingTarget == RecordingTarget(bundleIdentifier: bundleIdentifier, action: action) { + recordingTarget = nil + } + } + ) + } + + private func perAppBindingLabel(override: PerAppShortcutOverride, action: ShortcutAction) -> String { + switch action { + case .acceptWord: + return override.acceptKeyLabel ?? suggestionSettings.acceptanceKeyLabel + case .acceptEntireSuggestion: + return override.fullAcceptKeyLabel ?? suggestionSettings.fullAcceptanceKeyLabel + case .toggleTabby: + return suggestionSettings.globalToggleKeyLabel + } + } + + private func perAppBindingKeyCode(override: PerAppShortcutOverride, action: ShortcutAction) -> CGKeyCode { + switch action { + case .acceptWord: + return override.acceptKeyCode ?? suggestionSettings.acceptanceKeyCode + case .acceptEntireSuggestion: + return override.fullAcceptKeyCode ?? suggestionSettings.fullAcceptanceKeyCode + case .toggleTabby: + return suggestionSettings.globalToggleKeyCode + } + } + + private func perAppBindingModifiers( + override: PerAppShortcutOverride, + action: ShortcutAction + ) -> ShortcutModifierMask { + switch action { + case .acceptWord: + return override.acceptKeyModifiers ?? suggestionSettings.acceptanceKeyModifiers + case .acceptEntireSuggestion: + return override.fullAcceptKeyModifiers ?? suggestionSettings.fullAcceptanceKeyModifiers + case .toggleTabby: + return suggestionSettings.globalToggleKeyModifiers + } + } + + private func applyPerAppBinding( + override: PerAppShortcutOverride, + action: ShortcutAction, + keyCode: CGKeyCode, + modifiers: ShortcutModifierMask, + label: String + ) { + switch action { + case .acceptWord: + suggestionSettings.setPerAppAcceptKey( + bundleIdentifier: override.bundleIdentifier, + displayName: override.displayName, + keyCode: keyCode, + modifiers: modifiers, + label: label + ) + case .acceptEntireSuggestion: + suggestionSettings.setPerAppFullAcceptKey( + bundleIdentifier: override.bundleIdentifier, + displayName: override.displayName, + keyCode: keyCode, + modifiers: modifiers, + label: label + ) + case .toggleTabby: + // Per-app toggle is not exposed in this UI; the global toggle is intentionally global + // because its purpose is to disable Cotabby everywhere. + break + } + } + + private func clearPerAppBinding(override: PerAppShortcutOverride, action: ShortcutAction) { + switch action { + case .acceptWord: + suggestionSettings.clearPerAppAcceptKey(bundleIdentifier: override.bundleIdentifier) + case .acceptEntireSuggestion: + suggestionSettings.clearPerAppFullAcceptKey(bundleIdentifier: override.bundleIdentifier) + case .toggleTabby: + break + } + } + + private func perAppConflictChecker( + bundleIdentifier: String, + action: ShortcutAction + ) -> (CGKeyCode, ShortcutModifierMask) -> String? { + { keyCode, modifiers in + suggestionSettings.conflictingPerAppShortcutName( + forBundleIdentifier: bundleIdentifier, + keyCode: keyCode, + modifiers: modifiers, + excluding: action + ) + } + } + + private func presentPerAppOverridePicker() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.application] + panel.allowsMultipleSelection = true + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.directoryURL = URL(fileURLWithPath: "/Applications", isDirectory: true) + panel.prompt = "Add" + panel.message = "Choose apps that should get their own accept shortcut." + + guard panel.runModal() == .OK else { return } + + for url in panel.urls { + guard let metadata = ApplicationBundleMetadata(appURL: url) else { continue } + // Seed with the current global accept key so the row shows up immediately as an + // explicit override; the user can then re-bind it. Without this, an "Add App" with + // no further action would produce an empty row that the sanitizer removes on next + // launch — the user would think the add failed. + suggestionSettings.setPerAppAcceptKey( + bundleIdentifier: metadata.bundleIdentifier, + displayName: metadata.displayName, + keyCode: suggestionSettings.acceptanceKeyCode, + modifiers: suggestionSettings.acceptanceKeyModifiers, + label: suggestionSettings.acceptanceKeyLabel + ) + } + } + + private func icon(forBundleIdentifier bundleIdentifier: String) -> NSImage { + guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) else { + return NSWorkspace.shared.icon(for: .applicationBundle) + } + return NSWorkspace.shared.icon(forFile: appURL.path) + } + @ViewBuilder private func disabledAppRuleRow(_ rule: DisabledApplicationRule) -> some View { HStack(spacing: 12) { @@ -183,6 +483,13 @@ struct AppsPaneView: View { } } +/// Identifies which per-app row+action is currently capturing a keybind. Kept as a single State +/// value in the pane so opening one recorder dismisses any other (matches the Shortcuts pane). +private struct RecordingTarget: Equatable { + let bundleIdentifier: String + let action: ShortcutAction +} + /// One disable-able app surfaced from the running-process list. Captures the icon up front so the /// row doesn't have to hit NSWorkspace again on every redraw. private struct RunningAppSuggestion: Identifiable { diff --git a/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift b/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift index 4965d0cf..c66ebcc3 100644 --- a/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift +++ b/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift @@ -138,59 +138,3 @@ struct ShortcutsPaneView: View { } } } - -/// Shared row chrome for one keybinding. Owns the badge / Change / Reset / Clear layout and the -/// `KeyRecorderView` recording state hand-off so the surrounding pane stays focused on what each -/// binding does rather than how it is rendered. -private struct KeybindRow: View { - let label: String - let keyCode: CGKeyCode - let modifiers: ShortcutModifierMask - let defaultKeyCode: CGKeyCode - @Binding var isRecording: Bool - let onRecord: (CGKeyCode, ShortcutModifierMask, String) -> Void - /// `nil` hides the Reset button — used by bindings whose only sensible "reset" is unbind, which - /// the Clear button already covers (e.g. the opt-in global-toggle hotkey). - let onReset: (() -> Void)? - let onClear: () -> Void - let clearHelp: String - /// Names the action that already owns a proposed combo so the recorder can block duplicates. - let conflictChecker: (CGKeyCode, ShortcutModifierMask) -> String? - - var body: some View { - HStack(spacing: 8) { - // The same physical-keycap chrome the onboarding keys step renders, so a binding looks - // like the same object on both surfaces. - KeycapView(label: label, fontSize: 12, minWidth: 36) - - if isRecording { - KeyRecorderView( - onKeyRecorded: { keyCode, modifiers, label in - onRecord(keyCode, modifiers, label) - isRecording = false - }, - onCancelled: { isRecording = false }, - conflictChecker: conflictChecker - ) - } else { - Button("Change") { - isRecording = true - } - } - - if let onReset, keyCode != defaultKeyCode || !modifiers.isEmpty { - Button("Reset") { - onReset() - isRecording = false - } - } - - if keyCode != SuggestionSettingsModel.disabledKeyCode { - Button("Clear") { - onClear() - isRecording = false - } - } - } - } -} diff --git a/CotabbyTests/InputMonitorTests.swift b/CotabbyTests/InputMonitorTests.swift index 6babd9f6..0fa01d1a 100644 --- a/CotabbyTests/InputMonitorTests.swift +++ b/CotabbyTests/InputMonitorTests.swift @@ -38,7 +38,7 @@ final class InputMonitorTests: XCTestCase { func test_observerTapRoutesAcceptKeyToEmojiObserverWhileCapturingDespiteVisibleSuggestion() { runOnMainActor { let monitor = makeMonitor() - monitor.acceptanceKeyCodeProvider = { 48 } // Tab is the word-accept key + monitor.acceptanceBindingProvider = { (48, []) } // Tab is the word-accept key // Stage both conditions: a visible suggestion owns the accept key, AND an emoji capture is // open. (Set after the capture flag because `setCaptureInterceptionActive` recomputes // ownership; we stage it directly to avoid installing real CGEvent taps in the test host.) @@ -64,7 +64,7 @@ final class InputMonitorTests: XCTestCase { runOnMainActor { let monitor = makeMonitor() monitor.isAcceptTapOwningAcceptKeys = true - monitor.fullAcceptanceKeyCodeProvider = { 50 } + monitor.fullAcceptanceBindingProvider = { (50, []) } var observedKinds: [CapturedInputEvent.Kind] = [] monitor.onEvent = { event in observedKinds.append(event.kind) @@ -81,7 +81,7 @@ final class InputMonitorTests: XCTestCase { func test_observerTapTreatsBarePrintableAcceptKeyAsTypingWhenConsumingTapIsInactive() { runOnMainActor { let monitor = makeMonitor() - monitor.acceptanceKeyCodeProvider = { 0 } + monitor.acceptanceBindingProvider = { (0, []) } var observedKinds: [CapturedInputEvent.Kind] = [] monitor.onEvent = { event in observedKinds.append(event.kind) @@ -149,7 +149,7 @@ final class InputMonitorTests: XCTestCase { func test_acceptTapConsumesBarePrintableBoundKeyWhenCoordinatorAccepts() { runOnMainActor { let monitor = makeMonitor() - monitor.acceptanceKeyCodeProvider = { 0 } + monitor.acceptanceBindingProvider = { (0, []) } var observedKinds: [CapturedInputEvent.Kind] = [] monitor.shouldConsumeAcceptKeyProvider = { true } monitor.onEvent = { event in @@ -167,7 +167,7 @@ final class InputMonitorTests: XCTestCase { func test_acceptTapPassesBarePrintableBoundKeyThroughWhenNoVisibleSuggestionExists() { runOnMainActor { let monitor = makeMonitor() - monitor.acceptanceKeyCodeProvider = { 0 } + monitor.acceptanceBindingProvider = { (0, []) } monitor.shouldConsumeAcceptKeyProvider = { false } monitor.onEvent = { _ in XCTFail("Bare printable shortcuts should only route into acceptance for visible suggestions.") @@ -246,9 +246,8 @@ final class InputMonitorTests: XCTestCase { func test_isWordAcceptKey_matchesOnlyTheConfiguredWordAcceptBinding() { runOnMainActor { let monitor = makeMonitor() - monitor.acceptanceKeyCodeProvider = { 48 } // Tab is the word-accept key - monitor.acceptanceKeyModifiersProvider = { [] } - monitor.fullAcceptanceKeyCodeProvider = { 50 } // backtick is full-accept + monitor.acceptanceBindingProvider = { (48, []) } // Tab is the word-accept key + monitor.fullAcceptanceBindingProvider = { (50, []) } // backtick is full-accept XCTAssertTrue(monitor.isWordAcceptKey(InputMonitorKeyEvent(keyCode: 48))) XCTAssertFalse( diff --git a/CotabbyTests/PerAppShortcutOverrideStoreTests.swift b/CotabbyTests/PerAppShortcutOverrideStoreTests.swift new file mode 100644 index 00000000..1823db90 --- /dev/null +++ b/CotabbyTests/PerAppShortcutOverrideStoreTests.swift @@ -0,0 +1,191 @@ +import XCTest +@testable import Cotabby + +/// Locks down the load/sanitize/persist round-trip for the per-app shortcut overrides store. +/// +/// The store has to be as forgiving as `disabledAppRules`: a fresh install has an absent key +/// (not an empty array); a mutated-back-to-empty store removes the key entirely; whitespace +/// and duplicate bundle ids are normalized on read. These properties matter because the model +/// publishes the array to the InputMonitor's event-time closures — any decode quirk that +/// resurrects a zombie row would resolve to the wrong key on every keystroke. +@MainActor +final class PerAppShortcutOverrideStoreTests: XCTestCase { + + func test_freshInstall_hasNoOverrides() { + let model = makeModel() + XCTAssertTrue(model.perAppShortcutOverrides.isEmpty) + } + + func test_setPerAppAcceptKey_persistsAndRoundTrips() { + let suiteName = makeSuiteName() + let firstModel = makeModel(suiteName: suiteName) + firstModel.setPerAppAcceptKey( + bundleIdentifier: "com.apple.notes", + displayName: "Notes", + keyCode: 49, + modifiers: [.shift], + label: "⇧Space" + ) + + let secondModel = makeModel(suiteName: suiteName) + XCTAssertEqual(secondModel.perAppShortcutOverrides.count, 1) + let restored = try? XCTUnwrap(secondModel.perAppShortcutOverrides.first) + XCTAssertEqual(restored?.bundleIdentifier, "com.apple.notes") + XCTAssertEqual(restored?.acceptKeyCode, 49) + XCTAssertEqual(restored?.acceptKeyModifiers, [.shift]) + XCTAssertEqual(restored?.acceptKeyLabel, "⇧Space") + // The full-accept fields were never set on this app, so they must round-trip as nil so + // the resolver inherits the global binding. + XCTAssertNil(restored?.fullAcceptKeyCode) + XCTAssertNil(restored?.fullAcceptKeyModifiers) + XCTAssertNil(restored?.fullAcceptKeyLabel) + } + + /// Clearing both halves of an override must drop the row entirely so the resolver re-inherits + /// the global on this app. Leaving an empty row would publish a no-op that survives across + /// launches and burns lookup time forever. + func test_clearingBothActions_removesRowEntirely() { + let model = makeModel() + model.setPerAppAcceptKey( + bundleIdentifier: "com.apple.notes", displayName: "Notes", + keyCode: 49, modifiers: [], label: "Space" + ) + model.setPerAppFullAcceptKey( + bundleIdentifier: "com.apple.notes", displayName: "Notes", + keyCode: 36, modifiers: [.command], label: "⌘Return" + ) + XCTAssertEqual(model.perAppShortcutOverrides.count, 1) + + model.clearPerAppAcceptKey(bundleIdentifier: "com.apple.notes") + XCTAssertEqual(model.perAppShortcutOverrides.count, 1, "Row still has full-accept.") + + model.clearPerAppFullAcceptKey(bundleIdentifier: "com.apple.notes") + XCTAssertTrue( + model.perAppShortcutOverrides.isEmpty, + "Both actions cleared → row is gone and resolver inherits the global." + ) + } + + /// `removePerAppOverride` is the user's "Reset to global" affordance: it drops the row no + /// matter what bindings it held. + func test_removePerAppOverride_dropsRowImmediately() { + let model = makeModel() + model.setPerAppAcceptKey( + bundleIdentifier: "com.apple.notes", displayName: "Notes", + keyCode: 49, modifiers: [], label: "Space" + ) + model.removePerAppOverride(bundleIdentifier: "com.apple.notes") + XCTAssertTrue(model.perAppShortcutOverrides.isEmpty) + } + + /// Bundle identifiers with surrounding whitespace must be normalized; otherwise the same + /// app can end up with two rows after a typo-y manual edit of UserDefaults. + func test_setPerAppAcceptKey_normalizesBundleIdentifier() { + let model = makeModel() + model.setPerAppAcceptKey( + bundleIdentifier: " com.apple.notes ", + displayName: "Notes", + keyCode: 49, modifiers: [], label: "Space" + ) + XCTAssertEqual(model.perAppShortcutOverrides.first?.bundleIdentifier, "com.apple.notes") + } + + /// Empty/whitespace bundle ids are rejected outright — a row with no identity could never be + /// resolved. + func test_setPerAppAcceptKey_rejectsEmptyBundleIdentifier() { + let model = makeModel() + model.setPerAppAcceptKey( + bundleIdentifier: " ", + displayName: "Whatever", + keyCode: 49, modifiers: [], label: "Space" + ) + XCTAssertTrue(model.perAppShortcutOverrides.isEmpty) + } + + /// Two writes for the same bundle identifier replace each other rather than producing two + /// rows. Without this property the published array could grow unboundedly across a long + /// session of re-recording the same app. + func test_setPerAppAcceptKey_dedupesByBundleIdentifier() { + let model = makeModel() + model.setPerAppAcceptKey( + bundleIdentifier: "com.apple.notes", displayName: "Notes", + keyCode: 49, modifiers: [], label: "Space" + ) + model.setPerAppAcceptKey( + bundleIdentifier: "com.apple.notes", displayName: "Notes", + keyCode: 36, modifiers: [.command], label: "⌘Return" + ) + XCTAssertEqual(model.perAppShortcutOverrides.count, 1) + XCTAssertEqual(model.perAppShortcutOverrides.first?.acceptKeyCode, 36) + } + + /// A previously-persisted empty row (e.g. a corrupted/hand-edited UserDefault) is dropped on + /// read so the live store never carries a no-op forward. + func test_sanitize_dropsEmptyRowsOnLoad() throws { + let suiteName = makeSuiteName() + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + // Encode a row with all-nil bindings (which the live store never produces) and stash it + // directly to simulate a previous-version persisted blob. + let empty = PerAppShortcutOverride( + bundleIdentifier: "com.apple.notes", + displayName: "Notes", + acceptKeyCode: nil, acceptKeyModifiers: nil, acceptKeyLabel: nil, + fullAcceptKeyCode: nil, fullAcceptKeyModifiers: nil, fullAcceptKeyLabel: nil + ) + let data = try JSONEncoder().encode([empty]) + defaults.set(data, forKey: "cotabbyPerAppShortcutOverrides") + + let model = SuggestionSettingsModel(configuration: .standard, userDefaults: defaults) + XCTAssertTrue( + model.perAppShortcutOverrides.isEmpty, + "Empty row must be sanitized away so it doesn't waste resolver lookups." + ) + } + + /// A row persisted with a *partial* binding — a key code but no modifiers/label, which + /// `ShortcutResolver` would silently ignore — is collapsed to "inherit global" on load so it can't + /// show in Settings as a phantom that never fires. The well-formed full-accept binding on the same + /// row is preserved. + func test_sanitize_collapsesPartialBindingOnLoad() throws { + let suiteName = makeSuiteName() + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let partial = PerAppShortcutOverride( + bundleIdentifier: "com.apple.notes", + displayName: "Notes", + acceptKeyCode: 49, acceptKeyModifiers: nil, acceptKeyLabel: nil, + fullAcceptKeyCode: 36, fullAcceptKeyModifiers: [.command], fullAcceptKeyLabel: "⌘Return" + ) + let data = try JSONEncoder().encode([partial]) + defaults.set(data, forKey: "cotabbyPerAppShortcutOverrides") + + let model = SuggestionSettingsModel(configuration: .standard, userDefaults: defaults) + let restored = try XCTUnwrap(model.perAppShortcutOverrides.first) + // The partial accept binding collapses to inherit-global... + XCTAssertNil(restored.acceptKeyCode) + XCTAssertNil(restored.acceptKeyModifiers) + XCTAssertNil(restored.acceptKeyLabel) + // ...while the well-formed full-accept binding is preserved. + XCTAssertEqual(restored.fullAcceptKeyCode, 36) + XCTAssertEqual(restored.fullAcceptKeyModifiers, [.command]) + XCTAssertEqual(restored.fullAcceptKeyLabel, "⌘Return") + } + + // MARK: - Helpers + + private func makeSuiteName() -> String { + "cotabby.test.perAppOverride.\(UUID().uuidString)" + } + + private func makeModel(suiteName: String? = nil) -> SuggestionSettingsModel { + let name = suiteName ?? makeSuiteName() + let defaults = UserDefaults(suiteName: name)! + // Don't blow away the suite when an explicit suiteName was passed — that's the cross-launch + // test using two model instances against the same defaults. + if suiteName == nil { + defaults.removePersistentDomain(forName: name) + } + return SuggestionSettingsModel(configuration: .standard, userDefaults: defaults) + } +} diff --git a/CotabbyTests/ShortcutConflictTests.swift b/CotabbyTests/ShortcutConflictTests.swift index f8789cf4..eece2676 100644 --- a/CotabbyTests/ShortcutConflictTests.swift +++ b/CotabbyTests/ShortcutConflictTests.swift @@ -71,6 +71,75 @@ final class ShortcutConflictTests: XCTestCase { XCTAssertNil(conflict) } + // MARK: - Per-app override scoping + + /// Per-app conflict scoping: two different apps can bind the same combo without conflict, + /// because the resolver picks based on the frontmost bundle id at event time. Refusing this + /// combo would force the user into a "globally unique" universe that has no reason to exist. + func test_perAppConflict_allowsSameComboAcrossDifferentApps() { + let model = makeModel() + model.setPerAppAcceptKey( + bundleIdentifier: "com.apple.notes", displayName: "Notes", + keyCode: 49, modifiers: [], label: "Space" + ) + + let conflict = model.conflictingPerAppShortcutName( + forBundleIdentifier: "com.apple.terminal", + keyCode: 49, + modifiers: [], + excluding: .acceptWord + ) + XCTAssertNil(conflict, "Different app + same combo is not a conflict.") + } + + /// Within ONE app, binding accept-word to the same combo already held by full-accept must be + /// flagged so the keystroke isn't ambiguous on that app. + func test_perAppConflict_flagsSameAppCrossActionCollision() { + let model = makeModel() + model.setPerAppFullAcceptKey( + bundleIdentifier: "com.apple.notes", displayName: "Notes", + keyCode: 49, modifiers: [], label: "Space" + ) + + let conflict = model.conflictingPerAppShortcutName( + forBundleIdentifier: "com.apple.notes", + keyCode: 49, + modifiers: [], + excluding: .acceptWord + ) + XCTAssertEqual(conflict, "Accept Entire Suggestion") + } + + /// The global toggle is app-spanning, so a per-app accept that collides with it would still + /// be eaten by the toggle tap. Refuse the combo up front. + func test_perAppConflict_flagsGlobalToggleCollision() { + let model = makeModel() + model.setGlobalToggleKey(keyCode: 49, modifiers: [.command, .shift], label: "⌘⇧Space") + let conflict = model.conflictingPerAppShortcutName( + forBundleIdentifier: "com.apple.notes", + keyCode: 49, + modifiers: [.command, .shift], + excluding: .acceptWord + ) + XCTAssertEqual(conflict, "Toggle Tabby") + } + + /// Re-recording the same action on the same app must not be flagged as a self-collision. + func test_perAppConflict_allowsRebindingSameActionToSameKey() { + let model = makeModel() + model.setPerAppAcceptKey( + bundleIdentifier: "com.apple.notes", displayName: "Notes", + keyCode: 49, modifiers: [], label: "Space" + ) + let conflict = model.conflictingPerAppShortcutName( + forBundleIdentifier: "com.apple.notes", + keyCode: 49, + modifiers: [], + excluding: .acceptWord + ) + XCTAssertNil(conflict) + } + // MARK: - Helpers private func makeModel() -> SuggestionSettingsModel { diff --git a/CotabbyTests/ShortcutResolverTests.swift b/CotabbyTests/ShortcutResolverTests.swift new file mode 100644 index 00000000..72d72e86 --- /dev/null +++ b/CotabbyTests/ShortcutResolverTests.swift @@ -0,0 +1,161 @@ +import XCTest +@testable import Cotabby + +/// Locks down `ShortcutResolver`, the pure function that decides which (keyCode, modifiers, label) +/// fires for the frontmost app. The precedence rule lives in the type doc; these tests pin it. +final class ShortcutResolverTests: XCTestCase { + + // MARK: - Accept (word) + + /// No override for the focused app → resolver falls back to the global accept binding. + /// This is the most important property: a fresh install with no per-app rows must behave + /// exactly like the global Shortcuts pane. + func test_acceptBinding_fallsBackToGlobalWhenNoOverride() { + let resolved = ShortcutResolver.acceptBinding( + frontmostBundleIdentifier: "com.apple.notes", + overrides: [], + globalKeyCode: 48, + globalModifiers: [], + globalLabel: "Tab" + ) + XCTAssertEqual(resolved.keyCode, 48) + XCTAssertEqual(resolved.modifiers, []) + XCTAssertEqual(resolved.label, "Tab") + } + + /// A complete override (all three fields set) takes precedence over the global binding. + func test_acceptBinding_usesOverrideWhenPresent() { + let override = PerAppShortcutOverride( + bundleIdentifier: "com.apple.notes", + displayName: "Notes", + acceptKeyCode: 49, + acceptKeyModifiers: [.shift], + acceptKeyLabel: "⇧Space", + fullAcceptKeyCode: nil, + fullAcceptKeyModifiers: nil, + fullAcceptKeyLabel: nil + ) + let resolved = ShortcutResolver.acceptBinding( + frontmostBundleIdentifier: "com.apple.notes", + overrides: [override], + globalKeyCode: 48, + globalModifiers: [], + globalLabel: "Tab" + ) + XCTAssertEqual(resolved.keyCode, 49) + XCTAssertEqual(resolved.modifiers, [.shift]) + XCTAssertEqual(resolved.label, "⇧Space") + } + + /// A *partial* override that has only the full-accept fields set must NOT contaminate the + /// accept-word resolution — the absent accept-word fields fall back to global. + /// This is the heart of the "nil-means-inherit" design and the easiest case to get wrong. + func test_acceptBinding_partialOverrideOnlyAffectsItsOwnAction() { + let override = PerAppShortcutOverride( + bundleIdentifier: "com.apple.notes", + displayName: "Notes", + acceptKeyCode: nil, + acceptKeyModifiers: nil, + acceptKeyLabel: nil, + fullAcceptKeyCode: 50, + fullAcceptKeyModifiers: [.shift], + fullAcceptKeyLabel: "⇧`" + ) + let resolved = ShortcutResolver.acceptBinding( + frontmostBundleIdentifier: "com.apple.notes", + overrides: [override], + globalKeyCode: 48, + globalModifiers: [], + globalLabel: "Tab" + ) + XCTAssertEqual(resolved.keyCode, 48) + XCTAssertEqual(resolved.label, "Tab") + } + + /// The frontmost bundle id determines which override (if any) wins. A different app's + /// override must never leak across. + func test_acceptBinding_doesNotLeakAcrossUnrelatedApps() { + let override = PerAppShortcutOverride( + bundleIdentifier: "com.apple.notes", + displayName: "Notes", + acceptKeyCode: 49, acceptKeyModifiers: [], acceptKeyLabel: "Space", + fullAcceptKeyCode: nil, fullAcceptKeyModifiers: nil, fullAcceptKeyLabel: nil + ) + let resolved = ShortcutResolver.acceptBinding( + frontmostBundleIdentifier: "com.example.other", + overrides: [override], + globalKeyCode: 48, globalModifiers: [], globalLabel: "Tab" + ) + XCTAssertEqual(resolved.keyCode, 48) + } + + /// A nil frontmost bundle id (focus snapshot has no app yet) falls through to the global. + /// Without this property the resolver could misattribute the first keystroke after launch. + func test_acceptBinding_nilBundleIdResolvesToGlobal() { + let override = PerAppShortcutOverride( + bundleIdentifier: "com.apple.notes", displayName: "Notes", + acceptKeyCode: 49, acceptKeyModifiers: [], acceptKeyLabel: "Space", + fullAcceptKeyCode: nil, fullAcceptKeyModifiers: nil, fullAcceptKeyLabel: nil + ) + let resolved = ShortcutResolver.acceptBinding( + frontmostBundleIdentifier: nil, + overrides: [override], + globalKeyCode: 48, globalModifiers: [], globalLabel: "Tab" + ) + XCTAssertEqual(resolved.keyCode, 48) + XCTAssertEqual(resolved.label, "Tab") + } + + /// The disabled sentinel for an override means "no key accepts in this app" — a legitimate + /// user choice the resolver must honor verbatim, NOT silently inherit the global. + func test_acceptBinding_honorsDisabledSentinelOverride() { + let override = PerAppShortcutOverride( + bundleIdentifier: "com.apple.notes", displayName: "Notes", + acceptKeyCode: SuggestionSettingsModel.disabledKeyCode, + acceptKeyModifiers: [], + acceptKeyLabel: SuggestionSettingsModel.disabledKeyLabel, + fullAcceptKeyCode: nil, fullAcceptKeyModifiers: nil, fullAcceptKeyLabel: nil + ) + let resolved = ShortcutResolver.acceptBinding( + frontmostBundleIdentifier: "com.apple.notes", + overrides: [override], + globalKeyCode: 48, globalModifiers: [], globalLabel: "Tab" + ) + XCTAssertEqual(resolved.keyCode, SuggestionSettingsModel.disabledKeyCode) + } + + // MARK: - Full-accept + + /// Mirror property for the full-accept action: override wins when present, otherwise global. + func test_fullAcceptBinding_usesOverrideWhenPresent() { + let override = PerAppShortcutOverride( + bundleIdentifier: "com.apple.notes", displayName: "Notes", + acceptKeyCode: nil, acceptKeyModifiers: nil, acceptKeyLabel: nil, + fullAcceptKeyCode: 36, fullAcceptKeyModifiers: [.command], fullAcceptKeyLabel: "⌘Return" + ) + let resolved = ShortcutResolver.fullAcceptBinding( + frontmostBundleIdentifier: "com.apple.notes", + overrides: [override], + globalKeyCode: 50, globalModifiers: [], globalLabel: "`" + ) + XCTAssertEqual(resolved.keyCode, 36) + XCTAssertEqual(resolved.modifiers, [.command]) + XCTAssertEqual(resolved.label, "⌘Return") + } + + /// And the inverse: only the accept-word override is set → full-accept still inherits global. + func test_fullAcceptBinding_inheritsGlobalWhenOnlyAcceptOverrideIsSet() { + let override = PerAppShortcutOverride( + bundleIdentifier: "com.apple.notes", displayName: "Notes", + acceptKeyCode: 49, acceptKeyModifiers: [], acceptKeyLabel: "Space", + fullAcceptKeyCode: nil, fullAcceptKeyModifiers: nil, fullAcceptKeyLabel: nil + ) + let resolved = ShortcutResolver.fullAcceptBinding( + frontmostBundleIdentifier: "com.apple.notes", + overrides: [override], + globalKeyCode: 50, globalModifiers: [], globalLabel: "`" + ) + XCTAssertEqual(resolved.keyCode, 50) + XCTAssertEqual(resolved.label, "`") + } +}