mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 16:11:43 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
79
macos/Tests/CommandPaletteTests.swift
Normal file
79
macos/Tests/CommandPaletteTests.swift
Normal 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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user