From a6c0dfc4079a77a0d4bd158b698efa2dafee24ac Mon Sep 17 00:00:00 2001 From: Senad Date: Fri, 2 Oct 2026 11:22:09 +0200 Subject: [PATCH 1/4] Add opt-in double-tap of Accept Word to accept the entire suggestion The first press still inserts one word immediately, so single-word acceptance gets no added latency; a second press of the same key within 300 ms on the same suggestion inserts everything that remains. Any other key between the presses, a regenerated suggestion, or a correction session cancels the pair. The queued post-exhaustion accept stays a plain one-word accept, so a buffered Tab never commits an unseen continuation wholesale. The timing rule lives in the pure DoubleTapAcceptanceState; the setting travels through the shortcuts domain and the settings snapshot and is off by default. Shortcuts gains a "Double-Tap to Accept All" toggle. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017xvrRyxDAooaBCvNfZiAA7 --- Cotabby.xcodeproj/project.pbxproj | 14 +++ .../SuggestionCoordinator+Acceptance.swift | 33 +++++++ .../SuggestionCoordinator+Input.swift | 8 +- .../Suggestion/SuggestionCoordinator.swift | 4 + .../Settings/SuggestionSettingsData.swift | 9 ++ .../Settings/SuggestionSettingsModel.swift | 30 +++++-- .../Suggestion/SuggestionEngineModels.swift | 4 + .../Settings/SuggestionSettingsStore.swift | 14 ++- .../Acceptance/DoubleTapAcceptanceState.swift | 65 ++++++++++++++ .../UI/Settings/Panes/ShortcutsPaneView.swift | 19 ++++ Cotabby/UI/Settings/SettingsIndex.swift | 9 +- .../SuggestionCoordinatorDoubleTapTests.swift | 86 +++++++++++++++++++ .../DoubleTapAcceptanceStateTests.swift | 66 ++++++++++++++ .../TestSupport/CotabbyTestFixtures.swift | 6 +- 14 files changed, 356 insertions(+), 11 deletions(-) create mode 100644 Cotabby/Support/Suggestion/Acceptance/DoubleTapAcceptanceState.swift create mode 100644 CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorDoubleTapTests.swift create mode 100644 CotabbyTests/Support/Suggestion/Acceptance/DoubleTapAcceptanceStateTests.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index ddd90ac8..4cba2efa 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -387,6 +387,7 @@ 62B45766E8A2827E31AFD356 /* ScreenFrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B49898B64D4DB50736CBC46 /* ScreenFrameReader.swift */; }; 62DBCF429B7F464A6B467725 /* OnboardingFeatureShowcase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 926B332E7B4CFEE42C4CAA75 /* OnboardingFeatureShowcase.swift */; }; 632C9A837EC1949B098A0BD9 /* SettingsNavigationModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2CAE5772E4C748960219DA0 /* SettingsNavigationModelTests.swift */; }; + 632F4F9A37C801C8ED15D703 /* DoubleTapAcceptanceState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7380A4057E7BC21F0977A07 /* DoubleTapAcceptanceState.swift */; }; 635026FA5A2CDD7CB29AC20F /* LlamaSuggestionEnginePrewarmTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BCF5B9CC0B74A171519B6D8 /* LlamaSuggestionEnginePrewarmTests.swift */; }; 644EEF959D07D54CC779BBF6 /* SettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3350EDE01ED5125520C79D53 /* SettingsCoordinator.swift */; }; 6471AEB3C75714538D78DBA3 /* SuggestionSettingsModelDisabledAppsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC07B7CE715C69A63E6123DD /* SuggestionSettingsModelDisabledAppsTests.swift */; }; @@ -411,6 +412,7 @@ 6952E5B030740443C684F263 /* SettingsSearchRanker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57B29DAC600626783649475A /* SettingsSearchRanker.swift */; }; 6955C3A4D7AB3EEF7FA7C469 /* InputSuppressionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D1F9CEBAB0F330F8E7B61D8 /* InputSuppressionController.swift */; }; 696514ADA5A7D085C333BE35 /* TextLayoutCaretEstimatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 961A47BBFDBF7C233BC4DDC4 /* TextLayoutCaretEstimatorTests.swift */; }; + 69651DED6B023F32196500CF /* DoubleTapAcceptanceStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F86E2E28EBDFA1FEBE6CC90A /* DoubleTapAcceptanceStateTests.swift */; }; 698E568874BCAD20BBE82875 /* SuggestionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E76C76AEE756F43787EEECE /* SuggestionCoordinator.swift */; }; 6A36F49DE520844FC971F704 /* SuggestionOverlayStabilityGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D10022BEFABAD14D16EBF2 /* SuggestionOverlayStabilityGateTests.swift */; }; 6A3C43437DEECBCF935C1F12 /* ClipboardContextProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4B70455CA75A08FAD0F7409 /* ClipboardContextProvider.swift */; }; @@ -686,6 +688,7 @@ B1E93ED4D7C0B9BB77F95ADC /* CompositionInputModeClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9316289DD11A72D3EAC0A929 /* CompositionInputModeClassifier.swift */; }; B2A4C1D06FE4317F32BD8E39 /* InsertionStrategySelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D239BFA9C9061C04956C591 /* InsertionStrategySelector.swift */; }; B2A6C1474B9F3A6E26F488C9 /* UnitConversionEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6B0048AAD1B2A4EA09DCEE0 /* UnitConversionEvaluator.swift */; }; + B327BA8266E575FFF30AE288 /* DoubleTapAcceptanceState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7380A4057E7BC21F0977A07 /* DoubleTapAcceptanceState.swift */; }; B3706BDA93A1D8790CE8CC4A /* EmojiSynonymCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4DB988251D2B907F316A3B8 /* EmojiSynonymCatalogTests.swift */; }; B393275E40EA9EB901E20820 /* AppSurfaceClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A45844F33B55A3A58E9C71 /* AppSurfaceClassifier.swift */; }; B3E9C077CF064F2DEC3E22B8 /* ModelAndPresentationValueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BA1D28712B262ABD96F8399 /* ModelAndPresentationValueTests.swift */; }; @@ -977,6 +980,7 @@ 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 */; }; + FEF7B8713C48B4B9FB2DB399 /* SuggestionCoordinatorDoubleTapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E6587C61CBD8D64E852EBF /* SuggestionCoordinatorDoubleTapTests.swift */; }; FF46903861BA67AED8C24EF8 /* HuggingFaceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C736C539D4C97B5664E498BE /* HuggingFaceModels.swift */; }; FF4C26309DCF9532C9283284 /* EmojiRecentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF02469BF40CC6BAF2675E37 /* EmojiRecentsTests.swift */; }; FF65453AC3797BBF2F39EBBB /* SuggestionInteractionStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45207F82F22BAE1E89286E4B /* SuggestionInteractionStateTests.swift */; }; @@ -1234,6 +1238,7 @@ 655F56C230F45CAC13EEE59C /* SuggestionTextColorCodecTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionTextColorCodecTests.swift; sourceTree = ""; }; 6586D6D735F287F43D42520A /* DeviceInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfo.swift; sourceTree = ""; }; 65AF0FF1CE97A0761ADAFEB8 /* InlineCommandCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineCommandCoordinatorTests.swift; sourceTree = ""; }; + 65E6587C61CBD8D64E852EBF /* SuggestionCoordinatorDoubleTapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionCoordinatorDoubleTapTests.swift; sourceTree = ""; }; 65E97C4BFAF5EAF6792170AC /* FocusSnapshotExternalApplicationIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSnapshotExternalApplicationIdentityTests.swift; sourceTree = ""; }; 665A55CA2E7975CBFC63B4E2 /* InkBaselineAnalyzer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InkBaselineAnalyzer.swift; sourceTree = ""; }; 66B5FF2D1DCC846AABC134CF /* MenuBarRecoveryPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarRecoveryPolicyTests.swift; sourceTree = ""; }; @@ -1395,6 +1400,7 @@ A5B09274BE9500E3C41C18B2 /* HostZoomLadder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostZoomLadder.swift; sourceTree = ""; }; A66389B78743ABACE7CD4D82 /* SuggestionWorkController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionWorkController.swift; sourceTree = ""; }; A6B0048AAD1B2A4EA09DCEE0 /* UnitConversionEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitConversionEvaluator.swift; sourceTree = ""; }; + A7380A4057E7BC21F0977A07 /* DoubleTapAcceptanceState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleTapAcceptanceState.swift; sourceTree = ""; }; A73D95F2BF5CA59705653931 /* PromptSectionBudgetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptSectionBudgetTests.swift; sourceTree = ""; }; A7FBE9101AF744A4D205B32D /* EmojiSynonymCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiSynonymCatalog.swift; sourceTree = ""; }; A854CAFB1F557BC4CAED8819 /* VisualContextCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualContextCoordinator.swift; sourceTree = ""; }; @@ -1602,6 +1608,7 @@ F7014E6E969FD4579028BAC4 /* TypingCadence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingCadence.swift; sourceTree = ""; }; F7F3F983901FC3648076B23D /* MacroReferenceSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroReferenceSheet.swift; sourceTree = ""; }; F7F727A325ECE687B32CDC38 /* Aria2ProvisionerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Aria2ProvisionerTests.swift; sourceTree = ""; }; + F86E2E28EBDFA1FEBE6CC90A /* DoubleTapAcceptanceStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleTapAcceptanceStateTests.swift; sourceTree = ""; }; F952E54538EF82D1B1E672D8 /* WelcomePermissionStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomePermissionStepView.swift; sourceTree = ""; }; FA4364402391C8A8D9B3FCEC /* PhrasePredictionScoring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhrasePredictionScoring.swift; sourceTree = ""; }; FA97ED2A10499771F9C32B64 /* FoundationModelAvailabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelAvailabilityService.swift; sourceTree = ""; }; @@ -1744,6 +1751,7 @@ 022734DBBC384802C7CDB97D /* SuggestionConversationIsolationTests.swift */, 4E1EBD88A434C325CC6EFBDF /* SuggestionCoordinatorAcceptanceTests.swift */, 5830E18C10728B7AF1DB2CCF /* SuggestionCoordinatorContinuityTests.swift */, + 65E6587C61CBD8D64E852EBF /* SuggestionCoordinatorDoubleTapTests.swift */, 694D2DD5826C379BA4A6C775 /* SuggestionCoordinatorInputTests.swift */, 25C4859A89B59938DB905377 /* SuggestionCoordinatorLifecycleTests.swift */, EAA290A7B1BF37F3D125DC8F /* SuggestionCoordinatorPredictionTests.swift */, @@ -2391,6 +2399,7 @@ 6C207CE2D46ADA4661468CBA /* Acceptance */ = { isa = PBXGroup; children = ( + F86E2E28EBDFA1FEBE6CC90A /* DoubleTapAcceptanceStateTests.swift */, 35AA2C8F42B510F013D86C3C /* InsertedTextAdvanceTests.swift */, 67EA130AF8073D90CA89AAC6 /* InsertionSafetyGateTests.swift */, 12BB15F7F2E595129B190E89 /* InsertionStrategySelectorTests.swift */, @@ -3126,6 +3135,7 @@ BFDD0073B68616AB220BE0AE /* Acceptance */ = { isa = PBXGroup; children = ( + A7380A4057E7BC21F0977A07 /* DoubleTapAcceptanceState.swift */, 6C8C1A4EC935BB837432BAF2 /* InsertedTextAdvance.swift */, 8E89746E8CE7E9487337EE6F /* InsertionSafetyGate.swift */, 0D239BFA9C9061C04956C591 /* InsertionStrategySelector.swift */, @@ -3904,6 +3914,7 @@ 0A9F8963DABEDBFFD8D5A239 /* DeepGeometryWalkThrottle.swift in Sources */, 1C30344A181A9F6EBC4A9C8F /* DeviceInfo.swift in Sources */, F3FC8AEB8504F7D1BB1C6443 /* DisplayCoordinateConverter.swift in Sources */, + B327BA8266E575FFF30AE288 /* DoubleTapAcceptanceState.swift in Sources */, 936F87925A22DAC58525B60F /* DownloadFileRescuer.swift in Sources */, 6C4EFF98C9D606B3BE35D2A6 /* DownloadOutcomeClassifier.swift in Sources */, EDD9AE89E16AB1EF47D06269 /* DownloadableModelCatalogView.swift in Sources */, @@ -4231,6 +4242,7 @@ FECBB40B75788B449719170B /* DeepGeometryWalkThrottle.swift in Sources */, D3909BBB80781B0CE1982A8E /* DeviceInfo.swift in Sources */, FC4706CDB470537FE069FF22 /* DisplayCoordinateConverter.swift in Sources */, + 632F4F9A37C801C8ED15D703 /* DoubleTapAcceptanceState.swift in Sources */, F26DD9AEAFECAAAD13F3A0A0 /* DownloadFileRescuer.swift in Sources */, 2089CC6E33B24D5CD3DD1E57 /* DownloadOutcomeClassifier.swift in Sources */, 5B3FAD231ECFC7539C33FC1E /* DownloadableModelCatalogView.swift in Sources */, @@ -4542,6 +4554,7 @@ A6A24D462E6C5B8A4963145D /* DeepGeometryWalkThrottleTests.swift in Sources */, 4A460D26B773DC213B0E6471 /* DeviceInfoTests.swift in Sources */, 53BA549ABCA25E9F44DF6341 /* DisplayCoordinateConverterTests.swift in Sources */, + 69651DED6B023F32196500CF /* DoubleTapAcceptanceStateTests.swift in Sources */, E5022E7BF72591C0FEE80CD7 /* DownloadFileRescuerTests.swift in Sources */, C7A48F93691943EF2EEBD775 /* DownloadOutcomeClassifierTests.swift in Sources */, 311A4FDEFFEB67B337FEE47A /* EmojiCatalogMatcherTests.swift in Sources */, @@ -4687,6 +4700,7 @@ 764F157C87F3155FBE698A3A /* SuggestionConversationIsolationTests.swift in Sources */, BC0E4576CEA12EA3600D5A0A /* SuggestionCoordinatorAcceptanceTests.swift in Sources */, B6C2570DB8D17C8626D50F76 /* SuggestionCoordinatorContinuityTests.swift in Sources */, + FEF7B8713C48B4B9FB2DB399 /* SuggestionCoordinatorDoubleTapTests.swift in Sources */, 1DBFE5862FEFD38793600B01 /* SuggestionCoordinatorInputTests.swift in Sources */, 03175AAC8AA2789681B18DFB /* SuggestionCoordinatorLifecycleTests.swift in Sources */, 0A87210653A6C00EA62DC005 /* SuggestionCoordinatorPredictionTests.swift in Sources */, diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Acceptance.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Acceptance.swift index c35159cd..e1b03218 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Acceptance.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Acceptance.swift @@ -19,6 +19,39 @@ extension SuggestionCoordinator { acceptSuggestion(fullText: true, keyName: "full-accept") } + /// Entry point for a real press of the Accept Word key. + /// + /// With double-tap enabled, the first press still accepts one word immediately, and a second + /// press within `DoubleTapAcceptanceState.window` on the same suggestion accepts what remains. + /// Promoting the second press instead of delaying the first keeps single-word acceptance as fast + /// as before; the pair still commits the whole suggestion. The queued post-exhaustion accept + /// calls `acceptCurrentSuggestion` directly, so a Tab buffered during regeneration never turns + /// into an accept-everything of a continuation the user has not seen yet. + func acceptForWordAcceptKeyPress() -> Bool { + guard settingsSnapshot.doubleTapAcceptsEntireSuggestion else { + doubleTapAcceptanceState.reset() + return acceptCurrentSuggestion() + } + + let now = ProcessInfo.processInfo.systemUptime + if let session = interactionState.activeSession, !session.kind.isCorrection, + doubleTapAcceptanceState.consumeDoubleTap(of: .init(session: session), at: now) { + return acceptSuggestion(fullText: true, keyName: "double-tap") + } + + let accepted = acceptCurrentSuggestion() + // Arm only when this press left a continuation with text still to accept. An exhausted + // suggestion hands Tab to the post-exhaustion window instead, and a correction commits as + // a unit, so neither has a "rest" for a second press to take. + if accepted, let advanced = interactionState.activeSession, !advanced.kind.isCorrection, + !advanced.isExhausted { + doubleTapAcceptanceState.recordWordAccept(of: .init(session: advanced), at: now) + } else { + doubleTapAcceptanceState.reset() + } + return accepted + } + /// Shared acceptance path used by both word-by-word and full acceptance. private func acceptSuggestion( fullText: Bool, diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift index c0730e75..3ccff678 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift @@ -286,8 +286,14 @@ extension SuggestionCoordinator { completion: session.remainingText, at: ProcessInfo.processInfo.systemUptime) } + // Anything between two Accept Word presses (typing, navigation, the full-accept key) means + // they are not one double tap, even if both land inside the timing window. + if event.kind != .acceptance { + doubleTapAcceptanceState.reset() + } + if event.kind == .acceptance { - return acceptCurrentSuggestion() + return acceptForWordAcceptKeyPress() } if event.kind == .fullAcceptance { diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift index 227073e9..41724439 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift @@ -174,6 +174,10 @@ final class SuggestionCoordinator: ObservableObject { /// coordinator continues to own the timer and input-monitor effects around these transitions. var postExhaustionAcceptanceState = PostExhaustionAcceptanceState() + /// Pure state for recognizing a quick second press of the Accept Word key. Only real key presses + /// feed it; the queued post-exhaustion accept stays a plain one-word accept. + var doubleTapAcceptanceState = DoubleTapAcceptanceState() + init( permissionManager: any SuggestionPermissionProviding, lowPowerModeProvider: any SuggestionLowPowerModeProviding, diff --git a/Cotabby/Models/Settings/SuggestionSettingsData.swift b/Cotabby/Models/Settings/SuggestionSettingsData.swift index d8241b99..067eef5b 100644 --- a/Cotabby/Models/Settings/SuggestionSettingsData.swift +++ b/Cotabby/Models/Settings/SuggestionSettingsData.swift @@ -111,6 +111,10 @@ struct SuggestionShortcutSettings: Equatable { var fullAcceptance: SuggestionShortcutBindingSettings var globalToggle: SuggestionShortcutBindingSettings var perAppOverrides: [PerAppShortcutOverride] + /// When true, pressing the Accept Word key twice in quick succession accepts the whole + /// suggestion. It modifies the existing word-accept binding rather than adding a new binding, so + /// it applies to per-app accept overrides as well. + var doubleTapAcceptsEntireSuggestion: Bool } /// Pure domain representation of every durable suggestion preference. @@ -469,4 +473,9 @@ extension SuggestionSettingsData { get { shortcuts.perAppOverrides } set { shortcuts.perAppOverrides = newValue } } + + var doubleTapAcceptsEntireSuggestion: Bool { + get { shortcuts.doubleTapAcceptsEntireSuggestion } + set { shortcuts.doubleTapAcceptsEntireSuggestion = newValue } + } } diff --git a/Cotabby/Models/Settings/SuggestionSettingsModel.swift b/Cotabby/Models/Settings/SuggestionSettingsModel.swift index 61644320..d259f5c6 100644 --- a/Cotabby/Models/Settings/SuggestionSettingsModel.swift +++ b/Cotabby/Models/Settings/SuggestionSettingsModel.swift @@ -156,6 +156,8 @@ final class SuggestionSettingsModel: ObservableObject { /// 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] + /// Whether a quick second press of the Accept Word key accepts the rest of the suggestion. + @Published private(set) var doubleTapAcceptsEntireSuggestion: Bool @Published private(set) var acceptanceGranularity: AcceptanceGranularity @Published private(set) var isPowerBasedModelSwitchingEnabled: Bool @Published private(set) var batteryEngine: SuggestionEngineKind @@ -284,6 +286,7 @@ final class SuggestionSettingsModel: ObservableObject { globalToggleKeyModifiers = data.globalToggleKeyModifiers globalToggleKeyLabel = data.globalToggleKeyLabel perAppShortcutOverrides = data.perAppShortcutOverrides + doubleTapAcceptsEntireSuggestion = data.doubleTapAcceptsEntireSuggestion acceptanceGranularity = data.acceptanceGranularity isPowerBasedModelSwitchingEnabled = data.isPowerBasedModelSwitchingEnabled batteryEngine = data.batteryEngine @@ -367,6 +370,7 @@ final class SuggestionSettingsModel: ObservableObject { globalToggleKeyModifiers = data.globalToggleKeyModifiers globalToggleKeyLabel = data.globalToggleKeyLabel perAppShortcutOverrides = data.perAppShortcutOverrides + doubleTapAcceptsEntireSuggestion = data.doubleTapAcceptsEntireSuggestion acceptanceGranularity = data.acceptanceGranularity isPowerBasedModelSwitchingEnabled = data.isPowerBasedModelSwitchingEnabled batteryEngine = data.batteryEngine @@ -480,7 +484,8 @@ final class SuggestionSettingsModel: ObservableObject { modifiers: globalToggleKeyModifiers, label: globalToggleKeyLabel ), - perAppOverrides: perAppShortcutOverrides + perAppOverrides: perAppShortcutOverrides, + doubleTapAcceptsEntireSuggestion: doubleTapAcceptsEntireSuggestion ) ) } @@ -521,7 +526,8 @@ final class SuggestionSettingsModel: ObservableObject { suppressCompletionsOnTypo: settings.correction.suppressCompletionsOnTypo, offerTypoCorrections: settings.correction.offerTypoCorrections, enabledSpellingDictionaryCodes: settings.correction.enabledSpellingDictionaryCodes, - automaticallyFixTypos: settings.correction.automaticallyFixTypos + automaticallyFixTypos: settings.correction.automaticallyFixTypos, + doubleTapAcceptsEntireSuggestion: settings.shortcuts.doubleTapAcceptsEntireSuggestion ) } @@ -903,6 +909,14 @@ final class SuggestionSettingsModel: ObservableObject { ) } + func setDoubleTapAcceptsEntireSuggestion(_ enabled: Bool) { + guard doubleTapAcceptsEntireSuggestion != enabled else { + return + } + doubleTapAcceptsEntireSuggestion = enabled + store.saveDoubleTapAcceptsEntireSuggestion(enabled) + } + func setAutoAcceptTrailingPunctuation(_ enabled: Bool) { guard autoAcceptTrailingPunctuation != enabled else { return @@ -1647,10 +1661,13 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { // top-level setting gets layered above via another `CombineLatest`. `extendedContext` joins // alongside `acceptanceGranularity` here for the same reason. The three custom-range fields // travel together as a single tuple so they only cost one slot in this outer layer. - let customRange = Publishers.CombineLatest3( + // The double-tap toggle rides in this slot's last free input; it is unrelated to the range + // but costs no extra layer of nesting. + let customRange = Publishers.CombineLatest4( $isUsingCustomWordCountRange, $customWordCountLowWords, - $customWordCountHighWords + $customWordCountHighWords, + $doubleTapAcceptsEntireSuggestion ) // The outer `CombineLatest4` is full, so these settings share its grouped publisher slot. return Publishers.CombineLatest4( @@ -1674,7 +1691,7 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { let (debounce, focusPoll, generationToggles, acceptToggles) = timing let (multiLine, suggestWithinWords, showFollowingWords) = generationToggles let (autoAcceptPunctuation, addSpaceAfterAccept, streamWhileGenerating, predictAhead) = acceptToggles - let (isCustomActive, customLow, customHigh) = customRangeTuple + let (isCustomActive, customLow, customHigh, doubleTapAcceptsEntireSuggestion) = customRangeTuple let (extendedContext, suggestInIntegratedTerminals, surfaceContextEnabled, lowPowerModeAutoDisableEnabled) = extendedContextTuple return SuggestionSettingsSnapshot( @@ -1708,7 +1725,8 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { suppressCompletionsOnTypo: suppressOnTypo, offerTypoCorrections: offerCorrections, enabledSpellingDictionaryCodes: enabledSpellingDictionaryCodes, - automaticallyFixTypos: automaticallyFixTypos + automaticallyFixTypos: automaticallyFixTypos, + doubleTapAcceptsEntireSuggestion: doubleTapAcceptsEntireSuggestion ) } .removeDuplicates() diff --git a/Cotabby/Models/Suggestion/SuggestionEngineModels.swift b/Cotabby/Models/Suggestion/SuggestionEngineModels.swift index 4ccfd538..df7299c4 100644 --- a/Cotabby/Models/Suggestion/SuggestionEngineModels.swift +++ b/Cotabby/Models/Suggestion/SuggestionEngineModels.swift @@ -186,6 +186,10 @@ struct SuggestionSettingsSnapshot: Equatable, Sendable { /// commits the misspelled word with Space. The word boundary prevents pauses in unfinished words /// from triggering destructive edits. let automaticallyFixTypos: Bool + /// When true, a second press of the Accept Word key within `DoubleTapAcceptanceState.window` + /// accepts the rest of the suggestion. Travels in the snapshot so the acceptance path reads the + /// live value without subscribing to the settings model. + let doubleTapAcceptsEntireSuggestion: Bool /// Single chokepoint that picks between the preset's range and the user's custom range. /// Every downstream consumer (token-budget math, prompt-instruction text, UI labels in the diff --git a/Cotabby/Support/Settings/SuggestionSettingsStore.swift b/Cotabby/Support/Settings/SuggestionSettingsStore.swift index 2f724e93..c7172a4a 100644 --- a/Cotabby/Support/Settings/SuggestionSettingsStore.swift +++ b/Cotabby/Support/Settings/SuggestionSettingsStore.swift @@ -116,6 +116,7 @@ struct SuggestionSettingsStore { private static let pauseStateDefaultsKey = "cotabbySuggestionPauseState" private static let disabledAppRulesDefaultsKey = "cotabbyDisabledAppRules" private static let perAppShortcutOverridesDefaultsKey = "cotabbyPerAppShortcutOverrides" + private static let doubleTapAcceptsEntireSuggestionDefaultsKey = "cotabbyDoubleTapAcceptsEntireSuggestion" private static let suggestInIntegratedTerminalsDefaultsKey = "cotabbySuggestInIntegratedTerminals" private static let showCaretIndicatorDefaultsKey = "cotabbyShowCaretIndicator" private static let selectedIndicatorModeDefaultsKey = "cotabbySelectedIndicatorMode" @@ -209,6 +210,7 @@ struct SuggestionSettingsStore { pauseStateDefaultsKey, disabledAppRulesDefaultsKey, perAppShortcutOverridesDefaultsKey, + doubleTapAcceptsEntireSuggestionDefaultsKey, suggestInIntegratedTerminalsDefaultsKey, showCaretIndicatorDefaultsKey, selectedIndicatorModeDefaultsKey, @@ -294,6 +296,10 @@ struct SuggestionSettingsStore { let resolvedPauseState = persistedPauseState?.activeState() let resolvedDisabledAppRules = loadDisabledAppRules() let resolvedPerAppShortcutOverrides = loadPerAppShortcutOverrides() + // Off by default: a fast second Tab has always accepted one more word, and turning it into a + // whole-suggestion accept is an opt-in change to that muscle memory. + let resolvedDoubleTapAcceptsEntireSuggestion = + userDefaults.object(forKey: Self.doubleTapAcceptsEntireSuggestionDefaultsKey) as? Bool ?? false let resolvedShowIndicator: Bool = if let modeString = userDefaults.string( forKey: Self.selectedIndicatorModeDefaultsKey ) { @@ -650,7 +656,8 @@ struct SuggestionSettingsStore { modifiers: resolvedGlobalToggleKeyModifiers, label: resolvedGlobalToggleKeyLabel ), - perAppOverrides: resolvedPerAppShortcutOverrides + perAppOverrides: resolvedPerAppShortcutOverrides, + doubleTapAcceptsEntireSuggestion: resolvedDoubleTapAcceptsEntireSuggestion ) ) @@ -722,6 +729,7 @@ struct SuggestionSettingsStore { label: data.globalToggleKeyLabel ) savePerAppShortcutOverrides(data.perAppShortcutOverrides) + saveDoubleTapAcceptsEntireSuggestion(data.doubleTapAcceptsEntireSuggestion) saveAcceptanceGranularity(data.acceptanceGranularity) savePowerBasedModelSwitchingEnabled(data.isPowerBasedModelSwitchingEnabled) saveBatteryEngine(data.batteryEngine) @@ -786,6 +794,10 @@ struct SuggestionSettingsStore { } /// Removing the key for an empty list keeps reset state identical to a fresh install. + func saveDoubleTapAcceptsEntireSuggestion(_ enabled: Bool) { + userDefaults.set(enabled, forKey: Self.doubleTapAcceptsEntireSuggestionDefaultsKey) + } + func savePerAppShortcutOverrides(_ overrides: [PerAppShortcutOverride]) { guard !overrides.isEmpty else { userDefaults.removeObject(forKey: Self.perAppShortcutOverridesDefaultsKey) diff --git a/Cotabby/Support/Suggestion/Acceptance/DoubleTapAcceptanceState.swift b/Cotabby/Support/Suggestion/Acceptance/DoubleTapAcceptanceState.swift new file mode 100644 index 00000000..95cda9b7 --- /dev/null +++ b/Cotabby/Support/Suggestion/Acceptance/DoubleTapAcceptanceState.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Pure state machine that recognizes a double press of the Accept Word key. +/// +/// When the user enables "double-tap to accept the entire suggestion", the first press still accepts +/// one word immediately (no added latency on the common single-press path), and a second press of the +/// same key inside `window` accepts everything that remains. The net effect of the pair is that the +/// whole suggestion lands in the field. +/// +/// The coordinator owns one instance for its lifetime and supplies the clock and the session token. +/// Keeping the timing rule here, instead of inline in the coordinator, makes the invariants testable: +/// a pair only counts on the *same* suggestion, inside the window, and each pair is consumed once so a +/// triple press cannot promote twice. +struct DoubleTapAcceptanceState: Equatable { + /// Identifies one suggestion across its word-by-word advancement. Accepting a word keeps the + /// session's generation and full text and only moves its consumed count, so these two values + /// stay stable between the two presses, while a regenerated suggestion changes the generation. + struct SessionToken: Equatable { + let generation: UInt64 + let fullText: String + } + + /// Longest gap between the two presses that still counts as a double tap. Shorter than the + /// system double-click interval (0.5 s by default) so deliberate one-word-at-a-time Tabbing at a + /// normal pace keeps accepting single words. + static let window: TimeInterval = 0.3 + + private struct PendingPress: Equatable { + let session: SessionToken + let uptime: TimeInterval + } + + private var pendingPress: PendingPress? + + var hasPendingPress: Bool { + pendingPress != nil + } + + /// Remembers a first press that accepted a word and left the suggestion with text remaining. + mutating func recordWordAccept(of session: SessionToken, at uptime: TimeInterval) { + pendingPress = PendingPress(session: session, uptime: uptime) + } + + /// Returns true when this press completes a double tap. Always clears the pending press, so a + /// missed pair does not linger and a completed pair cannot be reused by a third press. + mutating func consumeDoubleTap(of session: SessionToken, at uptime: TimeInterval) -> Bool { + defer { pendingPress = nil } + guard let pendingPress, pendingPress.session == session else { + return false + } + let elapsed = uptime - pendingPress.uptime + return elapsed >= 0 && elapsed <= Self.window + } + + /// Cancels a pending first press, e.g. when the user types or moves the caret between presses. + mutating func reset() { + pendingPress = nil + } +} + +extension DoubleTapAcceptanceState.SessionToken { + init(session: ActiveSuggestionSession) { + self.init(generation: session.baseContext.generation, fullText: session.fullText) + } +} diff --git a/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift b/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift index cccd8d79..5c5b0c5b 100644 --- a/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift +++ b/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift @@ -104,6 +104,18 @@ struct ShortcutsPaneView: View { } .settingsItem(.acceptEntireSuggestion) + // A modifier on the Accept Word key rather than its own binding: the first press still + // takes a word immediately, and a quick second press takes the rest. + Toggle(isOn: doubleTapAcceptsEntireSuggestionBinding) { + SettingsRowLabel( + title: "Double-Tap to Accept All", + description: "Press \(suggestionSettings.acceptanceKeyLabel) twice quickly to insert the " + + "whole suggestion. A single press still inserts one word.", + systemImage: "hand.tap" + ) + } + .settingsItem(.doubleTapAcceptEntire) + // The opt-in toggle has no factory binding; Clear is its only reset action. LabeledContent { KeybindRow( @@ -142,4 +154,11 @@ struct ShortcutsPaneView: View { } } } + + private var doubleTapAcceptsEntireSuggestionBinding: Binding { + Binding( + get: { suggestionSettings.doubleTapAcceptsEntireSuggestion }, + set: { suggestionSettings.setDoubleTapAcceptsEntireSuggestion($0) } + ) + } } diff --git a/Cotabby/UI/Settings/SettingsIndex.swift b/Cotabby/UI/Settings/SettingsIndex.swift index 29ce5545..344d5f27 100644 --- a/Cotabby/UI/Settings/SettingsIndex.swift +++ b/Cotabby/UI/Settings/SettingsIndex.swift @@ -82,6 +82,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case acceptanceMode case acceptWord case acceptEntireSuggestion + case doubleTapAcceptEntire case toggleTabby // Apps case disabledApps @@ -170,6 +171,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .acceptanceMode: return "Acceptance Mode" case .acceptWord: return "Accept Word" case .acceptEntireSuggestion: return "Accept Entire Suggestion" + case .doubleTapAcceptEntire: return "Double-Tap to Accept All" case .toggleTabby: return "Toggle Cotabby" case .disabledApps: return "Disabled Apps" case .suggestInIntegratedTerminals: return "Suggest in Integrated Terminals" @@ -254,6 +256,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .acceptanceMode: return "textformat.abc" case .acceptWord: return "arrow.right.to.line" case .acceptEntireSuggestion: return "text.insert" + case .doubleTapAcceptEntire: return "hand.tap" case .toggleTabby: return "power.circle" case .disabledApps: return "nosign" case .suggestInIntegratedTerminals: return "terminal" @@ -298,7 +301,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { .downloadModels, .huggingFaceBrowser, .modelsFolder, .lmStudio, .endpointBaseURL, .endpointAPIMode, .endpointAPIKey, .endpointStatus, .endpointModel: return .engineAndModel - case .acceptanceMode, .acceptWord, .acceptEntireSuggestion, .toggleTabby: + case .acceptanceMode, .acceptWord, .acceptEntireSuggestion, .doubleTapAcceptEntire, .toggleTabby: return .shortcuts case .disabledApps, .suggestInIntegratedTerminals: return .apps @@ -381,6 +384,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .acceptanceMode: return "Whether the accept key takes a word or a phrase." case .acceptWord: return "The key that inserts the next word." case .acceptEntireSuggestion: return "The key that inserts the whole suggestion." + case .doubleTapAcceptEntire: return "Press the Accept Word key twice to insert the whole suggestion." case .toggleTabby: return "A global hotkey that turns Cotabby on or off." case .disabledApps: return "Apps where Cotabby never autocompletes." case .suggestInIntegratedTerminals: return "Ghost text in VS Code and Cursor terminals." @@ -590,6 +594,9 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .acceptEntireSuggestion: return ["accept all", "entire", "full", "shortcut", "complete", "all", "whole", "everything", "keybind", "binding"] + case .doubleTapAcceptEntire: + return ["double", "double tap", "double-tap", "twice", "tab tab", "accept all", "entire", + "whole", "everything", "full", "press twice"] case .toggleTabby: return ["toggle", "global", "on off", "shortcut", "hotkey", "pause", "enable", "disable", "keybind", "binding", "tabby"] diff --git a/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorDoubleTapTests.swift b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorDoubleTapTests.swift new file mode 100644 index 00000000..ed1dcd0c --- /dev/null +++ b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorDoubleTapTests.swift @@ -0,0 +1,86 @@ +import CoreGraphics +import Foundation +import XCTest +@testable import Cotabby + +/// Drives real Accept Word key events through the coordinator to pin the double-tap contract: the +/// first press always takes one word, a quick second press on the same suggestion takes the rest, +/// and anything else (setting off, a slow press, an intervening key) keeps word-by-word acceptance. +/// +/// The rig's host never publishes inserted text, so every accept reconciles against the original +/// "Hello " and drops the chunk's leading space; the expected chunks below reflect that. +@MainActor +final class SuggestionCoordinatorDoubleTapTests: XCTestCase { + private let tab = CapturedInputEvent(kind: .acceptance, keyCode: 48, characters: "\t", flags: []) + + func testQuickSecondPressAcceptsTheRestOfTheSuggestion() async { + let rig = await makeReadyRig(doubleTapEnabled: true) + defer { rig.coordinator.stop() } + + XCTAssertTrue(rig.coordinator.handleInputEvent(tab)) + XCTAssertEqual(rig.inserter.insertedChunks, ["world"]) + + XCTAssertTrue(rig.coordinator.handleInputEvent(tab)) + XCTAssertEqual(rig.inserter.insertedChunks, ["world", "again tomorrow"]) + XCTAssertNil(rig.interactionState.activeSession, "The pair must exhaust the suggestion") + } + + func testSettingOffKeepsWordByWordAcceptance() async { + let rig = await makeReadyRig(doubleTapEnabled: false) + defer { rig.coordinator.stop() } + + XCTAssertTrue(rig.coordinator.handleInputEvent(tab)) + XCTAssertTrue(rig.coordinator.handleInputEvent(tab)) + + XCTAssertEqual(rig.inserter.insertedChunks, ["world", "again"]) + XCTAssertEqual(rig.interactionState.activeSession?.remainingText, " tomorrow") + } + + func testSecondPressAfterTheWindowAcceptsOnlyTheNextWord() async { + let rig = await makeReadyRig(doubleTapEnabled: true) + defer { rig.coordinator.stop() } + + XCTAssertTrue(rig.coordinator.handleInputEvent(tab)) + // Backdate the first press past the window instead of sleeping in the test. + guard let session = rig.interactionState.activeSession else { + return XCTFail("Expected the suggestion to remain active after one word") + } + rig.coordinator.doubleTapAcceptanceState.recordWordAccept( + of: .init(session: session), + at: ProcessInfo.processInfo.systemUptime - DoubleTapAcceptanceState.window - 1 + ) + + XCTAssertTrue(rig.coordinator.handleInputEvent(tab)) + XCTAssertEqual(rig.inserter.insertedChunks, ["world", "again"]) + } + + func testInterveningKeyCancelsThePendingDoubleTap() async { + let rig = await makeReadyRig(doubleTapEnabled: true) + defer { rig.coordinator.stop() } + + XCTAssertTrue(rig.coordinator.handleInputEvent(tab)) + // A modifier-only or unmapped key leaves the suggestion alone but still breaks the pair. + _ = rig.coordinator.handleInputEvent( + CapturedInputEvent(kind: .other, keyCode: 56, characters: "", flags: .maskShift) + ) + + XCTAssertFalse(rig.coordinator.doubleTapAcceptanceState.hasPendingPress) + } + + private func makeReadyRig(doubleTapEnabled: Bool) async -> CoordinatorRig { + let rig = makeCoordinatorRig( + snapshot: CotabbyTestFixtures.focusedInputSnapshot(precedingText: "Hello "), + settingsSnapshot: CotabbyTestFixtures.settingsSnapshot( + debounceMilliseconds: 1, + doubleTapAcceptsEntireSuggestion: doubleTapEnabled + ) + ) + rig.engine.resultProvider = { request in + SuggestionResult(generation: request.generation, rawText: "world again tomorrow", + text: "world again tomorrow", latency: 0.01) + } + rig.coordinator.schedulePrediction() + await waitUntil { rig.interactionState.activeSession != nil } + return rig + } +} diff --git a/CotabbyTests/Support/Suggestion/Acceptance/DoubleTapAcceptanceStateTests.swift b/CotabbyTests/Support/Suggestion/Acceptance/DoubleTapAcceptanceStateTests.swift new file mode 100644 index 00000000..ad8ff50d --- /dev/null +++ b/CotabbyTests/Support/Suggestion/Acceptance/DoubleTapAcceptanceStateTests.swift @@ -0,0 +1,66 @@ +@testable import Cotabby +import XCTest + +final class DoubleTapAcceptanceStateTests: XCTestCase { + private let session = DoubleTapAcceptanceState.SessionToken(generation: 7, fullText: " world and more") + + func test_secondPressWithinWindowOnSameSession_isDoubleTap() { + var state = DoubleTapAcceptanceState() + state.recordWordAccept(of: session, at: 10.0) + + XCTAssertTrue(state.consumeDoubleTap(of: session, at: 10.2)) + } + + func test_secondPressAfterWindow_isNotDoubleTap() { + var state = DoubleTapAcceptanceState() + state.recordWordAccept(of: session, at: 10.0) + + XCTAssertFalse(state.consumeDoubleTap(of: session, at: 10.0 + DoubleTapAcceptanceState.window + 0.01)) + } + + func test_pressOnDifferentSession_isNotDoubleTap() { + var state = DoubleTapAcceptanceState() + state.recordWordAccept(of: session, at: 10.0) + let regenerated = DoubleTapAcceptanceState.SessionToken(generation: 8, fullText: " world and more") + + XCTAssertFalse(state.consumeDoubleTap(of: regenerated, at: 10.1)) + } + + func test_firstPressWithoutRecordedAccept_isNotDoubleTap() { + var state = DoubleTapAcceptanceState() + + XCTAssertFalse(state.consumeDoubleTap(of: session, at: 10.0)) + } + + func test_consumingEndsThePair_soATripleTapIsNotASecondDoubleTap() { + var state = DoubleTapAcceptanceState() + state.recordWordAccept(of: session, at: 10.0) + + XCTAssertTrue(state.consumeDoubleTap(of: session, at: 10.1)) + XCTAssertFalse(state.consumeDoubleTap(of: session, at: 10.2)) + } + + func test_missedDoubleTapAlsoClearsThePendingPress() { + var state = DoubleTapAcceptanceState() + state.recordWordAccept(of: session, at: 10.0) + + XCTAssertFalse(state.consumeDoubleTap(of: session, at: 11.0)) + XCTAssertFalse(state.hasPendingPress) + } + + func test_resetCancelsThePendingPress() { + var state = DoubleTapAcceptanceState() + state.recordWordAccept(of: session, at: 10.0) + + state.reset() + + XCTAssertFalse(state.consumeDoubleTap(of: session, at: 10.1)) + } + + func test_clockGoingBackwards_isNotDoubleTap() { + var state = DoubleTapAcceptanceState() + state.recordWordAccept(of: session, at: 10.0) + + XCTAssertFalse(state.consumeDoubleTap(of: session, at: 9.9)) + } +} diff --git a/CotabbyTests/TestSupport/CotabbyTestFixtures.swift b/CotabbyTests/TestSupport/CotabbyTestFixtures.swift index 25dd9819..b8f297e2 100644 --- a/CotabbyTests/TestSupport/CotabbyTestFixtures.swift +++ b/CotabbyTests/TestSupport/CotabbyTestFixtures.swift @@ -270,7 +270,8 @@ enum CotabbyTestFixtures { suppressCompletionsOnTypo: Bool = false, offerTypoCorrections: Bool = false, enabledSpellingDictionaryCodes: [String] = SpellingDictionaryCatalog.defaultEnabledCodes, - automaticallyFixTypos: Bool = false + automaticallyFixTypos: Bool = false, + doubleTapAcceptsEntireSuggestion: Bool = false ) -> SuggestionSettingsSnapshot { SuggestionSettingsSnapshot( isGloballyEnabled: isGloballyEnabled, @@ -303,7 +304,8 @@ enum CotabbyTestFixtures { suppressCompletionsOnTypo: suppressCompletionsOnTypo, offerTypoCorrections: offerTypoCorrections, enabledSpellingDictionaryCodes: enabledSpellingDictionaryCodes, - automaticallyFixTypos: automaticallyFixTypos + automaticallyFixTypos: automaticallyFixTypos, + doubleTapAcceptsEntireSuggestion: doubleTapAcceptsEntireSuggestion ) } } From 49ed8d62ad536d543f38bafcd31f57f3b500c65e Mon Sep 17 00:00:00 2001 From: Senad Date: Fri, 2 Oct 2026 11:31:54 +0200 Subject: [PATCH 2/4] Show the double-tap keys on the Accept Entire Suggestion row With double-tap on, the row draws the Accept Word key twice next to the one-press binding ("Tab Tab or `") and its description names the gesture, so both ways to accept everything are visible in one place. The keycaps follow the Accept Word binding and are hidden when that key is cleared, since double-tap cannot fire without it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017xvrRyxDAooaBCvNfZiAA7 --- .../UI/Settings/Panes/ShortcutsPaneView.swift | 102 ++++++++++++------ 1 file changed, 69 insertions(+), 33 deletions(-) diff --git a/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift b/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift index 5c5b0c5b..ec155cec 100644 --- a/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift +++ b/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift @@ -62,43 +62,58 @@ struct ShortcutsPaneView: View { .settingsItem(.acceptWord) LabeledContent { - KeybindRow( - label: suggestionSettings.fullAcceptanceKeyLabel, - keyCode: suggestionSettings.fullAcceptanceKeyCode, - isRecording: $isRecordingFullAcceptKeybind, - onRecord: { keyCode, modifiers, label in - suggestionSettings.setFullAcceptanceKey( - keyCode: keyCode, - modifiers: modifiers, - label: label - ) - }, - onReset: { - suggestionSettings.setFullAcceptanceKey( - keyCode: SuggestionSettingsModel.defaultFullAcceptanceKeyCode, - modifiers: [], - label: SuggestionSettingsModel.defaultFullAcceptanceKeyLabel - ) - }, - resetLabel: "Reset", - shouldShowReset: suggestionSettings.fullAcceptanceKeyCode - != SuggestionSettingsModel.defaultFullAcceptanceKeyCode - || !suggestionSettings.fullAcceptanceKeyModifiers.isEmpty, - onClear: { suggestionSettings.clearFullAcceptanceKey() }, - clearLabel: "Clear", - clearHelp: "Unbind this shortcut. No key will accept the whole suggestion at once.", - conflictChecker: { keyCode, modifiers in - suggestionSettings.conflictingShortcutName( - keyCode: keyCode, - modifiers: modifiers, - excluding: .acceptEntireSuggestion - ) + HStack(spacing: 8) { + // Double-tap is a second way to fire this action, so its keys sit in this + // row next to the one-press binding instead of only behind the toggle below. + if isDoubleTapAcceptActive { + DoubleTapKeycaps(label: suggestionSettings.acceptanceKeyLabel) + if suggestionSettings.fullAcceptanceKeyCode != SuggestionSettingsModel.disabledKeyCode { + Text("or") + .font(.callout) + .foregroundStyle(.secondary) + } } - ) + KeybindRow( + label: suggestionSettings.fullAcceptanceKeyLabel, + keyCode: suggestionSettings.fullAcceptanceKeyCode, + isRecording: $isRecordingFullAcceptKeybind, + onRecord: { keyCode, modifiers, label in + suggestionSettings.setFullAcceptanceKey( + keyCode: keyCode, + modifiers: modifiers, + label: label + ) + }, + onReset: { + suggestionSettings.setFullAcceptanceKey( + keyCode: SuggestionSettingsModel.defaultFullAcceptanceKeyCode, + modifiers: [], + label: SuggestionSettingsModel.defaultFullAcceptanceKeyLabel + ) + }, + resetLabel: "Reset", + shouldShowReset: suggestionSettings.fullAcceptanceKeyCode + != SuggestionSettingsModel.defaultFullAcceptanceKeyCode + || !suggestionSettings.fullAcceptanceKeyModifiers.isEmpty, + onClear: { suggestionSettings.clearFullAcceptanceKey() }, + clearLabel: "Clear", + clearHelp: "Unbind this shortcut. No key will accept the whole suggestion at once.", + conflictChecker: { keyCode, modifiers in + suggestionSettings.conflictingShortcutName( + keyCode: keyCode, + modifiers: modifiers, + excluding: .acceptEntireSuggestion + ) + } + ) + } } label: { SettingsRowLabel( title: "Accept Entire Suggestion", - description: "Insert the whole remaining suggestion in one keystroke.", + description: isDoubleTapAcceptActive + ? "Insert the whole remaining suggestion in one keystroke, or by pressing " + + "\(suggestionSettings.acceptanceKeyLabel) twice quickly." + : "Insert the whole remaining suggestion in one keystroke.", systemImage: "text.insert" ) } @@ -155,6 +170,12 @@ struct ShortcutsPaneView: View { } } + /// Double-tap rides on the Accept Word key, so it can only fire while that key is bound. + private var isDoubleTapAcceptActive: Bool { + suggestionSettings.doubleTapAcceptsEntireSuggestion + && suggestionSettings.acceptanceKeyCode != SuggestionSettingsModel.disabledKeyCode + } + private var doubleTapAcceptsEntireSuggestionBinding: Binding { Binding( get: { suggestionSettings.doubleTapAcceptsEntireSuggestion }, @@ -162,3 +183,18 @@ struct ShortcutsPaneView: View { ) } } + +/// The Accept Word key drawn twice, the way the double-tap gesture is pressed. Uses the same +/// keycap chrome and size as `KeybindRow` so it reads as part of the row's key area. +private struct DoubleTapKeycaps: View { + let label: String + + var body: some View { + HStack(spacing: 4) { + KeycapView(label: label, fontSize: 12, minWidth: 36) + KeycapView(label: label, fontSize: 12, minWidth: 36) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(label) twice") + } +} From cbeb16345d6b158029fda8bc2daa92d4ba2ddca8 Mon Sep 17 00:00:00 2001 From: Senad Date: Fri, 2 Oct 2026 11:41:09 +0200 Subject: [PATCH 3/4] Record the double tap in the Accept Entire Suggestion key box Replaces the separate "Double-Tap to Accept All" toggle and the extra keycaps on the row, which read as a second binding and wrapped the row. Accept Entire Suggestion now holds one shortcut: click Change and press the Accept Word key twice quickly to bind the double tap (the box shows "Tab Tab"), or press any other key for a one-press binding. A single press of Accept Word still reports the conflict, now with a hint about the double tap. Onboarding's keybind step records it the same way. The model keeps the slot exclusive: binding the double tap clears the one-press key, recording a key or clearing the slot clears the double tap, and clearing Accept Word drops it since there is nothing left to press twice. Views read the shared display label and default/bound helpers instead of re-deriving them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017xvrRyxDAooaBCvNfZiAA7 --- Cotabby.xcodeproj/project.pbxproj | 4 + .../Settings/SuggestionSettingsModel.swift | 48 +++++- .../Welcome/WelcomeKeybindStepView.swift | 30 +++- .../UI/Onboarding/Welcome/WelcomeView.swift | 6 +- .../Components/Controls/KeyRecorderView.swift | 50 +++++++ .../UI/Settings/Components/KeybindRow.swift | 16 +- Cotabby/UI/Settings/Panes/AppsPaneView.swift | 2 +- .../UI/Settings/Panes/ShortcutsPaneView.swift | 138 +++++++----------- Cotabby/UI/Settings/SettingsIndex.swift | 14 +- ...uggestionSettingsModelDoubleTapTests.swift | 121 +++++++++++++++ 10 files changed, 321 insertions(+), 108 deletions(-) create mode 100644 CotabbyTests/Models/Settings/SuggestionSettingsModelDoubleTapTests.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 4cba2efa..9010f722 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -901,6 +901,7 @@ E9B7E353B233F842AC859542 /* MenuBarRecoveryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E267F3BB7FC8DEAEB5E841B /* MenuBarRecoveryPolicy.swift */; }; E9D65F5D5452B595886F43DC /* GhostWrapBandPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B753DA96CA6BC168E675E61E /* GhostWrapBandPolicy.swift */; }; E9E4CC657771DF9F4C56183C /* VisualContextCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A854CAFB1F557BC4CAED8819 /* VisualContextCoordinator.swift */; }; + EA16F79329E37D921F0BB542 /* SuggestionSettingsModelDoubleTapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC30D79131D562C5CD7FA639 /* SuggestionSettingsModelDoubleTapTests.swift */; }; EA353CCECBFB4D297C865447 /* CurrentWordExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1AAC6367483952AB7D59A62 /* CurrentWordExtractor.swift */; }; EA6E9DD40B45A48FCBED4BCB /* SuggestionCoordinator+TypingPrediction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57D3DEF846A089D523BB8BC0 /* SuggestionCoordinator+TypingPrediction.swift */; }; EA79F5362D5CDBC3B4F3701A /* HostMarkedTextPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9CFD041A13DACE5612DC991 /* HostMarkedTextPolicy.swift */; }; @@ -1500,6 +1501,7 @@ CBD829565558CB1EA8CA69D5 /* BrowserDomainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserDomainTests.swift; sourceTree = ""; }; CBDBD55F205C609303913423 /* SpellingDictionaryResourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpellingDictionaryResourceTests.swift; sourceTree = ""; }; CC1EDFB535AAA2EE0D67828A /* CotabbyApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotabbyApp.swift; sourceTree = ""; }; + CC30D79131D562C5CD7FA639 /* SuggestionSettingsModelDoubleTapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsModelDoubleTapTests.swift; sourceTree = ""; }; CC5CD24D2FF35FD36B8D78DE /* SuggestionQualityMetricsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionQualityMetricsStoreTests.swift; sourceTree = ""; }; CD1FC547BE2E96D2ABB55DF4 /* LlamaSuggestionEngineStreamingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaSuggestionEngineStreamingTests.swift; sourceTree = ""; }; CE8B99181BF74C1F1DC5F6A7 /* HostTextMetricsCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostTextMetricsCache.swift; sourceTree = ""; }; @@ -3449,6 +3451,7 @@ A306C7B2B0790A0B98D8F353 /* PerDomainDisableSettingsTests.swift */, DA02598F555696782E180DA5 /* ShortcutConflictTests.swift */, 2E7C883990AE384602BA9AF6 /* SuggestionSettingsDomainTests.swift */, + CC30D79131D562C5CD7FA639 /* SuggestionSettingsModelDoubleTapTests.swift */, D253D06D941F60EDAF112625 /* SuggestionSettingsModelTests.swift */, ); path = Settings; @@ -4729,6 +4732,7 @@ B8B71602DFA0E6B7E61E2692 /* SuggestionSessionTypingTests.swift in Sources */, E83E989C5132F2A8C8FED8EB /* SuggestionSettingsDomainTests.swift in Sources */, 6471AEB3C75714538D78DBA3 /* SuggestionSettingsModelDisabledAppsTests.swift in Sources */, + EA16F79329E37D921F0BB542 /* SuggestionSettingsModelDoubleTapTests.swift in Sources */, 2D83CECB0FF076323226449D /* SuggestionSettingsModelTests.swift in Sources */, 19449988F10AD289E2682F04 /* SuggestionSettingsStoreTests.swift in Sources */, F9ED18D3A56FDAAA38C9B0F9 /* SuggestionStaleAcceptanceEchoTests.swift in Sources */, diff --git a/Cotabby/Models/Settings/SuggestionSettingsModel.swift b/Cotabby/Models/Settings/SuggestionSettingsModel.swift index d259f5c6..c49e2cb6 100644 --- a/Cotabby/Models/Settings/SuggestionSettingsModel.swift +++ b/Cotabby/Models/Settings/SuggestionSettingsModel.swift @@ -156,7 +156,9 @@ final class SuggestionSettingsModel: ObservableObject { /// 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] - /// Whether a quick second press of the Accept Word key accepts the rest of the suggestion. + /// Whether Accept Entire Suggestion is bound to a quick double press of the Accept Word key. + /// The Accept Entire Suggestion slot holds one shortcut, so while this is true the one-press + /// full-accept key is unbound; the setters below keep the two mutually exclusive. @Published private(set) var doubleTapAcceptsEntireSuggestion: Bool @Published private(set) var acceptanceGranularity: AcceptanceGranularity @Published private(set) var isPowerBasedModelSwitchingEnabled: Bool @@ -909,7 +911,7 @@ final class SuggestionSettingsModel: ObservableObject { ) } - func setDoubleTapAcceptsEntireSuggestion(_ enabled: Bool) { + private func setDoubleTapAcceptsEntireSuggestion(_ enabled: Bool) { guard doubleTapAcceptsEntireSuggestion != enabled else { return } @@ -917,6 +919,39 @@ final class SuggestionSettingsModel: ObservableObject { store.saveDoubleTapAcceptsEntireSuggestion(enabled) } + /// Binds Accept Entire Suggestion to a double press of the Accept Word key, replacing any + /// one-press key in that slot. Ignored while Accept Word is unbound, since there is no key to + /// press twice. + func setDoubleTapFullAcceptance() { + guard acceptanceKeyCode != Self.disabledKeyCode else { return } + setFullAcceptanceKey(keyCode: Self.disabledKeyCode, modifiers: [], label: Self.disabledKeyLabel) + setDoubleTapAcceptsEntireSuggestion(true) + } + + /// True when the double-tap binding can actually fire. It rides on the Accept Word key, so it + /// is inert while that key is unbound. + var isDoubleTapFullAcceptanceActive: Bool { + doubleTapAcceptsEntireSuggestion && acceptanceKeyCode != Self.disabledKeyCode + } + + /// The Accept Entire Suggestion shortcut as users should read it: the one-press key, or the + /// Accept Word key written twice ("Tab Tab"). + var fullAcceptanceDisplayLabel: String { + isDoubleTapFullAcceptanceActive ? "\(acceptanceKeyLabel) \(acceptanceKeyLabel)" : fullAcceptanceKeyLabel + } + + /// Whether any shortcut accepts the whole suggestion, so views can offer Clear. + var hasFullAcceptanceShortcut: Bool { + isDoubleTapFullAcceptanceActive || fullAcceptanceKeyCode != Self.disabledKeyCode + } + + /// Whether the slot still holds the factory one-press key, so views can hide Reset. + var isFullAcceptanceShortcutDefault: Bool { + !isDoubleTapFullAcceptanceActive + && fullAcceptanceKeyCode == Self.defaultFullAcceptanceKeyCode + && fullAcceptanceKeyModifiers.isEmpty + } + func setAutoAcceptTrailingPunctuation(_ enabled: Bool) { guard autoAcceptTrailingPunctuation != enabled else { return @@ -1342,10 +1377,18 @@ final class SuggestionSettingsModel: ObservableObject { } func clearAcceptanceKey() { + // The double-tap binding is a double press of this key, so it goes away with it rather than + // reappearing unexpectedly when a new Accept Word key is recorded later. + setDoubleTapAcceptsEntireSuggestion(false) setAcceptanceKey(keyCode: Self.disabledKeyCode, modifiers: [], label: Self.disabledKeyLabel) } func setFullAcceptanceKey(keyCode: CGKeyCode, modifiers: ShortcutModifierMask, label: String) { + // A real key takes the slot over from a double tap. Unbinding leaves the flag alone so + // `setDoubleTapFullAcceptance` can clear the key without undoing itself. + if keyCode != Self.disabledKeyCode { + setDoubleTapAcceptsEntireSuggestion(false) + } let normalizedModifiers = keyCode == Self.disabledKeyCode ? [] : modifiers guard fullAcceptanceKeyCode != keyCode || fullAcceptanceKeyModifiers != normalizedModifiers @@ -1367,6 +1410,7 @@ final class SuggestionSettingsModel: ObservableObject { } func clearFullAcceptanceKey() { + setDoubleTapAcceptsEntireSuggestion(false) setFullAcceptanceKey(keyCode: Self.disabledKeyCode, modifiers: [], label: Self.disabledKeyLabel) } diff --git a/Cotabby/UI/Onboarding/Welcome/WelcomeKeybindStepView.swift b/Cotabby/UI/Onboarding/Welcome/WelcomeKeybindStepView.swift index c2065051..e6149c81 100644 --- a/Cotabby/UI/Onboarding/Welcome/WelcomeKeybindStepView.swift +++ b/Cotabby/UI/Onboarding/Welcome/WelcomeKeybindStepView.swift @@ -58,7 +58,7 @@ struct WelcomeKeybindStepView: View { keybindRow( title: "Accept entire suggestion", - keyLabel: suggestionSettings.fullAcceptanceKeyLabel, + keyLabel: suggestionSettings.fullAcceptanceDisplayLabel, action: .acceptEntireSuggestion, onKeyRecorded: { keyCode, modifiers, label in suggestionSettings.setFullAcceptanceKey( @@ -67,18 +67,23 @@ struct WelcomeKeybindStepView: View { label: label ) }, - onReset: ( - suggestionSettings.fullAcceptanceKeyCode != SuggestionSettingsModel.defaultFullAcceptanceKeyCode - || !suggestionSettings.fullAcceptanceKeyModifiers.isEmpty - ) ? { + onReset: !suggestionSettings.isFullAcceptanceShortcutDefault ? { suggestionSettings.setFullAcceptanceKey( keyCode: SuggestionSettingsModel.defaultFullAcceptanceKeyCode, modifiers: [], label: SuggestionSettingsModel.defaultFullAcceptanceKeyLabel ) } : nil, - onClear: suggestionSettings.fullAcceptanceKeyCode != SuggestionSettingsModel.disabledKeyCode - ? { suggestionSettings.clearFullAcceptanceKey() } : nil + onClear: suggestionSettings.hasFullAcceptanceShortcut + ? { suggestionSettings.clearFullAcceptanceKey() } : nil, + // Pressing Accept Word twice here binds the double tap, the same as in Settings. + doubleTapKey: suggestionSettings.acceptanceKeyCode != SuggestionSettingsModel.disabledKeyCode + ? DoubleTapRecordingKey( + keyCode: suggestionSettings.acceptanceKeyCode, + modifiers: suggestionSettings.acceptanceKeyModifiers, + label: suggestionSettings.acceptanceKeyLabel + ) : nil, + onDoubleTapRecorded: { suggestionSettings.setDoubleTapFullAcceptance() } ) .onboardingReveal(3) @@ -112,7 +117,9 @@ struct WelcomeKeybindStepView: View { action: ShortcutAction, onKeyRecorded: @escaping (CGKeyCode, ShortcutModifierMask, String) -> Void, onReset: (() -> Void)? = nil, - onClear: (() -> Void)? = nil + onClear: (() -> Void)? = nil, + doubleTapKey: DoubleTapRecordingKey? = nil, + onDoubleTapRecorded: (() -> Void)? = nil ) -> some View { let isRecording = recordingAction == action HStack(spacing: 10) { @@ -137,6 +144,13 @@ struct WelcomeKeybindStepView: View { modifiers: modifiers, excluding: action ) + }, + doubleTapKey: doubleTapKey, + onDoubleTapRecorded: onDoubleTapRecorded.map { record in + { + record() + recordingAction = nil + } } ) } else { diff --git a/Cotabby/UI/Onboarding/Welcome/WelcomeView.swift b/Cotabby/UI/Onboarding/Welcome/WelcomeView.swift index d00e6727..87638a48 100644 --- a/Cotabby/UI/Onboarding/Welcome/WelcomeView.swift +++ b/Cotabby/UI/Onboarding/Welcome/WelcomeView.swift @@ -445,8 +445,10 @@ extension WelcomeView { private var doneStepSubtitle: String { let wordKey = suggestionSettings.acceptanceKeyLabel - let fullKey = suggestionSettings.fullAcceptanceKeyLabel - let hasFullAccept = suggestionSettings.fullAcceptanceKeyCode != SuggestionSettingsModel.disabledKeyCode + let fullKey = suggestionSettings.isDoubleTapFullAcceptanceActive + ? "\(wordKey) twice" + : suggestionSettings.fullAcceptanceKeyLabel + let hasFullAccept = suggestionSettings.hasFullAcceptanceShortcut if hasFullAccept { return "Start typing anywhere.\nPress \(wordKey) to accept a word, \(fullKey) for the full suggestion." diff --git a/Cotabby/UI/Settings/Components/Controls/KeyRecorderView.swift b/Cotabby/UI/Settings/Components/Controls/KeyRecorderView.swift index 0387653c..d9b7bda6 100644 --- a/Cotabby/UI/Settings/Components/Controls/KeyRecorderView.swift +++ b/Cotabby/UI/Settings/Components/Controls/KeyRecorderView.swift @@ -15,10 +15,16 @@ struct KeyRecorderView: View { /// When it reports a conflict the recorder refuses to commit and keeps listening, so a shortcut /// can never be assigned to two actions at once. var conflictChecker: ((CGKeyCode, ShortcutModifierMask) -> String?)? + /// A key that may be recorded as a double press instead of a single key: the Accept Word key, + /// when recording Accept Entire Suggestion. A single press of it is still a conflict. + var doubleTapKey: DoubleTapRecordingKey? + var onDoubleTapRecorded: (() -> Void)? @State private var monitor: Any? @State private var liveModifiers: ShortcutModifierMask = [] @State private var conflictMessage: String? + /// Event timestamp of a first press of `doubleTapKey` still waiting for its second press. + @State private var pendingFirstTapTimestamp: TimeInterval? var body: some View { Text(promptText) @@ -31,6 +37,9 @@ struct KeyRecorderView: View { if let conflictMessage { return conflictMessage } + if pendingFirstTapTimestamp != nil, let doubleTapKey { + return "Press \(doubleTapKey.label) again…" + } let glyphs = KeyCodeLabels.modifierGlyphs(liveModifiers) return glyphs.isEmpty ? "Press a key…" : "\(glyphs) + key…" } @@ -63,6 +72,12 @@ struct KeyRecorderView: View { return nil } + if let doubleTapKey, onDoubleTapRecorded != nil, + keyCode == doubleTapKey.keyCode, modifiers == doubleTapKey.modifiers { + return handleDoubleTapCandidate(event: event, key: doubleTapKey) + } + pendingFirstTapTimestamp = nil + // Any other key is fair game. The pipeline is key-agnostic: `InputMonitor.classify` // matches the bound shortcut before its behavioral branches, and acceptance only // consumes the key while a suggestion is visible (otherwise it passes through and does @@ -89,6 +104,34 @@ struct KeyRecorderView: View { return nil } + /// The first press waits up to `DoubleTapAcceptanceState.window` for a second one, the same + /// window acceptance uses, so a pair that records here is a pair that works while typing. If no + /// second press comes, the press is reported as the usual conflict, with a pointer to the + /// double tap so the gesture is discoverable from the error. + private func handleDoubleTapCandidate(event: NSEvent, key: DoubleTapRecordingKey) -> NSEvent? { + // Holding the key auto-repeats; that is one long press, not a double tap. + guard !event.isARepeat else { return nil } + + let timestamp = event.timestamp + if let first = pendingFirstTapTimestamp, timestamp - first <= DoubleTapAcceptanceState.window { + pendingFirstTapTimestamp = nil + removeMonitor() + onDoubleTapRecorded?() + return nil + } + + pendingFirstTapTimestamp = timestamp + conflictMessage = nil + DispatchQueue.main.asyncAfter(deadline: .now() + DoubleTapAcceptanceState.window) { + // Only the press that scheduled this timeout may turn into an error. + guard pendingFirstTapTimestamp == timestamp else { return } + pendingFirstTapTimestamp = nil + let owner = conflictChecker?(key.keyCode, key.modifiers) ?? "another shortcut" + conflictMessage = "Already used by \(owner). Press it twice quickly for a double tap." + } + return nil + } + private func bestCharacterFallback(for event: NSEvent) -> String? { if let ignoring = event.charactersIgnoringModifiers, !ignoring.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { @@ -117,3 +160,10 @@ extension ShortcutModifierMask { self = mask } } + +/// The key a recorder may accept as a double press, with the label shown while it waits. +struct DoubleTapRecordingKey: Equatable { + let keyCode: CGKeyCode + let modifiers: ShortcutModifierMask + let label: String +} diff --git a/Cotabby/UI/Settings/Components/KeybindRow.swift b/Cotabby/UI/Settings/Components/KeybindRow.swift index 8c314809..419296af 100644 --- a/Cotabby/UI/Settings/Components/KeybindRow.swift +++ b/Cotabby/UI/Settings/Components/KeybindRow.swift @@ -14,6 +14,11 @@ struct KeybindRow: View { let clearLabel: String let clearHelp: String let conflictChecker: (CGKeyCode, ShortcutModifierMask) -> String? + /// Overrides the "is anything bound" check behind Clear, for a binding that is not a single + /// key code (a double tap). `nil` derives it from `keyCode`. + var isBound: Bool? + var doubleTapKey: DoubleTapRecordingKey? + var onDoubleTapRecorded: (() -> Void)? var body: some View { HStack(spacing: 8) { @@ -26,7 +31,14 @@ struct KeybindRow: View { isRecording = false }, onCancelled: { isRecording = false }, - conflictChecker: conflictChecker + conflictChecker: conflictChecker, + doubleTapKey: doubleTapKey, + onDoubleTapRecorded: onDoubleTapRecorded.map { record in + { + record() + isRecording = false + } + } ) } else { Button("Change") { @@ -41,7 +53,7 @@ struct KeybindRow: View { } } - if keyCode != SuggestionSettingsModel.disabledKeyCode { + if isBound ?? (keyCode != SuggestionSettingsModel.disabledKeyCode) { Button(clearLabel) { onClear() isRecording = false diff --git a/Cotabby/UI/Settings/Panes/AppsPaneView.swift b/Cotabby/UI/Settings/Panes/AppsPaneView.swift index bc20470d..e0a26278 100644 --- a/Cotabby/UI/Settings/Panes/AppsPaneView.swift +++ b/Cotabby/UI/Settings/Panes/AppsPaneView.swift @@ -171,7 +171,7 @@ struct AppsPaneView: View { override: override, action: .acceptEntireSuggestion, title: "Accept Entire Suggestion", - inheritsHelp: "Uses the global shortcut (\(suggestionSettings.fullAcceptanceKeyLabel)). " + inheritsHelp: "Uses the global shortcut (\(suggestionSettings.fullAcceptanceDisplayLabel)). " + "Click Change to set a custom key for \(override.displayName)." ) } diff --git a/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift b/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift index ec155cec..f2162be4 100644 --- a/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift +++ b/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift @@ -61,76 +61,52 @@ struct ShortcutsPaneView: View { } .settingsItem(.acceptWord) + // One slot, one shortcut: either a one-press key or a double press of Accept Word. + // The recorder takes either, so the gesture is set where users look for it. LabeledContent { - HStack(spacing: 8) { - // Double-tap is a second way to fire this action, so its keys sit in this - // row next to the one-press binding instead of only behind the toggle below. - if isDoubleTapAcceptActive { - DoubleTapKeycaps(label: suggestionSettings.acceptanceKeyLabel) - if suggestionSettings.fullAcceptanceKeyCode != SuggestionSettingsModel.disabledKeyCode { - Text("or") - .font(.callout) - .foregroundStyle(.secondary) - } - } - KeybindRow( - label: suggestionSettings.fullAcceptanceKeyLabel, - keyCode: suggestionSettings.fullAcceptanceKeyCode, - isRecording: $isRecordingFullAcceptKeybind, - onRecord: { keyCode, modifiers, label in - suggestionSettings.setFullAcceptanceKey( - keyCode: keyCode, - modifiers: modifiers, - label: label - ) - }, - onReset: { - suggestionSettings.setFullAcceptanceKey( - keyCode: SuggestionSettingsModel.defaultFullAcceptanceKeyCode, - modifiers: [], - label: SuggestionSettingsModel.defaultFullAcceptanceKeyLabel - ) - }, - resetLabel: "Reset", - shouldShowReset: suggestionSettings.fullAcceptanceKeyCode - != SuggestionSettingsModel.defaultFullAcceptanceKeyCode - || !suggestionSettings.fullAcceptanceKeyModifiers.isEmpty, - onClear: { suggestionSettings.clearFullAcceptanceKey() }, - clearLabel: "Clear", - clearHelp: "Unbind this shortcut. No key will accept the whole suggestion at once.", - conflictChecker: { keyCode, modifiers in - suggestionSettings.conflictingShortcutName( - keyCode: keyCode, - modifiers: modifiers, - excluding: .acceptEntireSuggestion - ) - } - ) - } + KeybindRow( + label: suggestionSettings.fullAcceptanceDisplayLabel, + keyCode: suggestionSettings.fullAcceptanceKeyCode, + isRecording: $isRecordingFullAcceptKeybind, + onRecord: { keyCode, modifiers, label in + suggestionSettings.setFullAcceptanceKey( + keyCode: keyCode, + modifiers: modifiers, + label: label + ) + }, + onReset: { + suggestionSettings.setFullAcceptanceKey( + keyCode: SuggestionSettingsModel.defaultFullAcceptanceKeyCode, + modifiers: [], + label: SuggestionSettingsModel.defaultFullAcceptanceKeyLabel + ) + }, + resetLabel: "Reset", + shouldShowReset: !suggestionSettings.isFullAcceptanceShortcutDefault, + onClear: { suggestionSettings.clearFullAcceptanceKey() }, + clearLabel: "Clear", + clearHelp: "Unbind this shortcut. No key will accept the whole suggestion at once.", + conflictChecker: { keyCode, modifiers in + suggestionSettings.conflictingShortcutName( + keyCode: keyCode, + modifiers: modifiers, + excluding: .acceptEntireSuggestion + ) + }, + isBound: suggestionSettings.hasFullAcceptanceShortcut, + doubleTapKey: acceptWordDoubleTapKey, + onDoubleTapRecorded: { suggestionSettings.setDoubleTapFullAcceptance() } + ) } label: { SettingsRowLabel( title: "Accept Entire Suggestion", - description: isDoubleTapAcceptActive - ? "Insert the whole remaining suggestion in one keystroke, or by pressing " + - "\(suggestionSettings.acceptanceKeyLabel) twice quickly." - : "Insert the whole remaining suggestion in one keystroke.", + description: fullAcceptanceDescription, systemImage: "text.insert" ) } .settingsItem(.acceptEntireSuggestion) - // A modifier on the Accept Word key rather than its own binding: the first press still - // takes a word immediately, and a quick second press takes the rest. - Toggle(isOn: doubleTapAcceptsEntireSuggestionBinding) { - SettingsRowLabel( - title: "Double-Tap to Accept All", - description: "Press \(suggestionSettings.acceptanceKeyLabel) twice quickly to insert the " + - "whole suggestion. A single press still inserts one word.", - systemImage: "hand.tap" - ) - } - .settingsItem(.doubleTapAcceptEntire) - // The opt-in toggle has no factory binding; Clear is its only reset action. LabeledContent { KeybindRow( @@ -170,31 +146,27 @@ struct ShortcutsPaneView: View { } } - /// Double-tap rides on the Accept Word key, so it can only fire while that key is bound. - private var isDoubleTapAcceptActive: Bool { - suggestionSettings.doubleTapAcceptsEntireSuggestion - && suggestionSettings.acceptanceKeyCode != SuggestionSettingsModel.disabledKeyCode - } - - private var doubleTapAcceptsEntireSuggestionBinding: Binding { - Binding( - get: { suggestionSettings.doubleTapAcceptsEntireSuggestion }, - set: { suggestionSettings.setDoubleTapAcceptsEntireSuggestion($0) } + /// The Accept Word key, offered to the recorder as a double press. Nil while Accept Word is + /// unbound, because there is then no key to press twice. + private var acceptWordDoubleTapKey: DoubleTapRecordingKey? { + guard suggestionSettings.acceptanceKeyCode != SuggestionSettingsModel.disabledKeyCode else { + return nil + } + return DoubleTapRecordingKey( + keyCode: suggestionSettings.acceptanceKeyCode, + modifiers: suggestionSettings.acceptanceKeyModifiers, + label: suggestionSettings.acceptanceKeyLabel ) } -} - -/// The Accept Word key drawn twice, the way the double-tap gesture is pressed. Uses the same -/// keycap chrome and size as `KeybindRow` so it reads as part of the row's key area. -private struct DoubleTapKeycaps: View { - let label: String - var body: some View { - HStack(spacing: 4) { - KeycapView(label: label, fontSize: 12, minWidth: 36) - KeycapView(label: label, fontSize: 12, minWidth: 36) + private var fullAcceptanceDescription: String { + let wordKey = suggestionSettings.acceptanceKeyLabel + if suggestionSettings.isDoubleTapFullAcceptanceActive { + return "Press \(wordKey) twice quickly to insert the whole suggestion. A single press still inserts one word." + } + if acceptWordDoubleTapKey != nil { + return "Insert the whole remaining suggestion at once. For a double tap, click Change and press \(wordKey) twice." } - .accessibilityElement(children: .ignore) - .accessibilityLabel("\(label) twice") + return "Insert the whole remaining suggestion in one keystroke." } } diff --git a/Cotabby/UI/Settings/SettingsIndex.swift b/Cotabby/UI/Settings/SettingsIndex.swift index 344d5f27..ee110a8c 100644 --- a/Cotabby/UI/Settings/SettingsIndex.swift +++ b/Cotabby/UI/Settings/SettingsIndex.swift @@ -82,7 +82,6 @@ enum SettingsItem: String, CaseIterable, Identifiable { case acceptanceMode case acceptWord case acceptEntireSuggestion - case doubleTapAcceptEntire case toggleTabby // Apps case disabledApps @@ -171,7 +170,6 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .acceptanceMode: return "Acceptance Mode" case .acceptWord: return "Accept Word" case .acceptEntireSuggestion: return "Accept Entire Suggestion" - case .doubleTapAcceptEntire: return "Double-Tap to Accept All" case .toggleTabby: return "Toggle Cotabby" case .disabledApps: return "Disabled Apps" case .suggestInIntegratedTerminals: return "Suggest in Integrated Terminals" @@ -256,7 +254,6 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .acceptanceMode: return "textformat.abc" case .acceptWord: return "arrow.right.to.line" case .acceptEntireSuggestion: return "text.insert" - case .doubleTapAcceptEntire: return "hand.tap" case .toggleTabby: return "power.circle" case .disabledApps: return "nosign" case .suggestInIntegratedTerminals: return "terminal" @@ -301,7 +298,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { .downloadModels, .huggingFaceBrowser, .modelsFolder, .lmStudio, .endpointBaseURL, .endpointAPIMode, .endpointAPIKey, .endpointStatus, .endpointModel: return .engineAndModel - case .acceptanceMode, .acceptWord, .acceptEntireSuggestion, .doubleTapAcceptEntire, .toggleTabby: + case .acceptanceMode, .acceptWord, .acceptEntireSuggestion, .toggleTabby: return .shortcuts case .disabledApps, .suggestInIntegratedTerminals: return .apps @@ -383,8 +380,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .endpointModel: return "The model identifier sent to the configured endpoint." case .acceptanceMode: return "Whether the accept key takes a word or a phrase." case .acceptWord: return "The key that inserts the next word." - case .acceptEntireSuggestion: return "The key that inserts the whole suggestion." - case .doubleTapAcceptEntire: return "Press the Accept Word key twice to insert the whole suggestion." + case .acceptEntireSuggestion: return "The key, or Tab pressed twice, that inserts the whole suggestion." case .toggleTabby: return "A global hotkey that turns Cotabby on or off." case .disabledApps: return "Apps where Cotabby never autocompletes." case .suggestInIntegratedTerminals: return "Ghost text in VS Code and Cursor terminals." @@ -593,10 +589,8 @@ enum SettingsItem: String, CaseIterable, Identifiable { "hotkey", "accept word", "next word"] case .acceptEntireSuggestion: return ["accept all", "entire", "full", "shortcut", "complete", "all", - "whole", "everything", "keybind", "binding"] - case .doubleTapAcceptEntire: - return ["double", "double tap", "double-tap", "twice", "tab tab", "accept all", "entire", - "whole", "everything", "full", "press twice"] + "whole", "everything", "keybind", "binding", "double tap", "double-tap", + "twice", "tab tab", "press twice"] case .toggleTabby: return ["toggle", "global", "on off", "shortcut", "hotkey", "pause", "enable", "disable", "keybind", "binding", "tabby"] diff --git a/CotabbyTests/Models/Settings/SuggestionSettingsModelDoubleTapTests.swift b/CotabbyTests/Models/Settings/SuggestionSettingsModelDoubleTapTests.swift new file mode 100644 index 00000000..f71ef581 --- /dev/null +++ b/CotabbyTests/Models/Settings/SuggestionSettingsModelDoubleTapTests.swift @@ -0,0 +1,121 @@ +import CoreGraphics +import XCTest +@testable import Cotabby + +/// Locks the "one slot, one shortcut" rule for Accept Entire Suggestion: it is either a one-press +/// key or a double press of Accept Word, never both, and the display helpers views read agree with +/// whichever one is stored. +@MainActor +final class SuggestionSettingsModelDoubleTapTests: XCTestCase { + private var suiteName: String! + private var defaults: UserDefaults! + + override func setUp() { + super.setUp() + suiteName = "cotabby.test.settingsModelDoubleTap.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + private func makeModel() -> SuggestionSettingsModel { + SuggestionSettingsModel(configuration: .standard, userDefaults: defaults) + } + + func test_defaultSlotHoldsTheFactoryOnePressKey() { + let model = makeModel() + + XCTAssertFalse(model.isDoubleTapFullAcceptanceActive) + XCTAssertTrue(model.isFullAcceptanceShortcutDefault) + XCTAssertEqual(model.fullAcceptanceDisplayLabel, SuggestionSettingsModel.defaultFullAcceptanceKeyLabel) + XCTAssertFalse(model.snapshot.doubleTapAcceptsEntireSuggestion) + } + + func test_recordingDoubleTapReplacesTheOnePressKeyAndPersists() { + let model = makeModel() + + model.setDoubleTapFullAcceptance() + + XCTAssertTrue(model.isDoubleTapFullAcceptanceActive) + XCTAssertEqual(model.fullAcceptanceKeyCode, SuggestionSettingsModel.disabledKeyCode) + XCTAssertEqual(model.fullAcceptanceDisplayLabel, "Tab Tab") + XCTAssertTrue(model.hasFullAcceptanceShortcut) + XCTAssertFalse(model.isFullAcceptanceShortcutDefault) + XCTAssertTrue(model.snapshot.doubleTapAcceptsEntireSuggestion) + + let reloaded = makeModel() + XCTAssertTrue(reloaded.isDoubleTapFullAcceptanceActive) + XCTAssertEqual(reloaded.fullAcceptanceKeyCode, SuggestionSettingsModel.disabledKeyCode) + } + + func test_recordingAOnePressKeyReplacesTheDoubleTap() { + let model = makeModel() + model.setDoubleTapFullAcceptance() + + model.setFullAcceptanceKey(keyCode: 48, modifiers: [.option], label: "⌥ Tab") + + XCTAssertFalse(model.isDoubleTapFullAcceptanceActive) + XCTAssertEqual(model.fullAcceptanceDisplayLabel, "⌥ Tab") + XCTAssertFalse(makeModel().doubleTapAcceptsEntireSuggestion) + } + + func test_resetReturnsToTheFactoryKey() { + let model = makeModel() + model.setDoubleTapFullAcceptance() + + model.setFullAcceptanceKey( + keyCode: SuggestionSettingsModel.defaultFullAcceptanceKeyCode, + modifiers: [], + label: SuggestionSettingsModel.defaultFullAcceptanceKeyLabel + ) + + XCTAssertTrue(model.isFullAcceptanceShortcutDefault) + XCTAssertFalse(model.isDoubleTapFullAcceptanceActive) + } + + func test_clearRemovesTheDoubleTapToo() { + let model = makeModel() + model.setDoubleTapFullAcceptance() + + model.clearFullAcceptanceKey() + + XCTAssertFalse(model.hasFullAcceptanceShortcut) + XCTAssertFalse(model.doubleTapAcceptsEntireSuggestion) + XCTAssertEqual(model.fullAcceptanceDisplayLabel, SuggestionSettingsModel.disabledKeyLabel) + } + + func test_doubleTapCannotBeBoundWithoutAnAcceptWordKey() { + let model = makeModel() + model.clearAcceptanceKey() + + model.setDoubleTapFullAcceptance() + + XCTAssertFalse(model.doubleTapAcceptsEntireSuggestion) + XCTAssertTrue(model.isFullAcceptanceShortcutDefault, "The one-press key must survive the refused bind") + } + + func test_clearingAcceptWordAlsoRemovesTheDoubleTap() { + let model = makeModel() + model.setDoubleTapFullAcceptance() + + model.clearAcceptanceKey() + + XCTAssertFalse(model.doubleTapAcceptsEntireSuggestion) + XCTAssertFalse(model.hasFullAcceptanceShortcut) + } + + func test_doubleTapLabelFollowsTheAcceptWordKey() { + let model = makeModel() + model.setDoubleTapFullAcceptance() + + model.setAcceptanceKey(keyCode: 36, modifiers: [], label: "Return") + + XCTAssertEqual(model.fullAcceptanceDisplayLabel, "Return Return") + } +} From 7d78b65aa8719e9a9a43bb253f4cb5ee19ba6d7f Mon Sep 17 00:00:00 2001 From: Senad Date: Fri, 2 Oct 2026 14:53:48 +0200 Subject: [PATCH 4/4] Show the per-app double tap in inherited Accept Entire Suggestion labels The double tap is a double press of the focused app's own Accept Word key. Per-app rows now label the inherited full-accept shortcut with that key ("Return Return" when an app overrides Accept Word), and show no double tap where Accept Word is disabled, instead of the global "None". Co-Authored-By: Claude Opus 5.5 --- .../Settings/SuggestionSettingsModel.swift | 13 ++++++++++++ Cotabby/UI/Settings/Panes/AppsPaneView.swift | 15 +++++++++++-- ...uggestionSettingsModelDoubleTapTests.swift | 21 +++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/Cotabby/Models/Settings/SuggestionSettingsModel.swift b/Cotabby/Models/Settings/SuggestionSettingsModel.swift index c49e2cb6..084c0fbd 100644 --- a/Cotabby/Models/Settings/SuggestionSettingsModel.swift +++ b/Cotabby/Models/Settings/SuggestionSettingsModel.swift @@ -940,6 +940,19 @@ final class SuggestionSettingsModel: ObservableObject { isDoubleTapFullAcceptanceActive ? "\(acceptanceKeyLabel) \(acceptanceKeyLabel)" : fullAcceptanceKeyLabel } + /// What accepts the whole suggestion in one app when it inherits the global shortcut. The + /// double tap is a double press of that app's own Accept Word key, so a per-app Accept Word + /// override changes it too, and disabling Accept Word there leaves no double tap at all. + func inheritedFullAcceptanceDisplayLabel(forBundleIdentifier bundleIdentifier: String?) -> String { + let fullAccept = resolvedFullAcceptBinding(forBundleIdentifier: bundleIdentifier) + guard doubleTapAcceptsEntireSuggestion, fullAccept.keyCode == Self.disabledKeyCode else { + return fullAccept.label + } + let accept = resolvedAcceptBinding(forBundleIdentifier: bundleIdentifier) + guard accept.keyCode != Self.disabledKeyCode else { return fullAccept.label } + return "\(accept.label) \(accept.label)" + } + /// Whether any shortcut accepts the whole suggestion, so views can offer Clear. var hasFullAcceptanceShortcut: Bool { isDoubleTapFullAcceptanceActive || fullAcceptanceKeyCode != Self.disabledKeyCode diff --git a/Cotabby/UI/Settings/Panes/AppsPaneView.swift b/Cotabby/UI/Settings/Panes/AppsPaneView.swift index e0a26278..18152e94 100644 --- a/Cotabby/UI/Settings/Panes/AppsPaneView.swift +++ b/Cotabby/UI/Settings/Panes/AppsPaneView.swift @@ -171,7 +171,8 @@ struct AppsPaneView: View { override: override, action: .acceptEntireSuggestion, title: "Accept Entire Suggestion", - inheritsHelp: "Uses the global shortcut (\(suggestionSettings.fullAcceptanceDisplayLabel)). " + inheritsHelp: "Uses the global shortcut (" + + "\(suggestionSettings.inheritedFullAcceptanceDisplayLabel(forBundleIdentifier: override.bundleIdentifier))). " + "Click Change to set a custom key for \(override.displayName)." ) } @@ -196,7 +197,7 @@ struct AppsPaneView: View { .frame(width: 180, alignment: .leading) if inherits { - Text("Uses global (\(binding.label))") + Text("Uses global (\(inheritedLabel(override: override, action: action, binding: binding)))") .font(.caption) .foregroundStyle(.secondary) .help(inheritsHelp) @@ -261,6 +262,16 @@ struct AppsPaneView: View { } } + /// The inherited shortcut as this app sees it; for full acceptance that may be a double tap. + private func inheritedLabel( + override: PerAppShortcutOverride, + action: PerAppShortcutAction, + binding: ShortcutResolver.ResolvedBinding + ) -> String { + guard action == .acceptEntireSuggestion else { return binding.label } + return suggestionSettings.inheritedFullAcceptanceDisplayLabel(forBundleIdentifier: override.bundleIdentifier) + } + private func recordingBinding( forBundleIdentifier bundleIdentifier: String, action: PerAppShortcutAction diff --git a/CotabbyTests/Models/Settings/SuggestionSettingsModelDoubleTapTests.swift b/CotabbyTests/Models/Settings/SuggestionSettingsModelDoubleTapTests.swift index f71ef581..58b207c4 100644 --- a/CotabbyTests/Models/Settings/SuggestionSettingsModelDoubleTapTests.swift +++ b/CotabbyTests/Models/Settings/SuggestionSettingsModelDoubleTapTests.swift @@ -118,4 +118,25 @@ final class SuggestionSettingsModelDoubleTapTests: XCTestCase { XCTAssertEqual(model.fullAcceptanceDisplayLabel, "Return Return") } + + func test_inheritedLabelUsesTheAppsOwnAcceptWordKey() { + let model = makeModel() + model.setDoubleTapFullAcceptance() + model.setPerAppAcceptKey(bundleIdentifier: "com.apple.Terminal", displayName: "Terminal", + keyCode: 36, modifiers: [], label: "Return") + + XCTAssertEqual(model.inheritedFullAcceptanceDisplayLabel(forBundleIdentifier: "com.apple.mail"), "Tab Tab") + XCTAssertEqual(model.inheritedFullAcceptanceDisplayLabel(forBundleIdentifier: "com.apple.Terminal"), "Return Return") + } + + func test_inheritedLabelHasNoDoubleTapWhereAcceptWordIsDisabled() { + let model = makeModel() + model.setDoubleTapFullAcceptance() + model.setPerAppAcceptKey(bundleIdentifier: "com.apple.Terminal", displayName: "Terminal", + keyCode: SuggestionSettingsModel.disabledKeyCode, modifiers: [], label: "None") + + XCTAssertEqual(model.inheritedFullAcceptanceDisplayLabel(forBundleIdentifier: "com.apple.Terminal"), + SuggestionSettingsModel.disabledKeyLabel) + } + }