From b834477bf4c71cf33a9945ff8ddf5c23be49e10f Mon Sep 17 00:00:00 2001 From: Xuz Date: Thu, 8 Oct 2026 18:23:22 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=88=E7=AB=AF=E5=8A=A0=E4=B8=8A=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E6=9D=A1=EF=BC=9A=E7=BF=BB=E7=9C=8B=E5=9B=9E=E6=BB=9A?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E6=97=B6=E5=87=BA=E7=8E=B0=E5=9C=A8=E5=8F=B3?= =?UTF-8?q?=E4=BE=A7=EF=BC=8C=E5=8F=AF=E6=8B=96=E5=8A=A8=E3=80=81=E7=82=B9?= =?UTF-8?q?=E8=BD=A8=E9=81=93=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用 gpui-kit 的 Scrollbar,配一个按回滚行数换算像素的 ScrollbarHandle (terminal/scrollbar.rs)。拖动的回调没有 context,经引擎给出的 Scrollback 直接滚动终端,再由滚动条通知视图重画。滚动条盖在整个边距上, 滑块大多落在文字旁边;全屏程序没有回滚,不显示。 Co-Authored-By: Claude Opus 5.5 --- docs/manual.en.md | 1 + docs/manual.md | 1 + src/terminal/engine.rs | 45 +++++++++++++++ src/terminal/mod.rs | 1 + src/terminal/scrollbar.rs | 103 ++++++++++++++++++++++++++++++++++ src/terminal/terminal_view.rs | 29 +++++++++- tests/ui/terminal.rs | 19 +++++++ 7 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 src/terminal/scrollbar.rs diff --git a/docs/manual.en.md b/docs/manual.en.md index 052c2fc..6f23a40 100644 --- a/docs/manual.en.md +++ b/docs/manual.en.md @@ -193,6 +193,7 @@ Remote terminal tabs and SFTP tabs use the host’s system badge as their icon ( The terminal’s context menu has “Copy”, “Paste”, “Find…” and “Clear screen”; a remote terminal’s also has “Open SFTP”, “Reconnect” and “Disconnect”. “Disconnect” disconnects only this tab: the tab stays and can reconnect at any time, and other tabs and SFTP for the same host aren’t affected. +- **Scrollback**: each terminal keeps the last 10,000 lines. Scroll up with the wheel or trackpad and a scrollbar appears on the right (as in the app’s other lists, following the system’s “Show scroll bars” setting); drag it, or click the track to jump there. Full-screen programs such as vim, less and top have no scrollback and show no scrollbar. - **Find** (⌘F, or Ctrl+Shift+F on Windows and Linux): opens the find bar at the top right of the terminal and searches the screen and the scrollback for the literal text. An all-lowercase query ignores case; one with capital letters matches case. All matches are highlighted, the current one darker, and the find bar shows “current/total”. Enter or ⌘G (F3) goes to the next match (newer output), Shift+Enter or ⌘⇧G (Shift+F3) to the previous one (older output), wrapping around at the ends. Esc closes it. If text is selected when it opens, that text becomes the query. - **Clear screen** (⌘K, or Ctrl+Shift+K on Windows and Linux; when the terminal doesn’t have focus, ⌘K still focuses the sidebar’s search box): clears the screen and the scrollback, leaving only the prompt line the cursor is on, including anything typed but not yet entered. Clearing happens only locally; nothing is sent to the server. It isn’t available while a full-screen program such as vim or top is running. - **Links**: `http://` and `https://` addresses in the output, and links that programs mark explicitly (such as `ls --hyperlink` and gcc’s error messages), are shown in blue and underlined. Hold ⌘ (Ctrl on Windows and Linux) and click to open one in the system browser; a plain click still selects text. Rest the pointer on a link for a moment to see its real address: for a link a program marks, the text shown can differ from the address. A trailing period, comma or unmatched closing parenthesis isn’t part of the address, and a long address wrapped over two lines is still one link. Other schemes (such as `file://`) aren’t treated as links. diff --git a/docs/manual.md b/docs/manual.md index 4d120af..c0e5238 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -199,6 +199,7 @@ ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口] 右键终端内容区可复制、粘贴、查找、清屏;远程终端另有打开 SFTP、重新连接和断开连接。断开连接只断开这一个标签,标签保留,可随时重新连接,同一台主机的其他标签和 SFTP 不受影响。 +- **回滚历史**:每个终端保留最近 10000 行。用滚轮或触控板往上翻看,这时右侧出现滚动条(和应用里其他列表一样,按系统的「显示滚动条」设置),可以拖动,点轨道直接跳到那个位置。vim、less、top 这类全屏程序没有回滚历史,不显示滚动条。 - **查找**(⌘F,其他平台 Ctrl+Shift+F):在终端右上角打开查找条,按字面量搜索屏幕和回滚历史。查询全为小写时不区分大小写,含大写字母时区分。所有匹配高亮显示,当前项颜色更深,查找条显示「当前项/总数」。回车或 ⌘G(F3)跳到下一项(更新的输出),Shift+回车或 ⌘⇧G(Shift+F3)跳到上一项(更早的输出),到头后循环。Esc 关闭。打开时如有选中文字,会作为查询词。 - **清屏**(⌘K,其他平台 Ctrl+Shift+K;焦点不在终端时 ⌘K 仍是聚焦侧栏的搜索框):清掉屏幕和回滚历史,只留下光标所在的提示符行,包括已输入但未回车的内容。清屏只在本地进行,不向服务器发送任何内容。vim、top 等全屏程序运行时不可用。 - **链接**:输出里的 `http://`、`https://` 地址,以及程序明确标出的链接(如 `ls --hyperlink`、gcc 的报错),显示为带下划线的蓝色。按住 ⌘(其他平台 Ctrl)单击,用系统浏览器打开;不按时单击照旧是选择文字。鼠标在链接上停一会儿会显示它真正的地址:程序标出的链接,显示的文字可以和地址不同。地址末尾的句号、逗号和不成对的右括号不算在内;被折成两行的长地址仍是一个链接。其他协议(如 `file://`)不当作链接。 diff --git a/src/terminal/engine.rs b/src/terminal/engine.rs index 5a18a3f..ff27da3 100644 --- a/src/terminal/engine.rs +++ b/src/terminal/engine.rs @@ -63,6 +63,8 @@ pub struct TerminalSnapshot { pub cursor_shape: CursorShape, pub cursor_blinking: bool, pub display_offset: usize, + /// The lines above the screen, which the view can scroll back through. + pub history_size: usize, pub colors: [Option; COUNT], /// The addresses of the links on screen, by the cells' `link`. pub links: Vec, @@ -360,6 +362,11 @@ impl TerminalEngine { cx.notify(); } + /// Scrolls the view without a context, for the scrollbar's drag. + pub fn scrollback(&self) -> Scrollback { + Scrollback(self.runtime.term.clone()) + } + pub fn start_selection( &mut self, point: Point, @@ -588,12 +595,35 @@ impl TerminalEngine { cursor_shape: content.cursor.shape, cursor_blinking: term.cursor_style().blinking, display_offset: content.display_offset, + history_size: term.grid().history_size(), colors: std::array::from_fn(|index| content.colors[index]), links: links.into_links(), } } } +/// A terminal's view of its history, for the scrollbar, whose drag hands +/// over a position and no context: whoever draws the terminal is told to +/// redraw it. Taken for each frame, as a restart replaces the terminal. +#[derive(Clone)] +pub struct Scrollback(Arc>); + +impl Scrollback { + /// Show the history from `lines` below its oldest line. + pub fn scroll_to(&self, lines: usize) { + scroll_to(&mut self.0.lock(), lines); + } +} + +/// Lines are counted from the top, which stays put while output arrives +/// below: the view stays where the pointer let it go. +fn scroll_to(term: &mut AlacrittyTerm, lines: usize) { + let grid = term.grid(); + let offset = grid.history_size().saturating_sub(lines); + let delta = offset as i32 - grid.display_offset() as i32; + term.scroll_display(Scroll::Delta(delta)); +} + /// The text of the line the cursor is on, or of the nearest line above it /// on screen that has any: where a program that rings for an answer has /// its question. @@ -1592,6 +1622,21 @@ mod tests { assert_eq!(cells[2].search, SearchMark::None); } + #[test] + fn the_scrollbar_counts_lines_from_the_top_of_the_history() { + let mut term = test_term(8, 2); + feed(&mut term, "1\r\n2\r\n3\r\n4\r\n5".as_bytes()); + assert_eq!(term.grid().history_size(), 3); + + scroll_to(&mut term, 0); + assert_eq!(term.grid().display_offset(), 3); + scroll_to(&mut term, 2); + assert_eq!(term.grid().display_offset(), 1); + // Past the bottom is the bottom. + scroll_to(&mut term, 10); + assert_eq!(term.grid().display_offset(), 0); + } + #[test] fn user_input_returns_to_bottom_and_clears_selection() { let mut term = test_term(8, 2); diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index e06aa8e..a5d1942 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -11,6 +11,7 @@ mod model; mod mouse; mod notices; mod run; +mod scrollbar; mod search; mod terminal_panel; mod terminal_view; diff --git a/src/terminal/scrollbar.rs b/src/terminal/scrollbar.rs new file mode 100644 index 0000000..50d05ac --- /dev/null +++ b/src/terminal/scrollbar.rs @@ -0,0 +1,103 @@ +//! The terminal's scrollbar: gpui-kit's, over the scrollback. +//! +//! The grid is drawn afresh each frame and scrolls by whole lines, so there +//! is no scroll handle for gpui-kit's `Scrollbar` to follow. This one gives +//! it the history in pixels: a line is a row's height, the content is the +//! scrollbar's own height plus the history, and the offset is how far the +//! view is from the oldest line. + +use std::cell::Cell; +use std::rc::Rc; + +use gpui_kit::component::scroll::ScrollbarHandle; +use gpui_kit::{Bounds, Pixels, Point, Size, point, px, size}; + +use super::TerminalSnapshot; +use super::engine::Scrollback; + +#[derive(Clone)] +pub(super) struct ScrollbackHandle { + scrollback: Scrollback, + history_size: usize, + display_offset: usize, + line_height: Pixels, + /// Where the scrollbar lies, which the canvas under it notes before the + /// scrollbar is laid out in the same frame. + viewport: Rc>>, +} + +impl ScrollbackHandle { + pub(super) fn new( + scrollback: Scrollback, + snapshot: &TerminalSnapshot, + line_height: Pixels, + ) -> Self { + Self { + scrollback, + history_size: snapshot.history_size, + display_offset: snapshot.display_offset, + line_height, + viewport: Rc::default(), + } + } + + pub(super) fn set_viewport(&self, bounds: Bounds) { + self.viewport.set(bounds); + } +} + +impl ScrollbarHandle for ScrollbackHandle { + fn viewport_bounds(&self) -> Bounds { + self.viewport.get() + } + + fn offset(&self) -> Point { + point( + px(0.), + offset(self.history_size, self.display_offset, self.line_height), + ) + } + + fn set_offset(&self, offset: Point) { + self.scrollback + .scroll_to(lines_from_top(offset.y, self.line_height)); + } + + fn content_size(&self) -> Size { + let viewport = self.viewport.get().size; + size( + viewport.width, + viewport.height + self.line_height * self.history_size as f32, + ) + } +} + +/// How far the view is scrolled down from the oldest line, as gpui-kit +/// counts it: zero at the top, negative below. +fn offset(history_size: usize, display_offset: usize, line_height: Pixels) -> Pixels { + -(line_height * history_size.saturating_sub(display_offset) as f32) +} + +/// The line at the top of the view for a scroll `offset`. +fn lines_from_top(offset: Pixels, line_height: Pixels) -> usize { + (-offset / line_height).round().max(0.) as usize +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_offset_counts_whole_lines_from_the_oldest() { + let line = px(17.); + // At the bottom of 100 lines of history, 100 lines down. + assert_eq!(offset(100, 0, line), px(-1700.)); + // At the top. + assert_eq!(offset(100, 100, line), px(0.)); + assert_eq!(lines_from_top(offset(100, 40, line), line), 60); + // A drag lands between lines; past the top is the top. + assert_eq!(lines_from_top(px(-25.), line), 1); + assert_eq!(lines_from_top(px(-26.), line), 2); + assert_eq!(lines_from_top(px(5.), line), 0); + } +} diff --git a/src/terminal/terminal_view.rs b/src/terminal/terminal_view.rs index 98ca415..2887411 100644 --- a/src/terminal/terminal_view.rs +++ b/src/terminal/terminal_view.rs @@ -14,6 +14,7 @@ use gpui_kit::component::{ h_flex, input::{Input, InputEvent, InputState}, menu::PopupMenu, + scroll::Scrollbar, tooltip::Tooltip, v_flex, }; @@ -29,6 +30,7 @@ use crate::connection::{ConnectionPromptReply, Latency}; use crate::i18n::{UiLocale, t}; use super::mouse::{self, MouseReport, ReportButton, ReportKind}; +use super::scrollbar::ScrollbackHandle; use super::search::SearchMark; use super::{ SearchDirection, SharedTerminalTransportFactory, TerminalColors, TerminalEngine, TerminalEvent, @@ -915,6 +917,12 @@ impl EntityInputHandler for TerminalView { impl Render for TerminalView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let snapshot = self.engine.read(cx).snapshot(); + let font = TerminalFont::current(cx); + let scrollback = ScrollbackHandle::new( + self.engine.read(cx).scrollback(), + &snapshot, + font.row_height(window), + ); let view = cx.entity(); let context_menu = self.context_menu.clone(); let element = TerminalElement { @@ -927,7 +935,6 @@ impl Render for TerminalView { requested_size: self.requested_size, }; - let font = TerminalFont::current(cx); let terminal = div() .id(self.element_id.clone()) .test_support() @@ -984,6 +991,26 @@ impl Render for TerminalView { // the terminal's too. .bg(TerminalColors::current(cx).background()) .child(terminal) + // Over the whole margin, so the thumb lies mostly beside the text. + .child( + div() + .absolute() + .inset_0() + .child( + canvas( + { + let scrollback = scrollback.clone(); + move |bounds, _, _| scrollback.set_viewport(bounds) + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) + .child(Scrollbar::vertical(&scrollback).id("terminal-scrollbar")), + ) .when_some(self.find.as_ref(), |container, find| { container.child(self.render_find_bar(find, cx)) }) diff --git a/tests/ui/terminal.rs b/tests/ui/terminal.rs index 9d29d3e..e3b8f4c 100644 --- a/tests/ui/terminal.rs +++ b/tests/ui/terminal.rs @@ -921,6 +921,25 @@ async fn programs_that_ask_for_the_mouse_hear_clicks_and_the_wheel(cx: &mut Test take_written(cx, handle, &factory, "\x1b[<64;5;2M").await; } +#[gpui_kit::test] +async fn the_scrollbar_scrolls_back_through_the_history(cx: &mut TestAppContext) { + let text: String = (1..=200).map(|line| format!("line {line}\r\n")).collect(); + let (handle, workspace, _) = terminal_printing(cx, String::leak(text + "end"), "end").await; + let screen = |cx: &mut TestAppContext| cx.read(|cx| local_screen(&workspace, cx)); + assert!(!screen(cx).lines().any(|line| line == "line 1")); + + // A click at the top of the scrollbar's track, in the margin beside the + // text, goes to the oldest line. + in_frame(cx, handle, |window, cx| { + let terminal = window.find(("local-terminal", 1_u64)).bounds(); + let top = point(terminal.right() + px(2.), terminal.top() + px(2.)); + click_at(window, top, gpui_kit::Modifiers::none(), cx); + }); + let lines = screen(cx); + assert!(lines.lines().any(|line| line == "line 1"), "{lines}"); + assert!(!lines.contains("end"), "{lines}"); +} + #[gpui_kit::test] async fn a_program_copies_to_the_local_clipboard(cx: &mut TestAppContext) { // 「你好」 in base64.