ghostty/src/input/Binding.zig
Caleb Norton 2610f5b4e2 Docs: update goto_split documentation
In #4388, documentation was added for goto_split but in #3427 this
documentation was made outdated but not updated. This makes the
documentation up to date and brings the ordering in line with new_split
2025-01-03 14:32:39 -06:00

2369 lines
78 KiB
Zig

//! 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");
const KeyEvent = key.KeyEvent;
/// The trigger that needs to be performed to execute the action.
trigger: Trigger,
/// The action to take if this binding matches
action: Action,
/// Boolean flags that can be set per binding.
flags: Flags = .{},
pub const Error = error{
InvalidFormat,
InvalidAction,
};
/// Flags the full binding-scoped flags that can be set per binding.
pub const Flags = packed struct {
/// True if this binding should consume the input when the
/// action is triggered.
consumed: bool = true,
/// True if this binding should be forwarded to all active surfaces
/// in the application.
all: bool = false,
/// True if this binding is global. Global bindings should work system-wide
/// and not just while Ghostty is focused. This may not work on all platforms.
/// See the keybind config documentation for more information.
global: bool = false,
/// True if this binding should only be triggered if the action can be
/// performed. If the action can't be performed then the binding acts as
/// if it doesn't exist.
performable: bool = false,
};
/// 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 {
trigger_it: SequenceIterator,
action: Action,
flags: Flags = .{},
pub const Elem = union(enum) {
/// A leader trigger in a sequence.
leader: Trigger,
/// The final trigger and action in a sequence.
binding: Binding,
};
pub fn init(raw_input: []const u8) Error!Parser {
const flags, const start_idx = try parseFlags(raw_input);
const input = raw_input[start_idx..];
// 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 .{
.trigger_it = .{ .input = input[0..eql_idx] },
.action = try Action.parse(input[eql_idx + 1 ..]),
.flags = flags,
};
}
fn parseFlags(raw_input: []const u8) Error!struct { Flags, usize } {
var flags: Flags = .{};
var start_idx: usize = 0;
var input: []const u8 = raw_input;
while (true) {
// Find the next prefix
const idx = std.mem.indexOf(u8, input, ":") orelse break;
const prefix = input[0..idx];
// If the prefix is one of our flags then set it.
if (std.mem.eql(u8, prefix, "all")) {
if (flags.all) return Error.InvalidFormat;
flags.all = true;
} else if (std.mem.eql(u8, prefix, "global")) {
if (flags.global) return Error.InvalidFormat;
flags.global = true;
} else if (std.mem.eql(u8, prefix, "unconsumed")) {
if (!flags.consumed) return Error.InvalidFormat;
flags.consumed = false;
} else if (std.mem.eql(u8, prefix, "performable")) {
if (flags.performable) return Error.InvalidFormat;
flags.performable = true;
} else {
// If we don't recognize the prefix then we're done.
// There are trigger-specific prefixes like "physical:" so
// this lets us fall into that.
break;
}
// Move past the prefix
start_idx += idx + 1;
input = input[idx + 1 ..];
}
return .{ flags, start_idx };
}
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()) {
// Global/all bindings can't be sequences
if (self.flags.global or self.flags.all) return error.InvalidFormat;
return .{ .leader = trigger };
}
// Out of triggers, yield the final action.
return .{ .binding = .{
.trigger = trigger,
.action = self.action,
.flags = self.flags,
} };
}
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,
};
}
/// Returns true if lhs should be sorted before rhs
pub fn lessThan(_: void, lhs: Binding, rhs: Binding) bool {
const lhs_count: usize = blk: {
var count: usize = 0;
if (lhs.trigger.mods.super) count += 1;
if (lhs.trigger.mods.ctrl) count += 1;
if (lhs.trigger.mods.shift) count += 1;
if (lhs.trigger.mods.alt) count += 1;
break :blk count;
};
const rhs_count: usize = blk: {
var count: usize = 0;
if (rhs.trigger.mods.super) count += 1;
if (rhs.trigger.mods.ctrl) count += 1;
if (rhs.trigger.mods.shift) count += 1;
if (rhs.trigger.mods.alt) count += 1;
break :blk count;
};
if (lhs_count != rhs_count)
return lhs_count > rhs_count;
if (lhs.trigger.mods.int() != rhs.trigger.mods.int())
return lhs.trigger.mods.int() > rhs.trigger.mods.int();
const lhs_key: c_int = blk: {
switch (lhs.trigger.key) {
.translated => break :blk @intFromEnum(lhs.trigger.key.translated),
.physical => break :blk @intFromEnum(lhs.trigger.key.physical),
.unicode => break :blk @intCast(lhs.trigger.key.unicode),
}
};
const rhs_key: c_int = blk: {
switch (rhs.trigger.key) {
.translated => break :blk @intFromEnum(rhs.trigger.key.translated),
.physical => break :blk @intFromEnum(rhs.trigger.key.physical),
.unicode => break :blk @intCast(rhs.trigger.key.unicode),
}
};
return lhs_key < rhs_key;
}
/// 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 the given text. Uses Zig string literal syntax. This is currently
// not validated. If the text is invalid (i.e. contains an invalid escape
// sequence), the error will currently only show up in logs.
text: []const u8,
/// Send data to the pty depending on whether cursor key mode is enabled
/// (`application`) or disabled (`normal`).
cursor_key: CursorKey,
/// Reset the terminal. This can fix a lot of issues when a running
/// program puts the terminal into a broken state. This is equivalent to
/// when you type "reset" and press enter.
///
/// If you do this while in a TUI program such as vim, this may break
/// the program. If you do this while in a shell, you may have to press
/// enter after to get a new prompt.
reset: void,
/// Copy and paste.
copy_to_clipboard: void,
paste_from_clipboard: void,
paste_from_selection: void,
/// Increase/decrease the font size by a certain amount.
increase_font_size: f32,
decrease_font_size: f32,
/// Reset the font size to the original configured size.
reset_font_size: void,
/// Clear the screen. This also clears all scrollback.
clear_screen: void,
/// Select all text on the screen.
select_all: 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,
scroll_page_lines: i16,
/// Adjust an existing selection in a given direction. This action
/// does nothing if there is no active selection.
adjust_selection: AdjustSelection,
/// 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. The action
/// determines what to do with the filepath. Valid values are:
///
/// - "paste": Paste the file path into the terminal.
/// - "open": Open the file in the default OS editor for text files.
/// The default OS editor is determined by using `open` on macOS
/// and `xdg-open` on Linux.
///
write_scrollback_file: WriteScreenAction,
/// Same as write_scrollback_file but writes the full screen contents.
/// See write_scrollback_file for available values.
write_screen_file: WriteScreenAction,
/// Same as write_scrollback_file but writes the selected text.
/// If there is no selected text this does nothing (it doesn't
/// even create an empty file). See write_scrollback_file for
/// available values.
write_selection_file: WriteScreenAction,
/// Open a new window. If the application isn't currently focused,
/// this will bring it to the front.
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 last tab (the one with the highest index)
last_tab: void,
/// Go to the tab with the specific number, 1-indexed. If the tab number
/// is higher than the number of tabs, this will go to the last tab.
goto_tab: usize,
/// Moves a tab by a relative offset.
/// Adjusts the tab position based on `offset` (e.g., -1 for left, +1 for right).
/// If the new position is out of bounds, it wraps around cyclically within the tab range.
move_tab: isize,
/// Toggle the tab overview.
/// This only works with libadwaita enabled currently.
toggle_tab_overview: void,
/// Create a new split in the given direction. The new split will appear in
/// the direction given. For example `new_split:up`. Valid values are left, right, up, down and auto.
new_split: SplitDirection,
/// Focus on a split in a given direction. For example `goto_split:up`. Valid values are left, right, up, down, previous and next.
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. For example `resize_split:left,10`. The valid directions are up, down, left and right.
resize_split: SplitResizeParameter,
/// Equalize all splits in the current window
equalize_splits: void,
/// Show, hide, or toggle the terminal inspector for the currently focused
/// terminal.
inspector: InspectorMode,
/// Open the configuration file in the default OS editor. If your default OS
/// editor isn't configured then this will fail. Currently, any failures to
/// open the configuration will show up only in the logs.
open_config: void,
/// 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. This will trigger close confirmation as
/// configured.
close_surface: void,
/// Close the window, regardless of how many tabs or splits there may be.
/// This will trigger close confirmation as configured.
close_window: void,
/// Close all windows. This will trigger close confirmation as configured.
/// This only works for macOS currently.
close_all_windows: void,
/// Toggle fullscreen mode of window.
toggle_fullscreen: void,
/// Toggle window decorations on and off. This only works on Linux.
toggle_window_decorations: void,
/// Toggle secure input mode on or off. This is used to prevent apps
/// that monitor input from seeing what you type. This is useful for
/// entering passwords or other sensitive information.
///
/// This applies to the entire application, not just the focused
/// terminal. You must toggle it off to disable it, or quit Ghostty.
///
/// This only works on macOS, since this is a system API on macOS.
toggle_secure_input: void,
/// Toggle the "quick" terminal. The quick terminal is a terminal that
/// appears on demand from a keybinding, often sliding in from a screen
/// edge such as the top. This is useful for quick access to a terminal
/// without having to open a new window or tab.
///
/// When the quick terminal loses focus, it disappears. The terminal state
/// is preserved between appearances, so you can always press the keybinding
/// to bring it back up.
///
/// To enable the quick terminally globally so that Ghostty doesn't
/// have to be focused, prefix your keybind with `global`. Example:
///
/// ```ini
/// keybind = global:cmd+grave_accent=toggle_quick_terminal
/// ```
///
/// The quick terminal has some limitations:
///
/// - It is a singleton; only one instance can exist at a time.
/// - It does not support tabs, but it does support splits.
/// - It will not be restored when the application is restarted
/// (for systems that support window restoration).
/// - It supports fullscreen, but fullscreen will always be a non-native
/// fullscreen (macos-non-native-fullscreen = true). This only applies
/// to the quick terminal window. This is a requirement due to how
/// the quick terminal is rendered.
///
/// See the various configurations for the quick terminal in the
/// configuration file to customize its behavior.
///
/// This currently only works on macOS.
toggle_quick_terminal: void,
/// Show/hide all windows. If all windows become shown, we also ensure
/// Ghostty is focused.
///
/// This currently only works on macOS. When hiding all windows, we do
/// not yield focus to the previous application.
toggle_visibility: void,
/// Quit ghostty.
quit: void,
/// Crash ghostty in the desired thread for the focused surface.
///
/// WARNING: This is a hard crash (panic) and data can be lost.
///
/// The purpose of this action is to test crash handling. For some
/// users, it may be useful to test crash reporting functionality in
/// order to determine if it all works as expected.
///
/// The value determines the crash location:
///
/// - "main" - crash on the main (GUI) thread.
/// - "io" - crash on the IO thread for the focused surface.
/// - "render" - crash on the render thread for the focused surface.
///
crash: CrashThread,
pub const Key = @typeInfo(Action).Union.tag_type.?;
pub const CrashThread = enum {
main,
io,
render,
};
pub const CursorKey = struct {
normal: []const u8,
application: []const u8,
pub fn clone(
self: CursorKey,
alloc: Allocator,
) Allocator.Error!CursorKey {
return .{
.normal = try alloc.dupe(u8, self.normal),
.application = try alloc.dupe(u8, self.application),
};
}
};
pub const AdjustSelection = enum {
left,
right,
up,
down,
page_up,
page_down,
home,
end,
beginning_of_line,
end_of_line,
};
pub const SplitDirection = enum {
right,
down,
left,
up,
auto, // splits along the larger direction
};
pub const SplitFocusDirection = enum {
previous,
next,
up,
left,
down,
right,
pub fn parse(input: []const u8) !SplitFocusDirection {
return std.meta.stringToEnum(SplitFocusDirection, input) orelse {
// For backwards compatibility we map "top" and "bottom" onto the enum
// values "up" and "down"
if (std.mem.eql(u8, input, "top")) {
return .up;
} else if (std.mem.eql(u8, input, "bottom")) {
return .down;
} else {
return Error.InvalidFormat;
}
};
}
test "parse" {
const testing = std.testing;
try testing.expectEqual(.previous, try SplitFocusDirection.parse("previous"));
try testing.expectEqual(.next, try SplitFocusDirection.parse("next"));
try testing.expectEqual(.up, try SplitFocusDirection.parse("up"));
try testing.expectEqual(.left, try SplitFocusDirection.parse("left"));
try testing.expectEqual(.down, try SplitFocusDirection.parse("down"));
try testing.expectEqual(.right, try SplitFocusDirection.parse("right"));
try testing.expectEqual(.up, try SplitFocusDirection.parse("top"));
try testing.expectEqual(.down, try SplitFocusDirection.parse("bottom"));
try testing.expectError(error.InvalidFormat, SplitFocusDirection.parse(""));
try testing.expectError(error.InvalidFormat, SplitFocusDirection.parse("green"));
}
};
pub const SplitResizeDirection = enum {
up,
down,
left,
right,
};
pub const SplitResizeParameter = struct {
SplitResizeDirection,
u16,
};
pub const WriteScreenAction = enum {
paste,
open,
};
// Extern because it is used in the embedded runtime ABI.
pub const InspectorMode = enum {
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 {
const field_info = @typeInfo(field.type);
// Fields can provide a custom "parse" function
if (field_info == .Struct or field_info == .Union or field_info == .Enum) {
if (@hasDecl(field.type, "parse") and @typeInfo(@TypeOf(field.type.parse)) == .Fn) {
return field.type.parse(param);
}
}
return switch (field_info) {
.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.splitAny(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,
};
}
// If we have extra parameters it is an error
if (it.next() != null) return Error.InvalidFormat;
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;
}
/// The scope of an action. The scope is the context in which an action
/// must be executed.
pub const Scope = enum {
app,
surface,
};
/// Returns the scope of an action.
pub fn scope(self: Action) Scope {
return switch (self) {
// Doesn't really matter, so we'll see app.
.ignore,
.unbind,
=> .app,
// Obviously app actions.
.open_config,
.reload_config,
.close_all_windows,
.quit,
.toggle_quick_terminal,
.toggle_visibility,
=> .app,
// These are app but can be special-cased in a surface context.
.new_window,
=> .app,
// Obviously surface actions.
.csi,
.esc,
.text,
.cursor_key,
.reset,
.copy_to_clipboard,
.paste_from_clipboard,
.paste_from_selection,
.increase_font_size,
.decrease_font_size,
.reset_font_size,
.clear_screen,
.select_all,
.scroll_to_top,
.scroll_to_bottom,
.scroll_page_up,
.scroll_page_down,
.scroll_page_fractional,
.scroll_page_lines,
.adjust_selection,
.jump_to_prompt,
.write_scrollback_file,
.write_screen_file,
.write_selection_file,
.close_surface,
.close_window,
.toggle_fullscreen,
.toggle_window_decorations,
.toggle_secure_input,
.crash,
=> .surface,
// These are less obvious surface actions. They're surface
// actions because they are relevant to the surface they
// come from. For example `new_window` needs to be sourced to
// a surface so inheritance can be done correctly.
.new_tab,
.previous_tab,
.next_tab,
.last_tab,
.goto_tab,
.move_tab,
.toggle_tab_overview,
.new_split,
.goto_split,
.toggle_split_zoom,
.resize_split,
.equalize_splits,
.inspector,
=> .surface,
};
}
/// Returns a union type that only contains actions that are scoped to
/// the given scope.
pub fn Scoped(comptime s: Scope) type {
const all_fields = @typeInfo(Action).Union.fields;
// Find all fields that are app-scoped
var i: usize = 0;
var union_fields: [all_fields.len]std.builtin.Type.UnionField = undefined;
var enum_fields: [all_fields.len]std.builtin.Type.EnumField = undefined;
for (all_fields) |field| {
const action = @unionInit(Action, field.name, undefined);
if (action.scope() == s) {
union_fields[i] = field;
enum_fields[i] = .{ .name = field.name, .value = i };
i += 1;
}
}
// Build our union
return @Type(.{ .Union = .{
.layout = .auto,
.tag_type = @Type(.{ .Enum = .{
.tag_type = std.math.IntFittingRange(0, i),
.fields = enum_fields[0..i],
.decls = &.{},
.is_exhaustive = true,
} }),
.fields = union_fields[0..i],
.decls = &.{},
} });
}
/// Returns the scoped version of this action. If the action is not
/// scoped to the given scope then this returns null.
///
/// The benefit of this function is that it allows us to use Zig's
/// exhaustive switch safety to ensure we always properly handle certain
/// scoped actions.
pub fn scoped(self: Action, comptime s: Scope) ?Scoped(s) {
switch (self) {
inline else => |v, tag| {
// Use comptime to prune out non-app actions
if (comptime @unionInit(
Action,
@tagName(tag),
undefined,
).scope() != s) return null;
// Initialize our app action
return @unionInit(
Scoped(s),
@tagName(tag),
v,
);
},
}
}
/// 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)});
// Only write the value depending on the type if it's not void
if (@TypeOf(value) != void) {
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)),
},
}
}
/// Clone this action with the given allocator. The allocator
/// should be an arena-style allocator since fine-grained
/// deallocation is not possible.
pub fn clone(self: Action, alloc: Allocator) Allocator.Error!Action {
return switch (self) {
inline else => |value, tag| @unionInit(
Action,
@tagName(tag),
try cloneValue(alloc, value),
),
};
}
fn cloneValue(
alloc: Allocator,
value: anytype,
) Allocator.Error!@TypeOf(value) {
return switch (@typeInfo(@TypeOf(value))) {
.Void,
.Int,
.Float,
.Enum,
=> value,
.Pointer => |info| slice: {
comptime assert(info.size == .Slice);
break :slice try alloc.dupe(
info.child,
value,
);
},
.Struct => |info| if (info.is_tuple)
value
else
try value.clone(alloc),
else => {
@compileLog(@TypeOf(value));
@compileError("unexpected type");
},
};
}
/// 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);
self.hashIncremental(&hasher);
return hasher.final();
}
/// Hash the action into the given hasher.
fn hashIncremental(self: Action, hasher: anytype) void {
// 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,
),
}
},
}
}
};
// 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 = struct {
/// The key that has to be pressed for a binding to take action.
key: Trigger.Key = .{ .translated = .invalid },
/// The key modifiers that must be active for this to match.
mods: key.Mods = .{},
pub const Key = union(C.Tag) {
/// key is the translated version of a key. This is the key that
/// a logical keyboard layout at the OS level would translate the
/// physical key to. For example if you use a US hardware keyboard
/// but have a Dvorak layout, the key would be the Dvorak key.
translated: key.Key,
/// 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: key.Key,
/// This is used for binding to keys that produce a certain unicode
/// codepoint. This is useful for binding to keys that don't have a
/// registered keycode with Ghostty.
unicode: u21,
};
/// The extern struct used for triggers in the C API.
pub const C = extern struct {
tag: Tag = .translated,
key: C.Key = .{ .translated = .invalid },
mods: key.Mods = .{},
pub const Tag = enum(c_int) {
translated,
physical,
unicode,
};
pub const Key = extern union {
translated: key.Key,
physical: key.Key,
unicode: u32,
};
};
/// 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;
// If this is ASCII and we have a translated key, set that.
if (std.math.cast(u8, cp)) |ascii| {
if (key.Key.fromASCII(ascii)) |k| {
result.key = .{ .translated = k };
continue :loop;
}
}
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) {
.translated => |v| v == .invalid,
else => 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);
self.hashIncremental(&hasher);
return hasher.final();
}
/// Hash the trigger into the given hasher.
fn hashIncremental(self: Trigger, hasher: anytype) void {
std.hash.autoHash(hasher, self.key);
std.hash.autoHash(hasher, self.mods.binding());
}
/// Convert the trigger to a C API compatible trigger.
pub fn cval(self: Trigger) C {
return .{
.tag = self.key,
.key = switch (self.key) {
.translated => |v| .{ .translated = v },
.physical => |v| .{ .physical = v },
.unicode => |v| .{ .unicode = @intCast(v) },
},
.mods = self.mods,
};
}
/// 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
switch (self.key) {
.translated => |k| try writer.print("{s}", .{@tagName(k)}),
.physical => |k| try writer.print("physical:{s}", .{@tagName(k)}),
.unicode => |c| try writer.print("{u}", .{c}),
}
}
};
/// 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,
Value,
Context(Trigger),
std.hash_map.default_max_load_percentage,
);
const ReverseMap = std.HashMapUnmanaged(
Action,
Trigger,
Context(Action),
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 entry type for the forward mapping of trigger to action.
pub const Value = 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 along with the flags that may define binding behavior.
leaf: Leaf,
/// Implements the formatter for the fmt package. This encodes the
/// action back into the format used by parse.
pub fn format(
self: Value,
comptime layout: []const u8,
opts: std.fmt.FormatOptions,
writer: anytype,
) !void {
_ = layout;
_ = opts;
switch (self) {
.leader => |set| {
// the leader key was already printed.
var iter = set.bindings.iterator();
while (iter.next()) |binding| {
try writer.print(
">{s}{s}",
.{ binding.key_ptr.*, binding.value_ptr.* },
);
}
},
.leaf => |leaf| {
// action implements the format
try writer.print("={s}", .{leaf.action});
},
}
}
/// Writes the configuration entries for the binding
/// that this value is part of.
///
/// The value may be part of multiple configuration entries
/// if they're all part of the same prefix sequence (e.g. 'a>b', 'a>c').
/// These will result in multiple separate entries in the configuration.
///
/// `buffer_stream` is a FixedBufferStream used for temporary storage
/// that is shared between calls to nested levels of the set.
/// For example, 'a>b>c=x' and 'a>b>d=y' will re-use the 'a>b' written
/// to the buffer before flushing it to the formatter with 'c=x' and 'd=y'.
pub fn formatEntries(self: Value, buffer_stream: anytype, formatter: anytype) !void {
switch (self) {
.leader => |set| {
// We'll rewind to this position after each sub-entry,
// sharing the prefix between siblings.
const pos = try buffer_stream.getPos();
var iter = set.bindings.iterator();
while (iter.next()) |binding| {
buffer_stream.seekTo(pos) catch unreachable; // can't fail
std.fmt.format(buffer_stream.writer(), ">{s}", .{binding.key_ptr.*}) catch return error.OutOfMemory;
try binding.value_ptr.*.formatEntries(buffer_stream, formatter);
}
},
.leaf => |leaf| {
// When we get to the leaf, the buffer_stream contains
// the full sequence of keys needed to reach this action.
std.fmt.format(buffer_stream.writer(), "={s}", .{leaf.action}) catch return error.OutOfMemory;
try formatter.formatEntry([]const u8, buffer_stream.getWritten());
},
}
}
};
/// Leaf node of a set is an action to trigger. This is a "leaf" compared
/// to the inner nodes which are "leaders" for sequences.
pub const Leaf = struct {
action: Action,
flags: Flags,
pub fn clone(
self: Leaf,
alloc: Allocator,
) Allocator.Error!Leaf {
return .{
.action = try self.action.clone(alloc),
.flags = self.flags,
};
}
pub fn hash(self: Leaf) u64 {
var hasher = std.hash.Wyhash.init(0);
self.action.hash(&hasher);
std.hash.autoHash(&hasher, self.flags);
return hasher.final();
}
};
/// A full key-value entry for the set.
pub const Entry = HashMap.Entry;
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);
},
.leaf => {},
};
self.bindings.deinit(alloc);
self.reverse.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.
// Since we remove the value, we need to copy it.
const old: ?Value = if (set.get(t)) |entry|
entry.value_ptr.*
else
null;
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,
},
.leaf => {
// 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
.leaf => |leaf| set.putFlags(
alloc,
t,
leaf.action,
leaf.flags,
) catch {},
};
},
error.OutOfMemory => return error.OutOfMemory,
};
},
.binding => |b| switch (b.action) {
.unbind => {
set.remove(alloc, b.trigger);
return error.SequenceUnbind;
},
else => try set.putFlags(
alloc,
b.trigger,
b.action,
b.flags,
),
},
}
}
/// 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.putFlags(alloc, t, action, .{});
}
/// Add a binding to the set with explicit flags.
pub fn putFlags(
self: *Set,
alloc: Allocator,
t: Trigger,
action: Action,
flags: Flags,
) 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 (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.
.leaf => {
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;
}
}
},
};
gop.value_ptr.* = .{ .leaf = .{
.action = action,
.flags = flags,
} };
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) ?Entry {
return self.bindings.getEntry(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);
}
/// Get an entry for the given key event. This will attempt to find
/// a binding using multiple parts of the event in the following order:
///
/// 1. Translated key (event.key)
/// 2. Physical key (event.physical_key)
/// 3. Unshifted Unicode codepoint (event.unshifted_codepoint)
///
pub fn getEvent(self: *const Set, event: KeyEvent) ?Entry {
var trigger: Trigger = .{
.mods = event.mods.binding(),
.key = .{ .translated = event.key },
};
if (self.get(trigger)) |v| return v;
trigger.key = .{ .physical = event.physical_key };
if (self.get(trigger)) |v| return v;
if (event.unshifted_codepoint > 0) {
trigger.key = .{ .unicode = event.unshifted_codepoint };
if (self.get(trigger)) |v| return v;
}
return 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.
.leaf => |leaf| {
const action_hash = leaf.action.hash();
var it = self.bindings.iterator();
while (it.next()) |it_entry| {
switch (it_entry.value_ptr.*) {
.leader => {},
.leaf => |leaf_search| {
if (leaf_search.action.hash() == action_hash) {
self.reverse.putAssumeCapacity(leaf.action, it_entry.key_ptr.*);
break;
}
},
}
} else {
// No over trigger points to this action so we remove
// the reverse mapping completely.
_ = self.reverse.remove(leaf.action);
}
},
}
}
/// 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),
};
// If we have any leaders we need to clone them.
{
var it = result.bindings.iterator();
while (it.next()) |entry| switch (entry.value_ptr.*) {
// Leaves could have data to clone (i.e. text actions
// contain allocated strings).
.leaf => |*s| s.* = try s.clone(alloc),
// 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;
},
};
}
// We need to clone the action keys in the reverse map since
// they may contain allocated values.
{
var it = result.reverse.keyIterator();
while (it.next()) |action| action.* = try action.clone(alloc);
}
return result;
}
/// 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 = .{ .translated = .a } },
.action = .{ .ignore = {} },
},
try parseSingle("a=ignore"),
);
// unicode keys that map to translated
try testing.expectEqual(Binding{
.trigger = .{ .key = .{ .translated = .one } },
.action = .{ .ignore = {} },
}, try parseSingle("1=ignore"));
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .super = true },
.key = .{ .translated = .period },
},
.action = .{ .ignore = {} },
}, try parseSingle("cmd+.=ignore"));
// single modifier
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
}, try parseSingle("shift+a=ignore"));
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .ctrl = true },
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
}, try parseSingle("ctrl+a=ignore"));
// multiple modifier
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true, .ctrl = true },
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
}, try parseSingle("shift+ctrl+a=ignore"));
// key can come before modifier
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
}, try parseSingle("a+shift=ignore"));
// physical keys
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .physical = .a },
},
.action = .{ .ignore = {} },
}, try parseSingle("shift+physical:a=ignore"));
// unicode keys
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .unicode = 'ö' },
},
.action = .{ .ignore = {} },
}, try parseSingle("shift+ö=ignore"));
// unconsumed keys
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
.flags = .{ .consumed = false },
}, try parseSingle("unconsumed:shift+a=ignore"));
// unconsumed physical keys
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .physical = .a },
},
.action = .{ .ignore = {} },
.flags = .{ .consumed = false },
}, try parseSingle("unconsumed:physical:a+shift=ignore"));
// performable keys
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
.flags = .{ .performable = true },
}, try parseSingle("performable:shift+a=ignore"));
// invalid key
try testing.expectError(Error.InvalidFormat, parseSingle("foo=ignore"));
// repeated control
try testing.expectError(Error.InvalidFormat, parseSingle("shift+shift+a=ignore"));
// multiple character
try testing.expectError(Error.InvalidFormat, parseSingle("a+b=ignore"));
}
test "parse: global triggers" {
const testing = std.testing;
// global keys
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
.flags = .{ .global = true },
}, try parseSingle("global:shift+a=ignore"));
// global physical keys
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .physical = .a },
},
.action = .{ .ignore = {} },
.flags = .{ .global = true },
}, try parseSingle("global:physical:a+shift=ignore"));
// global unconsumed keys
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
.flags = .{
.global = true,
.consumed = false,
},
}, try parseSingle("unconsumed:global:a+shift=ignore"));
// global sequences not allowed
{
var p = try Parser.init("global:a>b=ignore");
try testing.expectError(Error.InvalidFormat, p.next());
}
}
test "parse: all triggers" {
const testing = std.testing;
// all keys
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
.flags = .{ .all = true },
}, try parseSingle("all:shift+a=ignore"));
// all physical keys
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .physical = .a },
},
.action = .{ .ignore = {} },
.flags = .{ .all = true },
}, try parseSingle("all:physical:a+shift=ignore"));
// all unconsumed keys
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
.flags = .{
.all = true,
.consumed = false,
},
}, try parseSingle("unconsumed:all:a+shift=ignore"));
// all sequences not allowed
{
var p = try Parser.init("all:a>b=ignore");
try testing.expectError(Error.InvalidFormat, p.next());
}
}
test "parse: modifier aliases" {
const testing = std.testing;
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .super = true },
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
}, try parseSingle("cmd+a=ignore"));
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .super = true },
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
}, try parseSingle("command+a=ignore"));
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .alt = true },
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
}, try parseSingle("opt+a=ignore"));
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .alt = true },
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
}, try parseSingle("option+a=ignore"));
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .ctrl = true },
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
}, try parseSingle("control+a=ignore"));
}
test "parse: action invalid" {
const testing = std.testing;
// invalid action
try testing.expectError(Error.InvalidAction, parseSingle("a=nopenopenope"));
}
test "parse: action no parameters" {
const testing = std.testing;
// no parameters
try testing.expectEqual(
Binding{
.trigger = .{ .key = .{ .translated = .a } },
.action = .{ .ignore = {} },
},
try parseSingle("a=ignore"),
);
try testing.expectError(Error.InvalidFormat, parseSingle("a=ignore:A"));
}
test "parse: action with string" {
const testing = std.testing;
// parameter
{
const binding = try parseSingle("a=csi:A");
try testing.expect(binding.action == .csi);
try testing.expectEqualStrings("A", binding.action.csi);
}
// parameter
{
const binding = try parseSingle("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 parseSingle("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 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 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);
}
}
test "parse: action with float" {
const testing = std.testing;
// parameter
{
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 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);
}
}
test "parse: action with a tuple" {
const testing = std.testing;
// parameter
{
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, parseSingle("a=resize_split:up"));
// too many
try testing.expectError(Error.InvalidFormat, parseSingle("a=resize_split:up,10,12"));
// invalid type
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 } }).?.value_ptr.*.leaf;
try testing.expect(action.action == .new_window);
try testing.expectEqual(Flags{}, action.flags);
}
// 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).?.value_ptr.*.leaf;
try testing.expect(action.action == .new_window);
try testing.expectEqual(Flags{ .consumed = false }, action.flags);
}
// 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).?.value_ptr.*;
try testing.expect(e == .leader);
current = e.leader;
}
{
const t: Trigger = .{ .key = .{ .translated = .b } };
const e = current.get(t).?.value_ptr.*;
try testing.expect(e == .leaf);
try testing.expect(e.leaf.action == .new_window);
try testing.expectEqual(Flags{}, e.leaf.flags);
}
}
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).?.value_ptr.*;
try testing.expect(e == .leader);
current = e.leader;
}
{
const t: Trigger = .{ .key = .{ .translated = .b } };
const e = current.get(t).?.value_ptr.*;
try testing.expect(e == .leaf);
try testing.expect(e.leaf.action == .new_window);
try testing.expectEqual(Flags{}, e.leaf.flags);
}
{
const t: Trigger = .{ .key = .{ .translated = .c } };
const e = current.get(t).?.value_ptr.*;
try testing.expect(e == .leaf);
try testing.expect(e.leaf.action == .new_tab);
try testing.expectEqual(Flags{}, e.leaf.flags);
}
}
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).?.value_ptr.*;
try testing.expect(e == .leader);
current = e.leader;
}
{
const t: Trigger = .{ .key = .{ .translated = .b } };
const e = current.get(t).?.value_ptr.*;
try testing.expect(e == .leaf);
try testing.expect(e.leaf.action == .new_window);
try testing.expectEqual(Flags{}, e.leaf.flags);
}
}
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).?.value_ptr.*;
try testing.expect(e == .leader);
current = e.leader;
}
{
const t: Trigger = .{ .key = .{ .translated = .b } };
const e = current.get(t).?.value_ptr.*;
try testing.expect(e == .leaf);
try testing.expect(e.leaf.action == .new_window);
try testing.expectEqual(Flags{}, e.leaf.flags);
}
}
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" {
const testing = std.testing;
const alloc = testing.allocator;
var s: Set = .{};
defer s.deinit(alloc);
try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} });
{
const trigger = s.getTrigger(.{ .new_window = {} }).?;
try testing.expect(trigger.key.translated == .a);
}
// should be most recent
try s.put(alloc, .{ .key = .{ .translated = .b } }, .{ .new_window = {} });
{
const trigger = s.getTrigger(.{ .new_window = {} }).?;
try testing.expect(trigger.key.translated == .b);
}
// removal should replace
s.remove(alloc, .{ .key = .{ .translated = .b } });
{
const trigger = s.getTrigger(.{ .new_window = {} }).?;
try testing.expect(trigger.key.translated == .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 = .{ .translated = .a } }, .{ .new_window = {} });
{
const trigger = s.getTrigger(.{ .new_window = {} }).?;
try testing.expect(trigger.key.translated == .a);
}
// should be most recent
try s.put(alloc, .{ .key = .{ .translated = .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 = .{ .translated = .a } }, .{ .new_window = {} });
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.* == .leaf);
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.*.leaf.flags.consumed);
try s.putFlags(
alloc,
.{ .key = .{ .translated = .a } },
.{ .new_window = {} },
.{ .consumed = false },
);
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.* == .leaf);
try testing.expect(!s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.*.leaf.flags.consumed);
try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} });
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.* == .leaf);
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.*.leaf.flags.consumed);
}
test "Action: clone" {
const testing = std.testing;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
{
var a: Action = .ignore;
const b = try a.clone(alloc);
try testing.expect(b == .ignore);
}
{
var a: Action = .{ .text = "foo" };
const b = try a.clone(alloc);
try testing.expect(b == .text);
}
}