diff --git a/crates/base/src/input/base/element.rs b/crates/base/src/input/base/element.rs index e01794866b..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, + Scrollbar, ScrollbarPlacement, 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, + placement: ScrollbarPlacement, } impl EditorScrollbarSnapshot { @@ -154,9 +155,11 @@ impl EditorScrollbarSnapshot { last_layout.line_number_width, scroll_size, state.editor_paddings, + state.scrollbar_placement, ), cursor_scroll_offset, soft_wrap: state.soft_wrap, + placement: state.scrollbar_placement, } } } @@ -167,8 +170,11 @@ impl EditorScrollbarLayout { line_number_width: Pixels, scroll_size: Size, paddings: Edges, + placement: ScrollbarPlacement, ) -> Self { - let left = if line_number_width == px(0.) { + let left = if placement.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) } + .placement(snapshot.placement) .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, + ScrollbarPlacement::BottomRight, + ); 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, + ScrollbarPlacement::BottomRight, + ); assert_eq!( layout_without_gutter.bounds, @@ -4830,6 +4847,41 @@ 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, + ScrollbarPlacement::BottomLeft, + ); + + 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.))); + assert_eq!( + EditorScrollbarLayout::new( + input_bounds, + px(40.), + size(px(1000.), px(200.)), + paddings, + ScrollbarPlacement::TopLeft, + ), + layout + ); + } + #[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..7ad3cf0e38 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::ScrollbarPlacement; 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_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, @@ -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_placement: ScrollbarPlacement::default(), longest_line_width: Cell::new(None), editor_paddings: Edges::default(), deferred_scroll_offset: None, @@ -10667,6 +10670,26 @@ impl InputBaseState { cx.notify(); } + /// The placement of the scrollbars, default is [`ScrollbarPlacement::BottomRight`]. + /// + /// 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_placement`]. + pub fn set_scrollbar_placement( + &mut self, + placement: ScrollbarPlacement, + cx: &mut Context, + ) { + self.scrollbar_placement = placement; + 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/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 456addb4df..da436b1b2e 100644 --- a/crates/base/src/scrollbar.rs +++ b/crates/base/src/scrollbar.rs @@ -474,10 +474,21 @@ impl VisibilityAnimation { } } -fn visibility_translation(axis: Axis, track_width: Pixels, progress: f32) -> Point { +fn visibility_translation( + axis: Axis, + placement: ScrollbarPlacement, + 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 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) } @@ -636,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 { @@ -815,6 +856,7 @@ impl ScrollbarAxis { pub struct Scrollbar { pub(crate) id: ElementId, axis: ScrollbarAxis, + placement: ScrollbarPlacement, mode: Option, scroll_handle: Rc, scroll_size: Option>, @@ -838,6 +880,7 @@ impl Scrollbar { Self { id: ElementId::CodeLocation(*caller), axis: ScrollbarAxis::Both, + placement: ScrollbarPlacement::default(), mode: None, scroll_handle: Rc::new(scroll_handle.clone()), max_fps: 120, @@ -921,6 +964,15 @@ impl Scrollbar { self } + /// Set the scrollbar placement, default is [`ScrollbarPlacement::BottomRight`]. + /// + /// 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 + } + pub fn styles(mut self, build: impl FnOnce(ScrollbarStyles) -> ScrollbarStyles) -> Self { self.styles = build(self.styles); self @@ -1394,7 +1446,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 +1460,16 @@ 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.placement.is_left() { + hitbox.origin + } else { + point( + hitbox.origin.x + hitbox.size.width - track_width, + hitbox.origin.y, + ) + } + } else if self.placement.is_top() { + hitbox.origin } else { point( hitbox.origin.x, @@ -1481,55 +1539,63 @@ impl Element for Scrollbar { } else { bounds.origin.x }; + let track_origin = if !is_vertical && self.placement.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, ); 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 { - 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 { - 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) @@ -1597,8 +1663,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.placement, + 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 +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, px(16.), shown.position), + visibility_translation( + Axis::Vertical, + ScrollbarPlacement::BottomRight, + px(16.), + shown.position + ), Point::default() ); @@ -2072,18 +2147,29 @@ mod tests { #[test] fn visibility_translation_moves_toward_the_nearest_edge() { - assert_eq!( - visibility_translation(Axis::Vertical, px(16.), 0.0), - point(px(16.), px(0.)) - ); - assert_eq!( - visibility_translation(Axis::Horizontal, px(16.), 0.0), - point(px(0.), px(16.)) - ); - assert_eq!( - visibility_translation(Axis::Vertical, 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] @@ -2255,15 +2341,18 @@ mod tests { struct ScrollbarHarness { handle: TestHandle, axis: ScrollbarAxis, + placement: ScrollbarPlacement, 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) + .placement(self.placement) + .mode(self.mode), + ) } } @@ -2272,11 +2361,26 @@ mod tests { axis: ScrollbarAxis, mode: ScrollbarMode, content_size: Size, + ) -> (&mut VisualTestContext, TestHandle) { + harness_with_placement(cx, axis, ScrollbarPlacement::default(), mode, content_size) + } + + fn harness_with_placement( + cx: &mut TestAppContext, + axis: ScrollbarAxis, + placement: ScrollbarPlacement, + 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, + placement, + mode, + } }); cx.update(|window, cx| window.draw(cx).clear(cx)); (cx, handle) @@ -2449,6 +2553,144 @@ mod tests { assert_eq!(vertical.offset().x, px(0.)); } + #[gpui::test] + 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, + ScrollbarPlacement::BottomLeft, + 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()); + // 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(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_eq!(handle.offset(), point(px(0.), px(-400.))); + } + + #[gpui::test] + fn left_placement_starts_the_horizontal_track_after_the_vertical_one(cx: &mut TestAppContext) { + let (cx, handle) = harness_with_placement( + cx, + ScrollbarAxis::Both, + ScrollbarPlacement::BottomLeft, + 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 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( @@ -2539,6 +2781,7 @@ mod tests { move |_, _| ScrollbarHarness { handle, axis: ScrollbarAxis::Vertical, + 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..714cc03ef0 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::{Scrollbar, ScrollbarPlacement}, + v_flex, +}; use gpui_kit::*; use serde::Deserialize; @@ -10,16 +15,27 @@ 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, } 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 { @@ -36,6 +52,7 @@ impl ScrollbarStory { item_sizes: Rc::new(item_sizes), test_width, size_mode: 0, + placement: ScrollbarPlacement::default(), scroll_handle: UniformListScrollHandle::new(), } } @@ -102,24 +119,45 @@ 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)), + ) + }, ) - }) + }, + ) } }, )) @@ -132,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() @@ -154,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)) }) } } diff --git a/website/base/primitives/scrollbar.md b/website/base/primitives/scrollbar.md index 655b6cdadc..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, Theme, + ScrollbarMotion, ScrollbarPlacement, ScrollbarStyles, ScrollbarTheme, Theme, }; ``` @@ -79,6 +79,24 @@ Scrollbar::horizontal(&scroll_handle); Scrollbar::new(&scroll_handle).axis(ScrollbarAxis::Vertical); ``` +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).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. @@ -182,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 the right 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 cd5322d464..50286623a1 100644 --- a/website/component/editor.md +++ b/website/component/editor.md @@ -118,6 +118,16 @@ let editor = cx.new(|cx| { }); ``` +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_placement(ScrollbarPlacement::BottomLeft) +``` + ## 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..17cf2fd18a 100644 --- a/website/zh-CN/base/primitives/scrollbar.md +++ b/website/zh-CN/base/primitives/scrollbar.md @@ -20,6 +20,23 @@ cargo run -p gpui-base-examples -- scrollbar 把 `ScrollHandle` 保存在持久视图状态中,通过 `track_scroll` 连接可滚动内容,并在同一个 `relative()` 容器中叠加 `Scrollbar`。`Scrollbar::new` 启用双轴;单轴使用 `vertical`、`horizontal` 或 `.axis(...)`。滚动条是绝对定位覆盖层,进入动画不会移动内容或命中区域。 +## 位置 + +使用 `placement` 选择滚动条所在的边缘,例如在并排视图的左侧窗格中把纵向滚动条放到左边: + +```rust +Scrollbar::new(&scroll_handle).placement(ScrollbarPlacement::BottomLeft); +``` + +| 位置 | 纵向滚动条 | 横向滚动条 | +| ---------------------- | ---------- | ---------- | +| `BottomRight`(默认) | 右侧 | 底部 | +| `BottomLeft` | 左侧 | 底部 | +| `TopRight` | 右侧 | 顶部 | +| `TopLeft` | 左侧 | 顶部 | + +单轴滚动条只使用位置中对应其轴向的部分。两个滚动条同时显示时,纵向轨道保持完整高度,横向轨道在纵向滚动条一侧让出其宽度。 + ## 可见模式 - `Scrolling`:滚动或拖动后显示,离开悬停区域后重新计算空闲等待。 @@ -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 8fb0579c7c..5b559846e4 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_placement` 可调整其位置;放在左侧的纵向滚动条位于编辑器左边缘,覆盖在行号之上。与覆盖最后一行的底部滚动条一样,顶部滚动条覆盖在第一行之上,不为自身预留空间。运行时可用 `set_scrollbar_placement` 替换位置。 + +```rust +EditorState::new(window, cx).scrollbar_placement(ScrollbarPlacement::BottomLeft) +``` + ## 快捷键与矩形列选 以下默认快捷键在编辑器聚焦时生效。macOS 的 Option 对应 Alt 修饰键;Linux 的这些操作不使用 Super/Win。