mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-06-05 11:24:13 +00:00
GTK: UI for key sequences and tables (#10034)
Fixes #2127 This adds a UI similar to macOS to show the current state of key sequences and/or key tables. https://github.com/user-attachments/assets/4399d2af-a88c-4b70-922b-7727dc4d2053 **AI disclosure:** AI was used for various things, but I did write most of the code myself, especially around the memory management of properties since agents can't get that quite right yet. 😄
This commit is contained in:
@@ -10,4 +10,5 @@ pub const WeakRef = @import("gtk/weak_ref.zig").WeakRef;
|
||||
test {
|
||||
@import("std").testing.refAllDecls(@This());
|
||||
_ = @import("gtk/ext.zig");
|
||||
_ = @import("gtk/key.zig");
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ pub const blueprints: []const Blueprint = &.{
|
||||
.{ .major = 1, .minor = 5, .name = "inspector-window" },
|
||||
.{ .major = 1, .minor = 2, .name = "resize-overlay" },
|
||||
.{ .major = 1, .minor = 2, .name = "search-overlay" },
|
||||
.{ .major = 1, .minor = 2, .name = "key-state-overlay" },
|
||||
.{ .major = 1, .minor = 5, .name = "split-tree" },
|
||||
.{ .major = 1, .minor = 5, .name = "split-tree-split" },
|
||||
.{ .major = 1, .minor = 2, .name = "surface" },
|
||||
|
||||
@@ -669,6 +669,9 @@ pub const Application = extern struct {
|
||||
|
||||
.inspector => return Action.controlInspector(target, value),
|
||||
|
||||
.key_sequence => return Action.keySequence(target, value),
|
||||
.key_table => return Action.keyTable(target, value),
|
||||
|
||||
.mouse_over_link => Action.mouseOverLink(target, value),
|
||||
.mouse_shape => Action.mouseShape(target, value),
|
||||
.mouse_visibility => Action.mouseVisibility(target, value),
|
||||
@@ -743,8 +746,6 @@ pub const Application = extern struct {
|
||||
.toggle_visibility,
|
||||
.toggle_background_opacity,
|
||||
.cell_size,
|
||||
.key_sequence,
|
||||
.key_table,
|
||||
.render_inspector,
|
||||
.renderer_health,
|
||||
.color_change,
|
||||
@@ -2660,6 +2661,36 @@ const Action = struct {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn keySequence(target: apprt.Target, value: apprt.Action.Value(.key_sequence)) bool {
|
||||
switch (target) {
|
||||
.app => {
|
||||
log.warn("key_sequence action to app is unexpected", .{});
|
||||
return false;
|
||||
},
|
||||
.surface => |core| {
|
||||
core.rt_surface.gobj().keySequenceAction(value) catch |err| {
|
||||
log.warn("error handling key_sequence action: {}", .{err});
|
||||
};
|
||||
return true;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn keyTable(target: apprt.Target, value: apprt.Action.Value(.key_table)) bool {
|
||||
switch (target) {
|
||||
.app => {
|
||||
log.warn("key_table action to app is unexpected", .{});
|
||||
return false;
|
||||
},
|
||||
.surface => |core| {
|
||||
core.rt_surface.gobj().keyTableAction(value) catch |err| {
|
||||
log.warn("error handling key_table action: {}", .{err});
|
||||
};
|
||||
return true;
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// This sets various GTK-related environment variables as necessary
|
||||
|
||||
342
src/apprt/gtk/class/key_state_overlay.zig
Normal file
342
src/apprt/gtk/class/key_state_overlay.zig
Normal file
@@ -0,0 +1,342 @@
|
||||
const std = @import("std");
|
||||
const adw = @import("adw");
|
||||
const glib = @import("glib");
|
||||
const gobject = @import("gobject");
|
||||
const gtk = @import("gtk");
|
||||
|
||||
const ext = @import("../ext.zig");
|
||||
const gresource = @import("../build/gresource.zig");
|
||||
const Application = @import("application.zig").Application;
|
||||
const Common = @import("../class.zig").Common;
|
||||
|
||||
const log = std.log.scoped(.gtk_ghostty_key_state_overlay);
|
||||
|
||||
/// An overlay that displays the current key table stack and pending key sequence.
|
||||
/// This helps users understand what key bindings are active and what keys they've
|
||||
/// pressed in a multi-key sequence.
|
||||
pub const KeyStateOverlay = extern struct {
|
||||
const Self = @This();
|
||||
parent_instance: Parent,
|
||||
pub const Parent = adw.Bin;
|
||||
pub const getGObjectType = gobject.ext.defineClass(Self, .{
|
||||
.name = "GhosttyKeyStateOverlay",
|
||||
.instanceInit = &init,
|
||||
.classInit = &Class.init,
|
||||
.parent_class = &Class.parent,
|
||||
.private = .{ .Type = Private, .offset = &Private.offset },
|
||||
});
|
||||
|
||||
pub const properties = struct {
|
||||
pub const tables = struct {
|
||||
pub const name = "tables";
|
||||
const impl = gobject.ext.defineProperty(
|
||||
name,
|
||||
Self,
|
||||
?*ext.StringList,
|
||||
.{
|
||||
.accessor = gobject.ext.typedAccessor(
|
||||
Self,
|
||||
?*ext.StringList,
|
||||
.{
|
||||
.getter = getTables,
|
||||
.getter_transfer = .none,
|
||||
.setter = setTables,
|
||||
.setter_transfer = .full,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
pub const @"has-tables" = struct {
|
||||
pub const name = "has-tables";
|
||||
const impl = gobject.ext.defineProperty(
|
||||
name,
|
||||
Self,
|
||||
bool,
|
||||
.{
|
||||
.default = false,
|
||||
.accessor = gobject.ext.typedAccessor(
|
||||
Self,
|
||||
bool,
|
||||
.{ .getter = getHasTables },
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
pub const sequence = struct {
|
||||
pub const name = "sequence";
|
||||
const impl = gobject.ext.defineProperty(
|
||||
name,
|
||||
Self,
|
||||
?*ext.StringList,
|
||||
.{
|
||||
.accessor = gobject.ext.typedAccessor(
|
||||
Self,
|
||||
?*ext.StringList,
|
||||
.{
|
||||
.getter = getSequence,
|
||||
.getter_transfer = .none,
|
||||
.setter = setSequence,
|
||||
.setter_transfer = .full,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
pub const @"has-sequence" = struct {
|
||||
pub const name = "has-sequence";
|
||||
const impl = gobject.ext.defineProperty(
|
||||
name,
|
||||
Self,
|
||||
bool,
|
||||
.{
|
||||
.default = false,
|
||||
.accessor = gobject.ext.typedAccessor(
|
||||
Self,
|
||||
bool,
|
||||
.{ .getter = getHasSequence },
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
pub const @"valign-target" = struct {
|
||||
pub const name = "valign-target";
|
||||
const impl = gobject.ext.defineProperty(
|
||||
name,
|
||||
Self,
|
||||
gtk.Align,
|
||||
.{
|
||||
.default = .end,
|
||||
.accessor = C.privateShallowFieldAccessor("valign_target"),
|
||||
},
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
const Private = struct {
|
||||
/// The key table stack.
|
||||
tables: ?*ext.StringList = null,
|
||||
|
||||
/// The key sequence.
|
||||
sequence: ?*ext.StringList = null,
|
||||
|
||||
/// Target vertical alignment for the overlay.
|
||||
valign_target: gtk.Align = .end,
|
||||
|
||||
pub var offset: c_int = 0;
|
||||
};
|
||||
|
||||
fn init(self: *Self, _: *Class) callconv(.c) void {
|
||||
gtk.Widget.initTemplate(self.as(gtk.Widget));
|
||||
}
|
||||
|
||||
fn getTables(self: *Self) ?*ext.StringList {
|
||||
return self.private().tables;
|
||||
}
|
||||
|
||||
fn getSequence(self: *Self) ?*ext.StringList {
|
||||
return self.private().sequence;
|
||||
}
|
||||
|
||||
fn setTables(self: *Self, value: ?*ext.StringList) void {
|
||||
const priv = self.private();
|
||||
if (priv.tables) |old| {
|
||||
old.destroy();
|
||||
priv.tables = null;
|
||||
}
|
||||
if (value) |v| {
|
||||
priv.tables = v;
|
||||
}
|
||||
|
||||
self.as(gobject.Object).notifyByPspec(properties.tables.impl.param_spec);
|
||||
self.as(gobject.Object).notifyByPspec(properties.@"has-tables".impl.param_spec);
|
||||
}
|
||||
|
||||
fn setSequence(self: *Self, value: ?*ext.StringList) void {
|
||||
const priv = self.private();
|
||||
if (priv.sequence) |old| {
|
||||
old.destroy();
|
||||
priv.sequence = null;
|
||||
}
|
||||
if (value) |v| {
|
||||
priv.sequence = v;
|
||||
}
|
||||
|
||||
self.as(gobject.Object).notifyByPspec(properties.sequence.impl.param_spec);
|
||||
self.as(gobject.Object).notifyByPspec(properties.@"has-sequence".impl.param_spec);
|
||||
}
|
||||
|
||||
fn getHasTables(self: *Self) bool {
|
||||
const v = self.private().tables orelse return false;
|
||||
return v.strings.len > 0;
|
||||
}
|
||||
|
||||
fn getHasSequence(self: *Self) bool {
|
||||
const v = self.private().sequence orelse return false;
|
||||
return v.strings.len > 0;
|
||||
}
|
||||
|
||||
fn closureShowChevron(
|
||||
_: *Self,
|
||||
has_tables: bool,
|
||||
has_sequence: bool,
|
||||
) callconv(.c) c_int {
|
||||
return if (has_tables and has_sequence) 1 else 0;
|
||||
}
|
||||
|
||||
fn closureHasState(
|
||||
_: *Self,
|
||||
has_tables: bool,
|
||||
has_sequence: bool,
|
||||
) callconv(.c) c_int {
|
||||
return if (has_tables or has_sequence) 1 else 0;
|
||||
}
|
||||
|
||||
fn closureTablesText(
|
||||
_: *Self,
|
||||
tables: ?*ext.StringList,
|
||||
) callconv(.c) ?[*:0]const u8 {
|
||||
const list = tables orelse return null;
|
||||
if (list.strings.len == 0) return null;
|
||||
|
||||
var buf: std.Io.Writer.Allocating = .init(Application.default().allocator());
|
||||
defer buf.deinit();
|
||||
|
||||
for (list.strings, 0..) |s, i| {
|
||||
if (i > 0) buf.writer.writeAll(" > ") catch return null;
|
||||
buf.writer.writeAll(s) catch return null;
|
||||
}
|
||||
|
||||
return glib.ext.dupeZ(u8, buf.written());
|
||||
}
|
||||
|
||||
fn closureSequenceText(
|
||||
_: *Self,
|
||||
sequence: ?*ext.StringList,
|
||||
) callconv(.c) ?[*:0]const u8 {
|
||||
const list = sequence orelse return null;
|
||||
if (list.strings.len == 0) return null;
|
||||
|
||||
var buf: std.Io.Writer.Allocating = .init(Application.default().allocator());
|
||||
defer buf.deinit();
|
||||
|
||||
for (list.strings, 0..) |s, i| {
|
||||
if (i > 0) buf.writer.writeAll(" ") catch return null;
|
||||
buf.writer.writeAll(s) catch return null;
|
||||
}
|
||||
|
||||
return glib.ext.dupeZ(u8, buf.written());
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------
|
||||
// Template callbacks
|
||||
|
||||
fn onDragEnd(
|
||||
_: *gtk.GestureDrag,
|
||||
_: f64,
|
||||
offset_y: f64,
|
||||
self: *Self,
|
||||
) callconv(.c) void {
|
||||
// Key state overlay only moves between top-center and bottom-center.
|
||||
// Horizontal alignment is always center.
|
||||
const priv = self.private();
|
||||
const widget = self.as(gtk.Widget);
|
||||
const parent = widget.getParent() orelse return;
|
||||
|
||||
const parent_height: f64 = @floatFromInt(parent.getAllocatedHeight());
|
||||
const self_height: f64 = @floatFromInt(widget.getAllocatedHeight());
|
||||
|
||||
const self_y: f64 = if (priv.valign_target == .start) 0 else parent_height - self_height;
|
||||
const new_y = self_y + offset_y + (self_height / 2);
|
||||
|
||||
const new_valign: gtk.Align = if (new_y > parent_height / 2) .end else .start;
|
||||
|
||||
if (new_valign != priv.valign_target) {
|
||||
priv.valign_target = new_valign;
|
||||
self.as(gobject.Object).notifyByPspec(properties.@"valign-target".impl.param_spec);
|
||||
self.as(gtk.Widget).queueResize();
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------
|
||||
// Virtual methods
|
||||
|
||||
fn dispose(self: *Self) callconv(.c) void {
|
||||
gtk.Widget.disposeTemplate(
|
||||
self.as(gtk.Widget),
|
||||
getGObjectType(),
|
||||
);
|
||||
|
||||
gobject.Object.virtual_methods.dispose.call(
|
||||
Class.parent,
|
||||
self.as(Parent),
|
||||
);
|
||||
}
|
||||
|
||||
fn finalize(self: *Self) callconv(.c) void {
|
||||
const priv = self.private();
|
||||
|
||||
if (priv.tables) |v| {
|
||||
v.destroy();
|
||||
}
|
||||
if (priv.sequence) |v| {
|
||||
v.destroy();
|
||||
}
|
||||
|
||||
gobject.Object.virtual_methods.finalize.call(
|
||||
Class.parent,
|
||||
self.as(Parent),
|
||||
);
|
||||
}
|
||||
|
||||
const C = Common(Self, Private);
|
||||
pub const as = C.as;
|
||||
pub const ref = C.ref;
|
||||
pub const unref = C.unref;
|
||||
const private = C.private;
|
||||
|
||||
pub const Class = extern struct {
|
||||
parent_class: Parent.Class,
|
||||
var parent: *Parent.Class = undefined;
|
||||
pub const Instance = Self;
|
||||
|
||||
fn init(class: *Class) callconv(.c) void {
|
||||
gtk.Widget.Class.setTemplateFromResource(
|
||||
class.as(gtk.Widget.Class),
|
||||
comptime gresource.blueprint(.{
|
||||
.major = 1,
|
||||
.minor = 2,
|
||||
.name = "key-state-overlay",
|
||||
}),
|
||||
);
|
||||
|
||||
// Template Callbacks
|
||||
class.bindTemplateCallback("on_drag_end", &onDragEnd);
|
||||
class.bindTemplateCallback("show_chevron", &closureShowChevron);
|
||||
class.bindTemplateCallback("has_state", &closureHasState);
|
||||
class.bindTemplateCallback("tables_text", &closureTablesText);
|
||||
class.bindTemplateCallback("sequence_text", &closureSequenceText);
|
||||
|
||||
// Properties
|
||||
gobject.ext.registerProperties(class, &.{
|
||||
properties.tables.impl,
|
||||
properties.@"has-tables".impl,
|
||||
properties.sequence.impl,
|
||||
properties.@"has-sequence".impl,
|
||||
properties.@"valign-target".impl,
|
||||
});
|
||||
|
||||
// Virtual methods
|
||||
gobject.Object.virtual_methods.dispose.implement(class, &dispose);
|
||||
gobject.Object.virtual_methods.finalize.implement(class, &finalize);
|
||||
}
|
||||
|
||||
pub const as = C.Class.as;
|
||||
pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
|
||||
pub const bindTemplateCallback = C.Class.bindTemplateCallback;
|
||||
};
|
||||
};
|
||||
@@ -26,6 +26,7 @@ const Application = @import("application.zig").Application;
|
||||
const Config = @import("config.zig").Config;
|
||||
const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay;
|
||||
const SearchOverlay = @import("search_overlay.zig").SearchOverlay;
|
||||
const KeyStateOverlay = @import("key_state_overlay.zig").KeyStateOverlay;
|
||||
const ChildExited = @import("surface_child_exited.zig").SurfaceChildExited;
|
||||
const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog;
|
||||
const TitleDialog = @import("surface_title_dialog.zig").SurfaceTitleDialog;
|
||||
@@ -360,6 +361,44 @@ pub const Surface = extern struct {
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
pub const @"key-sequence" = struct {
|
||||
pub const name = "key-sequence";
|
||||
const impl = gobject.ext.defineProperty(
|
||||
name,
|
||||
Self,
|
||||
?*ext.StringList,
|
||||
.{
|
||||
.accessor = gobject.ext.typedAccessor(
|
||||
Self,
|
||||
?*ext.StringList,
|
||||
.{
|
||||
.getter = getKeySequence,
|
||||
.getter_transfer = .full,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
pub const @"key-table" = struct {
|
||||
pub const name = "key-table";
|
||||
const impl = gobject.ext.defineProperty(
|
||||
name,
|
||||
Self,
|
||||
?*ext.StringList,
|
||||
.{
|
||||
.accessor = gobject.ext.typedAccessor(
|
||||
Self,
|
||||
?*ext.StringList,
|
||||
.{
|
||||
.getter = getKeyTable,
|
||||
.getter_transfer = .full,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
pub const signals = struct {
|
||||
@@ -553,6 +592,9 @@ pub const Surface = extern struct {
|
||||
/// The search overlay
|
||||
search_overlay: *SearchOverlay,
|
||||
|
||||
/// The key state overlay
|
||||
key_state_overlay: *KeyStateOverlay,
|
||||
|
||||
/// The apprt Surface.
|
||||
rt_surface: ApprtSurface = undefined,
|
||||
|
||||
@@ -617,6 +659,10 @@ pub const Surface = extern struct {
|
||||
vscroll_policy: gtk.ScrollablePolicy = .natural,
|
||||
vadj_signal_group: ?*gobject.SignalGroup = null,
|
||||
|
||||
// Key state tracking for key sequences and tables
|
||||
key_sequence: std.ArrayListUnmanaged([:0]const u8) = .empty,
|
||||
key_tables: std.ArrayListUnmanaged([:0]const u8) = .empty,
|
||||
|
||||
// Template binds
|
||||
child_exited_overlay: *ChildExited,
|
||||
context_menu: *gtk.PopoverMenu,
|
||||
@@ -778,6 +824,74 @@ pub const Surface = extern struct {
|
||||
if (priv.inspector) |v| v.queueRender();
|
||||
}
|
||||
|
||||
/// Handle a key sequence action from the apprt.
|
||||
pub fn keySequenceAction(
|
||||
self: *Self,
|
||||
value: apprt.action.KeySequence,
|
||||
) Allocator.Error!void {
|
||||
const priv = self.private();
|
||||
const alloc = Application.default().allocator();
|
||||
|
||||
self.as(gobject.Object).freezeNotify();
|
||||
defer self.as(gobject.Object).thawNotify();
|
||||
self.as(gobject.Object).notifyByPspec(properties.@"key-sequence".impl.param_spec);
|
||||
|
||||
switch (value) {
|
||||
.trigger => |trigger| {
|
||||
// Convert the trigger to a human-readable label
|
||||
var buf: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer buf.deinit();
|
||||
if (gtk_key.labelFromTrigger(&buf.writer, trigger)) |success| {
|
||||
if (!success) return;
|
||||
} else |_| return error.OutOfMemory;
|
||||
|
||||
// Make space
|
||||
try priv.key_sequence.ensureUnusedCapacity(alloc, 1);
|
||||
|
||||
// Copy and append
|
||||
const duped = try buf.toOwnedSliceSentinel(0);
|
||||
errdefer alloc.free(duped);
|
||||
priv.key_sequence.appendAssumeCapacity(duped);
|
||||
},
|
||||
.end => {
|
||||
// Free all the stored strings and clear
|
||||
for (priv.key_sequence.items) |s| alloc.free(s);
|
||||
priv.key_sequence.clearAndFree(alloc);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a key table action from the apprt.
|
||||
pub fn keyTableAction(
|
||||
self: *Self,
|
||||
value: apprt.action.KeyTable,
|
||||
) Allocator.Error!void {
|
||||
const priv = self.private();
|
||||
const alloc = Application.default().allocator();
|
||||
|
||||
self.as(gobject.Object).freezeNotify();
|
||||
defer self.as(gobject.Object).thawNotify();
|
||||
self.as(gobject.Object).notifyByPspec(properties.@"key-table".impl.param_spec);
|
||||
|
||||
switch (value) {
|
||||
.activate => |name| {
|
||||
// Duplicate the name string and push onto stack
|
||||
const duped = try alloc.dupeZ(u8, name);
|
||||
errdefer alloc.free(duped);
|
||||
try priv.key_tables.append(alloc, duped);
|
||||
},
|
||||
.deactivate => {
|
||||
// Pop and free the top table
|
||||
if (priv.key_tables.pop()) |s| alloc.free(s);
|
||||
},
|
||||
.deactivate_all => {
|
||||
// Free all tables and clear
|
||||
for (priv.key_tables.items) |s| alloc.free(s);
|
||||
priv.key_tables.clearAndFree(alloc);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn showOnScreenKeyboard(self: *Self, event: ?*gdk.Event) bool {
|
||||
const priv = self.private();
|
||||
return priv.im_context.as(gtk.IMContext).activateOsk(event) != 0;
|
||||
@@ -1787,6 +1901,14 @@ pub const Surface = extern struct {
|
||||
glib.free(@ptrCast(@constCast(v)));
|
||||
priv.title_override = null;
|
||||
}
|
||||
|
||||
// Clean up key sequence and key table state
|
||||
const alloc = Application.default().allocator();
|
||||
for (priv.key_sequence.items) |s| alloc.free(s);
|
||||
priv.key_sequence.deinit(alloc);
|
||||
for (priv.key_tables.items) |s| alloc.free(s);
|
||||
priv.key_tables.deinit(alloc);
|
||||
|
||||
self.clearCgroup();
|
||||
|
||||
gobject.Object.virtual_methods.finalize.call(
|
||||
@@ -1873,6 +1995,20 @@ pub const Surface = extern struct {
|
||||
self.as(gobject.Object).notifyByPspec(properties.@"default-size".impl.param_spec);
|
||||
}
|
||||
|
||||
/// Get the key sequence list. Full transfer.
|
||||
fn getKeySequence(self: *Self) ?*ext.StringList {
|
||||
const priv = self.private();
|
||||
const alloc = Application.default().allocator();
|
||||
return ext.StringList.create(alloc, priv.key_sequence.items) catch null;
|
||||
}
|
||||
|
||||
/// Get the key table list. Full transfer.
|
||||
fn getKeyTable(self: *Self) ?*ext.StringList {
|
||||
const priv = self.private();
|
||||
const alloc = Application.default().allocator();
|
||||
return ext.StringList.create(alloc, priv.key_tables.items) catch null;
|
||||
}
|
||||
|
||||
/// Return the min size, if set.
|
||||
pub fn getMinSize(self: *Self) ?*Size {
|
||||
const priv = self.private();
|
||||
@@ -3236,6 +3372,7 @@ pub const Surface = extern struct {
|
||||
fn init(class: *Class) callconv(.c) void {
|
||||
gobject.ext.ensureType(ResizeOverlay);
|
||||
gobject.ext.ensureType(SearchOverlay);
|
||||
gobject.ext.ensureType(KeyStateOverlay);
|
||||
gobject.ext.ensureType(ChildExited);
|
||||
gtk.Widget.Class.setTemplateFromResource(
|
||||
class.as(gtk.Widget.Class),
|
||||
@@ -3256,6 +3393,7 @@ pub const Surface = extern struct {
|
||||
class.bindTemplateChildPrivate("progress_bar_overlay", .{});
|
||||
class.bindTemplateChildPrivate("resize_overlay", .{});
|
||||
class.bindTemplateChildPrivate("search_overlay", .{});
|
||||
class.bindTemplateChildPrivate("key_state_overlay", .{});
|
||||
class.bindTemplateChildPrivate("terminal_page", .{});
|
||||
class.bindTemplateChildPrivate("drop_target", .{});
|
||||
class.bindTemplateChildPrivate("im_context", .{});
|
||||
@@ -3307,6 +3445,8 @@ pub const Surface = extern struct {
|
||||
properties.@"error".impl,
|
||||
properties.@"font-size-request".impl,
|
||||
properties.focused.impl,
|
||||
properties.@"key-sequence".impl,
|
||||
properties.@"key-table".impl,
|
||||
properties.@"min-size".impl,
|
||||
properties.@"mouse-shape".impl,
|
||||
properties.@"mouse-hidden".impl,
|
||||
|
||||
@@ -46,6 +46,18 @@ label.url-overlay.right {
|
||||
outline-width: 1px;
|
||||
}
|
||||
|
||||
/*
|
||||
* GhosttySurface key state overlay
|
||||
*/
|
||||
.key-state-overlay {
|
||||
padding: 6px 10px;
|
||||
margin: 8px;
|
||||
border-radius: 8px;
|
||||
outline-style: solid;
|
||||
outline-color: #555555;
|
||||
outline-width: 1px;
|
||||
}
|
||||
|
||||
/*
|
||||
* GhosttySurface resize overlay
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,8 @@ const gobject = @import("gobject");
|
||||
const gtk = @import("gtk");
|
||||
|
||||
pub const actions = @import("ext/actions.zig");
|
||||
const slice = @import("ext/slice.zig");
|
||||
pub const StringList = slice.StringList;
|
||||
|
||||
/// Wrapper around `gobject.boxedCopy` to copy a boxed type `T`.
|
||||
pub fn boxedCopy(comptime T: type, ptr: *const T) *T {
|
||||
@@ -64,4 +66,5 @@ pub fn gValueHolds(value_: ?*const gobject.Value, g_type: gobject.Type) bool {
|
||||
|
||||
test {
|
||||
_ = actions;
|
||||
_ = slice;
|
||||
}
|
||||
|
||||
111
src/apprt/gtk/ext/slice.zig
Normal file
111
src/apprt/gtk/ext/slice.zig
Normal file
@@ -0,0 +1,111 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
const glib = @import("glib");
|
||||
const gobject = @import("gobject");
|
||||
|
||||
/// A boxed type that holds a list of string slices.
|
||||
pub const StringList = struct {
|
||||
arena: ArenaAllocator,
|
||||
strings: []const [:0]const u8,
|
||||
|
||||
pub fn create(
|
||||
alloc: Allocator,
|
||||
strings: []const [:0]const u8,
|
||||
) Allocator.Error!*StringList {
|
||||
var arena: ArenaAllocator = .init(alloc);
|
||||
errdefer arena.deinit();
|
||||
const arena_alloc = arena.allocator();
|
||||
var stored = try arena_alloc.alloc([:0]const u8, strings.len);
|
||||
for (strings, 0..) |s, i| stored[i] = try arena_alloc.dupeZ(u8, s);
|
||||
|
||||
const ptr = try alloc.create(StringList);
|
||||
errdefer alloc.destroy(ptr);
|
||||
ptr.* = .{ .arena = arena, .strings = stored };
|
||||
|
||||
return ptr;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *StringList) void {
|
||||
self.arena.deinit();
|
||||
}
|
||||
|
||||
pub fn destroy(self: *StringList) void {
|
||||
const alloc = self.arena.child_allocator;
|
||||
self.deinit();
|
||||
alloc.destroy(self);
|
||||
}
|
||||
|
||||
/// Returns the general-purpose allocator used by this StringList.
|
||||
pub fn allocator(self: *const StringList) Allocator {
|
||||
return self.arena.child_allocator;
|
||||
}
|
||||
|
||||
pub const getGObjectType = gobject.ext.defineBoxed(
|
||||
StringList,
|
||||
.{
|
||||
.name = "GhosttyStringList",
|
||||
.funcs = .{
|
||||
.copy = &struct {
|
||||
fn copy(self: *StringList) callconv(.c) *StringList {
|
||||
return StringList.create(
|
||||
self.arena.child_allocator,
|
||||
self.strings,
|
||||
) catch @panic("OOM");
|
||||
}
|
||||
}.copy,
|
||||
.free = &struct {
|
||||
fn free(self: *StringList) callconv(.c) void {
|
||||
self.destroy();
|
||||
}
|
||||
}.free,
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
test "StringList create and destroy" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const input: []const [:0]const u8 = &.{ "hello", "world" };
|
||||
const list = try StringList.create(alloc, input);
|
||||
defer list.destroy();
|
||||
|
||||
try testing.expectEqual(@as(usize, 2), list.strings.len);
|
||||
try testing.expectEqualStrings("hello", list.strings[0]);
|
||||
try testing.expectEqualStrings("world", list.strings[1]);
|
||||
}
|
||||
|
||||
test "StringList create empty list" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const input: []const [:0]const u8 = &.{};
|
||||
const list = try StringList.create(alloc, input);
|
||||
defer list.destroy();
|
||||
|
||||
try testing.expectEqual(@as(usize, 0), list.strings.len);
|
||||
}
|
||||
|
||||
test "StringList boxedCopy and boxedFree" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const input: []const [:0]const u8 = &.{ "foo", "bar", "baz" };
|
||||
const original = try StringList.create(alloc, input);
|
||||
defer original.destroy();
|
||||
|
||||
const copied: *StringList = @ptrCast(@alignCast(gobject.boxedCopy(
|
||||
StringList.getGObjectType(),
|
||||
original,
|
||||
)));
|
||||
defer gobject.boxedFree(StringList.getGObjectType(), copied);
|
||||
|
||||
try testing.expectEqual(@as(usize, 3), copied.strings.len);
|
||||
try testing.expectEqualStrings("foo", copied.strings[0]);
|
||||
try testing.expectEqualStrings("bar", copied.strings[1]);
|
||||
try testing.expectEqualStrings("baz", copied.strings[2]);
|
||||
|
||||
try testing.expect(original.strings.ptr != copied.strings.ptr);
|
||||
}
|
||||
@@ -233,6 +233,70 @@ pub fn keyvalFromKey(key: input.Key) ?c_uint {
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a trigger to a human-readable label for display in UI.
|
||||
///
|
||||
/// Uses GTK accelerator-style formatting (e.g., "Ctrl+Shift+A").
|
||||
/// Returns false if the trigger cannot be formatted (e.g., catch_all).
|
||||
pub fn labelFromTrigger(
|
||||
writer: *std.Io.Writer,
|
||||
trigger: input.Binding.Trigger,
|
||||
) std.Io.Writer.Error!bool {
|
||||
// Modifiers first, using human-readable format
|
||||
if (trigger.mods.super) try writer.writeAll("Super+");
|
||||
if (trigger.mods.ctrl) try writer.writeAll("Ctrl+");
|
||||
if (trigger.mods.alt) try writer.writeAll("Alt+");
|
||||
if (trigger.mods.shift) try writer.writeAll("Shift+");
|
||||
|
||||
// Write the key
|
||||
return writeTriggerKeyLabel(writer, trigger);
|
||||
}
|
||||
|
||||
/// Writes the key portion of a trigger in human-readable format.
|
||||
fn writeTriggerKeyLabel(
|
||||
writer: *std.Io.Writer,
|
||||
trigger: input.Binding.Trigger,
|
||||
) error{WriteFailed}!bool {
|
||||
switch (trigger.key) {
|
||||
.physical => |k| {
|
||||
const keyval = keyvalFromKey(k) orelse return false;
|
||||
const name = gdk.keyvalName(keyval) orelse return false;
|
||||
// Capitalize the first letter for nicer display
|
||||
const span = std.mem.span(name);
|
||||
if (span.len > 0) {
|
||||
if (span[0] >= 'a' and span[0] <= 'z') {
|
||||
try writer.writeByte(span[0] - 'a' + 'A');
|
||||
if (span.len > 1) try writer.writeAll(span[1..]);
|
||||
} else {
|
||||
try writer.writeAll(span);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
.unicode => |cp| {
|
||||
// Try to get a nice name from GDK first
|
||||
if (gdk.keyvalName(cp)) |name| {
|
||||
const span = std.mem.span(name);
|
||||
if (span.len > 0) {
|
||||
// Capitalize the first letter for nicer display
|
||||
if (span[0] >= 'a' and span[0] <= 'z') {
|
||||
try writer.writeByte(span[0] - 'a' + 'A');
|
||||
if (span.len > 1) try writer.writeAll(span[1..]);
|
||||
} else {
|
||||
try writer.writeAll(span);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fall back to printing the character
|
||||
try writer.print("{u}", .{cp});
|
||||
}
|
||||
},
|
||||
|
||||
.catch_all => return false,
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
test "accelFromTrigger" {
|
||||
const testing = std.testing;
|
||||
var buf: [256]u8 = undefined;
|
||||
@@ -263,6 +327,64 @@ test "xdgShortcutFromTrigger" {
|
||||
})).?);
|
||||
}
|
||||
|
||||
test "labelFromTrigger" {
|
||||
const testing = std.testing;
|
||||
|
||||
// Simple unicode key with modifier
|
||||
{
|
||||
var buf: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer buf.deinit();
|
||||
try testing.expect(try labelFromTrigger(&buf.writer, .{
|
||||
.mods = .{ .super = true },
|
||||
.key = .{ .unicode = 'q' },
|
||||
}));
|
||||
try testing.expectEqualStrings("Super+Q", buf.written());
|
||||
}
|
||||
|
||||
// Multiple modifiers
|
||||
{
|
||||
var buf: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer buf.deinit();
|
||||
try testing.expect(try labelFromTrigger(&buf.writer, .{
|
||||
.mods = .{ .ctrl = true, .alt = true, .super = true, .shift = true },
|
||||
.key = .{ .unicode = 92 },
|
||||
}));
|
||||
try testing.expectEqualStrings("Super+Ctrl+Alt+Shift+Backslash", buf.written());
|
||||
}
|
||||
|
||||
// Physical key
|
||||
{
|
||||
var buf: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer buf.deinit();
|
||||
try testing.expect(try labelFromTrigger(&buf.writer, .{
|
||||
.mods = .{ .ctrl = true },
|
||||
.key = .{ .physical = .key_a },
|
||||
}));
|
||||
try testing.expectEqualStrings("Ctrl+A", buf.written());
|
||||
}
|
||||
|
||||
// No modifiers
|
||||
{
|
||||
var buf: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer buf.deinit();
|
||||
try testing.expect(try labelFromTrigger(&buf.writer, .{
|
||||
.mods = .{},
|
||||
.key = .{ .physical = .escape },
|
||||
}));
|
||||
try testing.expectEqualStrings("Escape", buf.written());
|
||||
}
|
||||
|
||||
// catch_all returns false
|
||||
{
|
||||
var buf: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer buf.deinit();
|
||||
try testing.expect(!try labelFromTrigger(&buf.writer, .{
|
||||
.mods = .{},
|
||||
.key = .catch_all,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// A raw entry in the keymap. Our keymap contains mappings between
|
||||
/// GDK keys and our own key enum.
|
||||
const RawEntry = struct { c_uint, input.Key };
|
||||
|
||||
58
src/apprt/gtk/ui/1.2/key-state-overlay.blp
Normal file
58
src/apprt/gtk/ui/1.2/key-state-overlay.blp
Normal file
@@ -0,0 +1,58 @@
|
||||
using Gtk 4.0;
|
||||
using Adw 1;
|
||||
|
||||
template $GhosttyKeyStateOverlay: Adw.Bin {
|
||||
visible: bind $has_state(template.has-tables, template.has-sequence) as <bool>;
|
||||
valign-target: end;
|
||||
halign: center;
|
||||
valign: bind template.valign-target;
|
||||
|
||||
GestureDrag {
|
||||
button: 1;
|
||||
propagation-phase: capture;
|
||||
drag-end => $on_drag_end();
|
||||
}
|
||||
|
||||
Adw.Bin {
|
||||
Box container {
|
||||
styles [
|
||||
"background",
|
||||
"key-state-overlay",
|
||||
]
|
||||
|
||||
orientation: horizontal;
|
||||
spacing: 6;
|
||||
|
||||
Image {
|
||||
icon-name: "input-keyboard-symbolic";
|
||||
pixel-size: 16;
|
||||
}
|
||||
|
||||
Label tables_label {
|
||||
visible: bind template.has-tables;
|
||||
label: bind $tables_text(template.tables) as <string>;
|
||||
xalign: 0.0;
|
||||
}
|
||||
|
||||
Label chevron_label {
|
||||
visible: bind $show_chevron(template.has-tables, template.has-sequence) as <bool>;
|
||||
label: "›";
|
||||
|
||||
styles [
|
||||
"dim-label",
|
||||
]
|
||||
}
|
||||
|
||||
Label sequence_label {
|
||||
visible: bind template.has-sequence;
|
||||
label: bind $sequence_text(template.sequence) as <string>;
|
||||
xalign: 0.0;
|
||||
}
|
||||
|
||||
Spinner pending_spinner {
|
||||
visible: bind template.has-sequence;
|
||||
spinning: bind template.has-sequence;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,12 @@ Overlay terminal_page {
|
||||
previous-match => $search_previous_match();
|
||||
}
|
||||
|
||||
[overlay]
|
||||
$GhosttyKeyStateOverlay key_state_overlay {
|
||||
tables: bind template.key-table;
|
||||
sequence: bind template.key-sequence;
|
||||
}
|
||||
|
||||
[overlay]
|
||||
// Apply unfocused-split-fill and unfocused-split-opacity to current surface
|
||||
// this is only applied when a tab has more than one surface
|
||||
|
||||
@@ -72,7 +72,16 @@
|
||||
fun:gdk_surface_handle_event
|
||||
...
|
||||
}
|
||||
|
||||
{
|
||||
GTK CSS Node Validation
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: possible
|
||||
fun:malloc
|
||||
...
|
||||
fun:gtk_css_node_validate_internal
|
||||
fun:gtk_css_node_validate
|
||||
...
|
||||
}
|
||||
{
|
||||
GTK CSS Provider Leak
|
||||
Memcheck:Leak
|
||||
@@ -196,8 +205,44 @@
|
||||
fun:svga_context_flush
|
||||
...
|
||||
}
|
||||
|
||||
{
|
||||
SVGA Stuff
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: definite
|
||||
fun:calloc
|
||||
fun:svga_create_surface_view
|
||||
fun:svga_set_framebuffer_state
|
||||
fun:st_update_framebuffer_state
|
||||
fun:st_Clear
|
||||
fun:gsk_gpu_render_pass_op_gl_command
|
||||
...
|
||||
}
|
||||
{
|
||||
GTK Icon
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: possible
|
||||
fun:*alloc
|
||||
...
|
||||
fun:gtk_icon_theme_set_display
|
||||
fun:gtk_icon_theme_get_for_display
|
||||
...
|
||||
}
|
||||
{
|
||||
GDK Wayland Connection
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: possible
|
||||
fun:calloc
|
||||
fun:wl_closure_init
|
||||
fun:wl_connection_demarshal
|
||||
fun:wl_display_read_events
|
||||
fun:gdk_wayland_poll_source_check
|
||||
fun:g_main_context_check_unlocked
|
||||
fun:g_main_context_iterate_unlocked.isra.0
|
||||
fun:g_main_context_iteration
|
||||
...
|
||||
}
|
||||
{
|
||||
|
||||
GSK Renderer GPU Stuff
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: possible
|
||||
@@ -297,6 +342,21 @@
|
||||
fun:g_main_context_iteration
|
||||
...
|
||||
}
|
||||
{
|
||||
GSK More Forms
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: possible
|
||||
...
|
||||
fun:gsk_gl_device_use_program
|
||||
fun:gsk_gl_frame_use_program
|
||||
fun:gsk_gpu_shader_op_gl_command_n
|
||||
fun:gsk_gpu_render_pass_op_gl_command
|
||||
fun:gsk_gl_frame_submit
|
||||
fun:gsk_gpu_renderer_render_texture
|
||||
fun:gsk_renderer_render_texture
|
||||
fun:render_contents
|
||||
...
|
||||
}
|
||||
{
|
||||
GTK Shader Selector
|
||||
Memcheck:Leak
|
||||
|
||||
Reference in New Issue
Block a user