Merge pull request #2418 from ghostty-org/apprt-key-seq

macOS: Key Sequence UI
This commit is contained in:
Mitchell Hashimoto
2024-10-09 09:30:53 -07:00
committed by GitHub
14 changed files with 241 additions and 71 deletions

View File

@ -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 {

View File

@ -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

View File

@ -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

View File

@ -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);

View File

@ -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 {}

View File

@ -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..<surfaceView.keySequence.count, id: \.description) { index in
let key = surfaceView.keySequence[index]
Text(verbatim: key.description)
.font(.system(.body, design: .monospaced))
.padding(3)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(Color(NSColor.selectedTextBackgroundColor))
)
}
}
.padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding))
.frame(maxWidth: .infinity)
.background(.background)
}
}
#endif
// If we have a URL from hovering a link, we show that.
if let url = surfaceView.hoverUrl {
let padding: CGFloat = 3

View File

@ -30,6 +30,9 @@ extension Ghostty {
// The hovered URL string
@Published var hoverUrl: String? = nil
// The currently active key sequence. The sequence is not active if this is empty.
@Published var keySequence: [Ghostty.KeyEquivalent] = []
// The time this surface last became focused. This is a ContinuousClock.Instant
// on supported platforms.
@Published var focusInstant: Any? = nil
@ -132,6 +135,16 @@ extension Ghostty {
selector: #selector(onUpdateRendererHealth),
name: Ghostty.Notification.didUpdateRendererHealth,
object: self)
center.addObserver(
self,
selector: #selector(ghosttyDidContinueKeySequence),
name: Ghostty.Notification.didContinueKeySequence,
object: self)
center.addObserver(
self,
selector: #selector(ghosttyDidEndKeySequence),
name: Ghostty.Notification.didEndKeySequence,
object: self)
center.addObserver(
self,
selector: #selector(windowDidChangeScreen),
@ -316,6 +329,16 @@ extension Ghostty {
healthy = health == GHOSTTY_RENDERER_HEALTH_OK
}
@objc private func ghosttyDidContinueKeySequence(notification: SwiftUI.Notification) {
guard let keyAny = notification.userInfo?[Ghostty.Notification.KeySequenceKey] else { return }
guard let key = keyAny as? Ghostty.KeyEquivalent else { return }
keySequence.append(key)
}
@objc private func ghosttyDidEndKeySequence(notification: SwiftUI.Notification) {
keySequence = []
}
@objc private func windowDidChangeScreen(notification: SwiftUI.Notification) {
guard let window = self.window else { return }
guard let object = notification.object as? NSWindow, window == object else { return }

View File

@ -318,7 +318,7 @@ pub fn keyEvent(
// 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) {
const leaf: input.Binding.Set.Leaf = switch (entry.value_ptr.*) {
// Sequences aren't supported. Our configuration parser verifies
// this for global keybinds but we may still get an entry for
// a non-global keybind.

View File

@ -1697,7 +1697,7 @@ fn maybeHandleBinding(
};
// Determine if this entry has an action or if its a leader key.
const leaf: input.Binding.Set.Leaf = switch (entry) {
const leaf: input.Binding.Set.Leaf = switch (entry.value_ptr.*) {
.leader => |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| {

View File

@ -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 = .{} },
};
}
};

View File

@ -203,6 +203,7 @@ pub const App = struct {
.render_inspector,
.quit_timer,
.secure_input,
.key_sequence,
.desktop_notification,
.mouse_over_link,
.cell_size,

View File

@ -411,6 +411,7 @@ pub fn performAction(
.size_limit,
.cell_size,
.secure_input,
.key_sequence,
.render_inspector,
.renderer_health,
=> log.warn("unimplemented action={}", .{action}),

View File

@ -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.

View File

@ -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);
}