Files
ghostty/macos/Sources/Features/Command Palette/CommandPalette.swift
2026-02-19 18:56:43 -05:00

421 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
struct CommandOption: Identifiable, Hashable {
/// Unique identifier for this option.
let id = UUID()
/// The primary text displayed for this command.
let title: String
/// Secondary text displayed below the title.
let subtitle: String?
/// Tooltip text shown on hover.
let description: String?
/// Keyboard shortcut symbols to display.
let symbols: [String]?
/// SF Symbol name for the leading icon.
let leadingIcon: String?
/// Color for the leading indicator circle.
let leadingColor: Color?
/// Badge text displayed as a pill.
let badge: String?
/// Whether to visually emphasize this option.
let emphasis: Bool
/// Sort key for stable ordering when titles are equal.
let sortKey: AnySortKey?
/// The action to perform when this option is selected.
let action: () -> Void
init(
title: String,
subtitle: String? = nil,
description: String? = nil,
symbols: [String]? = nil,
leadingIcon: String? = nil,
leadingColor: Color? = nil,
badge: String? = nil,
emphasis: Bool = false,
sortKey: AnySortKey? = nil,
action: @escaping () -> Void
) {
self.title = title
self.subtitle = subtitle
self.description = description
self.symbols = symbols
self.leadingIcon = leadingIcon
self.leadingColor = leadingColor
self.badge = badge
self.emphasis = emphasis
self.sortKey = sortKey
self.action = action
}
static func == (lhs: CommandOption, rhs: CommandOption) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
struct CommandPaletteView: View {
@Binding var isPresented: Bool
var backgroundColor: Color = Color(nsColor: .windowBackgroundColor)
var options: [CommandOption]
@State private var query = ""
@State private var selectedIndex: UInt?
@State private var hoveredOptionID: UUID?
@FocusState private var isTextFieldFocused: Bool
// The options that we should show, taking into account any filtering from
// the query. Options with matching leadingColor are ranked higher.
var filteredOptions: [CommandOption] {
if query.isEmpty {
return options
} else {
// Filter by title/subtitle match OR color match
let filtered = options.filter {
$0.title.localizedCaseInsensitiveContains(query) ||
($0.subtitle?.localizedCaseInsensitiveContains(query) ?? false) ||
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
}
}
}
var selectedOption: CommandOption? {
guard let selectedIndex else { return nil }
return if selectedIndex < filteredOptions.count {
filteredOptions[Int(selectedIndex)]
} else {
filteredOptions.last
}
}
var body: some View {
let scheme: ColorScheme = if OSColor(backgroundColor).isLightColor {
.light
} else {
.dark
}
VStack(alignment: .leading, spacing: 0) {
CommandPaletteQuery(query: $query, isTextFieldFocused: _isTextFieldFocused) { event in
switch event {
case .exit:
isPresented = false
case .submit:
isPresented = false
selectedOption?.action()
case .move(.up):
if filteredOptions.isEmpty { break }
let current = selectedIndex ?? UInt(filteredOptions.count)
selectedIndex = (current == 0)
? UInt(filteredOptions.count - 1)
: current - 1
case .move(.down):
if filteredOptions.isEmpty { break }
let current = selectedIndex ?? UInt.max
selectedIndex = (current >= UInt(filteredOptions.count - 1))
? 0
: current + 1
case .move:
// Unknown, ignore
break
}
}
.onChange(of: query) { newValue in
// If the user types a query then we want to make sure the first
// value is selected. If the user clears the query and we were selecting
// the first, we unset any selection.
if !newValue.isEmpty {
if selectedIndex == nil {
selectedIndex = 0
}
} else {
if let selectedIndex, selectedIndex == 0 {
self.selectedIndex = nil
}
}
}
Divider()
CommandTable(
options: filteredOptions,
selectedIndex: $selectedIndex,
hoveredOptionID: $hoveredOptionID) { option in
isPresented = false
option.action()
}
}
.frame(maxWidth: 500)
.background(
ZStack {
Rectangle()
.fill(.ultraThinMaterial)
Rectangle()
.fill(backgroundColor)
.blendMode(.color)
}
.compositingGroup()
)
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color(nsColor: .tertiaryLabelColor).opacity(0.75))
)
.shadow(radius: 32, x: 0, y: 12)
.padding()
.environment(\.colorScheme, scheme)
.onChange(of: isPresented) { newValue in
// Reset focus when quickly showing and hiding.
// macOS will destroy this view after a while,
// so task/onAppear will not be called again.
// If you toggle it rather quickly, we reset
// it here when dismissing.
isTextFieldFocused = newValue
if !isPresented {
// This is optional, since most of the time
// there will be a delay before the next use.
// To keep behavior the same as before, we reset it.
query = ""
}
}
.task {
// Grab focus on the first appearance.
// This happens right after onAppear,
// so we dont need to dispatch it again.
// Fixes: https://github.com/ghostty-org/ghostty/issues/8497
// Also fixes initial focus while animating.
isTextFieldFocused = isPresented
}
}
/// 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.
private struct CommandPaletteQuery: View {
@Binding var query: String
var onEvent: ((KeyboardEvent) -> Void)?
@FocusState private var isTextFieldFocused: Bool
init(query: Binding<String>, isTextFieldFocused: FocusState<Bool>, onEvent: ((KeyboardEvent) -> Void)? = nil) {
_query = query
self.onEvent = onEvent
_isTextFieldFocused = isTextFieldFocused
}
enum KeyboardEvent {
case exit
case submit
case move(MoveCommandDirection)
}
var body: some View {
ZStack {
Group {
Button { onEvent?(.move(.up)) } label: { Color.clear }
.buttonStyle(PlainButtonStyle())
.keyboardShortcut(.upArrow, modifiers: [])
Button { onEvent?(.move(.down)) } label: { Color.clear }
.buttonStyle(PlainButtonStyle())
.keyboardShortcut(.downArrow, modifiers: [])
Button { onEvent?(.move(.up)) } label: { Color.clear }
.buttonStyle(PlainButtonStyle())
.keyboardShortcut(.init("p"), modifiers: [.control])
Button { onEvent?(.move(.down)) } label: { Color.clear }
.buttonStyle(PlainButtonStyle())
.keyboardShortcut(.init("n"), modifiers: [.control])
}
.frame(width: 0, height: 0)
.accessibilityHidden(true)
TextField("Execute a command…", text: $query)
.padding()
.font(.system(size: 20, weight: .light))
.frame(height: 48)
.textFieldStyle(.plain)
.focused($isTextFieldFocused)
.onChange(of: isTextFieldFocused) { focused in
if !focused {
onEvent?(.exit)
}
}
.onExitCommand { onEvent?(.exit) }
.onMoveCommand { onEvent?(.move($0)) }
.onSubmit { onEvent?(.submit) }
}
}
}
private struct CommandTable: View {
var options: [CommandOption]
@Binding var selectedIndex: UInt?
@Binding var hoveredOptionID: UUID?
var action: (CommandOption) -> Void
var body: some View {
if options.isEmpty {
Text("No matches")
.foregroundStyle(.secondary)
.padding()
} else {
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 4) {
ForEach(Array(options.enumerated()), id: \.1.id) { index, option in
CommandRow(
option: option,
isSelected: {
if let selected = selectedIndex {
return selected == index ||
(selected >= options.count &&
index == options.count - 1)
} else {
return false
}
}(),
hoveredID: $hoveredOptionID
) {
action(option)
}
}
}
.padding(10)
}
.frame(maxHeight: 200)
.onChange(of: selectedIndex) { _ in
guard let selectedIndex,
selectedIndex < options.count else { return }
proxy.scrollTo(
options[Int(selectedIndex)].id)
}
}
}
}
}
/// A single row in the command palette.
private struct CommandRow: View {
let option: CommandOption
var isSelected: Bool
@Binding var hoveredID: UUID?
var action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
if let color = option.leadingColor {
Circle()
.fill(color)
.frame(width: 8, height: 8)
}
if let icon = option.leadingIcon {
Image(systemName: icon)
.foregroundStyle(option.emphasis ? Color.accentColor : .secondary)
.font(.system(size: 14, weight: .medium))
}
VStack(alignment: .leading, spacing: 2) {
Text(option.title)
.fontWeight(option.emphasis ? .medium : .regular)
if let subtitle = option.subtitle {
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if let badge = option.badge, !badge.isEmpty {
Text(badge)
.font(.caption2.weight(.medium))
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(
Capsule().fill(Color.accentColor.opacity(0.15))
)
.foregroundStyle(Color.accentColor)
}
if let symbols = option.symbols {
ShortcutSymbolsView(symbols: symbols)
.foregroundStyle(.secondary)
}
}
.padding(8)
.contentShape(Rectangle())
.background(
isSelected
? Color.accentColor.opacity(0.2)
: (hoveredID == option.id
? Color.secondary.opacity(0.2)
: Color.clear)
)
.overlay(
RoundedRectangle(cornerRadius: 5)
.strokeBorder(Color.accentColor.opacity(option.emphasis && !isSelected ? 0.3 : 0), lineWidth: 1.5)
)
.cornerRadius(5)
}
.help(option.description ?? "")
.buttonStyle(.plain)
.onHover { hovering in
hoveredID = hovering ? option.id : nil
}
}
}
/// A row of Text representing a shortcut.
private struct ShortcutSymbolsView: View {
let symbols: [String]
var body: some View {
HStack(spacing: 1) {
ForEach(symbols, id: \.self) { symbol in
Text(symbol)
.frame(minWidth: 13)
}
}
}
}