From d02ad967b62af94d7ffaca3bbac9029966ff8824 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Wed, 5 Aug 2026 14:59:49 +0200 Subject: [PATCH] macOS: update command options match order Matches are sorted in the following order: leadingColor > title > subtitle > description. Ranking is lexicographic on (colorScore, textScore) --- .../Command Palette/CommandPalette.swift | 116 +++++++++++------- macos/Tests/CommandPaletteTests.swift | 79 ++++++++++++ 2 files changed, 154 insertions(+), 41 deletions(-) create mode 100644 macos/Tests/CommandPaletteTests.swift diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 8a09d80eb..20ffb5c2b 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -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 + } +} diff --git a/macos/Tests/CommandPaletteTests.swift b/macos/Tests/CommandPaletteTests.swift new file mode 100644 index 000000000..64053728d --- /dev/null +++ b/macos/Tests/CommandPaletteTests.swift @@ -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]) + } +}