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.