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
179 changes: 179 additions & 0 deletions graphcode-windows/src/MainWindow.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1624,6 +1624,185 @@ test "workspace cycle keyboard actual accelerator descriptors provide both direc
}
}

test "production dispatch delivers IME composition lifecycle to a native EDIT control" {
const Probe = struct {
var original: c.WNDPROC = null;
var messages: [3]c.UINT = undefined;
var count: usize = 0;

fn editProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) callconv(.c) c.LRESULT {
if (message == c.WM_IME_STARTCOMPOSITION or
message == c.WM_IME_COMPOSITION or
message == c.WM_IME_ENDCOMPOSITION)
{
messages[count] = message;
count += 1;
}
return c.CallWindowProcW(original, hwnd, message, wparam, lparam);
}
};
Probe.count = 0;
const parent = c.CreateWindowExW(
0,
std.unicode.utf8ToUtf16LeStringLiteral("STATIC"),
std.unicode.utf8ToUtf16LeStringLiteral("IME dispatch test"),
c.WS_OVERLAPPED,
0,
0,
320,
120,
null,
null,
c.GetModuleHandleW(null),
null,
) orelse return error.WindowCreationFailed;
defer _ = c.DestroyWindow(parent);
const edit = c.CreateWindowExW(
0,
std.unicode.utf8ToUtf16LeStringLiteral("EDIT"),
std.unicode.utf8ToUtf16LeStringLiteral(""),
c.WS_CHILD | c.ES_AUTOHSCROLL,
0,
0,
280,
24,
parent,
null,
c.GetModuleHandleW(null),
null,
) orelse return error.EditCreationFailed;
const previous = c.SetWindowLongPtrW(
edit,
c.GWLP_WNDPROC,
@bitCast(@intFromPtr(&Probe.editProc)),
);
if (previous == 0) return error.EditSubclassFailed;
Probe.original = @ptrFromInt(@as(usize, @bitCast(previous)));

var window = Window{ .hwnd = parent };
var message = std.mem.zeroes(c.MSG);
message.hwnd = edit;
for ([_]struct { kind: c.UINT, lparam: c.LPARAM }{
.{ .kind = c.WM_IME_STARTCOMPOSITION, .lparam = 0 },
.{ .kind = c.WM_IME_COMPOSITION, .lparam = 0x0008 },
.{ .kind = c.WM_IME_ENDCOMPOSITION, .lparam = 0 },
}) |expected| {
message.message = expected.kind;
message.lParam = expected.lparam;
window.dispatchMessage(&message, .{}, edit);
}
try std.testing.expectEqualSlices(c.UINT, &.{
c.WM_IME_STARTCOMPOSITION,
c.WM_IME_COMPOSITION,
c.WM_IME_ENDCOMPOSITION,
}, Probe.messages[0..Probe.count]);
}

test "production dispatch preserves dead-key composition and non-US physical-key mapping" {
const Probe = struct {
var dead_chars: [4]u16 = undefined;
var dead_count: usize = 0;
var chars: [8]u16 = undefined;
var char_count: usize = 0;

fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) callconv(.c) c.LRESULT {
switch (message) {
c.WM_DEADCHAR => {
dead_chars[dead_count] = @truncate(wparam);
dead_count += 1;
return 0;
},
c.WM_CHAR => {
chars[char_count] = @truncate(wparam);
char_count += 1;
return 0;
},
else => return c.DefWindowProcW(hwnd, message, wparam, lparam),
}
}

fn reset() void {
dead_count = 0;
char_count = 0;
}
};
const test_class = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeKeyboardLayoutDispatchTest");
var window_class = std.mem.zeroes(c.WNDCLASSW);
window_class.hInstance = c.GetModuleHandleW(null);
window_class.lpszClassName = test_class;
window_class.lpfnWndProc = &Probe.windowProc;
if (c.RegisterClassW(&window_class) == 0) return error.WindowClassRegistrationFailed;
defer _ = c.UnregisterClassW(test_class, window_class.hInstance);
const hwnd = c.CreateWindowExW(
0,
test_class,
std.unicode.utf8ToUtf16LeStringLiteral("Keyboard layout dispatch test"),
c.WS_OVERLAPPED,
0,
0,
320,
120,
null,
null,
window_class.hInstance,
null,
) orelse return error.WindowCreationFailed;
defer _ = c.DestroyWindow(hwnd);

const original_layout = c.GetKeyboardLayout(0);
defer _ = c.ActivateKeyboardLayout(original_layout, 0);
var original_keyboard_state: [256]u8 = undefined;
if (c.GetKeyboardState(&original_keyboard_state) == 0) return error.KeyboardStateUnavailable;
defer _ = c.SetKeyboardState(&original_keyboard_state);
var clear_keyboard_state = [_]u8{0} ** 256;
if (c.SetKeyboardState(&clear_keyboard_state) == 0) return error.KeyboardStateUnavailable;

var window = Window{ .hwnd = hwnd };
const Dispatch = struct {
fn key(target: *Window, target_hwnd: c.HWND, layout: c.HKL, scan_code: u32) !void {
const virtual_key = c.MapVirtualKeyExW(scan_code, c.MAPVK_VSC_TO_VK_EX, layout);
if (virtual_key == 0) return error.VirtualKeyMappingUnavailable;
var message = std.mem.zeroes(c.MSG);
message.hwnd = target_hwnd;
message.message = c.WM_KEYDOWN;
message.wParam = virtual_key;
message.lParam = @intCast(1 | (scan_code << 16));
target.dispatchMessage(&message, .{}, target_hwnd);
while (c.PeekMessageW(&message, target_hwnd, c.WM_KEYFIRST, c.WM_KEYLAST, c.PM_REMOVE) != 0) {
target.dispatchMessage(&message, .{}, target_hwnd);
}
}
};

const international = c.LoadKeyboardLayoutW(
std.unicode.utf8ToUtf16LeStringLiteral("00020409"),
c.KLF_NOTELLSHELL,
) orelse {
std.log.warn("skipping keyboard composition test: US-International layout is unavailable", .{});
return error.SkipZigTest;
};
defer _ = c.UnloadKeyboardLayout(international);
if (c.ActivateKeyboardLayout(international, 0) == null) return error.KeyboardLayoutActivationFailed;
Probe.reset();
try Dispatch.key(&window, hwnd, international, 0x28);
try std.testing.expectEqual(@as(usize, 1), Probe.dead_count);
try Dispatch.key(&window, hwnd, international, 0x12);
try std.testing.expectEqualSlices(u16, &.{0x00e9}, Probe.chars[0..Probe.char_count]);

const french = c.LoadKeyboardLayoutW(
std.unicode.utf8ToUtf16LeStringLiteral("0000040c"),
c.KLF_NOTELLSHELL,
) orelse {
std.log.warn("skipping keyboard composition test: French layout is unavailable", .{});
return error.SkipZigTest;
};
defer _ = c.UnloadKeyboardLayout(french);
if (c.ActivateKeyboardLayout(french, 0) == null) return error.KeyboardLayoutActivationFailed;
Probe.reset();
try Dispatch.key(&window, hwnd, french, 0x10);
try std.testing.expectEqualSlices(u16, &.{'a'}, Probe.chars[0..Probe.char_count]);
}

test "workspace cycle keyboard pretranslation consumes owned normal and system keys before child dispatch" {
const Probe = struct {
direction: ?isize = null,
Expand Down
34 changes: 28 additions & 6 deletions graphcode-windows/src/TerminalSurface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2851,19 +2851,41 @@ fn onImeStart(user_data: ?*anyopaque, surface: *c.winghostty_surface) callconv(.
_ = surface;
}

fn onImeUpdate(user_data: ?*anyopaque, surface: *c.winghostty_surface, text: [*:0]const u8, length: u32, cursor: u32) callconv(.c) void {
_ = user_data;
_ = surface;
_ = text;
_ = length;
_ = cursor;
fn onImeUpdate(user_data: ?*anyopaque, surface: *c.winghostty_surface, text: [*:0]const u8, length: u32, committed: u8) callconv(.c) void {
if (committed == 0) return;
const workspace = workspaceFromUserData(user_data) orelse return;
_ = callbackSlot(workspace, surface) orelse return;
const index = surfaceIndex(workspace, surface) orelse return;
workspace.enqueueInput(index, text[0..length]);
}

fn onImeEnd(user_data: ?*anyopaque, surface: *c.winghostty_surface) callconv(.c) void {
_ = user_data;
_ = surface;
}

test "committed IME composition enters the terminal input queue" {
const allocator = std.testing.allocator;
var workspace = try minimalWorkspaceForOptionsTest(allocator);
defer workspace.layout.deinit();
defer workspace.input_queue.clear();

const fake_surface: *c.winghostty_surface = @ptrFromInt(0x1000);
workspace.surfaces[3].surface = fake_surface;

const preedit = "kana";
onImeUpdate(@ptrCast(&workspace), fake_surface, preedit, preedit.len, 0);
try std.testing.expectEqual(@as(usize, 0), workspace.input_queue.count);

const committed = "日本";
onImeUpdate(@ptrCast(&workspace), fake_surface, committed, committed.len, 1);
try std.testing.expectEqual(@as(usize, 1), workspace.input_queue.count);
const item = workspace.input_queue.dequeue().?;
defer allocator.free(item.bytes);
try std.testing.expectEqual(@as(usize, 3), item.surface);
try std.testing.expectEqualStrings(committed, item.bytes);
}

fn onMouse(user_data: ?*anyopaque, surface: *c.winghostty_surface, event: *const c.winghostty_mouse_event) callconv(.c) void {
_ = user_data;
_ = surface;
Expand Down
2 changes: 1 addition & 1 deletion investigation/ui-parity-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ case proves ownership only. All existing Partial rows remain Partial.
| UI Automation tree | Names, roles, selection, invoke/toggle, focus, live status for every visible surface | The synchronized live C++ provider exposes stable project rows, loop rows, project/overview/Quick Chat cards, worktree rows, destinations, canvas primary action, zoom controls, policy actions, focus, selection-change events, and status. The live gate uses explicitly in-process deterministic fixtures to validate populated RawView/ControlView navigation, real bounds, observable Quick Chat/workspace invocation effects, tagged-command isolation, identity-preserving reorder/removal, events, concurrency, and teardown. The Workspace menu's New, Rename, and Delete lifecycle commands and dynamic workspace-switch rows are now part of that native provider tree instead of existing only in `Accessibility.zig`; the Zig contract was reduced to the native fixed table, and a pinned-Zig executable test now fails when any contract id lacks an exact native fixed-table id. The live gate now also traverses the Workspace menu in RawView and ControlView, checks its fixed lifecycle names and InvokePattern exposure, and verifies at least one `workspace-switch-*` row under the same parent. `TerminalSurface.zig` retains reported selection and cell metrics, but callback metadata does not prove applied terminal selection. Exact-pin Winghostty `f5abc059` source already creates an embedded child-HWND Text/Text2 provider and routes `WM_GETOBJECT` to it; the earlier missing-provider premise was inaccurate. GraphCode now feeds that provider owned UTF-8 from its unchanged rendered-cell grid with independent UTF-16 length/cursor offsets, rather than a rolling raw VT byte tail. Focused producer tests exercise overwritten text, chunk splits, reset/rollover, Unicode scalar representation, bounds, allocation/lifetime and injected publication failures without native APIs. This is fixed current-grid content, not transcript/scrollback or full Unicode terminal rendering. Render/text publication remains best-effort: failures are reported and the provider may retain its last successful, no-longer-current snapshot. Native terminal text results, applied selection, visible caret/geometry, range/HRESULT conformance, retained-generation behavior, atomicity, daemon-to-model UIA integration, and remaining dialogs still need separate evidence or implementation; keyboard discovery and HelpText residuals are not closed | Partial |
| Reproducible DPI/geometry regression coverage | Control metrics for GraphCode-owned chrome scale correctly and predictably across 100/125/150/200% DPI | `Tools/windows/visual-baseline.ps1` previously only checked that each DPI variant's `scale`/`viewport` were present and positive. It now reimplements `Dpi.zig`'s exact `scale()` rounding formula, self-checked against `Dpi.zig`'s own fixed-point unit-test cases, reads the real base pixel values straight out of `DesignTokens.zig` (not a copy baked into the manifest), and asserts the scaled geometry for `sidebar_width`, `tab_bar_height`, `pane_header_height`, and `loop_bar_height` at the real Windows per-monitor DPI values (96/120/144/192) is monotonic and matches the 96-DPI base exactly at 100%. Every `regionGeometry` entry is required to target a `deterministicScreenshotRegions` (GraphCode-owned) region, never a Winghostty-owned one, keeping third-party terminal pixels structurally out of scope. **This check performs static manifest/geometry metadata validation, not rendered-output comparison: it never launches the app, captures a window, or rasterizes a bitmap.** It re-derives expected numeric geometry from source-of-truth code and checks the manifest against that math, which is materially stronger than the prior presence-only checks and does catch real drift, but it is not a screenshot diff and should not be read as one; this repo/CI has no deterministic way to rasterize a live Win32 window. Manually re-verified that corrupting either a DPI value or a `DesignTokens.zig` constant makes the script fail | Validated |
| Keyboard discovery | Every shortcut represented by a menu item or visible hint where practical | Restored File, Loop, Terminal, View, and Help menus expose the primary project, graph, terminal, workspace, settings, update, and zoom commands with shortcut labels. The shared node popup no longer advertises Enter for Open Terminal or Ctrl+E for Edit Details: root Enter has no standalone open action, and Ctrl+E renames a selected loop or edits a selected edge. Two executable contracts failed on the old captions and pass on the corrected ones through the actual menu builder and `GetMenuStringW`, preserving command IDs, mappings, order, and enabled states across ordinary/resolved/composite/unwired targets. They inspect unattached menu handles without creating windows or displaying popups; mapper checks preserve toolbar Enter activation and existing Ctrl+E/F2 routes. The Windows README now distinguishes root, terminal, and dialog contexts and corrects toggle/chat-delete bindings. Pure mapper regressions cover Ctrl+Shift+OEM/legacy ASCII comma selecting product Settings, plain Ctrl+comma retaining Advanced Connection Settings, and unmodified/unrelated keys. An unattached native accelerator-table test checks the matching documented product binding and the unchanged original 15 bindings/order; this is not physical keyboard, focus, or dialog-opening evidence. Some context-only actions and canvas gestures still lack visible hints. No live keyboard or macOS runtime walkthrough was performed; remaining context-only/gesture discovery, other caption/routing discrepancies, and keyboard popup access still need evidence | Partial |
| IME/dead keys/layouts | Native composition in forms and terminal | Winghostty gate covers terminal IME; generic EDIT controls cover forms | Partial |
| IME/dead keys/layouts | Native composition in forms and terminal | Fixed a real terminal input gap: Winghostty reports preedit and committed result text through `on_ime_update`, but `TerminalSurface.onImeUpdate` discarded both; it now ignores preedit and enqueues only committed UTF-8 through the same bounded terminal input queue as ordinary text. A focused executable test invokes that production callback and proves uncommitted text is absent while committed Japanese text reaches the correct surface queue. `MainWindow.Window.dispatchMessage` executable tests create a real Unicode `EDIT` control and observe `WM_IME_STARTCOMPOSITION` / `WM_IME_COMPOSITION` / `WM_IME_ENDCOMPOSITION` at its native window procedure, then activate US-International and French layouts and exercise the production `TranslateMessage`/`DispatchMessageW` path with physical scan codes, observing a real dead-key `WM_DEADCHAR` followed by `é` and French scan-code mapping to `a`; unavailable layouts are explicitly logged and skipped rather than silently weakened. These are hidden-window Win32 runtime tests, not a foreground UIA walkthrough or validation with a live IME candidate window, and terminal preedit rendering remains provider-owned, so the row remains Partial | Partial |
| Clipboard/selection | Terminal copy/paste and mouse selection | Terminal-context Ctrl+Shift+C copies the active surface's reported accessibility selection range through Winghostty into Windows `CF_UNICODETEXT`; Ctrl+Shift+V reads only `CF_UNICODETEXT`, converts UTF-16 to UTF-8, and calls Winghostty's paste validator and paste entry point with `allow_unsafe=0`. Clipboard conversion tests preserve Unicode, CRLF, LF, and empty text; app routing tests prove the shortcuts are terminal-context-only and preserve the existing global Ctrl+Shift+C Clone Repository route. Unsafe multiline/control-containing pastes are rejected with a status message rather than forced through, and no confirmation UI is provided. Clipboard Win32 calls, mouse-driven selection, actual rendered selection extraction, provider callback notifications, and end-to-end paste/copy on a live desktop were not exercised here; this row remains Partial | Partial |
| Per-monitor DPI | Layout and controls scale correctly across monitors | The process now declares real per-monitor-v2 DPI awareness at startup (`Win32.enablePerMonitorDpiAwareness()`, called before any window is created) instead of relying on system-DPI bitmap stretching; without this, Windows never delivers real per-monitor `WM_DPICHANGED` data to a DPI-unaware process. `App.zig` seeds the real startup DPI via `GetDpiForWindow` immediately after window creation (rather than assuming 96 DPI/100% until the first monitor move) and forwards every live `WM_DPICHANGED` to `TerminalWorkspace.Workspace.setDpi()`. Previously, `TerminalSurface.zig`'s `onDpiChanged` callback silently discarded the `dpi`/`scale` winghostty reported, and every terminal surface was created with `font_scale` hardcoded to `1.0`, so terminal text never actually rescaled on a DPI change or on a monitor with non-100% DPI at launch. `Workspace.setDpi()` now propagates the real runtime DPI to every live surface via winghostty's own `winghostty_surface_notify_dpi_changed` + `winghostty_surface_set_font_scale` (the two operations the provider actually exposes for this), and `surfaceOptions()` seeds new surfaces' `font_scale` from the workspace's last-known DPI instead of a fixed `1.0`. Deliberately does not also pre-scale `options.input.cell_width`/`cell_height` (kept at their 96-DPI logical baseline) so the DPI ratio is applied exactly once, through `font_scale`, avoiding double scaling. `onMetricsChanged`/`onAccessibilitySelection`, previously also fully discarded, now record the host's reported cell metrics and terminal text-selection range per surface instead of losing them. Verified with `zig build` (full app, pinned Zig 0.15.2 against the exact pinned Winghostty provider) and `zig test src/TerminalSurface.zig` (new `Dpi.fontScale` unit test plus all 13 pre-existing tests, 14/14). No live multi-monitor walkthrough was recorded (this environment has no interactive multi-DPI desktop), so this remains Partial pending that end-to-end evidence | Partial |
| Dark visual language | Dark canvas/cards/sheets and legible state hierarchy | Existing `DesignTokens`, repository dialogs and `NativeForms` supply the dark native palette and teaching tiles. [Actual production-renderer captures](visual-baseline/rendered-windows/README.md) now preserve canvas/sidebar, Product Settings and workspace clients at native 96 DPI. The real PNG comparator verifies exact opaque canvas/card/sidebar/dialog samples; setup uses a synthetic disconnected fixture, UIA invocation and native WM_COMMAND, not keyboard-menu proof. Settings retains native light buttons; current macOS Settings uses a native grouped form, so these were not speculatively darkened. Other sheets/state combinations, legibility on every surface and a compatible current macOS capture remain unverified | Partial |
Expand Down
Loading