gtk: implement command palette

This commit is contained in:
Leah Amelia Chen
2025-02-16 00:02:21 +01:00
parent da32534e8a
commit 4b394d7a41
10 changed files with 1009 additions and 3 deletions

View File

@ -599,6 +599,7 @@ typedef enum {
GHOSTTY_ACTION_COLOR_CHANGE,
GHOSTTY_ACTION_RELOAD_CONFIG,
GHOSTTY_ACTION_CONFIG_CHANGE,
GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE,
} ghostty_action_tag_e;
typedef union {

View File

@ -4224,6 +4224,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
{},
),
.toggle_command_palette => return try self.rt_app.performAction(
.{ .surface = self },
.toggle_command_palette,
{},
),
.toggle_secure_input => return try self.rt_app.performAction(
.{ .surface = self },
.secure_input,

View File

@ -230,6 +230,9 @@ pub const Action = union(Key) {
/// for changes.
config_change: ConfigChange,
/// Toggle the command palette
toggle_command_palette,
/// Sync with: ghostty_action_tag_e
pub const Key = enum(c_int) {
quit,
@ -271,6 +274,7 @@ pub const Action = union(Key) {
color_change,
reload_config,
config_change,
toggle_command_palette,
};
/// Sync with: ghostty_action_u

View File

@ -222,6 +222,7 @@ pub const App = struct {
.close_tab,
.toggle_tab_overview,
.toggle_window_decorations,
.toggle_command_palette,
.toggle_quick_terminal,
.toggle_visibility,
.goto_tab,

View File

@ -481,6 +481,7 @@ pub fn performAction(
.toggle_tab_overview => self.toggleTabOverview(target),
.toggle_split_zoom => self.toggleSplitZoom(target),
.toggle_window_decorations => self.toggleWindowDecorations(target),
.toggle_command_palette => return try self.toggleCommandPalette(target),
.quit_timer => self.quitTimer(value),
// Unimplemented
@ -739,7 +740,7 @@ fn toggleWindowDecorations(
.surface => |v| {
const window = v.rt_surface.container.window() orelse {
log.info(
"toggleFullscreen invalid for container={s}",
"toggleWindowDecorations invalid for container={s}",
.{@tagName(v.rt_surface.container)},
);
return;
@ -750,6 +751,27 @@ fn toggleWindowDecorations(
}
}
fn toggleCommandPalette(
_: *App,
target: apprt.Target,
) !bool {
switch (target) {
.app => return false,
.surface => |v| {
const window = v.rt_surface.container.window() orelse {
log.info(
"toggleCommandPalette invalid for container={s}",
.{@tagName(v.rt_surface.container)},
);
return false;
};
window.toggleCommandPalette();
return true;
},
}
}
fn quitTimer(self: *App, mode: apprt.action.QuitTimer) void {
switch (mode) {
.start => self.startQuitTimer(),

View File

@ -24,6 +24,7 @@ const adwaita = @import("adwaita.zig");
const gtk_key = @import("key.zig");
const TabView = @import("TabView.zig");
const HeaderBar = @import("headerbar.zig");
const CommandPalette = @import("command_palette.zig").CommandPalette;
const version = @import("version.zig");
const winproto = @import("winproto.zig");
@ -49,6 +50,8 @@ context_menu: *c.GtkWidget,
/// The libadwaita widget for receiving toast send requests.
toast_overlay: *c.GtkWidget,
command_palette: ?*CommandPalette = null,
/// See adwTabOverviewOpen for why we have this.
adw_tab_overview_focus_timer: ?c.guint = null,
@ -226,6 +229,15 @@ pub fn init(self: *Window, app: *App) !void {
);
c.gtk_box_append(@ptrCast(box), self.toast_overlay);
if (adwaita.versionAtLeast(1, 5, 0)) {
const command_palette = try CommandPalette.new(self);
// We manually reference the command palette here
// to prevent it from being destroyed when the dialog closes
command_palette.ref();
self.command_palette = command_palette;
}
errdefer if (self.command_palette) |palette| palette.unref();
// If we have a tab overview then we can set it on our notebook.
if (self.tab_overview) |tab_overview| {
if (!adwaita.versionAtLeast(1, 4, 0)) unreachable;
@ -427,6 +439,7 @@ pub fn deinit(self: *Window) void {
c.gtk_widget_unparent(@ptrCast(self.context_menu));
self.winproto.deinit(self.app.core_app.alloc);
if (self.command_palette) |palette| palette.unref();
if (self.adw_tab_overview_focus_timer) |timer| {
_ = c.g_source_remove(timer);
@ -544,6 +557,11 @@ pub fn toggleWindowDecorations(self: *Window) void {
self.updateConfig(&self.app.config) catch {};
}
/// Toggle the command palette for this window.
pub fn toggleCommandPalette(self: *Window) void {
if (self.command_palette) |palette| palette.present();
}
/// Grabs focus on the currently selected tab.
pub fn focusCurrentTab(self: *Window) void {
const tab = self.notebook.currentTab() orelse return;
@ -1018,7 +1036,7 @@ fn gtkActionReset(
}
/// Returns the surface to use for an action.
fn actionSurface(self: *Window) ?*CoreSurface {
pub fn actionSurface(self: *Window) ?*CoreSurface {
const tab = self.notebook.currentTab() orelse return null;
const surface = tab.focus_child orelse return null;
return &surface.core_surface;

View File

@ -0,0 +1,519 @@
const std = @import("std");
const Binding = @import("../../input/Binding.zig");
const Window = @import("Window.zig");
const key = @import("key.zig");
const zf = @import("zf");
const gobject = @import("gobject");
const gio = @import("gio");
const gtk = @import("gtk");
const adw = @import("adw");
const log = std.log.scoped(.command_palette);
/// List of "example" commands/queries
const example_queries = [_][:0]const u8{
"new window",
"inspector",
"window decoration",
"clear",
"reload config",
"new split",
"tab overview",
"maximize",
"fullscreen",
"zoom",
};
/// The command palette, which provides a pop-up dialog for searching
/// and running a list of possible "commands", or pre-parametrized actions.
pub const CommandPalette = extern struct {
parent: Parent,
pub const Parent = adw.Dialog;
const Private = struct {
window: *Window,
alloc: std.heap.ArenaAllocator,
list: *CommandListModel,
examples: @TypeOf(example_queries),
example_idx: usize = 0,
// To be filled in during template population
stack: *adw.ViewStack,
search: *gtk.SearchEntry,
actions: *gtk.ListView,
example: *adw.ActionRow,
var offset: c_int = 0;
};
pub const getGObjectType = gobject.ext.defineClass(CommandPalette, .{
.instanceInit = init,
.classInit = Class.init,
.parent_class = &Class.parent,
.private = .{ .Type = Private, .offset = &Private.offset },
});
pub fn new(window: *Window) !*CommandPalette {
const self = gobject.ext.newInstance(CommandPalette, .{});
const priv = self.private();
// TODO: This is bad style in GObject. This should be in `init`, but
// I have no way of smuggling a window pointer through.
priv.window = window;
priv.alloc = std.heap.ArenaAllocator.init(window.app.core_app.alloc);
priv.list = try CommandListModel.new(&priv.alloc);
try priv.list.private().commands.updateBindings(priv.alloc.allocator(), window.app.config.keybind.set);
priv.actions.setModel(priv.list.as(gtk.SelectionModel));
priv.examples = example_queries;
std.crypto.random.shuffle([:0]const u8, &priv.examples);
priv.search.setKeyCaptureWidget(self.as(gtk.Widget));
return self;
}
pub fn present(self: *CommandPalette) void {
self.refreshExample();
self.as(adw.Dialog).present(@ptrCast(self.private().window.window));
}
// Boilerplate
pub fn as(self: *CommandPalette, comptime T: type) *T {
return gobject.ext.as(T, self);
}
pub fn ref(self: *CommandPalette) void {
_ = self.as(gobject.Object).ref();
}
pub fn unref(self: *CommandPalette) void {
self.as(gobject.Object).unref();
}
fn private(self: *CommandPalette) *Private {
return gobject.ext.impl_helpers.getPrivate(self, Private, Private.offset);
}
fn refreshExample(self: *CommandPalette) void {
const priv = self.private();
const example = priv.examples[priv.example_idx];
priv.example.as(adw.PreferencesRow).setTitle(example);
priv.example_idx += 1;
if (priv.example_idx == priv.examples.len) priv.example_idx = 0;
}
fn setQuery(self: *CommandPalette, query: [:0]const u8) void {
const priv = self.private();
priv.list.setQuery(query);
const page = switch (priv.list.state()) {
.prompt => "prompt",
.items => items: {
priv.actions.scrollTo(0, .{ .focus = true }, null);
break :items "items";
},
.not_found => "not-found",
};
priv.stack.setVisibleChildName(page);
}
// Lifecycle functions
fn init(self: *CommandPalette, _: *Class) callconv(.C) void {
self.as(gtk.Widget).initTemplate();
}
fn dispose(self: *CommandPalette) callconv(.C) void {
self.as(gtk.Widget).disposeTemplate(getGObjectType());
gobject.Object.virtual_methods.dispose.call(Class.parent, self.as(Parent));
}
fn finalize(self: *CommandPalette) callconv(.C) void {
self.private().alloc.deinit();
gobject.Object.virtual_methods.finalize.call(Class.parent, self.as(Parent));
}
// Callbacks
fn searchChanged(self: *CommandPalette, _: *gtk.SearchEntry) callconv(.C) void {
const text = self.private().search.as(gtk.Editable).getText();
self.setQuery(std.mem.span(text));
}
fn searchStopped(self: *CommandPalette, _: *gtk.SearchEntry) callconv(.C) void {
_ = self.as(Parent).close();
}
fn searchActivated(self: *CommandPalette, _: *gtk.SearchEntry) callconv(.C) void {
self.activateAction(0, self.private().actions);
}
fn exampleActivated(self: *CommandPalette, row: *adw.ActionRow) callconv(.C) void {
const text = row.as(adw.PreferencesRow).getTitle();
self.private().search.as(gtk.Editable).setText(text);
self.setQuery(std.mem.span(text));
self.refreshExample();
}
fn activateAction(self: *CommandPalette, pos: c_uint, _: *gtk.ListView) callconv(.C) void {
const priv = self.private();
const action = priv.list.getAction(pos) orelse return;
const action_surface = priv.window.actionSurface() orelse return;
const performed = action_surface.performBindingAction(action) catch |err| {
log.err("failed to perform binding action={}", .{err});
return;
};
if (!performed) {
log.warn("binding action was not performed", .{});
return;
}
_ = self.as(Parent).close();
}
pub const Class = extern struct {
parent: Parent.Class,
var parent: *Parent.Class = undefined;
pub const Instance = CommandPalette;
fn init(class: *Class) callconv(.C) void {
gobject.Object.virtual_methods.dispose.implement(class, dispose);
gobject.Object.virtual_methods.finalize.implement(class, finalize);
const widget_class = class.as(gtk.WidgetClass);
widget_class.setTemplateFromResource("/com/mitchellh/ghostty/ui/command_palette.ui");
widget_class.bindTemplateCallbackFull("searchChanged", @ptrCast(&searchChanged));
widget_class.bindTemplateCallbackFull("searchStopped", @ptrCast(&searchStopped));
widget_class.bindTemplateCallbackFull("searchActivated", @ptrCast(&searchActivated));
widget_class.bindTemplateCallbackFull("exampleActivated", @ptrCast(&exampleActivated));
widget_class.bindTemplateCallbackFull("activateAction", @ptrCast(&activateAction));
class.bindTemplateChildPrivate("actions", .{});
class.bindTemplateChildPrivate("search", .{});
class.bindTemplateChildPrivate("stack", .{});
class.bindTemplateChildPrivate("example", .{});
}
fn as(self: *Class, comptime T: type) *T {
return gobject.ext.as(T, self);
}
fn bindTemplateChildPrivate(class: *Class, comptime name: [:0]const u8, comptime options: gtk.ext.BindTemplateChildOptions) void {
gtk.ext.impl_helpers.bindTemplateChildPrivate(class, name, Private, Private.offset, options);
}
};
};
pub const CommandListModel = extern struct {
parent: Parent,
pub const Parent = gobject.Object;
pub const Implements = [_]type{ gio.ListModel, gtk.SelectionModel };
const Private = struct {
alloc: *std.heap.ArenaAllocator,
tokens: std.ArrayListUnmanaged([]const u8),
commands: CommandList,
var offset: c_int = 0;
};
const State = enum {
prompt,
items,
not_found,
};
pub const getGObjectType = gobject.ext.defineClass(CommandListModel, .{
.classInit = Class.init,
.implements = &.{
gobject.ext.implement(gio.ListModel, .{ .init = Class.initListModel }),
gobject.ext.implement(gtk.SelectionModel, .{ .init = Class.initSelectionModel }),
},
.private = .{ .Type = Private, .offset = &Private.offset },
});
pub const properties = struct {
pub const query = struct {
pub const name = "query";
const impl = gobject.ext.defineProperty(name, CommandListModel, ?[:0]const u8, .{
.default = "",
.accessor = .{ .getter = @ptrCast(&noop), .setter = setQuery },
.flags = .{ .writable = true },
});
};
};
pub fn new(alloc: *std.heap.ArenaAllocator) !*CommandListModel {
const self = gobject.ext.newInstance(CommandListModel, .{});
self.private().* = .{
.alloc = alloc,
.tokens = .{},
.commands = try CommandList.init(alloc.allocator()),
};
return self;
}
pub fn getAction(self: *CommandListModel, pos: c_uint) ?Binding.Action {
const commands = self.private().commands;
if (pos >= commands.len) return null;
return commands.list.items(.command)[@intCast(pos)].action;
}
pub fn state(self: *CommandListModel) State {
const priv = self.private();
return if (priv.tokens.items.len > 0)
if (priv.commands.len > 0) .items else .not_found
else
.prompt;
}
// Boilerplate
pub fn as(self: *CommandListModel, comptime T: type) *T {
return gobject.ext.as(T, self);
}
fn private(self: *CommandListModel) *Private {
return gobject.ext.impl_helpers.getPrivate(self, Private, Private.offset);
}
// Lifecycle
fn finalize(self: *CommandListModel) callconv(.C) void {
const priv = self.private();
priv.tokens.deinit(priv.alloc.allocator());
priv.commands.deinit(priv.alloc.allocator());
}
// Properties
pub fn setQuery(self: *CommandListModel, query: ?[:0]const u8) void {
const priv = self.private();
priv.tokens.clearRetainingCapacity();
if (query) |q| {
var it = std.mem.tokenizeScalar(u8, q, ' ');
while (it.next()) |token|
priv.tokens.append(priv.alloc.allocator(), token) catch @panic("OOM");
}
const old_len = priv.commands.sortAndFilter(priv.tokens.items);
self.as(gio.ListModel).itemsChanged(0, old_len, priv.commands.len);
}
// ListModel interface
fn getItem(list: *gio.ListModel, pos: c_uint) callconv(.C) ?*gobject.Object {
const self = gobject.ext.cast(CommandListModel, list) orelse return null;
const priv = self.private();
if (pos >= priv.commands.len) return null;
const item = priv.commands.list.get(@intCast(pos));
return gobject.ext.as(gobject.Object, CommandObject.new(item));
}
fn getItemType(_: *gio.ListModel) callconv(.C) gobject.Type {
return CommandObject.getGObjectType();
}
fn getNItems(list: *gio.ListModel) callconv(.C) c_uint {
const self = gobject.ext.cast(CommandListModel, list) orelse return 0;
return self.private().commands.len;
}
// SelectionModel interface
fn isSelected(_: *gtk.SelectionModel, pos: c_uint) callconv(.C) c_int {
// Always select the first item (purely visual)
return @intFromBool(pos == 0);
}
pub const Class = extern struct {
parent: Parent.Class,
pub const Instance = CommandListModel;
fn init(class: *Class) callconv(.C) void {
gobject.ext.registerProperties(class, &.{
properties.query.impl,
});
gobject.Object.virtual_methods.finalize.implement(class, finalize);
}
fn initListModel(iface: *gio.ListModel.Iface) callconv(.C) void {
gio.ListModel.virtual_methods.get_item.implement(iface, getItem);
gio.ListModel.virtual_methods.get_item_type.implement(iface, getItemType);
gio.ListModel.virtual_methods.get_n_items.implement(iface, getNItems);
}
fn initSelectionModel(iface: *gtk.SelectionModel.Iface) callconv(.C) void {
gtk.SelectionModel.virtual_methods.is_selected.implement(iface, isSelected);
}
};
};
const CommandList = struct {
list: std.MultiArrayList(Item) = .{},
len: c_uint = 0,
const Item = struct {
command: Binding.Command,
accelerator: ?[:0]const u8 = null,
rank: ?f64 = null,
var offset: c_int = 0;
};
fn init(alloc: std.mem.Allocator) !CommandList {
var list: std.MultiArrayList(Item) = .{};
try list.ensureTotalCapacity(alloc, Binding.commands.len);
for (Binding.commands) |command| {
switch (command.action) {
// macOS-only
.prompt_surface_title,
.close_all_windows,
.toggle_secure_input,
.toggle_quick_terminal,
=> continue,
else => {},
}
list.appendAssumeCapacity(.{ .command = command });
}
return .{
.list = list,
.len = @intCast(list.len),
};
}
fn deinit(self: *CommandList, alloc: std.mem.Allocator) void {
self.list.deinit(alloc);
}
/// Sort and filter the current list of commands based on a slice of tokens.
/// Returns the old size of the command list.
fn sortAndFilter(self: *CommandList, tokens: [][]const u8) c_uint {
const old_len = self.len;
if (tokens.len == 0) {
self.len = @intCast(self.list.len);
return old_len;
}
const cmds = self.list.items(.command);
const ranks = self.list.items(.rank);
for (cmds, ranks) |cmd, *rank| {
rank.* = zf.rank(cmd.title, tokens, .{
.to_lower = true,
.plain = true,
});
}
self.list.sort(self);
// Limit new length to the items that are not null
// (null rank correspond to non-matches)
const new_len = std.mem.indexOfScalar(?f64, ranks, null);
self.len = @intCast(new_len orelse self.list.len);
return old_len;
}
fn updateBindings(self: *CommandList, alloc: std.mem.Allocator, set: Binding.Set) !void {
var buf: [256]u8 = undefined;
for (self.list.items(.command), self.list.items(.accelerator)) |cmd, *accel| {
if (accel.*) |accel_| alloc.free(accel_);
accel.* = accel: {
const trigger = set.getTrigger(cmd.action) orelse break :accel null;
const accel_ = try key.accelFromTrigger(&buf, trigger) orelse break :accel null;
break :accel try alloc.dupeZ(u8, accel_);
};
}
}
// A custom sorting criterion that guarantees that nulls
// (non-matches) always sink to the bottom of the list,
// so we can easily exclude them
pub fn lessThan(self: *CommandList, a: usize, b: usize) bool {
const ranks = self.list.items(.rank);
// If a is null, then always place a (null) after b
const rank_a = ranks[a] orelse return false;
// If a is non-null and b is null, then put b (null) after a
const rank_b = ranks[b] orelse return true;
// Normal ranking logic
return rank_a < rank_b;
}
};
pub const CommandObject = extern struct {
parent: Parent,
pub const Parent = gobject.Object;
const Private = CommandList.Item;
pub const getGObjectType = gobject.ext.defineClass(CommandObject, .{
.classInit = Class.init,
.private = .{ .Type = Private, .offset = &Private.offset },
});
pub const properties = struct {
pub const title = struct {
pub const name = "title";
const impl = gobject.ext.defineProperty(name, CommandObject, ?[:0]const u8, .{
.default = "",
.accessor = .{ .getter = getTitle, .setter = @ptrCast(&noop) },
.flags = .{ .readable = true },
});
};
pub const description = struct {
pub const name = "description";
const impl = gobject.ext.defineProperty(name, CommandObject, ?[:0]const u8, .{
.default = "",
.accessor = .{ .getter = getDescription, .setter = @ptrCast(&noop) },
.flags = .{ .readable = true },
});
};
pub const accelerator = struct {
pub const name = "accelerator";
const impl = gobject.ext.defineProperty(name, CommandObject, ?[:0]const u8, .{
.default = "",
.accessor = .{ .getter = getAccelerator, .setter = @ptrCast(&noop) },
.flags = .{ .readable = true },
});
};
};
pub fn new(item: CommandList.Item) *CommandObject {
const self = gobject.ext.newInstance(CommandObject, .{});
self.private().* = item;
return self;
}
fn private(self: *CommandObject) *Private {
return gobject.ext.impl_helpers.getPrivate(self, Private, Private.offset);
}
fn getTitle(self: *CommandObject) ?[:0]const u8 {
return self.private().command.title;
}
fn getDescription(self: *CommandObject) ?[:0]const u8 {
return self.private().command.description;
}
fn getAccelerator(self: *CommandObject) ?[:0]const u8 {
return self.private().accelerator;
}
pub const Class = extern struct {
parent: Parent.Class,
pub const Instance = CommandObject;
fn init(class: *Class) callconv(.C) void {
gobject.ext.registerProperties(class, &.{
properties.title.impl,
properties.description.impl,
properties.accelerator.impl,
});
}
};
};
// For some bizarre reason zig-gobject forces you to set a setter for a read-only field...
fn noop() callconv(.C) void {
unreachable;
}

View File

@ -54,7 +54,9 @@ const icons = [_]struct {
};
pub const ui_files = [_][]const u8{};
pub const blueprint_files = [_][]const u8{};
pub const blueprint_files = [_][]const u8{
"command_palette",
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};

View File

@ -0,0 +1,143 @@
using Gtk 4.0;
using Adw 1;
template $CommandPalette: Adw.Dialog {
content-width: 700;
Adw.ToolbarView {
top-bar-style: flat;
[top]
Adw.HeaderBar {
[title]
SearchEntry search {
hexpand: true;
placeholder-text: _("Search for Action");
search-changed => $searchChanged() swapped;
stop-search => $searchStopped() swapped;
activate => $searchActivated() swapped;
}
}
Adw.ViewStack stack {
visible-child-name: "prompt";
Adw.ViewStackPage {
name: "prompt";
child: Adw.StatusPage {
styles [
"compact"
]
title: _("Type to Search");
description: _("Find any command by searching for its name");
child: ListBox {
styles [
"boxed-list"
]
margin-start: 50;
margin-end: 50;
Adw.ActionRow example {
title: "new window";
selectable: false;
activatable: true;
activated => $exampleActivated() swapped;
[prefix]
Image {
icon-name: "system-search-symbolic";
}
[suffix]
Image {
icon-name: "keyboard-enter-symbolic";
}
}
};
};
}
Adw.ViewStackPage {
name: "not-found";
child: Adw.StatusPage {
styles [
"compact"
]
icon-name: "system-search-symbolic";
title: _("No Results Found");
description: _("Try a different search");
};
}
Adw.ViewStackPage {
name: "items";
child: ScrolledWindow {
// min-content-height: 200;
ListView actions {
show-separators: true;
single-click-activate: true;
activate => $activateAction() swapped;
styles [
"rich-list"
]
factory: BuilderListItemFactory {
template ListItem {
child: Box {
orientation: horizontal;
spacing: 10;
Box {
orientation: vertical;
hexpand: true;
Label {
ellipsize: end;
halign: start;
wrap: false;
single-line-mode: true;
styles [
"title"
]
label: bind template.item as <$CommandObject>.title;
}
Label {
ellipsize: end;
halign: start;
wrap: false;
single-line-mode: true;
styles [
"subtitle"
]
label: bind template.item as <$CommandObject>.description;
}
}
ShortcutLabel {
accelerator: bind template.item as <$CommandObject>.accelerator;
}
};
}
};
}
};
}
}
}
}

View File

@ -479,6 +479,11 @@ pub const Action = union(enum) {
/// This currently only works on macOS.
toggle_visibility: void,
/// Toggles the command palette.
///
/// Currently only supported on Linux.
toggle_command_palette,
/// Quit ghostty.
quit: void,
@ -772,6 +777,7 @@ pub const Action = union(enum) {
.toggle_maximize,
.toggle_fullscreen,
.toggle_window_decorations,
.toggle_command_palette,
.toggle_secure_input,
.crash,
=> .surface,
@ -855,6 +861,258 @@ pub const Action = union(enum) {
}
}
/// Returns zero or more commands associated to this action.
/// Actions that don't make sense in a GUI context would have
/// zero commands, while some parameterized actions (e.g. splitting)
/// would have multiple associated commands corresponding to
/// each value of the parameter (e.g. split direction).
pub fn commands(self: Action) []const Command {
// GENERAL RULES OF THUMB TO ASSIGNING COMMANDS TO AN ACTION:
//
// 1. Does this action require arbitrary user input,
// or parameters that don't have obvious default values?
// If so, then it shouldn't have any commands.
//
// 2. Is it easier for the user to activate the toggle command
// palette binding, type in the name of the action and select it,
// compared to just using its keybinding?
// If not, then it shouldn't have any commands.
// (For example, why go through all of that just to navigate to the next tab?)
//
// 3. Do you think that most people will access this action via
// the action palette, instead of using a keybind?
// (Potential reasons being that the keybind is too awkward or too infrequent)
// If so, then definitely add a command. If not, maybe think twice.
// For the vast majority of cases we only have one command,
// so we avoid a lot of syntactic noise by using an optional here
// to stand in for a zero-or-one item list.
const command: ?Command = switch (self) {
.new_tab => .{
.action = .new_tab,
.title = "New Tab",
.description = "Open a new tab in the current window.",
},
.close_tab => .{
.action = .close_tab,
.title = "Close Tab",
.description = "Close the current tab.",
},
.new_window => .{
.action = .new_window,
.title = "New Window",
.description = "Open a new window.",
},
.close_window => .{
.action = .close_window,
.title = "Close Window",
.description = "Close the current window.",
},
.close_all_windows => .{
.action = .close_all_windows,
.title = "Close All Windows",
.description = "Close all currently open windows.",
},
.close_surface => .{
.action = .close_surface,
.title = "Close Surface",
.description = "Closes the current terminal surface.",
},
.new_split => return &.{
.{
.action = .{ .new_split = .up },
.title = "Split Upwards",
.description = "Split and create a new surface upwards.",
},
.{
.action = .{ .new_split = .down },
.title = "Split Downwards",
.description = "Split and create a new surface downwards.",
},
.{
.action = .{ .new_split = .left },
.title = "Split Leftwards",
.description = "Split and create a new surface leftwards.",
},
.{
.action = .{ .new_split = .right },
.title = "Split Rightwards",
.description = "Split and create a new surface rightwards.",
},
},
.equalize_splits => .{
.action = .equalize_splits,
.title = "Equalize Splits",
.description = "Equalize the sizes of the current and adjacent surfaces.",
},
.prompt_surface_title => .{
.action = .prompt_surface_title,
.title = "Rename Surface Title",
.description = "Rename the title of the current surface.",
},
.open_config => .{
.action = .open_config,
.title = "Open Configuration",
.description = "Open Ghostty's configuration.",
},
.reload_config => .{
.action = .reload_config,
.title = "Reload Configuration",
.description = "Reload Ghostty's configuration.",
},
.copy_to_clipboard => .{
.action = .copy_to_clipboard,
.title = "Copy to Clipboard",
.description = "Copy the current selection to the clipboard.",
},
.copy_url_to_clipboard => .{
.action = .copy_url_to_clipboard,
.title = "Copy URL to Clipboard",
.description = "Copy the URL under the cursor to the clipboard.",
},
.paste_from_clipboard => .{
.action = .paste_from_clipboard,
.title = "Paste from Clipboard",
.description = "Paste from the clipboard.",
},
.paste_from_selection => .{
.action = .paste_from_selection,
.title = "Paste from Selection Clipboard",
.description = "Paste from the selection clipboard.",
},
.select_all => .{
.action = .select_all,
.title = "Select All",
.description = "Select everything in the current terminal.",
},
.scroll_to_top => .{
.action = .scroll_to_top,
.title = "Scroll to Top",
.description = "Scroll to the top of the terminal.",
},
.scroll_to_bottom => .{
.action = .scroll_to_bottom,
.title = "Scroll to Bottom",
.description = "Scroll to the bottom of the terminal.",
},
.reset => .{
.action = .reset,
.title = "Reset Terminal",
.description = "Reset the terminal.",
},
.clear_screen => .{
.action = .clear_screen,
.title = "Clear Screen",
.description = "Clear the terminal screen.",
},
.increase_font_size => .{
.action = .{ .increase_font_size = 1 },
.title = "Increase Font Size",
.description = "Increase the font size by 1pt.",
},
.decrease_font_size => .{
.action = .{ .decrease_font_size = 1 },
.title = "Decrease Font Size",
.description = "Decrease the font size by 1pt.",
},
.reset_font_size => .{
.action = .reset_font_size,
.title = "Reset Font Size",
.description = "Reset the font size to its original value.",
},
.inspector => .{
.action = .{ .inspector = .toggle },
.title = "Toggle Inspector",
.description = "Toggle the terminal inspector.",
},
.toggle_quick_terminal => .{
.action = .toggle_quick_terminal,
.title = "Toggle Quick Terminal",
.description = "Toggle the quick terminal.",
},
.toggle_maximize => .{
.action = .toggle_maximize,
.title = "Toggle Maximize",
.description = "Toggle the maximized state of the current window.",
},
.toggle_fullscreen => .{
.action = .toggle_fullscreen,
.title = "Toggle Fullscreen",
.description = "Toggle the fullscreened state of the current window.",
},
.toggle_tab_overview => .{
.action = .toggle_tab_overview,
.title = "Toggle Tab Overview",
.description = "Toggle the tab overview.",
},
.toggle_split_zoom => .{
.action = .toggle_split_zoom,
.title = "Toggle Split Zoom",
.description = "Zoom in or out of the current split.",
},
.toggle_window_decorations => .{
.action = .toggle_window_decorations,
.title = "Toggle Window Decorations",
.description = "Toggle the window decorations.",
},
.toggle_secure_input => .{
.action = .toggle_secure_input,
.title = "Toggle Secure Input",
.description = "Toggle the secure input state.",
},
.quit => .{
.action = .quit,
.title = "Quit",
.description = "Quit Ghostty.",
},
// Requires a parameter that requires arbitrary
// input or has no good default - see (1)
.ignore,
.unbind,
.csi,
.esc,
.text,
.cursor_key,
.goto_tab,
.resize_split,
.scroll_page_up,
.scroll_page_down,
.scroll_page_fractional,
.scroll_page_lines,
.adjust_selection,
.jump_to_prompt,
.write_scrollback_file,
.write_screen_file,
.write_selection_file,
// A lot easier to perform via existing GUI controls
// than using the command palette - see (2)
.previous_tab,
.next_tab,
.last_tab,
.goto_split,
.move_tab,
// Special reasons
.toggle_command_palette, // Just hit Esc or press the close button
.toggle_visibility, // You have to already be visible to trigger the palette
.crash, // Why would you want to do this?
=> null,
};
return if (command) |cmd| &.{cmd} else &.{};
}
/// Implements the formatter for the fmt package. This encodes the
/// action back into the format used by parse.
pub fn format(
@ -1006,6 +1264,38 @@ pub const Key = enum(c_int) {
new_window,
};
/// A command that can be executed in the command palette.
///
/// This is essentially a subset of binding actions that actually make *sense*
/// to be run by a user in a GUI interface (e.g. no control sequences or
/// arbitrary text), and has a translatable title attached to it.
pub const Command = struct {
action: Action,
title: [:0]const u8,
description: [:0]const u8,
};
pub const commands = commands: {
var size: usize = 0;
for (@typeInfo(Action).Union.fields) |field| {
// The value is ignored anyway, so this is safe.
const action = @unionInit(Binding.Action, field.name, undefined);
size += action.commands().len;
}
var list: [size]Command = undefined;
var i: usize = 0;
for (@typeInfo(Action).Union.fields) |field| {
const action = @unionInit(Binding.Action, field.name, undefined);
const cmds = action.commands();
@memcpy(list[i..][0..cmds.len], cmds);
i += cmds.len;
}
break :commands list;
};
/// 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.
///