mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-05-31 23:25:20 +00:00
228 lines
7.5 KiB
Swift
228 lines
7.5 KiB
Swift
import SwiftUI
|
|
|
|
struct CommandOption: Identifiable, Hashable {
|
|
let id = UUID()
|
|
let title: String
|
|
let shortcut: String?
|
|
let action: () -> Void
|
|
|
|
static func == (lhs: CommandOption, rhs: CommandOption) -> Bool {
|
|
lhs.id == rhs.id
|
|
}
|
|
|
|
func hash(into hasher: inout Hasher) {
|
|
hasher.combine(id)
|
|
}
|
|
|
|
// Sample data remains the same
|
|
static let sampleData: [CommandOption] = [
|
|
.init(title: "assistant: copy code", shortcut: nil, action: {}),
|
|
.init(title: "assistant: inline assist", shortcut: "⌃⏎", action: {}),
|
|
.init(title: "assistant: insert into editor", shortcut: "⌘<", action: {}),
|
|
.init(title: "assistant: new chat", shortcut: nil, action: {}),
|
|
.init(title: "assistant: open prompt library", shortcut: nil, action: {}),
|
|
.init(title: "assistant: quote selection", shortcut: "⌘>", action: {}),
|
|
.init(title: "assistant: show configuration", shortcut: nil, action: {}),
|
|
.init(title: "assistant: toggle focus", shortcut: "⌘?", action: {}),
|
|
]
|
|
}
|
|
|
|
struct CommandPaletteView: View {
|
|
@Binding var isPresented: Bool
|
|
var backgroundColor: Color = Color(nsColor: .windowBackgroundColor)
|
|
var options: [CommandOption] = CommandOption.sampleData
|
|
@State private var query = ""
|
|
@State private var selectedIndex: UInt = 0
|
|
@State private var hoveredOptionID: UUID? = nil
|
|
|
|
// The options that we should show, taking into account any filtering from
|
|
// the query.
|
|
var filteredOptions: [CommandOption] {
|
|
if query.isEmpty {
|
|
return options
|
|
} else {
|
|
return options.filter { $0.title.localizedCaseInsensitiveContains(query) }
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// Prompt Field
|
|
CommandPaletteQuery(query: $query) { event in
|
|
switch (event) {
|
|
case .exit:
|
|
isPresented = false
|
|
|
|
case .submit:
|
|
isPresented = false
|
|
|
|
case .move(.up):
|
|
if selectedIndex > 0 {
|
|
selectedIndex -= 1
|
|
}
|
|
|
|
case .move(.down):
|
|
if selectedIndex < filteredOptions.count - 1 {
|
|
selectedIndex += 1
|
|
}
|
|
|
|
case .move(_):
|
|
// Unknown, ignore
|
|
break
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
.padding(.bottom, 4)
|
|
|
|
CommandTable(
|
|
options: options,
|
|
query: $query,
|
|
selectedIndex: $selectedIndex,
|
|
hoveredOptionID: $hoveredOptionID)
|
|
}
|
|
.frame(width: 500)
|
|
.background(backgroundColor)
|
|
.cornerRadius(12)
|
|
.shadow(radius: 20)
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
/// The text field for building the query for the command palette.
|
|
fileprivate struct CommandPaletteQuery: View {
|
|
@Binding var query: String
|
|
var onEvent: ((KeyboardEvent) -> Void)? = nil
|
|
@FocusState private var isTextFieldFocused: Bool
|
|
|
|
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: 14))
|
|
.textFieldStyle(PlainTextFieldStyle())
|
|
.focused($isTextFieldFocused)
|
|
.onAppear {
|
|
isTextFieldFocused = true
|
|
}
|
|
.onChange(of: isTextFieldFocused) { focused in
|
|
if !focused {
|
|
onEvent?(.exit)
|
|
}
|
|
}
|
|
.onExitCommand { onEvent?(.exit) }
|
|
.onMoveCommand { onEvent?(.move($0)) }
|
|
.onSubmit { onEvent?(.submit) }
|
|
}
|
|
}
|
|
}
|
|
|
|
fileprivate struct CommandTable: View {
|
|
var options: [CommandOption] = CommandOption.sampleData
|
|
@Binding var query: String
|
|
@Binding var selectedIndex: UInt
|
|
@Binding var hoveredOptionID: UUID?
|
|
|
|
// The options that we should show, taking into account any filtering from
|
|
// the query.
|
|
var filteredOptions: [CommandOption] {
|
|
if query.isEmpty {
|
|
return options
|
|
} else {
|
|
return options.filter { $0.title.localizedCaseInsensitiveContains(query) }
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
if filteredOptions.isEmpty {
|
|
Text("No matches")
|
|
.foregroundStyle(.secondary)
|
|
.padding()
|
|
} else {
|
|
ScrollViewReader { proxy in
|
|
ScrollView(showsIndicators: false) {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
ForEach(Array(filteredOptions.enumerated()), id: \.1.id) { index, option in
|
|
CommandRow(
|
|
option: option,
|
|
isSelected: selectedIndex == index,
|
|
hoveredID: $hoveredOptionID
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.frame(height: 200)
|
|
.onChange(of: selectedIndex) { _ in
|
|
guard selectedIndex < filteredOptions.count else { return }
|
|
withAnimation {
|
|
proxy.scrollTo(
|
|
filteredOptions[Int(selectedIndex)].id,
|
|
anchor: .center)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A single row in the command palette.
|
|
fileprivate struct CommandRow: View {
|
|
let option: CommandOption
|
|
var isSelected: Bool
|
|
@Binding var hoveredID: UUID?
|
|
|
|
var body: some View {
|
|
Button(action: option.action) {
|
|
HStack {
|
|
Text(option.title.lowercased())
|
|
Spacer()
|
|
if let shortcut = option.shortcut {
|
|
Text(shortcut)
|
|
.foregroundStyle(.secondary)
|
|
.font(.system(size: 12))
|
|
}
|
|
}
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 8)
|
|
.background(
|
|
isSelected
|
|
? Color.accentColor.opacity(0.2)
|
|
: (hoveredID == option.id
|
|
? Color.secondary.opacity(0.2)
|
|
: Color.clear)
|
|
)
|
|
.cornerRadius(6)
|
|
}
|
|
.buttonStyle(PlainButtonStyle())
|
|
.onHover { hovering in
|
|
hoveredID = hovering ? option.id : nil
|
|
}
|
|
.padding(.horizontal, 4)
|
|
.padding(.vertical, 1)
|
|
}
|
|
}
|