fix: arm held Command on activation and animate number overlays
This commit is contained in:
@@ -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.
|
- 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. 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -94,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
|
||||||
@@ -112,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()
|
||||||
}
|
}
|
||||||
@@ -473,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) {
|
||||||
@@ -559,12 +563,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
return view
|
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
|
let visible = (window as? LauncherPanel)?.numberHintsVisible == true
|
||||||
&& calculation == nil && row < 9 && results.indices.contains(row)
|
&& calculation == nil && row < 9 && results.indices.contains(row)
|
||||||
cell.numberHint.isHidden = !visible
|
cell.setNumberHint(visible ? String(row + 1) : nil, animated: animated)
|
||||||
cell.kindLabel.isHidden = visible
|
|
||||||
if visible { cell.numberHint.setSingleKey(String(row + 1)) }
|
|
||||||
cell.setAccessibilityHelp(visible ? "Command-\(row + 1) opens this application" : nil)
|
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 }
|
guard rows.length > 0 else { return }
|
||||||
for row in rows.location..<(rows.location + rows.length) {
|
for row in rows.location..<(rows.location + rows.length) {
|
||||||
if let cell = tableView.view(atColumn: 0, row: row, makeIfNecessary: false) as? ApplicationCell {
|
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 {
|
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
|
|
||||||
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
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,10 +718,153 @@ 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")
|
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
|
@MainActor
|
||||||
@@ -802,28 +905,74 @@ final class LauncherPanel: NSPanel {
|
|||||||
private var commandHoldTask: Task<Void, Never>?
|
private var commandHoldTask: Task<Void, Never>?
|
||||||
var numberHintsVisible: Bool { numberShortcuts.isVisible }
|
var numberHintsVisible: Bool { numberShortcuts.isVisible }
|
||||||
|
|
||||||
private func commandOnly(_ event: NSEvent) -> Bool {
|
private func commandOnly(_ flags: NSEvent.ModifierFlags) -> Bool {
|
||||||
event.modifierFlags.intersection([.command, .control, .option, .shift, .function]) == .command
|
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() {
|
func resetNumberHints() {
|
||||||
commandHoldTask?.cancel()
|
commandHoldTask?.cancel()
|
||||||
commandHoldTask = nil
|
commandHoldTask = nil
|
||||||
let wasVisible = numberShortcuts.isVisible
|
let wasVisible = numberShortcuts.isVisible
|
||||||
numberShortcuts.reset()
|
numberShortcuts.cancel()
|
||||||
if wasVisible { numberHintsChanged?() }
|
if wasVisible { numberHintsChanged?() }
|
||||||
}
|
}
|
||||||
|
|
||||||
func finishCommandHold(at time: TimeInterval) {
|
func finishCommandHold(
|
||||||
guard isKeyWindow, isVisible, attachedSheet == nil, canUseNumberShortcuts?() == true else {
|
at time: TimeInterval,
|
||||||
resetNumberHints()
|
modifierFlags: NSEvent.ModifierFlags = NSEvent.modifierFlags,
|
||||||
return
|
eligible: Bool? = nil
|
||||||
}
|
) {
|
||||||
if numberShortcuts.reveal(at: time) {
|
|
||||||
commandHoldTask?.cancel()
|
commandHoldTask?.cancel()
|
||||||
commandHoldTask = nil
|
commandHoldTask = nil
|
||||||
numberHintsChanged?()
|
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() {
|
override func resignKey() {
|
||||||
@@ -837,18 +986,11 @@ final class LauncherPanel: NSPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func sendEvent(_ event: NSEvent) {
|
override func sendEvent(_ event: NSEvent) {
|
||||||
let eligible = isKeyWindow && isVisible && attachedSheet == nil && canUseNumberShortcuts?() == true
|
let eligible = numberShortcutsEligible
|
||||||
if event.type == .flagsChanged {
|
if event.type == .flagsChanged || (event.type == .keyUp && event.keyCode == 49) {
|
||||||
if !commandOnly(event) || !eligible {
|
reevaluateNumberHints(modifierFlags: event.modifierFlags, eligible: eligible, at: event.timestamp)
|
||||||
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 {
|
} else if event.type == .keyDown {
|
||||||
if eligible, commandOnly(event),
|
if eligible, commandOnly(event.modifierFlags),
|
||||||
let index = numberShortcuts.index(for: event.charactersIgnoringModifiers ?? "") {
|
let index = numberShortcuts.index(for: event.charactersIgnoringModifiers ?? "") {
|
||||||
resetNumberHints()
|
resetNumberHints()
|
||||||
if !event.isARepeat { launchNumber?(index) }
|
if !event.isARepeat { launchNumber?(index) }
|
||||||
|
|||||||
@@ -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) {
|
||||||
super.init(frame: .zero)
|
super.init(frame: .zero)
|
||||||
orientation = .horizontal
|
orientation = .horizontal
|
||||||
alignment = .centerY
|
alignment = .centerY
|
||||||
@@ -21,6 +21,7 @@ final class ShortcutIndicator: NSStackView {
|
|||||||
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([
|
||||||
|
|||||||
@@ -3,13 +3,20 @@ import Foundation
|
|||||||
/// Monotonic timestamps are supplied by the window; tests never wait on a real clock.
|
/// Monotonic timestamps are supplied by the window; tests never wait on a real clock.
|
||||||
public struct CommandNumberShortcuts: Sendable {
|
public struct CommandNumberShortcuts: Sendable {
|
||||||
private var heldSince: TimeInterval?
|
private var heldSince: TimeInterval?
|
||||||
|
private var cancelledUntilRelease = false
|
||||||
public private(set) var isVisible = false
|
public private(set) var isVisible = false
|
||||||
|
public var isHolding: Bool { heldSince != nil }
|
||||||
|
|
||||||
public init() {}
|
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 {
|
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 }
|
guard heldSince == nil else { return false }
|
||||||
heldSince = time
|
heldSince = time
|
||||||
return true
|
return true
|
||||||
@@ -27,8 +34,15 @@ public struct CommandNumberShortcuts: Sendable {
|
|||||||
return Int(digit - 49)
|
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() {
|
public mutating func reset() {
|
||||||
heldSince = nil
|
heldSince = nil
|
||||||
isVisible = false
|
isVisible = false
|
||||||
|
cancelledUntilRelease = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,3 +30,37 @@ import Testing
|
|||||||
let afterReset = shortcuts.reveal(at: 20)
|
let afterReset = shortcuts.reveal(at: 20)
|
||||||
#expect(!afterReset)
|
#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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user