diff --git a/include/ghostty.h b/include/ghostty.h index bf13500c8..6cc288b8f 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -500,6 +500,12 @@ typedef enum { GHOSTTY_RENDERER_HEALTH_UNHEALTHY, } ghostty_action_renderer_health_e; +// apprt.action.KeySequence +typedef struct { + bool active; + ghostty_input_trigger_s trigger; +} ghostty_action_key_sequence_s; + // apprt.Action.Key typedef enum { GHOSTTY_ACTION_NEW_WINDOW, @@ -531,6 +537,7 @@ typedef enum { GHOSTTY_ACTION_OPEN_CONFIG, GHOSTTY_ACTION_QUIT_TIMER, GHOSTTY_ACTION_SECURE_INPUT, + GHOSTTY_ACTION_KEY_SEQUENCE, } ghostty_action_tag_e; typedef union { @@ -551,6 +558,7 @@ typedef union { ghostty_action_renderer_health_e renderer_health; ghostty_action_quit_timer_e quit_timer; ghostty_action_secure_input_e secure_input; + ghostty_action_key_sequence_s key_sequence; } ghostty_action_u; typedef struct { diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 5716e9801..a2ed8903a 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -515,6 +515,9 @@ extension Ghostty { case GHOSTTY_ACTION_TOGGLE_VISIBILITY: toggleVisibility(app, target: target) + case GHOSTTY_ACTION_KEY_SEQUENCE: + keySequence(app, target: target, v: action.action.key_sequence) + case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: fallthrough case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: @@ -1071,6 +1074,38 @@ extension Ghostty { } } + private static func keySequence( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_key_sequence_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("key sequence does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + if v.active { + NotificationCenter.default.post( + name: Notification.didContinueKeySequence, + object: surfaceView, + userInfo: [ + Notification.KeySequenceKey: keyEquivalent(for: v.trigger) as Any + ] + ) + } else { + NotificationCenter.default.post( + name: Notification.didEndKeySequence, + object: surfaceView + ) + } + + default: + assertionFailure() + } + } + // MARK: User Notifications /// Handle a received user notification. This is called when a user notification is clicked or dismissed by the user diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 99e08bf9d..95f8ad734 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -87,25 +87,6 @@ extension Ghostty { #if os(macOS) // MARK: - Keybindings - /// A convenience struct that has the key + modifiers for some keybinding. - struct KeyEquivalent: CustomStringConvertible { - let key: String - let modifiers: NSEvent.ModifierFlags - - var description: String { - var key = self.key - - // Note: the order below matters; it matches the ordering modifiers - // shown for macOS menu shortcut labels. - if modifiers.contains(.command) { key = "⌘\(key)" } - if modifiers.contains(.shift) { key = "⇧\(key)" } - if modifiers.contains(.option) { key = "⌥\(key)" } - if modifiers.contains(.control) { key = "⌃\(key)" } - - return key - } - } - /// Return the key equivalent for the given action. The action is the name of the action /// in the Ghostty configuration. For example `keybind = cmd+q=quit` in Ghostty /// configuration would be "quit" action. @@ -115,33 +96,7 @@ extension Ghostty { guard let cfg = self.config else { return nil } let trigger = ghostty_config_trigger(cfg, action, UInt(action.count)) - let equiv: String - switch (trigger.tag) { - case GHOSTTY_TRIGGER_TRANSLATED: - if let v = Ghostty.keyEquivalent(key: trigger.key.translated) { - equiv = v - } else { - return nil - } - - case GHOSTTY_TRIGGER_PHYSICAL: - if let v = Ghostty.keyEquivalent(key: trigger.key.physical) { - equiv = v - } else { - return nil - } - - case GHOSTTY_TRIGGER_UNICODE: - equiv = String(trigger.key.unicode) - - default: - return nil - } - - return KeyEquivalent( - key: equiv, - modifiers: Ghostty.eventModifierFlags(mods: trigger.mods) - ) + return Ghostty.keyEquivalent(for: trigger) } #endif diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index d7fd96f12..43bf8d096 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -2,11 +2,68 @@ import Cocoa import GhosttyKit extension Ghostty { + // MARK: Key Equivalents + /// Returns the "keyEquivalent" string for a given input key. This doesn't always have a corresponding key. static func keyEquivalent(key: ghostty_input_key_e) -> String? { return Self.keyToEquivalent[key] } + /// A convenience struct that has the key + modifiers for some keybinding. + struct KeyEquivalent: CustomStringConvertible { + let key: String + let modifiers: NSEvent.ModifierFlags + + var description: String { + var key = self.key + + // Note: the order below matters; it matches the ordering modifiers + // shown for macOS menu shortcut labels. + if modifiers.contains(.command) { key = "⌘\(key)" } + if modifiers.contains(.shift) { key = "⇧\(key)" } + if modifiers.contains(.option) { key = "⌥\(key)" } + if modifiers.contains(.control) { key = "⌃\(key)" } + + return key + } + } + + /// Return the key equivalent for the given trigger. + /// + /// Returns nil if the trigger can't be processed. This should only happen for unknown trigger types + /// or keys. + static func keyEquivalent(for trigger: ghostty_input_trigger_s) -> KeyEquivalent? { + let equiv: String + switch (trigger.tag) { + case GHOSTTY_TRIGGER_TRANSLATED: + if let v = Ghostty.keyEquivalent(key: trigger.key.translated) { + equiv = v + } else { + return nil + } + + case GHOSTTY_TRIGGER_PHYSICAL: + if let v = Ghostty.keyEquivalent(key: trigger.key.physical) { + equiv = v + } else { + return nil + } + + case GHOSTTY_TRIGGER_UNICODE: + equiv = String(trigger.key.unicode) + + default: + return nil + } + + return KeyEquivalent( + key: equiv, + modifiers: Ghostty.eventModifierFlags(mods: trigger.mods) + ) + } + + // MARK: Mods + /// Returns the event modifier flags set for the Ghostty mods enum. static func eventModifierFlags(mods: ghostty_input_mods_e) -> NSEvent.ModifierFlags { var flags = NSEvent.ModifierFlags(rawValue: 0); diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 1c5695319..63a41596a 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -262,7 +262,12 @@ extension Ghostty.Notification { /// Notification that renderer health changed static let didUpdateRendererHealth = Notification.Name("com.mitchellh.ghostty.didUpdateRendererHealth") + + /// Notifications related to key sequences + static let didContinueKeySequence = Notification.Name("com.mitchellh.ghostty.didContinueKeySequence") + static let didEndKeySequence = Notification.Name("com.mitchellh.ghostty.didEndKeySequence") + static let KeySequenceKey = didContinueKeySequence.rawValue + ".key" } // Make the input enum hashable. -extension ghostty_input_key_e : Hashable {} +extension ghostty_input_key_e : @retroactive Hashable {} diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 5eb277ba1..b7643aada 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -184,6 +184,34 @@ extension Ghostty { } .ghosttySurfaceView(surfaceView) +#if canImport(AppKit) + // If we are in the middle of a key sequence, then we show a visual element. We only + // support this on macOS currently although in theory we can support mobile with keyboards! + if !surfaceView.keySequence.isEmpty { + let padding: CGFloat = 5 + VStack { + Spacer() + + HStack { + Text(verbatim: "Pending Key Sequence:") + ForEach(0.. |set| { // Setup the next set we'll look at. self.keyboard.bindings = set; @@ -1709,6 +1709,18 @@ fn maybeHandleBinding( try self.keyboard.queued.append(self.alloc, req); } + // Start or continue our key sequence + self.rt_app.performAction( + .{ .surface = self }, + .key_sequence, + .{ .trigger = entry.key_ptr.* }, + ) catch |err| { + log.warn( + "failed to notify app of key sequence err={}", + .{err}, + ); + }; + return .consumed; }, @@ -1795,6 +1807,18 @@ fn endKeySequence( action: KeySequenceQueued, mem: KeySequenceMemory, ) void { + // Notify apprt key sequence ended + self.rt_app.performAction( + .{ .surface = self }, + .key_sequence, + .end, + ) catch |err| { + log.warn( + "failed to notify app of key sequence end err={}", + .{err}, + ); + }; + if (self.keyboard.queued.items.len > 0) { switch (action) { .flush => for (self.keyboard.queued.items) |write_req| { diff --git a/src/apprt/action.zig b/src/apprt/action.zig index a5da51d1d..9fce8502f 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -1,6 +1,7 @@ const std = @import("std"); const assert = std.debug.assert; const apprt = @import("../apprt.zig"); +const input = @import("../input.zig"); const renderer = @import("../renderer.zig"); const terminal = @import("../terminal/main.zig"); const CoreSurface = @import("../Surface.zig"); @@ -173,6 +174,11 @@ pub const Action = union(Key) { /// system APIs to not log the input, etc. secure_input: SecureInput, + /// A sequenced key binding has started, continued, or stopped. + /// The UI should show some indication that the user is in a sequenced + /// key mode because other input may be ignored. + key_sequence: KeySequence, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { new_window, @@ -204,6 +210,7 @@ pub const Action = union(Key) { open_config, quit_timer, secure_input, + key_sequence, }; /// Sync with: ghostty_action_u @@ -411,3 +418,21 @@ pub const DesktopNotification = struct { }; } }; + +pub const KeySequence = union(enum) { + trigger: input.Trigger, + end, + + // Sync with: ghostty_action_key_sequence_s + pub const C = extern struct { + active: bool, + trigger: input.Trigger.C, + }; + + pub fn cval(self: KeySequence) C { + return switch (self) { + .trigger => |t| .{ .active = true, .trigger = t.cval() }, + .end => .{ .active = false, .trigger = .{} }, + }; + } +}; diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 948b38a29..980c2dba3 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -203,6 +203,7 @@ pub const App = struct { .render_inspector, .quit_timer, .secure_input, + .key_sequence, .desktop_notification, .mouse_over_link, .cell_size, diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 3855d27c3..b18753344 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -411,6 +411,7 @@ pub fn performAction( .size_limit, .cell_size, .secure_input, + .key_sequence, .render_inspector, .renderer_health, => log.warn("unimplemented action={}", .{action}), diff --git a/src/input.zig b/src/input.zig index 204adcfbe..9e3997d97 100644 --- a/src/input.zig +++ b/src/input.zig @@ -23,6 +23,7 @@ pub const MousePressureStage = mouse.PressureStage; pub const ScrollMods = mouse.ScrollMods; pub const SplitFocusDirection = Binding.Action.SplitFocusDirection; pub const SplitResizeDirection = Binding.Action.SplitResizeDirection; +pub const Trigger = Binding.Trigger; // Keymap is only available on macOS right now. We could implement it // in theory for XKB too on Linux but we don't need it right now. diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 9667f2d17..30984b0de 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1020,7 +1020,7 @@ pub const Trigger = struct { pub const Set = struct { const HashMap = std.HashMapUnmanaged( Trigger, - Entry, + Value, Context(Trigger), std.hash_map.default_max_load_percentage, ); @@ -1046,7 +1046,7 @@ pub const Set = struct { reverse: ReverseMap = .{}, /// The entry type for the forward mapping of trigger to action. - pub const Entry = union(enum) { + pub const Value = union(enum) { /// This key is a leader key in a sequence. You must follow the given /// set to find the next key in the sequence. leader: *Set, @@ -1058,7 +1058,7 @@ pub const Set = struct { /// Implements the formatter for the fmt package. This encodes the /// action back into the format used by parse. pub fn format( - self: Entry, + self: Value, comptime layout: []const u8, opts: std.fmt.FormatOptions, writer: anytype, @@ -1100,6 +1100,9 @@ pub const Set = struct { } }; + /// A full key-value entry for the set. + pub const Entry = HashMap.Entry; + pub fn deinit(self: *Set, alloc: Allocator) void { // Clear any leaders if we have them var it = self.bindings.iterator(); @@ -1160,7 +1163,11 @@ pub const Set = struct { switch (elem) { .leader => |t| { // If we have a leader, we need to upsert a set for it. - const old = set.get(t); + // Since we remove the value, we need to copy it. + const old: ?Value = if (set.get(t)) |entry| + entry.value_ptr.* + else + null; if (old) |entry| switch (entry) { // We have an existing leader for this key already // so recurse into this set. @@ -1289,7 +1296,7 @@ pub const Set = struct { /// Get a binding for a given trigger. pub fn get(self: Set, t: Trigger) ?Entry { - return self.bindings.get(t); + return self.bindings.getEntry(t); } /// Get a trigger for the given action. An action can have multiple @@ -1811,7 +1818,7 @@ test "set: parseAndPut typical binding" { // Creates forward mapping { - const action = s.get(.{ .key = .{ .translated = .a } }).?.leaf; + const action = s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.*.leaf; try testing.expect(action.action == .new_window); try testing.expectEqual(Flags{}, action.flags); } @@ -1835,7 +1842,7 @@ test "set: parseAndPut unconsumed binding" { // Creates forward mapping { const trigger: Trigger = .{ .key = .{ .translated = .a } }; - const action = s.get(trigger).?.leaf; + const action = s.get(trigger).?.value_ptr.*.leaf; try testing.expect(action.action == .new_window); try testing.expectEqual(Flags{ .consumed = false }, action.flags); } @@ -1876,13 +1883,13 @@ test "set: parseAndPut sequence" { var current: *Set = &s; { const t: Trigger = .{ .key = .{ .translated = .a } }; - const e = current.get(t).?; + const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leader); current = e.leader; } { const t: Trigger = .{ .key = .{ .translated = .b } }; - const e = current.get(t).?; + const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leaf); try testing.expect(e.leaf.action == .new_window); try testing.expectEqual(Flags{}, e.leaf.flags); @@ -1901,20 +1908,20 @@ test "set: parseAndPut sequence with two actions" { var current: *Set = &s; { const t: Trigger = .{ .key = .{ .translated = .a } }; - const e = current.get(t).?; + const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leader); current = e.leader; } { const t: Trigger = .{ .key = .{ .translated = .b } }; - const e = current.get(t).?; + const e = current.get(t).?.value_ptr.*; 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).?; + const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leaf); try testing.expect(e.leaf.action == .new_tab); try testing.expectEqual(Flags{}, e.leaf.flags); @@ -1933,13 +1940,13 @@ test "set: parseAndPut overwrite sequence" { var current: *Set = &s; { const t: Trigger = .{ .key = .{ .translated = .a } }; - const e = current.get(t).?; + const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leader); current = e.leader; } { const t: Trigger = .{ .key = .{ .translated = .b } }; - const e = current.get(t).?; + const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leaf); try testing.expect(e.leaf.action == .new_window); try testing.expectEqual(Flags{}, e.leaf.flags); @@ -1958,13 +1965,13 @@ test "set: parseAndPut overwrite leader" { var current: *Set = &s; { const t: Trigger = .{ .key = .{ .translated = .a } }; - const e = current.get(t).?; + const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leader); current = e.leader; } { const t: Trigger = .{ .key = .{ .translated = .b } }; - const e = current.get(t).?; + const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leaf); try testing.expect(e.leaf.action == .new_window); try testing.expectEqual(Flags{}, e.leaf.flags); @@ -2096,8 +2103,8 @@ 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 } }).? == .leaf); - try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.leaf.flags.consumed); + try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.* == .leaf); + try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.*.leaf.flags.consumed); try s.putFlags( alloc, @@ -2105,10 +2112,10 @@ test "set: consumed state" { .{ .new_window = {} }, .{ .consumed = false }, ); - try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .leaf); - try testing.expect(!s.get(.{ .key = .{ .translated = .a } }).?.leaf.flags.consumed); + try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.* == .leaf); + try testing.expect(!s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.*.leaf.flags.consumed); try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); - try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .leaf); - try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.leaf.flags.consumed); + try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.* == .leaf); + try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.*.leaf.flags.consumed); }