diff --git a/src/Surface.zig b/src/Surface.zig index 4b5b3f98c..99fa4cff6 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -72,6 +72,9 @@ renderer_thr: std.Thread, /// Mouse state. mouse: Mouse, +/// Keyboard input state. +keyboard: Keyboard, + /// A currently pressed key. This is used so that we can send a keyboard /// release event when the surface is unfocused. Note that when the surface /// is refocused, a key press event may not be sent again -- this depends @@ -192,6 +195,30 @@ const Mouse = struct { link_point: ?terminal.point.Coordinate = null, }; +/// Keyboard state for the surface. +pub const Keyboard = struct { + /// The currently active keybindings for the surface. This is used to + /// implement sequences: as leader keys are pressed, the active bindings + /// set is updated to reflect the current leader key sequence. If this is + /// null then the root bindings are used. + bindings: ?*const input.Binding.Set = null, + + /// The last handled binding. This is used to prevent encoding release + /// events for handled bindings. We only need to keep track of one because + /// at least at the time of writing this, its impossible for two keys of + /// a combination to be handled by different bindings before the release + /// of the prior (namely since you can't bind modifier-only). + last_trigger: ?u64 = null, + + /// The queued keys when we're in the middle of a sequenced binding. + /// These are flushed when the sequence is completed and unconsumed or + /// invalid. + /// + /// This is naturally bounded due to the configuration maximum + /// length of a sequence. + queued: std.ArrayListUnmanaged(termio.Message.WriteReq) = .{}, +}; + /// The configuration that a surface has, this is copied from the main /// Config struct usually to prevent sharing a single value. const DerivedConfig = struct { @@ -428,6 +455,7 @@ pub fn init( }, .renderer_thr = undefined, .mouse = .{}, + .keyboard = .{}, .io = undefined, .io_thread = io_thread, .io_thr = undefined, @@ -605,6 +633,10 @@ pub fn deinit(self: *Surface) void { self.alloc.destroy(v); } + // Clean up our keyboard state + for (self.keyboard.queued.items) |req| req.deinit(); + self.keyboard.queued.deinit(self.alloc); + // Clean up our font grid self.app.font_grid_set.deref(self.font_grid_key); @@ -857,6 +889,10 @@ fn changeConfig(self: *Surface, config: *const configpkg.Config) !void { self.showMouse(); } + // If we are in the middle of a key sequence, clear it. + self.keyboard.bindings = null; + self.endKeySequence(.drop, .free); + // Before sending any other config changes, we give the renderer a new font // grid. We could check to see if there was an actual change to the font, // but this is easier and pretty rare so it's not a performance concern. @@ -1322,76 +1358,13 @@ pub fn keyCallback( } }; - // Before encoding, we see if we have any keybindings for this - // key. Those always intercept before any encoding tasks. - binding: { - const binding_action: input.Binding.Action, const binding_trigger: input.Binding.Trigger, const consumed = action: { - const binding_mods = event.mods.binding(); - var trigger: input.Binding.Trigger = .{ - .mods = binding_mods, - .key = .{ .translated = event.key }, - }; - - const set = self.config.keybind.set; - if (set.get(trigger)) |v| break :action .{ - v, - trigger, - set.getConsumed(trigger), - }; - - trigger.key = .{ .physical = event.physical_key }; - if (set.get(trigger)) |v| break :action .{ - v, - trigger, - set.getConsumed(trigger), - }; - - if (event.unshifted_codepoint > 0) { - trigger.key = .{ .unicode = event.unshifted_codepoint }; - if (set.get(trigger)) |v| break :action .{ - v, - trigger, - set.getConsumed(trigger), - }; - } - - break :binding; - }; - - // We only execute the binding on press/repeat but we still consume - // the key on release so that we don't send any release events. - log.debug("key event binding consumed={} action={}", .{ consumed, binding_action }); - const performed = if (event.action == .press or event.action == .repeat) press: { - self.last_binding_trigger = 0; - break :press try self.performBindingAction(binding_action); - } else false; - - // If we performed an action and it was a closing action, - // our "self" pointer is not safe to use anymore so we need to - // just exit immediately. - if (performed and closingAction(binding_action)) { - log.debug("key binding is a closing binding, halting key event processing", .{}); - return .closed; - } - - // If we consume this event, then we are done. If we don't consume - // it, we processed the action but we still want to process our - // encodings, too. - if (consumed and performed) { - self.last_binding_trigger = binding_trigger.hash(); - if (insp_ev) |*ev| ev.binding = binding_action; - return .consumed; - } - - // If we have a previous binding trigger and it matches this one, - // then we handled the down event so we don't want to send any further - // events. - if (self.last_binding_trigger > 0 and - self.last_binding_trigger == binding_trigger.hash()) - { - return .consumed; - } - } + // Handle keybindings first. We need to handle this on all events + // (press, repeat, release) because a press may perform a binding but + // a release should not encode if we consumed the press. + if (try self.maybeHandleBinding( + 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) { @@ -1461,26 +1434,6 @@ pub fn keyCallback( }).keyToMouseShape()) |shape| try self.rt_surface.setMouseShape(shape); - // No binding, so we have to perform an encoding task. This - // may still result in no encoding. Under different modes and - // inputs there are many keybindings that result in no encoding - // whatsoever. - const enc: input.KeyEncoder = enc: { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - const t = &self.io.terminal; - break :enc .{ - .event = event, - .macos_option_as_alt = self.config.macos_option_as_alt, - .alt_esc_prefix = t.modes.get(.alt_esc_prefix), - .cursor_key_application = t.modes.get(.cursor_keys), - .keypad_key_application = t.modes.get(.keypad_keys), - .ignore_keypad_with_numlock = t.modes.get(.ignore_keypad_with_numlock), - .modify_other_keys_state_2 = t.flags.modify_other_keys_2, - .kitty_flags = t.screen.kitty_keyboard.current(), - }; - }; - // We've processed a key event that produced some data so we want to // track the last pressed key. self.pressed_key = event: { @@ -1504,6 +1457,216 @@ pub fn keyCallback( break :event copy; }; + // Encode and send our key. If we didn't encode anything, then we + // return the effect as ignored. + if (try self.encodeKey( + event, + if (insp_ev) |*ev| ev else null, + )) |write_req| { + errdefer write_req.deinit(); + self.io.queueMessage(switch (write_req) { + .small => |v| .{ .write_small = v }, + .stable => |v| .{ .write_stable = v }, + .alloc => |v| .{ .write_alloc = v }, + }, .unlocked); + } else { + // No valid request means that we didn't encode anything. + return .ignored; + } + + // If our event is any keypress that isn't a modifier and we generated + // some data to send to the pty, then we move the viewport down to the + // bottom. We also clear the selection for any key other then modifiers. + if (!event.key.modifier()) { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + try self.setSelection(null); + try self.io.terminal.scrollViewport(.{ .bottom = {} }); + try self.queueRender(); + } + + return .consumed; +} + +/// Maybe handles a binding for a given event and if so returns the effect. +/// Returns null if the event is not handled in any way and processing should +/// continue. +fn maybeHandleBinding( + self: *Surface, + event: input.KeyEvent, + insp_ev: ?*inspector.key.Event, +) !?InputEffect { + switch (event.action) { + // Release events never trigger a binding but we need to check if + // we consumed the press event so we don't encode the release. + .release => { + if (self.keyboard.last_trigger) |last| { + if (last == event.bindingHash()) { + // We don't reset the last trigger on release because + // an apprt may send multiple release events for a single + // press event. + return .consumed; + } + } + + return null; + }, + + // Carry on processing. + .press, .repeat => {}, + } + + // Find an entry in the keybind set that matches our event. + const entry: input.Binding.Set.Entry = entry: { + const set = self.keyboard.bindings orelse &self.config.keybind.set; + + var trigger: input.Binding.Trigger = .{ + .mods = event.mods.binding(), + .key = .{ .translated = event.key }, + }; + if (set.get(trigger)) |v| break :entry v; + + trigger.key = .{ .physical = event.physical_key }; + if (set.get(trigger)) |v| break :entry v; + + if (event.unshifted_codepoint > 0) { + trigger.key = .{ .unicode = event.unshifted_codepoint }; + if (set.get(trigger)) |v| break :entry v; + } + + // No entry found. If we're not looking at the root set of the + // bindings we need to encode everything up to this point and + // send to the pty. + if (self.keyboard.bindings != null) { + // Reset to the root set + self.keyboard.bindings = null; + + // Encode everything up to this point + self.endKeySequence(.flush, .retain); + } + + return null; + }; + + // Determine if this entry has an action or if its a leader key. + const action: input.Binding.Action, const consumed: bool = switch (entry) { + .leader => |set| { + // Setup the next set we'll look at. + self.keyboard.bindings = set; + + // Store this event so that we can drain and encode on invalid. + // We don't need to cap this because it is naturally capped by + // the config validation. + if (try self.encodeKey(event, insp_ev)) |req| { + try self.keyboard.queued.append(self.alloc, req); + } + + return .consumed; + }, + + .action => |v| .{ v, true }, + .action_unconsumed => |v| .{ v, false }, + }; + + // We have an action, so at this point we're handling SOMETHING so + // we reset the last trigger to null. We only set this if we actually + // perform an action (below) + self.keyboard.last_trigger = null; + + // An action also always resets the binding set. + self.keyboard.bindings = null; + + // Attempt to perform the action + log.debug("key event binding consumed={} action={}", .{ consumed, action }); + const performed = try self.performBindingAction(action); + + // If we performed an action and it was a closing action, + // our "self" pointer is not safe to use anymore so we need to + // just exit immediately. + if (performed and closingAction(action)) { + log.debug("key binding is a closing binding, halting key event processing", .{}); + return .closed; + } + + // If we consume this event, then we are done. If we don't consume + // it, we processed the action but we still want to process our + // encodings, too. + if (performed and consumed) { + // If we had queued events, we deinit them since we consumed + self.endKeySequence(.drop, .retain); + + // Store our last trigger so we don't encode the release event + self.keyboard.last_trigger = event.bindingHash(); + + if (insp_ev) |ev| ev.binding = action; + return .consumed; + } + + // If we didn't perform OR we didn't consume, then we want to + // encode any queued events for a sequence. + self.endKeySequence(.flush, .retain); + + return null; +} + +const KeySequenceQueued = enum { flush, drop }; +const KeySequenceMemory = enum { retain, free }; + +/// End a key sequence. Safe to call if no key sequence is active. +/// +/// Action and mem determine the behavior of the queued inputs up to this +/// point. +fn endKeySequence( + self: *Surface, + action: KeySequenceQueued, + mem: KeySequenceMemory, +) void { + if (self.keyboard.queued.items.len > 0) { + switch (action) { + .flush => for (self.keyboard.queued.items) |write_req| { + self.io.queueMessage(switch (write_req) { + .small => |v| .{ .write_small = v }, + .stable => |v| .{ .write_stable = v }, + .alloc => |v| .{ .write_alloc = v }, + }, .unlocked); + }, + + .drop => for (self.keyboard.queued.items) |req| req.deinit(), + } + + switch (mem) { + .free => self.keyboard.queued.clearAndFree(self.alloc), + .retain => self.keyboard.queued.clearRetainingCapacity(), + } + } +} + +/// Encodes the key event into a write request. The write request will +/// always copy or allocate so the caller can safely free the event. +fn encodeKey( + self: *Surface, + event: input.KeyEvent, + insp_ev: ?*inspector.key.Event, +) !?termio.Message.WriteReq { + // Build up our encoder. Under different modes and + // inputs there are many keybindings that result in no encoding + // whatsoever. + const enc: input.KeyEncoder = enc: { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const t = &self.io.terminal; + break :enc .{ + .event = event, + .macos_option_as_alt = self.config.macos_option_as_alt, + .alt_esc_prefix = t.modes.get(.alt_esc_prefix), + .cursor_key_application = t.modes.get(.cursor_keys), + .keypad_key_application = t.modes.get(.keypad_keys), + .ignore_keypad_with_numlock = t.modes.get(.ignore_keypad_with_numlock), + .modify_other_keys_state_2 = t.flags.modify_other_keys_2, + .kitty_flags = t.screen.kitty_keyboard.current(), + }; + }; + const write_req: termio.Message.WriteReq = req: { // Try to write the input into a small array. This fits almost // every scenario. Larger situations can happen due to long @@ -1511,7 +1674,7 @@ pub fn keyCallback( var data: termio.Message.WriteReq.Small.Array = undefined; if (enc.encode(&data)) |seq| { // Special-case: we did nothing. - if (seq.len == 0) return .ignored; + if (seq.len == 0) return null; break :req .{ .small = .{ .data = data, @@ -1544,7 +1707,7 @@ pub fn keyCallback( // Copy the encoded data into the inspector event if we have one. // We do this before the mailbox because the IO thread could // release the memory before we get a chance to copy it. - if (insp_ev) |*ev| pty: { + if (insp_ev) |ev| pty: { const slice = write_req.slice(); const copy = self.alloc.alloc(u8, slice.len) catch |err| { log.warn("error allocating pty data for inspector err={}", .{err}); @@ -1555,24 +1718,7 @@ pub fn keyCallback( ev.pty = copy; } - self.io.queueMessage(switch (write_req) { - .small => |v| .{ .write_small = v }, - .stable => |v| .{ .write_stable = v }, - .alloc => |v| .{ .write_alloc = v }, - }, .unlocked); - - // If our event is any keypress that isn't a modifier and we generated - // some data to send to the pty, then we move the viewport down to the - // bottom. We also clear the selection for any key other then modifiers. - if (!event.key.modifier()) { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - try self.setSelection(null); - try self.io.terminal.scrollViewport(.{ .bottom = {} }); - try self.queueRender(); - } - - return .consumed; + return write_req; } /// Sends text as-is to the terminal without triggering any keyboard diff --git a/src/cli/list_keybinds.zig b/src/cli/list_keybinds.zig index 393753335..ca28a2765 100644 --- a/src/cli/list_keybinds.zig +++ b/src/cli/list_keybinds.zig @@ -117,13 +117,17 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 { var widest_key: usize = 0; var buf: [64]u8 = undefined; while (iter.next()) |bind| { + const action = switch (bind.value_ptr.*) { + .leader => continue, // TODO: support this + .action, .action_unconsumed => |action| action, + }; const key = switch (bind.key_ptr.key) { .translated => |k| try std.fmt.bufPrint(&buf, "{s}", .{@tagName(k)}), .physical => |k| try std.fmt.bufPrint(&buf, "physical:{s}", .{@tagName(k)}), .unicode => |c| try std.fmt.bufPrint(&buf, "{u}", .{c}), }; widest_key = @max(widest_key, win.gwidth(key)); - try bindings.append(.{ .trigger = bind.key_ptr.*, .action = bind.value_ptr.* }); + try bindings.append(.{ .trigger = bind.key_ptr.*, .action = action }); } std.mem.sort(Binding, bindings.items, {}, Binding.lessThan); diff --git a/src/config/Config.zig b/src/config/Config.zig index 6d45ab4ec..906a762b9 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -618,6 +618,36 @@ class: ?[:0]const u8 = null, /// or the alias. When debugging keybinds, the non-aliased modifier will always /// be used in output. /// +/// You may also specify multiple triggers separated by `>` to require a +/// sequence of triggers to activate the action. For example, +/// `ctrl+a>n=new_window` will only trigger the `new_window` action if the +/// user presses `ctrl+a` followed separately by `n`. In other software, this +/// is sometimes called a leader key, a key chord, a key table, etc. There +/// is no hardcoded limit on the number of parts in a sequence. +/// +/// Warning: if you define a sequence as a CLI argument to `ghostty`, +/// you probably have to quote the keybind since `>` is a special character +/// in most shells. Example: ghostty --keybind='ctrl+a>n=new_window' +/// +/// A trigger sequence has some special handling: +/// +/// * Ghostty will wait an indefinite amount of time for the next key in +/// the sequence. There is no way to specify a timeout. The only way to +/// force the output of a prefix key is to assign another keybind to +/// specifically output that key (i.e. `ctrl+a>ctrl+a=text:foo`) or +/// press an unbound key which will send both keys to the program. +/// +/// * If a prefix in a sequence is previously bound, the sequence will +/// override the previous binding. For example, if `ctrl+a` is bound to +/// `new_window` and `ctrl+a>n` is bound to `new_tab`, pressing `ctrl+a` +/// will do nothing. +/// +/// * Adding to the above, if a previously bound sequence prefix is +/// used in a new, non-sequence binding, the entire previously bound +/// sequence will be unbound. For example, if you bind `ctrl+a>n` and +/// `ctrl+a>t`, and then bind `ctrl+a` directly, both `ctrl+a>n` and +/// `ctrl+a>t` will become unbound. +/// /// Action is the action to take when the trigger is satisfied. It takes the /// format `action` or `action:param`. The latter form is only valid if the /// action requires a parameter. @@ -3311,43 +3341,56 @@ pub const Keybinds = struct { return; } - const binding = try inputpkg.Binding.parse(value); - switch (binding.action) { - .unbind => self.set.remove(binding.trigger), - else => if (binding.consumed) { - try self.set.put(alloc, binding.trigger, binding.action); - } else { - try self.set.putUnconsumed(alloc, binding.trigger, binding.action); - }, - } + // Let our much better tested binding package handle parsing and storage. + try self.set.parseAndPut(alloc, value); } /// Deep copy of the struct. Required by Config. pub fn clone(self: *const Keybinds, alloc: Allocator) !Keybinds { - return .{ - .set = .{ - .bindings = try self.set.bindings.clone(alloc), - .reverse = try self.set.reverse.clone(alloc), - .unconsumed = try self.set.unconsumed.clone(alloc), - }, - }; + return .{ .set = try self.set.clone(alloc) }; } /// Compare if two of our value are requal. Required by Config. pub fn equal(self: Keybinds, other: Keybinds) bool { - const self_map = self.set.bindings; - const other_map = other.set.bindings; + return equalSet(&self.set, &other.set); + } + + fn equalSet( + self: *const inputpkg.Binding.Set, + other: *const inputpkg.Binding.Set, + ) bool { + // Two keybinds are considered equal if their primary bindings + // are the same. We don't compare reverse mappings and such. + const self_map = &self.bindings; + const other_map = &other.bindings; + + // If the count of mappings isn't identical they can't be equal if (self_map.count() != other_map.count()) return false; var it = self_map.iterator(); while (it.next()) |self_entry| { + // If the trigger isn't in the other map, they can't be equal const other_entry = other_map.getEntry(self_entry.key_ptr.*) orelse return false; - if (!equalField( - inputpkg.Binding.Action, - self_entry.value_ptr.*, - other_entry.value_ptr.*, - )) return false; + + // If the entry types are different, they can't be equal + if (std.meta.activeTag(self_entry.value_ptr.*) != + std.meta.activeTag(other_entry.value_ptr.*)) return false; + + switch (self_entry.value_ptr.*) { + // They're equal if both leader sets are equal. + .leader => if (!equalSet( + self_entry.value_ptr.*.leader, + other_entry.value_ptr.*.leader, + )) return false, + + // Actions are compared by field directly + inline .action, .action_unconsumed => |_, tag| if (!equalField( + inputpkg.Binding.Action, + @field(self_entry.value_ptr.*, @tagName(tag)), + @field(other_entry.value_ptr.*, @tagName(tag)), + )) return false, + } } return true; diff --git a/src/input/Binding.zig b/src/input/Binding.zig index ef0f8e4c1..c0850ec94 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -22,114 +22,97 @@ pub const Error = error{ 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! +/// Full binding parser. The binding parser is implemented as an iterator +/// which yields elements to support multi-key sequences without allocation. +pub const Parser = struct { + unconsumed: bool = false, + trigger_it: SequenceIterator, + action: Action, - // 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..]; + pub const Elem = union(enum) { + /// A leader trigger in a sequence. + leader: Trigger, - // 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.tokenizeScalar(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; - } - } - } - - // Alias modifiers - const alias_mods = .{ - .{ "cmd", "super" }, .{ "command", "super" }, - .{ "opt", "alt" }, .{ "option", "alt" }, - .{ "control", "ctrl" }, - }; - inline for (alias_mods) |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; - continue :loop; - } - } - - // If the key starts with "physical" then this is an physical key. - const physical_prefix = "physical:"; - const physical = std.mem.startsWith(u8, part, physical_prefix); - const key_part = if (physical) part[physical_prefix.len..] else part; - - // Check if its a key - const keysInfo = @typeInfo(key.Key).Enum; - 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.isKeyUnset()) return Error.InvalidFormat; - - const keyval = @field(key.Key, field.name); - result.key = if (physical) - .{ .physical = keyval } - else - .{ .translated = keyval }; - continue :loop; - } - } - } - - // If we're still unset and we have exactly one unicode - // character then we can use that as a key. - if (result.isKeyUnset()) unicode: { - // Invalid UTF8 drops to invalid format - const view = std.unicode.Utf8View.init(key_part) catch break :unicode; - var it = view.iterator(); - - // No codepoints or multiple codepoints drops to invalid format - const cp = it.nextCodepoint() orelse break :unicode; - if (it.nextCodepoint() != null) break :unicode; - - result.key = .{ .unicode = cp }; - continue :loop; - } - - // We didn't recognize this value - return Error.InvalidFormat; - } - - break :trigger result; + /// The final trigger and action in a sequence. + binding: Binding, }; - // Find a matching action - const action = try Action.parse(input[eqlIdx + 1 ..]); + pub fn init(raw_input: []const u8) Error!Parser { + // 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..]; - return Binding{ - .trigger = trigger, - .action = action, - .consumed = !unconsumed, + // Find the first = which splits are mapping into the trigger + // and action, respectively. + const eql_idx = std.mem.indexOf(u8, input, "=") orelse return Error.InvalidFormat; + + // Sequence iterator goes up to the equal, action is after. We can + // parse the action now. + return .{ + .unconsumed = unconsumed, + .trigger_it = .{ .input = input[0..eql_idx] }, + .action = try Action.parse(input[eql_idx + 1 ..]), + }; + } + + pub fn next(self: *Parser) Error!?Elem { + // Get our trigger. If we're out of triggers then we're done. + const trigger = (try self.trigger_it.next()) orelse return null; + + // If this is our last trigger then it is our final binding. + if (!self.trigger_it.done()) return .{ .leader = trigger }; + + // Out of triggers, yield the final action. + return .{ .binding = .{ + .trigger = trigger, + .action = self.action, + .consumed = !self.unconsumed, + } }; + } + + pub fn reset(self: *Parser) void { + self.trigger_it.i = 0; + } +}; + +/// An iterator that yields each trigger in a sequence of triggers. For +/// example, the sequence "ctrl+a>ctrl+b" would yield "ctrl+a" and then +/// "ctrl+b". The iterator approach allows us to parse a sequence of +/// triggers without allocations. +const SequenceIterator = struct { + /// The input of triggers. This is expected to be ONLY triggers. Things + /// like the "unconsumed:" prefix or action must be stripped before + /// passing to this iterator. + input: []const u8, + i: usize = 0, + + /// Returns the next trigger in the sequence if there is no parsing error. + pub fn next(self: *SequenceIterator) Error!?Trigger { + if (self.done()) return null; + const rem = self.input[self.i..]; + const idx = std.mem.indexOf(u8, rem, ">") orelse rem.len; + defer self.i += idx + 1; + return try Trigger.parse(rem[0..idx]); + } + + /// Returns true if there are no more triggers to parse. + pub fn done(self: *const SequenceIterator) bool { + return self.i > self.input.len; + } +}; + +/// Parse a single, non-sequenced binding. To support sequences you must +/// use parse. This is a convenience function for single bindings aimed +/// primarily at tests. +fn parseSingle(raw_input: []const u8) (Error || error{UnexpectedSequence})!Binding { + var p = try Parser.init(raw_input); + const elem = (try p.next()) orelse return Error.InvalidFormat; + return switch (elem) { + .leader => error.UnexpectedSequence, + .binding => elem.binding, }; } @@ -624,6 +607,90 @@ pub const Trigger = struct { }; }; + /// Parse a single trigger. The input is expected to be ONLY the trigger + /// (i.e. in the sequence `a=ignore` input is only `a`). The trigger may + /// not be part of a sequence (i.e. `a>b`). This parses exactly a single + /// trigger. + pub fn parse(input: []const u8) !Trigger { + if (input.len == 0) return Error.InvalidFormat; + var result: Trigger = .{}; + var iter = std.mem.tokenizeScalar(u8, input, '+'); + 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; + } + } + } + + // Alias modifiers + const alias_mods = .{ + .{ "cmd", "super" }, .{ "command", "super" }, + .{ "opt", "alt" }, .{ "option", "alt" }, + .{ "control", "ctrl" }, + }; + inline for (alias_mods) |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; + continue :loop; + } + } + + // If the key starts with "physical" then this is an physical key. + const physical_prefix = "physical:"; + const physical = std.mem.startsWith(u8, part, physical_prefix); + const key_part = if (physical) part[physical_prefix.len..] else part; + + // Check if its a key + const keysInfo = @typeInfo(key.Key).Enum; + 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.isKeyUnset()) return Error.InvalidFormat; + + const keyval = @field(key.Key, field.name); + result.key = if (physical) + .{ .physical = keyval } + else + .{ .translated = keyval }; + continue :loop; + } + } + } + + // If we're still unset and we have exactly one unicode + // character then we can use that as a key. + if (result.isKeyUnset()) unicode: { + // Invalid UTF8 drops to invalid format + const view = std.unicode.Utf8View.init(key_part) catch break :unicode; + var it = view.iterator(); + + // No codepoints or multiple codepoints drops to invalid format + const cp = it.nextCodepoint() orelse break :unicode; + if (it.nextCodepoint() != null) break :unicode; + + result.key = .{ .unicode = cp }; + continue :loop; + } + + // We didn't recognize this value + return Error.InvalidFormat; + } + + return result; + } /// Returns true if this trigger has no key set. pub fn isKeyUnset(self: Trigger) bool { return switch (self.key) { @@ -684,7 +751,7 @@ pub const Trigger = struct { pub const Set = struct { const HashMap = std.HashMapUnmanaged( Trigger, - Action, + Entry, Context(Trigger), std.hash_map.default_max_load_percentage, ); @@ -696,41 +763,185 @@ pub const Set = struct { 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. + /// + /// Sequenced triggers are never present in the reverse map at this time. + /// This is a conscious decision since the primary use case of the reverse + /// map is to support GUI toolkit keyboard accelerators and no mainstream + /// GUI toolkit supports sequences. 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 = .{}, + /// The entry type for the forward mapping of trigger to action. + pub const Entry = union(enum) { + /// This key is a leader key in a sequence. You must follow the given + /// set to find the next key in the sequence. + leader: *Set, + + /// This trigger completes a sequence and the value is the action + /// to take. The "_unconsumed" variant is used for triggers that + /// should not consume the input. + action: Action, + action_unconsumed: Action, + + /// Implements the formatter for the fmt package. This encodes the + /// action back into the format used by parse. + pub fn format( + self: Entry, + comptime layout: []const u8, + opts: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = layout; + _ = opts; + + switch (self) { + .leader => @panic("TODO"), + + .action, .action_unconsumed => |action| { + // action implements the format + try writer.print("{s}", .{action}); + }, + } + } + }; pub fn deinit(self: *Set, alloc: Allocator) void { + // Clear any leaders if we have them + var it = self.bindings.iterator(); + while (it.next()) |entry| switch (entry.value_ptr.*) { + .leader => |s| { + s.deinit(alloc); + alloc.destroy(s); + }, + .action, .action_unconsumed => {}, + }; + self.bindings.deinit(alloc); self.reverse.deinit(alloc); - self.unconsumed.deinit(alloc); self.* = undefined; } + /// Parse a user input binding and add it to the set. This will handle + /// the "unbind" case, ensure consumed/unconsumed fields are set correctly, + /// handle sequences, etc. + /// + /// If this returns an OutOfMemory error then the set is in a broken + /// state and should not be used again. Any Error returned is validated + /// before any set modifications are made. + pub fn parseAndPut( + self: *Set, + alloc: Allocator, + input: []const u8, + ) (Allocator.Error || Error)!void { + // To make cleanup easier, we ensure that the full sequence is + // valid before making any set modifications. This is more expensive + // computationally but it makes cleanup way, way easier. + var it = try Parser.init(input); + while (try it.next()) |_| {} + it.reset(); + + // We use recursion so that we can utilize the stack as our state + // for cleanup. + self.parseAndPutRecurse(alloc, &it) catch |err| switch (err) { + // If this gets sent up to the root then we've unbound + // all the way up and this put was a success. + error.SequenceUnbind => {}, + + // Unrecoverable + error.OutOfMemory => return error.OutOfMemory, + }; + } + + const ParseAndPutRecurseError = Allocator.Error || error{ + SequenceUnbind, + }; + + fn parseAndPutRecurse( + set: *Set, + alloc: Allocator, + it: *Parser, + ) ParseAndPutRecurseError!void { + const elem = (it.next() catch unreachable) orelse return; + switch (elem) { + .leader => |t| { + // If we have a leader, we need to upsert a set for it. + const old = set.get(t); + if (old) |entry| switch (entry) { + // We have an existing leader for this key already + // so recurse into this set. + .leader => |s| return parseAndPutRecurse( + s, + alloc, + it, + ) catch |err| switch (err) { + // Our child put unbound. If our set is empty we + // need to dealloc and continue up. If our set is + // not empty then we're done. + error.SequenceUnbind => if (s.bindings.count() == 0) { + set.remove(alloc, t); + return error.SequenceUnbind; + }, + + error.OutOfMemory => return error.OutOfMemory, + }, + + .action, .action_unconsumed => { + // Remove the existing action. Fallthrough as if + // we don't have a leader. + set.remove(alloc, t); + }, + }; + + // Create our new set for this leader + const next = try alloc.create(Set); + errdefer alloc.destroy(next); + next.* = .{}; + errdefer next.deinit(alloc); + + // Insert the leader entry + try set.bindings.put(alloc, t, .{ .leader = next }); + + // Recurse + parseAndPutRecurse(next, alloc, it) catch |err| switch (err) { + // If our action was to unbind, we restore the old + // action if we have it. + error.SequenceUnbind => { + set.remove(alloc, t); + if (old) |entry| switch (entry) { + .leader => unreachable, // Handled above + inline .action, .action_unconsumed => |action, tag| set.put_( + alloc, + t, + action, + tag == .action, + ) catch {}, + }; + }, + + error.OutOfMemory => return error.OutOfMemory, + }; + }, + + .binding => |b| switch (b.action) { + .unbind => { + set.remove(alloc, b.trigger); + return error.SequenceUnbind; + }, + + else => if (b.consumed) { + try set.put(alloc, b.trigger, b.action); + } else { + try set.putUnconsumed(alloc, b.trigger, b.action); + }, + }, + } + } + /// Add a binding to the set. If the binding already exists then /// this will overwrite it. pub fn put( @@ -767,32 +978,40 @@ pub const Set = struct { 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; + if (gop.found_existing) switch (gop.value_ptr.*) { + // If we have a leader we need to clean up the memory + .leader => |s| { + s.deinit(alloc); + alloc.destroy(s); + }, + + // If we have an existing binding for this trigger, we have to + // update the reverse mapping to remove the old action. + .action, .action_unconsumed => { + 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; + gop.value_ptr.* = if (consumed) .{ + .action = action, + } else .{ + .action_unconsumed = 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 { + pub fn get(self: Set, t: Trigger) ?Entry { return self.bindings.get(t); } @@ -802,35 +1021,70 @@ pub const Set = struct { 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, alloc: Allocator, t: Trigger) void { + const entry = self.bindings.get(t) orelse return; + _ = self.bindings.remove(t); + + switch (entry) { + // For a leader removal, we need to deallocate our child set. + // Leaders are never part of reverse maps so no other accounting + // needs to be done. + .leader => |s| { + s.deinit(alloc); + alloc.destroy(s); + }, + + // For an action we need to fix up the reverse mapping. + // 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. + .action, .action_unconsumed => |action| { + const action_hash = action.hash(); + var it = self.bindings.iterator(); + while (it.next()) |it_entry| { + switch (it_entry.value_ptr.*) { + .leader => {}, + .action, .action_unconsumed => |action_search| { + if (action_search.hash() == action_hash) { + self.reverse.putAssumeCapacity(action, it_entry.key_ptr.*); + break; + } + }, + } + } else { + // No over trigger points to this action so we remove + // the reverse mapping completely. + _ = self.reverse.remove(action); + } + }, + } } - /// 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); + /// Deep clone the set. + pub fn clone(self: *const Set, alloc: Allocator) !Set { + var result: Set = .{ + .bindings = try self.bindings.clone(alloc), + .reverse = try self.reverse.clone(alloc), + }; - // 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); - } + // If we have any leaders we need to clone them. + var it = result.bindings.iterator(); + while (it.next()) |entry| switch (entry.value_ptr.*) { + // No data to clone + .action, .action_unconsumed => {}, + + // Must be deep cloned. + .leader => |*s| { + const ptr = try alloc.create(Set); + errdefer alloc.destroy(ptr); + ptr.* = try s.*.clone(alloc); + errdefer ptr.deinit(alloc); + s.* = ptr; + }, + }; + + return result; } /// The hash map context for the set. This defines how the hash map @@ -858,7 +1112,7 @@ test "parse: triggers" { .trigger = .{ .key = .{ .translated = .a } }, .action = .{ .ignore = {} }, }, - try parse("a=ignore"), + try parseSingle("a=ignore"), ); // single modifier @@ -868,14 +1122,14 @@ test "parse: triggers" { .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, - }, try parse("shift+a=ignore")); + }, try parseSingle("shift+a=ignore")); try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .ctrl = true }, .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, - }, try parse("ctrl+a=ignore")); + }, try parseSingle("ctrl+a=ignore")); // multiple modifier try testing.expectEqual(Binding{ @@ -884,7 +1138,7 @@ test "parse: triggers" { .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, - }, try parse("shift+ctrl+a=ignore")); + }, try parseSingle("shift+ctrl+a=ignore")); // key can come before modifier try testing.expectEqual(Binding{ @@ -893,7 +1147,7 @@ test "parse: triggers" { .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, - }, try parse("a+shift=ignore")); + }, try parseSingle("a+shift=ignore")); // physical keys try testing.expectEqual(Binding{ @@ -902,7 +1156,7 @@ test "parse: triggers" { .key = .{ .physical = .a }, }, .action = .{ .ignore = {} }, - }, try parse("shift+physical:a=ignore")); + }, try parseSingle("shift+physical:a=ignore")); // unicode keys try testing.expectEqual(Binding{ @@ -911,7 +1165,7 @@ test "parse: triggers" { .key = .{ .unicode = 'ö' }, }, .action = .{ .ignore = {} }, - }, try parse("shift+ö=ignore")); + }, try parseSingle("shift+ö=ignore")); // unconsumed keys try testing.expectEqual(Binding{ @@ -921,7 +1175,7 @@ test "parse: triggers" { }, .action = .{ .ignore = {} }, .consumed = false, - }, try parse("unconsumed:shift+a=ignore")); + }, try parseSingle("unconsumed:shift+a=ignore")); // unconsumed physical keys try testing.expectEqual(Binding{ @@ -931,16 +1185,16 @@ test "parse: triggers" { }, .action = .{ .ignore = {} }, .consumed = false, - }, try parse("unconsumed:physical:a+shift=ignore")); + }, try parseSingle("unconsumed:physical:a+shift=ignore")); // invalid key - try testing.expectError(Error.InvalidFormat, parse("foo=ignore")); + try testing.expectError(Error.InvalidFormat, parseSingle("foo=ignore")); // repeated control - try testing.expectError(Error.InvalidFormat, parse("shift+shift+a=ignore")); + try testing.expectError(Error.InvalidFormat, parseSingle("shift+shift+a=ignore")); // multiple character - try testing.expectError(Error.InvalidFormat, parse("a+b=ignore")); + try testing.expectError(Error.InvalidFormat, parseSingle("a+b=ignore")); } test "parse: modifier aliases" { @@ -952,14 +1206,14 @@ test "parse: modifier aliases" { .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, - }, try parse("cmd+a=ignore")); + }, try parseSingle("cmd+a=ignore")); try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .super = true }, .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, - }, try parse("command+a=ignore")); + }, try parseSingle("command+a=ignore")); try testing.expectEqual(Binding{ .trigger = .{ @@ -967,14 +1221,14 @@ test "parse: modifier aliases" { .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, - }, try parse("opt+a=ignore")); + }, try parseSingle("opt+a=ignore")); try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .alt = true }, .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, - }, try parse("option+a=ignore")); + }, try parseSingle("option+a=ignore")); try testing.expectEqual(Binding{ .trigger = .{ @@ -982,14 +1236,14 @@ test "parse: modifier aliases" { .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, - }, try parse("control+a=ignore")); + }, try parseSingle("control+a=ignore")); } test "parse: action invalid" { const testing = std.testing; // invalid action - try testing.expectError(Error.InvalidAction, parse("a=nopenopenope")); + try testing.expectError(Error.InvalidAction, parseSingle("a=nopenopenope")); } test "parse: action no parameters" { @@ -1001,9 +1255,9 @@ test "parse: action no parameters" { .trigger = .{ .key = .{ .translated = .a } }, .action = .{ .ignore = {} }, }, - try parse("a=ignore"), + try parseSingle("a=ignore"), ); - try testing.expectError(Error.InvalidFormat, parse("a=ignore:A")); + try testing.expectError(Error.InvalidFormat, parseSingle("a=ignore:A")); } test "parse: action with string" { @@ -1011,13 +1265,13 @@ test "parse: action with string" { // parameter { - const binding = try parse("a=csi:A"); + const binding = try parseSingle("a=csi:A"); try testing.expect(binding.action == .csi); try testing.expectEqualStrings("A", binding.action.csi); } // parameter { - const binding = try parse("a=esc:A"); + const binding = try parseSingle("a=esc:A"); try testing.expect(binding.action == .esc); try testing.expectEqualStrings("A", binding.action.esc); } @@ -1028,7 +1282,7 @@ test "parse: action with enum" { // parameter { - const binding = try parse("a=new_split:right"); + const binding = try parseSingle("a=new_split:right"); try testing.expect(binding.action == .new_split); try testing.expectEqual(Action.SplitDirection.right, binding.action.new_split); } @@ -1039,12 +1293,12 @@ test "parse: action with int" { // parameter { - const binding = try parse("a=jump_to_prompt:-1"); + const binding = try parseSingle("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"); + const binding = try parseSingle("a=jump_to_prompt:10"); try testing.expect(binding.action == .jump_to_prompt); try testing.expectEqual(@as(i16, 10), binding.action.jump_to_prompt); } @@ -1055,12 +1309,12 @@ test "parse: action with float" { // parameter { - const binding = try parse("a=scroll_page_fractional:-0.5"); + const binding = try parseSingle("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"); + const binding = try parseSingle("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); } @@ -1071,20 +1325,322 @@ test "parse: action with a tuple" { // parameter { - const binding = try parse("a=resize_split:up,10"); + const binding = try parseSingle("a=resize_split:up,10"); try testing.expect(binding.action == .resize_split); try testing.expectEqual(Action.SplitResizeDirection.up, binding.action.resize_split[0]); try testing.expectEqual(@as(u16, 10), binding.action.resize_split[1]); } // missing parameter - try testing.expectError(Error.InvalidFormat, parse("a=resize_split:up")); + try testing.expectError(Error.InvalidFormat, parseSingle("a=resize_split:up")); // too many - try testing.expectError(Error.InvalidFormat, parse("a=resize_split:up,10,12")); + try testing.expectError(Error.InvalidFormat, parseSingle("a=resize_split:up,10,12")); // invalid type - try testing.expectError(Error.InvalidFormat, parse("a=resize_split:up,four")); + try testing.expectError(Error.InvalidFormat, parseSingle("a=resize_split:up,four")); +} + +test "sequence iterator" { + const testing = std.testing; + + // single character + { + var it: SequenceIterator = .{ .input = "a" }; + try testing.expectEqual(Trigger{ .key = .{ .translated = .a } }, (try it.next()).?); + try testing.expect(try it.next() == null); + } + + // multi character + { + var it: SequenceIterator = .{ .input = "a>b" }; + try testing.expectEqual(Trigger{ .key = .{ .translated = .a } }, (try it.next()).?); + try testing.expectEqual(Trigger{ .key = .{ .translated = .b } }, (try it.next()).?); + try testing.expect(try it.next() == null); + } + + // empty + { + var it: SequenceIterator = .{ .input = "" }; + try testing.expectError(Error.InvalidFormat, it.next()); + } + + // empty starting sequence + { + var it: SequenceIterator = .{ .input = ">a" }; + try testing.expectError(Error.InvalidFormat, it.next()); + } + + // empty ending sequence + { + var it: SequenceIterator = .{ .input = "a>" }; + try testing.expectEqual(Trigger{ .key = .{ .translated = .a } }, (try it.next()).?); + try testing.expectError(Error.InvalidFormat, it.next()); + } +} + +test "parse: sequences" { + const testing = std.testing; + + // single character + { + var p = try Parser.init("ctrl+a=ignore"); + try testing.expectEqual(Parser.Elem{ .binding = .{ + .trigger = .{ + .mods = .{ .ctrl = true }, + .key = .{ .translated = .a }, + }, + .action = .{ .ignore = {} }, + } }, (try p.next()).?); + try testing.expect(try p.next() == null); + } + + // sequence + { + var p = try Parser.init("a>b=ignore"); + try testing.expectEqual(Parser.Elem{ .leader = .{ + .key = .{ .translated = .a }, + } }, (try p.next()).?); + try testing.expectEqual(Parser.Elem{ .binding = .{ + .trigger = .{ + .key = .{ .translated = .b }, + }, + .action = .{ .ignore = {} }, + } }, (try p.next()).?); + try testing.expect(try p.next() == null); + } +} + +test "set: parseAndPut typical binding" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + + // Creates forward mapping + { + const action = s.get(.{ .key = .{ .translated = .a } }).?.action; + try testing.expect(action == .new_window); + } + + // Creates reverse mapping + { + const trigger = s.getTrigger(.{ .new_window = {} }).?; + try testing.expect(trigger.key.translated == .a); + } +} + +test "set: parseAndPut unconsumed binding" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "unconsumed:a=new_window"); + + // Creates forward mapping + { + const trigger: Trigger = .{ .key = .{ .translated = .a } }; + const action = s.get(trigger).?.action_unconsumed; + try testing.expect(action == .new_window); + } + + // Creates reverse mapping + { + const trigger = s.getTrigger(.{ .new_window = {} }).?; + try testing.expect(trigger.key.translated == .a); + } +} + +test "set: parseAndPut removed binding" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + try s.parseAndPut(alloc, "a=unbind"); + + // Creates forward mapping + { + const trigger: Trigger = .{ .key = .{ .translated = .a } }; + try testing.expect(s.get(trigger) == null); + } + try testing.expect(s.getTrigger(.{ .new_window = {} }) == null); +} + +test "set: parseAndPut sequence" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a>b=new_window"); + var current: *Set = &s; + { + const t: Trigger = .{ .key = .{ .translated = .a } }; + const e = current.get(t).?; + try testing.expect(e == .leader); + current = e.leader; + } + { + const t: Trigger = .{ .key = .{ .translated = .b } }; + const e = current.get(t).?; + try testing.expect(e == .action); + try testing.expect(e.action == .new_window); + } +} + +test "set: parseAndPut sequence with two actions" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a>b=new_window"); + try s.parseAndPut(alloc, "a>c=new_tab"); + var current: *Set = &s; + { + const t: Trigger = .{ .key = .{ .translated = .a } }; + const e = current.get(t).?; + try testing.expect(e == .leader); + current = e.leader; + } + { + const t: Trigger = .{ .key = .{ .translated = .b } }; + const e = current.get(t).?; + try testing.expect(e == .action); + try testing.expect(e.action == .new_window); + } + { + const t: Trigger = .{ .key = .{ .translated = .c } }; + const e = current.get(t).?; + try testing.expect(e == .action); + try testing.expect(e.action == .new_tab); + } +} + +test "set: parseAndPut overwrite sequence" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a>b=new_tab"); + try s.parseAndPut(alloc, "a>b=new_window"); + var current: *Set = &s; + { + const t: Trigger = .{ .key = .{ .translated = .a } }; + const e = current.get(t).?; + try testing.expect(e == .leader); + current = e.leader; + } + { + const t: Trigger = .{ .key = .{ .translated = .b } }; + const e = current.get(t).?; + try testing.expect(e == .action); + try testing.expect(e.action == .new_window); + } +} + +test "set: parseAndPut overwrite leader" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_tab"); + try s.parseAndPut(alloc, "a>b=new_window"); + var current: *Set = &s; + { + const t: Trigger = .{ .key = .{ .translated = .a } }; + const e = current.get(t).?; + try testing.expect(e == .leader); + current = e.leader; + } + { + const t: Trigger = .{ .key = .{ .translated = .b } }; + const e = current.get(t).?; + try testing.expect(e == .action); + try testing.expect(e.action == .new_window); + } +} + +test "set: parseAndPut unbind sequence unbinds leader" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a>b=new_window"); + try s.parseAndPut(alloc, "a>b=unbind"); + var current: *Set = &s; + { + const t: Trigger = .{ .key = .{ .translated = .a } }; + try testing.expect(current.get(t) == null); + } +} + +test "set: parseAndPut unbind sequence unbinds leader if not set" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a>b=unbind"); + var current: *Set = &s; + { + const t: Trigger = .{ .key = .{ .translated = .a } }; + try testing.expect(current.get(t) == null); + } +} + +test "set: parseAndPut sequence preserves reverse mapping" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + try s.parseAndPut(alloc, "ctrl+a>b=new_window"); + + // Creates reverse mapping + { + const trigger = s.getTrigger(.{ .new_window = {} }).?; + try testing.expect(trigger.key.translated == .a); + } +} + +test "set: put overwrites sequence" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "ctrl+a>b=new_window"); + try s.put(alloc, .{ + .mods = .{ .ctrl = true }, + .key = .{ .translated = .a }, + }, .{ .new_window = {} }); + + // Creates reverse mapping + { + const trigger = s.getTrigger(.{ .new_window = {} }).?; + try testing.expect(trigger.key.translated == .a); + } } test "set: maintains reverse mapping" { @@ -1108,7 +1664,7 @@ test "set: maintains reverse mapping" { } // removal should replace - s.remove(.{ .key = .{ .translated = .b } }); + s.remove(alloc, .{ .key = .{ .translated = .b } }); { const trigger = s.getTrigger(.{ .new_window = {} }).?; try testing.expect(trigger.key.translated == .a); @@ -1144,11 +1700,11 @@ test "set: consumed state" { defer s.deinit(alloc); try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); - try testing.expect(s.getConsumed(.{ .key = .{ .translated = .a } })); + try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .action); try s.putUnconsumed(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); - try testing.expect(!s.getConsumed(.{ .key = .{ .translated = .a } })); + try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .action_unconsumed); try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); - try testing.expect(s.getConsumed(.{ .key = .{ .translated = .a } })); + try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .action); } diff --git a/src/input/key.zig b/src/input/key.zig index 7886d0b55..8fc7c6f20 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -54,6 +54,29 @@ pub const KeyEvent = struct { if (self.utf8.len == 0) return self.mods; return self.mods.unset(self.consumed_mods); } + + /// Returns a unique hash for this key event to be used for tracking + /// uniquess specifically with bindings. This omits fields that are + /// irrelevant for bindings. + pub fn bindingHash(self: KeyEvent) u64 { + var hasher = std.hash.Wyhash.init(0); + + // These are all the fields that are explicitly part of Trigger. + std.hash.autoHash(&hasher, self.key); + std.hash.autoHash(&hasher, self.physical_key); + std.hash.autoHash(&hasher, self.unshifted_codepoint); + std.hash.autoHash(&hasher, self.mods.binding()); + + // Notes on unmapped things and why: + // + // - action: we don't have action-specific bindings right now + // AND we want to know if a key resulted in a binding regardless + // of action because a press should also ignore a release and so on. + // + // We can add to this if there is other confusion. + + return hasher.final(); + } }; /// A bitmask for all key modifiers. diff --git a/src/termio/message.zig b/src/termio/message.zig index a5307498e..20d47fd32 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -179,7 +179,7 @@ pub fn MessageData(comptime Elem: type, comptime small_size: comptime_int) type pub fn deinit(self: Self) void { switch (self) { .small, .stable => {}, - .alloc => |v| v.alloc.free(v.alloc.data), + .alloc => |v| v.alloc.free(v.data), } }