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:
1 parent
c68e3229bb
commit
eea99b8e41
16 files changed
+1288
-27
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user