fix: arm held Command on activation and animate number overlays
This commit is contained in:
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user