Skip to content
Merged
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
42 changes: 36 additions & 6 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5750,18 +5750,48 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
ghostty_surface_has_selection(surface)
}

/// Whether `event` is the key equivalent AppKit matches against the
/// standard Edit ▸ Copy menu item for the active keyboard layout.
///
/// `layoutCharacterProvider` is injectable so layout-specific routing can
/// be covered without installing the layout on the test host.
static func isStandardCopyMenuKeyEquivalent(
_ event: NSEvent,
layoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:)
) -> Bool {
let normalizedFlags = event.modifierFlags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function, .capsLock])
guard event.type == .keyDown, normalizedFlags == [.command] else {
return false
}

// AppKit resolves menu key equivalents through the layout's Command
// table, so Command-swapped layouts ("Dvorak - QWERTY ⌘") match a
// different character than `charactersIgnoringModifiers` reports: the
// physical C key reports the Dvorak "j" while the menu matched "c".
// Resolving the same way AppKit did keeps this guard aligned with the
// Copy item that actually declined the chord.
if let commandAwareCharacter = layoutCharacterProvider(event.keyCode, normalizedFlags),
!commandAwareCharacter.isEmpty,
commandAwareCharacter.allSatisfy(\.isASCII) {
return commandAwareCharacter == "c"
}

let rawCharacters = (event.charactersIgnoringModifiers ?? "").lowercased()
let resolved = rawCharacters.allSatisfy(\.isASCII)
? rawCharacters
: (layoutCharacterProvider(event.keyCode, []) ?? rawCharacters)
return resolved == "c"
}

/// Keep the standard Copy shortcut a native no-op when AppKit disables
/// Copy. Replaying this menu miss into Ghostty lets the failed Copy binding
/// enter its terminal-input path, which moves scrollback to the bottom when
/// `scroll-to-bottom=keystroke` is enabled even if the terminal program
/// does not visibly echo that input.
func consumeUnavailableCopyMenuAction(_ event: NSEvent) -> Bool {
let normalizedFlags = event.modifierFlags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function, .capsLock])
guard event.type == .keyDown,
normalizedFlags == [.command],
KeyboardLayout.normalizedCharacters(for: event) == "c" else {
guard Self.isStandardCopyMenuKeyEquivalent(event) else {
return false
}
guard let surface = ensureSurfaceReadyForInput(),
Expand Down
201 changes: 201 additions & 0 deletions cmuxTests/WindowKeyDownReplayGuardTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -481,4 +481,205 @@ struct WindowKeyDownReplayGuardTests {
#expect(terminal.keyDownEvents.map { $0.charactersIgnoringModifiers } == ["z"])
#expect(textView.undoCallCount == 0)
}

// MARK: - Copy key equivalent resolution

/// Regression coverage for https://github.com/manaflow-ai/cmux/issues/10872.
///
/// "Dvorak - QWERTY ⌘" swaps to the QWERTY table while Command is held, so
/// AppKit matches Edit ▸ Copy from the physical C key even though
/// `charactersIgnoringModifiers` still reports the Dvorak character "j".
/// The guard has to resolve the same character AppKit matched, otherwise a
/// Copy that the menu disabled falls through to Ghostty and types "j" into
/// the pty.
@Test
func dvorakQwertyCommandCopyKeyMatchesTheCopyKeyEquivalent() {
guard let event = makeCommandKeyDownEvent(
charactersIgnoringModifiers: "j",
keyCode: UInt16(kVK_ANSI_C)
) else {
Issue.record("Failed to construct Dvorak-QWERTY ⌘ Copy event")
return
}

#expect(
GhosttyNSView.isStandardCopyMenuKeyEquivalent(
event,
layoutCharacterProvider: dvorakQwertyCommandLayoutCharacter
),
Comment(rawValue: "⌘ + physical C on Dvorak - QWERTY ⌘ is the Copy key equivalent AppKit matched")
)
}

/// The same layout in the other direction: the physical I key reports the
/// Dvorak character "c" without Command, but AppKit matches it as ⌘I. The
/// guard must not swallow it as an unavailable Copy.
@Test
func dvorakQwertyCommandDoesNotMatchCopyOnTheDvorakCKey() {
guard let event = makeCommandKeyDownEvent(
charactersIgnoringModifiers: "c",
keyCode: UInt16(kVK_ANSI_I)
) else {
Issue.record("Failed to construct Dvorak-QWERTY ⌘ ⌘I event")
return
}

#expect(
!GhosttyNSView.isStandardCopyMenuKeyEquivalent(
event,
layoutCharacterProvider: dvorakQwertyCommandLayoutCharacter
),
Comment(rawValue: "⌘ + physical I on Dvorak - QWERTY ⌘ is ⌘I, not Copy")
)
}

@Test
func usQwertyCopyKeyMatchesTheCopyKeyEquivalent() {
guard let event = makeCommandKeyDownEvent(
charactersIgnoringModifiers: "c",
keyCode: UInt16(kVK_ANSI_C)
) else {
Issue.record("Failed to construct US-QWERTY Copy event")
return
}

#expect(
GhosttyNSView.isStandardCopyMenuKeyEquivalent(
event,
layoutCharacterProvider: usQwertyLayoutCharacter
)
)
}

/// Plain Dvorak has no Command table swap, so the physical I key really is
/// the user's Copy key on that layout and stays a match.
@Test
func plainDvorakCopyKeyMatchesTheCopyKeyEquivalent() {
guard let event = makeCommandKeyDownEvent(
charactersIgnoringModifiers: "c",
keyCode: UInt16(kVK_ANSI_I)
) else {
Issue.record("Failed to construct plain Dvorak Copy event")
return
}

#expect(
GhosttyNSView.isStandardCopyMenuKeyEquivalent(
event,
layoutCharacterProvider: plainDvorakLayoutCharacter
)
)
}

/// Non-Latin input sources report non-ASCII characters that can never match
/// a Latin key equivalent, so the layout lookup stays the source of truth.
@Test
func nonLatinInputSourceStillMatchesTheCopyKeyEquivalent() {
guard let event = makeCommandKeyDownEvent(
charactersIgnoringModifiers: "ㅊ",
keyCode: UInt16(kVK_ANSI_C)
) else {
Issue.record("Failed to construct Hangul Copy event")
return
}

#expect(
GhosttyNSView.isStandardCopyMenuKeyEquivalent(
event,
layoutCharacterProvider: usQwertyLayoutCharacter
)
)
}

@Test
func otherCommandKeysDoNotMatchTheCopyKeyEquivalent() {
guard let event = makeCommandKeyDownEvent(
charactersIgnoringModifiers: "v",
keyCode: UInt16(kVK_ANSI_V)
) else {
Issue.record("Failed to construct ⌘V event")
return
}

#expect(
!GhosttyNSView.isStandardCopyMenuKeyEquivalent(
event,
layoutCharacterProvider: usQwertyLayoutCharacter
)
)
}

@Test
func copyKeyWithExtraModifiersDoesNotMatchTheCopyKeyEquivalent() {
guard let event = makeCommandKeyDownEvent(
charactersIgnoringModifiers: "c",
keyCode: UInt16(kVK_ANSI_C),
modifierFlags: [.command, .shift]
) else {
Issue.record("Failed to construct ⌘⇧C event")
return
}

#expect(
!GhosttyNSView.isStandardCopyMenuKeyEquivalent(
event,
layoutCharacterProvider: usQwertyLayoutCharacter
)
)
}

private func makeCommandKeyDownEvent(
charactersIgnoringModifiers: String,
keyCode: UInt16,
modifierFlags: NSEvent.ModifierFlags = [.command]
) -> NSEvent? {
NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: modifierFlags,
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: 0,
context: nil,
characters: charactersIgnoringModifiers,
charactersIgnoringModifiers: charactersIgnoringModifiers,
isARepeat: false,
keyCode: keyCode
)
}
}

/// `KeyboardLayout.character(forKeyCode:modifierFlags:)` translates through
/// the Command table in shortcut mode, which is what makes this layout
/// resolve QWERTY characters for ⌘ chords and Dvorak characters otherwise.
private func dvorakQwertyCommandLayoutCharacter(
keyCode: UInt16,
modifierFlags: NSEvent.ModifierFlags
) -> String? {
modifierFlags.contains(.command)
? usQwertyLayoutCharacter(keyCode: keyCode, modifierFlags: modifierFlags)
: plainDvorakLayoutCharacter(keyCode: keyCode, modifierFlags: modifierFlags)
}

private func plainDvorakLayoutCharacter(
keyCode: UInt16,
modifierFlags: NSEvent.ModifierFlags
) -> String? {
switch Int(keyCode) {
case kVK_ANSI_C: return "j"
case kVK_ANSI_I: return "c"
case kVK_ANSI_V: return "k"
default: return nil
}
}

private func usQwertyLayoutCharacter(
keyCode: UInt16,
modifierFlags: NSEvent.ModifierFlags
) -> String? {
switch Int(keyCode) {
case kVK_ANSI_C: return "c"
case kVK_ANSI_I: return "i"
case kVK_ANSI_V: return "v"
default: return nil
}
}
Loading