mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-16 16:56:09 +03:00
Keybindings, support arrow keys, home/end, page up/down
Introduces a more robust keybinding system. Previously, keybinds were hardcoded into literal `if key = this and modifier = that`. Now, we have a system for defining keybindings in a human friendly way i.e. `super+c=copy_from_clipboard` and a system internally for looking up and executing keybind actions. There's still a lot more keybinds we can add support for but now that this system is in place it should be easy to do as we get there. Namely, we need to fully implement this: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-PC-Style-Function-Keys You can now set custom keybinds by specifying the `--keybind` flag to the program. Example: `ghostty --keybind=super+c=copy_from_clipboard`. You can repeat this flag to specify more keybinds. The full set of actions available is in `src/input/Binding.zig` under `Action`.
This commit is contained in:
2
TODO.md
2
TODO.md
@ -27,8 +27,6 @@ Improvements:
|
|||||||
* double-click to select a word
|
* double-click to select a word
|
||||||
* triple-click to select a line
|
* triple-click to select a line
|
||||||
* shift-click and drag to continue selection
|
* shift-click and drag to continue selection
|
||||||
* arrow keys do nothing, should send proper codes
|
|
||||||
* home/end should scroll to top/bottom of scrollback
|
|
||||||
|
|
||||||
Major Features:
|
Major Features:
|
||||||
|
|
||||||
|
225
src/Window.zig
225
src/Window.zig
@ -20,6 +20,7 @@ const trace = @import("tracy").trace;
|
|||||||
const max_timer = @import("max_timer.zig");
|
const max_timer = @import("max_timer.zig");
|
||||||
const terminal = @import("terminal/main.zig");
|
const terminal = @import("terminal/main.zig");
|
||||||
const Config = @import("config.zig").Config;
|
const Config = @import("config.zig").Config;
|
||||||
|
const input = @import("input.zig");
|
||||||
|
|
||||||
const RenderTimer = max_timer.MaxTimer(renderTimerCallback);
|
const RenderTimer = max_timer.MaxTimer(renderTimerCallback);
|
||||||
|
|
||||||
@ -486,104 +487,162 @@ fn keyCallback(
|
|||||||
const tracy = trace(@src());
|
const tracy = trace(@src());
|
||||||
defer tracy.end();
|
defer tracy.end();
|
||||||
|
|
||||||
|
const win = window.getUserPointer(Window) orelse return;
|
||||||
|
|
||||||
|
// Reset the ignore char setting. If we didn't handle the char
|
||||||
|
// by here, we aren't going to get it so we just reset this.
|
||||||
|
win.ignore_char = false;
|
||||||
|
|
||||||
|
//log.info("KEY {} {} {} {}", .{ key, scancode, mods, action });
|
||||||
_ = scancode;
|
_ = scancode;
|
||||||
|
|
||||||
if (action == .press and mods.super) {
|
if (action == .press or action == .repeat) {
|
||||||
switch (key) {
|
// Convert our glfw input into a platform agnostic trigger. When we
|
||||||
// Copy
|
// extract the platform out of this file, we'll pull a lot of this out
|
||||||
.c => {
|
// into a function. For now, this is the only place we do it so we just
|
||||||
const win = window.getUserPointer(Window) orelse return;
|
// put it right here.
|
||||||
|
const trigger: input.Binding.Trigger = .{
|
||||||
// Ignore this character for writing
|
.mods = @bitCast(input.Mods, mods),
|
||||||
win.ignore_char = true;
|
.key = switch (key) {
|
||||||
|
.a => .a,
|
||||||
// If we have a selection, copy it.
|
.b => .b,
|
||||||
if (win.terminal.selection) |sel| {
|
.c => .c,
|
||||||
var buf = win.terminal.screen.selectionString(win.alloc, sel) catch |err| {
|
.d => .d,
|
||||||
log.err("error reading selection string err={}", .{err});
|
.e => .e,
|
||||||
return;
|
.f => .f,
|
||||||
};
|
.g => .g,
|
||||||
defer win.alloc.free(buf);
|
.h => .h,
|
||||||
|
.i => .i,
|
||||||
glfw.setClipboardString(buf) catch |err| {
|
.j => .j,
|
||||||
log.err("error setting clipboard string err={}", .{err});
|
.k => .k,
|
||||||
return;
|
.l => .l,
|
||||||
};
|
.m => .m,
|
||||||
}
|
.n => .n,
|
||||||
|
.o => .o,
|
||||||
return;
|
.p => .p,
|
||||||
|
.q => .q,
|
||||||
|
.r => .r,
|
||||||
|
.s => .s,
|
||||||
|
.t => .t,
|
||||||
|
.u => .u,
|
||||||
|
.v => .v,
|
||||||
|
.w => .w,
|
||||||
|
.x => .x,
|
||||||
|
.y => .y,
|
||||||
|
.z => .z,
|
||||||
|
.up => .up,
|
||||||
|
.down => .down,
|
||||||
|
.right => .right,
|
||||||
|
.left => .left,
|
||||||
|
.home => .home,
|
||||||
|
.end => .end,
|
||||||
|
.page_up => .page_up,
|
||||||
|
.page_down => .page_down,
|
||||||
|
else => .invalid,
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Paste
|
if (win.config.keybind.set.get(trigger)) |binding_action| {
|
||||||
.v => {
|
//log.warn("BINDING ACTION={}", .{binding_action});
|
||||||
const win = window.getUserPointer(Window) orelse return;
|
|
||||||
|
|
||||||
// Ignore this character for writing
|
switch (binding_action) {
|
||||||
win.ignore_char = true;
|
.unbind => unreachable,
|
||||||
|
.ignore => {},
|
||||||
|
|
||||||
const data = glfw.getClipboardString() catch |err| {
|
.csi => |data| {
|
||||||
log.warn("error reading clipboard: {}", .{err});
|
win.queueWrite("\x1B[") catch |err|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (data.len > 0) {
|
|
||||||
if (win.bracketed_paste) win.queueWrite("\x1B[200~") catch |err|
|
|
||||||
log.err("error queueing write in keyCallback err={}", .{err});
|
log.err("error queueing write in keyCallback err={}", .{err});
|
||||||
win.queueWrite(data) catch |err|
|
win.queueWrite(data) catch |err|
|
||||||
log.warn("error pasting clipboard: {}", .{err});
|
log.warn("error pasting clipboard: {}", .{err});
|
||||||
if (win.bracketed_paste) win.queueWrite("\x1B[201~") catch |err|
|
},
|
||||||
log.err("error queueing write in keyCallback err={}", .{err});
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
.copy_to_clipboard => {
|
||||||
},
|
if (win.terminal.selection) |sel| {
|
||||||
|
var buf = win.terminal.screen.selectionString(win.alloc, sel) catch |err| {
|
||||||
|
log.err("error reading selection string err={}", .{err});
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
defer win.alloc.free(buf);
|
||||||
|
|
||||||
else => {},
|
glfw.setClipboardString(buf) catch |err| {
|
||||||
|
log.err("error setting clipboard string err={}", .{err});
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
.paste_from_clipboard => {
|
||||||
|
const data = glfw.getClipboardString() catch |err| {
|
||||||
|
log.warn("error reading clipboard: {}", .{err});
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.len > 0) {
|
||||||
|
if (win.bracketed_paste) win.queueWrite("\x1B[200~") catch |err|
|
||||||
|
log.err("error queueing write in keyCallback err={}", .{err});
|
||||||
|
win.queueWrite(data) catch |err|
|
||||||
|
log.warn("error pasting clipboard: {}", .{err});
|
||||||
|
if (win.bracketed_paste) win.queueWrite("\x1B[201~") catch |err|
|
||||||
|
log.err("error queueing write in keyCallback err={}", .{err});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bindings always result in us ignoring the char if printable
|
||||||
|
win.ignore_char = true;
|
||||||
|
|
||||||
|
// No matter what, if there is a binding then we are done.
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
//log.info("KEY {} {} {} {}", .{ key, scancode, mods, action });
|
// Handle non-printables
|
||||||
if (action == .press or action == .repeat) {
|
const char: u8 = switch (@bitCast(u8, mods)) {
|
||||||
const c: u8 = switch (key) {
|
// No modifiers pressed at all
|
||||||
// Lots more of these:
|
0 => @as(u8, switch (key) {
|
||||||
// https://www.physics.udel.edu/~watson/scen103/ascii.html
|
.backspace => 0x7F,
|
||||||
.a => if (mods.control and !mods.shift) 0x01 else return,
|
.enter => '\r',
|
||||||
.b => if (mods.control and !mods.shift) 0x02 else return,
|
.tab => '\t',
|
||||||
.c => if (mods.control and !mods.shift) 0x03 else return,
|
.escape => 0x1B,
|
||||||
.d => if (mods.control and !mods.shift) 0x04 else return,
|
else => 0,
|
||||||
.e => if (mods.control and !mods.shift) 0x05 else return,
|
}),
|
||||||
.f => if (mods.control and !mods.shift) 0x06 else return,
|
|
||||||
.g => if (mods.control and !mods.shift) 0x07 else return,
|
|
||||||
.h => if (mods.control and !mods.shift) 0x08 else return,
|
|
||||||
.i => if (mods.control and !mods.shift) 0x09 else return,
|
|
||||||
.j => if (mods.control and !mods.shift) 0x0A else return,
|
|
||||||
.k => if (mods.control and !mods.shift) 0x0B else return,
|
|
||||||
.l => if (mods.control and !mods.shift) 0x0C else return,
|
|
||||||
.m => if (mods.control and !mods.shift) 0x0D else return,
|
|
||||||
.n => if (mods.control and !mods.shift) 0x0E else return,
|
|
||||||
.o => if (mods.control and !mods.shift) 0x0F else return,
|
|
||||||
.p => if (mods.control and !mods.shift) 0x10 else return,
|
|
||||||
.q => if (mods.control and !mods.shift) 0x11 else return,
|
|
||||||
.r => if (mods.control and !mods.shift) 0x12 else return,
|
|
||||||
.s => if (mods.control and !mods.shift) 0x13 else return,
|
|
||||||
.t => if (mods.control and !mods.shift) 0x14 else return,
|
|
||||||
.u => if (mods.control and !mods.shift) 0x15 else return,
|
|
||||||
.v => if (mods.control and !mods.shift) 0x16 else return,
|
|
||||||
.w => if (mods.control and !mods.shift) 0x17 else return,
|
|
||||||
.x => if (mods.control and !mods.shift) 0x18 else return,
|
|
||||||
.y => if (mods.control and !mods.shift) 0x19 else return,
|
|
||||||
.z => if (mods.control and !mods.shift) 0x1A else return,
|
|
||||||
|
|
||||||
.backspace => 0x08,
|
// Control only
|
||||||
.enter => '\r',
|
@bitCast(u8, glfw.Mods{ .control = true }) => @as(u8, switch (key) {
|
||||||
.tab => '\t',
|
.a => 0x01,
|
||||||
.escape => 0x1B,
|
.b => 0x02,
|
||||||
else => return,
|
.c => 0x03,
|
||||||
|
.d => 0x04,
|
||||||
|
.e => 0x05,
|
||||||
|
.f => 0x06,
|
||||||
|
.g => 0x07,
|
||||||
|
.h => 0x08,
|
||||||
|
.i => 0x09,
|
||||||
|
.j => 0x0A,
|
||||||
|
.k => 0x0B,
|
||||||
|
.l => 0x0C,
|
||||||
|
.m => 0x0D,
|
||||||
|
.n => 0x0E,
|
||||||
|
.o => 0x0F,
|
||||||
|
.p => 0x10,
|
||||||
|
.q => 0x11,
|
||||||
|
.r => 0x12,
|
||||||
|
.s => 0x13,
|
||||||
|
.t => 0x14,
|
||||||
|
.u => 0x15,
|
||||||
|
.v => 0x16,
|
||||||
|
.w => 0x17,
|
||||||
|
.x => 0x18,
|
||||||
|
.y => 0x19,
|
||||||
|
.z => 0x1A,
|
||||||
|
else => 0,
|
||||||
|
}),
|
||||||
|
|
||||||
|
else => 0,
|
||||||
};
|
};
|
||||||
|
if (char > 0) {
|
||||||
const win = window.getUserPointer(Window) orelse return;
|
win.queueWrite(&[1]u8{char}) catch |err|
|
||||||
win.queueWrite(&[1]u8{c}) catch |err|
|
log.err("error queueing write in keyCallback err={}", .{err});
|
||||||
log.err("error queueing write in keyCallback err={}", .{err});
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
112
src/config.zig
112
src/config.zig
@ -1,6 +1,7 @@
|
|||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||||
|
const inputpkg = @import("input.zig");
|
||||||
|
|
||||||
/// Config is the main config struct. These fields map directly to the
|
/// Config is the main config struct. These fields map directly to the
|
||||||
/// CLI flag names hence we use a lot of `@""` syntax to support hyphens.
|
/// CLI flag names hence we use a lot of `@""` syntax to support hyphens.
|
||||||
@ -19,7 +20,38 @@ pub const Config = struct {
|
|||||||
|
|
||||||
/// The command to run, usually a shell. If this is not an absolute path,
|
/// The command to run, usually a shell. If this is not an absolute path,
|
||||||
/// it'll be looked up in the PATH.
|
/// it'll be looked up in the PATH.
|
||||||
command: ?[]const u8 = null,
|
command: ?[]const u8,
|
||||||
|
|
||||||
|
/// Key bindings. The format is "trigger=action". Duplicate triggers
|
||||||
|
/// will overwrite previously set values.
|
||||||
|
///
|
||||||
|
/// Trigger: "+"-separated list of keys and modifiers. Example:
|
||||||
|
/// "ctrl+a", "ctrl+shift+b", "up". Some notes:
|
||||||
|
///
|
||||||
|
/// - modifiers cannot repeat, "ctrl+ctrl+a" is invalid.
|
||||||
|
/// - modifers and key scan be in any order, "shift+a+ctrl" is weird,
|
||||||
|
/// but valid.
|
||||||
|
/// - only a single key input is allowed, "ctrl+a+b" is invalid.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// - "ignore" - Do nothing, ignore the key input. This can be used to
|
||||||
|
/// black hole certain inputs to have no effect.
|
||||||
|
/// - "unbind" - Remove the binding. This makes it so the previous action
|
||||||
|
/// is removed, and the key will be sent through to the child command
|
||||||
|
/// if it is printable.
|
||||||
|
/// - "csi:text" - Send a CSI sequence. i.e. "csi:A" sends "cursor up".
|
||||||
|
///
|
||||||
|
/// Some notes for the action:
|
||||||
|
///
|
||||||
|
/// - The parameter is taken as-is after the ":". Double quotes or
|
||||||
|
/// other mechanisms are included and NOT parsed. If you want to
|
||||||
|
/// send a string value that includes spaces, wrap the entire
|
||||||
|
/// trigger/action in double quotes. Example: --keybind="up=csi:A B"
|
||||||
|
///
|
||||||
|
keybind: Keybinds = .{},
|
||||||
|
|
||||||
/// Additional configuration files to read.
|
/// Additional configuration files to read.
|
||||||
@"config-file": RepeatableString = .{},
|
@"config-file": RepeatableString = .{},
|
||||||
@ -31,6 +63,42 @@ pub const Config = struct {
|
|||||||
if (self._arena) |arena| arena.deinit();
|
if (self._arena) |arena| arena.deinit();
|
||||||
self.* = undefined;
|
self.* = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
|
||||||
|
var arena = ArenaAllocator.init(alloc_gpa);
|
||||||
|
errdefer arena.deinit();
|
||||||
|
const alloc = arena.allocator();
|
||||||
|
|
||||||
|
// Build up our basic config
|
||||||
|
var result: Config = .{
|
||||||
|
._arena = arena,
|
||||||
|
.command = "sh",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add our default keybindings
|
||||||
|
try result.keybind.set.put(
|
||||||
|
alloc,
|
||||||
|
.{ .key = .c, .mods = .{ .super = true } },
|
||||||
|
.{ .copy_to_clipboard = 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
try result.keybind.set.put(
|
||||||
|
alloc,
|
||||||
|
.{ .key = .v, .mods = .{ .super = true } },
|
||||||
|
.{ .paste_from_clipboard = 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
try result.keybind.set.put(alloc, .{ .key = .up }, .{ .csi = "A" });
|
||||||
|
try result.keybind.set.put(alloc, .{ .key = .down }, .{ .csi = "B" });
|
||||||
|
try result.keybind.set.put(alloc, .{ .key = .right }, .{ .csi = "C" });
|
||||||
|
try result.keybind.set.put(alloc, .{ .key = .left }, .{ .csi = "D" });
|
||||||
|
try result.keybind.set.put(alloc, .{ .key = .home }, .{ .csi = "H" });
|
||||||
|
try result.keybind.set.put(alloc, .{ .key = .end }, .{ .csi = "F" });
|
||||||
|
try result.keybind.set.put(alloc, .{ .key = .page_up }, .{ .csi = "5~" });
|
||||||
|
try result.keybind.set.put(alloc, .{ .key = .page_down }, .{ .csi = "6~" });
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Color represents a color using RGB.
|
/// Color represents a color using RGB.
|
||||||
@ -114,6 +182,48 @@ pub const RepeatableString = struct {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Stores a set of keybinds.
|
||||||
|
pub const Keybinds = struct {
|
||||||
|
set: inputpkg.Binding.Set = .{},
|
||||||
|
|
||||||
|
pub fn parseCLI(self: *Keybinds, alloc: Allocator, input: ?[]const u8) !void {
|
||||||
|
var copy: ?[]u8 = null;
|
||||||
|
var value = value: {
|
||||||
|
const value = input orelse return error.ValueRequired;
|
||||||
|
|
||||||
|
// If we don't have a colon, use the value as-is, no copy
|
||||||
|
if (std.mem.indexOf(u8, value, ":") == null)
|
||||||
|
break :value value;
|
||||||
|
|
||||||
|
// If we have a colon, we copy the whole value for now. We could
|
||||||
|
// do this more efficiently later if we wanted to.
|
||||||
|
const buf = try alloc.alloc(u8, value.len);
|
||||||
|
copy = buf;
|
||||||
|
|
||||||
|
std.mem.copy(u8, buf, value);
|
||||||
|
break :value buf;
|
||||||
|
};
|
||||||
|
errdefer if (copy) |v| alloc.free(v);
|
||||||
|
|
||||||
|
const binding = try inputpkg.Binding.parse(value);
|
||||||
|
switch (binding.action) {
|
||||||
|
.unbind => self.set.remove(binding.trigger),
|
||||||
|
else => try self.set.put(alloc, binding.trigger, binding.action),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCLI" {
|
||||||
|
const testing = std.testing;
|
||||||
|
var arena = ArenaAllocator.init(testing.allocator);
|
||||||
|
defer arena.deinit();
|
||||||
|
const alloc = arena.allocator();
|
||||||
|
|
||||||
|
var set: Keybinds = .{};
|
||||||
|
try set.parseCLI(alloc, "shift+a=copy_to_clipboard");
|
||||||
|
try set.parseCLI(alloc, "shift+a=csi:hello");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
test {
|
test {
|
||||||
std.testing.refAllDecls(@This());
|
std.testing.refAllDecls(@This());
|
||||||
}
|
}
|
||||||
|
8
src/input.zig
Normal file
8
src/input.zig
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
pub usingnamespace @import("input/key.zig");
|
||||||
|
pub const Binding = @import("input/Binding.zig");
|
||||||
|
|
||||||
|
test {
|
||||||
|
std.testing.refAllDecls(@This());
|
||||||
|
}
|
266
src/input/Binding.zig
Normal file
266
src/input/Binding.zig
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
//! 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,
|
||||||
|
|
||||||
|
pub const Error = error{
|
||||||
|
InvalidFormat,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 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(input: []const u8) !Binding {
|
||||||
|
// NOTE(mitchellh): This is not the most efficient way to do any
|
||||||
|
// of this, I welcome any improvements here!
|
||||||
|
|
||||||
|
// 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.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, 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: Action = 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 actionRaw = input[eqlIdx + 1 ..];
|
||||||
|
const colonIdx = std.mem.indexOf(u8, actionRaw, ":");
|
||||||
|
const action = actionRaw[0..(colonIdx orelse actionRaw.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.field_type) {
|
||||||
|
void => {
|
||||||
|
if (colonIdx != null) return Error.InvalidFormat;
|
||||||
|
break :action @unionInit(Action, field.name, {});
|
||||||
|
},
|
||||||
|
|
||||||
|
// see note about what Void is
|
||||||
|
Action.Void => {
|
||||||
|
if (colonIdx != null) return Error.InvalidFormat;
|
||||||
|
break :action @unionInit(Action, field.name, 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
[]const u8 => {
|
||||||
|
const idx = colonIdx orelse return Error.InvalidFormat;
|
||||||
|
const param = actionRaw[idx + 1 ..];
|
||||||
|
break :action @unionInit(Action, field.name, param);
|
||||||
|
},
|
||||||
|
|
||||||
|
else => unreachable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Error.InvalidFormat;
|
||||||
|
};
|
||||||
|
|
||||||
|
return Binding{ .trigger = trigger, .action = action };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The set of actions that a keybinding can take.
|
||||||
|
pub const Action = union(enum) {
|
||||||
|
// stage1 compiler bug where if this is "void" then it crashes the
|
||||||
|
// compiler. TODO: check this out when we upgrade to stage2.
|
||||||
|
const Void = u0;
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
|
||||||
|
/// Copy and paste.
|
||||||
|
copy_to_clipboard: Void,
|
||||||
|
paste_from_clipboard: Void,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Trigger is the associated key state that can trigger an action.
|
||||||
|
pub const Trigger = 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 = .{},
|
||||||
|
|
||||||
|
/// Returns a hash code that can be used to uniquely identify this trigger.
|
||||||
|
pub fn hash(self: Binding) u64 {
|
||||||
|
var hasher = std.hash.Wyhash.init(0);
|
||||||
|
std.hash.autoHash(&hasher, self.key);
|
||||||
|
std.hash.autoHash(&hasher, self.mods);
|
||||||
|
return hasher.final();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 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.AutoHashMapUnmanaged(Trigger, Action);
|
||||||
|
|
||||||
|
/// The set of bindings.
|
||||||
|
bindings: HashMap = .{},
|
||||||
|
|
||||||
|
/// 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) !void {
|
||||||
|
// unbind should never go into the set, it should be handled prior
|
||||||
|
assert(action != .unbind);
|
||||||
|
|
||||||
|
try self.bindings.put(alloc, t, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a binding for a given trigger.
|
||||||
|
pub fn get(self: Set, t: Trigger) ?Action {
|
||||||
|
return self.bindings.get(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a binding for a given trigger.
|
||||||
|
pub fn remove(self: *Set, t: Trigger) void {
|
||||||
|
_ = self.bindings.remove(t);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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"));
|
||||||
|
|
||||||
|
// 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" {
|
||||||
|
const testing = std.testing;
|
||||||
|
|
||||||
|
// invalid action
|
||||||
|
try testing.expectError(Error.InvalidFormat, parse("a=nopenopenope"));
|
||||||
|
|
||||||
|
// no parameters
|
||||||
|
try testing.expectEqual(
|
||||||
|
Binding{ .trigger = .{ .key = .a }, .action = .{ .ignore = {} } },
|
||||||
|
try parse("a=ignore"),
|
||||||
|
);
|
||||||
|
try testing.expectError(Error.InvalidFormat, parse("a=ignore:A"));
|
||||||
|
|
||||||
|
// parameter
|
||||||
|
{
|
||||||
|
const binding = try parse("a=csi:A");
|
||||||
|
try testing.expect(binding.action == .csi);
|
||||||
|
try testing.expectEqualStrings("A", binding.action.csi);
|
||||||
|
}
|
||||||
|
}
|
64
src/input/key.zig
Normal file
64
src/input/key.zig
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
const std = @import("std");
|
||||||
|
const Allocator = std.mem.Allocator;
|
||||||
|
|
||||||
|
/// A bitmask for all key modifiers. This is taken directly from the
|
||||||
|
/// GLFW representation, but we use this generically.
|
||||||
|
pub const Mods = packed struct {
|
||||||
|
shift: bool = false,
|
||||||
|
ctrl: bool = false,
|
||||||
|
alt: bool = false,
|
||||||
|
super: bool = false,
|
||||||
|
caps_lock: bool = false,
|
||||||
|
num_lock: bool = false,
|
||||||
|
_padding: u2 = 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The set of keys that can map to keybindings. These have no fixed enum
|
||||||
|
/// values because we map platform-specific keys to this set. Note that
|
||||||
|
/// this only needs to accomodate what maps to a key. If a key is not bound
|
||||||
|
/// to anything and the key can be mapped to a printable character, then that
|
||||||
|
/// unicode character is sent directly to the pty.
|
||||||
|
pub const Key = enum {
|
||||||
|
invalid,
|
||||||
|
|
||||||
|
// a-z
|
||||||
|
a,
|
||||||
|
b,
|
||||||
|
c,
|
||||||
|
d,
|
||||||
|
e,
|
||||||
|
f,
|
||||||
|
g,
|
||||||
|
h,
|
||||||
|
i,
|
||||||
|
j,
|
||||||
|
k,
|
||||||
|
l,
|
||||||
|
m,
|
||||||
|
n,
|
||||||
|
o,
|
||||||
|
p,
|
||||||
|
q,
|
||||||
|
r,
|
||||||
|
s,
|
||||||
|
t,
|
||||||
|
u,
|
||||||
|
v,
|
||||||
|
w,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
z,
|
||||||
|
|
||||||
|
// control
|
||||||
|
up,
|
||||||
|
down,
|
||||||
|
right,
|
||||||
|
left,
|
||||||
|
home,
|
||||||
|
end,
|
||||||
|
page_up,
|
||||||
|
page_down,
|
||||||
|
|
||||||
|
// To support more keys (there are obviously more!) add them here
|
||||||
|
// and ensure the mapping is up to date in the Window key handler.
|
||||||
|
};
|
@ -39,7 +39,8 @@ pub fn main() !void {
|
|||||||
|
|
||||||
// Parse the config from the CLI args
|
// Parse the config from the CLI args
|
||||||
var config = config: {
|
var config = config: {
|
||||||
var result: Config = .{};
|
var result = try Config.default(alloc);
|
||||||
|
errdefer result.deinit();
|
||||||
var iter = try std.process.argsWithAllocator(alloc);
|
var iter = try std.process.argsWithAllocator(alloc);
|
||||||
defer iter.deinit();
|
defer iter.deinit();
|
||||||
try cli_args.parse(Config, alloc, &result, &iter);
|
try cli_args.parse(Config, alloc, &result, &iter);
|
||||||
@ -101,6 +102,7 @@ test {
|
|||||||
_ = @import("TempDir.zig");
|
_ = @import("TempDir.zig");
|
||||||
_ = @import("font/main.zig");
|
_ = @import("font/main.zig");
|
||||||
_ = @import("terminal/Terminal.zig");
|
_ = @import("terminal/Terminal.zig");
|
||||||
|
_ = @import("input.zig");
|
||||||
|
|
||||||
// Libraries
|
// Libraries
|
||||||
_ = @import("libuv");
|
_ = @import("libuv");
|
||||||
|
Reference in New Issue
Block a user