command palette SwiftUI view

This commit is contained in:
Mitchell Hashimoto
2025-04-18 14:14:50 -07:00
parent 40c3dbbb31
commit be7fb45e9f
3 changed files with 307 additions and 32 deletions

View File

@@ -36,6 +36,7 @@
A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A535B9D9299C569B0017E2E4 /* ErrorView.swift */; };
A53A297F2DB4480F00B6E02C /* EventModifiers+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */; };
A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */; };
A53A297B2DB2E49700B6E02C /* CommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A297A2DB2E49400B6E02C /* CommandPalette.swift */; };
A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */; };
A53D0C8E2B53B0EA00305CE6 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; };
A53D0C942B53B43700305CE6 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C932B53B43700305CE6 /* iOSApp.swift */; };
@@ -142,6 +143,7 @@
A535B9D9299C569B0017E2E4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; };
A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EventModifiers+Extension.swift"; sourceTree = "<group>"; };
A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyboardShortcut+Extension.swift"; sourceTree = "<group>"; };
A53A297A2DB2E49400B6E02C /* CommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPalette.swift; sourceTree = "<group>"; };
A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Action.swift; sourceTree = "<group>"; };
A53D0C932B53B43700305CE6 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; };
A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.App.swift; sourceTree = "<group>"; };
@@ -271,6 +273,7 @@
A5CBD05A2CA0C5910017A1AE /* QuickTerminal */,
A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */,
A57D79252C9C8782001D522E /* Secure Input */,
A53A29742DB2E04900B6E02C /* Command Palette */,
A534263E2A7DCC5800EBB7A2 /* Settings */,
A51BFC1C2B2FB5AB00E92F16 /* About */,
A54B0CE72D0CEC9800CBEFF8 /* Colorized Ghostty Icon */,
@@ -325,6 +328,14 @@
path = Settings;
sourceTree = "<group>";
};
A53A29742DB2E04900B6E02C /* Command Palette */ = {
isa = PBXGroup;
children = (
A53A297A2DB2E49400B6E02C /* CommandPalette.swift */,
);
path = "Command Palette";
sourceTree = "<group>";
};
A53D0C912B53B41900305CE6 /* App */ = {
isa = PBXGroup;
children = (
@@ -699,6 +710,7 @@
A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */,
A5FEB3002ABB69450068369E /* main.swift in Sources */,
A53A297F2DB4480F00B6E02C /* EventModifiers+Extension.swift in Sources */,
A53A297B2DB2E49700B6E02C /* CommandPalette.swift in Sources */,
A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */,
A51B78472AF4B58B00F3EDB9 /* TerminalWindow.swift in Sources */,
A57D79272C9C879B001D522E /* SecureInput.swift in Sources */,

View File

@@ -0,0 +1,226 @@
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(
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)
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)
}
}

View File

@@ -68,6 +68,8 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
return URL(fileURLWithPath: surfacePwd)
}
@State var showingCommandPalette = false
var body: some View {
switch ghostty.readiness {
case .loading:
@@ -75,42 +77,77 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
case .error:
ErrorView()
case .ready:
VStack(spacing: 0) {
// If we're running in debug mode we show a warning so that users
// know that performance will be degraded.
if (Ghostty.info.mode == GHOSTTY_BUILD_MODE_DEBUG || Ghostty.info.mode == GHOSTTY_BUILD_MODE_RELEASE_SAFE) {
DebugBuildWarningView()
}
ZStack {
VStack(spacing: 0) {
// If we're running in debug mode we show a warning so that users
// know that performance will be degraded.
if (Ghostty.info.mode == GHOSTTY_BUILD_MODE_DEBUG || Ghostty.info.mode == GHOSTTY_BUILD_MODE_RELEASE_SAFE) {
DebugBuildWarningView()
}
Ghostty.TerminalSplit(node: $viewModel.surfaceTree)
.environmentObject(ghostty)
.focused($focused)
.onAppear { self.focused = true }
.onChange(of: focusedSurface) { newValue in
self.delegate?.focusedSurfaceDidChange(to: newValue)
HStack {
Spacer()
Button("Command Palette") {
showingCommandPalette.toggle()
}
Spacer()
}
.onChange(of: title) { newValue in
self.delegate?.titleDidChange(to: newValue)
}
.onChange(of: pwdURL) { newValue in
self.delegate?.pwdDidChange(to: newValue)
}
.onChange(of: cellSize) { newValue in
guard let size = newValue else { return }
self.delegate?.cellSizeDidChange(to: size)
}
.onChange(of: viewModel.surfaceTree?.hashValue) { _ in
// This is funky, but its the best way I could think of to detect
// ANY CHANGE within the deeply nested surface tree -- detecting a change
// in the hash value.
self.delegate?.surfaceTreeDidChange()
}
.onChange(of: zoomedSplit) { newValue in
self.delegate?.zoomStateDidChange(to: newValue ?? false)
.background(Color(.windowBackgroundColor))
.frame(maxWidth: .infinity)
Ghostty.TerminalSplit(node: $viewModel.surfaceTree)
.environmentObject(ghostty)
.focused($focused)
.onAppear { self.focused = true }
.onChange(of: focusedSurface) { newValue in
self.delegate?.focusedSurfaceDidChange(to: newValue)
}
.onChange(of: title) { newValue in
self.delegate?.titleDidChange(to: newValue)
}
.onChange(of: pwdURL) { newValue in
self.delegate?.pwdDidChange(to: newValue)
}
.onChange(of: cellSize) { newValue in
guard let size = newValue else { return }
self.delegate?.cellSizeDidChange(to: size)
}
.onChange(of: viewModel.surfaceTree?.hashValue) { _ in
// This is funky, but its the best way I could think of to detect
// ANY CHANGE within the deeply nested surface tree -- detecting a change
// in the hash value.
self.delegate?.surfaceTreeDidChange()
}
.onChange(of: zoomedSplit) { newValue in
self.delegate?.zoomStateDidChange(to: newValue ?? false)
}
}
// Ignore safe area to extend up in to the titlebar region if we have the "hidden" titlebar style
.ignoresSafeArea(.container, edges: ghostty.config.macosTitlebarStyle == "hidden" ? .top : [])
if showingCommandPalette {
// The Palette View Itself
GeometryReader { geometry in
VStack {
Spacer().frame(height: geometry.size.height * 0.1)
CommandPaletteView(
isPresented: $showingCommandPalette,
backgroundColor: ghostty.config.backgroundColor
)
.transition(
.move(edge: .top)
.combined(with: .opacity)
.animation(.spring(response: 0.4, dampingFraction: 0.8))
) // Spring animation
.zIndex(1) // Ensure it's on top
Spacer()
}
.frame(width: geometry.size.width, height: geometry.size.height, alignment: .top)
}
}
}
// Ignore safe area to extend up in to the titlebar region if we have the "hidden" titlebar style
.ignoresSafeArea(.container, edges: ghostty.config.macosTitlebarStyle == "hidden" ? .top : [])
}
}
}