mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-18 13:30:29 +00:00
Tip works fine, but I've tried release and debug build with Xcode 26.4, it failed to focus as expected
416 lines
14 KiB
Swift
416 lines
14 KiB
Swift
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?
|
|
|
|
// 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) { 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
|
|
if !newValue {
|
|
// 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 = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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>, onEvent: ((KeyboardEvent) -> Void)? = nil) {
|
|
_query = query
|
|
self.onEvent = onEvent
|
|
}
|
|
|
|
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) }
|
|
.onAppear {
|
|
// Grab focus on the first appearance.
|
|
// Debug and Release build using Xcode 26.4,
|
|
// has same issue again
|
|
// Fixes: https://github.com/ghostty-org/ghostty/issues/8497
|
|
// SearchOverlay works magically as expected, I don't know
|
|
// why it's different here, but dispatching to next loop fixes it
|
|
DispatchQueue.main.async {
|
|
isTextFieldFocused = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|