macOS: update command options match order (#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.
This commit is contained in:
Mitchell Hashimoto
2026-08-05 15:29:50 -07:00
committed by GitHub
2 changed files with 154 additions and 41 deletions

View File

@@ -70,25 +70,13 @@ struct CommandPaletteView: View {
}
// The options that we should show, taking into account any filtering from
// the query. Options with matching leadingColor are ranked higher.
// the query. Matched options are ranked in the following order:
// leadingColor > title > subtitle > description.
var filteredOptions: [CommandOption] {
if query.isEmpty {
return options
} else {
// Filter by title/subtitle match OR color match
let filtered = options.filter {
$0.title.matchedIndices(for: query) != nil ||
($0.subtitle?.matchedIndices(for: query) != nil) ||
($0.description?.matchedIndices(for: query) != nil) ||
colorMatchScore(for: $0.leadingColor, query: query) > 0
}
// Sort by color match score (higher scores first), then maintain original order
return filtered.sorted { a, b in
let scoreA = colorMatchScore(for: a.leadingColor, query: query)
let scoreB = colorMatchScore(for: b.leadingColor, query: query)
return scoreA > scoreB
}
return options.filteredAndSorted(query: query)
}
}
@@ -161,7 +149,7 @@ struct CommandPaletteView: View {
hoveredOptionID: $hoveredOptionID) { option in
isPresented = false
option.action()
}
}
}
.frame(maxWidth: 500)
.background(
@@ -192,31 +180,6 @@ struct CommandPaletteView: View {
}
}
/// Returns a score (0.0 to 1.0) indicating how well a color matches a search query color name.
/// Returns 0 if no color name in the query matches, or if the color is nil.
private func colorMatchScore(for color: Color?, query: String) -> Double {
guard let color = color else { return 0 }
let queryLower = query.lowercased()
let nsColor = NSColor(color)
var bestScore: Double = 0
for name in NSColor.colorNames {
guard queryLower.contains(name),
let systemColor = NSColor(named: name) else { continue }
let distance = nsColor.distance(to: systemColor)
// Max distance in weighted RGB space is ~3.0, so normalize and invert
// Use a threshold to determine "close enough" matches
let maxDistance: Double = 1.5
if distance < maxDistance {
let score = 1.0 - (distance / maxDistance)
bestScore = max(bestScore, score)
}
}
return bestScore
}
}
/// The text field for building the query for the command palette.
@@ -493,3 +456,74 @@ extension String {
return queryIndex == query.endIndex ? matched : nil
}
}
// MARK: - Match score
extension Collection where Element == CommandOption {
/// Filters to the options matching `query` and ranks them best-first while
/// maintaining original order: a closer leading color match always wins,
/// then a title match beats a subtitle match beats a description match.
func filteredAndSorted(query: String) -> [Element] {
compactMap { CommandOptionMatch(option: $0, query: query) }
.sorted {
($0.colorScore, $0.textScore) > ($1.colorScore, $1.textScore)
}
.map(\.option)
}
}
/// A scored match of a command option against a palette query.
struct CommandOptionMatch {
let option: CommandOption
/// How closely the option's leading color matches a color name in the
/// query, from 0 (no match) to 1 (exact).
let colorScore: Double
/// Which text field matched, ranked: title (3), subtitle (2),
/// description (1), none (0).
let textScore: Int
/// Returns nil if the option doesn't match the query at all.
init?(option: CommandOption, query: String) {
let colorScore = Self.colorMatchScore(for: option.leadingColor, query: query)
let textScore: Int = if option.title.matchedIndices(for: query) != nil {
3
} else if option.subtitle?.matchedIndices(for: query) != nil {
2
} else if option.description?.matchedIndices(for: query) != nil {
1
} else {
0
}
guard colorScore > 0 || textScore > 0 else { return nil }
self.option = option
self.colorScore = colorScore
self.textScore = textScore
}
/// Returns a score (0.0 to 1.0) indicating how well a color matches a search query color name.
/// Returns 0 if no color name in the query matches, or if the color is nil.
static func colorMatchScore(for color: Color?, query: String) -> Double {
guard let color = color else { return 0 }
let queryLower = query.lowercased()
let nsColor = NSColor(color)
var bestScore: Double = 0
for name in NSColor.colorNames {
guard queryLower.contains(name),
let systemColor = NSColor(named: name) else { continue }
let distance = nsColor.distance(to: systemColor)
// Max distance in weighted RGB space is ~3.0, so normalize and invert
// Use a threshold to determine "close enough" matches
let maxDistance: Double = 1.5
if distance < maxDistance {
let score = 1.0 - (distance / maxDistance)
bestScore = max(bestScore, score)
}
}
return bestScore
}
}

View File

@@ -0,0 +1,79 @@
//
// CommandPaletteTests.swift
// GhosttyTests
//
// Tests for command palette query filtering and match ranking.
//
import Testing
import SwiftUI
@testable import Ghostty
struct CommandPaletteFilterTests {
private func option(
title: String,
subtitle: String? = nil,
description: String? = nil,
leadingColor: Color? = nil
) -> CommandOption {
CommandOption(
title: title,
subtitle: subtitle,
description: description,
leadingColor: leadingColor
) {}
}
/// Title matches outrank subtitle matches, which outrank description
/// matches. Options that don't match at all are dropped.
@Test func textMatchTiers() {
let byDescription = option(title: "Alpha", description: "make it fast")
let bySubtitle = option(title: "Beta", subtitle: "fast scrolling")
let byTitle = option(title: "Fast Redraw")
let noMatch = option(title: "Quit")
let results = [noMatch, byDescription, bySubtitle, byTitle]
.filteredAndSorted(query: "fast")
#expect(results == [byTitle, bySubtitle, byDescription])
}
/// A strong color match outranks any text match.
@Test func colorMatchOutranksTextMatch() {
let byColor = option(title: "Alpha", leadingColor: .red)
let byTitle = option(title: "Reduce Motion")
let results = [byTitle, byColor].filteredAndSorted(query: "red")
#expect(results == [byColor, byTitle])
}
/// Even a barely-matching color outranks a text match, and the option
/// is not dropped from the results. (A previous integer-based score
/// truncated weak color matches to 0-3, colliding with the text tiers.)
@Test func weakColorMatchOutranksTextMatchAndIsKept() throws {
// Weighted distance to the Apple color list's red is just under the
// 1.5 match threshold, producing a color score near 0.
let weakColor = Color(red: 0.31, green: 0.49, blue: 0.49)
let byColor = option(title: "Alpha", leadingColor: weakColor)
let byTitle = option(title: "Reduce Motion")
// Sanity-check the fixture: the color must match, but only weakly.
let match = try #require(CommandOptionMatch(option: byColor, query: "red"))
#expect(match.colorScore > 0)
#expect(match.colorScore < 0.05)
let results = [byTitle, byColor].filteredAndSorted(query: "red")
#expect(results == [byColor, byTitle])
}
/// Options with equal scores keep their original relative order.
@Test func tiesPreserveOriginalOrder() {
let first = option(title: "New Window")
let second = option(title: "New Tab")
#expect([first, second].filteredAndSorted(query: "new") == [first, second])
#expect([second, first].filteredAndSorted(query: "new") == [second, first])
}
}