diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index a090651c..6f459bc7 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -300,12 +300,15 @@ that `Ctrl+C` copies while the terminal has a selection. Paste reads only (NUL, BS, ESC, DEL and the tty's special characters; BEL and tab pass through) plus the C1 controls U+0080 to U+009F (8-bit CSI and friends), and wraps the text in bracketed-paste markers only when the running program asked -for them (DECSET 2004); text over 1,000,000 UTF-8 bytes is refused. A copy that the -Windows clipboard refuses puts the previous Unicode text back and says whether -that worked; if the existing Unicode text cannot be copied safely the copy is -refused before the clipboard is touched. Only the Unicode text is protected: -emptying the clipboard removes every other format (an image, HTML, RTF), which a -copy replaces and a failed copy does not restore. Multi-line text for a program that did not ask asks for +for them (DECSET 2004); text over 1,000,000 UTF-8 bytes is refused. A successful copy +intentionally replaces every clipboard format with plain Unicode text, as macOS does +with `clearContents` then `setString`: an image, HTML, RTF or file list that was on +the clipboard is gone afterwards, and nothing earlier is read or restored. The text +is copied into a clipboard block before the clipboard is opened, so running out of +memory leaves the clipboard unchanged; if the clipboard refuses the block after it +was emptied, the copy fails with a status saying the clipboard may now be empty. +Copy and paste through Remote Desktop or a Dev Box (`rdpclip`) has not been +verified. Multi-line text for a program that did not ask asks for confirmation first instead of running each line as it is pasted. That includes `pwsh` and `cmd.exe`: neither asks for bracketed paste under ConPTY, and each runs the pasted lines as they arrive. macOS auto-confirms that case without a diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 71c543af..40790da5 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -273,16 +273,14 @@ fn terminalPasteFailureStatus(err: anyerror) []const u8 { return "Unable to paste clipboard text"; } -/// What the status line says when the Windows clipboard refused a copy. The user's previous -/// clipboard text is restored when the write fails, and the message says whether it was. +/// What the status line says when a copy to the Windows clipboard fails. A copy that fails +/// after the clipboard was emptied leaves it empty, and the message says so; nothing earlier is +/// restored. fn terminalCopyFailureStatus(err: anyerror) []const u8 { - if (err == error.ClipboardWriteFailedKeptPreviousText) - return "Unable to copy: the clipboard refused the text; its previous text was kept"; - if (err == error.ClipboardWriteFailedLostPreviousText) - return "Unable to copy: the clipboard refused the text and its previous text could not be restored"; + if (err == error.ClipboardWriteFailed) + return "Unable to copy: the clipboard refused the text and may now be empty"; + if (err == error.OutOfMemory) return "Unable to copy: not enough memory; the clipboard was left unchanged"; if (err == error.ClipboardOpenFailed) return "Unable to copy: the clipboard is in use by another program"; - if (err == error.ClipboardPreviousTextUnreadable) - return "Unable to copy: the clipboard's current text could not be read, so it was left unchanged"; return "Unable to copy terminal selection"; } @@ -10734,24 +10732,20 @@ test "terminal paste refuses clipboard text above the size limit with a clear st } } -test "a refused terminal copy says whether the user's previous clipboard text survived" { +test "a failed terminal copy says what happened to the clipboard" { try std.testing.expectEqualStrings( - "Unable to copy: the clipboard refused the text; its previous text was kept", - terminalCopyFailureStatus(error.ClipboardWriteFailedKeptPreviousText), + "Unable to copy: the clipboard refused the text and may now be empty", + terminalCopyFailureStatus(error.ClipboardWriteFailed), ); try std.testing.expectEqualStrings( - "Unable to copy: the clipboard refused the text and its previous text could not be restored", - terminalCopyFailureStatus(error.ClipboardWriteFailedLostPreviousText), + "Unable to copy: not enough memory; the clipboard was left unchanged", + terminalCopyFailureStatus(error.OutOfMemory), ); try std.testing.expectEqualStrings( "Unable to copy: the clipboard is in use by another program", terminalCopyFailureStatus(error.ClipboardOpenFailed), ); - try std.testing.expectEqualStrings( - "Unable to copy: the clipboard's current text could not be read, so it was left unchanged", - terminalCopyFailureStatus(error.ClipboardPreviousTextUnreadable), - ); - try std.testing.expectEqualStrings("Unable to copy terminal selection", terminalCopyFailureStatus(error.ClipboardWriteFailed)); + try std.testing.expectEqualStrings("Unable to copy terminal selection", terminalCopyFailureStatus(error.ClipboardClearFailed)); } test "workspace cycle keyboard fallback never turns Alt paging into terminal tabs" { @@ -14402,10 +14396,10 @@ test "live terminal copy: a clipboard that refuses the text keeps the selection return err; } }; - Refusing.err = error.ClipboardWriteFailedLostPreviousText; + Refusing.err = error.ClipboardWriteFailed; fixture.app.copyTerminalSelectionWith(Refusing); try std.testing.expectEqualStrings( - "Unable to copy: the clipboard refused the text and its previous text could not be restored", + "Unable to copy: the clipboard refused the text and may now be empty", fixture.app.status(), ); // Nothing was copied, so the selection stays for another attempt. diff --git a/graphcode-windows/src/Clipboard.zig b/graphcode-windows/src/Clipboard.zig index 2c317d45..de868a4a 100644 --- a/graphcode-windows/src/Clipboard.zig +++ b/graphcode-windows/src/Clipboard.zig @@ -9,34 +9,6 @@ pub fn decodeUtf16(allocator: std.mem.Allocator, text: []const u16) ![]u8 { return std.unicode.utf16LeToUtf8Alloc(allocator, text); } -/// What the clipboard held as Unicode text before a write. -const Snapshot = union(enum) { - /// No Unicode text: nothing there to lose. - none, - /// The text through its terminator; the caller frees it. - units: []u16, - /// Unicode text is there but could not be copied safely, so a write must not empty it. - unreadable, -}; - -/// Copies a clipboard text block through its first terminator, never reading past `capacity` -/// units. A block with no terminator is not valid CF_UNICODETEXT and is not republished. -fn snapshotUnits(allocator: std.mem.Allocator, units: [*]const u16, capacity: usize) Snapshot { - var length: usize = 0; - while (length < capacity and units[length] != 0) : (length += 1) {} - if (length == capacity) return .unreadable; - const copy = allocator.alloc(u16, length + 1) catch return .unreadable; - @memcpy(copy[0..length], units[0..length]); - copy[length] = 0; - return .{ .units = copy }; -} - -/// A clipboard block as Windows reports it: a size in bytes that must hold whole UTF-16 units. -fn snapshotBlock(allocator: std.mem.Allocator, units: [*]const u16, byte_count: usize) Snapshot { - if (byte_count < @sizeOf(u16) or byte_count % @sizeOf(u16) != 0) return .unreadable; - return snapshotUnits(allocator, units, byte_count / @sizeOf(u16)); -} - const NativeApi = struct { fn open(owner: c.HWND) bool { return c.OpenClipboard(owner) != 0; @@ -50,31 +22,27 @@ const NativeApi = struct { return c.EmptyClipboard() != 0; } - fn snapshot(allocator: std.mem.Allocator) Snapshot { - if (c.IsClipboardFormatAvailable(c.CF_UNICODETEXT) == 0) return .none; - const memory = c.GetClipboardData(c.CF_UNICODETEXT) orelse return .unreadable; - const byte_count = c.GlobalSize(memory); - const locked = c.GlobalLock(memory) orelse return .unreadable; - defer _ = c.GlobalUnlock(memory); - const source: [*]const u16 = @ptrCast(@alignCast(locked)); - return snapshotBlock(allocator, source, byte_count); - } - - /// Hands `units` (terminator included) to the clipboard, which owns the memory on success. - fn setUnits(units: []const u16) bool { - const memory = c.GlobalAlloc(c.GMEM_MOVEABLE, units.len * @sizeOf(u16)) orelse return false; + /// Copies `units` (terminator included) into a new movable block. Until `publish` succeeds + /// the caller owns the block and must `free` it exactly once. + fn stage(units: []const u16) ?*anyopaque { + const memory = c.GlobalAlloc(c.GMEM_MOVEABLE, units.len * @sizeOf(u16)) orelse return null; const locked = c.GlobalLock(memory) orelse { _ = c.GlobalFree(memory); - return false; + return null; }; const destination: [*]u16 = @ptrCast(@alignCast(locked)); @memcpy(destination[0..units.len], units); _ = c.GlobalUnlock(memory); - if (c.SetClipboardData(c.CF_UNICODETEXT, memory) == null) { - _ = c.GlobalFree(memory); - return false; - } - return true; + return memory; + } + + fn free(memory: *anyopaque) void { + _ = c.GlobalFree(memory); + } + + /// Windows owns the block only when this succeeds; after a failure the caller still owns it. + fn publish(memory: *anyopaque) bool { + return c.SetClipboardData(c.CF_UNICODETEXT, memory) != null; } }; @@ -82,30 +50,31 @@ pub fn writeText(owner: c.HWND, allocator: std.mem.Allocator, text: []const u8) return writeTextWith(NativeApi, owner, allocator, text); } -/// Replaces the clipboard's text. Windows only lets a program set the clipboard after emptying -/// it, so a refusal of the new text would lose what the user had copied. The previous Unicode -/// text is therefore copied first and put back if the write fails, and the error says which -/// happened. If Unicode text is there but cannot be copied safely, the clipboard is left alone -/// and the copy fails. Only the Unicode text is protected: emptying the clipboard removes every -/// other format (images, HTML, RTF, files), which are not restored, and a successful copy -/// replaces them as well. +/// Replaces the whole clipboard with `text`, as Unicode text only. Windows only lets a program +/// set the clipboard after emptying it, and emptying removes every other format (images, HTML, +/// RTF, files, and the history and cloud opt-out formats), so a successful copy replaces all +/// of them, as macOS does with `clearContents` then `setString`. Nothing earlier is read or +/// restored: reading it could block on another program that renders it on demand, and putting +/// back only its text would drop an opt-out marker and could republish text that was excluded +/// from history and sync. The text is copied into a clipboard block before the clipboard is +/// opened, so running out of memory cannot happen once it has been emptied. If the clipboard +/// then refuses the block, the copy fails and the clipboard may be left empty. The block belongs +/// to this function until the clipboard accepts it, and is freed exactly once on every +/// failure; after success Windows owns it. fn writeTextWith(comptime Api: type, owner: c.HWND, allocator: std.mem.Allocator, text: []const u8) !void { if (owner == null) return error.ClipboardOwnerUnavailable; const encoded = try encodeUtf16(allocator, text); defer allocator.free(encoded); + const staged = Api.stage(encoded.ptr[0 .. encoded.len + 1]) orelse return error.OutOfMemory; + var owned = true; + defer if (owned) Api.free(staged); + if (!Api.open(owner)) return error.ClipboardOpenFailed; defer Api.close(); - const previous = Api.snapshot(allocator); - defer if (previous == .units) allocator.free(previous.units); - if (previous == .unreadable) return error.ClipboardPreviousTextUnreadable; if (!Api.empty()) return error.ClipboardClearFailed; - if (Api.setUnits(encoded.ptr[0 .. encoded.len + 1])) return; - if (previous == .units) { - if (Api.setUnits(previous.units)) return error.ClipboardWriteFailedKeptPreviousText; - return error.ClipboardWriteFailedLostPreviousText; - } - return error.ClipboardWriteFailed; + if (!Api.publish(staged)) return error.ClipboardWriteFailed; + owned = false; } /// Reads the clipboard's Unicode text as UTF-8. Text that would be longer than `max_bytes` @@ -194,18 +163,30 @@ test "clipboard Win32 operations require an owner window" { } /// A stand-in clipboard that follows the Win32 rules this code depends on: a write needs the -/// clipboard emptied first, and the stand-in can refuse writes or fail to open or empty. +/// clipboard emptied first, and the stand-in can refuse to allocate, open, empty or accept a +/// block. It counts opens, empties, publishes, closes and frees, and tracks which staged blocks +/// the caller still owns, so a leaked or doubly freed block fails a test. It never touches the +/// real clipboard. const FakeClipboard = struct { var storage: [64]u16 = undefined; var held: ?[]const u16 = null; var open_ok = true; var empty_ok = true; - var unreadable = false; - /// The block size Windows would report, when it is not a whole number of units. - var reported_bytes: ?usize = null; - var refused_sets: usize = 0; var sets: usize = 0; var closes: usize = 0; + var opens: usize = 0; + var empties: usize = 0; + var stages: usize = 0; + var frees: usize = 0; + /// Staged blocks the caller still owns: staged, not yet freed and not yet handed to the clipboard. + var live: isize = 0; + var alloc_ok = true; + var publish_ok = true; + var is_open = false; + var emptied = false; + var staged_while_open = false; + var staged_storage: [64]u16 = undefined; + var staged_len: usize = 0; fn units(comptime text: []const u8) []const u16 { const literal = std.unicode.utf8ToUtf16LeStringLiteral(text); @@ -219,38 +200,66 @@ const FakeClipboard = struct { } else held = null; open_ok = true; empty_ok = true; - unreadable = false; - reported_bytes = null; - refused_sets = 0; sets = 0; closes = 0; + opens = 0; + empties = 0; + stages = 0; + frees = 0; + live = 0; + alloc_ok = true; + publish_ok = true; + is_open = false; + emptied = false; + staged_while_open = false; + staged_len = 0; } fn open(_: c.HWND) bool { + opens += 1; + is_open = open_ok; + emptied = false; return open_ok; } fn close() void { closes += 1; + is_open = false; + emptied = false; } + /// Windows refuses to empty a clipboard the caller has not opened. fn empty() bool { - if (!empty_ok) return false; + empties += 1; + if (!is_open or !empty_ok) return false; held = null; + emptied = true; return true; } - fn snapshot(allocator: std.mem.Allocator) Snapshot { - if (unreadable) return .unreadable; - const value = held orelse return .none; - return snapshotBlock(allocator, value.ptr, reported_bytes orelse value.len * @sizeOf(u16)); + fn stage(value: []const u16) ?usize { + stages += 1; + if (is_open) staged_while_open = true; + if (!alloc_ok) return null; + @memcpy(staged_storage[0..value.len], value); + staged_len = value.len; + live += 1; + return 1; } - fn setUnits(value: []const u16) bool { + fn free(_: usize) void { + frees += 1; + live -= 1; + } + + /// Windows takes ownership of the block only when this succeeds, and only for a caller that + /// has the clipboard open and has emptied it to become its owner. + fn publish(_: usize) bool { sets += 1; - if (sets <= refused_sets) return false; - @memcpy(storage[0..value.len], value); - held = storage[0..value.len]; + if (!is_open or !emptied or !publish_ok) return false; + @memcpy(storage[0..staged_len], staged_storage[0..staged_len]); + held = storage[0..staged_len]; + live -= 1; return true; } @@ -271,38 +280,6 @@ test "clipboard write replaces the previous text" { try std.testing.expectEqual(@as(usize, 1), FakeClipboard.closes); } -test "a refused clipboard write puts the user's previous text back and says so" { - FakeClipboard.reset(FakeClipboard.units("old")); - FakeClipboard.refused_sets = 1; - try std.testing.expectError( - error.ClipboardWriteFailedKeptPreviousText, - writeTextWith(FakeClipboard, fake_owner, std.testing.allocator, "new"), - ); - try FakeClipboard.expectHeld(FakeClipboard.units("old")); - try std.testing.expectEqual(@as(usize, 2), FakeClipboard.sets); - try std.testing.expectEqual(@as(usize, 1), FakeClipboard.closes); -} - -test "a refused write that also cannot restore the previous text reports the loss" { - FakeClipboard.reset(FakeClipboard.units("old")); - FakeClipboard.refused_sets = 2; - try std.testing.expectError( - error.ClipboardWriteFailedLostPreviousText, - writeTextWith(FakeClipboard, fake_owner, std.testing.allocator, "new"), - ); - try FakeClipboard.expectHeld(null); -} - -test "a refused write over a clipboard with no text is a plain write failure" { - FakeClipboard.reset(null); - FakeClipboard.refused_sets = 1; - try std.testing.expectError( - error.ClipboardWriteFailed, - writeTextWith(FakeClipboard, fake_owner, std.testing.allocator, "new"), - ); - try std.testing.expectEqual(@as(usize, 1), FakeClipboard.sets); -} - test "a clipboard that cannot be opened or emptied is left as it was" { FakeClipboard.reset(FakeClipboard.units("old")); FakeClipboard.open_ok = false; @@ -362,67 +339,133 @@ test "the paste limit counts UTF-8 bytes, so wide characters are refused before try std.testing.expectError(error.ClipboardTextTooLarge, textFromUnits(failing.allocator(), euro.ptr, euro.len, 5)); } -test "a snapshot copies text through its terminator and refuses an unterminated block" { - const allocator = std.testing.allocator; - var block = [_]u16{ 'h', 'i', 0, 'x', 'y' }; - const kept = snapshotUnits(allocator, &block, block.len); - defer allocator.free(kept.units); - try std.testing.expectEqualSlices(u16, &[_]u16{ 'h', 'i', 0 }, kept.units); - const bare = [_]u16{ 'a', 'b', 'c' }; - try std.testing.expect(snapshotUnits(allocator, &bare, bare.len) == .unreadable); +test "the stand-in refuses calls made in the wrong order, as Windows does" { + FakeClipboard.reset(FakeClipboard.units("old")); + // Emptying a clipboard that is not open fails and changes nothing. + try std.testing.expect(!FakeClipboard.empty()); + try FakeClipboard.expectHeld(FakeClipboard.units("old")); + // Publishing without having emptied fails: the caller is not the owner. + try std.testing.expect(FakeClipboard.open(fake_owner)); + const staged = FakeClipboard.stage(FakeClipboard.units("new")).?; + try std.testing.expect(!FakeClipboard.publish(staged)); + try FakeClipboard.expectHeld(FakeClipboard.units("old")); + // Emptying then publishing works, and closing ends the ownership. + try std.testing.expect(FakeClipboard.empty()); + try std.testing.expect(FakeClipboard.publish(staged)); + FakeClipboard.close(); + try std.testing.expect(!FakeClipboard.publish(staged)); + // Reopening starts again unemptied. + try std.testing.expect(FakeClipboard.open(fake_owner)); + try std.testing.expect(!FakeClipboard.publish(staged)); + FakeClipboard.close(); } -test "unreadable or unterminated existing text stops a copy before the clipboard is emptied" { - // Unicode text is there but cannot be copied: the write is refused and nothing changes. +test "a copy that skips emptying the clipboard is a write failure and the block is freed" { + const SkipsEmpty = struct { + fn open(owner: c.HWND) bool { + return FakeClipboard.open(owner); + } + fn close() void { + FakeClipboard.close(); + } + fn empty() bool { + return true; + } + fn stage(value: []const u16) ?usize { + return FakeClipboard.stage(value); + } + fn free(block: usize) void { + FakeClipboard.free(block); + } + fn publish(block: usize) bool { + return FakeClipboard.publish(block); + } + }; FakeClipboard.reset(FakeClipboard.units("old")); - FakeClipboard.unreadable = true; try std.testing.expectError( - error.ClipboardPreviousTextUnreadable, - writeTextWith(FakeClipboard, fake_owner, std.testing.allocator, "new"), + error.ClipboardWriteFailed, + writeTextWith(SkipsEmpty, fake_owner, std.testing.allocator, "new"), ); + try std.testing.expectEqual(@as(usize, 1), FakeClipboard.frees); + try std.testing.expectEqual(@as(isize, 0), FakeClipboard.live); try FakeClipboard.expectHeld(FakeClipboard.units("old")); - try std.testing.expectEqual(@as(usize, 0), FakeClipboard.sets); - try std.testing.expectEqual(@as(usize, 1), FakeClipboard.closes); +} - // An existing block with no terminator must not be republished by a restore: the copy is - // stopped before the clipboard is emptied, so a refused write has nothing to restore. - FakeClipboard.reset(&[_]u16{ 'a', 'b', 'c' }); - FakeClipboard.refused_sets = 1; +test "a copy that cannot stage its text touches nothing" { + FakeClipboard.reset(FakeClipboard.units("old")); + FakeClipboard.alloc_ok = false; try std.testing.expectError( - error.ClipboardPreviousTextUnreadable, + error.OutOfMemory, writeTextWith(FakeClipboard, fake_owner, std.testing.allocator, "new"), ); - try FakeClipboard.expectHeld(&[_]u16{ 'a', 'b', 'c' }); + try std.testing.expectEqual(@as(usize, 1), FakeClipboard.stages); + try std.testing.expectEqual(@as(usize, 0), FakeClipboard.opens); + try std.testing.expectEqual(@as(usize, 0), FakeClipboard.empties); try std.testing.expectEqual(@as(usize, 0), FakeClipboard.sets); + try std.testing.expectEqual(@as(usize, 0), FakeClipboard.frees); + try FakeClipboard.expectHeld(FakeClipboard.units("old")); } -test "a block whose size is not whole UTF-16 units is unreadable and stops the copy before the clipboard is emptied" { - const allocator = std.testing.allocator; - const block = [_]u16{ 'a', 0, 'b' }; - for ([_]usize{ 0, 1, 3, 5 }) |bytes| try std.testing.expect(snapshotBlock(allocator, &block, bytes) == .unreadable); - const whole = snapshotBlock(allocator, &block, 4); - defer allocator.free(whole.units); - try std.testing.expectEqualSlices(u16, &[_]u16{ 'a', 0 }, whole.units); - - // Windows reports 3 bytes for the existing item: it must not be truncated to one unit and - // treated as valid, nor emptied and lost. +test "the new text is staged before the clipboard is opened" { FakeClipboard.reset(FakeClipboard.units("old")); - FakeClipboard.reported_bytes = 3; - FakeClipboard.refused_sets = 1; + try writeTextWith(FakeClipboard, fake_owner, std.testing.allocator, "new"); + try std.testing.expectEqual(@as(usize, 1), FakeClipboard.stages); + try std.testing.expect(!FakeClipboard.staged_while_open); +} + +test "a copy that cannot open the clipboard frees its staged text exactly once" { + FakeClipboard.reset(FakeClipboard.units("old")); + FakeClipboard.open_ok = false; try std.testing.expectError( - error.ClipboardPreviousTextUnreadable, + error.ClipboardOpenFailed, writeTextWith(FakeClipboard, fake_owner, std.testing.allocator, "new"), ); + try std.testing.expectEqual(@as(usize, 1), FakeClipboard.stages); + try std.testing.expectEqual(@as(usize, 1), FakeClipboard.frees); + try std.testing.expectEqual(@as(isize, 0), FakeClipboard.live); + try std.testing.expectEqual(@as(usize, 0), FakeClipboard.empties); + try std.testing.expectEqual(@as(usize, 0), FakeClipboard.closes); try FakeClipboard.expectHeld(FakeClipboard.units("old")); - try std.testing.expectEqual(@as(usize, 0), FakeClipboard.sets); } -test "a restore republishes only the text through its terminator" { - FakeClipboard.reset(&[_]u16{ 'o', 'l', 'd', 0, 'j', 'u', 'n', 'k' }); - FakeClipboard.refused_sets = 1; +test "a copy that cannot empty the clipboard frees its staged text exactly once" { + FakeClipboard.reset(FakeClipboard.units("old")); + FakeClipboard.empty_ok = false; try std.testing.expectError( - error.ClipboardWriteFailedKeptPreviousText, + error.ClipboardClearFailed, writeTextWith(FakeClipboard, fake_owner, std.testing.allocator, "new"), ); + try std.testing.expectEqual(@as(usize, 1), FakeClipboard.frees); + try std.testing.expectEqual(@as(isize, 0), FakeClipboard.live); + try std.testing.expectEqual(@as(usize, 0), FakeClipboard.sets); + try std.testing.expectEqual(@as(usize, 1), FakeClipboard.closes); try FakeClipboard.expectHeld(FakeClipboard.units("old")); } + +test "a refused publish frees its staged text exactly once, is an ordinary write failure, and restores nothing" { + FakeClipboard.reset(FakeClipboard.units("old")); + FakeClipboard.publish_ok = false; + try std.testing.expectError( + error.ClipboardWriteFailed, + writeTextWith(FakeClipboard, fake_owner, std.testing.allocator, "new"), + ); + try std.testing.expectEqual(@as(usize, 1), FakeClipboard.empties); + try std.testing.expectEqual(@as(usize, 1), FakeClipboard.sets); + try std.testing.expectEqual(@as(usize, 1), FakeClipboard.frees); + try std.testing.expectEqual(@as(isize, 0), FakeClipboard.live); + try std.testing.expectEqual(@as(usize, 1), FakeClipboard.closes); + // The clipboard was emptied and stays empty: no earlier text is put back. + try FakeClipboard.expectHeld(null); +} + +test "a successful publish hands the block to Windows and the caller never frees it" { + FakeClipboard.reset(FakeClipboard.units("old")); + try writeTextWith(FakeClipboard, fake_owner, std.testing.allocator, "new"); + try FakeClipboard.expectHeld(FakeClipboard.units("new")); + try std.testing.expectEqual(@as(usize, 1), FakeClipboard.opens); + try std.testing.expectEqual(@as(usize, 1), FakeClipboard.empties); + try std.testing.expectEqual(@as(usize, 1), FakeClipboard.sets); + try std.testing.expectEqual(@as(usize, 1), FakeClipboard.closes); + try std.testing.expectEqual(@as(usize, 0), FakeClipboard.frees); + try std.testing.expectEqual(@as(isize, 0), FakeClipboard.live); +} diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index eccae05c..92563e1f 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -246,7 +246,7 @@ case proves ownership only. All existing Partial rows remain 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 | The Loop, View, and File menus label Show in Graph (Ctrl+Shift+G), Reconnect (Ctrl+R), and Project Worktree Policy (Ctrl+Shift+P); Help > Keyboard Shortcuts lists remaining useful mapped routes (Ctrl+M, context-sensitive Ctrl+E, Ctrl+Up/Down, worktree Up/Down, terminal focus 1/2, Ctrl+Shift+X, Ctrl+Shift+V) and toolbar/palette navigation. Context menus retain their applicable F2, Delete, Ctrl+S, Ctrl+N, and Quick Chat labels. No gesture hints are advertised. Canvas/pointer discovery remains incomplete: canvas click-to-select/open, blank-canvas drag-to-pan, node drag-to-move, connector drag-to-create-edge, wheel/pinch zoom, right-click context-menu access, and sidebar root-loop drag-to-reorder still lack visible gesture hints. Closing the canvas gesture gap requires rendering in the canvas-owning `GraphCanvas.zig`; sidebar reorder guidance belongs to excluded `Sidebar.zig`. Both are outside this PR's scope. The hidden-window regression inspects the production HMENU labels and verifies keyboard Help content has no gesture rows. RED: `& $env:GRAPHCODE_ZIG0152 test src\MainWindow.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 -ladvapi32 "-I$env:GRAPHCODE_WINGHOSTTY_ROOT\include" --test-filter 'main and help menus expose missing shortcuts without canvas gesture claims'` -> failed because 10 keyboard-only rows were expected but the Help menu contained 18 rows, including gesture claims. GREEN: same focused pinned-Zig command after the change -> 1 test passed. REGRESSION: `& $env:GRAPHCODE_ZIG0152 test src\MainWindow.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 -ladvapi32 "-I$env:GRAPHCODE_WINGHOSTTY_ROOT\include"` -> all 48 MainWindow and imported InputRouter tests passed; the 18-entry accelerator assertion and original 15-binding order remain unchanged. Ctrl+P duplicates Ctrl+J; Ctrl+Shift+I duplicates Ctrl+Shift+W. The earlier claim that Ctrl+Shift+C is a binding conflict was wrong: a source audit of the accelerator table, `pretranslateKey`, `onShellKey`, `fallbackKeyAction` and `InputRouter.keyAction` found no Ctrl+Shift+C accelerator and no dispatch collision. The chord deliberately copies while a terminal is focused and clones otherwise, and Clone stays reachable from a terminal through File > Add Folder. The real defect was discovery text, now fixed: the File menu reads `Clone Repository...\tCtrl+Shift+C outside terminal` and Help > Keyboard Shortcuts carries a greyed `Copy terminal text\tCtrl+Shift+C` entry immediately before Paste. A MainWindow test asserts a positive accelerator count, zero Ctrl+Shift+C accelerators, `keyAction('C', ctrl, shift) == clone_repository`, both exact labels, and that the Copy entry is informational with command ID 0; MainWindow passed 50/50 and InputRouter 9/9, and the hosted integration job 109828242393 ran the live Add Folder menu check against the qualified label. These are menu/source and live-menu tests, not physical keyboard, touch-device, or macOS runtime evidence, and the canvas and sidebar gesture hints above are still absent; the row remains Partial. Terminal-focused keys now follow one rule: Ctrl+D, W, S, T, N, `[` and `]` go to the shell instead of the matching menu accelerators, which stay available elsewhere and have terminal-safe alternatives (Ctrl+Shift+T new tab, Ctrl+Shift+N new loop, Alt+Shift+D split right, Ctrl+Shift+W close tab, Ctrl+Shift+[ / ] pane focus) that the Terminal and Loop menus now label; Help > Keyboard Shortcuts states the rule. F6 and F10 follow the same rule: while a terminal has focus they are encoded to it (`ESC[17~`, `ESC[21~`; Shift+F10 stays the terminal context menu) instead of entering the window toolbar or the native menu bar, and the shell keeps Ctrl+Shift+F6 (window toolbar) and Ctrl+Shift+F10 (menu bar) for those, labelled in View > Focus Window Toolbar and Help > Keyboard Shortcuts. Evidence is in-process: routing and dispatch unit tests, a hidden real-window fixture that checks a terminal-owned F10 is neither buffered nor passed to default menu processing, and a production-dispatch test that F1-F12 each enqueue their encoder sequence once. Known limitation, Ctrl+S: plain Ctrl+S is still the raw 0x13 byte. In a program that reads with line input on (for example pwsh `[Console]::ReadKey`) conhost's pause-output handling swallows Ctrl+S and the next key, while Ctrl+T alone works; a win32-input-mode workaround was rejected because an isolated zmx 785b3fd matrix showed it breaks Git MSYS vim (Enter no longer honored) and makes cmd.exe insert a literal ^S. That evidence is not a Dev Box walkthrough, and Ctrl+S is not claimed to work. Tests assert only that Ctrl+S/T/D/W/N add no shell-encoded bytes. The App `live terminal keyboard` key table does drive synthesized WM_KEYDOWN through the production dispatch into a real Winghostty surface (F6, Shift+F6, F10 in both key-down families, Ctrl+F10, F11 and F12 reach the input queue; Ctrl+Shift+F10 posts SC_KEYMENU), but no physical keyboard, real conhost, or Dev Box walkthrough ran, and menu mnemonics (Alt, Alt+letter) are terminal input while a terminal has focus | 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. A native-linked test now also drives US-International dead keys (`'` then `e`/`a` committing `é`/`á`) and German AltGr+Q (`@`) through the production dispatch into a real Winghostty terminal surface and observes the bytes queued for the shell, so the shell's own key encoding cannot swallow or double them; it is in-process evidence, not a physical keyboard or live IME walkthrough | Partial | -| Clipboard/selection | Terminal copy/paste and mouse selection | Terminal-context Ctrl+Shift+C copies the active surface's mouse selection (read from the shell's own VT through the pinned libghostty-vt formatter) or, when there is none, its UI Automation-reported selection range through Winghostty into Windows `CF_UNICODETEXT`; Ctrl+Shift+V or Shift+Insert reads only `CF_UNICODETEXT`, converts UTF-16 to UTF-8, normalises line endings, and encodes it with the pinned `ghostty_paste_encode` (the control bytes xterm blanks, including ESC, become spaces, as do the C1 controls U+0080 to U+009F; BEL and tab pass through; text over 1,000,000 UTF-8 bytes is refused before it is converted or copied), wrapping it in bracketed-paste markers only when the running program set DECSET 2004 (read from the shell's own VT state; Winghostty's `winghostty_surface_paste_text` always brackets and does not sanitize, so it is not used, and the shell no longer registers the provider's `on_paste` callback; in the pinned provider that callback is reached only from that exported function, which the shell never calls, and the host has no WM_PASTE, drag-and-drop, middle-click or context-menu paste; a live test calls the provider entry point and observes nothing reach the input queue). A refused clipboard write restores the previous Unicode text, or is refused before the clipboard is emptied when that text cannot be copied safely; other clipboard formats are not preserved (Clipboard.zig tests use a stand-in clipboard, not the real one; the native Open/Empty/SetClipboardData path is still unexecuted) and the status says what happened. Pinned Zig `Clipboard.zig` tests pass 14/14 including that stand-in and the size limit; app routing tests prove the shortcuts are terminal-context-only and preserve the existing global Ctrl+Shift+C Clone Repository route. Multi-line text for a program that did not enable bracketed paste asks for confirmation before it is sent (macOS auto-confirms with no dialog; bracketed pastes are never prompted); a declined paste sends nothing. A measured ConPTY probe (pwsh 7.6.6, PSReadLine 2.4.5) shows pwsh never requests bracketed paste and runs each pasted line as soon as it arrives, exactly as cmd.exe does, so pwsh is asked too and that confirmation is deliberate, not a defect. The winghostty modifier mask (Shift 0x04, Ctrl 0x08, Alt 0x80) was previously decoded as GhosttyMods bits, so these chords never routed and Shift+Tab was treated as loop navigation; the mask is now decoded correctly and the translated `^C`/`^V` characters are discarded for chords the shell handles. Terminal menu Copy/Paste, a terminal context menu (right-click, Menu key, Shift+F10), and Ctrl+Insert/Shift+Insert exist; plain Ctrl+C copies only while the surface reports a selection. Evidence is in-process: unit tests, native-linked tests that drive real WM_KEYDOWN/WM_CHAR and real mouse messages (press, drag, double click, Shift+click, release) through a real Winghostty surface and check the resulting selection text, the colors published to the host, and the copy route (through a clipboard seam); and menu tests. They did not use the real clipboard, a Dev Box, or a physical keyboard or mouse. Mouse selection follows Ghostty: drag selects with a visible highlight (the host draws none, so the shell swaps foreground and background in the snapshot it publishes), double click selects a word, triple click a soft-wrapped logical line, Shift+click extends from the far end, a click or typing clears, soft-wrapped rows copy as one line and trailing blanks are dropped. It is tracked in screen coordinates, so it stays on its text as output scrolls, and it ends on resize, paste, typing and an alternate-screen switch. Not done: auto-scroll while dragging past the pane, block (rectangular) selection, scrollback viewport scrolling by wheel (not wired in the shell at all yet), and the selection is not adjusted if more than 10,000 scrollback rows are pruned underneath it. It needs the VT parser, which is the default; the `GRAPHCODE_EXPERIMENTAL_TERMINAL_VT=0` opt-out has no mouse selection. The full Windows shell compiles against the pinned provider, but the probe did not reach any named live residual: the focused clipboard tests do not execute `OpenClipboard`/`GetClipboardData`/`SetClipboardData`; a real copy would replace the shared desktop clipboard, and no isolated clipboard desktop is available. `uia-live-gate.ps1` can reacquire `graphcode-root` through `FromHandle` while backgrounded, but its UI actions explicitly reacquire foreground and its mouse/key helpers post to the shell window; it has no clipboard-specific action/assertion or terminal text/selection fixture. A successful build is not runtime terminal evidence, so the UIA gate was not run for this row. Real-desktop mouse-driven selection and the rendered highlight pixels were not observed; the tests above check the cells handed to the host, not what it drew. The registered `onClipboardRead` and `onClipboardWrite` callbacks still discard their event data; `onSelection` now only ends a drag when the host loses mouse capture. End-to-end copy/paste likewise remains untested without a live Dev Box walkthrough and desktop input; the row remains Partial. Mouse reporting to terminal programs: while the VT state shows DECSET 9/1000/1002/1003 the left, middle and right buttons, motion and wheel are encoded by the pinned libghostty-vt mouse encoder (X10, UTF-8, SGR, URXVT and SGR-pixels formats) and queued to the shell, Shift keeps the local selection (and the context menu on right-click), and a gesture keeps the route it began on; before this, every left press became a local selection and mouse-aware TUIs (vim, tmux, htop, the Copilot CLI) received nothing. This is evidenced only by exact-byte VT tests and in-process synthetic mouse messages to a real Winghostty surface window feeding the fake zmx stand-in: no real tmux, vim, pwsh, conhost or Dev Box, no physical mouse, and not Alt, side buttons, horizontal wheel or WM_CANCELMODE (see the README); lost capture, lost focus and a release outside the window are closed by releasing the program's buttons, tested with synthetic messages only; hover under 1003 and wheel were checked only as bytes in the input queue | Partial | +| Clipboard/selection | Terminal copy/paste and mouse selection | Terminal-context Ctrl+Shift+C copies the active surface's mouse selection (read from the shell's own VT through the pinned libghostty-vt formatter) or, when there is none, its UI Automation-reported selection range through Winghostty into Windows `CF_UNICODETEXT`; Ctrl+Shift+V or Shift+Insert reads only `CF_UNICODETEXT`, converts UTF-16 to UTF-8, normalises line endings, and encodes it with the pinned `ghostty_paste_encode` (the control bytes xterm blanks, including ESC, become spaces, as do the C1 controls U+0080 to U+009F; BEL and tab pass through; text over 1,000,000 UTF-8 bytes is refused before it is converted or copied), wrapping it in bracketed-paste markers only when the running program set DECSET 2004 (read from the shell's own VT state; Winghostty's `winghostty_surface_paste_text` always brackets and does not sanitize, so it is not used, and the shell no longer registers the provider's `on_paste` callback; in the pinned provider that callback is reached only from that exported function, which the shell never calls, and the host has no WM_PASTE, drag-and-drop, middle-click or context-menu paste; a live test calls the provider entry point and observes nothing reach the input queue). A successful terminal copy intentionally replaces every clipboard format with plain Unicode text (macOS does the same with `clearContents` then `setString`); nothing earlier is read or restored, because reading another program's delayed-render data has no timeout and restoring only its text would drop the history and cloud opt-out formats. The new text is copied into a clipboard block before the clipboard is opened, so running out of memory cannot happen after it is emptied; a copy the clipboard then refuses fails with an ordinary write failure that can leave the clipboard empty, and the status says so (Clipboard.zig tests use a stand-in clipboard with open, empty, publish and free counters that refuses calls made in the wrong order (empty while closed, publish before empty) and checks the staged block is freed exactly once on every failure and never after success, not the real one; the native Open/Empty/SetClipboardData path is still unexecuted, and RDP or Dev Box copy and paste through rdpclip is not verified). Pinned Zig `Clipboard.zig` tests pass 16/16 (its 15 tests plus the imported Win32 one) including that stand-in and the size limit; app routing tests prove the shortcuts are terminal-context-only and preserve the existing global Ctrl+Shift+C Clone Repository route. Multi-line text for a program that did not enable bracketed paste asks for confirmation before it is sent (macOS auto-confirms with no dialog; bracketed pastes are never prompted); a declined paste sends nothing. A measured ConPTY probe (pwsh 7.6.6, PSReadLine 2.4.5) shows pwsh never requests bracketed paste and runs each pasted line as soon as it arrives, exactly as cmd.exe does, so pwsh is asked too and that confirmation is deliberate, not a defect. The winghostty modifier mask (Shift 0x04, Ctrl 0x08, Alt 0x80) was previously decoded as GhosttyMods bits, so these chords never routed and Shift+Tab was treated as loop navigation; the mask is now decoded correctly and the translated `^C`/`^V` characters are discarded for chords the shell handles. Terminal menu Copy/Paste, a terminal context menu (right-click, Menu key, Shift+F10), and Ctrl+Insert/Shift+Insert exist; plain Ctrl+C copies only while the surface reports a selection. Evidence is in-process: unit tests, native-linked tests that drive real WM_KEYDOWN/WM_CHAR and real mouse messages (press, drag, double click, Shift+click, release) through a real Winghostty surface and check the resulting selection text, the colors published to the host, and the copy route (through a clipboard seam); and menu tests. They did not use the real clipboard, a Dev Box, or a physical keyboard or mouse. Mouse selection follows Ghostty: drag selects with a visible highlight (the host draws none, so the shell swaps foreground and background in the snapshot it publishes), double click selects a word, triple click a soft-wrapped logical line, Shift+click extends from the far end, a click or typing clears, soft-wrapped rows copy as one line and trailing blanks are dropped. It is tracked in screen coordinates, so it stays on its text as output scrolls, and it ends on resize, paste, typing and an alternate-screen switch. Not done: auto-scroll while dragging past the pane, block (rectangular) selection, scrollback viewport scrolling by wheel (not wired in the shell at all yet), and the selection is not adjusted if more than 10,000 scrollback rows are pruned underneath it. It needs the VT parser, which is the default; the `GRAPHCODE_EXPERIMENTAL_TERMINAL_VT=0` opt-out has no mouse selection. The full Windows shell compiles against the pinned provider, but the probe did not reach any named live residual: the focused clipboard tests do not execute `OpenClipboard`/`GetClipboardData`/`SetClipboardData`; a real copy would replace the shared desktop clipboard, and no isolated clipboard desktop is available. `uia-live-gate.ps1` can reacquire `graphcode-root` through `FromHandle` while backgrounded, but its UI actions explicitly reacquire foreground and its mouse/key helpers post to the shell window; it has no clipboard-specific action/assertion or terminal text/selection fixture. A successful build is not runtime terminal evidence, so the UIA gate was not run for this row. Real-desktop mouse-driven selection and the rendered highlight pixels were not observed; the tests above check the cells handed to the host, not what it drew. The registered `onClipboardRead` and `onClipboardWrite` callbacks still discard their event data; `onSelection` now only ends a drag when the host loses mouse capture. End-to-end copy/paste likewise remains untested without a live Dev Box walkthrough and desktop input; the row remains Partial. Mouse reporting to terminal programs: while the VT state shows DECSET 9/1000/1002/1003 the left, middle and right buttons, motion and wheel are encoded by the pinned libghostty-vt mouse encoder (X10, UTF-8, SGR, URXVT and SGR-pixels formats) and queued to the shell, Shift keeps the local selection (and the context menu on right-click), and a gesture keeps the route it began on; before this, every left press became a local selection and mouse-aware TUIs (vim, tmux, htop, the Copilot CLI) received nothing. This is evidenced only by exact-byte VT tests and in-process synthetic mouse messages to a real Winghostty surface window feeding the fake zmx stand-in: no real tmux, vim, pwsh, conhost or Dev Box, no physical mouse, and not Alt, side buttons, horizontal wheel or WM_CANCELMODE (see the README); lost capture, lost focus and a release outside the window are closed by releasing the program's buttons, tested with synthetic messages only; hover under 1003 and wheel were checked only as bytes in the input queue | 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 | | Font rendering quality | Legible, ClearType-quality text on every surface, matching macOS's default anti-aliased text | The shared `AppFont.zig` cache requests Segoe UI at `CLEARTYPE_QUALITY`, with per-DPI native controls and logical-size selection for buffered canvas painting. [Actual 96-DPI glyph samples](visual-baseline/rendered-windows/README.md#what-the-pixels-establish) preserve card/sidebar/Settings-title pixels with 27/31/55 distinct colors; the native button sample has two. These counts and recorded system font-smoothing settings are observations, not legibility thresholds or actual font-face certification. Native control LOGFONT metadata, every-surface/multi-DPI review, terminal-text readability and matched current macOS evidence remain unavailable. No speculative font fix was made | Partial |