diff --git a/README.md b/README.md index f1f0006..13f51f9 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ exactly as a human would see it. - A full-screen session manager: `boo ui` lists sessions in a sidebar with their titles, and renders the focused one next to it. Click to switch, create, kill, or rename sessions; drag to select and copy - text (OSC 52); everything also works from the keyboard. + text (OSC 52); scroll the wheel to page through a session's history; + everything also works from the keyboard. - One command per session, named after your current directory by default. Sessions are cheap; run one per task. - Faithful redraws from libghostty terminal state, including SGR styles, diff --git a/src/daemon.zig b/src/daemon.zig index 3a0ba87..540f32c 100644 --- a/src/daemon.zig +++ b/src/daemon.zig @@ -571,6 +571,10 @@ pub const Daemon = struct { // from a clean slate. win.alt_filter.reset(); conn.send(.output, bytes); + // Repaints accompany every screen identity change (attach, + // redraw, alt-screen switches), so this keeps the client's + // picture of the application's screen current. + conn.send(.screen, if (win.onAltScreen()) "alt" else "primary"); } fn detachConn(self: *Daemon, conn: *Conn, reason: []const u8) void { diff --git a/src/help.zig b/src/help.zig index fca4ae0..6c2ba1f 100644 --- a/src/help.zig +++ b/src/help.zig @@ -117,6 +117,10 @@ pub const commands = [_]Entry{ \\ click its 'x' kill it (asks for confirmation) \\ click + new session start a session running $SHELL \\ scroll the sidebar scroll the session list + \\ wheel in viewport scroll the session's history; wheel + \\ back down or press esc to return to + \\ live output (full-screen applications + \\ receive arrow keys instead) \\ in the viewport forwarded to the application when it \\ asked for mouse reporting; otherwise \\ dragging selects text and copies it on diff --git a/src/protocol.zig b/src/protocol.zig index 8d3d0bd..acf6280 100644 --- a/src/protocol.zig +++ b/src/protocol.zig @@ -29,6 +29,11 @@ pub const MsgType = enum(u8) { exit = 66, ok = 67, err = 68, + /// Which screen the application is on ("alt" or "primary"), + /// sent alongside every repaint. The passthrough strips screen + /// toggles, so clients cannot tell on their own; the ui uses it + /// to decide what a wheel over the viewport should do. + screen = 69, _, }; diff --git a/src/ui.zig b/src/ui.zig index 1e5e33b..15dcf12 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -43,6 +43,9 @@ const render_interval_ms: i64 = 15; /// after this long without a follow-up byte. Escape sequences arrive /// as one chunk, so only a human pressing the ESC key waits this long. const esc_flush_ms: i64 = 50; +/// Rows per mouse wheel tick, both for paging local scrollback and +/// for the arrow keys sent to alternate-screen applications. +const wheel_lines = 3; // -- Layout ----------------------------------------------------------------- @@ -387,6 +390,10 @@ pub const View = struct { title_changed: bool = false, /// The application rang the bell; the UI forwards it. bell: bool = false, + /// The application is on the alternate screen, per the daemon's + /// `.screen` messages. Decides whether a wheel over the viewport + /// pages local scrollback or sends arrow keys. + app_alt: bool = false, pub const State = enum { live, ended, stolen, lost }; pub const Stream = vt.TerminalStream; @@ -415,7 +422,10 @@ pub const View = struct { self.term = try vt.Terminal.init(alloc, .{ .cols = @max(cols, 1), .rows = @max(rows, 1), - .max_scrollback = 0, + // Output that scrolls off while attached accumulates + // here; the wheel pages through it for primary-screen + // applications. + .max_scrollback = 512 * 1024, }); errdefer self.term.deinit(alloc); @@ -973,7 +983,9 @@ const Ui = struct { .forward => |bytes| { if (self.resizeConsumes(bytes)) return; if (self.browseConsumes(bytes)) return; + if (self.scrollConsumes(bytes)) return; const v = self.liveView() orelse return; + self.snapViewBottom(); v.sendInput(bytes) catch self.markViewLost(); }, .prefix => |byte| { @@ -999,6 +1011,7 @@ const Ui = struct { self.need_render = true; } const v = self.liveView() orelse return; + self.snapViewBottom(); v.sendInput(if (a.dir == .left) "\x1b[D" else "\x1b[C") catch self.markViewLost(); }, @@ -1014,6 +1027,7 @@ const Ui = struct { return; } const v = self.liveView() orelse return; + self.snapViewBottom(); v.sendInput(if (a.dir == .up) "\x1b[A" else "\x1b[B") catch self.markViewLost(); }, @@ -1027,6 +1041,7 @@ const Ui = struct { .paste => |begin| { const v = self.liveView() orelse return; if (!v.term.modes.get(.bracketed_paste)) return; + self.snapViewBottom(); const marker: []const u8 = if (begin) "\x1b[200~" else "\x1b[201~"; v.sendInput(marker) catch self.markViewLost(); }, @@ -1194,7 +1209,7 @@ const Ui = struct { if (m.isWheel() and !m.release) { switch (self.layout.hit(x, y)) { - .viewport => return self.forwardMouse(m), + .viewport => return self.wheelViewport(m), else => { // Wheel over the sidebar scrolls the session list. const down = m.code & 1 != 0; @@ -1242,6 +1257,72 @@ const Ui = struct { } } + /// Wheel over the viewport. Applications that asked for mouse + /// reporting get the event. Alternate-screen applications get + /// arrow keys per tick, like terminals' alternate-scroll mode, + /// so pagers scroll without mouse support. Otherwise the wheel + /// pages the view's local scrollback. + fn wheelViewport(self: *Ui, m: Mouse) !void { + const v = self.liveView() orelse return; + if (v.term.flags.mouse_event != .none) return self.forwardMouse(m); + const down = m.code & 1 != 0; + if (v.app_alt) { + const seq: []const u8 = if (v.term.modes.get(.cursor_keys)) + (if (down) "\x1bOB" else "\x1bOA") + else + (if (down) "\x1b[B" else "\x1b[A"); + for (0..wheel_lines) |_| { + v.sendInput(seq) catch return self.markViewLost(); + } + return; + } + self.scrollView(if (down) wheel_lines else -@as(isize, wheel_lines)); + } + + /// Page the focused view's scrollback by delta rows (up is + /// negative). A scrolled viewport pins to its content, so + /// streaming output does not move it; the bottom row hints how + /// to get back. + fn scrollView(self: *Ui, delta: isize) void { + const v = self.liveView() orelse return; + if (!self.viewScrolled()) { + // The scrollback hint renders on the bottom row; a stale + // transient message would cover it up. + self.message.clearRetainingCapacity(); + self.message_deadline = 0; + } + v.term.scrollViewport(.{ .delta = delta }); + self.full_render = true; + self.need_render = true; + } + + /// Whether the focused view's viewport is scrolled into history. + fn viewScrolled(self: *Ui) bool { + const v = self.view orelse return false; + if (v.state != .live) return false; + return !v.term.screens.active.viewportIsBottom(); + } + + /// Return the viewport to the live bottom, so input lands where + /// the user can see it. + fn snapViewBottom(self: *Ui) void { + if (!self.viewScrolled()) return; + if (self.view) |v| v.term.scrollViewport(.{ .bottom = {} }); + self.full_render = true; + self.need_render = true; + } + + /// A lone Esc while the view is scrolled returns it to the + /// bottom instead of reaching the application. + fn scrollConsumes(self: *Ui, bytes: []const u8) bool { + if (!self.viewScrolled()) return false; + if (bytes.len == 1 and bytes[0] == 0x1b) { + self.snapViewBottom(); + return true; + } + return false; + } + /// Track press state and forward the event to the application /// when it asked for mouse reporting, with coordinates translated /// into viewport space. @@ -1350,8 +1431,8 @@ const Ui = struct { if (e.y < s.y or (e.y == s.y and e.x < s.x)) std.mem.swap(CellPos, &s, &e); const screen = v.term.screens.active; - const start = screen.pages.pin(.{ .active = .{ .x = s.x, .y = s.y } }) orelse return; - const end = screen.pages.pin(.{ .active = .{ .x = e.x, .y = e.y } }) orelse return; + const start = screen.pages.pin(.{ .viewport = .{ .x = s.x, .y = s.y } }) orelse return; + const end = screen.pages.pin(.{ .viewport = .{ .x = e.x, .y = e.y } }) orelse return; var formatter: vt.formatter.ScreenFormatter = .init(screen, .plain); formatter.content = .{ .selection = vt.Selection.init(start, end, false) }; @@ -1420,6 +1501,9 @@ const Ui = struct { self.setMessage("session attached elsewhere", .{}); self.need_render = true; }, + .screen => { + v.app_alt = std.mem.eql(u8, msg.payload, "alt"); + }, .exit => { v.state = .ended; ended = true; @@ -2137,6 +2221,10 @@ const Ui = struct { if (self.renameCursor()) |s| return s; if (self.searchCursor()) |s| return s; const v = self.liveView() orelse return state; + // While scrolled back the cursor coordinates belong to the + // bottom of the screen, not the history rows on display, so + // keep the cursor hidden until the viewport snaps back. + if (self.viewScrolled()) return state; const cursor = &v.term.screens.active.cursor; const row: usize = @min(cursor.y, self.layout.viewportRows() -| 1); const col: usize = @min( @@ -2193,12 +2281,13 @@ const Ui = struct { } /// Whether the bottom-row status overlay has content to show: an - /// open prompt, the armed-prefix keybind list, an active browse - /// or resize, or a live message. + /// open prompt, the armed-prefix keybind list, an active browse, + /// resize, or scrollback, or a live message. fn statusActive(self: *Ui) bool { return self.rename_input != null or self.search_input != null or self.confirm_kill != null or self.parser.pending_prefix or - self.browsing or self.resizing or self.message.items.len > 0; + self.browsing or self.resizing or self.viewScrolled() or + self.message.items.len > 0; } /// One full screen row: sidebar columns, separator, then the @@ -2258,6 +2347,8 @@ const Ui = struct { try text.appendSlice(alloc, " left/right resize enter done esc cancel"); } else if (self.browsing) { try text.appendSlice(alloc, " up/down select enter attach esc cancel"); + } else if (self.viewScrolled()) { + try text.appendSlice(alloc, " scrollback wheel down or esc to return"); } try appendClipped(alloc, out, text.items, w); try out.appendSlice(alloc, sgr_reset); @@ -2436,8 +2527,10 @@ pub fn appendTermRow( ) !void { const screen = term.screens.active; if (term.cols == 0) return; - const start = screen.pages.pin(.{ .active = .{ .x = 0, .y = y } }) orelse return; - const end = screen.pages.pin(.{ .active = .{ .x = term.cols - 1, .y = y } }) orelse return; + // Viewport pins follow scrollback paging; at the bottom the + // viewport and the active screen are the same rows. + const start = screen.pages.pin(.{ .viewport = .{ .x = 0, .y = y } }) orelse return; + const end = screen.pages.pin(.{ .viewport = .{ .x = term.cols - 1, .y = y } }) orelse return; var formatter: vt.formatter.ScreenFormatter = .init(screen, .vt); formatter.content = .{ .selection = vt.Selection.init(start, end, true) }; @@ -2467,8 +2560,8 @@ fn appendPlainSpan( out: *std.ArrayList(u8), ) !void { const screen = term.screens.active; - const start = screen.pages.pin(.{ .active = .{ .x = x0, .y = y } }) orelse return; - const end = screen.pages.pin(.{ .active = .{ .x = x1, .y = y } }) orelse return; + const start = screen.pages.pin(.{ .viewport = .{ .x = x0, .y = y } }) orelse return; + const end = screen.pages.pin(.{ .viewport = .{ .x = x1, .y = y } }) orelse return; var formatter: vt.formatter.ScreenFormatter = .init(screen, .plain); formatter.content = .{ .selection = vt.Selection.init(start, end, false) }; diff --git a/src/window.zig b/src/window.zig index 16a0999..cfdd2da 100644 --- a/src/window.zig +++ b/src/window.zig @@ -205,6 +205,13 @@ pub const Window = struct { return self.term.screens.active.kitty_keyboard.current().int() != 0; } + /// Whether the application is on the alternate screen. The + /// passthrough strips screen toggles, so clients cannot tell + /// from the byte stream. + pub fn onAltScreen(self: *Window) bool { + return self.term.screens.active_key == .alternate; + } + /// Plain-text dump of the screen, for peek. pub fn plainScreen(self: *Window, alloc: std.mem.Allocator) ![]const u8 { return self.term.plainString(alloc); diff --git a/test/integration.zig b/test/integration.zig index 10b6545..9c24a97 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -1844,6 +1844,75 @@ fn waitPeekSize(h: *Harness, name: []const u8, rows: u16, cols: u16) !void { } } +test "ui: wheel scrolls primary-screen scrollback" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + try h.startDetached("scrolly", &.{"cat"}); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("+ new session"); + + // Stream enough lines through cat that the earliest ones scroll + // off the 24-row screen into the view's scrollback. + var n: usize = 1; + while (n <= 40) : (n += 1) { + var buf: [16]u8 = undefined; + const line = try std.fmt.bufPrint(&buf, "SCROLL-{d:0>3}", .{n}); + try h.sendLine("scrolly", line); + } + try ui.waitFor("SCROLL-040"); + + // cat never asked for mouse reporting and stays on the primary + // screen, so wheel-up over the viewport pages local scrollback. + // Over-scrolling clamps at the top, which puts the first line on + // screen regardless of exact row math. + ui.clearOutput(); + for (0..35) |_| try ui.send("\x1b[<64;50;10M"); + try ui.waitFor(" scrollback"); + try ui.waitFor("SCROLL-001"); + + // A lone Esc snaps the viewport back to the live bottom. + ui.clearOutput(); + try ui.send("\x1b"); + try ui.waitFor("SCROLL-040"); +} + +test "ui: wheel sends arrows to alternate-screen applications" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + // The application switches to the alternate screen before the UI + // attaches; the painted marker proves the switch landed. cat -v + // makes the arrow bytes visible in peek. + try h.startDetached("alty", &.{ + "bash", "-c", "printf '\\033[?1049hALTREADY'; exec cat -v", + }); + const seeded = try h.waitPeekContains("alty", "ALTREADY"); + alloc.free(seeded); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("ALTREADY"); + + // Round-trip a typed marker before wheeling: its echo rendering + // in the viewport proves the attach repaint, and the `.screen` + // message sent with it, has been processed. + try ui.send("READY\r"); + try ui.waitFor("READY"); + + // One wheel-up tick over the viewport turns into arrow keys for + // the application instead of paging local scrollback. The tty is + // canonical, so Enter flushes the buffered arrows through cat -v. + try ui.send("\x1b[<64;50;10M"); + try ui.send("\r"); + const peeked = try h.waitPeekContains("alty", "^[[A^[[A^[[A"); + alloc.free(peeked); +} + test "ui: session titles render in the sidebar" { const alloc = std.testing.allocator; var h = try Harness.init(alloc);