//! A binding maps some input trigger to an action. When the trigger //! occurs, the action is performed. const Binding = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; const key = @import("key.zig"); /// The trigger that needs to be performed to execute the action. trigger: Trigger, /// The action to take if this binding matches action: Action, /// True if this binding should consume the input when the /// action is triggered. consumed: bool = true, pub const Error = error{ InvalidFormat, InvalidAction, }; /// Parse the format "ctrl+a=csi:A" into a binding. The format is /// specifically "trigger=action". Trigger is a "+"-delimited series of /// modifiers and keys. Action is the action name and optionally a /// parameter after a colon, i.e. "csi:A" or "ignore". pub fn parse(raw_input: []const u8) !Binding { // NOTE(mitchellh): This is not the most efficient way to do any // of this, I welcome any improvements here! // If our entire input is prefixed with "unconsumed:" then we are // not consuming this keybind when the action is triggered. const unconsumed_prefix = "unconsumed:"; const unconsumed = std.mem.startsWith(u8, raw_input, unconsumed_prefix); const start_idx = if (unconsumed) unconsumed_prefix.len else 0; const input = raw_input[start_idx..]; // Find the first = which splits are mapping into the trigger // and action, respectively. const eqlIdx = std.mem.indexOf(u8, input, "=") orelse return Error.InvalidFormat; // Determine our trigger conditions by parsing the part before // the "=", i.e. "ctrl+shift+a" or "a" const trigger = trigger: { var result: Trigger = .{}; var iter = std.mem.tokenize(u8, input[0..eqlIdx], "+"); loop: while (iter.next()) |part| { // All parts must be non-empty if (part.len == 0) return Error.InvalidFormat; // Check if its a modifier const modsInfo = @typeInfo(key.Mods).Struct; inline for (modsInfo.fields) |field| { if (field.type == bool) { if (std.mem.eql(u8, part, field.name)) { // Repeat not allowed if (@field(result.mods, field.name)) return Error.InvalidFormat; @field(result.mods, field.name) = true; continue :loop; } } } // 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; // Check if its a key const keysInfo = @typeInfo(key.Key).Enum; inline for (keysInfo.fields) |field| { 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; result.key = @field(key.Key, field.name); continue :loop; } } } // We didn't recognize this value return Error.InvalidFormat; } break :trigger result; }; // Find a matching action const action = try Action.parse(input[eqlIdx + 1 ..]); return Binding{ .trigger = trigger, .action = action, .consumed = !unconsumed, }; } /// The set of actions that a keybinding can take. pub const Action = union(enum) { /// Ignore this key combination, don't send it to the child process, /// just black hole it. ignore: void, /// This action is used to flag that the binding should be removed /// from the set. This should never exist in an active set and /// `set.put` has an assertion to verify this. unbind: void, /// Send a CSI sequence. The value should be the CSI sequence /// without the CSI header ("ESC ]" or "\x1b]"). csi: []const u8, /// Send an ESC sequence. esc: []const u8, /// Send data to the pty depending on whether cursor key mode is /// enabled ("application") or disabled ("normal"). cursor_key: CursorKey, /// Copy and paste. copy_to_clipboard: void, paste_from_clipboard: void, /// Increase/decrease the font size by a certain amount increase_font_size: u16, decrease_font_size: u16, /// Reset the font size to the original configured size reset_font_size: void, /// Clear the screen. This also clears all scrollback. clear_screen: void, /// Scroll the screen varying amounts. scroll_to_top: void, scroll_to_bottom: void, scroll_page_up: void, scroll_page_down: void, scroll_page_fractional: f32, /// Jump the viewport forward or back by prompt. Positive /// number is the number of prompts to jump forward, negative /// is backwards. jump_to_prompt: i16, /// Write the entire scrollback into a temporary file and write the /// path to the file to the tty. write_scrollback_file: void, /// Open a new window new_window: void, /// Open a new tab new_tab: void, /// Go to the previous tab previous_tab: void, /// Go to the next tab next_tab: void, /// Go to the tab with the specific number, 1-indexed. goto_tab: usize, /// Create a new split in the given direction. The new split will appear /// in the direction given. new_split: SplitDirection, /// Focus on a split in a given direction. goto_split: SplitFocusDirection, /// zoom/unzoom the current split. toggle_split_zoom: void, /// Resize the current split by moving the split divider in the given /// direction resize_split: SplitResizeParameter, /// Show, hide, or toggle the terminal inspector for the currently /// focused terminal. inspector: InspectorMode, /// Reload the configuration. The exact meaning depends on the app runtime /// in use but this usually involves re-reading the configuration file /// and applying any changes. Note that not all changes can be applied at /// runtime. reload_config: void, /// Close the current "surface", whether that is a window, tab, split, /// etc. This only closes ONE surface. close_surface: void, /// Close the window, regardless of how many tabs or splits there may be. close_window: void, /// Toggle fullscreen mode of window. toggle_fullscreen: void, /// Quit ghostty quit: void, pub const CursorKey = struct { normal: []const u8, application: []const u8, }; // This is made extern (c_int) to make interop easier with our embedded // runtime. The small size cost doesn't make a difference in our union. pub const SplitDirection = enum(c_int) { right, down, // Note: we don't support top or left yet }; // Extern because it is used in the embedded runtime ABI. pub const SplitFocusDirection = enum(c_int) { previous, next, top, left, bottom, right, }; // Extern because it is used in the embedded runtime ABI. pub const SplitResizeDirection = enum(c_int) { up, down, left, right, }; pub const SplitResizeParameter = struct { SplitResizeDirection, u16, }; // Extern because it is used in the embedded runtime ABI. pub const InspectorMode = enum(c_int) { toggle, show, hide, }; fn parseEnum(comptime T: type, value: []const u8) !T { return std.meta.stringToEnum(T, value) orelse return Error.InvalidFormat; } fn parseInt(comptime T: type, value: []const u8) !T { return std.fmt.parseInt(T, value, 10) catch return Error.InvalidFormat; } fn parseFloat(comptime T: type, value: []const u8) !T { return std.fmt.parseFloat(T, value) catch return Error.InvalidFormat; } fn parseParameter( comptime field: std.builtin.Type.UnionField, param: []const u8, ) !field.type { return switch (@typeInfo(field.type)) { .Enum => try parseEnum(field.type, param), .Int => try parseInt(field.type, param), .Float => try parseFloat(field.type, param), .Struct => |info| blk: { // Only tuples are supported to avoid ambiguity with field // ordering comptime assert(info.is_tuple); var it = std.mem.split(u8, param, ","); var value: field.type = undefined; inline for (info.fields) |field_| { const next = it.next() orelse return Error.InvalidFormat; @field(value, field_.name) = switch (@typeInfo(field_.type)) { .Enum => try parseEnum(field_.type, next), .Int => try parseInt(field_.type, next), .Float => try parseFloat(field_.type, next), else => unreachable, }; } break :blk value; }, else => unreachable, }; } /// Parse an action in the format of "key=value" where key is the /// action name and value is the action parameter. The parameter /// is optional depending on the action. pub fn parse(input: []const u8) !Action { // Split our action by colon. A colon may not exist for some // actions so it is optional. The part preceding the colon is the // action name. const colonIdx = std.mem.indexOf(u8, input, ":"); const action = input[0..(colonIdx orelse input.len)]; // An action name is always required if (action.len == 0) return Error.InvalidFormat; const actionInfo = @typeInfo(Action).Union; inline for (actionInfo.fields) |field| { if (std.mem.eql(u8, action, field.name)) { // If the field type is void we expect no value switch (field.type) { void => { if (colonIdx != null) return Error.InvalidFormat; return @unionInit(Action, field.name, {}); }, []const u8 => { const idx = colonIdx orelse return Error.InvalidFormat; const param = input[idx + 1 ..]; return @unionInit(Action, field.name, param); }, // Cursor keys can't be set currently Action.CursorKey => return Error.InvalidAction, else => { const idx = colonIdx orelse return Error.InvalidFormat; const param = input[idx + 1 ..]; return @unionInit( Action, field.name, try parseParameter(field, param), ); }, } } } return Error.InvalidAction; } /// Implements the formatter for the fmt package. This encodes the /// action back into the format used by parse. pub fn format( self: Action, comptime layout: []const u8, opts: std.fmt.FormatOptions, writer: anytype, ) !void { _ = layout; _ = opts; switch (self) { inline else => |value| { // All actions start with the tag. try writer.print("{s}", .{@tagName(self)}); // Write the value depending on the type try writer.writeAll(":"); try formatValue(writer, value); }, } } fn formatValue( writer: anytype, value: anytype, ) !void { const Value = @TypeOf(value); const value_info = @typeInfo(Value); switch (Value) { void => {}, []const u8 => try writer.print("{s}", .{value}), else => switch (value_info) { .Enum => try writer.print("{s}", .{@tagName(value)}), .Float => try writer.print("{d}", .{value}), .Int => try writer.print("{d}", .{value}), .Struct => |info| if (!info.is_tuple) { try writer.print("{} (not configurable)", .{value}); } else { inline for (info.fields, 0..) |field, i| { try formatValue(writer, @field(value, field.name)); if (i + 1 < info.fields.len) try writer.writeAll(","); } }, else => @compileError("unhandled type: " ++ @typeName(Value)), }, } } /// Returns a hash code that can be used to uniquely identify this /// action. pub fn hash(self: Action) u64 { var hasher = std.hash.Wyhash.init(0); // Always has the active tag. const Tag = @typeInfo(Action).Union.tag_type.?; std.hash.autoHash(&hasher, @as(Tag, self)); // Hash the value of the field. switch (self) { inline else => |field| { const FieldType = @TypeOf(field); switch (FieldType) { // Do nothing for void void => {}, // Floats are hashed by their bits. This is totally not // portable and there are edge cases such as NaNs and // signed zeros but these are not cases we expect for // our bindings. f32 => std.hash.autoHash( &hasher, @as(u32, @bitCast(field)), ), f64 => std.hash.autoHash( &hasher, @as(u64, @bitCast(field)), ), // Everything else automatically handle. else => std.hash.autoHashStrat( &hasher, field, .DeepRecursive, ), } }, } return hasher.final(); } }; // A key for the C API to execute an action. This must be kept in sync // with include/ghostty.h. pub const Key = enum(c_int) { copy_to_clipboard, paste_from_clipboard, new_tab, new_window, }; /// Trigger is the associated key state that can trigger an action. /// 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 { /// The key that has to be pressed for a binding to take action. key: key.Key = .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, /// 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(); } /// Format implementation for fmt package. pub fn format( self: Trigger, comptime layout: []const u8, opts: std.fmt.FormatOptions, writer: anytype, ) !void { _ = layout; _ = opts; // Modifiers first if (self.mods.super) try writer.writeAll("super+"); if (self.mods.ctrl) try writer.writeAll("ctrl+"); if (self.mods.alt) try writer.writeAll("alt+"); if (self.mods.shift) try writer.writeAll("shift+"); // Key if (self.physical) try writer.writeAll("physical:"); try writer.print("{s}", .{@tagName(self.key)}); } }; /// A structure that contains a set of bindings and focuses on fast lookup. /// The use case is that this will be called on EVERY key input to look /// for an associated action so it must be fast. pub const Set = struct { const HashMap = std.HashMapUnmanaged( Trigger, Action, Context(Trigger), std.hash_map.default_max_load_percentage, ); const ReverseMap = std.HashMapUnmanaged( Action, Trigger, Context(Action), std.hash_map.default_max_load_percentage, ); const UnconsumedMap = std.HashMapUnmanaged( Trigger, void, Context(Trigger), std.hash_map.default_max_load_percentage, ); /// The set of bindings. bindings: HashMap = .{}, /// The reverse mapping of action to binding. Note that multiple /// bindings can map to the same action and this map will only have /// the most recently added binding for an action. reverse: ReverseMap = .{}, /// The map of triggers that explicitly do not want to be consumed /// when matched. A trigger is "consumed" when it is not further /// processed and potentially sent to the terminal. An "unconsumed" /// trigger will perform both its action and also continue normal /// encoding processing (if any). /// /// This is stored as a separate map since unconsumed triggers are /// rare and we don't want to bloat our map with a byte per entry /// (for boolean state) when most entries will be consumed. /// /// Assert: trigger in this map is also in bindings. unconsumed: UnconsumedMap = .{}, pub fn deinit(self: *Set, alloc: Allocator) void { self.bindings.deinit(alloc); self.reverse.deinit(alloc); self.unconsumed.deinit(alloc); self.* = undefined; } /// Add a binding to the set. If the binding already exists then /// this will overwrite it. pub fn put( self: *Set, alloc: Allocator, t: Trigger, action: Action, ) Allocator.Error!void { try self.put_(alloc, t, action, true); } /// Same as put but marks the trigger as unconsumed. An unconsumed /// trigger will evaluate the action and continue to encode for the /// terminal. /// /// This is a separate function because this case is rare. pub fn putUnconsumed( self: *Set, alloc: Allocator, t: Trigger, action: Action, ) Allocator.Error!void { try self.put_(alloc, t, action, false); } fn put_( self: *Set, alloc: Allocator, t: Trigger, action: Action, consumed: bool, ) Allocator.Error!void { // unbind should never go into the set, it should be handled prior assert(action != .unbind); const gop = try self.bindings.getOrPut(alloc, t); if (!consumed) try self.unconsumed.put(alloc, t, {}); // If we have an existing binding for this trigger, we have to // update the reverse mapping to remove the old action. if (gop.found_existing) { const t_hash = t.hash(); var it = self.reverse.iterator(); while (it.next()) |reverse_entry| it: { if (t_hash == reverse_entry.value_ptr.hash()) { self.reverse.removeByPtr(reverse_entry.key_ptr); break :it; } } // We also have to remove the unconsumed state if it exists. if (consumed) _ = self.unconsumed.remove(t); } gop.value_ptr.* = action; errdefer _ = self.bindings.remove(t); try self.reverse.put(alloc, action, t); errdefer _ = self.reverse.remove(action); } /// Get a binding for a given trigger. pub fn get(self: Set, t: Trigger) ?Action { return self.bindings.get(t); } /// Get a trigger for the given action. An action can have multiple /// triggers so this will return the first one found. pub fn getTrigger(self: Set, a: Action) ?Trigger { return self.reverse.get(a); } /// Returns true if the given trigger should be consumed. Requires /// that trigger is in the set to be valid so this should only follow /// a non-null get. pub fn getConsumed(self: Set, t: Trigger) bool { return self.unconsumed.get(t) == null; } /// Remove a binding for a given trigger. pub fn remove(self: *Set, t: Trigger) void { const action = self.bindings.get(t) orelse return; _ = self.bindings.remove(t); _ = self.unconsumed.remove(t); // Look for a matching action in bindings and use that. // Note: we'd LIKE to replace this with the most recent binding but // our hash map obviously has no concept of ordering so we have to // choose whatever. Maybe a switch to an array hash map here. const action_hash = action.hash(); var it = self.bindings.iterator(); while (it.next()) |entry| { if (entry.value_ptr.hash() == action_hash) { self.reverse.putAssumeCapacity(action, entry.key_ptr.*); break; } } else { // No over trigger points to this action so we remove // the reverse mapping completely. _ = self.reverse.remove(action); } } /// The hash map context for the set. This defines how the hash map /// gets the hash key and checks for equality. fn Context(comptime KeyType: type) type { return struct { pub fn hash(ctx: @This(), k: KeyType) u64 { _ = ctx; return k.hash(); } pub fn eql(ctx: @This(), a: KeyType, b: KeyType) bool { return ctx.hash(a) == ctx.hash(b); } }; } }; test "parse: triggers" { const testing = std.testing; // single character try testing.expectEqual( Binding{ .trigger = .{ .key = .a }, .action = .{ .ignore = {} }, }, try parse("a=ignore"), ); // single modifier try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, .key = .a, }, .action = .{ .ignore = {} }, }, try parse("shift+a=ignore")); try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .ctrl = true }, .key = .a, }, .action = .{ .ignore = {} }, }, try parse("ctrl+a=ignore")); // multiple modifier try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true, .ctrl = true }, .key = .a, }, .action = .{ .ignore = {} }, }, try parse("shift+ctrl+a=ignore")); // key can come before modifier try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, .key = .a, }, .action = .{ .ignore = {} }, }, try parse("a+shift=ignore")); // physical keys try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, .key = .a, .physical = true, }, .action = .{ .ignore = {} }, }, try parse("shift+physical:a=ignore")); // unconsumed keys try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, .key = .a, }, .action = .{ .ignore = {} }, .consumed = false, }, try parse("unconsumed:shift+a=ignore")); // unconsumed physical keys try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, .key = .a, .physical = true, }, .action = .{ .ignore = {} }, .consumed = false, }, try parse("unconsumed:physical:a+shift=ignore")); // invalid key try testing.expectError(Error.InvalidFormat, parse("foo=ignore")); // repeated control try testing.expectError(Error.InvalidFormat, parse("shift+shift+a=ignore")); // multiple character try testing.expectError(Error.InvalidFormat, parse("a+b=ignore")); } test "parse: action invalid" { const testing = std.testing; // invalid action try testing.expectError(Error.InvalidAction, parse("a=nopenopenope")); } test "parse: action no parameters" { const testing = std.testing; // no parameters try testing.expectEqual( Binding{ .trigger = .{ .key = .a }, .action = .{ .ignore = {} } }, try parse("a=ignore"), ); try testing.expectError(Error.InvalidFormat, parse("a=ignore:A")); } test "parse: action with string" { const testing = std.testing; // parameter { const binding = try parse("a=csi:A"); try testing.expect(binding.action == .csi); try testing.expectEqualStrings("A", binding.action.csi); } // parameter { const binding = try parse("a=esc:A"); try testing.expect(binding.action == .esc); try testing.expectEqualStrings("A", binding.action.esc); } } test "parse: action with enum" { const testing = std.testing; // parameter { const binding = try parse("a=new_split:right"); try testing.expect(binding.action == .new_split); try testing.expectEqual(Action.SplitDirection.right, binding.action.new_split); } } test "parse: action with int" { const testing = std.testing; // parameter { const binding = try parse("a=jump_to_prompt:-1"); try testing.expect(binding.action == .jump_to_prompt); try testing.expectEqual(@as(i16, -1), binding.action.jump_to_prompt); } { const binding = try parse("a=jump_to_prompt:10"); try testing.expect(binding.action == .jump_to_prompt); try testing.expectEqual(@as(i16, 10), binding.action.jump_to_prompt); } } test "parse: action with float" { const testing = std.testing; // parameter { const binding = try parse("a=scroll_page_fractional:-0.5"); try testing.expect(binding.action == .scroll_page_fractional); try testing.expectEqual(@as(f32, -0.5), binding.action.scroll_page_fractional); } { const binding = try parse("a=scroll_page_fractional:+0.5"); try testing.expect(binding.action == .scroll_page_fractional); try testing.expectEqual(@as(f32, 0.5), binding.action.scroll_page_fractional); } } test "set: maintains reverse mapping" { const testing = std.testing; const alloc = testing.allocator; var s: Set = .{}; defer s.deinit(alloc); try s.put(alloc, .{ .key = .a }, .{ .new_window = {} }); { const trigger = s.getTrigger(.{ .new_window = {} }).?; try testing.expect(trigger.key == .a); } // should be most recent try s.put(alloc, .{ .key = .b }, .{ .new_window = {} }); { const trigger = s.getTrigger(.{ .new_window = {} }).?; try testing.expect(trigger.key == .b); } // removal should replace s.remove(.{ .key = .b }); { const trigger = s.getTrigger(.{ .new_window = {} }).?; try testing.expect(trigger.key == .a); } } test "set: overriding a mapping updates reverse" { const testing = std.testing; const alloc = testing.allocator; var s: Set = .{}; defer s.deinit(alloc); try s.put(alloc, .{ .key = .a }, .{ .new_window = {} }); { const trigger = s.getTrigger(.{ .new_window = {} }).?; try testing.expect(trigger.key == .a); } // should be most recent try s.put(alloc, .{ .key = .a }, .{ .new_tab = {} }); { const trigger = s.getTrigger(.{ .new_window = {} }); try testing.expect(trigger == null); } } test "set: consumed state" { const testing = std.testing; const alloc = testing.allocator; var s: Set = .{}; defer s.deinit(alloc); try s.put(alloc, .{ .key = .a }, .{ .new_window = {} }); try testing.expect(s.getConsumed(.{ .key = .a })); try s.putUnconsumed(alloc, .{ .key = .a }, .{ .new_window = {} }); try testing.expect(!s.getConsumed(.{ .key = .a })); try s.put(alloc, .{ .key = .a }, .{ .new_window = {} }); try testing.expect(s.getConsumed(.{ .key = .a })); }