From 8865210426bd2f1d5d1cb2f00ec8c5e186a763e1 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 10 Jun 2026 23:47:48 +0000 Subject: [PATCH] feat: give sessions the full height and overlay status content The last screen row is no longer reserved for a status bar. The keybind hint lives in the sidebar's bottom row, the separator runs through the last row, and the viewport (and the session inside it) spans the full terminal height. Status content (rename prompt, kill confirmation, the armed-prefix keybind list, transient messages) overlays the last row full-width while present; when it clears, the row repaints from cached sidebar and session state. A session cursor on the last row hides while the overlay shows so it does not blink over the text. --- src/ui.zig | 70 +++++++++++++++++++++++++++++++------------- test/integration.zig | 65 +++++++++++++++++++++++++++++++++------- 2 files changed, 103 insertions(+), 32 deletions(-) diff --git a/src/ui.zig b/src/ui.zig index 6c5d3d0..fcb4896 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -43,9 +43,12 @@ const render_interval_ms: i64 = 15; // -- Layout ----------------------------------------------------------------- /// Screen geometry: a sidebar on the left, a one-column separator, -/// the session viewport filling the rest, and a full-width status -/// bar on the last row. The viewport always reaches the right edge, -/// so erase-to-end-of-line stays inside it. +/// and the session viewport filling the rest of every row, including +/// the last. The bottom sidebar row shows the keybind hint; transient +/// status content (prompts, the keybind list, messages) overlays the +/// last row full-width and the row repaints from session state when +/// it clears. The viewport always reaches the right edge, so +/// erase-to-end-of-line stays inside it. pub const Layout = struct { rows: u16, cols: u16, @@ -71,9 +74,10 @@ pub const Layout = struct { return self.cols -| (self.sidebar_w + 1); } - /// Viewport rows: everything above the status bar. + /// Viewport rows: the full terminal height. The status overlay + /// borrows the last row only while it has something to show. pub fn viewportRows(self: Layout) u16 { - return self.rows -| 1; + return self.rows; } /// First viewport column, 0-based. @@ -82,7 +86,8 @@ pub const Layout = struct { } /// Sidebar rows available for session entries between the - /// new-session button (plus its gap row) and the status bar. + /// new-session button (plus its gap row) and the keybind hint + /// on the bottom row. pub fn listRows(self: Layout) u16 { return self.rows -| (list_top + 1); } @@ -97,7 +102,6 @@ pub const Layout = struct { /// rows per session; scroll applied by the caller). session: struct { row: u16, kill: bool }, new_button, - status, viewport: struct { x: u16, y: u16 }, none, }; @@ -106,11 +110,11 @@ pub const Layout = struct { /// report whether the kill target ('x' in the last column) was hit. pub fn hit(self: Layout, x: u16, y: u16) Hit { if (y >= self.rows or x >= self.cols) return .none; - if (y == self.rows -| 1) return .status; // full-width bar if (x >= self.viewportX()) { return .{ .viewport = .{ .x = x - self.viewportX(), .y = y } }; } if (x >= self.sidebar_w) return .none; // separator column + if (y == self.rows -| 1) return .none; // keybind hint row if (y == 0) return .new_button; if (y < list_top) return .none; // gap under the button return .{ .session = .{ @@ -1696,6 +1700,12 @@ const Ui = struct { }) catch return state; state.pos_len = text.len; state.visible = v.term.modes.get(.cursor_visible); + // A session cursor on the last row would blink over the + // status overlay while it shows; keep it hidden until the + // overlay clears. + if (self.statusActive() and row == self.layout.rows -| 1) { + state.visible = false; + } return state; } @@ -1717,15 +1727,23 @@ const Ui = struct { return state; } - /// One full screen row. The last row is the full-width status - /// bar; every other row is sidebar columns, separator, then the + /// Whether the bottom-row status overlay has content to show: an + /// open prompt, the armed-prefix keybind list, or a live message. + fn statusActive(self: *Ui) bool { + return self.rename_input != null or self.confirm_kill != null or + self.parser.pending_prefix or self.message.items.len > 0; + } + + /// One full screen row: sidebar columns, separator, then the /// viewport slice. The sidebar segment is always exactly /// sidebar_w columns so the row never bleeds into the viewport. + /// While status content is active it overlays the last row full + /// width; the row repaints from cached state when it clears. fn composeRow(self: *Ui, y: u16, out: *std.ArrayList(u8)) !void { const alloc = self.alloc; try out.appendSlice(alloc, sgr_reset); - if (y == self.layout.rows -| 1) { + if (y == self.layout.rows -| 1 and self.statusActive()) { try self.composeStatusRow(out); return; } @@ -1739,9 +1757,9 @@ const Ui = struct { const keybind_bar = " c new k kill r rename n/p switch d quit C-a last a literal l redraw esc cancel"; - /// The full-width bar on the last screen row: rename prompt, kill - /// confirmation, the keybind list while the prefix is armed, a - /// transient message, or the default hint. + /// Status content overlaid full-width on the last screen row + /// while present: rename prompt, kill confirmation, the keybind + /// list while the prefix is armed, or a transient message. fn composeStatusRow(self: *Ui, out: *std.ArrayList(u8)) !void { const alloc = self.alloc; const w = self.layout.cols; @@ -1767,8 +1785,6 @@ const Ui = struct { try text.appendSlice(alloc, keybind_bar); } else if (self.message.items.len > 0) { try text.print(alloc, " {s}", .{self.message.items}); - } else { - try text.appendSlice(alloc, " Keybinds: Ctrl+A"); } try appendClipped(alloc, out, text.items, w); try out.appendSlice(alloc, sgr_reset); @@ -1779,6 +1795,14 @@ const Ui = struct { const l = self.layout; const w = l.sidebar_w; + if (y == l.rows -| 1) { + // The bottom sidebar row always shows how to reach the + // keybinds; the status overlay covers it while active. + try out.appendSlice(alloc, style_dim); + try appendClipped(alloc, out, " Keybinds: Ctrl+A", w); + try out.appendSlice(alloc, sgr_reset); + return; + } if (y == 0) { try out.appendSlice(alloc, style_dim); try appendClipped(alloc, out, " + new session", w); @@ -2152,16 +2176,20 @@ test "layout: geometry and hit testing" { try std.testing.expectEqual(@as(u16, 24), l.sidebar_w); try std.testing.expectEqual(@as(u16, 75), l.viewportCols()); try std.testing.expectEqual(@as(u16, 25), l.viewportX()); - try std.testing.expectEqual(@as(u16, 23), l.viewportRows()); + try std.testing.expectEqual(@as(u16, 24), l.viewportRows()); try std.testing.expectEqual(@as(usize, 10), l.visibleEntries()); - // The new-session button is the top row, a blank gap sits under - // it, and the status bar spans the full width of the last row. + // The new-session button is the top row and a blank gap sits + // under it. The bottom sidebar row holds the keybind hint, and + // the viewport extends through the last row. try std.testing.expectEqual(Layout.Hit.new_button, l.hit(3, 0)); try std.testing.expectEqual(Layout.Hit.none, l.hit(3, 1)); - try std.testing.expectEqual(Layout.Hit.status, l.hit(3, 23)); - try std.testing.expectEqual(Layout.Hit.status, l.hit(80, 23)); + try std.testing.expectEqual(Layout.Hit.none, l.hit(3, 23)); + const bottom = l.hit(80, 23); + try std.testing.expectEqual(@as(u16, 55), bottom.viewport.x); + try std.testing.expectEqual(@as(u16, 23), bottom.viewport.y); try std.testing.expectEqual(Layout.Hit.none, l.hit(24, 5)); // separator + try std.testing.expectEqual(Layout.Hit.none, l.hit(24, 23)); // separator, last row // Sessions take two display rows: name, then title. const s = l.hit(3, 5); diff --git a/test/integration.zig b/test/integration.zig index 77df806..93e685a 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -1171,8 +1171,9 @@ test "ui: a row touching the viewport's right edge keeps its last cell" { // from a variable so the echoed command line cannot match. try h.sendLine("edge", "T=EDGE; printf \"\\\\033[3;71H${T}Z\""); try ui.waitFor("EDGEZ"); - // The status bar repaints after arming the prefix, so once the - // keybind bar shows, the marker row's frame is fully captured. + // The bottom row repaints with the keybind bar after arming the + // prefix, so once it shows, the marker row's frame is fully + // captured. try ui.send("\x01"); try ui.waitFor("r rename"); try ui.send("\x1b"); @@ -1318,7 +1319,8 @@ test "ui: viewport size tracks the terminal minus the sidebar" { try h.startDetached("rz", &.{"/bin/sh"}); // 100 columns - 24 sidebar - 1 separator = 75 viewport columns; - // 24 rows - 1 status bar = 23 viewport rows. + // the viewport spans all 24 rows, since status content only + // overlays the bottom row while it has something to show. var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); defer ui.deinit(); try ui.waitFor("rz"); @@ -1329,7 +1331,7 @@ test "ui: viewport size tracks the terminal minus the sidebar" { defer alloc.free(cmd); try h.sendLine("rz", cmd); - try waitFileEquals(alloc, size_file, "23 75\n"); + try waitFileEquals(alloc, size_file, "24 75\n"); // Resizing the outer terminal resizes the viewport with it. try ui.setSize(30, 120); @@ -1339,7 +1341,7 @@ test "ui: viewport size tracks the terminal minus the sidebar" { std.Thread.sleep(50 * std.time.ns_per_ms); const content = std.fs.cwd().readFileAlloc(alloc, size_file, 4096) catch ""; defer if (content.len > 0) alloc.free(content); - if (std.mem.eql(u8, content, "29 95\n")) break; + if (std.mem.eql(u8, content, "30 95\n")) break; try deadline.tick("viewport resize never reached the session"); } } @@ -1417,7 +1419,42 @@ test "ui: a stolen view reclaims the session once the thief lets go" { try ui.waitFor("BACK-MARK"); } -test "ui: the status bar reveals keybinds and C-a r renames" { +/// Pump the client until the rendered screen's last row contains +/// every needle (or, for `absent`, none of them). +fn waitLastRow( + alloc: std.mem.Allocator, + ui: *PtyClient, + rows: u16, + cols: u16, + present: []const []const u8, + absent: []const []const u8, +) !void { + var deadline = Deadline.init(default_timeout_ms); + while (true) { + const screen = try renderScreen(alloc, ui.output.items, rows, cols); + defer alloc.free(screen); + var lines = std.mem.splitScalar(u8, screen, '\n'); + var last: []const u8 = ""; + while (lines.next()) |line| last = line; + + var ok = true; + for (present) |needle| { + if (std.mem.indexOf(u8, last, needle) == null) ok = false; + } + for (absent) |needle| { + if (std.mem.indexOf(u8, last, needle) != null) ok = false; + } + if (ok) return; + + _ = try ui.pump(100); + deadline.tick("waiting for the bottom row") catch |err| { + std.debug.print("--- bottom row --- {s}\n", .{last}); + return err; + }; + } +} + +test "ui: the keybind bar overlays the bottom row and C-a r renames" { const alloc = std.testing.allocator; var h = try Harness.init(alloc); defer h.deinit(); @@ -1427,16 +1464,22 @@ test "ui: the status bar reveals keybinds and C-a r renames" { var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); defer ui.deinit(); try ui.waitFor("oldname"); - try ui.waitFor("Keybinds: Ctrl+A"); - // Arming the prefix swaps the hint for the keybind list; Esc - // backs out and the hint returns. + // The keybind hint sits in the sidebar's bottom row and the + // separator runs through the last row: no reserved status bar. + try waitLastRow(alloc, &ui, 24, 100, &.{ "Keybinds: Ctrl+A", "\u{2502}" }, &.{}); + + // Arming the prefix overlays the keybind list across the whole + // bottom row, covering the sidebar hint and the separator. try ui.send("\x01"); try ui.waitFor("r rename"); try ui.waitFor("esc cancel"); - ui.clearOutput(); + try waitLastRow(alloc, &ui, 24, 100, &.{"r rename"}, &.{"\u{2502}"}); + + // Esc backs out: the overlay reverts to the hint, the separator, + // and whatever the viewport had underneath. try ui.send("\x1b"); - try ui.waitFor("Keybinds: Ctrl+A"); + try waitLastRow(alloc, &ui, 24, 100, &.{ "Keybinds: Ctrl+A", "\u{2502}" }, &.{"r rename"}); // C-a r opens the prompt pre-filled with the old name; erase it // and type a new one.