diff --git a/alt-tab-macos.xcodeproj/project.pbxproj b/alt-tab-macos.xcodeproj/project.pbxproj index f6230bfb..bc9c6076 100644 --- a/alt-tab-macos.xcodeproj/project.pbxproj +++ b/alt-tab-macos.xcodeproj/project.pbxproj @@ -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 = ""; }; BF0C8FC083A2443F3EAF5DB7 /* preferences-blacklist.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "preferences-blacklist.jpg"; sourceTree = ""; }; BF0C8FEE46E9A73F617EF679 /* gu */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = gu; path = Localizable.strings; sourceTree = ""; }; + BF0CAAAA0000000000000001 /* SearchAcronymTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchAcronymTests.swift; sourceTree = ""; }; 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 = ""; }; @@ -736,6 +741,8 @@ D04BAFC2A1B2C3D4E5F60003 /* TileUnderLayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TileUnderLayer.swift; sourceTree = ""; }; D04BAFDA9E2623E21CBD6CEF /* zh-TW */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = "zh-TW"; path = InfoPlist.strings; sourceTree = ""; }; 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 = ""; }; + F00DCAFE0000000000000002 /* Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search.swift; sourceTree = ""; }; + F00DCAFE0000000000000003 /* SearchTestable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTestable.swift; sourceTree = ""; }; /* 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 = ""; }; + F00DCAFE0000000000002000 /* search */ = { + isa = PBXGroup; + children = ( + F00DCAFE0000000000000002 /* Search.swift */, + F00DCAFE0000000000000003 /* SearchTestable.swift */, + ); + path = search; + sourceTree = ""; + }; /* 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 */, diff --git a/src/logic/Appearance.swift b/src/logic/Appearance.swift index 8459801e..34a5d087 100644 --- a/src/logic/Appearance.swift +++ b/src/logic/Appearance.swift @@ -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 } diff --git a/src/logic/KeyRepeatTimer.swift b/src/logic/KeyRepeatTimer.swift index 1bdc0197..7cce79ea 100644 --- a/src/logic/KeyRepeatTimer.swift +++ b/src/logic/KeyRepeatTimer.swift @@ -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() diff --git a/src/logic/MacroPreferences.swift b/src/logic/MacroPreferences.swift index 901030e9..aafc28bc 100644 --- a/src/logic/MacroPreferences.swift +++ b/src/logic/MacroPreferences.swift @@ -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: "") } } } diff --git a/src/logic/Preferences.swift b/src/logic/Preferences.swift index cc2364ee..af7e147e 100644 --- a/src/logic/Preferences.swift +++ b/src/logic/Preferences.swift @@ -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), diff --git a/src/logic/Window.swift b/src/logic/Window.swift index 96587c23..1c667fbd 100644 --- a/src/logic/Window.swift +++ b/src/logic/Window.swift @@ -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 { diff --git a/src/logic/Windows.swift b/src/logic/Windows.swift index d7bf8309..7829b0c3 100644 --- a/src/logic/Windows.swift +++ b/src/logic/Windows.swift @@ -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 } diff --git a/src/logic/events/CursorEvents.swift b/src/logic/events/CursorEvents.swift index eed6a20b..30896284 100644 --- a/src/logic/events/CursorEvents.swift +++ b/src/logic/events/CursorEvents.swift @@ -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? { + private static func handleLeftMouseDown(_ cgEvent: CGEvent) -> Unmanaged? { + 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? { + private static func handleLeftMouseUp(_ cgEvent: CGEvent) -> Unmanaged? { + 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)) } diff --git a/src/logic/events/KeyboardEvents.swift b/src/logic/events/KeyboardEvents.swift index a7191927..8cb8e9d6 100644 --- a/src/logic/events/KeyboardEvents.swift +++ b/src/logic/events/KeyboardEvents.swift @@ -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 } } diff --git a/src/logic/events/KeyboardEventsTestable.swift b/src/logic/events/KeyboardEventsTestable.swift index 6c6bdc33..212532fc 100644 --- a/src/logic/events/KeyboardEventsTestable.swift +++ b/src/logic/events/KeyboardEventsTestable.swift @@ -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() { diff --git a/src/logic/search/Search.swift b/src/logic/search/Search.swift new file mode 100644 index 00000000..cffe3620 --- /dev/null +++ b/src/logic/search/Search.swift @@ -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, _ normalizedToOriginal: [Int]) -> Range? { + 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.. 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] = [] + func rangesOverlap(_ a: Range, _ b: Range) -> Bool { a.lowerBound < b.upperBound && b.lowerBound < a.upperBound } + func backtrack(_ iStart: Int, _ jStart: Int) -> (ops: [SWOp], span: Range, subspans: [Range], 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..] = [] + 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..= 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 + let subspans: [Range] + let ops: [SWOp] +} diff --git a/src/logic/search/SearchTestable.swift b/src/logic/search/SearchTestable.swift new file mode 100644 index 00000000..33c6ec96 --- /dev/null +++ b/src/logic/search/SearchTestable.swift @@ -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.. 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) } } +} diff --git a/src/ui/App.swift b/src/ui/App.swift index c41bf01c..0e050344 100644 --- a/src/ui/App.swift +++ b/src/ui/App.swift @@ -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) } diff --git a/src/ui/main-window/TileTitleView.swift b/src/ui/main-window/TileTitleView.swift index 94a908a2..0498f8cf 100644 --- a/src/ui/main-window/TileTitleView.swift +++ b/src/ui/main-window/TileTitleView.swift @@ -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 + } } diff --git a/src/ui/main-window/TileView.swift b/src/ui/main-window/TileView.swift index 9c46f32f..1a1057a9 100644 --- a/src/ui/main-window/TileView.swift +++ b/src/ui/main-window/TileView.swift @@ -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 { + var indexes = Set() + 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..) -> [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.. 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.. 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.. CGFloat { + (text as NSString).size(withAttributes: attributes).width + } + private func updateSizes(_ newHeight: CGFloat) { setFrameWidthHeight(newHeight) if Preferences.appearanceStyle != .appIcons { diff --git a/src/ui/main-window/TilesView.swift b/src/ui/main-window/TilesView.swift index 14d59776..ffa10b9f 100644 --- a/src/ui/main-window/TilesView.swift +++ b/src/ui/main-window/TilesView.swift @@ -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.. tab-down > tab-up > `-down > `-up func testTransitionFromOneShortcutToAnother() throws { resetState() diff --git a/unit-tests/Mocks.swift b/unit-tests/Mocks.swift index 3ef7faa3..245bedd2 100644 --- a/unit-tests/Mocks.swift +++ b/unit-tests/Mocks.swift @@ -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 { diff --git a/unit-tests/SearchAcronymTests.swift b/unit-tests/SearchAcronymTests.swift new file mode 100644 index 00000000..4b571aa9 --- /dev/null +++ b/unit-tests/SearchAcronymTests.swift @@ -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) + } +}