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 */; };
|
||||
BF0C8F9B8014AD21E68B1349 /* HelperExtensionsTestable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C810FA0F6BC70E5886E11 /* HelperExtensionsTestable.swift */; };
|
||||
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 */; };
|
||||
D04BA0182F4734A5C8BB63DC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA5461DAEE9B5350C3020 /* Localizable.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 */; };
|
||||
D04BAFC3A1B2C3D4E5F60004 /* TileUnderLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAFC2A1B2C3D4E5F60003 /* TileUnderLayer.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 */
|
||||
|
||||
/* 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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
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>"; };
|
||||
@@ -736,6 +741,8 @@
|
||||
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>"; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -1102,6 +1109,7 @@
|
||||
children = (
|
||||
BF0C84933936C6FE676CD33C /* AppearanceTests.swift */,
|
||||
BF0C899905524253DC170B56 /* KeyboardEventsTests.swift */,
|
||||
BF0CAAAA0000000000000001 /* SearchAcronymTests.swift */,
|
||||
BF0C829EAA93A4C4ADB42CF8 /* Mocks.swift */,
|
||||
BF0C8C6810FF50908E637A48 /* ConcurrentScreenshots.swift */,
|
||||
BF0C855CD9DBCAEFE48F9EA5 /* AXUIElementTests.swift */,
|
||||
@@ -1358,6 +1366,7 @@
|
||||
D04BAB74451B79FE18B8BEDF /* BackgroundWork.swift */,
|
||||
D04BACABD048E62EBE4576CC /* DebugProfile.swift */,
|
||||
D04BAC8857A527C2E15D6598 /* events */,
|
||||
F00DCAFE0000000000002000 /* search */,
|
||||
D04BA7E3EA104941FE76DA70 /* AppCenterCrashes.swift */,
|
||||
BF0C8BA452332236D972C60E /* ATShortcut.swift */,
|
||||
1C961AC3633CD0C62CCE050E /* Appearance.swift */,
|
||||
@@ -2007,6 +2016,15 @@
|
||||
path = docs;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F00DCAFE0000000000002000 /* search */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F00DCAFE0000000000000002 /* Search.swift */,
|
||||
F00DCAFE0000000000000003 /* SearchTestable.swift */,
|
||||
);
|
||||
path = search;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -2439,7 +2457,9 @@
|
||||
BF0C81FA62D810342F59291A /* AppearanceTests.swift in Sources */,
|
||||
5F4753072CEE5A2E002C6C5E /* AppearanceTestable.swift in Sources */,
|
||||
BF0C8B787C76D9B961CC2F45 /* KeyboardEventsTests.swift in Sources */,
|
||||
BF0CAAAA0000000000001001 /* SearchAcronymTests.swift in Sources */,
|
||||
BF0C8C5FA4B401A8C0F75B7A /* KeyboardEventsTestable.swift in Sources */,
|
||||
F00DCAFE0000000000001103 /* SearchTestable.swift in Sources */,
|
||||
BF0C875A28B98904F8F4A66A /* ATShortcut.swift in Sources */,
|
||||
BF0C84E5AC1D423625ABF3E6 /* Mocks.swift in Sources */,
|
||||
BF0C8CBED8D88DFBADE7EC03 /* ConcurrentScreenshots.swift in Sources */,
|
||||
@@ -2463,6 +2483,8 @@
|
||||
5F21C9E22C6E94410091F72F /* AdditionalControlsSheet.swift in Sources */,
|
||||
D04BA8480A8FF466CA89DA5B /* main.swift in Sources */,
|
||||
D04BAFB973C3D28718FAEB87 /* Windows.swift in Sources */,
|
||||
F00DCAFE0000000000001002 /* Search.swift in Sources */,
|
||||
F00DCAFE0000000000001003 /* SearchTestable.swift in Sources */,
|
||||
D04BAC011A71E0418154F8CD /* Preferences.swift in Sources */,
|
||||
D04BADCDA9F9A6C3D6499877 /* SystemPermissions.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 highlightFocusedBorderColor: NSColor { get { NSColor.systemAccentColor } }
|
||||
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 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) {
|
||||
guard timerIsSuspended && atShortcut.state != .up else { return }
|
||||
guard timerIsSuspended && atShortcut.state != .up && !isSearchEditing() else { return }
|
||||
currentTimerShortcutName = atShortcut.id
|
||||
// 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")
|
||||
@@ -48,7 +52,7 @@ class KeyRepeatTimer {
|
||||
|
||||
private static func handleEvent(_ atShortcut: ATShortcut, _ block: @escaping () -> Void) {
|
||||
DispatchQueue.main.async {
|
||||
if atShortcut.state == .up {
|
||||
if atShortcut.state == .up || isSearchEditing() {
|
||||
stopTimerForRepeatingKey(atShortcut.id)
|
||||
} else {
|
||||
block()
|
||||
|
||||
@@ -220,11 +220,13 @@ enum LanguagePreference: CaseIterable, MacroPreference {
|
||||
enum ShortcutStylePreference: CaseIterable, MacroPreference {
|
||||
case focusOnRelease
|
||||
case doNothingOnRelease
|
||||
case searchOnRelease
|
||||
|
||||
var localizedString: LocalizedString {
|
||||
switch self {
|
||||
case .focusOnRelease: return NSLocalizedString("Focus selected window", comment: "")
|
||||
case .doNothingOnRelease: return NSLocalizedString("Do nothing", comment: "")
|
||||
case .searchOnRelease: return NSLocalizedString("Search", comment: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,14 +12,16 @@ class Preferences {
|
||||
"nextWindowShortcut2": keyAboveTabDependingOnInputSource(),
|
||||
"nextWindowShortcut3": "",
|
||||
"nextWindowGesture": GesturePreference.disabled.indexAsString,
|
||||
"focusWindowShortcut": "Space",
|
||||
"focusWindowShortcut": returnKeyEquivalent(),
|
||||
"previousWindowShortcut": "⇧",
|
||||
"cancelShortcut": "⎋",
|
||||
"lockSearchShortcut": "Space",
|
||||
"closeWindowShortcut": "W",
|
||||
"minDeminWindowShortcut": "M",
|
||||
"toggleFullscreenWindowShortcut": "F",
|
||||
"quitAppShortcut": "Q",
|
||||
"hideShowAppShortcut": "H",
|
||||
"searchShortcut": "S",
|
||||
"arrowKeysEnabled": "true",
|
||||
"vimKeysEnabled": "false",
|
||||
"mouseHoverEnabled": "false",
|
||||
@@ -100,11 +102,13 @@ class Preferences {
|
||||
static var focusWindowShortcut: String { CachedUserDefaults.string("focusWindowShortcut") }
|
||||
static var previousWindowShortcut: String { CachedUserDefaults.string("previousWindowShortcut") }
|
||||
static var cancelShortcut: String { CachedUserDefaults.string("cancelShortcut") }
|
||||
static var lockSearchShortcut: String { CachedUserDefaults.string("lockSearchShortcut") }
|
||||
static var closeWindowShortcut: String { CachedUserDefaults.string("closeWindowShortcut") }
|
||||
static var minDeminWindowShortcut: String { CachedUserDefaults.string("minDeminWindowShortcut") }
|
||||
static var toggleFullscreenWindowShortcut: String { CachedUserDefaults.string("toggleFullscreenWindowShortcut") }
|
||||
static var quitAppShortcut: String { CachedUserDefaults.string("quitAppShortcut") }
|
||||
static var hideShowAppShortcut: String { CachedUserDefaults.string("hideShowAppShortcut") }
|
||||
static var searchShortcut: String { CachedUserDefaults.string("searchShortcut") }
|
||||
// periphery:ignore
|
||||
static var arrowKeysEnabled: Bool { CachedUserDefaults.bool("arrowKeysEnabled") }
|
||||
// periphery:ignore
|
||||
@@ -189,6 +193,10 @@ class Preferences {
|
||||
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 {
|
||||
return jsonEncode([
|
||||
BlacklistEntry(bundleIdentifier: "com.McAfee.McAfeeSafariHost", hide: .always, ignore: .none),
|
||||
|
||||
@@ -36,6 +36,10 @@ class Window {
|
||||
var axObserver: AXObserver?
|
||||
var rowIndex: Int?
|
||||
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?) {
|
||||
id = "\(wid)"
|
||||
@@ -76,6 +80,7 @@ class Window {
|
||||
self.position = position
|
||||
self.isFullscreen = isFullscreen ?? false
|
||||
self.isMinimized = isMinimized ?? false
|
||||
lastSearchQuery = nil
|
||||
}
|
||||
|
||||
func isEqualRobust(_ otherWindowAxUiElement: AXUIElement, _ otherWindowWid: CGWindowID?) -> Bool {
|
||||
|
||||
+83
-11
@@ -6,6 +6,37 @@ class Windows {
|
||||
static var selectedWindowTarget: String?
|
||||
static var hoveredWindowIndex: Int?
|
||||
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() {
|
||||
let windowsOnCurrentSpace = list.filter { !$0.isWindowlessApp }
|
||||
@@ -28,9 +59,11 @@ class Windows {
|
||||
|
||||
static func voiceOverWindow(_ windowIndex: Int = selectedWindowIndex) {
|
||||
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
|
||||
// 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)) {
|
||||
if App.app.thumbnailsPanel.tilesView.isSearchEditing { return }
|
||||
let window = TilesView.recycledViews[windowIndex]
|
||||
if window.window_ != nil && window.window != nil {
|
||||
App.app.thumbnailsPanel.makeFirstResponder(window)
|
||||
@@ -180,7 +213,9 @@ class Windows {
|
||||
//////////////////////////////
|
||||
|
||||
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() {
|
||||
@@ -206,20 +241,46 @@ class Windows {
|
||||
}
|
||||
|
||||
static func updateSelectedWindow() {
|
||||
// selectedWindowTarget still exists: we select its new index
|
||||
if let index = (list.firstIndex { $0.id == selectedWindowTarget }) {
|
||||
if shouldRestoreDefaultSelectionOnSearchClear {
|
||||
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)
|
||||
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 lastIndex = list.filter { $0.shouldShowTheUser }.count - 1
|
||||
if selectedWindowIndex > lastIndex {
|
||||
updateSelectedAndHoveredWindowIndex(lastIndex)
|
||||
let lastVisibleIndex = visibleIndexes.last!
|
||||
if !visibleIndexes.contains(selectedWindowIndex) {
|
||||
updateSelectedAndHoveredWindowIndex(firstVisibleIndex)
|
||||
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) {
|
||||
guard newIndex >= 0 && newIndex < list.count else { return }
|
||||
guard shouldDisplay(list[newIndex]) else { return }
|
||||
var index: Int?
|
||||
if fromMouse && (newIndex != hoveredWindowIndex || lastWindowActivityType == .focus) {
|
||||
let oldIndex = hoveredWindowIndex
|
||||
@@ -249,6 +310,7 @@ class Windows {
|
||||
|
||||
static func cycleSelectedWindowIndex(_ step: Int, allowWrap: Bool = true) {
|
||||
guard App.app.appIsBeingUsed else { return }
|
||||
guard list.contains(where: { shouldDisplay($0) }) else { return }
|
||||
let nextIndex = selectedWindowIndexAfterCycling(step)
|
||||
// don't wrap-around at the end, if key-repeat
|
||||
if (((step > 0 && nextIndex < selectedWindowIndex) || (step < 0 && nextIndex > selectedWindowIndex)) &&
|
||||
@@ -261,14 +323,14 @@ class Windows {
|
||||
}
|
||||
|
||||
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 targetIndex = selectedWindowIndex
|
||||
repeat {
|
||||
let next = (targetIndex + step) % list.count
|
||||
targetIndex = next < 0 ? list.count + next : next
|
||||
iterations += 1
|
||||
} while !list[targetIndex].shouldShowTheUser && iterations <= list.count
|
||||
} while !shouldDisplay(list[targetIndex]) && iterations <= list.count
|
||||
return targetIndex
|
||||
}
|
||||
|
||||
@@ -296,7 +358,17 @@ class Windows {
|
||||
|
||||
/// reordered list based on preferences, keeping the original index
|
||||
private static func sort() {
|
||||
let trimmedQuery = Search.normalizedQuery(searchQuery)
|
||||
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
|
||||
if Preferences.showWindowlessApps[App.app.shortcutIndex] == .showAtTheEnd && $0.isWindowlessApp != $1.isWindowlessApp {
|
||||
return $1.isWindowlessApp
|
||||
@@ -344,7 +416,7 @@ class Windows {
|
||||
var index: Int? = nil
|
||||
var lastFocusOrderMin = Int.max
|
||||
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
|
||||
index = offset
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ class CursorEvents {
|
||||
private static var shouldBeEnabled: Bool!
|
||||
private static var mouseDownTileView: TileView?
|
||||
private static var mouseDownButton: TrafficLightButton?
|
||||
private static var mouseDownInsideSearchField = false
|
||||
static var deadZoneInitialPosition: CGPoint?
|
||||
static var isAllowedToMouseHover = true
|
||||
|
||||
@@ -46,8 +47,8 @@ class CursorEvents {
|
||||
|
||||
private static let handleEvent: CGEventTapCallBack = { _, type, cgEvent, _ in
|
||||
switch type {
|
||||
case .leftMouseDown: return handleLeftMouseDown()
|
||||
case .leftMouseUp where cgEvent.getIntegerValueField(.mouseEventClickState) >= 1: return handleLeftMouseUp()
|
||||
case .leftMouseDown: return handleLeftMouseDown(cgEvent)
|
||||
case .leftMouseUp where cgEvent.getIntegerValueField(.mouseEventClickState) >= 1: return handleLeftMouseUp(cgEvent)
|
||||
case .otherMouseUp: return handleOtherMouseUp(cgEvent)
|
||||
case .mouseMoved: return handleMouseMoved(cgEvent)
|
||||
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 }
|
||||
if let button = findButtonUnderPointer() {
|
||||
mouseDownButton = button
|
||||
@@ -69,7 +75,11 @@ class CursorEvents {
|
||||
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 {
|
||||
App.app.hideUi()
|
||||
return nil
|
||||
@@ -120,6 +130,13 @@ class CursorEvents {
|
||||
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 {
|
||||
view.bounds.contains(view.convert(pointerLocationInWindow(), from: nil))
|
||||
}
|
||||
|
||||
@@ -79,8 +79,10 @@ class KeyboardEvents {
|
||||
// TODO: handle this on a background thread?
|
||||
private static func addLocalMonitorForKeyDownAndKeyUp() {
|
||||
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)
|
||||
return someShortcutTriggered ? nil : event
|
||||
let keyCode = event.type == .keyDown ? UInt32(event.keyCode) : nil
|
||||
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
|
||||
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 {
|
||||
Logger.debug {
|
||||
let shortcut = KeyboardEventsTestable.globalShortcutsIds.first { $0.value == globalId }
|
||||
return "globalShortcut:\(shortcut?.key ?? "") state:\(shortcutState)"
|
||||
}
|
||||
} else {
|
||||
// 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)"
|
||||
}
|
||||
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)"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
for shortcut in ControlsTab.shortcuts.values {
|
||||
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
|
||||
isFirstSummon = true
|
||||
forceDoNothingOnRelease = false
|
||||
thumbnailsPanel.tilesView.endSearchSession()
|
||||
CursorEvents.toggle(false)
|
||||
TrackpadEvents.reset()
|
||||
hideTilesPanelWithoutChangingKeyWindow()
|
||||
@@ -115,6 +116,25 @@ class App: AppCenterApplication {
|
||||
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() {
|
||||
guard appIsBeingUsed else { return } // already hidden
|
||||
let selectedWindow = Windows.selectedWindow()
|
||||
@@ -251,6 +271,11 @@ class App: AppCenterApplication {
|
||||
}
|
||||
isFirstSummon = false
|
||||
self.shortcutIndex = shortcutIndex
|
||||
let shouldStartInSearchMode = Preferences.shortcutStyle[self.shortcutIndex] == .searchOnRelease
|
||||
thumbnailsPanel.tilesView.startSearchSession(shouldStartInSearchMode)
|
||||
if shouldStartInSearchMode {
|
||||
forceDoNothingOnRelease = true
|
||||
}
|
||||
if !Windows.updatesBeforeShowing() { hideUi(); return }
|
||||
Windows.setInitialSelectedAndHoveredWindowIndex()
|
||||
if Preferences.windowDisplayDelay == DispatchTimeInterval.milliseconds(0) {
|
||||
@@ -277,6 +302,9 @@ class App: AppCenterApplication {
|
||||
refreshUi()
|
||||
guard appIsBeingUsed else { return }
|
||||
thumbnailsPanel.show()
|
||||
if thumbnailsPanel.tilesView.isSearchEditing {
|
||||
thumbnailsPanel.tilesView.enableSearchEditing()
|
||||
}
|
||||
KeyRepeatTimer.startRepeatingKeyNextWindow()
|
||||
Windows.refreshThumbnailsAsync(Windows.list, .refreshOnlyThumbnailsAfterShowUi)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Cocoa
|
||||
|
||||
class TileTitleView: NSTextField {
|
||||
static let searchHighlightBackgroundKey = NSAttributedString.Key("tileSearchHighlightBackground")
|
||||
private var currentWidth: CGFloat = -1
|
||||
|
||||
// we set their size manually; override this to remove wasteful appkit-side work
|
||||
@@ -21,6 +22,11 @@ class TileTitleView: NSTextField {
|
||||
allowsDefaultTighteningForTruncation = false
|
||||
}
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
drawRoundedSearchHighlights()
|
||||
super.draw(dirtyRect)
|
||||
}
|
||||
|
||||
func fixHeight() {
|
||||
frame.size.height = cell!.cellSize.height
|
||||
}
|
||||
@@ -47,4 +53,54 @@ class TileTitleView: NSTextField {
|
||||
}
|
||||
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 dockLabelIcon = TileFontIconView(badgeSize: TileFontIconView.badgeBaseSize(forIconSize: TileView.iconSize().width))
|
||||
var windowlessAppIndicator = WindowlessAppIndicator(tooltip: TileView.noOpenWindowToolTip)
|
||||
private var fullTitle = ""
|
||||
private var fullTitleWidth = CGFloat(0)
|
||||
|
||||
var mouseUpCallback: (() -> Void)!
|
||||
var mouseMovedCallback: (() -> Void)!
|
||||
@@ -75,7 +77,7 @@ class TileView: FlippedView {
|
||||
|
||||
private func updateLabelTooltipIfNeeded() {
|
||||
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() {
|
||||
@@ -100,6 +102,7 @@ class TileView: FlippedView {
|
||||
updateValues(element, index, newHeight)
|
||||
updateSizes(newHeight)
|
||||
updatePositions(newHeight)
|
||||
applySearchHighlight()
|
||||
}
|
||||
|
||||
func drawHighlight() {
|
||||
@@ -187,7 +190,7 @@ class TileView: FlippedView {
|
||||
|
||||
private func updateAppIconsLabelFrame() {
|
||||
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 maxAllowedLabelWidth = getMaxAllowedLabelWidth()
|
||||
let sidesToOffset: CGFloat = (isFirstInRow ? 1 : 0) + (isLastInRow ? 1 : 0)
|
||||
@@ -221,7 +224,8 @@ class TileView: FlippedView {
|
||||
let yPosition = appIcon.frame.maxY + Appearance.intraCellPadding * 2
|
||||
label.frame = NSRect(x: xPosition, y: yPosition, width: effectiveLabelWidth, height: height)
|
||||
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) {
|
||||
@@ -257,6 +261,8 @@ class TileView: FlippedView {
|
||||
label.stringValue = title
|
||||
setAccessibilityLabel(title)
|
||||
}
|
||||
fullTitle = title
|
||||
fullTitleWidth = label.cell!.cellSize.width
|
||||
label.updateTruncationModeIfNeeded()
|
||||
if statusIcons.spaceVisible {
|
||||
let spaceIndex = element.spaceIndexes.first
|
||||
@@ -273,6 +279,174 @@ class TileView: FlippedView {
|
||||
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) {
|
||||
setFrameWidthHeight(newHeight)
|
||||
if Preferences.appearanceStyle != .appIcons {
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
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 contentView: EffectView!
|
||||
var searchField = NSSearchField(frame: .zero)
|
||||
private(set) var searchMode: SearchMode = .off
|
||||
var rows = [[TileView]]()
|
||||
private var lastRowSignature = [Int]()
|
||||
static var recycledViews = [TileView]()
|
||||
@@ -12,13 +22,242 @@ class TilesView {
|
||||
var thumbnailUnderLayer = TileUnderLayer()
|
||||
var thumbnailOverView = TileOverView()
|
||||
|
||||
init() {
|
||||
override init() {
|
||||
super.init()
|
||||
configureSearchField()
|
||||
updateBackgroundView()
|
||||
// TODO: think about this optimization more
|
||||
(1...20).forEach { _ in TilesView.recycledViews.append(TileView()) }
|
||||
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() {
|
||||
guard let firstView = TilesView.recycledViews.first else { return }
|
||||
layoutCache.labelHeight = firstView.label.cell!.cellSize.height
|
||||
@@ -32,6 +271,8 @@ class TilesView {
|
||||
func updateBackgroundView() {
|
||||
contentView = makeAppropriateEffectView()
|
||||
scrollView = ScrollView()
|
||||
searchField.isHidden = searchMode == .off
|
||||
contentView.addSubview(searchField)
|
||||
contentView.addSubview(scrollView)
|
||||
}
|
||||
|
||||
@@ -61,13 +302,15 @@ class TilesView {
|
||||
}
|
||||
|
||||
static func highlight(_ indexInRecycledViews: Int) {
|
||||
guard indexInRecycledViews >= 0, indexInRecycledViews < recycledViews.count else { return }
|
||||
let view = recycledViews[indexInRecycledViews]
|
||||
view.indexInRecycledViews = indexInRecycledViews
|
||||
guard view.frame != .zero else { return }
|
||||
view.drawHighlight()
|
||||
let underLayer = App.app.thumbnailsPanel.tilesView.thumbnailUnderLayer
|
||||
guard Windows.selectedWindowIndex >= 0, Windows.selectedWindowIndex < recycledViews.count else { return }
|
||||
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(
|
||||
focusedView: focusedView.frame != .zero ? focusedView : nil,
|
||||
hoveredView: hoveredView != focusedView && hoveredView?.frame != .zero ? hoveredView : nil
|
||||
@@ -123,7 +366,7 @@ class TilesView {
|
||||
if let (maxX, maxY, labelHeight, rowSignature) = layoutTileViews(widthMax) {
|
||||
layoutParentViews(maxX, widthMax, maxY, labelHeight)
|
||||
if Preferences.alignThumbnails == .center {
|
||||
centerRows(maxX)
|
||||
centerRows(TilesView.thumbnailsWidth)
|
||||
}
|
||||
if rowSignature != lastRowSignature {
|
||||
for row in rows {
|
||||
@@ -178,7 +421,10 @@ class TilesView {
|
||||
let view = TilesView.recycledViews[index]
|
||||
if index < Windows.list.count {
|
||||
let window = Windows.list[index]
|
||||
guard window.shouldShowTheUser else { continue }
|
||||
guard Windows.shouldDisplay(window) else {
|
||||
view.frame = .zero
|
||||
continue
|
||||
}
|
||||
view.updateRecycledCellWithNewContent(window, index, height)
|
||||
let width = view.frame.size.width
|
||||
let projectedX = projectedWidth(currentX, width).rounded(.down)
|
||||
@@ -233,12 +479,16 @@ class TilesView {
|
||||
}
|
||||
|
||||
private func layoutParentViews(_ maxX: CGFloat, _ widthMax: CGFloat, _ maxY: CGFloat, _ labelHeight: CGFloat) {
|
||||
let heightMax = TilesPanel.maxThumbnailsHeight()
|
||||
TilesView.thumbnailsWidth = min(maxX, widthMax)
|
||||
let searchBarHeight = searchBarHeight()
|
||||
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)
|
||||
let appIconsBottomViewportPadding = appIconsBottomViewportPadding(maxY, heightMax, labelHeight)
|
||||
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
|
||||
var originY = Appearance.windowPadding
|
||||
if Preferences.appearanceStyle == .appIcons {
|
||||
@@ -248,11 +498,16 @@ class TilesView {
|
||||
}
|
||||
contentView.frame.size = NSSize(width: frameWidth, height: frameHeight)
|
||||
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.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 {
|
||||
let croppedWidth = widthMax - maxX
|
||||
let croppedWidth = max(0, TilesView.thumbnailsWidth - maxX)
|
||||
scrollView.documentView!.subviews.forEach { $0.frame.origin.x -= croppedWidth }
|
||||
}
|
||||
scrollView.documentView!.frame.size = NSSize(width: maxX, height: maxY)
|
||||
@@ -261,52 +516,61 @@ class TilesView {
|
||||
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 {
|
||||
guard Preferences.appearanceStyle == .appIcons, maxY > heightMax else { return 0 }
|
||||
return max(0, Appearance.windowPadding - labelHeight)
|
||||
}
|
||||
|
||||
func centerRows(_ maxX: CGFloat) {
|
||||
var rowStartIndex = 0
|
||||
var rowWidth = Appearance.interCellPadding
|
||||
var rowY = Appearance.interCellPadding
|
||||
for (index, window) in Windows.list.enumerated() {
|
||||
for row in rows where !row.isEmpty {
|
||||
guard App.app.appIsBeingUsed else { return }
|
||||
guard window.shouldShowTheUser else { continue }
|
||||
let view = TilesView.recycledViews[index]
|
||||
if view.frame.origin.y == rowY {
|
||||
rowWidth += view.frame.size.width + Appearance.interCellPadding
|
||||
} else {
|
||||
shiftRow(maxX, rowWidth, rowStartIndex, index)
|
||||
rowStartIndex = index
|
||||
rowWidth = Appearance.interCellPadding + view.frame.size.width + Appearance.interCellPadding
|
||||
rowY = view.frame.origin.y
|
||||
let rowWidth = Appearance.interCellPadding + row.reduce(CGFloat(0)) { $0 + $1.frame.size.width + Appearance.interCellPadding }
|
||||
let offset = ((maxX - rowWidth) / 2).rounded()
|
||||
if offset > 0 {
|
||||
for view in row {
|
||||
view.frame.origin.x += App.shared.userInterfaceLayoutDirection == .leftToRight ? offset : -offset
|
||||
}
|
||||
}
|
||||
}
|
||||
shiftRow(maxX, rowWidth, rowStartIndex, Windows.list.count)
|
||||
}
|
||||
|
||||
private func highlightStartView() {
|
||||
TilesView.highlight(Windows.selectedWindowIndex)
|
||||
if let hoveredWindowIndex = Windows.hoveredWindowIndex {
|
||||
if Windows.selectedWindow() != nil {
|
||||
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)
|
||||
if thumbnailOverView.isShowingWindowControls {
|
||||
thumbnailOverView.showWindowControls(for: TilesView.recycledViews[hoveredWindowIndex])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
} else {
|
||||
thumbnailOverView.hideWindowControls()
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
view.needsLayout = false
|
||||
view.needsDisplay = false
|
||||
@@ -320,6 +584,8 @@ class TilesView {
|
||||
var iconHeight = CGFloat(0)
|
||||
var comfortableReadabilityWidth: CGFloat?
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
class ScrollView: NSScrollView {
|
||||
@@ -372,6 +638,7 @@ class FlippedView: NSView {
|
||||
@objc func _windowChangedKeyState() {}
|
||||
}
|
||||
|
||||
|
||||
enum Direction {
|
||||
case right
|
||||
case left
|
||||
|
||||
@@ -22,12 +22,14 @@ class ControlsTab {
|
||||
"vimCycleLeft": { App.app.cycleSelection(.left) },
|
||||
"vimCycleUp": { App.app.cycleSelection(.up) },
|
||||
"vimCycleDown": { App.app.cycleSelection(.down) },
|
||||
"cancelShortcut": { App.app.hideUi() },
|
||||
"cancelShortcut": { App.app.cancelSearchModeOrHideUi() },
|
||||
"closeWindowShortcut": { App.app.closeSelectedWindow() },
|
||||
"minDeminWindowShortcut": { App.app.minDeminSelectedWindow() },
|
||||
"toggleFullscreenWindowShortcut": { App.app.toggleFullscreenSelectedWindow() },
|
||||
"quitAppShortcut": { App.app.quitSelectedApp() },
|
||||
"hideShowAppShortcut": { App.app.hideShowSelectedApp() },
|
||||
"searchShortcut": { App.app.toggleSearchMode() },
|
||||
"lockSearchShortcut": { App.app.lockSearchMode() },
|
||||
]
|
||||
static var arrowKeysCheckbox: 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]])
|
||||
let previousWindowShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Select previous window", comment: ""),
|
||||
rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Select previous window", comment: ""), "previousWindowShortcut", Preferences.previousWindowShortcut, labelPosition: .right)[0]])
|
||||
let cancelShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Cancel and hide", comment: ""),
|
||||
rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Cancel and hide", comment: ""), "cancelShortcut", Preferences.cancelShortcut, labelPosition: .right)[0]])
|
||||
let cancelShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Cancel", comment: ""),
|
||||
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: ""),
|
||||
rightViews: [LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Close window", comment: ""), "closeWindowShortcut", Preferences.closeWindowShortcut, labelPosition: .right)[0]])
|
||||
let minDeminWindowShortcut = TableGroupView.Row(leftTitle: NSLocalizedString("Minimize/Deminimize window", comment: ""),
|
||||
@@ -22,6 +26,8 @@ class ShortcutsWhenActiveSheet: SheetWindow {
|
||||
_ = table.addRow(focusWindowShortcut)
|
||||
_ = table.addRow(previousWindowShortcut)
|
||||
_ = table.addRow(cancelShortcut)
|
||||
_ = table.addRow(searchShortcut)
|
||||
_ = table.addRow(lockSearchShortcut)
|
||||
_ = table.addRow(closeWindowShortcut)
|
||||
_ = table.addRow(minDeminWindowShortcut)
|
||||
_ = table.addRow(toggleFullscreenWindowShortcut)
|
||||
|
||||
@@ -126,6 +126,21 @@ final class KeyboardEventsUtilsTests: XCTestCase {
|
||||
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
|
||||
func testTransitionFromOneShortcutToAnother() throws {
|
||||
resetState()
|
||||
|
||||
@@ -27,6 +27,7 @@ class ControlsTab {
|
||||
"focusWindowShortcut": ATShortcut(Shortcut(keyEquivalent: " ")!, "focusWindowShortcut", .local, .down),
|
||||
"previousWindowShortcut": ATShortcut(Shortcut(keyEquivalent: "⇧")!, "previousWindowShortcut", .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),
|
||||
"minDeminWindowShortcut": ATShortcut(Shortcut(keyEquivalent: "m")!, "minDeminWindowShortcut", .local, .down),
|
||||
"toggleFullscreenWindowShortcut": ATShortcut(Shortcut(keyEquivalent: "f")!, "toggleFullscreenWindowShortcut", .local, .down),
|
||||
@@ -78,6 +79,7 @@ class Preferences {
|
||||
enum ShortcutStylePreference: CaseIterable {
|
||||
case focusOnRelease
|
||||
case doNothingOnRelease
|
||||
case searchOnRelease
|
||||
}
|
||||
|
||||
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