Compare commits
6 Commits
d3f9ec92d7
...
9f6aa1db39
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f6aa1db39 | |||
| f3be147423 | |||
| 74d16092bb | |||
| 8308864443 | |||
| 006c7e7dd0 | |||
| 77f62b8d41 |
@@ -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)
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user