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:
50 files changed
+2490
-330
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(¤t),
|
||||
"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");
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user