feat: allow up to 9 shortcuts (#3882)

This commit is contained in:
lwouis committed 2026-02-16 22:53:01 +01:00
1 parent 3f1617cba0
commit 8cab3e1f8b
11 files changed
+731 -180

No files matched your search

+19 -21
View File
@@ -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";
+1 -3
View File
@@ -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) {
+4 -4
View File
@@ -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: "")
}
}
+40 -34
View File
@@ -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..<maxShortcutCount).forEach { index in
values[indexToName("holdShortcut", index)] = "⌥"
values[indexToName("nextWindowShortcut", index)] = index == 0 ? "⇥" : (index == 1 ? keyAboveTabDependingOnInputSource() : "")
}
(0...maxShortcutCount).forEach { index in
values[indexToName("appsToShow", index)] = index == 1 ? AppsToShowPreference.active.indexAsString : (index == 2 ? AppsToShowPreference.nonActive.indexAsString : AppsToShowPreference.all.indexAsString)
values[indexToName("spacesToShow", index)] = SpacesToShowPreference.all.indexAsString
values[indexToName("screensToShow", index)] = ScreensToShowPreference.all.indexAsString
values[indexToName("showMinimizedWindows", index)] = ShowHowPreference.show.indexAsString
values[indexToName("showHiddenWindows", index)] = ShowHowPreference.show.indexAsString
values[indexToName("showFullscreenWindows", index)] = ShowHowPreference.show.indexAsString
values[indexToName("showWindowlessApps", index)] = ShowHowPreference.showAtTheEnd.indexAsString
values[indexToName("windowOrder", index)] = WindowOrderPreference.recentlyFocused.indexAsString
values[indexToName("shortcutStyle", index)] = ShortcutStylePreference.focusOnRelease.indexAsString
}
return values
}()
// system preferences
static var finderShowsQuitMenuItem: Bool { UserDefaults(suiteName: "com.apple.Finder")?.bool(forKey: "QuitMenuItem") ?? false }
// persisted values
static var holdShortcut: [String] { ["holdShortcut", "holdShortcut2", "holdShortcut3"].map { CachedUserDefaults.string($0) } }
static var nextWindowShortcut: [String] { ["nextWindowShortcut", "nextWindowShortcut2", "nextWindowShortcut3"].map { CachedUserDefaults.string($0) } }
static var holdShortcut: [String] { (0..<shortcutCount).map { CachedUserDefaults.string(indexToName("holdShortcut", $0)) } }
static var nextWindowShortcut: [String] { (0..<shortcutCount).map { CachedUserDefaults.string(indexToName("nextWindowShortcut", $0)) } }
static var nextWindowGesture: GesturePreference { CachedUserDefaults.macroPref("nextWindowGesture", GesturePreference.allCases) }
static var focusWindowShortcut: String { CachedUserDefaults.string("focusWindowShortcut") }
static var previousWindowShortcut: String { CachedUserDefaults.string("previousWindowShortcut") }
@@ -145,20 +144,26 @@ class Preferences {
static var showTitles: ShowTitlesPreference { CachedUserDefaults.macroPref("showTitles", ShowTitlesPreference.allCases) }
static var updatePolicy: UpdatePolicyPreference { CachedUserDefaults.macroPref("updatePolicy", UpdatePolicyPreference.allCases) }
static var crashPolicy: CrashPolicyPreference { CachedUserDefaults.macroPref("crashPolicy", CrashPolicyPreference.allCases) }
static var appsToShow: [AppsToShowPreference] { ["appsToShow", "appsToShow2", "appsToShow3", "appsToShow4"].map { CachedUserDefaults.macroPref($0, AppsToShowPreference.allCases) } }
static var spacesToShow: [SpacesToShowPreference] { ["spacesToShow", "spacesToShow2", "spacesToShow3", "spacesToShow4"].map { CachedUserDefaults.macroPref($0, SpacesToShowPreference.allCases) } }
static var screensToShow: [ScreensToShowPreference] { ["screensToShow", "screensToShow2", "screensToShow3", "screensToShow4"].map { CachedUserDefaults.macroPref($0, ScreensToShowPreference.allCases) } }
static var showMinimizedWindows: [ShowHowPreference] { ["showMinimizedWindows", "showMinimizedWindows2", "showMinimizedWindows3", "showMinimizedWindows4"].map { CachedUserDefaults.macroPref($0, ShowHowPreference.allCases) } }
static var showHiddenWindows: [ShowHowPreference] { ["showHiddenWindows", "showHiddenWindows2", "showHiddenWindows3", "showHiddenWindows4"].map { CachedUserDefaults.macroPref($0, ShowHowPreference.allCases) } }
static var showFullscreenWindows: [ShowHowPreference] { ["showFullscreenWindows", "showFullscreenWindows2", "showFullscreenWindows3", "showFullscreenWindows4"].map { CachedUserDefaults.macroPref($0, ShowHowPreference.allCases) } }
static var showWindowlessApps: [ShowHowPreference] { ["showWindowlessApps", "showWindowlessApps2", "showWindowlessApps3", "showWindowlessApps4"].map { CachedUserDefaults.macroPref($0, ShowHowPreference.allCases) } }
static var windowOrder: [WindowOrderPreference] { ["windowOrder", "windowOrder2", "windowOrder3", "windowOrder4"].map { CachedUserDefaults.macroPref($0, WindowOrderPreference.allCases) } }
static var appsToShow: [AppsToShowPreference] { (0...maxShortcutCount).map { CachedUserDefaults.macroPref(indexToName("appsToShow", $0), AppsToShowPreference.allCases) } }
static var spacesToShow: [SpacesToShowPreference] { (0...maxShortcutCount).map { CachedUserDefaults.macroPref(indexToName("spacesToShow", $0), SpacesToShowPreference.allCases) } }
static var screensToShow: [ScreensToShowPreference] { (0...maxShortcutCount).map { CachedUserDefaults.macroPref(indexToName("screensToShow", $0), ScreensToShowPreference.allCases) } }
static var showMinimizedWindows: [ShowHowPreference] { (0...maxShortcutCount).map { CachedUserDefaults.macroPref(indexToName("showMinimizedWindows", $0), ShowHowPreference.allCases) } }
static var showHiddenWindows: [ShowHowPreference] { (0...maxShortcutCount).map { CachedUserDefaults.macroPref(indexToName("showHiddenWindows", $0), ShowHowPreference.allCases) } }
static var showFullscreenWindows: [ShowHowPreference] { (0...maxShortcutCount).map { CachedUserDefaults.macroPref(indexToName("showFullscreenWindows", $0), ShowHowPreference.allCases) } }
static var showWindowlessApps: [ShowHowPreference] { (0...maxShortcutCount).map { CachedUserDefaults.macroPref(indexToName("showWindowlessApps", $0), ShowHowPreference.allCases) } }
static var windowOrder: [WindowOrderPreference] { (0...maxShortcutCount).map { CachedUserDefaults.macroPref(indexToName("windowOrder", $0), WindowOrderPreference.allCases) } }
static var shortcutStyle: ShortcutStylePreference { CachedUserDefaults.macroPref("shortcutStyle", ShortcutStylePreference.allCases) }
static var menubarIcon: MenubarIconPreference { CachedUserDefaults.macroPref("menubarIcon", MenubarIconPreference.allCases) }
static var menubarIconShown: Bool { CachedUserDefaults.bool("menubarIconShown") }
static var language: LanguagePreference { CachedUserDefaults.macroPref("language", LanguagePreference.allCases) }
static let gestureIndex = 3
static let minShortcutCount = 1
static let maxShortcutCount = 9
static var shortcutCount: Int {
max(minShortcutCount, min(maxShortcutCount, CachedUserDefaults.int("shortcutCount")))
}
static let gestureIndex = maxShortcutCount
static func initialize() {
PreferencesMigrations.removeCorruptedPreferences()
@@ -238,7 +243,8 @@ class Preferences {
}
static func nameToIndex(_ name: String) -> 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
}
}
+58 -33
View File
@@ -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)
+1 -1
View File
@@ -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..<Preferences.shortcutCount).contains(shortcutIndex) {
App.app.showUi(shortcutIndex)
return noOutput
}
+5 -3
View File
@@ -58,14 +58,16 @@ class KeyboardEvents {
private static func unregisterHotKeyIfNeeded(_ controlId: String, _ shortcut: Shortcut) {
if shortcut.keyCode != .none {
UnregisterEventHotKey(eventHotKeyRefs[controlId]!)
eventHotKeyRefs[controlId] = nil
if let ref = eventHotKeyRefs[controlId] {
UnregisterEventHotKey(ref)
eventHotKeyRefs[controlId] = nil
}
}
}
private static func registerHotKeyIfNeeded(_ controlId: String, _ shortcut: Shortcut) {
if shortcut.keyCode != .none {
let id = KeyboardEventsTestable.globalShortcutsIds[controlId]!
guard let id = KeyboardEventsTestable.globalShortcutsIds[controlId] else { return }
let hotkeyId = EventHotKeyID(signature: signature, id: UInt32(id))
let key = shortcut.carbonKeyCode
let mods = shortcut.carbonModifierFlags
@@ -1,14 +1,12 @@
import ShortcutRecorder
class KeyboardEventsTestable {
static let globalShortcutsIds = [
"nextWindowShortcut": 0,
"nextWindowShortcut2": 1,
"nextWindowShortcut3": 2,
"holdShortcut": 5,
"holdShortcut2": 6,
"holdShortcut3": 7,
]
static var globalShortcutsIds: [String: Int] {
var ids = [String: Int]()
(0..<Preferences.maxShortcutCount).forEach { ids[Preferences.indexToName("nextWindowShortcut", $0)] = $0 }
(0..<Preferences.maxShortcutCount).forEach { ids[Preferences.indexToName("holdShortcut", $0)] = Preferences.maxShortcutCount + $0 }
return ids
}
}
@discardableResult
+27 -3
View File
@@ -20,9 +20,9 @@ class BlacklistView: NSScrollView {
super.scrollWheel(with: event)
return
}
let y = contentView.bounds.origin.y
super.scrollWheel(with: event)
if abs(contentView.bounds.origin.y - y) < 0.5 {
if canScrollInEventDirection(event) {
super.scrollWheel(with: event)
} else {
parentScrollView()?.scrollWheel(with: event)
}
}
@@ -31,6 +31,30 @@ class BlacklistView: NSScrollView {
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 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 {
+1 -1
View File
@@ -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?
@@ -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..<Preferences.shortcutCount).forEach { initializeShortcutRecorderState($0) }
refreshShortcutUi()
case "nextWindowGesture":
refreshGestureRow()
case let k where isShortcutPreferenceKey(k):
if Preferences.nameToIndex(k) < Preferences.shortcutCount {
syncShortcutRecorderControlValue(k)
applyShortcutPreference(k)
} else {
removeShortcutIfExists(k)
}
refreshShortcutRows()
case let k where staticManagedShortcutPreferences.contains(k):
applyShortcutPreference(k)
case "arrowKeysEnabled":
applyArrowKeysPreference()
case "vimKeysEnabled" where vimKeysCheckbox == nil:
applyVimKeysPreferenceWithoutDialogs()
default:
break
}
}
static func initTab() -> 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..<Preferences.maxShortcutCount).map { shortcutTab($0) }
gestureEditorView = gestureTab(Preferences.gestureIndex)
let shortcutsView = makeShortcutsView()
let additionalControlsButton = NSButton(title: NSLocalizedString("Additional controls…", comment: ""), target: self, action: #selector(showAdditionalControlsSettings))
let shortcutsButton = NSButton(title: NSLocalizedString("Shortcuts when active…", comment: ""), target: self, action: #selector(showShortcutsSettings))
let tools = StackView([additionalControlsButton, shortcutsButton], .horizontal)
let view = TableGroupSetView(originalViews: [table, tab1View, tab2View, tab3View, tab6View], toolsViews: [tools], bottomPadding: 0, toolsAlignment: .trailing)
let view = TableGroupSetView(originalViews: [shortcutsView], toolsViews: [tools], bottomPadding: 0, othersAlignment: .leading, toolsAlignment: .trailing)
shortcutsWhenActiveSheet = ShortcutsWhenActiveSheet()
additionalControlsSheet = AdditionalControlsSheet()
ControlsTab.switchIndexTab(0)
refreshShortcutUi()
(0..<Preferences.shortcutCount).forEach { initializeShortcutRecorderState($0) }
return view
}
private static func shortcutTab(_ index: Int) -> ([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 {
let row = ShortcutSidebarRow()
row.setContent(shortcutTitle(index), shortcutSummary(index))
row.setSelected(index == selectedShortcutIndex && selectedShortcutIndex != gestureSelectionIndex)
row.onClick = { _, _ in selectShortcut(index) }
row.onMouseEntered = { _, _ in row.setHovered(true) }
row.onMouseExited = { _, _ in row.setHovered(false) }
rows.addArrangedSubview(row)
row.widthAnchor.constraint(equalTo: rows.widthAnchor).isActive = true
row.heightAnchor.constraint(equalToConstant: sidebarRowHeight).isActive = true
shortcutRows.append(row)
if index < Preferences.shortcutCount - 1 {
let separator = NSView()
separator.translatesAutoresizingMaskIntoConstraints = false
separator.wantsLayer = true
separator.layer?.backgroundColor = NSColor.tableSeparatorColor.cgColor
rows.addArrangedSubview(separator)
separator.widthAnchor.constraint(equalTo: rows.widthAnchor).isActive = true
separator.heightAnchor.constraint(equalToConstant: TableGroupView.borderWidth).isActive = true
}
}
}
private static func refreshShortcutSelection() {
shortcutRows.enumerated().forEach { $1.setSelected($0 == selectedShortcutIndex) }
shortcutEditorViews.enumerated().forEach { index, view in
view.isHidden = index != selectedShortcutIndex || index >= 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.shortcutCount).contains(index) else { return }
selectedShortcutIndex = index
refreshShortcutSelection()
refreshShortcutCountButtons()
}
private static func selectGesture() {
selectedShortcutIndex = gestureSelectionIndex
refreshShortcutSelection()
refreshShortcutCountButtons()
}
@objc private static func updateShortcutCount(_ sender: NSSegmentedControl) {
let segment = sender.selectedSegment
sender.selectedSegment = -1
if segment == 0 {
addShortcutSlot()
} else if segment == 1 && selectedShortcutIndex != gestureSelectionIndex {
removeShortcutSlot()
}
}
private static func addShortcutSlot() {
let currentCount = Preferences.shortcutCount
guard currentCount < Preferences.maxShortcutCount else { return }
resetShortcutPreferences(currentCount)
setAddedShortcutTriggerDefaults(currentCount)
selectedShortcutIndex = currentCount
Preferences.set("shortcutCount", String(currentCount + 1))
initializeShortcutRecorderState(currentCount)
}
private static func removeShortcutSlot() {
let currentCount = Preferences.shortcutCount
guard currentCount > 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..<Preferences.maxShortcutCount).contains(where: { index in
["holdShortcut", "nextWindowShortcut"].contains { key == Preferences.indexToName($0, index) }
})
}
private static func applyActiveShortcutPreferences() {
(0..<Preferences.maxShortcutCount).forEach { index in
["holdShortcut", "nextWindowShortcut"].forEach { base in
let key = Preferences.indexToName(base, index)
if index < Preferences.shortcutCount {
applyShortcutPreference(key)
} else {
removeShortcutIfExists(key)
}
}
}
}
@@ -172,7 +653,7 @@ class ControlsTab {
private static func addShortcut(_ triggerPhase: ShortcutTriggerPhase, _ scope: ShortcutScope, _ shortcut: Shortcut, _ controlId: String, _ index: Int?) {
let atShortcut = ATShortcut(shortcut, controlId, scope, triggerPhase, index)
removeShortcutIfExists(controlId) // remove the previous shortcut
removeShortcutIfExists(controlId)
shortcuts[controlId] = atShortcut
if scope == .global {
KeyboardEvents.addGlobalShortcut(controlId, atShortcut.shortcut)
@@ -182,12 +663,11 @@ class ControlsTab {
static func toggleNativeCommandTabIfNeeded() {
let nativeHotkeys: [CGSSymbolicHotKey: (Shortcut) -> 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)
}
}
}