diff --git a/src/apprt/gtk.zig b/src/apprt/gtk.zig index 415d3773d..07b4eb0e7 100644 --- a/src/apprt/gtk.zig +++ b/src/apprt/gtk.zig @@ -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"); } diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 1c0863f3c..b16bce049 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -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 diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 93d1beeb2..c35c78302 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -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( diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig index 35c9390b2..5f717e14a 100644 --- a/src/apprt/gtk/key.zig +++ b/src/apprt/gtk/key.zig @@ -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 };