Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Terminal scrollbar visibility

## Status
Proposed implementation for Issue #425.

## Context
The overlay scrollbar disappears at the live bottom even when scrollback exists.
Mouse users cannot grab it without first scrolling, and dragging to live loses feedback.

## Evidence
`terminal/view/pointer.rs` previously returned no thumb for offset zero. The
24px minimum thumb also made the old inverse mapping diverge from painted geometry.

## Decision
Shared settings parse and reset `scrollbar_visibility=auto|hover|always`, default
Auto. Auto displays in history, Hover displays in the existing right-edge hit band,
and Always displays whenever history exists. Active drags stay visible in every
mode; no history always means no scrollbar or input interception. Hot application
updates existing terminals. Hover clears when leaving the terminal.

Rendering and input share the same visible thumb and right-edge bounds. The drag
mapping uses the actual thumb travel range so minimum-size thumbs reach both ends
without jumping when first grabbed at live bottom.

## Rejected alternatives
A boolean omits the requested hover mode. Separate visible/hit geometry can make
hidden controls consume selection or TUI input. Drawing an empty disabled bar adds
no usable scrollback and occupies the terminal edge without purpose.

## Consequences
No grid width is reserved and no shell/PTY behavior changes. Hover is owned by the
view; the shared model contains only stable preference values.

## Validation
Regression coverage checks persistence/reset, real dropdown choices, hover/leave,
selection when hidden, live dragging with a minimum-size thumb, and outside release.
Execution and visual/platform acceptance are reported separately through Actions.

## Supersedes
None.

## Revisit when
Scrollbar geometry or the terminal input ownership model changes.
5 changes: 5 additions & 0 deletions nebula_app/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@
"save_failed": "Could not save cursor motion: {error}"
},
"scrolling": {
"scrollbar": "Scrollbar visibility",
"scrollbar_auto": "Auto-hide",
"scrollbar_hover": "On hover",
"scrollbar_always": "Always",
"scrollbar_description": "Auto-hide shows while scrolled into history. Hover shows at the right edge; always shows whenever history exists.",
"history": "Scrollback lines",
"history_description": "History lines retained per terminal. Applies to new terminals only.",
"speed": "Scrolling speed",
Expand Down
5 changes: 5 additions & 0 deletions nebula_app/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@
"save_failed": "无法保存光标动画设置:{error}"
},
"scrolling": {
"scrollbar": "滚动条显示方式",
"scrollbar_auto": "自动隐藏",
"scrollbar_hover": "悬停显示",
"scrollbar_always": "始终显示",
"scrollbar_description": "自动隐藏仅在回滚历史中显示;悬停在终端右缘时显示;始终显示在有回滚历史时常显。",
"history": "回滚行数",
"history_description": "每个窗格保留的历史行数。仅适用于新窗格。",
"speed": "滚动速度",
Expand Down
2 changes: 2 additions & 0 deletions nebula_app/src/gpui_shell/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ pub struct Settings {
pub copy_on_select: bool,
pub scrollback_lines: usize,
pub scroll_speed: f32,
pub scrollbar_visibility: nebula_settings::ScrollbarVisibility,
/// Pointer handlers and split rendering only read these cached preferences.
pub focus_follows_mouse: bool,
pub dim_inactive_panes: bool,
Expand Down Expand Up @@ -296,6 +297,7 @@ impl Settings {
copy_on_select: runtime.copy_on_select,
scrollback_lines: runtime.scrollback_lines,
scroll_speed: runtime.scroll_speed,
scrollbar_visibility: runtime.scrollbar_visibility,
focus_follows_mouse: runtime
.focus_follows_mouse
.unwrap_or(raw.mouse.focus_follows_mouse),
Expand Down
1 change: 1 addition & 0 deletions nebula_app/src/gpui_shell/settings_pane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,7 @@ impl SettingsPane {
"windowing_behavior" => pick!(windowing_behavior),
"cell_width_mode" => pick!(cell_width_mode),
"ligatures" => pick!(ligatures),
"scrollbar_visibility" => pick!(scrollbar_visibility),
"scrollback_lines" => Some((
cur.scrollback_lines != def.scrollback_lines,
def.scrollback_lines.to_string(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,12 @@ impl SettingsPane {
cx,
))
.child(self.scroll_speed_row(cx))
.child(self.select_row(
"scrollbar_visibility",
language.text(crate::i18n::Message::SettingsScrollingScrollbar),
language.text(crate::i18n::Message::SettingsScrollingScrollbarDescription),
cx,
))
.child(self.switch_row(
"fetch",
language.pick("启动欢迎信息", "Startup system information"),
Expand Down
7 changes: 7 additions & 0 deletions nebula_app/src/gpui_shell/settings_pane/initialization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,13 @@ impl SettingsPane {
window,
cx,
);
add_select(
"scrollbar_visibility",
nebula_settings::ScrollbarVisibility::VALUES,
runtime.scrollbar_visibility.settings_value(),
window,
cx,
);
// 文案照抄旧壳 `accept_label` / `completion_style_label`。
add_select(
"bell",
Expand Down
5 changes: 5 additions & 0 deletions nebula_app/src/gpui_shell/settings_pane/localization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ pub(super) fn localized_select_labels(
language.text(crate::i18n::Message::SettingsQuickTerminalDedicated),
language.text(crate::i18n::Message::SettingsQuickTerminalExisting),
],
"scrollbar_visibility" => vec![
language.text(crate::i18n::Message::SettingsScrollingScrollbarAuto),
language.text(crate::i18n::Message::SettingsScrollingScrollbarHover),
language.text(crate::i18n::Message::SettingsScrollingScrollbarAlways),
],
"notification_duration" => vec![
language.text(crate::i18n::Message::SettingsNotificationsDurationDefault),
language.text(crate::i18n::Message::SettingsNotificationsDurationFive),
Expand Down
47 changes: 47 additions & 0 deletions nebula_app/src/gpui_shell/settings_pane/theme_studio_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,53 @@ fn scrolling_controls_persist_dropdown_and_slider_without_changing_existing_hist
assert_eq!(existing.grid().history_size(), 10_000);
}

#[gpui::test]
fn scrollbar_menu_persists_all_modes_and_reopens_with_the_selected_value(cx: &mut TestAppContext) {
use crate::gpui_shell::config::Settings;
use nebula_settings::ScrollbarVisibility;

let _fixture_guard = lock_theme_studio();
let _settings_guard = SettingsBytesGuard::capture();
std::fs::create_dir_all(nebula_settings::settings_dir()).unwrap();
std::fs::write(nebula_settings::settings_path(), TEST_SETTINGS).unwrap();
let (pane, mut window) = open_settings(cx);
pane.update(&mut window, |pane, cx| {
pane.active_section = 1;
cx.notify();
});
window.simulate_resize(size(px(1280.0), px(1800.0)));
draw(&mut window);
for mode in [ScrollbarVisibility::Hover, ScrollbarVisibility::Always, ScrollbarVisibility::Auto]
{
click("settings-select-scrollbar_visibility", &mut window);
if mode == ScrollbarVisibility::Auto {
press("up", &mut window);
press("up", &mut window);
} else {
press("down", &mut window);
}
press("enter", &mut window);
assert_eq!(RuntimeSettings::load().scrollbar_visibility, mode);
assert_eq!(window.read(|cx| cx.global::<Settings>().scrollbar_visibility), mode);
let reopened = window.update(|window, cx| cx.new(|cx| SettingsPane::new(window, cx)));
reopened.read_with(&mut window, |pane, cx| {
assert_eq!(pane.runtime.scrollbar_visibility, mode);
assert_eq!(
pane.select_of("scrollbar_visibility")
.unwrap()
.read(cx)
.selected_index(cx)
.unwrap()
.row,
ScrollbarVisibility::VALUES
.iter()
.position(|value| *value == mode.settings_value())
.unwrap(),
);
});
}
}

fn draw(cx: &mut VisualTestContext) {
cx.run_until_parked();
cx.update(|window, cx| {
Expand Down
4 changes: 2 additions & 2 deletions nebula_app/src/gpui_shell/terminal/element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -983,12 +983,12 @@ impl Element for TerminalElement {
}
}

// Overlay 滚动条:贴着底部时拇指为 None 不画,滚进历史才浮在网格右缘。
// Overlay 滚动条:可见性由用户策略和拖拽状态共同决定。
// 几何来自 view 的单一真值源——命中测试拿的是同一个矩形(旧壳
// `draw_scrollbar` / `scrollbar_grab` 共用 `scrollbar_geometry` 同构)。
let (dragging, thumb) = {
let view = self.view.read(cx);
(view.scrollbar_dragging(), view.scrollbar_thumb(snap.display_offset, history))
(view.scrollbar_highlighted(), view.scrollbar_thumb(snap.display_offset, history))
};
if let Some(thumb) = thumb {
let color = if dragging {
Expand Down
6 changes: 6 additions & 0 deletions nebula_app/src/gpui_shell/terminal/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,8 @@ pub struct TerminalView {
/// 滚动条拖拽中:按下时记下的「指针在拇指内的 y 偏移」,拖动全程据此
/// 反算 `display_offset`,拇指不会在按下那一刻跳到指针中心。
scrollbar_drag: Option<f32>,
scrollbar_hovered: bool,
scrollbar_visibility: nebula_settings::ScrollbarVisibility,
origin: Point<Pixels>,
cell_width: Pixels,
line_height: Pixels,
Expand Down Expand Up @@ -956,6 +958,7 @@ impl TerminalView {
}
self.palette = palette;
self.copy_on_select = copy_on_select;
self.scrollbar_visibility = settings.scrollbar_visibility;
self.default_cursor_style = default_cursor_style;
if let Some(session) = &self.session {
let mut term = session.term.lock();
Expand Down Expand Up @@ -1428,6 +1431,9 @@ impl Render for TerminalView {
))
.on_hover(cx.listener(|this, hovered: &bool, _, cx| {
if !*hovered {
if std::mem::take(&mut this.scrollbar_hovered) {
cx.notify();
}
this.clear_link_hover(cx);
if this.completion_viewport.hovered.take().is_some() {
cx.notify();
Expand Down
63 changes: 46 additions & 17 deletions nebula_app/src/gpui_shell/terminal/view/pointer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,19 @@ impl TerminalView {
display_offset: usize,
history: usize,
) -> Option<Bounds<Pixels>> {
let visible = self.scrollbar_drag.is_some()
|| match self.scrollbar_visibility {
nebula_settings::ScrollbarVisibility::Auto => display_offset > 0,
nebula_settings::ScrollbarVisibility::Hover => self.scrollbar_hovered,
nebula_settings::ScrollbarVisibility::Always => true,
};
visible.then(|| self.scrollbar_geometry(display_offset, history)).flatten()
}

fn scrollbar_geometry(&self, display_offset: usize, history: usize) -> Option<Bounds<Pixels>> {
let screen = self.rows;
let total = history + screen;
if display_offset == 0 || screen == 0 || total <= screen {
if screen == 0 || total <= screen {
return None;
}
let track_top = self.origin.y.as_f32();
Expand All @@ -26,7 +36,7 @@ impl TerminalView {
// 视口顶端之上还剩多少行历史:0 = 拉到最顶,history = 贴着底部。
let above = (history - display_offset) as f32;
let max_y = (track_h - thumb_h).max(0.0);
let thumb_y = track_top + (track_h * above / total as f32).clamp(0.0, max_y);
let thumb_y = track_top + (max_y * above / history as f32).clamp(0.0, max_y);
// 浮在网格右缘(overlay 风格:不占列宽、不画轨道)。
let grid_right = self.origin.x.as_f32() + self.cell_width.as_f32() * self.cols as f32;
Some(Bounds::new(
Expand All @@ -39,6 +49,26 @@ impl TerminalView {
self.scrollbar_drag.is_some()
}

pub(in crate::gpui_shell::terminal) fn scrollbar_highlighted(&self) -> bool {
self.scrollbar_dragging() || self.scrollbar_hovered
}

fn scrollbar_hot_zone(&self) -> Bounds<Pixels> {
let right = self.origin.x + self.cell_width * self.cols as f32;
Bounds::new(
point(right - px(SCROLLBAR_W + SCROLLBAR_SLOP), self.origin.y),
gpui::size(px(SCROLLBAR_W + 2.0 * SCROLLBAR_SLOP), self.line_height * self.rows as f32),
)
}

fn update_scrollbar_hover(&mut self, position: Point<Pixels>, cx: &mut Context<Self>) {
let hovered = self.scrollbar_hot_zone().contains(&position);
if self.scrollbar_hovered != hovered {
self.scrollbar_hovered = hovered;
cx.notify();
}
}

/// 回滚历史行数:拇指高度与拖拽反算的分母来源。
pub(in crate::gpui_shell::terminal) fn history_size(&self) -> usize {
self.session.as_ref().map(|s| s.term.lock().history_size()).unwrap_or(0)
Expand All @@ -53,17 +83,10 @@ impl TerminalView {
history: usize,
) -> Option<f32> {
let thumb = self.scrollbar_thumb(display_offset, history)?;
let x = position.x.as_f32();
let thumb_x = thumb.origin.x.as_f32();
if x < thumb_x - SCROLLBAR_SLOP || x > thumb_x + SCROLLBAR_W + SCROLLBAR_SLOP {
if !self.scrollbar_hot_zone().contains(&position) {
return None;
}
let track_top = self.origin.y.as_f32();
let track_h = self.line_height.as_f32() * self.rows as f32;
let y = position.y.as_f32();
if y < track_top || y > track_top + track_h {
return None;
}
let thumb_top = thumb.origin.y.as_f32();
let thumb_h = thumb.size.height.as_f32();
if y >= thumb_top && y <= thumb_top + thumb_h {
Expand All @@ -76,14 +99,15 @@ impl TerminalView {
/// 把拖动中的指针 y 反算回 `display_offset`——`scrollbar_thumb` 那套定位
/// 数学的逆运算(旧壳 `scrollbar_target_offset` 同合同)。
pub(super) fn scrollbar_target_offset(&self, y: f32, grab: f32, history: usize) -> usize {
if history == 0 {
let Some(thumb) = self.scrollbar_geometry(0, history) else { return 0 };
let track_top = self.origin.y.as_f32();
let travel = self.line_height.as_f32() * self.rows as f32 - thumb.size.height.as_f32();
if travel <= 0.0 {
return 0;
}
let total = (history + self.rows) as f32;
let track_top = self.origin.y.as_f32();
let track_h = (self.line_height.as_f32() * self.rows as f32).max(1.0);
let above =
((y - grab - track_top) / track_h * total).round().clamp(0.0, history as f32) as usize;
let above = ((y - grab - track_top) / travel * history as f32)
.round()
.clamp(0.0, history as f32) as usize;
history - above
}

Expand Down Expand Up @@ -445,7 +469,8 @@ impl TerminalView {
return;
}
// 滚动条是壳的控件,命中优先于选区和鼠标上报——否则在开了鼠标追踪的
// TUI 里(codex/vim)根本抓不住条。贴底时拇指为 None,正常操作零影响。
// TUI 里(codex/vim)根本抓不住条。隐藏时不占用正文的输入/选区。
self.update_scrollbar_hover(event.position, cx);
let (display_offset, history) = self.scroll_state();
if let Some(grab) = self.scrollbar_grab(event.position, display_offset, history) {
self.scrollbar_drag = Some(grab);
Expand Down Expand Up @@ -508,6 +533,7 @@ impl TerminalView {
window: &mut Window,
cx: &mut Context<Self>,
) {
self.update_scrollbar_hover(event.position, cx);
// Retain the pressed link until release; dragging must not retarget it
// or leak part of the consumed gesture to the application.
if self.pending_link_open {
Expand Down Expand Up @@ -535,6 +561,7 @@ impl TerminalView {
if let Some(grab) = self.scrollbar_drag {
if event.pressed_button != Some(MouseButton::Left) {
self.scrollbar_drag = None;
cx.notify();
return;
}
let (display_offset, history) = self.scroll_state();
Expand Down Expand Up @@ -625,6 +652,7 @@ impl TerminalView {
}
self.stop_selection_scroll();
if self.scrollbar_drag.take().is_some() {
self.update_scrollbar_hover(event.position, cx);
cx.notify();
return;
}
Expand Down Expand Up @@ -667,6 +695,7 @@ impl TerminalView {
}
self.stop_selection_scroll();
let dragging_scrollbar = self.scrollbar_drag.take().is_some();
self.scrollbar_hovered = false;
self.pending_link_open = false;
if !self.selecting {
if dragging_scrollbar {
Expand Down
Loading
Loading