Skip to content

Commit 9b2bb43

Browse files
austinywangclaude
andcommitted
Add Option/Alt side-bit and composition regression tests (#5993)
Covers the consolidated Option/Alt issue: NSEvent device side bits must map to GHOSTTY_MODS_*_RIGHT so libghostty can apply macos-option-as-alt = left|right per physical key, translation flags must keep Option on the composing side, and Option composition must resolve on US (Opt+; -> ...), German (Opt+L -> @), Polish Pro (Opt+A -> a-ogonek), and Canadian-CSA (Opt -> /) layouts. The four right-side-bit tests fail at this commit by design (two-commit regression policy): cmux currently maps both physical Option keys to the generic GHOSTTY_MODS_ALT bit. The follow-up commit adds the side bits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 123eb90 commit 9b2bb43

3 files changed

Lines changed: 192 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,25 @@ jobs:
350350
-only-testing:cmuxTests/BrowserSystemProxyMirrorTests \
351351
test
352352
353+
- name: Run Option/Alt sided-modifier regression
354+
run: |
355+
# Focused gate for https://github.com/manaflow-ai/cmux/issues/5993.
356+
# The full "Run unit tests" step tolerates app-host crashes by
357+
# parsing only the last test summary, so suites that run late can
358+
# be silently skipped when an earlier crash aborts the run. This
359+
# step makes the Option/Alt sided-modifier and Option-composition
360+
# regression an unconditional gate.
361+
set -euo pipefail
362+
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
363+
xcodebuild -project cmux.xcodeproj -scheme cmux-unit -configuration Debug \
364+
-derivedDataPath "$CMUX_DERIVED_DATA_PATH" \
365+
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
366+
-disableAutomaticPackageResolution \
367+
-destination "platform=macOS" \
368+
CMUX_SKIP_ZIG_BUILD=1 \
369+
-only-testing:cmuxTests/GhosttyOptionAsAltModsTests \
370+
test
371+
353372
- name: Run unit tests
354373
run: |
355374
set -euo pipefail

cmux.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,7 @@
378378
A11EAF000000000000000000 /* GhosttyNotificationDispatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11EAF000000000000000001 /* GhosttyNotificationDispatcherTests.swift */; };
379379
D3571000A1B2C3D4E5F60718 /* GhosttyNSView+IMEComposition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3571001A1B2C3D4E5F60718 /* GhosttyNSView+IMEComposition.swift */; };
380380
D7AB00000000000000000005 /* GhosttyNSView+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000006 /* GhosttyNSView+MoveTabToNewWorkspace.swift */; };
381+
BDFCA25C0CE77A299C915E26 /* GhosttyOptionAsAltModsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F34462AE2F2EECEF5298031 /* GhosttyOptionAsAltModsTests.swift */; };
381382
3069F1D10000000000000003 /* GhosttyPasteboardFidelityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3069F1D10000000000000004 /* GhosttyPasteboardFidelityTests.swift */; };
382383
B84BD7AD94EE485B8DDAB6FF /* GhosttySurfaceConfigurationRefresh.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DAB808A8EC74C40B95F7672 /* GhosttySurfaceConfigurationRefresh.swift */; };
383384
A11EAC000000000000000000 /* GhosttyTerminalAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11EAC000000000000000001 /* GhosttyTerminalAppearance.swift */; };
@@ -1190,6 +1191,7 @@
11901191
A11EAF000000000000000001 /* GhosttyNotificationDispatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyNotificationDispatcherTests.swift; sourceTree = "<group>"; };
11911192
D3571001A1B2C3D4E5F60718 /* GhosttyNSView+IMEComposition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyNSView+IMEComposition.swift"; sourceTree = "<group>"; };
11921193
D7AB00000000000000000006 /* GhosttyNSView+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyNSView+MoveTabToNewWorkspace.swift"; sourceTree = "<group>"; };
1194+
4F34462AE2F2EECEF5298031 /* GhosttyOptionAsAltModsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyOptionAsAltModsTests.swift; sourceTree = "<group>"; };
11931195
3069F1D10000000000000004 /* GhosttyPasteboardFidelityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyPasteboardFidelityTests.swift; sourceTree = "<group>"; };
11941196
9DAB808A8EC74C40B95F7672 /* GhosttySurfaceConfigurationRefresh.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/GhosttySurfaceConfigurationRefresh.swift; sourceTree = "<group>"; };
11951197
A11EAC000000000000000001 /* GhosttyTerminalAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalAppearance.swift; sourceTree = "<group>"; };
@@ -2422,6 +2424,7 @@
24222424
D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */,
24232425
4E5F60720000000000000002 /* NotificationSoundSettingsTests.swift */,
24242426
42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */,
2427+
4F34462AE2F2EECEF5298031 /* GhosttyOptionAsAltModsTests.swift */,
24252428
D7C0DE00000000000000A104 /* CmuxWebViewContextMenuLinkCaptureTests.swift */,
24262429
D1F0A00300000000000000C2 /* PhonePushPresenceGateTests.swift */,
24272430
B79482F1ECA54E98BE5C8953 /* WindowKeyDownReplayGuardTests.swift */,
@@ -3523,6 +3526,7 @@
35233526
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */,
35243527
F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */,
35253528
A11EAF000000000000000000 /* GhosttyNotificationDispatcherTests.swift in Sources */,
3529+
BDFCA25C0CE77A299C915E26 /* GhosttyOptionAsAltModsTests.swift in Sources */,
35263530
3069F1D10000000000000003 /* GhosttyPasteboardFidelityTests.swift in Sources */,
35273531
C13519000000000000000003 /* GhosttyTerminalStartupEnvironmentTests.swift in Sources */,
35283532
D7AB34400000000000000003 /* GhosttyTerminalViewVisibilityPolicyTests.swift in Sources */,
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import AppKit
2+
import Carbon.HIToolbox
3+
import Testing
4+
5+
#if canImport(cmux_DEV)
6+
@testable import cmux_DEV
7+
#elseif canImport(cmux)
8+
@testable import cmux
9+
#endif
10+
11+
/// Regression coverage for https://github.com/manaflow-ai/cmux/issues/5993:
12+
/// cmux ignored `macos-option-as-alt` left/right and captured Option before
13+
/// character composition.
14+
///
15+
/// libghostty applies `macos-option-as-alt = left|right` (both in
16+
/// `ghostty_surface_key_translation_mods` and in the key encoder's
17+
/// Alt-prefix rules) from the `GHOSTTY_MODS_*_RIGHT` side bits of the mods
18+
/// cmux sends. If cmux maps both physical Option keys to the same generic
19+
/// `GHOSTTY_MODS_ALT`, every Option key looks like the left one: with
20+
/// `= left` the right Option can never compose characters (`…`, `@`, `ą`,
21+
/// `/`), and with `= right` the right Option is never treated as Alt.
22+
@MainActor
23+
@Suite struct GhosttyOptionAsAltModsTests {
24+
// MARK: NSEvent flags -> libghostty mods side bits
25+
26+
@Test func rightOptionCarriesAltAndAltRightSideBit() {
27+
let raw = NSEvent.ModifierFlags.option.rawValue | UInt(NX_DEVICERALTKEYMASK)
28+
let mods = cmuxGhosttyModsFromFlags(modifierFlagsRawValue: raw)
29+
#expect(mods.rawValue & GHOSTTY_MODS_ALT.rawValue != 0)
30+
#expect(
31+
mods.rawValue & GHOSTTY_MODS_ALT_RIGHT.rawValue != 0,
32+
"right Option must set GHOSTTY_MODS_ALT_RIGHT so macos-option-as-alt = left|right can distinguish sides"
33+
)
34+
}
35+
36+
@Test func leftOptionCarriesAltWithoutAltRightSideBit() {
37+
let raw = NSEvent.ModifierFlags.option.rawValue | UInt(NX_DEVICELALTKEYMASK)
38+
let mods = cmuxGhosttyModsFromFlags(modifierFlagsRawValue: raw)
39+
#expect(mods.rawValue & GHOSTTY_MODS_ALT.rawValue != 0)
40+
#expect(mods.rawValue & GHOSTTY_MODS_ALT_RIGHT.rawValue == 0)
41+
}
42+
43+
@Test func rightShiftCarriesShiftRightSideBit() {
44+
let raw = NSEvent.ModifierFlags.shift.rawValue | UInt(NX_DEVICERSHIFTKEYMASK)
45+
let mods = cmuxGhosttyModsFromFlags(modifierFlagsRawValue: raw)
46+
#expect(mods.rawValue & GHOSTTY_MODS_SHIFT.rawValue != 0)
47+
#expect(mods.rawValue & GHOSTTY_MODS_SHIFT_RIGHT.rawValue != 0)
48+
}
49+
50+
@Test func rightControlCarriesCtrlRightSideBit() {
51+
let raw = NSEvent.ModifierFlags.control.rawValue | UInt(NX_DEVICERCTLKEYMASK)
52+
let mods = cmuxGhosttyModsFromFlags(modifierFlagsRawValue: raw)
53+
#expect(mods.rawValue & GHOSTTY_MODS_CTRL.rawValue != 0)
54+
#expect(mods.rawValue & GHOSTTY_MODS_CTRL_RIGHT.rawValue != 0)
55+
}
56+
57+
@Test func rightCommandCarriesSuperRightSideBit() {
58+
let raw = NSEvent.ModifierFlags.command.rawValue | UInt(NX_DEVICERCMDKEYMASK)
59+
let mods = cmuxGhosttyModsFromFlags(modifierFlagsRawValue: raw)
60+
#expect(mods.rawValue & GHOSTTY_MODS_SUPER.rawValue != 0)
61+
#expect(mods.rawValue & GHOSTTY_MODS_SUPER_RIGHT.rawValue != 0)
62+
}
63+
64+
@Test func genericModifiersMapWithoutSideBits() {
65+
let raw = NSEvent.ModifierFlags.shift.rawValue
66+
| NSEvent.ModifierFlags.control.rawValue
67+
| NSEvent.ModifierFlags.option.rawValue
68+
| NSEvent.ModifierFlags.command.rawValue
69+
let mods = cmuxGhosttyModsFromFlags(modifierFlagsRawValue: raw)
70+
#expect(mods.rawValue & GHOSTTY_MODS_SHIFT.rawValue != 0)
71+
#expect(mods.rawValue & GHOSTTY_MODS_CTRL.rawValue != 0)
72+
#expect(mods.rawValue & GHOSTTY_MODS_ALT.rawValue != 0)
73+
#expect(mods.rawValue & GHOSTTY_MODS_SUPER.rawValue != 0)
74+
#expect(mods.rawValue & GHOSTTY_MODS_SHIFT_RIGHT.rawValue == 0)
75+
#expect(mods.rawValue & GHOSTTY_MODS_CTRL_RIGHT.rawValue == 0)
76+
#expect(mods.rawValue & GHOSTTY_MODS_ALT_RIGHT.rawValue == 0)
77+
#expect(mods.rawValue & GHOSTTY_MODS_SUPER_RIGHT.rawValue == 0)
78+
}
79+
80+
// MARK: libghostty translation mods -> AppKit translation flags
81+
82+
@Test func translationFlagsDropOptionWhenGhosttyStripsAlt() {
83+
// macos-option-as-alt stripped Alt for this side: the AppKit
84+
// character translation must not apply Option (Alt/Meta encoding).
85+
let translated = cmuxTranslationModifierFlags(
86+
original: [.option],
87+
ghosttyTranslationMods: GHOSTTY_MODS_NONE
88+
)
89+
#expect(!translated.contains(.option))
90+
}
91+
92+
@Test func translationFlagsKeepOptionWhenGhosttyKeepsAlt() {
93+
// Option on the composing side must stay available to AppKit so
94+
// Option-composed characters keep working.
95+
let translated = cmuxTranslationModifierFlags(
96+
original: [.option, .shift],
97+
ghosttyTranslationMods: ghostty_input_mods_e(
98+
rawValue: GHOSTTY_MODS_ALT.rawValue | GHOSTTY_MODS_SHIFT.rawValue
99+
)
100+
)
101+
#expect(translated.contains(.option))
102+
#expect(translated.contains(.shift))
103+
}
104+
105+
@Test func translationFlagsPreserveFlagsGhosttyDoesNotModel() {
106+
let translated = cmuxTranslationModifierFlags(
107+
original: [.option, .function, .numericPad],
108+
ghosttyTranslationMods: GHOSTTY_MODS_NONE
109+
)
110+
#expect(translated.contains(.function))
111+
#expect(translated.contains(.numericPad))
112+
#expect(!translated.contains(.option))
113+
}
114+
115+
// MARK: Option composition per keyboard layout (issue #5993 acceptance)
116+
117+
@Test func usLayoutOptionSemicolonComposesEllipsis() throws {
118+
try expectOptionComposes(
119+
layoutID: "com.apple.keylayout.US",
120+
keyCode: UInt16(kVK_ANSI_Semicolon),
121+
expected: ""
122+
)
123+
}
124+
125+
@Test func germanLayoutOptionLComposesAtSign() throws {
126+
try expectOptionComposes(
127+
layoutID: "com.apple.keylayout.German",
128+
keyCode: UInt16(kVK_ANSI_L),
129+
expected: "@"
130+
)
131+
}
132+
133+
@Test func polishProLayoutOptionAComposesAOgonek() throws {
134+
try expectOptionComposes(
135+
layoutID: "com.apple.keylayout.PolishPro",
136+
keyCode: UInt16(kVK_ANSI_A),
137+
expected: "ą"
138+
)
139+
}
140+
141+
@Test func canadianCSALayoutOptionComposesSlash() throws {
142+
// On Canadian-CSA the key at the ANSI-slash position types "é";
143+
// Option/AltGr must still produce "/" (issue #5025).
144+
try expectOptionComposes(
145+
layoutID: "com.apple.keylayout.Canadian-CSA",
146+
keyCode: UInt16(kVK_ANSI_Slash),
147+
expected: "/"
148+
)
149+
}
150+
151+
private func expectOptionComposes(
152+
layoutID: String,
153+
keyCode: UInt16,
154+
expected: String
155+
) throws {
156+
let composed = try #require(
157+
KeyboardLayout.textInputCharacter(
158+
forKeyCode: keyCode,
159+
modifierFlags: .option,
160+
inputSourceID: layoutID
161+
),
162+
Comment(rawValue: "input source \(layoutID) unavailable or produced no character")
163+
)
164+
#expect(
165+
composed == expected,
166+
Comment(rawValue: "Option translation on \(layoutID) produced \(composed) instead of \(expected)")
167+
)
168+
}
169+
}

0 commit comments

Comments
 (0)