feat: add fuzzy search mode (closes #590)
BREAKING CHANGE: a whole new alt-tab experience by pressing "s". You can also set search as the default style in the settings
This commit is contained in:
1 parent
e3092b4299
commit
4e986f37a0
21 files changed
+1066
-70
No files matched your search
@@ -202,6 +202,7 @@
|
|||||||
BF0C8F40C2D0FF7601D926D3 /* LightweightTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8363D45FECB8D6F8DCD5 /* LightweightTimer.swift */; };
|
BF0C8F40C2D0FF7601D926D3 /* LightweightTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8363D45FECB8D6F8DCD5 /* LightweightTimer.swift */; };
|
||||||
BF0C8F9B8014AD21E68B1349 /* HelperExtensionsTestable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C810FA0F6BC70E5886E11 /* HelperExtensionsTestable.swift */; };
|
BF0C8F9B8014AD21E68B1349 /* HelperExtensionsTestable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C810FA0F6BC70E5886E11 /* HelperExtensionsTestable.swift */; };
|
||||||
BF0C8FF2A4B00D409494EA80 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C88D79FA0C773D94F351A /* InfoPlist.strings */; };
|
BF0C8FF2A4B00D409494EA80 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C88D79FA0C773D94F351A /* InfoPlist.strings */; };
|
||||||
|
BF0CAAAA0000000000001001 /* SearchAcronymTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0CAAAA0000000000000001 /* SearchAcronymTests.swift */; };
|
||||||
D04BA004884A273D4D2D3EF1 /* HelperExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD91161791D42FEC4A60 /* HelperExtensions.swift */; };
|
D04BA004884A273D4D2D3EF1 /* HelperExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD91161791D42FEC4A60 /* HelperExtensions.swift */; };
|
||||||
D04BA0182F4734A5C8BB63DC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA5461DAEE9B5350C3020 /* Localizable.strings */; };
|
D04BA0182F4734A5C8BB63DC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA5461DAEE9B5350C3020 /* Localizable.strings */; };
|
||||||
D04BA03B560B681AF96E4610 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA88ED495C54CFE0B7B87 /* InfoPlist.strings */; };
|
D04BA03B560B681AF96E4610 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA88ED495C54CFE0B7B87 /* InfoPlist.strings */; };
|
||||||
@@ -299,6 +300,9 @@
|
|||||||
D04BAFC1A1B2C3D4E5F60002 /* TileOverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAFC0A1B2C3D4E5F60001 /* TileOverView.swift */; };
|
D04BAFC1A1B2C3D4E5F60002 /* TileOverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAFC0A1B2C3D4E5F60001 /* TileOverView.swift */; };
|
||||||
D04BAFC3A1B2C3D4E5F60004 /* TileUnderLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAFC2A1B2C3D4E5F60003 /* TileUnderLayer.swift */; };
|
D04BAFC3A1B2C3D4E5F60004 /* TileUnderLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAFC2A1B2C3D4E5F60003 /* TileUnderLayer.swift */; };
|
||||||
D04BAFF30A98CF287F85DA1E /* BlacklistsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA27695D9A5824720BD7B /* BlacklistsTab.swift */; };
|
D04BAFF30A98CF287F85DA1E /* BlacklistsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA27695D9A5824720BD7B /* BlacklistsTab.swift */; };
|
||||||
|
F00DCAFE0000000000001002 /* Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00DCAFE0000000000000002 /* Search.swift */; };
|
||||||
|
F00DCAFE0000000000001003 /* SearchTestable.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00DCAFE0000000000000003 /* SearchTestable.swift */; };
|
||||||
|
F00DCAFE0000000000001103 /* SearchTestable.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00DCAFE0000000000000003 /* SearchTestable.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
@@ -564,6 +568,7 @@
|
|||||||
BF0C8FB9777EE9BE74EF542B /* zh-hk */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = "zh-hk"; path = Localizable.strings; sourceTree = "<group>"; };
|
BF0C8FB9777EE9BE74EF542B /* zh-hk */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = "zh-hk"; path = Localizable.strings; sourceTree = "<group>"; };
|
||||||
BF0C8FC083A2443F3EAF5DB7 /* preferences-blacklist.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "preferences-blacklist.jpg"; sourceTree = "<group>"; };
|
BF0C8FC083A2443F3EAF5DB7 /* preferences-blacklist.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "preferences-blacklist.jpg"; sourceTree = "<group>"; };
|
||||||
BF0C8FEE46E9A73F617EF679 /* gu */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = gu; path = Localizable.strings; sourceTree = "<group>"; };
|
BF0C8FEE46E9A73F617EF679 /* gu */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = gu; path = Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
BF0CAAAA0000000000000001 /* SearchAcronymTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchAcronymTests.swift; sourceTree = "<group>"; };
|
||||||
C0712B3BEA2B3780398C0999 /* Pods_alt_tab_macos.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_alt_tab_macos.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
C0712B3BEA2B3780398C0999 /* Pods_alt_tab_macos.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_alt_tab_macos.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
C7E6F409CA442F83B7BE71AD /* Pods_unit_tests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_unit_tests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
C7E6F409CA442F83B7BE71AD /* Pods_unit_tests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_unit_tests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
D04BA015A45DE7AFDC9794FE /* Window.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Window.swift; sourceTree = "<group>"; };
|
D04BA015A45DE7AFDC9794FE /* Window.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Window.swift; sourceTree = "<group>"; };
|
||||||
@@ -736,6 +741,8 @@
|
|||||||
D04BAFC2A1B2C3D4E5F60003 /* TileUnderLayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TileUnderLayer.swift; sourceTree = "<group>"; };
|
D04BAFC2A1B2C3D4E5F60003 /* TileUnderLayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TileUnderLayer.swift; sourceTree = "<group>"; };
|
||||||
D04BAFDA9E2623E21CBD6CEF /* zh-TW */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = "zh-TW"; path = InfoPlist.strings; sourceTree = "<group>"; };
|
D04BAFDA9E2623E21CBD6CEF /* zh-TW */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = "zh-TW"; path = InfoPlist.strings; sourceTree = "<group>"; };
|
||||||
E20104DD2C3DE10E7CCC5B5A /* Pods-unit-tests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-unit-tests.release.xcconfig"; path = "Target Support Files/Pods-unit-tests/Pods-unit-tests.release.xcconfig"; sourceTree = "<group>"; };
|
E20104DD2C3DE10E7CCC5B5A /* Pods-unit-tests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-unit-tests.release.xcconfig"; path = "Target Support Files/Pods-unit-tests/Pods-unit-tests.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
F00DCAFE0000000000000002 /* Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search.swift; sourceTree = "<group>"; };
|
||||||
|
F00DCAFE0000000000000003 /* SearchTestable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTestable.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -1102,6 +1109,7 @@
|
|||||||
children = (
|
children = (
|
||||||
BF0C84933936C6FE676CD33C /* AppearanceTests.swift */,
|
BF0C84933936C6FE676CD33C /* AppearanceTests.swift */,
|
||||||
BF0C899905524253DC170B56 /* KeyboardEventsTests.swift */,
|
BF0C899905524253DC170B56 /* KeyboardEventsTests.swift */,
|
||||||
|
BF0CAAAA0000000000000001 /* SearchAcronymTests.swift */,
|
||||||
BF0C829EAA93A4C4ADB42CF8 /* Mocks.swift */,
|
BF0C829EAA93A4C4ADB42CF8 /* Mocks.swift */,
|
||||||
BF0C8C6810FF50908E637A48 /* ConcurrentScreenshots.swift */,
|
BF0C8C6810FF50908E637A48 /* ConcurrentScreenshots.swift */,
|
||||||
BF0C855CD9DBCAEFE48F9EA5 /* AXUIElementTests.swift */,
|
BF0C855CD9DBCAEFE48F9EA5 /* AXUIElementTests.swift */,
|
||||||
@@ -1358,6 +1366,7 @@
|
|||||||
D04BAB74451B79FE18B8BEDF /* BackgroundWork.swift */,
|
D04BAB74451B79FE18B8BEDF /* BackgroundWork.swift */,
|
||||||
D04BACABD048E62EBE4576CC /* DebugProfile.swift */,
|
D04BACABD048E62EBE4576CC /* DebugProfile.swift */,
|
||||||
D04BAC8857A527C2E15D6598 /* events */,
|
D04BAC8857A527C2E15D6598 /* events */,
|
||||||
|
F00DCAFE0000000000002000 /* search */,
|
||||||
D04BA7E3EA104941FE76DA70 /* AppCenterCrashes.swift */,
|
D04BA7E3EA104941FE76DA70 /* AppCenterCrashes.swift */,
|
||||||
BF0C8BA452332236D972C60E /* ATShortcut.swift */,
|
BF0C8BA452332236D972C60E /* ATShortcut.swift */,
|
||||||
1C961AC3633CD0C62CCE050E /* Appearance.swift */,
|
1C961AC3633CD0C62CCE050E /* Appearance.swift */,
|
||||||
@@ -2007,6 +2016,15 @@
|
|||||||
path = docs;
|
path = docs;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
F00DCAFE0000000000002000 /* search */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
F00DCAFE0000000000000002 /* Search.swift */,
|
||||||
|
F00DCAFE0000000000000003 /* SearchTestable.swift */,
|
||||||
|
);
|
||||||
|
path = search;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -2439,7 +2457,9 @@
|
|||||||
BF0C81FA62D810342F59291A /* AppearanceTests.swift in Sources */,
|
BF0C81FA62D810342F59291A /* AppearanceTests.swift in Sources */,
|
||||||
5F4753072CEE5A2E002C6C5E /* AppearanceTestable.swift in Sources */,
|
5F4753072CEE5A2E002C6C5E /* AppearanceTestable.swift in Sources */,
|
||||||
BF0C8B787C76D9B961CC2F45 /* KeyboardEventsTests.swift in Sources */,
|
BF0C8B787C76D9B961CC2F45 /* KeyboardEventsTests.swift in Sources */,
|
||||||
|
BF0CAAAA0000000000001001 /* SearchAcronymTests.swift in Sources */,
|
||||||
BF0C8C5FA4B401A8C0F75B7A /* KeyboardEventsTestable.swift in Sources */,
|
BF0C8C5FA4B401A8C0F75B7A /* KeyboardEventsTestable.swift in Sources */,
|
||||||
|
F00DCAFE0000000000001103 /* SearchTestable.swift in Sources */,
|
||||||
BF0C875A28B98904F8F4A66A /* ATShortcut.swift in Sources */,
|
BF0C875A28B98904F8F4A66A /* ATShortcut.swift in Sources */,
|
||||||
BF0C84E5AC1D423625ABF3E6 /* Mocks.swift in Sources */,
|
BF0C84E5AC1D423625ABF3E6 /* Mocks.swift in Sources */,
|
||||||
BF0C8CBED8D88DFBADE7EC03 /* ConcurrentScreenshots.swift in Sources */,
|
BF0C8CBED8D88DFBADE7EC03 /* ConcurrentScreenshots.swift in Sources */,
|
||||||
@@ -2463,6 +2483,8 @@
|
|||||||
5F21C9E22C6E94410091F72F /* AdditionalControlsSheet.swift in Sources */,
|
5F21C9E22C6E94410091F72F /* AdditionalControlsSheet.swift in Sources */,
|
||||||
D04BA8480A8FF466CA89DA5B /* main.swift in Sources */,
|
D04BA8480A8FF466CA89DA5B /* main.swift in Sources */,
|
||||||
D04BAFB973C3D28718FAEB87 /* Windows.swift in Sources */,
|
D04BAFB973C3D28718FAEB87 /* Windows.swift in Sources */,
|
||||||
|
F00DCAFE0000000000001002 /* Search.swift in Sources */,
|
||||||
|
F00DCAFE0000000000001003 /* SearchTestable.swift in Sources */,
|
||||||
D04BAC011A71E0418154F8CD /* Preferences.swift in Sources */,
|
D04BAC011A71E0418154F8CD /* Preferences.swift in Sources */,
|
||||||
D04BADCDA9F9A6C3D6499877 /* SystemPermissions.swift in Sources */,
|
D04BADCDA9F9A6C3D6499877 /* SystemPermissions.swift in Sources */,
|
||||||
D04BABEECBC6D922298BC93A /* Spaces.swift in Sources */,
|
D04BABEECBC6D922298BC93A /* Spaces.swift in Sources */,
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ class Appearance {
|
|||||||
static var highlightHoveredBackgroundColor: NSColor { get { NSColor.systemAccentColor.withAlphaComponent(0.1) } }
|
static var highlightHoveredBackgroundColor: NSColor { get { NSColor.systemAccentColor.withAlphaComponent(0.1) } }
|
||||||
static var highlightFocusedBorderColor: NSColor { get { NSColor.systemAccentColor } }
|
static var highlightFocusedBorderColor: NSColor { get { NSColor.systemAccentColor } }
|
||||||
static var highlightHoveredBorderColor: NSColor { get { NSColor.systemAccentColor.withAlphaComponent(0.7) } }
|
static var highlightHoveredBorderColor: NSColor { get { NSColor.systemAccentColor.withAlphaComponent(0.7) } }
|
||||||
|
static var searchMatchHighlightColor: NSColor { get { NSColor.systemYellow.withAlphaComponent(0.5) } }
|
||||||
|
static var searchMatchForegroundColor: NSColor { get { NSColor(calibratedWhite: 0.12, alpha: 1) } }
|
||||||
|
|
||||||
private static var currentStyle: AppearanceStylePreference { Preferences.appearanceStyle }
|
private static var currentStyle: AppearanceStylePreference { Preferences.appearanceStyle }
|
||||||
private static var currentSize: AppearanceSizePreference { Preferences.appearanceSize }
|
private static var currentSize: AppearanceSizePreference { Preferences.appearanceSize }
|
||||||
|
|||||||
@@ -33,8 +33,12 @@ class KeyRepeatTimer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func isSearchEditing() -> Bool {
|
||||||
|
App.app.appIsBeingUsed && App.app.thumbnailsPanel.isKeyWindow && App.app.thumbnailsPanel.tilesView.isSearchEditing
|
||||||
|
}
|
||||||
|
|
||||||
private static func startTimerForRepeatingKey(_ atShortcut: ATShortcut, _ block: @escaping () -> Void) {
|
private static func startTimerForRepeatingKey(_ atShortcut: ATShortcut, _ block: @escaping () -> Void) {
|
||||||
guard timerIsSuspended && atShortcut.state != .up else { return }
|
guard timerIsSuspended && atShortcut.state != .up && !isSearchEditing() else { return }
|
||||||
currentTimerShortcutName = atShortcut.id
|
currentTimerShortcutName = atShortcut.id
|
||||||
// reading these user defaults every time guarantees we have the latest value, if the user has updated those
|
// reading these user defaults every time guarantees we have the latest value, if the user has updated those
|
||||||
let repeatRate = ticksToSeconds(CachedUserDefaults.globalString("KeyRepeat") ?? "6")
|
let repeatRate = ticksToSeconds(CachedUserDefaults.globalString("KeyRepeat") ?? "6")
|
||||||
@@ -48,7 +52,7 @@ class KeyRepeatTimer {
|
|||||||
|
|
||||||
private static func handleEvent(_ atShortcut: ATShortcut, _ block: @escaping () -> Void) {
|
private static func handleEvent(_ atShortcut: ATShortcut, _ block: @escaping () -> Void) {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if atShortcut.state == .up {
|
if atShortcut.state == .up || isSearchEditing() {
|
||||||
stopTimerForRepeatingKey(atShortcut.id)
|
stopTimerForRepeatingKey(atShortcut.id)
|
||||||
} else {
|
} else {
|
||||||
block()
|
block()
|
||||||
|
|||||||
@@ -220,11 +220,13 @@ enum LanguagePreference: CaseIterable, MacroPreference {
|
|||||||
enum ShortcutStylePreference: CaseIterable, MacroPreference {
|
enum ShortcutStylePreference: CaseIterable, MacroPreference {
|
||||||
case focusOnRelease
|
case focusOnRelease
|
||||||
case doNothingOnRelease
|
case doNothingOnRelease
|
||||||
|
case searchOnRelease
|
||||||
|
|
||||||
var localizedString: LocalizedString {
|
var localizedString: LocalizedString {
|
||||||
switch self {
|
switch self {
|
||||||
case .focusOnRelease: return NSLocalizedString("Focus selected window", comment: "")
|
case .focusOnRelease: return NSLocalizedString("Focus selected window", comment: "")
|
||||||
case .doNothingOnRelease: return NSLocalizedString("Do nothing", comment: "")
|
case .doNothingOnRelease: return NSLocalizedString("Do nothing", comment: "")
|
||||||
|
case .searchOnRelease: return NSLocalizedString("Search", comment: "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,14 +12,16 @@ class Preferences {
|
|||||||
"nextWindowShortcut2": keyAboveTabDependingOnInputSource(),
|
"nextWindowShortcut2": keyAboveTabDependingOnInputSource(),
|
||||||
"nextWindowShortcut3": "",
|
"nextWindowShortcut3": "",
|
||||||
"nextWindowGesture": GesturePreference.disabled.indexAsString,
|
"nextWindowGesture": GesturePreference.disabled.indexAsString,
|
||||||
"focusWindowShortcut": "Space",
|
"focusWindowShortcut": returnKeyEquivalent(),
|
||||||
"previousWindowShortcut": "⇧",
|
"previousWindowShortcut": "⇧",
|
||||||
"cancelShortcut": "⎋",
|
"cancelShortcut": "⎋",
|
||||||
|
"lockSearchShortcut": "Space",
|
||||||
"closeWindowShortcut": "W",
|
"closeWindowShortcut": "W",
|
||||||
"minDeminWindowShortcut": "M",
|
"minDeminWindowShortcut": "M",
|
||||||
"toggleFullscreenWindowShortcut": "F",
|
"toggleFullscreenWindowShortcut": "F",
|
||||||
"quitAppShortcut": "Q",
|
"quitAppShortcut": "Q",
|
||||||
"hideShowAppShortcut": "H",
|
"hideShowAppShortcut": "H",
|
||||||
|
"searchShortcut": "S",
|
||||||
"arrowKeysEnabled": "true",
|
"arrowKeysEnabled": "true",
|
||||||
"vimKeysEnabled": "false",
|
"vimKeysEnabled": "false",
|
||||||
"mouseHoverEnabled": "false",
|
"mouseHoverEnabled": "false",
|
||||||
@@ -100,11 +102,13 @@ class Preferences {
|
|||||||
static var focusWindowShortcut: String { CachedUserDefaults.string("focusWindowShortcut") }
|
static var focusWindowShortcut: String { CachedUserDefaults.string("focusWindowShortcut") }
|
||||||
static var previousWindowShortcut: String { CachedUserDefaults.string("previousWindowShortcut") }
|
static var previousWindowShortcut: String { CachedUserDefaults.string("previousWindowShortcut") }
|
||||||
static var cancelShortcut: String { CachedUserDefaults.string("cancelShortcut") }
|
static var cancelShortcut: String { CachedUserDefaults.string("cancelShortcut") }
|
||||||
|
static var lockSearchShortcut: String { CachedUserDefaults.string("lockSearchShortcut") }
|
||||||
static var closeWindowShortcut: String { CachedUserDefaults.string("closeWindowShortcut") }
|
static var closeWindowShortcut: String { CachedUserDefaults.string("closeWindowShortcut") }
|
||||||
static var minDeminWindowShortcut: String { CachedUserDefaults.string("minDeminWindowShortcut") }
|
static var minDeminWindowShortcut: String { CachedUserDefaults.string("minDeminWindowShortcut") }
|
||||||
static var toggleFullscreenWindowShortcut: String { CachedUserDefaults.string("toggleFullscreenWindowShortcut") }
|
static var toggleFullscreenWindowShortcut: String { CachedUserDefaults.string("toggleFullscreenWindowShortcut") }
|
||||||
static var quitAppShortcut: String { CachedUserDefaults.string("quitAppShortcut") }
|
static var quitAppShortcut: String { CachedUserDefaults.string("quitAppShortcut") }
|
||||||
static var hideShowAppShortcut: String { CachedUserDefaults.string("hideShowAppShortcut") }
|
static var hideShowAppShortcut: String { CachedUserDefaults.string("hideShowAppShortcut") }
|
||||||
|
static var searchShortcut: String { CachedUserDefaults.string("searchShortcut") }
|
||||||
// periphery:ignore
|
// periphery:ignore
|
||||||
static var arrowKeysEnabled: Bool { CachedUserDefaults.bool("arrowKeysEnabled") }
|
static var arrowKeysEnabled: Bool { CachedUserDefaults.bool("arrowKeysEnabled") }
|
||||||
// periphery:ignore
|
// periphery:ignore
|
||||||
@@ -189,6 +193,10 @@ class Preferences {
|
|||||||
return LiteralKeyCodeTransformer.shared.transformedValue(NSNumber(value: kVK_ANSI_Grave)) ?? "`"
|
return LiteralKeyCodeTransformer.shared.transformedValue(NSNumber(value: kVK_ANSI_Grave)) ?? "`"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func returnKeyEquivalent() -> String {
|
||||||
|
return LiteralKeyCodeTransformer.shared.transformedValue(NSNumber(value: kVK_Return)) ?? "↩"
|
||||||
|
}
|
||||||
|
|
||||||
static func defaultBlacklist() -> String {
|
static func defaultBlacklist() -> String {
|
||||||
return jsonEncode([
|
return jsonEncode([
|
||||||
BlacklistEntry(bundleIdentifier: "com.McAfee.McAfeeSafariHost", hide: .always, ignore: .none),
|
BlacklistEntry(bundleIdentifier: "com.McAfee.McAfeeSafariHost", hide: .always, ignore: .none),
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ class Window {
|
|||||||
var axObserver: AXObserver?
|
var axObserver: AXObserver?
|
||||||
var rowIndex: Int?
|
var rowIndex: Int?
|
||||||
var debugId: String!
|
var debugId: String!
|
||||||
|
var lastSearchQuery: String?
|
||||||
|
var swAppResults: [SWResult] = []
|
||||||
|
var swTitleResults: [SWResult] = []
|
||||||
|
var swBestSimilarity = 0.0
|
||||||
|
|
||||||
init(_ axUiElement: AXUIElement, _ application: Application, _ wid: CGWindowID, _ title: String?, _ isFullscreen: Bool?, _ isMinimized: Bool?, _ position: CGPoint?, _ size: CGSize?) {
|
init(_ axUiElement: AXUIElement, _ application: Application, _ wid: CGWindowID, _ title: String?, _ isFullscreen: Bool?, _ isMinimized: Bool?, _ position: CGPoint?, _ size: CGSize?) {
|
||||||
id = "\(wid)"
|
id = "\(wid)"
|
||||||
@@ -76,6 +80,7 @@ class Window {
|
|||||||
self.position = position
|
self.position = position
|
||||||
self.isFullscreen = isFullscreen ?? false
|
self.isFullscreen = isFullscreen ?? false
|
||||||
self.isMinimized = isMinimized ?? false
|
self.isMinimized = isMinimized ?? false
|
||||||
|
lastSearchQuery = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func isEqualRobust(_ otherWindowAxUiElement: AXUIElement, _ otherWindowWid: CGWindowID?) -> Bool {
|
func isEqualRobust(_ otherWindowAxUiElement: AXUIElement, _ otherWindowWid: CGWindowID?) -> Bool {
|
||||||
|
|||||||
+83
-11
@@ -6,6 +6,37 @@ class Windows {
|
|||||||
static var selectedWindowTarget: String?
|
static var selectedWindowTarget: String?
|
||||||
static var hoveredWindowIndex: Int?
|
static var hoveredWindowIndex: Int?
|
||||||
private static var lastWindowActivityType = WindowActivityType.none
|
private static var lastWindowActivityType = WindowActivityType.none
|
||||||
|
static var searchQuery = ""
|
||||||
|
private static var shouldSelectBestMatchOnSearchChange = false
|
||||||
|
private static var shouldRestoreDefaultSelectionOnSearchClear = false
|
||||||
|
|
||||||
|
static func shouldDisplay(_ window: Window) -> Bool {
|
||||||
|
window.shouldShowTheUser && Search.matches(window, query: searchQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func updateSearchQuery(_ query: String) {
|
||||||
|
let previousTrimmedQuery = Search.normalizedQuery(searchQuery)
|
||||||
|
let newTrimmedQuery = Search.normalizedQuery(query)
|
||||||
|
searchQuery = query
|
||||||
|
guard App.app.appIsBeingUsed else {
|
||||||
|
shouldSelectBestMatchOnSearchChange = false
|
||||||
|
shouldRestoreDefaultSelectionOnSearchClear = false
|
||||||
|
sort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if previousTrimmedQuery != newTrimmedQuery {
|
||||||
|
if newTrimmedQuery.isEmpty {
|
||||||
|
shouldRestoreDefaultSelectionOnSearchClear = !previousTrimmedQuery.isEmpty
|
||||||
|
shouldSelectBestMatchOnSearchChange = false
|
||||||
|
} else {
|
||||||
|
shouldSelectBestMatchOnSearchChange = true
|
||||||
|
shouldRestoreDefaultSelectionOnSearchClear = false
|
||||||
|
hoveredWindowIndex = nil
|
||||||
|
selectedWindowTarget = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort()
|
||||||
|
}
|
||||||
|
|
||||||
static func updateIsFullscreenOnCurrentSpace() {
|
static func updateIsFullscreenOnCurrentSpace() {
|
||||||
let windowsOnCurrentSpace = list.filter { !$0.isWindowlessApp }
|
let windowsOnCurrentSpace = list.filter { !$0.isWindowlessApp }
|
||||||
@@ -28,9 +59,11 @@ class Windows {
|
|||||||
|
|
||||||
static func voiceOverWindow(_ windowIndex: Int = selectedWindowIndex) {
|
static func voiceOverWindow(_ windowIndex: Int = selectedWindowIndex) {
|
||||||
guard App.app.appIsBeingUsed && App.app.thumbnailsPanel.isKeyWindow else { return }
|
guard App.app.appIsBeingUsed && App.app.thumbnailsPanel.isKeyWindow else { return }
|
||||||
|
if App.app.thumbnailsPanel.tilesView.isSearchEditing { return }
|
||||||
// it seems that sometimes makeFirstResponder is called before the view is visible
|
// it seems that sometimes makeFirstResponder is called before the view is visible
|
||||||
// and it creates a delay in showing the main window; calling it with some delay seems to work around this
|
// and it creates a delay in showing the main window; calling it with some delay seems to work around this
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) {
|
||||||
|
if App.app.thumbnailsPanel.tilesView.isSearchEditing { return }
|
||||||
let window = TilesView.recycledViews[windowIndex]
|
let window = TilesView.recycledViews[windowIndex]
|
||||||
if window.window_ != nil && window.window != nil {
|
if window.window_ != nil && window.window != nil {
|
||||||
App.app.thumbnailsPanel.makeFirstResponder(window)
|
App.app.thumbnailsPanel.makeFirstResponder(window)
|
||||||
@@ -180,7 +213,9 @@ class Windows {
|
|||||||
//////////////////////////////
|
//////////////////////////////
|
||||||
|
|
||||||
static func selectedWindow() -> Window? {
|
static func selectedWindow() -> Window? {
|
||||||
return list.count > selectedWindowIndex ? list[selectedWindowIndex] : nil
|
guard list.count > selectedWindowIndex else { return nil }
|
||||||
|
let window = list[selectedWindowIndex]
|
||||||
|
return shouldDisplay(window) ? window : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
static func setInitialSelectedAndHoveredWindowIndex() {
|
static func setInitialSelectedAndHoveredWindowIndex() {
|
||||||
@@ -206,20 +241,46 @@ class Windows {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func updateSelectedWindow() {
|
static func updateSelectedWindow() {
|
||||||
// selectedWindowTarget still exists: we select its new index
|
if shouldRestoreDefaultSelectionOnSearchClear {
|
||||||
if let index = (list.firstIndex { $0.id == selectedWindowTarget }) {
|
shouldRestoreDefaultSelectionOnSearchClear = false
|
||||||
|
setInitialSelectedAndHoveredWindowIndex()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let visibleIndexes = list.indices.filter { shouldDisplay(list[$0]) }
|
||||||
|
guard let firstVisibleIndex = visibleIndexes.first else {
|
||||||
|
selectedWindowTarget = nil
|
||||||
|
hoveredWindowIndex = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if shouldSelectBestMatchOnSearchChange {
|
||||||
|
shouldSelectBestMatchOnSearchChange = false
|
||||||
|
updateSelectedAndHoveredWindowIndex(firstVisibleIndex)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let index = list.firstIndex(where: { $0.id == selectedWindowTarget && shouldDisplay($0) }) {
|
||||||
updateSelectedAndHoveredWindowIndex(index)
|
updateSelectedAndHoveredWindowIndex(index)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// list has reduced (e.g. app was quit). Selection should be at the end, so users can for example quit many apps in a sequence
|
let lastVisibleIndex = visibleIndexes.last!
|
||||||
let lastIndex = list.filter { $0.shouldShowTheUser }.count - 1
|
if !visibleIndexes.contains(selectedWindowIndex) {
|
||||||
if selectedWindowIndex > lastIndex {
|
updateSelectedAndHoveredWindowIndex(firstVisibleIndex)
|
||||||
updateSelectedAndHoveredWindowIndex(lastIndex)
|
return
|
||||||
|
}
|
||||||
|
if selectedWindowIndex > lastVisibleIndex {
|
||||||
|
updateSelectedAndHoveredWindowIndex(lastVisibleIndex)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if selectedWindowTarget == nil {
|
||||||
|
selectedWindowTarget = list[selectedWindowIndex].id
|
||||||
|
}
|
||||||
|
if selectedWindowIndex < firstVisibleIndex {
|
||||||
|
updateSelectedAndHoveredWindowIndex(firstVisibleIndex)
|
||||||
}
|
}
|
||||||
// selectedWindowIndex is undisturbed: do nothing
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func updateSelectedAndHoveredWindowIndex(_ newIndex: Int, _ fromMouse: Bool = false) {
|
static func updateSelectedAndHoveredWindowIndex(_ newIndex: Int, _ fromMouse: Bool = false) {
|
||||||
|
guard newIndex >= 0 && newIndex < list.count else { return }
|
||||||
|
guard shouldDisplay(list[newIndex]) else { return }
|
||||||
var index: Int?
|
var index: Int?
|
||||||
if fromMouse && (newIndex != hoveredWindowIndex || lastWindowActivityType == .focus) {
|
if fromMouse && (newIndex != hoveredWindowIndex || lastWindowActivityType == .focus) {
|
||||||
let oldIndex = hoveredWindowIndex
|
let oldIndex = hoveredWindowIndex
|
||||||
@@ -249,6 +310,7 @@ class Windows {
|
|||||||
|
|
||||||
static func cycleSelectedWindowIndex(_ step: Int, allowWrap: Bool = true) {
|
static func cycleSelectedWindowIndex(_ step: Int, allowWrap: Bool = true) {
|
||||||
guard App.app.appIsBeingUsed else { return }
|
guard App.app.appIsBeingUsed else { return }
|
||||||
|
guard list.contains(where: { shouldDisplay($0) }) else { return }
|
||||||
let nextIndex = selectedWindowIndexAfterCycling(step)
|
let nextIndex = selectedWindowIndexAfterCycling(step)
|
||||||
// don't wrap-around at the end, if key-repeat
|
// don't wrap-around at the end, if key-repeat
|
||||||
if (((step > 0 && nextIndex < selectedWindowIndex) || (step < 0 && nextIndex > selectedWindowIndex)) &&
|
if (((step > 0 && nextIndex < selectedWindowIndex) || (step < 0 && nextIndex > selectedWindowIndex)) &&
|
||||||
@@ -261,14 +323,14 @@ class Windows {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func selectedWindowIndexAfterCycling(_ step: Int) -> Int {
|
static func selectedWindowIndexAfterCycling(_ step: Int) -> Int {
|
||||||
if list.count == 0 { return 0 }
|
if list.count == 0 || !list.contains(where: { shouldDisplay($0) }) { return selectedWindowIndex }
|
||||||
var iterations = 0
|
var iterations = 0
|
||||||
var targetIndex = selectedWindowIndex
|
var targetIndex = selectedWindowIndex
|
||||||
repeat {
|
repeat {
|
||||||
let next = (targetIndex + step) % list.count
|
let next = (targetIndex + step) % list.count
|
||||||
targetIndex = next < 0 ? list.count + next : next
|
targetIndex = next < 0 ? list.count + next : next
|
||||||
iterations += 1
|
iterations += 1
|
||||||
} while !list[targetIndex].shouldShowTheUser && iterations <= list.count
|
} while !shouldDisplay(list[targetIndex]) && iterations <= list.count
|
||||||
return targetIndex
|
return targetIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +358,17 @@ class Windows {
|
|||||||
|
|
||||||
/// reordered list based on preferences, keeping the original index
|
/// reordered list based on preferences, keeping the original index
|
||||||
private static func sort() {
|
private static func sort() {
|
||||||
|
let trimmedQuery = Search.normalizedQuery(searchQuery)
|
||||||
list.sort {
|
list.sort {
|
||||||
|
if !trimmedQuery.isEmpty {
|
||||||
|
let matches0 = Search.matches($0, query: trimmedQuery)
|
||||||
|
let matches1 = Search.matches($1, query: trimmedQuery)
|
||||||
|
if matches0 != matches1 { return matches0 }
|
||||||
|
let score0 = Search.relevance(for: $0, query: trimmedQuery)
|
||||||
|
let score1 = Search.relevance(for: $1, query: trimmedQuery)
|
||||||
|
if score0 != score1 { return score0 > score1 }
|
||||||
|
return $0.lastFocusOrder < $1.lastFocusOrder
|
||||||
|
}
|
||||||
// separate buckets for these types of windows
|
// separate buckets for these types of windows
|
||||||
if Preferences.showWindowlessApps[App.app.shortcutIndex] == .showAtTheEnd && $0.isWindowlessApp != $1.isWindowlessApp {
|
if Preferences.showWindowlessApps[App.app.shortcutIndex] == .showAtTheEnd && $0.isWindowlessApp != $1.isWindowlessApp {
|
||||||
return $1.isWindowlessApp
|
return $1.isWindowlessApp
|
||||||
@@ -344,7 +416,7 @@ class Windows {
|
|||||||
var index: Int? = nil
|
var index: Int? = nil
|
||||||
var lastFocusOrderMin = Int.max
|
var lastFocusOrderMin = Int.max
|
||||||
for (offset, w) in list.enumerated() {
|
for (offset, w) in list.enumerated() {
|
||||||
if !w.isWindowlessApp && w.shouldShowTheUser && w.lastFocusOrder < lastFocusOrderMin {
|
if !w.isWindowlessApp && shouldDisplay(w) && w.lastFocusOrder < lastFocusOrderMin {
|
||||||
lastFocusOrderMin = w.lastFocusOrder
|
lastFocusOrderMin = w.lastFocusOrder
|
||||||
index = offset
|
index = offset
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class CursorEvents {
|
|||||||
private static var shouldBeEnabled: Bool!
|
private static var shouldBeEnabled: Bool!
|
||||||
private static var mouseDownTileView: TileView?
|
private static var mouseDownTileView: TileView?
|
||||||
private static var mouseDownButton: TrafficLightButton?
|
private static var mouseDownButton: TrafficLightButton?
|
||||||
|
private static var mouseDownInsideSearchField = false
|
||||||
static var deadZoneInitialPosition: CGPoint?
|
static var deadZoneInitialPosition: CGPoint?
|
||||||
static var isAllowedToMouseHover = true
|
static var isAllowedToMouseHover = true
|
||||||
|
|
||||||
@@ -46,8 +47,8 @@ class CursorEvents {
|
|||||||
|
|
||||||
private static let handleEvent: CGEventTapCallBack = { _, type, cgEvent, _ in
|
private static let handleEvent: CGEventTapCallBack = { _, type, cgEvent, _ in
|
||||||
switch type {
|
switch type {
|
||||||
case .leftMouseDown: return handleLeftMouseDown()
|
case .leftMouseDown: return handleLeftMouseDown(cgEvent)
|
||||||
case .leftMouseUp where cgEvent.getIntegerValueField(.mouseEventClickState) >= 1: return handleLeftMouseUp()
|
case .leftMouseUp where cgEvent.getIntegerValueField(.mouseEventClickState) >= 1: return handleLeftMouseUp(cgEvent)
|
||||||
case .otherMouseUp: return handleOtherMouseUp(cgEvent)
|
case .otherMouseUp: return handleOtherMouseUp(cgEvent)
|
||||||
case .mouseMoved: return handleMouseMoved(cgEvent)
|
case .mouseMoved: return handleMouseMoved(cgEvent)
|
||||||
case .tapDisabledByUserInput, .tapDisabledByTimeout:
|
case .tapDisabledByUserInput, .tapDisabledByTimeout:
|
||||||
@@ -57,7 +58,12 @@ class CursorEvents {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func handleLeftMouseDown() -> Unmanaged<CGEvent>? {
|
private static func handleLeftMouseDown(_ cgEvent: CGEvent) -> Unmanaged<CGEvent>? {
|
||||||
|
if isPointerInsideSearchField() {
|
||||||
|
mouseDownInsideSearchField = true
|
||||||
|
return Unmanaged.passUnretained(cgEvent)
|
||||||
|
}
|
||||||
|
mouseDownInsideSearchField = false
|
||||||
guard isPointerInsideUi() else { return nil }
|
guard isPointerInsideUi() else { return nil }
|
||||||
if let button = findButtonUnderPointer() {
|
if let button = findButtonUnderPointer() {
|
||||||
mouseDownButton = button
|
mouseDownButton = button
|
||||||
@@ -69,7 +75,11 @@ class CursorEvents {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func handleLeftMouseUp() -> Unmanaged<CGEvent>? {
|
private static func handleLeftMouseUp(_ cgEvent: CGEvent) -> Unmanaged<CGEvent>? {
|
||||||
|
if mouseDownInsideSearchField || isPointerInsideSearchField() {
|
||||||
|
mouseDownInsideSearchField = false
|
||||||
|
return Unmanaged.passUnretained(cgEvent)
|
||||||
|
}
|
||||||
guard isPointerInsideUi() else {
|
guard isPointerInsideUi() else {
|
||||||
App.app.hideUi()
|
App.app.hideUi()
|
||||||
return nil
|
return nil
|
||||||
@@ -120,6 +130,13 @@ class CursorEvents {
|
|||||||
App.app.thumbnailsPanel.contentLayoutRect.contains(pointerLocationInWindow())
|
App.app.thumbnailsPanel.contentLayoutRect.contains(pointerLocationInWindow())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func isPointerInsideSearchField() -> Bool {
|
||||||
|
let searchField = App.app.thumbnailsPanel.tilesView.searchField
|
||||||
|
if searchField.isHidden { return false }
|
||||||
|
let point = searchField.convert(pointerLocationInWindow(), from: nil)
|
||||||
|
return searchField.bounds.contains(point)
|
||||||
|
}
|
||||||
|
|
||||||
private static func isPointerOver(_ view: NSView) -> Bool {
|
private static func isPointerOver(_ view: NSView) -> Bool {
|
||||||
view.bounds.contains(view.convert(pointerLocationInWindow(), from: nil))
|
view.bounds.contains(view.convert(pointerLocationInWindow(), from: nil))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,8 +79,10 @@ class KeyboardEvents {
|
|||||||
// TODO: handle this on a background thread?
|
// TODO: handle this on a background thread?
|
||||||
private static func addLocalMonitorForKeyDownAndKeyUp() {
|
private static func addLocalMonitorForKeyDownAndKeyUp() {
|
||||||
NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { (event: NSEvent) in
|
NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { (event: NSEvent) in
|
||||||
let someShortcutTriggered = handleKeyboardEvent(nil, nil, event.type == .keyDown ? UInt32(event.keyCode) : nil, event.modifierFlags, event.type == .keyDown ? event.isARepeat : false)
|
let keyCode = event.type == .keyDown ? UInt32(event.keyCode) : nil
|
||||||
return someShortcutTriggered ? nil : event
|
let isARepeat = event.type == .keyDown ? event.isARepeat : false
|
||||||
|
let shouldAbsorbEvent = handleKeyboardEvent(nil, nil, keyCode, event.modifierFlags, isARepeat, event)
|
||||||
|
return shouldAbsorbEvent ? nil : event
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,20 +12,40 @@ class KeyboardEventsTestable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func handleKeyboardEvent(_ globalId: Int?, _ shortcutState: ShortcutState?, _ keyCode: UInt32?, _ modifiers: NSEvent.ModifierFlags?, _ isARepeat: Bool) -> Bool {
|
func handleKeyboardEvent(_ globalId: Int?, _ shortcutState: ShortcutState?, _ keyCode: UInt32?, _ modifiers: NSEvent.ModifierFlags?, _ isARepeat: Bool, _ event: NSEvent? = nil) -> Bool {
|
||||||
|
let shouldAbsorbEvent = shouldAbsorbSearchEditingKeyDown(event)
|
||||||
|
if let event, shouldAbsorbEvent, App.app.thumbnailsPanel.tilesView.handleSearchEditingKeyDown(event) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
logKeyboardEvent(globalId, shortcutState, keyCode, modifiers, isARepeat)
|
||||||
|
let someShortcutTriggered = triggerMatchingShortcuts(globalId, shortcutState, keyCode, modifiers, isARepeat)
|
||||||
|
return shouldAbsorbEvent || someShortcutTriggered
|
||||||
|
}
|
||||||
|
|
||||||
|
private func logKeyboardEvent(_ globalId: Int?, _ shortcutState: ShortcutState?, _ keyCode: UInt32?, _ modifiers: NSEvent.ModifierFlags?, _ isARepeat: Bool) {
|
||||||
if let globalId, let shortcutState {
|
if let globalId, let shortcutState {
|
||||||
Logger.debug {
|
Logger.debug {
|
||||||
let shortcut = KeyboardEventsTestable.globalShortcutsIds.first { $0.value == globalId }
|
let shortcut = KeyboardEventsTestable.globalShortcutsIds.first { $0.value == globalId }
|
||||||
return "globalShortcut:\(shortcut?.key ?? "") state:\(shortcutState)"
|
return "globalShortcut:\(shortcut?.key ?? "") state:\(shortcutState)"
|
||||||
}
|
}
|
||||||
} else {
|
return
|
||||||
// TODO: use proper pattern from SwiftBeaver to not compute SymbolicModifierFlagsTransformer when logs are off
|
|
||||||
Logger.debug {
|
|
||||||
let modifiersAsString = modifiers.flatMap { SymbolicModifierFlagsTransformer.shared.transformedValue(NSNumber(value: $0.rawValue)) }
|
|
||||||
let keyCodeAsString = keyCode.flatMap { SymbolicKeyCodeTransformer.shared.transformedValue(NSNumber(value: $0)) }
|
|
||||||
return "keys:\(modifiersAsString ?? "")\(keyCodeAsString ?? "") isARepeat:\(isARepeat)"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// TODO: use proper pattern from SwiftBeaver to not compute SymbolicModifierFlagsTransformer when logs are off
|
||||||
|
Logger.debug {
|
||||||
|
let modifiersAsString = modifiers.flatMap { SymbolicModifierFlagsTransformer.shared.transformedValue(NSNumber(value: $0.rawValue)) }
|
||||||
|
let keyCodeAsString = keyCode.flatMap { SymbolicKeyCodeTransformer.shared.transformedValue(NSNumber(value: $0)) }
|
||||||
|
return "keys:\(modifiersAsString ?? "")\(keyCodeAsString ?? "") isARepeat:\(isARepeat)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldAbsorbSearchEditingKeyDown(_ event: NSEvent?) -> Bool {
|
||||||
|
guard let event, event.type == .keyDown, App.app.appIsBeingUsed, App.app.thumbnailsPanel.isKeyWindow, App.app.thumbnailsPanel.tilesView.isSearchEditing else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func triggerMatchingShortcuts(_ globalId: Int?, _ shortcutState: ShortcutState?, _ keyCode: UInt32?, _ modifiers: NSEvent.ModifierFlags?, _ isARepeat: Bool) -> Bool {
|
||||||
var someShortcutTriggered = false
|
var someShortcutTriggered = false
|
||||||
for shortcut in ControlsTab.shortcuts.values {
|
for shortcut in ControlsTab.shortcuts.values {
|
||||||
if shortcut.matches(globalId, shortcutState, keyCode, modifiers) && shortcut.shouldTrigger() {
|
if shortcut.matches(globalId, shortcutState, keyCode, modifiers) && shortcut.shouldTrigger() {
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class Search {
|
||||||
|
static func normalizedQuery(_ query: String) -> String {
|
||||||
|
normalizeForSpaceInsensitiveSearch(query).text
|
||||||
|
}
|
||||||
|
|
||||||
|
static func matches(_ window: Window, query: String) -> Bool {
|
||||||
|
let normalized = normalizedQuery(query)
|
||||||
|
if normalized.isEmpty { return true }
|
||||||
|
ensureCache(for: window, normalizedQuery: normalized)
|
||||||
|
return !window.swAppResults.isEmpty || !window.swTitleResults.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
static func relevance(for window: Window, query: String) -> Double {
|
||||||
|
let normalized = normalizedQuery(query)
|
||||||
|
if normalized.isEmpty { return 0.0 }
|
||||||
|
ensureCache(for: window, normalizedQuery: normalized)
|
||||||
|
return window.swBestSimilarity
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ensureCache(for window: Window, normalizedQuery normalized: String) {
|
||||||
|
let cacheKey = normalized + "|2"
|
||||||
|
if window.lastSearchQuery == cacheKey { return }
|
||||||
|
if normalized.isEmpty {
|
||||||
|
window.lastSearchQuery = cacheKey
|
||||||
|
window.swAppResults = []
|
||||||
|
window.swTitleResults = []
|
||||||
|
window.swBestSimilarity = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let appName = window.application.localizedName ?? ""
|
||||||
|
let title = window.title ?? ""
|
||||||
|
let topK = 3
|
||||||
|
let appResList = smithWatermanHighlightsIgnoringSpaces(query: normalized, text: appName, topK: topK, allowOverlaps: false)
|
||||||
|
let titleResList = smithWatermanHighlightsIgnoringSpaces(query: normalized, text: title, topK: topK, allowOverlaps: false)
|
||||||
|
window.swAppResults = appResList
|
||||||
|
window.swTitleResults = titleResList
|
||||||
|
let nameSim = appResList.first?.similarity ?? 0.0
|
||||||
|
let titleSim = titleResList.first?.similarity ?? 0.0
|
||||||
|
var similarity = max(nameSim * 1.02, titleSim)
|
||||||
|
similarity += max(SearchTestable.acronymBonus(query: normalized, text: appName), SearchTestable.acronymBonus(query: normalized, text: title))
|
||||||
|
window.swBestSimilarity = similarity
|
||||||
|
window.lastSearchQuery = cacheKey
|
||||||
|
}
|
||||||
|
|
||||||
|
static func smithWatermanHighlightsIgnoringSpaces(query: String, text: String, topK: Int = 1, allowOverlaps: Bool = false) -> [SWResult] {
|
||||||
|
let normalized = normalizedQuery(query)
|
||||||
|
if normalized.isEmpty { return [] }
|
||||||
|
let normalizedText = normalizeForSpaceInsensitiveSearch(text)
|
||||||
|
if normalizedText.text.isEmpty { return [] }
|
||||||
|
let normalizedResults = smithWatermanHighlights(query: normalized, text: normalizedText.text, topK: topK, allowOverlaps: allowOverlaps)
|
||||||
|
return normalizedResults.compactMap { mapNormalizedResultToOriginal($0, normalizedText.normalizedToOriginal) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizeForSpaceInsensitiveSearch(_ text: String) -> (text: String, normalizedToOriginal: [Int]) {
|
||||||
|
let chars = Array(text)
|
||||||
|
var normalizedChars = [Character]()
|
||||||
|
var normalizedToOriginal = [Int]()
|
||||||
|
normalizedChars.reserveCapacity(chars.count)
|
||||||
|
normalizedToOriginal.reserveCapacity(chars.count)
|
||||||
|
for (idx, c) in chars.enumerated() {
|
||||||
|
if c.unicodeScalars.allSatisfy({ CharacterSet.whitespacesAndNewlines.contains($0) }) { continue }
|
||||||
|
normalizedChars.append(c)
|
||||||
|
normalizedToOriginal.append(idx)
|
||||||
|
}
|
||||||
|
return (String(normalizedChars), normalizedToOriginal)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mapNormalizedResultToOriginal(_ result: SWResult, _ normalizedToOriginal: [Int]) -> SWResult? {
|
||||||
|
guard let span = mapNormalizedRangeToOriginal(result.span, normalizedToOriginal) else { return nil }
|
||||||
|
let subspans = result.subspans.compactMap { mapNormalizedRangeToOriginal($0, normalizedToOriginal) }
|
||||||
|
let ops = result.ops.compactMap { mapNormalizedOpToOriginal($0, normalizedToOriginal) }
|
||||||
|
return SWResult(score: result.score, similarity: result.similarity, span: span, subspans: subspans, ops: ops)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mapNormalizedRangeToOriginal(_ range: Range<Int>, _ normalizedToOriginal: [Int]) -> Range<Int>? {
|
||||||
|
if range.isEmpty { return nil }
|
||||||
|
guard range.lowerBound >= 0, range.upperBound <= normalizedToOriginal.count else { return nil }
|
||||||
|
let start = normalizedToOriginal[range.lowerBound]
|
||||||
|
let end = normalizedToOriginal[range.upperBound - 1] + 1
|
||||||
|
return start..<end
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mapNormalizedOpToOriginal(_ op: SWOp, _ normalizedToOriginal: [Int]) -> SWOp? {
|
||||||
|
if op.op == "D" {
|
||||||
|
if normalizedToOriginal.isEmpty { return SWOp(op: op.op, qi: op.qi, tj: 0) }
|
||||||
|
if op.tj <= 0 { return SWOp(op: op.op, qi: op.qi, tj: normalizedToOriginal[0]) }
|
||||||
|
if op.tj >= normalizedToOriginal.count { return SWOp(op: op.op, qi: op.qi, tj: normalizedToOriginal[normalizedToOriginal.count - 1] + 1) }
|
||||||
|
return SWOp(op: op.op, qi: op.qi, tj: normalizedToOriginal[op.tj])
|
||||||
|
}
|
||||||
|
guard op.tj >= 0, op.tj < normalizedToOriginal.count else { return nil }
|
||||||
|
return SWOp(op: op.op, qi: op.qi, tj: normalizedToOriginal[op.tj])
|
||||||
|
}
|
||||||
|
|
||||||
|
static func smithWatermanHighlights(query: String,
|
||||||
|
text: String,
|
||||||
|
match: Int = 2,
|
||||||
|
mismatch: Int = -1,
|
||||||
|
gap: Int = -2,
|
||||||
|
topK: Int = 1,
|
||||||
|
minScore: Int = 1,
|
||||||
|
allowOverlaps: Bool = false,
|
||||||
|
caseInsensitive: Bool = true) -> [SWResult] {
|
||||||
|
let qArr = Array(caseInsensitive ? query.lowercased() : query)
|
||||||
|
let tArr = Array(caseInsensitive ? text.lowercased() : text)
|
||||||
|
let n = qArr.count
|
||||||
|
let m = tArr.count
|
||||||
|
if n == 0 || m == 0 { return [] }
|
||||||
|
var H = Array(repeating: Array(repeating: 0, count: m + 1), count: n + 1)
|
||||||
|
var bt = Array(repeating: Array(repeating: Character("\0"), count: m + 1), count: n + 1)
|
||||||
|
for i in 1...n {
|
||||||
|
for j in 1...m {
|
||||||
|
let sDiag = H[i - 1][j - 1] + (qArr[i - 1] == tArr[j - 1] ? match : mismatch)
|
||||||
|
let sUp = H[i - 1][j] + gap
|
||||||
|
let sLeft = H[i][j - 1] + gap
|
||||||
|
var val = sDiag
|
||||||
|
var ptr: Character = "D"
|
||||||
|
if sUp > val { val = sUp; ptr = "U" }
|
||||||
|
if sLeft > val { val = sLeft; ptr = "L" }
|
||||||
|
if val < 0 { val = 0; ptr = "\0" }
|
||||||
|
H[i][j] = val
|
||||||
|
bt[i][j] = ptr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var candidates: [(score: Int, i: Int, j: Int)] = []
|
||||||
|
for i in 1...n {
|
||||||
|
for j in 1...m {
|
||||||
|
let s = H[i][j]
|
||||||
|
if s > 0 { candidates.append((s, i, j)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if candidates.isEmpty { return [] }
|
||||||
|
candidates.sort { $0.score > $1.score }
|
||||||
|
var results: [SWResult] = []
|
||||||
|
var usedSpans: [Range<Int>] = []
|
||||||
|
func rangesOverlap(_ a: Range<Int>, _ b: Range<Int>) -> Bool { a.lowerBound < b.upperBound && b.lowerBound < a.upperBound }
|
||||||
|
func backtrack(_ iStart: Int, _ jStart: Int) -> (ops: [SWOp], span: Range<Int>, subspans: [Range<Int>], score: Int) {
|
||||||
|
var opsRev: [SWOp] = []
|
||||||
|
var consumedJ: [Int] = []
|
||||||
|
var i = iStart
|
||||||
|
var j = jStart
|
||||||
|
while i > 0 && j > 0 && H[i][j] > 0 {
|
||||||
|
let p = bt[i][j]
|
||||||
|
if p == "D" {
|
||||||
|
opsRev.append(SWOp(op: qArr[i - 1] == tArr[j - 1] ? "M" : "S", qi: i - 1, tj: j - 1))
|
||||||
|
consumedJ.append(j - 1)
|
||||||
|
i -= 1
|
||||||
|
j -= 1
|
||||||
|
} else if p == "U" {
|
||||||
|
opsRev.append(SWOp(op: "D", qi: i - 1, tj: j))
|
||||||
|
i -= 1
|
||||||
|
} else if p == "L" {
|
||||||
|
opsRev.append(SWOp(op: "I", qi: i, tj: j - 1))
|
||||||
|
consumedJ.append(j - 1)
|
||||||
|
j -= 1
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ops = Array(opsRev.reversed())
|
||||||
|
let jStartIdx = consumedJ.min() ?? jStart
|
||||||
|
let jEndIdx = (consumedJ.max() ?? (jStart - 1)) + 1
|
||||||
|
let span = jStartIdx..<jEndIdx
|
||||||
|
var subspans: [Range<Int>] = []
|
||||||
|
var runStart: Int? = nil
|
||||||
|
var jCursor = jStartIdx
|
||||||
|
for op in ops {
|
||||||
|
switch op.op {
|
||||||
|
case "M":
|
||||||
|
if runStart == nil { runStart = jCursor }
|
||||||
|
jCursor += 1
|
||||||
|
case "S":
|
||||||
|
if let runStart { subspans.append(runStart..<jCursor) }
|
||||||
|
runStart = nil
|
||||||
|
jCursor += 1
|
||||||
|
case "I":
|
||||||
|
if let runStart { subspans.append(runStart..<jCursor) }
|
||||||
|
runStart = nil
|
||||||
|
jCursor += 1
|
||||||
|
case "D":
|
||||||
|
if let runStart { subspans.append(runStart..<jCursor) }
|
||||||
|
runStart = nil
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let runStart { subspans.append(runStart..<jCursor) }
|
||||||
|
return (ops, span, subspans, H[iStart][jStart])
|
||||||
|
}
|
||||||
|
for (score, i, j) in candidates {
|
||||||
|
if results.count >= topK || score < minScore { break }
|
||||||
|
let result = backtrack(i, j)
|
||||||
|
if !allowOverlaps && usedSpans.contains(where: { rangesOverlap($0, result.span) }) { continue }
|
||||||
|
let similarity = Double(result.score) / Double(max(1, match * n))
|
||||||
|
results.append(SWResult(score: result.score, similarity: similarity, span: result.span, subspans: result.subspans, ops: result.ops))
|
||||||
|
usedSpans.append(result.span)
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
static func smithWatermanSimilarity(query: String, text: String) -> Double {
|
||||||
|
smithWatermanHighlights(query: query, text: text, topK: 1).first?.similarity ?? 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SWOp {
|
||||||
|
let op: Character
|
||||||
|
let qi: Int
|
||||||
|
let tj: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SWResult {
|
||||||
|
let score: Int
|
||||||
|
let similarity: Double
|
||||||
|
let span: Range<Int>
|
||||||
|
let subspans: [Range<Int>]
|
||||||
|
let ops: [SWOp]
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
class SearchTestable {
|
||||||
|
static func acronymBonus(query: String, text: String) -> Double {
|
||||||
|
let q = normalizedQuery(query)
|
||||||
|
if q.isEmpty { return 0 }
|
||||||
|
let t = text.lowercased()
|
||||||
|
if t.hasPrefix(q) { return 6.0 + min(2.0, Double(q.count) * 0.25) }
|
||||||
|
let starts = wordStarts(in: text)
|
||||||
|
if starts.isEmpty { return 0 }
|
||||||
|
let qChars = Array(q)
|
||||||
|
var qi = 0
|
||||||
|
var firstMatch: Int?
|
||||||
|
for (i, c) in starts.enumerated() {
|
||||||
|
if qi >= qChars.count { break }
|
||||||
|
if c == qChars[qi] {
|
||||||
|
if firstMatch == nil { firstMatch = i }
|
||||||
|
qi += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard qi == qChars.count else { return 0 }
|
||||||
|
let pos = firstMatch ?? 0
|
||||||
|
return 4.0 + min(2.0, Double(q.count) * 0.2) + (pos == 0 ? 1.0 : max(0.0, 0.6 - Double(pos) * 0.15))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizedQuery(_ query: String) -> String {
|
||||||
|
String(Array(query).filter { c in !c.unicodeScalars.allSatisfy { CharacterSet.whitespacesAndNewlines.contains($0) } }).lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func wordStarts(in text: String) -> [Character] {
|
||||||
|
let chars = Array(text)
|
||||||
|
if chars.isEmpty { return [] }
|
||||||
|
var starts: [Character] = []
|
||||||
|
starts.reserveCapacity(min(16, chars.count))
|
||||||
|
for i in 0..<chars.count {
|
||||||
|
let c = chars[i]
|
||||||
|
if !isAlphaNum(c) { continue }
|
||||||
|
if i == 0 || !isAlphaNum(chars[i - 1]) || (isLower(chars[i - 1]) && isUpper(c)) {
|
||||||
|
starts.append(Character(String(c).lowercased()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return starts
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isAlphaNum(_ c: Character) -> Bool { c.unicodeScalars.allSatisfy { CharacterSet.alphanumerics.contains($0) } }
|
||||||
|
private static func isLower(_ c: Character) -> Bool { c.unicodeScalars.allSatisfy { CharacterSet.lowercaseLetters.contains($0) } }
|
||||||
|
private static func isUpper(_ c: Character) -> Bool { c.unicodeScalars.allSatisfy { CharacterSet.uppercaseLetters.contains($0) } }
|
||||||
|
}
|
||||||
@@ -64,6 +64,7 @@ class App: AppCenterApplication {
|
|||||||
appIsBeingUsed = false
|
appIsBeingUsed = false
|
||||||
isFirstSummon = true
|
isFirstSummon = true
|
||||||
forceDoNothingOnRelease = false
|
forceDoNothingOnRelease = false
|
||||||
|
thumbnailsPanel.tilesView.endSearchSession()
|
||||||
CursorEvents.toggle(false)
|
CursorEvents.toggle(false)
|
||||||
TrackpadEvents.reset()
|
TrackpadEvents.reset()
|
||||||
hideTilesPanelWithoutChangingKeyWindow()
|
hideTilesPanelWithoutChangingKeyWindow()
|
||||||
@@ -115,6 +116,25 @@ class App: AppCenterApplication {
|
|||||||
Windows.selectedWindow()?.application.hideOrShow()
|
Windows.selectedWindow()?.application.hideOrShow()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toggleSearchMode() {
|
||||||
|
guard appIsBeingUsed else { return }
|
||||||
|
thumbnailsPanel.tilesView.toggleSearchModeFromShortcut()
|
||||||
|
}
|
||||||
|
|
||||||
|
func lockSearchMode() {
|
||||||
|
guard appIsBeingUsed, thumbnailsPanel.tilesView.isSearchModeOn else { return }
|
||||||
|
thumbnailsPanel.tilesView.lockSearchMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelSearchModeOrHideUi() {
|
||||||
|
guard appIsBeingUsed else { return }
|
||||||
|
if thumbnailsPanel.tilesView.isSearchModeOn {
|
||||||
|
thumbnailsPanel.tilesView.disableSearchMode()
|
||||||
|
} else {
|
||||||
|
hideUi()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func focusTarget() {
|
func focusTarget() {
|
||||||
guard appIsBeingUsed else { return } // already hidden
|
guard appIsBeingUsed else { return } // already hidden
|
||||||
let selectedWindow = Windows.selectedWindow()
|
let selectedWindow = Windows.selectedWindow()
|
||||||
@@ -251,6 +271,11 @@ class App: AppCenterApplication {
|
|||||||
}
|
}
|
||||||
isFirstSummon = false
|
isFirstSummon = false
|
||||||
self.shortcutIndex = shortcutIndex
|
self.shortcutIndex = shortcutIndex
|
||||||
|
let shouldStartInSearchMode = Preferences.shortcutStyle[self.shortcutIndex] == .searchOnRelease
|
||||||
|
thumbnailsPanel.tilesView.startSearchSession(shouldStartInSearchMode)
|
||||||
|
if shouldStartInSearchMode {
|
||||||
|
forceDoNothingOnRelease = true
|
||||||
|
}
|
||||||
if !Windows.updatesBeforeShowing() { hideUi(); return }
|
if !Windows.updatesBeforeShowing() { hideUi(); return }
|
||||||
Windows.setInitialSelectedAndHoveredWindowIndex()
|
Windows.setInitialSelectedAndHoveredWindowIndex()
|
||||||
if Preferences.windowDisplayDelay == DispatchTimeInterval.milliseconds(0) {
|
if Preferences.windowDisplayDelay == DispatchTimeInterval.milliseconds(0) {
|
||||||
@@ -277,6 +302,9 @@ class App: AppCenterApplication {
|
|||||||
refreshUi()
|
refreshUi()
|
||||||
guard appIsBeingUsed else { return }
|
guard appIsBeingUsed else { return }
|
||||||
thumbnailsPanel.show()
|
thumbnailsPanel.show()
|
||||||
|
if thumbnailsPanel.tilesView.isSearchEditing {
|
||||||
|
thumbnailsPanel.tilesView.enableSearchEditing()
|
||||||
|
}
|
||||||
KeyRepeatTimer.startRepeatingKeyNextWindow()
|
KeyRepeatTimer.startRepeatingKeyNextWindow()
|
||||||
Windows.refreshThumbnailsAsync(Windows.list, .refreshOnlyThumbnailsAfterShowUi)
|
Windows.refreshThumbnailsAsync(Windows.list, .refreshOnlyThumbnailsAfterShowUi)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Cocoa
|
import Cocoa
|
||||||
|
|
||||||
class TileTitleView: NSTextField {
|
class TileTitleView: NSTextField {
|
||||||
|
static let searchHighlightBackgroundKey = NSAttributedString.Key("tileSearchHighlightBackground")
|
||||||
private var currentWidth: CGFloat = -1
|
private var currentWidth: CGFloat = -1
|
||||||
|
|
||||||
// we set their size manually; override this to remove wasteful appkit-side work
|
// we set their size manually; override this to remove wasteful appkit-side work
|
||||||
@@ -21,6 +22,11 @@ class TileTitleView: NSTextField {
|
|||||||
allowsDefaultTighteningForTruncation = false
|
allowsDefaultTighteningForTruncation = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func draw(_ dirtyRect: NSRect) {
|
||||||
|
drawRoundedSearchHighlights()
|
||||||
|
super.draw(dirtyRect)
|
||||||
|
}
|
||||||
|
|
||||||
func fixHeight() {
|
func fixHeight() {
|
||||||
frame.size.height = cell!.cellSize.height
|
frame.size.height = cell!.cellSize.height
|
||||||
}
|
}
|
||||||
@@ -47,4 +53,54 @@ class TileTitleView: NSTextField {
|
|||||||
}
|
}
|
||||||
return .byTruncatingHead
|
return .byTruncatingHead
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func drawRoundedSearchHighlights() {
|
||||||
|
let attributed = attributedStringValue
|
||||||
|
guard attributed.length > 0 else { return }
|
||||||
|
var hasHighlights = false
|
||||||
|
attributed.enumerateAttribute(Self.searchHighlightBackgroundKey, in: NSRange(location: 0, length: attributed.length)) { value, _, stop in
|
||||||
|
if value != nil {
|
||||||
|
hasHighlights = true
|
||||||
|
stop.pointee = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard hasHighlights else { return }
|
||||||
|
let textRect = cell?.drawingRect(forBounds: bounds) ?? bounds
|
||||||
|
guard textRect.width > 0, textRect.height > 0 else { return }
|
||||||
|
let storage = NSTextStorage(attributedString: attributed)
|
||||||
|
let layout = NSLayoutManager()
|
||||||
|
let container = NSTextContainer(size: textRect.size)
|
||||||
|
container.lineFragmentPadding = 0
|
||||||
|
container.maximumNumberOfLines = 1
|
||||||
|
container.lineBreakMode = .byClipping
|
||||||
|
layout.addTextContainer(container)
|
||||||
|
storage.addLayoutManager(layout)
|
||||||
|
layout.ensureLayout(for: container)
|
||||||
|
attributed.enumerateAttribute(Self.searchHighlightBackgroundKey, in: NSRange(location: 0, length: attributed.length)) { value, range, _ in
|
||||||
|
guard let color = value as? NSColor else { return }
|
||||||
|
let glyphRange = layout.glyphRange(forCharacterRange: range, actualCharacterRange: nil)
|
||||||
|
guard glyphRange.length > 0 else { return }
|
||||||
|
let glyphRect = layout.boundingRect(forGlyphRange: glyphRange, in: container)
|
||||||
|
guard glyphRect.width > 0, glyphRect.height > 0 else { return }
|
||||||
|
var rect = glyphRect
|
||||||
|
rect.origin.x += textRect.origin.x + 1.05
|
||||||
|
rect.origin.y += textRect.origin.y + 0.45
|
||||||
|
rect.size.width += 0.75
|
||||||
|
rect.size.height = max(1, rect.size.height - 0.9)
|
||||||
|
rect = pixelAligned(rect)
|
||||||
|
let radius = min(4, rect.height * 0.35)
|
||||||
|
color.setFill()
|
||||||
|
NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius).fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pixelAligned(_ rect: NSRect) -> NSRect {
|
||||||
|
let scale = window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2
|
||||||
|
var result = rect
|
||||||
|
result.origin.x = round(result.origin.x * scale) / scale
|
||||||
|
result.origin.y = round(result.origin.y * scale) / scale
|
||||||
|
result.size.width = max(1 / scale, ceil(result.size.width * scale) / scale)
|
||||||
|
result.size.height = max(1 / scale, ceil(result.size.height * scale) / scale)
|
||||||
|
return result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,8 @@ class TileView: FlippedView {
|
|||||||
var statusIcons = StatusIconsView()
|
var statusIcons = StatusIconsView()
|
||||||
var dockLabelIcon = TileFontIconView(badgeSize: TileFontIconView.badgeBaseSize(forIconSize: TileView.iconSize().width))
|
var dockLabelIcon = TileFontIconView(badgeSize: TileFontIconView.badgeBaseSize(forIconSize: TileView.iconSize().width))
|
||||||
var windowlessAppIndicator = WindowlessAppIndicator(tooltip: TileView.noOpenWindowToolTip)
|
var windowlessAppIndicator = WindowlessAppIndicator(tooltip: TileView.noOpenWindowToolTip)
|
||||||
|
private var fullTitle = ""
|
||||||
|
private var fullTitleWidth = CGFloat(0)
|
||||||
|
|
||||||
var mouseUpCallback: (() -> Void)!
|
var mouseUpCallback: (() -> Void)!
|
||||||
var mouseMovedCallback: (() -> Void)!
|
var mouseMovedCallback: (() -> Void)!
|
||||||
@@ -75,7 +77,7 @@ class TileView: FlippedView {
|
|||||||
|
|
||||||
private func updateLabelTooltipIfNeeded() {
|
private func updateLabelTooltipIfNeeded() {
|
||||||
guard Preferences.appearanceStyle != .appIcons else { return }
|
guard Preferences.appearanceStyle != .appIcons else { return }
|
||||||
label.toolTip = label.cell!.cellSize.width >= label.frame.size.width ? label.stringValue : nil
|
label.toolTip = fullTitleWidth >= label.frame.size.width ? fullTitle : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
convenience init() {
|
convenience init() {
|
||||||
@@ -100,6 +102,7 @@ class TileView: FlippedView {
|
|||||||
updateValues(element, index, newHeight)
|
updateValues(element, index, newHeight)
|
||||||
updateSizes(newHeight)
|
updateSizes(newHeight)
|
||||||
updatePositions(newHeight)
|
updatePositions(newHeight)
|
||||||
|
applySearchHighlight()
|
||||||
}
|
}
|
||||||
|
|
||||||
func drawHighlight() {
|
func drawHighlight() {
|
||||||
@@ -187,7 +190,7 @@ class TileView: FlippedView {
|
|||||||
|
|
||||||
private func updateAppIconsLabelFrame() {
|
private func updateAppIconsLabelFrame() {
|
||||||
let viewWidth = frame.width
|
let viewWidth = frame.width
|
||||||
let labelWidth = label.cell!.cellSize.width
|
let labelWidth = fullTitleWidth
|
||||||
let padding = (Preferences.appearanceSize == .small ? 0 : ( Preferences.appearanceSize == .medium ? 1 : 2)) * Appearance.intraCellPadding
|
let padding = (Preferences.appearanceSize == .small ? 0 : ( Preferences.appearanceSize == .medium ? 1 : 2)) * Appearance.intraCellPadding
|
||||||
let maxAllowedLabelWidth = getMaxAllowedLabelWidth()
|
let maxAllowedLabelWidth = getMaxAllowedLabelWidth()
|
||||||
let sidesToOffset: CGFloat = (isFirstInRow ? 1 : 0) + (isLastInRow ? 1 : 0)
|
let sidesToOffset: CGFloat = (isFirstInRow ? 1 : 0) + (isLastInRow ? 1 : 0)
|
||||||
@@ -221,7 +224,8 @@ class TileView: FlippedView {
|
|||||||
let yPosition = appIcon.frame.maxY + Appearance.intraCellPadding * 2
|
let yPosition = appIcon.frame.maxY + Appearance.intraCellPadding * 2
|
||||||
label.frame = NSRect(x: xPosition, y: yPosition, width: effectiveLabelWidth, height: height)
|
label.frame = NSRect(x: xPosition, y: yPosition, width: effectiveLabelWidth, height: height)
|
||||||
label.setWidth(effectiveLabelWidth)
|
label.setWidth(effectiveLabelWidth)
|
||||||
label.toolTip = label.cell!.cellSize.width >= label.frame.size.width ? label.stringValue : nil
|
label.toolTip = labelWidth >= label.frame.size.width ? fullTitle : nil
|
||||||
|
applySearchHighlight()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateAppIcon(_ element: Window, _ title: String) {
|
private func updateAppIcon(_ element: Window, _ title: String) {
|
||||||
@@ -257,6 +261,8 @@ class TileView: FlippedView {
|
|||||||
label.stringValue = title
|
label.stringValue = title
|
||||||
setAccessibilityLabel(title)
|
setAccessibilityLabel(title)
|
||||||
}
|
}
|
||||||
|
fullTitle = title
|
||||||
|
fullTitleWidth = label.cell!.cellSize.width
|
||||||
label.updateTruncationModeIfNeeded()
|
label.updateTruncationModeIfNeeded()
|
||||||
if statusIcons.spaceVisible {
|
if statusIcons.spaceVisible {
|
||||||
let spaceIndex = element.spaceIndexes.first
|
let spaceIndex = element.spaceIndexes.first
|
||||||
@@ -273,6 +279,174 @@ class TileView: FlippedView {
|
|||||||
mouseMovedCallback = { () -> Void in Windows.updateSelectedAndHoveredWindowIndex(index, true) }
|
mouseMovedCallback = { () -> Void in Windows.updateSelectedAndHoveredWindowIndex(index, true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func applySearchHighlight() {
|
||||||
|
let attributes = baseTitleAttributes()
|
||||||
|
let query = Search.normalizedQuery(Windows.searchQuery)
|
||||||
|
if query.isEmpty {
|
||||||
|
label.attributedStringValue = NSAttributedString(string: fullTitle, attributes: attributes)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let clippingAttributes = baseTitleAttributes(true)
|
||||||
|
let spanRanges = searchSpanRanges()
|
||||||
|
let titleLength = Array(fullTitle).count
|
||||||
|
let highlightedIndexes = highlightedIndexes(spanRanges, titleLength)
|
||||||
|
let truncation = truncatedDisplay(fullTitle, maxWidth: label.frame.size.width, mode: label.lineBreakMode, attributes: clippingAttributes)
|
||||||
|
let attributed = NSMutableAttributedString(string: truncation.text, attributes: clippingAttributes)
|
||||||
|
for range in visibleHighlightRanges(truncation.visibleToOriginal, highlightedIndexes) {
|
||||||
|
attributed.addAttribute(TileTitleView.searchHighlightBackgroundKey, value: Appearance.searchMatchHighlightColor, range: range)
|
||||||
|
attributed.addAttribute(.foregroundColor, value: Appearance.searchMatchForegroundColor, range: range)
|
||||||
|
}
|
||||||
|
let visibleOriginalIndexes = Set(truncation.visibleToOriginal.compactMap { $0 })
|
||||||
|
let hasHiddenHighlights = highlightedIndexes.contains { !visibleOriginalIndexes.contains($0) }
|
||||||
|
if hasHiddenHighlights, let ellipsisIndex = truncation.ellipsisIndex {
|
||||||
|
let range = NSRange(location: ellipsisIndex, length: 1)
|
||||||
|
attributed.addAttribute(TileTitleView.searchHighlightBackgroundKey, value: Appearance.searchMatchHighlightColor, range: range)
|
||||||
|
attributed.addAttribute(.foregroundColor, value: Appearance.searchMatchForegroundColor, range: range)
|
||||||
|
}
|
||||||
|
label.attributedStringValue = attributed
|
||||||
|
}
|
||||||
|
|
||||||
|
private func baseTitleAttributes(_ forceClipping: Bool = false) -> [NSAttributedString.Key: Any] {
|
||||||
|
let paragraphStyle = NSMutableParagraphStyle()
|
||||||
|
paragraphStyle.alignment = label.alignment
|
||||||
|
paragraphStyle.baseWritingDirection = .leftToRight
|
||||||
|
paragraphStyle.lineBreakMode = forceClipping ? .byClipping : label.lineBreakMode
|
||||||
|
return [.foregroundColor: Appearance.fontColor, .font: Appearance.font, .paragraphStyle: paragraphStyle]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func searchSpanRanges() -> [NSRange] {
|
||||||
|
var spanRanges = [NSRange]()
|
||||||
|
if Preferences.onlyShowApplications() || Preferences.showTitles == .appName {
|
||||||
|
for result in window_?.swAppResults ?? [] {
|
||||||
|
spanRanges.append(NSRange(location: result.span.lowerBound, length: result.span.count))
|
||||||
|
}
|
||||||
|
return spanRanges
|
||||||
|
}
|
||||||
|
if Preferences.showTitles == .appNameAndWindowTitle {
|
||||||
|
let appName = window_?.application.localizedName ?? ""
|
||||||
|
let offset = appName.isEmpty ? 0 : (appName + " - ").count
|
||||||
|
for result in window_?.swAppResults ?? [] {
|
||||||
|
spanRanges.append(NSRange(location: result.span.lowerBound, length: result.span.count))
|
||||||
|
}
|
||||||
|
for result in window_?.swTitleResults ?? [] {
|
||||||
|
spanRanges.append(NSRange(location: offset + result.span.lowerBound, length: result.span.count))
|
||||||
|
}
|
||||||
|
return spanRanges
|
||||||
|
}
|
||||||
|
for result in window_?.swTitleResults ?? [] {
|
||||||
|
spanRanges.append(NSRange(location: result.span.lowerBound, length: result.span.count))
|
||||||
|
}
|
||||||
|
return spanRanges
|
||||||
|
}
|
||||||
|
|
||||||
|
private func highlightedIndexes(_ ranges: [NSRange], _ titleLength: Int) -> Set<Int> {
|
||||||
|
var indexes = Set<Int>()
|
||||||
|
for range in ranges {
|
||||||
|
if range.length <= 0 { continue }
|
||||||
|
let start = max(0, range.location)
|
||||||
|
let end = min(titleLength, start + range.length)
|
||||||
|
if start >= end { continue }
|
||||||
|
for index in start..<end {
|
||||||
|
indexes.insert(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return indexes
|
||||||
|
}
|
||||||
|
|
||||||
|
private func visibleHighlightRanges(_ visibleToOriginal: [Int?], _ highlightedIndexes: Set<Int>) -> [NSRange] {
|
||||||
|
var ranges = [NSRange]()
|
||||||
|
var runStart: Int?
|
||||||
|
for (displayIndex, originalIndex) in visibleToOriginal.enumerated() {
|
||||||
|
let highlighted = originalIndex.flatMap { highlightedIndexes.contains($0) } ?? false
|
||||||
|
if highlighted {
|
||||||
|
if runStart == nil {
|
||||||
|
runStart = displayIndex
|
||||||
|
}
|
||||||
|
} else if let runStartValue = runStart {
|
||||||
|
ranges.append(NSRange(location: runStartValue, length: displayIndex - runStartValue))
|
||||||
|
runStart = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let runStart {
|
||||||
|
ranges.append(NSRange(location: runStart, length: visibleToOriginal.count - runStart))
|
||||||
|
}
|
||||||
|
return ranges
|
||||||
|
}
|
||||||
|
|
||||||
|
private func truncatedDisplay(_ title: String, maxWidth: CGFloat, mode: NSLineBreakMode, attributes: [NSAttributedString.Key: Any]) -> (text: String, visibleToOriginal: [Int?], ellipsisIndex: Int?) {
|
||||||
|
let chars = Array(title)
|
||||||
|
if chars.isEmpty { return ("", [], nil) }
|
||||||
|
if maxWidth <= 0 { return ("", [], nil) }
|
||||||
|
if measuredWidth(title, attributes) <= maxWidth {
|
||||||
|
return (title, Array(0..<chars.count).map { Optional($0) }, nil)
|
||||||
|
}
|
||||||
|
let ellipsis = "…"
|
||||||
|
if measuredWidth(ellipsis, attributes) > maxWidth {
|
||||||
|
return (ellipsis, [nil], 0)
|
||||||
|
}
|
||||||
|
if mode == .byTruncatingHead {
|
||||||
|
var low = 0
|
||||||
|
var high = chars.count
|
||||||
|
while low < high {
|
||||||
|
let mid = (low + high + 1) / 2
|
||||||
|
let candidate = ellipsis + String(chars.suffix(mid))
|
||||||
|
if measuredWidth(candidate, attributes) <= maxWidth {
|
||||||
|
low = mid
|
||||||
|
} else {
|
||||||
|
high = mid - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let suffixCount = low
|
||||||
|
let suffixStart = chars.count - suffixCount
|
||||||
|
let text = ellipsis + String(chars.suffix(suffixCount))
|
||||||
|
let mapping = [Int?](arrayLiteral: nil) + Array(suffixStart..<chars.count).map { Optional($0) }
|
||||||
|
return (text, mapping, 0)
|
||||||
|
}
|
||||||
|
if mode == .byTruncatingMiddle {
|
||||||
|
var leftCount = (chars.count + 1) / 2
|
||||||
|
var rightStart = leftCount
|
||||||
|
var candidate = String(chars.prefix(leftCount)) + ellipsis + String(chars.suffix(chars.count - rightStart))
|
||||||
|
while measuredWidth(candidate, attributes) > maxWidth && (leftCount > 0 || rightStart < chars.count) {
|
||||||
|
if rightStart < chars.count {
|
||||||
|
rightStart += 1
|
||||||
|
}
|
||||||
|
candidate = String(chars.prefix(leftCount)) + ellipsis + String(chars.suffix(chars.count - rightStart))
|
||||||
|
if measuredWidth(candidate, attributes) <= maxWidth {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if leftCount > 0 {
|
||||||
|
leftCount -= 1
|
||||||
|
}
|
||||||
|
candidate = String(chars.prefix(leftCount)) + ellipsis + String(chars.suffix(chars.count - rightStart))
|
||||||
|
}
|
||||||
|
if measuredWidth(candidate, attributes) > maxWidth {
|
||||||
|
return (ellipsis, [nil], 0)
|
||||||
|
}
|
||||||
|
let text = String(chars.prefix(leftCount)) + ellipsis + String(chars.suffix(chars.count - rightStart))
|
||||||
|
let mapping = Array(0..<leftCount).map { Optional($0) } + [nil] + Array(rightStart..<chars.count).map { Optional($0) }
|
||||||
|
return (text, mapping, leftCount)
|
||||||
|
}
|
||||||
|
var low = 0
|
||||||
|
var high = chars.count
|
||||||
|
while low < high {
|
||||||
|
let mid = (low + high + 1) / 2
|
||||||
|
let candidate = String(chars.prefix(mid)) + ellipsis
|
||||||
|
if measuredWidth(candidate, attributes) <= maxWidth {
|
||||||
|
low = mid
|
||||||
|
} else {
|
||||||
|
high = mid - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let prefixCount = low
|
||||||
|
let text = String(chars.prefix(prefixCount)) + ellipsis
|
||||||
|
let mapping = Array(0..<prefixCount).map { Optional($0) } + [nil]
|
||||||
|
return (text, mapping, prefixCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func measuredWidth(_ text: String, _ attributes: [NSAttributedString.Key: Any]) -> CGFloat {
|
||||||
|
(text as NSString).size(withAttributes: attributes).width
|
||||||
|
}
|
||||||
|
|
||||||
private func updateSizes(_ newHeight: CGFloat) {
|
private func updateSizes(_ newHeight: CGFloat) {
|
||||||
setFrameWidthHeight(newHeight)
|
setFrameWidthHeight(newHeight)
|
||||||
if Preferences.appearanceStyle != .appIcons {
|
if Preferences.appearanceStyle != .appIcons {
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import Cocoa
|
import Cocoa
|
||||||
|
import Carbon.HIToolbox.Events
|
||||||
|
import ShortcutRecorder
|
||||||
|
|
||||||
class TilesView {
|
enum SearchMode {
|
||||||
|
case off
|
||||||
|
case editing
|
||||||
|
case locked
|
||||||
|
}
|
||||||
|
|
||||||
|
class TilesView: NSObject {
|
||||||
var scrollView: ScrollView!
|
var scrollView: ScrollView!
|
||||||
var contentView: EffectView!
|
var contentView: EffectView!
|
||||||
|
var searchField = NSSearchField(frame: .zero)
|
||||||
|
private(set) var searchMode: SearchMode = .off
|
||||||
var rows = [[TileView]]()
|
var rows = [[TileView]]()
|
||||||
private var lastRowSignature = [Int]()
|
private var lastRowSignature = [Int]()
|
||||||
static var recycledViews = [TileView]()
|
static var recycledViews = [TileView]()
|
||||||
@@ -12,13 +22,242 @@ class TilesView {
|
|||||||
var thumbnailUnderLayer = TileUnderLayer()
|
var thumbnailUnderLayer = TileUnderLayer()
|
||||||
var thumbnailOverView = TileOverView()
|
var thumbnailOverView = TileOverView()
|
||||||
|
|
||||||
init() {
|
override init() {
|
||||||
|
super.init()
|
||||||
|
configureSearchField()
|
||||||
updateBackgroundView()
|
updateBackgroundView()
|
||||||
// TODO: think about this optimization more
|
// TODO: think about this optimization more
|
||||||
(1...20).forEach { _ in TilesView.recycledViews.append(TileView()) }
|
(1...20).forEach { _ in TilesView.recycledViews.append(TileView()) }
|
||||||
Self.updateCachedSizes()
|
Self.updateCachedSizes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isSearchModeOn: Bool { searchMode != .off }
|
||||||
|
var isSearchEditing: Bool { searchMode == .editing }
|
||||||
|
var isSearchLocked: Bool { searchMode == .locked }
|
||||||
|
|
||||||
|
func startSearchSession(_ startInSearchMode: Bool) {
|
||||||
|
searchField.stringValue = ""
|
||||||
|
Windows.updateSearchQuery("")
|
||||||
|
searchMode = startInSearchMode ? .editing : .off
|
||||||
|
updateSearchFieldEditability()
|
||||||
|
}
|
||||||
|
|
||||||
|
func endSearchSession() {
|
||||||
|
searchField.stringValue = ""
|
||||||
|
Windows.updateSearchQuery("")
|
||||||
|
searchMode = .off
|
||||||
|
updateSearchFieldEditability()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleSearchModeFromShortcut() {
|
||||||
|
if searchMode == .off {
|
||||||
|
enableSearchEditing()
|
||||||
|
} else if searchMode == .editing {
|
||||||
|
disableSearchMode()
|
||||||
|
} else {
|
||||||
|
enableSearchEditing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func disableSearchMode() {
|
||||||
|
guard searchMode != .off else { return }
|
||||||
|
searchMode = .off
|
||||||
|
updateSearchFieldEditability()
|
||||||
|
searchField.stringValue = ""
|
||||||
|
clearHover()
|
||||||
|
Windows.updateSearchQuery("")
|
||||||
|
App.app.refreshUi(true)
|
||||||
|
focusSelectedTileIfPossible()
|
||||||
|
}
|
||||||
|
|
||||||
|
func lockSearchMode() {
|
||||||
|
if searchMode == .editing {
|
||||||
|
searchMode = .locked
|
||||||
|
updateSearchFieldEditability()
|
||||||
|
focusSelectedTileIfPossible()
|
||||||
|
} else if searchMode == .locked {
|
||||||
|
enableSearchEditing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func enableSearchEditing() {
|
||||||
|
guard searchMode != .editing else {
|
||||||
|
placeSearchCaretAtEnd()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let wasOff = searchMode == .off
|
||||||
|
searchMode = .editing
|
||||||
|
updateSearchFieldEditability()
|
||||||
|
App.app.forceDoNothingOnRelease = true
|
||||||
|
clearHover()
|
||||||
|
stopKeyRepeatTimers()
|
||||||
|
if wasOff {
|
||||||
|
App.app.refreshUi(true)
|
||||||
|
}
|
||||||
|
App.app.thumbnailsPanel.makeFirstResponder(searchField)
|
||||||
|
placeSearchCaretAtEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSearchEditingKeyDown(_ event: NSEvent) -> Bool {
|
||||||
|
let keyCode = event.keyCode
|
||||||
|
if keyCode == UInt16(kVK_LeftArrow) { App.app.cycleSelection(.left); return true }
|
||||||
|
if keyCode == UInt16(kVK_RightArrow) { App.app.cycleSelection(.right); return true }
|
||||||
|
if keyCode == UInt16(kVK_UpArrow) { App.app.cycleSelection(.up); return true }
|
||||||
|
if keyCode == UInt16(kVK_DownArrow) { App.app.cycleSelection(.down); return true }
|
||||||
|
if keyCode == UInt16(kVK_Space) { return false }
|
||||||
|
if keyCode == UInt16(kVK_Delete) || keyCode == UInt16(kVK_ForwardDelete) {
|
||||||
|
deleteSearchCharacter(keyCode)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if let insertedText = insertedSearchText(from: event) {
|
||||||
|
appendSearchText(insertedText)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if matchesShortcut(event, "cancelShortcut") {
|
||||||
|
App.app.cancelSearchModeOrHideUi()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if matchesSearchShortcut(event) {
|
||||||
|
toggleSearchModeFromShortcut()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureSearchField() {
|
||||||
|
searchField.placeholderString = NSLocalizedString("Search windows", comment: "")
|
||||||
|
searchField.sendsSearchStringImmediately = true
|
||||||
|
searchField.sendsWholeSearchString = true
|
||||||
|
searchField.bezelStyle = .roundedBezel
|
||||||
|
if #available(macOS 26.0, *) {
|
||||||
|
searchField.controlSize = .extraLarge
|
||||||
|
} else if #available(macOS 13.0, *) {
|
||||||
|
searchField.controlSize = .large
|
||||||
|
} else {
|
||||||
|
searchField.controlSize = .regular
|
||||||
|
}
|
||||||
|
searchField.usesSingleLineMode = true
|
||||||
|
searchField.target = self
|
||||||
|
searchField.action = #selector(searchFieldChanged(_:))
|
||||||
|
updateSearchFieldEditability()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func searchFieldChanged(_ sender: NSSearchField) {
|
||||||
|
updateSearchQuery(sender.stringValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateSearchQuery(_ query: String) {
|
||||||
|
if Windows.searchQuery == query { return }
|
||||||
|
clearHover()
|
||||||
|
Windows.updateSearchQuery(query)
|
||||||
|
stopKeyRepeatTimers()
|
||||||
|
App.app.refreshUi(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func appendSearchText(_ text: String) {
|
||||||
|
guard !text.isEmpty else { return }
|
||||||
|
if let editor = searchField.currentEditor() {
|
||||||
|
let current = searchField.stringValue as NSString
|
||||||
|
let range = clampedSelectionRange(current.length, editor.selectedRange)
|
||||||
|
searchField.stringValue = current.replacingCharacters(in: range, with: text)
|
||||||
|
editor.selectedRange = NSRange(location: range.location + (text as NSString).length, length: 0)
|
||||||
|
} else {
|
||||||
|
searchField.stringValue += text
|
||||||
|
}
|
||||||
|
updateSearchQuery(searchField.stringValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteSearchCharacter(_ keyCode: UInt16) {
|
||||||
|
guard !searchField.stringValue.isEmpty else { return }
|
||||||
|
if let editor = searchField.currentEditor() {
|
||||||
|
let current = searchField.stringValue as NSString
|
||||||
|
var range = clampedSelectionRange(current.length, editor.selectedRange)
|
||||||
|
if range.length == 0 {
|
||||||
|
if keyCode == UInt16(kVK_Delete) {
|
||||||
|
guard range.location > 0 else { return }
|
||||||
|
range = NSRange(location: range.location - 1, length: 1)
|
||||||
|
} else {
|
||||||
|
guard range.location < current.length else { return }
|
||||||
|
range = NSRange(location: range.location, length: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
searchField.stringValue = current.replacingCharacters(in: range, with: "")
|
||||||
|
editor.selectedRange = NSRange(location: range.location, length: 0)
|
||||||
|
} else {
|
||||||
|
searchField.stringValue.removeLast()
|
||||||
|
}
|
||||||
|
updateSearchQuery(searchField.stringValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clampedSelectionRange(_ stringLength: Int, _ selectedRange: NSRange) -> NSRange {
|
||||||
|
let location = max(0, min(selectedRange.location, stringLength))
|
||||||
|
let length = max(0, min(selectedRange.length, stringLength - location))
|
||||||
|
return NSRange(location: location, length: length)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearHover() {
|
||||||
|
if let oldHoveredWindowIndex = Windows.hoveredWindowIndex {
|
||||||
|
Windows.hoveredWindowIndex = nil
|
||||||
|
TilesView.highlight(oldHoveredWindowIndex)
|
||||||
|
TilesView.highlight(Windows.selectedWindowIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopKeyRepeatTimers() {
|
||||||
|
KeyRepeatTimer.stopTimerForRepeatingKey(Preferences.indexToName("nextWindowShortcut", App.app.shortcutIndex))
|
||||||
|
KeyRepeatTimer.stopTimerForRepeatingKey("previousWindowShortcut")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func focusSelectedTileIfPossible() {
|
||||||
|
guard Windows.selectedWindowIndex >= 0, Windows.selectedWindowIndex < TilesView.recycledViews.count else { return }
|
||||||
|
App.app.thumbnailsPanel.makeFirstResponder(TilesView.recycledViews[Windows.selectedWindowIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func placeSearchCaretAtEnd() {
|
||||||
|
guard searchMode == .editing else { return }
|
||||||
|
if App.app.thumbnailsPanel.firstResponder !== searchField.currentEditor() {
|
||||||
|
App.app.thumbnailsPanel.makeFirstResponder(searchField)
|
||||||
|
}
|
||||||
|
guard let editor = searchField.currentEditor() else { return }
|
||||||
|
let end = searchField.stringValue.utf16.count
|
||||||
|
editor.selectedRange = NSRange(location: end, length: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func insertedSearchText(from event: NSEvent) -> String? {
|
||||||
|
if event.modifierFlags.contains(.command) || event.modifierFlags.contains(.control) || event.modifierFlags.contains(.function) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let keyCode = event.keyCode
|
||||||
|
if keyCode == UInt16(kVK_Tab) || keyCode == UInt16(kVK_Escape) || keyCode == UInt16(kVK_Return) || keyCode == UInt16(kVK_ANSI_KeypadEnter) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if keyCode == UInt16(kVK_LeftArrow) || keyCode == UInt16(kVK_RightArrow) || keyCode == UInt16(kVK_UpArrow) || keyCode == UInt16(kVK_DownArrow) || keyCode == UInt16(kVK_Home) || keyCode == UInt16(kVK_End) || keyCode == UInt16(kVK_PageUp) || keyCode == UInt16(kVK_PageDown) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let value = event.charactersIgnoringModifiers, value.count == 1 else { return nil }
|
||||||
|
guard let scalar = value.unicodeScalars.first, !CharacterSet.controlCharacters.contains(scalar) else { return nil }
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func matchesSearchShortcut(_ event: NSEvent) -> Bool {
|
||||||
|
matchesShortcut(event, "searchShortcut")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateSearchFieldEditability() {
|
||||||
|
let editable = searchMode == .editing
|
||||||
|
searchField.isEditable = editable
|
||||||
|
searchField.isSelectable = editable
|
||||||
|
}
|
||||||
|
|
||||||
|
private func matchesShortcut(_ event: NSEvent, _ shortcutId: String) -> Bool {
|
||||||
|
guard let shortcut = ControlsTab.shortcuts[shortcutId]?.shortcut else { return false }
|
||||||
|
if shortcut.keyCode == .none || shortcut.carbonKeyCode != UInt32(event.keyCode) { return false }
|
||||||
|
let holdModifiers = ControlsTab.shortcuts[Preferences.indexToName("holdShortcut", App.app.shortcutIndex)]?.shortcut.carbonModifierFlags.cleaned() ?? 0
|
||||||
|
let eventModifiers = cocoaToCarbonFlags(event.modifierFlags).cleaned()
|
||||||
|
let shortcutModifiers = shortcut.carbonModifierFlags.cleaned()
|
||||||
|
return eventModifiers == shortcutModifiers || eventModifiers == (shortcutModifiers | holdModifiers)
|
||||||
|
}
|
||||||
|
|
||||||
static func updateCachedSizes() {
|
static func updateCachedSizes() {
|
||||||
guard let firstView = TilesView.recycledViews.first else { return }
|
guard let firstView = TilesView.recycledViews.first else { return }
|
||||||
layoutCache.labelHeight = firstView.label.cell!.cellSize.height
|
layoutCache.labelHeight = firstView.label.cell!.cellSize.height
|
||||||
@@ -32,6 +271,8 @@ class TilesView {
|
|||||||
func updateBackgroundView() {
|
func updateBackgroundView() {
|
||||||
contentView = makeAppropriateEffectView()
|
contentView = makeAppropriateEffectView()
|
||||||
scrollView = ScrollView()
|
scrollView = ScrollView()
|
||||||
|
searchField.isHidden = searchMode == .off
|
||||||
|
contentView.addSubview(searchField)
|
||||||
contentView.addSubview(scrollView)
|
contentView.addSubview(scrollView)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,13 +302,15 @@ class TilesView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func highlight(_ indexInRecycledViews: Int) {
|
static func highlight(_ indexInRecycledViews: Int) {
|
||||||
|
guard indexInRecycledViews >= 0, indexInRecycledViews < recycledViews.count else { return }
|
||||||
let view = recycledViews[indexInRecycledViews]
|
let view = recycledViews[indexInRecycledViews]
|
||||||
view.indexInRecycledViews = indexInRecycledViews
|
view.indexInRecycledViews = indexInRecycledViews
|
||||||
guard view.frame != .zero else { return }
|
guard view.frame != .zero else { return }
|
||||||
view.drawHighlight()
|
view.drawHighlight()
|
||||||
let underLayer = App.app.thumbnailsPanel.tilesView.thumbnailUnderLayer
|
let underLayer = App.app.thumbnailsPanel.tilesView.thumbnailUnderLayer
|
||||||
|
guard Windows.selectedWindowIndex >= 0, Windows.selectedWindowIndex < recycledViews.count else { return }
|
||||||
let focusedView = recycledViews[Windows.selectedWindowIndex]
|
let focusedView = recycledViews[Windows.selectedWindowIndex]
|
||||||
let hoveredView = Windows.hoveredWindowIndex.map { recycledViews[$0] }
|
let hoveredView = Windows.hoveredWindowIndex.flatMap { $0 >= 0 && $0 < recycledViews.count ? recycledViews[$0] : nil }
|
||||||
underLayer.updateHighlight(
|
underLayer.updateHighlight(
|
||||||
focusedView: focusedView.frame != .zero ? focusedView : nil,
|
focusedView: focusedView.frame != .zero ? focusedView : nil,
|
||||||
hoveredView: hoveredView != focusedView && hoveredView?.frame != .zero ? hoveredView : nil
|
hoveredView: hoveredView != focusedView && hoveredView?.frame != .zero ? hoveredView : nil
|
||||||
@@ -123,7 +366,7 @@ class TilesView {
|
|||||||
if let (maxX, maxY, labelHeight, rowSignature) = layoutTileViews(widthMax) {
|
if let (maxX, maxY, labelHeight, rowSignature) = layoutTileViews(widthMax) {
|
||||||
layoutParentViews(maxX, widthMax, maxY, labelHeight)
|
layoutParentViews(maxX, widthMax, maxY, labelHeight)
|
||||||
if Preferences.alignThumbnails == .center {
|
if Preferences.alignThumbnails == .center {
|
||||||
centerRows(maxX)
|
centerRows(TilesView.thumbnailsWidth)
|
||||||
}
|
}
|
||||||
if rowSignature != lastRowSignature {
|
if rowSignature != lastRowSignature {
|
||||||
for row in rows {
|
for row in rows {
|
||||||
@@ -178,7 +421,10 @@ class TilesView {
|
|||||||
let view = TilesView.recycledViews[index]
|
let view = TilesView.recycledViews[index]
|
||||||
if index < Windows.list.count {
|
if index < Windows.list.count {
|
||||||
let window = Windows.list[index]
|
let window = Windows.list[index]
|
||||||
guard window.shouldShowTheUser else { continue }
|
guard Windows.shouldDisplay(window) else {
|
||||||
|
view.frame = .zero
|
||||||
|
continue
|
||||||
|
}
|
||||||
view.updateRecycledCellWithNewContent(window, index, height)
|
view.updateRecycledCellWithNewContent(window, index, height)
|
||||||
let width = view.frame.size.width
|
let width = view.frame.size.width
|
||||||
let projectedX = projectedWidth(currentX, width).rounded(.down)
|
let projectedX = projectedWidth(currentX, width).rounded(.down)
|
||||||
@@ -233,12 +479,16 @@ class TilesView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func layoutParentViews(_ maxX: CGFloat, _ widthMax: CGFloat, _ maxY: CGFloat, _ labelHeight: CGFloat) {
|
private func layoutParentViews(_ maxX: CGFloat, _ widthMax: CGFloat, _ maxY: CGFloat, _ labelHeight: CGFloat) {
|
||||||
let heightMax = TilesPanel.maxThumbnailsHeight()
|
let searchBarHeight = searchBarHeight()
|
||||||
TilesView.thumbnailsWidth = min(maxX, widthMax)
|
let searchBottomPadding = CGFloat(10)
|
||||||
|
let searchReservedHeight = searchMode == .off ? 0 : searchBarHeight + searchBottomPadding
|
||||||
|
let heightMax = max(0, TilesPanel.maxThumbnailsHeight() - searchReservedHeight)
|
||||||
|
let minSearchWidth = min(widthMax, 320)
|
||||||
|
TilesView.thumbnailsWidth = max(min(maxX, widthMax), searchMode == .off ? 0 : minSearchWidth)
|
||||||
TilesView.thumbnailsHeight = min(maxY, heightMax)
|
TilesView.thumbnailsHeight = min(maxY, heightMax)
|
||||||
let appIconsBottomViewportPadding = appIconsBottomViewportPadding(maxY, heightMax, labelHeight)
|
let appIconsBottomViewportPadding = appIconsBottomViewportPadding(maxY, heightMax, labelHeight)
|
||||||
let frameWidth = TilesView.thumbnailsWidth + Appearance.windowPadding * 2
|
let frameWidth = TilesView.thumbnailsWidth + Appearance.windowPadding * 2
|
||||||
var frameHeight = TilesView.thumbnailsHeight + Appearance.windowPadding * 2
|
var frameHeight = TilesView.thumbnailsHeight + Appearance.windowPadding * 2 + searchReservedHeight
|
||||||
let originX = Appearance.windowPadding
|
let originX = Appearance.windowPadding
|
||||||
var originY = Appearance.windowPadding
|
var originY = Appearance.windowPadding
|
||||||
if Preferences.appearanceStyle == .appIcons {
|
if Preferences.appearanceStyle == .appIcons {
|
||||||
@@ -248,11 +498,16 @@ class TilesView {
|
|||||||
}
|
}
|
||||||
contentView.frame.size = NSSize(width: frameWidth, height: frameHeight)
|
contentView.frame.size = NSSize(width: frameWidth, height: frameHeight)
|
||||||
let scrollHeight = max(0, min(maxY, heightMax) - appIconsBottomViewportPadding * 2)
|
let scrollHeight = max(0, min(maxY, heightMax) - appIconsBottomViewportPadding * 2)
|
||||||
scrollView.frame.size = NSSize(width: min(maxX, widthMax), height: scrollHeight)
|
scrollView.frame.size = NSSize(width: TilesView.thumbnailsWidth, height: scrollHeight)
|
||||||
scrollView.frame.origin = CGPoint(x: originX, y: originY + appIconsBottomViewportPadding * 2)
|
scrollView.frame.origin = CGPoint(x: originX, y: originY + appIconsBottomViewportPadding * 2)
|
||||||
scrollView.contentView.frame.size = scrollView.frame.size
|
scrollView.contentView.frame.size = scrollView.frame.size
|
||||||
|
searchField.isHidden = searchMode == .off
|
||||||
|
if searchMode != .off {
|
||||||
|
searchField.frame.size = NSSize(width: TilesView.thumbnailsWidth, height: searchBarHeight)
|
||||||
|
searchField.frame.origin = CGPoint(x: originX, y: frameHeight - Appearance.windowPadding - searchBarHeight)
|
||||||
|
}
|
||||||
if App.shared.userInterfaceLayoutDirection == .rightToLeft {
|
if App.shared.userInterfaceLayoutDirection == .rightToLeft {
|
||||||
let croppedWidth = widthMax - maxX
|
let croppedWidth = max(0, TilesView.thumbnailsWidth - maxX)
|
||||||
scrollView.documentView!.subviews.forEach { $0.frame.origin.x -= croppedWidth }
|
scrollView.documentView!.subviews.forEach { $0.frame.origin.x -= croppedWidth }
|
||||||
}
|
}
|
||||||
scrollView.documentView!.frame.size = NSSize(width: maxX, height: maxY)
|
scrollView.documentView!.frame.size = NSSize(width: maxX, height: maxY)
|
||||||
@@ -261,52 +516,61 @@ class TilesView {
|
|||||||
thumbnailUnderLayer.frame = CGRect(origin: .zero, size: docSize)
|
thumbnailUnderLayer.frame = CGRect(origin: .zero, size: docSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func searchBarHeight() -> CGFloat {
|
||||||
|
let fitting = searchField.fittingSize.height
|
||||||
|
if fitting > 0 {
|
||||||
|
return ceil(fitting)
|
||||||
|
}
|
||||||
|
return ceil(searchField.cell?.cellSize.height ?? 30)
|
||||||
|
}
|
||||||
|
|
||||||
private func appIconsBottomViewportPadding(_ maxY: CGFloat, _ heightMax: CGFloat, _ labelHeight: CGFloat) -> CGFloat {
|
private func appIconsBottomViewportPadding(_ maxY: CGFloat, _ heightMax: CGFloat, _ labelHeight: CGFloat) -> CGFloat {
|
||||||
guard Preferences.appearanceStyle == .appIcons, maxY > heightMax else { return 0 }
|
guard Preferences.appearanceStyle == .appIcons, maxY > heightMax else { return 0 }
|
||||||
return max(0, Appearance.windowPadding - labelHeight)
|
return max(0, Appearance.windowPadding - labelHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
func centerRows(_ maxX: CGFloat) {
|
func centerRows(_ maxX: CGFloat) {
|
||||||
var rowStartIndex = 0
|
for row in rows where !row.isEmpty {
|
||||||
var rowWidth = Appearance.interCellPadding
|
|
||||||
var rowY = Appearance.interCellPadding
|
|
||||||
for (index, window) in Windows.list.enumerated() {
|
|
||||||
guard App.app.appIsBeingUsed else { return }
|
guard App.app.appIsBeingUsed else { return }
|
||||||
guard window.shouldShowTheUser else { continue }
|
let rowWidth = Appearance.interCellPadding + row.reduce(CGFloat(0)) { $0 + $1.frame.size.width + Appearance.interCellPadding }
|
||||||
let view = TilesView.recycledViews[index]
|
let offset = ((maxX - rowWidth) / 2).rounded()
|
||||||
if view.frame.origin.y == rowY {
|
if offset > 0 {
|
||||||
rowWidth += view.frame.size.width + Appearance.interCellPadding
|
for view in row {
|
||||||
} else {
|
view.frame.origin.x += App.shared.userInterfaceLayoutDirection == .leftToRight ? offset : -offset
|
||||||
shiftRow(maxX, rowWidth, rowStartIndex, index)
|
}
|
||||||
rowStartIndex = index
|
|
||||||
rowWidth = Appearance.interCellPadding + view.frame.size.width + Appearance.interCellPadding
|
|
||||||
rowY = view.frame.origin.y
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
shiftRow(maxX, rowWidth, rowStartIndex, Windows.list.count)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func highlightStartView() {
|
private func highlightStartView() {
|
||||||
TilesView.highlight(Windows.selectedWindowIndex)
|
if Windows.selectedWindow() != nil {
|
||||||
if let hoveredWindowIndex = Windows.hoveredWindowIndex {
|
TilesView.highlight(Windows.selectedWindowIndex)
|
||||||
|
} else {
|
||||||
|
thumbnailUnderLayer.updateHighlight(focusedView: nil, hoveredView: nil)
|
||||||
|
thumbnailOverView.hideWindowControls()
|
||||||
|
}
|
||||||
|
if let hoveredWindowIndex = Windows.hoveredWindowIndex,
|
||||||
|
hoveredWindowIndex >= 0,
|
||||||
|
hoveredWindowIndex < Windows.list.count,
|
||||||
|
Windows.shouldDisplay(Windows.list[hoveredWindowIndex]) {
|
||||||
TilesView.highlight(hoveredWindowIndex)
|
TilesView.highlight(hoveredWindowIndex)
|
||||||
if thumbnailOverView.isShowingWindowControls {
|
if thumbnailOverView.isShowingWindowControls {
|
||||||
thumbnailOverView.showWindowControls(for: TilesView.recycledViews[hoveredWindowIndex])
|
thumbnailOverView.showWindowControls(for: TilesView.recycledViews[hoveredWindowIndex])
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
}
|
thumbnailOverView.hideWindowControls()
|
||||||
|
|
||||||
private func shiftRow(_ maxX: CGFloat, _ rowWidth: CGFloat, _ rowStartIndex: Int, _ index: Int) {
|
|
||||||
let offset = ((maxX - rowWidth) / 2).rounded()
|
|
||||||
if offset > 0 {
|
|
||||||
for i in rowStartIndex..<index {
|
|
||||||
TilesView.recycledViews[i].frame.origin.x += App.shared.userInterfaceLayoutDirection == .leftToRight ? offset : -offset
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearNeedsLayout() {
|
func clearNeedsLayout() {
|
||||||
let views = [contentView, scrollView, scrollView.contentView, scrollView.documentView].compactMap { $0 }
|
var views = [NSView]()
|
||||||
|
if let contentView { views.append(contentView as NSView) }
|
||||||
|
views.append(searchField)
|
||||||
|
if let scrollView {
|
||||||
|
views.append(scrollView)
|
||||||
|
views.append(scrollView.contentView)
|
||||||
|
if let documentView = scrollView.documentView { views.append(documentView) }
|
||||||
|
}
|
||||||
for view in views {
|
for view in views {
|
||||||
view.needsLayout = false
|
view.needsLayout = false
|
||||||
view.needsDisplay = false
|
view.needsDisplay = false
|
||||||
@@ -320,6 +584,8 @@ class TilesView {
|
|||||||
var iconHeight = CGFloat(0)
|
var iconHeight = CGFloat(0)
|
||||||
var comfortableReadabilityWidth: CGFloat?
|
var comfortableReadabilityWidth: CGFloat?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ScrollView: NSScrollView {
|
class ScrollView: NSScrollView {
|
||||||
@@ -372,6 +638,7 @@ class FlippedView: NSView {
|
|||||||
@objc func _windowChangedKeyState() {}
|
@objc func _windowChangedKeyState() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
enum Direction {
|
enum Direction {
|
||||||
case right
|
case right
|
||||||
case left
|
case left
|
||||||
|
|||||||
@@ -22,12 +22,14 @@ class ControlsTab {
|
|||||||
"vimCycleLeft": { App.app.cycleSelection(.left) },
|
"vimCycleLeft": { App.app.cycleSelection(.left) },
|
||||||
"vimCycleUp": { App.app.cycleSelection(.up) },
|
"vimCycleUp": { App.app.cycleSelection(.up) },
|
||||||
"vimCycleDown": { App.app.cycleSelection(.down) },
|
"vimCycleDown": { App.app.cycleSelection(.down) },
|
||||||
"cancelShortcut": { App.app.hideUi() },
|
"cancelShortcut": { App.app.cancelSearchModeOrHideUi() },
|
||||||
"closeWindowShortcut": { App.app.closeSelectedWindow() },
|
"closeWindowShortcut": { App.app.closeSelectedWindow() },
|
||||||
"minDeminWindowShortcut": { App.app.minDeminSelectedWindow() },
|
"minDeminWindowShortcut": { App.app.minDeminSelectedWindow() },
|
||||||
"toggleFullscreenWindowShortcut": { App.app.toggleFullscreenSelectedWindow() },
|
"toggleFullscreenWindowShortcut": { App.app.toggleFullscreenSelectedWindow() },
|
||||||
"quitAppShortcut": { App.app.quitSelectedApp() },
|
"quitAppShortcut": { App.app.quitSelectedApp() },
|
||||||
"hideShowAppShortcut": { App.app.hideShowSelectedApp() },
|
"hideShowAppShortcut": { App.app.hideShowSelectedApp() },
|
||||||
|
"searchShortcut": { App.app.toggleSearchMode() },
|
||||||
|
"lockSearchShortcut": { App.app.lockSearchMode() },
|
||||||
]
|
]
|
||||||
static var arrowKeysCheckbox: Switch!
|
static var arrowKeysCheckbox: Switch!
|
||||||
static var vimKeysCheckbox: Switch!
|
static var vimKeysCheckbox: Switch!
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ class ShortcutsWhenActiveSheet: SheetWindow {
|
|||||||
rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Focus selected window", comment: ""), "focusWindowShortcut", Preferences.focusWindowShortcut, labelPosition: .right)[0]])
|
rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Focus selected window", comment: ""), "focusWindowShortcut", Preferences.focusWindowShortcut, labelPosition: .right)[0]])
|
||||||
let previousWindowShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Select previous window", comment: ""),
|
let previousWindowShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Select previous window", comment: ""),
|
||||||
rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Select previous window", comment: ""), "previousWindowShortcut", Preferences.previousWindowShortcut, labelPosition: .right)[0]])
|
rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Select previous window", comment: ""), "previousWindowShortcut", Preferences.previousWindowShortcut, labelPosition: .right)[0]])
|
||||||
let cancelShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Cancel and hide", comment: ""),
|
let cancelShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Cancel", comment: ""),
|
||||||
rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Cancel and hide", comment: ""), "cancelShortcut", Preferences.cancelShortcut, labelPosition: .right)[0]])
|
rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Cancel", comment: ""), "cancelShortcut", Preferences.cancelShortcut, labelPosition: .right)[0]])
|
||||||
|
let searchShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Search", comment: ""),
|
||||||
|
rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Search", comment: ""), "searchShortcut", Preferences.searchShortcut, labelPosition: .right)[0]])
|
||||||
|
let lockSearchShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Lock search", comment: ""),
|
||||||
|
rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Lock search", comment: ""), "lockSearchShortcut", Preferences.lockSearchShortcut, labelPosition: .right)[0]])
|
||||||
let closeWindowShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Close window", comment: ""),
|
let closeWindowShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Close window", comment: ""),
|
||||||
rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Close window", comment: ""), "closeWindowShortcut", Preferences.closeWindowShortcut, labelPosition: .right)[0]])
|
rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Close window", comment: ""), "closeWindowShortcut", Preferences.closeWindowShortcut, labelPosition: .right)[0]])
|
||||||
let minDeminWindowShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Minimize/Deminimize window", comment: ""),
|
let minDeminWindowShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Minimize/Deminimize window", comment: ""),
|
||||||
@@ -22,6 +26,8 @@ class ShortcutsWhenActiveSheet: SheetWindow {
|
|||||||
_ = table.addRow(focusWindowShortcut)
|
_ = table.addRow(focusWindowShortcut)
|
||||||
_ = table.addRow(previousWindowShortcut)
|
_ = table.addRow(previousWindowShortcut)
|
||||||
_ = table.addRow(cancelShortcut)
|
_ = table.addRow(cancelShortcut)
|
||||||
|
_ = table.addRow(searchShortcut)
|
||||||
|
_ = table.addRow(lockSearchShortcut)
|
||||||
_ = table.addRow(closeWindowShortcut)
|
_ = table.addRow(closeWindowShortcut)
|
||||||
_ = table.addRow(minDeminWindowShortcut)
|
_ = table.addRow(minDeminWindowShortcut)
|
||||||
_ = table.addRow(toggleFullscreenWindowShortcut)
|
_ = table.addRow(toggleFullscreenWindowShortcut)
|
||||||
|
|||||||
@@ -126,6 +126,21 @@ final class KeyboardEventsUtilsTests: XCTestCase {
|
|||||||
XCTAssertEqual(ControlsTab.shortcutsActionsTriggered, ["nextWindowShortcut"])
|
XCTAssertEqual(ControlsTab.shortcutsActionsTriggered, ["nextWindowShortcut"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testOnReleaseToggleSearchModeDoesNotFocus() throws {
|
||||||
|
resetState()
|
||||||
|
Preferences.shortcutStyle[0] = .searchOnRelease
|
||||||
|
ModifierFlags.current = [.option]
|
||||||
|
handleKeyboardEvent(nil, nil, nil, [.option], false)
|
||||||
|
XCTAssertEqual(ControlsTab.shortcutsActionsTriggered, [])
|
||||||
|
handleKeyboardEvent(KeyboardEventsTestable.globalShortcutsIds["nextWindowShortcut"], .down, nil, nil, false)
|
||||||
|
XCTAssertEqual(ControlsTab.shortcutsActionsTriggered, ["nextWindowShortcut"])
|
||||||
|
handleKeyboardEvent(KeyboardEventsTestable.globalShortcutsIds["nextWindowShortcut"], .up, nil, nil, false)
|
||||||
|
XCTAssertEqual(ControlsTab.shortcutsActionsTriggered, ["nextWindowShortcut"])
|
||||||
|
ModifierFlags.current = []
|
||||||
|
handleKeyboardEvent(nil, nil, nil, [], false)
|
||||||
|
XCTAssertEqual(ControlsTab.shortcutsActionsTriggered, ["nextWindowShortcut"])
|
||||||
|
}
|
||||||
|
|
||||||
// alt-down > tab-down > tab-up > `-down > `-up
|
// alt-down > tab-down > tab-up > `-down > `-up
|
||||||
func testTransitionFromOneShortcutToAnother() throws {
|
func testTransitionFromOneShortcutToAnother() throws {
|
||||||
resetState()
|
resetState()
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class ControlsTab {
|
|||||||
"focusWindowShortcut": ATShortcut(Shortcut(keyEquivalent: " ")!, "focusWindowShortcut", .local, .down),
|
"focusWindowShortcut": ATShortcut(Shortcut(keyEquivalent: " ")!, "focusWindowShortcut", .local, .down),
|
||||||
"previousWindowShortcut": ATShortcut(Shortcut(keyEquivalent: "⇧")!, "previousWindowShortcut", .local, .down),
|
"previousWindowShortcut": ATShortcut(Shortcut(keyEquivalent: "⇧")!, "previousWindowShortcut", .local, .down),
|
||||||
"cancelShortcut": ATShortcut(Shortcut(keyEquivalent: "⎋")!, "cancelShortcut", .local, .down),
|
"cancelShortcut": ATShortcut(Shortcut(keyEquivalent: "⎋")!, "cancelShortcut", .local, .down),
|
||||||
|
"searchShortcut": ATShortcut(Shortcut(keyEquivalent: "s")!, "searchShortcut", .local, .down),
|
||||||
"closeWindowShortcut": ATShortcut(Shortcut(keyEquivalent: "w")!, "closeWindowShortcut", .local, .down),
|
"closeWindowShortcut": ATShortcut(Shortcut(keyEquivalent: "w")!, "closeWindowShortcut", .local, .down),
|
||||||
"minDeminWindowShortcut": ATShortcut(Shortcut(keyEquivalent: "m")!, "minDeminWindowShortcut", .local, .down),
|
"minDeminWindowShortcut": ATShortcut(Shortcut(keyEquivalent: "m")!, "minDeminWindowShortcut", .local, .down),
|
||||||
"toggleFullscreenWindowShortcut": ATShortcut(Shortcut(keyEquivalent: "f")!, "toggleFullscreenWindowShortcut", .local, .down),
|
"toggleFullscreenWindowShortcut": ATShortcut(Shortcut(keyEquivalent: "f")!, "toggleFullscreenWindowShortcut", .local, .down),
|
||||||
@@ -78,6 +79,7 @@ class Preferences {
|
|||||||
enum ShortcutStylePreference: CaseIterable {
|
enum ShortcutStylePreference: CaseIterable {
|
||||||
case focusOnRelease
|
case focusOnRelease
|
||||||
case doNothingOnRelease
|
case doNothingOnRelease
|
||||||
|
case searchOnRelease
|
||||||
}
|
}
|
||||||
|
|
||||||
class ModifierFlags {
|
class ModifierFlags {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class SearchAcronymTests: XCTestCase {
|
||||||
|
func testAcronymBonusPrefixMatch() throws {
|
||||||
|
XCTAssertGreaterThan(SearchTestable.acronymBonus(query: "chr", text: "Chrome"), 0)
|
||||||
|
XCTAssertEqual(SearchTestable.acronymBonus(query: "chr", text: "My Chrome"), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAcronymBonusWordStarts() throws {
|
||||||
|
XCTAssertGreaterThan(SearchTestable.acronymBonus(query: "cd", text: "Chrome DevTools"), 0)
|
||||||
|
XCTAssertEqual(SearchTestable.acronymBonus(query: "cd", text: "Chrome"), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAcronymBonusPrefersEarlierMatch() throws {
|
||||||
|
let early = SearchTestable.acronymBonus(query: "cd", text: "Chrome DevTools")
|
||||||
|
let later = SearchTestable.acronymBonus(query: "cd", text: "Google Chrome DevTools")
|
||||||
|
XCTAssertGreaterThan(early, later)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAcronymBonusIgnoresSpacesInQuery() throws {
|
||||||
|
let compact = SearchTestable.acronymBonus(query: "cd", text: "Chrome DevTools")
|
||||||
|
let spaced = SearchTestable.acronymBonus(query: " c d ", text: "Chrome DevTools")
|
||||||
|
XCTAssertEqual(compact, spaced)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user