Skip to content

Commit

Permalink
Merge pull request #2418 from ghostty-org/apprt-key-seq
Browse files Browse the repository at this point in the history
macOS: Key Sequence UI
  • Loading branch information
mitchellh authored Oct 9, 2024
2 parents 78d6cfb + 8994a8c commit 3e55c47
Show file tree
Hide file tree
Showing 14 changed files with 241 additions and 71 deletions.
8 changes: 8 additions & 0 deletions include/ghostty.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
35 changes: 35 additions & 0 deletions macos/Sources/Ghostty/Ghostty.App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
47 changes: 1 addition & 46 deletions macos/Sources/Ghostty/Ghostty.Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
57 changes: 57 additions & 0 deletions macos/Sources/Ghostty/Ghostty.Input.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 6 additions & 1 deletion macos/Sources/Ghostty/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
28 changes: 28 additions & 0 deletions macos/Sources/Ghostty/SurfaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions macos/Sources/Ghostty/SurfaceView_AppKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 }
Expand Down
2 changes: 1 addition & 1 deletion src/App.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 25 additions & 1 deletion src/Surface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
},

Expand Down Expand Up @@ -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| {
Expand Down
Loading

0 comments on commit 3e55c47

Please sign in to comment.