From 0394c8e2dfe05d2d0135130041db166b8abc0671 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Sep 2024 09:50:32 -0700 Subject: [PATCH 01/13] 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; From 66143a33efca9467a530dc9188ca33fe2e8d4493 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Sep 2024 10:16:35 -0700 Subject: [PATCH 02/13] input: move flags to a packed struct --- src/Surface.zig | 3 +- src/cli/list_keybinds.zig | 2 +- src/config/Config.zig | 41 +++++---- src/input/Binding.zig | 174 ++++++++++++++++++++++---------------- 4 files changed, 129 insertions(+), 91 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index a9b2c17d6..aa37b462b 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1605,8 +1605,7 @@ fn maybeHandleBinding( return .consumed; }, - .action => |v| .{ v, true }, - .action_unconsumed => |v| .{ v, false }, + .leaf => |leaf| .{ leaf.action, leaf.flags.consumed }, }; // We have an action, so at this point we're handling SOMETHING so diff --git a/src/cli/list_keybinds.zig b/src/cli/list_keybinds.zig index b12694625..9e734d1ec 100644 --- a/src/cli/list_keybinds.zig +++ b/src/cli/list_keybinds.zig @@ -116,7 +116,7 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 { while (iter.next()) |bind| { const action = switch (bind.value_ptr.*) { .leader => continue, // TODO: support this - .action, .action_unconsumed => |action| action, + .leaf => |leaf| leaf.action, }; const key = switch (bind.key_ptr.key) { .translated => |k| try std.fmt.bufPrint(&buf, "{s}", .{@tagName(k)}), diff --git a/src/config/Config.zig b/src/config/Config.zig index 628afa4ad..f654b7fa8 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -743,6 +743,20 @@ class: ?[:0]const u8 = null, /// The keybind trigger can be prefixed with some special values to change /// the behavior of the keybind. These are: /// +/// * `all:` - Make the keybind apply to all terminal surfaces. By default, +/// keybinds only apply to the focused terminal surface. If this is true, +/// then the keybind will be sent to all terminal surfaces. This only +/// applies to actions that are surface-specific. For actions that +/// are already global (i.e. `quit`), this prefix has no effect. +/// +/// * `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. This prefix implies `all:`. Note: this does not +/// work in all environments; see the additional notes below for more +/// information. +/// /// * `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 @@ -750,13 +764,6 @@ class: ?[:0]const u8 = null, /// `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. @@ -767,11 +774,6 @@ class: ?[:0]const u8 = null, /// 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 @@ -3735,11 +3737,16 @@ pub const Keybinds = struct { )) return false, // Actions are compared by field directly - inline .action, .action_unconsumed => |_, tag| if (!equalField( - inputpkg.Binding.Action, - @field(self_entry.value_ptr.*, @tagName(tag)), - @field(other_entry.value_ptr.*, @tagName(tag)), - )) return false, + .leaf => { + const self_leaf = self_entry.value_ptr.*.leaf; + const other_leaf = other_entry.value_ptr.*.leaf; + + if (!equalField( + inputpkg.Binding.Set.Leaf, + self_leaf, + other_leaf, + )) return false; + }, } } diff --git a/src/input/Binding.zig b/src/input/Binding.zig index b491756c8..b49f153b6 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -13,14 +13,8 @@ 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, - -/// 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, +/// Boolean flags that can be set per binding. +flags: Flags = .{}, pub const Error = error{ InvalidFormat, @@ -33,6 +27,10 @@ pub const Flags = packed struct { /// 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. @@ -82,12 +80,15 @@ pub const Parser = struct { 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; + 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 we don't recognize the prefix then we're done. // There are trigger-specific prefixes like "physical:" so @@ -114,8 +115,7 @@ pub const Parser = struct { return .{ .binding = .{ .trigger = trigger, .action = self.action, - .consumed = self.flags.consumed, - .global = self.flags.global, + .flags = self.flags, } }; } @@ -590,10 +590,15 @@ pub const Action = union(enum) { /// 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)); + std.hash.autoHash(hasher, @as(Tag, self)); // Hash the value of the field. switch (self) { @@ -608,25 +613,23 @@ pub const Action = union(enum) { // signed zeros but these are not cases we expect for // our bindings. f32 => std.hash.autoHash( - &hasher, + hasher, @as(u32, @bitCast(field)), ), f64 => std.hash.autoHash( - &hasher, + hasher, @as(u64, @bitCast(field)), ), // Everything else automatically handle. else => std.hash.autoHashStrat( - &hasher, + hasher, field, .DeepRecursive, ), } }, } - - return hasher.final(); } }; @@ -783,11 +786,16 @@ pub const Trigger = struct { /// 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()); + 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 .{ @@ -864,10 +872,8 @@ pub const Set = struct { leader: *Set, /// This trigger completes a sequence and the value is the action - /// to take. The "_unconsumed" variant is used for triggers that - /// should not consume the input. - action: Action, - action_unconsumed: 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. @@ -892,14 +898,28 @@ pub const Set = struct { } }, - .action, .action_unconsumed => |action| { + .leaf => |leaf| { // action implements the format - try writer.print("={s}", .{action}); + try writer.print("={s}", .{leaf.action}); }, } } }; + /// 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 hash(self: Leaf) u64 { + var hasher = std.hash.Wyhash.init(0); + self.action.hash(&hasher); + std.hash.autoHash(&hasher, self.flags); + return hasher.final(); + } + }; + pub fn deinit(self: *Set, alloc: Allocator) void { // Clear any leaders if we have them var it = self.bindings.iterator(); @@ -908,7 +928,7 @@ pub const Set = struct { s.deinit(alloc); alloc.destroy(s); }, - .action, .action_unconsumed => {}, + .leaf => {}, }; self.bindings.deinit(alloc); @@ -980,7 +1000,7 @@ pub const Set = struct { error.OutOfMemory => return error.OutOfMemory, }, - .action, .action_unconsumed => { + .leaf => { // Remove the existing action. Fallthrough as if // we don't have a leader. set.remove(alloc, t); @@ -1004,11 +1024,11 @@ pub const Set = struct { set.remove(alloc, t); if (old) |entry| switch (entry) { .leader => unreachable, // Handled above - inline .action, .action_unconsumed => |action, tag| set.put_( + .leaf => |leaf| set.put_( alloc, t, - action, - tag == .action, + leaf.action, + leaf.flags, ) catch {}, }; }, @@ -1023,7 +1043,7 @@ pub const Set = struct { return error.SequenceUnbind; }, - else => if (b.consumed) { + else => if (b.flags.consumed) { try set.put(alloc, b.trigger, b.action); } else { try set.putUnconsumed(alloc, b.trigger, b.action); @@ -1040,7 +1060,7 @@ pub const Set = struct { t: Trigger, action: Action, ) Allocator.Error!void { - try self.put_(alloc, t, action, true); + try self.put_(alloc, t, action, .{}); } /// Same as put but marks the trigger as unconsumed. An unconsumed @@ -1054,7 +1074,7 @@ pub const Set = struct { t: Trigger, action: Action, ) Allocator.Error!void { - try self.put_(alloc, t, action, false); + try self.put_(alloc, t, action, .{ .consumed = false }); } fn put_( @@ -1062,7 +1082,7 @@ pub const Set = struct { alloc: Allocator, t: Trigger, action: Action, - consumed: bool, + flags: Flags, ) Allocator.Error!void { // unbind should never go into the set, it should be handled prior assert(action != .unbind); @@ -1078,7 +1098,7 @@ pub const Set = struct { // If we have an existing binding for this trigger, we have to // update the reverse mapping to remove the old action. - .action, .action_unconsumed => { + .leaf => { const t_hash = t.hash(); var it = self.reverse.iterator(); while (it.next()) |reverse_entry| it: { @@ -1090,11 +1110,10 @@ pub const Set = struct { }, }; - gop.value_ptr.* = if (consumed) .{ + gop.value_ptr.* = .{ .leaf = .{ .action = action, - } else .{ - .action_unconsumed = action, - }; + .flags = flags, + } }; errdefer _ = self.bindings.remove(t); try self.reverse.put(alloc, action, t); errdefer _ = self.reverse.remove(action); @@ -1129,15 +1148,16 @@ pub const Set = struct { // 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. - .action, .action_unconsumed => |action| { - const action_hash = action.hash(); + .leaf => |leaf| { + const action_hash = leaf.action.hash(); + var it = self.bindings.iterator(); while (it.next()) |it_entry| { switch (it_entry.value_ptr.*) { .leader => {}, - .action, .action_unconsumed => |action_search| { - if (action_search.hash() == action_hash) { - self.reverse.putAssumeCapacity(action, it_entry.key_ptr.*); + .leaf => |leaf_search| { + if (leaf_search.action.hash() == action_hash) { + self.reverse.putAssumeCapacity(leaf.action, it_entry.key_ptr.*); break; } }, @@ -1145,7 +1165,7 @@ pub const Set = struct { } else { // No over trigger points to this action so we remove // the reverse mapping completely. - _ = self.reverse.remove(action); + _ = self.reverse.remove(leaf.action); } }, } @@ -1162,7 +1182,7 @@ pub const Set = struct { var it = result.bindings.iterator(); while (it.next()) |entry| switch (entry.value_ptr.*) { // No data to clone - .action, .action_unconsumed => {}, + .leaf => {}, // Must be deep cloned. .leader => |*s| { @@ -1264,7 +1284,7 @@ test "parse: triggers" { .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, - .consumed = false, + .flags = .{ .consumed = false }, }, try parseSingle("unconsumed:shift+a=ignore")); // unconsumed physical keys @@ -1274,7 +1294,7 @@ test "parse: triggers" { .key = .{ .physical = .a }, }, .action = .{ .ignore = {} }, - .consumed = false, + .flags = .{ .consumed = false }, }, try parseSingle("unconsumed:physical:a+shift=ignore")); // invalid key @@ -1297,7 +1317,7 @@ test "parse: global triggers" { .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, - .global = true, + .flags = .{ .global = true }, }, try parseSingle("global:shift+a=ignore")); // global physical keys @@ -1307,7 +1327,7 @@ test "parse: global triggers" { .key = .{ .physical = .a }, }, .action = .{ .ignore = {} }, - .global = true, + .flags = .{ .global = true }, }, try parseSingle("global:physical:a+shift=ignore")); // global unconsumed keys @@ -1317,8 +1337,10 @@ test "parse: global triggers" { .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, - .consumed = false, - .global = true, + .flags = .{ + .global = true, + .consumed = false, + }, }, try parseSingle("unconsumed:global:a+shift=ignore")); } @@ -1547,8 +1569,9 @@ test "set: parseAndPut typical binding" { // Creates forward mapping { - const action = s.get(.{ .key = .{ .translated = .a } }).?.action; - try testing.expect(action == .new_window); + const action = s.get(.{ .key = .{ .translated = .a } }).?.leaf; + try testing.expect(action.action == .new_window); + try testing.expectEqual(Flags{}, action.flags); } // Creates reverse mapping @@ -1570,8 +1593,9 @@ test "set: parseAndPut unconsumed binding" { // Creates forward mapping { const trigger: Trigger = .{ .key = .{ .translated = .a } }; - const action = s.get(trigger).?.action_unconsumed; - try testing.expect(action == .new_window); + const action = s.get(trigger).?.leaf; + try testing.expect(action.action == .new_window); + try testing.expectEqual(Flags{ .consumed = false }, action.flags); } // Creates reverse mapping @@ -1617,8 +1641,9 @@ test "set: parseAndPut sequence" { { const t: Trigger = .{ .key = .{ .translated = .b } }; const e = current.get(t).?; - try testing.expect(e == .action); - try testing.expect(e.action == .new_window); + try testing.expect(e == .leaf); + try testing.expect(e.leaf.action == .new_window); + try testing.expectEqual(Flags{}, e.leaf.flags); } } @@ -1641,14 +1666,16 @@ test "set: parseAndPut sequence with two actions" { { const t: Trigger = .{ .key = .{ .translated = .b } }; const e = current.get(t).?; - try testing.expect(e == .action); - try testing.expect(e.action == .new_window); + 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).?; - try testing.expect(e == .action); - try testing.expect(e.action == .new_tab); + try testing.expect(e == .leaf); + try testing.expect(e.leaf.action == .new_tab); + try testing.expectEqual(Flags{}, e.leaf.flags); } } @@ -1671,8 +1698,9 @@ test "set: parseAndPut overwrite sequence" { { const t: Trigger = .{ .key = .{ .translated = .b } }; const e = current.get(t).?; - try testing.expect(e == .action); - try testing.expect(e.action == .new_window); + try testing.expect(e == .leaf); + try testing.expect(e.leaf.action == .new_window); + try testing.expectEqual(Flags{}, e.leaf.flags); } } @@ -1695,8 +1723,9 @@ test "set: parseAndPut overwrite leader" { { const t: Trigger = .{ .key = .{ .translated = .b } }; const e = current.get(t).?; - try testing.expect(e == .action); - try testing.expect(e.action == .new_window); + try testing.expect(e == .leaf); + try testing.expect(e.leaf.action == .new_window); + try testing.expectEqual(Flags{}, e.leaf.flags); } } @@ -1825,11 +1854,14 @@ test "set: consumed state" { defer s.deinit(alloc); try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); - try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .action); + try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .leaf); + try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.leaf.flags.consumed); try s.putUnconsumed(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); - try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .action_unconsumed); + try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .leaf); + try testing.expect(!s.get(.{ .key = .{ .translated = .a } }).?.leaf.flags.consumed); try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); - try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .action); + try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .leaf); + try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.leaf.flags.consumed); } From 070cc221726e74f3a88bc8b9a5efb826f8071583 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Sep 2024 14:12:29 -0700 Subject: [PATCH 03/13] input: global/all bindings can't be sequenced --- src/config/Config.zig | 8 +++++++- src/input/Binding.zig | 12 +++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index f654b7fa8..18d34171f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -704,6 +704,9 @@ class: ?[:0]const u8 = null, /// `ctrl+a>t`, and then bind `ctrl+a` directly, both `ctrl+a>n` and /// `ctrl+a>t` will become unbound. /// +/// * Trigger sequences are not allowed for `global:` or `all:`-prefixed +/// triggers. This is a limitation we could remove in the future. +/// /// 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. @@ -762,7 +765,10 @@ class: ?[:0]const u8 = null, /// 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` +/// `unconsumed:ctrl+a=reload_config`. `global:` and `all:`-prefixed +/// keybinds will always consume the input regardless of this setting. +/// Since they are not associated with a specific terminal surface, +/// they're never encoded. /// /// Multiple prefixes can be specified. For example, /// `global:unconsumed:ctrl+a=reload_config` will make the keybind global diff --git a/src/input/Binding.zig b/src/input/Binding.zig index b49f153b6..97798463f 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -109,7 +109,11 @@ pub const Parser = struct { 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()) return .{ .leader = trigger }; + 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 = .{ @@ -1342,6 +1346,12 @@ test "parse: global triggers" { .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: modifier aliases" { From 7f8c1a37ffa2e644af65c0d0ec6bbbee9c98635b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Sep 2024 15:05:36 -0700 Subject: [PATCH 04/13] core: handle app bindings in the App struct --- src/App.zig | 52 +++++++++++++++++ src/Surface.zig | 26 ++++----- src/input/Binding.zig | 132 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 13 deletions(-) diff --git a/src/App.zig b/src/App.zig index f933b7126..4b9c2673e 100644 --- a/src/App.zig +++ b/src/App.zig @@ -262,6 +262,58 @@ pub fn setQuit(self: *App) !void { self.quit = true; } +/// Perform a binding action. This only accepts actions that are scoped +/// to the app. Callers can use performAllAction to perform any action +/// and any non-app-scoped actions will be performed on all surfaces. +pub fn performAction( + self: *App, + rt_app: *apprt.App, + action: input.Binding.Action.Scoped(.app), +) !void { + switch (action) { + .unbind => unreachable, + .ignore => {}, + .quit => try self.setQuit(), + .open_config => try self.openConfig(rt_app), + .reload_config => try self.reloadConfig(rt_app), + .close_all_windows => { + if (@hasDecl(apprt.App, "closeAllWindows")) { + rt_app.closeAllWindows(); + } else log.warn("runtime doesn't implement closeAllWindows", .{}); + }, + } +} + +/// Perform an app-wide binding action. If the action is surface-specific +/// then it will be performed on all surfaces. To perform only app-scoped +/// actions, use performAction. +pub fn performAllAction( + self: *App, + rt_app: *apprt.App, + action: input.Binding.Action, +) !void { + switch (action.scope()) { + // App-scoped actions are handled by the app so that they aren't + // repeated for each surface (since each surface forwards + // app-scoped actions back up). + .app => try self.performAction( + rt_app, + action.scoped(.app).?, // asserted through the scope match + ), + + // Surface-scoped actions are performed on all surfaces. Errors + // are logged but processing continues. + .surface => for (self.surfaces.items) |surface| { + _ = surface.core_surface.performBindingAction(action) catch |err| { + log.warn("error performing binding action on surface ptr={X} err={}", .{ + @intFromPtr(surface), + err, + }); + }; + }, + } +} + /// Handle a window message fn surfaceMessage(self: *App, surface: *Surface, msg: apprt.surface.Message) !void { // We want to ensure our window is still active. Window messages diff --git a/src/Surface.zig b/src/Surface.zig index aa37b462b..9e25ef0ad 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3400,14 +3400,22 @@ fn showMouse(self: *Surface) void { /// will ever return false. We can expand this in the future if it becomes /// useful. We did previous/next tab so we could implement #498. pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool { - switch (action) { - .unbind => unreachable, - .ignore => {}, + // Handle app-scoped bindings by sending it to the app. + switch (action.scope()) { + .app => { + try self.app.performAction( + self.rt_app, + action.scoped(.app).?, + ); - .open_config => try self.app.openConfig(self.rt_app), + return true; + }, - .reload_config => try self.app.reloadConfig(self.rt_app), + // Surface fallthrough and handle + .surface => {}, + } + switch (action.scoped(.surface).?) { .csi, .esc => |data| { // We need to send the CSI/ESC sequence as a single write request. // If you split it across two then the shell can interpret it @@ -3757,14 +3765,6 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .close_window => try self.app.closeSurface(self), - .close_all_windows => { - if (@hasDecl(apprt.Surface, "closeAllWindows")) { - self.rt_surface.closeAllWindows(); - } else log.warn("runtime doesn't implement closeAllWindows", .{}); - }, - - .quit => try self.app.setQuit(), - .crash => |location| switch (location) { .main => @panic("crash binding action, crashing intentionally"), diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 97798463f..ba7b62af2 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -539,6 +539,138 @@ pub const Action = union(enum) { 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, + => .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, + + // 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_window, + .new_tab, + .previous_tab, + .next_tab, + .last_tab, + .goto_tab, + .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( From 17caeb5fac1a13aeb0d261556ceacef583d7c328 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Sep 2024 19:20:34 -0700 Subject: [PATCH 05/13] core: "all" bindings work --- src/Surface.zig | 35 +++++++++++++++++--- src/input/Binding.zig | 75 ++++++++++++++++++++++++++++++------------- 2 files changed, 84 insertions(+), 26 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 9e25ef0ad..49017883a 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1590,7 +1590,7 @@ fn maybeHandleBinding( }; // Determine if this entry has an action or if its a leader key. - const action: input.Binding.Action, const consumed: bool = switch (entry) { + const leaf: input.Binding.Set.Leaf = switch (entry) { .leader => |set| { // Setup the next set we'll look at. self.keyboard.bindings = set; @@ -1605,7 +1605,20 @@ fn maybeHandleBinding( return .consumed; }, - .leaf => |leaf| .{ leaf.action, leaf.flags.consumed }, + .leaf => |leaf| leaf, + }; + const action = leaf.action; + + // consumed determines if the input is consumed or if we continue + // encoding the key (if we have a key to encode). + const consumed = consumed: { + // If the consumed flag is explicitly set, then we are consumed. + if (leaf.flags.consumed) break :consumed true; + + // If the global or all flag is set, we always consume. + if (leaf.flags.global or leaf.flags.all) break :consumed true; + + break :consumed false; }; // We have an action, so at this point we're handling SOMETHING so @@ -1617,8 +1630,22 @@ fn maybeHandleBinding( self.keyboard.bindings = null; // Attempt to perform the action - log.debug("key event binding consumed={} action={}", .{ consumed, action }); - const performed = try self.performBindingAction(action); + log.debug("key event binding flags={} action={}", .{ + leaf.flags, + action, + }); + const performed = performed: { + // If this is a global or all action, then we perform it on + // the app and it applies to every surface. + if (leaf.flags.global or leaf.flags.all) { + try self.app.performAllAction(self.rt_app, action); + + // "All" actions are always performed since they are global. + break :performed true; + } + + break :performed try self.performBindingAction(action); + }; // If we performed an action and it was a closing action, // our "self" pointer is not safe to use anymore so we need to diff --git a/src/input/Binding.zig b/src/input/Binding.zig index ba7b62af2..57c98f351 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1160,7 +1160,7 @@ pub const Set = struct { set.remove(alloc, t); if (old) |entry| switch (entry) { .leader => unreachable, // Handled above - .leaf => |leaf| set.put_( + .leaf => |leaf| set.putFlags( alloc, t, leaf.action, @@ -1179,11 +1179,12 @@ pub const Set = struct { return error.SequenceUnbind; }, - else => if (b.flags.consumed) { - try set.put(alloc, b.trigger, b.action); - } else { - try set.putUnconsumed(alloc, b.trigger, b.action); - }, + else => try set.putFlags( + alloc, + b.trigger, + b.action, + b.flags, + ), }, } } @@ -1196,24 +1197,11 @@ pub const Set = struct { t: Trigger, action: Action, ) Allocator.Error!void { - try self.put_(alloc, t, action, .{}); + try self.putFlags(alloc, t, action, .{}); } - /// 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, .{ .consumed = false }); - } - - fn put_( + /// Add a binding to the set with explicit flags. + pub fn putFlags( self: *Set, alloc: Allocator, t: Trigger, @@ -1486,6 +1474,49 @@ test "parse: global triggers" { } } +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; From c5eeb514cdf8658c32835f9404791b30199b0ec4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Sep 2024 20:39:36 -0700 Subject: [PATCH 06/13] input: fix tests --- src/input/Binding.zig | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 57c98f351..20fd80716 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -2030,7 +2030,12 @@ test "set: consumed state" { try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .leaf); try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.leaf.flags.consumed); - try s.putUnconsumed(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); + try s.putFlags( + alloc, + .{ .key = .{ .translated = .a } }, + .{ .new_window = {} }, + .{ .consumed = false }, + ); try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .leaf); try testing.expect(!s.get(.{ .key = .{ .translated = .a } }).?.leaf.flags.consumed); From 0f3f01483e8734e3f7c89ba422a2ed535ccc3535 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Sep 2024 20:45:46 -0700 Subject: [PATCH 07/13] apprt/embedded: API for checking if there are global keybinds --- include/ghostty.h | 1 + src/apprt/embedded.zig | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 072a8536a..676cbd5e0 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -531,6 +531,7 @@ void ghostty_app_keyboard_changed(ghostty_app_t); void ghostty_app_open_config(ghostty_app_t); void ghostty_app_reload_config(ghostty_app_t); bool ghostty_app_needs_confirm_quit(ghostty_app_t); +bool ghostty_app_has_global_keybinds(ghostty_app_t); ghostty_surface_config_s ghostty_surface_config_new(); diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index b59ab1c9d..be3df896a 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -161,6 +161,19 @@ pub const App = struct { self.keymap.deinit(); } + /// Returns true if there are any global keybinds in the configuration. + pub fn hasGlobalKeybinds(self: *const App) bool { + var it = self.config.keybind.set.bindings.iterator(); + while (it.next()) |entry| { + switch (entry.value_ptr.*) { + .leader => {}, + .leaf => |leaf| if (leaf.flags.global) return true, + } + } + + return false; + } + /// This should be called whenever the keyboard layout was changed. pub fn reloadKeymap(self: *App) !void { // Reload the keymap @@ -1514,6 +1527,11 @@ pub const CAPI = struct { return v.core_app.needsConfirmQuit(); } + /// Returns true if the app has global keybinds. + export fn ghostty_app_has_global_keybinds(v: *App) bool { + return v.hasGlobalKeybinds(); + } + /// Returns initial surface options. export fn ghostty_surface_config_new() apprt.Surface.Options { return .{}; From 1ad904478d2c6f3622268e0fd775f6936159d9e7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Sep 2024 20:58:37 -0700 Subject: [PATCH 08/13] Tap events, core API to handle global keybinds --- include/ghostty.h | 1 + macos/Ghostty.xcodeproj/project.pbxproj | 12 + macos/Sources/App/macOS/AppDelegate.swift | 8 + .../Global Keybinds/GlobalEventTap.swift | 150 ++++++ src/App.zig | 43 ++ src/Surface.zig | 15 +- src/apprt/embedded.zig | 487 ++++++++++-------- src/input/Binding.zig | 26 + 8 files changed, 516 insertions(+), 226 deletions(-) create mode 100644 macos/Sources/Features/Global Keybinds/GlobalEventTap.swift diff --git a/include/ghostty.h b/include/ghostty.h index 676cbd5e0..77671140f 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -527,6 +527,7 @@ ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*, void ghostty_app_free(ghostty_app_t); bool ghostty_app_tick(ghostty_app_t); void* ghostty_app_userdata(ghostty_app_t); +bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s); void ghostty_app_keyboard_changed(ghostty_app_t); void ghostty_app_open_config(ghostty_app_t); void ghostty_app_reload_config(ghostty_app_t); diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index d2b3cff83..9bff35757 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -61,6 +61,7 @@ A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; }; A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.swift */; }; A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; + A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */; }; A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */; }; A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36142C9CDA03004D6760 /* View+Extension.swift */; }; A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */; }; @@ -131,6 +132,7 @@ A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = ""; }; A5CBD0572C9F30860017A1AE /* Cursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cursor.swift; sourceTree = ""; }; + A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalEventTap.swift; sourceTree = ""; }; A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInputOverlay.swift; sourceTree = ""; }; A5CC36142C9CDA03004D6760 /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = ""; }; A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfigurationErrors.xib; sourceTree = ""; }; @@ -199,6 +201,7 @@ A53426362A7DC53000EBB7A2 /* Features */ = { isa = PBXGroup; children = ( + A5CBD0672CA2704E0017A1AE /* Global Keybinds */, A56D58872ACDE6BE00508D2C /* Services */, A59630982AEE1C4400D64628 /* Terminal */, A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */, @@ -370,6 +373,14 @@ name = Products; sourceTree = ""; }; + A5CBD0672CA2704E0017A1AE /* Global Keybinds */ = { + isa = PBXGroup; + children = ( + A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */, + ); + path = "Global Keybinds"; + sourceTree = ""; + }; A5CEAFDA29B8005900646FDA /* SplitView */ = { isa = PBXGroup; children = ( @@ -529,6 +540,7 @@ A59630972AEE163600D64628 /* HostingWindow.swift in Sources */, A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */, A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */, + A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */, AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */, A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */, A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 41815631d..76dfdb5ec 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -418,6 +418,14 @@ class AppDelegate: NSObject, c.showWindow(self) } } + + // If our reload adds global keybinds and we don't have ax permissions then + // we need to request them. + global: if (ghostty_app_has_global_keybinds(ghostty.app!)) { + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { + GlobalEventTap.shared.enable() + } + } } /// Sync the appearance of our app with the theme specified in the config. diff --git a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift new file mode 100644 index 000000000..3a768df79 --- /dev/null +++ b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift @@ -0,0 +1,150 @@ +import Cocoa +import CoreGraphics +import Carbon +import OSLog +import GhosttyKit + +// Manages the event tap to monitor global events, currently only used for +// global keybindings. +class GlobalEventTap { + static let shared = GlobalEventTap() + + fileprivate static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: GlobalEventTap.self) + ) + + // The event tap used for global event listening. This is non-nil if it is + // created. + private var eventTap: CFMachPort? = nil + + // This is the timer used to retry enabling the global event tap if we + // don't have permissions. + private var enableTimer: Timer? = nil + + private init() {} + + deinit { + disable() + } + + // Enable the global event tap. This is safe to call if it is already enabled. + // If enabling fails due to permissions, this will start a timer to retry since + // accessibility permissions take affect immediately. + func enable() { + if (eventTap != nil) { + // Already enabled + return + } + + // If we are already trying to enable, then stop the timer and restart it. + if let enableTimer { + enableTimer.invalidate() + } + + // Try to enable the event tap immediately. If this succeeds then we're done! + if (tryEnable()) { + return + } + + // Failed, probably due to permissions. The permissions dialog should've + // popped up. We retry on a timer since once the permisisons are granted + // then they take affect immediately. + enableTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in + _ = self.tryEnable() + } + } + + // Disable the global event tap. This is safe to call if it is already disabled. + func disable() { + // Stop our enable timer if it is on + if let enableTimer { + enableTimer.invalidate() + self.enableTimer = nil + } + + // Stop our event tap + if let eventTap { + Self.logger.debug("invalidating event tap mach port") + CFMachPortInvalidate(eventTap) + self.eventTap = nil + } + } + + // Try to enable the global event type, returns false if it fails. + private func tryEnable() -> Bool { + // The events we care about + let eventMask = [ + CGEventType.keyDown + ].reduce(CGEventMask(0), { $0 | (1 << $1.rawValue)}) + + // Try to create it + guard let eventTap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: eventMask, + callback: cgEventFlagsChangedHandler(proxy:type:cgEvent:userInfo:), + userInfo: nil + ) else { + // Return false if creation failed. This is usually because we don't have + // Accessibility permissions but can probably be other reasons I don't + // know about. + Self.logger.debug("creating global event tap failed, missing permissions?") + return false + } + + // Store our event tap + self.eventTap = eventTap + + // If we have an enable timer we always want to disable it + if let enableTimer { + enableTimer.invalidate() + self.enableTimer = nil + } + + // Attach our event tap to the main run loop. Note if you don't do this then + // the event tap will block every + CFRunLoopAddSource( + CFRunLoopGetMain(), + CFMachPortCreateRunLoopSource(nil, eventTap, 0), + .commonModes + ) + + Self.logger.info("global event tap enabled for global keybinds") + return true + } +} + +fileprivate func cgEventFlagsChangedHandler( + proxy: CGEventTapProxy, + type: CGEventType, + cgEvent: CGEvent, + userInfo: UnsafeMutableRawPointer? +) -> Unmanaged? { + let result = Unmanaged.passUnretained(cgEvent) + + // We only care about keydown events + guard type == .keyDown else { return result } + + // We need an app delegate to get the Ghostty app instance + guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return result } + guard let ghostty = appDelegate.ghostty.app else { return result } + + // We need an NSEvent for our logic below + guard let event: NSEvent = .init(cgEvent: cgEvent) else { return result } + + // Build our event input and call ghostty + var key_ev = ghostty_input_key_s() + key_ev.action = GHOSTTY_ACTION_PRESS + key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags) + key_ev.keycode = UInt32(event.keyCode) + key_ev.text = nil + key_ev.composing = false + if (ghostty_app_key(ghostty, key_ev)) { + GlobalEventTap.logger.info("global key event handled event=\(event)") + return nil + } + + return result +} diff --git a/src/App.zig b/src/App.zig index 4b9c2673e..31f3e451b 100644 --- a/src/App.zig +++ b/src/App.zig @@ -262,6 +262,49 @@ pub fn setQuit(self: *App) !void { self.quit = true; } +/// Handle a key event at the app-scope. If this key event is used, +/// this will return true and the caller shouldn't continue processing +/// the event. If the event is not used, this will return false. +pub fn keyEvent( + self: *App, + rt_app: *apprt.App, + event: input.KeyEvent, +) bool { + switch (event.action) { + // We don't care about key release events. + .release => return false, + + // Continue processing key press events. + .press, .repeat => {}, + } + + // Get the keybind entry for this event. We don't support key sequences + // so we can look directly in the top-level set. + const entry = rt_app.config.keybind.set.getEvent(event) orelse return false; + const leaf: input.Binding.Set.Leaf = switch (entry) { + // Sequences aren't supported. Our configuration parser verifies + // this for global keybinds but we may still get an entry for + // a non-global keybind. + .leader => return false, + + // Leaf entries are good + .leaf => |leaf| leaf, + }; + + // We only care about global keybinds + if (!leaf.flags.global) return false; + + // Perform the action + self.performAllAction(rt_app, leaf.action) catch |err| { + log.warn("error performing global keybind action action={s} err={}", .{ + @tagName(leaf.action), + err, + }); + }; + + return true; +} + /// Perform a binding action. This only accepts actions that are scoped /// to the app. Callers can use performAllAction to perform any action /// and any non-app-scoped actions will be performed on all surfaces. diff --git a/src/Surface.zig b/src/Surface.zig index 49017883a..b5f7b9293 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1561,19 +1561,8 @@ fn maybeHandleBinding( const entry: input.Binding.Set.Entry = entry: { const set = self.keyboard.bindings orelse &self.config.keybind.set; - var trigger: input.Binding.Trigger = .{ - .mods = event.mods.binding(), - .key = .{ .translated = event.key }, - }; - if (set.get(trigger)) |v| break :entry v; - - trigger.key = .{ .physical = event.physical_key }; - if (set.get(trigger)) |v| break :entry v; - - if (event.unshifted_codepoint > 0) { - trigger.key = .{ .unicode = event.unshifted_codepoint }; - if (set.get(trigger)) |v| break :entry v; - } + // Get our entry from the set for the given event. + if (set.getEvent(event)) |v| break :entry v; // No entry found. If we're not looking at the root set of the // bindings we need to encode everything up to this point and diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index be3df896a..e0eecf8cc 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -143,17 +143,37 @@ pub const App = struct { toggle_secure_input: ?*const fn () callconv(.C) void = null, }; + /// This is the key event sent for ghostty_surface_key and + /// ghostty_app_key. + pub const KeyEvent = struct { + /// The three below are absolutely required. + action: input.Action, + mods: input.Mods, + keycode: u32, + + /// Optionally, the embedder can handle text translation and send + /// the text value here. If text is non-nil, it is assumed that the + /// embedder also handles dead key states and sets composing as necessary. + text: ?[:0]const u8, + composing: bool, + }; + core_app: *CoreApp, config: *const Config, opts: Options, keymap: input.Keymap, + /// The keymap state is used for global keybinds only. Each surface + /// also has its own keymap state for focused keybinds. + keymap_state: input.Keymap.State, + pub fn init(core_app: *CoreApp, config: *const Config, opts: Options) !App { return .{ .core_app = core_app, .config = config, .opts = opts, .keymap = try input.Keymap.init(), + .keymap_state = .{}, }; } @@ -174,6 +194,241 @@ pub const App = struct { return false; } + /// The target of a key event. This is used to determine some subtly + /// different behavior between app and surface key events. + pub const KeyTarget = union(enum) { + app, + surface: *Surface, + }; + + /// See CoreApp.keyEvent. + pub fn keyEvent( + self: *App, + target: KeyTarget, + event: KeyEvent, + ) !bool { + // NOTE: If this is updated, take a look at Surface.keyCallback as well. + // Their logic is very similar but not identical. + + const action = event.action; + const keycode = event.keycode; + const mods = event.mods; + + // True if this is a key down event + const is_down = action == .press or action == .repeat; + + // If we're on macOS and we have macos-option-as-alt enabled, + // then we strip the alt modifier from the mods for translation. + const translate_mods = translate_mods: { + var translate_mods = mods; + if (comptime builtin.target.isDarwin()) { + const strip = switch (self.config.@"macos-option-as-alt") { + .false => false, + .true => mods.alt, + .left => mods.sides.alt == .left, + .right => mods.sides.alt == .right, + }; + if (strip) translate_mods.alt = false; + } + + // On macOS we strip ctrl because UCKeyTranslate + // converts to the masked values (i.e. ctrl+c becomes 3) + // and we don't want that behavior. + // + // We also strip super because its not used for translation + // on macos and it results in a bad translation. + if (comptime builtin.target.isDarwin()) { + translate_mods.ctrl = false; + translate_mods.super = false; + } + + break :translate_mods translate_mods; + }; + + const event_text: ?[]const u8 = event_text: { + // This logic only applies to macOS. + if (comptime builtin.os.tag != .macos) break :event_text event.text; + + // If the modifiers are ONLY "control" then we never process + // the event text because we want to do our own translation so + // we can handle ctrl+c, ctrl+z, etc. + // + // This is specifically because on macOS using the + // "Dvorak - QWERTY ⌘" keyboard layout, ctrl+z is translated as + // "/" (the physical key that is z on a qwerty keyboard). But on + // other layouts, ctrl+ is not translated by AppKit. So, + // we just avoid this by never allowing AppKit to translate + // ctrl+ and instead do it ourselves. + const ctrl_only = comptime (input.Mods{ .ctrl = true }).int(); + break :event_text if (mods.binding().int() == ctrl_only) null else event.text; + }; + + // Translate our key using the keymap for our localized keyboard layout. + // We only translate for keydown events. Otherwise, we only care about + // the raw keycode. + var buf: [128]u8 = undefined; + const result: input.Keymap.Translation = if (is_down) translate: { + // If the event provided us with text, then we use this as a result + // and do not do manual translation. + const result: input.Keymap.Translation = if (event_text) |text| .{ + .text = text, + .composing = event.composing, + } else try self.keymap.translate( + &buf, + switch (target) { + .app => &self.keymap_state, + .surface => |surface| &surface.keymap_state, + }, + @intCast(keycode), + translate_mods, + ); + + // If this is a dead key, then we're composing a character and + // we need to set our proper preedit state if we're targeting a + // surface. + if (result.composing) { + switch (target) { + .app => {}, + .surface => |surface| surface.core_surface.preeditCallback( + result.text, + ) catch |err| { + log.err("error in preedit callback err={}", .{err}); + return false; + }, + } + } else { + switch (target) { + .app => {}, + .surface => |surface| surface.core_surface.preeditCallback(null) catch |err| { + log.err("error in preedit callback err={}", .{err}); + return false; + }, + } + + // If the text is just a single non-printable ASCII character + // then we clear the text. We handle non-printables in the + // key encoder manual (such as tab, ctrl+c, etc.) + if (result.text.len == 1 and result.text[0] < 0x20) { + break :translate .{ .composing = false, .text = "" }; + } + } + + break :translate result; + } else .{ .composing = false, .text = "" }; + + // UCKeyTranslate always consumes all mods, so if we have any output + // then we've consumed our translate mods. + const consumed_mods: input.Mods = if (result.text.len > 0) translate_mods else .{}; + + // We need to always do a translation with no modifiers at all in + // order to get the "unshifted_codepoint" for the key event. + const unshifted_codepoint: u21 = unshifted: { + var nomod_buf: [128]u8 = undefined; + var nomod_state: input.Keymap.State = .{}; + const nomod = try self.keymap.translate( + &nomod_buf, + &nomod_state, + @intCast(keycode), + .{}, + ); + + const view = std.unicode.Utf8View.init(nomod.text) catch |err| { + log.warn("cannot build utf8 view over text: {}", .{err}); + break :unshifted 0; + }; + var it = view.iterator(); + break :unshifted it.nextCodepoint() orelse 0; + }; + + // log.warn("TRANSLATE: action={} keycode={x} dead={} key_len={} key={any} key_str={s} mods={}", .{ + // action, + // keycode, + // result.composing, + // result.text.len, + // result.text, + // result.text, + // mods, + // }); + + // We want to get the physical unmapped key to process keybinds. + const physical_key = keycode: for (input.keycodes.entries) |entry| { + if (entry.native == keycode) break :keycode entry.key; + } else .invalid; + + // If the resulting text has length 1 then we can take its key + // and attempt to translate it to a key enum and call the key callback. + // If the length is greater than 1 then we're going to call the + // charCallback. + // + // We also only do key translation if this is not a dead key. + const key = if (!result.composing) key: { + // If our physical key is a keypad key, we use that. + if (physical_key.keypad()) break :key physical_key; + + // A completed key. If the length of the key is one then we can + // attempt to translate it to a key enum and call the key + // callback. First try plain ASCII. + if (result.text.len > 0) { + if (input.Key.fromASCII(result.text[0])) |key| { + break :key key; + } + } + + // If the above doesn't work, we use the unmodified value. + if (std.math.cast(u8, unshifted_codepoint)) |ascii| { + if (input.Key.fromASCII(ascii)) |key| { + break :key key; + } + } + + break :key physical_key; + } else .invalid; + + // Build our final key event + const input_event: input.KeyEvent = .{ + .action = action, + .key = key, + .physical_key = physical_key, + .mods = mods, + .consumed_mods = consumed_mods, + .composing = result.composing, + .utf8 = result.text, + .unshifted_codepoint = unshifted_codepoint, + }; + + // Invoke the core Ghostty logic to handle this input. + const effect: CoreSurface.InputEffect = switch (target) { + .app => if (self.core_app.keyEvent( + self, + input_event, + )) + .consumed + else + .ignored, + + .surface => |surface| try surface.core_surface.keyCallback(input_event), + }; + + return switch (effect) { + .closed => true, + .ignored => false, + .consumed => consumed: { + if (is_down) { + // If we consume the key then we want to reset the dead + // key state. + self.keymap_state = .{}; + + switch (target) { + .app => {}, + .surface => |surface| surface.core_surface.preeditCallback(null) catch {}, + } + } + + break :consumed true; + }, + }; + } + /// This should be called whenever the keyboard layout was changed. pub fn reloadKeymap(self: *App) !void { // Reload the keymap @@ -349,20 +604,6 @@ pub const Surface = struct { command: [*:0]const u8 = "", }; - /// This is the key event sent for ghostty_surface_key. - pub const KeyEvent = struct { - /// The three below are absolutely required. - action: input.Action, - mods: input.Mods, - keycode: u32, - - /// Optionally, the embedder can handle text translation and send - /// the text value here. If text is non-nil, it is assumed that the - /// embedder also handles dead key states and sets composing as necessary. - text: ?[:0]const u8, - composing: bool, - }; - pub fn init(self: *Surface, app: *App, opts: Options) !void { self.* = .{ .app = app, @@ -800,198 +1041,6 @@ pub const Surface = struct { }; } - pub fn keyCallback( - self: *Surface, - event: KeyEvent, - ) !void { - const action = event.action; - const keycode = event.keycode; - const mods = event.mods; - - // True if this is a key down event - const is_down = action == .press or action == .repeat; - - // If we're on macOS and we have macos-option-as-alt enabled, - // then we strip the alt modifier from the mods for translation. - const translate_mods = translate_mods: { - var translate_mods = mods; - if (comptime builtin.target.isDarwin()) { - const strip = switch (self.app.config.@"macos-option-as-alt") { - .false => false, - .true => mods.alt, - .left => mods.sides.alt == .left, - .right => mods.sides.alt == .right, - }; - if (strip) translate_mods.alt = false; - } - - // On macOS we strip ctrl because UCKeyTranslate - // converts to the masked values (i.e. ctrl+c becomes 3) - // and we don't want that behavior. - // - // We also strip super because its not used for translation - // on macos and it results in a bad translation. - if (comptime builtin.target.isDarwin()) { - translate_mods.ctrl = false; - translate_mods.super = false; - } - - break :translate_mods translate_mods; - }; - - const event_text: ?[]const u8 = event_text: { - // This logic only applies to macOS. - if (comptime builtin.os.tag != .macos) break :event_text event.text; - - // If the modifiers are ONLY "control" then we never process - // the event text because we want to do our own translation so - // we can handle ctrl+c, ctrl+z, etc. - // - // This is specifically because on macOS using the - // "Dvorak - QWERTY ⌘" keyboard layout, ctrl+z is translated as - // "/" (the physical key that is z on a qwerty keyboard). But on - // other layouts, ctrl+ is not translated by AppKit. So, - // we just avoid this by never allowing AppKit to translate - // ctrl+ and instead do it ourselves. - const ctrl_only = comptime (input.Mods{ .ctrl = true }).int(); - break :event_text if (mods.binding().int() == ctrl_only) null else event.text; - }; - - // Translate our key using the keymap for our localized keyboard layout. - // We only translate for keydown events. Otherwise, we only care about - // the raw keycode. - var buf: [128]u8 = undefined; - const result: input.Keymap.Translation = if (is_down) translate: { - // If the event provided us with text, then we use this as a result - // and do not do manual translation. - const result: input.Keymap.Translation = if (event_text) |text| .{ - .text = text, - .composing = event.composing, - } else try self.app.keymap.translate( - &buf, - &self.keymap_state, - @intCast(keycode), - translate_mods, - ); - - // If this is a dead key, then we're composing a character and - // we need to set our proper preedit state. - if (result.composing) { - self.core_surface.preeditCallback(result.text) catch |err| { - log.err("error in preedit callback err={}", .{err}); - return; - }; - } else { - // If we aren't composing, then we set our preedit to - // empty no matter what. - self.core_surface.preeditCallback(null) catch {}; - - // If the text is just a single non-printable ASCII character - // then we clear the text. We handle non-printables in the - // key encoder manual (such as tab, ctrl+c, etc.) - if (result.text.len == 1 and result.text[0] < 0x20) { - break :translate .{ .composing = false, .text = "" }; - } - } - - break :translate result; - } else .{ .composing = false, .text = "" }; - - // UCKeyTranslate always consumes all mods, so if we have any output - // then we've consumed our translate mods. - const consumed_mods: input.Mods = if (result.text.len > 0) translate_mods else .{}; - - // We need to always do a translation with no modifiers at all in - // order to get the "unshifted_codepoint" for the key event. - const unshifted_codepoint: u21 = unshifted: { - var nomod_buf: [128]u8 = undefined; - var nomod_state: input.Keymap.State = .{}; - const nomod = try self.app.keymap.translate( - &nomod_buf, - &nomod_state, - @intCast(keycode), - .{}, - ); - - const view = std.unicode.Utf8View.init(nomod.text) catch |err| { - log.warn("cannot build utf8 view over text: {}", .{err}); - break :unshifted 0; - }; - var it = view.iterator(); - break :unshifted it.nextCodepoint() orelse 0; - }; - - // log.warn("TRANSLATE: action={} keycode={x} dead={} key_len={} key={any} key_str={s} mods={}", .{ - // action, - // keycode, - // result.composing, - // result.text.len, - // result.text, - // result.text, - // mods, - // }); - - // We want to get the physical unmapped key to process keybinds. - const physical_key = keycode: for (input.keycodes.entries) |entry| { - if (entry.native == keycode) break :keycode entry.key; - } else .invalid; - - // If the resulting text has length 1 then we can take its key - // and attempt to translate it to a key enum and call the key callback. - // If the length is greater than 1 then we're going to call the - // charCallback. - // - // We also only do key translation if this is not a dead key. - const key = if (!result.composing) key: { - // If our physical key is a keypad key, we use that. - if (physical_key.keypad()) break :key physical_key; - - // A completed key. If the length of the key is one then we can - // attempt to translate it to a key enum and call the key - // callback. First try plain ASCII. - if (result.text.len > 0) { - if (input.Key.fromASCII(result.text[0])) |key| { - break :key key; - } - } - - // If the above doesn't work, we use the unmodified value. - if (std.math.cast(u8, unshifted_codepoint)) |ascii| { - if (input.Key.fromASCII(ascii)) |key| { - break :key key; - } - } - - break :key physical_key; - } else .invalid; - - // Invoke the core Ghostty logic to handle this input. - const effect = self.core_surface.keyCallback(.{ - .action = action, - .key = key, - .physical_key = physical_key, - .mods = mods, - .consumed_mods = consumed_mods, - .composing = result.composing, - .utf8 = result.text, - .unshifted_codepoint = unshifted_codepoint, - }) catch |err| { - log.err("error in key callback err={}", .{err}); - return; - }; - - switch (effect) { - .closed => return, - .ignored => {}, - .consumed => if (is_down) { - // If we consume the key then we want to reset the dead - // key state. - self.keymap_state = .{}; - self.core_surface.preeditCallback(null) catch {}; - }, - } - } - pub fn textCallback(self: *Surface, text: []const u8) void { _ = self.core_surface.textCallback(text) catch |err| { log.err("error in key callback err={}", .{err}); @@ -1411,7 +1460,7 @@ pub const CAPI = struct { composing: bool, /// Convert to surface key event. - fn keyEvent(self: KeyEvent) Surface.KeyEvent { + fn keyEvent(self: KeyEvent) App.KeyEvent { return .{ .action = self.action, .mods = @bitCast(@as( @@ -1497,6 +1546,19 @@ pub const CAPI = struct { core_app.destroy(); } + /// Notify the app of a global keypress capture. This will return + /// true if the key was captured by the app, in which case the caller + /// should not process the key. + export fn ghostty_app_key( + app: *App, + event: KeyEvent, + ) bool { + return app.keyEvent(.app, event.keyEvent()) catch |err| { + log.warn("error processing key event err={}", .{err}); + return false; + }; + } + /// Notify the app that the keyboard was changed. This causes the /// keyboard layout to be reloaded from the OS. export fn ghostty_app_keyboard_changed(v: *App) void { @@ -1690,16 +1752,15 @@ pub const CAPI = struct { /// Send this for raw keypresses (i.e. the keyDown event on macOS). /// This will handle the keymap translation and send the appropriate /// key and char events. - /// - /// You do NOT need to also send "ghostty_surface_char" unless - /// you want to send a unicode character that is not associated - /// with a keypress, i.e. IME keyboard. export fn ghostty_surface_key( surface: *Surface, event: KeyEvent, ) void { - surface.keyCallback(event.keyEvent()) catch |err| { - log.err("error processing key event err={}", .{err}); + _ = surface.app.keyEvent( + .{ .surface = surface }, + event.keyEvent(), + ) catch |err| { + log.warn("error processing key event err={}", .{err}); return; }; } diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 20fd80716..93e046d1a 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -6,6 +6,7 @@ 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, @@ -1254,6 +1255,31 @@ pub const Set = struct { 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; From ed7ac8aa2120aee674b19f70ecf59ecfb8241dc0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Sep 2024 10:39:20 -0700 Subject: [PATCH 09/13] macos: better enable timing depending on process launch time --- macos/Sources/App/macOS/AppDelegate.swift | 27 +++++++++++++++++-- .../Global Keybinds/GlobalEventTap.swift | 1 + 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 76dfdb5ec..1aeea6626 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -63,6 +63,10 @@ class AppDelegate: NSObject, /// This is only true before application has become active. private var applicationHasBecomeActive: Bool = false + /// This is set in applicationDidFinishLaunching with the system uptime so we can determine the + /// seconds since the process was launched. + private var applicationLaunchTime: TimeInterval = 0 + /// The ghostty global state. Only one per process. let ghostty: Ghostty.App = Ghostty.App() @@ -73,6 +77,11 @@ class AppDelegate: NSObject, let updaterController: SPUStandardUpdaterController let updaterDelegate: UpdaterDelegate = UpdaterDelegate() + /// The elapsed time since the process was started + var timeSinceLaunch: TimeInterval { + return ProcessInfo.processInfo.systemUptime - applicationLaunchTime + } + override init() { terminalManager = TerminalManager(ghostty) updaterController = SPUStandardUpdaterController( @@ -106,6 +115,9 @@ class AppDelegate: NSObject, "ApplePressAndHoldEnabled": false, ]) + // Store our start time + applicationLaunchTime = ProcessInfo.processInfo.systemUptime + // Check if secure input was enabled when we last quit. if (UserDefaults.standard.bool(forKey: "SecureInput") != SecureInput.shared.enabled) { toggleSecureInput(self) @@ -421,10 +433,21 @@ class AppDelegate: NSObject, // If our reload adds global keybinds and we don't have ax permissions then // we need to request them. - global: if (ghostty_app_has_global_keybinds(ghostty.app!)) { - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { + if (ghostty_app_has_global_keybinds(ghostty.app!)) { + if (timeSinceLaunch > 5) { + // If the process has been running for awhile we enable right away + // because no windows are likely to pop up. GlobalEventTap.shared.enable() + } else { + // If the process just started, we wait a couple seconds to allow + // the initial windows and so on to load so our permissions dialog + // doesn't get buried. + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { + GlobalEventTap.shared.enable() + } } + } else { + GlobalEventTap.shared.disable() } } diff --git a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift index 3a768df79..73c081df3 100644 --- a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift +++ b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift @@ -22,6 +22,7 @@ class GlobalEventTap { // don't have permissions. private var enableTimer: Timer? = nil + // Private init so it can't be constructed outside of our singleton private init() {} deinit { From 689ee0f3858c4220cb35c4e2b5a1c7a174ec07f5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Sep 2024 16:38:41 -0700 Subject: [PATCH 10/13] typos --- macos/Sources/Features/Global Keybinds/GlobalEventTap.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift index 73c081df3..f1bb93506 100644 --- a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift +++ b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift @@ -49,7 +49,7 @@ class GlobalEventTap { } // Failed, probably due to permissions. The permissions dialog should've - // popped up. We retry on a timer since once the permisisons are granted + // popped up. We retry on a timer since once the permissions are granted // then they take affect immediately. enableTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in _ = self.tryEnable() From bea24f772564ebdf3d31ecd54bd33add32cfec22 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Sep 2024 16:41:26 -0700 Subject: [PATCH 11/13] config: clarify docs --- src/config/Config.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index 18d34171f..efa741307 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -770,6 +770,11 @@ class: ?[:0]const u8 = null, /// Since they are not associated with a specific terminal surface, /// they're never encoded. /// +/// Keybind trigger are not unique per prefix combination. For example, +/// `ctrl+a` and `global:ctrl+a` are not two separate keybinds. The keybind +/// set later will overwrite the keybind set earlier. In this case, the +/// `global:` keybind will be used. +/// /// 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. From 1b316638659bcc09633618ff499bd198cb86bb62 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Sep 2024 17:00:38 -0700 Subject: [PATCH 12/13] apprt/embedded: new_window can be called without a parent --- .../Features/Terminal/TerminalManager.swift | 6 +++++ macos/Sources/Ghostty/Ghostty.App.swift | 6 ++++- src/App.zig | 1 + src/Surface.zig | 27 ++++++++++--------- src/apprt/embedded.zig | 18 ++++++++----- src/input/Binding.zig | 9 +++++-- 6 files changed, 45 insertions(+), 22 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 8b9ed3cad..3930012df 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -78,6 +78,12 @@ class TerminalManager { window.toggleFullScreen(nil) } + // If our app isn't active, we make it active. All new_window actions + // force our app to be active. + if !NSApp.isActive { + NSApp.activate(ignoringOtherApps: true) + } + // We're dispatching this async because otherwise the lastCascadePoint doesn't // take effect. Our best theory is there is some next-event-loop-tick logic // that Cocoa is doing that we need to be after. diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 7b8c5688f..30efb289e 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -631,7 +631,11 @@ extension Ghostty { } static func newWindow(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) { - let surface = self.surfaceUserdata(from: userdata) + let surface: SurfaceView? = if let userdata { + self.surfaceUserdata(from: userdata) + } else { + nil + } NotificationCenter.default.post( name: Notification.ghosttyNewWindow, diff --git a/src/App.zig b/src/App.zig index 31f3e451b..d93e00a2a 100644 --- a/src/App.zig +++ b/src/App.zig @@ -317,6 +317,7 @@ pub fn performAction( .unbind => unreachable, .ignore => {}, .quit => try self.setQuit(), + .new_window => try self.newWindow(rt_app, .{ .parent = null }), .open_config => try self.openConfig(rt_app), .reload_config => try self.reloadConfig(rt_app), .close_all_windows => { diff --git a/src/Surface.zig b/src/Surface.zig index b5f7b9293..85a5face0 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3416,19 +3416,22 @@ fn showMouse(self: *Surface) void { /// will ever return false. We can expand this in the future if it becomes /// useful. We did previous/next tab so we could implement #498. pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool { - // Handle app-scoped bindings by sending it to the app. - switch (action.scope()) { - .app => { - try self.app.performAction( + // Forward app-scoped actions to the app. Some app-scoped actions are + // special-cased here because they do some special things when performed + // from the surface. + if (action.scoped(.app)) |app_action| { + switch (app_action) { + .new_window => try self.app.newWindow( + self.rt_app, + .{ .parent = self }, + ), + + else => try self.app.performAction( self.rt_app, action.scoped(.app).?, - ); - - return true; - }, - - // Surface fallthrough and handle - .surface => {}, + ), + } + return true; } switch (action.scoped(.surface).?) { @@ -3653,8 +3656,6 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool v, ), - .new_window => try self.app.newWindow(self.rt_app, .{ .parent = self }), - .new_tab => { if (@hasDecl(apprt.Surface, "newTab")) { try self.rt_surface.newTab(); diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index e0eecf8cc..01e3d0743 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -86,7 +86,8 @@ pub const App = struct { /// New tab with options. new_tab: ?*const fn (SurfaceUD, apprt.Surface.Options) callconv(.C) void = null, - /// New window with options. + /// New window with options. The surface may be null if there is no + /// target surface. new_window: ?*const fn (SurfaceUD, apprt.Surface.Options) callconv(.C) void = null, /// Control the inspector visibility @@ -495,14 +496,19 @@ pub const App = struct { } pub fn newWindow(self: *App, parent: ?*CoreSurface) !void { - _ = self; - - // Right now we only support creating a new window with a parent - // through this code. - // The other case is handled by the embedding runtime. + // If we have a parent, the surface logic handles it. if (parent) |surface| { try surface.rt_surface.newWindow(); + return; } + + // No parent, call the new window callback. + const func = self.opts.new_window orelse { + log.info("runtime embedder does not support new_window", .{}); + return; + }; + + func(null, .{}); } }; diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 93e046d1a..8f129065d 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -279,7 +279,8 @@ pub const Action = union(enum) { /// available values. write_selection_file: WriteScreenAction, - /// Open a new window. + /// 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. @@ -562,6 +563,10 @@ pub const Action = union(enum) { .quit, => .app, + // These are app but can be special-cased in a surface context. + .new_window, + => .app, + // Obviously surface actions. .csi, .esc, @@ -593,12 +598,12 @@ pub const Action = union(enum) { .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_window, .new_tab, .previous_tab, .next_tab, From 6d6052d204a19bfa41776e7864d6f42eb32dc587 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Sep 2024 20:53:32 -0700 Subject: [PATCH 13/13] small comment changes --- macos/Sources/App/macOS/AppDelegate.swift | 4 ++-- src/apprt/embedded.zig | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 1aeea6626..44f0f0291 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -431,8 +431,8 @@ class AppDelegate: NSObject, } } - // If our reload adds global keybinds and we don't have ax permissions then - // we need to request them. + // We need to handle our global event tap depending on if there are global + // events that we care about in Ghostty. if (ghostty_app_has_global_keybinds(ghostty.app!)) { if (timeSinceLaunch > 5) { // If the process has been running for awhile we enable right away diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 01e3d0743..c540694d0 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -208,9 +208,6 @@ pub const App = struct { target: KeyTarget, event: KeyEvent, ) !bool { - // NOTE: If this is updated, take a look at Surface.keyCallback as well. - // Their logic is very similar but not identical. - const action = event.action; const keycode = event.keycode; const mods = event.mods;