mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-09-14 18:01:58 +00:00
macOS: fix AppleScript send key for non-control keys
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -226,7 +226,7 @@
|
||||
</parameter>
|
||||
</command>
|
||||
|
||||
<command name="send key" code="GhstSKey" description="Send a keyboard event to a terminal.">
|
||||
<command name="send key" code="GhstSKey" description="Send a keyboard event to a terminal. Keys are translated once through the current keyboard layout. Dead keys do not compose with subsequent keys.">
|
||||
<cocoa class="GhosttyScriptKeyEventCommand"/>
|
||||
<direct-parameter type="text" description="The key name (e.g. "enter", "a", "space")."/>
|
||||
<parameter name="action" code="GKeA" type="input action" optional="yes" description="Press or release (default: press).">
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
115
macos/Tests/Ghostty/KeyEventSynthesizeTests.swift
Normal file
115
macos/Tests/Ghostty/KeyEventSynthesizeTests.swift
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user