From 5cf7f99517e448c55a16e271ab4e13c37480c814 Mon Sep 17 00:00:00 2001 From: Sai Chand Date: Mon, 9 Oct 2023 16:49:37 -0400 Subject: [PATCH] feat: add vim key window navigation (closes #1229) --- resources/l10n/Localizable.strings | 6 ++ src/logic/Preferences.swift | 7 ++ .../CustomRecorderControl.swift | 16 +++-- .../preferences-window/tabs/ControlsTab.swift | 69 ++++++++++++++++++- 4 files changed, 92 insertions(+), 6 deletions(-) diff --git a/resources/l10n/Localizable.strings b/resources/l10n/Localizable.strings index c8ae7eb96..3ca161e46 100644 --- a/resources/l10n/Localizable.strings +++ b/resources/l10n/Localizable.strings @@ -376,6 +376,12 @@ /* No comment provided by engineer. */ "View existing discussions" = "View existing discussions"; +/* No comment provided by engineer. */ +"Vim keys" = "Vim keys"; + +/* No comment provided by engineer. */ +"Vim keys already assigned to other actions:\n%@" = "Vim keys already assigned to other actions:\n%@"; + /* No comment provided by engineer. */ "Visible Spaces" = "Visible Spaces"; diff --git a/src/logic/Preferences.swift b/src/logic/Preferences.swift index dd31475a2..8586d4daf 100644 --- a/src/logic/Preferences.swift +++ b/src/logic/Preferences.swift @@ -30,6 +30,7 @@ class Preferences { "quitAppShortcut": "Q", "hideShowAppShortcut": "H", "arrowKeysEnabled": "true", + "vimKeysEnabled": "false", "mouseHoverEnabled": "false", "cursorFollowFocusEnabled": "false", "showMinimizedWindows": ShowHowPreference.show.rawValue, @@ -125,6 +126,7 @@ class Preferences { static var quitAppShortcut: String { defaults.string("quitAppShortcut") } static var hideShowAppShortcut: String { defaults.string("hideShowAppShortcut") } static var arrowKeysEnabled: Bool { defaults.bool("arrowKeysEnabled") } + static var vimKeysEnabled: Bool { defaults.bool("vimKeysEnabled") } static var mouseHoverEnabled: Bool { defaults.bool("mouseHoverEnabled") } static var cursorFollowFocusEnabled: Bool { defaults.bool("cursorFollowFocusEnabled") } static var showTabsAsWindows: Bool { defaults.bool("showTabsAsWindows") } @@ -194,6 +196,11 @@ class Preferences { UserDefaults.cache.removeValue(forKey: key) } + static func remove(_ key: String) { + defaults.removeObject(forKey: key) + UserDefaults.cache.removeValue(forKey: key) + } + static var all: [String: Any] { defaults.persistentDomain(forName: App.id)! } static func migratePreferences() { diff --git a/src/ui/generic-components/CustomRecorderControl.swift b/src/ui/generic-components/CustomRecorderControl.swift index 6a8e6e204..7128e5b9a 100644 --- a/src/ui/generic-components/CustomRecorderControl.swift +++ b/src/ui/generic-components/CustomRecorderControl.swift @@ -50,11 +50,13 @@ class CustomRecorderControl: RecorderControl, RecorderControlDelegate { func alertIfSameShortcutAlreadyAssigned(_ shortcut: Shortcut, _ shortcutAlreadyAssigned: ATShortcut) { let isArrowKeys = ["←", "→", "↑", "↓"].contains(shortcutAlreadyAssigned.id) - let existing = ControlsTab.shortcutControls[shortcutAlreadyAssigned.id] + let isVimKeys = shortcutAlreadyAssigned.id.starts(with: "vimCycle") + let existingShortcutLabel = ControlsTab.shortcutControls[shortcutAlreadyAssigned.id] let alert = NSAlert() alert.alertStyle = .warning alert.messageText = NSLocalizedString("Conflicting shortcut", comment: "") - alert.informativeText = String(format: NSLocalizedString("Shortcut already assigned to another action: %@", comment: ""), (isArrowKeys ? "Arrow keys" : existing!.1).replacingOccurrences(of: " ", with: "\u{00A0}")) + alert.informativeText = String(format: NSLocalizedString("Shortcut already assigned to another action: %@", comment: ""), + (isArrowKeys ? "Arrow keys" : (isVimKeys ? "Vim keys" : existingShortcutLabel!.1)).replacingOccurrences(of: " ", with: "\u{00A0}")) if !id.starts(with: "holdShortcut") { alert.addButton(withTitle: NSLocalizedString("Unassign existing shortcut and continue", comment: "")).setAccessibilityFocused(true) } @@ -69,10 +71,14 @@ class CustomRecorderControl: RecorderControl, RecorderControlDelegate { ControlsTab.arrowKeysCheckbox.state = .off ControlsTab.arrowKeysEnabledCallback(ControlsTab.arrowKeysCheckbox) LabelAndControl.controlWasChanged(ControlsTab.arrowKeysCheckbox, nil) + } else if isVimKeys { + ControlsTab.vimKeysCheckbox.state = .off + ControlsTab.vimKeysEnabledCallback(ControlsTab.vimKeysCheckbox) + LabelAndControl.controlWasChanged(ControlsTab.vimKeysCheckbox, nil) } else { - existing!.0.objectValue = nil - ControlsTab.shortcutChangedCallback(existing!.0) - LabelAndControl.controlWasChanged(existing!.0, shortcutAlreadyAssigned.id) + existingShortcutLabel!.0.objectValue = nil + ControlsTab.shortcutChangedCallback(existingShortcutLabel!.0) + LabelAndControl.controlWasChanged(existingShortcutLabel!.0, shortcutAlreadyAssigned.id) } ControlsTab.shortcutControls[id]!.0.objectValue = shortcut ControlsTab.shortcutChangedCallback(self) diff --git a/src/ui/preferences-window/tabs/ControlsTab.swift b/src/ui/preferences-window/tabs/ControlsTab.swift index 932dc7fff..4bd2b3eca 100644 --- a/src/ui/preferences-window/tabs/ControlsTab.swift +++ b/src/ui/preferences-window/tabs/ControlsTab.swift @@ -21,6 +21,10 @@ class ControlsTab { "←": { App.app.cycleSelection(.left) }, "↑": { App.app.cycleSelection(.up) }, "↓": { App.app.cycleSelection(.down) }, + "vimCycleRight": { App.app.cycleSelection(.right) }, + "vimCycleLeft": { App.app.cycleSelection(.left) }, + "vimCycleUp": { App.app.cycleSelection(.up) }, + "vimCycleDown": { App.app.cycleSelection(.down) }, "cancelShortcut": { App.app.hideUi() }, "closeWindowShortcut": { App.app.closeSelectedWindow() }, "minDeminWindowShortcut": { App.app.minDeminSelectedWindow() }, @@ -29,6 +33,7 @@ class ControlsTab { "hideShowAppShortcut": { App.app.hideShowSelectedApp() }, ] static var arrowKeysCheckbox: NSButton! + static var vimKeysCheckbox: NSButton! static func initTab() -> NSView { let focusWindowShortcut = LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Focus selected window", comment: ""), "focusWindowShortcut", Preferences.focusWindowShortcut, labelPosition: .right) @@ -40,11 +45,13 @@ class ControlsTab { let quitAppShortcut = LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Quit app", comment: ""), "quitAppShortcut", Preferences.quitAppShortcut, labelPosition: .right) let hideShowAppShortcut = LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Hide/Show app", comment: ""), "hideShowAppShortcut", Preferences.hideShowAppShortcut, labelPosition: .right) let enableArrows = LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Arrow keys", comment: ""), "arrowKeysEnabled", extraAction: ControlsTab.arrowKeysEnabledCallback, labelPosition: .right) + let enableVimKeys = LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Vim keys", comment: ""), "vimKeysEnabled", extraAction: ControlsTab.vimKeysEnabledCallback, labelPosition: .right) arrowKeysCheckbox = enableArrows[0] as? NSButton + vimKeysCheckbox = enableVimKeys[0] as? NSButton let enableMouse = LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Mouse hover", comment: ""), "mouseHoverEnabled", labelPosition: .right) let enableCursorFollowFocus = LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Cursor follows focus", comment: ""), "cursorFollowFocusEnabled", labelPosition: .right) let selectWindowcheckboxesExplanations = LabelAndControl.makeLabel(NSLocalizedString("Also select windows using:", comment: "")) - let selectWindowCheckboxes = StackView([StackView(enableArrows), StackView(enableMouse)], .vertical) + let selectWindowCheckboxes = StackView([StackView(enableArrows), StackView(enableVimKeys), StackView(enableMouse)], .vertical) let miscCheckboxesExplanations = LabelAndControl.makeLabel(NSLocalizedString("Miscellaneous:", comment: "")) let miscCheckboxes = StackView([StackView(enableCursorFollowFocus)], .vertical) let shortcuts = StackView([focusWindowShortcut, previousWindowShortcut, cancelShortcut, closeWindowShortcut, minDeminWindowShortcut, toggleFullscreenWindowShortcut, quitAppShortcut, hideShowAppShortcut].map { (view: [NSView]) in StackView(view) }, .vertical) @@ -63,6 +70,7 @@ class ControlsTab { ]) ControlsTab.arrowKeysEnabledCallback(arrowKeysCheckbox) + ControlsTab.vimKeysEnabledCallback(vimKeysCheckbox) // trigger shortcutChanged for these shortcuts to trigger .restrictModifiers [holdShortcut, holdShortcut2, holdShortcut3, holdShortcut4, holdShortcut5].forEach { ControlsTab.shortcutChangedCallback($0[1] as! NSControl) } [nextWindowShortcut, nextWindowShortcut2, nextWindowShortcut3, nextWindowShortcut4, nextWindowShortcut5].forEach { ControlsTab.shortcutChangedCallback($0[0] as! NSControl) } @@ -219,6 +227,65 @@ class ControlsTab { } } + @objc static func vimKeysEnabledCallback(_ sender: NSControl) { + let keyActions = [ + "h": "vimCycleLeft", + "l": "vimCycleRight", + "k": "vimCycleUp", + "j": "vimCycleDown" + ] + if (sender as! NSButton).state == .on { + if App.app.preferencesWindow != nil && isClearVimKeysSuccessful() { + keyActions.forEach { addShortcut(.down, .local, Shortcut(keyEquivalent: $0)!, $1, nil) } + } else { + (sender as! NSButton).state = .off + Preferences.remove("vimKeysEnabled") + } + } else { + keyActions.forEach { removeShortcutIfExists($1) } + } + } + + private static func isClearVimKeysSuccessful() -> Bool { + let vimKeys = ["h", "l", "j", "k"] + var conflicts = [String: String]() + shortcuts.forEach { + let keymap = $1.shortcut.characters + if keymap != nil && vimKeys.contains(keymap!) { + let control_id = $1.id + conflicts[control_id] = shortcutControls[control_id]!.1 + } + } + if !conflicts.isEmpty { + if !shouldClearConflictingShortcuts(conflicts.map { $0.value }) { + return false + } + conflicts.forEach { + removeShortcutIfExists($0.key) + let existing = shortcutControls[$0.key] + if existing != nil { + existing!.0.objectValue = nil + shortcutChangedCallback(existing!.0) + LabelAndControl.controlWasChanged(existing!.0, $0.key) + } + } + } + return true + } + + private static func shouldClearConflictingShortcuts(_ conflicts: [String]) -> Bool { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = NSLocalizedString("Conflicting shortcut", comment: "") + let informativeText = conflicts.map { "• " + $0 }.joined(separator: "\n") + alert.informativeText = String(format: NSLocalizedString("Vim keys already assigned to other actions:\n%@", comment: ""), informativeText.replacingOccurrences(of: " ", with: "\u{00A0}")) + alert.addButton(withTitle: NSLocalizedString("Unassign existing shortcut and continue", comment: "")).setAccessibilityFocused(true) + let cancelButton = alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "")) + cancelButton.keyEquivalent = "\u{1b}" + let userChoice = alert.runModal() + return userChoice == .alertFirstButtonReturn + } + private static func removeShortcutIfExists(_ controlId: String) { if let atShortcut = shortcuts[controlId] { if atShortcut.scope == .global {