navigation::Wizard keeps one focus handle for Next and Finish, so a second press on the step before last finishes; Back that reaches the first step, and a finished step picked by key, hand focus to Next. Popover::with_opener hands its content a close that gives focus back: Got it closes the hotspot's tip before the owner hides it, and the event details close before Edit and Delete run, so focus never lands on a panel that left the tree. The wizard's count and the setup checklist's tally name their selectors after their words, and the bar takes the count's own value.
187 lines
5.8 KiB
Rust
187 lines
5.8 KiB
Rust
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 reached = current + 1;
|
|
let place = format!("Step {reached} of {count}");
|
|
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({
|
|
let place = place.clone();
|
|
move || format!("onboarding-{place}")
|
|
})
|
|
.text_size(theme.text_size(TextSize::Sm))
|
|
.text_color(theme.colors.fg_muted)
|
|
.child(place),
|
|
)
|
|
.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"), reached as f32 / count as f32)
|
|
.segments(count),
|
|
),
|
|
)
|
|
.child(wizard)
|
|
}
|
|
}
|