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:
Jakku Sakura authored and lwouis committed 2026-02-16 22:52:56 +01:00
1 parent e3092b4299
commit 4e986f37a0
21 files changed
+1066 -70

No files matched your search

+22
View File
@@ -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 */,
+2
View File
@@ -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 }
+6 -2
View File
@@ -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()
+2
View File
@@ -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: "")
}
}
}
+9 -1
View File
@@ -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),
+5
View File
@@ -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
View File
@@ -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
}
+21 -4
View File
@@ -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))
}
+4 -2
View File
@@ -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
}
}
+28 -8
View File
@@ -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() {
+219
View File
@@ -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]
}
+48
View File
@@ -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) } }
}
+28
View File
@@ -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)
}
+56
View File
@@ -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
}
}
+177 -3
View File
@@ -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 {
+303 -36
View File
@@ -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)
+15
View File
@@ -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()
+2
View File
@@ -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 {
+25
View File
@@ -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)
}
}