diff --git a/README.md b/README.md index b4d9507..713d1d2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index 52d9be9..6cca264 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -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? 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) } diff --git a/Sources/Conjure/ShortcutIndicator.swift b/Sources/Conjure/ShortcutIndicator.swift index 1dc3e25..e55e045 100644 --- a/Sources/Conjure/ShortcutIndicator.swift +++ b/Sources/Conjure/ShortcutIndicator.swift @@ -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([ diff --git a/Sources/ConjureKit/CommandNumberShortcuts.swift b/Sources/ConjureKit/CommandNumberShortcuts.swift index a52d13f..2e71b76 100644 --- a/Sources/ConjureKit/CommandNumberShortcuts.swift +++ b/Sources/ConjureKit/CommandNumberShortcuts.swift @@ -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 } } diff --git a/Tests/ConjureKitTests/CommandNumberShortcutsTests.swift b/Tests/ConjureKitTests/CommandNumberShortcutsTests.swift index 071937e..956e13d 100644 --- a/Tests/ConjureKitTests/CommandNumberShortcutsTests.swift +++ b/Tests/ConjureKitTests/CommandNumberShortcutsTests.swift @@ -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) +}