gtk: implement command palette

pull/7167/head
Leah Amelia Chen 2025-04-22 13:09:15 +08:00
parent a090e8eeed
commit 048e4acb2c
No known key found for this signature in database
8 changed files with 399 additions and 12 deletions

View File

@ -492,11 +492,11 @@ pub fn performAction(
.toggle_quick_terminal => return try self.toggleQuickTerminal(),
.secure_input => self.setSecureInput(target, value),
.ring_bell => try self.ringBell(target),
.toggle_command_palette => try self.toggleCommandPalette(target),
// Unimplemented
.close_all_windows,
.float_window,
.toggle_command_palette,
.toggle_visibility,
.cell_size,
.key_sequence,
@ -750,7 +750,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;
@ -792,6 +792,23 @@ fn ringBell(_: *App, target: apprt.Target) !void {
}
}
fn toggleCommandPalette(_: *App, target: apprt.Target) !void {
switch (target) {
.app => {},
.surface => |surface| {
const window = surface.rt_surface.container.window() orelse {
log.info(
"toggleCommandPalette invalid for container={s}",
.{@tagName(surface.rt_surface.container)},
);
return;
};
window.toggleCommandPalette();
},
}
}
fn quitTimer(self: *App, mode: apprt.action.QuitTimer) void {
switch (mode) {
.start => self.startQuitTimer(),

View File

@ -0,0 +1,229 @@
const CommandPalette = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const adw = @import("adw");
const gio = @import("gio");
const gobject = @import("gobject");
const gtk = @import("gtk");
const configpkg = @import("../../config.zig");
const inputpkg = @import("../../input.zig");
const key = @import("key.zig");
const Builder = @import("Builder.zig");
const Window = @import("Window.zig");
const log = std.log.scoped(.command_palette);
window: *Window,
arena: std.heap.ArenaAllocator,
/// The dialog object containing the palette UI.
dialog: *adw.Dialog,
/// The search input text field.
search: *gtk.SearchEntry,
/// The view containing each result row.
view: *gtk.ListView,
/// The model that provides filtered data for the view to display.
model: *gio.ListModel,
/// The list that serves as the data source of the model.
/// This is where all command data is ultimately stored.
source: *gio.ListStore,
pub fn init(self: *CommandPalette, window: *Window) !void {
// Register the custom command type *before* initializing the builder
// If we don't do this now, the builder will complain that it doesn't know
// about this type and fail to initialize
_ = Command.getGObjectType();
var builder = Builder.init("command-palette", 1, 5);
self.* = .{
.window = window,
.arena = .init(window.app.core_app.alloc),
.dialog = builder.getObject(adw.Dialog, "command-palette").?,
.search = builder.getObject(gtk.SearchEntry, "search").?,
.view = builder.getObject(gtk.ListView, "view").?,
.model = builder.getObject(gio.ListModel, "model").?,
.source = builder.getObject(gio.ListStore, "source").?,
};
// Manually take a reference here so that the dialog
// remains in memory after closing
self.dialog.ref();
errdefer self.dialog.unref();
_ = gtk.SearchEntry.signals.stop_search.connect(
self.search,
*CommandPalette,
searchStopped,
self,
.{},
);
_ = gtk.SearchEntry.signals.activate.connect(
self.search,
*CommandPalette,
searchActivated,
self,
.{},
);
_ = gtk.ListView.signals.activate.connect(
self.view,
*CommandPalette,
rowActivated,
self,
.{},
);
try self.updateConfig(&self.window.app.config);
}
pub fn deinit(self: *CommandPalette) void {
self.arena.deinit();
self.dialog.unref();
}
pub fn toggle(self: *CommandPalette) void {
self.dialog.present(self.window.window.as(gtk.Widget));
}
pub fn updateConfig(self: *CommandPalette, config: *const configpkg.Config) !void {
// Clear existing binds and clear allocated data
self.source.removeAll();
_ = self.arena.reset(.retain_capacity);
// TODO: Allow user-configured palette entries
for (inputpkg.command.defaults) |command| {
const cmd = try Command.new(
self.arena.allocator(),
command,
config.keybind.set,
);
self.source.append(cmd.as(gobject.Object));
}
}
fn activated(self: *CommandPalette, pos: c_uint) void {
// Use self.model and not self.source here to use the list of *visible* results
const object = self.model.as(gio.ListModel).getObject(pos) orelse return;
const cmd = gobject.ext.cast(Command, object) orelse return;
const action = inputpkg.Binding.Action.parse(
std.mem.span(cmd.cmd_c.action_key),
) catch |err| {
log.err("got invalid action={s} ({})", .{ cmd.cmd_c.action_key, err });
return;
};
self.window.performBindingAction(action);
_ = self.dialog.close();
}
fn searchStopped(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void {
// ESC was pressed - close the palette
_ = self.dialog.close();
}
fn searchActivated(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void {
// If Enter is pressed in the search bar,
// then activate the first entry (if any)
self.activated(0);
}
fn rowActivated(_: *gtk.ListView, pos: c_uint, self: *CommandPalette) callconv(.c) void {
self.activated(pos);
}
/// Object that wraps around a command.
///
/// As GTK list models only accept objects that are within the GObject hierarchy,
/// we have to construct a wrapper to be easily consumed by the list model.
const Command = extern struct {
parent: Parent,
cmd_c: inputpkg.Command.C,
pub const getGObjectType = gobject.ext.defineClass(Command, .{
.name = "GhosttyCommand",
.classInit = Class.init,
});
pub fn new(alloc: Allocator, cmd: inputpkg.Command, keybinds: inputpkg.Binding.Set) !*Command {
const self = gobject.ext.newInstance(Command, .{});
var buf: [64]u8 = undefined;
const action = action: {
const trigger = keybinds.getTrigger(cmd.action) orelse break :action null;
const accel = try key.accelFromTrigger(&buf, trigger) orelse break :action null;
break :action try alloc.dupeZ(u8, accel);
};
self.cmd_c = .{
.title = cmd.title.ptr,
.description = cmd.description.ptr,
.action = if (action) |v| v.ptr else "",
.action_key = try std.fmt.allocPrintZ(alloc, "{}", .{cmd.action}),
};
return self;
}
fn as(self: *Command, comptime T: type) *T {
return gobject.ext.as(T, self);
}
pub const Parent = gobject.Object;
pub const Class = extern struct {
parent: Parent.Class,
pub const Instance = Command;
pub fn init(class: *Class) callconv(.c) void {
const info = @typeInfo(inputpkg.Command.C).@"struct";
// Expose all fields on the Command.C struct as properties
// that can be accessed by the GObject type system
// (and by extension, blueprints)
const properties = comptime props: {
var props: [info.fields.len]type = undefined;
for (info.fields, 0..) |field, i| {
const accessor = struct {
fn getter(cmd: *Command) ?[:0]const u8 {
return std.mem.span(@field(cmd.cmd_c, field.name));
}
};
// "Canonicalize" field names into the format GObject expects
const prop_name = prop_name: {
var buf: [field.name.len:0]u8 = undefined;
_ = std.mem.replace(u8, field.name, "_", "-", &buf);
break :prop_name buf;
};
props[i] = gobject.ext.defineProperty(
&prop_name,
Command,
?[:0]const u8,
.{
.default = null,
.accessor = .{ .getter = &accessor.getter },
},
);
}
break :props props;
};
gobject.ext.registerProperties(class, &properties);
}
};
};

View File

@ -34,6 +34,7 @@ const gtk_key = @import("key.zig");
const TabView = @import("TabView.zig");
const HeaderBar = @import("headerbar.zig");
const CloseDialog = @import("CloseDialog.zig");
const CommandPalette = @import("CommandPalette.zig");
const winprotopkg = @import("winproto.zig");
const gtk_version = @import("gtk_version.zig");
const adw_version = @import("adw_version.zig");
@ -67,6 +68,9 @@ titlebar_menu: Menu(Window, "titlebar_menu", true),
/// The libadwaita widget for receiving toast send requests.
toast_overlay: *adw.ToastOverlay,
/// The command palette.
command_palette: CommandPalette,
/// See adwTabOverviewOpen for why we have this.
adw_tab_overview_focus_timer: ?c_uint = null,
@ -139,6 +143,7 @@ pub fn init(self: *Window, app: *App) !void {
.notebook = undefined,
.titlebar_menu = undefined,
.toast_overlay = undefined,
.command_palette = undefined,
.winproto = .none,
};
@ -167,6 +172,8 @@ pub fn init(self: *Window, app: *App) !void {
// Setup our notebook
self.notebook.init(self);
if (adw_version.supportsDialogs()) try self.command_palette.init(self);
// If we are using Adwaita, then we can support the tab overview.
self.tab_overview = if (adw_version.supportsTabOverview()) overview: {
const tab_overview = adw.TabOverview.new();
@ -460,6 +467,9 @@ pub fn updateConfig(
// We always resync our appearance whenever the config changes.
try self.syncAppearance();
// Update binds inside the command palette
try self.command_palette.updateConfig(config);
}
/// Updates appearance based on config settings. Will be called once upon window
@ -600,6 +610,7 @@ fn initActions(self: *Window) void {
pub fn deinit(self: *Window) void {
self.winproto.deinit(self.app.core_app.alloc);
if (adw_version.supportsDialogs()) self.command_palette.deinit();
if (self.adw_tab_overview_focus_timer) |timer| {
_ = glib.Source.remove(timer);
@ -729,6 +740,15 @@ pub fn toggleWindowDecorations(self: *Window) void {
};
}
/// Toggle the window decorations for this window.
pub fn toggleCommandPalette(self: *Window) void {
if (adw_version.supportsDialogs()) {
self.command_palette.toggle();
} else {
log.warn("libadwaita 1.5+ is required for the command palette", .{});
}
}
/// Grabs focus on the currently selected tab.
pub fn focusCurrentTab(self: *Window) void {
const tab = self.notebook.currentTab() orelse return;
@ -820,7 +840,7 @@ fn gtkWindowUpdateScaleFactor(
}
/// Perform a binding action on the window's action surface.
fn performBindingAction(self: *Window, action: input.Binding.Action) void {
pub fn performBindingAction(self: *Window, action: input.Binding.Action) void {
const surface = self.actionSurface() orelse return;
_ = surface.performBindingAction(action) catch |err| {
log.warn("error performing binding action error={}", .{err});

View File

@ -63,6 +63,7 @@ pub const blueprint_files = [_]VersionedBlueprint{
.{ .major = 1, .minor = 5, .name = "prompt-title-dialog" },
.{ .major = 1, .minor = 5, .name = "config-errors-dialog" },
.{ .major = 1, .minor = 0, .name = "menu-headerbar-split_menu" },
.{ .major = 1, .minor = 5, .name = "command-palette" },
.{ .major = 1, .minor = 0, .name = "menu-surface-context_menu" },
.{ .major = 1, .minor = 0, .name = "menu-window-titlebar_menu" },
.{ .major = 1, .minor = 5, .name = "ccw-osc-52-read" },

View File

@ -73,3 +73,19 @@ window.ssd.no-border-radius {
filter: blur(5px);
transition: filter 0.3s ease;
}
.command-palette-search {
font-size: 1.25rem;
padding: 4px;
-gtk-icon-size: 20px;
}
.command-palette-search > image:first-child {
margin-left: 8px;
margin-right: 4px;
}
.command-palette-search > image:last-child {
margin-left: 4px;
margin-right: 8px;
}

View File

@ -0,0 +1,106 @@
using Gtk 4.0;
using Gio 2.0;
using Adw 1;
Adw.Dialog command-palette {
content-width: 700;
Adw.ToolbarView {
top-bar-style: flat;
[top]
Adw.HeaderBar {
[title]
SearchEntry search {
hexpand: true;
placeholder-text: _("Execute a command…");
styles [
"command-palette-search",
]
}
}
ScrolledWindow {
min-content-height: 300;
ListView view {
show-separators: true;
single-click-activate: true;
model: NoSelection model {
model: FilterListModel {
incremental: true;
filter: AnyFilter {
StringFilter {
expression: expr item as <$GhosttyCommand>.title;
search: bind search.text;
}
StringFilter {
expression: expr item as <$GhosttyCommand>.action-key;
search: bind search.text;
}
};
model: Gio.ListStore source {
item-type: typeof<$GhosttyCommand>;
};
};
};
styles [
"rich-list",
]
factory: BuilderListItemFactory {
template ListItem {
child: Box {
orientation: horizontal;
spacing: 10;
tooltip-text: bind template.item as <$GhosttyCommand>.description;
Box {
orientation: vertical;
hexpand: true;
Label {
ellipsize: end;
halign: start;
wrap: false;
single-line-mode: true;
styles [
"title",
]
label: bind template.item as <$GhosttyCommand>.title;
}
Label {
ellipsize: end;
halign: start;
wrap: false;
single-line-mode: true;
styles [
"subtitle",
"monospace",
]
label: bind template.item as <$GhosttyCommand>.action-key;
}
}
ShortcutLabel {
accelerator: bind template.item as <$GhosttyCommand>.action;
valign: center;
}
};
}
};
}
}
}
}

View File

@ -4736,6 +4736,13 @@ pub const Keybinds = struct {
.{ .toggle_split_zoom = {} },
);
// Toggle command palette, matches VSCode
try self.set.put(
alloc,
.{ .key = .{ .unicode = 'p' }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) },
.toggle_command_palette,
);
// Mac-specific keyboard bindings.
if (comptime builtin.target.os.tag.isDarwin()) {
try self.set.put(
@ -4908,13 +4915,6 @@ pub const Keybinds = struct {
.{ .jump_to_prompt = 1 },
);
// Toggle command palette, matches VSCode
try self.set.put(
alloc,
.{ .key = .{ .unicode = 'p' }, .mods = .{ .super = true, .shift = true } },
.{ .toggle_command_palette = {} },
);
// Inspector, matching Chromium
try self.set.put(
alloc,

View File

@ -455,8 +455,6 @@ pub const Action = union(enum) {
/// 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