diff --git a/include/ghostty.h b/include/ghostty.h index 86de4266d..07f507312 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -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 { diff --git a/src/Surface.zig b/src/Surface.zig index 98c344927..2ff174a69 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -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, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 20b86707e..4cfd8560f 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -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 diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 531269ee1..56673060a 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -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, diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 227c36ec4..d56e825e2 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -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(), diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 0fd1e7429..7cc2ef663 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -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; diff --git a/src/apprt/gtk/command_palette.zig b/src/apprt/gtk/command_palette.zig new file mode 100644 index 000000000..70d27f81b --- /dev/null +++ b/src/apprt/gtk/command_palette.zig @@ -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; +} diff --git a/src/apprt/gtk/gresource.zig b/src/apprt/gtk/gresource.zig index 050605b00..4d8f046a6 100644 --- a/src/apprt/gtk/gresource.zig +++ b/src/apprt/gtk/gresource.zig @@ -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(.{}){}; diff --git a/src/apprt/gtk/ui/command_palette.blp b/src/apprt/gtk/ui/command_palette.blp new file mode 100644 index 000000000..2cf8cc303 --- /dev/null +++ b/src/apprt/gtk/ui/command_palette.blp @@ -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; + } + }; + } + }; + } + }; + } + } + } +} + + diff --git a/src/input/Binding.zig b/src/input/Binding.zig index f91967293..7c6fa2f24 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -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. ///