Onboarding · Flows and highlights: wizard, hotspot, highlight, setup checklist

OnboardingWizard runs a first run on navigation::Wizard without its
numbered steps: "Step 2 of 4" over a bar in as many parts, the step's
title, line and content, Back and Next once ready, Finish last, and
Skip for now. Hotspot pulses on a dot that opens a tip on a press or
Enter, with Got it. FeatureHighlight names something new under a New
badge, with Try it and Not now, and rises in. SetupChecklist counts
first things to do in words and a bar, offers Start on each left, eases
a check in as one gets done, and offers Hide once all are.

navigation::Wizard now keys its buttons, steps and motion by its own id
and takes on_step and on_finish at render, failing loud without them.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 14:20:45 -04:00
1 parent c68e3229bb
commit eea99b8e41
16 files changed
+1288 -27

No files matched your search

+1
View File
@@ -28,6 +28,7 @@ pub mod menus;
pub mod messaging;
pub mod motion;
pub mod navigation;
pub mod onboarding;
pub mod overlays;
pub mod primitives;
pub mod project;
+40 -20
View File
@@ -62,7 +62,8 @@ impl Steps {
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 id = self.id.clone();
let turn = motion::changes((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();
@@ -79,7 +80,7 @@ impl RenderOnce for Steps {
});
let fill = if filled && ix + 1 == current && turn > 0 {
fill.with_animation(
("step-fill", turn),
(id.clone(), format!("fill-{turn}")),
Animation::new(duration).with_easing(motion::ease_out_cubic),
move |fill, t| {
if vertical {
@@ -155,7 +156,7 @@ impl RenderOnce for Steps {
});
let select = self.on_select.clone().filter(|_| done);
let head = div()
.id(("step", ix))
.id((id.clone(), format!("step-{ix}")))
.flex()
.items_center()
.gap_2()
@@ -215,6 +216,7 @@ pub struct Wizard {
current: usize,
content: Option<AnyElement>,
ready: bool,
headless: bool,
on_step: Option<OnStep>,
on_finish: Option<Run>,
}
@@ -225,17 +227,30 @@ impl Wizard {
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: steps.into_iter().collect(),
steps,
current,
content: None,
ready: true,
headless: false,
on_step: None,
on_finish: None,
}
}
/// Leaves out the numbered steps; the owner shows where the flow stands.
pub(crate) fn headless(mut self) -> Self {
self.headless = true;
self
}
/// The current step's content.
pub fn content(mut self, content: impl IntoElement) -> Self {
self.content = Some(content.into_any_element());
@@ -264,28 +279,31 @@ 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 id = self.id.clone();
let on_step = self
.on_step
.unwrap_or_else(|| panic!("wizard {id:?} has no on_step"));
let on_finish = self
.on_finish
.unwrap_or_else(|| panic!("wizard {id:?} has no on_finish"));
let step: OnStep = {
let (id, on_step) = (self.id.clone(), self.on_step);
let id = id.clone();
Rc::new(move |to, window, cx| {
log::info!("wizard {id:?}: step {to}");
if let Some(on_step) = &on_step {
on_step(to, window, cx);
}
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);
let id = id.clone();
move |window: &mut Window, cx: &mut App| {
log::info!("wizard {id:?}: finished");
if let Some(on_finish) = &on_finish {
on_finish(window, cx);
}
on_finish(window, cx);
}
};
let content = self.content.map(|content| {
div().child(content).with_animation(
("wizard-step", turn),
(id.clone(), format!("step-{turn}")),
Animation::new(motion::duration(motion::BASE, cx))
.with_easing(motion::ease_out_cubic),
move |content, t| {
@@ -298,12 +316,12 @@ impl RenderOnce for Wizard {
)
});
let advance = if current == last {
Button::new("wizard-finish", "Finish")
Button::new((id.clone(), "finish"), "Finish")
.primary()
.disabled(!self.ready)
.on_click(move |_, window, cx| finish(window, cx))
} else {
Button::new("wizard-next", "Next")
Button::new((id.clone(), "next"), "Next")
.primary()
.disabled(!self.ready)
.on_click(move |_, window, cx| next(current + 1, window, cx))
@@ -314,10 +332,12 @@ impl RenderOnce for Wizard {
.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)),
)
.when(!self.headless, |wizard| {
wizard.child(
Steps::new((id.clone(), "steps"), self.steps, current)
.on_select(move |to, window, cx| jump(to, window, cx)),
)
})
.children(content)
.child(
div()
@@ -325,7 +345,7 @@ impl RenderOnce for Wizard {
.items_center()
.justify_between()
.child(
Button::new("wizard-back", "Back")
Button::new((id.clone(), "back"), "Back")
.variant(ButtonVariant::Ghost)
.disabled(current == 0)
.on_click(move |_, window, cx| back(current - 1, window, cx)),
+219
View File
@@ -0,0 +1,219 @@
use std::rc::Rc;
use gpui::{
Animation, AnimationExt, App, ElementId, FontWeight, InteractiveElement, IntoElement,
ParentElement, RenderOnce, SharedString, Styled, Window, div, prelude::*,
};
use crate::{
buttons::{Button, ButtonVariant},
forms::Run,
motion::{self, ProgressBar},
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
typography::tabular,
};
type OnKey = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// A first thing to do: its key, its title, a line on it, and whether it is done.
#[derive(Clone, Debug, PartialEq)]
pub struct SetupTask {
pub key: SharedString,
pub title: SharedString,
pub body: SharedString,
pub done: bool,
}
/// First things to do in a new app: how many are done, in words and a bar in as many parts; each task with its line and Start while not done, done ones struck through. The check eases in as a task gets done. Once all are, it says so and, with `on_dismiss`, offers Hide.
#[derive(IntoElement)]
pub struct SetupChecklist {
id: ElementId,
title: SharedString,
tasks: Vec<SetupTask>,
on_start: Option<OnKey>,
on_dismiss: Option<Run>,
}
impl SetupChecklist {
pub fn new(
id: impl Into<ElementId>,
title: impl Into<SharedString>,
tasks: impl IntoIterator<Item = SetupTask>,
) -> Self {
let tasks: Vec<SetupTask> = tasks.into_iter().collect();
assert!(
tasks.len() >= 2,
"a setup checklist needs two tasks, got {}",
tasks.len()
);
Self {
id: id.into(),
title: title.into(),
tasks,
on_start: None,
on_dismiss: None,
}
}
/// Runs with the key of the task whose Start was pressed.
pub fn on_start(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_start = Some(Rc::new(handler));
self
}
/// Shows Hide once every task is done.
pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_dismiss = Some(Rc::new(handler));
self
}
}
impl RenderOnce for SetupChecklist {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id;
let on_start = self
.on_start
.unwrap_or_else(|| panic!("setup checklist {id:?} has no on_start"));
let (done, all) = (
self.tasks.iter().filter(|task| task.done).count(),
self.tasks.len(),
);
let turns: Vec<usize> = self
.tasks
.iter()
.map(|task| {
motion::changes(
(id.clone(), format!("done-{}", task.key)),
task.done,
window,
cx,
)
})
.collect();
let theme = cx.theme();
let duration = motion::duration(motion::BASE, cx);
let rows = self
.tasks
.into_iter()
.zip(turns)
.enumerate()
.map(|(ix, (task, turn))| {
let (icon, ink) = match task.done {
true => (IconName::CircleCheck, theme.colors.success),
false => (IconName::Circle, theme.colors.fg_subtle),
};
let mark = div()
.flex_none()
.pt_0p5()
.child(Icon::new(icon).size(IconSize::Sm).color(ink))
.with_animation(
(id.clone(), format!("mark-{}-{turn}", task.key)),
Animation::new(duration).with_easing(motion::ease_out_cubic),
move |mark, t| match turn {
0 => mark,
_ => mark.opacity(t),
},
);
let start = (!task.done).then(|| {
let (key, start) = (task.key.clone(), on_start.clone());
Button::new((id.clone(), format!("start-{}", task.key)), "Start")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("setup checklist: start {key}");
start(&key, window, cx)
})
});
let key = task.key.clone();
div()
.debug_selector(move || format!("setup-task-{key}"))
.flex()
.items_start()
.gap_3()
.py_2p5()
.when(ix > 0, |row| {
row.border_t_1().border_color(theme.colors.border)
})
.child(mark)
.child(
div()
.flex_1()
.min_w_0()
.child(
div()
.font_weight(FontWeight::MEDIUM)
.when(task.done, |title| {
title.line_through().text_color(theme.colors.fg_muted)
})
.child(task.title),
)
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child(task.body),
),
)
.children(start.map(|start| div().flex_none().child(start)))
});
let finished = (done == all).then(|| {
div()
.flex()
.flex_wrap()
.items_center()
.justify_between()
.gap_2()
.pt_2()
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.success)
.child("All set."),
)
.children(self.on_dismiss.map(|run| {
Button::new((id.clone(), "hide"), "Hide")
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("setup checklist: hidden");
run(window, cx)
})
}))
});
div()
.flex()
.flex_col()
.gap_2()
.child(
div()
.flex()
.flex_wrap()
.items_center()
.justify_between()
.gap_2()
.child(
div()
.flex_1()
.min_w(theme.label_width())
.font_weight(FontWeight::SEMIBOLD)
.child(self.title),
)
.child(
tabular(div())
.debug_selector(move || format!("setup-{done}-of-{all}"))
.flex_none()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child(format!("{done} of {all} done")),
),
)
.child(
ProgressBar::new((id.clone(), "progress"), done as f32 / all as f32).segments(all),
)
.child(div().flex().flex_col().children(rows))
.children(finished)
}
}
+134
View File
@@ -0,0 +1,134 @@
use std::rc::Rc;
use gpui::{
Animation, AnimationExt, App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce,
SharedString, Styled, Window, div,
};
use crate::{
buttons::{Button, ButtonVariant},
data_display::{Badge, Tone},
forms::Run,
layout::Card,
motion,
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
};
/// Word of something new: its icon, a New badge over its name, a line on what it does, then Try it and Not now. It rises in when it appears.
#[derive(IntoElement)]
pub struct FeatureHighlight {
id: ElementId,
icon: IconName,
title: SharedString,
body: SharedString,
on_try: Option<Run>,
on_dismiss: Option<Run>,
}
impl FeatureHighlight {
pub fn new(
id: impl Into<ElementId>,
icon: IconName,
title: impl Into<SharedString>,
body: impl Into<SharedString>,
) -> Self {
Self {
id: id.into(),
icon,
title: title.into(),
body: body.into(),
on_try: None,
on_dismiss: None,
}
}
pub fn on_try(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_try = Some(Rc::new(handler));
self
}
/// Runs on Not now; the owner stops showing it.
pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_dismiss = Some(Rc::new(handler));
self
}
}
impl RenderOnce for FeatureHighlight {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id;
let try_it = self
.on_try
.unwrap_or_else(|| panic!("feature highlight {id:?} has no on_try"));
let dismiss = self
.on_dismiss
.unwrap_or_else(|| panic!("feature highlight {id:?} has no on_dismiss"));
let theme = cx.theme();
let title = self.title.clone();
let actions = div()
.flex()
.flex_wrap()
.gap_2()
.mt_3()
.child(
Button::new((id.clone(), "try"), "Try it")
.variant(ButtonVariant::Primary)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("feature highlight: try {title}");
try_it(window, cx)
}),
)
.child(
Button::new((id.clone(), "dismiss"), "Not now")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| dismiss(window, cx)),
);
let words = div()
.flex_1()
.min_w_0()
.child(div().flex().child(Badge::new("New").tone(Tone::Accent)))
.child(
div()
.mt_2()
.text_size(theme.text_size(TextSize::Base))
.font_weight(FontWeight::SEMIBOLD)
.child(self.title),
)
.child(
div()
.mt_1()
.text_color(theme.colors.fg_muted)
.child(self.body),
)
.child(actions);
let mark = div()
.flex_none()
.p_2()
.rounded(theme.radius(Radius::Md))
.bg(theme.colors.sunken)
.child(
Icon::new(self.icon)
.size(IconSize::Md)
.color(theme.colors.fg_muted),
);
Card::new()
.child(
div()
.flex()
.items_start()
.gap_3()
.text_size(theme.text_size(TextSize::Sm))
.child(mark)
.child(words),
)
.with_animation(
(id, "in"),
Animation::new(motion::duration(motion::SLOW, cx))
.with_easing(motion::ease_out_cubic),
|card, t| card.opacity(t).mt(motion::NUDGE * 2.0 * (1.0 - t)),
)
}
}
+124
View File
@@ -0,0 +1,124 @@
use std::rc::Rc;
use gpui::{
App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
};
use crate::{
buttons::{Button, ButtonVariant},
forms::Run,
motion::Pulse,
overlays::Popover,
primitives::FocusRing,
theme::{ActiveTheme, ControlSize, TextSize},
};
/// The dot: a Tab stop that opens the tip on a press or on Enter.
#[derive(IntoElement)]
struct Dot {
id: ElementId,
toggle: Run,
}
impl RenderOnce for Dot {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let side = theme.onboarding().hotspot;
let toggle = self.toggle;
div()
.id(self.id.clone())
.flex()
.flex_none()
.items_center()
.justify_center()
.size(side * 2.0)
.rounded_full()
.border_1()
.border_color(gpui::transparent_black())
.tab_index(0)
.focus_ring(cx)
.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| toggle(window, cx))
.child(
Pulse::new((self.id, "pulse"))
.size(side)
.rounded_full()
.bg(theme.colors.accent),
)
}
}
/// A dot that calls out something new, with rings that widen from it; a press or Enter opens a tip beside it with a title, a line and Got it. Under reduced motion no ring shows.
#[derive(IntoElement)]
pub struct Hotspot {
id: ElementId,
title: SharedString,
body: SharedString,
on_dismiss: Option<Run>,
}
impl Hotspot {
pub fn new(
id: impl Into<ElementId>,
title: impl Into<SharedString>,
body: impl Into<SharedString>,
) -> Self {
Self {
id: id.into(),
title: title.into(),
body: body.into(),
on_dismiss: None,
}
}
/// Runs on Got it; the owner stops showing the hotspot.
pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_dismiss = Some(Rc::new(handler));
self
}
}
impl RenderOnce for Hotspot {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let id = self.id;
let dismiss = self
.on_dismiss
.unwrap_or_else(|| panic!("hotspot {id:?} has no on_dismiss"));
let (dot, got_it) = ((id.clone(), "dot"), (id.clone(), "got-it"));
let (title, body) = (self.title, self.body);
Popover::with_opener(
id.clone(),
move |toggle| Dot {
id: dot.into(),
toggle,
},
move |_, cx| {
let theme = cx.theme();
let prose =
|text: SharedString| div().flex().child(div().flex_1().min_w_0().child(text));
div()
.debug_selector(|| "hotspot-tip".into())
.w(theme.tooltip_max_width())
.flex()
.flex_col()
.gap_2()
.text_size(theme.text_size(TextSize::Sm))
.child(prose(title).font_weight(FontWeight::SEMIBOLD))
.child(prose(body).text_color(theme.colors.fg_muted))
.child(
div().flex().justify_end().pt_1().child(
Button::new(got_it, "Got it")
.variant(ButtonVariant::Primary)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("hotspot: dismissed");
dismiss(window, cx)
}),
),
)
},
)
}
}
+12
View File
@@ -0,0 +1,12 @@
mod checklist;
mod highlight;
mod hotspot;
mod wizard;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub use checklist::{SetupChecklist, SetupTask};
pub use highlight::FeatureHighlight;
pub use hotspot::Hotspot;
pub use wizard::{OnboardingStep, OnboardingWizard};
+235
View File
@@ -0,0 +1,235 @@
use gpui::{
AnyElement, App, Context, Entity, IntoElement, KeyBinding, ParentElement, Render, Styled,
TestAppContext, VisualTestContext, Window, div, px,
};
use super::{
FeatureHighlight, Hotspot, OnboardingStep, OnboardingWizard, SetupChecklist, SetupTask,
};
use crate::{
forms,
primitives::{FocusNext, IconName},
theme::Theme,
};
type Part = fn(&Bench, Entity<Bench>) -> AnyElement;
/// A view that shows one onboarding part, keeps what it heard, and holds the owner's step and readiness.
struct Bench {
part: Part,
said: Vec<String>,
step: usize,
ready: bool,
all_done: bool,
}
impl Render for Bench {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.w(px(360.0))
.p_4()
.child((self.part)(self, cx.entity()))
}
}
fn bench(part: Part, cx: &mut TestAppContext) -> (Entity<Bench>, &mut VisualTestContext) {
cx.update(|cx| {
Theme::init(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
forms::bind_keys(cx);
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
});
let (host, cx) = cx.add_window_view(|_, _| Bench {
part,
said: Vec::new(),
step: 0,
ready: true,
all_done: false,
});
settle(cx);
(host, cx)
}
fn settle(cx: &mut VisualTestContext) {
for _ in 0..3 {
cx.run_until_parked();
cx.update(|window, _| window.refresh());
}
cx.run_until_parked();
}
fn say(owner: &Entity<Bench>, words: String, cx: &mut App) {
owner.update(cx, |bench, cx| {
bench.said.push(words);
cx.notify();
});
}
fn said(host: &Entity<Bench>, cx: &mut VisualTestContext) -> Vec<String> {
host.read_with(cx, |bench, _| bench.said.clone())
}
fn edit(host: &Entity<Bench>, cx: &mut VisualTestContext, change: impl FnOnce(&mut Bench)) {
host.update(cx, |bench, cx| {
change(bench);
cx.notify();
});
settle(cx);
}
/// Focus on the `stops`th Tab stop from the top.
fn tab(stops: usize, cx: &mut VisualTestContext) {
cx.update(|window, _| {
window.blur();
(0..stops).for_each(|_| window.focus_next());
});
settle(cx);
}
fn tap(key: &str, cx: &mut VisualTestContext) {
cx.simulate_keystrokes(key);
cx.simulate_event(gpui::KeyUpEvent {
keystroke: gpui::Keystroke::parse(key).expect("a key"),
});
settle(cx);
}
fn wizard(bench: &Bench, owner: Entity<Bench>) -> AnyElement {
let steps = ["name", "invite", "theme"].map(|key| OnboardingStep {
key: key.into(),
title: format!("About {key}").into(),
body: "A line on it".into(),
});
let [stepper, finisher, skipper] = [(); 3].map(|_| owner.clone());
OnboardingWizard::new("welcome", steps, bench.step)
.ready(bench.ready)
.content(div().child("Fields"))
.on_step(move |to, _, cx| {
stepper.update(cx, |bench, cx| {
bench.step = to;
bench.said.push(format!("step {to}"));
cx.notify();
})
})
.on_finish(move |_, cx| say(&finisher, "finish".into(), cx))
.on_skip(move |_, cx| say(&skipper, "skip".into(), cx))
.into_any_element()
}
/// Stops: Skip, then Next on the first step; Skip, Back, then Next or Finish after it.
#[gpui::test]
fn the_wizard_walks_on_and_back_and_finishes(cx: &mut TestAppContext) {
let (host, cx) = bench(wizard, cx);
assert!(cx.debug_bounds("onboarding-step-1-of-3").is_some());
tab(2, cx);
tap("space", cx);
tab(2, cx);
tap("space", cx);
edit(&host, cx, |bench| bench.step = 2);
assert!(cx.debug_bounds("onboarding-step-3-of-3").is_some());
tab(3, cx);
tap("space", cx);
tab(1, cx);
tap("space", cx);
assert_eq!(said(&host, cx), ["step 1", "step 0", "finish", "skip"]);
}
/// While the step's fields are not ready, Next rests, so the second stop wraps to Skip.
#[gpui::test]
fn next_rests_until_the_step_is_ready(cx: &mut TestAppContext) {
let (host, cx) = bench(wizard, cx);
edit(&host, cx, |bench| bench.ready = false);
tab(2, cx);
tap("space", cx);
assert_eq!(said(&host, cx), ["skip"]);
}
fn hotspot(_: &Bench, owner: Entity<Bench>) -> AnyElement {
Hotspot::new("pins", "Pin a view", "Keep it one press away.")
.on_dismiss(move |_, cx| say(&owner, "seen".into(), cx))
.into_any_element()
}
/// Stops: the dot; Enter opens the tip, where Got it is the one stop.
#[gpui::test]
fn a_hotspot_opens_its_tip_from_the_keyboard_and_is_dismissed(cx: &mut TestAppContext) {
let (host, cx) = bench(hotspot, cx);
assert!(
cx.debug_bounds("hotspot-tip").is_none(),
"the tip rests shut"
);
tab(1, cx);
tap("enter", cx);
assert!(cx.debug_bounds("hotspot-tip").is_some());
tap("escape", cx);
tap("enter", cx);
tap("tab", cx);
tap("space", cx);
assert_eq!(
said(&host, cx),
["seen"],
"Escape handed focus back to the dot"
);
}
fn highlight(_: &Bench, owner: Entity<Bench>) -> AnyElement {
let later = owner.clone();
FeatureHighlight::new(
"split",
IconName::Columns2,
"Split view",
"Work on two files side by side.",
)
.on_try(move |_, cx| say(&owner, "try".into(), cx))
.on_dismiss(move |_, cx| say(&later, "later".into(), cx))
.into_any_element()
}
/// Stops: Try it, then Not now.
#[gpui::test]
fn a_feature_highlight_is_tried_or_put_off(cx: &mut TestAppContext) {
let (host, cx) = bench(highlight, cx);
tab(1, cx);
tap("space", cx);
tab(2, cx);
tap("space", cx);
assert_eq!(said(&host, cx), ["try", "later"]);
}
fn checklist(bench: &Bench, owner: Entity<Bench>) -> AnyElement {
let task = |key: &str, done: bool| SetupTask {
key: key.to_string().into(),
title: format!("Do {key}").into(),
body: "Why it helps".into(),
done: done || bench.all_done,
};
let hidden = owner.clone();
SetupChecklist::new(
"setup",
"Get started",
[
task("profile", true),
task("invite", false),
task("connect", false),
],
)
.on_start(move |key, _, cx| say(&owner, format!("start {key}"), cx))
.on_dismiss(move |_, cx| say(&hidden, "hide".into(), cx))
.into_any_element()
}
/// Stops: Start on each task not done, so the third wraps to the first; once all are done, Hide.
#[gpui::test]
fn a_setup_checklist_starts_what_is_left_and_hides_when_done(cx: &mut TestAppContext) {
let (host, cx) = bench(checklist, cx);
assert!(cx.debug_bounds("setup-1-of-3").is_some());
tab(2, cx);
tap("space", cx);
tab(3, cx);
tap("space", cx);
edit(&host, cx, |bench| bench.all_done = true);
assert!(cx.debug_bounds("setup-3-of-3").is_some());
tab(1, cx);
tap("space", cx);
assert_eq!(said(&host, cx), ["start connect", "start invite", "hide"]);
}
+184
View File
@@ -0,0 +1,184 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, Styled, Window, div,
};
use crate::{
buttons::{Button, ButtonVariant},
forms::{Choice, Run},
motion::ProgressBar,
navigation::Wizard,
theme::{ActiveTheme, ControlSize, TextSize},
};
type OnStep = Rc<dyn Fn(usize, &mut Window, &mut App)>;
/// A step on the way in: its key, its title and a line on what it is for.
#[derive(Clone, Debug, PartialEq)]
pub struct OnboardingStep {
pub key: SharedString,
pub title: SharedString,
pub body: SharedString,
}
/// A first-run flow on `navigation::Wizard`: "Step 2 of 4" over a bar in as many parts, the step's title, line and content, then Back and Next; Next is Finish on the last step. With `on_skip`, Skip for now leaves early.
#[derive(IntoElement)]
pub struct OnboardingWizard {
id: ElementId,
steps: Vec<OnboardingStep>,
current: usize,
content: Option<AnyElement>,
ready: bool,
on_step: Option<OnStep>,
on_finish: Option<Run>,
on_skip: Option<Run>,
}
impl OnboardingWizard {
pub fn new(
id: impl Into<ElementId>,
steps: impl IntoIterator<Item = OnboardingStep>,
current: usize,
) -> Self {
let steps: Vec<OnboardingStep> = steps.into_iter().collect();
assert!(
steps.len() >= 2,
"an onboarding wizard needs two steps, got {}",
steps.len()
);
assert!(
current < steps.len(),
"step {current} is past the last of {}",
steps.len()
);
Self {
id: id.into(),
steps,
current,
content: None,
ready: true,
on_step: None,
on_finish: None,
on_skip: None,
}
}
/// The current step's content, under its title and line.
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 or Next 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
}
/// Shows Skip for now.
pub fn on_skip(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_skip = Some(Rc::new(handler));
self
}
}
impl RenderOnce for OnboardingWizard {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id;
let on_step = self
.on_step
.unwrap_or_else(|| panic!("onboarding wizard {id:?} has no on_step"));
let on_finish = self
.on_finish
.unwrap_or_else(|| panic!("onboarding wizard {id:?} has no on_finish"));
let theme = cx.theme();
let (current, count) = (self.current, self.steps.len());
let skip = self.on_skip.map(|run| {
Button::new((id.clone(), "skip"), "Skip for now")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("onboarding wizard: skipped at step {current}");
run(window, cx)
})
});
let head = div()
.flex()
.flex_wrap()
.items_center()
.justify_between()
.gap_2()
.child(
div()
.debug_selector(move || format!("onboarding-step-{}-of-{count}", current + 1))
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child(format!("Step {} of {count}", current + 1)),
)
.children(skip.map(|skip| div().flex_none().child(skip)));
let step = &self.steps[current];
let words = div().flex().child(
div()
.flex_1()
.min_w_0()
.child(
div()
.text_size(theme.text_size(TextSize::Lg))
.font_weight(FontWeight::SEMIBOLD)
.child(step.title.clone()),
)
.child(
div()
.mt_1()
.text_color(theme.colors.fg_muted)
.child(step.body.clone()),
),
);
let wizard = Wizard::new(
(id.clone(), "wizard"),
self.steps
.iter()
.map(|step| Choice::new(step.key.clone(), step.title.clone())),
current,
)
.headless()
.ready(self.ready)
.content(
div()
.flex()
.flex_col()
.gap_5()
.child(words)
.children(self.content),
)
.on_step(move |to, window, cx| on_step(to, window, cx))
.on_finish(move |window, cx| on_finish(window, cx));
div()
.flex()
.flex_col()
.gap_6()
.child(
div().flex().flex_col().gap_2().child(head).child(
ProgressBar::new(
(id.clone(), "progress"),
(current + 1) as f32 / count as f32,
)
.segments(count),
),
)
.child(wizard)
}
}
+2
View File
@@ -6,6 +6,7 @@ mod files;
mod generative;
mod media;
mod messaging;
mod onboarding;
mod palette;
mod project;
mod tokens;
@@ -23,6 +24,7 @@ pub use files::FileSizes;
pub use generative::GenerativeSizes;
pub use media::MediaSizes;
pub use messaging::MessagingSizes;
pub use onboarding::OnboardingSizes;
pub use palette::{HUE_NAMES, Mix, Palette, Syntax};
pub use project::ProjectSizes;
pub use tokens::{
+18
View File
@@ -0,0 +1,18 @@
use gpui::Rems;
use super::{Theme, tokens::px_to_rems};
/// Onboarding's measures.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct OnboardingSizes {
/// A hotspot's dot, wide; its rings reach twice that.
pub hotspot: Rems,
}
impl Theme {
pub fn onboarding(&self) -> OnboardingSizes {
OnboardingSizes {
hotspot: px_to_rems(10.0),
}
}
}