fix: arm held Command on activation and animate number overlays

This commit is contained in:
2026-10-13 22:31:32 +02:00
parent 77f62b8d41
commit 006c7e7dd0
5 changed files with 268 additions and 77 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ 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.
- 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.
- Hold Command alone for one second to show numbered badges at the right edge of the first nine application results. 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. Releasing Command, pressing another key/modifier, losing focus, dismissing, or opening Actions/uninstall review cancels the hints. Calculator/currency results and modal sheets do not enable numbered launching; missing result numbers do nothing.
- 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.
- 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.
+215 -73
View File
@@ -94,10 +94,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
func present() {
(window as? LauncherPanel)?.beginNumberHintSession()
if launcherFrameBeforeReview != nil {
showWindow(nil)
NSApp.activate()
window?.makeKeyAndOrderFront(nil)
(window as? LauncherPanel)?.sampleNumberHints()
return
}
let frontmost = NSWorkspace.shared.frontmostApplication
@@ -112,6 +114,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
NSApp.activate()
window?.makeKeyAndOrderFront(nil)
window?.makeFirstResponder(searchField)
(window as? LauncherPanel)?.sampleNumberHints()
(searchField.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
refreshApplications()
}
@@ -473,6 +476,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
statusLabel.stringValue = results.count == 1 ? "1 application" : "\(results.count) applications"
}
updateOpenButton()
(window as? LauncherPanel)?.sampleNumberHints()
}
func controlTextDidChange(_ notification: Notification) {
@@ -559,12 +563,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
return view
}
private func configureNumberHint(_ cell: ApplicationCell, row: Int) {
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.numberHint.isHidden = !visible
cell.kindLabel.isHidden = visible
if visible { cell.numberHint.setSingleKey(String(row + 1)) }
cell.setNumberHint(visible ? String(row + 1) : nil, animated: animated)
cell.setAccessibilityHelp(visible ? "Command-\(row + 1) opens this application" : nil)
}
@@ -573,7 +575,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
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)
configureNumberHint(cell, row: row, animated: true)
}
}
}
@@ -581,48 +583,6 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private func makeCell(identifier: NSUserInterfaceItemIdentifier) -> ApplicationCell {
let cell = ApplicationCell()
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
cell.numberHint.isHidden = true
for view in [icon, title, kind, cell.numberHint] {
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),
cell.numberHint.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -8),
cell.numberHint.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
])
return cell
}
@@ -758,10 +718,153 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
@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 kindLabel = NSTextField(labelWithString: "Application")
let numberHint = ShortcutIndicator("1")
let numberHint = ShortcutIndicator("1", backgroundColor: SunsetPalette.background.withAlphaComponent(1))
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 - 18) / 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
@@ -802,28 +905,74 @@ final class LauncherPanel: NSPanel {
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
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() {
commandHoldTask?.cancel()
commandHoldTask = nil
let wasVisible = numberShortcuts.isVisible
numberShortcuts.reset()
numberShortcuts.cancel()
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?()
}
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() {
@@ -837,18 +986,11 @@ final class LauncherPanel: NSPanel {
}
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)
}
}
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),
if eligible, commandOnly(event.modifierFlags),
let index = numberShortcuts.index(for: event.charactersIgnoringModifiers ?? "") {
resetNumberHints()
if !event.isARepeat { launchNumber?(index) }
+2 -1
View File
@@ -3,7 +3,7 @@ import CoreText
@MainActor
final class ShortcutIndicator: NSStackView {
init(_ keys: String...) {
init(_ keys: String..., backgroundColor: NSColor? = nil) {
super.init(frame: .zero)
orientation = .horizontal
alignment = .centerY
@@ -21,6 +21,7 @@ final class ShortcutIndicator: NSStackView {
badge.layer?.cornerRadius = 5
badge.layer?.borderWidth = 1
badge.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.35).cgColor
badge.layer?.backgroundColor = backgroundColor?.cgColor
badge.setAccessibilityElement(false)
badge.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
@@ -3,13 +3,20 @@ 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() {}
/// Returns true only when a fresh, eligible Command-only hold begins.
/// 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 && eligible else { reset(); return false }
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
@@ -27,8 +34,15 @@ public struct CommandNumberShortcuts: Sendable {
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
}
}
@@ -30,3 +30,37 @@ import Testing
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)
}