feat(terminal): draw a scrollback scrollbar down the right edge of a pane (#480)

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>
This commit is contained in:
l0ng-ai
2026-08-11 10:54:12 +08:00
committed by GitHub
co-authored by l0ng-ai
parent 9735a490b5
commit c4645aeea8
4 changed files with 411 additions and 0 deletions
+6
View File
@@ -16,6 +16,12 @@ The wheel and trackpad scroll the pane's scrollback. All of it lives under
<kbd>⌘</kbd> 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**:
+1
View File
@@ -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;
+274
View File
@@ -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<Cell<GridScroll>>,
/// The `display_offset` the bar wants and the pane has not applied yet.
pending: Rc<Cell<Option<usize>>>,
}
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<usize> {
self.pending.take()
}
#[cfg(test)]
fn snapshot(&self) -> GridScroll {
self.grid.get()
}
}
impl ScrollbarHandle for TerminalScrollHandle {
fn offset(&self) -> Point<Pixels> {
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<Pixels>) {
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<Pixels> {
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"
);
}
}
+130
View File
@@ -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<SearchState>,
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<crate::terminal::remote::EventProxy>,
row: usize,
@@ -5270,6 +5326,7 @@ impl Drop for TerminalView {
impl Render for TerminalView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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);