Navigation: a wide tab strip scrolls and shows its chosen tab
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:
2 files changed
+100
-4
No files matched your search
+38
-4
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user