@@ -70,25 +70,13 @@ struct CommandPaletteView: View {
7070 }
7171
7272 // The options that we should show, taking into account any filtering from
73- // the query. Options with matching leadingColor are ranked higher.
73+ // the query. Matched options are ranked in the following order:
74+ // leadingColor > title > subtitle > description.
7475 var filteredOptions : [ CommandOption ] {
7576 if query. isEmpty {
7677 return options
7778 } else {
78- // Filter by title/subtitle match OR color match
79- let filtered = options. filter {
80- $0. title. matchedIndices ( for: query) != nil ||
81- ( $0. subtitle? . matchedIndices ( for: query) != nil ) ||
82- ( $0. description? . matchedIndices ( for: query) != nil ) ||
83- colorMatchScore ( for: $0. leadingColor, query: query) > 0
84- }
85-
86- // Sort by color match score (higher scores first), then maintain original order
87- return filtered. sorted { a, b in
88- let scoreA = colorMatchScore ( for: a. leadingColor, query: query)
89- let scoreB = colorMatchScore ( for: b. leadingColor, query: query)
90- return scoreA > scoreB
91- }
79+ return options. filteredAndSorted ( query: query)
9280 }
9381 }
9482
@@ -161,7 +149,7 @@ struct CommandPaletteView: View {
161149 hoveredOptionID: $hoveredOptionID) { option in
162150 isPresented = false
163151 option. action ( )
164- }
152+ }
165153 }
166154 . frame ( maxWidth: 500 )
167155 . background (
@@ -192,31 +180,6 @@ struct CommandPaletteView: View {
192180 }
193181 }
194182
195- /// Returns a score (0.0 to 1.0) indicating how well a color matches a search query color name.
196- /// Returns 0 if no color name in the query matches, or if the color is nil.
197- private func colorMatchScore( for color: Color ? , query: String ) -> Double {
198- guard let color = color else { return 0 }
199-
200- let queryLower = query. lowercased ( )
201- let nsColor = NSColor ( color)
202-
203- var bestScore : Double = 0
204- for name in NSColor . colorNames {
205- guard queryLower. contains ( name) ,
206- let systemColor = NSColor ( named: name) else { continue }
207-
208- let distance = nsColor. distance ( to: systemColor)
209- // Max distance in weighted RGB space is ~3.0, so normalize and invert
210- // Use a threshold to determine "close enough" matches
211- let maxDistance : Double = 1.5
212- if distance < maxDistance {
213- let score = 1.0 - ( distance / maxDistance)
214- bestScore = max ( bestScore, score)
215- }
216- }
217-
218- return bestScore
219- }
220183}
221184
222185/// The text field for building the query for the command palette.
@@ -493,3 +456,74 @@ extension String {
493456 return queryIndex == query. endIndex ? matched : nil
494457 }
495458}
459+
460+ // MARK: - Match score
461+
462+ extension Collection where Element == CommandOption {
463+ /// Filters to the options matching `query` and ranks them best-first while
464+ /// maintaining original order: a closer leading color match always wins,
465+ /// then a title match beats a subtitle match beats a description match.
466+ func filteredAndSorted( query: String ) -> [ Element ] {
467+ compactMap { CommandOptionMatch ( option: $0, query: query) }
468+ . sorted {
469+ ( $0. colorScore, $0. textScore) > ( $1. colorScore, $1. textScore)
470+ }
471+ . map ( \. option)
472+ }
473+ }
474+
475+ /// A scored match of a command option against a palette query.
476+ struct CommandOptionMatch {
477+ let option : CommandOption
478+ /// How closely the option's leading color matches a color name in the
479+ /// query, from 0 (no match) to 1 (exact).
480+ let colorScore : Double
481+ /// Which text field matched, ranked: title (3), subtitle (2),
482+ /// description (1), none (0).
483+ let textScore : Int
484+
485+ /// Returns nil if the option doesn't match the query at all.
486+ init ? ( option: CommandOption , query: String ) {
487+ let colorScore = Self . colorMatchScore ( for: option. leadingColor, query: query)
488+ let textScore : Int = if option. title. matchedIndices ( for: query) != nil {
489+ 3
490+ } else if option. subtitle? . matchedIndices ( for: query) != nil {
491+ 2
492+ } else if option. description? . matchedIndices ( for: query) != nil {
493+ 1
494+ } else {
495+ 0
496+ }
497+
498+ guard colorScore > 0 || textScore > 0 else { return nil }
499+ self . option = option
500+ self . colorScore = colorScore
501+ self . textScore = textScore
502+ }
503+
504+ /// Returns a score (0.0 to 1.0) indicating how well a color matches a search query color name.
505+ /// Returns 0 if no color name in the query matches, or if the color is nil.
506+ static func colorMatchScore( for color: Color ? , query: String ) -> Double {
507+ guard let color = color else { return 0 }
508+
509+ let queryLower = query. lowercased ( )
510+ let nsColor = NSColor ( color)
511+
512+ var bestScore : Double = 0
513+ for name in NSColor . colorNames {
514+ guard queryLower. contains ( name) ,
515+ let systemColor = NSColor ( named: name) else { continue }
516+
517+ let distance = nsColor. distance ( to: systemColor)
518+ // Max distance in weighted RGB space is ~3.0, so normalize and invert
519+ // Use a threshold to determine "close enough" matches
520+ let maxDistance : Double = 1.5
521+ if distance < maxDistance {
522+ let score = 1.0 - ( distance / maxDistance)
523+ bestScore = max ( bestScore, score)
524+ }
525+ }
526+
527+ return bestScore
528+ }
529+ }
0 commit comments