diff --git a/src/Surface.zig b/src/Surface.zig index 7e9a307e5..cc727826f 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: input.KeyRemapSet, 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 = try config.@"key-remap".clone(alloc), // 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.isRemapped(event_orig.mods)) { + event.mods = self.config.key_remaps.apply(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.isRemapped(event_orig.mods)) { + event.mods = self.config.key_remaps.apply(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..3ff31ae0e 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -37,6 +37,7 @@ const RepeatableStringMap = @import("RepeatableStringMap.zig"); pub const Path = @import("path.zig").Path; pub const RepeatablePath = @import("path.zig").RepeatablePath; const ClipboardCodepointMap = @import("ClipboardCodepointMap.zig"); +const KeyRemapSet = @import("../input/key_mods.zig").RemapSet; // We do this instead of importing all of terminal/main.zig to // limit the dependency graph. This is important because some things @@ -1757,6 +1758,46 @@ 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. +/// +/// This will NOT change keyboard layout or key encodings outside of Ghostty. +/// For example, on macOS, `option+a` may still produce `å` even if `option` is +/// remapped to `ctrl`. Desktop environments usually handle key layout long +/// before Ghostty receives the key events. +/// +/// 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. +/// This does NOT impact keyboard layout or how keys are interpreted +/// prior to Ghostty receiving them. For example, `option+a` on macOS +/// may still produce `å` even if `option` is remapped to `ctrl`. +/// +/// * 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": KeyRemapSet = .empty, + /// 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. @@ -4436,6 +4477,9 @@ pub fn finalize(self: *Config) !void { } self.@"faint-opacity" = std.math.clamp(self.@"faint-opacity", 0.0, 1.0); + + // Finalize key remapping set for efficient lookups + self.@"key-remap".finalize(); } /// Callback for src/cli/args.zig to allow us to handle special cases diff --git a/src/input.zig b/src/input.zig index be84a60d6..bad3ac1f3 100644 --- a/src/input.zig +++ b/src/input.zig @@ -4,6 +4,7 @@ const builtin = @import("builtin"); const config = @import("input/config.zig"); const mouse = @import("input/mouse.zig"); const key = @import("input/key.zig"); +const key_mods = @import("input/key_mods.zig"); const keyboard = @import("input/keyboard.zig"); pub const command = @import("input/command.zig"); @@ -21,8 +22,9 @@ pub const Link = @import("input/Link.zig"); pub const Key = key.Key; pub const KeyboardLayout = keyboard.Layout; pub const KeyEvent = key.KeyEvent; +pub const KeyRemapSet = key_mods.RemapSet; pub const InspectorMode = Binding.Action.InspectorMode; -pub const Mods = key.Mods; +pub const Mods = key_mods.Mods; pub const MouseButton = mouse.Button; pub const MouseButtonState = mouse.ButtonState; pub const MousePressureStage = mouse.PressureStage; diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 08f5fdf7c..3197bb7d1 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -9,6 +9,7 @@ const build_config = @import("../build_config.zig"); const uucode = @import("uucode"); const EntryFormatter = @import("../config/formatter.zig").EntryFormatter; const key = @import("key.zig"); +const key_mods = @import("key_mods.zig"); const KeyEvent = key.KeyEvent; /// The trigger that needs to be performed to execute the action. @@ -1640,18 +1641,12 @@ pub const Trigger = struct { } // Alias modifiers - const alias_mods = .{ - .{ "cmd", "super" }, - .{ "command", "super" }, - .{ "opt", "alt" }, - .{ "option", "alt" }, - .{ "control", "ctrl" }, - }; - inline for (alias_mods) |pair| { + inline for (key_mods.alias) |pair| { if (std.mem.eql(u8, part, pair[0])) { // Repeat not allowed - if (@field(result.mods, pair[1])) return Error.InvalidFormat; - @field(result.mods, pair[1]) = true; + const field = @tagName(pair[1]); + if (@field(result.mods, field)) return Error.InvalidFormat; + @field(result.mods, field) = true; continue :loop; } } diff --git a/src/input/key.zig b/src/input/key.zig index 6445871eb..a929a0323 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -4,6 +4,8 @@ const Allocator = std.mem.Allocator; const cimgui = @import("dcimgui"); const OptionAsAlt = @import("config.zig").OptionAsAlt; +pub const Mods = @import("key_mods.zig").Mods; + /// A generic key input event. This is the information that is necessary /// regardless of apprt in order to generate the proper terminal /// control sequences for a given key press. @@ -76,161 +78,6 @@ pub const KeyEvent = struct { } }; -/// A bitmask for all key modifiers. -/// -/// IMPORTANT: Any changes here update include/ghostty.h -pub const Mods = packed struct(Mods.Backing) { - pub const Backing = u16; - - shift: bool = false, - ctrl: bool = false, - alt: bool = false, - super: bool = false, - caps_lock: bool = false, - num_lock: bool = false, - sides: side = .{}, - _padding: u6 = 0, - - /// Tracks the side that is active for any given modifier. Note - /// that this doesn't confirm a modifier is pressed; you must check - /// the bool for that in addition to this. - /// - /// Not all platforms support this, check apprt for more info. - pub const side = packed struct(u4) { - shift: Side = .left, - ctrl: Side = .left, - alt: Side = .left, - super: Side = .left, - }; - - pub const Side = enum(u1) { left, right }; - - /// Integer value of this struct. - pub fn int(self: Mods) Backing { - return @bitCast(self); - } - - /// Returns true if no modifiers are set. - pub fn empty(self: Mods) bool { - return self.int() == 0; - } - - /// Returns true if two mods are equal. - pub fn equal(self: Mods, other: Mods) bool { - return self.int() == other.int(); - } - - /// Return mods that are only relevant for bindings. - pub fn binding(self: Mods) Mods { - return .{ - .shift = self.shift, - .ctrl = self.ctrl, - .alt = self.alt, - .super = self.super, - }; - } - - /// Perform `self &~ other` to remove the other mods from self. - pub fn unset(self: Mods, other: Mods) Mods { - return @bitCast(self.int() & ~other.int()); - } - - /// Returns the mods without locks set. - pub fn withoutLocks(self: Mods) Mods { - var copy = self; - copy.caps_lock = false; - copy.num_lock = false; - return copy; - } - - /// Return the mods to use for key translation. This handles settings - /// like macos-option-as-alt. The translation mods should be used for - /// translation but never sent back in for the key callback. - pub fn translation(self: Mods, option_as_alt: OptionAsAlt) Mods { - var result = self; - - // macos-option-as-alt for darwin - if (comptime builtin.target.os.tag.isDarwin()) alt: { - // Alt has to be set only on the correct side - switch (option_as_alt) { - .false => break :alt, - .true => {}, - .left => if (self.sides.alt == .right) break :alt, - .right => if (self.sides.alt == .left) break :alt, - } - - // Unset alt - result.alt = false; - } - - return result; - } - - /// Checks to see if super is on (MacOS) or ctrl. - pub fn ctrlOrSuper(self: Mods) bool { - if (comptime builtin.target.os.tag.isDarwin()) { - return self.super; - } - return self.ctrl; - } - - // For our own understanding - test { - const testing = std.testing; - try testing.expectEqual(@as(Backing, @bitCast(Mods{})), @as(Backing, 0b0)); - try testing.expectEqual( - @as(Backing, @bitCast(Mods{ .shift = true })), - @as(Backing, 0b0000_0001), - ); - } - - test "translation macos-option-as-alt" { - if (comptime !builtin.target.os.tag.isDarwin()) return error.SkipZigTest; - - const testing = std.testing; - - // Unset - { - const mods: Mods = .{}; - const result = mods.translation(.true); - try testing.expectEqual(result, mods); - } - - // Set - { - const mods: Mods = .{ .alt = true }; - const result = mods.translation(.true); - try testing.expectEqual(Mods{}, result); - } - - // Set but disabled - { - const mods: Mods = .{ .alt = true }; - const result = mods.translation(.false); - try testing.expectEqual(result, mods); - } - - // Set wrong side - { - const mods: Mods = .{ .alt = true, .sides = .{ .alt = .right } }; - const result = mods.translation(.left); - try testing.expectEqual(result, mods); - } - { - const mods: Mods = .{ .alt = true, .sides = .{ .alt = .left } }; - const result = mods.translation(.right); - try testing.expectEqual(result, mods); - } - - // Set with other mods - { - const mods: Mods = .{ .alt = true, .shift = true }; - const result = mods.translation(.true); - try testing.expectEqual(Mods{ .shift = true }, result); - } - } -}; - /// The action associated with an input event. This is backed by a c_int /// so that we can use the enum as-is for our embedding API. /// diff --git a/src/input/key_mods.zig b/src/input/key_mods.zig new file mode 100644 index 000000000..35e1c1038 --- /dev/null +++ b/src/input/key_mods.zig @@ -0,0 +1,914 @@ +const std = @import("std"); +const assert = @import("../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const builtin = @import("builtin"); +const OptionAsAlt = @import("config.zig").OptionAsAlt; + +/// Aliases for modifier names. +pub const alias: []const struct { []const u8, Mod } = &.{ + .{ "cmd", .super }, + .{ "command", .super }, + .{ "opt", .alt }, + .{ "option", .alt }, + .{ "control", .ctrl }, +}; + +/// Single modifier +pub const Mod = enum { + shift, + ctrl, + alt, + super, + + pub const Side = enum(u1) { left, right }; +}; + +/// A bitmask for all key modifiers. +/// +/// IMPORTANT: Any changes here update include/ghostty.h +pub const Mods = packed struct(Mods.Backing) { + pub const Backing = u16; + + shift: bool = false, + ctrl: bool = false, + alt: bool = false, + super: bool = false, + caps_lock: bool = false, + num_lock: bool = false, + sides: Side = .{}, + _padding: u6 = 0, + + /// The standard modifier keys only. Does not include the lock keys, + /// only standard bindable keys. + pub const Keys = packed struct(u4) { + shift: bool = false, + ctrl: bool = false, + alt: bool = false, + super: bool = false, + + pub const Backing = @typeInfo(Keys).@"struct".backing_integer.?; + + pub inline fn int(self: Keys) Keys.Backing { + return @bitCast(self); + } + }; + + /// Tracks the side that is active for any given modifier. Note + /// that this doesn't confirm a modifier is pressed; you must check + /// the bool for that in addition to this. + /// + /// Not all platforms support this, check apprt for more info. + pub const Side = packed struct(u4) { + shift: Mod.Side = .left, + ctrl: Mod.Side = .left, + alt: Mod.Side = .left, + super: Mod.Side = .left, + + pub const Backing = @typeInfo(Side).@"struct".backing_integer.?; + }; + + /// The mask that has all the side bits set. + pub const side_mask: Mods = .{ + .sides = .{ + .shift = .right, + .ctrl = .right, + .alt = .right, + .super = .right, + }, + }; + + /// Integer value of this struct. + pub fn int(self: Mods) Backing { + return @bitCast(self); + } + + /// Returns true if no modifiers are set. + pub fn empty(self: Mods) bool { + return self.int() == 0; + } + + /// Returns true if two mods are equal. + pub fn equal(self: Mods, other: Mods) bool { + return self.int() == other.int(); + } + + /// Returns only the keys. + /// + /// In the future I want to remove `binding` for this. I didn't want + /// to do that all in one PR where I added this because its a bigger + /// change. + pub fn keys(self: Mods) Keys { + const backing: Keys.Backing = @truncate(self.int()); + return @bitCast(backing); + } + + /// Return mods that are only relevant for bindings. + pub fn binding(self: Mods) Mods { + return .{ + .shift = self.shift, + .ctrl = self.ctrl, + .alt = self.alt, + .super = self.super, + }; + } + + /// Perform `self &~ other` to remove the other mods from self. + pub fn unset(self: Mods, other: Mods) Mods { + return @bitCast(self.int() & ~other.int()); + } + + /// Returns the mods without locks set. + pub fn withoutLocks(self: Mods) Mods { + var copy = self; + copy.caps_lock = false; + copy.num_lock = false; + return copy; + } + + /// Return the mods to use for key translation. This handles settings + /// like macos-option-as-alt. The translation mods should be used for + /// translation but never sent back in for the key callback. + pub fn translation(self: Mods, option_as_alt: OptionAsAlt) Mods { + var result = self; + + // macos-option-as-alt for darwin + if (comptime builtin.target.os.tag.isDarwin()) alt: { + // Alt has to be set only on the correct side + switch (option_as_alt) { + .false => break :alt, + .true => {}, + .left => if (self.sides.alt == .right) break :alt, + .right => if (self.sides.alt == .left) break :alt, + } + + // Unset alt + result.alt = false; + } + + return result; + } + + /// Checks to see if super is on (MacOS) or ctrl. + pub fn ctrlOrSuper(self: Mods) bool { + if (comptime builtin.target.os.tag.isDarwin()) { + return self.super; + } + return self.ctrl; + } + + // For our own understanding + test { + const testing = std.testing; + try testing.expectEqual(@as(Backing, @bitCast(Mods{})), @as(Backing, 0b0)); + try testing.expectEqual( + @as(Backing, @bitCast(Mods{ .shift = true })), + @as(Backing, 0b0000_0001), + ); + } + + test "translation macos-option-as-alt" { + if (comptime !builtin.target.os.tag.isDarwin()) return error.SkipZigTest; + + const testing = std.testing; + + // Unset + { + const mods: Mods = .{}; + const result = mods.translation(.true); + try testing.expectEqual(result, mods); + } + + // Set + { + const mods: Mods = .{ .alt = true }; + const result = mods.translation(.true); + try testing.expectEqual(Mods{}, result); + } + + // Set but disabled + { + const mods: Mods = .{ .alt = true }; + const result = mods.translation(.false); + try testing.expectEqual(result, mods); + } + + // Set wrong side + { + const mods: Mods = .{ .alt = true, .sides = .{ .alt = .right } }; + const result = mods.translation(.left); + try testing.expectEqual(result, mods); + } + { + const mods: Mods = .{ .alt = true, .sides = .{ .alt = .left } }; + const result = mods.translation(.right); + try testing.expectEqual(result, mods); + } + + // Set with other mods + { + const mods: Mods = .{ .alt = true, .shift = true }; + const result = mods.translation(.true); + try testing.expectEqual(Mods{ .shift = true }, result); + } + } +}; + +/// Modifier remapping. See `key-remap` in Config.zig for detailed docs. +pub const RemapSet = struct { + /// Available mappings. + map: std.AutoArrayHashMapUnmanaged(Mods, Mods), + + /// The mask of remapped modifiers that can be used to quickly + /// check if some input mods need remapping. + mask: Mask, + + pub const empty: RemapSet = .{ + .map = .{}, + .mask = .{}, + }; + + pub const ParseError = Allocator.Error || error{ + MissingAssignment, + InvalidMod, + }; + + /// Parse from CLI input. Required by Config. + pub fn parseCLI(self: *RemapSet, alloc: Allocator, input: ?[]const u8) !void { + const value = input orelse ""; + + // Empty value resets the set + if (value.len == 0) { + self.map.clearRetainingCapacity(); + self.mask = .{}; + return; + } + + self.parse(alloc, value) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.MissingAssignment, error.InvalidMod => return error.InvalidValue, + }; + } + + /// Parse a modifier remap and add it to the set. + pub fn parse( + self: *RemapSet, + alloc: Allocator, + input: []const u8, + ) ParseError!void { + // Find the assignment point ('=') + const eql_idx = std.mem.indexOfScalar( + u8, + input, + '=', + ) orelse return error.MissingAssignment; + + // The to side defaults to "left" if no explicit side is given. + // This is because this is the default unsided value provided by + // the apprts in the current Mods layout. + const to: Mods = to: { + const raw = try parseMod(input[eql_idx + 1 ..]); + break :to initMods(raw[0], raw[1] orelse .left); + }; + + // The from side, if sided, is easy and we put it directly into + // the map. + const from_raw = try parseMod(input[0..eql_idx]); + if (from_raw[1]) |from_side| { + const from: Mods = initMods(from_raw[0], from_side); + try self.map.put( + alloc, + from, + to, + ); + errdefer comptime unreachable; + self.mask.update(from); + return; + } + + // We need to do some combinatorial explosion here for unsided + // from in order to assign all possible sides. + const from_left = initMods(from_raw[0], .left); + const from_right = initMods(from_raw[0], .right); + try self.map.put( + alloc, + from_left, + to, + ); + errdefer _ = self.map.swapRemove(from_left); + try self.map.put( + alloc, + from_right, + to, + ); + errdefer _ = self.map.swapRemove(from_right); + + errdefer comptime unreachable; + self.mask.update(from_left); + self.mask.update(from_right); + } + + pub fn deinit(self: *RemapSet, alloc: Allocator) void { + self.map.deinit(alloc); + } + + /// Must be called prior to any remappings so that the mapping + /// is sorted properly. Otherwise, you will get invalid results. + pub fn finalize(self: *RemapSet) void { + const Context = struct { + keys: []const Mods, + + pub fn lessThan( + ctx: @This(), + a_index: usize, + b_index: usize, + ) bool { + _ = b_index; + + // Mods with any right sides prioritize + const side_mask = comptime Mods.side_mask.int(); + const a = ctx.keys[a_index]; + return a.int() & side_mask != 0; + } + }; + + self.map.sort(Context{ .keys = self.map.keys() }); + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const RemapSet, alloc: Allocator) Allocator.Error!RemapSet { + return .{ + .map = try self.map.clone(alloc), + .mask = self.mask, + }; + } + + /// Compare if two RemapSets are equal. Required by Config. + pub fn equal(self: RemapSet, other: RemapSet) bool { + if (self.map.count() != other.map.count()) return false; + + var it = self.map.iterator(); + while (it.next()) |entry| { + const other_value = other.map.get(entry.key_ptr.*) orelse return false; + if (!entry.value_ptr.equal(other_value)) return false; + } + + return true; + } + + /// Used by Formatter. Required by Config. + pub fn formatEntry(self: RemapSet, formatter: anytype) !void { + if (self.map.count() == 0) { + try formatter.formatEntry(void, {}); + return; + } + + var it = self.map.iterator(); + while (it.next()) |entry| { + const from = entry.key_ptr.*; + const to = entry.value_ptr.*; + + var buf: [64]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + const writer = fbs.writer(); + formatMod(writer, from) catch return error.OutOfMemory; + writer.writeByte('=') catch return error.OutOfMemory; + formatMod(writer, to) catch return error.OutOfMemory; + try formatter.formatEntry([]const u8, fbs.getWritten()); + } + } + + fn formatMod(writer: anytype, mods: Mods) !void { + // Check which mod is set and format it with optional side prefix + inline for (.{ "shift", "ctrl", "alt", "super" }) |name| { + if (@field(mods, name)) { + const side = @field(mods.sides, name); + if (side == .right) { + try writer.writeAll("right_"); + } else { + // Only write left_ if we need to distinguish + // For now, always write left_ if it's a sided mapping + try writer.writeAll("left_"); + } + try writer.writeAll(name); + return; + } + } + } + + /// Parses a single mode in a single remapping string. E.g. + /// `ctrl` or `left_shift`. + fn parseMod(input: []const u8) error{InvalidMod}!struct { Mod, ?Mod.Side } { + const side_str, const mod_str = if (std.mem.indexOfScalar( + u8, + input, + '_', + )) |idx| .{ + input[0..idx], + input[idx + 1 ..], + } else .{ + "", + input, + }; + + const mod: Mod = if (std.meta.stringToEnum( + Mod, + mod_str, + )) |mod| mod else mod: { + inline for (alias) |pair| { + if (std.mem.eql(u8, mod_str, pair[0])) { + break :mod pair[1]; + } + } + + return error.InvalidMod; + }; + + return .{ + mod, + if (side_str.len > 0) std.meta.stringToEnum( + Mod.Side, + side_str, + ) orelse return error.InvalidMod else null, + }; + } + + fn initMods(mod: Mod, side: Mod.Side) Mods { + switch (mod) { + inline else => |tag| { + var mods: Mods = .{}; + @field(mods, @tagName(tag)) = true; + @field(mods.sides, @tagName(tag)) = side; + return mods; + }, + } + } + + /// Returns true if the given mods need remapping. + pub fn isRemapped(self: *const RemapSet, mods: Mods) bool { + return self.mask.match(mods); + } + + /// Apply a remap to the given mods. + pub fn apply(self: *const RemapSet, mods: Mods) Mods { + if (!self.isRemapped(mods)) return mods; + + const mods_binding: Mods.Keys.Backing = @truncate(mods.int()); + const mods_sides: Mods.Side.Backing = @bitCast(mods.sides); + + var it = self.map.iterator(); + while (it.next()) |entry| { + const from = entry.key_ptr.*; + const from_binding: Mods.Keys.Backing = @truncate(from.int()); + if (mods_binding & from_binding != from_binding) continue; + const from_sides: Mods.Side.Backing = @bitCast(from.sides); + if ((mods_sides ^ from_sides) & from_binding != 0) continue; + + var mods_int = mods.int(); + mods_int &= ~from.int(); + mods_int |= entry.value_ptr.int(); + return @bitCast(mods_int); + } + + unreachable; + } + + /// Tracks which modifier keys and sides have remappings registered. + /// Used as a fast pre-check before doing expensive map lookups. + /// + /// The mask uses separate tracking for left and right sides because + /// remappings can be side-specific (e.g., only remap left_ctrl). + /// + /// Note: `left_sides` uses inverted logic where 1 means "left is remapped" + /// even though `Mod.Side.left = 0`. This allows efficient bitwise matching + /// since we can AND directly with the side bits. + pub const Mask = packed struct(u12) { + /// Which modifier keys (shift/ctrl/alt/super) have any remapping. + keys: Mods.Keys = .{}, + /// Which modifiers have left-side remappings (inverted: 1 = left remapped). + left_sides: Mods.Side = .{}, + /// Which modifiers have right-side remappings (1 = right remapped). + right_sides: Mods.Side = .{}, + + /// Adds a modifier to the mask, marking it as having a remapping. + pub fn update(self: *Mask, mods: Mods) void { + const keys_int: Mods.Keys.Backing = mods.keys().int(); + + // OR the new keys into our existing keys mask. + // Example: keys=0b0000, new ctrl → keys=0b0010 + self.keys = @bitCast(self.keys.int() | keys_int); + + // Both Keys and Side are u4 with matching bit positions. + // This lets us use keys_int to select which side bits to update. + const sides: Mods.Side.Backing = @bitCast(mods.sides); + const left_int: Mods.Side.Backing = @bitCast(self.left_sides); + const right_int: Mods.Side.Backing = @bitCast(self.right_sides); + + // Update left_sides: set bit if this key is active AND side is left. + // Since Side.left=0, we invert sides (~sides) so left becomes 1. + // keys_int masks to only affect the modifier being added. + // Example: left_ctrl → keys_int=0b0010, ~sides=0b1111 (left=0 inverted) + // result: left_int | (0b0010 & 0b1111) = left_int | 0b0010 + self.left_sides = @bitCast(left_int | (keys_int & ~sides)); + + // Update right_sides: set bit if this key is active AND side is right. + // Since Side.right=1, we use sides directly. + // Example: right_ctrl → keys_int=0b0010, sides=0b0010 (right=1) + // result: right_int | (0b0010 & 0b0010) = right_int | 0b0010 + self.right_sides = @bitCast(right_int | (keys_int & sides)); + } + + /// Returns true if the given mods match any remapping in this mask. + /// This is a fast check to avoid expensive map lookups when no + /// remapping could possibly apply. + /// + /// Checks both that the modifier key is remapped AND that the + /// specific side (left/right) being pressed has a remapping. + pub fn match(self: *const Mask, mods: Mods) bool { + // Find which pressed keys have remappings registered. + // Example: pressed={ctrl,alt}, mask={ctrl} → active=0b0010 (just ctrl) + const active = mods.keys().int() & self.keys.int(); + if (active == 0) return false; + + // Check if the pressed side matches a remapped side. + // For left (sides bit = 0): check against left_int (where 1 = left remapped) + // ~sides inverts so left becomes 1, then AND with left_int + // For right (sides bit = 1): check against right_int directly + // + // Example: pressing left_ctrl (sides.ctrl=0, left_int.ctrl=1) + // ~sides = 0b1111, left_int = 0b0010 + // (~sides & left_int) = 0b0010 ✓ matches + // + // Example: pressing right_ctrl but only left_ctrl is remapped + // sides = 0b0010, left_int = 0b0010, right_int = 0b0000 + // (~0b0010 & 0b0010) | (0b0010 & 0b0000) = 0b0000 ✗ no match + const sides: Mods.Side.Backing = @bitCast(mods.sides); + const left_int: Mods.Side.Backing = @bitCast(self.left_sides); + const right_int: Mods.Side.Backing = @bitCast(self.right_sides); + const side_match = (~sides & left_int) | (sides & right_int); + + // Final check: is any active (pressed + remapped) key also side-matched? + return (active & side_match) != 0; + } + }; +}; + +test "RemapSet: unsided remap creates both left and right mappings" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + try set.parse(alloc, "ctrl=super"); + set.finalize(); + try testing.expectEqual( + Mods{ + .super = true, + .sides = .{ .super = .left }, + }, + set.apply(.{ + .ctrl = true, + .sides = .{ .ctrl = .left }, + }), + ); + try testing.expectEqual( + Mods{ + .super = true, + .sides = .{ .super = .left }, + }, + set.apply(.{ + .ctrl = true, + .sides = .{ .ctrl = .right }, + }), + ); +} + +test "RemapSet: sided from only maps that side" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "left_alt=ctrl"); + set.finalize(); + + const left_alt: Mods = .{ .alt = true, .sides = .{ .alt = .left } }; + const left_ctrl: Mods = .{ .ctrl = true, .sides = .{ .ctrl = .left } }; + try testing.expectEqual(left_ctrl, set.apply(left_alt)); + + const right_alt: Mods = .{ .alt = true, .sides = .{ .alt = .right } }; + try testing.expectEqual(right_alt, set.apply(right_alt)); +} + +test "RemapSet: sided to" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "ctrl=right_super"); + set.finalize(); + + const left_ctrl: Mods = .{ .ctrl = true, .sides = .{ .ctrl = .left } }; + const right_super: Mods = .{ .super = true, .sides = .{ .super = .right } }; + try testing.expectEqual(right_super, set.apply(left_ctrl)); +} + +test "RemapSet: both sides specified" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "left_shift=right_ctrl"); + set.finalize(); + + const left_shift: Mods = .{ .shift = true, .sides = .{ .shift = .left } }; + const right_ctrl: Mods = .{ .ctrl = true, .sides = .{ .ctrl = .right } }; + try testing.expectEqual(right_ctrl, set.apply(left_shift)); +} + +test "RemapSet: multiple parses accumulate" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "left_ctrl=super"); + try set.parse(alloc, "left_alt=ctrl"); + set.finalize(); + + const left_ctrl: Mods = .{ .ctrl = true, .sides = .{ .ctrl = .left } }; + const left_super: Mods = .{ .super = true, .sides = .{ .super = .left } }; + try testing.expectEqual(left_super, set.apply(left_ctrl)); + + const left_alt: Mods = .{ .alt = true, .sides = .{ .alt = .left } }; + const left_ctrl_result: Mods = .{ .ctrl = true, .sides = .{ .ctrl = .left } }; + try testing.expectEqual(left_ctrl_result, set.apply(left_alt)); +} + +test "RemapSet: error on missing assignment" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try testing.expectError(error.MissingAssignment, set.parse(alloc, "ctrl")); + try testing.expectError(error.MissingAssignment, set.parse(alloc, "")); +} + +test "RemapSet: error on invalid modifier" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try testing.expectError(error.InvalidMod, set.parse(alloc, "invalid=ctrl")); + try testing.expectError(error.InvalidMod, set.parse(alloc, "ctrl=invalid")); + try testing.expectError(error.InvalidMod, set.parse(alloc, "middle_ctrl=super")); +} + +test "RemapSet: isRemapped checks mask" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "ctrl=super"); + set.finalize(); + + try testing.expect(set.isRemapped(.{ .ctrl = true })); + try testing.expect(!set.isRemapped(.{ .alt = true })); + try testing.expect(!set.isRemapped(.{ .shift = true })); +} + +test "RemapSet: clone creates independent copy" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "ctrl=super"); + set.finalize(); + + var cloned = try set.clone(alloc); + defer cloned.deinit(alloc); + + try testing.expect(set.equal(cloned)); + try testing.expect(cloned.isRemapped(.{ .ctrl = true })); +} + +test "RemapSet: equal compares correctly" { + const testing = std.testing; + const alloc = testing.allocator; + + var set1: RemapSet = .empty; + defer set1.deinit(alloc); + + var set2: RemapSet = .empty; + defer set2.deinit(alloc); + + try testing.expect(set1.equal(set2)); + + try set1.parse(alloc, "ctrl=super"); + try testing.expect(!set1.equal(set2)); + + try set2.parse(alloc, "ctrl=super"); + try testing.expect(set1.equal(set2)); + + try set1.parse(alloc, "alt=shift"); + try testing.expect(!set1.equal(set2)); +} + +test "RemapSet: parseCLI basic" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parseCLI(alloc, "ctrl=super"); + try testing.expectEqual(@as(usize, 2), set.map.count()); +} + +test "RemapSet: parseCLI empty clears" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parseCLI(alloc, "ctrl=super"); + try testing.expectEqual(@as(usize, 2), set.map.count()); + + try set.parseCLI(alloc, ""); + try testing.expectEqual(@as(usize, 0), set.map.count()); +} + +test "RemapSet: parseCLI invalid" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try testing.expectError(error.InvalidValue, set.parseCLI(alloc, "foo=bar")); + try testing.expectError(error.InvalidValue, set.parseCLI(alloc, "ctrl")); +} + +test "RemapSet: parse aliased modifiers" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "cmd=ctrl"); + set.finalize(); + + const left_super: Mods = .{ .super = true, .sides = .{ .super = .left } }; + const left_ctrl: Mods = .{ .ctrl = true, .sides = .{ .ctrl = .left } }; + try testing.expectEqual(left_ctrl, set.apply(left_super)); +} + +test "RemapSet: parse aliased modifiers command" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "command=alt"); + set.finalize(); + + const left_super: Mods = .{ .super = true, .sides = .{ .super = .left } }; + const left_alt: Mods = .{ .alt = true, .sides = .{ .alt = .left } }; + try testing.expectEqual(left_alt, set.apply(left_super)); +} + +test "RemapSet: parse aliased modifiers opt and option" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "opt=super"); + set.finalize(); + + const left_alt: Mods = .{ .alt = true, .sides = .{ .alt = .left } }; + const left_super: Mods = .{ .super = true, .sides = .{ .super = .left } }; + try testing.expectEqual(left_super, set.apply(left_alt)); + + set.deinit(alloc); + set = .empty; + + try set.parse(alloc, "option=shift"); + set.finalize(); + + const left_shift: Mods = .{ .shift = true, .sides = .{ .shift = .left } }; + try testing.expectEqual(left_shift, set.apply(left_alt)); +} + +test "RemapSet: parse aliased modifiers control" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "control=super"); + set.finalize(); + + const left_ctrl: Mods = .{ .ctrl = true, .sides = .{ .ctrl = .left } }; + const left_super: Mods = .{ .super = true, .sides = .{ .super = .left } }; + try testing.expectEqual(left_super, set.apply(left_ctrl)); +} + +test "RemapSet: parse aliased modifiers on target side" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "alt=cmd"); + set.finalize(); + + const left_alt: Mods = .{ .alt = true, .sides = .{ .alt = .left } }; + const left_super: Mods = .{ .super = true, .sides = .{ .super = .left } }; + try testing.expectEqual(left_super, set.apply(left_alt)); +} + +test "RemapSet: formatEntry empty" { + const testing = std.testing; + const formatterpkg = @import("../config/formatter.zig"); + + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + const set: RemapSet = .empty; + try set.formatEntry(formatterpkg.entryFormatter("key-remap", &buf.writer)); + try testing.expectEqualSlices(u8, "key-remap = \n", buf.written()); +} + +test "RemapSet: formatEntry single sided" { + const testing = std.testing; + const formatterpkg = @import("../config/formatter.zig"); + + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var set: RemapSet = .empty; + defer set.deinit(testing.allocator); + + try set.parse(testing.allocator, "left_ctrl=super"); + set.finalize(); + + try set.formatEntry(formatterpkg.entryFormatter("key-remap", &buf.writer)); + try testing.expectEqualSlices(u8, "key-remap = left_ctrl=left_super\n", buf.written()); +} + +test "RemapSet: formatEntry unsided creates two entries" { + const testing = std.testing; + const formatterpkg = @import("../config/formatter.zig"); + + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var set: RemapSet = .empty; + defer set.deinit(testing.allocator); + + try set.parse(testing.allocator, "ctrl=super"); + set.finalize(); + + try set.formatEntry(formatterpkg.entryFormatter("key-remap", &buf.writer)); + // Unsided creates both left and right mappings + const written = buf.written(); + try testing.expect(std.mem.indexOf(u8, written, "left_ctrl=left_super") != null); + try testing.expect(std.mem.indexOf(u8, written, "right_ctrl=left_super") != null); +} + +test "RemapSet: formatEntry right sided" { + const testing = std.testing; + const formatterpkg = @import("../config/formatter.zig"); + + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var set: RemapSet = .empty; + defer set.deinit(testing.allocator); + + try set.parse(testing.allocator, "left_alt=right_ctrl"); + set.finalize(); + + try set.formatEntry(formatterpkg.entryFormatter("key-remap", &buf.writer)); + try testing.expectEqualSlices(u8, "key-remap = left_alt=right_ctrl\n", buf.written()); +}