Compare commits

...

7 Commits

9 changed files with 167 additions and 56 deletions
+5 -1
View File
@@ -79,9 +79,13 @@ Applications with identical display names show a muted containing-folder hint be
Select an application and open **Actions ⌘K**. **Open** is the first, default-selected action and uses the same launch path, successful-launch history tracking, dismissal, and error reporting as Return in the application list. Filtering resets selection to the first matching action; an empty result does nothing on Return. Up/Down or Control-K/Control-J navigates actions without changing the selected application.
- **Show in Finder** reveals the selected application bundle and dismisses the launcher without restoring the previous application's focus.
- **Reset ranking** clears only the selected bundle's launch count and last-launch timestamp, including persisted history. Results reorder immediately while retaining the selected application; other copies and applications keep their history. Future launches start counting again.
- **Quit application** sends a normal macOS quit request to running instances of that exact bundle, not another copy with the same name or bundle identifier. A non-running application is a no-op. The launcher dismisses after an accepted request; the application may still present save prompts or cancel quitting. Rejected requests show a native alert. Conjure never force-quits the application.
The red **Uninstall application** action morphs the existing launcher into a file-review screen with checkboxes, paths, sizes, and a selected-item count; it does not create another window or review sheet. A native 180-millisecond resize and content crossfade runs in both directions, or switches immediately with Reduce Motion. The filter uses a centered, intrinsic-height text field without built-in search/cancel icons, beside an explicit back chevron. Filtering files by name or path does not change file selections. Up/Down or Control-K/Control-J highlights files; Return toggles the highlighted eligible file, including from the filter input, and Space also toggles when the list has focus. Back or Escape restores the original launcher frame, query, and selection. Temporary review geometry is not saved as the launcher position. Leaving is blocked during confirmation or an uninstall operation. Command-Return or the uninstall button opens a native final confirmation dialog; nothing is moved merely by entering review. The confirmation control uses the launcher's frosted rounded pill with separate Command and Return key badges. The launcher Actions control likewise shows separate Command and K badges matching its Open Return badge.
Keyboard file toggles update the existing checkbox without reloading or fading the list, preserving both the highlighted row and scroll position. Multi-key footer badges have a small gap between their outlines rather than touching.
Keyboard file toggles update the existing checkbox without reloading or fading the list, preserving both the highlighted row and scroll position. The launcher's footer Open and Actions labels use 12-point medium text. All custom shortcut hints—in the launcher footer, uninstall footer, and actions selector—use `ShortcutIndicator` from `Sources/Conjure/ShortcutIndicator.swift` (for example, `ShortcutIndicator("⌘", "K")`). It owns 12-point medium key text, square 18×18-point containers, visible-glyph centering, border styling, and 2-point gaps between adjacent keys; callers only position or hide the whole indicator. Native menus and shortcut-recording controls retain their platform styling.
The actions selector is an arrowless panel inside the launcher, aligned to the bottom and right edges of the footer action pill, replacing it visually while open rather than floating above it. It shares the launcher's frosted Sunset background, 38-point selection row height, 22×22-point icon boxes with 12-point icon-to-text spacing, 14-point action and search text, rounded selection styling, and Return key badge; selection width follows the panel. Application and command rows use the same sizing constants. SF Symbol optical alignment insets are removed so command icons render inside the same bounds as application icons. The application name appears at the top and borderless action search sits eight points from the bottom, without a divider. Native animations crossfade the panel and footer pill: 160 milliseconds to open and 120 milliseconds to close, with unchanged geometry. Reduce Motion disables the animation. Rapid reopening invalidates stale dismissal completions, and closing panels immediately stop intercepting input. Escape, Command-K, an outside click, or switching away closes it and restores the pill. Closing while Conjure remains active restores launcher search focus; switching away does not steal focus back. Return in action search executes the highlighted action: Open launches the application, while Uninstall opens its review.
+13
View File
@@ -18,4 +18,17 @@ enum AppLauncher {
}
}
}
static func quit(_ url: URL) throws {
let path = url.standardizedFileURL.resolvingSymlinksInPath().path
for application in NSWorkspace.shared.runningApplications {
guard application.bundleURL?.standardizedFileURL.resolvingSymlinksInPath().path == path,
!application.isTerminated else { continue }
guard application.terminate() else {
throw NSError(domain: "Conjure", code: 1, userInfo: [
NSLocalizedDescriptionKey: "The application refused the quit request. Save your work and quit it manually."
])
}
}
}
}
@@ -6,9 +6,12 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
private let search = ActionsSearchField()
private let entries = [
ActionEntry(title: "Open", symbol: "arrow.up.forward.app", color: SunsetPalette.text),
ActionEntry(title: "Show in Finder", symbol: "folder", color: SunsetPalette.text),
ActionEntry(title: "Reset ranking", symbol: "arrow.counterclockwise", color: SunsetPalette.text),
ActionEntry(title: "Quit application", symbol: "stop.circle", color: SunsetPalette.text),
ActionEntry(title: "Uninstall application", symbol: "trash", color: .systemRed)
]
private var visibleEntries = [0, 1]
private var visibleEntries: [Int] = []
private var selectedEntry: Int? = 0
private var rowTopConstraints: [NSLayoutConstraint] = []
private var heightConstraint: NSLayoutConstraint?
@@ -21,6 +24,9 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
private var transitionGeneration = 0
private var panel: ActionsPanelView { view as! ActionsPanelView }
var open: (() -> Void)?
var showInFinder: (() -> Void)?
var resetRanking: (() -> Void)?
var quit: (() -> Void)?
var uninstall: (() -> Void)?
var closed: (() -> Void)?
var isShown: Bool { viewIfLoaded?.superview != nil && !isClosing }
@@ -88,7 +94,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
top
])
}
let height = view.heightAnchor.constraint(equalToConstant: 116 + LauncherRowView.rowHeight)
let height = view.heightAnchor.constraint(equalToConstant: 116 + CGFloat(entries.count - 1) * LauncherRowView.rowHeight)
heightConstraint = height
NSLayoutConstraint.activate([
view.widthAnchor.constraint(equalToConstant: 360), height,
@@ -234,7 +240,14 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
private func chooseAction(at index: Int) {
guard isShown, visibleEntries.contains(index) else { return }
close()
if index == 0 { open?() } else { uninstall?() }
switch index {
case 0: open?()
case 1: showInFinder?()
case 2: resetRanking?()
case 3: quit?()
case 4: uninstall?()
default: break
}
}
}
@@ -242,7 +255,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
private struct ActionEntry {
let view = LauncherRowView()
let button: NSButton
let returnKey = NSTextField(labelWithString: "↵")
let returnKey = ShortcutIndicator("↵")
init(title: String, symbol: String, color: NSColor) {
button = NSButton(title: title, target: nil, action: nil)
@@ -259,14 +272,6 @@ private struct ActionEntry {
icon.contentTintColor = color
icon.imageScaling = .scaleProportionallyDown
icon.setAccessibilityElement(false)
returnKey.font = .systemFont(ofSize: 14)
returnKey.textColor = SunsetPalette.secondaryText
returnKey.alignment = .center
returnKey.wantsLayer = true
returnKey.layer?.cornerRadius = 5
returnKey.layer?.borderWidth = 1
returnKey.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.35).cgColor
returnKey.setAccessibilityElement(false)
for child in [icon, button, returnKey] {
child.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(child)
@@ -281,9 +286,7 @@ private struct ActionEntry {
button.centerYAnchor.constraint(equalTo: view.centerYAnchor),
button.trailingAnchor.constraint(equalTo: returnKey.leadingAnchor, constant: -12),
returnKey.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -12),
returnKey.centerYAnchor.constraint(equalTo: view.centerYAnchor),
returnKey.widthAnchor.constraint(equalToConstant: 18),
returnKey.heightAnchor.constraint(equalToConstant: 18)
returnKey.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
}
+29 -32
View File
@@ -249,7 +249,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
emptyLabel.alignment = .center
openButton.isBordered = false
openButton.font = .systemFont(ofSize: 14, weight: .medium)
openButton.font = .systemFont(ofSize: 12, weight: .medium)
openButton.contentTintColor = SunsetPalette.text
openButton.keyEquivalent = "\r"
openButton.target = self
@@ -257,7 +257,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
openButton.isEnabled = false
actionsButton.isBordered = false
actionsButton.title = "Actions"
actionsButton.font = .systemFont(ofSize: 14, weight: .medium)
actionsButton.font = .systemFont(ofSize: 12, weight: .medium)
actionsButton.contentTintColor = SunsetPalette.text
actionsButton.keyEquivalent = "k"
actionsButton.keyEquivalentModifierMask = .command
@@ -267,9 +267,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
actionsButton.toolTip = "Actions for the selected application (Command-K)"
actionsButton.isEnabled = false
openButton.toolTip = "Open the selected application (Return). Use ↑ and ↓ to select."
let returnKey = shortcutBadge("↵")
let commandKey = shortcutBadge("⌘")
let kKey = shortcutBadge("K")
let returnKey = ShortcutIndicator("↵")
let actionKeys = ShortcutIndicator("⌘", "K")
let actionPill = NSVisualEffectView()
actionPill.material = .popover
@@ -277,7 +276,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
actionPill.state = .active
roundMaterialCorners(actionPill, radius: 18)
actionPill.layer?.borderWidth = 0.5
for view in [openButton, returnKey, actionsButton, commandKey, kKey] {
for view in [openButton, returnKey, actionsButton, actionKeys] {
view.translatesAutoresizingMaskIntoConstraints = false
actionPill.addSubview(view)
}
@@ -316,20 +315,15 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
openButton.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
returnKey.leadingAnchor.constraint(equalTo: openButton.trailingAnchor, constant: 8),
actionsButton.leadingAnchor.constraint(equalTo: returnKey.trailingAnchor, constant: 14),
commandKey.leadingAnchor.constraint(equalTo: actionsButton.trailingAnchor, constant: 8),
commandKey.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
kKey.leadingAnchor.constraint(equalTo: commandKey.trailingAnchor, constant: 8),
kKey.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
kKey.trailingAnchor.constraint(equalTo: actionPill.trailingAnchor, constant: -12),
actionKeys.leadingAnchor.constraint(equalTo: actionsButton.trailingAnchor, constant: 8),
actionKeys.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
actionKeys.trailingAnchor.constraint(equalTo: actionPill.trailingAnchor, constant: -12),
actionsButton.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
returnKey.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
returnKey.widthAnchor.constraint(equalToConstant: 18),
returnKey.heightAnchor.constraint(equalToConstant: 18),
])
window.effectiveAppearance.performAsCurrentDrawingAppearance {
let border = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor
content.layer?.borderColor = border
returnKey.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.35).cgColor
actionPill.layer?.borderColor = SunsetPalette.accent.withAlphaComponent(0.35).cgColor
actionPill.layer?.backgroundColor = SunsetPalette.accent.withAlphaComponent(0.08).cgColor
}
@@ -511,6 +505,16 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil, results.indices.contains(tableView.selectedRow) else { return }
let application = results[tableView.selectedRow]
actions.open = { [weak self] in self?.openApplication(application) }
actions.showInFinder = { [weak self] in
self?.dismiss(restoringFocus: false)
NSWorkspace.shared.activateFileViewerSelecting([application.url])
}
actions.resetRanking = { [weak self] in
guard let self else { return }
self.history.resetRanking(of: application)
self.updateResults(preservingSelection: true)
}
actions.quit = { [weak self] in self?.quitApplication(application) }
actions.uninstall = { [weak self] in self?.reviewUninstall(application) }
actions.show(for: application, relativeTo: actionsButton)
openButton.keyEquivalent = actions.isShown ? "" : "\r"
@@ -582,6 +586,17 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
openApplication(results[tableView.selectedRow])
}
private func quitApplication(_ application: Application) {
do {
try AppLauncher.quit(application.url)
dismiss()
} catch {
let alert = NSAlert(error: error)
alert.messageText = "Couldn’t quit \(application.name)"
if let window { alert.beginSheetModal(for: window) }
}
}
private func openApplication(_ application: Application) {
guard !isLaunching else { return }
isLaunching = true
@@ -700,21 +715,3 @@ func roundMaterialCorners(_ view: NSVisualEffectView, radius: CGFloat) {
view.maskImage = mask
}
@MainActor
func shortcutBadge(_ title: String) -> NSTextField {
let badge = NSTextField(labelWithString: title)
badge.font = .systemFont(ofSize: 14)
badge.textColor = SunsetPalette.secondaryText
badge.alignment = .center
badge.wantsLayer = true
badge.layer?.cornerRadius = 5
badge.layer?.borderWidth = 1
badge.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.35).cgColor
badge.setAccessibilityElement(false)
badge.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
badge.widthAnchor.constraint(equalToConstant: 18),
badge.heightAnchor.constraint(equalToConstant: 18)
])
return badge
}
+55
View File
@@ -0,0 +1,55 @@
import AppKit
import CoreText
@MainActor
final class ShortcutIndicator: NSStackView {
init(_ keys: String...) {
super.init(frame: .zero)
orientation = .horizontal
alignment = .centerY
spacing = 2
setHuggingPriority(.required, for: .horizontal)
setHuggingPriority(.required, for: .vertical)
translatesAutoresizingMaskIntoConstraints = false
setAccessibilityElement(false)
for key in keys {
let badge = ShortcutKeyBadge(labelWithString: key)
badge.font = .systemFont(ofSize: 12, weight: .medium)
badge.textColor = SunsetPalette.secondaryText
badge.alignment = .center
badge.wantsLayer = true
badge.layer?.cornerRadius = 5
badge.layer?.borderWidth = 1
badge.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.35).cgColor
badge.setAccessibilityElement(false)
badge.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
badge.widthAnchor.constraint(equalToConstant: 18),
badge.heightAnchor.constraint(equalToConstant: 18)
])
addArrangedSubview(badge)
}
}
required init?(coder: NSCoder) { fatalError("Conjure creates shortcut indicators programmatically") }
}
@MainActor
private final class ShortcutKeyBadge: NSTextField {
// NSTextField's horizontal alignment insets otherwise make an 18-point badge 22 points wide.
override var alignmentRectInsets: NSEdgeInsets { NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) }
override func draw(_ dirtyRect: NSRect) {
guard let context = NSGraphicsContext.current?.cgContext else { return }
let line = CTLineCreateWithAttributedString(attributedStringValue)
let ink = CTLineGetBoundsWithOptions(line, .useGlyphPathBounds)
context.saveGState()
context.textMatrix = .identity
// Center visible ink, not the font's asymmetric ascender/descender line box.
context.translateBy(x: bounds.midX - ink.midX, y: bounds.midY + ink.midY)
context.scaleBy(x: 1, y: -1)
context.textPosition = .zero
CTLineDraw(line, context)
context.restoreGState()
}
}
@@ -118,9 +118,8 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
pill.layer?.borderWidth = 0.5
pill.layer?.borderColor = SunsetPalette.accent.withAlphaComponent(0.35).cgColor
pill.layer?.backgroundColor = SunsetPalette.accent.withAlphaComponent(0.08).cgColor
let commandKey = shortcutBadge("⌘")
let returnKey = shortcutBadge("↵")
for child in [remove, commandKey, returnKey] {
let shortcut = ShortcutIndicator("⌘", "↵")
for child in [remove, shortcut] {
child.translatesAutoresizingMaskIntoConstraints = false
pill.addSubview(child)
}
@@ -128,11 +127,9 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
pill.heightAnchor.constraint(equalToConstant: 36),
remove.leadingAnchor.constraint(equalTo: pill.leadingAnchor, constant: 14),
remove.centerYAnchor.constraint(equalTo: pill.centerYAnchor),
commandKey.leadingAnchor.constraint(equalTo: remove.trailingAnchor, constant: 8),
commandKey.centerYAnchor.constraint(equalTo: pill.centerYAnchor),
returnKey.leadingAnchor.constraint(equalTo: commandKey.trailingAnchor, constant: 8),
returnKey.centerYAnchor.constraint(equalTo: pill.centerYAnchor),
returnKey.trailingAnchor.constraint(equalTo: pill.trailingAnchor, constant: -12)
shortcut.leadingAnchor.constraint(equalTo: remove.trailingAnchor, constant: 8),
shortcut.centerYAnchor.constraint(equalTo: pill.centerYAnchor),
shortcut.trailingAnchor.constraint(equalTo: pill.trailingAnchor, constant: -12)
])
let footer = NSStackView(views: [NSView(), pill])
footer.orientation = .horizontal
+9
View File
@@ -31,6 +31,15 @@ public final class LaunchHistory {
if record.count < Int.max { record.count += 1 }
record.lastLaunch = timestamp
usage[application.id] = record
save()
}
public func resetRanking(of application: Application) {
usage.removeValue(forKey: application.id)
save()
}
private func save() {
var saved: [String: [String: Any]] = [:]
saved.reserveCapacity(usage.count)
for (url, record) in usage {
+7
View File
@@ -0,0 +1,7 @@
- [x] make the actions indicator font size smaller.
- [x] decrease the distance between the icons and their text by 1/3.
- [x] add more actions to the actions selector:
- show in finder
- reset ranking
- quit application
- [ ] add unit conversion and calculator behavior like raylib has.
@@ -109,6 +109,32 @@ func typedSearchUsesHistoryOnlyForEqualTextRanks() {
])
}
@Test @MainActor
func resetRankingClearsOnlySelectedIdentityAndPersistsAcrossRelaunch() {
let suite = "ConjureHistoryTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let instant = Date(timeIntervalSinceReferenceDate: 6_000)
let history = LaunchHistory(defaults: defaults, now: { instant })
let alpha = historyApplication("Editor Alpha")
let first = Application(url: URL(fileURLWithPath: "/First/Editor.app"), name: "Editor")
let second = Application(url: URL(fileURLWithPath: "/Second/Editor.app"), name: "Editor")
let apps = [alpha, first, second]
history.recordLaunch(of: second)
history.recordLaunch(of: first)
history.recordLaunch(of: first)
#expect(history.suggestions(in: apps).map(\.id) == [first.id, second.id, alpha.id])
history.resetRanking(of: first)
history.resetRanking(of: first)
#expect(history.suggestions(in: apps).map(\.id) == [second.id, first.id, alpha.id])
#expect(history.results(for: "editor", in: apps).map(\.id) == [second.id, first.id, alpha.id])
let reloaded = LaunchHistory(defaults: UserDefaults(suiteName: suite)!, now: { instant })
#expect(reloaded.suggestions(in: apps).map(\.id) == [second.id, first.id, alpha.id])
reloaded.recordLaunch(of: first)
#expect(reloaded.suggestions(in: apps).map(\.id) == [first.id, second.id, alpha.id])
}
private func historyApplication(_ name: String) -> Application {
Application(url: URL(fileURLWithPath: "/Applications/HistoryTests/\(name).app"), name: name)
}