From 6229d4eb62f9e2483b3ea00c11fcde70590d2d84 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:10:16 +0200 Subject: [PATCH] macOS: fix AppleScript send key for non-control keys Co-Authored-By: Claude Fable 5 --- macos/Ghostty.sdef | 2 +- .../AppleScript/ScriptKeyEventCommand.swift | 7 +- macos/Sources/Ghostty/Ghostty.Input.swift | 60 +++++++++ macos/Sources/Ghostty/Ghostty.Surface.swift | 11 ++ .../Surface View/SurfaceView_AppKit.swift | 7 +- .../Helpers/Extensions/String+Extension.swift | 11 ++ .../ScriptKeyEventCommandTests.swift | 91 ++++---------- .../Ghostty/KeyEventSynthesizeTests.swift | 115 ++++++++++++++++++ 8 files changed, 228 insertions(+), 76 deletions(-) create mode 100644 macos/Tests/Ghostty/KeyEventSynthesizeTests.swift diff --git a/macos/Ghostty.sdef b/macos/Ghostty.sdef index c84cf2f6c..edf1c7e15 100644 --- a/macos/Ghostty.sdef +++ b/macos/Ghostty.sdef @@ -226,7 +226,7 @@ - + diff --git a/macos/Sources/Features/AppleScript/ScriptKeyEventCommand.swift b/macos/Sources/Features/AppleScript/ScriptKeyEventCommand.swift index d7dcf9d68..a7f0c8b17 100644 --- a/macos/Sources/Features/AppleScript/ScriptKeyEventCommand.swift +++ b/macos/Sources/Features/AppleScript/ScriptKeyEventCommand.swift @@ -33,7 +33,9 @@ final class ScriptKeyEventCommand: NSScriptCommand { do { keyEvent = try Self.parse( directParameter: directParameter, - evaluatedArguments: evaluatedArguments) + evaluatedArguments: evaluatedArguments, + translationMods: surface.keyTranslationMods, + ) } catch ArgumentError.missingKey { scriptErrorNumber = errAEParamMissed scriptErrorString = "Missing key name." @@ -108,9 +110,10 @@ extension ScriptKeyEventCommand { } return Ghostty.Input.KeyEvent( - key: key, + synthesizing: key, action: action, mods: mods, + translationMods: translationMods(mods), ) } } diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index 7a7371b89..fe28a4d51 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -240,6 +240,66 @@ extension Ghostty.Input { } } +extension Ghostty.Input.KeyEvent { + /// Create a translated key event for programmatic input (e.g. AppleScript). + /// + /// - Parameters: + /// - key: The key being pressed or released. + /// - action: The key action. + /// - mods: The full set of modifiers for the event. + /// - translationMods: The subset of `mods` that participates in text + /// translation. Use `Surface.keyTranslationMods(_:)` so that + /// configuration such as `macos-option-as-alt` is honored. + /// + /// - Note: Translation is a single stateless pass through the keyboard layout. + /// A key that starts a dead-key sequence (e.g. option+E on a US layout) + /// produces its standalone character or nothing. + @MainActor + init( + synthesizing key: Ghostty.Input.Key, + action: Ghostty.Input.Action, + mods: Ghostty.Input.Mods, + translationMods: Ghostty.Input.Mods + ) { + let keyCode = key.keyCode + + // Control never contributes to the translation of text, + // matching `NSEvent.ghosttyCharacters`. + let text: String? + if action == .release { + // We don't need to attach text to a release key event, + // as real NSEvents don't carry them in most cases. + text = nil + } else { + text = keyCode + .flatMap { + KeyboardLayout.character( + for: $0, + modifiers: translationMods.nsFlags.subtracting(.control)) + } + .flatMap { String($0).keyEventText } + } + + // The unshifted codepoint ignores all modifiers. Control characters are + // reported as no codepoint (0) so that Ghostty encodes such keys from + // the key enum instead. + let unshiftedCodepoint = keyCode + .flatMap { KeyboardLayout.character(for: $0, modifiers: []) } + .flatMap { String($0).keyEventText }? + .unicodeScalars.first?.value ?? 0 + + self.init( + key: key, + action: action, + text: text, + mods: mods, + // Same as `NSEvent.ghosttyKeyEvent` + consumedMods: translationMods.subtracting([.ctrl, .super]), + unshiftedCodepoint: unshiftedCodepoint + ) + } +} + // MARK: Ghostty.Input.Action extension Ghostty.Input { diff --git a/macos/Sources/Ghostty/Ghostty.Surface.swift b/macos/Sources/Ghostty/Ghostty.Surface.swift index 0ccf51c9f..278e8f4ff 100644 --- a/macos/Sources/Ghostty/Ghostty.Surface.swift +++ b/macos/Sources/Ghostty/Ghostty.Surface.swift @@ -58,6 +58,17 @@ extension Ghostty { } } + /// Returns the modifiers that participate in text translation for key + /// events on this surface. This honors configuration such as + /// `macos-option-as-alt`, which may exclude option from translation. + /// + /// - Parameter mods: The full set of modifiers for the key event. + /// - Returns: The subset of `mods` to use for keyboard layout translation. + @MainActor + func keyTranslationMods(_ mods: Input.Mods) -> Input.Mods { + Input.Mods(cMods: ghostty_surface_key_translation_mods(surface, mods.cMods)) + } + /// Send a key event to the terminal. /// /// This sends the full key event including modifiers, action type, and text to the terminal. diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 06b185985..f77985f7e 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -1484,12 +1484,7 @@ extension Ghostty { var key_ev = event.ghosttyKeyEvent(action, translationMods: translationEvent?.modifierFlags) key_ev.composing = composing - // Control characters are encoded by Ghostty itself so that the - // physical key and its modifiers remain available to protocols - // such as the Kitty keyboard protocol. - if let text, - !text.isEmpty, - !text.startsWithASCIIControlCharacter { + if let text = text?.keyEventText { return text.withCString { ptr in key_ev.text = ptr return ghostty_surface_key(surface, key_ev) diff --git a/macos/Sources/Helpers/Extensions/String+Extension.swift b/macos/Sources/Helpers/Extensions/String+Extension.swift index 491c18b2f..989386ae2 100644 --- a/macos/Sources/Helpers/Extensions/String+Extension.swift +++ b/macos/Sources/Helpers/Extensions/String+Extension.swift @@ -5,6 +5,17 @@ extension String { return scalar.value < 0x20 || scalar.value == 0x7F } + /// The string as the text of a terminal key event, or nil when it is empty + /// or begins with an ASCII control character. + /// + /// - Note: Control characters are encoded by Ghostty itself so that the + /// physical key and its modifiers remain available to protocols + /// such as the Kitty keyboard protocol. + var keyEventText: String? { + guard !isEmpty, !startsWithASCIIControlCharacter else { return nil } + return self + } + func truncate(length: Int, trailing: String = "…") -> String { let maxLength = length - trailing.count guard maxLength > 0, !self.isEmpty, self.count > length else { diff --git a/macos/Tests/Features/AppleScript/ScriptKeyEventCommandTests.swift b/macos/Tests/Features/AppleScript/ScriptKeyEventCommandTests.swift index 31e907bb1..b99724e75 100644 --- a/macos/Tests/Features/AppleScript/ScriptKeyEventCommandTests.swift +++ b/macos/Tests/Features/AppleScript/ScriptKeyEventCommandTests.swift @@ -110,34 +110,19 @@ struct ScriptKeyEventCommandTests { @Test func pressCarriesLayoutText() throws { let event = try parse("a") -// let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: [])) -// #expect(event.text == String(expected)) -// #expect(event.unshiftedCodepoint == expected.unicodeScalars.first?.value) -// #expect(event.consumedMods == []) - Issue.record( - """ - press should carry the layout's text and unshifted codepoint; \ - got text \(String(describing: event.text)), \ - codepoint \(event.unshiftedCodepoint) - """, - severity: .warning - ) + let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: [])) + #expect(event.text == String(expected)) + #expect(event.unshiftedCodepoint == expected.unicodeScalars.first?.value) + #expect(event.consumedMods == []) } @Test func shiftShiftsTextAndIsConsumed() throws { let event = try parse("a", modifiers: "shift") -// let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: .shift)) -// let unshifted = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: [])) -// #expect(event.text == String(expected)) -// #expect(event.consumedMods == .shift) -// #expect(event.unshiftedCodepoint == unshifted.unicodeScalars.first?.value) - Issue.record( - """ - shift should apply to the translated text and be consumed; \ - got text \(String(describing: event.text)) - """, - severity: .warning - ) + let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: .shift)) + let unshifted = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: [])) + #expect(event.text == String(expected)) + #expect(event.consumedMods == .shift) + #expect(event.unshiftedCodepoint == unshifted.unicodeScalars.first?.value) } /// The original bug scenario: `send key "c" with modifiers "control"` @@ -145,34 +130,20 @@ struct ScriptKeyEventCommandTests { /// unconsumed, so core can encode the control sequence itself. @Test func controlKeepsBaseTextAndIsNotConsumed() throws { let event = try parse("c", modifiers: "control") -// let expected = try #require(KeyboardLayout.character( -// for: 0x08, // W3C KeyC -// modifiers: [])) -// #expect(event.text == String(expected)) -// #expect(event.mods == .ctrl) -// #expect(event.consumedMods == []) - Issue.record( - """ - ctrl should be stripped from translation ("c", not 0x03) and \ - stay unconsumed; got text \(String(describing: event.text)) - """, - severity: .warning - ) + let expected = try #require(KeyboardLayout.character( + for: 0x08, // W3C KeyC + modifiers: [])) + #expect(event.text == String(expected)) + #expect(event.mods == .ctrl) + #expect(event.consumedMods == []) } @Test func optionIncludedInTranslationIsConsumed() throws { // macos-option-as-alt=false: option participates in translation. let event = try parse("a", modifiers: "option") -// let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: .option)) -// #expect(event.text == String(expected)) -// #expect(event.consumedMods == .alt) - Issue.record( - """ - option in the translation mods should apply to the text and be \ - consumed; got text \(String(describing: event.text)) - """, - severity: .warning - ) + let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: .option)) + #expect(event.text == String(expected)) + #expect(event.consumedMods == .alt) } @Test func optionExcludedFromTranslationIsNotConsumed() throws { @@ -180,16 +151,9 @@ struct ScriptKeyEventCommandTests { // option, so it stays unconsumed and core can encode it (e.g. ESC // prefix). let event = try parse("a", modifiers: "option") { $0.subtracting(.alt) } -// let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: [])) -// #expect(event.text == String(expected)) -// #expect(event.consumedMods == []) - Issue.record( - """ - option excluded from the translation mods should leave the base \ - text and stay unconsumed; got text \(String(describing: event.text)) - """, - severity: .warning - ) + let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: [])) + #expect(event.text == String(expected)) + #expect(event.consumedMods == []) } @Test func releaseCarriesNoText() throws { @@ -198,16 +162,9 @@ struct ScriptKeyEventCommandTests { modifiers: "shift", action: "GIrl".fourCharCode ) -// let unshifted = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: [])) -// #expect(event.text == nil) -// #expect(event.unshiftedCodepoint == unshifted.unicodeScalars.first?.value) - Issue.record( - """ - release should carry no text but still report the unshifted \ - codepoint; got codepoint \(event.unshiftedCodepoint) - """, - severity: .warning - ) + let unshifted = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: [])) + #expect(event.text == nil) + #expect(event.unshiftedCodepoint == unshifted.unicodeScalars.first?.value) } /// Keys whose layout translation is a control character (or a PUA diff --git a/macos/Tests/Ghostty/KeyEventSynthesizeTests.swift b/macos/Tests/Ghostty/KeyEventSynthesizeTests.swift new file mode 100644 index 000000000..bc252b0e6 --- /dev/null +++ b/macos/Tests/Ghostty/KeyEventSynthesizeTests.swift @@ -0,0 +1,115 @@ +import AppKit +import SwiftUI +import Testing +@testable import Ghostty +import GhosttyKit + +/// Tests for `Ghostty.Input.KeyEvent.init(synthesizing:...)`, the derivation +/// used for programmatic key input such as AppleScript's `send key`. +/// +/// Expected characters are computed through `KeyboardLayout` rather than +/// hardcoded so the tests hold on any keyboard layout; what's under test is +/// the text/consumed/unshifted derivation, not the layout itself. +@MainActor +struct KeyEventSynthesizeTests { + private let keyCodeA: UInt16 = 0x00 // W3C KeyA + + @Test func pressHasLayoutText() throws { + let event = Ghostty.Input.KeyEvent( + synthesizing: .a, action: .press, mods: [], translationMods: []) + let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: [])) + #expect(event.text == String(expected)) + #expect(event.unshiftedCodepoint == expected.unicodeScalars.first?.value) + #expect(event.consumedMods == []) + } + + @Test func shiftAppliesToTextAndIsConsumed() throws { + let event = Ghostty.Input.KeyEvent( + synthesizing: .a, action: .press, mods: .shift, translationMods: .shift) + let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: .shift)) + let unshifted = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: [])) + #expect(event.text == String(expected)) + #expect(event.consumedMods == .shift) + #expect(event.unshiftedCodepoint == unshifted.unicodeScalars.first?.value) + } + + @Test func controlIsNeverConsumedAndDoesNotAffectText() throws { + // Core passes ctrl through translation mods; the event must still + // produce the base character ("a", not 0x01) and not consume ctrl. + let event = Ghostty.Input.KeyEvent( + synthesizing: .a, action: .press, mods: .ctrl, translationMods: .ctrl) + let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: [])) + #expect(event.text == String(expected)) + #expect(event.consumedMods == []) + } + + @Test func optionFollowsTranslationMods() throws { + // macos-option-as-alt=false: option participates in translation and + // is consumed. + let translated = Ghostty.Input.KeyEvent( + synthesizing: .a, action: .press, mods: .alt, translationMods: .alt) + let optionChar = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: .option)) + #expect(translated.text == String(optionChar)) + #expect(translated.consumedMods == .alt) + + // macos-option-as-alt=true: option is excluded from translation and + // remains unconsumed, so core can encode it (e.g. ESC prefix). + let asAlt = Ghostty.Input.KeyEvent( + synthesizing: .a, action: .press, mods: .alt, translationMods: []) + let baseChar = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: [])) + #expect(asAlt.text == String(baseChar)) + #expect(asAlt.consumedMods == []) + } + + @Test func releaseHasNoText() throws { + let event = Ghostty.Input.KeyEvent( + synthesizing: .a, action: .release, mods: .shift, translationMods: .shift) + let unshifted = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: [])) + #expect(event.text == nil) + #expect(event.unshiftedCodepoint == unshifted.unicodeScalars.first?.value) + #expect(event.consumedMods == .shift) + } + + /// Every functional key with a Mac keycode. Their layout translations are + /// control characters (or nothing), which must never be attached as text + /// or reported as an unshifted codepoint; core encodes them from the key + /// enum. This also guards against a translation leaking through in some + /// other form, such as a PUA function-key character. + @Test(arguments: [ + Ghostty.Input.Key.enter, .numpadEnter, .escape, .tab, .backspace, + .delete, .insert, .home, .end, .pageUp, .pageDown, + .arrowUp, .arrowDown, .arrowLeft, .arrowRight, + .contextMenu, .numLock, + .f1, .f2, .f3, .f4, .f5, .f6, .f7, .f8, .f9, .f10, + .f11, .f12, .f13, .f14, .f15, .f16, .f17, .f18, .f19, .f20, + ]) + func controlKeyTranslationsProduceNoTextOrCodepoint(key: Ghostty.Input.Key) { + let event = Ghostty.Input.KeyEvent( + synthesizing: key, action: .press, mods: [], translationMods: []) + #expect(event.text == nil) + #expect(event.unshiftedCodepoint == 0) + } +} + +/// The menu-shortcut path must translate the key equivalent with only the +/// command modifier applied: command can select a distinct layout table, while +/// the other modifiers live in the shortcut's modifier mask. +@MainActor +struct KeyboardShortcutTranslationTests { + @Test func keyEquivalentIgnoresNonCommandModifiers() throws { + var trigger = ghostty_input_trigger_s() + trigger.tag = GHOSTTY_TRIGGER_PHYSICAL + trigger.key.physical = GHOSTTY_KEY_BACKQUOTE + trigger.mods = ghostty_input_mods_e( + GHOSTTY_MODS_SUPER.rawValue | GHOSTTY_MODS_SHIFT.rawValue | GHOSTTY_MODS_ALT.rawValue) + + let shortcut = try #require(Ghostty.keyboardShortcut(for: trigger)) + let expected = try #require(KeyboardLayout.character( + for: 0x32, // W3C Backquote + modifiers: .command)) + #expect(shortcut.key.character == expected) + #expect(shortcut.modifiers.contains(.shift)) + #expect(shortcut.modifiers.contains(.option)) + #expect(shortcut.modifiers.contains(.command)) + } +}