mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-08-02 14:57:31 +03:00
gtk: implement command palette
This commit is contained in:
@ -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 {
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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(),
|
||||
|
@ -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;
|
||||
|
519
src/apprt/gtk/command_palette.zig
Normal file
519
src/apprt/gtk/command_palette.zig
Normal 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;
|
||||
}
|
@ -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(.{}){};
|
||||
|
143
src/apprt/gtk/ui/command_palette.blp
Normal file
143
src/apprt/gtk/ui/command_palette.blp
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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.
|
||||
///
|
||||
|
Reference in New Issue
Block a user