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::{
|
use gpui::{
|
||||||
Animation, AnimationExt, AnyElement, App, ElementId, FontWeight, InteractiveElement,
|
Animation, AnimationExt, AnyElement, App, ElementId, FontWeight, InteractiveElement,
|
||||||
IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce, Role, SharedString,
|
IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce, Role, ScrollHandle,
|
||||||
StatefulInteractiveElement, Styled, Window, div, prelude::*,
|
SharedString, StatefulInteractiveElement, Styled, Window, canvas, div, prelude::*,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -108,6 +108,20 @@ impl RenderOnce for Tabs {
|
|||||||
window,
|
window,
|
||||||
cx,
|
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 focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
|
||||||
let focused = focus.is_focused(window);
|
let focused = focus.is_focused(window);
|
||||||
let tabs = Rc::new(self.tabs);
|
let tabs = Rc::new(self.tabs);
|
||||||
@@ -236,6 +250,9 @@ impl RenderOnce for Tabs {
|
|||||||
.relative()
|
.relative()
|
||||||
.flex()
|
.flex()
|
||||||
.when(axis == Axis::Vertical, |strip| strip.flex_col())
|
.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()
|
.gap_1()
|
||||||
.border_color(colors.border)
|
.border_color(colors.border)
|
||||||
.map(|strip| match placement {
|
.map(|strip| match placement {
|
||||||
@@ -270,10 +287,27 @@ impl RenderOnce for Tabs {
|
|||||||
key_pick(to, window, cx);
|
key_pick(to, window, cx);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
// Items first: a child's index is its tab's.
|
||||||
|
.children(items)
|
||||||
.child(measure_width(strip_width))
|
.child(measure_width(strip_width))
|
||||||
.child(measure_origin(state, axis))
|
.child(measure_origin(state, axis))
|
||||||
.children(indicator)
|
.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| {
|
let panel = self.panel.map(|panel| {
|
||||||
div().flex_1().min_w_0().child(panel).with_animation(
|
div().flex_1().min_w_0().child(panel).with_animation(
|
||||||
("tab-panel", turn),
|
("tab-panel", turn),
|
||||||
@@ -282,7 +316,7 @@ impl RenderOnce for Tabs {
|
|||||||
move |panel, t| if turn == 0 { panel } else { panel.opacity(t) },
|
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 {
|
match placement {
|
||||||
TabPlacement::Top => frame.flex_col().child(strip).children(panel),
|
TabPlacement::Top => frame.flex_col().child(strip).children(panel),
|
||||||
TabPlacement::Bottom => frame.flex_col().children(panel).child(strip),
|
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");
|
cx.simulate_keystrokes("left");
|
||||||
assert_eq!(view.read_with(cx, |view, _| view.chosen.clone()), "c");
|
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