apprt/gtk: store key sequences/tables in surface state

This commit is contained in:
Mitchell Hashimoto
2025-12-23 09:06:46 -08:00
parent 6b7a7aacf2
commit a1ee2f0764
4 changed files with 228 additions and 2 deletions

View File

@@ -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");
}

View File

@@ -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

View File

@@ -617,6 +617,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 +782,66 @@ 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();
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();
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 +1851,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(

View File

@@ -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 };