diff --git a/include/ghostty.h b/include/ghostty.h index c4ef11930..3db280c93 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -279,6 +279,13 @@ typedef struct { ghostty_input_mods_e mods; } ghostty_input_trigger_s; +typedef struct { + const char* action_key; + const char* action; + const char* title; + const char* description; +} ghostty_command_s; + typedef enum { GHOSTTY_BUILD_MODE_DEBUG, GHOSTTY_BUILD_MODE_RELEASE_SAFE, @@ -573,6 +580,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, @@ -724,6 +732,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/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 5d02ba12b..a34c4685f 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -34,8 +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 */; }; + 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 */; }; @@ -140,8 +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 = ""; }; + 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 = ""; }; @@ -271,6 +275,7 @@ A5CBD05A2CA0C5910017A1AE /* QuickTerminal */, A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */, A57D79252C9C8782001D522E /* Secure Input */, + A53A29742DB2E04900B6E02C /* Command Palette */, A534263E2A7DCC5800EBB7A2 /* Settings */, A51BFC1C2B2FB5AB00E92F16 /* About */, A54B0CE72D0CEC9800CBEFF8 /* Colorized Ghostty Icon */, @@ -325,6 +330,15 @@ path = Settings; sourceTree = ""; }; + A53A29742DB2E04900B6E02C /* Command Palette */ = { + isa = PBXGroup; + children = ( + A53A297A2DB2E49400B6E02C /* CommandPalette.swift */, + A53A29872DB69D2C00B6E02C /* TerminalCommandPalette.swift */, + ); + path = "Command Palette"; + sourceTree = ""; + }; A53D0C912B53B41900305CE6 /* App */ = { isa = PBXGroup; children = ( @@ -699,6 +713,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 */, @@ -714,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/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 14a59e0f2..682099e92 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? @@ -406,6 +407,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/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift new file mode 100644 index 000000000..cad93aa22 --- /dev/null +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -0,0 +1,234 @@ +import SwiftUI + +struct CommandOption: Identifiable, Hashable { + let id = UUID() + let title: String + let description: 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) + } +} + +struct CommandPaletteView: View { + @Binding var isPresented: Bool + var backgroundColor: Color = Color(nsColor: .windowBackgroundColor) + var options: [CommandOption] + @State private var query = "" + @State private var selectedIndex: UInt = 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 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 + switch (event) { + case .exit: + isPresented = false + + case .submit: + isPresented = false + selectedOption?.action() + + case .move(.up): + if selectedIndex > 0 { + selectedIndex -= 1 + } + + case .move(.down): + if selectedIndex < filteredOptions.count - 1 { + selectedIndex += 1 + } + + case .move(_): + // Unknown, ignore + break + } + } + + Divider() + .padding(.bottom, 4) + + CommandTable( + options: filteredOptions, + selectedIndex: $selectedIndex, + hoveredOptionID: $hoveredOptionID) { option in + isPresented = false + option.action() + } + } + .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() + } +} + +/// 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] + @Binding var selectedIndex: UInt + @Binding var hoveredOptionID: UUID? + var action: (CommandOption) -> Void + + var body: some View { + if options.isEmpty { + Text("No matches") + .foregroundStyle(.secondary) + .padding() + } else { + ScrollViewReader { proxy in + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(options.enumerated()), id: \.1.id) { index, option in + CommandRow( + option: option, + isSelected: selectedIndex == index || + (selectedIndex >= options.count && + index == options.count - 1), + hoveredID: $hoveredOptionID + ) { + action(option) + } + } + } + } + .frame(maxHeight: 200) + .onChange(of: selectedIndex) { _ in + guard selectedIndex < options.count else { return } + withAnimation { + proxy.scrollTo( + options[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 action: () -> Void + + var body: some View { + Button(action: action) { + HStack { + Text(option.title) + Spacer() + if let shortcut = option.shortcut { + Text(shortcut) + .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) + .padding(.vertical, 8) + .background( + isSelected + ? Color.accentColor.opacity(0.2) + : (hoveredID == option.id + ? Color.secondary.opacity(0.2) + : Color.clear) + ) + .cornerRadius(6) + } + .help(option.description ?? "") + .buttonStyle(PlainButtonStyle()) + .onHover { hovering in + hoveredID = hovering ? option.id : nil + } + .padding(.horizontal, 4) + .padding(.vertical, 1) + } +} diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift new file mode 100644 index 000000000..2e895d4d9 --- /dev/null +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -0,0 +1,106 @@ +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).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), + description: String(cString: c.description), + 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.05) + + ResponderChainInjector(responder: surfaceView) + .frame(width: 0, height: 0) + + 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) + } + } + } + } +} + +/// 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 3b4b1a2ef..b502e56e0 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 @@ -107,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. @@ -144,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) @@ -209,16 +218,22 @@ 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) } + @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? { @@ -288,6 +303,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. @@ -619,6 +643,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 3d4165e91..1178c75a5 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 @@ -32,6 +35,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. @@ -44,6 +50,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 @@ -75,42 +85,58 @@ 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) - } - .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) + Ghostty.TerminalSplit(node: $viewModel.surfaceTree) + .environmentObject(ghostty) + .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 + 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 let surfaceView = lastFocusedSurface.value { + TerminalCommandPaletteView( + surfaceView: surfaceView, + isPresented: $viewModel.commandPaletteIsShowing, + ghosttyConfig: ghostty.config) { action in + self.delegate?.performAction(action, on: surfaceView) } + } } - // 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 : []) } } } 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/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/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/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) 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 } } 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/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. 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.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..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, @@ -1017,15 +1026,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..c757736c7 --- /dev/null +++ b/src/input/command.zig @@ -0,0 +1,408 @@ +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_key: [*:0]const u8, + 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_key = @tagName(self.action), + .action = std.fmt.comptimePrint("{s}", .{self.action}), + .title = self.title, + .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); + 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; + } + } + + std.mem.sortUnstable(Command, &result, {}, Command.lessThan); + + 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_command_palette, + .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); +}