Navigation: a wide tab strip scrolls and shows its chosen tab
CI / platforms (ubuntu-24.04) (push) Waiting to run
CI / site (push) Successful in 35s
CI / platforms (windows-latest) (push) Successful in 5m7s
CI / crate (push) Failing after 12m21s

A horizontal strip with more tabs than room clipped the rest out
of reach (ElyTable's sheets, Gitea #8). The strip now scrolls
sideways, the wheel included, and scrolls the chosen tab fully
into view whenever the choice changes; a manual scroll stays
until then. The tabs may shrink below their natural width.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude committed 2026-10-18 21:39:42 -04:00
1 parent da3fdcaf39
commit e56a34301d
2 files changed
+100 -4

No files matched your search

+38 -4
View File
@@ -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::<SharedString>,
);
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),
+62
View File
@@ -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<SharedString>,
}
impl Render for Sheets {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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<Sheets>, 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");
}