mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-09-05 19:08:17 +00:00
command palette SwiftUI view
This commit is contained in:
@@ -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 */,
|
||||
|
226
macos/Sources/Features/Command Palette/CommandPalette.swift
Normal file
226
macos/Sources/Features/Command Palette/CommandPalette.swift
Normal 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)
|
||||
}
|
||||
}
|
@@ -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 : [])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user