macOS: Command palette highlight matches (#12264)

- Highlight matching text in command palette search results
- Support initials matching
- Trim query before filtering commands

### AI Disclosure

Claude wrote most of it. I tested and reviewed it myself.

<img width="1544" height="297" alt="image"
src="https://github.com/user-attachments/assets/6ed98538-d6d3-48a0-8bb0-ac705611d058"
/>
This commit is contained in:
Mitchell Hashimoto
2026-04-13 06:44:27 -07:00
committed by GitHub

View File

@@ -61,10 +61,14 @@ struct CommandPaletteView: View {
@Binding var isPresented: Bool
var backgroundColor: Color = Color(nsColor: .windowBackgroundColor)
var options: [CommandOption]
@State private var query = ""
@State private var rawQuery = ""
@State private var selectedIndex: UInt?
@State private var hoveredOptionID: UUID?
var query: String {
rawQuery.trimmingCharacters(in: .whitespacesAndNewlines)
}
// The options that we should show, taking into account any filtering from
// the query. Options with matching leadingColor are ranked higher.
var filteredOptions: [CommandOption] {
@@ -73,8 +77,8 @@ struct CommandPaletteView: View {
} else {
// Filter by title/subtitle match OR color match
let filtered = options.filter {
$0.title.localizedCaseInsensitiveContains(query) ||
($0.subtitle?.localizedCaseInsensitiveContains(query) ?? false) ||
$0.title.matchedIndices(for: query) != nil ||
($0.subtitle?.matchedIndices(for: query) != nil) ||
colorMatchScore(for: $0.leadingColor, query: query) > 0
}
@@ -104,7 +108,7 @@ struct CommandPaletteView: View {
}
VStack(alignment: .leading, spacing: 0) {
CommandPaletteQuery(query: $query) { event in
CommandPaletteQuery(query: $rawQuery) { event in
switch event {
case .exit:
isPresented = false
@@ -151,6 +155,7 @@ struct CommandPaletteView: View {
CommandTable(
options: filteredOptions,
query: query,
selectedIndex: $selectedIndex,
hoveredOptionID: $hoveredOptionID) { option in
isPresented = false
@@ -181,7 +186,7 @@ struct CommandPaletteView: View {
// This is optional, since most of the time
// there will be a delay before the next use.
// To keep behavior the same as before, we reset it.
query = ""
rawQuery = ""
}
}
}
@@ -281,6 +286,7 @@ private struct CommandPaletteQuery: View {
private struct CommandTable: View {
var options: [CommandOption]
var query: String
@Binding var selectedIndex: UInt?
@Binding var hoveredOptionID: UUID?
var action: (CommandOption) -> Void
@@ -297,6 +303,7 @@ private struct CommandTable: View {
ForEach(Array(options.enumerated()), id: \.1.id) { index, option in
CommandRow(
option: option,
query: query,
isSelected: {
if let selected = selectedIndex {
return selected == index ||
@@ -329,10 +336,53 @@ private struct CommandTable: View {
/// A single row in the command palette.
private struct CommandRow: View {
let option: CommandOption
var query: String
var isSelected: Bool
@Binding var hoveredID: UUID?
var action: () -> Void
private var highlightedTitle: Text {
guard !query.isEmpty,
let indices = option.title.matchedIndices(for: query) else {
return Text(option.title)
.fontWeight(option.emphasis ? .medium : .regular)
}
var attributed = AttributedString(option.title)
attributed[attributed.startIndex...].font = .body
.weight(option.emphasis ? .medium : .regular)
for idx in indices {
let offset = option.title.distance(from: option.title.startIndex, to: idx)
let attrStart = attributed.index(attributed.startIndex, offsetByCharacters: offset)
let attrEnd = attributed.index(attrStart, offsetByCharacters: 1)
attributed[attrStart..<attrEnd].font = .body.bold()
attributed[attrStart..<attrEnd].foregroundColor = Color.accentColor
}
return Text(attributed)
}
private func highlightedSubtitle(_ subtitle: String) -> Text {
guard !query.isEmpty,
option.title.matchedIndices(for: query) == nil,
let indices = subtitle.matchedIndices(for: query) else {
return Text(subtitle)
}
var attributed = AttributedString(subtitle)
for idx in indices {
let offset = subtitle.distance(from: subtitle.startIndex, to: idx)
let attrStart = attributed.index(attributed.startIndex, offsetByCharacters: offset)
let attrEnd = attributed.index(attrStart, offsetByCharacters: 1)
attributed[attrStart..<attrEnd].font = .caption.bold()
attributed[attrStart..<attrEnd].foregroundColor = Color.accentColor
}
return Text(attributed)
}
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
@@ -349,11 +399,10 @@ private struct CommandRow: View {
}
VStack(alignment: .leading, spacing: 2) {
Text(option.title)
.fontWeight(option.emphasis ? .medium : .regular)
highlightedTitle
if let subtitle = option.subtitle {
Text(subtitle)
highlightedSubtitle(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -413,3 +462,33 @@ private struct ShortcutSymbolsView: View {
}
}
}
extension String {
/// Returns the character indices that match `query`, trying a substring match first,
/// then falling back to initials matching (first letter of each word).
/// - Returns: `nil` if neither matches.
func matchedIndices(for query: String) -> [String.Index]? {
guard !query.isEmpty else { return nil }
// Prefer substring match.
if let range = self.range(of: query, options: .caseInsensitive) {
return Array(self[range].indices)
}
// Fall back to initials match.
let words = self.split(whereSeparator: \.isWhitespace)
var queryIndex = query.startIndex
var matched: [String.Index] = []
for word in words {
guard queryIndex < query.endIndex else { break }
if word.first?.lowercased() == query[queryIndex].lowercased() {
matched.append(word.startIndex)
queryIndex = query.index(after: queryIndex)
}
}
return queryIndex == query.endIndex ? matched : nil
}
}