mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-05-23 21:30:19 +00:00
macos: suppress control-char input while composing (#12518)
macos: suppress control-char input while composing When AppKit delivers a single C0 control character during marked-text composition, Ghostty should treat it as input consumed by the composing state instead of forwarding it to the terminal. This prevents control-key IME actions, such as Japanese input shortcuts like ctrl+h/j/m/n, from leaking into the terminal while composition is still active. Printable text and non-composing control input continue through the normal key path. Refs #10460 Related: #2628, #4539 Vouched in #12169 Testing: - xcodebuild test -scheme Ghostty -destination platform=macOS -only-testing:GhosttyTests/SurfaceViewAppKitTests - Manually tested Japanese IME control-key shortcuts on macOS AI usage: - OpenAI Codex helped investigate, implement, test, and refine this change. I reviewed and tested the resulting code.
This commit is contained in:
@@ -1130,6 +1130,14 @@ extension Ghostty {
|
||||
// we control the preedit state only through the preedit API.
|
||||
syncPreedit(clearIfNeeded: markedTextBefore)
|
||||
|
||||
// We're composing if we have preedit (the obvious case). But we're also
|
||||
// composing if we don't have preedit and we had marked text before,
|
||||
// because this input probably just reset the preedit state. It shouldn't
|
||||
// be encoded. Example: Japanese begin composing, then press backspace
|
||||
// or ctrl+h. This should only cancel the composing state but not
|
||||
// actually delete the prior input characters (prior to the composing).
|
||||
let composing = markedText.length > 0 || markedTextBefore
|
||||
|
||||
// Korean IMEs on macOS may commit preedit text via insertText
|
||||
// while handling an arrow key. Send that committed text separately
|
||||
// before replaying arrow movement, except for plain left-arrow
|
||||
@@ -1157,10 +1165,20 @@ extension Ghostty {
|
||||
}
|
||||
|
||||
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.
|
||||
// Accumulated text from interpretKeyEvents (committed by the IME).
|
||||
for text in list {
|
||||
// Drop bare control characters the IME accumulated while
|
||||
// composing so they don't leak through to the terminal.
|
||||
if Ghostty.SurfaceView.shouldSuppressComposingControlInput(
|
||||
text,
|
||||
composing: composing
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
// We've composed a character; send it down. keyAction's
|
||||
// default composing=false applies because this is the
|
||||
// committed result of a composition, not in-progress preedit.
|
||||
_ = keyAction(
|
||||
action,
|
||||
event: event,
|
||||
@@ -1169,20 +1187,22 @@ extension Ghostty {
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Raw control characters (e.g. ctrl+h) arriving during
|
||||
// composition belong to the IME, not the terminal.
|
||||
if Ghostty.SurfaceView.shouldSuppressComposingControlInput(
|
||||
event.characters,
|
||||
composing: composing
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// We have no accumulated text so this is a normal key event.
|
||||
_ = keyAction(
|
||||
action,
|
||||
event: event,
|
||||
translationEvent: translationEvent,
|
||||
text: translationEvent.ghosttyCharacters,
|
||||
|
||||
// We're composing if we have preedit (the obvious case). But we're also
|
||||
// composing if we don't have preedit and we had marked text before,
|
||||
// because this input probably just reset the preedit state. It shouldn't
|
||||
// be encoded. Example: Japanese begin composing, the press backspace.
|
||||
// This should only cancel the composing state but not actually delete
|
||||
// the prior input characters (prior to the composing).
|
||||
composing: markedText.length > 0 || markedTextBefore
|
||||
composing: composing
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2025,6 +2045,22 @@ extension Ghostty.SurfaceView: NSTextInputClient {
|
||||
ghostty_surface_preedit(surface, nil, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `text` is a single C0 control character (U+0000-U+001F)
|
||||
/// arriving while the IME is composing. Such input belongs to the IME
|
||||
/// and must not be forwarded to the terminal.
|
||||
static func shouldSuppressComposingControlInput(
|
||||
_ text: String?,
|
||||
composing: Bool
|
||||
) -> Bool {
|
||||
guard composing, let text else { return false }
|
||||
let scalars = text.unicodeScalars
|
||||
guard let scalar = scalars.first,
|
||||
scalars.index(after: scalars.startIndex) == scalars.endIndex else {
|
||||
return false
|
||||
}
|
||||
return scalar.value < 0x20
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Services
|
||||
|
||||
44
macos/Tests/Ghostty/SurfaceViewAppKitTests.swift
Normal file
44
macos/Tests/Ghostty/SurfaceViewAppKitTests.swift
Normal file
@@ -0,0 +1,44 @@
|
||||
@testable import Ghostty
|
||||
import Testing
|
||||
|
||||
struct SurfaceViewAppKitTests {
|
||||
@Test(arguments: [
|
||||
("\u{0008}", true),
|
||||
("\u{001F}", true),
|
||||
("\u{007F}", false),
|
||||
(" ", false),
|
||||
("h", false),
|
||||
("", false),
|
||||
("\u{0009}x", false),
|
||||
("\u{0009}\u{0009}", false),
|
||||
])
|
||||
func suppressesOnlySingleC0ControlTextWhileComposing(
|
||||
text: String,
|
||||
expected: Bool
|
||||
) {
|
||||
#expect(
|
||||
Ghostty.SurfaceView.shouldSuppressComposingControlInput(
|
||||
text,
|
||||
composing: true
|
||||
) == expected
|
||||
)
|
||||
}
|
||||
|
||||
@Test func doesNotSuppressControlTextWhenNotComposing() {
|
||||
#expect(
|
||||
Ghostty.SurfaceView.shouldSuppressComposingControlInput(
|
||||
"\u{0008}",
|
||||
composing: false
|
||||
) == false
|
||||
)
|
||||
}
|
||||
|
||||
@Test func doesNotSuppressMissingText() {
|
||||
#expect(
|
||||
Ghostty.SurfaceView.shouldSuppressComposingControlInput(
|
||||
nil,
|
||||
composing: true
|
||||
) == false
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user