macos: implement key sequence UI

This commit is contained in:
Mitchell Hashimoto
2024-10-08 21:54:22 -07:00
parent 5c1ffbb642
commit 8994a8c627
6 changed files with 149 additions and 48 deletions

View File

@ -516,7 +516,8 @@ extension Ghostty {
toggleVisibility(app, target: target)
case GHOSTTY_ACTION_KEY_SEQUENCE:
fallthrough
keySequence(app, target: target, v: action.action.key_sequence)
case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS:
fallthrough
case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW:
@ -1073,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 }