mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
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:
@@ -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**:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user