diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index e72730e3..ae650c33 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -12606,6 +12606,32 @@ const LiveKeyboard = struct { } } + /// What Windows sends for a character typed with AltGr: a plain WM_KEYDOWN with Ctrl and Alt + /// both down, then the WM_CHAR its layout translates it to. The test machine's layout may + /// have no AltGr, so what TranslateMessage queued is dropped and the layout's character + /// is sent in its place. + fn pressAltGr(self: *LiveKeyboard, vk: u32, unit: u16) !void { + const chord = Chord{ .vk = vk, .ctrl = true, .alt = true }; + try setModifiers(chord); + var message = std.mem.zeroes(c.MSG); + message.hwnd = self.surface; + message.message = c.WM_KEYDOWN; + message.wParam = vk; + message.lParam = @intCast(1 | (c.MapVirtualKeyW(vk, c.MAPVK_VK_TO_VSC) << 16)); + const keys = MainWindow.KeyContext{ + .active = true, + .owner_enabled = true, + .target_owned = true, + .target_visible = true, + .target_enabled = true, + .ctrl = true, + .alt = true, + }; + self.fixture.app.window.dispatchMessage(&message, keys, self.surface); + while (c.PeekMessageW(&message, self.surface, c.WM_CHAR, c.WM_SYSDEADCHAR, c.PM_REMOVE) != 0) {} + _ = c.SendMessageW(self.surface, c.WM_CHAR, unit, 1); + } + /// Everything queued for the shell since the last call. fn drainInput(self: *LiveKeyboard, out: *std.ArrayListUnmanaged(u8)) !void { out.clearRetainingCapacity(); @@ -12938,6 +12964,39 @@ const KeyTableCase = struct { expected: []const u8, }; +test "live terminal keyboard: AltGr characters reach the shell and never run a shell shortcut" { + var fixture: LiveTerminalFixture = undefined; + try fixture.init(&.{.{ .id = "loop-a" }}); + defer fixture.deinit(); + try fixture.setLive(&.{"loop-a"}); + try clickSidebarLoopRow(&fixture.app, fixture.project, "loop-a"); + try fixture.waitFor(LiveTerminalFixture.shows, "loop-a"); + var keyboard = try LiveKeyboard.begin(&fixture, "loop-a"); + defer keyboard.end(); + fixture.workspace.key_callback = &ClipboardRouteProbe.callback; + var sent: std.ArrayListUnmanaged(u8) = .empty; + defer sent.deinit(std.testing.allocator); + + // O, J and comma are application shortcuts with plain Ctrl (open folder, jump, settings); + // Polish AltGr+O is o-acute, and the others are common AltGr characters. + const cases = [_]struct { vk: u32, unit: u16, text: []const u8 }{ + .{ .vk = 'O', .unit = 0x00F3, .text = "ó" }, + .{ .vk = 'J', .unit = 'j', .text = "j" }, + .{ .vk = c.VK_OEM_COMMA, .unit = ',', .text = "," }, + .{ .vk = 'Q', .unit = '@', .text = "@" }, + .{ .vk = '7', .unit = '{', .text = "{" }, + .{ .vk = 'E', .unit = 0x20AC, .text = "€" }, + }; + for (cases) |case| { + ClipboardRouteProbe.calls = 0; + try keyboard.pressAltGr(case.vk, case.unit); + if (ClipboardRouteProbe.calls != 0) std.debug.print("AltGr vk 0x{x} ran shortcut key {d}\n", .{ case.vk, ClipboardRouteProbe.key }); + try std.testing.expectEqual(@as(usize, 0), ClipboardRouteProbe.calls); + try keyboard.drainInput(&sent); + try std.testing.expectEqualStrings(case.text, sent.items); + } +} + fn expectKeyTable(keyboard: *LiveKeyboard, cases: []const KeyTableCase) !void { var sent: std.ArrayListUnmanaged(u8) = .empty; defer sent.deinit(std.testing.allocator); diff --git a/graphcode-windows/src/TerminalKeyEncoding.zig b/graphcode-windows/src/TerminalKeyEncoding.zig index 1d301421..a5d8a8a4 100644 --- a/graphcode-windows/src/TerminalKeyEncoding.zig +++ b/graphcode-windows/src/TerminalKeyEncoding.zig @@ -1,6 +1,7 @@ //! Translates a Windows key press into the bytes a terminal program expects, using the //! pinned Ghostty key encoder so cursor-key application mode, modifier parameters, and -//! function keys match what the macOS terminal sends. +//! function keys match what the macOS terminal sends. Presses and auto-repeats only: +//! releases, application keypad mode, and Kitty-protocol event reporting are not handled. //! //! Keys that produce text (letters, digits, punctuation, dead keys, AltGr, IME) are not //! encoded here: Windows turns them into WM_CHAR and the surface forwards that text. @@ -118,8 +119,13 @@ pub fn encodesAltChord(event: Event) bool { namedKey(event.vk, event.extended) == null; } -/// Encodes one key event. `terminal` supplies the program's current modes (application -/// cursor keys, Kitty keyboard flags, modifyOtherKeys); without it legacy defaults apply. +/// Encodes one key event. `terminal`, when given, lends its current modes to the encoder +/// (`ghostty_key_encoder_setopt_from_terminal`). Only application cursor keys (DECCKM) is +/// exercised by the tests below; Kitty keyboard flags and modifyOtherKeys are whatever the +/// pinned encoder does with them and are not verified here. Key releases never reach this +/// function (`TerminalSurface.onKey` drops them), so Kitty release/event-type reporting is +/// not implemented, and of the keypad keys only the numpad Enter is told apart from its +/// main-keyboard twin. Without a terminal, legacy defaults apply. pub fn encode(buffer: *[max_sequence_bytes]u8, event: Event, terminal: c.GhosttyTerminal) Outcome { const key: c.GhosttyKey = if (encodes(event)) namedKey(event.vk, event.extended).? diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index e79e0e3e..5567f3a7 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -4211,6 +4211,7 @@ fn onAccessibilitySelection(user_data: ?*anyopaque, surface: *c.winghostty_surfa fn onKey(user_data: ?*anyopaque, surface: *c.winghostty_surface, event: *const c.winghostty_key_event) callconv(.c) void { const workspace = workspaceFromUserData(user_data) orelse return; const slot = callbackSlot(workspace, surface) orelse return; + // Releases are never sent to the program: the shell does not negotiate Kitty release reporting. if (event.action == c.WINGHOSTTY_KEY_RELEASE) return; const modifiers = TerminalKeys.decodeProviderModifiers(event.modifiers); const ctrl = modifiers.ctrl; @@ -4225,7 +4226,7 @@ fn onKey(user_data: ?*anyopaque, surface: *c.winghostty_surface, event: *const c if (event.action == c.WINGHOSTTY_KEY_PRESS) workspace.runContextMenu(); return; } - if (isApplicationShortcut(event.virtual_key, ctrl, shift) or + if (isApplicationShortcut(event.virtual_key, ctrl, shift, modifiers.alt) or (event.virtual_key == c.VK_TAB and (modifiers.alt or (event.modifiers & ~(TerminalKeys.provider_shift | TerminalKeys.provider_ctrl | TerminalKeys.provider_alt)) != 0))) { @@ -4288,9 +4289,13 @@ fn discardTranslatedCharacters(target: c.HWND) void { while (c.PeekMessageW(&message, target, c.WM_SYSCHAR, c.WM_SYSDEADCHAR, c.PM_REMOVE) != 0) {} } -fn isApplicationShortcut(key: usize, ctrl: bool, shift: bool) bool { +fn isApplicationShortcut(key: usize, ctrl: bool, shift: bool, alt: bool) bool { if (key == c.VK_TAB) return ctrl; - if (!ctrl) return false; + // Windows reports AltGr as Ctrl+Alt, so a Ctrl+Alt chord types text on layouts that have + // one and is never a shortcut here, except Tab (handled above and in onKey, unchanged); + // Ctrl+Alt+PageUp/PageDown stay with the window's own accelerators, which are handled + // before the key reaches the terminal. + if (!ctrl or alt) return false; return switch (key) { 'O', 'J', c.VK_PRIOR, c.VK_NEXT, 0xBC => true, // Ctrl+Shift+[ and ] move between panes; plain Ctrl+[ (ESC) and Ctrl+] belong to the shell. @@ -4480,25 +4485,41 @@ test "ordinary Tab production dispatch reaches the real terminal key callback" { } test "TerminalSurface.isApplicationShortcut forwards only the chords a terminal does not keep" { - try std.testing.expect(isApplicationShortcut(c.VK_PRIOR, true, false)); - try std.testing.expect(isApplicationShortcut(c.VK_NEXT, true, false)); - try std.testing.expect(isApplicationShortcut(c.VK_TAB, true, false)); - try std.testing.expect(!isApplicationShortcut(c.VK_TAB, false, false)); - try std.testing.expect(isApplicationShortcut(0xBC, true, false)); - try std.testing.expect(isApplicationShortcut('O', true, false)); - try std.testing.expect(isApplicationShortcut('J', true, false)); + try std.testing.expect(isApplicationShortcut(c.VK_PRIOR, true, false, false)); + try std.testing.expect(isApplicationShortcut(c.VK_NEXT, true, false, false)); + try std.testing.expect(isApplicationShortcut(c.VK_TAB, true, false, false)); + try std.testing.expect(!isApplicationShortcut(c.VK_TAB, false, false, false)); + try std.testing.expect(isApplicationShortcut(0xBC, true, false, false)); + try std.testing.expect(isApplicationShortcut('O', true, false, false)); + try std.testing.expect(isApplicationShortcut('J', true, false, false)); // Ctrl+D/W/S/T/N and Ctrl+[ / ] are terminal input: EOF, delete word, XOFF, transpose, next // history, ESC, and GS. Ctrl+Shift+[ / ] move between panes. for ([_]usize{ 'D', 'W', 'S', 'T', 'N', 0xDB, 0xDD }) |key| { - try std.testing.expect(!isApplicationShortcut(key, true, false)); + try std.testing.expect(!isApplicationShortcut(key, true, false, false)); + } + try std.testing.expect(isApplicationShortcut(0xDB, true, true, false)); + try std.testing.expect(isApplicationShortcut(0xDD, true, true, false)); + try std.testing.expect(!isApplicationShortcut('C', true, true, false)); + try std.testing.expect(!isApplicationShortcut('V', true, true, false)); + try std.testing.expect(!isApplicationShortcut(c.VK_UP, false, false, false)); + try std.testing.expect(!isApplicationShortcut(c.VK_DOWN, false, false, false)); + try std.testing.expect(!isApplicationShortcut('M', true, false, false)); +} + +test "TerminalSurface.isApplicationShortcut rejects every shortcut key once Alt is held with Ctrl (AltGr)" { + const shortcuts = [_]struct { vk: usize, shift: bool }{ + .{ .vk = 'O', .shift = false }, + .{ .vk = 'J', .shift = false }, + .{ .vk = 0xBC, .shift = false }, + .{ .vk = c.VK_PRIOR, .shift = false }, + .{ .vk = c.VK_NEXT, .shift = false }, + .{ .vk = 0xDB, .shift = true }, + .{ .vk = 0xDD, .shift = true }, + }; + for (shortcuts) |shortcut| { + try std.testing.expect(isApplicationShortcut(shortcut.vk, true, shortcut.shift, false)); + try std.testing.expect(!isApplicationShortcut(shortcut.vk, true, shortcut.shift, true)); } - try std.testing.expect(isApplicationShortcut(0xDB, true, true)); - try std.testing.expect(isApplicationShortcut(0xDD, true, true)); - try std.testing.expect(!isApplicationShortcut('C', true, true)); - try std.testing.expect(!isApplicationShortcut('V', true, true)); - try std.testing.expect(!isApplicationShortcut(c.VK_UP, false, false)); - try std.testing.expect(!isApplicationShortcut(c.VK_DOWN, false, false)); - try std.testing.expect(!isApplicationShortcut('M', true, false)); } // The pinned provider fills winghostty_key_event.modifiers from GetKeyState using the Win32 @@ -4515,6 +4536,68 @@ fn providerKey(vk: usize, modifiers: u32, action: u32) c.winghostty_key_event { return key; } +test "AltGr (Ctrl+Alt) never triggers an application shortcut, so its text reaches the terminal" { + const Probe = OrdinaryTabKeyboardTest; + // Windows reports AltGr as Ctrl+Alt. Each row is a character that layout types with AltGr, + // on a key the shell also uses with plain Ctrl as an application shortcut (O, J, comma) + // or not (the rest). The WM_CHAR text is what the provider hands to on_text. + const altgr = [_]struct { layout: []const u8, vk: usize, text: []const u8 }{ + .{ .layout = "Polish", .vk = 'O', .text = "ó" }, + .{ .layout = "Polish", .vk = 'A', .text = "ą" }, + .{ .layout = "Polish", .vk = 'L', .text = "ł" }, + .{ .layout = "German", .vk = 'Q', .text = "@" }, + .{ .layout = "German", .vk = '7', .text = "{" }, + .{ .layout = "German", .vk = 'E', .text = "€" }, + .{ .layout = "French", .vk = '0', .text = "@" }, + .{ .layout = "French", .vk = 'J', .text = "j" }, + .{ .layout = "Spanish", .vk = '2', .text = "@" }, + .{ .layout = "Layout with AltGr+comma", .vk = 0xBC, .text = "," }, + }; + for (altgr) |case| { + var workspace = try minimalWorkspaceForOptionsTest(std.testing.allocator); + defer workspace.layout.deinit(); + defer workspace.input_queue.clear(); + var probe = Probe{}; + probe.bind(&workspace); + const key = providerKey(case.vk, provider_ctrl | provider_alt, c.WINGHOSTTY_KEY_PRESS); + onKey(@ptrCast(&workspace), Probe.registered, &key); + if (probe.calls != 0 or workspace.input_queue.count != 0) { + std.debug.print("{s} AltGr vk 0x{x}: {d} shortcut calls, {d} queued inputs\n", .{ case.layout, case.vk, probe.calls, workspace.input_queue.count }); + } + try std.testing.expectEqual(@as(usize, 0), probe.calls); + try std.testing.expectEqual(@as(usize, 0), workspace.input_queue.count); + onText(@ptrCast(&workspace), Probe.registered, @ptrCast(case.text.ptr), @intCast(case.text.len)); + try Probe.expectInput(&workspace, case.text); + } +} + +test "Ctrl+Alt held on every application-shortcut key leaves the key to the terminal" { + const Probe = OrdinaryTabKeyboardTest; + const with_shift = [_]usize{ 0xDB, 0xDD }; + const without_shift = [_]usize{ 'O', 'J', 0xBC, c.VK_PRIOR, c.VK_NEXT }; + var workspace = try minimalWorkspaceForOptionsTest(std.testing.allocator); + defer workspace.layout.deinit(); + defer workspace.input_queue.clear(); + var probe = Probe{}; + probe.bind(&workspace); + for (without_shift) |vk| for ([_]u32{ 0, provider_shift }) |shift| { + const key = providerKey(vk, provider_ctrl | provider_alt | shift, c.WINGHOSTTY_KEY_PRESS); + onKey(@ptrCast(&workspace), Probe.registered, &key); + }; + for (with_shift) |vk| { + const key = providerKey(vk, provider_ctrl | provider_alt | provider_shift, c.WINGHOSTTY_KEY_PRESS); + onKey(@ptrCast(&workspace), Probe.registered, &key); + } + try std.testing.expectEqual(@as(usize, 0), probe.calls); + // Plain Ctrl still reaches the shell's shortcut route, so the guard is the Alt bit alone. + for (without_shift) |vk| { + const key = providerKey(vk, provider_ctrl, c.WINGHOSTTY_KEY_PRESS); + probe.calls = 0; + onKey(@ptrCast(&workspace), Probe.registered, &key); + try std.testing.expectEqual(@as(usize, 1), probe.calls); + } +} + test "provider Shift modifier bit makes Tab a backtab instead of loop navigation" { const Probe = OrdinaryTabKeyboardTest; var workspace = try minimalWorkspaceForOptionsTest(std.testing.allocator);