mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-08-02 14:57:31 +03:00
macOS: Command Palette (#7153)
This introduces a command palette (inspired by @pluiedev's work in #5681, but not using it as a base) for macOS. The command palette is available in the `View` menu and also bindable via `toggle_command_palette`, default binding is `cmd+shift+p` to match VSCode. The commands in the command palette must map to a _bindable_ action, though they may not have an associated keybinding. This means that any new binding actions we add in the future can be represented here and also makes it easy in the future to add configuration to add new custom entries to the command palette. For this initial PR, the available commands are hardcoded (`src/input/commands.zig`). I've noticed in other programs (VSCode, Zed), the command palette contains pretty much _all available actions_ even if they're basically useless in the context of a command palette. For example, Zed has the "toggle command palette" action in the command palette and it... does nothing (it probably should hide the palette). I followed @pluiedev's lead and made this subjective in this PR but I wonder if we should actually force all binding actions to be available. There are various other improvements I'd like to make but omitted from this PR for the sake of limiting scope: * Instead of an entry with no matches doing nothing, we can allow users to manually input _any_ configurable binding. * Localization, since macOS doesn't have any yet. But for Linux when we port this we probably have to change our strings extraction. ## Demo https://github.com/user-attachments/assets/a2155cfb-d86b-4c1a-82b5-74ba927e4d69
This commit is contained in:
@ -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);
|
||||
|
@ -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 = "<group>"; };
|
||||
A53426342A7DA53D00EBB7A2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
A535B9D9299C569B0017E2E4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; };
|
||||
A53A297A2DB2E49400B6E02C /* CommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPalette.swift; sourceTree = "<group>"; };
|
||||
A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EventModifiers+Extension.swift"; sourceTree = "<group>"; };
|
||||
A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyboardShortcut+Extension.swift"; sourceTree = "<group>"; };
|
||||
A53A29872DB69D2C00B6E02C /* TerminalCommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCommandPalette.swift; sourceTree = "<group>"; };
|
||||
A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Action.swift; sourceTree = "<group>"; };
|
||||
A53D0C932B53B43700305CE6 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; };
|
||||
A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.App.swift; sourceTree = "<group>"; };
|
||||
@ -271,6 +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 = "<group>";
|
||||
};
|
||||
A53A29742DB2E04900B6E02C /* Command Palette */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A53A297A2DB2E49400B6E02C /* CommandPalette.swift */,
|
||||
A53A29872DB69D2C00B6E02C /* TerminalCommandPalette.swift */,
|
||||
);
|
||||
path = "Command Palette";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 */,
|
||||
|
@ -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)
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="23504" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="23727" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<dependencies>
|
||||
<deployment identifier="macosx"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23504"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23727"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
|
||||
@ -21,6 +21,7 @@
|
||||
<outlet property="menuCloseAllWindows" destination="yKr-Vi-Yqw" id="Zet-Ir-zbm"/>
|
||||
<outlet property="menuCloseTab" destination="Obb-Mk-j8J" id="Gda-L0-gdz"/>
|
||||
<outlet property="menuCloseWindow" destination="W5w-UZ-crk" id="6ff-BT-ENV"/>
|
||||
<outlet property="menuCommandPalette" destination="et6-de-Mh7" id="53t-cu-dm5"/>
|
||||
<outlet property="menuCopy" destination="Jqf-pv-Zcu" id="bKd-1C-oy9"/>
|
||||
<outlet property="menuDecreaseFontSize" destination="kzb-SZ-dOA" id="Y1B-Vh-6Z2"/>
|
||||
<outlet property="menuEqualizeSplits" destination="3gH-VD-vL9" id="SiZ-ce-FOF"/>
|
||||
@ -249,6 +250,12 @@
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem isSeparatorItem="YES" id="L3L-I8-sqk"/>
|
||||
<menuItem title="Command Palette" id="et6-de-Mh7">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="toggleCommandPalette:" target="-1" id="FcT-XD-gM1"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Change Title..." id="24I-xg-qIq">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
|
234
macos/Sources/Features/Command Palette/CommandPalette.swift
Normal file
234
macos/Sources/Features/Command Palette/CommandPalette.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
@ -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<ghostty_command_s>? = 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) {}
|
||||
}
|
@ -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)
|
||||
@ -219,6 +228,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? {
|
||||
@ -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)
|
||||
|
@ -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<ViewModel: TerminalViewModel>: 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<Ghostty.SurfaceView> = .init()
|
||||
|
||||
// This seems like a crutch after switching from SwiftUI to AppKit lifecycle.
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
@ -75,6 +85,7 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
|
||||
case .error:
|
||||
ErrorView()
|
||||
case .ready:
|
||||
ZStack {
|
||||
VStack(spacing: 0) {
|
||||
// If we're running in debug mode we show a warning so that users
|
||||
// know that performance will be degraded.
|
||||
@ -87,8 +98,13 @@ struct TerminalView<ViewModel: TerminalViewModel>: 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
|
||||
self.delegate?.titleDidChange(to: newValue)
|
||||
}
|
||||
@ -111,6 +127,16 @@ struct TerminalView<ViewModel: TerminalViewModel>: 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 let surfaceView = lastFocusedSurface.value {
|
||||
TerminalCommandPaletteView(
|
||||
surfaceView: surfaceView,
|
||||
isPresented: $viewModel.commandPaletteIsShowing,
|
||||
ghosttyConfig: ghostty.config) { action in
|
||||
self.delegate?.performAction(action, on: surfaceView)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -3,7 +3,7 @@
|
||||
class Weak<T: AnyObject> {
|
||||
weak var value: T?
|
||||
|
||||
init(_ value: T) {
|
||||
init(_ value: T? = nil) {
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
|
@ -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| {
|
||||
|
@ -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,
|
||||
|
@ -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.
|
||||
|
@ -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,
|
||||
|
@ -488,6 +488,7 @@ pub fn performAction(
|
||||
|
||||
// Unimplemented
|
||||
.close_all_windows,
|
||||
.toggle_command_palette,
|
||||
.toggle_visibility,
|
||||
.cell_size,
|
||||
.key_sequence,
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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.
|
||||
///
|
||||
|
408
src/input/command.zig
Normal file
408
src/input/command.zig
Normal file
@ -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);
|
||||
}
|
Reference in New Issue
Block a user