Skip to content
This repository was archived by the owner on Oct 17, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ All notable changes to this project are documented here. The format follows [Kee
- A result chosen for a query also leans shorter and longer starts of that query its way, so you can type less next time.
- Unit conversions such as "10 km to mi", "70 f in c", or "5 GB in MB", offline, with Return to copy.
- Moving the pointer over a result selects it, and apps, files, and folders can be dragged out of the panel.
- A macOS 26 look: a glass capsule for the field and a glass card for results, grouped into Top Hit, Applications, System Settings, Files, and Search the Web. A short fade on opening, none with Reduce Motion.
- "No Results" when nothing matches and web search is off.

### Changed

- Rows say less: no "Application" label, a pane's name instead of "System Settings ›", and file paths keep their start and end when shortened.
- System Settings topics match only where each typed word starts a word of the topic, and keywords only from the fourth letter, so a short query finds apps instead of a dozen loosely related settings.

### Fixed

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@ Conventional Commits: `type(scope): subject`. Write the subject in the imperativ
- The panel is a non-activating `NSPanel`. Don't call `NSApp.activate()` when showing it; the user's app must stay frontmost.
- A bug fix comes with the test that would have caught it. Ranking changes need a test showing the order they fix.
- `CommonTools.swift` is shared word for word across the Common Tools apps. Change it in all of them together.
- For visual review, use `swift build && .build/debug/CommonCommand --snapshot out.png "query" [--theme light|dark]` and inspect `out.png`.
- For visual review, use `swift build && .build/debug/CommonCommand --snapshot out.png "query" [--theme light|dark]` and inspect `out.png`. It renders offscreen; never drive the live screen with synthesized keys or the global shortcut to check a design.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ A small, fast launcher for macOS. Press ⌥Space, type a few letters, press Retu

Common Command is built to feel like part of macOS:

- **It matches your Mac.** The panel follows light and dark mode, uses Liquid Glass on macOS 26 (the popover material before that), and highlights with your accent colour. Choose a fixed theme, a solid background, or a size in Settings.
- **It looks like macOS 26.** A Liquid Glass capsule for the search field and a glass card for the results under it, with Spotlight's sections: the Top Hit, then Applications, System Settings, Files, and the web. It follows light and dark mode and highlights with your accent colour; earlier macOS versions get the popover material. Choose a fixed theme, a solid background, or a size in Settings.
- **It stays out of the way.** The panel never activates the app, so the app you were in stays frontmost and keeps its menu bar. Escape, or a click anywhere else, returns you to it with nothing to restore.
- **It learns.** Whatever you choose for a query comes first the next time you type it, and leans the same way for a shorter start: choose Safari for "safari" once, and "sa" prefers it too. Things you open often rise for every query. Usage decays with a one-week half-life, so last year's habits fade.
- **Typing is forgiving.** "vsc" finds Visual Studio Code, "sys set" finds System Settings, "cafe" finds Café. Matched letters are drawn in bold.
Expand Down Expand Up @@ -102,7 +102,7 @@ scripts/agent doctor # check tools and repository state

See [CONTRIBUTING.md](CONTRIBUTING.md) for the workflow.

`swift build && .build/debug/CommonCommand --snapshot out.png "query" --theme dark` opens the real panel with the query typed, captures it, and quits. Use it to review design changes. It uses its own settings, so it never changes yours.
`swift build && .build/debug/CommonCommand --snapshot out.png "query" --theme dark` draws the panel with the query typed into `out.png` and quits. It renders offscreen, so nothing appears on screen and nothing takes focus; glass shows as a solid fill. It uses its own settings, so it never changes yours.

Layout:

Expand Down
9 changes: 5 additions & 4 deletions Sources/CommandKit/Catalogs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public enum AppCatalog {
let fileName = url.deletingPathExtension().lastPathComponent
let name = displayName(url)
let item = Item(
id: "app:" + resolved, title: name, subtitle: "Application", kind: .application,
id: "app:" + resolved, title: name, subtitle: "", kind: .application,
action: .open(url))
candidates.append(Candidate(item, alternatives: [fileName], weight: weight))
}
Expand Down Expand Up @@ -137,13 +137,14 @@ public enum SettingsPanes {
guard seenTitles.insert(key).inserted else { continue }
let item = Item(
id: "pane:\(identifier)#\(topic.title)", title: topic.title,
subtitle: name.map { "System Settings › \($0)" } ?? "System Settings", kind: .settings,
subtitle: name ?? "", kind: .settings,
action: .open(target), iconFile: icon)
result.append(Candidate(item, keywords: topic.keywords, weight: topicWeight))
// Hundreds of long topic names would match almost any three letters as initials.
result.append(Candidate(item, keywords: topic.keywords, weight: topicWeight, matchesWordStarts: true))
}
if let name {
let item = Item(
id: "pane:" + identifier, title: name, subtitle: "System Settings", kind: .settings,
id: "pane:" + identifier, title: name, subtitle: "", kind: .settings,
action: .open(target), iconFile: icon)
result.insert(Candidate(item, keywords: paneKeywords), at: 0)
}
Expand Down
39 changes: 39 additions & 0 deletions Sources/CommandKit/Fuzzy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,47 @@ public enum Fuzzy {
return Match(score: score, positions: positions)
}

/// A stricter match for long, numerous names such as System Settings topics: each typed word must start
/// a word of the name, in order. "low power" finds "Low Power Mode"; "saf" doesn't find "Screen area
/// for display", which as initials would. Scored like a run of word-start matches.
public static func wordPrefixMatch(_ words: [[UInt32]], in text: SearchText) -> Match? {
guard !words.isEmpty else { return nil }
let t = text.keys
var cursor = 0
var positions: [Int] = []
for word in words {
var found: Int?
var start = cursor
while start + word.count <= t.count {
if text.bonus[start] > 0, t[start..<(start + word.count)].elementsEqual(word) {
found = start
break
}
start += 1
}
guard let found else { return nil }
positions += Array(found..<(found + word.count))
cursor = found + word.count
}
let m = positions.count
let starts = words.count
var score = matchScore * m + consecutiveBonus * (m - starts) + 9 * starts - (t.count - m) / 4
if positions.first == 0 { score += 1 }
if m == t.count { score += exactBonus }
return Match(score: score, positions: positions)
}

/// The query's words, folded, for `wordPrefixMatch`.
public static func prepareWords(_ query: String) -> [[UInt32]] {
query.split(whereSeparator: \.isWhitespace).map { $0.map(fold) }
}

static let minimumKeywordLength = 4

/// A keyword the query starts: below a name that starts the same way, above a scattered name match.
static func keywordScore(_ q: [UInt32], _ keywords: [[UInt32]]) -> Int? {
// Three letters are nearly always the start of an app's name, and hundreds of keywords would match.
guard q.count >= minimumKeywordLength else { return nil }
var best: Int?
for keyword in keywords where keyword.count >= q.count && keyword.starts(with: q) {
let score = 12 * q.count + 8 + (keyword.count == q.count ? 10 : 0)
Expand Down
9 changes: 8 additions & 1 deletion Sources/CommandKit/Item.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,15 @@ public struct Candidate: Sendable {
/// clearly closer name still wins: "Bluetooth" the pane over "Bluetooth File Exchange" the app.
public let weight: Int

public init(_ item: Item, alternatives: [String] = [], keywords: [String] = [], weight: Int = 0) {
/// Matches only where each typed word starts a word of a name, never as scattered letters or initials.
let matchesWordStarts: Bool

public init(
_ item: Item, alternatives: [String] = [], keywords: [String] = [], weight: Int = 0,
matchesWordStarts: Bool = false
) {
self.item = item
self.matchesWordStarts = matchesWordStarts
var seen: Set<String> = [item.title]
names = [SearchText(item.title)] + alternatives.filter { seen.insert($0).inserted }.map(SearchText.init)
// Each keyword whole, spaces dropped, and each of its words, so "darkm" and "mode" both find "Dark Mode".
Expand Down
40 changes: 28 additions & 12 deletions Sources/CommandKit/Layout.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import CoreGraphics

/// The panel's dimensions. Every size is a little smaller than Spotlight's: one line of type and a short
/// list, not a window.
/// The panel's dimensions: a capsule search field and, under it, a card of results. Every size is a little
/// smaller than Spotlight's: one line of type and a short list, not a window.
public enum PanelSize: String, CaseIterable, Sendable, Identifiable {
case small, medium, large

Expand All @@ -17,9 +17,9 @@ public enum PanelSize: String, CaseIterable, Sendable, Identifiable {

public var fieldHeight: CGFloat {
switch self {
case .small: 42
case .medium: 48
case .large: 54
case .small: 44
case .medium: 50
case .large: 56
}
}

Expand Down Expand Up @@ -47,14 +47,30 @@ public enum PanelSize: String, CaseIterable, Sendable, Identifiable {
}
}

public var iconSize: CGFloat { rowHeight - 12 }
public var cornerRadius: CGFloat { fieldHeight / 2.6 }
/// Space around the list, so the selection's rounded corners don't touch the panel's edge.
public var listPadding: CGFloat { 6 }
public var headerHeight: CGFloat { rowHeight * 0.75 }
public var headerFontSize: CGFloat { rowFontSize - 3 }
public var iconSize: CGFloat { rowHeight - 10 }
/// The field is a capsule, as on macOS 26.
public var fieldRadius: CGFloat { fieldHeight / 2 }
public var cardRadius: CGFloat { rowHeight * 0.6 }
public var rowRadius: CGFloat { cardRadius - cardPadding }
/// Space inside the card, so the selection's rounded corners sit inside the card's.
public var cardPadding: CGFloat { 7 }
/// Between the field and the card: two pieces of glass, not one box.
public var gap: CGFloat { 8 }

/// The panel's height with `rows` results, of which at most `visible` show before the list scrolls.
public func height(rows: Int, visible: Int) -> CGFloat {
/// The card's height for `rows` results under `headers` section headers, scrolling past `visible` rows.
public func cardHeight(rows: Int, headers: Int, visible: Int) -> CGFloat {
guard rows > 0 else { return 0 }
let content = CGFloat(rows) * rowHeight + CGFloat(headers) * headerHeight
// Room for the visible rows and a couple of headers; past that the list scrolls.
let cap = CGFloat(max(visible, 1)) * rowHeight + 2 * headerHeight
return min(content, cap) + 2 * cardPadding
}

/// The whole panel: the field alone, or the field, the gap, and the card.
public func height(rows: Int, headers: Int, visible: Int) -> CGFloat {
guard rows > 0 else { return fieldHeight }
return fieldHeight + 1 + CGFloat(min(rows, max(visible, 1))) * rowHeight + 2 * listPadding
return fieldHeight + gap + cardHeight(rows: rows, headers: headers, visible: visible)
}
}
5 changes: 4 additions & 1 deletion Sources/CommandKit/Ranker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public enum Ranker {
) -> [Ranked] {
let q = Fuzzy.prepare(query)
guard !q.isEmpty, limit > 0 else { return [] }
let words = Fuzzy.prepareWords(query)
let booster = usage.booster(for: query, at: now)
// Only the best few are ever shown, so keep those in order instead of sorting every match.
var top: [(ranked: Ranked, name: [UInt32])] = []
Expand All @@ -30,7 +31,9 @@ public enum Ranker {
var best: Match?
var bestIsTitle = false
for (index, name) in candidate.names.enumerated() {
guard let match = Fuzzy.match(q, in: name), match.score > (best?.score ?? .min) else { continue }
let found =
candidate.matchesWordStarts ? Fuzzy.wordPrefixMatch(words, in: name) : Fuzzy.match(q, in: name)
guard let match = found, match.score > (best?.score ?? .min) else { continue }
best = match
bestIsTitle = index == 0
}
Expand Down
99 changes: 82 additions & 17 deletions Sources/CommandKit/Results.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,46 +12,111 @@ public struct SearchOptions: Sendable, Equatable {
public init() {}
}

/// Puts one query's results in order: a web address or calculation first, since typing one is
/// unambiguous, then apps and settings, then files, then the web search that's always there.
/// A run of results under one header, as Spotlight groups them.
public struct ResultSection: Identifiable, Sendable {
public let title: String
public let rows: [Ranked]

public var id: String { title }

public init(title: String, rows: [Ranked]) {
self.title = title
self.rows = rows
}
}

/// Puts one query's results in order and into sections. A web address or a calculation comes first, since
/// typing one is unambiguous. Then the best match on its own as the Top Hit, then the rest grouped by
/// kind, each group placed by its best match, and last the web search that's always there.
public enum Results {
public static func compose(
query: String, candidates: [Candidate], files: [Candidate], usage: Usage, now: Date,
options: SearchOptions
) -> [Ranked] {
) -> [ResultSection] {
let text = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return [] }
var results: [Ranked] = []
var sections: [ResultSection] = []

if let url = Web.address(text) {
let item = Item(
id: "link:" + url.absoluteString, title: url.absoluteString, subtitle: "Open Link", kind: .link,
id: "link:" + url.absoluteString, title: url.absoluteString, subtitle: "Open", kind: .link,
action: .open(url))
results.append(Ranked(item: item, score: .max))
sections.append(ResultSection(title: "Website", rows: [Ranked(item: item, score: .max)]))
}
if options.calculator, let conversion = Converter.convert(text, locale: options.locale) {
let digits = Converter.significantDigits
let shown = Calculator.format(conversion.value, locale: options.locale, digits: digits)
let copied = Calculator.format(conversion.value, locale: options.locale, grouping: false, digits: digits)
let item = Item(
id: "convert", title: "= \(shown) \(conversion.unit)", subtitle: "Copy", kind: .calculation,
id: "convert", title: "\(shown) \(conversion.unit)", subtitle: "Copy", kind: .calculation,
action: .copy(copied))
results.append(Ranked(item: item, score: .max))
sections.append(ResultSection(title: "Conversion", rows: [Ranked(item: item, score: .max)]))
} else if options.calculator, let value = Calculator.evaluate(text, locale: options.locale) {
let shown = Calculator.format(value, locale: options.locale)
let copied = Calculator.format(value, locale: options.locale, grouping: false)
let item = Item(
id: "calc", title: "= " + shown, subtitle: "Copy", kind: .calculation, action: .copy(copied))
results.append(Ranked(item: item, score: .max))
let item = Item(id: "calc", title: shown, subtitle: "Copy", kind: .calculation, action: .copy(copied))
sections.append(ResultSection(title: "Calculator", rows: [Ranked(item: item, score: .max)]))
}
results += Ranker.rank(candidates, query: text, usage: usage, now: now, limit: options.limit)
results += Ranker.rank(files, query: text, usage: usage, now: now, limit: options.fileLimit)

let ranked = merge(
Ranker.rank(candidates, query: text, usage: usage, now: now, limit: options.limit),
Ranker.rank(files, query: text, usage: usage, now: now, limit: options.fileLimit))
sections += group(ranked)

if options.webSearch, let url = options.engine.url(searching: text) {
let item = Item(
id: "web", title: text, subtitle: "Search \(options.engine.name)", kind: .webSearch,
action: .open(url))
results.append(Ranked(item: item, score: .min))
id: "web", title: text, subtitle: options.engine.name, kind: .webSearch, action: .open(url))
sections.append(ResultSection(title: "Search the Web", rows: [Ranked(item: item, score: .min)]))
}
return sections
}

/// Two best-first lists as one, best first; on a tie the first list's entry leads.
static func merge(_ a: [Ranked], _ b: [Ranked]) -> [Ranked] {
var merged: [Ranked] = []
merged.reserveCapacity(a.count + b.count)
var i = 0
var j = 0
while i < a.count || j < b.count {
if j == b.count || (i < a.count && a[i].score >= b[j].score) {
merged.append(a[i])
i += 1
} else {
merged.append(b[j])
j += 1
}
}
return merged
}

/// The best result as the Top Hit, then the others under their kind's header, groups in order of
/// their best member.
static func group(_ ranked: [Ranked]) -> [ResultSection] {
guard let top = ranked.first else { return [] }
var order: [String] = []
var rows: [String: [Ranked]] = [:]
for result in ranked.dropFirst() {
let title = header(for: result.item.kind)
if rows[title] == nil { order.append(title) }
rows[title, default: []].append(result)
}
return results
return [ResultSection(title: "Top Hit", rows: [top])] + order.map { ResultSection(title: $0, rows: rows[$0]!) }
}

static func header(for kind: Kind) -> String {
switch kind {
case .application: "Applications"
case .settings: "System Settings"
case .command: "Common Command"
case .file, .folder: "Files"
case .calculation: "Calculator"
case .link: "Website"
case .webSearch: "Search the Web"
}
}
}

extension [ResultSection] {
/// Every row in display order: what the selection steps through.
public var rows: [Ranked] { flatMap(\.rows) }
}
4 changes: 2 additions & 2 deletions Sources/CommonCommand/AppController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ final class AppController: NSObject {
library.commands = [
Candidate(
Item(
id: "command:settings", title: "Common Command Settings", subtitle: "Command", kind: .command,
id: "command:settings", title: "Common Command Settings", subtitle: "", kind: .command,
action: .command("settings")), alternatives: ["Preferences"]),
Candidate(
Item(
id: "command:quit", title: "Quit Common Command", subtitle: "Command", kind: .command,
id: "command:quit", title: "Quit Common Command", subtitle: "", kind: .command,
action: .command("quit"))),
]
library.refreshIfStale()
Expand Down
Loading
Loading