Compare commits

...

6 Commits

9 changed files with 597 additions and 62 deletions
+9 -4
View File
@@ -33,7 +33,9 @@ Open `Package.swift` in Xcode to edit the project. Use the bundled app above to
- Type to filter applications, calculate an expression, or convert units and currencies; the first result is selected automatically. - Type to filter applications, calculate an expression, or convert units and currencies; the first result is selected automatically.
- Up/Down or Control-K/Control-J changes the selection without leaving search or modifying its text. - Up/Down or Control-K/Control-J changes the selection without leaving search or modifying its text.
- Return, keypad Enter, the footer button, or a double-click opens the selected application or copies a calculator answer and dismisses the launcher. - Return, keypad Enter, the footer button, or a double-click opens the selected application or copies a calculator answer and dismisses the launcher.
- Hold Command alone for one second to show numbered badges at the right edge of the first nine application results. Command already held when opening the launcher also counts: press Command-Space, release Space, and keep Command held. Newly discovered results start the delay once eligible. While still holding Command, press 1–9 to open that result or bring its running instance forward through the normal launch/history path. Numbers refer to the current ranked search results, not the selection or a scroll-relative position. Badges overlay the existing Application text, fading its right edge with an alpha mask while sliding in 14 points from the right over 160 milliseconds; Reduce Motion switches immediately. Releasing Command, pressing another key/modifier, losing focus, dismissing, or opening Actions/uninstall review cancels the hints. A cancelled hold does not restart just because results refresh or focus returns; release Command or reopen the launcher to start a new hold. Calculator/currency results and modal sheets do not enable numbered launching; missing result numbers do nothing.
- Command-K or the bottom-right Actions button opens a searchable selector for the selected application. **Open** is first and selected by default. Up/Down or Control-K/Control-J selects an action; Return executes it. Escape closes the selector and returns focus to search. - Command-K or the bottom-right Actions button opens a searchable selector for the selected application. **Open** is first and selected by default. Up/Down or Control-K/Control-J selects an action; Return executes it. Escape closes the selector and returns focus to search.
- Closing Actions with Escape while Command is released leaves numbered hints ready for the next Command hold; cancellation waits for a release only when Command was actually held.
- Escape or Command-W dismisses the launcher and restores the previously active application. Switching focus away dismisses it without stealing focus back. - Escape or Command-W dismisses the launcher and restores the previously active application. Switching focus away dismisses it without stealing focus back.
- Reopen from Finder to show it again with an empty search. Dismissal leaves the process running; Command-Q quits it. - Reopen from Finder to show it again with an empty search. Dismissal leaves the process running; Command-Q quits it.
- Every activation brings the launcher to the currently active macOS Space, including full-screen Spaces, without switching desktops. - Every activation brings the launcher to the currently active macOS Space, including full-screen Spaces, without switching desktops.
@@ -65,11 +67,11 @@ Conjure recognizes the native [login-launch Apple event](https://developer.apple
Applications are discovered in `/Applications`, `/System/Applications`, `/System/Library/CoreServices/Applications`, and `~/Applications`, including nested folders. Discovery runs off the UI thread and refreshes whenever the launcher is presented. Hidden items, invalid bundles, and helpers inside application packages are excluded; symlinked applications are deduplicated by their canonical paths. Applications are discovered in `/Applications`, `/System/Applications`, `/System/Library/CoreServices/Applications`, and `~/Applications`, including nested folders. Discovery runs off the UI thread and refreshes whenever the launcher is presented. Hidden items, invalid bundles, and helpers inside application packages are excluded; symlinked applications are deduplicated by their canonical paths.
Search is case- and accent-insensitive. Ranking is exact name, full-name prefix, word prefix, ordered word initials, substring, then ordered-subsequence fuzzy matches. Fuzzy matches favor smaller gaps and earlier starts. Equally good text matches are ordered by successful launch count, then most recent launch, then alphabetically. Usage never promotes a weaker text match above a stronger one or changes which applications match. Hover over a result to see its path. Launch failures appear in a native alert without dismissing the launcher. Search is case- and accent-insensitive. Ranking is exact name, full-name prefix, word prefix, ordered word initials, substring, then ordered-subsequence fuzzy matches. Fuzzy matches favor smaller gaps and earlier starts. Equally good text matches put favorites first, then order by successful launch count, most recent launch, and alphabetically. Neither favorites nor usage promote a weaker text match above a stronger one or change which applications match. Hover over a result to see its path. Launch failures appear in a native alert without dismissing the launcher.
Words are runs of letters or numbers separated by spaces, punctuation, or symbols. For example, `settings` matches the second word of System Settings, `gc` matches Google Chrome, and `ss` matches System Settings. Initials must occur in order but can skip words: both `vsc` and `vc` match Visual Studio Code. Words are runs of letters or numbers separated by spaces, punctuation, or symbols. For example, `settings` matches the second word of System Settings, `gc` matches Google Chrome, and `ss` matches System Settings. Initials must occur in order but can skip words: both `vsc` and `vc` match Visual Studio Code.
With an empty search, applications are ordered by successful launch count, then most recent launch, then alphabetically. Usage is stored locally in the app's preferences and survives restarts. Only launches through Conjure count; failed launches do not. Applications without history remain alphabetical. With an empty search, favorites appear first. Within both favorite and non-favorite groups, applications are ordered by successful launch count, then most recent launch, then alphabetically. Favorites and usage are stored locally by application bundle identity and survive restarts; copies with the same name remain independent. Only launches through Conjure count; failed launches do not. Applications without history remain alphabetical within their group.
Background discovery preserves the selected application even if its row changes. If that application disappears, the first remaining result is selected. Changing the query or reopening the launcher selects the first result. Background discovery preserves the selected application even if its row changes. If that application disappears, the first remaining result is selected. Changing the query or reopening the launcher selects the first result.
@@ -114,13 +116,16 @@ This is not full Raycast parity: calendar/date/time-zone calculations, design un
Select an application and open **Actions ⌘K**. **Open** is the first, default-selected action and uses the same launch path, successful-launch history tracking, dismissal, and error reporting as Return in the application list. Filtering resets selection to the first matching action; an empty result does nothing on Return. Up/Down or Control-K/Control-J navigates actions without changing the selected application. Select an application and open **Actions ⌘K**. **Open** is the first, default-selected action and uses the same launch path, successful-launch history tracking, dismissal, and error reporting as Return in the application list. Filtering resets selection to the first matching action; an empty result does nothing on Return. Up/Down or Control-K/Control-J navigates actions without changing the selected application.
- **Show in Finder** reveals the selected application bundle and dismisses the launcher without restoring the previous application's focus. - **Favorite / Remove Favorite** toggles the selected application's persisted favorite status, updates ranking immediately, and preserves the selection without recording a launch. Open remains the default action. Reset ranking clears usage but does not remove a favorite.
- **Show in Finder** reveals the selected application bundle and dismisses the launcher without restoring the previous application's focus. Its icon is the native monochrome Finder glyph; macOS versions without that symbol use Finder's application icon.
- **Reset ranking** clears only the selected bundle's launch count and last-launch timestamp, including persisted history. Results reorder immediately while retaining the selected application; other copies and applications keep their history. Future launches start counting again. - **Reset ranking** clears only the selected bundle's launch count and last-launch timestamp, including persisted history. Results reorder immediately while retaining the selected application; other copies and applications keep their history. Future launches start counting again.
- **Quit application** sends a normal macOS quit request to running instances of that exact bundle, not another copy with the same name or bundle identifier. A non-running application is a no-op. The launcher dismisses after an accepted request; the application may still present save prompts or cancel quitting. Rejected requests show a native alert. Conjure never force-quits the application. - **Quit application** sends a normal macOS quit request to running instances of that exact bundle, not another copy with the same name or bundle identifier. A non-running application is a no-op. The launcher dismisses after an accepted request; the application may still present save prompts or cancel quitting. Rejected requests show a native alert. Conjure never force-quits the application.
The red **Uninstall application** action morphs the existing launcher into a file-review screen with checkboxes, paths, sizes, and a selected-item count; it does not create another window or review sheet. A native 180-millisecond resize and content crossfade runs in both directions, or switches immediately with Reduce Motion. The filter uses a centered, intrinsic-height text field without built-in search/cancel icons, beside an explicit back chevron. Filtering files by name or path does not change file selections. Up/Down or Control-K/Control-J highlights files; Return toggles the highlighted eligible file, including from the filter input, and Space also toggles when the list has focus. Back or Escape restores the original launcher frame, query, and selection. Temporary review geometry is not saved as the launcher position. Leaving is blocked during confirmation or an uninstall operation. Command-Return or the uninstall button opens a native final confirmation dialog; nothing is moved merely by entering review. The confirmation control uses the launcher's frosted rounded pill with separate Command and Return key badges. The launcher Actions control likewise shows separate Command and K badges matching its Open Return badge. The red **Uninstall application** action morphs the existing launcher into a file-review screen with checkboxes, paths, sizes, and a selected-item count; it does not create another window or review sheet. A native 180-millisecond resize and content crossfade runs in both directions, or switches immediately with Reduce Motion. The filter uses a centered, intrinsic-height text field without built-in search/cancel icons, beside an explicit back chevron. Filtering files by name or path does not change file selections. Up/Down or Control-K/Control-J highlights files; Return toggles the highlighted eligible file, including from the filter input, and Space also toggles when the list has focus. Back or Escape restores the original launcher frame, query, and selection. Temporary review geometry is not saved as the launcher position. Leaving is blocked during confirmation or an uninstall operation. Command-Return or the uninstall button opens a native final confirmation dialog; nothing is moved merely by entering review. The confirmation control uses the launcher's frosted rounded pill with separate Command and Return key badges. The launcher Actions control likewise shows separate Command and K badges matching its Open Return badge.
Keyboard file toggles update the existing checkbox without reloading or fading the list, preserving both the highlighted row and scroll position. The launcher's footer Open and Actions labels use 12-point medium text. All custom shortcut hints—in the launcher footer, uninstall footer, and actions selector—use `ShortcutIndicator` from `Sources/Conjure/ShortcutIndicator.swift` (for example, `ShortcutIndicator("⌘", "K")`). It owns 12-point medium key text, square 18×18-point containers, visible-glyph centering, border styling, and 2-point gaps between adjacent keys; callers only position or hide the whole indicator. Native menus and shortcut-recording controls retain their platform styling. Keyboard file toggles update the existing checkbox without reloading or fading the list, preserving both the highlighted row and scroll position. The launcher's footer Open and Actions labels use 12-point medium text. All custom shortcut hints—in the launcher footer, numbered application rows, uninstall footer, and actions selector—use `ShortcutIndicator` from `Sources/Conjure/ShortcutIndicator.swift` (for example, `ShortcutIndicator("⌘", "K")`). It owns 12-point medium key text, square 18×18-point containers, visible-glyph centering, border styling, and 2-point gaps between adjacent keys; callers position or hide the whole indicator, and numbered rows update their single key without rebuilding the cell. Native menus and shortcut-recording controls retain their platform styling.
Numbered row overlays use 13-point numbers in 22×22-point boxes with a lighter opaque warm-brown backing. Their label fade begins 36 points before the badge, twice the original lead-in; other shortcut hints retain their 12-point text and 18×18-point boxes.
The actions selector is an arrowless panel inside the launcher, aligned to the bottom and right edges of the footer action pill, replacing it visually while open rather than floating above it. It shares the launcher's frosted Sunset background, 38-point selection row height, 22×22-point icon boxes with 12-point icon-to-text spacing, 14-point action and search text, rounded selection styling, and Return key badge; selection width follows the panel. Application and command rows use the same sizing constants. SF Symbol optical alignment insets are removed so command icons render inside the same bounds as application icons. The application name appears at the top and borderless action search sits eight points from the bottom, without a divider. Native animations crossfade the panel and footer pill: 160 milliseconds to open and 120 milliseconds to close, with unchanged geometry. Reduce Motion disables the animation. Rapid reopening invalidates stale dismissal completions, and closing panels immediately stop intercepting input. Escape, Command-K, an outside click, or switching away closes it and restores the pill. Closing while Conjure remains active restores launcher search focus; switching away does not steal focus back. Return in action search executes the highlighted action: Open launches the application, while Uninstall opens its review. The actions selector is an arrowless panel inside the launcher, aligned to the bottom and right edges of the footer action pill, replacing it visually while open rather than floating above it. It shares the launcher's frosted Sunset background, 38-point selection row height, 22×22-point icon boxes with 12-point icon-to-text spacing, 14-point action and search text, rounded selection styling, and Return key badge; selection width follows the panel. Application and command rows use the same sizing constants. SF Symbol optical alignment insets are removed so command icons render inside the same bounds as application icons. The application name appears at the top and borderless action search sits eight points from the bottom, without a divider. Native animations crossfade the panel and footer pill: 160 milliseconds to open and 120 milliseconds to close, with unchanged geometry. Reduce Motion disables the animation. Rapid reopening invalidates stale dismissal completions, and closing panels immediately stop intercepting input. Escape, Command-K, an outside click, or switching away closes it and restores the pill. Closing while Conjure remains active restores launcher search focus; switching away does not steal focus back. Return in action search executes the highlighted action: Open launches the application, while Uninstall opens its review.
@@ -6,7 +6,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
private let search = ActionsSearchField() private let search = ActionsSearchField()
private let entries = [ private let entries = [
ActionEntry(title: "Open", symbol: "arrow.up.forward.app", color: SunsetPalette.text), ActionEntry(title: "Open", symbol: "arrow.up.forward.app", color: SunsetPalette.text),
ActionEntry(title: "Show in Finder", symbol: "folder", color: SunsetPalette.text), ActionEntry(title: "Favorite", symbol: "star", color: SunsetPalette.text),
ActionEntry(title: "Show in Finder", symbol: "finder", color: SunsetPalette.text),
ActionEntry(title: "Reset ranking", symbol: "arrow.counterclockwise", color: SunsetPalette.text), ActionEntry(title: "Reset ranking", symbol: "arrow.counterclockwise", color: SunsetPalette.text),
ActionEntry(title: "Quit application", symbol: "stop.circle", color: SunsetPalette.text), ActionEntry(title: "Quit application", symbol: "stop.circle", color: SunsetPalette.text),
ActionEntry(title: "Uninstall application", symbol: "trash", color: .systemRed) ActionEntry(title: "Uninstall application", symbol: "trash", color: .systemRed)
@@ -24,6 +25,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
private var transitionGeneration = 0 private var transitionGeneration = 0
private var panel: ActionsPanelView { view as! ActionsPanelView } private var panel: ActionsPanelView { view as! ActionsPanelView }
var open: (() -> Void)? var open: (() -> Void)?
var toggleFavorite: (() -> Void)?
var isFavorite = false
var showInFinder: (() -> Void)? var showInFinder: (() -> Void)?
var resetRanking: (() -> Void)? var resetRanking: (() -> Void)?
var quit: (() -> Void)? var quit: (() -> Void)?
@@ -126,6 +129,13 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
let pill = anchor.superview ?? anchor let pill = anchor.superview ?? anchor
_ = view _ = view
applicationLabel.stringValue = application.name applicationLabel.stringValue = application.name
let favorite = entries[1]
let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite"
favorite.button.attributedTitle = NSAttributedString(string: favoriteTitle, attributes: [
.foregroundColor: SunsetPalette.text, .font: NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium)
])
favorite.button.setAccessibilityLabel(favoriteTitle)
favorite.icon.image = NSImage(systemSymbolName: isFavorite ? "star.fill" : "star", accessibilityDescription: nil)
search.stringValue = "" search.stringValue = ""
controlTextDidChange(Notification(name: NSControl.textDidChangeNotification)) controlTextDidChange(Notification(name: NSControl.textDidChangeNotification))
transitionGeneration += 1 transitionGeneration += 1
@@ -242,10 +252,11 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
close() close()
switch index { switch index {
case 0: open?() case 0: open?()
case 1: showInFinder?() case 1: toggleFavorite?()
case 2: resetRanking?() case 2: showInFinder?()
case 3: quit?() case 3: resetRanking?()
case 4: uninstall?() case 4: quit?()
case 5: uninstall?()
default: break default: break
} }
} }
@@ -255,6 +266,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
private struct ActionEntry { private struct ActionEntry {
let view = LauncherRowView() let view = LauncherRowView()
let button: NSButton let button: NSButton
let icon = ActionIconView()
let returnKey = ShortcutIndicator("↵") let returnKey = ShortcutIndicator("↵")
init(title: String, symbol: String, color: NSColor) { init(title: String, symbol: String, color: NSColor) {
@@ -267,8 +279,8 @@ private struct ActionEntry {
.foregroundColor: color, .font: NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium) .foregroundColor: color, .font: NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium)
]) ])
button.setAccessibilityLabel(title) button.setAccessibilityLabel(title)
let icon = ActionIconView()
icon.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) icon.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)
?? (symbol == "finder" ? NSWorkspace.shared.icon(forFile: "/System/Library/CoreServices/Finder.app") : nil)
icon.contentTintColor = color icon.contentTintColor = color
icon.imageScaling = .scaleProportionallyDown icon.imageScaling = .scaleProportionallyDown
icon.setAccessibilityElement(false) icon.setAccessibilityElement(false)
+291 -41
View File
@@ -61,6 +61,16 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
else { self.moveSelection(by: offset) } else { self.moveSelection(by: offset) }
} }
window.dismiss = { [weak self] in self?.dismiss() } window.dismiss = { [weak self] in self?.dismiss() }
window.canUseNumberShortcuts = { [weak self] in
guard let self else { return false }
return !self.isLaunching && self.calculation == nil && !self.results.isEmpty
&& self.launcherFrameBeforeReview == nil && !self.actions.isShown
}
window.numberHintsChanged = { [weak self] in self?.updateNumberHints() }
window.launchNumber = { [weak self] index in
guard let self, self.results.indices.contains(index) else { return }
self.openApplication(self.results[index])
}
window.dragEnded = { [weak self] in window.dragEnded = { [weak self] in
guard let self else { return } guard let self else { return }
self.alignmentGuides?.end(snapping: self.window) self.alignmentGuides?.end(snapping: self.window)
@@ -84,10 +94,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} }
func present() { func present() {
(window as? LauncherPanel)?.beginNumberHintSession()
if launcherFrameBeforeReview != nil { if launcherFrameBeforeReview != nil {
showWindow(nil) showWindow(nil)
NSApp.activate() NSApp.activate()
window?.makeKeyAndOrderFront(nil) window?.makeKeyAndOrderFront(nil)
(window as? LauncherPanel)?.sampleNumberHints()
return return
} }
let frontmost = NSWorkspace.shared.frontmostApplication let frontmost = NSWorkspace.shared.frontmostApplication
@@ -102,6 +114,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
NSApp.activate() NSApp.activate()
window?.makeKeyAndOrderFront(nil) window?.makeKeyAndOrderFront(nil)
window?.makeFirstResponder(searchField) window?.makeFirstResponder(searchField)
(window as? LauncherPanel)?.sampleNumberHints()
(searchField.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent (searchField.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
refreshApplications() refreshApplications()
} }
@@ -171,6 +184,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} }
func windowDidResignKey(_ notification: Notification) { func windowDidResignKey(_ notification: Notification) {
(window as? LauncherPanel)?.resetNumberHints()
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return } guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
dismiss(restoringFocus: false) dismiss(restoringFocus: false)
} }
@@ -433,6 +447,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
detail: "\(answer.detail) · \(cacheStatus)" detail: "\(answer.detail) · \(cacheStatus)"
) )
} }
if calculation != nil { (window as? LauncherPanel)?.resetNumberHints() }
if calculation != nil { if calculation != nil {
results = [] results = []
} else if searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { } else if searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
@@ -461,6 +476,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
statusLabel.stringValue = results.count == 1 ? "1 application" : "\(results.count) applications" statusLabel.stringValue = results.count == 1 ? "1 application" : "\(results.count) applications"
} }
updateOpenButton() updateOpenButton()
(window as? LauncherPanel)?.sampleNumberHints()
} }
func controlTextDidChange(_ notification: Notification) { func controlTextDidChange(_ notification: Notification) {
@@ -535,6 +551,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
cell.imageView?.image = NSWorkspace.shared.icon(forFile: application.url.path) cell.imageView?.image = NSWorkspace.shared.icon(forFile: application.url.path)
cell.toolTip = application.url.path cell.toolTip = application.url.path
} }
configureNumberHint(cell, row: row)
return cell return cell
} }
@@ -546,55 +563,40 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
return view return view
} }
private func configureNumberHint(_ cell: ApplicationCell, row: Int, animated: Bool = false) {
let visible = (window as? LauncherPanel)?.numberHintsVisible == true
&& calculation == nil && row < 9 && results.indices.contains(row)
cell.setNumberHint(visible ? String(row + 1) : nil, animated: animated)
cell.setAccessibilityHelp(visible ? "Command-\(row + 1) opens this application" : nil)
}
private func updateNumberHints() {
let rows = tableView.rows(in: tableView.visibleRect)
guard rows.length > 0 else { return }
for row in rows.location..<(rows.location + rows.length) {
if let cell = tableView.view(atColumn: 0, row: row, makeIfNecessary: false) as? ApplicationCell {
configureNumberHint(cell, row: row, animated: true)
}
}
}
private func makeCell(identifier: NSUserInterfaceItemIdentifier) -> ApplicationCell { private func makeCell(identifier: NSUserInterfaceItemIdentifier) -> ApplicationCell {
let cell = ApplicationCell() let cell = ApplicationCell()
cell.identifier = identifier cell.identifier = identifier
let icon = NSImageView()
icon.imageScaling = .scaleProportionallyDown
icon.setAccessibilityElement(false)
let label = NSTextField(labelWithString: "")
label.font = .systemFont(ofSize: LauncherRowView.textSize, weight: .medium)
label.textColor = SunsetPalette.text
label.lineBreakMode = .byTruncatingTail
let location = cell.locationLabel
location.font = .systemFont(ofSize: 12)
location.textColor = SunsetPalette.secondaryText
location.lineBreakMode = .byTruncatingMiddle
location.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
location.isHidden = true
let title = NSStackView(views: [label, location])
title.orientation = .horizontal
title.alignment = .centerY
title.spacing = 8
let kind = cell.kindLabel
kind.font = .systemFont(ofSize: 14)
kind.textColor = SunsetPalette.secondaryText
kind.setContentCompressionResistancePriority(.required, for: .horizontal)
kind.setAccessibilityElement(false)
cell.imageView = icon
cell.textField = label
for view in [icon, title, kind] {
view.translatesAutoresizingMaskIntoConstraints = false
cell.addSubview(view)
}
NSLayoutConstraint.activate([
icon.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 8),
icon.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
icon.widthAnchor.constraint(equalToConstant: LauncherRowView.iconSize),
icon.heightAnchor.constraint(equalToConstant: LauncherRowView.iconSize),
title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: LauncherRowView.iconToTextSpacing),
title.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
title.trailingAnchor.constraint(lessThanOrEqualTo: kind.leadingAnchor, constant: -12),
kind.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
kind.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -8),
])
return cell return cell
} }
@objc private func showActions() { @objc private func showActions() {
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil, results.indices.contains(tableView.selectedRow) else { return } guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil, results.indices.contains(tableView.selectedRow) else { return }
(window as? LauncherPanel)?.resetNumberHints()
let application = results[tableView.selectedRow] let application = results[tableView.selectedRow]
actions.open = { [weak self] in self?.openApplication(application) } actions.open = { [weak self] in self?.openApplication(application) }
actions.isFavorite = history.isFavorite(application)
actions.toggleFavorite = { [weak self] in
guard let self else { return }
self.history.toggleFavorite(application)
self.updateResults(preservingSelection: true)
}
actions.showInFinder = { [weak self] in actions.showInFinder = { [weak self] in
self?.dismiss(restoringFocus: false) self?.dismiss(restoringFocus: false)
NSWorkspace.shared.activateFileViewerSelecting([application.url]) NSWorkspace.shared.activateFileViewerSelecting([application.url])
@@ -635,6 +637,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
review.view.bottomAnchor.constraint(equalTo: content.bottomAnchor) review.view.bottomAnchor.constraint(equalTo: content.bottomAnchor)
]) ])
for view in launcherViews { view.setAccessibilityHidden(true) } for view in launcherViews { view.setAccessibilityHidden(true) }
(window as? LauncherPanel)?.resetNumberHints()
openButton.keyEquivalent = "" openButton.keyEquivalent = ""
updateOpenButton() updateOpenButton()
var frame = window.frame var frame = window.frame
@@ -699,6 +702,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private func openApplication(_ application: Application) { private func openApplication(_ application: Application) {
guard !isLaunching else { return } guard !isLaunching else { return }
(window as? LauncherPanel)?.resetNumberHints()
isLaunching = true isLaunching = true
updateOpenButton() updateOpenButton()
Task { Task {
@@ -720,9 +724,156 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} }
@MainActor @MainActor
private final class ApplicationCell: NSTableCellView { final class ApplicationCell: NSTableCellView {
private static let hintOffset: CGFloat = 14
private static let opaqueMask = [NSColor.black.cgColor, NSColor.black.cgColor]
private static let fadedMask = [NSColor.black.cgColor, NSColor.clear.cgColor]
private static let timing = CAMediaTimingFunction(name: .easeOut)
let locationLabel = NSTextField(labelWithString: "") let locationLabel = NSTextField(labelWithString: "")
let kindLabel = NSTextField(labelWithString: "Application") let kindLabel = NSTextField(labelWithString: "Application")
let numberHint = ShortcutIndicator(
"1", backgroundColor: NSColor(srgbRed: 0.26, green: 0.22, blue: 0.19, alpha: 1),
fontSize: 10, keySize: 20
)
private let kindFade = CAGradientLayer()
private var numberHintVisible = false
private var hintGeneration = 0
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
let icon = NSImageView()
icon.imageScaling = .scaleProportionallyDown
icon.setAccessibilityElement(false)
let label = NSTextField(labelWithString: "")
label.font = .systemFont(ofSize: LauncherRowView.textSize, weight: .medium)
label.textColor = SunsetPalette.text
label.lineBreakMode = .byTruncatingTail
locationLabel.font = .systemFont(ofSize: 12)
locationLabel.textColor = SunsetPalette.secondaryText
locationLabel.lineBreakMode = .byTruncatingMiddle
locationLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
locationLabel.isHidden = true
let title = NSStackView(views: [label, locationLabel])
title.orientation = .horizontal
title.alignment = .centerY
title.spacing = 8
kindLabel.font = .systemFont(ofSize: 14)
kindLabel.textColor = SunsetPalette.secondaryText
kindLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
kindLabel.setAccessibilityElement(false)
kindLabel.wantsLayer = true
imageView = icon
textField = label
numberHint.wantsLayer = true
numberHint.isHidden = true
numberHint.layer?.opacity = 0
numberHint.layer?.transform = CATransform3DMakeTranslation(Self.hintOffset, 0, 0)
kindFade.colors = Self.opaqueMask
kindFade.transform = CATransform3DMakeTranslation(Self.hintOffset, 0, 0)
// Added last so the badge overlays the label without changing the row's layout.
for view in [icon, title, kindLabel, numberHint] {
view.translatesAutoresizingMaskIntoConstraints = false
addSubview(view)
}
NSLayoutConstraint.activate([
icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
icon.centerYAnchor.constraint(equalTo: centerYAnchor),
icon.widthAnchor.constraint(equalToConstant: LauncherRowView.iconSize),
icon.heightAnchor.constraint(equalToConstant: LauncherRowView.iconSize),
title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: LauncherRowView.iconToTextSpacing),
title.centerYAnchor.constraint(equalTo: centerYAnchor),
title.trailingAnchor.constraint(lessThanOrEqualTo: kindLabel.leadingAnchor, constant: -12),
kindLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
kindLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
numberHint.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
numberHint.centerYAnchor.constraint(equalTo: centerYAnchor),
])
}
required init?(coder: NSCoder) { fatalError("Conjure creates application cells programmatically") }
override func layout() {
super.layout()
updateKindFadeGeometry()
}
func setNumberHint(_ key: String?, animated: Bool = true) {
if let key { numberHint.setSingleKey(key) }
let visible = key != nil
let animate = animated && !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
if animate && visible == numberHintVisible { return }
numberHintVisible = visible
hintGeneration += 1
let generation = hintGeneration
if animate { layoutSubtreeIfNeeded() }
updateKindFadeGeometry()
guard let hintLayer = numberHint.layer, let labelLayer = kindLabel.layer else { return }
let hintPresentation = hintLayer.presentation() ?? hintLayer
let fadePresentation = kindFade.presentation() ?? kindFade
let hintOffset = hintPresentation.transform.m41
let hintOpacity = hintPresentation.opacity
let fadeOffset = fadePresentation.transform.m41
let fadeColors = fadePresentation.colors
let targetOffset: CGFloat = visible ? 0 : Self.hintOffset
let targetOpacity: Float = visible ? 1 : 0
let targetColors = visible ? Self.fadedMask : Self.opaqueMask
CATransaction.begin()
CATransaction.setDisableActions(true)
hintLayer.removeAllAnimations()
kindFade.removeAllAnimations()
kindLabel.isHidden = false
numberHint.isHidden = !(visible || animate)
labelLayer.mask = visible || animate ? kindFade : nil
hintLayer.transform = CATransform3DMakeTranslation(targetOffset, 0, 0)
hintLayer.opacity = targetOpacity
kindFade.transform = CATransform3DMakeTranslation(targetOffset, 0, 0)
kindFade.colors = targetColors
if animate {
if !visible {
CATransaction.setCompletionBlock { [weak self] in
MainActor.assumeIsolated {
guard let self, self.hintGeneration == generation, !self.numberHintVisible else { return }
CATransaction.begin()
CATransaction.setDisableActions(true)
self.numberHint.isHidden = true
self.kindLabel.layer?.mask = nil
CATransaction.commit()
}
}
}
animateLayer(hintLayer, key: "transform.translation.x", from: hintOffset, to: targetOffset)
animateLayer(hintLayer, key: "opacity", from: hintOpacity, to: targetOpacity)
animateLayer(kindFade, key: "transform.translation.x", from: fadeOffset, to: targetOffset)
animateLayer(kindFade, key: "colors", from: fadeColors, to: targetColors)
}
CATransaction.commit()
}
private func updateKindFadeGeometry() {
let padding = Self.hintOffset
let width = kindLabel.bounds.width + padding * 2
guard width > padding * 2 else { return }
let badgeLeading = numberHint.frame.minX - kindLabel.frame.minX + padding
CATransaction.begin()
CATransaction.setDisableActions(true)
// Extend the mask so its moving left edge never clips the start of the label.
kindFade.bounds = CGRect(x: 0, y: 0, width: width, height: kindLabel.bounds.height)
kindFade.position = CGPoint(x: kindLabel.bounds.midX, y: kindLabel.bounds.midY)
kindFade.startPoint = CGPoint(x: max(0, min(1, (badgeLeading - 36) / width)), y: 0.5)
kindFade.endPoint = CGPoint(x: max(0, min(1, (badgeLeading + 2) / width)), y: 0.5)
CATransaction.commit()
}
private func animateLayer(_ layer: CALayer, key: String, from: Any?, to: Any) {
let animation = CABasicAnimation(keyPath: key)
animation.fromValue = from
animation.toValue = to
animation.duration = 0.16
animation.timingFunction = Self.timing
layer.add(animation, forKey: key)
}
} }
@MainActor @MainActor
@@ -750,14 +901,113 @@ private final class ApplicationTableView: NSTableView {
} }
@MainActor @MainActor
private final class LauncherPanel: NSPanel { final class LauncherPanel: NSPanel {
override var canBecomeKey: Bool { true } override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { true } override var canBecomeMain: Bool { true }
var navigateSelection: ((Int) -> Void)? var navigateSelection: ((Int) -> Void)?
var dismiss: (() -> Void)? var dismiss: (() -> Void)?
var dragEnded: (() -> Void)? var dragEnded: (() -> Void)?
var canUseNumberShortcuts: (() -> Bool)?
var numberHintsChanged: (() -> Void)?
var launchNumber: ((Int) -> Void)?
private var numberShortcuts = CommandNumberShortcuts()
private var commandHoldTask: Task<Void, Never>?
var numberHintsVisible: Bool { numberShortcuts.isVisible }
private func commandOnly(_ flags: NSEvent.ModifierFlags) -> Bool {
flags.intersection([.command, .control, .option, .shift, .function]) == .command
}
private var numberShortcutsEligible: Bool {
isKeyWindow && isVisible && attachedSheet == nil && canUseNumberShortcuts?() == true
}
func beginNumberHintSession() {
resetNumberHints()
numberShortcuts.reset()
}
func sampleNumberHints() {
reevaluateNumberHints(
modifierFlags: NSEvent.modifierFlags,
eligible: numberShortcutsEligible,
at: ProcessInfo.processInfo.systemUptime
)
}
/// The same sampling path accepts fixed flags, eligibility, and uptime for native smoke checks.
func reevaluateNumberHints(modifierFlags: NSEvent.ModifierFlags, eligible: Bool, at time: TimeInterval) {
let wasVisible = numberShortcuts.isVisible
let began = numberShortcuts.update(commandOnly: commandOnly(modifierFlags), eligible: eligible, at: time)
if !numberShortcuts.isHolding {
commandHoldTask?.cancel()
commandHoldTask = nil
} else if began {
commandHoldTask = Task { [weak self] in
do { try await Task.sleep(for: .seconds(1)) } catch { return }
self?.finishCommandHold(at: ProcessInfo.processInfo.systemUptime)
}
}
if wasVisible != numberShortcuts.isVisible { numberHintsChanged?() }
}
func resetNumberHints(modifierFlags: NSEvent.ModifierFlags = NSEvent.modifierFlags) {
commandHoldTask?.cancel()
commandHoldTask = nil
let wasVisible = numberShortcuts.isVisible
if commandOnly(modifierFlags) { numberShortcuts.cancel() }
else { numberShortcuts.reset() }
if wasVisible { numberHintsChanged?() }
}
func finishCommandHold(
at time: TimeInterval,
modifierFlags: NSEvent.ModifierFlags = NSEvent.modifierFlags,
eligible: Bool? = nil
) {
commandHoldTask?.cancel()
commandHoldTask = nil
let wasVisible = numberShortcuts.isVisible
_ = numberShortcuts.update(
commandOnly: commandOnly(modifierFlags), eligible: eligible ?? numberShortcutsEligible, at: time
)
_ = numberShortcuts.reveal(at: time)
if wasVisible != numberShortcuts.isVisible { numberHintsChanged?() }
}
override func becomeKey() {
super.becomeKey()
sampleNumberHints()
}
override func beginSheet(_ sheetWindow: NSWindow, completionHandler handler: ((NSApplication.ModalResponse) -> Void)? = nil) {
resetNumberHints()
super.beginSheet(sheetWindow, completionHandler: handler)
}
override func resignKey() {
resetNumberHints()
super.resignKey()
}
override func orderOut(_ sender: Any?) {
resetNumberHints()
super.orderOut(sender)
}
override func sendEvent(_ event: NSEvent) { override func sendEvent(_ event: NSEvent) {
let eligible = numberShortcutsEligible
if event.type == .flagsChanged || (event.type == .keyUp && event.keyCode == 49) {
reevaluateNumberHints(modifierFlags: event.modifierFlags, eligible: eligible, at: event.timestamp)
} else if event.type == .keyDown {
if eligible, commandOnly(event.modifierFlags),
let index = numberShortcuts.index(for: event.charactersIgnoringModifiers ?? "") {
resetNumberHints()
if !event.isARepeat { launchNumber?(index) }
return
}
resetNumberHints(modifierFlags: event.modifierFlags)
}
if event.type == .keyDown, attachedSheet == nil, if event.type == .keyDown, attachedSheet == nil,
event.modifierFlags.intersection([.control, .command, .option, .shift]) == .control { event.modifierFlags.intersection([.control, .command, .option, .shift]) == .control {
switch event.charactersIgnoringModifiers?.lowercased() { switch event.charactersIgnoringModifiers?.lowercased() {
+10 -4
View File
@@ -3,7 +3,7 @@ import CoreText
@MainActor @MainActor
final class ShortcutIndicator: NSStackView { final class ShortcutIndicator: NSStackView {
init(_ keys: String...) { init(_ keys: String..., backgroundColor: NSColor? = nil, fontSize: CGFloat = 12, keySize: CGFloat = 18) {
super.init(frame: .zero) super.init(frame: .zero)
orientation = .horizontal orientation = .horizontal
alignment = .centerY alignment = .centerY
@@ -14,23 +14,29 @@ final class ShortcutIndicator: NSStackView {
setAccessibilityElement(false) setAccessibilityElement(false)
for key in keys { for key in keys {
let badge = ShortcutKeyBadge(labelWithString: key) let badge = ShortcutKeyBadge(labelWithString: key)
badge.font = .systemFont(ofSize: 12, weight: .medium) badge.font = .systemFont(ofSize: fontSize, weight: .medium)
badge.textColor = SunsetPalette.secondaryText badge.textColor = SunsetPalette.secondaryText
badge.alignment = .center badge.alignment = .center
badge.wantsLayer = true badge.wantsLayer = true
badge.layer?.cornerRadius = 5 badge.layer?.cornerRadius = 5
badge.layer?.borderWidth = 1 badge.layer?.borderWidth = 1
badge.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.35).cgColor badge.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.35).cgColor
badge.layer?.backgroundColor = backgroundColor?.cgColor
badge.setAccessibilityElement(false) badge.setAccessibilityElement(false)
badge.translatesAutoresizingMaskIntoConstraints = false badge.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
badge.widthAnchor.constraint(equalToConstant: 18), badge.widthAnchor.constraint(equalToConstant: keySize),
badge.heightAnchor.constraint(equalToConstant: 18) badge.heightAnchor.constraint(equalToConstant: keySize)
]) ])
addArrangedSubview(badge) addArrangedSubview(badge)
} }
} }
func setSingleKey(_ key: String) {
precondition(arrangedSubviews.count == 1)
(arrangedSubviews[0] as? NSTextField)?.stringValue = key
}
required init?(coder: NSCoder) { fatalError("Conjure creates shortcut indicators programmatically") } required init?(coder: NSCoder) { fatalError("Conjure creates shortcut indicators programmatically") }
} }
@@ -0,0 +1,48 @@
import Foundation
/// Monotonic timestamps are supplied by the window; tests never wait on a real clock.
public struct CommandNumberShortcuts: Sendable {
private var heldSince: TimeInterval?
private var cancelledUntilRelease = false
public private(set) var isVisible = false
public var isHolding: Bool { heldSince != nil }
public init() {}
/// Samples an existing hold too, including when results first become eligible.
public mutating func update(commandOnly: Bool, eligible: Bool, at time: TimeInterval) -> Bool {
guard commandOnly else { reset(); return false }
guard eligible && !cancelledUntilRelease else {
heldSince = nil
isVisible = false
return false
}
guard heldSince == nil else { return false }
heldSince = time
return true
}
public mutating func reveal(at time: TimeInterval) -> Bool {
guard let heldSince, time - heldSince >= 1 else { return false }
isVisible = true
return true
}
public func index(for key: String) -> Int? {
guard isVisible, key.utf8.count == 1, let digit = key.utf8.first,
(49...57).contains(digit) else { return nil }
return Int(digit - 49)
}
/// An action cancels this hold; modifier sampling must not restart it.
public mutating func cancel() {
reset()
cancelledUntilRelease = true
}
public mutating func reset() {
heldSince = nil
isVisible = false
cancelledUntilRelease = false
}
}
+20
View File
@@ -8,9 +8,11 @@ public final class LaunchHistory {
} }
private static let storageKey = "applicationLaunchHistory" private static let storageKey = "applicationLaunchHistory"
private static let favoritesStorageKey = "applicationFavorites"
private let defaults: UserDefaults private let defaults: UserDefaults
private let now: () -> Date private let now: () -> Date
private var usage: [URL: Usage] = [:] private var usage: [URL: Usage] = [:]
private var favorites = Set<URL>()
public init(defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init) { public init(defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init) {
self.defaults = defaults self.defaults = defaults
@@ -23,6 +25,21 @@ public final class LaunchHistory {
lastLaunch.timeIntervalSinceReferenceDate.isFinite else { continue } lastLaunch.timeIntervalSinceReferenceDate.isFinite else { continue }
usage[url] = Usage(count: count, lastLaunch: lastLaunch) usage[url] = Usage(count: count, lastLaunch: lastLaunch)
} }
for identifier in defaults.stringArray(forKey: Self.favoritesStorageKey) ?? [] {
guard let url = URL(string: identifier), url.isFileURL else { continue }
favorites.insert(url)
}
}
public func isFavorite(_ application: Application) -> Bool {
favorites.contains(application.id)
}
public func toggleFavorite(_ application: Application) {
if favorites.remove(application.id) == nil {
favorites.insert(application.id)
}
defaults.set(favorites.map(\.absoluteString), forKey: Self.favoritesStorageKey)
} }
public func recordLaunch(of application: Application) { public func recordLaunch(of application: Application) {
@@ -57,6 +74,9 @@ public final class LaunchHistory {
} }
private func preferred(_ lhs: Application, _ rhs: Application) -> Bool { private func preferred(_ lhs: Application, _ rhs: Application) -> Bool {
let leftFavorite = isFavorite(lhs)
let rightFavorite = isFavorite(rhs)
if leftFavorite != rightFavorite { return leftFavorite }
let left = usage[lhs.id] let left = usage[lhs.id]
let right = usage[rhs.id] let right = usage[rhs.id]
let leftCount = left?.count ?? 0 let leftCount = left?.count ?? 0
+3 -7
View File
@@ -1,7 +1,3 @@
- [x] make the actions indicator font size smaller. - [x] Add Favorite / Remove Favorite actions and persist favorites by application bundle.
- [x] decrease the distance between the icons and their text by 1/3. - Empty search: favorites first, then usage ranking within each group.
- [x] add more actions to the actions selector: - Typed search: match quality first, then favorites, launch count, recency, and alphabetical order.
- show in finder
- reset ranking
- quit application
- [x] add unit conversion and calculator behavior like raylib has.
@@ -0,0 +1,66 @@
import ConjureKit
import Testing
@Test func commandNumbersRequireAnUninterruptedEligibleOneSecondHold() {
var shortcuts = CommandNumberShortcuts()
let started = shortcuts.update(commandOnly: true, eligible: true, at: 10)
let repeated = shortcuts.update(commandOnly: true, eligible: true, at: 10.5)
let early = shortcuts.reveal(at: 10.999)
#expect(started && !repeated && !early)
#expect(shortcuts.index(for: "1") == nil)
let revealed = shortcuts.reveal(at: 11)
#expect(revealed)
#expect(shortcuts.index(for: "1") == 0)
#expect(shortcuts.index(for: "9") == 8)
for key in ["0", "10", "k", "", "١"] { #expect(shortcuts.index(for: key) == nil) }
_ = shortcuts.update(commandOnly: false, eligible: true, at: 11.1)
#expect(!shortcuts.isVisible)
let afterRelease = shortcuts.reveal(at: 12)
#expect(!afterRelease)
_ = shortcuts.update(commandOnly: true, eligible: true, at: 13)
_ = shortcuts.update(commandOnly: true, eligible: false, at: 13.5)
let blocked = shortcuts.reveal(at: 14)
#expect(!blocked)
_ = shortcuts.update(commandOnly: true, eligible: true, at: 15)
let restartedEarly = shortcuts.reveal(at: 15.5)
let restarted = shortcuts.reveal(at: 16)
#expect(!restartedEarly && restarted)
shortcuts.reset()
#expect(shortcuts.index(for: "2") == nil)
let afterReset = shortcuts.reveal(at: 20)
#expect(!afterReset)
}
@Test(arguments: [false, true])
func commandHeldAtActivationArmsWithoutAModifierEvent(resultsInitiallyEligible: Bool) {
var shortcuts = CommandNumberShortcuts()
let activation = shortcuts.update(commandOnly: true, eligible: resultsInitiallyEligible, at: 10)
let afterDiscovery = shortcuts.update(commandOnly: true, eligible: true, at: 10.1)
let spaceReleased = shortcuts.update(commandOnly: true, eligible: true, at: 10.2)
#expect(activation == resultsInitiallyEligible)
#expect(afterDiscovery == !resultsInitiallyEligible && !spaceReleased)
let heldAt = resultsInitiallyEligible ? 10.0 : 10.1
let early = shortcuts.reveal(at: heldAt + 0.999)
let revealed = shortcuts.reveal(at: heldAt + 1)
#expect(!early && revealed)
}
@Test func commandShortcutCancellationSurvivesEligibilityAndSpaceReleaseSamples() {
var shortcuts = CommandNumberShortcuts()
_ = shortcuts.update(commandOnly: true, eligible: true, at: 10)
shortcuts.cancel()
_ = shortcuts.update(commandOnly: true, eligible: false, at: 10.2)
let afterDiscovery = shortcuts.update(commandOnly: true, eligible: true, at: 10.3)
let spaceReleased = shortcuts.update(commandOnly: true, eligible: true, at: 10.4)
let cancelled = shortcuts.reveal(at: 12)
#expect(!afterDiscovery && !spaceReleased && !cancelled && !shortcuts.isHolding)
_ = shortcuts.update(commandOnly: false, eligible: true, at: 12.1)
let freshHold = shortcuts.update(commandOnly: true, eligible: true, at: 13)
let revealed = shortcuts.reveal(at: 14)
#expect(freshHold && revealed)
shortcuts.cancel()
shortcuts.reset() // Reopening the launcher starts a new activation session.
let reopened = shortcuts.update(commandOnly: true, eligible: true, at: 15)
let reopenedVisible = shortcuts.reveal(at: 16)
#expect(reopened && reopenedVisible)
}
@@ -77,6 +77,12 @@ func applicationIdentityIgnoresDirectoryURLFormattingInLaunchHistory() {
#expect(plain.id == directory.id) #expect(plain.id == directory.id)
history.recordLaunch(of: directory) history.recordLaunch(of: directory)
#expect(history.suggestions(in: [historyApplication("Alpha"), plain]).first?.id == plain.id) #expect(history.suggestions(in: [historyApplication("Alpha"), plain]).first?.id == plain.id)
history.toggleFavorite(directory)
#expect(history.isFavorite(plain))
let reloaded = LaunchHistory(defaults: UserDefaults(suiteName: suite)!, now: { instant })
#expect(reloaded.isFavorite(plain))
reloaded.toggleFavorite(plain)
#expect(!reloaded.isFavorite(directory))
} }
@Test @MainActor @Test @MainActor
@@ -135,6 +141,132 @@ func resetRankingClearsOnlySelectedIdentityAndPersistsAcrossRelaunch() {
#expect(reloaded.suggestions(in: apps).map(\.id) == [first.id, second.id, alpha.id]) #expect(reloaded.suggestions(in: apps).map(\.id) == [first.id, second.id, alpha.id])
} }
@Test @MainActor
func emptySearchPlacesFavoritesFirstAndPreservesUsageOrderingWithinEachGroup() {
let suite = "ConjureHistoryTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
var instant = Date(timeIntervalSinceReferenceDate: 7_000)
let history = LaunchHistory(defaults: defaults, now: { instant })
let favorites = ["Favorite Frequent", "Favorite Recent", "Favorite Older", "Favorite Unused Alpha", "Favorite Unused Zebra"]
.map(historyApplication)
let others = ["Other Frequent", "Other Recent", "Other Older", "Other Unused Alpha", "Other Unused Zebra"]
.map(historyApplication)
for application in favorites { history.toggleFavorite(application) }
for group in [favorites, others] {
history.recordLaunch(of: group[0])
history.recordLaunch(of: group[0])
history.recordLaunch(of: group[2])
instant += 10
history.recordLaunch(of: group[1])
}
for _ in 0..<8 { history.recordLaunch(of: others[0]) }
let expected = (favorites + others).map(\.id)
let apps = Array((others + favorites).reversed())
#expect(history.suggestions(in: apps).map(\.id) == expected)
#expect(history.results(for: "", in: apps).map(\.id) == expected)
#expect(history.results(for: " \n", in: apps).map(\.id) == expected)
}
@Test @MainActor
func typedSearchPrefersTextRankBeforeFavoritesAndFavoritesBeforeUsage() {
let suite = "ConjureHistoryTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let instant = Date(timeIntervalSinceReferenceDate: 8_000)
let history = LaunchHistory(defaults: defaults, now: { instant })
let apps = ["AB", "AB Alpha", "AB Heavy", "AyB", "AzB", "AxxxxB", "Unrelated"].map(historyApplication)
for index in [1, 3, 5, 6] { history.toggleFavorite(apps[index]) }
for _ in 0..<5 {
history.recordLaunch(of: apps[2])
history.recordLaunch(of: apps[4])
}
#expect(history.results(for: "ab", in: Array(apps.reversed())).map(\.id) == Array(apps.dropLast()).map(\.id))
}
@Test @MainActor
func favoritesPersistWithoutUsageAndToggleRemovalPersistsAcrossRelaunch() {
let suite = "ConjureHistoryTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let instant = Date(timeIntervalSinceReferenceDate: 9_000)
let history = LaunchHistory(defaults: defaults, now: { instant })
let alpha = historyApplication("Alpha")
let zebra = historyApplication("Zebra")
let removed = historyApplication("Removed")
#expect(!history.isFavorite(zebra))
history.toggleFavorite(zebra)
history.toggleFavorite(removed)
#expect(history.isFavorite(zebra))
let reloaded = LaunchHistory(defaults: UserDefaults(suiteName: suite)!, now: { instant })
#expect(reloaded.isFavorite(zebra))
#expect(reloaded.isFavorite(removed))
#expect(reloaded.suggestions(in: [alpha, zebra]).map(\.id) == [zebra.id, alpha.id])
reloaded.toggleFavorite(zebra)
#expect(!reloaded.isFavorite(zebra))
let afterRemoval = LaunchHistory(defaults: UserDefaults(suiteName: suite)!, now: { instant })
#expect(!afterRemoval.isFavorite(zebra))
#expect(afterRemoval.isFavorite(removed))
#expect(afterRemoval.suggestions(in: [zebra, alpha]).map(\.id) == [alpha.id, zebra.id])
}
@Test @MainActor
func resettingUsageRetainsFavoritesAndTheirPersistence() {
let suite = "ConjureHistoryTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let instant = Date(timeIntervalSinceReferenceDate: 10_000)
let history = LaunchHistory(defaults: defaults, now: { instant })
let alpha = historyApplication("Alpha")
let zebra = historyApplication("Zebra")
let other = historyApplication("Other")
let apps = [other, zebra, alpha]
history.toggleFavorite(alpha)
history.toggleFavorite(zebra)
history.recordLaunch(of: zebra)
history.recordLaunch(of: zebra)
for _ in 0..<5 { history.recordLaunch(of: other) }
#expect(history.suggestions(in: apps).map(\.id) == [zebra.id, alpha.id, other.id])
history.resetRanking(of: zebra)
history.resetRanking(of: zebra)
#expect(history.isFavorite(zebra))
#expect(history.suggestions(in: apps).map(\.id) == [alpha.id, zebra.id, other.id])
let reloaded = LaunchHistory(defaults: UserDefaults(suiteName: suite)!, now: { instant })
#expect(reloaded.isFavorite(alpha))
#expect(reloaded.isFavorite(zebra))
#expect(reloaded.suggestions(in: apps).map(\.id) == [alpha.id, zebra.id, other.id])
reloaded.recordLaunch(of: zebra)
#expect(reloaded.isFavorite(zebra))
#expect(reloaded.suggestions(in: apps).map(\.id) == [zebra.id, alpha.id, other.id])
}
@Test @MainActor
func favoritesTrackApplicationURLsRatherThanDisplayNames() {
let suite = "ConjureHistoryTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let instant = Date(timeIntervalSinceReferenceDate: 11_000)
let history = LaunchHistory(defaults: defaults, now: { instant })
let first = Application(url: URL(fileURLWithPath: "/First/Editor.app"), name: "Editor")
let second = Application(url: URL(fileURLWithPath: "/Second/Editor.app"), name: "Editor")
for _ in 0..<5 { history.recordLaunch(of: first) }
history.toggleFavorite(second)
#expect(!history.isFavorite(first))
#expect(history.isFavorite(second))
#expect(history.results(for: "editor", in: [first, second]).map(\.id) == [second.id, first.id])
let renamed = Application(url: second.url, name: "Renamed Editor")
let reloaded = LaunchHistory(defaults: UserDefaults(suiteName: suite)!, now: { instant })
#expect(!reloaded.isFavorite(first))
#expect(reloaded.isFavorite(renamed))
#expect(reloaded.suggestions(in: [first, renamed]).map(\.id) == [second.id, first.id])
reloaded.toggleFavorite(renamed)
#expect(!reloaded.isFavorite(second))
#expect(reloaded.suggestions(in: [second, first]).map(\.id) == [first.id, second.id])
}
private func historyApplication(_ name: String) -> Application { private func historyApplication(_ name: String) -> Application {
Application(url: URL(fileURLWithPath: "/Applications/HistoryTests/\(name).app"), name: name) Application(url: URL(fileURLWithPath: "/Applications/HistoryTests/\(name).app"), name: name)
} }