Skip to content

Commit e886012

Browse files
authored
macOS: update command options match order (ghostty-org#13624)
Matches are sorted in the following order: leadingColor > title > subtitle > description. Ranking is lexicographic on (colorScore, textScore) <img height="300" alt="image" src="https://github.com/user-attachments/assets/1ec99e67-537e-4fc6-b595-d7eec8cbf31d" /> ### AI Disclosure Claude reviewed and added unit tests, also did some refactoring of my original implementation.
2 parents 9cb2147 + d02ad96 commit e886012

2 files changed

Lines changed: 154 additions & 41 deletions

File tree

macos/Sources/Features/Command Palette/CommandPalette.swift

Lines changed: 75 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
//
2+
// CommandPaletteTests.swift
3+
// GhosttyTests
4+
//
5+
// Tests for command palette query filtering and match ranking.
6+
//
7+
8+
import Testing
9+
import SwiftUI
10+
@testable import Ghostty
11+
12+
struct CommandPaletteFilterTests {
13+
private func option(
14+
title: String,
15+
subtitle: String? = nil,
16+
description: String? = nil,
17+
leadingColor: Color? = nil
18+
) -> CommandOption {
19+
CommandOption(
20+
title: title,
21+
subtitle: subtitle,
22+
description: description,
23+
leadingColor: leadingColor
24+
) {}
25+
}
26+
27+
/// Title matches outrank subtitle matches, which outrank description
28+
/// matches. Options that don't match at all are dropped.
29+
@Test func textMatchTiers() {
30+
let byDescription = option(title: "Alpha", description: "make it fast")
31+
let bySubtitle = option(title: "Beta", subtitle: "fast scrolling")
32+
let byTitle = option(title: "Fast Redraw")
33+
let noMatch = option(title: "Quit")
34+
35+
let results = [noMatch, byDescription, bySubtitle, byTitle]
36+
.filteredAndSorted(query: "fast")
37+
38+
#expect(results == [byTitle, bySubtitle, byDescription])
39+
}
40+
41+
/// A strong color match outranks any text match.
42+
@Test func colorMatchOutranksTextMatch() {
43+
let byColor = option(title: "Alpha", leadingColor: .red)
44+
let byTitle = option(title: "Reduce Motion")
45+
46+
let results = [byTitle, byColor].filteredAndSorted(query: "red")
47+
48+
#expect(results == [byColor, byTitle])
49+
}
50+
51+
/// Even a barely-matching color outranks a text match, and the option
52+
/// is not dropped from the results. (A previous integer-based score
53+
/// truncated weak color matches to 0-3, colliding with the text tiers.)
54+
@Test func weakColorMatchOutranksTextMatchAndIsKept() throws {
55+
// Weighted distance to the Apple color list's red is just under the
56+
// 1.5 match threshold, producing a color score near 0.
57+
let weakColor = Color(red: 0.31, green: 0.49, blue: 0.49)
58+
let byColor = option(title: "Alpha", leadingColor: weakColor)
59+
let byTitle = option(title: "Reduce Motion")
60+
61+
// Sanity-check the fixture: the color must match, but only weakly.
62+
let match = try #require(CommandOptionMatch(option: byColor, query: "red"))
63+
#expect(match.colorScore > 0)
64+
#expect(match.colorScore < 0.05)
65+
66+
let results = [byTitle, byColor].filteredAndSorted(query: "red")
67+
68+
#expect(results == [byColor, byTitle])
69+
}
70+
71+
/// Options with equal scores keep their original relative order.
72+
@Test func tiesPreserveOriginalOrder() {
73+
let first = option(title: "New Window")
74+
let second = option(title: "New Tab")
75+
76+
#expect([first, second].filteredAndSorted(query: "new") == [first, second])
77+
#expect([second, first].filteredAndSorted(query: "new") == [second, first])
78+
}
79+
}

0 commit comments

Comments
 (0)