Block assigning one shortcut to multiple keybindings#465
Merged
Conversation
Toggle Tabby silently did nothing whenever its hotkey collided with an accept binding: the default Accept Entire Suggestion key is backtick, and a user who bound the toggle to the same key hit a collision the toggle never won. The head-inserted accept tap consumes the shared key before the toggle tap sees it (logs show 'Accept tap consumed keyCode=50'), so the toggle callback never fires. Nothing in the recorder or setters guarded the global toggle against duplicating another action's binding. Add a single source of truth, SuggestionSettingsModel.conflictingShortcutName, that reports which action already owns a proposed key combo (the disabled sentinel never conflicts; same key with different modifiers stays distinct). KeyRecorderView now consults it and refuses to commit a duplicate, staying in recording mode with a red 'Already used by X' prompt so the user can pick another key. Wired into both the Settings shortcuts pane and onboarding. Add ShortcutConflictTests covering the toggle-vs-full-accept collision, the self-exclusion, the disabled sentinel, and modifier distinctness.
Comment on lines
+40
to
+49
| func test_conflict_allowsUnusedCombo() { | ||
| let model = makeModel() | ||
| // keyCode 49 is Space, unused by any default binding. | ||
| let conflict = model.conflictingShortcutName( | ||
| keyCode: 49, | ||
| modifiers: [], | ||
| excluding: .toggleTabby | ||
| ) | ||
| XCTAssertNil(conflict) | ||
| } |
Contributor
There was a problem hiding this comment.
The "allows unused combo" test hard-codes keyCode 49 (Space) with a comment that it is "unused by any default binding." If the default bindings ever change and Space is assigned, the test would still pass but for a subtly wrong reason. Adding precondition assertions makes the assumption explicit and the test self-documenting.
Suggested change
| func test_conflict_allowsUnusedCombo() { | |
| let model = makeModel() | |
| // keyCode 49 is Space, unused by any default binding. | |
| let conflict = model.conflictingShortcutName( | |
| keyCode: 49, | |
| modifiers: [], | |
| excluding: .toggleTabby | |
| ) | |
| XCTAssertNil(conflict) | |
| } | |
| func test_conflict_allowsUnusedCombo() { | |
| let model = makeModel() | |
| // keyCode 49 is Space. Verify it isn't already taken by any of the other defaults before | |
| // using it as the "free combo" probe, so a future default-binding change won't silently | |
| // make this test pass for the wrong reason. | |
| let spaceKeyCode: CGKeyCode = 49 | |
| XCTAssertNil(model.conflictingShortcutName(keyCode: spaceKeyCode, modifiers: [], excluding: .acceptWord), | |
| "Precondition: Space must not be a default binding for acceptWord") | |
| XCTAssertNil(model.conflictingShortcutName(keyCode: spaceKeyCode, modifiers: [], excluding: .acceptEntireSuggestion), | |
| "Precondition: Space must not be a default binding for acceptEntireSuggestion") | |
| let conflict = model.conflictingShortcutName( | |
| keyCode: spaceKeyCode, | |
| modifiers: [], | |
| excluding: .toggleTabby | |
| ) | |
| XCTAssertNil(conflict) | |
| } |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Bug: Toggle Tabby's hotkey appeared to do nothing. Root cause is a binding collision, not the tap wiring. The default Accept Entire Suggestion key is backtick (`), the same key a user naturally assigns to the toggle. Nothing guarded the global toggle against duplicating another action's shortcut, so both ended up on the same key. The accept tap is head-inserted into the event chain ahead of the toggle tap, so it consumes the shared key first and the toggle callback never fires. The runtime logs confirm it:
Accept tap consumed keyCode=50(backtick) appears while the toggle-tap install/consume lines never do.Fix: Prevent the collision at the source. A new single source of truth,
SuggestionSettingsModel.conflictingShortcutName(keyCode:modifiers:excluding:), reports which action already owns a proposed combo.KeyRecorderViewconsults it and refuses to commit a duplicate, staying in recording mode with a red "Already used by X" prompt so the user can immediately try another key. Wired into both the Settings shortcuts pane and the onboarding Keybinds step.Notes:
Validation
Diagnosis was driven from
~/Library/Logs/Cotabby/cotabby.jsonl*: across the captured sessions the toggle tap install message never appears while the accept tap (33x) and observer tap (8x) do, and backtick (keyCode 50) is logged as consumed by the accept tap.Linked issues
None.
Risk / rollout notes
Cotabby.xcodeprojwas regenerated with XcodeGen to pick up the new test file (noproject.ymlchange).Greptile Summary
This PR introduces a guard against binding two shortcuts to the same key combination. A new
conflictingShortcutName(keyCode:modifiers:excluding:)method onSuggestionSettingsModelserves as the single source of truth;KeyRecorderViewconsults it before committing a new binding and shows a red "Already used by X" message instead of accepting the duplicate.SuggestionSettingsModelgains aShortcutActionenum andconflictingShortcutName/shortcutBindinghelpers to centralise conflict detection across all three configurable shortcuts.KeyRecorderViewgains an optionalconflictCheckerclosure that blocks commitment and surfaces a red conflict prompt, while keeping the monitor active so the user can immediately try another key.ShortcutsPaneViewandWelcomeVieware wired up to pass the appropriateconflictChecker, and five unit tests inShortcutConflictTestscover the key cases.Confidence Score: 4/5
Safe to merge. The change is a pure UI-layer guard with no migrations or mutations to stored bindings, so existing user settings are unaffected.
The conflict-detection logic is correct and well-tested. The two observations — installMonitor() not clearing conflictMessage, and a test relying on a hardcoded key code assumption — are both benign today but worth addressing before the view or defaults evolve.
KeyRecorderView.swift and ShortcutConflictTests.swift have the minor hardening gaps noted in the comments; all other files are straightforward wiring.
Important Files Changed
Sequence Diagram
sequenceDiagram participant User participant KeyRecorderView participant conflictChecker participant SuggestionSettingsModel User->>KeyRecorderView: Press key combo KeyRecorderView->>conflictChecker: (keyCode, modifiers) conflictChecker->>SuggestionSettingsModel: conflictingShortcutName(keyCode:modifiers:excluding:) alt another action owns this combo SuggestionSettingsModel-->>conflictChecker: Accept Entire Suggestion conflictChecker-->>KeyRecorderView: conflict name KeyRecorderView-->>User: Red Already used by X. Try another key. else combo is free SuggestionSettingsModel-->>conflictChecker: nil conflictChecker-->>KeyRecorderView: nil KeyRecorderView->>KeyRecorderView: removeMonitor() KeyRecorderView-->>User: Recording committed, onKeyRecorded called endComments Outside Diff (1)
Cotabby/UI/KeyRecorderView.swift, line 38-41 (link)installMonitor()resetsliveModifiersbut leaves any staleconflictMessagein place. Today this is harmless becauseKeyRecorderViewlives behind anif isRecordingbranch so it is always torn down and recreated, resetting@State. If that conditional is ever relaxed (e.g. the view is kept alive between sessions), the red "Already used by…" message from the previous recording attempt would be visible the moment the new one starts. A one-line reset here makes the method self-contained.Reviews (1): Last reviewed commit: "Block assigning one shortcut to multiple..." | Re-trigger Greptile