mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-28 01:51:43 +00:00
macOS/libghostty: rework keyboard input handling
This is a large refactor of the keyboard input handling code in libghostty and macOS. Previously, libghostty did a lot of things that felt out of scope or was repeated work due to lacking context. For example, libghostty would do full key translation from key event to character (including unshifted translation) as well as managing dead key states and setting the proper preedit text. This is all information the apprt can and should have on its own. NSEvent on macOS already provides us with all of this information, there's no need to redo the work. The reason we did in the first place is mostly historical: libghostty powered our initial macOS port years ago when we didn't have an AppKit runtime yet. This cruft has already practically been the source of numerous issues, e.g. #5558, but many other hacks along the way, too. This commit pushes all preedit (e.g. dead key) handling and key translation including unshifted keys up into the caller of libghostty. Besides code cleanup, a practical benefit of this is that key event handling on macOS is now about 10x faster on average. That's because we're avoiding repeated key translations as well as other unnecessary work. This should have a meaningful impact on input latency but I didn't measure the full end-to-end latency. A scarier part of this commit is that key handling is not well tested since its a GUI component. I suspect we'll have some fallout for certain keyboard layouts or input methods, but I did my best to run through everything I could think of.
This commit is contained in:
@@ -3,13 +3,56 @@ import GhosttyKit
|
||||
|
||||
extension NSEvent {
|
||||
/// Create a Ghostty key event for a given keyboard action.
|
||||
///
|
||||
/// This will not set the "text" or "composing" fields since these can't safely be set
|
||||
/// with the information or lifetimes given.
|
||||
func ghosttyKeyEvent(_ action: ghostty_input_action_e) -> ghostty_input_key_s {
|
||||
var key_ev = ghostty_input_key_s()
|
||||
var key_ev: ghostty_input_key_s = .init()
|
||||
key_ev.action = action
|
||||
key_ev.mods = Ghostty.ghosttyMods(modifierFlags)
|
||||
key_ev.keycode = UInt32(keyCode)
|
||||
|
||||
// We can't infer or set these safely from this method. Since text is
|
||||
// a cString, we can't use self.characters because of garbage collection.
|
||||
// We have to let the caller handle this.
|
||||
key_ev.text = nil
|
||||
key_ev.composing = false
|
||||
|
||||
// macOS provides no easy way to determine the consumed modifiers for
|
||||
// producing text. We apply a simple heuristic here that has worked for years
|
||||
// so far: control and command never contribute to the translation of text,
|
||||
// assume everything else did.
|
||||
key_ev.mods = Ghostty.ghosttyMods(modifierFlags)
|
||||
key_ev.consumed_mods = Ghostty.ghosttyMods(modifierFlags.subtracting([.control, .command]))
|
||||
|
||||
// Our unshifted codepoint is the codepoint with no modifiers. We
|
||||
// ignore multi-codepoint values.
|
||||
key_ev.unshifted_codepoint = 0
|
||||
if let charactersIgnoringModifiers,
|
||||
let codepoint = charactersIgnoringModifiers.unicodeScalars.first
|
||||
{
|
||||
key_ev.unshifted_codepoint = codepoint.value
|
||||
}
|
||||
|
||||
return key_ev
|
||||
}
|
||||
|
||||
/// Returns the text to set for a key event for Ghostty.
|
||||
///
|
||||
/// This namely contains logic to avoid control characters, since we handle control character
|
||||
/// mapping manually within Ghostty.
|
||||
var ghosttyCharacters: String? {
|
||||
// If we have no characters associated with this event we do nothing.
|
||||
guard let characters else { return nil }
|
||||
|
||||
// If we have a single control character, then we return the characters
|
||||
// without control pressed. We do this because we handle control character
|
||||
// encoding directly within Ghostty's KeyEncoder.
|
||||
if characters.count == 1,
|
||||
let scalar = characters.unicodeScalars.first,
|
||||
scalar.value < 0x20 {
|
||||
return self.characters(byApplyingModifiers: modifierFlags.subtracting(.control))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -951,29 +951,39 @@ extension Ghostty {
|
||||
return
|
||||
}
|
||||
|
||||
// If we have text, then we've composed a character, send that down. We do this
|
||||
// first because if we completed a preedit, the text will be available here
|
||||
// AND we'll have a preedit.
|
||||
var handled: Bool = false
|
||||
if let list = keyTextAccumulator, list.count > 0 {
|
||||
handled = true
|
||||
for text in list {
|
||||
_ = keyAction(action, event: event, text: text)
|
||||
// If we have marked text, we're in a preedit state. The order we
|
||||
// do this and the key event callbacks below doesn't matter since
|
||||
// we control the preedit state only through the preedit API.
|
||||
if markedText.length > 0 {
|
||||
let str = markedText.string
|
||||
let len = str.utf8CString.count
|
||||
if len > 0 {
|
||||
markedText.string.withCString { ptr in
|
||||
// Subtract 1 for the null terminator
|
||||
ghostty_surface_preedit(surface, ptr, UInt(len - 1))
|
||||
}
|
||||
}
|
||||
} else if markedTextBefore {
|
||||
// If we had marked text before but don't now, we're no longer
|
||||
// in a preedit state so we can clear it.
|
||||
ghostty_surface_preedit(surface, nil, 0)
|
||||
}
|
||||
|
||||
// If we have marked text, we're in a preedit state. Send that down.
|
||||
// If we don't have marked text but we had marked text before, then the preedit
|
||||
// was cleared so we want to send down an empty string to ensure we've cleared
|
||||
// the preedit.
|
||||
if (markedText.length > 0 || markedTextBefore) {
|
||||
handled = true
|
||||
_ = keyAction(action, event: event, preedit: markedText.string)
|
||||
}
|
||||
|
||||
if (!handled) {
|
||||
// No text or anything, we want to handle this manually.
|
||||
_ = keyAction(action, event: event)
|
||||
if let list = keyTextAccumulator, list.count > 0 {
|
||||
// If we have text, then we've composed a character, send that down.
|
||||
// These never have "composing" set to true because these are the
|
||||
// result of a composition.
|
||||
for text in list {
|
||||
_ = keyAction(action, event: translationEvent, text: text)
|
||||
}
|
||||
} else {
|
||||
// We have no accumulated text so this is a normal key event.
|
||||
_ = keyAction(
|
||||
action,
|
||||
event: translationEvent,
|
||||
text: translationEvent.ghosttyCharacters,
|
||||
composing: markedText.length > 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1165,34 +1175,22 @@ extension Ghostty {
|
||||
_ = keyAction(action, event: event)
|
||||
}
|
||||
|
||||
private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) -> Bool {
|
||||
guard let surface = self.surface else { return false }
|
||||
return ghostty_surface_key(surface, event.ghosttyKeyEvent(action))
|
||||
}
|
||||
|
||||
private func keyAction(
|
||||
_ action: ghostty_input_action_e,
|
||||
event: NSEvent, preedit: String
|
||||
event: NSEvent,
|
||||
text: String? = nil,
|
||||
composing: Bool = false
|
||||
) -> Bool {
|
||||
guard let surface = self.surface else { return false }
|
||||
|
||||
return preedit.withCString { ptr in
|
||||
var key_ev = event.ghosttyKeyEvent(action)
|
||||
key_ev.text = ptr
|
||||
key_ev.composing = true
|
||||
return ghostty_surface_key(surface, key_ev)
|
||||
}
|
||||
}
|
||||
|
||||
private func keyAction(
|
||||
_ action: ghostty_input_action_e,
|
||||
event: NSEvent, text: String
|
||||
) -> Bool {
|
||||
guard let surface = self.surface else { return false }
|
||||
|
||||
return text.withCString { ptr in
|
||||
var key_ev = event.ghosttyKeyEvent(action)
|
||||
key_ev.text = ptr
|
||||
var key_ev = event.ghosttyKeyEvent(action)
|
||||
key_ev.composing = composing
|
||||
if let text {
|
||||
return text.withCString { ptr in
|
||||
key_ev.text = ptr
|
||||
return ghostty_surface_key(surface, key_ev)
|
||||
}
|
||||
} else {
|
||||
return ghostty_surface_key(surface, key_ev)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user