ghostty/src/input/Binding.zig
2023-11-06 09:19:59 -08:00

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