diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index c4ba7106c..de440fdc3 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -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.. 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.. [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 + } +}