Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

macOS: Key Sequence UI #2418

Merged
merged 3 commits into from
Oct 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading