feat: key-remap configuration to remap modifiers at the app-level

Signed-off-by: Jagjeevan Kashid <jagjeevandev97@gmail.com>
This commit is contained in:
Jagjeevan Kashid
2025-12-26 19:33:50 +05:30
committed by Mitchell Hashimoto
parent 891f442041
commit 111b0996d2
4 changed files with 484 additions and 4 deletions

View File

@@ -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();

View File

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

View File

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

327
src/input/KeyRemap.zig Normal file
View File

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