diff --git a/src/navigation/tabs.rs b/src/navigation/tabs.rs index b6d1671..44a2444 100644 --- a/src/navigation/tabs.rs +++ b/src/navigation/tabs.rs @@ -2,8 +2,8 @@ use std::rc::Rc; use gpui::{ Animation, AnimationExt, AnyElement, App, ElementId, FontWeight, InteractiveElement, - IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce, Role, SharedString, - StatefulInteractiveElement, Styled, Window, div, prelude::*, + IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce, Role, ScrollHandle, + SharedString, StatefulInteractiveElement, Styled, Window, canvas, div, prelude::*, }; use crate::{ @@ -108,6 +108,20 @@ impl RenderOnce for Tabs { window, cx, ); + // A wide strip scrolls; the chosen tab scrolls in once. + let scroll = window + .use_keyed_state((self.id.clone(), "scroll"), cx, |_, _| ScrollHandle::new()) + .read(cx) + .clone(); + let followed = + window.use_keyed_state( + (self.id.clone(), "followed"), + cx, + |_, _| None::, + ); + let follow = (axis == Axis::Horizontal + && followed.read(cx).as_ref() != Some(&self.selected)) + .then(|| (followed, self.selected.clone(), scroll.clone())); let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx); let focused = focus.is_focused(window); let tabs = Rc::new(self.tabs); @@ -236,6 +250,9 @@ impl RenderOnce for Tabs { .relative() .flex() .when(axis == Axis::Vertical, |strip| strip.flex_col()) + .when(axis == Axis::Horizontal, |strip| { + strip.min_w_0().overflow_x_scroll().track_scroll(&scroll) + }) .gap_1() .border_color(colors.border) .map(|strip| match placement { @@ -270,10 +287,27 @@ impl RenderOnce for Tabs { key_pick(to, window, cx); } }) + // Items first: a child's index is its tab's. + .children(items) .child(measure_width(strip_width)) .child(measure_origin(state, axis)) .children(indicator) - .children(items); + .children(follow.map(|(followed, selected, scroll)| { + canvas( + move |_, window, cx| { + if scroll.bounds().size.width <= Pixels::ZERO { + return; + } + followed.update(cx, |followed, _| *followed = Some(selected)); + if let Some(ix) = chosen { + scroll.scroll_to_item(ix); + window.request_animation_frame(); + } + }, + |_, _, _, _| {}, + ) + .absolute() + })); let panel = self.panel.map(|panel| { div().flex_1().min_w_0().child(panel).with_animation( ("tab-panel", turn), @@ -282,7 +316,7 @@ impl RenderOnce for Tabs { move |panel, t| if turn == 0 { panel } else { panel.opacity(t) }, ) }); - let frame = div().id(self.id).flex().gap_4(); + let frame = div().id(self.id).flex().min_w_0().gap_4(); match placement { TabPlacement::Top => frame.flex_col().child(strip).children(panel), TabPlacement::Bottom => frame.flex_col().children(panel).child(strip), diff --git a/src/navigation/tests/tabs.rs b/src/navigation/tests/tabs.rs index 14315ca..325ef58 100644 --- a/src/navigation/tests/tabs.rs +++ b/src/navigation/tests/tabs.rs @@ -180,3 +180,65 @@ fn tabs_whose_choice_closed_mark_none_and_arrows_start_at_the_ends(cx: &mut Test cx.simulate_keystrokes("left"); assert_eq!(view.read_with(cx, |view, _| view.chosen.clone()), "c"); } + +struct Sheets { + chosen: SharedString, + clicked: Option, +} + +impl Render for Sheets { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let tabs = (0..30).map(|ix| Choice::new(format!("s{ix}"), format!("Sheet {ix}"))); + div() + .w(px(300.0)) + .child( + Tabs::new("sheets", tabs, self.chosen.clone()).on_change(move |value, _, cx| { + view.update(cx, |view, _| view.clicked = Some(value.clone())) + }), + ) + } +} + +/// The tab number a click at `x` picks. +fn picked_at(x: f32, view: &gpui::Entity, cx: &mut VisualTestContext) -> usize { + view.update(cx, |view, _| view.clicked = None); + click_at(x, cx); + let clicked = view + .read_with(cx, |view, _| view.clicked.clone()) + .expect("a tab is picked"); + clicked[1..].parse().expect("a tab number") +} + +#[gpui::test] +fn a_wide_strip_scrolls_its_chosen_tab_in_and_the_wheel_reaches_the_rest(cx: &mut TestAppContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|_, _| Sheets { + chosen: "s29".into(), + clicked: None, + }); + settle(cx); + assert!( + picked_at(10.0, &view, cx) > 20, + "the strip starts at its last tab" + ); + cx.simulate_event(ScrollWheelEvent { + position: point(px(60.0), px(16.0)), + delta: ScrollDelta::Pixels(point(px(0.0), px(5000.0))), + modifiers: Modifiers::none(), + touch_phase: TouchPhase::Moved, + }); + view.update(cx, |_, cx| cx.notify()); + settle(cx); + assert_eq!( + picked_at(10.0, &view, cx), + 0, + "the wheel scrolls back to the first tab" + ); + view.update(cx, |view, cx| { + view.chosen = "s25".into(); + cx.notify(); + }); + settle(cx); + assert!(picked_at(10.0, &view, cx) > 15, "a new choice scrolls in"); +}