diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index 4585d5d7..d8a40af2 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -296,9 +296,16 @@ it. Copy puts exactly the selected text on the clipboard: soft-wrapped rows join into one line and trailing blanks are dropped. Plain `Ctrl+V` stays terminal input and plain `Ctrl+C` stays the interrupt, except that `Ctrl+C` copies while the terminal has a selection. Paste reads only -`CF_UNICODETEXT`, normalises line endings, blanks unsafe control bytes, and +`CF_UNICODETEXT`, normalises line endings, blanks the control bytes xterm blanks +(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); 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 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 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 2ed80a9a..77606b91 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -265,11 +265,27 @@ fn terminalPasteFailureStatus(err: anyerror) []const u8 { if (err == error.TerminalPasteRequiresConfirmation) { return "Paste cancelled: multi-line text was not sent to the terminal"; } + if (err == error.TerminalPasteTooLarge or err == error.ClipboardTextTooLarge) { + return "Paste cancelled: clipboard text is larger than 1,000,000 bytes"; + } if (err == error.TerminalClipboardUnavailable) return "Terminal clipboard is unavailable"; if (err == error.TerminalSurfaceUnavailable) return "No terminal is ready to paste into"; 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. +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.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"; +} + fn pasteLineCount(text: []const u8) usize { var lines: usize = 1; var index: usize = 0; @@ -296,7 +312,7 @@ const TerminalCopyApi = struct { const TerminalPasteApi = struct { fn readClipboard(owner: c.HWND, allocator: std.mem.Allocator) ![]u8 { - return Clipboard.readText(owner, allocator); + return Clipboard.readText(owner, allocator, @import("TerminalVt.zig").max_paste_bytes); } fn confirmMultiline(owner: c.HWND, lines: usize) bool { @@ -6536,7 +6552,7 @@ pub const App = struct { defer self.allocator.free(selection); Api.writeClipboard(self.window.hwnd, self.allocator, selection) catch |err| { std.log.warn("Unable to write terminal selection to Windows clipboard: {s}", .{@errorName(err)}); - self.setStatus("Unable to copy terminal selection"); + self.setStatus(terminalCopyFailureStatus(err)); return; }; self.setStatus("Terminal selection copied"); @@ -6551,7 +6567,7 @@ pub const App = struct { const workspace = self.workspace orelse return; const text = Api.readClipboard(self.window.hwnd, self.allocator) catch |err| { std.log.warn("Unable to read Windows clipboard for terminal paste: {s}", .{@errorName(err)}); - self.setStatus("Unable to paste clipboard text"); + self.setStatus(terminalPasteFailureStatus(err)); return; }; defer self.allocator.free(text); @@ -10655,6 +10671,59 @@ test "terminal paste never prompts when the program asked for bracketed paste" { try std.testing.expectEqualStrings("\x1b[200~line one\nline two\n\x1b[201~", item.bytes); } +test "terminal paste refuses clipboard text above the size limit with a clear status and sends nothing" { + const Api = struct { + var oversize = false; + + fn readClipboard(_: c.HWND, allocator: std.mem.Allocator) ![]u8 { + if (!oversize) return error.ClipboardTextTooLarge; + const text = try allocator.alloc(u8, 1_000_001); + @memset(text, 'a'); + return text; + } + + fn confirmMultiline(_: c.HWND, _: usize) bool { + return true; + } + }; + const allocator = std.testing.allocator; + var workspace = try reopenTestWorkspace(allocator, "terminal-paste-too-large-layout.json"); + defer workspace.deinit(); + defer workspace.surfaces[0].surface = null; + workspace.surfaces[0].surface = @ptrFromInt(0x1000); + var app = try overviewTestApp(Dpi.base_dpi); + defer deinitOverviewTestApp(&app); + defer app.workspace = null; + app.workspace = &workspace; + + for ([_]bool{ false, true }) |oversize| { + Api.oversize = oversize; + app.pasteClipboardTextWith(Api); + try std.testing.expectEqualStrings("Paste cancelled: clipboard text is larger than 1,000,000 bytes", app.status()); + try std.testing.expectEqual(@as(usize, 0), workspace.input_queue.count); + } +} + +test "a refused terminal copy says whether the user's previous clipboard text survived" { + try std.testing.expectEqualStrings( + "Unable to copy: the clipboard refused the text; its previous text was kept", + terminalCopyFailureStatus(error.ClipboardWriteFailedKeptPreviousText), + ); + try std.testing.expectEqualStrings( + "Unable to copy: the clipboard refused the text and its previous text could not be restored", + terminalCopyFailureStatus(error.ClipboardWriteFailedLostPreviousText), + ); + 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)); +} + test "workspace cycle keyboard fallback never turns Alt paging into terminal tabs" { try std.testing.expectEqual(InputRouter.Action.none, fallbackKeyAction(c.VK_NEXT, true, false, true)); try std.testing.expectEqual(InputRouter.Action.none, fallbackKeyAction(c.VK_PRIOR, true, false, true)); @@ -13689,6 +13758,60 @@ const KeyTableCase = struct { expected: []const u8, }; +test "live terminal copy: a clipboard that refuses the text keeps the selection and reports it" { + 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(); + const mouse = try LiveMouse.begin(&keyboard); + try feedMouseFixture(&fixture, keyboard.index); + mouse.drag(0, 1, 8, 1); + try mouse.expectSelected("GC-COPY-2"); + + const Refusing = struct { + var err: anyerror = error.ClipboardWriteFailed; + + fn writeClipboard(_: c.HWND, _: std.mem.Allocator, _: []const u8) !void { + return err; + } + }; + Refusing.err = error.ClipboardWriteFailedLostPreviousText; + fixture.app.copyTerminalSelectionWith(Refusing); + try std.testing.expectEqualStrings( + "Unable to copy: the clipboard refused the text and its previous text could not be restored", + fixture.app.status(), + ); + // Nothing was copied, so the selection stays for another attempt. + try mouse.expectSelected("GC-COPY-2"); +} + +test "live terminal paste: the provider's own paste entry point puts nothing raw on the pty" { + 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(); + const surface = fixture.workspace.surfaces[keyboard.index].surface.?; + var sent: std.ArrayListUnmanaged(u8) = .empty; + defer sent.deinit(std.testing.allocator); + + // winghostty_surface_paste_text brackets unconditionally and hands the payload to on_paste + // as is, so the shell must not route that callback to the pty. + for ([_][]const u8{ "plain", "x\x1b[201~rm -rf /\r\n", "line one\r\nline two" }) |text| { + _ = c.winghostty_surface_paste_text(surface, text.ptr, @intCast(text.len), 1); + try keyboard.drainInput(&sent); + if (sent.items.len != 0) std.debug.print("provider paste reached the pty: {any}\n", .{sent.items}); + try std.testing.expectEqualStrings("", sent.items); + } +} + 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" }}); diff --git a/graphcode-windows/src/Clipboard.zig b/graphcode-windows/src/Clipboard.zig index 7b42e046..2c317d45 100644 --- a/graphcode-windows/src/Clipboard.zig +++ b/graphcode-windows/src/Clipboard.zig @@ -9,33 +9,110 @@ 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; + } + + fn close() void { + _ = c.CloseClipboard(); + } + + fn empty() bool { + 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; + const locked = c.GlobalLock(memory) orelse { + _ = c.GlobalFree(memory); + return false; + }; + 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; + } +}; + pub fn writeText(owner: c.HWND, allocator: std.mem.Allocator, text: []const u8) !void { + 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. +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 unit_count = try std.math.add(usize, encoded.len, 1); - const byte_count = try std.math.mul(usize, unit_count, @sizeOf(u16)); - const memory = c.GlobalAlloc(c.GMEM_MOVEABLE, byte_count) orelse return error.ClipboardAllocationFailed; - var clipboard_owns_memory = false; - defer if (!clipboard_owns_memory) { - _ = c.GlobalFree(memory); - }; - - const locked = c.GlobalLock(memory) orelse return error.ClipboardLockFailed; - const destination: [*]u16 = @ptrCast(@alignCast(locked)); - @memcpy(destination[0..encoded.len], encoded); - destination[encoded.len] = 0; - _ = c.GlobalUnlock(memory); - - if (c.OpenClipboard(owner) == 0) return error.ClipboardOpenFailed; - defer _ = c.CloseClipboard(); - if (c.EmptyClipboard() == 0) return error.ClipboardClearFailed; - if (c.SetClipboardData(c.CF_UNICODETEXT, memory) == null) return error.ClipboardWriteFailed; - clipboard_owns_memory = true; + 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; } -pub fn readText(owner: c.HWND, allocator: std.mem.Allocator) ![]u8 { +/// Reads the clipboard's Unicode text as UTF-8. Text that would be longer than `max_bytes` +/// UTF-8 bytes is refused with nothing allocated for it: a block of more than `max_bytes` +/// UTF-16 units cannot fit (each unit is at least one byte), and a smaller one has its exact +/// UTF-8 length computed before it is converted. +pub fn readText(owner: c.HWND, allocator: std.mem.Allocator, max_bytes: usize) ![]u8 { if (owner == null) return error.ClipboardOwnerUnavailable; if (c.OpenClipboard(owner) == 0) return error.ClipboardOpenFailed; defer _ = c.CloseClipboard(); @@ -47,13 +124,42 @@ pub fn readText(owner: c.HWND, allocator: std.mem.Allocator) ![]u8 { defer _ = c.GlobalUnlock(memory); const units: [*]const u16 = @ptrCast(@alignCast(locked)); - const capacity = byte_count / @sizeOf(u16); + return textFromUnits(allocator, units, byte_count / @sizeOf(u16), max_bytes); +} + +fn textFromUnits(allocator: std.mem.Allocator, units: [*]const u16, capacity: usize, max_bytes: usize) ![]u8 { + const scan_limit = @min(capacity, max_bytes +| 1); var length: usize = 0; - while (length < capacity and units[length] != 0) : (length += 1) {} + while (length < scan_limit and units[length] != 0) : (length += 1) {} + if (length > max_bytes) return error.ClipboardTextTooLarge; if (length == capacity) return error.UnterminatedClipboardText; + if (try utf8Length(units[0..length]) > max_bytes) return error.ClipboardTextTooLarge; return decodeUtf16(allocator, units[0..length]); } +/// The UTF-8 length of UTF-16 text, without converting it. +fn utf8Length(units: []const u16) !usize { + var total: usize = 0; + var index: usize = 0; + while (index < units.len) : (index += 1) { + const unit = units[index]; + if (unit < 0x80) { + total += 1; + } else if (unit < 0x800) { + total += 2; + } else if (unit >= 0xD800 and unit <= 0xDBFF) { + if (index + 1 >= units.len or units[index + 1] < 0xDC00 or units[index + 1] > 0xDFFF) return error.InvalidClipboardText; + total += 4; + index += 1; + } else if (unit >= 0xDC00 and unit <= 0xDFFF) { + return error.InvalidClipboardText; + } else { + total += 3; + } + } + return total; +} + test "clipboard UTF-16 conversion preserves Unicode and multiline text" { const text = "snowman ☃, emoji \u{1f642}\r\nsecond line\nthird line"; const wide = try encodeUtf16(std.testing.allocator, text); @@ -83,6 +189,240 @@ test "clipboard Win32 operations require an owner window" { ); try std.testing.expectError( error.ClipboardOwnerUnavailable, - readText(null, std.testing.allocator), + readText(null, std.testing.allocator, 100), + ); +} + +/// 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. +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; + + fn units(comptime text: []const u8) []const u16 { + const literal = std.unicode.utf8ToUtf16LeStringLiteral(text); + return literal.ptr[0 .. literal.len + 1]; + } + + fn reset(text: ?[]const u16) void { + if (text) |value| { + @memcpy(storage[0..value.len], value); + held = storage[0..value.len]; + } else held = null; + open_ok = true; + empty_ok = true; + unreadable = false; + reported_bytes = null; + refused_sets = 0; + sets = 0; + closes = 0; + } + + fn open(_: c.HWND) bool { + return open_ok; + } + + fn close() void { + closes += 1; + } + + fn empty() bool { + if (!empty_ok) return false; + held = null; + 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 setUnits(value: []const u16) bool { + sets += 1; + if (sets <= refused_sets) return false; + @memcpy(storage[0..value.len], value); + held = storage[0..value.len]; + return true; + } + + fn expectHeld(expected: ?[]const u16) !void { + if (expected) |value| { + try std.testing.expect(held != null); + try std.testing.expectEqualSlices(u16, value, held.?); + } else try std.testing.expect(held == null); + } +}; + +const fake_owner: c.HWND = @ptrFromInt(0x1000); + +test "clipboard write replaces the previous text" { + 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.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; + try std.testing.expectError( + error.ClipboardOpenFailed, + writeTextWith(FakeClipboard, fake_owner, std.testing.allocator, "new"), + ); + try std.testing.expectEqual(@as(usize, 0), FakeClipboard.closes); + try FakeClipboard.expectHeld(FakeClipboard.units("old")); + + FakeClipboard.reset(FakeClipboard.units("old")); + FakeClipboard.empty_ok = false; + try std.testing.expectError( + error.ClipboardClearFailed, + writeTextWith(FakeClipboard, fake_owner, std.testing.allocator, "new"), + ); + try std.testing.expectEqual(@as(usize, 1), FakeClipboard.closes); + try FakeClipboard.expectHeld(FakeClipboard.units("old")); +} + +test "clipboard text over the limit is refused before it is decoded" { + const allocator = std.testing.allocator; + const exact = FakeClipboard.units("abc"); + const text = try textFromUnits(allocator, exact.ptr, exact.len, 3); + defer allocator.free(text); + try std.testing.expectEqualStrings("abc", text); + + const longer = FakeClipboard.units("abcd"); + try std.testing.expectError(error.ClipboardTextTooLarge, textFromUnits(allocator, longer.ptr, longer.len, 3)); + // A terminator before the limit ends the text even when the block is much larger. + var block = [_]u16{0} ** 16; + block[0] = 'o'; + block[1] = 'k'; + const short = try textFromUnits(allocator, &block, block.len, 3); + defer allocator.free(short); + try std.testing.expectEqualStrings("ok", short); + const unterminated = [_]u16{ 'a', 'b', 'c' }; + try std.testing.expectError(error.UnterminatedClipboardText, textFromUnits(allocator, &unterminated, unterminated.len, 10)); +} + +test "the paste limit counts UTF-8 bytes, so wide characters are refused before conversion" { + const allocator = std.testing.allocator; + // Euro sign: one UTF-16 unit, three UTF-8 bytes. Emoji: a surrogate pair, four bytes. + const euro = FakeClipboard.units("\u{20ac}\u{20ac}"); + try std.testing.expectError(error.ClipboardTextTooLarge, textFromUnits(allocator, euro.ptr, euro.len, 5)); + const six = try textFromUnits(allocator, euro.ptr, euro.len, 6); + defer allocator.free(six); + try std.testing.expectEqualStrings("\u{20ac}\u{20ac}", six); + + const emoji = FakeClipboard.units("\u{1f600}"); + try std.testing.expectError(error.ClipboardTextTooLarge, textFromUnits(allocator, emoji.ptr, emoji.len, 3)); + const four = try textFromUnits(allocator, emoji.ptr, emoji.len, 4); + defer allocator.free(four); + try std.testing.expectEqualStrings("\u{1f600}", four); + // Refused by size, nothing was allocated: a failing allocator still reports the size error. + var failing = std.testing.FailingAllocator.init(allocator, .{ .fail_index = 0 }); + 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 "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. + FakeClipboard.reset(FakeClipboard.units("old")); + FakeClipboard.unreadable = true; + try std.testing.expectError( + error.ClipboardPreviousTextUnreadable, + writeTextWith(FakeClipboard, fake_owner, std.testing.allocator, "new"), + ); + 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; + try std.testing.expectError( + error.ClipboardPreviousTextUnreadable, + writeTextWith(FakeClipboard, fake_owner, std.testing.allocator, "new"), + ); + try FakeClipboard.expectHeld(&[_]u16{ 'a', 'b', 'c' }); + try std.testing.expectEqual(@as(usize, 0), FakeClipboard.sets); +} + +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. + FakeClipboard.reset(FakeClipboard.units("old")); + FakeClipboard.reported_bytes = 3; + FakeClipboard.refused_sets = 1; + try std.testing.expectError( + error.ClipboardPreviousTextUnreadable, + writeTextWith(FakeClipboard, fake_owner, std.testing.allocator, "new"), + ); + 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; + try std.testing.expectError( + error.ClipboardWriteFailedKeptPreviousText, + writeTextWith(FakeClipboard, fake_owner, std.testing.allocator, "new"), ); + try FakeClipboard.expectHeld(FakeClipboard.units("old")); } diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index d39477ed..00e01c31 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -2296,7 +2296,12 @@ pub const Workspace = struct { options.input_callbacks.on_mouse = @ptrCast(&onMouseDrawn); options.input_callbacks.on_selection = @ptrCast(&onSelection); options.input_callbacks.on_link = @ptrCast(&onLink); - options.input_callbacks.on_paste = @ptrCast(&onPaste); + // No on_paste. In the pinned provider (winghostty 6286560, win32_host.zig) emitPaste is + // reached only from the exported winghostty_surface_paste_text, which this shell never + // calls: the host has no WM_PASTE, drag-and-drop, middle-click or context-menu paste. + // That entry point always brackets and does not sanitize, so a callback would be an + // unsanitized route for any future caller. Every paste the shell performs goes through + // Workspace.pasteText. options.input_callbacks.on_clipboard_read = @ptrCast(&onClipboardRead); options.input_callbacks.on_clipboard_write = @ptrCast(&onClipboardWrite); options.input.cell_width = 8; @@ -4869,6 +4874,88 @@ test "terminal paste has no target without a live surface" { try workspace.pasteText("", false); } +const PasteCase = struct { name: []const u8, text: []const u8, bracketed: bool, expected: []const u8 }; + +test "every paste is normalized and stripped of control bytes, with or without bracketed paste" { + const cases = [_]PasteCase{ + .{ .name = "CRLF", .text = "a\r\nb", .bracketed = false, .expected = "a\rb" }, + .{ .name = "bare CR", .text = "a\rb", .bracketed = false, .expected = "a\rb" }, + .{ .name = "bare LF", .text = "a\nb", .bracketed = false, .expected = "a\rb" }, + .{ .name = "trailing LF", .text = "a\n", .bracketed = false, .expected = "a\r" }, + .{ .name = "trailing CRLF", .text = "a\r\n", .bracketed = false, .expected = "a\r" }, + .{ .name = "NUL", .text = "a\x00b", .bracketed = false, .expected = "a b" }, + .{ .name = "ESC closing a bracket", .text = "x\x1b[201~y", .bracketed = false, .expected = "x [201~y" }, + .{ .name = "DEL, BS, Ctrl+C and Ctrl+Z", .text = "a\x7fb\x08c\x03d\x1ae", .bracketed = false, .expected = "a b c d e" }, + // xterm's strip list, which the pinned encoder follows, does not include BEL. + .{ .name = "BEL passes through", .text = "a\x07b", .bracketed = false, .expected = "a\x07b" }, + .{ .name = "tab", .text = "a\tb", .bracketed = false, .expected = "a\tb" }, + .{ .name = "bracketed ESC closing the bracket", .text = "x\x1b[201~y\n", .bracketed = true, .expected = "\x1b[200~x [201~y\n\x1b[201~" }, + .{ .name = "bracketed CRLF", .text = "a\r\nb", .bracketed = true, .expected = "\x1b[200~a\nb\x1b[201~" }, + .{ .name = "bracketed NUL", .text = "a\x00b", .bracketed = true, .expected = "\x1b[200~a b\x1b[201~" }, + .{ .name = "bracketed trailing CRLF", .text = "a\r\n", .bracketed = true, .expected = "\x1b[200~a\n\x1b[201~" }, + }; + for (cases) |case| { + var workspace = try minimalWorkspaceForOptionsTest(std.testing.allocator); + defer workspace.layout.deinit(); + defer workspace.input_queue.clear(); + const slot = &workspace.surfaces[0]; + slot.surface = OrdinaryTabKeyboardTest.registered; + slot.vt = try TerminalVt.State.create(std.testing.allocator, 20, 3); + defer slot.vt.?.destroy(); + if (case.bracketed) try slot.vt.?.feed("\x1b[?2004h"); + try workspace.pasteText(case.text, true); + const item = workspace.input_queue.dequeue().?; + defer workspace.allocator.free(item.bytes); + if (!std.mem.eql(u8, case.expected, item.bytes)) + std.debug.print("paste case {s}: expected {any}, queued {any}\n", .{ case.name, case.expected, item.bytes }); + try std.testing.expectEqualSlices(u8, case.expected, item.bytes); + try std.testing.expect(workspace.input_queue.dequeue() == null); + } +} + +test "text that could close a bracket or run lines needs confirmation unless the program asked for brackets" { + var workspace = try minimalWorkspaceForOptionsTest(std.testing.allocator); + defer workspace.layout.deinit(); + defer workspace.input_queue.clear(); + const slot = &workspace.surfaces[0]; + slot.surface = OrdinaryTabKeyboardTest.registered; + slot.vt = try TerminalVt.State.create(std.testing.allocator, 20, 3); + defer slot.vt.?.destroy(); + for ([_][]const u8{ "a\nb", "a\r\nb", "x\x1b[201~y", "a\n" }) |text| { + try std.testing.expectError(error.TerminalPasteRequiresConfirmation, workspace.pasteText(text, false)); + } + try std.testing.expectEqual(@as(usize, 0), workspace.input_queue.count); + try workspace.pasteText("one line", false); + workspace.allocator.free(workspace.input_queue.dequeue().?.bytes); + // A program that asked for bracketed paste is not asked, and the markers cannot be closed early. + try slot.vt.?.feed("\x1b[?2004h"); + try workspace.pasteText("x\x1b[201~y\na", false); + const item = workspace.input_queue.dequeue().?; + defer workspace.allocator.free(item.bytes); + try std.testing.expectEqualStrings("\x1b[200~x [201~y\na\x1b[201~", item.bytes); +} + +test "a paste above the documented size limit is refused before anything is queued or allocated for it" { + var workspace = try minimalWorkspaceForOptionsTest(std.testing.allocator); + defer workspace.layout.deinit(); + defer workspace.input_queue.clear(); + const slot = &workspace.surfaces[0]; + slot.surface = OrdinaryTabKeyboardTest.registered; + const big = try std.testing.allocator.alloc(u8, 1_000_001); + defer std.testing.allocator.free(big); + @memset(big, 'a'); + try std.testing.expectError(error.TerminalPasteTooLarge, workspace.pasteText(big, true)); + try std.testing.expectEqual(@as(usize, 0), workspace.input_queue.count); + // The refusal does not allocate: a failing allocator cannot turn it into OutOfMemory. + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 0 }); + try std.testing.expectError(error.TerminalPasteTooLarge, TerminalVt.encodePaste(failing.allocator(), big, true, true)); + // The limit itself is accepted, bracket markers included. + try workspace.pasteText(big[0..1_000_000], true); + const item = workspace.input_queue.dequeue().?; + defer workspace.allocator.free(item.bytes); + try std.testing.expectEqual(@as(usize, 1_000_000), item.bytes.len); +} + test "right button release, Menu key and Shift+F10 raise the terminal context menu route once" { var workspace = try minimalWorkspaceForOptionsTest(std.testing.allocator); defer workspace.layout.deinit(); @@ -5311,14 +5398,6 @@ fn onLink( _ = clicked; } -fn onPaste(user_data: ?*anyopaque, surface: *c.winghostty_surface, text: [*:0]const u8, length: u32, bracketed: u8) callconv(.c) void { - 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]); - _ = bracketed; -} - fn onClipboardRead( user_data: ?*anyopaque, surface: *c.winghostty_surface, diff --git a/graphcode-windows/src/TerminalVt.zig b/graphcode-windows/src/TerminalVt.zig index 15769625..acdc69f9 100644 --- a/graphcode-windows/src/TerminalVt.zig +++ b/graphcode-windows/src/TerminalVt.zig @@ -559,32 +559,66 @@ fn dimensions(columns: usize, rows: usize) Error![2]u16 { return .{ @intCast(columns), @intCast(rows) }; } -pub const PasteError = error{ OutOfMemory, TerminalPasteRequiresConfirmation, TerminalPasteFailed }; +pub const PasteError = error{ OutOfMemory, TerminalPasteRequiresConfirmation, TerminalPasteFailed, TerminalPasteTooLarge }; + +/// The most clipboard text one paste carries, in UTF-8 bytes. It is a decimal million, a little +/// under the shell's 1 MiB terminal input queue, so a pasted payload with its bracketed-paste +/// markers still fits; larger text is refused before anything is allocated for it. +pub const max_paste_bytes: usize = 1_000_000; /// Prepares clipboard text for the pty the way Ghostty does for a paste: line endings -/// are normalised, unsafe control bytes (including ESC, so pasted text cannot close the -/// bracket itself) are blanked, and the text is wrapped in bracketed-paste markers only when -/// the running program enabled them. Multi-line text for a program that did not enable -/// them would run each line as typed, so it needs `allow_unbracketed_multiline`. +/// are normalised, the control bytes xterm blanks (NUL, BS, ENQ, EOT, ESC, DEL and the tty's +/// interrupt, quit, kill, suspend, start, stop, word-erase, literal-next, reprint and discard +/// characters, so pasted text cannot close the bracket itself) become spaces, and the text is +/// wrapped in bracketed-paste markers only when the running program enabled them. Other +/// control bytes, such as BEL, pass through. Multi-line text for a program that did not enable +/// them would run each line as typed, so it needs `allow_unbracketed_multiline`. Text over +/// `max_paste_bytes` is refused before it is copied. pub fn encodePaste( allocator: std.mem.Allocator, text: []const u8, bracketed: bool, allow_unbracketed_multiline: bool, ) PasteError![]u8 { + if (text.len > max_paste_bytes) return error.TerminalPasteTooLarge; var normalized = try allocator.alloc(u8, text.len); defer allocator.free(normalized); var length: usize = 0; var index: usize = 0; - while (index < text.len) : (index += 1) { + while (index < text.len) { const byte = text[index]; if (byte == '\r') { - if (index + 1 < text.len and text[index + 1] == '\n') index += 1; + index += if (index + 1 < text.len and text[index + 1] == '\n') 2 else 1; normalized[length] = '\n'; - } else { + length += 1; + continue; + } + if (byte < 0x80) { normalized[length] = byte; + length += 1; + index += 1; + continue; + } + // C1 controls (U+0080 to U+009F) include the 8-bit CSI, OSC and DCS introducers that some + // terminals honour even in UTF-8, so a pasted one is blanked, as a lone byte in that range + // is. Other multi-byte characters are copied whole. + const sequence = std.unicode.utf8ByteSequenceLength(byte) catch 1; + const whole = sequence > 1 and index + sequence <= text.len; + const codepoint: ?u21 = if (whole) std.unicode.utf8Decode(text[index .. index + sequence]) catch null else null; + if (codepoint) |value| { + if (value >= 0x80 and value <= 0x9f) { + normalized[length] = ' '; + length += 1; + } else { + @memcpy(normalized[length .. length + sequence], text[index .. index + sequence]); + length += sequence; + } + index += sequence; + } else { + normalized[length] = if (byte >= 0x80 and byte <= 0x9f) ' ' else byte; + length += 1; + index += 1; } - length += 1; } const data = normalized[0..length]; if (!bracketed and !allow_unbracketed_multiline and !c.ghostty_paste_is_safe(data.ptr, data.len)) @@ -638,6 +672,28 @@ test "paste encoding keeps Unicode and blanks escapes that could close the brack try std.testing.expect(std.mem.indexOfScalar(u8, inner, 0x1b) == null); } +test "paste encoding blanks C1 control characters, which some terminals read as escape introducers" { + const allocator = std.testing.allocator; + // U+009B is the 8-bit CSI: "\xc2\x9b" as UTF-8, or a lone 0x9b byte. It is blanked with the + // rest of the C1 range (0x80 to 0x9f) whether or not the program asked for brackets. + const cases = [_]struct { text: []const u8, bracketed: bool, expected: []const u8 }{ + .{ .text = "x\xc2\x9b201~y", .bracketed = false, .expected = "x 201~y" }, + .{ .text = "x\xc2\x9b201~y", .bracketed = true, .expected = "\x1b[200~x 201~y\x1b[201~" }, + .{ .text = "x\x9b201~y", .bracketed = false, .expected = "x 201~y" }, + .{ .text = "x\x9b201~y", .bracketed = true, .expected = "\x1b[200~x 201~y\x1b[201~" }, + .{ .text = "a\xc2\x90b\xc2\x9dc\xc2\x9fd", .bracketed = false, .expected = "a b c d" }, + // Characters whose UTF-8 bytes merely contain bytes in that range are untouched. + .{ .text = "\xe2\x82\xac\xc3\xa9\xf0\x9f\x98\x80", .bracketed = false, .expected = "\xe2\x82\xac\xc3\xa9\xf0\x9f\x98\x80" }, + .{ .text = "\xc2\xa0\xc2\x80", .bracketed = false, .expected = "\xc2\xa0 " }, + }; + for (cases) |case| { + const encoded = try encodePaste(allocator, case.text, case.bracketed, true); + defer allocator.free(encoded); + if (!std.mem.eql(u8, case.expected, encoded)) std.debug.print("C1 case {any}: got {any}\n", .{ case.text, encoded }); + try std.testing.expectEqualSlices(u8, case.expected, encoded); + } +} + test "VT reports the program's bracketed-paste request" { const state = try State.create(std.testing.allocator, 20, 3); defer state.destroy(); diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index da10c141..1adea979 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` (control bytes including ESC blanked), 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, so it is no longer used). Pinned Zig `Clipboard.zig` tests pass 4/4 for UTF-16 conversion, paste classification, and the null-owner guard; 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 | 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 | 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 |