From be7fb45e9f42303bb7b5720b408199fe4c44d098 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 18 Apr 2025 14:14:50 -0700 Subject: [PATCH 01/16] command palette SwiftUI view --- macos/Ghostty.xcodeproj/project.pbxproj | 12 + .../Command Palette/CommandPalette.swift | 226 ++++++++++++++++++ .../Features/Terminal/TerminalView.swift | 101 +++++--- 3 files changed, 307 insertions(+), 32 deletions(-) create mode 100644 macos/Sources/Features/Command Palette/CommandPalette.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 5d02ba12b..1a2fc7caa 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -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 = ""; }; A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EventModifiers+Extension.swift"; sourceTree = ""; }; A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyboardShortcut+Extension.swift"; sourceTree = ""; }; + A53A297A2DB2E49400B6E02C /* CommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPalette.swift; sourceTree = ""; }; A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Action.swift; sourceTree = ""; }; A53D0C932B53B43700305CE6 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.App.swift; sourceTree = ""; }; @@ -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 = ""; }; + A53A29742DB2E04900B6E02C /* Command Palette */ = { + isa = PBXGroup; + children = ( + A53A297A2DB2E49400B6E02C /* CommandPalette.swift */, + ); + path = "Command Palette"; + sourceTree = ""; + }; 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 */, diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift new file mode 100644 index 000000000..cd68eeeb1 --- /dev/null +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -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) + } +} diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 3d4165e91..fe48b6b73 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -68,6 +68,8 @@ struct TerminalView: View { return URL(fileURLWithPath: surfacePwd) } + @State var showingCommandPalette = false + var body: some View { switch ghostty.readiness { case .loading: @@ -75,42 +77,77 @@ struct TerminalView: 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 : []) } } } From a34134e643abe2a9ccc1c79b84a5b0fe6e5b2095 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 19 Apr 2025 12:41:22 -0700 Subject: [PATCH 02/16] input: defind Command struct and default commands --- include/ghostty.h | 6 + src/input.zig | 2 + src/input/Binding.zig | 9 - src/input/command.zig | 393 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 401 insertions(+), 9 deletions(-) create mode 100644 src/input/command.zig diff --git a/include/ghostty.h b/include/ghostty.h index c4ef11930..06b812948 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -279,6 +279,12 @@ typedef struct { ghostty_input_mods_e mods; } ghostty_input_trigger_s; +typedef struct { + const char* action; + const char* title; + const char* description; +} ghostty_command_s; + typedef enum { GHOSTTY_BUILD_MODE_DEBUG, GHOSTTY_BUILD_MODE_RELEASE_SAFE, diff --git a/src/input.zig b/src/input.zig index 83be38d3d..caaf80509 100644 --- a/src/input.zig +++ b/src/input.zig @@ -5,6 +5,7 @@ const mouse = @import("input/mouse.zig"); const key = @import("input/key.zig"); const keyboard = @import("input/keyboard.zig"); +pub const command = @import("input/command.zig"); pub const function_keys = @import("input/function_keys.zig"); pub const keycodes = @import("input/keycodes.zig"); pub const kitty = @import("input/kitty.zig"); @@ -12,6 +13,7 @@ pub const kitty = @import("input/kitty.zig"); pub const ctrlOrSuper = key.ctrlOrSuper; pub const Action = key.Action; pub const Binding = @import("input/Binding.zig"); +pub const Command = command.Command; pub const Link = @import("input/Link.zig"); pub const Key = key.Key; pub const KeyboardLayout = keyboard.Layout; diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 244cd29cd..0b9ae1136 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1017,15 +1017,6 @@ pub const Action = union(enum) { } }; -// A key for the C API to execute an action. This must be kept in sync -// with include/ghostty.h. -pub const Key = enum(c_int) { - copy_to_clipboard, - paste_from_clipboard, - new_tab, - new_window, -}; - /// Trigger is the associated key state that can trigger an action. /// This is an extern struct because this is also used in the C API. /// diff --git a/src/input/command.zig b/src/input/command.zig new file mode 100644 index 000000000..51bcbaad6 --- /dev/null +++ b/src/input/command.zig @@ -0,0 +1,393 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const Action = @import("binding.zig").Action; + +/// A command is a named binding action that can be executed from +/// something like a command palette. +/// +/// A command must be associated with a binding; all commands can be +/// mapped to traditional `keybind` configurations. This restriction +/// makes it so that there is nothing special about commands and likewise +/// it makes it trivial and consistent to define custom commands. +/// +/// For apprt implementers: a command palette doesn't have to make use +/// of all the fields here. We try to provide as much information as +/// possible to make it easier to implement a command palette in the way +/// that makes the most sense for the application. +pub const Command = struct { + action: Action, + title: [:0]const u8, + description: [:0]const u8, + + /// ghostty_command_s + pub const C = extern struct { + action: [*:0]const u8, + title: [*:0]const u8, + description: [*:0]const u8, + }; + + /// Convert this command to a C struct. + pub fn comptimeCval(self: Command) C { + assert(@inComptime()); + + return .{ + .action = std.fmt.comptimePrint("{s}", .{self.action}), + .title = self.title, + .description = self.description, + }; + } +}; + +pub const defaults: []const Command = defaults: { + var count: usize = 0; + for (@typeInfo(Action.Key).@"enum".fields) |field| { + const action = @field(Action.Key, field.name); + count += actionCommands(action).len; + } + + var result: [count]Command = undefined; + var i: usize = 0; + for (@typeInfo(Action.Key).@"enum".fields) |field| { + const action = @field(Action.Key, field.name); + const commands = actionCommands(action); + for (commands) |cmd| { + result[i] = cmd; + i += 1; + } + } + + assert(i == count); + const final = result; + break :defaults &final; +}; + +/// Defaults in C-compatible form. +pub const defaultsC: []const Command.C = defaults: { + var result: [defaults.len]Command.C = undefined; + for (defaults, 0..) |cmd, i| result[i] = cmd.comptimeCval(); + const final = result; + break :defaults &final; +}; + +/// Returns the set of commands associated with this action key by +/// default. Not all actions should have commands. As a general guideline, +/// an action should have a command only if it is useful and reasonable +/// to appear in a command palette. +fn actionCommands(action: Action.Key) []const Command { + // This is implemented as a function and switch rather than a + // flat comptime const because we want to ensure we get a compiler + // error when a new binding is added so that the contributor has + // to consider whether that new binding should have commands or not. + const result: []const Command = switch (action) { + // Note: the use of `comptime` prefix on the return values + // ensures that the data returned is all in the binary and + // and not pointing to the stack. + + .reset => comptime &.{.{ + .action = .reset, + .title = "Reset Terminal", + .description = "Reset the terminal to a clean state.", + }}, + + .copy_to_clipboard => comptime &.{.{ + .action = .copy_to_clipboard, + .title = "Copy to Clipboard", + .description = "Copy the selected text to the clipboard.", + }}, + + .copy_url_to_clipboard => comptime &.{.{ + .action = .copy_url_to_clipboard, + .title = "Copy URL to Clipboard", + .description = "Copy the URL under the cursor to the clipboard.", + }}, + + .paste_from_clipboard => comptime &.{.{ + .action = .paste_from_clipboard, + .title = "Paste from Clipboard", + .description = "Paste the contents of the clipboard.", + }}, + + .paste_from_selection => comptime &.{.{ + .action = .paste_from_selection, + .title = "Paste from Selection", + .description = "Paste the contents of the selection clipboard.", + }}, + + .increase_font_size => comptime &.{.{ + .action = .{ .increase_font_size = 1 }, + .title = "Increase Font Size", + .description = "Increase the font size by 1 point.", + }}, + + .decrease_font_size => comptime &.{.{ + .action = .{ .decrease_font_size = 1 }, + .title = "Decrease Font Size", + .description = "Decrease the font size by 1 point.", + }}, + + .reset_font_size => comptime &.{.{ + .action = .reset_font_size, + .title = "Reset Font Size", + .description = "Reset the font size to the default.", + }}, + + .clear_screen => comptime &.{.{ + .action = .clear_screen, + .title = "Clear Screen", + .description = "Clear the screen and scrollback.", + }}, + + .select_all => comptime &.{.{ + .action = .select_all, + .title = "Select All", + .description = "Select all text on the screen.", + }}, + + .scroll_to_top => comptime &.{.{ + .action = .scroll_to_top, + .title = "Scroll to Top", + .description = "Scroll to the top of the screen.", + }}, + + .scroll_to_bottom => comptime &.{.{ + .action = .scroll_to_bottom, + .title = "Scroll to Bottom", + .description = "Scroll to the bottom of the screen.", + }}, + + .scroll_page_up => comptime &.{.{ + .action = .scroll_page_up, + .title = "Scroll Page Up", + .description = "Scroll the screen up by a page.", + }}, + + .scroll_page_down => comptime &.{.{ + .action = .scroll_page_down, + .title = "Scroll Page Down", + .description = "Scroll the screen down by a page.", + }}, + + .write_screen_file => comptime &.{ + .{ + .action = .{ .write_screen_file = .paste }, + .title = "Copy Screen to Temporary File and Paste Path", + .description = "Copy the screen contents to a temporary file and paste the path to the file.", + }, + .{ + .action = .{ .write_screen_file = .open }, + .title = "Copy Screen to Temporary File and Open", + .description = "Copy the screen contents to a temporary file and open it.", + }, + }, + + .write_selection_file => comptime &.{ + .{ + .action = .{ .write_selection_file = .paste }, + .title = "Copy Selection to Temporary File and Paste Path", + .description = "Copy the selection contents to a temporary file and paste the path to the file.", + }, + .{ + .action = .{ .write_selection_file = .open }, + .title = "Copy Selection to Temporary File and Open", + .description = "Copy the selection contents to a temporary file and open it.", + }, + }, + + .new_window => comptime &.{.{ + .action = .new_window, + .title = "New Window", + .description = "Open a new window.", + }}, + + .new_tab => comptime &.{.{ + .action = .new_tab, + .title = "New Tab", + .description = "Open a new tab.", + }}, + + .move_tab => comptime &.{ + .{ + .action = .{ .move_tab = -1 }, + .title = "Move Tab Left", + .description = "Move the current tab to the left.", + }, + .{ + .action = .{ .move_tab = 1 }, + .title = "Move Tab Right", + .description = "Move the current tab to the right.", + }, + }, + + .toggle_tab_overview => comptime &.{.{ + .action = .toggle_tab_overview, + .title = "Toggle Tab Overview", + .description = "Toggle the tab overview.", + }}, + + .prompt_surface_title => comptime &.{.{ + .action = .prompt_surface_title, + .title = "Change Title...", + .description = "Prompt for a new title for the current terminal.", + }}, + + .new_split => comptime &.{ + .{ + .action = .{ .new_split = .left }, + .title = "Split Left", + .description = "Split the terminal to the left.", + }, + .{ + .action = .{ .new_split = .right }, + .title = "Split Right", + .description = "Split the terminal to the right.", + }, + .{ + .action = .{ .new_split = .up }, + .title = "Split Up", + .description = "Split the terminal up.", + }, + .{ + .action = .{ .new_split = .down }, + .title = "Split Down", + .description = "Split the terminal down.", + }, + }, + + .toggle_split_zoom => comptime &.{.{ + .action = .toggle_split_zoom, + .title = "Toggle Split Zoom", + .description = "Toggle the zoom state of the current split.", + }}, + + .equalize_splits => comptime &.{.{ + .action = .equalize_splits, + .title = "Equalize Splits", + .description = "Equalize the size of all splits.", + }}, + + .reset_window_size => comptime &.{.{ + .action = .reset_window_size, + .title = "Reset Window Size", + .description = "Reset the window size to the default.", + }}, + + .inspector => comptime &.{.{ + .action = .{ .inspector = .toggle }, + .title = "Toggle Inspector", + .description = "Toggle the inspector.", + }}, + + .open_config => comptime &.{.{ + .action = .open_config, + .title = "Open Config", + .description = "Open the config file.", + }}, + + .reload_config => comptime &.{.{ + .action = .reload_config, + .title = "Reload Config", + .description = "Reload the config file.", + }}, + + .close_surface => comptime &.{.{ + .action = .close_surface, + .title = "Close Terminal", + .description = "Close the current terminal.", + }}, + + .close_tab => comptime &.{.{ + .action = .close_tab, + .title = "Close Tab", + .description = "Close the current tab.", + }}, + + .close_window => comptime &.{.{ + .action = .close_window, + .title = "Close Window", + .description = "Close the current window.", + }}, + + .close_all_windows => comptime &.{.{ + .action = .close_all_windows, + .title = "Close All Windows", + .description = "Close all windows.", + }}, + + .toggle_maximize => comptime &.{.{ + .action = .toggle_maximize, + .title = "Toggle Maximize", + .description = "Toggle the maximized state of the current window.", + }}, + + .toggle_fullscreen => comptime &.{.{ + .action = .toggle_fullscreen, + .title = "Toggle Fullscreen", + .description = "Toggle the fullscreen state of the current window.", + }}, + + .toggle_window_decorations => comptime &.{.{ + .action = .toggle_window_decorations, + .title = "Toggle Window Decorations", + .description = "Toggle the window decorations.", + }}, + + .toggle_secure_input => comptime &.{.{ + .action = .toggle_secure_input, + .title = "Toggle Secure Input", + .description = "Toggle secure input mode.", + }}, + + .quit => comptime &.{.{ + .action = .quit, + .title = "Quit", + .description = "Quit the application.", + }}, + + // No commands because they're parameterized and there + // aren't obvious values users would use. It is possible that + // these may have commands in the future if there are very + // common values that users tend to use. + .csi, + .esc, + .text, + .cursor_key, + .scroll_page_fractional, + .scroll_page_lines, + .adjust_selection, + .jump_to_prompt, + .write_scrollback_file, + .goto_tab, + .goto_split, + .resize_split, + .crash, + => comptime &.{}, + + // No commands because I'm not sure they make sense in a command + // palette context. + .toggle_quick_terminal, + .toggle_visibility, + .previous_tab, + .next_tab, + .last_tab, + => comptime &.{}, + + // No commands for obvious reasons + .ignore, + .unbind, + => comptime &.{}, + }; + + // All generated commands should have the same action as the + // action passed in. + for (result) |cmd| assert(cmd.action == action); + + return result; +} + +test "command defaults" { + // This just ensures that defaults is analyzed and works. + const testing = std.testing; + try testing.expect(defaults.len > 0); + try testing.expectEqual(defaults.len, defaultsC.len); +} From 8615dfb73de79cb8e31d8217ea1cf8fe2e5bc7bb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 19 Apr 2025 13:22:36 -0700 Subject: [PATCH 03/16] libghostty: add API for getting commands --- include/ghostty.h | 1 + src/apprt/embedded.zig | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 06b812948..05e15c54f 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -730,6 +730,7 @@ void ghostty_surface_set_color_scheme(ghostty_surface_t, ghostty_color_scheme_e); ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t, ghostty_input_mods_e); +void ghostty_surface_commands(ghostty_surface_t, ghostty_command_s**, size_t*); bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); bool ghostty_surface_key_is_binding(ghostty_surface_t, ghostty_input_key_s); void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t); diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index e8da8612c..22ae6e488 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1487,6 +1487,23 @@ pub const CAPI = struct { return @intCast(@as(input.Mods.Backing, @bitCast(result))); } + /// Returns the current possible commands for a surface + /// in the output parameter. The memory is owned by libghostty + /// and doesn't need to be freed. + export fn ghostty_surface_commands( + surface: *Surface, + out: *[*]const input.Command.C, + len: *usize, + ) void { + // In the future we may use this information to filter + // some commands. + _ = surface; + + const commands = input.command.defaultsC; + out.* = commands.ptr; + len.* = commands.len; + } + /// Send this for raw keypresses (i.e. the keyDown event on macOS). /// This will handle the keymap translation and send the appropriate /// key and char events. From 5fab6faf042c06706a7da5606e6d21d9b445fcb3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 19 Apr 2025 13:44:47 -0700 Subject: [PATCH 04/16] macOS: hook up command palette C API to actual command palette --- .../Command Palette/CommandPalette.swift | 3 +- .../Features/Terminal/TerminalView.swift | 32 ++++++++++++++++++- macos/Sources/Ghostty/Ghostty.Input.swift | 2 +- macos/Sources/Helpers/Weak.swift | 2 +- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index cd68eeeb1..09425f471 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -76,6 +76,7 @@ struct CommandPaletteView: View { .padding(.bottom, 4) CommandTable( + options: options, query: $query, selectedIndex: $selectedIndex, hoveredOptionID: $hoveredOptionID) @@ -197,7 +198,7 @@ fileprivate struct CommandRow: View { var body: some View { Button(action: option.action) { HStack { - Text(option.title) + Text(option.title.lowercased()) Spacer() if let shortcut = option.shortcut { Text(shortcut) diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index fe48b6b73..5a86b9ff8 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -44,6 +44,10 @@ struct TerminalView: View { // An optional delegate to receive information about terminal changes. weak var delegate: (any TerminalViewDelegate)? = nil + // The most recently focused surface, equal to focusedSurface when + // it is non-nil. + @State private var lastFocusedSurface: Weak = .init() + // This seems like a crutch after switching from SwiftUI to AppKit lifecycle. @FocusState private var focused: Bool @@ -68,6 +72,25 @@ struct TerminalView: View { return URL(fileURLWithPath: surfacePwd) } + // The commands available to the command palette. + private var commandOptions: [CommandOption] { + guard let surface = lastFocusedSurface.value?.surface else { return [] } + + var ptr: UnsafeMutablePointer? = nil + var count: Int = 0 + ghostty_surface_commands(surface, &ptr, &count) + guard let ptr else { return [] } + + let buffer = UnsafeBufferPointer(start: ptr, count: count) + return Array(buffer).map { c in + let action = String(cString: c.action) + return CommandOption( + title: String(cString: c.title), + shortcut: ghostty.config.keyEquivalent(for: action)?.description + ) {} + } + } + @State var showingCommandPalette = false var body: some View { @@ -100,6 +123,12 @@ struct TerminalView: View { .focused($focused) .onAppear { self.focused = true } .onChange(of: focusedSurface) { newValue in + // We want to keep track of our last focused surface so even if + // we lose focus we keep this set to the last non-nil value. + if newValue != nil { + lastFocusedSurface = .init(newValue) + } + self.delegate?.focusedSurfaceDidChange(to: newValue) } .onChange(of: title) { newValue in @@ -133,7 +162,8 @@ struct TerminalView: View { CommandPaletteView( isPresented: $showingCommandPalette, - backgroundColor: ghostty.config.backgroundColor + backgroundColor: ghostty.config.backgroundColor, + options: commandOptions ) .transition( .move(edge: .top) diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index cb4fdc451..0be579122 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -11,7 +11,7 @@ extension Ghostty { return Self.keyToEquivalent[key] } - /// Return the keyboard shortcut for a trigger. + /// Return the key equivalent for the given trigger. /// /// Returns nil if the trigger doesn't have an equivalent KeyboardShortcut. This is possible /// because Ghostty input triggers are a superset of what can be represented by a macOS diff --git a/macos/Sources/Helpers/Weak.swift b/macos/Sources/Helpers/Weak.swift index d5f784844..0fbb9bd87 100644 --- a/macos/Sources/Helpers/Weak.swift +++ b/macos/Sources/Helpers/Weak.swift @@ -3,7 +3,7 @@ class Weak { weak var value: T? - init(_ value: T) { + init(_ value: T? = nil) { self.value = value } } From 0915a7af46c9dd75791999bdc4c405eb2de014d9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 21 Apr 2025 08:34:46 -0700 Subject: [PATCH 05/16] macOS: extract TerminalCommandPalette --- macos/Ghostty.xcodeproj/project.pbxproj | 8 +- .../TerminalCommandPalette.swift | 77 +++++++++++++++++++ .../Terminal/BaseTerminalController.swift | 7 +- .../Features/Terminal/TerminalView.swift | 53 +++---------- 4 files changed, 98 insertions(+), 47 deletions(-) create mode 100644 macos/Sources/Features/Command Palette/TerminalCommandPalette.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 1a2fc7caa..a34c4685f 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -34,9 +34,10 @@ A5333E242B5A22D9008AEFF7 /* Ghostty.Shell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */; }; A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53426342A7DA53D00EBB7A2 /* AppDelegate.swift */; }; A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A535B9D9299C569B0017E2E4 /* ErrorView.swift */; }; + A53A297B2DB2E49700B6E02C /* CommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A297A2DB2E49400B6E02C /* CommandPalette.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 */; }; + A53A29882DB69D2F00B6E02C /* TerminalCommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A29872DB69D2C00B6E02C /* TerminalCommandPalette.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 */; }; @@ -141,9 +142,10 @@ A5333E212B5A2128008AEFF7 /* SurfaceView_AppKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView_AppKit.swift; sourceTree = ""; }; A53426342A7DA53D00EBB7A2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; A535B9D9299C569B0017E2E4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; + A53A297A2DB2E49400B6E02C /* CommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPalette.swift; sourceTree = ""; }; A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EventModifiers+Extension.swift"; sourceTree = ""; }; A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyboardShortcut+Extension.swift"; sourceTree = ""; }; - A53A297A2DB2E49400B6E02C /* CommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPalette.swift; sourceTree = ""; }; + A53A29872DB69D2C00B6E02C /* TerminalCommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCommandPalette.swift; sourceTree = ""; }; A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Action.swift; sourceTree = ""; }; A53D0C932B53B43700305CE6 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.App.swift; sourceTree = ""; }; @@ -332,6 +334,7 @@ isa = PBXGroup; children = ( A53A297A2DB2E49400B6E02C /* CommandPalette.swift */, + A53A29872DB69D2C00B6E02C /* TerminalCommandPalette.swift */, ); path = "Command Palette"; sourceTree = ""; @@ -726,6 +729,7 @@ A52FFF572CA90484000C6A5B /* QuickTerminalScreen.swift in Sources */, A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */, A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */, + A53A29882DB69D2F00B6E02C /* TerminalCommandPalette.swift in Sources */, A51BFC202B2FB64F00E92F16 /* AboutController.swift in Sources */, A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */, A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */, diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift new file mode 100644 index 000000000..7b6676aad --- /dev/null +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -0,0 +1,77 @@ +import SwiftUI +import GhosttyKit + +struct TerminalCommandPaletteView: View { + /// The surface that this command palette represents. + let surfaceView: Ghostty.SurfaceView + + /// Set this to true to show the view, this will be set to false if any actions + /// result in the view disappearing. + @Binding var isPresented: Bool + + /// The configuration so we can lookup keyboard shortcuts. + @ObservedObject var ghosttyConfig: Ghostty.Config + + /// The callback when an action is submitted. + var onAction: ((String) -> Void) + + // The commands available to the command palette. + private var commandOptions: [CommandOption] { + guard let surface = surfaceView.surface else { return [] } + + var ptr: UnsafeMutablePointer? = nil + var count: Int = 0 + ghostty_surface_commands(surface, &ptr, &count) + guard let ptr else { return [] } + + let buffer = UnsafeBufferPointer(start: ptr, count: count) + return Array(buffer).map { c in + let action = String(cString: c.action) + return CommandOption( + title: String(cString: c.title), + shortcut: ghosttyConfig.keyboardShortcut(for: action)?.description + ) { + onAction(action) + } + } + } + + var body: some View { + ZStack { + if isPresented { + GeometryReader { geometry in + VStack { + Spacer().frame(height: geometry.size.height * 0.1) + + CommandPaletteView( + isPresented: $isPresented, + backgroundColor: ghosttyConfig.backgroundColor, + options: commandOptions + ) + .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) + } + } + } + .onChange(of: isPresented) { newValue in + // When the command palette disappears we need to send focus back to the + // surface view we were overlaid on top of. There's probably a better way + // to handle the first responder state here but I don't know it. + if !newValue { + // Has to be on queue because onChange happens on a user-interactive + // thread and Xcode is mad about this call on that. + DispatchQueue.main.async { + surfaceView.window?.makeFirstResponder(surfaceView) + } + } + } + } +} diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 3b4b1a2ef..b9a1def1b 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -45,6 +45,9 @@ class BaseTerminalController: NSWindowController, didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) } } + /// This can be set to show/hide the command palette. + @Published var commandPaletteIsShowing: Bool = false + /// Whether the terminal surface should focus when the mouse is over it. var focusFollowsMouse: Bool { self.derivedConfig.focusFollowsMouse @@ -209,12 +212,12 @@ class BaseTerminalController: NSWindowController, // We only care if the configuration is a global configuration, not a // surface-specific one. guard notification.object == nil else { return } - + // Get our managed configuration object out guard let config = notification.userInfo?[ Notification.Name.GhosttyConfigChangeKey ] as? Ghostty.Config else { return } - + // Update our derived config self.derivedConfig = DerivedConfig(config) } diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 5a86b9ff8..e3c4f04b7 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -32,6 +32,9 @@ protocol TerminalViewModel: ObservableObject { /// The tree of terminal surfaces (splits) within the view. This is mutated by TerminalView /// and children. This should be @Published. var surfaceTree: Ghostty.SplitNode? { get set } + + /// The command palette state. + var commandPaletteIsShowing: Bool { get set } } /// The main terminal view. This terminal view supports splits. @@ -72,27 +75,6 @@ struct TerminalView: View { return URL(fileURLWithPath: surfacePwd) } - // The commands available to the command palette. - private var commandOptions: [CommandOption] { - guard let surface = lastFocusedSurface.value?.surface else { return [] } - - var ptr: UnsafeMutablePointer? = nil - var count: Int = 0 - ghostty_surface_commands(surface, &ptr, &count) - guard let ptr else { return [] } - - let buffer = UnsafeBufferPointer(start: ptr, count: count) - return Array(buffer).map { c in - let action = String(cString: c.action) - return CommandOption( - title: String(cString: c.title), - shortcut: ghostty.config.keyEquivalent(for: action)?.description - ) {} - } - } - - @State var showingCommandPalette = false - var body: some View { switch ghostty.readiness { case .loading: @@ -111,7 +93,7 @@ struct TerminalView: View { HStack { Spacer() Button("Command Palette") { - showingCommandPalette.toggle() + viewModel.commandPaletteIsShowing.toggle() } Spacer() } @@ -154,27 +136,12 @@ struct TerminalView: View { // 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, - options: commandOptions - ) - .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) + if let surfaceView = lastFocusedSurface.value { + TerminalCommandPaletteView( + surfaceView: surfaceView, + isPresented: $viewModel.commandPaletteIsShowing, + ghosttyConfig: ghostty.config) { action in + print(action) } } } From afd4ec6de267e0deb27099fd8ce9604d9cceb031 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 21 Apr 2025 09:32:39 -0700 Subject: [PATCH 06/16] macOS: command palette "enter" works --- .../Command Palette/CommandPalette.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 09425f471..71fc7ae4d 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -55,6 +55,9 @@ struct CommandPaletteView: View { case .submit: isPresented = false + if selectedIndex < filteredOptions.count { + filteredOptions[Int(selectedIndex)].action() + } case .move(.up): if selectedIndex > 0 { @@ -79,7 +82,10 @@ struct CommandPaletteView: View { options: options, query: $query, selectedIndex: $selectedIndex, - hoveredOptionID: $hoveredOptionID) + hoveredOptionID: $hoveredOptionID) { option in + isPresented = false + option.action() + } } .frame(width: 500) .background(backgroundColor) @@ -146,6 +152,7 @@ fileprivate struct CommandTable: View { @Binding var query: String @Binding var selectedIndex: UInt @Binding var hoveredOptionID: UUID? + var action: (CommandOption) -> Void // The options that we should show, taking into account any filtering from // the query. @@ -171,7 +178,9 @@ fileprivate struct CommandTable: View { option: option, isSelected: selectedIndex == index, hoveredID: $hoveredOptionID - ) + ) { + action(option) + } } } } @@ -194,9 +203,10 @@ fileprivate struct CommandRow: View { let option: CommandOption var isSelected: Bool @Binding var hoveredID: UUID? + var action: () -> Void var body: some View { - Button(action: option.action) { + Button(action: action) { HStack { Text(option.title.lowercased()) Spacer() From 8bd91e71041145377eb685d4e804429aaf35d6f4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 21 Apr 2025 09:40:02 -0700 Subject: [PATCH 07/16] macOS: hook up full action execution --- .../Features/Command Palette/CommandPalette.swift | 1 - .../Features/Terminal/BaseTerminalController.swift | 9 +++++++++ macos/Sources/Features/Terminal/TerminalView.swift | 5 ++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 71fc7ae4d..33b97585a 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -47,7 +47,6 @@ struct CommandPaletteView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - // Prompt Field CommandPaletteQuery(query: $query) { event in switch (event) { case .exit: diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index b9a1def1b..d4e7dfb45 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -291,6 +291,15 @@ class BaseTerminalController: NSWindowController, func zoomStateDidChange(to: Bool) {} + func performAction(_ action: String, on surfaceView: Ghostty.SurfaceView) { + guard let surface = surfaceView.surface else { return } + let len = action.utf8CString.count + if (len == 0) { return } + _ = action.withCString { cString in + ghostty_surface_binding_action(surface, cString, UInt(len - 1)) + } + } + // MARK: Fullscreen /// Toggle fullscreen for the given mode. diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index e3c4f04b7..1bc0603a9 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -23,6 +23,9 @@ protocol TerminalViewDelegate: AnyObject { /// This is called when a split is zoomed. func zoomStateDidChange(to: Bool) + + /// Perform an action. At the time of writing this is only triggered by the command palette. + func performAction(_ action: String, on: Ghostty.SurfaceView) } /// The view model is a required implementation for TerminalView callers. This contains @@ -141,7 +144,7 @@ struct TerminalView: View { surfaceView: surfaceView, isPresented: $viewModel.commandPaletteIsShowing, ghosttyConfig: ghostty.config) { action in - print(action) + self.delegate?.performAction(action, on: surfaceView) } } } From 6d2685b5a2353e95f697f4be9c1e603a4c886211 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 21 Apr 2025 09:52:21 -0700 Subject: [PATCH 08/16] add toggle command palette binding --- include/ghostty.h | 1 + macos/Sources/App/macOS/AppDelegate.swift | 2 ++ macos/Sources/App/macOS/MainMenu.xib | 11 ++++++-- .../Terminal/BaseTerminalController.swift | 15 +++++++++++ .../Features/Terminal/TerminalView.swift | 10 -------- macos/Sources/Ghostty/Ghostty.App.swift | 25 +++++++++++++++++++ macos/Sources/Ghostty/Package.swift | 1 + src/Surface.zig | 6 +++++ src/apprt/action.zig | 6 +++++ src/apprt/glfw.zig | 1 + src/apprt/gtk/App.zig | 1 + src/config/Config.zig | 7 ++++++ src/input/Binding.zig | 9 +++++++ src/input/command.zig | 1 + 14 files changed, 84 insertions(+), 12 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 05e15c54f..3fd582077 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -579,6 +579,7 @@ typedef enum { GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW, GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS, GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL, + GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE, GHOSTTY_ACTION_TOGGLE_VISIBILITY, GHOSTTY_ACTION_MOVE_TAB, GHOSTTY_ACTION_GOTO_TAB, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 2d83c0074..75d0ef7ac 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -59,6 +59,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuChangeTitle: NSMenuItem? @IBOutlet private var menuQuickTerminal: NSMenuItem? @IBOutlet private var menuTerminalInspector: NSMenuItem? + @IBOutlet private var menuCommandPalette: NSMenuItem? @IBOutlet private var menuEqualizeSplits: NSMenuItem? @IBOutlet private var menuMoveSplitDividerUp: NSMenuItem? @@ -402,6 +403,7 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal) syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility) syncMenuShortcut(config, action: "inspector:toggle", menuItem: self.menuTerminalInspector) + syncMenuShortcut(config, action: "toggle_command_palette", menuItem: self.menuCommandPalette) syncMenuShortcut(config, action: "toggle_secure_input", menuItem: self.menuSecureInput) diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index 88db6ed01..8f7b16aa9 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -1,8 +1,8 @@ - + - + @@ -21,6 +21,7 @@ + @@ -249,6 +250,12 @@ + + + + + + diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index d4e7dfb45..d73e85111 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -110,6 +110,11 @@ class BaseTerminalController: NSWindowController, selector: #selector(ghosttyConfigDidChangeBase(_:)), name: .ghosttyConfigDidChange, object: nil) + center.addObserver( + self, + selector: #selector(ghosttyCommandPaletteDidToggle(_:)), + name: .ghosttyCommandPaletteDidToggle, + object: nil) // Listen for local events that we need to know of outside of // single surface handlers. @@ -222,6 +227,12 @@ class BaseTerminalController: NSWindowController, self.derivedConfig = DerivedConfig(config) } + @objc private func ghosttyCommandPaletteDidToggle(_ notification: Notification) { + guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } + guard surfaceTree?.contains(view: surfaceView) ?? false else { return } + toggleCommandPalette(nil) + } + // MARK: Local Events private func localEventHandler(_ event: NSEvent) -> NSEvent? { @@ -631,6 +642,10 @@ class BaseTerminalController: NSWindowController, ghostty.changeFontSize(surface: surface, .reset) } + @IBAction func toggleCommandPalette(_ sender: Any?) { + commandPaletteIsShowing.toggle() + } + @objc func resetTerminal(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.resetTerminal(surface: surface) diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 1bc0603a9..758ee4b81 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -93,16 +93,6 @@ struct TerminalView: View { DebugBuildWarningView() } - HStack { - Spacer() - Button("Command Palette") { - viewModel.commandPaletteIsShowing.toggle() - } - Spacer() - } - .background(Color(.windowBackgroundColor)) - .frame(maxWidth: .infinity) - Ghostty.TerminalSplit(node: $viewModel.surfaceTree) .environmentObject(ghostty) .focused($focused) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index d7fd0c777..677129960 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -520,6 +520,9 @@ extension Ghostty { case GHOSTTY_ACTION_RENDERER_HEALTH: rendererHealth(app, target: target, v: action.action.renderer_health) + case GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE: + toggleCommandPalette(app, target: target) + case GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL: toggleQuickTerminal(app, target: target) @@ -742,6 +745,28 @@ extension Ghostty { } } + private static func toggleCommandPalette( + _ app: ghostty_app_t, + target: ghostty_target_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("toggle command palette does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + NotificationCenter.default.post( + name: .ghosttyCommandPaletteDidToggle, + object: surfaceView + ) + + + default: + assertionFailure() + } + } + private static func toggleVisibility( _ app: ghostty_app_t, target: ghostty_target_s diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 3afca56aa..e2c770899 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -256,6 +256,7 @@ extension Notification.Name { /// Ring the bell static let ghosttyBellDidRing = Notification.Name("com.mitchellh.ghostty.ghosttyBellDidRing") + static let ghosttyCommandPaletteDidToggle = Notification.Name("com.mitchellh.ghostty.commandPaletteDidToggle") } // NOTE: I am moving all of these to Notification.Name extensions over time. This diff --git a/src/Surface.zig b/src/Surface.zig index b9eb9e14a..c776fed36 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4295,6 +4295,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .toggle, ), + .toggle_command_palette => return try self.rt_app.performAction( + .{ .surface = self }, + .toggle_command_palette, + {}, + ), + .select_all => { const sel = self.io.terminal.screen.selectAll(); if (sel) |s| { diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 30cbfb1e1..da0ebf8e6 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -107,6 +107,9 @@ pub const Action = union(Key) { /// Toggle the quick terminal in or out. toggle_quick_terminal, + /// Toggle the command palette. This currently only works on macOS. + toggle_command_palette, + /// Toggle the visibility of all Ghostty terminal windows. toggle_visibility, @@ -244,6 +247,8 @@ pub const Action = union(Key) { /// Closes the currently focused window. close_window, + /// Called when the bell character is seen. The apprt should do whatever + /// it needs to ring the bell. This is usually a sound or visual effect. ring_bell, /// Sync with: ghostty_action_tag_e @@ -259,6 +264,7 @@ pub const Action = union(Key) { toggle_tab_overview, toggle_window_decorations, toggle_quick_terminal, + toggle_command_palette, toggle_visibility, move_tab, goto_tab, diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index c5ee802c4..66b994051 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -228,6 +228,7 @@ pub const App = struct { .toggle_tab_overview, .toggle_window_decorations, .toggle_quick_terminal, + .toggle_command_palette, .toggle_visibility, .goto_tab, .move_tab, diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index a14383ca3..72c0d7509 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -488,6 +488,7 @@ pub fn performAction( // Unimplemented .close_all_windows, + .toggle_command_palette, .toggle_visibility, .cell_size, .key_sequence, diff --git a/src/config/Config.zig b/src/config/Config.zig index f243a88a0..f71e0972d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4866,6 +4866,13 @@ pub const Keybinds = struct { .{ .jump_to_prompt = 1 }, ); + // Toggle command palette, matches VSCode + try self.set.put( + alloc, + .{ .key = .{ .translated = .p }, .mods = .{ .super = true, .shift = true } }, + .{ .toggle_command_palette = {} }, + ); + // Inspector, matching Chromium try self.set.put( alloc, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 0b9ae1136..1a2961a53 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -441,6 +441,14 @@ pub const Action = union(enum) { /// This only works on macOS, since this is a system API on macOS. toggle_secure_input: void, + /// Toggle the command palette. The command palette is a UI element + /// that lets you see what actions you can perform, their associated + /// keybindings (if any), a search bar to filter the actions, and + /// the ability to then execute the action. + /// + /// This only works on macOS. + toggle_command_palette, + /// Toggle the "quick" terminal. The quick terminal is a terminal that /// appears on demand from a keybinding, often sliding in from a screen /// edge such as the top. This is useful for quick access to a terminal @@ -790,6 +798,7 @@ pub const Action = union(enum) { .toggle_fullscreen, .toggle_window_decorations, .toggle_secure_input, + .toggle_command_palette, .reset_window_size, .crash, => .surface, diff --git a/src/input/command.zig b/src/input/command.zig index 51bcbaad6..6c6e3a55b 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -365,6 +365,7 @@ fn actionCommands(action: Action.Key) []const Command { // No commands because I'm not sure they make sense in a command // palette context. + .toggle_command_palette, .toggle_quick_terminal, .toggle_visibility, .previous_tab, From 63b4cb4ead3a06789186db0e3f00c06497e49f14 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 21 Apr 2025 10:16:52 -0700 Subject: [PATCH 09/16] macOS: fix responder chain --- .../TerminalCommandPalette.swift | 18 ++++++++++++++++++ .../Terminal/BaseTerminalController.swift | 1 + .../Features/Terminal/TerminalView.swift | 3 +-- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 7b6676aad..29ce28906 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -43,6 +43,9 @@ struct TerminalCommandPaletteView: View { VStack { Spacer().frame(height: geometry.size.height * 0.1) + ResponderChainInjector(responder: surfaceView) + .frame(width: 0, height: 0) + CommandPaletteView( isPresented: $isPresented, backgroundColor: ghosttyConfig.backgroundColor, @@ -75,3 +78,18 @@ struct TerminalCommandPaletteView: View { } } } + +/// This is done to ensure that the given view is in the responder chain. +fileprivate struct ResponderChainInjector: NSViewRepresentable { + let responder: NSResponder + + func makeNSView(context: Context) -> NSView { + let dummy = NSView() + DispatchQueue.main.async { + dummy.nextResponder = responder + } + return dummy + } + + func updateNSView(_ nsView: NSView, context: Context) {} +} diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index d73e85111..b502e56e0 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -152,6 +152,7 @@ class BaseTerminalController: NSWindowController, // Our focus state requires that this window is key and our currently // focused surface is the surface in this leaf. let focused: Bool = (window?.isKeyWindow ?? false) && + !commandPaletteIsShowing && focusedSurface != nil && leaf.surface == focusedSurface! leaf.surface.focusDidChange(focused) diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 758ee4b81..1178c75a5 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -102,9 +102,8 @@ struct TerminalView: View { // we lose focus we keep this set to the last non-nil value. if newValue != nil { lastFocusedSurface = .init(newValue) + self.delegate?.focusedSurfaceDidChange(to: newValue) } - - self.delegate?.focusedSurfaceDidChange(to: newValue) } .onChange(of: title) { newValue in self.delegate?.titleDidChange(to: newValue) From 6dad763e69877b17a09f6ea5634b2d0112fb0b9e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 21 Apr 2025 10:20:25 -0700 Subject: [PATCH 10/16] input: omit commands that are platform-specific --- src/input/command.zig | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/input/command.zig b/src/input/command.zig index 6c6e3a55b..017a14a18 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const builtin = @import("builtin"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; const Action = @import("binding.zig").Action; @@ -219,11 +220,11 @@ fn actionCommands(action: Action.Key) []const Command { }, }, - .toggle_tab_overview => comptime &.{.{ + .toggle_tab_overview => comptime if (builtin.os.tag == .linux) &.{.{ .action = .toggle_tab_overview, .title = "Toggle Tab Overview", .description = "Toggle the tab overview.", - }}, + }} else &.{}, .prompt_surface_title => comptime &.{.{ .action = .prompt_surface_title, @@ -314,11 +315,11 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Close all windows.", }}, - .toggle_maximize => comptime &.{.{ + .toggle_maximize => comptime if (!builtin.os.tag.isDarwin()) &.{.{ .action = .toggle_maximize, .title = "Toggle Maximize", .description = "Toggle the maximized state of the current window.", - }}, + }} else &.{}, .toggle_fullscreen => comptime &.{.{ .action = .toggle_fullscreen, @@ -326,17 +327,17 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Toggle the fullscreen state of the current window.", }}, - .toggle_window_decorations => comptime &.{.{ + .toggle_window_decorations => comptime if (!builtin.os.tag.isDarwin()) &.{.{ .action = .toggle_window_decorations, .title = "Toggle Window Decorations", .description = "Toggle the window decorations.", - }}, + }} else &.{}, - .toggle_secure_input => comptime &.{.{ + .toggle_secure_input => comptime if (builtin.os.tag.isDarwin()) &.{.{ .action = .toggle_secure_input, .title = "Toggle Secure Input", .description = "Toggle secure input mode.", - }}, + }} else &.{}, .quit => comptime &.{.{ .action = .quit, From baad08243883212133644eaaa90daa8c67258822 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 21 Apr 2025 10:26:18 -0700 Subject: [PATCH 11/16] macOS: command palette selection tweaks --- .../Command Palette/CommandPalette.swift | 38 +++++++++---------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 33b97585a..24677debc 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -45,6 +45,14 @@ struct CommandPaletteView: View { } } + var selectedOption: CommandOption? { + if selectedIndex < filteredOptions.count { + filteredOptions[Int(selectedIndex)] + } else { + filteredOptions.last + } + } + var body: some View { VStack(alignment: .leading, spacing: 0) { CommandPaletteQuery(query: $query) { event in @@ -54,9 +62,7 @@ struct CommandPaletteView: View { case .submit: isPresented = false - if selectedIndex < filteredOptions.count { - filteredOptions[Int(selectedIndex)].action() - } + selectedOption?.action() case .move(.up): if selectedIndex > 0 { @@ -78,8 +84,7 @@ struct CommandPaletteView: View { .padding(.bottom, 4) CommandTable( - options: options, - query: $query, + options: filteredOptions, selectedIndex: $selectedIndex, hoveredOptionID: $hoveredOptionID) { option in isPresented = false @@ -148,23 +153,12 @@ fileprivate struct CommandPaletteQuery: View { fileprivate struct CommandTable: View { var options: [CommandOption] = CommandOption.sampleData - @Binding var query: String @Binding var selectedIndex: UInt @Binding var hoveredOptionID: UUID? var action: (CommandOption) -> Void - // 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 { + if options.isEmpty { Text("No matches") .foregroundStyle(.secondary) .padding() @@ -172,10 +166,12 @@ fileprivate struct CommandTable: View { ScrollViewReader { proxy in ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { - ForEach(Array(filteredOptions.enumerated()), id: \.1.id) { index, option in + ForEach(Array(options.enumerated()), id: \.1.id) { index, option in CommandRow( option: option, - isSelected: selectedIndex == index, + isSelected: selectedIndex == index || + (selectedIndex >= options.count && + index == options.count - 1), hoveredID: $hoveredOptionID ) { action(option) @@ -185,10 +181,10 @@ fileprivate struct CommandTable: View { } .frame(height: 200) .onChange(of: selectedIndex) { _ in - guard selectedIndex < filteredOptions.count else { return } + guard selectedIndex < options.count else { return } withAnimation { proxy.scrollTo( - filteredOptions[Int(selectedIndex)].id, + options[Int(selectedIndex)].id, anchor: .center) } } From e33eed0216b49ba4ee8a2d4eacdbca9bada67417 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 21 Apr 2025 10:32:07 -0700 Subject: [PATCH 12/16] macOS: command palette visual tweaks --- .../Command Palette/CommandPalette.swift | 26 ++++++++++++++----- .../TerminalCommandPalette.swift | 2 +- .../Helpers/KeyboardShortcut+Extension.swift | 2 +- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 24677debc..887ea3464 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -91,10 +91,16 @@ struct CommandPaletteView: View { option.action() } } - .frame(width: 500) - .background(backgroundColor) - .cornerRadius(12) - .shadow(radius: 20) + .frame(maxWidth: 500) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(backgroundColor) + .shadow(color: .black.opacity(0.4), radius: 10, x: 0, y: 10) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.black.opacity(0.1), lineWidth: 1) + ) + ) .padding() } } @@ -179,7 +185,7 @@ fileprivate struct CommandTable: View { } } } - .frame(height: 200) + .frame(maxHeight: 200) .onChange(of: selectedIndex) { _ in guard selectedIndex < options.count else { return } withAnimation { @@ -207,8 +213,14 @@ fileprivate struct CommandRow: View { Spacer() if let shortcut = option.shortcut { Text(shortcut) - .foregroundStyle(.secondary) - .font(.system(size: 12)) + .font(.system(.body, design: .monospaced)) + .kerning(1.5) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.gray.opacity(0.2)) + ) } } .padding(.horizontal, 6) diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 29ce28906..fe23d5bf8 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -41,7 +41,7 @@ struct TerminalCommandPaletteView: View { if isPresented { GeometryReader { geometry in VStack { - Spacer().frame(height: geometry.size.height * 0.1) + Spacer().frame(height: geometry.size.height * 0.05) ResponderChainInjector(responder: surfaceView) .frame(width: 0, height: 0) diff --git a/macos/Sources/Helpers/KeyboardShortcut+Extension.swift b/macos/Sources/Helpers/KeyboardShortcut+Extension.swift index b953f5755..9b5855757 100644 --- a/macos/Sources/Helpers/KeyboardShortcut+Extension.swift +++ b/macos/Sources/Helpers/KeyboardShortcut+Extension.swift @@ -29,7 +29,7 @@ extension KeyboardShortcut: @retroactive CustomStringConvertible { case .leftArrow: keyString = "←" case .rightArrow: keyString = "→" default: - keyString = String(key.character) + keyString = String(key.character.uppercased()) } result.append(keyString) From a732bb272d4c0da8d9210314a3d8993aeea5cb50 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 21 Apr 2025 10:50:34 -0700 Subject: [PATCH 13/16] fix CI --- .../Command Palette/CommandPalette.swift | 16 ++-------------- src/input/command.zig | 2 +- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 887ea3464..09b216a39 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -13,24 +13,12 @@ struct CommandOption: Identifiable, Hashable { 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 + var options: [CommandOption] @State private var query = "" @State private var selectedIndex: UInt = 0 @State private var hoveredOptionID: UUID? = nil @@ -158,7 +146,7 @@ fileprivate struct CommandPaletteQuery: View { } fileprivate struct CommandTable: View { - var options: [CommandOption] = CommandOption.sampleData + var options: [CommandOption] @Binding var selectedIndex: UInt @Binding var hoveredOptionID: UUID? var action: (CommandOption) -> Void diff --git a/src/input/command.zig b/src/input/command.zig index 017a14a18..a36232d48 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -2,7 +2,7 @@ const std = @import("std"); const builtin = @import("builtin"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; -const Action = @import("binding.zig").Action; +const Action = @import("Binding.zig").Action; /// A command is a named binding action that can be executed from /// something like a command palette. From 28404e946b196f0d6e62ad3e602d2426b8fa0b24 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 21 Apr 2025 17:13:00 -0700 Subject: [PATCH 14/16] order commands alphabetically and preserve capitalization --- .../Features/Command Palette/CommandPalette.swift | 2 +- src/input/command.zig | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 09b216a39..943cc7846 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -197,7 +197,7 @@ fileprivate struct CommandRow: View { var body: some View { Button(action: action) { HStack { - Text(option.title.lowercased()) + Text(option.title) Spacer() if let shortcut = option.shortcut { Text(shortcut) diff --git a/src/input/command.zig b/src/input/command.zig index a36232d48..dcfcd0b39 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -38,9 +38,19 @@ pub const Command = struct { .description = self.description, }; } + + /// Implements a comparison function for std.mem.sortUnstable + /// and similar functions. The sorting is defined by Ghostty + /// to be what we prefer. If a caller wants some other sorting, + /// they should do it themselves. + pub fn lessThan(_: void, lhs: Command, rhs: Command) bool { + return std.ascii.orderIgnoreCase(lhs.title, rhs.title) == .lt; + } }; pub const defaults: []const Command = defaults: { + @setEvalBranchQuota(100_000); + var count: usize = 0; for (@typeInfo(Action.Key).@"enum".fields) |field| { const action = @field(Action.Key, field.name); @@ -58,6 +68,8 @@ pub const defaults: []const Command = defaults: { } } + std.mem.sortUnstable(Command, &result, {}, Command.lessThan); + assert(i == count); const final = result; break :defaults &final; From 3e5fe5de9a02e2abf8f7425a7ce70c082a014a13 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 22 Apr 2025 08:33:24 -0700 Subject: [PATCH 15/16] move command filtering into apprt --- include/ghostty.h | 1 + .../TerminalCommandPalette.swift | 12 +++++++++++- src/input/command.zig | 19 ++++++++++--------- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 3fd582077..3db280c93 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -280,6 +280,7 @@ typedef struct { } ghostty_input_trigger_s; typedef struct { + const char* action_key; const char* action; const char* title; const char* description; diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index fe23d5bf8..e0c8435af 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -25,7 +25,17 @@ struct TerminalCommandPaletteView: View { guard let ptr else { return [] } let buffer = UnsafeBufferPointer(start: ptr, count: count) - return Array(buffer).map { c in + return Array(buffer).filter { c in + let key = String(cString: c.action_key) + switch (key) { + case "toggle_tab_overview", + "toggle_maximize", + "toggle_window_decorations": + return false + default: + return true + } + }.map { c in let action = String(cString: c.action) return CommandOption( title: String(cString: c.title), diff --git a/src/input/command.zig b/src/input/command.zig index dcfcd0b39..c757736c7 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const builtin = @import("builtin"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; const Action = @import("Binding.zig").Action; @@ -23,6 +22,7 @@ pub const Command = struct { /// ghostty_command_s pub const C = extern struct { + action_key: [*:0]const u8, action: [*:0]const u8, title: [*:0]const u8, description: [*:0]const u8, @@ -33,6 +33,7 @@ pub const Command = struct { assert(@inComptime()); return .{ + .action_key = @tagName(self.action), .action = std.fmt.comptimePrint("{s}", .{self.action}), .title = self.title, .description = self.description, @@ -232,11 +233,11 @@ fn actionCommands(action: Action.Key) []const Command { }, }, - .toggle_tab_overview => comptime if (builtin.os.tag == .linux) &.{.{ + .toggle_tab_overview => comptime &.{.{ .action = .toggle_tab_overview, .title = "Toggle Tab Overview", .description = "Toggle the tab overview.", - }} else &.{}, + }}, .prompt_surface_title => comptime &.{.{ .action = .prompt_surface_title, @@ -327,11 +328,11 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Close all windows.", }}, - .toggle_maximize => comptime if (!builtin.os.tag.isDarwin()) &.{.{ + .toggle_maximize => comptime &.{.{ .action = .toggle_maximize, .title = "Toggle Maximize", .description = "Toggle the maximized state of the current window.", - }} else &.{}, + }}, .toggle_fullscreen => comptime &.{.{ .action = .toggle_fullscreen, @@ -339,17 +340,17 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Toggle the fullscreen state of the current window.", }}, - .toggle_window_decorations => comptime if (!builtin.os.tag.isDarwin()) &.{.{ + .toggle_window_decorations => comptime &.{.{ .action = .toggle_window_decorations, .title = "Toggle Window Decorations", .description = "Toggle the window decorations.", - }} else &.{}, + }}, - .toggle_secure_input => comptime if (builtin.os.tag.isDarwin()) &.{.{ + .toggle_secure_input => comptime &.{.{ .action = .toggle_secure_input, .title = "Toggle Secure Input", .description = "Toggle secure input mode.", - }} else &.{}, + }}, .quit => comptime &.{.{ .action = .quit, From 5427b0b507a3b35b29c50f9fb85316b40791a805 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 22 Apr 2025 08:36:12 -0700 Subject: [PATCH 16/16] macOS: add description as hover tooltip --- macos/Sources/Features/Command Palette/CommandPalette.swift | 2 ++ .../Features/Command Palette/TerminalCommandPalette.swift | 1 + 2 files changed, 3 insertions(+) diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 943cc7846..cad93aa22 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -3,6 +3,7 @@ import SwiftUI struct CommandOption: Identifiable, Hashable { let id = UUID() let title: String + let description: String? let shortcut: String? let action: () -> Void @@ -222,6 +223,7 @@ fileprivate struct CommandRow: View { ) .cornerRadius(6) } + .help(option.description ?? "") .buttonStyle(PlainButtonStyle()) .onHover { hovering in hoveredID = hovering ? option.id : nil diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index e0c8435af..2e895d4d9 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -39,6 +39,7 @@ struct TerminalCommandPaletteView: View { let action = String(cString: c.action) return CommandOption( title: String(cString: c.title), + description: String(cString: c.description), shortcut: ghosttyConfig.keyboardShortcut(for: action)?.description ) { onAction(action)