From 14417d2592377f39a96df48197382a2e03d026a3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 2 Jun 2024 10:53:36 -0700 Subject: [PATCH] Allow keybinding arbitrary unicode codepoints (#1814) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1802 This allows `keybind` configurations to map to any Unicode codepoint. This enables keybindings for which we don't have a registered keycode or for custom keyboard firmwares that may produce arbitrary text (but the Ghostty support is limited to a single codepoint). The `keybind` syntax is unchanged. If a bound character doesn't map to a known logical key that Ghostty knows about, we map it to a Unicode codepoint. The unicode codepoint is compared against the _unshifted codepoint_ from the apprt key event. Note that this binding is to a single _codepoint_. We don't support arbitrary sequences of characters or multi-code point graphemes for keybindings due to the complexity in memory management that would introduce. This also provides a good fallback for scenarios where it might make sense to educate Ghostty about a key code or fix a bug in our keyboard input system, but the unicode data is correct. In that scenario, unicode key binds should allow key binds to still work while we investigate the input issues. Example: ``` shift+ö=text:hello ``` This now works as expected on a US hardware keyboard with the Hungarian keyboard layout. --- include/ghostty.h | 16 +- macos/Sources/Ghostty/Ghostty.Config.swift | 23 ++- src/Surface.zig | 14 +- src/apprt/gtk/key.zig | 10 +- src/config/CAPI.zig | 7 +- src/config/Config.zig | 156 +++++++++--------- src/input/Binding.zig | 178 +++++++++++++++------ 7 files changed, 268 insertions(+), 136 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 890e5f50e..1254f71bd 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -330,10 +330,22 @@ typedef struct { bool composing; } ghostty_input_key_s; +typedef enum { + GHOSTTY_TRIGGER_TRANSLATED, + GHOSTTY_TRIGGER_PHYSICAL, + GHOSTTY_TRIGGER_UNICODE, +} ghostty_input_trigger_tag_e; + +typedef union { + ghostty_input_key_e translated; + ghostty_input_key_e physical; + uint32_t unicode; +} ghostty_input_trigger_key_u; + typedef struct { - ghostty_input_key_e key; + ghostty_input_trigger_tag_e tag; + ghostty_input_trigger_key_u key; ghostty_input_mods_e mods; - bool physical; } ghostty_input_trigger_s; typedef enum { diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index a444c1d9a..2f455e578 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -115,7 +115,28 @@ extension Ghostty { guard let cfg = self.config else { return nil } let trigger = ghostty_config_trigger(cfg, action, UInt(action.count)) - guard let equiv = Ghostty.keyEquivalent(key: trigger.key) else { return nil } + let equiv: String + switch (trigger.tag) { + case GHOSTTY_TRIGGER_TRANSLATED: + if let v = Ghostty.keyEquivalent(key: trigger.key.translated) { + equiv = v + } else { + return nil + } + + case GHOSTTY_TRIGGER_PHYSICAL: + if let v = Ghostty.keyEquivalent(key: trigger.key.physical) { + equiv = v + } else { + return nil + } + + case GHOSTTY_TRIGGER_UNICODE: + equiv = String(trigger.key.unicode) + + default: + return nil + } return KeyEquivalent( key: equiv, diff --git a/src/Surface.zig b/src/Surface.zig index c2ac81240..2839f908c 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1188,7 +1188,7 @@ pub fn keyCallback( const binding_mods = event.mods.binding(); var trigger: input.Binding.Trigger = .{ .mods = binding_mods, - .key = event.key, + .key = .{ .translated = event.key }, }; const set = self.config.keybind.set; @@ -1198,14 +1198,22 @@ pub fn keyCallback( set.getConsumed(trigger), }; - trigger.key = event.physical_key; - trigger.physical = true; + trigger.key = .{ .physical = event.physical_key }; if (set.get(trigger)) |v| break :action .{ v, trigger, set.getConsumed(trigger), }; + if (event.unshifted_codepoint > 0) { + trigger.key = .{ .unicode = event.unshifted_codepoint }; + if (set.get(trigger)) |v| break :action .{ + v, + trigger, + set.getConsumed(trigger), + }; + } + break :binding; }; diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig index 70abd18ca..c2b001738 100644 --- a/src/apprt/gtk/key.zig +++ b/src/apprt/gtk/key.zig @@ -14,8 +14,14 @@ pub fn accelFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u if (trigger.mods.super) try writer.writeAll(""); // Write our key - const keyval = keyvalFromKey(trigger.key) orelse return null; - try writer.writeAll(std.mem.sliceTo(c.gdk_keyval_name(keyval), 0)); + switch (trigger.key) { + .physical, .translated => |k| { + const keyval = keyvalFromKey(k) orelse return null; + try writer.writeAll(std.mem.sliceTo(c.gdk_keyval_name(keyval), 0)); + }, + + .unicode => |cp| try writer.print("{u}", .{cp}), + } // We need to make the string null terminated. try writer.writeByte(0); diff --git a/src/config/CAPI.zig b/src/config/CAPI.zig index cff6a4ea3..ef50c4e2b 100644 --- a/src/config/CAPI.zig +++ b/src/config/CAPI.zig @@ -96,7 +96,7 @@ export fn ghostty_config_trigger( self: *Config, str: [*]const u8, len: usize, -) inputpkg.Binding.Trigger { +) inputpkg.Binding.Trigger.C { return config_trigger_(self, str[0..len]) catch |err| err: { log.err("error finding trigger err={}", .{err}); break :err .{}; @@ -106,9 +106,10 @@ export fn ghostty_config_trigger( fn config_trigger_( self: *Config, str: []const u8, -) !inputpkg.Binding.Trigger { +) !inputpkg.Binding.Trigger.C { const action = try inputpkg.Binding.Action.parse(str); - return self.keybind.set.getTrigger(action) orelse .{}; + const trigger: inputpkg.Binding.Trigger = self.keybind.set.getTrigger(action) orelse .{}; + return trigger.cval(); } export fn ghostty_config_errors_count(self: *Config) u32 { diff --git a/src/config/Config.zig b/src/config/Config.zig index eb2cf0915..c17d1e42d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1100,12 +1100,12 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { // keybinds for opening and reloading config try result.keybind.set.put( alloc, - .{ .key = .comma, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, + .{ .key = .{ .translated = .comma }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, .{ .reload_config = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .comma, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .key = .{ .translated = .comma }, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .open_config = {} }, ); @@ -1119,12 +1119,12 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { try result.keybind.set.put( alloc, - .{ .key = .c, .mods = mods }, + .{ .key = .{ .translated = .c }, .mods = mods }, .{ .copy_to_clipboard = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .v, .mods = mods }, + .{ .key = .{ .translated = .v }, .mods = mods }, .{ .paste_from_clipboard = {} }, ); } @@ -1132,29 +1132,29 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { // Fonts try result.keybind.set.put( alloc, - .{ .key = .equal, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .key = .{ .translated = .equal }, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .increase_font_size = 1 }, ); // Increase font size mapping for keyboards with dedicated plus keys (like german) try result.keybind.set.put( alloc, - .{ .key = .plus, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .key = .{ .translated = .plus }, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .increase_font_size = 1 }, ); try result.keybind.set.put( alloc, - .{ .key = .minus, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .key = .{ .translated = .minus }, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .decrease_font_size = 1 }, ); try result.keybind.set.put( alloc, - .{ .key = .zero, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .key = .{ .translated = .zero }, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .reset_font_size = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .j, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, + .{ .key = .{ .translated = .j }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, .{ .write_scrollback_file = {} }, ); @@ -1162,169 +1162,169 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { if (comptime !builtin.target.isDarwin()) { try result.keybind.set.put( alloc, - .{ .key = .n, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .translated = .n }, .mods = .{ .ctrl = true, .shift = true } }, .{ .new_window = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .w, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .translated = .w }, .mods = .{ .ctrl = true, .shift = true } }, .{ .close_surface = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .q, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .translated = .q }, .mods = .{ .ctrl = true, .shift = true } }, .{ .quit = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .f4, .mods = .{ .alt = true } }, + .{ .key = .{ .translated = .f4 }, .mods = .{ .alt = true } }, .{ .close_window = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .t, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .translated = .t }, .mods = .{ .ctrl = true, .shift = true } }, .{ .new_tab = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .left, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .translated = .left }, .mods = .{ .ctrl = true, .shift = true } }, .{ .previous_tab = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .right, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .translated = .right }, .mods = .{ .ctrl = true, .shift = true } }, .{ .next_tab = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .page_up, .mods = .{ .ctrl = true } }, + .{ .key = .{ .translated = .page_up }, .mods = .{ .ctrl = true } }, .{ .previous_tab = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .page_down, .mods = .{ .ctrl = true } }, + .{ .key = .{ .translated = .page_down }, .mods = .{ .ctrl = true } }, .{ .next_tab = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .o, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .translated = .o }, .mods = .{ .ctrl = true, .shift = true } }, .{ .new_split = .right }, ); try result.keybind.set.put( alloc, - .{ .key = .e, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .translated = .e }, .mods = .{ .ctrl = true, .shift = true } }, .{ .new_split = .down }, ); try result.keybind.set.put( alloc, - .{ .key = .left_bracket, .mods = .{ .ctrl = true, .super = true } }, + .{ .key = .{ .translated = .left_bracket }, .mods = .{ .ctrl = true, .super = true } }, .{ .goto_split = .previous }, ); try result.keybind.set.put( alloc, - .{ .key = .right_bracket, .mods = .{ .ctrl = true, .super = true } }, + .{ .key = .{ .translated = .right_bracket }, .mods = .{ .ctrl = true, .super = true } }, .{ .goto_split = .next }, ); try result.keybind.set.put( alloc, - .{ .key = .up, .mods = .{ .ctrl = true, .alt = true } }, + .{ .key = .{ .translated = .up }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .top }, ); try result.keybind.set.put( alloc, - .{ .key = .down, .mods = .{ .ctrl = true, .alt = true } }, + .{ .key = .{ .translated = .down }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .bottom }, ); try result.keybind.set.put( alloc, - .{ .key = .left, .mods = .{ .ctrl = true, .alt = true } }, + .{ .key = .{ .translated = .left }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .left }, ); try result.keybind.set.put( alloc, - .{ .key = .right, .mods = .{ .ctrl = true, .alt = true } }, + .{ .key = .{ .translated = .right }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .right }, ); // Resizing splits try result.keybind.set.put( alloc, - .{ .key = .up, .mods = .{ .super = true, .ctrl = true, .shift = true } }, + .{ .key = .{ .translated = .up }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .up, 10 } }, ); try result.keybind.set.put( alloc, - .{ .key = .down, .mods = .{ .super = true, .ctrl = true, .shift = true } }, + .{ .key = .{ .translated = .down }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .down, 10 } }, ); try result.keybind.set.put( alloc, - .{ .key = .left, .mods = .{ .super = true, .ctrl = true, .shift = true } }, + .{ .key = .{ .translated = .left }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .left, 10 } }, ); try result.keybind.set.put( alloc, - .{ .key = .right, .mods = .{ .super = true, .ctrl = true, .shift = true } }, + .{ .key = .{ .translated = .right }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .right, 10 } }, ); try result.keybind.set.put( alloc, - .{ .key = .equal, .mods = .{ .super = true, .ctrl = true, .shift = true } }, + .{ .key = .{ .translated = .equal }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .equalize_splits = {} }, ); // Viewport scrolling try result.keybind.set.put( alloc, - .{ .key = .home, .mods = .{ .shift = true } }, + .{ .key = .{ .translated = .home }, .mods = .{ .shift = true } }, .{ .scroll_to_top = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .end, .mods = .{ .shift = true } }, + .{ .key = .{ .translated = .end }, .mods = .{ .shift = true } }, .{ .scroll_to_bottom = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .page_up, .mods = .{ .shift = true } }, + .{ .key = .{ .translated = .page_up }, .mods = .{ .shift = true } }, .{ .scroll_page_up = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .page_down, .mods = .{ .shift = true } }, + .{ .key = .{ .translated = .page_down }, .mods = .{ .shift = true } }, .{ .scroll_page_down = {} }, ); // Semantic prompts try result.keybind.set.put( alloc, - .{ .key = .page_up, .mods = .{ .shift = true, .ctrl = true } }, + .{ .key = .{ .translated = .page_up }, .mods = .{ .shift = true, .ctrl = true } }, .{ .jump_to_prompt = -1 }, ); try result.keybind.set.put( alloc, - .{ .key = .page_down, .mods = .{ .shift = true, .ctrl = true } }, + .{ .key = .{ .translated = .page_down }, .mods = .{ .shift = true, .ctrl = true } }, .{ .jump_to_prompt = 1 }, ); // Inspector, matching Chromium try result.keybind.set.put( alloc, - .{ .key = .i, .mods = .{ .shift = true, .ctrl = true } }, + .{ .key = .{ .translated = .i }, .mods = .{ .shift = true, .ctrl = true } }, .{ .inspector = .toggle }, ); // Terminal try result.keybind.set.put( alloc, - .{ .key = .a, .mods = .{ .shift = true, .ctrl = true } }, + .{ .key = .{ .translated = .a }, .mods = .{ .shift = true, .ctrl = true } }, .{ .select_all = {} }, ); // Selection clipboard paste try result.keybind.set.put( alloc, - .{ .key = .insert, .mods = .{ .shift = true } }, + .{ .key = .{ .translated = .insert }, .mods = .{ .shift = true } }, .{ .paste_from_selection = {} }, ); } @@ -1344,15 +1344,17 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { try result.keybind.set.put( alloc, .{ - .key = @enumFromInt(i), - .mods = mods, - // On macOS, we use the physical key for tab changing so // that this works across all keyboard layouts. This may // want to be true on other platforms as well but this // is definitely true on macOS so we just do it here for // now (#817) - .physical = builtin.target.isDarwin(), + .key = if (comptime builtin.target.isDarwin()) + .{ .physical = @enumFromInt(i) } + else + .{ .translated = @enumFromInt(i) }, + + .mods = mods, }, .{ .goto_tab = (i - start) + 1 }, ); @@ -1362,14 +1364,14 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { // Toggle fullscreen try result.keybind.set.put( alloc, - .{ .key = .enter, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .key = .{ .translated = .enter }, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .toggle_fullscreen = {} }, ); // Toggle zoom a split try result.keybind.set.put( alloc, - .{ .key = .enter, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, + .{ .key = .{ .translated = .enter }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, .{ .toggle_split_zoom = {} }, ); @@ -1377,167 +1379,167 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { if (comptime builtin.target.isDarwin()) { try result.keybind.set.put( alloc, - .{ .key = .q, .mods = .{ .super = true } }, + .{ .key = .{ .translated = .q }, .mods = .{ .super = true } }, .{ .quit = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .k, .mods = .{ .super = true } }, + .{ .key = .{ .translated = .k }, .mods = .{ .super = true } }, .{ .clear_screen = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .a, .mods = .{ .super = true } }, + .{ .key = .{ .translated = .a }, .mods = .{ .super = true } }, .{ .select_all = {} }, ); // Viewport scrolling try result.keybind.set.put( alloc, - .{ .key = .home, .mods = .{ .super = true } }, + .{ .key = .{ .translated = .home }, .mods = .{ .super = true } }, .{ .scroll_to_top = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .end, .mods = .{ .super = true } }, + .{ .key = .{ .translated = .end }, .mods = .{ .super = true } }, .{ .scroll_to_bottom = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .page_up, .mods = .{ .super = true } }, + .{ .key = .{ .translated = .page_up }, .mods = .{ .super = true } }, .{ .scroll_page_up = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .page_down, .mods = .{ .super = true } }, + .{ .key = .{ .translated = .page_down }, .mods = .{ .super = true } }, .{ .scroll_page_down = {} }, ); // Semantic prompts try result.keybind.set.put( alloc, - .{ .key = .up, .mods = .{ .super = true, .shift = true } }, + .{ .key = .{ .translated = .up }, .mods = .{ .super = true, .shift = true } }, .{ .jump_to_prompt = -1 }, ); try result.keybind.set.put( alloc, - .{ .key = .down, .mods = .{ .super = true, .shift = true } }, + .{ .key = .{ .translated = .down }, .mods = .{ .super = true, .shift = true } }, .{ .jump_to_prompt = 1 }, ); // Mac windowing try result.keybind.set.put( alloc, - .{ .key = .n, .mods = .{ .super = true } }, + .{ .key = .{ .translated = .n }, .mods = .{ .super = true } }, .{ .new_window = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .w, .mods = .{ .super = true } }, + .{ .key = .{ .translated = .w }, .mods = .{ .super = true } }, .{ .close_surface = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .w, .mods = .{ .super = true, .shift = true } }, + .{ .key = .{ .translated = .w }, .mods = .{ .super = true, .shift = true } }, .{ .close_window = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .w, .mods = .{ .super = true, .shift = true, .alt = true } }, + .{ .key = .{ .translated = .w }, .mods = .{ .super = true, .shift = true, .alt = true } }, .{ .close_all_windows = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .t, .mods = .{ .super = true } }, + .{ .key = .{ .translated = .t }, .mods = .{ .super = true } }, .{ .new_tab = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .left_bracket, .mods = .{ .super = true, .shift = true } }, + .{ .key = .{ .translated = .left_bracket }, .mods = .{ .super = true, .shift = true } }, .{ .previous_tab = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .right_bracket, .mods = .{ .super = true, .shift = true } }, + .{ .key = .{ .translated = .right_bracket }, .mods = .{ .super = true, .shift = true } }, .{ .next_tab = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .d, .mods = .{ .super = true } }, + .{ .key = .{ .translated = .d }, .mods = .{ .super = true } }, .{ .new_split = .right }, ); try result.keybind.set.put( alloc, - .{ .key = .d, .mods = .{ .super = true, .shift = true } }, + .{ .key = .{ .translated = .d }, .mods = .{ .super = true, .shift = true } }, .{ .new_split = .down }, ); try result.keybind.set.put( alloc, - .{ .key = .left_bracket, .mods = .{ .super = true } }, + .{ .key = .{ .translated = .left_bracket }, .mods = .{ .super = true } }, .{ .goto_split = .previous }, ); try result.keybind.set.put( alloc, - .{ .key = .right_bracket, .mods = .{ .super = true } }, + .{ .key = .{ .translated = .right_bracket }, .mods = .{ .super = true } }, .{ .goto_split = .next }, ); try result.keybind.set.put( alloc, - .{ .key = .up, .mods = .{ .super = true, .alt = true } }, + .{ .key = .{ .translated = .up }, .mods = .{ .super = true, .alt = true } }, .{ .goto_split = .top }, ); try result.keybind.set.put( alloc, - .{ .key = .down, .mods = .{ .super = true, .alt = true } }, + .{ .key = .{ .translated = .down }, .mods = .{ .super = true, .alt = true } }, .{ .goto_split = .bottom }, ); try result.keybind.set.put( alloc, - .{ .key = .left, .mods = .{ .super = true, .alt = true } }, + .{ .key = .{ .translated = .left }, .mods = .{ .super = true, .alt = true } }, .{ .goto_split = .left }, ); try result.keybind.set.put( alloc, - .{ .key = .right, .mods = .{ .super = true, .alt = true } }, + .{ .key = .{ .translated = .right }, .mods = .{ .super = true, .alt = true } }, .{ .goto_split = .right }, ); try result.keybind.set.put( alloc, - .{ .key = .up, .mods = .{ .super = true, .ctrl = true } }, + .{ .key = .{ .translated = .up }, .mods = .{ .super = true, .ctrl = true } }, .{ .resize_split = .{ .up, 10 } }, ); try result.keybind.set.put( alloc, - .{ .key = .down, .mods = .{ .super = true, .ctrl = true } }, + .{ .key = .{ .translated = .down }, .mods = .{ .super = true, .ctrl = true } }, .{ .resize_split = .{ .down, 10 } }, ); try result.keybind.set.put( alloc, - .{ .key = .left, .mods = .{ .super = true, .ctrl = true } }, + .{ .key = .{ .translated = .left }, .mods = .{ .super = true, .ctrl = true } }, .{ .resize_split = .{ .left, 10 } }, ); try result.keybind.set.put( alloc, - .{ .key = .right, .mods = .{ .super = true, .ctrl = true } }, + .{ .key = .{ .translated = .right }, .mods = .{ .super = true, .ctrl = true } }, .{ .resize_split = .{ .right, 10 } }, ); try result.keybind.set.put( alloc, - .{ .key = .equal, .mods = .{ .shift = true, .alt = true } }, + .{ .key = .{ .translated = .equal }, .mods = .{ .shift = true, .alt = true } }, .{ .equalize_splits = {} }, ); // Inspector, matching Chromium try result.keybind.set.put( alloc, - .{ .key = .i, .mods = .{ .alt = true, .super = true } }, + .{ .key = .{ .translated = .i }, .mods = .{ .alt = true, .super = true } }, .{ .inspector = .toggle }, ); // Alternate keybind, common to Mac programs try result.keybind.set.put( alloc, - .{ .key = .f, .mods = .{ .super = true, .ctrl = true } }, + .{ .key = .{ .translated = .f }, .mods = .{ .super = true, .ctrl = true } }, .{ .toggle_fullscreen = {} }, ); } diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 200fd6d58..5640843d9 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -79,11 +79,9 @@ pub fn parse(raw_input: []const u8) !Binding { } // If the key starts with "physical" then this is an physical key. - const physical = "physical:"; - const key_part = if (std.mem.startsWith(u8, part, physical)) key_part: { - result.physical = true; - break :key_part part[physical.len..]; - } else part; + const physical_prefix = "physical:"; + const physical = std.mem.startsWith(u8, part, physical_prefix); + const key_part = if (physical) part[physical_prefix.len..] else part; // Check if its a key const keysInfo = @typeInfo(key.Key).Enum; @@ -91,14 +89,33 @@ pub fn parse(raw_input: []const u8) !Binding { if (!std.mem.eql(u8, field.name, "invalid")) { if (std.mem.eql(u8, key_part, field.name)) { // Repeat not allowed - if (result.key != .invalid) return Error.InvalidFormat; + if (!result.isKeyUnset()) return Error.InvalidFormat; - result.key = @field(key.Key, field.name); + const keyval = @field(key.Key, field.name); + result.key = if (physical) + .{ .physical = keyval } + else + .{ .translated = keyval }; continue :loop; } } } + // If we're still unset and we have exactly one unicode + // character then we can use that as a key. + if (result.isKeyUnset()) unicode: { + // Invalid UTF8 drops to invalid format + const view = std.unicode.Utf8View.init(key_part) catch break :unicode; + var it = view.iterator(); + + // No codepoints or multiple codepoints drops to invalid format + const cp = it.nextCodepoint() orelse break :unicode; + if (it.nextCodepoint() != null) break :unicode; + + result.key = .{ .unicode = cp }; + continue :loop; + } + // We didn't recognize this value return Error.InvalidFormat; } @@ -499,28 +516,80 @@ pub const Key = enum(c_int) { /// This is an extern struct because this is also used in the C API. /// /// This must be kept in sync with include/ghostty.h ghostty_input_trigger_s -pub const Trigger = extern struct { +pub const Trigger = struct { /// The key that has to be pressed for a binding to take action. - key: key.Key = .invalid, + key: Trigger.Key = .{ .translated = .invalid }, /// The key modifiers that must be active for this to match. mods: key.Mods = .{}, - /// key is the "physical" version. This is the same as mapped for - /// standard US keyboard layouts. For non-US keyboard layouts, this - /// is used to bind to a physical key location rather than a translated - /// key. - physical: bool = false, + pub const Key = union(C.Tag) { + /// key is the translated version of a key. This is the key that + /// a logical keyboard layout at the OS level would translate the + /// physical key to. For example if you use a US hardware keyboard + /// but have a Dvorak layout, the key would be the Dvorak key. + translated: key.Key, + + /// key is the "physical" version. This is the same as mapped for + /// standard US keyboard layouts. For non-US keyboard layouts, this + /// is used to bind to a physical key location rather than a translated + /// key. + physical: key.Key, + + /// This is used for binding to keys that produce a certain unicode + /// codepoint. This is useful for binding to keys that don't have a + /// registered keycode with Ghostty. + unicode: u21, + }; + + /// The extern struct used for triggers in the C API. + pub const C = extern struct { + tag: Tag = .translated, + key: C.Key = .{ .translated = .invalid }, + mods: key.Mods = .{}, + + pub const Tag = enum(c_int) { + translated, + physical, + unicode, + }; + + pub const Key = extern union { + translated: key.Key, + physical: key.Key, + unicode: u32, + }; + }; + + /// Returns true if this trigger has no key set. + pub fn isKeyUnset(self: Trigger) bool { + return switch (self.key) { + .translated => |v| v == .invalid, + else => false, + }; + } /// Returns a hash code that can be used to uniquely identify this trigger. pub fn hash(self: Trigger) u64 { var hasher = std.hash.Wyhash.init(0); std.hash.autoHash(&hasher, self.key); std.hash.autoHash(&hasher, self.mods.binding()); - std.hash.autoHash(&hasher, self.physical); return hasher.final(); } + /// Convert the trigger to a C API compatible trigger. + pub fn cval(self: Trigger) C { + return .{ + .tag = self.key, + .key = switch (self.key) { + .translated => |v| .{ .translated = v }, + .physical => |v| .{ .physical = v }, + .unicode => |v| .{ .unicode = @intCast(v) }, + }, + .mods = self.mods, + }; + } + /// Format implementation for fmt package. pub fn format( self: Trigger, @@ -538,8 +607,11 @@ pub const Trigger = extern struct { if (self.mods.shift) try writer.writeAll("shift+"); // Key - if (self.physical) try writer.writeAll("physical:"); - try writer.print("{s}", .{@tagName(self.key)}); + switch (self.key) { + .translated => |k| try writer.print("{s}", .{@tagName(k)}), + .physical => |k| try writer.print("physical:{s}", .{@tagName(k)}), + .unicode => |c| try writer.print("{u}", .{c}), + } } }; @@ -720,7 +792,7 @@ test "parse: triggers" { // single character try testing.expectEqual( Binding{ - .trigger = .{ .key = .a }, + .trigger = .{ .key = .{ .translated = .a } }, .action = .{ .ignore = {} }, }, try parse("a=ignore"), @@ -730,14 +802,14 @@ test "parse: triggers" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .a, + .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, }, try parse("shift+a=ignore")); try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .ctrl = true }, - .key = .a, + .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, }, try parse("ctrl+a=ignore")); @@ -746,7 +818,7 @@ test "parse: triggers" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true, .ctrl = true }, - .key = .a, + .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, }, try parse("shift+ctrl+a=ignore")); @@ -755,7 +827,7 @@ test "parse: triggers" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .a, + .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, }, try parse("a+shift=ignore")); @@ -764,17 +836,25 @@ test "parse: triggers" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .a, - .physical = true, + .key = .{ .physical = .a }, }, .action = .{ .ignore = {} }, }, try parse("shift+physical:a=ignore")); + // unicode keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .unicode = 'ö' }, + }, + .action = .{ .ignore = {} }, + }, try parse("shift+ö=ignore")); + // unconsumed keys try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .a, + .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, .consumed = false, @@ -784,8 +864,7 @@ test "parse: triggers" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .a, - .physical = true, + .key = .{ .physical = .a }, }, .action = .{ .ignore = {} }, .consumed = false, @@ -807,14 +886,14 @@ test "parse: modifier aliases" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .super = true }, - .key = .a, + .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, }, try parse("cmd+a=ignore")); try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .super = true }, - .key = .a, + .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, }, try parse("command+a=ignore")); @@ -822,14 +901,14 @@ test "parse: modifier aliases" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .alt = true }, - .key = .a, + .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, }, try parse("opt+a=ignore")); try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .alt = true }, - .key = .a, + .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, }, try parse("option+a=ignore")); @@ -837,7 +916,7 @@ test "parse: modifier aliases" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .ctrl = true }, - .key = .a, + .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, }, try parse("control+a=ignore")); @@ -855,7 +934,10 @@ test "parse: action no parameters" { // no parameters try testing.expectEqual( - Binding{ .trigger = .{ .key = .a }, .action = .{ .ignore = {} } }, + Binding{ + .trigger = .{ .key = .{ .translated = .a } }, + .action = .{ .ignore = {} }, + }, try parse("a=ignore"), ); try testing.expectError(Error.InvalidFormat, parse("a=ignore:A")); @@ -949,24 +1031,24 @@ test "set: maintains reverse mapping" { var s: Set = .{}; defer s.deinit(alloc); - try s.put(alloc, .{ .key = .a }, .{ .new_window = {} }); + try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key == .a); + try testing.expect(trigger.key.translated == .a); } // should be most recent - try s.put(alloc, .{ .key = .b }, .{ .new_window = {} }); + try s.put(alloc, .{ .key = .{ .translated = .b } }, .{ .new_window = {} }); { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key == .b); + try testing.expect(trigger.key.translated == .b); } // removal should replace - s.remove(.{ .key = .b }); + s.remove(.{ .key = .{ .translated = .b } }); { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key == .a); + try testing.expect(trigger.key.translated == .a); } } @@ -977,14 +1059,14 @@ test "set: overriding a mapping updates reverse" { var s: Set = .{}; defer s.deinit(alloc); - try s.put(alloc, .{ .key = .a }, .{ .new_window = {} }); + try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key == .a); + try testing.expect(trigger.key.translated == .a); } // should be most recent - try s.put(alloc, .{ .key = .a }, .{ .new_tab = {} }); + try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_tab = {} }); { const trigger = s.getTrigger(.{ .new_window = {} }); try testing.expect(trigger == null); @@ -998,12 +1080,12 @@ test "set: consumed state" { var s: Set = .{}; defer s.deinit(alloc); - try s.put(alloc, .{ .key = .a }, .{ .new_window = {} }); - try testing.expect(s.getConsumed(.{ .key = .a })); + try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); + try testing.expect(s.getConsumed(.{ .key = .{ .translated = .a } })); - try s.putUnconsumed(alloc, .{ .key = .a }, .{ .new_window = {} }); - try testing.expect(!s.getConsumed(.{ .key = .a })); + try s.putUnconsumed(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); + try testing.expect(!s.getConsumed(.{ .key = .{ .translated = .a } })); - try s.put(alloc, .{ .key = .a }, .{ .new_window = {} }); - try testing.expect(s.getConsumed(.{ .key = .a })); + try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); + try testing.expect(s.getConsumed(.{ .key = .{ .translated = .a } })); }