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
+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) }