feat: add Command-number launch hints and Finder icon

This commit is contained in:
2026-10-13 22:08:14 +02:00
parent d3f9ec92d7
commit 77f62b8d41
7 changed files with 176 additions and 12 deletions
@@ -6,7 +6,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
private let search = ActionsSearchField()
private let entries = [
ActionEntry(title: "Open", symbol: "arrow.up.forward.app", color: SunsetPalette.text),
ActionEntry(title: "Show in Finder", symbol: "folder", 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: "Quit application", symbol: "stop.circle", color: SunsetPalette.text),
ActionEntry(title: "Uninstall application", symbol: "trash", color: .systemRed)
@@ -269,6 +269,7 @@ private struct ActionEntry {
button.setAccessibilityLabel(title)
let icon = ActionIconView()
icon.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)
?? (symbol == "finder" ? NSWorkspace.shared.icon(forFile: "/System/Library/CoreServices/Finder.app") : nil)
icon.contentTintColor = color
icon.imageScaling = .scaleProportionallyDown
icon.setAccessibilityElement(false)
+100 -2
View File
@@ -61,6 +61,16 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
else { self.moveSelection(by: offset) }
}
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
guard let self else { return }
self.alignmentGuides?.end(snapping: self.window)
@@ -171,6 +181,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
func windowDidResignKey(_ notification: Notification) {
(window as? LauncherPanel)?.resetNumberHints()
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
dismiss(restoringFocus: false)
}
@@ -433,6 +444,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
detail: "\(answer.detail) · \(cacheStatus)"
)
}
if calculation != nil { (window as? LauncherPanel)?.resetNumberHints() }
if calculation != nil {
results = []
} else if searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
@@ -535,6 +547,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
cell.imageView?.image = NSWorkspace.shared.icon(forFile: application.url.path)
cell.toolTip = application.url.path
}
configureNumberHint(cell, row: row)
return cell
}
@@ -546,6 +559,25 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
return view
}
private func configureNumberHint(_ cell: ApplicationCell, row: Int) {
let visible = (window as? LauncherPanel)?.numberHintsVisible == true
&& calculation == nil && row < 9 && results.indices.contains(row)
cell.numberHint.isHidden = !visible
cell.kindLabel.isHidden = visible
if visible { cell.numberHint.setSingleKey(String(row + 1)) }
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)
}
}
}
private func makeCell(identifier: NSUserInterfaceItemIdentifier) -> ApplicationCell {
let cell = ApplicationCell()
cell.identifier = identifier
@@ -573,7 +605,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
kind.setAccessibilityElement(false)
cell.imageView = icon
cell.textField = label
for view in [icon, title, kind] {
cell.numberHint.isHidden = true
for view in [icon, title, kind, cell.numberHint] {
view.translatesAutoresizingMaskIntoConstraints = false
cell.addSubview(view)
}
@@ -587,12 +620,15 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
title.trailingAnchor.constraint(lessThanOrEqualTo: kind.leadingAnchor, constant: -12),
kind.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
kind.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -8),
cell.numberHint.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -8),
cell.numberHint.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
])
return cell
}
@objc private func showActions() {
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil, results.indices.contains(tableView.selectedRow) else { return }
(window as? LauncherPanel)?.resetNumberHints()
let application = results[tableView.selectedRow]
actions.open = { [weak self] in self?.openApplication(application) }
actions.showInFinder = { [weak self] in
@@ -635,6 +671,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
review.view.bottomAnchor.constraint(equalTo: content.bottomAnchor)
])
for view in launcherViews { view.setAccessibilityHidden(true) }
(window as? LauncherPanel)?.resetNumberHints()
openButton.keyEquivalent = ""
updateOpenButton()
var frame = window.frame
@@ -699,6 +736,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private func openApplication(_ application: Application) {
guard !isLaunching else { return }
(window as? LauncherPanel)?.resetNumberHints()
isLaunching = true
updateOpenButton()
Task {
@@ -723,6 +761,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private final class ApplicationCell: NSTableCellView {
let locationLabel = NSTextField(labelWithString: "")
let kindLabel = NSTextField(labelWithString: "Application")
let numberHint = ShortcutIndicator("1")
}
@MainActor
@@ -750,14 +789,73 @@ private final class ApplicationTableView: NSTableView {
}
@MainActor
private final class LauncherPanel: NSPanel {
final class LauncherPanel: NSPanel {
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { true }
var navigateSelection: ((Int) -> Void)?
var dismiss: (() -> 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(_ event: NSEvent) -> Bool {
event.modifierFlags.intersection([.command, .control, .option, .shift, .function]) == .command
}
func resetNumberHints() {
commandHoldTask?.cancel()
commandHoldTask = nil
let wasVisible = numberShortcuts.isVisible
numberShortcuts.reset()
if wasVisible { numberHintsChanged?() }
}
func finishCommandHold(at time: TimeInterval) {
guard isKeyWindow, isVisible, attachedSheet == nil, canUseNumberShortcuts?() == true else {
resetNumberHints()
return
}
if numberShortcuts.reveal(at: time) {
commandHoldTask?.cancel()
commandHoldTask = nil
numberHintsChanged?()
}
}
override func resignKey() {
resetNumberHints()
super.resignKey()
}
override func orderOut(_ sender: Any?) {
resetNumberHints()
super.orderOut(sender)
}
override func sendEvent(_ event: NSEvent) {
let eligible = isKeyWindow && isVisible && attachedSheet == nil && canUseNumberShortcuts?() == true
if event.type == .flagsChanged {
if !commandOnly(event) || !eligible {
resetNumberHints()
} else if numberShortcuts.update(commandOnly: true, eligible: true, at: event.timestamp) {
commandHoldTask = Task { [weak self] in
do { try await Task.sleep(for: .seconds(1)) } catch { return }
self?.finishCommandHold(at: ProcessInfo.processInfo.systemUptime)
}
}
} else if event.type == .keyDown {
if eligible, commandOnly(event),
let index = numberShortcuts.index(for: event.charactersIgnoringModifiers ?? "") {
resetNumberHints()
if !event.isARepeat { launchNumber?(index) }
return
}
resetNumberHints()
}
if event.type == .keyDown, attachedSheet == nil,
event.modifierFlags.intersection([.control, .command, .option, .shift]) == .control {
switch event.charactersIgnoringModifiers?.lowercased() {
+5
View File
@@ -31,6 +31,11 @@ final class ShortcutIndicator: NSStackView {
}
}
func setSingleKey(_ key: String) {
precondition(arrangedSubviews.count == 1)
(arrangedSubviews[0] as? NSTextField)?.stringValue = key
}
required init?(coder: NSCoder) { fatalError("Conjure creates shortcut indicators programmatically") }
}
@@ -0,0 +1,34 @@
import Foundation
/// Monotonic timestamps are supplied by the window; tests never wait on a real clock.
public struct CommandNumberShortcuts: Sendable {
private var heldSince: TimeInterval?
public private(set) var isVisible = false
public init() {}
/// Returns true only when a fresh, eligible Command-only hold begins.
public mutating func update(commandOnly: Bool, eligible: Bool, at time: TimeInterval) -> Bool {
guard commandOnly && eligible else { reset(); 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)
}
public mutating func reset() {
heldSince = nil
isVisible = false
}
}