T07a navigation tabs and paths: tabs, editor tabs, tab overflow menu, breadcrumb, pagination, steps and wizard; shared slide, list trigger and tab stop

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 11:37:38 -04:00
1 parent 05ff67ad9f
commit 0883a999f4
50 files changed
+2490 -330

No files matched your search

+183
View File
@@ -0,0 +1,183 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
};
use crate::{
forms::{Choice, Listing, listing},
primitives::{Icon, IconName, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
};
/// One level of a path, and the siblings a click on it lists.
#[derive(Clone, Debug)]
pub struct Crumb {
value: SharedString,
label: SharedString,
icon: Option<IconName>,
siblings: Vec<Choice>,
}
impl Crumb {
pub fn new(value: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
Self {
value: value.into(),
label: label.into(),
icon: None,
siblings: Vec::new(),
}
}
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
/// The level's neighbors, itself among them; a click lists them.
pub fn siblings(mut self, siblings: impl IntoIterator<Item = Choice>) -> Self {
self.siblings = siblings.into_iter().collect();
assert!(
self.siblings
.iter()
.any(|sibling| sibling.value == self.value),
"crumb {} is not among its siblings",
self.value
);
self
}
}
type OnLevel = Rc<dyn Fn(usize, &SharedString, &mut Window, &mut App)>;
/// A path of levels. A level with siblings lists them on click; an earlier level goes to itself.
#[derive(IntoElement)]
pub struct Breadcrumb {
id: ElementId,
crumbs: Vec<Crumb>,
on_select: Option<OnLevel>,
}
impl Breadcrumb {
pub fn new(id: impl Into<ElementId>, crumbs: impl IntoIterator<Item = Crumb>) -> Self {
Self {
id: id.into(),
crumbs: crumbs.into_iter().collect(),
on_select: None,
}
}
/// Runs with the level and the value chosen there.
pub fn on_select(
mut self,
handler: impl Fn(usize, &SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
}
impl RenderOnce for Breadcrumb {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
assert!(
!self.crumbs.is_empty(),
"breadcrumb {:?} has no levels",
self.id
);
let last = self.crumbs.len() - 1;
let mut levels: Vec<AnyElement> = Vec::new();
for (level, crumb) in self.crumbs.into_iter().enumerate() {
let here: ElementId = (self.id.clone(), format!("crumb-{level}")).into();
let listed = !crumb.siblings.is_empty();
let active = listed || level < last;
let focus = tab_stop((here.clone(), "focus").into(), active, window, cx);
let focused = focus.is_focused(window);
let theme = cx.theme();
let colors = &theme.colors;
let fg = if level == last {
colors.fg
} else {
colors.fg_muted
};
let trigger = div()
.id(here.clone())
.track_focus(&focus)
.relative()
.flex()
.items_center()
.gap_1()
.h(theme.control_height(ControlSize::Sm))
.px_1p5()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(if focused {
colors.focus
} else {
gpui::transparent_black()
})
.text_size(theme.text_size(TextSize::Sm))
.font_weight(if level == last {
FontWeight::MEDIUM
} else {
FontWeight::NORMAL
})
.text_color(fg)
.when(active, |crumb| {
crumb
.cursor_pointer()
.hover(|style| style.bg(colors.hover).text_color(colors.fg))
})
.when_some(crumb.icon, |el, icon| {
el.child(Icon::new(icon).size(IconSize::Sm).color(fg))
})
.child(crumb.label.clone())
.when(listed, |el| {
el.child(
Icon::new(IconName::ChevronDown)
.size(IconSize::Xs)
.color(colors.fg_subtle),
)
});
let on_select = self.on_select.clone();
let (id, value) = (self.id.clone(), crumb.value.clone());
let go = move |value: &SharedString, window: &mut Window, cx: &mut App| {
log::info!("breadcrumb {id:?}: level {level} -> {value}");
if let Some(on_select) = &on_select {
on_select(level, value, window, cx);
}
};
let element = if listed {
let list = Listing {
id: &here,
rows: Rc::new(crumb.siblings),
selected: Some(&crumb.value),
focused,
};
listing(list, trigger, go, window, cx).into_any_element()
} else if active {
trigger
.on_click(move |_, window, cx| go(&value, window, cx))
.into_any_element()
} else {
trigger.into_any_element()
};
if level > 0 {
levels.push(
Icon::new(IconName::ChevronRight)
.size(IconSize::Xs)
.color(cx.theme().colors.fg_subtle)
.into_any_element(),
);
}
levels.push(element);
}
div()
.id(self.id)
.flex()
.flex_wrap()
.items_center()
.gap_0p5()
.children(levels)
}
}
+380
View File
@@ -0,0 +1,380 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, EntityId, InteractiveElement, IntoElement, MouseButton,
ParentElement, Pixels, RenderOnce, ScrollHandle, SharedString, StatefulInteractiveElement,
Styled, Window, canvas, div, prelude::*,
};
use super::TabOverflowMenu;
use crate::{
buttons::{ButtonVariant, IconButton},
forms::Choice,
primitives::{DragGhost, Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
};
/// One open document: its id, title and state.
#[derive(Clone, Debug)]
pub struct EditorTab {
id: SharedString,
title: SharedString,
icon: Option<IconName>,
dirty: bool,
pinned: bool,
preview: bool,
}
impl EditorTab {
pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
title: title.into(),
icon: None,
dirty: false,
pinned: false,
preview: false,
}
}
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
/// Unsaved work: a dot that turns into the close button on hover.
pub fn dirty(mut self, dirty: bool) -> Self {
self.dirty = dirty;
self
}
/// A pin in place of the close button.
pub fn pinned(mut self, pinned: bool) -> Self {
self.pinned = pinned;
self
}
/// A tab the next preview replaces, shown in italics.
pub fn preview(mut self, preview: bool) -> Self {
self.preview = preview;
self
}
}
struct TabDrag {
owner: EntityId,
from: usize,
}
type OnTab = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
type OnReorder = Rc<dyn Fn(usize, usize, &mut Window, &mut App)>;
/// Document tabs: close, pin, drag to reorder, keep a preview. Every tab is listed at the end.
#[derive(IntoElement)]
pub struct EditorTabs {
id: ElementId,
tabs: Vec<EditorTab>,
selected: Option<SharedString>,
on_select: Option<OnTab>,
on_close: Option<OnTab>,
on_pin: Option<OnTab>,
on_keep: Option<OnTab>,
on_reorder: Option<OnReorder>,
}
impl EditorTabs {
pub fn new(id: impl Into<ElementId>, tabs: impl IntoIterator<Item = EditorTab>) -> Self {
Self {
id: id.into(),
tabs: tabs.into_iter().collect(),
selected: None,
on_select: None,
on_close: None,
on_pin: None,
on_keep: None,
on_reorder: None,
}
}
pub fn selected(mut self, id: impl Into<SharedString>) -> Self {
self.selected = Some(id.into());
self
}
pub fn on_select(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
/// The close button, a middle click, or the button over a dirty dot.
pub fn on_close(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_close = Some(Rc::new(handler));
self
}
/// The pin on a pinned tab, which unpins it.
pub fn on_pin(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_pin = Some(Rc::new(handler));
self
}
/// A double click on a preview tab, which keeps it open.
pub fn on_keep(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_keep = Some(Rc::new(handler));
self
}
/// Reports `(from, to)` when a tab is dropped on another.
pub fn on_reorder(
mut self,
handler: impl Fn(usize, usize, &mut Window, &mut App) + 'static,
) -> Self {
self.on_reorder = Some(Rc::new(handler));
self
}
}
fn run(handler: &Option<OnTab>, what: &str, id: &SharedString, window: &mut Window, cx: &mut App) {
log::info!("editor tabs: {what} {id}");
if let Some(handler) = handler {
handler(id, window, cx);
}
}
impl RenderOnce for EditorTabs {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let owner = window
.use_keyed_state((self.id.clone(), "drag"), cx, |_, _| ())
.entity_id();
let scroll = window
.use_keyed_state((self.id.clone(), "scroll"), cx, |_, _| ScrollHandle::new())
.read(cx)
.clone();
let chosen = self
.selected
.as_ref()
.and_then(|id| self.tabs.iter().position(|tab| tab.id == *id));
let followed = window.use_keyed_state((self.id.clone(), "followed"), cx, |_, _| {
None::<Option<SharedString>>
});
let follow = (followed.read(cx).as_ref() != Some(&self.selected))
.then(|| (followed, self.selected.clone(), scroll.clone()));
let theme = cx.theme();
let colors = &theme.colors;
let wide = theme.tab_width().1;
let tabs: Vec<AnyElement> = self
.tabs
.iter()
.enumerate()
.map(|(ix, tab)| {
let on = chosen == Some(ix);
let group = SharedString::from(format!("editor-tab-{}", tab.id));
let fg = if on { colors.fg } else { colors.fg_muted };
let (select, keep, close) = (
self.on_select.clone(),
self.on_keep.clone(),
self.on_close.clone(),
);
let (id, middle_id, preview) = (tab.id.clone(), tab.id.clone(), tab.preview);
let slot_id = tab.id.clone();
let slot: AnyElement = if tab.pinned {
let pin = self.on_pin.clone();
IconButton::new(("pin", ix), IconName::Pin)
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.tooltip("Unpin")
.on_click(move |_, window, cx| {
cx.stop_propagation();
run(&pin, "unpin", &slot_id, window, cx);
})
.into_any_element()
} else {
let close_button = {
let close = self.on_close.clone();
IconButton::new(("close", ix), IconName::X)
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.tooltip("Close")
.on_click(move |_, window, cx| {
cx.stop_propagation();
run(&close, "close", &slot_id, window, cx);
})
};
let hidden = tab.dirty || !on;
div()
.relative()
.flex_none()
.size(theme.control_height(ControlSize::Sm))
.when(tab.dirty, |slot| {
slot.child(
div()
.absolute()
.size_full()
.flex()
.items_center()
.justify_center()
.group_hover(group.clone(), |style| style.invisible())
.child(div().size(theme.status_dot()).rounded_full().bg(fg)),
)
})
.child(
div()
.absolute()
.size_full()
.when(hidden, |cover| {
cover
.invisible()
.group_hover(group.clone(), |style| style.visible())
})
.child(close_button),
)
.into_any_element()
};
let reorder = self.on_reorder.clone();
let (title, icon, edge) = (tab.title.clone(), tab.icon, colors.focus);
div()
.id(tab.id.clone())
.group(group)
.relative()
.flex()
.flex_none()
.items_center()
.gap_1p5()
.max_w(wide)
.h(theme.control_height(ControlSize::Lg))
.pl_3()
.pr_1()
.border_r_1()
.border_color(colors.border)
.text_size(theme.text_size(TextSize::Sm))
.text_color(fg)
.map(|el| {
if on {
el.bg(colors.bg).child(
div()
.absolute()
.top_0()
.left_0()
.right_0()
.h(theme.tab_indicator())
.bg(colors.accent),
)
} else {
el.hover(|style| style.text_color(colors.fg))
}
})
.when(preview, |el| el.italic())
.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_mouse_down(MouseButton::Middle, move |_, window, cx| {
run(&close, "close", &middle_id, window, cx)
})
.on_click(move |event, window, cx| {
if preview && event.click_count() == 2 {
run(&keep, "keep", &id, window, cx);
} else {
run(&select, "select", &id, window, cx);
}
})
.when_some(reorder, |el, reorder| {
el.on_drag(TabDrag { owner, from: ix }, move |_, _, _, cx| {
DragGhost::new(title.clone(), icon, cx)
})
.drag_over::<TabDrag>(move |style, drag, _, _| {
if drag.owner == owner {
style.border_color(edge)
} else {
style
}
})
.on_drop(move |drag: &TabDrag, window, cx| {
if drag.owner == owner && drag.from != ix {
log::info!("editor tabs: move {} -> {ix}", drag.from);
reorder(drag.from, ix, window, cx);
}
})
})
.when_some(tab.icon, |el, icon| {
el.child(Icon::new(icon).size(IconSize::Sm).color(fg))
})
.child(
div()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_ellipsis()
.child(tab.title.clone()),
)
.child(slot)
.into_any_element()
})
.collect();
let listed: Vec<Choice> = self
.tabs
.iter()
.map(|tab| {
let choice = Choice::new(tab.id.clone(), tab.title.clone());
match tab.icon {
Some(icon) => choice.icon(icon),
None => choice,
}
})
.collect();
let overflow = (!listed.is_empty()).then(|| {
let select = self.on_select.clone();
let menu = TabOverflowMenu::new((self.id.clone(), "overflow"), listed)
.on_select(move |id, window, cx| run(&select, "select", id, window, cx));
match &self.selected {
Some(id) => menu.selected(id.clone()),
None => menu,
}
});
div()
.id(self.id)
.flex()
.items_center()
.w_full()
.bg(colors.sunken)
.border_b_1()
.border_color(colors.border)
.child(
div()
.id("strip")
.flex()
.flex_1()
.min_w_0()
.overflow_x_scroll()
.track_scroll(&scroll)
.children(tabs)
.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()
})),
)
.children(overflow.map(|menu| div().flex_none().px_1().child(menu)))
}
}
+15
View File
@@ -0,0 +1,15 @@
mod breadcrumb;
mod editor_tabs;
mod overflow;
mod pages;
mod steps;
mod tabs;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub use breadcrumb::{Breadcrumb, Crumb};
pub use editor_tabs::{EditorTab, EditorTabs};
pub use overflow::TabOverflowMenu;
pub use pages::Pagination;
pub use steps::{Steps, Wizard};
pub use tabs::{TabPlacement, Tabs};
+103
View File
@@ -0,0 +1,103 @@
use std::rc::Rc;
use gpui::{
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, Window, div,
};
use crate::{
forms::{Choice, Listing, listing},
primitives::{Icon, IconName, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize, Radius},
};
type OnValue = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// A button that lists every tab, for a strip too narrow to show them all.
#[derive(IntoElement)]
pub struct TabOverflowMenu {
id: ElementId,
tabs: Vec<Choice>,
selected: Option<SharedString>,
on_select: Option<OnValue>,
}
impl TabOverflowMenu {
pub fn new(id: impl Into<ElementId>, tabs: impl IntoIterator<Item = Choice>) -> Self {
Self {
id: id.into(),
tabs: tabs.into_iter().collect(),
selected: None,
on_select: None,
}
}
pub fn selected(mut self, value: impl Into<SharedString>) -> Self {
self.selected = Some(value.into());
self
}
pub fn on_select(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
}
impl RenderOnce for TabOverflowMenu {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
assert!(
!self.tabs.is_empty(),
"tab overflow menu {:?} has no tabs",
self.id
);
let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
let focused = focus.is_focused(window);
let theme = cx.theme();
let colors = &theme.colors;
let trigger = div()
.id((self.id.clone(), "button"))
.track_focus(&focus)
.relative()
.flex()
.flex_none()
.items_center()
.justify_center()
.size(theme.control_height(ControlSize::Sm))
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(if focused {
colors.focus
} else {
gpui::transparent_black()
})
.cursor_pointer()
.hover(|style| style.bg(colors.hover))
.child(
Icon::new(IconName::ChevronDown)
.size(IconSize::Sm)
.color(colors.fg_muted),
);
let (id, on_select) = (self.id.clone(), self.on_select);
let list = Listing {
id: &self.id,
rows: Rc::new(self.tabs),
selected: self.selected.as_ref(),
focused,
};
listing(
list,
trigger,
move |value, window, cx| {
log::info!("tab overflow menu {id:?}: {value}");
if let Some(on_select) = &on_select {
on_select(value, window, cx);
}
},
window,
cx,
)
}
}
+166
View File
@@ -0,0 +1,166 @@
use std::rc::Rc;
use gpui::{
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, Styled, Window, div,
};
use crate::{
buttons::{Button, ButtonVariant, IconButton},
primitives::IconName,
theme::{ActiveTheme, ControlSize},
};
/// The first, the last, `current` and `near` pages either side; `None` marks a gap of two or more.
pub(crate) fn page_list(current: usize, total: usize, near: usize) -> Vec<Option<usize>> {
assert!(
total >= 1 && (1..=total).contains(&current),
"page {current} is outside 1 to {total}"
);
if total == 1 {
return vec![Some(1)];
}
let start = current.saturating_sub(near).max(2);
let end = (current + near).min(total - 1);
let mut pages = vec![Some(1)];
if start > 3 {
pages.push(None);
} else {
pages.extend((2..start).map(Some));
}
pages.extend((start..=end).map(Some));
if end + 2 < total {
pages.push(None);
} else {
pages.extend((end + 1..total).map(Some));
}
pages.push(Some(total));
pages
}
type OnPage = Rc<dyn Fn(usize, &mut Window, &mut App)>;
/// Numbered pages between previous and next; long runs fold into gaps.
#[derive(IntoElement)]
pub struct Pagination {
id: ElementId,
page: usize,
pages: usize,
near: usize,
on_change: Option<OnPage>,
}
impl Pagination {
/// `page` counts from 1.
pub fn new(id: impl Into<ElementId>, page: usize, pages: usize) -> Self {
Self {
id: id.into(),
page,
pages,
near: 1,
on_change: None,
}
}
/// Pages shown on each side of the current one.
pub fn near(mut self, near: usize) -> Self {
self.near = near;
self
}
pub fn on_change(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for Pagination {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let list = page_list(self.page, self.pages, self.near);
let (page, pages) = (self.page, self.pages);
let go = {
let (id, on_change) = (self.id.clone(), self.on_change);
Rc::new(move |to: usize, window: &mut Window, cx: &mut App| {
log::info!("pagination {id:?}: page {to}");
if let Some(on_change) = &on_change {
on_change(to, window, cx);
}
})
};
let subtle = cx.theme().colors.fg_subtle;
let (back, ahead) = (go.clone(), go.clone());
let cells = list
.into_iter()
.enumerate()
.map(move |(ix, entry)| match entry {
Some(number) => {
let go = go.clone();
Button::new(("page", number), number.to_string())
.size(ControlSize::Sm)
.variant(if number == page {
ButtonVariant::Secondary
} else {
ButtonVariant::Ghost
})
.on_click(move |_, window, cx| {
if number != page {
go(number, window, cx);
}
})
.into_any_element()
}
None => div()
.id(("gap", ix))
.px_1()
.text_color(subtle)
.child("…")
.into_any_element(),
});
div()
.id(self.id)
.flex()
.items_center()
.gap_1()
.child(
IconButton::new("previous", IconName::ChevronLeft)
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.tooltip("Previous page")
.disabled(page == 1)
.on_click(move |_, window, cx| back(page - 1, window, cx)),
)
.children(cells)
.child(
IconButton::new("next", IconName::ChevronRight)
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.tooltip("Next page")
.disabled(page == pages)
.on_click(move |_, window, cx| ahead(page + 1, window, cx)),
)
}
}
#[cfg(test)]
mod tests {
use super::page_list;
fn shown(current: usize, total: usize) -> String {
page_list(current, total, 1)
.into_iter()
.map(|page| page.map_or("…".to_string(), |page| page.to_string()))
.collect::<Vec<_>>()
.join(" ")
}
#[test]
fn gaps_fold_long_runs_and_never_hide_one_page() {
assert_eq!(shown(6, 20), "1 … 5 6 7 … 20");
assert_eq!(shown(1, 20), "1 2 … 20");
assert_eq!(shown(4, 20), "1 2 3 4 5 … 20");
assert_eq!(shown(17, 20), "1 … 16 17 18 19 20");
assert_eq!(shown(20, 20), "1 … 19 20");
assert_eq!(shown(1, 1), "1");
assert_eq!(shown(2, 2), "1 2");
assert_eq!(shown(3, 5), "1 2 3 4 5");
}
}
+336
View File
@@ -0,0 +1,336 @@
use std::rc::Rc;
use gpui::{
Animation, AnimationExt, AnyElement, App, ElementId, FontWeight, InteractiveElement,
IntoElement, ParentElement, RenderOnce, StatefulInteractiveElement, Styled, Window, div,
prelude::*, relative,
};
use crate::{
buttons::{Button, ButtonVariant},
forms::{Choice, Run},
motion,
primitives::{FocusRing, Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
};
type OnStep = Rc<dyn Fn(usize, &mut Window, &mut App)>;
/// Numbered steps joined by lines. Finished steps carry a check and can be revisited.
#[derive(IntoElement)]
pub struct Steps {
id: ElementId,
steps: Vec<Choice>,
current: usize,
vertical: bool,
on_select: Option<OnStep>,
}
impl Steps {
/// Each step's label is its title, its note a line of detail.
pub fn new(
id: impl Into<ElementId>,
steps: impl IntoIterator<Item = Choice>,
current: usize,
) -> Self {
let steps: Vec<Choice> = steps.into_iter().collect();
assert!(
current < steps.len(),
"step {current} is past the last of {}",
steps.len()
);
Self {
id: id.into(),
steps,
current,
vertical: false,
on_select: None,
}
}
pub fn vertical(mut self) -> Self {
self.vertical = true;
self
}
/// Runs when a finished step is clicked.
pub fn on_select(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
}
impl RenderOnce for Steps {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let turn = motion::changes((self.id.clone(), "current"), self.current, window, cx);
let (current, vertical, count) = (self.current, self.vertical, self.steps.len());
let duration = motion::duration(motion::SLOW, cx);
let theme = cx.theme();
let colors = &theme.colors;
let mark = theme.control_height(ControlSize::Sm);
let line = |ix: usize| {
let filled = ix < current;
let fill = div().bg(colors.accent).map(|fill| {
if vertical {
fill.w_full()
} else {
fill.h_full()
}
});
let fill = if filled && ix + 1 == current && turn > 0 {
fill.with_animation(
("step-fill", turn),
Animation::new(duration).with_easing(motion::ease_out_cubic),
move |fill, t| {
if vertical {
fill.h(relative(t))
} else {
fill.w(relative(t))
}
},
)
.into_any_element()
} else {
let size = relative(if filled { 1.0 } else { 0.0 });
(if vertical { fill.h(size) } else { fill.w(size) }).into_any_element()
};
div()
.flex_1()
.bg(colors.border)
.map(|track| if vertical { track.w_px() } else { track.h_px() })
.child(fill)
};
let steps = self.steps.into_iter().enumerate().map(|(ix, step)| {
let (done, now) = (ix < current, ix == current);
let circle = div()
.flex()
.flex_none()
.items_center()
.justify_center()
.size(mark)
.rounded_full()
.text_size(theme.text_size(TextSize::Xs))
.font_weight(FontWeight::SEMIBOLD)
.map(|circle| {
if done {
circle.bg(colors.accent).child(
Icon::new(IconName::Check)
.size(IconSize::Xs)
.color(colors.on_accent),
)
} else if now {
circle
.border_2()
.border_color(colors.accent)
.text_color(colors.fg)
.child((ix + 1).to_string())
} else {
circle
.border_1()
.border_color(colors.border_strong)
.text_color(colors.fg_subtle)
.child((ix + 1).to_string())
}
});
let words = div()
.flex()
.flex_col()
.gap_0p5()
.text_size(theme.text_size(TextSize::Sm))
.text_color(if done || now {
colors.fg
} else {
colors.fg_muted
})
.when(now, |words| words.font_weight(FontWeight::MEDIUM))
.child(step.label)
.when_some(step.note, |words, note| {
words.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.font_weight(FontWeight::NORMAL)
.child(note),
)
});
let select = self.on_select.clone().filter(|_| done);
let head = div()
.id(("step", ix))
.flex()
.items_center()
.gap_2()
.rounded_full()
.border_1()
.border_color(gpui::transparent_black())
.when_some(select, |head, select| {
head.cursor_pointer().tab_index(0).focus_ring(cx).on_click(
move |_, window, cx| {
log::info!("steps: back to {ix}");
select(ix, window, cx)
},
)
});
let after = (ix + 1 < count).then(|| line(ix));
if vertical {
div()
.flex()
.gap_3()
.child(
div()
.flex()
.flex_col()
.items_center()
.gap_1()
.child(head.child(circle))
.children(after),
)
.child(div().pb_5().child(words))
.into_any_element()
} else {
div()
.flex()
.flex_1()
.items_center()
.gap_3()
.when(ix + 1 == count, |step| step.flex_none())
.child(head.child(circle).child(words))
.children(after)
.into_any_element()
}
});
div()
.id(self.id)
.flex()
.when(vertical, |steps| steps.flex_col())
.w_full()
.children(steps)
}
}
/// Steps, the current step's content, then Back and Next. Next becomes Finish on the last step.
#[derive(IntoElement)]
pub struct Wizard {
id: ElementId,
steps: Vec<Choice>,
current: usize,
content: Option<AnyElement>,
ready: bool,
on_step: Option<OnStep>,
on_finish: Option<Run>,
}
impl Wizard {
pub fn new(
id: impl Into<ElementId>,
steps: impl IntoIterator<Item = Choice>,
current: usize,
) -> Self {
Self {
id: id.into(),
steps: steps.into_iter().collect(),
current,
content: None,
ready: true,
on_step: None,
on_finish: None,
}
}
/// The current step's content.
pub fn content(mut self, content: impl IntoElement) -> Self {
self.content = Some(content.into_any_element());
self
}
/// Whether Next may run; the step's fields decide.
pub fn ready(mut self, ready: bool) -> Self {
self.ready = ready;
self
}
/// Runs with the step Back, Next or a finished step leads to.
pub fn on_step(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_step = Some(Rc::new(handler));
self
}
pub fn on_finish(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_finish = Some(Rc::new(handler));
self
}
}
impl RenderOnce for Wizard {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let (current, last) = (self.current, self.steps.len().saturating_sub(1));
let turn = motion::changes((self.id.clone(), "content"), current, window, cx);
let step: OnStep = {
let (id, on_step) = (self.id.clone(), self.on_step);
Rc::new(move |to, window, cx| {
log::info!("wizard {id:?}: step {to}");
if let Some(on_step) = &on_step {
on_step(to, window, cx);
}
})
};
let (back, next, jump) = (step.clone(), step.clone(), step);
let finish = {
let (id, on_finish) = (self.id.clone(), self.on_finish);
move |window: &mut Window, cx: &mut App| {
log::info!("wizard {id:?}: finished");
if let Some(on_finish) = &on_finish {
on_finish(window, cx);
}
}
};
let content = self.content.map(|content| {
div().child(content).with_animation(
("wizard-step", turn),
Animation::new(motion::duration(motion::BASE, cx))
.with_easing(motion::ease_out_cubic),
move |content, t| {
if turn == 0 {
content
} else {
content.opacity(t).mt(motion::NUDGE * (1.0 - t))
}
},
)
});
let advance = if current == last {
Button::new("wizard-finish", "Finish")
.primary()
.disabled(!self.ready)
.on_click(move |_, window, cx| finish(window, cx))
} else {
Button::new("wizard-next", "Next")
.primary()
.disabled(!self.ready)
.on_click(move |_, window, cx| next(current + 1, window, cx))
};
div()
.id(self.id.clone())
.flex()
.flex_col()
.gap_6()
.w_full()
.child(
Steps::new((self.id, "steps"), self.steps, current)
.on_select(move |to, window, cx| jump(to, window, cx)),
)
.children(content)
.child(
div()
.flex()
.items_center()
.justify_between()
.child(
Button::new("wizard-back", "Back")
.variant(ButtonVariant::Ghost)
.disabled(current == 0)
.on_click(move |_, window, cx| back(current - 1, window, cx)),
)
.child(advance),
)
}
}
+247
View File
@@ -0,0 +1,247 @@
use std::rc::Rc;
use gpui::{
Animation, AnimationExt, AnyElement, App, ElementId, FontWeight, InteractiveElement,
IntoElement, MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement,
Styled, Window, div, prelude::*,
};
use crate::{
forms::{Choice, Pick, step},
motion::{self, Axis, Marker, glide, measure_item, measure_origin, slide},
primitives::{Icon, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
};
/// Where a tab strip sits against its panel.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TabPlacement {
#[default]
Top,
Left,
Bottom,
}
type OnValue = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// A strip of tabs and the chosen tab's panel. A line slides to the chosen tab; arrows move it.
#[derive(IntoElement)]
pub struct Tabs {
id: ElementId,
tabs: Vec<Choice>,
selected: SharedString,
placement: TabPlacement,
panel: Option<AnyElement>,
on_change: Option<OnValue>,
}
impl Tabs {
pub fn new(
id: impl Into<ElementId>,
tabs: impl IntoIterator<Item = Choice>,
selected: impl Into<SharedString>,
) -> Self {
Self {
id: id.into(),
tabs: tabs.into_iter().collect(),
selected: selected.into(),
placement: TabPlacement::default(),
panel: None,
on_change: None,
}
}
pub fn placement(mut self, placement: TabPlacement) -> Self {
self.placement = placement;
self
}
/// The chosen tab's content.
pub fn panel(mut self, panel: impl IntoElement) -> Self {
self.panel = Some(panel.into_any_element());
self
}
pub fn on_change(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for Tabs {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let values: Vec<SharedString> = self.tabs.iter().map(|tab| tab.value.clone()).collect();
let chosen = values
.iter()
.position(|value| *value == self.selected)
.unwrap_or_else(|| panic!("tabs {:?} have no tab {}", self.id, self.selected));
let placement = self.placement;
let axis = match placement {
TabPlacement::Left => Axis::Vertical,
TabPlacement::Top | TabPlacement::Bottom => Axis::Horizontal,
};
let (state, marker) = slide(
(self.id.clone(), "slide"),
&values,
&self.selected,
window,
cx,
);
let turn = motion::changes(
(self.id.clone(), "panel"),
self.selected.clone(),
window,
cx,
);
let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
let focused = focus.is_focused(window);
let tabs = Rc::new(self.tabs);
let pick: Pick = {
let (id, tabs, on_change) = (self.id.clone(), tabs.clone(), self.on_change);
Rc::new(move |ix, window, cx| {
let value = &tabs[ix].value;
log::info!("tabs {id:?}: {value}");
if let Some(on_change) = &on_change {
on_change(value, window, cx);
}
})
};
let theme = cx.theme();
let colors = &theme.colors;
let (thickness, duration) = (theme.tab_indicator(), motion::duration(motion::SLOW, cx));
let indicator = marker.map(
|Marker {
from,
to,
generation,
}| {
let bar = div().absolute().bg(colors.accent);
let bar = match placement {
TabPlacement::Top => bar.bottom_0().h(thickness),
TabPlacement::Bottom => bar.top_0().h(thickness),
TabPlacement::Left => bar.right_0().w(thickness),
};
bar.with_animation(
("tab-indicator", generation),
Animation::new(duration),
move |bar, t| {
let (start, length) = glide(from, to, t);
match axis {
Axis::Horizontal => bar.left(start).w(length),
Axis::Vertical => bar.top(start).h(length),
}
},
)
},
);
let items = tabs
.iter()
.enumerate()
.map(|(ix, tab)| {
let on = ix == chosen;
let fg = match (tab.disabled, on) {
(true, _) => colors.fg_disabled,
(false, true) => colors.fg,
(false, false) => colors.fg_muted,
};
let pick = pick.clone();
div()
.id(("tab", ix))
.relative()
.flex()
.items_center()
.gap_1p5()
.h(theme.control_height(ControlSize::Md))
.px_3()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(if focused && on {
colors.focus
} else {
gpui::transparent_black()
})
.text_size(theme.text_size(TextSize::Sm))
.font_weight(if on {
FontWeight::MEDIUM
} else {
FontWeight::NORMAL
})
.text_color(fg)
.when(!tab.disabled, |item| {
item.cursor_pointer()
.hover(|style| style.text_color(colors.fg))
.on_mouse_down(MouseButton::Left, |_, window, _| {
window.prevent_default()
})
.on_click(move |_, window, cx| {
if !on {
pick(ix, window, cx);
}
})
})
.when_some(tab.icon, |item, icon| {
item.child(Icon::new(icon).size(IconSize::Sm).color(fg))
})
.child(tab.label.clone())
.when_some(tab.note.clone(), |item, note| {
item.child(div().text_color(colors.fg_subtle).child(note))
})
.child(measure_item(state.clone(), ix, axis))
})
.collect::<Vec<_>>();
let (keys, key_pick) = (tabs.clone(), pick.clone());
let strip = div()
.id((self.id.clone(), "strip"))
.track_focus(&focus)
.relative()
.flex()
.when(axis == Axis::Vertical, |strip| strip.flex_col())
.gap_1()
.border_color(colors.border)
.map(|strip| match placement {
TabPlacement::Top => strip.border_b_1(),
TabPlacement::Bottom => strip.border_t_1(),
TabPlacement::Left => strip.border_r_1().pr_1(),
})
.on_key_down(move |event, window, cx| {
let back = if axis == Axis::Vertical { "up" } else { "left" };
let ahead = if axis == Axis::Vertical {
"down"
} else {
"right"
};
let key = event.keystroke.key.as_str();
let to = match key {
key if key == back => step(&keys, chosen, -1),
key if key == ahead => step(&keys, chosen, 1),
"home" => step(&keys, keys.len() - 1, 1),
"end" => step(&keys, 0, -1),
_ => return,
};
cx.stop_propagation();
if to != chosen {
key_pick(to, window, cx);
}
})
.child(measure_origin(state, axis))
.children(indicator)
.children(items);
let panel = self.panel.map(|panel| {
div().flex_1().min_w_0().child(panel).with_animation(
("tab-panel", turn),
Animation::new(motion::duration(motion::BASE, cx))
.with_easing(motion::ease_out_cubic),
move |panel, t| if turn == 0 { panel } else { panel.opacity(t) },
)
});
let frame = div().id(self.id).flex().gap_4().w_full();
match placement {
TabPlacement::Top => frame.flex_col().child(strip).children(panel),
TabPlacement::Bottom => frame.flex_col().children(panel).child(strip),
TabPlacement::Left => frame.child(strip).children(panel),
}
}
}
+235
View File
@@ -0,0 +1,235 @@
use gpui::{
Context, IntoElement, KeyUpEvent, Keystroke, Modifiers, ParentElement, Render, ScrollDelta,
ScrollWheelEvent, SharedString, Styled, TestAppContext, TouchPhase, VisualTestContext, Window,
div, point, px,
};
use super::{Breadcrumb, Crumb, EditorTab, EditorTabs, Tabs, Wizard};
use crate::{forms::Choice, theme::Theme};
fn setup(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
crate::forms::bind_keys(cx);
});
}
fn press(key: &str, cx: &mut VisualTestContext) {
cx.simulate_keystrokes(key);
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse(key).unwrap(),
});
}
struct Strip {
chosen: SharedString,
}
impl Render for Strip {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
let tabs = [
Choice::new("a", "A"),
Choice::new("b", "B").disabled(),
Choice::new("c", "C"),
];
Tabs::new("tabs", tabs, self.chosen.clone()).on_change(move |value, _, cx| {
view.update(cx, |view, cx| {
view.chosen = value.clone();
cx.notify();
})
})
}
}
#[gpui::test]
fn tab_arrows_skip_disabled_tabs_and_wrap(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Strip { chosen: "a".into() });
cx.update(|window, _| window.focus_next());
cx.simulate_keystrokes("right");
assert_eq!(view.read_with(cx, |view, _| view.chosen.clone()), "c");
cx.simulate_keystrokes("right");
assert_eq!(view.read_with(cx, |view, _| view.chosen.clone()), "a");
}
struct Path {
picked: Option<(usize, SharedString)>,
}
impl Render for Path {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
Breadcrumb::new(
"path",
[
Crumb::new("home", "Home"),
Crumb::new("docs", "Docs").siblings([
Choice::new("docs", "Docs"),
Choice::new("music", "Music"),
Choice::new("photos", "Photos"),
]),
Crumb::new("notes", "notes.md"),
],
)
.on_select(move |level, value, _, cx| {
let value = value.clone();
view.update(cx, |view, _| view.picked = Some((level, value)));
})
}
}
#[gpui::test]
fn a_level_lists_its_siblings_and_picks_one(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Path { picked: None });
cx.update(|window, _| {
window.focus_next();
window.focus_next();
});
press("down", cx);
press("down", cx);
press("enter", cx);
let picked = view.read_with(cx, |view, _| view.picked.clone());
assert_eq!(picked, Some((1, SharedString::from("music"))));
}
struct Flow {
step: usize,
done: bool,
}
impl Render for Flow {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (stepper, finisher) = (cx.entity(), cx.entity());
let steps = [
Choice::new("account", "Account"),
Choice::new("plan", "Plan"),
Choice::new("confirm", "Confirm"),
];
Wizard::new("flow", steps, self.step)
.on_step(move |to, _, cx| {
stepper.update(cx, |view, cx| {
view.step = to;
cx.notify();
})
})
.on_finish(move |_, cx| finisher.update(cx, |view, _| view.done = true))
}
}
#[gpui::test]
fn the_wizard_walks_forward_back_and_finishes(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Flow {
step: 0,
done: false,
});
cx.update(|window, _| window.focus_next());
press("enter", cx);
assert_eq!(view.read_with(cx, |view, _| view.step), 1);
cx.update(|window, _| {
window.blur();
window.focus_next();
window.focus_next();
});
press("enter", cx);
assert_eq!(view.read_with(cx, |view, _| view.step), 0);
view.update(cx, |view, cx| {
view.step = 2;
cx.notify();
});
cx.update(|window, _| {
window.blur();
for _ in 0..4 {
window.focus_next();
}
});
press("enter", cx);
assert!(view.read_with(cx, |view, _| view.done));
}
struct Docs {
chosen: SharedString,
clicked: Option<SharedString>,
closed: Option<SharedString>,
}
impl Render for Docs {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (picker, closer) = (cx.entity(), cx.entity());
let tabs = (0..10).map(|ix| EditorTab::new(format!("t{ix}"), format!("document-{ix}.rs")));
div().w(px(200.0)).child(
EditorTabs::new("docs", tabs)
.selected(self.chosen.clone())
.on_select(move |id, _, cx| {
let id = id.clone();
picker.update(cx, |view, _| view.clicked = Some(id));
})
.on_close(move |id, _, cx| {
let id = id.clone();
closer.update(cx, |view, _| view.closed = Some(id));
}),
)
}
}
/// The test platform never runs next-frame callbacks; a refresh stands in for the display link.
fn settle(cx: &mut VisualTestContext) {
cx.run_until_parked();
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
fn click_at(x: f32, cx: &mut VisualTestContext) {
let at = point(px(x), px(16.0));
cx.simulate_mouse_move(at, None, Modifiers::none());
cx.simulate_click(at, Modifiers::none());
}
#[gpui::test]
fn the_chosen_tab_scrolls_in_once_and_a_manual_scroll_stays(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Docs {
chosen: "t9".into(),
clicked: None,
closed: None,
});
settle(cx);
let strip_end = cx
.update(|window, _| window.viewport_size().width)
.min(px(200.0))
- cx.update(|_, cx| {
crate::theme::ActiveTheme::theme(cx)
.control_height(crate::theme::ControlSize::Sm)
.to_pixels(px(16.0))
})
- px(8.0);
click_at(f32::from(strip_end) - 16.0, cx);
let closed = view.read_with(cx, |view, _| view.closed.clone());
assert_eq!(
closed,
Some(SharedString::from("t9")),
"the chosen tab's close button sits at the strip's end"
);
view.update(cx, |view, cx| {
view.chosen = "t8".into();
cx.notify();
});
settle(cx);
cx.simulate_event(ScrollWheelEvent {
position: point(px(60.0), px(16.0)),
delta: ScrollDelta::Pixels(point(px(5000.0), px(0.0))),
modifiers: Modifiers::none(),
touch_phase: TouchPhase::Moved,
});
view.update(cx, |_, cx| cx.notify());
settle(cx);
click_at(6.0, cx);
let clicked = view.read_with(cx, |view, _| view.clicked.clone());
assert_eq!(
clicked,
Some(SharedString::from("t0")),
"a manual scroll stays put"
);
}