From d343a6cad27830294c353bca3ed825094b9b2a9f Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Sun, 5 Apr 2026 14:35:24 -0500 Subject: [PATCH] Fix Ctrl+C and other control keys not working in terminal (#49) Ctrl+key combinations were broken because the key event metadata sent to libghostty was incomplete. Control characters (e.g. \x03 for Ctrl+C) were passed as text, but Ghostty handles control-character mapping internally. Align key event construction with upstream Ghostty: set consumed_mods and unshifted_codepoint, strip control characters from text, add flagsChanged for modifier tracking, and add performKeyEquivalent to prevent macOS from intercepting Ctrl+key events. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../CrowTerminal/GhosttySurfaceView.swift | 105 +++++++++++++++--- 1 file changed, 89 insertions(+), 16 deletions(-) diff --git a/Packages/CrowTerminal/Sources/CrowTerminal/GhosttySurfaceView.swift b/Packages/CrowTerminal/Sources/CrowTerminal/GhosttySurfaceView.swift index 95e2520..57edc84 100644 --- a/Packages/CrowTerminal/Sources/CrowTerminal/GhosttySurfaceView.swift +++ b/Packages/CrowTerminal/Sources/CrowTerminal/GhosttySurfaceView.swift @@ -179,6 +179,21 @@ public final class GhosttySurfaceView: NSView { // MARK: - Keyboard Input + public override func performKeyEquivalent(with event: NSEvent) -> Bool { + guard event.type == .keyDown else { return false } + guard surface != nil else { return false } + + // Intercept Ctrl+key and Cmd+key events so macOS doesn't steal them + // from the terminal (e.g., Ctrl+C, Ctrl+/, Ctrl+Enter). + // Cmd+C and Cmd+V are handled inside keyDown explicitly. + if event.modifierFlags.contains(.control) || event.modifierFlags.contains(.command) { + self.keyDown(with: event) + return true + } + + return false + } + public override func keyDown(with event: NSEvent) { guard let surface else { return } @@ -196,21 +211,48 @@ public final class GhosttySurfaceView: NSView { // Send key press to libghostty var key = ghostty_input_key_s() - key.action = GHOSTTY_ACTION_PRESS + key.action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS key.mods = translateMods(event.modifierFlags) key.keycode = UInt32(event.keyCode) + key.composing = false - // Only set text for printable characters (not function keys, arrows, etc.) - // macOS uses Unicode private use area F700-F7FF for function keys - let isPrintable: Bool - if let chars = event.characters, let scalar = chars.unicodeScalars.first { - isPrintable = scalar.value < 0xF700 && !event.modifierFlags.contains(.command) - } else { - isPrintable = false + // consumed_mods: control and command never contribute to text translation on macOS + key.consumed_mods = translateMods(event.modifierFlags.subtracting([.control, .command])) + + // unshifted_codepoint: the base character with no modifiers applied + if event.type == .keyDown || event.type == .keyUp { + if let chars = event.characters(byApplyingModifiers: []), + let codepoint = chars.unicodeScalars.first { + key.unshifted_codepoint = codepoint.value + } } - if isPrintable, let chars = event.characters { - chars.withCString { ptr in + // Determine the text to send with the key event. + // Control characters (< 0x20) are NOT sent as text — Ghostty handles + // control-character mapping internally. For control chars, we send the + // character with the control modifier stripped so Ghostty knows which key + // was pressed. Function keys in the PUA range (F700-F8FF) are also excluded. + let text: String? = { + guard let characters = event.characters else { return nil } + if characters.count == 1, let scalar = characters.unicodeScalars.first { + if scalar.value < 0x20 { + // Control character — return the un-control'd character + return event.characters(byApplyingModifiers: event.modifierFlags.subtracting(.control)) + } + if scalar.value >= 0xF700 && scalar.value <= 0xF8FF { + // Function key in Private Use Area — no text + return nil + } + } + // Don't send text for command-modified keys + if event.modifierFlags.contains(.command) { return nil } + return characters + }() + + // Only send text if the first codepoint is printable (>= 0x20) + if let text, text.count > 0, + let codepoint = text.utf8.first, codepoint >= 0x20 { + text.withCString { ptr in key.text = ptr let handled = ghostty_surface_key(surface, key) if !handled { @@ -259,6 +301,31 @@ public final class GhosttySurfaceView: NSView { ghostty_surface_key(surface, key) } + public override func flagsChanged(with event: NSEvent) { + guard let surface else { return } + + let mod: UInt32 + switch event.keyCode { + case 0x39: mod = GHOSTTY_MODS_CAPS.rawValue + case 0x38, 0x3C: mod = GHOSTTY_MODS_SHIFT.rawValue + case 0x3B, 0x3E: mod = GHOSTTY_MODS_CTRL.rawValue + case 0x3A, 0x3D: mod = GHOSTTY_MODS_ALT.rawValue + case 0x37, 0x36: mod = GHOSTTY_MODS_SUPER.rawValue + default: return + } + + let mods = translateMods(event.modifierFlags) + let action: ghostty_input_action_e = (mods.rawValue & mod != 0) + ? GHOSTTY_ACTION_PRESS + : GHOSTTY_ACTION_RELEASE + + var key = ghostty_input_key_s() + key.action = action + key.mods = mods + key.keycode = UInt32(event.keyCode) + ghostty_surface_key(surface, key) + } + // MARK: - Mouse Input public override func mouseDown(with event: NSEvent) { @@ -370,12 +437,18 @@ public final class GhosttySurfaceView: NSView { // MARK: - Helpers private func translateMods(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e { - var mods = ghostty_input_mods_e(rawValue: 0) - if flags.contains(.shift) { mods = ghostty_input_mods_e(rawValue: mods.rawValue | GHOSTTY_MODS_SHIFT.rawValue) } - if flags.contains(.control) { mods = ghostty_input_mods_e(rawValue: mods.rawValue | GHOSTTY_MODS_CTRL.rawValue) } - if flags.contains(.option) { mods = ghostty_input_mods_e(rawValue: mods.rawValue | GHOSTTY_MODS_ALT.rawValue) } - if flags.contains(.command) { mods = ghostty_input_mods_e(rawValue: mods.rawValue | GHOSTTY_MODS_SUPER.rawValue) } - return mods + var mods: UInt32 = GHOSTTY_MODS_NONE.rawValue + if flags.contains(.shift) { mods |= GHOSTTY_MODS_SHIFT.rawValue } + if flags.contains(.control) { mods |= GHOSTTY_MODS_CTRL.rawValue } + if flags.contains(.option) { mods |= GHOSTTY_MODS_ALT.rawValue } + if flags.contains(.command) { mods |= GHOSTTY_MODS_SUPER.rawValue } + if flags.contains(.capsLock) { mods |= GHOSTTY_MODS_CAPS.rawValue } + let raw = flags.rawValue + if raw & UInt(NX_DEVICERSHIFTKEYMASK) != 0 { mods |= GHOSTTY_MODS_SHIFT_RIGHT.rawValue } + if raw & UInt(NX_DEVICERCTLKEYMASK) != 0 { mods |= GHOSTTY_MODS_CTRL_RIGHT.rawValue } + if raw & UInt(NX_DEVICERALTKEYMASK) != 0 { mods |= GHOSTTY_MODS_ALT_RIGHT.rawValue } + if raw & UInt(NX_DEVICERCMDKEYMASK) != 0 { mods |= GHOSTTY_MODS_SUPER_RIGHT.rawValue } + return ghostty_input_mods_e(rawValue: mods) } // MARK: - Cleanup