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);
| |