From 0338bdc73601ee89772953ac6f621e298903aade Mon Sep 17 00:00:00 2001 From: des Date: Wed, 7 Oct 2026 20:32:26 +0200 Subject: [PATCH 1/3] scrollbar: Let the vertical scrollbar sit on the left side --- crates/base/src/input/base/element.rs | 54 ++++++- crates/base/src/input/base/state.rs | 18 +++ crates/base/src/scrollbar.rs | 166 +++++++++++++++++---- website/base/primitives/scrollbar.md | 12 +- website/component/editor.md | 7 + website/zh-CN/base/primitives/scrollbar.md | 4 +- website/zh-CN/component/editor.md | 6 + 7 files changed, 232 insertions(+), 35 deletions(-) diff --git a/crates/base/src/input/base/element.rs b/crates/base/src/input/base/element.rs index e01794866b..b40340a01b 100644 --- a/crates/base/src/input/base/element.rs +++ b/crates/base/src/input/base/element.rs @@ -16,7 +16,7 @@ use smallvec::SmallVec; use std::{ops::Range, rc::Rc}; use crate::{ - Scrollbar, + Scrollbar, Side, input::{RopeExt as _, blink_cursor::CURSOR_WIDTH, display_map::LineLayout}, }; @@ -138,6 +138,7 @@ pub(super) struct EditorScrollbarSnapshot { layout: EditorScrollbarLayout, cursor_scroll_offset: Point, soft_wrap: bool, + side: Side, } impl EditorScrollbarSnapshot { @@ -154,9 +155,11 @@ impl EditorScrollbarSnapshot { last_layout.line_number_width, scroll_size, state.editor_paddings, + state.scrollbar_side, ), cursor_scroll_offset, soft_wrap: state.soft_wrap, + side: state.scrollbar_side, } } } @@ -167,8 +170,11 @@ impl EditorScrollbarLayout { line_number_width: Pixels, scroll_size: Size, paddings: Edges, + side: Side, ) -> Self { - let left = if line_number_width == px(0.) { + let left = if side.is_left() { + -paddings.left + } else if line_number_width == px(0.) { px(0.) } else { paddings.left + line_number_width - LINE_NUMBER_RIGHT_MARGIN @@ -262,6 +268,7 @@ impl Element for EditorScrollbar { } else { Scrollbar::vertical(&scroll_handle) } + .side(snapshot.side) .viewport_bounds(snapshot.layout.bounds) .scroll_size(snapshot.layout.scroll_size) .into_any_element(); @@ -4811,8 +4818,13 @@ mod tests { left: px(7.), }; - let layout = - EditorScrollbarLayout::new(input_bounds, px(40.), size(px(1000.), px(200.)), paddings); + let layout = EditorScrollbarLayout::new( + input_bounds, + px(40.), + size(px(1000.), px(200.)), + paddings, + Side::Right, + ); assert_eq!( layout.bounds, @@ -4820,8 +4832,13 @@ mod tests { ); assert_eq!(layout.scroll_size, size(px(972.), px(200.))); - let layout_without_gutter = - EditorScrollbarLayout::new(input_bounds, px(0.), size(px(500.), px(120.)), paddings); + let layout_without_gutter = EditorScrollbarLayout::new( + input_bounds, + px(0.), + size(px(500.), px(120.)), + paddings, + Side::Right, + ); assert_eq!( layout_without_gutter.bounds, @@ -4830,6 +4847,31 @@ mod tests { assert_eq!(layout_without_gutter.scroll_size, size(px(513.), px(120.))); } + #[test] + fn test_editor_scrollbar_layout_on_the_left_covers_the_gutter() { + let input_bounds = Bounds::new(point(px(10.), px(20.)), size(px(300.), px(80.))); + let paddings = Edges { + top: px(2.), + right: px(3.), + bottom: px(5.), + left: px(7.), + }; + + let layout = EditorScrollbarLayout::new( + input_bounds, + px(40.), + size(px(1000.), px(200.)), + paddings, + Side::Left, + ); + + assert_eq!( + layout.bounds, + Bounds::new(point(px(3.), px(18.)), size(px(310.), px(87.))) + ); + assert_eq!(layout.scroll_size, size(px(1020.), px(200.))); + } + #[test] fn test_editor_gutter_covers_the_complete_fixed_column() { let input_bounds = Bounds::new(point(px(10.), px(20.)), size(px(300.), px(80.))); diff --git a/crates/base/src/input/base/state.rs b/crates/base/src/input/base/state.rs index 8945f981bb..8c8505f708 100644 --- a/crates/base/src/input/base/state.rs +++ b/crates/base/src/input/base/state.rs @@ -34,6 +34,7 @@ use super::{ mode::LayoutMode, undo_manager::{EditIntent, UndoManager}, }; +use crate::Side; use crate::actions::{SelectDown, SelectLeft, SelectRight, SelectUp}; use crate::input::blink_cursor::CURSOR_WIDTH; use crate::input::movement::MoveDirection; @@ -430,6 +431,7 @@ pub struct InputBaseState { /// The size of the scrollable content. pub(crate) scroll_size: gpui::Size, pub(super) editor_scrollbar_snapshot: Cell>, + pub(super) scrollbar_side: Side, /// The unwrapped width of the longest line and what it was measured for. pub(super) longest_line_width: Cell>, pub(super) editor_paddings: Edges, @@ -765,6 +767,7 @@ impl InputBaseState { scroll_handle: ScrollHandle::new(), scroll_size: gpui::size(px(0.), px(0.)), editor_scrollbar_snapshot: Cell::new(None), + scrollbar_side: Side::Right, longest_line_width: Cell::new(None), editor_paddings: Edges::default(), deferred_scroll_offset: None, @@ -10667,6 +10670,21 @@ impl InputBaseState { cx.notify(); } + /// The side of the vertical scrollbar, default is [`Side::Right`]. + /// + /// On the left, the scrollbar sits on the input's left edge, over the + /// line numbers. The horizontal scrollbar stays at the bottom. + pub fn scrollbar_side(mut self, side: Side) -> Self { + self.scrollbar_side = side; + self + } + + /// See [`InputBaseState::scrollbar_side`]. + pub fn set_scrollbar_side(&mut self, side: Side, cx: &mut Context) { + self.scrollbar_side = side; + cx.notify(); + } + /// Set the soft wrap mode, default is true. #[doc(hidden)] pub fn soft_wrap(mut self, wrap: bool) -> Self { diff --git a/crates/base/src/scrollbar.rs b/crates/base/src/scrollbar.rs index 456addb4df..86e9e21e3a 100644 --- a/crates/base/src/scrollbar.rs +++ b/crates/base/src/scrollbar.rs @@ -3,7 +3,7 @@ use std::{cell::Cell, ops::Deref, panic::Location, rc::Rc}; use instant::{Duration, Instant}; use crate::{ - AxisExt, + AxisExt, Side, animation::{ease_in_cubic, ease_out_cubic}, theme::ActiveTheme as _, }; @@ -474,10 +474,19 @@ impl VisibilityAnimation { } } -fn visibility_translation(axis: Axis, track_width: Pixels, progress: f32) -> Point { +fn visibility_translation( + axis: Axis, + side: Side, + track_width: Pixels, + progress: f32, +) -> Point { let offset = track_width * (1.0 - progress.clamp(0.0, 1.0)); if axis.is_vertical() { - point(offset, px(0.)) + if side.is_left() { + point(-offset, px(0.)) + } else { + point(offset, px(0.)) + } } else { point(px(0.), offset) } @@ -815,6 +824,7 @@ impl ScrollbarAxis { pub struct Scrollbar { pub(crate) id: ElementId, axis: ScrollbarAxis, + side: Side, mode: Option, scroll_handle: Rc, scroll_size: Option>, @@ -838,6 +848,7 @@ impl Scrollbar { Self { id: ElementId::CodeLocation(*caller), axis: ScrollbarAxis::Both, + side: Side::Right, mode: None, scroll_handle: Rc::new(scroll_handle.clone()), max_fps: 120, @@ -921,6 +932,14 @@ impl Scrollbar { self } + /// Set the side of the vertical scrollbar. + /// + /// Default is `Side::Right`. The horizontal scrollbar stays at the bottom. + pub fn side(mut self, side: Side) -> Self { + self.side = side; + self + } + pub fn styles(mut self, build: impl FnOnce(ScrollbarStyles) -> ScrollbarStyles) -> Self { self.styles = build(self.styles); self @@ -1394,7 +1413,7 @@ impl Element for Scrollbar { }; // The horizontal scrollbar is set avoid overlapping with the vertical scrollbar, if the vertical scrollbar is visible. - let margin_end = if has_both && !is_vertical { + let margin = if has_both && !is_vertical { track_width } else { px(0.) @@ -1408,10 +1427,14 @@ impl Element for Scrollbar { let bounds = Bounds { origin: if is_vertical { - point( - hitbox.origin.x + hitbox.size.width - track_width, - hitbox.origin.y, - ) + if self.side.is_left() { + hitbox.origin + } else { + point( + hitbox.origin.x + hitbox.size.width - track_width, + hitbox.origin.y, + ) + } } else { point( hitbox.origin.x, @@ -1481,20 +1504,25 @@ impl Element for Scrollbar { } else { bounds.origin.x }; + let track_origin = if !is_vertical && self.side.is_left() { + origin + margin + } else { + origin + }; let geometry = ThumbGeometry::new( - origin, + track_origin, container_size, scroll_area_size, - margin_end, + margin, inset, min_length, ); let (_, _, _, _, active_inset, _, active_min_length) = self.style_for_active(cx); let active_geometry = ThumbGeometry::new( - origin, + track_origin, container_size, scroll_area_size, - margin_end, + margin, active_inset, active_min_length, ); @@ -1502,7 +1530,13 @@ impl Element for Scrollbar { // The clickable area of the thumb let thumb_length = geometry.length; - let thumb_bounds = if is_vertical { + let thumb_bounds = if is_vertical && self.side.is_left() { + Bounds::from_anchor_and_size( + Anchor::TopLeft, + bounds.origin + point(inset, thumb_start), + size(track_width, thumb_length), + ) + } else if is_vertical { Bounds::from_anchor_and_size( Anchor::TopRight, bounds.top_right() + point(-inset, thumb_start), @@ -1517,7 +1551,13 @@ impl Element for Scrollbar { }; // The actual render area of the thumb - let thumb_fill_bounds = if is_vertical { + let thumb_fill_bounds = if is_vertical && self.side.is_left() { + Bounds::from_anchor_and_size( + Anchor::TopLeft, + bounds.origin + point(inset, thumb_start), + size(thumb_width, thumb_length), + ) + } else if is_vertical { Bounds::from_anchor_and_size( Anchor::TopRight, bounds.top_right() + point(-inset, thumb_start), @@ -1597,8 +1637,12 @@ impl Element for Scrollbar { let is_vertical = axis.is_vertical(); let visibility_opacity = state.visibility_opacity; let is_visible = state.visibility_requested || visibility_opacity > 0.0; - let translation = - visibility_translation(axis, state.track_width, state.visibility_position); + let translation = visibility_translation( + axis, + self.side, + state.track_width, + state.visibility_position, + ); let painted_bounds = state.bounds + translation; let painted_thumb_bounds = state.thumb_fill_bounds + translation; let painted_track_bg = state.bg.opacity(visibility_opacity); @@ -2002,7 +2046,7 @@ mod tests { assert_eq!(shown.position, 1.0); assert!(!shown.running, "a motionless theme must request no frames"); assert_eq!( - visibility_translation(Axis::Vertical, px(16.), shown.position), + visibility_translation(Axis::Vertical, Side::Right, px(16.), shown.position), Point::default() ); @@ -2073,15 +2117,19 @@ mod tests { #[test] fn visibility_translation_moves_toward_the_nearest_edge() { assert_eq!( - visibility_translation(Axis::Vertical, px(16.), 0.0), + visibility_translation(Axis::Vertical, Side::Right, px(16.), 0.0), point(px(16.), px(0.)) ); assert_eq!( - visibility_translation(Axis::Horizontal, px(16.), 0.0), + visibility_translation(Axis::Vertical, Side::Left, px(16.), 0.0), + point(px(-16.), px(0.)) + ); + assert_eq!( + visibility_translation(Axis::Horizontal, Side::Left, px(16.), 0.0), point(px(0.), px(16.)) ); assert_eq!( - visibility_translation(Axis::Vertical, px(16.), 1.0), + visibility_translation(Axis::Vertical, Side::Right, px(16.), 1.0), Point::default() ); } @@ -2255,15 +2303,18 @@ mod tests { struct ScrollbarHarness { handle: TestHandle, axis: ScrollbarAxis, + side: Side, mode: ScrollbarMode, } impl Render for ScrollbarHarness { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - div() - .relative() - .size(px(100.)) - .child(Scrollbar::new(&self.handle).axis(self.axis).mode(self.mode)) + div().relative().size(px(100.)).child( + Scrollbar::new(&self.handle) + .axis(self.axis) + .side(self.side) + .mode(self.mode), + ) } } @@ -2272,11 +2323,26 @@ mod tests { axis: ScrollbarAxis, mode: ScrollbarMode, content_size: Size, + ) -> (&mut VisualTestContext, TestHandle) { + harness_on_side(cx, axis, Side::Right, mode, content_size) + } + + fn harness_on_side( + cx: &mut TestAppContext, + axis: ScrollbarAxis, + side: Side, + mode: ScrollbarMode, + content_size: Size, ) -> (&mut VisualTestContext, TestHandle) { let handle = TestHandle::new(content_size); let (_, cx) = cx.add_window_view({ let handle = handle.clone(); - move |_, _| ScrollbarHarness { handle, axis, mode } + move |_, _| ScrollbarHarness { + handle, + axis, + side, + mode, + } }); cx.update(|window, cx| window.draw(cx).clear(cx)); (cx, handle) @@ -2449,6 +2515,55 @@ mod tests { assert_eq!(vertical.offset().x, px(0.)); } + #[gpui::test] + fn left_side_puts_the_vertical_track_and_thumb_on_the_left_edge(cx: &mut TestAppContext) { + let (cx, handle) = harness_on_side( + cx, + ScrollbarAxis::Vertical, + Side::Left, + ScrollbarMode::Always, + size(px(100.), px(500.)), + ); + cx.simulate_click(point(px(95.), px(80.)), Modifiers::default()); + assert_eq!(handle.offset(), Point::default()); + + cx.simulate_mouse_down( + point(px(14.), px(20.)), + MouseButton::Left, + Modifiers::default(), + ); + assert_eq!(handle.drag_starts.get(), 1); + assert_eq!(handle.offset(), Point::default()); + cx.simulate_mouse_up( + point(px(14.), px(20.)), + MouseButton::Left, + Modifiers::default(), + ); + + cx.simulate_click(point(px(5.), px(80.)), Modifiers::default()); + assert!(handle.offset().y < px(0.)); + assert_eq!(handle.offset().x, px(0.)); + } + + #[gpui::test] + fn left_side_starts_the_horizontal_track_after_the_vertical_one(cx: &mut TestAppContext) { + let (cx, handle) = harness_on_side( + cx, + ScrollbarAxis::Both, + Side::Left, + ScrollbarMode::Always, + size(px(500.), px(500.)), + ); + // The 48 px thumb, inset by 4 px, covers 50 px only when the track + // begins after the 16 px vertical track. + cx.simulate_click(point(px(50.), px(95.)), Modifiers::default()); + assert_eq!(handle.drag_starts.get(), 1); + assert_eq!(handle.offset(), Point::default()); + + cx.simulate_click(point(px(99.), px(95.)), Modifiers::default()); + assert_eq!(handle.offset(), point(px(-400.), px(0.))); + } + #[gpui::test] fn horizontal_track_click_updates_horizontal_offset(cx: &mut TestAppContext) { let (cx, horizontal) = harness( @@ -2539,6 +2654,7 @@ mod tests { move |_, _| ScrollbarHarness { handle, axis: ScrollbarAxis::Vertical, + side: Side::Right, mode: ScrollbarMode::Always, } }); diff --git a/website/base/primitives/scrollbar.md b/website/base/primitives/scrollbar.md index 655b6cdadc..2737fe07f2 100644 --- a/website/base/primitives/scrollbar.md +++ b/website/base/primitives/scrollbar.md @@ -33,7 +33,7 @@ use std::time::Duration; use gpui_kit::{div, px, rgb, ScrollHandle, Styled as _}; use gpui_kit::base::{ Scrollbar, ScrollbarAxis, ScrollbarEntrance, ScrollbarMode, - ScrollbarMotion, ScrollbarStyles, ScrollbarTheme, Theme, + ScrollbarMotion, ScrollbarStyles, ScrollbarTheme, Side, Theme, }; ``` @@ -79,6 +79,14 @@ Scrollbar::horizontal(&scroll_handle); Scrollbar::new(&scroll_handle).axis(ScrollbarAxis::Vertical); ``` +The vertical scrollbar sits on the right edge by default. Use `side` to move it +to the left edge, for example in the left pane of a side-by-side view. The +horizontal scrollbar stays at the bottom and starts after the vertical one: + +```rust +Scrollbar::new(&scroll_handle).side(Side::Left); +``` + The scrollbar is an absolute overlay. Its layout and hitboxes stay fixed while the painted track and thumb animate, so entrance motion does not move content or change the interaction geometry. @@ -182,7 +190,7 @@ The example theme above produces this choreography: | Thumb hover in `Hover` mode | `thumb_hover_entrance`: slide from the nearest edge while fading | | `Always` mode | Immediate; visibility motion is skipped | -For `SlideAndFade`, a vertical scrollbar enters from the right and a horizontal +For `SlideAndFade`, a vertical scrollbar enters from its side and a horizontal scrollbar enters from the bottom. Opacity uses linear entrance progress; position uses cubic ease-out. Exit opacity and position use cubic ease-in. diff --git a/website/component/editor.md b/website/component/editor.md index cd5322d464..2b1f94ae59 100644 --- a/website/component/editor.md +++ b/website/component/editor.md @@ -118,6 +118,13 @@ let editor = cx.new(|cx| { }); ``` +The vertical scrollbar sits on the right edge by default. Use `scrollbar_side` +to put it on the editor's left edge, over the line numbers: + +```rust +EditorState::new(window, cx).scrollbar_side(Side::Left) +``` + ## Keyboard shortcuts and column selection These defaults apply while the editor is focused. On macOS, Option is the Alt diff --git a/website/zh-CN/base/primitives/scrollbar.md b/website/zh-CN/base/primitives/scrollbar.md index 56e0a9bb6d..47e27a8193 100644 --- a/website/zh-CN/base/primitives/scrollbar.md +++ b/website/zh-CN/base/primitives/scrollbar.md @@ -18,7 +18,7 @@ cargo run -p gpui-base-examples -- scrollbar ## 基本用法 -把 `ScrollHandle` 保存在持久视图状态中,通过 `track_scroll` 连接可滚动内容,并在同一个 `relative()` 容器中叠加 `Scrollbar`。`Scrollbar::new` 启用双轴;单轴使用 `vertical`、`horizontal` 或 `.axis(...)`。滚动条是绝对定位覆盖层,进入动画不会移动内容或命中区域。 +把 `ScrollHandle` 保存在持久视图状态中,通过 `track_scroll` 连接可滚动内容,并在同一个 `relative()` 容器中叠加 `Scrollbar`。`Scrollbar::new` 启用双轴;单轴使用 `vertical`、`horizontal` 或 `.axis(...)`。纵向滚动条默认位于右侧,`.side(Side::Left)` 可将其放到左侧,例如并排视图的左侧窗格;横向滚动条仍在底部,并从纵向滚动条之后开始。滚动条是绝对定位覆盖层,进入动画不会移动内容或命中区域。 ## 可见模式 @@ -34,7 +34,7 @@ cargo run -p gpui-base-examples -- scrollbar ## 动画行为 -Base 不附带产品动画。默认仅有 2 秒行为性空闲等待,进入、退出和展开时长均为零。`Fade` 原地淡入;`SlideAndFade` 让纵向滚动条从右侧、横向滚动条从底部进入。被中断的过渡从当前视觉值反向;零时长立即采用目标值。GPUI 的减少动态效果偏好也会把可见性和宽度时长降为零。 +Base 不附带产品动画。默认仅有 2 秒行为性空闲等待,进入、退出和展开时长均为零。`Fade` 原地淡入;`SlideAndFade` 让纵向滚动条从其所在一侧、横向滚动条从底部进入。被中断的过渡从当前视觉值反向;零时长立即采用目标值。GPUI 的减少动态效果偏好也会把可见性和宽度时长降为零。 ## 自定义视口与句柄 diff --git a/website/zh-CN/component/editor.md b/website/zh-CN/component/editor.md index 8fb0579c7c..0a0c300ffe 100644 --- a/website/zh-CN/component/editor.md +++ b/website/zh-CN/component/editor.md @@ -104,6 +104,12 @@ let editor = cx.new(|cx| { }); ``` +纵向滚动条默认位于右侧。使用 `scrollbar_side` 可将其放到编辑器左边缘,覆盖在行号之上: + +```rust +EditorState::new(window, cx).scrollbar_side(Side::Left) +``` + ## 快捷键与矩形列选 以下默认快捷键在编辑器聚焦时生效。macOS 的 Option 对应 Alt 修饰键;Linux 的这些操作不使用 Super/Win。 From 14ef7932c5579c74741377ac86b2dccd230a7d5a Mon Sep 17 00:00:00 2001 From: des Date: Fri, 9 Oct 2026 14:00:09 +0200 Subject: [PATCH 2/3] scrollbar: Place both axes with ScrollbarPlacement Replace `Scrollbar::side(Side)` with `Scrollbar::placement(ScrollbarPlacement)`, which places both axes at once: `BottomRight` (the default), `BottomLeft`, `TopRight` and `TopLeft`. A horizontal scrollbar at the top gets its track, thumb and hitbox on the top edge and slides in from the top; when both scrollbars show, the horizontal track gives way at the vertical scrollbar's end. The editor's `scrollbar_side` and `set_scrollbar_side` become `scrollbar_placement` and `set_scrollbar_placement`. --- crates/base/src/input/base/element.rs | 30 ++- crates/base/src/input/base/state.rs | 27 +- crates/base/src/lib.rs | 2 +- crates/base/src/scrollbar.rs | 285 ++++++++++++++------ crates/component/src/scroll/mod.rs | 2 +- crates/story/src/stories/scrollbar_story.rs | 64 ++++- website/base/primitives/scrollbar.md | 24 +- website/component/editor.md | 9 +- website/zh-CN/base/primitives/scrollbar.md | 21 +- website/zh-CN/component/editor.md | 4 +- 10 files changed, 351 insertions(+), 117 deletions(-) diff --git a/crates/base/src/input/base/element.rs b/crates/base/src/input/base/element.rs index b40340a01b..012092b775 100644 --- a/crates/base/src/input/base/element.rs +++ b/crates/base/src/input/base/element.rs @@ -16,7 +16,7 @@ use smallvec::SmallVec; use std::{ops::Range, rc::Rc}; use crate::{ - Scrollbar, Side, + Scrollbar, ScrollbarPlacement, input::{RopeExt as _, blink_cursor::CURSOR_WIDTH, display_map::LineLayout}, }; @@ -138,7 +138,7 @@ pub(super) struct EditorScrollbarSnapshot { layout: EditorScrollbarLayout, cursor_scroll_offset: Point, soft_wrap: bool, - side: Side, + placement: ScrollbarPlacement, } impl EditorScrollbarSnapshot { @@ -155,11 +155,11 @@ impl EditorScrollbarSnapshot { last_layout.line_number_width, scroll_size, state.editor_paddings, - state.scrollbar_side, + state.scrollbar_placement, ), cursor_scroll_offset, soft_wrap: state.soft_wrap, - side: state.scrollbar_side, + placement: state.scrollbar_placement, } } } @@ -170,9 +170,9 @@ impl EditorScrollbarLayout { line_number_width: Pixels, scroll_size: Size, paddings: Edges, - side: Side, + placement: ScrollbarPlacement, ) -> Self { - let left = if side.is_left() { + let left = if placement.is_left() { -paddings.left } else if line_number_width == px(0.) { px(0.) @@ -268,7 +268,7 @@ impl Element for EditorScrollbar { } else { Scrollbar::vertical(&scroll_handle) } - .side(snapshot.side) + .placement(snapshot.placement) .viewport_bounds(snapshot.layout.bounds) .scroll_size(snapshot.layout.scroll_size) .into_any_element(); @@ -4823,7 +4823,7 @@ mod tests { px(40.), size(px(1000.), px(200.)), paddings, - Side::Right, + ScrollbarPlacement::BottomRight, ); assert_eq!( @@ -4837,7 +4837,7 @@ mod tests { px(0.), size(px(500.), px(120.)), paddings, - Side::Right, + ScrollbarPlacement::BottomRight, ); assert_eq!( @@ -4862,7 +4862,7 @@ mod tests { px(40.), size(px(1000.), px(200.)), paddings, - Side::Left, + ScrollbarPlacement::BottomLeft, ); assert_eq!( @@ -4870,6 +4870,16 @@ mod tests { Bounds::new(point(px(3.), px(18.)), size(px(310.), px(87.))) ); assert_eq!(layout.scroll_size, size(px(1020.), px(200.))); + assert_eq!( + EditorScrollbarLayout::new( + input_bounds, + px(40.), + size(px(1000.), px(200.)), + paddings, + ScrollbarPlacement::TopLeft, + ), + layout + ); } #[test] diff --git a/crates/base/src/input/base/state.rs b/crates/base/src/input/base/state.rs index 8c8505f708..7ad3cf0e38 100644 --- a/crates/base/src/input/base/state.rs +++ b/crates/base/src/input/base/state.rs @@ -34,7 +34,7 @@ use super::{ mode::LayoutMode, undo_manager::{EditIntent, UndoManager}, }; -use crate::Side; +use crate::ScrollbarPlacement; use crate::actions::{SelectDown, SelectLeft, SelectRight, SelectUp}; use crate::input::blink_cursor::CURSOR_WIDTH; use crate::input::movement::MoveDirection; @@ -431,7 +431,7 @@ pub struct InputBaseState { /// The size of the scrollable content. pub(crate) scroll_size: gpui::Size, pub(super) editor_scrollbar_snapshot: Cell>, - pub(super) scrollbar_side: Side, + pub(super) scrollbar_placement: ScrollbarPlacement, /// The unwrapped width of the longest line and what it was measured for. pub(super) longest_line_width: Cell>, pub(super) editor_paddings: Edges, @@ -767,7 +767,7 @@ impl InputBaseState { scroll_handle: ScrollHandle::new(), scroll_size: gpui::size(px(0.), px(0.)), editor_scrollbar_snapshot: Cell::new(None), - scrollbar_side: Side::Right, + scrollbar_placement: ScrollbarPlacement::default(), longest_line_width: Cell::new(None), editor_paddings: Edges::default(), deferred_scroll_offset: None, @@ -10670,18 +10670,23 @@ impl InputBaseState { cx.notify(); } - /// The side of the vertical scrollbar, default is [`Side::Right`]. + /// The placement of the scrollbars, default is [`ScrollbarPlacement::BottomRight`]. /// - /// On the left, the scrollbar sits on the input's left edge, over the - /// line numbers. The horizontal scrollbar stays at the bottom. - pub fn scrollbar_side(mut self, side: Side) -> Self { - self.scrollbar_side = side; + /// On the left, the vertical scrollbar sits on the input's left edge, over + /// the line numbers. At the top, the horizontal scrollbar overlays the + /// first line, as at the bottom it overlays the last. + pub fn scrollbar_placement(mut self, placement: ScrollbarPlacement) -> Self { + self.scrollbar_placement = placement; self } - /// See [`InputBaseState::scrollbar_side`]. - pub fn set_scrollbar_side(&mut self, side: Side, cx: &mut Context) { - self.scrollbar_side = side; + /// See [`InputBaseState::scrollbar_placement`]. + pub fn set_scrollbar_placement( + &mut self, + placement: ScrollbarPlacement, + cx: &mut Context, + ) { + self.scrollbar_placement = placement; cx.notify(); } diff --git a/crates/base/src/lib.rs b/crates/base/src/lib.rs index 1978efc4ab..3616b298b8 100644 --- a/crates/base/src/lib.rs +++ b/crates/base/src/lib.rs @@ -161,7 +161,7 @@ pub use scroll_bounce::{ScrollBounce, ScrollBounceMotion}; pub use scrollable_mask::ScrollableMask; pub use scrollbar::{ Scrollbar, ScrollbarAxis, ScrollbarEntrance, ScrollbarHandle, ScrollbarMode, ScrollbarMotion, - ScrollbarStyles, ScrollbarThumbStyle, ScrollbarTrackStyle, + ScrollbarPlacement, ScrollbarStyles, ScrollbarThumbStyle, ScrollbarTrackStyle, }; pub use select::Select; pub use selectable_text::SelectableText; diff --git a/crates/base/src/scrollbar.rs b/crates/base/src/scrollbar.rs index 86e9e21e3a..da436b1b2e 100644 --- a/crates/base/src/scrollbar.rs +++ b/crates/base/src/scrollbar.rs @@ -3,7 +3,7 @@ use std::{cell::Cell, ops::Deref, panic::Location, rc::Rc}; use instant::{Duration, Instant}; use crate::{ - AxisExt, Side, + AxisExt, animation::{ease_in_cubic, ease_out_cubic}, theme::ActiveTheme as _, }; @@ -476,17 +476,19 @@ impl VisibilityAnimation { fn visibility_translation( axis: Axis, - side: Side, + placement: ScrollbarPlacement, track_width: Pixels, progress: f32, ) -> Point { let offset = track_width * (1.0 - progress.clamp(0.0, 1.0)); if axis.is_vertical() { - if side.is_left() { + if placement.is_left() { point(-offset, px(0.)) } else { point(offset, px(0.)) } + } else if placement.is_top() { + point(px(0.), -offset) } else { point(px(0.), offset) } @@ -645,6 +647,36 @@ pub enum ScrollbarAxis { Both, } +/// Where the scrollbars sit in the viewport. +/// +/// A single-axis scrollbar uses only the part of the placement for its axis. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ScrollbarPlacement { + /// Vertical scrollbar on the right, horizontal scrollbar at the bottom. + #[default] + BottomRight, + /// Vertical scrollbar on the left, horizontal scrollbar at the bottom. + BottomLeft, + /// Vertical scrollbar on the right, horizontal scrollbar at the top. + TopRight, + /// Vertical scrollbar on the left, horizontal scrollbar at the top. + TopLeft, +} + +impl ScrollbarPlacement { + /// Return true if the vertical scrollbar is on the left. + #[inline] + pub fn is_left(&self) -> bool { + matches!(self, Self::BottomLeft | Self::TopLeft) + } + + /// Return true if the horizontal scrollbar is at the top. + #[inline] + pub fn is_top(&self) -> bool { + matches!(self, Self::TopRight | Self::TopLeft) + } +} + /// Paint-only styles for a scrollbar track. #[derive(Clone, Default)] pub struct ScrollbarTrackStyle { @@ -824,7 +856,7 @@ impl ScrollbarAxis { pub struct Scrollbar { pub(crate) id: ElementId, axis: ScrollbarAxis, - side: Side, + placement: ScrollbarPlacement, mode: Option, scroll_handle: Rc, scroll_size: Option>, @@ -848,7 +880,7 @@ impl Scrollbar { Self { id: ElementId::CodeLocation(*caller), axis: ScrollbarAxis::Both, - side: Side::Right, + placement: ScrollbarPlacement::default(), mode: None, scroll_handle: Rc::new(scroll_handle.clone()), max_fps: 120, @@ -932,11 +964,12 @@ impl Scrollbar { self } - /// Set the side of the vertical scrollbar. + /// Set the scrollbar placement, default is [`ScrollbarPlacement::BottomRight`]. /// - /// Default is `Side::Right`. The horizontal scrollbar stays at the bottom. - pub fn side(mut self, side: Side) -> Self { - self.side = side; + /// When both scrollbars are shown, the horizontal track ends before the + /// vertical one, on the vertical scrollbar's side. + pub fn placement(mut self, placement: ScrollbarPlacement) -> Self { + self.placement = placement; self } @@ -1427,7 +1460,7 @@ impl Element for Scrollbar { let bounds = Bounds { origin: if is_vertical { - if self.side.is_left() { + if self.placement.is_left() { hitbox.origin } else { point( @@ -1435,6 +1468,8 @@ impl Element for Scrollbar { hitbox.origin.y, ) } + } else if self.placement.is_top() { + hitbox.origin } else { point( hitbox.origin.x, @@ -1504,7 +1539,7 @@ impl Element for Scrollbar { } else { bounds.origin.x }; - let track_origin = if !is_vertical && self.side.is_left() { + let track_origin = if !is_vertical && self.placement.is_left() { origin + margin } else { origin @@ -1528,48 +1563,39 @@ impl Element for Scrollbar { ); let thumb_start = geometry.start(scroll_position) - origin; - // The clickable area of the thumb let thumb_length = geometry.length; - let thumb_bounds = if is_vertical && self.side.is_left() { - Bounds::from_anchor_and_size( - Anchor::TopLeft, - bounds.origin + point(inset, thumb_start), - size(track_width, thumb_length), - ) - } else if is_vertical { - Bounds::from_anchor_and_size( - Anchor::TopRight, - bounds.top_right() + point(-inset, thumb_start), - size(track_width, thumb_length), - ) + let (thumb_anchor, thumb_origin) = if is_vertical { + if self.placement.is_left() { + (Anchor::TopLeft, bounds.origin + point(inset, thumb_start)) + } else { + ( + Anchor::TopRight, + bounds.top_right() + point(-inset, thumb_start), + ) + } + } else if self.placement.is_top() { + (Anchor::TopLeft, bounds.origin + point(thumb_start, inset)) } else { - Bounds::from_anchor_and_size( + ( Anchor::BottomLeft, bounds.bottom_left() + point(thumb_start, -inset), - size(thumb_length, track_width), ) }; + let thumb_size = |width: Pixels| { + if is_vertical { + size(width, thumb_length) + } else { + size(thumb_length, width) + } + }; + + // The clickable area of the thumb + let thumb_bounds = + Bounds::from_anchor_and_size(thumb_anchor, thumb_origin, thumb_size(track_width)); // The actual render area of the thumb - let thumb_fill_bounds = if is_vertical && self.side.is_left() { - Bounds::from_anchor_and_size( - Anchor::TopLeft, - bounds.origin + point(inset, thumb_start), - size(thumb_width, thumb_length), - ) - } else if is_vertical { - Bounds::from_anchor_and_size( - Anchor::TopRight, - bounds.top_right() + point(-inset, thumb_start), - size(thumb_width, thumb_length), - ) - } else { - Bounds::from_anchor_and_size( - Anchor::BottomLeft, - bounds.bottom_left() + point(thumb_start, -inset), - size(thumb_length, thumb_width), - ) - }; + let thumb_fill_bounds = + Bounds::from_anchor_and_size(thumb_anchor, thumb_origin, thumb_size(thumb_width)); let bar_hitbox = window.with_content_mask(Some(ContentMask { bounds }), |window| { window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal) @@ -1639,7 +1665,7 @@ impl Element for Scrollbar { let is_visible = state.visibility_requested || visibility_opacity > 0.0; let translation = visibility_translation( axis, - self.side, + self.placement, state.track_width, state.visibility_position, ); @@ -2046,7 +2072,12 @@ mod tests { assert_eq!(shown.position, 1.0); assert!(!shown.running, "a motionless theme must request no frames"); assert_eq!( - visibility_translation(Axis::Vertical, Side::Right, px(16.), shown.position), + visibility_translation( + Axis::Vertical, + ScrollbarPlacement::BottomRight, + px(16.), + shown.position + ), Point::default() ); @@ -2116,22 +2147,29 @@ mod tests { #[test] fn visibility_translation_moves_toward_the_nearest_edge() { - assert_eq!( - visibility_translation(Axis::Vertical, Side::Right, px(16.), 0.0), - point(px(16.), px(0.)) - ); - assert_eq!( - visibility_translation(Axis::Vertical, Side::Left, px(16.), 0.0), - point(px(-16.), px(0.)) - ); - assert_eq!( - visibility_translation(Axis::Horizontal, Side::Left, px(16.), 0.0), - point(px(0.), px(16.)) - ); - assert_eq!( - visibility_translation(Axis::Vertical, Side::Right, px(16.), 1.0), - Point::default() - ); + for (placement, vertical, horizontal) in [ + (ScrollbarPlacement::BottomRight, px(16.), px(16.)), + (ScrollbarPlacement::BottomLeft, px(-16.), px(16.)), + (ScrollbarPlacement::TopRight, px(16.), px(-16.)), + (ScrollbarPlacement::TopLeft, px(-16.), px(-16.)), + ] { + assert_eq!( + visibility_translation(Axis::Vertical, placement, px(16.), 0.0), + point(vertical, px(0.)) + ); + assert_eq!( + visibility_translation(Axis::Horizontal, placement, px(16.), 0.0), + point(px(0.), horizontal) + ); + assert_eq!( + visibility_translation(Axis::Vertical, placement, px(16.), 1.0), + Point::default() + ); + assert_eq!( + visibility_translation(Axis::Horizontal, placement, px(16.), 1.0), + Point::default() + ); + } } #[test] @@ -2303,7 +2341,7 @@ mod tests { struct ScrollbarHarness { handle: TestHandle, axis: ScrollbarAxis, - side: Side, + placement: ScrollbarPlacement, mode: ScrollbarMode, } @@ -2312,7 +2350,7 @@ mod tests { div().relative().size(px(100.)).child( Scrollbar::new(&self.handle) .axis(self.axis) - .side(self.side) + .placement(self.placement) .mode(self.mode), ) } @@ -2324,13 +2362,13 @@ mod tests { mode: ScrollbarMode, content_size: Size, ) -> (&mut VisualTestContext, TestHandle) { - harness_on_side(cx, axis, Side::Right, mode, content_size) + harness_with_placement(cx, axis, ScrollbarPlacement::default(), mode, content_size) } - fn harness_on_side( + fn harness_with_placement( cx: &mut TestAppContext, axis: ScrollbarAxis, - side: Side, + placement: ScrollbarPlacement, mode: ScrollbarMode, content_size: Size, ) -> (&mut VisualTestContext, TestHandle) { @@ -2340,7 +2378,7 @@ mod tests { move |_, _| ScrollbarHarness { handle, axis, - side, + placement, mode, } }); @@ -2516,11 +2554,11 @@ mod tests { } #[gpui::test] - fn left_side_puts_the_vertical_track_and_thumb_on_the_left_edge(cx: &mut TestAppContext) { - let (cx, handle) = harness_on_side( + fn left_placement_puts_the_vertical_track_and_thumb_on_the_left_edge(cx: &mut TestAppContext) { + let (cx, handle) = harness_with_placement( cx, ScrollbarAxis::Vertical, - Side::Left, + ScrollbarPlacement::BottomLeft, ScrollbarMode::Always, size(px(100.), px(500.)), ); @@ -2534,23 +2572,29 @@ mod tests { ); assert_eq!(handle.drag_starts.get(), 1); assert_eq!(handle.offset(), Point::default()); + // Half of the 52 px travel scrolls half of the 400 px extent. + cx.simulate_mouse_move( + point(px(14.), px(46.)), + Some(MouseButton::Left), + Modifiers::default(), + ); cx.simulate_mouse_up( - point(px(14.), px(20.)), + point(px(14.), px(46.)), MouseButton::Left, Modifiers::default(), ); + assert_eq!(handle.offset(), point(px(0.), px(-200.))); cx.simulate_click(point(px(5.), px(80.)), Modifiers::default()); - assert!(handle.offset().y < px(0.)); - assert_eq!(handle.offset().x, px(0.)); + assert_eq!(handle.offset(), point(px(0.), px(-400.))); } #[gpui::test] - fn left_side_starts_the_horizontal_track_after_the_vertical_one(cx: &mut TestAppContext) { - let (cx, handle) = harness_on_side( + fn left_placement_starts_the_horizontal_track_after_the_vertical_one(cx: &mut TestAppContext) { + let (cx, handle) = harness_with_placement( cx, ScrollbarAxis::Both, - Side::Left, + ScrollbarPlacement::BottomLeft, ScrollbarMode::Always, size(px(500.), px(500.)), ); @@ -2564,6 +2608,89 @@ mod tests { assert_eq!(handle.offset(), point(px(-400.), px(0.))); } + #[gpui::test] + fn top_placement_puts_the_horizontal_track_and_thumb_on_the_top_edge(cx: &mut TestAppContext) { + let (cx, handle) = harness_with_placement( + cx, + ScrollbarAxis::Horizontal, + ScrollbarPlacement::TopRight, + ScrollbarMode::Always, + size(px(500.), px(100.)), + ); + cx.simulate_click(point(px(80.), px(95.)), Modifiers::default()); + assert_eq!(handle.offset(), Point::default()); + + cx.simulate_mouse_down( + point(px(20.), px(14.)), + MouseButton::Left, + Modifiers::default(), + ); + assert_eq!(handle.drag_starts.get(), 1); + assert_eq!(handle.offset(), Point::default()); + // Half of the 52 px travel scrolls half of the 400 px extent. + cx.simulate_mouse_move( + point(px(46.), px(14.)), + Some(MouseButton::Left), + Modifiers::default(), + ); + cx.simulate_mouse_up( + point(px(46.), px(14.)), + MouseButton::Left, + Modifiers::default(), + ); + assert_eq!(handle.offset(), point(px(-200.), px(0.))); + + cx.simulate_click(point(px(90.), px(5.)), Modifiers::default()); + assert_eq!(handle.offset(), point(px(-400.), px(0.))); + } + + #[gpui::test] + fn top_right_placement_ends_the_horizontal_track_before_the_vertical_one( + cx: &mut TestAppContext, + ) { + let (cx, handle) = harness_with_placement( + cx, + ScrollbarAxis::Both, + ScrollbarPlacement::TopRight, + ScrollbarMode::Always, + size(px(500.), px(500.)), + ); + // The thumb's centre reaches the end at 60 px only when the track + // ends before the 16 px vertical track. + cx.simulate_click(point(px(60.), px(5.)), Modifiers::default()); + assert_eq!(handle.offset(), point(px(-400.), px(0.))); + + // The vertical track keeps the full height and the corner. + cx.simulate_click(point(px(95.), px(90.)), Modifiers::default()); + assert_eq!(handle.offset(), point(px(-400.), px(-400.))); + cx.simulate_click(point(px(95.), px(5.)), Modifiers::default()); + assert_eq!(handle.offset(), point(px(-400.), px(0.))); + } + + #[gpui::test] + fn top_left_placement_starts_the_horizontal_track_after_the_vertical_one( + cx: &mut TestAppContext, + ) { + let (cx, handle) = harness_with_placement( + cx, + ScrollbarAxis::Both, + ScrollbarPlacement::TopLeft, + ScrollbarMode::Always, + size(px(500.), px(500.)), + ); + // The 48 px thumb, inset by 4 px, covers 50 px only when the track + // begins after the 16 px vertical track. + cx.simulate_click(point(px(50.), px(5.)), Modifiers::default()); + assert_eq!(handle.drag_starts.get(), 1); + assert_eq!(handle.offset(), Point::default()); + + cx.simulate_click(point(px(99.), px(5.)), Modifiers::default()); + assert_eq!(handle.offset(), point(px(-400.), px(0.))); + + cx.simulate_click(point(px(5.), px(95.)), Modifiers::default()); + assert_eq!(handle.offset(), point(px(-400.), px(-400.))); + } + #[gpui::test] fn horizontal_track_click_updates_horizontal_offset(cx: &mut TestAppContext) { let (cx, horizontal) = harness( @@ -2654,7 +2781,7 @@ mod tests { move |_, _| ScrollbarHarness { handle, axis: ScrollbarAxis::Vertical, - side: Side::Right, + placement: ScrollbarPlacement::default(), mode: ScrollbarMode::Always, } }); diff --git a/crates/component/src/scroll/mod.rs b/crates/component/src/scroll/mod.rs index 83df6417f1..93c197a102 100644 --- a/crates/component/src/scroll/mod.rs +++ b/crates/component/src/scroll/mod.rs @@ -4,7 +4,7 @@ pub use gpui_base::AutoScroll; pub use gpui_base::ScrollableMask; pub use gpui_base::{ Scrollbar, ScrollbarAxis, ScrollbarEntrance, ScrollbarHandle, ScrollbarMode, ScrollbarMotion, - ScrollbarStyles, ScrollbarThumbStyle, ScrollbarTrackStyle, + ScrollbarPlacement, ScrollbarStyles, ScrollbarThumbStyle, ScrollbarTrackStyle, }; pub use scrollable::*; diff --git a/crates/story/src/stories/scrollbar_story.rs b/crates/story/src/stories/scrollbar_story.rs index 11be76be61..df91ad8a01 100644 --- a/crates/story/src/stories/scrollbar_story.rs +++ b/crates/story/src/stories/scrollbar_story.rs @@ -1,6 +1,11 @@ use std::rc::Rc; -use gpui_kit::component::{ActiveTheme as _, button::Button, scroll::ScrollableElement, v_flex}; +use gpui_kit::component::{ + ActiveTheme as _, + button::Button, + scroll::{ScrollableElement, Scrollbar, ScrollbarPlacement}, + v_flex, +}; use gpui_kit::*; use serde::Deserialize; @@ -17,9 +22,16 @@ pub struct ScrollbarStory { test_width: Pixels, size_mode: usize, scroll_handle: UniformListScrollHandle, + placement_scroll_handles: [ScrollHandle; 4], } const ITEM_HEIGHT: Pixels = px(50.); +const PLACEMENTS: [ScrollbarPlacement; 4] = [ + ScrollbarPlacement::TopLeft, + ScrollbarPlacement::TopRight, + ScrollbarPlacement::BottomLeft, + ScrollbarPlacement::BottomRight, +]; impl ScrollbarStory { fn new(_: &mut Window, cx: &mut Context) -> Self { @@ -37,6 +49,7 @@ impl ScrollbarStory { test_width, size_mode: 0, scroll_handle: UniformListScrollHandle::new(), + placement_scroll_handles: std::array::from_fn(|_| ScrollHandle::new()), } } @@ -68,6 +81,48 @@ impl ScrollbarStory { .into(); cx.notify(); } + + fn render_placement(&self, ix: usize, cx: &App) -> impl IntoElement { + let placement = PLACEMENTS[ix]; + let scroll_handle = &self.placement_scroll_handles[ix]; + + v_flex() + .gap_1() + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(format!("{:?}", placement)), + ) + .child( + div() + .id(("placement", ix)) + .relative() + .h(px(160.)) + .border_1() + .border_color(cx.theme().border) + .overflow_scroll() + .track_scroll(scroll_handle) + .child( + v_flex() + .w(px(900.)) + .p_3() + .gap_2() + .text_sm() + .children((1..=20).map(|row| { + div() + .p_2() + .bg(cx.theme().secondary) + .child(format!("Row {}", row)) + })), + ) + .child( + Scrollbar::new(scroll_handle) + .id(("placement-scrollbar", ix)) + .placement(placement), + ), + ) + } } impl super::Story for ScrollbarStory { @@ -123,6 +178,13 @@ impl Render for ScrollbarStory { } }, )) + .child( + div() + .grid() + .grid_cols(2) + .gap_4() + .children((0..PLACEMENTS.len()).map(|ix| self.render_placement(ix, cx))), + ) .child({ div() .relative() diff --git a/website/base/primitives/scrollbar.md b/website/base/primitives/scrollbar.md index 2737fe07f2..532916082c 100644 --- a/website/base/primitives/scrollbar.md +++ b/website/base/primitives/scrollbar.md @@ -33,7 +33,7 @@ use std::time::Duration; use gpui_kit::{div, px, rgb, ScrollHandle, Styled as _}; use gpui_kit::base::{ Scrollbar, ScrollbarAxis, ScrollbarEntrance, ScrollbarMode, - ScrollbarMotion, ScrollbarStyles, ScrollbarTheme, Side, Theme, + ScrollbarMotion, ScrollbarPlacement, ScrollbarStyles, ScrollbarTheme, Theme, }; ``` @@ -79,14 +79,24 @@ Scrollbar::horizontal(&scroll_handle); Scrollbar::new(&scroll_handle).axis(ScrollbarAxis::Vertical); ``` -The vertical scrollbar sits on the right edge by default. Use `side` to move it -to the left edge, for example in the left pane of a side-by-side view. The -horizontal scrollbar stays at the bottom and starts after the vertical one: +Use `placement` to choose the edges the scrollbars sit on, for example to put +the vertical scrollbar on the left edge of the left pane in a side-by-side view: ```rust -Scrollbar::new(&scroll_handle).side(Side::Left); +Scrollbar::new(&scroll_handle).placement(ScrollbarPlacement::BottomLeft); ``` +| Placement | Vertical scrollbar | Horizontal scrollbar | +| ----------------------- | ------------------ | -------------------- | +| `BottomRight` (default) | Right | Bottom | +| `BottomLeft` | Left | Bottom | +| `TopRight` | Right | Top | +| `TopLeft` | Left | Top | + +A single-axis scrollbar uses only the part of the placement for its axis. When +both scrollbars are shown, the vertical track keeps the full height and the +horizontal track stops short of it on the vertical scrollbar's side. + The scrollbar is an absolute overlay. Its layout and hitboxes stay fixed while the painted track and thumb animate, so entrance motion does not move content or change the interaction geometry. @@ -190,8 +200,8 @@ The example theme above produces this choreography: | Thumb hover in `Hover` mode | `thumb_hover_entrance`: slide from the nearest edge while fading | | `Always` mode | Immediate; visibility motion is skipped | -For `SlideAndFade`, a vertical scrollbar enters from its side and a horizontal -scrollbar enters from the bottom. Opacity uses linear entrance progress; +For `SlideAndFade`, each scrollbar enters from the edge it sits on: the left or +right for a vertical scrollbar, the top or bottom for a horizontal one. Opacity uses linear entrance progress; position uses cubic ease-out. Exit opacity and position use cubic ease-in. An interrupted transition samples its current opacity and position before diff --git a/website/component/editor.md b/website/component/editor.md index 2b1f94ae59..50286623a1 100644 --- a/website/component/editor.md +++ b/website/component/editor.md @@ -118,11 +118,14 @@ let editor = cx.new(|cx| { }); ``` -The vertical scrollbar sits on the right edge by default. Use `scrollbar_side` -to put it on the editor's left edge, over the line numbers: +The scrollbars sit on the right and bottom edges by default. Use +`scrollbar_placement` to move them; a vertical scrollbar on the left sits on +the editor's left edge, over the line numbers. Like the bottom scrollbar over +the last line, a top scrollbar overlays the first line rather than reserving +space for itself. `set_scrollbar_placement` replaces the placement at runtime. ```rust -EditorState::new(window, cx).scrollbar_side(Side::Left) +EditorState::new(window, cx).scrollbar_placement(ScrollbarPlacement::BottomLeft) ``` ## Keyboard shortcuts and column selection diff --git a/website/zh-CN/base/primitives/scrollbar.md b/website/zh-CN/base/primitives/scrollbar.md index 47e27a8193..17cf2fd18a 100644 --- a/website/zh-CN/base/primitives/scrollbar.md +++ b/website/zh-CN/base/primitives/scrollbar.md @@ -18,7 +18,24 @@ cargo run -p gpui-base-examples -- scrollbar ## 基本用法 -把 `ScrollHandle` 保存在持久视图状态中,通过 `track_scroll` 连接可滚动内容,并在同一个 `relative()` 容器中叠加 `Scrollbar`。`Scrollbar::new` 启用双轴;单轴使用 `vertical`、`horizontal` 或 `.axis(...)`。纵向滚动条默认位于右侧,`.side(Side::Left)` 可将其放到左侧,例如并排视图的左侧窗格;横向滚动条仍在底部,并从纵向滚动条之后开始。滚动条是绝对定位覆盖层,进入动画不会移动内容或命中区域。 +把 `ScrollHandle` 保存在持久视图状态中,通过 `track_scroll` 连接可滚动内容,并在同一个 `relative()` 容器中叠加 `Scrollbar`。`Scrollbar::new` 启用双轴;单轴使用 `vertical`、`horizontal` 或 `.axis(...)`。滚动条是绝对定位覆盖层,进入动画不会移动内容或命中区域。 + +## 位置 + +使用 `placement` 选择滚动条所在的边缘,例如在并排视图的左侧窗格中把纵向滚动条放到左边: + +```rust +Scrollbar::new(&scroll_handle).placement(ScrollbarPlacement::BottomLeft); +``` + +| 位置 | 纵向滚动条 | 横向滚动条 | +| ---------------------- | ---------- | ---------- | +| `BottomRight`(默认) | 右侧 | 底部 | +| `BottomLeft` | 左侧 | 底部 | +| `TopRight` | 右侧 | 顶部 | +| `TopLeft` | 左侧 | 顶部 | + +单轴滚动条只使用位置中对应其轴向的部分。两个滚动条同时显示时,纵向轨道保持完整高度,横向轨道在纵向滚动条一侧让出其宽度。 ## 可见模式 @@ -34,7 +51,7 @@ cargo run -p gpui-base-examples -- scrollbar ## 动画行为 -Base 不附带产品动画。默认仅有 2 秒行为性空闲等待,进入、退出和展开时长均为零。`Fade` 原地淡入;`SlideAndFade` 让纵向滚动条从其所在一侧、横向滚动条从底部进入。被中断的过渡从当前视觉值反向;零时长立即采用目标值。GPUI 的减少动态效果偏好也会把可见性和宽度时长降为零。 +Base 不附带产品动画。默认仅有 2 秒行为性空闲等待,进入、退出和展开时长均为零。`Fade` 原地淡入;`SlideAndFade` 让滚动条从其所在的边缘进入:纵向滚动条从左侧或右侧,横向滚动条从顶部或底部。被中断的过渡从当前视觉值反向;零时长立即采用目标值。GPUI 的减少动态效果偏好也会把可见性和宽度时长降为零。 ## 自定义视口与句柄 diff --git a/website/zh-CN/component/editor.md b/website/zh-CN/component/editor.md index 0a0c300ffe..5b559846e4 100644 --- a/website/zh-CN/component/editor.md +++ b/website/zh-CN/component/editor.md @@ -104,10 +104,10 @@ let editor = cx.new(|cx| { }); ``` -纵向滚动条默认位于右侧。使用 `scrollbar_side` 可将其放到编辑器左边缘,覆盖在行号之上: +滚动条默认位于右侧和底部。使用 `scrollbar_placement` 可调整其位置;放在左侧的纵向滚动条位于编辑器左边缘,覆盖在行号之上。与覆盖最后一行的底部滚动条一样,顶部滚动条覆盖在第一行之上,不为自身预留空间。运行时可用 `set_scrollbar_placement` 替换位置。 ```rust -EditorState::new(window, cx).scrollbar_side(Side::Left) +EditorState::new(window, cx).scrollbar_placement(ScrollbarPlacement::BottomLeft) ``` ## 快捷键与矩形列选 From d8057f92df2f34427b4db2f0daa4140d073fa637 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 9 Oct 2026 20:39:17 +0800 Subject: [PATCH 3/3] scrollbar: Switch story options in one menu --- crates/story/src/stories/scrollbar_story.rs | 117 ++++++++------------ 1 file changed, 49 insertions(+), 68 deletions(-) diff --git a/crates/story/src/stories/scrollbar_story.rs b/crates/story/src/stories/scrollbar_story.rs index df91ad8a01..714cc03ef0 100644 --- a/crates/story/src/stories/scrollbar_story.rs +++ b/crates/story/src/stories/scrollbar_story.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use gpui_kit::component::{ ActiveTheme as _, button::Button, - scroll::{ScrollableElement, Scrollbar, ScrollbarPlacement}, + scroll::{Scrollbar, ScrollbarPlacement}, v_flex, }; use gpui_kit::*; @@ -15,14 +15,18 @@ use crate::story_toolbar_group; #[action(namespace = scrollbar_story, no_json)] struct ChangeDataset(pub usize); +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = scrollbar_story, no_json)] +struct ChangePlacement(pub usize); + pub struct ScrollbarStory { focus_handle: FocusHandle, items: Rc>, item_sizes: Rc>>, test_width: Pixels, size_mode: usize, + placement: ScrollbarPlacement, scroll_handle: UniformListScrollHandle, - placement_scroll_handles: [ScrollHandle; 4], } const ITEM_HEIGHT: Pixels = px(50.); @@ -48,8 +52,8 @@ impl ScrollbarStory { item_sizes: Rc::new(item_sizes), test_width, size_mode: 0, + placement: ScrollbarPlacement::default(), scroll_handle: UniformListScrollHandle::new(), - placement_scroll_handles: std::array::from_fn(|_| ScrollHandle::new()), } } @@ -81,48 +85,6 @@ impl ScrollbarStory { .into(); cx.notify(); } - - fn render_placement(&self, ix: usize, cx: &App) -> impl IntoElement { - let placement = PLACEMENTS[ix]; - let scroll_handle = &self.placement_scroll_handles[ix]; - - v_flex() - .gap_1() - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child(format!("{:?}", placement)), - ) - .child( - div() - .id(("placement", ix)) - .relative() - .h(px(160.)) - .border_1() - .border_color(cx.theme().border) - .overflow_scroll() - .track_scroll(scroll_handle) - .child( - v_flex() - .w(px(900.)) - .p_3() - .gap_2() - .text_sm() - .children((1..=20).map(|row| { - div() - .p_2() - .bg(cx.theme().secondary) - .child(format!("Row {}", row)) - })), - ) - .child( - Scrollbar::new(scroll_handle) - .id(("placement-scrollbar", ix)) - .placement(placement), - ), - ) - } } impl super::Story for ScrollbarStory { @@ -157,34 +119,48 @@ impl Render for ScrollbarStory { .on_action(cx.listener(|this, action: &ChangeDataset, _, cx| { this.change_test_cases(action.0, cx); })) + .on_action(cx.listener(|this, action: &ChangePlacement, _, cx| { + this.placement = PLACEMENTS[action.0]; + cx.notify(); + })) .child(story_toolbar_group().dropdown_child( - Button::new("scrollbar-dataset").label(format!( - "Dataset: {}", - ["Standard", "Wide", "Stress", "Short"][self.size_mode] - )), + Button::new("scrollbar-options").label("Options"), { - let selected = self.size_mode; - move |menu, _, _| { - ["Standard", "Wide", "Stress", "Short"] - .into_iter() - .enumerate() - .fold(menu, |menu, (index, label)| { - menu.menu_with_check( - label, - selected == index, - Box::new(ChangeDataset(index)), + let dataset = self.size_mode; + let placement = self.placement; + move |menu, window, cx| { + menu.submenu("Dataset", window, cx, move |menu, _, _| { + ["Standard", "Wide", "Stress", "Short"] + .into_iter() + .enumerate() + .fold(menu, |menu, (ix, label)| { + menu.menu_with_check( + label, + dataset == ix, + Box::new(ChangeDataset(ix)), + ) + }) + }) + .submenu( + "Placement", + window, + cx, + move |menu, _, _| { + PLACEMENTS.into_iter().enumerate().fold( + menu, + |menu, (ix, value)| { + menu.menu_with_check( + format!("{:?}", value), + placement == value, + Box::new(ChangePlacement(ix)), + ) + }, ) - }) + }, + ) } }, )) - .child( - div() - .grid() - .grid_cols(2) - .gap_4() - .children((0..PLACEMENTS.len()).map(|ix| self.render_placement(ix, cx))), - ) .child({ div() .relative() @@ -194,12 +170,14 @@ impl Render for ScrollbarStory { .child( uniform_list("list", self.items.len(), { let items = self.items.clone(); + let width = self.test_width; move |visible_range, _, cx| { let mut elements = Vec::with_capacity(visible_range.len()); for ix in visible_range { let item = &items[ix]; elements.push( div() + .w(width) .h(ITEM_HEIGHT) .pt_1() .items_center() @@ -216,12 +194,15 @@ impl Render for ScrollbarStory { elements } }) + .with_horizontal_sizing_behavior( + ListHorizontalSizingBehavior::Unconstrained, + ) .py_1() .px_3() .size_full() .track_scroll(&self.scroll_handle), ) - .vertical_scrollbar(&self.scroll_handle) + .child(Scrollbar::new(&self.scroll_handle).placement(self.placement)) }) } }