From 0394c8e2dfe05d2d0135130041db166b8abc0671 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Sep 2024 09:50:32 -0700 Subject: [PATCH] input: parse global keys, document them --- src/config/Config.zig | 43 ++++++++++++++++--- src/input/Binding.zig | 97 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 126 insertions(+), 14 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 9bc518326..628afa4ad 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -651,7 +651,8 @@ class: ?[:0]const u8 = null, @"working-directory": ?[]const u8 = null, /// Key bindings. The format is `trigger=action`. Duplicate triggers will -/// overwrite previously set values. +/// overwrite previously set values. The list of actions is available in +/// the documentation or using the `ghostty +list-actions` command. /// /// Trigger: `+`-separated list of keys and modifiers. Example: `ctrl+a`, /// `ctrl+shift+b`, `up`. Some notes: @@ -722,6 +723,9 @@ class: ?[:0]const u8 = null, /// * `text:text` - Send a string. Uses Zig string literal syntax. /// i.e. `text:\x15` sends Ctrl-U. /// +/// * All other actions can be found in the documentation or by using the +/// `ghostty +list-actions` command. +/// /// Some notes for the action: /// /// * The parameter is taken as-is after the `:`. Double quotes or @@ -736,11 +740,38 @@ class: ?[:0]const u8 = null, /// removes ALL keybindings up to this point, including the default /// keybindings. /// -/// A keybind by default causes the input to be consumed. This means that the -/// associated encoding (if any) will not be sent to the running program -/// in the terminal. If you wish to send the encoded value to the program, -/// specify the "unconsumed:" prefix before the entire keybind. For example: -/// "unconsumed:ctrl+a=reload_config" +/// The keybind trigger can be prefixed with some special values to change +/// the behavior of the keybind. These are: +/// +/// * `unconsumed:` - Do not consume the input. By default, a keybind +/// will consume the input, meaning that the associated encoding (if +/// any) will not be sent to the running program in the terminal. If +/// you wish to send the encoded value to the program, specify the +/// `unconsumed:` prefix before the entire keybind. For example: +/// `unconsumed:ctrl+a=reload_config` +/// +/// * `global:` - Make the keybind global. By default, keybinds only work +/// within Ghostty and under the right conditions (application focused, +/// sometimes terminal focused, etc.). If you want a keybind to work +/// globally across your system (i.e. even when Ghostty is not focused), +/// specify this prefix. Note: this does not work in all environments; +/// see the additional notes below for more information. +/// +/// Multiple prefixes can be specified. For example, +/// `global:unconsumed:ctrl+a=reload_config` will make the keybind global +/// and not consume the input to reload the config. +/// +/// A note on `global:`: this feature is only supported on macOS. On macOS, +/// this feature requires accessibility permissions to be granted to Ghostty. +/// When a `global:` keybind is specified and Ghostty is launched or reloaded, +/// Ghostty will attempt to request these permissions. If the permissions are +/// not granted, the keybind will not work. On macOS, you can find these +/// permissions in System Preferences -> Privacy & Security -> Accessibility. +/// +/// Additionally, `global:` keybinds associated with actions that affect +/// a specific terminal surface will target the last focused terminal surface +/// within Ghostty. There is not a way to target a specific terminal surface +/// with a `global:` keybind. keybind: Keybinds = .{}, /// Horizontal window padding. This applies padding between the terminal cells diff --git a/src/input/Binding.zig b/src/input/Binding.zig index b347d263b..b491756c8 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -17,17 +17,34 @@ action: Action, /// action is triggered. consumed: bool = true, +/// 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, + 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 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, +}; + /// Full binding parser. The binding parser is implemented as an iterator /// which yields elements to support multi-key sequences without allocation. pub const Parser = struct { - unconsumed: bool = false, trigger_it: SequenceIterator, action: Action, + flags: Flags = .{}, pub const Elem = union(enum) { /// A leader trigger in a sequence. @@ -38,11 +55,7 @@ pub const Parser = struct { }; pub fn init(raw_input: []const u8) Error!Parser { - // If our entire input is prefixed with "unconsumed:" then we are - // not consuming this keybind when the action is triggered. - const unconsumed_prefix = "unconsumed:"; - const unconsumed = std.mem.startsWith(u8, raw_input, unconsumed_prefix); - const start_idx = if (unconsumed) unconsumed_prefix.len else 0; + const flags, const start_idx = try parseFlags(raw_input); const input = raw_input[start_idx..]; // Find the first = which splits are mapping into the trigger @@ -52,12 +65,44 @@ pub const Parser = struct { // Sequence iterator goes up to the equal, action is after. We can // parse the action now. return .{ - .unconsumed = unconsumed, .trigger_it = .{ .input = input[0..eql_idx] }, .action = try Action.parse(input[eql_idx + 1 ..]), + .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, "unconsumed")) { + if (!flags.consumed) return Error.InvalidFormat; + flags.consumed = false; + } else if (std.mem.eql(u8, prefix, "global")) { + if (flags.global) return Error.InvalidFormat; + flags.global = 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; @@ -69,7 +114,8 @@ pub const Parser = struct { return .{ .binding = .{ .trigger = trigger, .action = self.action, - .consumed = !self.unconsumed, + .consumed = self.flags.consumed, + .global = self.flags.global, } }; } @@ -1241,6 +1287,41 @@ test "parse: triggers" { 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 = {} }, + .global = true, + }, try parseSingle("global:shift+a=ignore")); + + // global physical keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .physical = .a }, + }, + .action = .{ .ignore = {} }, + .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 = {} }, + .consumed = false, + .global = true, + }, try parseSingle("unconsumed:global:a+shift=ignore")); +} + test "parse: modifier aliases" { const testing = std.testing;