diff --git a/resources/l10n/Localizable.strings b/resources/l10n/Localizable.strings index dd409e31..433da53c 100644 --- a/resources/l10n/Localizable.strings +++ b/resources/l10n/Localizable.strings @@ -1,3 +1,15 @@ +/* No comment provided by engineer. */ +"3-finger Horizontal Swipe" = "3-finger Horizontal Swipe"; + +/* No comment provided by engineer. */ +"3-finger Vertical Swipe" = "3-finger Vertical Swipe"; + +/* No comment provided by engineer. */ +"4-finger Horizontal Swipe" = "4-finger Horizontal Swipe"; + +/* No comment provided by engineer. */ +"4-finger Vertical Swipe" = "4-finger Vertical Swipe"; + /* Menubar option. %@ is AltTab */ "About %@" = "About %@"; @@ -220,12 +232,6 @@ /* No comment provided by engineer. */ "Hold" = "Hold"; -/* No comment provided by engineer. */ -"Horizontal Swipe with Four Fingers" = "Horizontal Swipe with Four Fingers"; - -/* No comment provided by engineer. */ -"Horizontal Swipe with Three Fingers" = "Horizontal Swipe with Three Fingers"; - /* No comment provided by engineer. */ "I think the app could be improved with…" = "I think the app could be improved with…"; @@ -295,6 +301,9 @@ /* No comment provided by engineer. */ "Not allowed" = "Not allowed"; +/* No comment provided by engineer. */ +"Not set" = "Not set"; + /* No comment provided by engineer. */ "Only on different screen" = "Only on different screen"; @@ -325,7 +334,8 @@ /* No comment provided by engineer. */ "Preview selected window" = "Preview selected window"; -/* Menubar option. %@ is AltTab */ +/* %@ is AltTab + Menubar option. %@ is AltTab */ "Quit %@" = "Quit %@"; /* No comment provided by engineer. */ @@ -404,13 +414,7 @@ "Share improvement ideas, or report bugs" = "Share improvement ideas, or report bugs"; /* No comment provided by engineer. */ -"Shortcut 1" = "Shortcut 1"; - -/* No comment provided by engineer. */ -"Shortcut 2" = "Shortcut 2"; - -/* No comment provided by engineer. */ -"Shortcut 3" = "Shortcut 3"; +"Shortcut" = "Shortcut"; /* No comment provided by engineer. */ "Shortcut already assigned to another action: %@" = "Shortcut already assigned to another action: %@"; @@ -536,7 +540,7 @@ "Trackpad haptic feedback" = "Trackpad haptic feedback"; /* No comment provided by engineer. */ -"Trigger shortcut" = "Trigger shortcut"; +"Trigger" = "Trigger"; /* No comment provided by engineer. */ "Unassign existing shortcut and continue" = "Unassign existing shortcut and continue"; @@ -550,12 +554,6 @@ /* No comment provided by engineer. */ "Version" = "Version"; -/* No comment provided by engineer. */ -"Vertical Swipe with Four Fingers" = "Vertical Swipe with Four Fingers"; - -/* No comment provided by engineer. */ -"Vertical Swipe with Three Fingers" = "Vertical Swipe with Three Fingers"; - /* No comment provided by engineer. */ "View existing discussions" = "View existing discussions"; diff --git a/src/logic/ATShortcut.swift b/src/logic/ATShortcut.swift index 1ec68718..ea62181a 100644 --- a/src/logic/ATShortcut.swift +++ b/src/logic/ATShortcut.swift @@ -19,9 +19,7 @@ class ATShortcut { } func matches(_ id: Int?, _ shortcutState: ShortcutState?, _ keyCode: UInt32?, _ modifiers: NSEvent.ModifierFlags?) -> Bool { - if let id, let shortcutState { - let shortcutIndex = id - let shortcutId = KeyboardEventsTestable.globalShortcutsIds.first { $0.value == shortcutIndex }!.key + if let id, let shortcutState, let shortcutId = KeyboardEventsTestable.globalShortcutsIds.first(where: { $0.value == id })?.key { if shortcutId == self.id { state = shortcutState if (triggerPhase == .down && state == .down) || (triggerPhase == .up && state == .up) { diff --git a/src/logic/MacroPreferences.swift b/src/logic/MacroPreferences.swift index ef1ca1d9..c82c0eb8 100644 --- a/src/logic/MacroPreferences.swift +++ b/src/logic/MacroPreferences.swift @@ -23,10 +23,10 @@ enum GesturePreference: CaseIterable, MacroPreference { var localizedString: LocalizedString { switch self { case .disabled: return NSLocalizedString("Disabled", comment: "") - case .threeFingerHorizontalSwipe: return NSLocalizedString("Horizontal Swipe with Three Fingers", comment: "") - case .threeFingerVerticalSwipe: return NSLocalizedString("Vertical Swipe with Three Fingers", comment: "") - case .fourFingerHorizontalSwipe: return NSLocalizedString("Horizontal Swipe with Four Fingers", comment: "") - case .fourFingerVerticalSwipe: return NSLocalizedString("Vertical Swipe with Four Fingers", comment: "") + case .threeFingerHorizontalSwipe: return NSLocalizedString("3-finger Horizontal Swipe", comment: "") + case .threeFingerVerticalSwipe: return NSLocalizedString("3-finger Vertical Swipe", comment: "") + case .fourFingerHorizontalSwipe: return NSLocalizedString("4-finger Horizontal Swipe", comment: "") + case .fourFingerVerticalSwipe: return NSLocalizedString("4-finger Vertical Swipe", comment: "") } } diff --git a/src/logic/Preferences.swift b/src/logic/Preferences.swift index a0e6240c..0cc2f1c1 100644 --- a/src/logic/Preferences.swift +++ b/src/logic/Preferences.swift @@ -4,13 +4,13 @@ import ShortcutRecorder class Preferences { // default values - static var defaultValues: [String: String] = [ + static var defaultValues: [String: String] = { + var values: [String: String] = [ + "shortcutCount": "2", "holdShortcut": "⌥", "holdShortcut2": "⌥", - "holdShortcut3": "⌥", "nextWindowShortcut": "⇥", "nextWindowShortcut2": keyAboveTabDependingOnInputSource(), - "nextWindowShortcut3": "", "nextWindowGesture": GesturePreference.disabled.indexAsString, "focusWindowShortcut": returnKeyEquivalent(), "previousWindowShortcut": "⇧", @@ -28,24 +28,14 @@ class Preferences { "cursorFollowFocus": CursorFollowFocus.never.indexAsString, "showMinimizedWindows": ShowHowPreference.show.indexAsString, "showMinimizedWindows2": ShowHowPreference.show.indexAsString, - "showMinimizedWindows3": ShowHowPreference.show.indexAsString, - "showMinimizedWindows4": ShowHowPreference.show.indexAsString, "showHiddenWindows": ShowHowPreference.show.indexAsString, "showHiddenWindows2": ShowHowPreference.show.indexAsString, - "showHiddenWindows3": ShowHowPreference.show.indexAsString, - "showHiddenWindows4": ShowHowPreference.show.indexAsString, "showFullscreenWindows": ShowHowPreference.show.indexAsString, "showFullscreenWindows2": ShowHowPreference.show.indexAsString, - "showFullscreenWindows3": ShowHowPreference.show.indexAsString, - "showFullscreenWindows4": ShowHowPreference.show.indexAsString, "showWindowlessApps": ShowHowPreference.showAtTheEnd.indexAsString, "showWindowlessApps2": ShowHowPreference.showAtTheEnd.indexAsString, - "showWindowlessApps3": ShowHowPreference.showAtTheEnd.indexAsString, - "showWindowlessApps4": ShowHowPreference.showAtTheEnd.indexAsString, "windowOrder": WindowOrderPreference.recentlyFocused.indexAsString, "windowOrder2": WindowOrderPreference.recentlyFocused.indexAsString, - "windowOrder3": WindowOrderPreference.recentlyFocused.indexAsString, - "windowOrder4": WindowOrderPreference.recentlyFocused.indexAsString, "showTabsAsWindows": "false", "hideColoredCircles": "false", "windowDisplayDelay": "100", @@ -60,16 +50,10 @@ class Preferences { "showTitles": ShowTitlesPreference.windowTitle.indexAsString, "appsToShow": AppsToShowPreference.all.indexAsString, "appsToShow2": AppsToShowPreference.active.indexAsString, - "appsToShow3": AppsToShowPreference.nonActive.indexAsString, - "appsToShow4": AppsToShowPreference.all.indexAsString, "spacesToShow": SpacesToShowPreference.all.indexAsString, "spacesToShow2": SpacesToShowPreference.all.indexAsString, - "spacesToShow3": SpacesToShowPreference.all.indexAsString, - "spacesToShow4": SpacesToShowPreference.all.indexAsString, "screensToShow": ScreensToShowPreference.all.indexAsString, "screensToShow2": ScreensToShowPreference.all.indexAsString, - "screensToShow3": ScreensToShowPreference.all.indexAsString, - "screensToShow4": ScreensToShowPreference.all.indexAsString, "fadeOutAnimation": "false", "previewFadeInAnimation": "true", "hideSpaceNumberLabels": "false", @@ -83,22 +67,37 @@ class Preferences { "crashPolicy": CrashPolicyPreference.ask.indexAsString, "shortcutStyle": ShortcutStylePreference.focusOnRelease.indexAsString, "shortcutStyle2": ShortcutStylePreference.focusOnRelease.indexAsString, - "shortcutStyle3": ShortcutStylePreference.focusOnRelease.indexAsString, - "shortcutStyle4": ShortcutStylePreference.focusOnRelease.indexAsString, "hideAppBadges": "false", "hideThumbnails": "false", "previewFocusedWindow": "false", "screenRecordingPermissionSkipped": "false", "trackpadHapticFeedbackEnabled": "true", "settingsWindowShownOnFirstLaunch": "false", - ] + ] + (0.. Int { - guard let number = name.last?.wholeNumberValue else { return 0 } + let digits = String(name.reversed().prefix { $0.isNumber }.reversed()) + guard !digits.isEmpty, let number = Int(digits) else { return 0 } return number - 1 } } diff --git a/src/logic/PreferencesMigrations.swift b/src/logic/PreferencesMigrations.swift index e5134fe1..37ad2faf 100644 --- a/src/logic/PreferencesMigrations.swift +++ b/src/logic/PreferencesMigrations.swift @@ -21,39 +21,42 @@ class PreferencesMigrations { private static func updateToNewPreferences(_ versionInPlist: String) { Logger.debug { "App-version:\(App.version), Plist-version:\(versionInPlist)" } // x.compare(y) is .orderedDescending if x > y - if versionInPlist.compare("7.27.0", options: .numeric) != .orderedDescending { - migrateCursorFollowFocus() - if versionInPlist.compare("7.26.0", options: .numeric) != .orderedDescending { - migrateShowWindowlessApps() - if versionInPlist.compare("7.25.0", options: .numeric) != .orderedDescending { - migrateHideWindowlessApps() - if versionInPlist.compare("7.13.1", options: .numeric) != .orderedDescending { - migrateGestures() - if versionInPlist.compare("7.8.0", options: .numeric) != .orderedDescending { - migrateMenubarIconWithNewShownToggle() - if versionInPlist.compare("7.0.0", options: .numeric) != .orderedDescending { - migratePreferencesIndexes() - if versionInPlist.compare("6.43.0", options: .numeric) != .orderedDescending { - migrateBlacklists() - if versionInPlist.compare("6.28.1", options: .numeric) != .orderedDescending { - migrateMinMaxWindowsWidthInRow() - if versionInPlist.compare("6.27.1", options: .numeric) != .orderedDescending { - // "Start at login" new implem doesn't use Login Items; we remove the entry from previous versions - (PreferencesMigrations.self as AvoidDeprecationWarnings.Type).migrateLoginItem() - if versionInPlist.compare("6.23.0", options: .numeric) != .orderedDescending { - // "Show windows from:" got the "Active Space" option removed - migrateShowWindowsFrom() - if versionInPlist.compare("6.18.1", options: .numeric) != .orderedDescending { - // nextWindowShortcut used to be able to have modifiers already present in holdShortcut; we remove these - migrateNextWindowShortcuts() - // dropdowns preferences used to store English text; now they store indexes - migrateDropdownsFromTextToIndexes() - // the "Hide menubar icon" checkbox was replaced with a dropdown of: icon1, icon2, hidden - migrateMenubarIconFromCheckboxToDropdown() - // "Show minimized/hidden/fullscreen windows" checkboxes were replaced with dropdowns - migrateShowWindowsCheckboxToDropdown() - // "Max size on screen" was split into max width and max height - migrateMaxSizeOnScreenToWidthAndHeight() + if versionInPlist.compare("9.0.0", options: .numeric) != .orderedDescending { + migrateShortcutIndexes() + if versionInPlist.compare("7.27.0", options: .numeric) != .orderedDescending { + migrateCursorFollowFocus() + if versionInPlist.compare("7.26.0", options: .numeric) != .orderedDescending { + migrateShowWindowlessApps() + if versionInPlist.compare("7.25.0", options: .numeric) != .orderedDescending { + migrateHideWindowlessApps() + if versionInPlist.compare("7.13.1", options: .numeric) != .orderedDescending { + migrateGestures() + if versionInPlist.compare("7.8.0", options: .numeric) != .orderedDescending { + migrateMenubarIconWithNewShownToggle() + if versionInPlist.compare("7.0.0", options: .numeric) != .orderedDescending { + migratePreferencesIndexes() + if versionInPlist.compare("6.43.0", options: .numeric) != .orderedDescending { + migrateBlacklists() + if versionInPlist.compare("6.28.1", options: .numeric) != .orderedDescending { + migrateMinMaxWindowsWidthInRow() + if versionInPlist.compare("6.27.1", options: .numeric) != .orderedDescending { + // "Start at login" new implem doesn't use Login Items; we remove the entry from previous versions + (PreferencesMigrations.self as AvoidDeprecationWarnings.Type).migrateLoginItem() + if versionInPlist.compare("6.23.0", options: .numeric) != .orderedDescending { + // "Show windows from:" got the "Active Space" option removed + migrateShowWindowsFrom() + if versionInPlist.compare("6.18.1", options: .numeric) != .orderedDescending { + // nextWindowShortcut used to be able to have modifiers already present in holdShortcut; we remove these + migrateNextWindowShortcuts() + // dropdowns preferences used to store English text; now they store indexes + migrateDropdownsFromTextToIndexes() + // the "Hide menubar icon" checkbox was replaced with a dropdown of: icon1, icon2, hidden + migrateMenubarIconFromCheckboxToDropdown() + // "Show minimized/hidden/fullscreen windows" checkboxes were replaced with dropdowns + migrateShowWindowsCheckboxToDropdown() + // "Max size on screen" was split into max width and max height + migrateMaxSizeOnScreenToWidthAndHeight() + } } } } @@ -67,6 +70,28 @@ class PreferencesMigrations { } } + // gesture index moved from 3 (suffix "4") to 9 (suffix "10"); set shortcutCount for users who had a 3rd shortcut + private static func migrateShortcutIndexes() { + let gesturePrefs = [ + "nextWindowGesture", "appsToShow", "spacesToShow", "screensToShow", + "showMinimizedWindows", "showHiddenWindows", "showFullscreenWindows", + "showWindowlessApps", "windowOrder", "shortcutStyle", + ] + for baseName in gesturePrefs { + if let old = UserDefaults.standard.string(forKey: baseName + "4") { + UserDefaults.standard.set(old, forKey: baseName + "10") + UserDefaults.standard.removeObject(forKey: baseName + "4") + } + } + let allPerShortcutPrefs = gesturePrefs + ["holdShortcut", "nextWindowShortcut"] + let hasThirdShortcut = allPerShortcutPrefs.contains { + UserDefaults.standard.string(forKey: $0 + "3") != nil + } + if hasThirdShortcut { + UserDefaults.standard.set("3", forKey: "shortcutCount") + } + } + // cursorFollowFocus was a toggle. It's now a dropdown // before: cursorFollowFocusEnabled: true/false // after: cursorFollowFocus: 0 (never), 1 (always), 2 (differentScreen) diff --git a/src/logic/events/CliEvents.swift b/src/logic/events/CliEvents.swift index c5042a1f..4cf37887 100644 --- a/src/logic/events/CliEvents.swift +++ b/src/logic/events/CliEvents.swift @@ -84,7 +84,7 @@ class CliServer { return noOutput } if rawValue.hasPrefix("--show="), - let shortcutIndex = Int(rawValue.dropFirst("--show=".count)), (0...3).contains(shortcutIndex) { + let shortcutIndex = Int(rawValue.dropFirst("--show=".count)), (0.. abs(event.scrollingDeltaX) && abs(event.scrollingDeltaY) > 0.1 } + private func canScrollInEventDirection(_ event: NSEvent) -> Bool { + let maxOffset = maxVerticalOffset() + guard maxOffset > 0 else { return false } + let y = contentView.bounds.origin.y + let dy = normalizedVerticalDelta(event) + if dy > 0 { + return y > 0.5 + } + if dy < 0 { + return y < maxOffset - 0.5 + } + return false + } + + private func maxVerticalOffset() -> CGFloat { + guard let content = documentView else { return 0 } + return max(0, content.fittingSize.height - contentView.bounds.height) + } + + private func normalizedVerticalDelta(_ event: NSEvent) -> CGFloat { + let delta = event.hasPreciseScrollingDeltas ? event.scrollingDeltaY : event.deltaY + return event.isDirectionInvertedFromDevice ? -delta : delta + } + private func parentScrollView() -> NSScrollView? { var parent = superview while let view = parent { diff --git a/src/ui/settings-window/SettingsWindow.swift b/src/ui/settings-window/SettingsWindow.swift index d55e4d31..622799c4 100644 --- a/src/ui/settings-window/SettingsWindow.swift +++ b/src/ui/settings-window/SettingsWindow.swift @@ -501,7 +501,7 @@ class SettingsWindow: NSWindow { private let sectionsStack = NSStackView() private let supportButton = AboutTab.makeSupportProjectButton() private let resetButton = NSButton(title: NSLocalizedString("Reset settings and restart…", comment: ""), target: nil, action: nil) - private let quitButton = NSButton(title: NSLocalizedString("Quit %@", comment: ""), target: nil, action: #selector(NSApplication.terminate(_:))) + private let quitButton = NSButton(title: String(format: NSLocalizedString("Quit %@", comment: "%@ is AltTab"), App.name), target: nil, action: #selector(NSApplication.terminate(_:))) private var sections = [SettingsSection]() private var visibleSections = [SettingsSection]() private var selectedSectionId: String? diff --git a/src/ui/settings-window/tabs/controls/ControlsTab.swift b/src/ui/settings-window/tabs/controls/ControlsTab.swift index 1b2c910b..fb2fabcb 100644 --- a/src/ui/settings-window/tabs/controls/ControlsTab.swift +++ b/src/ui/settings-window/tabs/controls/ControlsTab.swift @@ -2,6 +2,127 @@ import Cocoa import Carbon.HIToolbox.Events import ShortcutRecorder +private class ShortcutSidebarRow: ClickHoverStackView { + private let titleLabel = NSTextField(labelWithString: "") + private let summaryLabel = NSTextField(labelWithString: "") + private let chevronLabel = NSTextField(labelWithString: "›") + private let textColumn = NSStackView() + private var isSelectedRow = false + private var isHoveredRow = false + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + orientation = .horizontal + alignment = .centerY + spacing = 8 + translatesAutoresizingMaskIntoConstraints = false + wantsLayer = true + layer?.cornerRadius = TableGroupView.cornerRadius + textColumn.orientation = .vertical + textColumn.alignment = .leading + textColumn.spacing = 0 + textColumn.translatesAutoresizingMaskIntoConstraints = false + let spacer = NSView() + titleLabel.alignment = .left + summaryLabel.alignment = .left + summaryLabel.font = NSFont.systemFont(ofSize: 12) + summaryLabel.textColor = .secondaryLabelColor + chevronLabel.font = NSFont.systemFont(ofSize: 22) + chevronLabel.textColor = .secondaryLabelColor + textColumn.addArrangedSubview(titleLabel) + textColumn.addArrangedSubview(summaryLabel) + addArrangedSubview(textColumn) + addArrangedSubview(spacer) + addArrangedSubview(chevronLabel) + textColumn.leadingAnchor.constraint(equalTo: leadingAnchor, constant: TableGroupView.padding).isActive = true + textColumn.trailingAnchor.constraint(lessThanOrEqualTo: chevronLabel.leadingAnchor, constant: -8).isActive = true + chevronLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -TableGroupView.padding).isActive = true + updateStyle() + } + + required init?(coder: NSCoder) { + fatalError("Class only supports programmatic initialization") + } + + func setContent(_ title: String, _ summary: String) { + titleLabel.stringValue = title + summaryLabel.stringValue = summary + } + + func setSelected(_ selected: Bool) { + isSelectedRow = selected + updateStyle() + } + + func setHovered(_ hovered: Bool) { + isHoveredRow = hovered + updateStyle() + } + + private func updateStyle() { + let selectedColor = NSColor.systemAccentColor.withAlphaComponent(0.16) + let backgroundColor = isSelectedRow ? selectedColor : (isHoveredRow ? NSColor.tableHoverColor : .clear) + let titleFont = NSFont.systemFont(ofSize: 13, weight: isSelectedRow ? .semibold : .regular) + titleLabel.attributedStringValue = NSAttributedString(string: titleLabel.stringValue, attributes: [.font: titleFont, .foregroundColor: NSColor.labelColor]) + layer?.backgroundColor = backgroundColor.cgColor + } +} + +private class ControlsSidebarScrollView: NSScrollView { + override class var isCompatibleWithResponsiveScrolling: Bool { true } + + override func scrollWheel(with event: NSEvent) { + guard shouldHandleVerticalScroll(event) else { + super.scrollWheel(with: event) + return + } + if canScrollInEventDirection(event) { + super.scrollWheel(with: event) + } else { + parentScrollView()?.scrollWheel(with: event) + } + } + + private func shouldHandleVerticalScroll(_ event: NSEvent) -> Bool { + abs(event.scrollingDeltaY) > abs(event.scrollingDeltaX) && abs(event.scrollingDeltaY) > 0.1 + } + + private func canScrollInEventDirection(_ event: NSEvent) -> Bool { + let maxOffset = maxVerticalOffset() + guard maxOffset > 0 else { return false } + let y = contentView.bounds.origin.y + let dy = normalizedVerticalDelta(event) + if dy > 0 { + return y > 0.5 + } + if dy < 0 { + return y < maxOffset - 0.5 + } + return false + } + + private func maxVerticalOffset() -> CGFloat { + guard let content = documentView?.subviews.first else { return 0 } + return max(0, content.fittingSize.height - contentView.bounds.height) + } + + private func normalizedVerticalDelta(_ event: NSEvent) -> CGFloat { + let delta = event.hasPreciseScrollingDeltas ? event.scrollingDeltaY : event.deltaY + return event.isDirectionInvertedFromDevice ? -delta : delta + } + + private func parentScrollView() -> NSScrollView? { + var parent = superview + while let view = parent { + if let scrollView = view as? NSScrollView { + return scrollView + } + parent = view.superview + } + return nil + } +} + class ControlsTab { static var shortcuts = [String: ATShortcut]() static var shortcutControls = [String: (CustomRecorderControl, String)]() @@ -34,16 +155,28 @@ class ControlsTab { static var arrowKeysCheckbox: Switch! static var vimKeysCheckbox: Switch! - static var tableGroupViews: [TableGroupView]! - static var shortcutsWhenActiveSheet: ShortcutsWhenActiveSheet! static var additionalControlsSheet: AdditionalControlsSheet! - private static let managedShortcutPreferences = [ - "holdShortcut", "holdShortcut2", "holdShortcut3", - "nextWindowShortcut", "nextWindowShortcut2", "nextWindowShortcut3", + + private static let shortcutSidebarWidth = CGFloat(200) + private static let sidebarRowHeight = CGFloat(52) + private static var shortcutEditorWidth: CGFloat { SettingsWindow.contentWidth - shortcutSidebarWidth - 1 } + private static let gestureSelectionIndex = -1 + private static let staticManagedShortcutPreferences = [ "focusWindowShortcut", "previousWindowShortcut", "cancelShortcut", "searchShortcut", "lockSearchShortcut", "closeWindowShortcut", "minDeminWindowShortcut", "toggleFullscreenWindowShortcut", "quitAppShortcut", "hideShowAppShortcut", ] + private static let removableShortcutPreferences = [ + "holdShortcut", "nextWindowShortcut", + "appsToShow", "spacesToShow", "screensToShow", + "showMinimizedWindows", "showHiddenWindows", "showFullscreenWindows", "showWindowlessApps", + "windowOrder", "shortcutStyle", + ] + private static let shortcutDropdownPreferences = [ + "appsToShow", "spacesToShow", "screensToShow", + "showMinimizedWindows", "showHiddenWindows", "showFullscreenWindows", "showWindowlessApps", + "windowOrder", + ] private static let arrowKeys = ["←", "→", "↑", "↓"] private static let vimKeyActions = [ "h": "vimCycleLeft", @@ -52,89 +185,235 @@ class ControlsTab { "j": "vimCycleDown", ] + private static var selectedShortcutIndex = 0 + private static var shortcutRowsStackView: NSStackView? + private static var shortcutRows = [ShortcutSidebarRow]() + private static var shortcutEditorViews = [TableGroupView]() + private static var gestureSidebarRow: ShortcutSidebarRow? + private static var gestureEditorView: TableGroupView? + private static var shortcutCountButtons: NSSegmentedControl? + static func initializePreferencesDependentState() { - managedShortcutPreferences.forEach { applyShortcutPreference($0) } + applyActiveShortcutPreferences() + staticManagedShortcutPreferences.forEach { applyShortcutPreference($0) } applyArrowKeysPreference() applyVimKeysPreferenceWithoutDialogs() } static func preferenceChanged(_ key: String) { switch key { - case let k where managedShortcutPreferences.contains(k): applyShortcutPreference(k) - case "arrowKeysEnabled": applyArrowKeysPreference() - case "vimKeysEnabled" where vimKeysCheckbox == nil: applyVimKeysPreferenceWithoutDialogs() - default: break + case "shortcutCount": + applyActiveShortcutPreferences() + (0.. NSView { - let (holdShortcut, nextWindowShortcut, tab1View) = shortcutTab(0) - let (holdShortcut2, nextWindowShortcut2, tab2View) = shortcutTab(1) - let (holdShortcut3, nextWindowShortcut3, tab3View) = shortcutTab(2) - let tab6View = gestureTab(Preferences.gestureIndex) - tableGroupViews = [tab1View, tab2View, tab3View, tab6View] - // trigger shortcutChanged for these shortcuts to trigger .restrictModifiers - [holdShortcut, holdShortcut2, holdShortcut3].forEach { ControlsTab.shortcutChangedCallback($0[1] as! NSControl) } - [nextWindowShortcut, nextWindowShortcut2, nextWindowShortcut3].forEach { ControlsTab.shortcutChangedCallback($0[0] as! NSControl) } - let table = TableGroupView(hasHeader: true, width: SettingsWindow.contentWidth) - let tab = NSSegmentedControl(labels: [ - NSLocalizedString("Shortcut 1", comment: ""), - NSLocalizedString("Shortcut 2", comment: ""), - NSLocalizedString("Shortcut 3", comment: ""), - NSLocalizedString("Gesture", comment: ""), - ], trackingMode: .selectOne, target: self, action: #selector(switchTab(_:))) - tab.selectedSegment = 0 - tab.segmentStyle = .automatic - tab.widthAnchor.constraint(equalToConstant: SettingsWindow.contentWidth).isActive = true - table.addHeader(views: [tab]) - let additionalControlsButton = NSButton(title: NSLocalizedString("Additional controls…", comment: ""), target: self, action: #selector(ControlsTab.showAdditionalControlsSettings)) - let shortcutsButton = NSButton(title: NSLocalizedString("Shortcuts when active…", comment: ""), target: self, action: #selector(ControlsTab.showShortcutsSettings)) + shortcutEditorViews = (0.. ([NSView], [NSView], TableGroupView) { - var holdShortcut = LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Hold", comment: ""), Preferences.indexToName("holdShortcut", index), Preferences.holdShortcut[index], false, labelPosition: .leftWithoutSeparator) + private static func makeShortcutsView() -> NSView { + let sidebar = makeShortcutSidebar() + let editorPane = makeEditorPane() + let separator = NSView() + separator.wantsLayer = true + separator.layer?.backgroundColor = NSColor.tableSeparatorColor.cgColor + separator.translatesAutoresizingMaskIntoConstraints = false + separator.widthAnchor.constraint(equalToConstant: 1).isActive = true + let content = NSStackView(views: [sidebar, separator, editorPane]) + content.orientation = .horizontal + content.alignment = .top + content.spacing = 0 + content.translatesAutoresizingMaskIntoConstraints = false + content.wantsLayer = true + content.layer?.backgroundColor = NSColor.tableBackgroundColor.cgColor + content.layer?.cornerRadius = TableGroupView.cornerRadius + content.layer?.borderColor = NSColor.tableBorderColor.cgColor + content.layer?.borderWidth = TableGroupView.borderWidth + content.layer?.masksToBounds = true + content.widthAnchor.constraint(equalToConstant: SettingsWindow.contentWidth).isActive = true + sidebar.heightAnchor.constraint(equalTo: editorPane.heightAnchor).isActive = true + return content + } + + private static func makeEditorPane() -> NSView { + let pane = NSView() + pane.translatesAutoresizingMaskIntoConstraints = false + pane.widthAnchor.constraint(equalToConstant: shortcutEditorWidth).isActive = true + var views = shortcutEditorViews + if let gestureEditorView { + views.append(gestureEditorView) + } + let editorsStack = NSStackView(views: views) + editorsStack.orientation = .vertical + editorsStack.alignment = .leading + editorsStack.spacing = 0 + editorsStack.translatesAutoresizingMaskIntoConstraints = false + pane.addSubview(editorsStack) + NSLayoutConstraint.activate([ + editorsStack.topAnchor.constraint(equalTo: pane.topAnchor), + editorsStack.leadingAnchor.constraint(equalTo: pane.leadingAnchor), + editorsStack.trailingAnchor.constraint(equalTo: pane.trailingAnchor), + editorsStack.bottomAnchor.constraint(equalTo: pane.bottomAnchor), + ]) + return pane + } + + private static func makeShortcutSidebar() -> NSView { + let sidebar = NSView() + sidebar.translatesAutoresizingMaskIntoConstraints = false + sidebar.widthAnchor.constraint(equalToConstant: shortcutSidebarWidth).isActive = true + sidebar.wantsLayer = true + sidebar.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.25).cgColor + let listContainer = NSView() + listContainer.translatesAutoresizingMaskIntoConstraints = false + listContainer.wantsLayer = true + listContainer.layer?.backgroundColor = NSColor.tableBackgroundColor.cgColor + listContainer.layer?.cornerRadius = TableGroupView.cornerRadius + listContainer.layer?.borderColor = NSColor.tableBorderColor.cgColor + listContainer.layer?.borderWidth = TableGroupView.borderWidth + let rows = NSStackView() + rows.orientation = .vertical + rows.alignment = .leading + rows.spacing = 0 + rows.translatesAutoresizingMaskIntoConstraints = false + shortcutRowsStackView = rows + let rowsScrollView = ControlsSidebarScrollView() + rowsScrollView.translatesAutoresizingMaskIntoConstraints = false + rowsScrollView.drawsBackground = false + rowsScrollView.hasVerticalScroller = true + rowsScrollView.hasHorizontalScroller = false + rowsScrollView.scrollerStyle = .overlay + rowsScrollView.verticalScrollElasticity = .none + rowsScrollView.usesPredominantAxisScrolling = true + let documentView = FlippedView(frame: .zero) + documentView.translatesAutoresizingMaskIntoConstraints = false + rowsScrollView.documentView = documentView + documentView.addSubview(rows) + let gestureSeparator = NSView() + gestureSeparator.translatesAutoresizingMaskIntoConstraints = false + gestureSeparator.wantsLayer = true + gestureSeparator.layer?.backgroundColor = NSColor.tableSeparatorColor.cgColor + let gestureRow = ShortcutSidebarRow() + gestureRow.onClick = { _, _ in selectGesture() } + gestureRow.onMouseEntered = { _, _ in gestureRow.setHovered(true) } + gestureRow.onMouseExited = { _, _ in gestureRow.setHovered(false) } + gestureSidebarRow = gestureRow + listContainer.addSubview(rowsScrollView) + listContainer.addSubview(gestureSeparator) + listContainer.addSubview(gestureRow) + let countButtons = NSSegmentedControl(labels: ["+", "-"], trackingMode: .momentary, target: self, action: #selector(updateShortcutCount(_:))) + countButtons.translatesAutoresizingMaskIntoConstraints = false + countButtons.segmentStyle = .rounded + countButtons.setWidth(28, forSegment: 0) + countButtons.setWidth(28, forSegment: 1) + shortcutCountButtons = countButtons + let buttonsRow = NSStackView(views: [countButtons]) + buttonsRow.orientation = .horizontal + buttonsRow.alignment = .leading + buttonsRow.translatesAutoresizingMaskIntoConstraints = false + sidebar.addSubview(listContainer) + sidebar.addSubview(buttonsRow) + NSLayoutConstraint.activate([ + listContainer.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: 10), + listContainer.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 10), + listContainer.trailingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: -10), + listContainer.bottomAnchor.constraint(equalTo: buttonsRow.topAnchor, constant: -10), + documentView.widthAnchor.constraint(equalTo: rowsScrollView.contentView.widthAnchor), + documentView.heightAnchor.constraint(greaterThanOrEqualTo: rowsScrollView.contentView.heightAnchor), + rows.topAnchor.constraint(equalTo: documentView.topAnchor), + rows.leadingAnchor.constraint(equalTo: documentView.leadingAnchor), + rows.trailingAnchor.constraint(equalTo: documentView.trailingAnchor), + rows.bottomAnchor.constraint(lessThanOrEqualTo: documentView.bottomAnchor), + rowsScrollView.topAnchor.constraint(equalTo: listContainer.topAnchor), + rowsScrollView.leadingAnchor.constraint(equalTo: listContainer.leadingAnchor), + rowsScrollView.trailingAnchor.constraint(equalTo: listContainer.trailingAnchor), + rowsScrollView.bottomAnchor.constraint(equalTo: gestureSeparator.topAnchor), + gestureSeparator.leadingAnchor.constraint(equalTo: listContainer.leadingAnchor), + gestureSeparator.trailingAnchor.constraint(equalTo: listContainer.trailingAnchor), + gestureSeparator.heightAnchor.constraint(equalToConstant: TableGroupView.borderWidth), + gestureSeparator.bottomAnchor.constraint(equalTo: gestureRow.topAnchor), + gestureRow.leadingAnchor.constraint(equalTo: listContainer.leadingAnchor), + gestureRow.trailingAnchor.constraint(equalTo: listContainer.trailingAnchor), + gestureRow.heightAnchor.constraint(equalToConstant: sidebarRowHeight), + gestureRow.bottomAnchor.constraint(equalTo: listContainer.bottomAnchor), + buttonsRow.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 10), + buttonsRow.bottomAnchor.constraint(equalTo: sidebar.bottomAnchor, constant: -10), + ]) + refreshGestureRow() + return sidebar + } + + private static func shortcutTab(_ index: Int) -> TableGroupView { + let holdName = Preferences.indexToName("holdShortcut", index) + let holdValue = UserDefaults.standard.string(forKey: holdName) ?? "" + var holdShortcut = LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Hold", comment: ""), holdName, holdValue, false, labelPosition: .leftWithoutSeparator) holdShortcut.append(LabelAndControl.makeLabel(NSLocalizedString("and press", comment: ""))) - let nextWindowShortcut = LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Select next window", comment: ""), Preferences.indexToName("nextWindowShortcut", index), Preferences.nextWindowShortcut[index], labelPosition: .right) - let tab = controlTab(index, holdShortcut + [nextWindowShortcut[0]]) - return (holdShortcut, nextWindowShortcut, tab) + let nextName = Preferences.indexToName("nextWindowShortcut", index) + let nextValue = UserDefaults.standard.string(forKey: nextName) ?? "" + let nextWindowShortcut = LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Select next window", comment: ""), nextName, nextValue, labelPosition: .right) + return controlTab(index, holdShortcut + [nextWindowShortcut[0]], shortcutEditorWidth) } private static func gestureTab(_ index: Int) -> TableGroupView { - let label = NSLocalizedString("You may need to disable some conflicting system gestures", comment: "") + let message = NSLocalizedString("You may need to disable some conflicting system gestures", comment: "") let button = NSButton(title: NSLocalizedString("Open Trackpad Settings…", comment: ""), target: self, action: #selector(openSystemGestures(_:))) - let infoBtn = LabelAndControl.makeInfoButton(searchableTooltipTexts: [label], onMouseEntered: { event, view in - Popover.shared.show(event: event, positioningView: view, message: label, extraView: button) + let infoBtn = LabelAndControl.makeInfoButton(searchableTooltipTexts: [message], onMouseEntered: { event, view in + Popover.shared.show(event: event, positioningView: view, message: message, extraView: button) }) let gesture = LabelAndControl.makeDropdown("nextWindowGesture", GesturePreference.allCases) let gestureWithTooltip = NSStackView() gestureWithTooltip.orientation = .horizontal gestureWithTooltip.alignment = .centerY - // this is a trick to get the height of that row to be consistent when switching between the tabs let dummyRecorderForHeight = CustomRecorderControl("d", true, "dummy") gestureWithTooltip.setViews([gesture, dummyRecorderForHeight], in: .trailing) gestureWithTooltip.setViews([infoBtn], in: .leading) gestureWithTooltip.heightAnchor.constraint(equalTo: dummyRecorderForHeight.heightAnchor).isActive = true dummyRecorderForHeight.isHidden = true - return controlTab(index, [gestureWithTooltip]) + return controlTab(index, [gestureWithTooltip], shortcutEditorWidth) } - private static func controlTab(_ index: Int, _ trigger: [NSView]) -> TableGroupView { + private static func controlTab(_ index: Int, _ trigger: [NSView], _ width: CGFloat) -> TableGroupView { let appsToShow = LabelAndControl.makeDropdown(Preferences.indexToName("appsToShow", index), AppsToShowPreference.allCases) let spacesToShow = LabelAndControl.makeDropdown(Preferences.indexToName("spacesToShow", index), SpacesToShowPreference.allCases) let screensToShow = LabelAndControl.makeDropdown(Preferences.indexToName("screensToShow", index), ScreensToShowPreference.allCases) let showMinimizedWindows = LabelAndControl.makeDropdown(Preferences.indexToName("showMinimizedWindows", index), ShowHowPreference.allCases) let showHiddenWindows = LabelAndControl.makeDropdown(Preferences.indexToName("showHiddenWindows", index), ShowHowPreference.allCases) - let showFullscreenWindows = LabelAndControl.makeDropdown(Preferences.indexToName("showFullscreenWindows", index), ShowHowPreference.allCases.filter { $0 != .showAtTheEnd }) // this filter is ok for serialization because the filtered value is last in the enum + let showFullscreenWindows = LabelAndControl.makeDropdown(Preferences.indexToName("showFullscreenWindows", index), ShowHowPreference.allCases.filter { $0 != .showAtTheEnd }) let showWindowlessApps = LabelAndControl.makeDropdown(Preferences.indexToName("showWindowlessApps", index), ShowHowPreference.allCases) let windowOrder = LabelAndControl.makeDropdown(Preferences.indexToName("windowOrder", index), WindowOrderPreference.allCases) - let table = TableGroupView(width: SettingsWindow.contentWidth) - table.addRow(TableGroupView.Row(leftTitle: NSLocalizedString("Trigger shortcut", comment: ""), rightViews: trigger)) + let table = TableGroupView(width: width) + table.addRow(TableGroupView.Row(leftTitle: NSLocalizedString("Trigger", comment: ""), rightViews: trigger)) table.addNewTable() table.addRow(leftViews: [TableGroupView.makeText(NSLocalizedString("Show windows from applications", comment: ""))], rightViews: [appsToShow]) table.addRow(leftViews: [TableGroupView.makeText(NSLocalizedString("Show windows from Spaces", comment: ""))], rightViews: [spacesToShow]) @@ -147,17 +426,219 @@ class ControlsTab { return table } - @objc static func switchTab(_ sender: NSSegmentedControl) { - let selectedIndex = sender.selectedSegment - switchIndexTab(selectedIndex) + private static func refreshShortcutUi() { + if selectedShortcutIndex != gestureSelectionIndex { + selectedShortcutIndex = max(0, min(selectedShortcutIndex, Preferences.shortcutCount - 1)) + } + refreshShortcutRows() + refreshGestureRow() + refreshShortcutSelection() + refreshShortcutCountButtons() } - static func switchIndexTab(_ selectedIndex: Int) { - ControlsTab.tableGroupViews.enumerated().forEach { (index, view) in - if selectedIndex == index { - view.isHidden = false + private static func refreshShortcutRows() { + guard let rows = shortcutRowsStackView else { return } + clearArrangedSubviews(rows) + shortcutRows.removeAll(keepingCapacity: true) + for index in 0..= Preferences.shortcutCount + } + gestureSidebarRow?.setSelected(selectedShortcutIndex == gestureSelectionIndex) + gestureEditorView?.isHidden = selectedShortcutIndex != gestureSelectionIndex + } + + private static func refreshShortcutCountButtons() { + shortcutCountButtons?.setEnabled(Preferences.shortcutCount < Preferences.maxShortcutCount, forSegment: 0) + shortcutCountButtons?.setEnabled(Preferences.shortcutCount > Preferences.minShortcutCount && selectedShortcutIndex != gestureSelectionIndex, forSegment: 1) + } + + private static func selectShortcut(_ index: Int) { + guard (0.. Preferences.minShortcutCount, selectedShortcutIndex != gestureSelectionIndex else { return } + let removedIndex = min(selectedShortcutIndex, currentCount - 1) + if removedIndex < currentCount - 1 { + for index in removedIndex..<(currentCount - 1) { + copyShortcutPreferences(index + 1, index) + } + } + resetShortcutPreferences(currentCount - 1) + selectedShortcutIndex = min(removedIndex, currentCount - 2) + Preferences.set("shortcutCount", String(currentCount - 1)) + } + + private static func resetShortcutPreferences(_ index: Int) { + removableShortcutPreferences.forEach { + Preferences.remove(Preferences.indexToName($0, index), false) + } + } + + private static func setAddedShortcutTriggerDefaults(_ index: Int) { + Preferences.set(Preferences.indexToName("holdShortcut", index), "⌥", false) + Preferences.set(Preferences.indexToName("nextWindowShortcut", index), "", false) + Preferences.set(Preferences.indexToName("appsToShow", index), AppsToShowPreference.all.indexAsString, false) + } + + private static func copyShortcutPreferences(_ fromIndex: Int, _ toIndex: Int) { + removableShortcutPreferences.forEach { baseName in + let fromKey = Preferences.indexToName(baseName, fromIndex) + let toKey = Preferences.indexToName(baseName, toIndex) + if let value = UserDefaults.standard.string(forKey: fromKey) { + Preferences.set(toKey, value, false) } else { - view.isHidden = true + Preferences.remove(toKey, false) + } + } + } + + private static func initializeShortcutRecorderState(_ index: Int) { + guard index < Preferences.shortcutCount else { return } + let holdControlId = Preferences.indexToName("holdShortcut", index) + let nextControlId = Preferences.indexToName("nextWindowShortcut", index) + syncShortcutRecorderControlValue(holdControlId) + syncShortcutRecorderControlValue(nextControlId) + shortcutDropdownPreferences.forEach { syncShortcutDropdownControlValue(Preferences.indexToName($0, index)) } + if let holdShortcut = shortcutControls[holdControlId]?.0 { + shortcutChangedCallback(holdShortcut) + } + if let nextWindowShortcut = shortcutControls[nextControlId]?.0 { + shortcutChangedCallback(nextWindowShortcut) + } + } + + private static func syncShortcutRecorderControlValue(_ controlId: String) { + guard let control = shortcutControls[controlId]?.0 else { return } + let value = UserDefaults.standard.string(forKey: controlId) ?? "" + control.objectValue = value.isEmpty ? nil : Shortcut(keyEquivalent: value) + } + + private static func syncShortcutDropdownControlValue(_ controlId: String) { + let index = Preferences.nameToIndex(controlId) + guard index < shortcutEditorViews.count else { return } + guard let dropdown = findDropdownControl(shortcutEditorViews[index], controlId) else { return } + guard dropdown.numberOfItems > 0 else { return } + let selectedIndex = UserDefaults.standard.string(forKey: controlId).flatMap(Int.init) ?? 0 + dropdown.selectItem(at: min(max(0, selectedIndex), dropdown.numberOfItems - 1)) + } + + private static func findDropdownControl(_ root: NSView, _ controlId: String) -> NSPopUpButton? { + if let dropdown = root as? NSPopUpButton, dropdown.identifier?.rawValue == controlId { + return dropdown + } + for child in root.subviews { + if let found = findDropdownControl(child, controlId) { + return found + } + } + return nil + } + + private static func shortcutTitle(_ index: Int) -> String { + return NSLocalizedString("Shortcut", comment: "") + " " + String(index + 1) + } + + private static func shortcutSummary(_ index: Int) -> String { + let holdShortcut = UserDefaults.standard.string(forKey: Preferences.indexToName("holdShortcut", index)) ?? "" + let nextWindowShortcut = UserDefaults.standard.string(forKey: Preferences.indexToName("nextWindowShortcut", index)) ?? "" + if nextWindowShortcut.isEmpty { + return holdShortcut.isEmpty ? NSLocalizedString("Not set", comment: "") : holdShortcut + } + return holdShortcut + " + " + nextWindowShortcut + } + + private static func gestureTitle() -> String { + return NSLocalizedString("Gesture", comment: "") + } + + private static func gestureSummary() -> String { + return Preferences.nextWindowGesture.localizedString + } + + private static func refreshGestureRow() { + guard let gestureSidebarRow else { return } + gestureSidebarRow.setContent(gestureTitle(), gestureSummary()) + gestureSidebarRow.setSelected(selectedShortcutIndex == gestureSelectionIndex) + } + + private static func clearArrangedSubviews(_ stackView: NSStackView) { + stackView.arrangedSubviews.forEach { + stackView.removeArrangedSubview($0) + $0.removeFromSuperview() + } + } + + private static func isShortcutPreferenceKey(_ key: String) -> Bool { + return (0.. Bool] = [ - .commandTab: { (shortcut) in shortcut.carbonModifierFlags == cmdKey && shortcut.carbonKeyCode == kVK_Tab }, - .commandShiftTab: { (shortcut) in CustomRecorderControlTestable.combinedModifiersMatch(shortcut.carbonModifierFlags, UInt32(cmdKey | shiftKey)) && shortcut.carbonKeyCode == kVK_Tab }, - .commandKeyAboveTab: { (shortcut) in shortcut.carbonModifierFlags == cmdKey && shortcut.carbonKeyCode == kVK_ANSI_Grave }, + .commandTab: { shortcut in shortcut.carbonModifierFlags == cmdKey && shortcut.carbonKeyCode == kVK_Tab }, + .commandShiftTab: { shortcut in CustomRecorderControlTestable.combinedModifiersMatch(shortcut.carbonModifierFlags, UInt32(cmdKey | shiftKey)) && shortcut.carbonKeyCode == kVK_Tab }, + .commandKeyAboveTab: { shortcut in shortcut.carbonModifierFlags == cmdKey && shortcut.carbonKeyCode == kVK_ANSI_Grave }, ] - var overlappingHotkeys = shortcuts.values.compactMap { (atShortcut) in nativeHotkeys.first { $1(atShortcut.shortcut) }?.key } - // if command+tab if bound, disable command+shift+tab always, to avoid confused users + var overlappingHotkeys = shortcuts.values.compactMap { atShortcut in nativeHotkeys.first { $1(atShortcut.shortcut) }?.key } if overlappingHotkeys.contains(.commandTab) && !overlappingHotkeys.contains(.commandShiftTab) { overlappingHotkeys.append(.commandShiftTab) } @@ -198,9 +678,17 @@ class ControlsTab { @objc static func shortcutChangedCallback(_ sender: NSControl) { let controlId = sender.identifier!.rawValue + if isShortcutPreferenceKey(controlId) && Preferences.nameToIndex(controlId) >= Preferences.shortcutCount { + return + } if controlId.hasPrefix("holdShortcut") { let i = Preferences.nameToIndex(controlId) - addShortcut(.up, .global, Shortcut(keyEquivalent: Preferences.holdShortcut[i])!, controlId, i) + let holdShortcut = UserDefaults.standard.string(forKey: controlId) ?? "" + guard let shortcut = Shortcut(keyEquivalent: holdShortcut) else { + removeShortcutIfExists(controlId) + return + } + addShortcut(.up, .global, shortcut, controlId, i) if let nextWindowShortcut = shortcutControls[Preferences.indexToName("nextWindowShortcut", i)]?.0 { nextWindowShortcut.restrictModifiers([(sender as! CustomRecorderControl).objectValue!.modifierFlags]) shortcutChangedCallback(nextWindowShortcut) @@ -234,7 +722,7 @@ class ControlsTab { return "" } if controlId.starts(with: "nextWindowShortcut") { - let holdShortcut = Preferences.holdShortcut[Preferences.nameToIndex(controlId)] + let holdShortcut = UserDefaults.standard.string(forKey: Preferences.indexToName("holdShortcut", Preferences.nameToIndex(controlId))) ?? "" return holdShortcut + baseValue } return baseValue @@ -270,8 +758,6 @@ class ControlsTab { } } if !conflicts.isEmpty { - // if the app is still launching (App.app.settingsWindow == nil) and we have a conflict - // then we don't show the user a dialog, and simply disable vim keys if App.app.settingsWindow == nil || !shouldClearConflictingShortcuts(conflicts.map { $0.value }) { return false } @@ -327,6 +813,10 @@ class ControlsTab { } private static func applyShortcutPreference(_ controlId: String) { + if isShortcutPreferenceKey(controlId) && Preferences.nameToIndex(controlId) >= Preferences.shortcutCount { + removeShortcutIfExists(controlId) + return + } if controlId.hasPrefix("holdShortcut") { applyHoldShortcutPreference(controlId) applyShortcutPreference(Preferences.indexToName("nextWindowShortcut", Preferences.nameToIndex(controlId))) @@ -344,7 +834,8 @@ class ControlsTab { private static func applyHoldShortcutPreference(_ controlId: String) { let i = Preferences.nameToIndex(controlId) - guard let shortcut = Shortcut(keyEquivalent: Preferences.holdShortcut[i]) else { + let holdShortcut = UserDefaults.standard.string(forKey: controlId) ?? "" + guard let shortcut = Shortcut(keyEquivalent: holdShortcut) else { removeShortcutIfExists(controlId) return } @@ -356,7 +847,7 @@ class ControlsTab { return "" } if controlId.starts(with: "nextWindowShortcut") { - let holdShortcut = Preferences.holdShortcut[Preferences.nameToIndex(controlId)] + let holdShortcut = UserDefaults.standard.string(forKey: Preferences.indexToName("holdShortcut", Preferences.nameToIndex(controlId))) ?? "" return holdShortcut + baseValue } return baseValue @@ -393,11 +884,20 @@ class ControlsTab { } @objc private static func openSystemGestures(_ sender: NSButton) { - // Apple doesn't expose the More Gestures tab directly NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.Trackpad-Settings.extension")!) } static func executeAction(_ action: String) { - shortcutsActions[action]!() + if let staticAction = shortcutsActions[action] { + staticAction() + return + } + if action.hasPrefix("holdShortcut") { + App.app.focusTarget() + return + } + if action.hasPrefix("nextWindowShortcut") { + App.app.showUiOrCycleSelection(Preferences.nameToIndex(action), false) + } } }