diff --git a/TODO.md b/TODO.md index 1b0d10762..87902f83d 100644 --- a/TODO.md +++ b/TODO.md @@ -27,8 +27,6 @@ Improvements: * double-click to select a word * triple-click to select a line * 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: diff --git a/src/Window.zig b/src/Window.zig index 71983b158..5eb806e20 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -20,6 +20,7 @@ const trace = @import("tracy").trace; const max_timer = @import("max_timer.zig"); const terminal = @import("terminal/main.zig"); const Config = @import("config.zig").Config; +const input = @import("input.zig"); const RenderTimer = max_timer.MaxTimer(renderTimerCallback); @@ -486,104 +487,162 @@ fn keyCallback( const tracy = trace(@src()); 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; - if (action == .press and mods.super) { - switch (key) { - // Copy - .c => { - const win = window.getUserPointer(Window) orelse return; - - // Ignore this character for writing - win.ignore_char = true; - - // If we have a selection, copy it. - 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); - - glfw.setClipboardString(buf) catch |err| { - log.err("error setting clipboard string err={}", .{err}); - return; - }; - } - - return; + if (action == .press or action == .repeat) { + // Convert our glfw input into a platform agnostic trigger. When we + // extract the platform out of this file, we'll pull a lot of this out + // into a function. For now, this is the only place we do it so we just + // put it right here. + const trigger: input.Binding.Trigger = .{ + .mods = @bitCast(input.Mods, mods), + .key = switch (key) { + .a => .a, + .b => .b, + .c => .c, + .d => .d, + .e => .e, + .f => .f, + .g => .g, + .h => .h, + .i => .i, + .j => .j, + .k => .k, + .l => .l, + .m => .m, + .n => .n, + .o => .o, + .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 - .v => { - const win = window.getUserPointer(Window) orelse return; + if (win.config.keybind.set.get(trigger)) |binding_action| { + //log.warn("BINDING ACTION={}", .{binding_action}); - // Ignore this character for writing - win.ignore_char = true; + switch (binding_action) { + .unbind => unreachable, + .ignore => {}, - 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| + .csi => |data| { + win.queueWrite("\x1B[") 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}); - } + }, - 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 }); - if (action == .press or action == .repeat) { - const c: u8 = switch (key) { - // Lots more of these: - // https://www.physics.udel.edu/~watson/scen103/ascii.html - .a => if (mods.control and !mods.shift) 0x01 else return, - .b => if (mods.control and !mods.shift) 0x02 else return, - .c => if (mods.control and !mods.shift) 0x03 else return, - .d => if (mods.control and !mods.shift) 0x04 else return, - .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, + // Handle non-printables + const char: u8 = switch (@bitCast(u8, mods)) { + // No modifiers pressed at all + 0 => @as(u8, switch (key) { + .backspace => 0x7F, + .enter => '\r', + .tab => '\t', + .escape => 0x1B, + else => 0, + }), - .backspace => 0x08, - .enter => '\r', - .tab => '\t', - .escape => 0x1B, - else => return, + // Control only + @bitCast(u8, glfw.Mods{ .control = true }) => @as(u8, switch (key) { + .a => 0x01, + .b => 0x02, + .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, }; - - const win = window.getUserPointer(Window) orelse return; - win.queueWrite(&[1]u8{c}) catch |err| - log.err("error queueing write in keyCallback err={}", .{err}); + if (char > 0) { + win.queueWrite(&[1]u8{char}) catch |err| + log.err("error queueing write in keyCallback err={}", .{err}); + } } } diff --git a/src/config.zig b/src/config.zig index 0b68542e4..c8a622ce7 100644 --- a/src/config.zig +++ b/src/config.zig @@ -1,6 +1,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; +const inputpkg = @import("input.zig"); /// 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. @@ -19,7 +20,38 @@ pub const Config = struct { /// The command to run, usually a shell. If this is not an absolute 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. @"config-file": RepeatableString = .{}, @@ -31,6 +63,42 @@ pub const Config = struct { if (self._arena) |arena| arena.deinit(); 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. @@ -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 { std.testing.refAllDecls(@This()); } diff --git a/src/input.zig b/src/input.zig new file mode 100644 index 000000000..14efd40cc --- /dev/null +++ b/src/input.zig @@ -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()); +} diff --git a/src/input/Binding.zig b/src/input/Binding.zig new file mode 100644 index 000000000..be59c60f7 --- /dev/null +++ b/src/input/Binding.zig @@ -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); + } +} diff --git a/src/input/key.zig b/src/input/key.zig new file mode 100644 index 000000000..2bb40b4c7 --- /dev/null +++ b/src/input/key.zig @@ -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. +}; diff --git a/src/main.zig b/src/main.zig index 748d4f85d..9585274f2 100644 --- a/src/main.zig +++ b/src/main.zig @@ -39,7 +39,8 @@ pub fn main() !void { // Parse the config from the CLI args var config = config: { - var result: Config = .{}; + var result = try Config.default(alloc); + errdefer result.deinit(); var iter = try std.process.argsWithAllocator(alloc); defer iter.deinit(); try cli_args.parse(Config, alloc, &result, &iter); @@ -101,6 +102,7 @@ test { _ = @import("TempDir.zig"); _ = @import("font/main.zig"); _ = @import("terminal/Terminal.zig"); + _ = @import("input.zig"); // Libraries _ = @import("libuv");