mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-08-02 14:57:31 +03:00
macos: implement key sequence UI
This commit is contained in:
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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 {}
|
||||
|
@ -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
|
||||
|
@ -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 }
|
||||
|
Reference in New Issue
Block a user