mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-05-25 06:18:37 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user