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
59 changes: 59 additions & 0 deletions graphcode-windows/src/App.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 9 additions & 3 deletions graphcode-windows/src/TerminalKeyEncoding.zig
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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).?
Expand Down
119 changes: 101 additions & 18 deletions graphcode-windows/src/TerminalSurface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)))
{
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down
Loading