diff --git a/README.md b/README.md index c8766ba..8873ba2 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Bindings follow GNU screen's defaults, including the `C-x` variants | `C-a l`, `C-a C-l` | redraw | | `C-a a` | send a literal `C-a` | -`boo ui` adds additional keybinds for switching, resizing, creating sessions, and killing them. +`boo ui` adds additional keybinds for switching, resizing and hiding the sidebar, creating sessions, and killing them. ### Automation diff --git a/src/help.zig b/src/help.zig index c095407..dced491 100644 --- a/src/help.zig +++ b/src/help.zig @@ -139,6 +139,8 @@ pub const commands = [_]Entry{ \\ C-a Left, C-a Right \\ resize the sidebar: Left/Right adjust the width, \\ Enter keeps it, Esc restores the previous width + \\ C-a s show or hide the sidebar; the viewport takes the + \\ full width while it is hidden \\ C-a C-a focus the previously focused session \\ C-a d quit the UI (sessions keep running) \\ C-a l redraw diff --git a/src/ui.zig b/src/ui.zig index 8d42c80..f7f07d4 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -55,12 +55,16 @@ const wheel_lines = 3; /// 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. +/// erase-to-end-of-line stays inside it. While the sidebar is hidden +/// the viewport takes every column; sidebar_w is kept so the sidebar +/// returns at its old width. pub const Layout = struct { rows: u16, cols: u16, /// Sidebar text columns, excluding the separator column. sidebar_w: u16, + /// The sidebar (and its separator) is not shown: C-a s. + hidden: bool = false, /// Each session occupies two sidebar rows: name and title. pub const entry_rows: u16 = 2; @@ -74,6 +78,7 @@ pub const Layout = struct { } pub fn viewportCols(self: Layout) u16 { + if (self.hidden) return self.cols; return self.cols -| (self.sidebar_w + 1); } @@ -85,6 +90,7 @@ pub const Layout = struct { /// First viewport column, 0-based. pub fn viewportX(self: Layout) u16 { + if (self.hidden) return 0; return self.sidebar_w + 1; } @@ -112,6 +118,7 @@ pub const Layout = struct { pub fn hit(self: Layout, x: u16, y: u16) Hit { if (y >= self.rows or x >= self.cols) return .none; if (x >= self.viewportX()) { + // A hidden sidebar puts every cell here: viewportX is 0. return .{ .viewport = .{ .x = x - self.viewportX(), .y = y } }; } if (x >= self.sidebar_w) return .none; // separator column @@ -974,20 +981,13 @@ const Ui = struct { fn relayout(self: *Ui) void { const ws = ptypkg.getSize(self.tty) catch return; + const hidden = self.layout.hidden; self.layout = .init(ws.row, ws.col); + self.layout.hidden = hidden; if (self.sidebar_pref) |w| { self.layout.sidebar_w = self.clampSidebarWidth(w); } - if (self.view) |v| { - v.resize(self.layout.viewportRows(), self.layout.viewportCols()) catch |err| { - log.warn("viewport resize failed: {}", .{err}); - }; - } - // Cell coordinates shift with the layout, so any in-progress - // selection no longer points at the text the user dragged over. - self.select_anchor = null; - self.full_render = true; - self.need_render = true; + self.viewportChanged(); } // -- Terminal input ------------------------------------------------------ @@ -1248,6 +1248,7 @@ const Ui = struct { 'd', 0x04, 'q' => self.quitting = true, 'n', 0x0e => self.focusOffset(1), 'p', 0x10 => self.focusOffset(-1), + 's', 0x13 => self.toggleSidebar(), keys.escape_byte => self.focusLast(), 'l', 0x0c => { // Re-seed the local terminal from daemon state and @@ -1844,6 +1845,9 @@ const Ui = struct { fn browseMove(self: *Ui, dir: i2) void { const len = self.sessions.items.len; if (len == 0) return; + // A hidden sidebar would make the selection invisible: bring + // it back so the browse can be seen. + if (self.layout.hidden) self.toggleSidebar(); self.browsing = true; // The browse hint renders on the bottom row; a stale // transient message would cover it up. @@ -1927,6 +1931,9 @@ const Ui = struct { /// resize instead of selecting. fn resizeMove(self: *Ui, dir: i2) void { if (self.browsing) self.cancelBrowse(); + // Resizing a hidden sidebar would be invisible: bring it + // back and let the arrows adjust it from there. + if (self.layout.hidden) self.toggleSidebar(); if (!self.resizing) { self.resizing = true; self.resize_origin = self.layout.sidebar_w; @@ -1988,13 +1995,30 @@ const Ui = struct { self.need_render = true; if (w == self.layout.sidebar_w) return; self.layout.sidebar_w = w; + self.viewportChanged(); + } + + /// Show or hide the sidebar: C-a s. The viewport takes the full + /// width while hidden; the width and selection are kept for when + /// it returns. Hiding cancels an active browse, whose selection + /// would be invisible. + fn toggleSidebar(self: *Ui) void { + if (!self.layout.hidden and self.browsing) self.cancelBrowse(); + self.layout.hidden = !self.layout.hidden; + self.viewportChanged(); + } + + /// The viewport geometry changed: resize the live view (and the + /// session pty behind it) and repaint every row. Cell coordinates + /// shift with the layout, so any in-progress selection no longer + /// points at the text the user dragged over. + fn viewportChanged(self: *Ui) void { + self.need_render = true; if (self.view) |v| { v.resize(self.layout.viewportRows(), self.layout.viewportCols()) catch |err| { log.warn("viewport resize failed: {}", .{err}); }; } - // Cell coordinates shift with the layout, so any in-progress - // selection no longer points at the text the user dragged over. self.select_anchor = null; self.full_render = true; } @@ -2387,6 +2411,7 @@ const Ui = struct { /// 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. + /// A hidden sidebar leaves the whole row to the viewport. fn composeRow(self: *Ui, y: u16, out: *std.ArrayList(u8)) !void { const alloc = self.alloc; @@ -2395,15 +2420,17 @@ const Ui = struct { try self.composeStatusRow(out); return; } - try self.composeSidebarCell(y, out); - try out.appendSlice(alloc, style_dim); - try out.appendSlice(alloc, "\u{2502}"); - try out.appendSlice(alloc, sgr_reset); + if (!self.layout.hidden) { + try self.composeSidebarCell(y, out); + try out.appendSlice(alloc, style_dim); + try out.appendSlice(alloc, "\u{2502}"); + try out.appendSlice(alloc, sgr_reset); + } try self.composeViewportCell(y, out); } const keybind_bar = - " c new k kill r rename g goto n/p switch up/dn browse lt/rt resize d quit C-a last a literal l redraw esc cancel"; + " c new k kill r rename g goto n/p switch up/dn browse lt/rt resize s sidebar d quit C-a last a literal l redraw esc cancel"; /// Status content overlaid full-width on the last screen row /// while present: rename prompt, kill confirmation, the keybind @@ -3003,6 +3030,81 @@ test "ui: sidebar resize clamps to the layout bounds" { try std.testing.expectEqual(@as(u16, 8), ui.clampSidebarWidth(999)); } +test "layout: hidden sidebar gives the viewport every column" { + var l = Layout.init(24, 100); + l.hidden = true; + try std.testing.expectEqual(@as(u16, 100), l.viewportCols()); + try std.testing.expectEqual(@as(u16, 0), l.viewportX()); + try std.testing.expectEqual(@as(u16, 24), l.viewportRows()); + + // Every cell is the viewport: the old sidebar area, the + // separator column, and the keybind hint row included. + const top = l.hit(0, 0); + try std.testing.expectEqual(@as(u16, 0), top.viewport.x); + try std.testing.expectEqual(@as(u16, 0), top.viewport.y); + const sep = l.hit(24, 5); + try std.testing.expectEqual(@as(u16, 24), sep.viewport.x); + const hint = l.hit(3, 23); + try std.testing.expectEqual(@as(u16, 3), hint.viewport.x); + try std.testing.expectEqual(@as(u16, 23), hint.viewport.y); + try std.testing.expectEqual(Layout.Hit.none, l.hit(100, 5)); + + // The width survives the hide for when the sidebar returns. + l.hidden = false; + try std.testing.expectEqual(@as(u16, 75), l.viewportCols()); + try std.testing.expectEqual(@as(u16, 25), l.viewportX()); +} + +test "ui: sidebar toggle hides and restores, cancelling a browse" { + var ui: Ui = .{ + .alloc = std.testing.allocator, + .dir = "", + .tty = -1, + }; + ui.layout = .{ .rows = 24, .cols = 100, .sidebar_w = 30 }; + + // Hiding cancels an active browse and keeps the width. + ui.browsing = true; + ui.toggleSidebar(); + try std.testing.expect(ui.layout.hidden); + try std.testing.expect(!ui.browsing); + try std.testing.expectEqual(@as(u16, 100), ui.layout.viewportCols()); + + // Showing restores the old width. + ui.toggleSidebar(); + try std.testing.expect(!ui.layout.hidden); + try std.testing.expectEqual(@as(u16, 30), ui.layout.sidebar_w); + try std.testing.expectEqual(@as(u16, 69), ui.layout.viewportCols()); +} + +test "ui: sidebar arrows reveal a hidden sidebar" { + const alloc = std.testing.allocator; + var ui: Ui = .{ .alloc = alloc, .dir = "", .tty = -1 }; + defer ui.sessions.deinit(alloc); + ui.layout = .{ .rows = 24, .cols = 100, .sidebar_w = 24, .hidden = true }; + + // An arrow resize un-hides the sidebar before adjusting it. + ui.resizeMove(1); + try std.testing.expect(!ui.layout.hidden); + try std.testing.expect(ui.resizing); + try std.testing.expectEqual(@as(u16, 25), ui.layout.sidebar_w); + + // An arrow browse un-hides it so the selection is visible. + ui.resizing = false; + ui.layout.hidden = true; + var name = "work".*; + var no_title = "".*; + try ui.sessions.append(alloc, .{ + .name = &name, + .attached = false, + .idle_ms = 0, + .title = &no_title, + }); + ui.browseMove(1); + try std.testing.expect(!ui.layout.hidden); + try std.testing.expect(ui.browsing); +} + test "layout: narrow terminals shrink the sidebar" { const l = Layout.init(24, 48); try std.testing.expectEqual(@as(u16, 16), l.sidebar_w); diff --git a/test/integration.zig b/test/integration.zig index c7336ef..99a0a77 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -1746,6 +1746,38 @@ fn waitLastRow( } } +/// Pump output until the rendered screen contains every `present` +/// needle and none of the `absent` ones. +fn waitScreen( + 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 ok = true; + for (present) |needle| { + if (std.mem.indexOf(u8, screen, needle) == null) ok = false; + } + for (absent) |needle| { + if (std.mem.indexOf(u8, screen, needle) != null) ok = false; + } + if (ok) return; + + _ = try ui.pump(100); + deadline.tick("waiting for the screen") catch |err| { + std.debug.print("--- screen ---\n{s}\n", .{screen}); + return err; + }; + } +} + test "ui: the focused session exiting hands focus to the next one" { const alloc = std.testing.allocator; var h = try Harness.init(alloc); @@ -1788,13 +1820,13 @@ test "ui: the keybind bar overlays the bottom row and C-a r renames" { try h.startDetached("oldname", &.{"cat"}); - var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 132); + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 140); defer ui.deinit(); try ui.waitFor("oldname"); // 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, 132, &.{ "Keybinds: Ctrl+A", "\u{2502}" }, &.{}); + try waitLastRow(alloc, &ui, 24, 140, &.{ "Keybinds: Ctrl+A", "\u{2502}" }, &.{}); // Arming the prefix overlays the keybind list across the whole // bottom row, covering the sidebar hint and the separator. @@ -1803,12 +1835,12 @@ test "ui: the keybind bar overlays the bottom row and C-a r renames" { try ui.waitFor("up/dn browse"); try ui.waitFor("lt/rt resize"); try ui.waitFor("esc cancel"); - try waitLastRow(alloc, &ui, 24, 132, &.{"r rename"}, &.{"\u{2502}"}); + try waitLastRow(alloc, &ui, 24, 140, &.{"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 waitLastRow(alloc, &ui, 24, 132, &.{ "Keybinds: Ctrl+A", "\u{2502}" }, &.{"r rename"}); + try waitLastRow(alloc, &ui, 24, 140, &.{ "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. @@ -2044,6 +2076,40 @@ test "ui: C-a side arrows resize the sidebar" { try waitPeekSize(&h, "resized", 24, 76); } +test "ui: C-a s hides the sidebar and brings it back" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("tucked", &.{"cat"}); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("tucked"); + try ui.waitFor("Keybinds: Ctrl+A"); + try waitPeekSize(&h, "tucked", 24, 75); + + // C-a s hides the sidebar: the session list, the keybind hint, + // and the separator all leave the screen, and the viewport (with + // the session pty behind it) takes the full terminal width. + try ui.send("\x01s"); + try waitPeekSize(&h, "tucked", 24, 100); + try waitScreen(alloc, &ui, 24, 100, &.{}, &.{ + "tucked", "Keybinds: Ctrl+A", "\u{2502}", + }); + + // Typed input still reaches the focused session while hidden. + try ui.send("HIDDEN-MARK\r"); + try ui.waitFor("HIDDEN-MARK"); + + // C-a s again brings the sidebar back at its old width. + try ui.send("\x01s"); + try waitPeekSize(&h, "tucked", 24, 75); + try waitScreen(alloc, &ui, 24, 100, &.{ + "tucked", "Keybinds: Ctrl+A", "\u{2502}", + }, &.{}); +} + /// Pump `peek --json` until the session reports the given pty size. fn waitPeekSize(h: *Harness, name: []const u8, rows: u16, cols: u16) !void { var buf: [64]u8 = undefined;