From ee7c1940d8256a20ca7198ad1e0d51ac09f3bfe3 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 29 Jun 2026 16:13:11 -0300 Subject: resizing handler --- main.zig | 69 ++++++++++++++++++++++++++++++++--- tests.zig | 123 ++++++++++++++++++++++++++++++++------------------------------ 2 files changed, 127 insertions(+), 65 deletions(-) diff --git a/main.zig b/main.zig index 1181daf7..5d8cb228 100644 --- a/main.zig +++ b/main.zig @@ -64,6 +64,20 @@ fn fork_pty(argv: [:0]const u8, ws: std.posix.winsize) std.Io.File { return f; } +// TIOCGWINSZ: ask the kernel what size the pty currently thinks it is. +fn pty_get_size(pty: std.Io.File) std.posix.winsize { + var ws: std.posix.winsize = .{ .row = 0, .col = 0, .xpixel = 0, .ypixel = 0 }; + _ = std.posix.system.ioctl(pty.handle, std.posix.T.IOCGWINSZ, @intFromPtr(&ws)); + return ws; +} + +// TIOCSWINSZ: set the pty size; the kernel raises SIGWINCH in the child (bash) +// so it (and anything running in it) reflows to the new grid. +fn pty_set_size(pty: std.Io.File, rows: u16, cols: u16) void { + const ws: std.posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; + _ = std.posix.system.ioctl(pty.handle, std.posix.T.IOCSWINSZ, @intFromPtr(&ws)); +} + fn readPty(io: std.Io, gpa: std.mem.Allocator, pty: std.Io.File, loop: *Loop) !void { var read_buf: [0x1000]u8 = undefined; var reader = pty.readerStreaming(io, &read_buf); @@ -79,14 +93,20 @@ fn readPty(io: std.Io, gpa: std.mem.Allocator, pty: std.Io.File, loop: *Loop) !v try loop.postEvent(.quit); } -const nrows = 64; -const ncols = 100; const term_x_padding = 3; pub fn main(init: std.process.Init) !void { const io = init.io; const gpa = init.gpa; + // live grid size of the embedded terminal; recomputed on every resize. + // seeded so the pty/ghostty have something before the first winsize event. + var nrows: u16 = 64; + var ncols: u16 = 100; + + // how many winsize events we've handled; shown on the debug panel. + var resize_count: usize = 0; + var term: ghostty_vt.Terminal = try .init(gpa, .{ .cols = ncols, .rows = nrows, @@ -123,6 +143,14 @@ pub fn main(init: std.process.Init) !void { try loop.start(); defer loop.stop(); + // start() only spawns the tty-read thread; it does NOT wire up resize + // delivery. Without this, the only winsize event we ever see is the initial + // one ttyRun posts, so the terminal never tracks the window after startup. + // installResizeHandler registers the SIGWINCH handler that posts a winsize + // event on every resize. (vaxis resets it automatically if the terminal + // later negotiates in-band resize via queryTerminal.) + try loop.installResizeHandler(); + var pty_reader = try io.concurrent(readPty, .{ io, gpa, pty_master, &loop }); defer _ = pty_reader.cancel(io) catch {}; defer pty_master.close(io); @@ -145,6 +173,9 @@ pub fn main(init: std.process.Init) !void { if (mouse.button == .left and mouse.col <= term_x_padding) { term.screens.active.scroll(.{ .delta_row = -(mouse.row) }); } + + if (mouse.button == .left and mouse.col > term_x_padding) {} + if (mouse.button == .right and mouse.col <= term_x_padding) { term.screens.active.scroll(.{ .delta_row = mouse.row }); } @@ -190,7 +221,27 @@ pub fn main(init: std.process.Init) !void { defer gpa.free(bytes); term_stream.nextSlice(bytes); }, - .winsize => |ws| try vx.resize(gpa, tty.writer(), ws), + .winsize => |ws| { + resize_count += 1; + + // 1. let vaxis resize its own screen buffers + try vx.resize(gpa, tty.writer(), ws); + + // 2. carve a new grid out of the window. layout is: left + // scrollbar gutter, then the terminal pane and the debug pane + // side by side, each wrapped in a 1-cell border. so the two + // panes split the leftover width, minus 4 cols of borders. + const usable: u16 = if (ws.cols > term_x_padding + 4) ws.cols - term_x_padding - 4 else 2; + // ncols = @max(1, usable / 2); + ncols = usable; + nrows = @max(1, if (ws.rows > 2) ws.rows - 2 else 1); + + // 3. push it to ghostty's emulator grid + try term.resize(gpa, ncols, nrows); + + // 4. push it to the pty so bash (and its children) reflow + pty_set_size(pty_master, nrows, ncols); + }, .quit => break, else => {}, } @@ -231,22 +282,28 @@ pub fn main(init: std.process.Init) !void { const win_dbg = win.child(.{ .border = .{ .where = .all }, - .x_off = ncols + 2 + term_x_padding, + .x_off = (ncols / 2) + 2 + term_x_padding, .height = nrows + 2, - .width = ncols + 2, + .width = (ncols / 2) + 2, }); win_child.clear(); _ = win_child.printSegment(.{ .text = tty_str }, .{}); win_child.showCursor(term.screens.active.cursor.x, term.screens.active.cursor.y); + const pty_ws = pty_get_size(pty_master); const dbg_str = try std.fmt.bufPrintSentinel( &dbg_print_buf, - "c {} {}\npty pid {}\n wow it's working! debugging things will show here\n scroll {}/{} vis: {}\n", + "c {} {}\npty pid {}\n grid {}x{} pty {}x{}\n resizes={}\n scroll {}/{} vis: {}\n", .{ pty_master.handle, term.screens.active.cursor.x, term.screens.active.cursor.y, + ncols, + nrows, + pty_ws.col, + pty_ws.row, + resize_count, term.screens.active.pages.scrollbar().offset, term.screens.active.pages.scrollbar().total, term.screens.active.pages.scrollbar().len, diff --git a/tests.zig b/tests.zig index 4cae1c26..51f9cefc 100644 --- a/tests.zig +++ b/tests.zig @@ -26,10 +26,45 @@ fn writeAll(fd: c_int, bytes: []const u8) !void { } } -fn waitNoHang(pid: c_int) bool { - var status: u32 = 0; - const rc = linux.waitpid(pid, &status, linux.W.NOHANG); - return posix.errno(rc) == .SUCCESS and rc == @as(usize, @intCast(pid)); +// TIOCSWINSZ on the pty master: changes the size and makes the kernel raise +// SIGWINCH in the slave's foreground process group (i.e. the app under test). +fn setWinsize(fd: c_int, rows: u16, cols: u16) !void { + const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; + const rc = linux.ioctl(fd, linux.T.IOCSWINSZ, @intFromPtr(&ws)); + if (posix.errno(rc) != .SUCCESS) return error.IoctlFailed; +} + +// Poll the pty for up to `iters` * 100ms, appending everything read into +// `output`. Returns true as soon as `needle` is present in the accumulated +// output (the app renders to the alt screen, so we just scan the byte stream). +fn pollFor( + gpa: std.mem.Allocator, + master: c_int, + output: *std.ArrayList(u8), + needle: []const u8, + iters: usize, +) !bool { + if (std.mem.indexOf(u8, output.items, needle) != null) return true; + var buf: [4096]u8 = undefined; + for (0..iters) |_| { + var fds = [_]posix.pollfd{.{ .fd = master, .events = posix.POLL.IN, .revents = 0 }}; + _ = posix.poll(&fds, 100) catch {}; + if ((fds[0].revents & (posix.POLL.IN | posix.POLL.HUP | posix.POLL.ERR)) != 0) { + const n = posix.read(master, &buf) catch break; + if (n == 0) break; + try output.appendSlice(gpa, buf[0..n]); + if (std.mem.indexOf(u8, output.items, needle) != null) return true; + } + } + return std.mem.indexOf(u8, output.items, needle) != null; +} + +fn dump(items: []const u8, msg: []const u8) void { + std.debug.print("E2E FAIL: {s}\ncaptured {d} bytes:\n{s}\n", .{ + msg, + items.len, + items[0..@min(items.len, 8192)], + }); } pub fn main(init: std.process.Init) !void { @@ -41,9 +76,8 @@ pub fn main(init: std.process.Init) !void { } const exe = args[1]; - const marker = "PARDES_E2E"; - const command = "echo " ++ marker ++ "\r"; + // Boot the app in a small pty. var master: c_int = undefined; const ws = posix.winsize{ .row = 24, .col = 80, .xpixel = 0, .ypixel = 0 }; const pid = forkpty(&master, null, null, &ws); @@ -58,63 +92,34 @@ pub fn main(init: std.process.Init) !void { var output: std.ArrayList(u8) = .empty; defer output.deinit(gpa); - var buf: [4096]u8 = undefined; - for (0..20) |_| { - var fds = [_]posix.pollfd{.{ - .fd = master, - .events = posix.POLL.IN, - .revents = 0, - }}; - _ = try posix.poll(&fds, 100); - if ((fds[0].revents & posix.POLL.IN) != 0) { - const n = posix.read(master, &buf) catch break; - if (n == 0) break; - try output.appendSlice(gpa, buf[0..n]); - break; - } - } - - try writeAll(master, command); - - var saw_marker = false; - for (0..80) |_| { - var fds = [_]posix.pollfd{.{ - .fd = master, - .events = posix.POLL.IN, - .revents = 0, - }}; - _ = try posix.poll(&fds, 100); - if ((fds[0].revents & (posix.POLL.IN | posix.POLL.HUP | posix.POLL.ERR)) != 0) { - const n = posix.read(master, &buf) catch break; - if (n == 0) break; - try output.appendSlice(gpa, buf[0..n]); - if (std.mem.containsAtLeast(u8, output.items, 1, marker)) { - saw_marker = true; - break; - } - } - if (waitNoHang(pid)) break; + // 1. The app posts an initial winsize on startup. Its winsize handler bumps + // the resize counter to 1 and calls vx.resize, which forces a full + // repaint, so "resizes 1" shows up in the rendered stream. This just + // confirms the app is up and handled its first size. + if (!try pollFor(gpa, master, &output, "resizes=1", 60)) { + dump(output.items, "app never handled its initial winsize (no 'resizes=1')"); + return error.InitialResizeMissing; } - try writeAll(master, "\x03"); - for (0..20) |_| { - if (waitNoHang(pid)) break; - var fds = [_]posix.pollfd{.{ - .fd = master, - .events = posix.POLL.IN, - .revents = 0, - }}; - _ = posix.poll(&fds, 50) catch {}; - if ((fds[0].revents & posix.POLL.IN) != 0) { - _ = posix.read(master, &buf) catch 0; - } + // 2. Regression check for "resizes aren't happening": grow the outer pty. + // This delivers SIGWINCH to the app. Only if the app installed its + // resize handler will it post a new winsize event, bump the counter to + // 2, and repaint -> "resizes 2". If the handler is missing the counter + // stays at 1 forever and this times out. + try setWinsize(master, 50, 200); + if (!try pollFor(gpa, master, &output, "resizes=2", 80)) { + dump(output.items, "SIGWINCH was not turned into a resize (counter stuck at 1)"); + return error.ResizeNotDelivered; } - if (!saw_marker) { - std.debug.print("app did not render marker; captured {d} bytes:\n{s}\n", .{ - output.items.len, - output.items[0..@min(output.items.len, 4096)], - }); + // 3. Sanity: the app still forwards keystrokes to its embedded shell. + const marker = "PARDES_E2E"; + try writeAll(master, "echo " ++ marker ++ "\r"); + if (!try pollFor(gpa, master, &output, marker, 80)) { + dump(output.items, "app did not render echoed marker"); return error.MarkerNotRendered; } + + // Tell the app to quit (ctrl-c is the app's own exit key). + try writeAll(master, "\x03"); } -- cgit v1.3