From c4645aeea8a60c7a20cf23ffbfae225091d27a7e Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Tue, 11 Aug 2026 10:54:12 +0800 Subject: [PATCH] feat(terminal): draw a scrollback scrollbar down the right edge of a pane (#480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pane's scroll position lives in alacritty's `display_offset` — rows of scrollback, not pixels of laid-out content — so it has no `ScrollHandle` to hand a scrollbar. `TerminalScrollHandle` implements gpui-component's `ScrollbarHandle` over the grid instead, which lets the pane draw the same `Scrollbar` the sidebar and every list already use: same theme, same `Scrolling` show mode, same fade-out. The bar never touches the terminal. `set_offset` only records the row it wants; `sync_scrollbar` applies that on the next render — clearing the sub-line remainder and cancelling an in-flight smooth scroll on the way — and reports back where the grid actually ended up. Scrollback piling up at the live edge is deliberately not reported: the bar shows itself whenever the offset it reads changed, so a pane printing a build log would otherwise hold a thumb on screen for as long as the output ran. Every other change passes through, including the history shrinking, which is a cleared scrollback rather than growth. Closes #432 Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- docs/terminal/mouse-and-scrolling.mdx | 6 + src/terminal/mod.rs | 1 + src/terminal/scrollbar.rs | 274 ++++++++++++++++++++++++++ src/terminal/view.rs | 130 ++++++++++++ 4 files changed, 411 insertions(+) create mode 100644 src/terminal/scrollbar.rs diff --git a/docs/terminal/mouse-and-scrolling.mdx b/docs/terminal/mouse-and-scrolling.mdx index fa842da3..cd77a3a7 100644 --- a/docs/terminal/mouse-and-scrolling.mdx +++ b/docs/terminal/mouse-and-scrolling.mdx @@ -16,6 +16,12 @@ The wheel and trackpad scroll the pane's scrollback. All of it lives under plus the wheel zooms the font instead of scrolling. +A scrollbar appears down the right edge of the pane as soon as the view moves, +and fades out once it stops — the same bar the sidebar and every list in the app +use. Drag its thumb to travel the whole scrollback at once, or click the track +to jump. A pane sitting at the live edge stays bare, however much output is +running through it. + ## The pointer Under **Settings → Terminal → Mouse**: diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index cdc79d9d..29b63685 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -19,6 +19,7 @@ pub(crate) mod pane_liveness; pub(crate) mod parked_cursor; mod remote; mod reverse_search; +pub(crate) mod scrollbar; pub mod search; mod signature; mod size; diff --git a/src/terminal/scrollbar.rs b/src/terminal/scrollbar.rs new file mode 100644 index 00000000..bba1dd05 --- /dev/null +++ b/src/terminal/scrollbar.rs @@ -0,0 +1,274 @@ +//! The scrollback bar down the right edge of a terminal pane (issue #432). +//! +//! A pane's scroll position does not live in a [`gpui::ScrollHandle`]: it is +//! alacritty's `display_offset`, counted in rows of scrollback rather than in +//! pixels of laid-out content. This handle translates between the two, so the +//! pane can hand the grid to the same [`gpui_component::scroll::Scrollbar`] the +//! sidebar and every list in the app already draw, and get their behaviour for +//! free — a thumb that appears while the view moves and fades out once it +//! stops. +//! +//! The bar never touches the terminal. [`set_offset`](ScrollbarHandle::set_offset) +//! only records the row it wants; `TerminalView::sync_scrollbar` applies that on +//! the next render and reports back where the grid actually ended up. + +use std::cell::Cell; +use std::rc::Rc; + +use gpui::{Pixels, Point, Size, point, px, size}; +use gpui_component::scroll::ScrollbarHandle; + +/// Where the grid stood the last time the pane reported in. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct GridScroll { + /// Rows of scrollback behind the viewport. + pub(crate) history: usize, + /// How many of those rows the viewport has been scrolled back over. Zero is + /// the live edge. + pub(crate) display_offset: usize, + /// Rows the viewport shows. + pub(crate) screen_lines: usize, + /// The height of one row, in logical pixels. + pub(crate) line_height: f32, +} + +impl GridScroll { + /// A row is never zero pixels tall; a zero here would only ever be the + /// default this starts life with, one render before the first layout. + fn line_height(&self) -> f32 { + self.line_height.max(1.) + } + + /// Rows that have scrolled off the top of the viewport. + fn above(&self) -> usize { + self.history.saturating_sub(self.display_offset) + } +} + +/// The pane's end of the scrollbar: a snapshot of the grid the bar reads, and a +/// row the bar asks for. +#[derive(Clone, Default)] +pub(crate) struct TerminalScrollHandle { + grid: Rc>, + /// The `display_offset` the bar wants and the pane has not applied yet. + pending: Rc>>, +} + +impl TerminalScrollHandle { + /// Report where the grid stands now. + /// + /// Scrollback piling up at the live edge is deliberately *not* reported: + /// the bar shows itself whenever the offset it reads has changed since the + /// last frame, so a pane printing a build log — its viewport pinned to the + /// bottom, its history growing under it — would hold the thumb on screen + /// for as long as the output ran. Freezing that one case keeps the bar to + /// what it is for: saying where you are once you have gone looking. Every + /// other change is taken as it comes, including the history *shrinking*, + /// which is a cleared scrollback and not growth at all. + pub(crate) fn sync(&self, live: GridScroll) { + let snap = self.grid.get(); + let pinned_growth = live.display_offset == 0 + && snap.display_offset == 0 + && live.history >= snap.history + && live.screen_lines == snap.screen_lines + && live.line_height == snap.line_height; + if !pinned_growth { + self.grid.set(live); + } + } + + /// The row the bar was dragged to, if it was dragged since the last render. + pub(crate) fn take_pending(&self) -> Option { + self.pending.take() + } + + #[cfg(test)] + fn snapshot(&self) -> GridScroll { + self.grid.get() + } +} + +impl ScrollbarHandle for TerminalScrollHandle { + fn offset(&self) -> Point { + let grid = self.grid.get(); + // Scroll offsets run negative as the content moves up past the top of + // the viewport, which is what the rows above it have done. + point(px(0.), px(-(grid.above() as f32) * grid.line_height())) + } + + fn set_offset(&self, offset: Point) { + let grid = self.grid.get(); + let above = (-offset.y.as_f32() / grid.line_height()) + .round() + .clamp(0., grid.history as f32) as usize; + let target = grid.history - above; + if target == grid.display_offset { + return; + } + // Move the snapshot with the thumb rather than waiting for the pane to + // confirm: the bar reads the offset back on the very next mouse move to + // decide where the thumb sits, and a snapshot still showing the old row + // would drag it back under the cursor. + self.grid.set(GridScroll { + display_offset: target, + ..grid + }); + self.pending.set(Some(target)); + } + + fn content_size(&self) -> Size { + let grid = self.grid.get(); + // Width is never read for a vertical-only bar, and the pane has no + // horizontal scroll to describe. + size( + px(0.), + px((grid.history + grid.screen_lines) as f32 * grid.line_height()), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn grid(history: usize, display_offset: usize) -> GridScroll { + GridScroll { + history, + display_offset, + screen_lines: 24, + line_height: 10., + } + } + + #[test] + fn the_thumb_sits_at_the_bottom_at_the_live_edge_and_at_the_top_of_the_scrollback() { + let handle = TerminalScrollHandle::default(); + + handle.sync(grid(100, 0)); + assert_eq!(handle.offset().y, px(-1000.)); + assert_eq!(handle.content_size().height, px(1240.)); + + handle.sync(grid(100, 100)); + assert_eq!( + handle.offset().y, + px(0.), + "scrolled all the way back is the top of the content" + ); + } + + #[test] + fn dragging_the_thumb_asks_the_pane_for_a_row() { + let handle = TerminalScrollHandle::default(); + handle.sync(grid(100, 0)); + + handle.set_offset(point(px(0.), px(-250.))); + assert_eq!( + handle.take_pending(), + Some(75), + "25 rows down from the top of a 100-row scrollback" + ); + assert_eq!( + handle.offset().y, + px(-250.), + "and the thumb stays where the drag put it until the pane renders" + ); + assert_eq!( + handle.take_pending(), + None, + "asked for once, not every frame" + ); + } + + #[test] + fn a_drag_past_either_end_lands_on_it() { + let handle = TerminalScrollHandle::default(); + handle.sync(grid(100, 50)); + + handle.set_offset(point(px(0.), px(400.))); + assert_eq!( + handle.take_pending(), + Some(100), + "no further back than the top" + ); + + handle.sync(grid(100, 50)); + handle.set_offset(point(px(0.), px(-9000.))); + assert_eq!( + handle.take_pending(), + Some(0), + "no further forward than the live edge" + ); + } + + #[test] + fn a_drag_that_lands_on_the_row_it_started_on_asks_for_nothing() { + let handle = TerminalScrollHandle::default(); + handle.sync(grid(100, 40)); + + // Half a row's worth of travel, which rounds back to where it was. + handle.set_offset(point(px(0.), px(-604.))); + assert_eq!(handle.take_pending(), None); + } + + #[test] + fn output_at_the_live_edge_does_not_move_the_bar() { + let handle = TerminalScrollHandle::default(); + handle.sync(grid(100, 0)); + let before = handle.offset(); + + // A screenful of new output, all of it pushing history under a viewport + // that is already at the bottom. + handle.sync(grid(124, 0)); + assert_eq!( + handle.offset(), + before, + "a streaming pane would otherwise hold the thumb on screen the whole time" + ); + assert_eq!(handle.snapshot().history, 100); + } + + #[test] + fn everything_other_than_growth_at_the_live_edge_is_reported() { + let handle = TerminalScrollHandle::default(); + + handle.sync(grid(100, 0)); + handle.sync(grid(100, 3)); + assert_eq!(handle.snapshot().display_offset, 3, "the viewport moved"); + + handle.sync(grid(140, 5)); + assert_eq!( + handle.snapshot().history, + 140, + "output arriving while scrolled back moves the rows under the thumb" + ); + + handle.sync(grid(140, 0)); + handle.sync(grid(0, 0)); + assert_eq!( + handle.snapshot().history, + 0, + "a cleared scrollback is not growth, and leaves nothing to scroll" + ); + + handle.sync(grid(0, 0)); + handle.sync(GridScroll { + screen_lines: 40, + ..grid(0, 0) + }); + assert_eq!( + handle.snapshot().screen_lines, + 40, + "a resized pane changes how much of the content is on screen" + ); + + handle.sync(GridScroll { + line_height: 18., + ..grid(0, 0) + }); + assert_eq!( + handle.snapshot().line_height, + 18., + "and so does a font-size change" + ); + } +} diff --git a/src/terminal/view.rs b/src/terminal/view.rs index ca08315e..401124ee 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -10,6 +10,7 @@ use gpui::{ }; use gpui_component::kbd::Kbd; use gpui_component::menu::{ContextMenuExt, PopupMenuItem}; +use gpui_component::scroll::Scrollbar; use gpui_component::{ActiveTheme as _, Icon, IconName, WindowExt as _, h_flex}; use super::TermSize; @@ -20,6 +21,7 @@ use super::highlight::{self, TokenKind}; use super::hold::{GapHold, Verdict}; use super::remote::RemoteTerminal; use super::reverse_search::{self, ReverseSearch}; +use super::scrollbar::{GridScroll, TerminalScrollHandle}; use super::search::{LinkTarget, SearchState}; use super::typeahead::{RawInput, Typeahead}; use crate::core::actions::{ @@ -171,6 +173,9 @@ pub struct TerminalView { /// to the other. zoom_debt: f32, pub(super) scroll_frac: f32, + /// The scrollback bar's end of the grid: where it thinks the viewport is, + /// and where it has asked for it to go. See [`super::scrollbar`]. + pub(super) scroll_handle: TerminalScrollHandle, pub search: Option, pub cursor_visible: bool, pub focused: bool, @@ -1133,6 +1138,7 @@ impl TerminalView { scroll_debt: 0., zoom_debt: 0., scroll_frac: 0., + scroll_handle: TerminalScrollHandle::default(), search: None, cursor_visible: true, focused: true, @@ -4520,6 +4526,56 @@ impl TerminalView { false } + /// Settle up with the scrollback bar for this frame: move the viewport + /// where a drag asked for, then tell the bar where the grid ended up. + /// + /// Both halves belong here rather than in the handle, so the bar — which + /// runs from a mouse handler, with no pane to call into — never reaches + /// into the terminal behind the pane's back. + fn sync_scrollbar(&mut self) { + if let Some(target) = self.scroll_handle.take_pending() { + // Whatever the wheel had in flight was heading somewhere else. + self.cancel_scroll_anim(); + let mut term = self.terminal.term.lock(); + let delta = target as i32 - term.grid().display_offset() as i32; + if delta != 0 { + term.scroll_display(Scroll::Delta(delta)); + } + drop(term); + // A sub-line remainder left over from a smooth wheel scroll would + // paint the grid shifted off the row the thumb just picked. + self.scroll_frac = 0.; + } + let term = self.terminal.term.lock(); + let grid = GridScroll { + history: term.grid().history_size(), + display_offset: term.grid().display_offset(), + screen_lines: term.screen_lines(), + line_height: self.line_height.as_f32(), + }; + drop(term); + self.scroll_handle.sync(grid); + } + + /// The scrollback bar, laid down the right edge of the grid. + /// + /// The track is inset to the rows themselves — [`GRID_PAD_Y`] is padding + /// the grid never scrolls through, and counting it would leave the thumb + /// short of the ends by that much. + fn render_scrollbar(&self) -> impl IntoElement + use<> { + div() + .absolute() + .top(px(GRID_PAD_Y)) + .left_0() + .right_0() + .h(self.line_height * self.terminal.size().rows as f32) + // No `scrollbar_show` override: the bar takes `cx.theme()`'s, which + // `apply_theme` pins to `Scrolling` for every list in the app. A + // pane disagreeing with the sidebar about when a scrollbar is worth + // showing would be the odd one out. + .child(Scrollbar::vertical(&self.scroll_handle).id("terminal-scrollbar")) + } + fn grid_line( term: &alacritty_terminal::Term, row: usize, @@ -5270,6 +5326,7 @@ impl Drop for TerminalView { impl Render for TerminalView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.sync_typeahead_owner(); + self.sync_scrollbar(); if self.shell_owns_prompt() { if let Some((_net, bytes)) = self.hold.release() { self.terminal.write(bytes); @@ -5377,6 +5434,7 @@ impl Render for TerminalView { this.tab_pressed(false, cx); })) .child(TerminalElement::new(entity)) + .child(self.render_scrollbar()) .children(search_bar) .children(input_bar) .children(completion_menu) @@ -7742,6 +7800,78 @@ mod gpui_tests { .unwrap(); } + #[gpui::test] + fn the_scrollbar_moves_the_viewport_and_follows_it_back(cx: &mut TestAppContext) { + use gpui_component::scroll::ScrollbarHandle as _; + + let (window, mut daemon) = harness(cx); + + // Overflow the 24-row viewport so there is a scrollback to scroll. + let mut out = Vec::new(); + for i in 0..60 { + out.extend_from_slice(format!("line {i}\r\n").as_bytes()); + } + DaemonMsg::Output(out).encode(&mut daemon).unwrap(); + // Wait for the reader to go quiet, not just to start: a scrollback + // still filling underneath would move every row this test names. + let mut settled = 0; + for _ in 0..200 { + let now = window + .update(cx, |view, _, _| { + view.terminal.term.lock().grid().history_size() + }) + .unwrap(); + if now > 0 && now == settled { + break; + } + settled = now; + std::thread::sleep(std::time::Duration::from_millis(5)); + } + + window + .update(cx, |view, _, _| { + view.sync_scrollbar(); + let history = view.terminal.term.lock().grid().history_size(); + assert!(history > 0, "the test needs a scrollback to scroll"); + let row = view.line_height.as_f32(); + assert_eq!( + view.scroll_handle.offset().y, + px(-(history as f32) * row), + "at the live edge the whole scrollback sits above the viewport" + ); + + // Drag the thumb a third of the way up its track. The bar only + // records the row; the pane applies it on its next render. + view.scroll_frac = 0.5; + view.scroll_handle + .set_offset(point(px(0.), px(-(history as f32) * row / 3.))); + assert_eq!( + view.terminal.term.lock().grid().display_offset(), + 0, + "the bar does not reach into the terminal itself" + ); + + view.sync_scrollbar(); + let offset = view.terminal.term.lock().grid().display_offset(); + assert_eq!( + offset, + history - (history as f32 / 3.).round() as usize, + "the viewport lands on the row the thumb was dropped on" + ); + assert_eq!( + view.scroll_frac, 0., + "a sub-line remainder left over from the wheel would paint \ + the grid off the row the thumb picked" + ); + assert_eq!( + view.scroll_handle.offset().y, + px(-((history - offset) as f32) * row), + "and the thumb reports the row the grid actually reached" + ); + }) + .unwrap(); + } + #[gpui::test] fn a_stale_hover_row_does_not_index_the_shrunken_grid(cx: &mut TestAppContext) { let (window, _daemon) = harness(cx);