From 111b0996d23e997d4b10411b10be04a6067bd9da Mon Sep 17 00:00:00 2001 From: Jagjeevan Kashid Date: Fri, 26 Dec 2025 19:33:50 +0530 Subject: [PATCH] feat: key-remap configuration to remap modifiers at the app-level Signed-off-by: Jagjeevan Kashid --- src/Surface.zig | 22 ++- src/config/Config.zig | 138 +++++++++++++++++ src/input.zig | 1 + src/input/KeyRemap.zig | 327 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 484 insertions(+), 4 deletions(-) create mode 100644 src/input/KeyRemap.zig diff --git a/src/Surface.zig b/src/Surface.zig index 7e9a307e5..c0c933d1c 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -333,6 +333,7 @@ const DerivedConfig = struct { notify_on_command_finish: configpkg.Config.NotifyOnCommandFinish, notify_on_command_finish_action: configpkg.Config.NotifyOnCommandFinishAction, notify_on_command_finish_after: Duration, + key_remaps: []const input.KeyRemap, const Link = struct { regex: oni.Regex, @@ -408,6 +409,7 @@ const DerivedConfig = struct { .notify_on_command_finish = config.@"notify-on-command-finish", .notify_on_command_finish_action = config.@"notify-on-command-finish-action", .notify_on_command_finish_after = config.@"notify-on-command-finish-after", + .key_remaps = config.@"key-remap".value.items, // Assignments happen sequentially so we have to do this last // so that the memory is captured from allocs above. @@ -2576,8 +2578,14 @@ pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void { /// then Ghosty will act as though the binding does not exist. pub fn keyEventIsBinding( self: *Surface, - event: input.KeyEvent, + event_orig: input.KeyEvent, ) bool { + // Apply key remappings for consistency with keyCallback + var event = event_orig; + if (self.config.key_remaps.len > 0) { + event.mods = input.KeyRemap.applyRemaps(self.config.key_remaps, event_orig.mods); + } + switch (event.action) { .release => return false, .press, .repeat => {}, @@ -2605,9 +2613,16 @@ pub fn keyEventIsBinding( /// sending to the terminal, etc. pub fn keyCallback( self: *Surface, - event: input.KeyEvent, + event_orig: input.KeyEvent, ) !InputEffect { - // log.warn("text keyCallback event={}", .{event}); + // log.warn("text keyCallback event={}", .{event_orig}); + + // Apply key remappings to transform modifiers before any processing. + // This allows users to remap modifier keys at the app level. + var event = event_orig; + if (self.config.key_remaps.len > 0) { + event.mods = input.KeyRemap.applyRemaps(self.config.key_remaps, event_orig.mods); + } // Crash metadata in case we crash in here crash.sentry.thread_state = self.crashThreadState(); @@ -2645,7 +2660,6 @@ pub fn keyCallback( event, if (insp_ev) |*ev| ev else null, )) |v| return v; - // If we allow KAM and KAM is enabled then we do nothing. if (self.config.vt_kam_allowed) { self.renderer_state.mutex.lock(); diff --git a/src/config/Config.zig b/src/config/Config.zig index d9066b06d..2f0be6ab3 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1757,6 +1757,38 @@ class: ?[:0]const u8 = null, /// Key tables are available since Ghostty 1.3.0. keybind: Keybinds = .{}, +/// Remap modifier keys within Ghostty. This allows you to swap or reassign +/// modifier keys at the application level without affecting system-wide +/// settings. +/// +/// The format is `from=to` where both `from` and `to` are modifier key names. +/// You can use generic names like `ctrl`, `alt`, `shift`, `super` (macOS: +/// `cmd`/`command`) or sided names like `left_ctrl`, `right_alt`, etc. +/// +/// Example: +/// +/// key-remap = ctrl=super +/// key-remap = left_control=right_alt +/// +/// Important notes: +/// +/// * This is a one-way remap. If you remap `ctrl=super`, then the physical +/// Ctrl key acts as Super, but the Super key remains Super. +/// +/// * Remaps are not transitive. If you remap `ctrl=super` and `alt=ctrl`, +/// pressing Alt will produce Ctrl, NOT Super. +/// +/// * This affects both keybind matching and terminal input encoding. +/// +/// * Generic modifiers (e.g. `ctrl`) match both left and right physical keys. +/// Use sided names (e.g. `left_ctrl`) to remap only one side. +/// +/// This configuration can be repeated to specify multiple remaps. +/// +/// Currently only supported on macOS. Linux/GTK support is planned for +/// a future release. +@"key-remap": RepeatableKeyRemap = .{}, + /// Horizontal window padding. This applies padding between the terminal cells /// and the left and right window borders. The value is in points, meaning that /// it will be scaled appropriately for screen DPI. @@ -8021,6 +8053,112 @@ pub const RepeatableLink = struct { } }; +/// RepeatableKeyRemap is used for the key-remap configuration which +/// allows remapping modifier keys within Ghostty. +pub const RepeatableKeyRemap = struct { + const Self = @This(); + + value: std.ArrayListUnmanaged(inputpkg.KeyRemap) = .empty, + + pub fn parseCLI(self: *Self, alloc: Allocator, input_: ?[]const u8) !void { + // Empty/unset input clears the list + const input = input_ orelse ""; + if (input.len == 0) { + self.value.clearRetainingCapacity(); + return; + } + + // Parse the key remap + const remap = inputpkg.KeyRemap.parse(input) catch |err| switch (err) { + error.InvalidFormat => return error.InvalidValue, + error.InvalidModifier => return error.InvalidValue, + }; + + // Reserve space and append + try self.value.ensureUnusedCapacity(alloc, 1); + self.value.appendAssumeCapacity(remap); + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self { + return .{ + .value = try self.value.clone(alloc), + }; + } + + /// Compare if two values are equal. Required by Config. + pub fn equal(self: Self, other: Self) bool { + if (self.value.items.len != other.value.items.len) return false; + for (self.value.items, other.value.items) |a, b| { + if (!a.equal(b)) return false; + } + return true; + } + + /// Used by Formatter + pub fn formatEntry(self: Self, formatter: formatterpkg.EntryFormatter) !void { + if (self.value.items.len == 0) { + try formatter.formatEntry(void, {}); + return; + } + + for (self.value.items) |item| { + // Format as "from=to" + var buf: [64]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + const writer = fbs.writer(); + writer.print("{s}={s}", .{ @tagName(item.from), @tagName(item.to) }) catch + return error.OutOfMemory; + try formatter.formatEntry([]const u8, fbs.getWritten()); + } + } + + test "RepeatableKeyRemap parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: RepeatableKeyRemap = .{}; + + try list.parseCLI(alloc, "ctrl=super"); + try testing.expectEqual(@as(usize, 1), list.value.items.len); + try testing.expectEqual(inputpkg.KeyRemap.ModKey.ctrl, list.value.items[0].from); + try testing.expectEqual(inputpkg.KeyRemap.ModKey.super, list.value.items[0].to); + + try list.parseCLI(alloc, "alt=shift"); + try testing.expectEqual(@as(usize, 2), list.value.items.len); + } + + test "RepeatableKeyRemap parseCLI clear" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: RepeatableKeyRemap = .{}; + + try list.parseCLI(alloc, "ctrl=super"); + try testing.expectEqual(@as(usize, 1), list.value.items.len); + + // Empty clears the list + try list.parseCLI(alloc, ""); + try testing.expectEqual(@as(usize, 0), list.value.items.len); + } + + test "RepeatableKeyRemap parseCLI invalid" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: RepeatableKeyRemap = .{}; + + try testing.expectError(error.InvalidValue, list.parseCLI(alloc, "foo=bar")); + try testing.expectError(error.InvalidValue, list.parseCLI(alloc, "ctrl")); + } +}; + /// Options for copy on select behavior. pub const CopyOnSelect = enum { /// Disables copy on select entirely. diff --git a/src/input.zig b/src/input.zig index be84a60d6..fe981ccc9 100644 --- a/src/input.zig +++ b/src/input.zig @@ -31,6 +31,7 @@ pub const ScrollMods = mouse.ScrollMods; pub const SplitFocusDirection = Binding.Action.SplitFocusDirection; pub const SplitResizeDirection = Binding.Action.SplitResizeDirection; pub const Trigger = Binding.Trigger; +pub const KeyRemap = @import("input/KeyRemap.zig"); // Keymap is only available on macOS right now. We could implement it // in theory for XKB too on Linux but we don't need it right now. diff --git a/src/input/KeyRemap.zig b/src/input/KeyRemap.zig new file mode 100644 index 000000000..de13ab70d --- /dev/null +++ b/src/input/KeyRemap.zig @@ -0,0 +1,327 @@ +//! Key remapping support for modifier keys within Ghostty. +//! +//! This module allows users to remap modifier keys (ctrl, alt, shift, super) +//! at the application level without affecting system-wide settings. +//! +//! Syntax: `key-remap = from=to` +//! +//! Examples: +//! key-remap = ctrl=super -- Ctrl acts as Super +//! key-remap = left_alt=ctrl -- Left Alt acts as Ctrl +//! +//! Remapping is one-way and non-transitive: +//! - `ctrl=super` means Ctrl→Super, but Super stays Super +//! - `ctrl=super` + `alt=ctrl` means Alt→Ctrl (NOT Super) + +const KeyRemap = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const key = @import("key.zig"); +const Mods = key.Mods; + +from: ModKey, +to: ModKey, + +pub const ModKey = enum { + ctrl, + alt, + shift, + super, + left_ctrl, + left_alt, + left_shift, + left_super, + right_ctrl, + right_alt, + right_shift, + right_super, + + pub fn isGeneric(self: ModKey) bool { + return switch (self) { + .ctrl, .alt, .shift, .super => true, + else => false, + }; + } + + pub fn parse(input: []const u8) ?ModKey { + const map = std.StaticStringMap(ModKey).initComptime(.{ + .{ "ctrl", .ctrl }, + .{ "control", .ctrl }, + .{ "alt", .alt }, + .{ "opt", .alt }, + .{ "option", .alt }, + .{ "shift", .shift }, + .{ "super", .super }, + .{ "cmd", .super }, + .{ "command", .super }, + .{ "left_ctrl", .left_ctrl }, + .{ "left_control", .left_ctrl }, + .{ "leftctrl", .left_ctrl }, + .{ "leftcontrol", .left_ctrl }, + .{ "left_alt", .left_alt }, + .{ "left_opt", .left_alt }, + .{ "left_option", .left_alt }, + .{ "leftalt", .left_alt }, + .{ "leftopt", .left_alt }, + .{ "leftoption", .left_alt }, + .{ "left_shift", .left_shift }, + .{ "leftshift", .left_shift }, + .{ "left_super", .left_super }, + .{ "left_cmd", .left_super }, + .{ "left_command", .left_super }, + .{ "leftsuper", .left_super }, + .{ "leftcmd", .left_super }, + .{ "leftcommand", .left_super }, + .{ "right_ctrl", .right_ctrl }, + .{ "right_control", .right_ctrl }, + .{ "rightctrl", .right_ctrl }, + .{ "rightcontrol", .right_ctrl }, + .{ "right_alt", .right_alt }, + .{ "right_opt", .right_alt }, + .{ "right_option", .right_alt }, + .{ "rightalt", .right_alt }, + .{ "rightopt", .right_alt }, + .{ "rightoption", .right_alt }, + .{ "right_shift", .right_shift }, + .{ "rightshift", .right_shift }, + .{ "right_super", .right_super }, + .{ "right_cmd", .right_super }, + .{ "right_command", .right_super }, + .{ "rightsuper", .right_super }, + .{ "rightcmd", .right_super }, + .{ "rightcommand", .right_super }, + }); + + var buf: [32]u8 = undefined; + if (input.len > buf.len) return null; + const lower = std.ascii.lowerString(&buf, input); + return map.get(lower); + } +}; + +pub fn parse(input: []const u8) !KeyRemap { + const eql_idx = std.mem.indexOf(u8, input, "=") orelse + return error.InvalidFormat; + + const from_str = std.mem.trim(u8, input[0..eql_idx], " \t"); + const to_str = std.mem.trim(u8, input[eql_idx + 1 ..], " \t"); + + if (from_str.len == 0 or to_str.len == 0) { + return error.InvalidFormat; + } + + const from = ModKey.parse(from_str) orelse return error.InvalidModifier; + const to = ModKey.parse(to_str) orelse return error.InvalidModifier; + + return .{ .from = from, .to = to }; +} + +pub fn apply(self: KeyRemap, mods: Mods) ?Mods { + var result = mods; + var matched = false; + + switch (self.from) { + .ctrl => if (mods.ctrl) { + result.ctrl = false; + matched = true; + }, + .left_ctrl => if (mods.ctrl and mods.sides.ctrl == .left) { + result.ctrl = false; + matched = true; + }, + .right_ctrl => if (mods.ctrl and mods.sides.ctrl == .right) { + result.ctrl = false; + matched = true; + }, + .alt => if (mods.alt) { + result.alt = false; + matched = true; + }, + .left_alt => if (mods.alt and mods.sides.alt == .left) { + result.alt = false; + matched = true; + }, + .right_alt => if (mods.alt and mods.sides.alt == .right) { + result.alt = false; + matched = true; + }, + .shift => if (mods.shift) { + result.shift = false; + matched = true; + }, + .left_shift => if (mods.shift and mods.sides.shift == .left) { + result.shift = false; + matched = true; + }, + .right_shift => if (mods.shift and mods.sides.shift == .right) { + result.shift = false; + matched = true; + }, + .super => if (mods.super) { + result.super = false; + matched = true; + }, + .left_super => if (mods.super and mods.sides.super == .left) { + result.super = false; + matched = true; + }, + .right_super => if (mods.super and mods.sides.super == .right) { + result.super = false; + matched = true; + }, + } + + if (!matched) return null; + + switch (self.to) { + .ctrl, .left_ctrl => { + result.ctrl = true; + result.sides.ctrl = .left; + }, + .right_ctrl => { + result.ctrl = true; + result.sides.ctrl = .right; + }, + .alt, .left_alt => { + result.alt = true; + result.sides.alt = .left; + }, + .right_alt => { + result.alt = true; + result.sides.alt = .right; + }, + .shift, .left_shift => { + result.shift = true; + result.sides.shift = .left; + }, + .right_shift => { + result.shift = true; + result.sides.shift = .right; + }, + .super, .left_super => { + result.super = true; + result.sides.super = .left; + }, + .right_super => { + result.super = true; + result.sides.super = .right; + }, + } + + return result; +} + +/// Apply remaps non-transitively: each remap checks the original mods. +pub fn applyRemaps(remaps: []const KeyRemap, mods: Mods) Mods { + var result = mods; + for (remaps) |remap| { + if (remap.apply(mods)) |_| { + switch (remap.from) { + .ctrl, .left_ctrl, .right_ctrl => result.ctrl = false, + .alt, .left_alt, .right_alt => result.alt = false, + .shift, .left_shift, .right_shift => result.shift = false, + .super, .left_super, .right_super => result.super = false, + } + switch (remap.to) { + .ctrl, .left_ctrl => { + result.ctrl = true; + result.sides.ctrl = .left; + }, + .right_ctrl => { + result.ctrl = true; + result.sides.ctrl = .right; + }, + .alt, .left_alt => { + result.alt = true; + result.sides.alt = .left; + }, + .right_alt => { + result.alt = true; + result.sides.alt = .right; + }, + .shift, .left_shift => { + result.shift = true; + result.sides.shift = .left; + }, + .right_shift => { + result.shift = true; + result.sides.shift = .right; + }, + .super, .left_super => { + result.super = true; + result.sides.super = .left; + }, + .right_super => { + result.super = true; + result.sides.super = .right; + }, + } + } + } + return result; +} + +pub fn clone(self: KeyRemap, alloc: Allocator) Allocator.Error!KeyRemap { + _ = alloc; + return self; +} + +pub fn equal(self: KeyRemap, other: KeyRemap) bool { + return self.from == other.from and self.to == other.to; +} + +test "ModKey.parse" { + const testing = std.testing; + + try testing.expectEqual(ModKey.ctrl, ModKey.parse("ctrl").?); + try testing.expectEqual(ModKey.ctrl, ModKey.parse("control").?); + try testing.expectEqual(ModKey.ctrl, ModKey.parse("CTRL").?); + try testing.expectEqual(ModKey.alt, ModKey.parse("alt").?); + try testing.expectEqual(ModKey.super, ModKey.parse("cmd").?); + try testing.expectEqual(ModKey.left_ctrl, ModKey.parse("left_ctrl").?); + try testing.expectEqual(ModKey.right_alt, ModKey.parse("right_alt").?); + try testing.expect(ModKey.parse("foo") == null); +} + +test "parse" { + const testing = std.testing; + + const remap = try parse("ctrl=super"); + try testing.expectEqual(ModKey.ctrl, remap.from); + try testing.expectEqual(ModKey.super, remap.to); + + const spaced = try parse(" ctrl = super "); + try testing.expectEqual(ModKey.ctrl, spaced.from); + + try testing.expectError(error.InvalidFormat, parse("ctrl")); + try testing.expectError(error.InvalidModifier, parse("foo=bar")); +} + +test "apply" { + const testing = std.testing; + + const remap = try parse("ctrl=super"); + const mods = Mods{ .ctrl = true }; + const result = remap.apply(mods).?; + + try testing.expect(!result.ctrl); + try testing.expect(result.super); + try testing.expect(remap.apply(Mods{ .alt = true }) == null); +} + +test "applyRemaps non-transitive" { + const testing = std.testing; + + const remaps = [_]KeyRemap{ + try parse("ctrl=super"), + try parse("alt=ctrl"), + }; + + const mods = Mods{ .alt = true }; + const result = applyRemaps(&remaps, mods); + + try testing.expect(!result.alt); + try testing.expect(result.ctrl); + try testing.expect(!result.super); +}