Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion macos/Sources/Features/AppleScript/ScriptKeyEventCommand.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import Carbon

/// Handler for the `send key` AppleScript command defined in `Ghostty.sdef`.
///
Expand Down Expand Up @@ -64,13 +65,67 @@ final class ScriptKeyEventCommand: NSScriptCommand {
mods = []
}

// derive text and unshifted codepoint via `UCKeyTranslate`
let (text, unshiftedCodepoint) = ScriptKeyEventTranslator.translate(key: key, mods: mods)

let keyEvent = Ghostty.Input.KeyEvent(
key: key,
action: action,
mods: mods
text: text,
mods: mods,
unshiftedCodepoint: unshiftedCodepoint
)

surface.sendKeyEvent(keyEvent)

return nil
}
}

/// extracted translation logic to map a Ghostty key to its generated text and unshifted codepoint,
/// primarily so it can be unit-tested without instantiating an `NSScriptCommand`.
struct ScriptKeyEventTranslator {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need UCKeyTranslate here, ppl who use it should know which key they are trying to type.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KeymapDarwin.zig already using it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me clarify one thing.

Are you saying that the below should be able to derive .text in KeymapDarwin.zig (via UCKeyTranslate)?

let keyEvent = Ghostty.Input.KeyEvent(
    key: key,
    action: action,
    mods: mods
)

And my use case/repro should work without the fix?

static func translate(key: Ghostty.Input.Key, mods: Ghostty.Input.Mods) -> (text: String?, unshiftedCodepoint: UInt32) {
let text: String?
let unshiftedCodepoint: UInt32

if let keyCode = key.keyCode {
let source = TISCopyCurrentKeyboardLayoutInputSource().takeRetainedValue()
let layoutData = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData)
let layoutPtr = unsafeBitCast(layoutData, to: CFData.self)
let keyboardLayout = unsafeBitCast(CFDataGetBytePtr(layoutPtr), to: UnsafePointer<UCKeyboardLayout>.self)

var deadKeyState: UInt32 = 0
var unicodeLength = 0
var unicodeBuffer = [UniChar](repeating: 0, count: 4)

let shiftedModifiers = mods.contains(.shift) ? UInt32(shiftKey >> 8) : 0
let kbdType = UInt32(LMGetKbdType())

// source terminal already resolved any dead keys; bypass dead-key state entirely.
let noDeadKeys = OptionBits(kUCKeyTranslateNoDeadKeysBit)

UCKeyTranslate(
keyboardLayout, UInt16(keyCode),
UInt16(kUCKeyActionDown), shiftedModifiers,
kbdType, noDeadKeys,
&deadKeyState, 4, &unicodeLength, &unicodeBuffer)
text = unicodeLength > 0 ? String(utf16CodeUnits: unicodeBuffer, count: unicodeLength) : nil

deadKeyState = 0
UCKeyTranslate(
keyboardLayout, UInt16(keyCode),
UInt16(kUCKeyActionDown), 0,
kbdType, noDeadKeys,
&deadKeyState, 4, &unicodeLength, &unicodeBuffer)
let unshiftedChar = unicodeLength > 0 ? String(utf16CodeUnits: unicodeBuffer, count: unicodeLength) : nil

unshiftedCodepoint = unshiftedChar?.unicodeScalars.first?.value ?? 0
} else {
text = nil
unshiftedCodepoint = 0
}

return (text, unshiftedCodepoint)
}
}
74 changes: 74 additions & 0 deletions macos/Tests/AppleScript/KeyEventCommandTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import Testing
@testable import Ghostty

fileprivate extension Ghostty.Input.Key {
var printableCharacter: String? {
switch self {
case .a: return "a"
case .b: return "b"
case .c: return "c"
case .d: return "d"
case .e: return "e"
case .f: return "f"
case .g: return "g"
case .h: return "h"
case .i: return "i"
case .j: return "j"
case .k: return "k"
case .l: return "l"
case .m: return "m"
case .n: return "n"
case .o: return "o"
case .p: return "p"
case .q: return "q"
case .r: return "r"
case .s: return "s"
case .t: return "t"
case .u: return "u"
case .v: return "v"
case .w: return "w"
case .x: return "x"
case .y: return "y"
case .z: return "z"
case .space: return " "
default: return nil
}
}
}

@Suite
struct ScriptKeyEventTranslatorTests {
typealias Key = Ghostty.Input.Key

@Test func letterKeyTranslation() {
let a = ScriptKeyEventTranslator.translate(key: .a, mods: [])
#expect(a.text == "a")
#expect(a.unshiftedCodepoint == UInt32(("a" as UnicodeScalar).value))

let shiftedA = ScriptKeyEventTranslator.translate(key: .a, mods: [.shift])
#expect(shiftedA.text == "A")
#expect(shiftedA.unshiftedCodepoint == UInt32(("a" as UnicodeScalar).value))

let j = ScriptKeyEventTranslator.translate(key: .j, mods: [])
#expect(j.text == "j")
#expect(j.unshiftedCodepoint == UInt32(("j" as UnicodeScalar).value))
}

@Test func spaceKeyTranslation() {
let space = ScriptKeyEventTranslator.translate(key: .space, mods: [])
#expect(space.text == " ")
#expect(space.unshiftedCodepoint == 32)
}

@Test func enterKeyTranslation() {
let enter = ScriptKeyEventTranslator.translate(key: .enter, mods: [])
#expect(enter.text == "\r")
#expect(enter.unshiftedCodepoint == 13) // Carriage Return
}

@Test func tabKeyTranslation() {
let tab = ScriptKeyEventTranslator.translate(key: .tab, mods: [])
#expect(tab.text == "\t")
#expect(tab.unshiftedCodepoint == 9)
}
}