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
@@ -78,6 +78,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- A focus handle a component hands back lives across all its modes: take it with `tab_stop` before any early return, or keyed state drops it while the other mode draws and the handed-back focus lands nowhere. A thread's message keeps one handle folded and open, a Tab stop only while folded (`mail::MailThreadView`).
|
||||
- A button whose words and action change with a mode keeps one handle through `Button::focus_handle`, and the component moves focus to it or back to its field as the mode turns (`project::TimeTracker`).
|
||||
- A panel that opens from something other than a button goes through `overlays::Popover::with_opener`, which hands the opener its toggle; focus, placement and every way out stay in the popover (`calendar::EventPopover`'s chip).
|
||||
- A flow in steps goes through `navigation::Wizard`: Back, Next, Finish and the step's motion live there. `onboarding::OnboardingWizard` leaves out its numbered steps (`headless`) and shows a count over a bar in as many parts.
|
||||
- A dialog goes through `overlays::Dialog`: scrim, focus trap, Escape and focus return live there. Its close runs on every way out, so a dialog with two answers marks the chosen one before it closes (`messaging::IncomingCallDialog`). AlertDialog, ConfirmDialog and PromptDialog build on it; a fullscreen dialog fills the window below its title bar. A dialog's card fits the window, a title bar's height clear at each end; its body scrolls and its actions stay.
|
||||
- Controls on a dark layer over pictures are `overlays::media_button`, light icons that dim when they have nowhere to go, for the lightbox and a video player's bar alike.
|
||||
- A component's letter keys step aside while Command or Control is held, so the app's shortcuts reach the app (`media::ImageAnnotator`, `VideoPlayer`, `VideoThumbnailStrip`).
|
||||
|
||||
@@ -128,7 +128,8 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
- [x] T36a Account · Sign in — `tasks/ch31-43.md`
|
||||
- [x] T36b Account · Profile and access — `tasks/ch31-43.md`
|
||||
- [x] T36c Account · Billing and team — `tasks/ch31-43.md`
|
||||
- [ ] T37 Onboarding & Help
|
||||
- [ ] T37a Onboarding · Flows and highlights
|
||||
- [ ] T37b Onboarding · Help
|
||||
- [ ] T38 Interaction
|
||||
- [ ] T39 Theme
|
||||
- [ ] T40 i18n & a11y
|
||||
|
||||
@@ -26,6 +26,7 @@ mod menus;
|
||||
mod messaging;
|
||||
mod motion;
|
||||
mod navigation;
|
||||
mod onboarding;
|
||||
mod overlays;
|
||||
mod primitives;
|
||||
mod project;
|
||||
@@ -87,6 +88,7 @@ pub const ALL: &[Page] = &[
|
||||
dashboard::PAGE,
|
||||
settings::PAGE,
|
||||
account::PAGE,
|
||||
onboarding::PAGE,
|
||||
theme::PAGE,
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant, IconButton},
|
||||
data_display::{Badge, Tone},
|
||||
forms::{FormField, Input, TextInput},
|
||||
lists::{List, ListItem},
|
||||
onboarding::{
|
||||
FeatureHighlight, Hotspot, OnboardingStep, OnboardingWizard, SetupChecklist, SetupTask,
|
||||
},
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize},
|
||||
};
|
||||
use gpui::{App, Entity, IntoElement, ParentElement, Styled, Window, div, prelude::*, px};
|
||||
|
||||
use crate::{
|
||||
probe::probe,
|
||||
ui::{change, keep, section},
|
||||
};
|
||||
|
||||
/// The wizard demo: its step, and whether it was finished.
|
||||
#[derive(Default)]
|
||||
struct Welcome {
|
||||
step: usize,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
fn field(
|
||||
key: &'static str,
|
||||
hint: &'static str,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<TextInput> {
|
||||
window.use_keyed_state(key, cx, move |window, cx| {
|
||||
TextInput::new(window, cx).placeholder(hint)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn wizard(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let state = keep("onboarding-welcome", Welcome::default, window, cx);
|
||||
let name = field("onboarding-name", "Acme", window, cx);
|
||||
let invite = field("onboarding-invite", "name@example.com", window, cx);
|
||||
let (step, finished) = (state.read(cx).step, state.read(cx).finished);
|
||||
let step_of = |key: &str, title: &str, body: &str| OnboardingStep {
|
||||
key: key.to_string().into(),
|
||||
title: title.to_string().into(),
|
||||
body: body.to_string().into(),
|
||||
};
|
||||
let steps = [
|
||||
step_of(
|
||||
"name",
|
||||
"Name your workspace",
|
||||
"It shows in the sidebar and in every invitation.",
|
||||
),
|
||||
step_of(
|
||||
"invite",
|
||||
"Bring your team",
|
||||
"Add an address now, or later from Settings.",
|
||||
),
|
||||
step_of(
|
||||
"ready",
|
||||
"You're set",
|
||||
"Your workspace is ready. Press Finish to open it.",
|
||||
),
|
||||
];
|
||||
let ready = step != 0 || !name.read(cx).text().trim().is_empty();
|
||||
let content = match step {
|
||||
0 => FormField::new("onboarding-name-field", "Workspace name")
|
||||
.child(Input::new(&name))
|
||||
.into_any_element(),
|
||||
1 => FormField::new("onboarding-invite-field", "Email")
|
||||
.child(Input::new(&invite))
|
||||
.into_any_element(),
|
||||
_ => div().into_any_element(),
|
||||
};
|
||||
let [walk, finish, skip, again] = [(); 4].map(|_| state.clone());
|
||||
let body = match finished {
|
||||
true => div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.child(format!("{} is ready.", name.read(cx).text()))
|
||||
.child(
|
||||
Button::new("onboarding-again", "Start over")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, _, cx| {
|
||||
change(&again, cx, |welcome| *welcome = Welcome::default())
|
||||
}),
|
||||
)
|
||||
.into_any_element(),
|
||||
false => OnboardingWizard::new("onboarding-wizard", steps, step)
|
||||
.content(content)
|
||||
.ready(ready)
|
||||
.on_step(move |to, _, cx| change(&walk, cx, |welcome| welcome.step = to))
|
||||
.on_finish(move |_, cx| change(&finish, cx, |welcome| welcome.finished = true))
|
||||
.on_skip(move |_, cx| change(&skip, cx, |welcome| welcome.finished = true))
|
||||
.into_any_element(),
|
||||
};
|
||||
section(
|
||||
"OnboardingWizard · WelcomeTour",
|
||||
"A first run in a few steps: where it stands over a bar in as many parts, each step's title and line, and Next once its fields are ready. The welcome tour that follows is overlays::Tour.",
|
||||
cx,
|
||||
)
|
||||
.child(probe("onboarding-wizard", div().w(px(440.)).child(body)))
|
||||
}
|
||||
|
||||
/// The highlights demo: what was dismissed.
|
||||
#[derive(Default)]
|
||||
struct Seen {
|
||||
hotspot: bool,
|
||||
highlight: bool,
|
||||
}
|
||||
|
||||
pub fn highlights(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let state = keep("onboarding-seen", Seen::default, window, cx);
|
||||
let (spot_seen, card_seen) = (state.read(cx).hotspot, state.read(cx).highlight);
|
||||
let [spot, card, reset] = [(); 3].map(|_| state.clone());
|
||||
let theme = cx.theme();
|
||||
let tool =
|
||||
|id: &'static str, icon: IconName| IconButton::new(id, icon).variant(ButtonVariant::Ghost);
|
||||
let pin = div()
|
||||
.relative()
|
||||
.child(tool("onboarding-pin", IconName::Pin))
|
||||
.when(!spot_seen, |pin| {
|
||||
pin.child(
|
||||
div().absolute().top_neg_1p5().right_neg_1p5().child(probe(
|
||||
"onboarding-hotspot",
|
||||
Hotspot::new(
|
||||
"onboarding-hotspot",
|
||||
"Pin a view",
|
||||
"Keep the views you use most one press away.",
|
||||
)
|
||||
.on_dismiss(move |_, cx| change(&spot, cx, |seen| seen.hotspot = true)),
|
||||
)),
|
||||
)
|
||||
});
|
||||
let toolbar = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.p_1()
|
||||
.rounded_lg()
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.child(tool("onboarding-search", IconName::Search))
|
||||
.child(pin)
|
||||
.child(tool("onboarding-share", IconName::Share2));
|
||||
let highlight = (!card_seen).then(|| {
|
||||
FeatureHighlight::new(
|
||||
"onboarding-split",
|
||||
IconName::Columns2,
|
||||
"Split view",
|
||||
"Work on two files side by side, each with its own history.",
|
||||
)
|
||||
.on_try(|_, _| log::info!("gallery: split view tried"))
|
||||
.on_dismiss(move |_, cx| change(&card, cx, |seen| seen.highlight = true))
|
||||
});
|
||||
let menu = List::new()
|
||||
.child(
|
||||
ListItem::new("onboarding-inbox", "Inbox")
|
||||
.leading(Icon::new(IconName::Inbox).size(IconSize::Sm)),
|
||||
)
|
||||
.child(
|
||||
ListItem::new("onboarding-automations", "Automations")
|
||||
.leading(Icon::new(IconName::Zap).size(IconSize::Sm))
|
||||
.trailing(Badge::new("New").tone(Tone::Accent)),
|
||||
);
|
||||
section(
|
||||
"Coachmark / Hotspot · FeatureHighlight / NewBadge",
|
||||
"A hotspot's rings call out something new, and a press or Enter opens its tip. A highlight names a feature with a New badge over it; the badge alone marks a new row. Coachmarks are overlays::Spotlight.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_start()
|
||||
.gap_6()
|
||||
.child(div().w(px(240.)).flex().flex_col().gap_6().child(toolbar).child(menu))
|
||||
.child(div().w(px(360.)).children(highlight))
|
||||
.when(spot_seen || card_seen, |row| {
|
||||
row.child(
|
||||
Button::new("onboarding-show-again", "Show again")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, _, cx| change(&reset, cx, |seen| *seen = Seen::default())),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Which setup tasks are done, and whether the list was hidden.
|
||||
struct Setup {
|
||||
done: [bool; 4],
|
||||
hidden: bool,
|
||||
}
|
||||
|
||||
pub fn checklist(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let state = keep(
|
||||
"onboarding-setup",
|
||||
|| Setup {
|
||||
done: [true, false, false, false],
|
||||
hidden: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let (done, hidden) = (state.read(cx).done, state.read(cx).hidden);
|
||||
let [start, hide, reset] = [(); 3].map(|_| state.clone());
|
||||
const TASKS: [(&str, &str, &str); 4] = [
|
||||
(
|
||||
"profile",
|
||||
"Add your photo",
|
||||
"So your team knows who is who.",
|
||||
),
|
||||
("invite", "Invite a teammate", "Work is better shared."),
|
||||
(
|
||||
"connect",
|
||||
"Connect your calendar",
|
||||
"See meetings beside your tasks.",
|
||||
),
|
||||
(
|
||||
"shortcuts",
|
||||
"Learn three shortcuts",
|
||||
"Command K opens everything.",
|
||||
),
|
||||
];
|
||||
let tasks = TASKS
|
||||
.iter()
|
||||
.zip(done)
|
||||
.map(|((key, title, body), done)| SetupTask {
|
||||
key: (*key).into(),
|
||||
title: (*title).into(),
|
||||
body: (*body).into(),
|
||||
done,
|
||||
});
|
||||
let body = match hidden {
|
||||
true => Button::new("onboarding-setup-again", "Show the checklist")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, _, cx| {
|
||||
change(&reset, cx, |setup| {
|
||||
*setup = Setup {
|
||||
done: [true, false, false, false],
|
||||
hidden: false,
|
||||
}
|
||||
})
|
||||
})
|
||||
.into_any_element(),
|
||||
false => SetupChecklist::new("onboarding-setup", "Get started", tasks)
|
||||
.on_start(move |key, _, cx| {
|
||||
let at = TASKS
|
||||
.iter()
|
||||
.position(|(each, _, _)| *each == key.as_ref())
|
||||
.expect("a setup task");
|
||||
change(&start, cx, |setup| setup.done[at] = true)
|
||||
})
|
||||
.on_dismiss(move |_, cx| change(&hide, cx, |setup| setup.hidden = true))
|
||||
.into_any_element(),
|
||||
};
|
||||
section(
|
||||
"Checklist (first-run tasks)",
|
||||
"First things to do, counted in words and a bar. Start opens each; here it marks it done, and the check eases in. Once all are done, Hide puts it away.",
|
||||
cx,
|
||||
)
|
||||
.child(probe("onboarding-setup", div().w(px(400.)).child(body)))
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use gpui::{AnyElement, App, Window, div, prelude::*};
|
||||
|
||||
mod flows;
|
||||
|
||||
use super::Page;
|
||||
use crate::script::Step;
|
||||
|
||||
pub const PAGE: Page = Page {
|
||||
number: 37,
|
||||
slug: "onboarding",
|
||||
title: "Onboarding & Help",
|
||||
summary: "A first run in steps, what is new called out, and first things to do.",
|
||||
render,
|
||||
script: SCRIPT,
|
||||
};
|
||||
|
||||
const SCRIPT: &[Step] = &[
|
||||
Step::DownAt("onboarding-wizard", 200.0, 167.0),
|
||||
Step::UpAt("onboarding-wizard", 200.0, 167.0),
|
||||
Step::Type("Acme"),
|
||||
Step::Key("tab"),
|
||||
Step::Key("enter"),
|
||||
Step::Wait(200),
|
||||
Step::Shot("wizard-step-2"),
|
||||
Step::DownAt("onboarding-hotspot", 10.0, 10.0),
|
||||
Step::UpAt("onboarding-hotspot", 10.0, 10.0),
|
||||
Step::Wait(200),
|
||||
Step::Shot("hotspot-tip"),
|
||||
Step::Key("escape"),
|
||||
];
|
||||
|
||||
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
div()
|
||||
.child(flows::wizard(window, cx))
|
||||
.child(flows::highlights(window, cx))
|
||||
.child(flows::checklist(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -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
@@ -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)),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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::{
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-6
@@ -198,13 +198,16 @@
|
||||
- [x] RoleSelector
|
||||
- [x] InvitationList
|
||||
|
||||
## T37 Onboarding & Help — `src/onboarding`
|
||||
## T37a Onboarding · Flows and highlights — `src/onboarding`
|
||||
|
||||
- [x] OnboardingWizard — on `navigation::Wizard` without its numbered steps
|
||||
- [x] WelcomeTour → `overlays::Tour` (T09b)
|
||||
- [x] Coachmark / Hotspot — Coachmark → `overlays::Spotlight`; Hotspot here
|
||||
- [x] FeatureHighlight / NewBadge — NewBadge → `data_display::Badge` with `Tone::Accent`, which FeatureHighlight wears
|
||||
- [x] Checklist (first-run tasks) → `onboarding::SetupChecklist`; a to-do list ticked by hand is `documents::Checklist`
|
||||
|
||||
## T37b Onboarding · Help
|
||||
|
||||
- [ ] OnboardingWizard
|
||||
- [ ] WelcomeTour → `overlays::Tour` (T09b)
|
||||
- [ ] Coachmark / Hotspot — Coachmark → `overlays::Spotlight`; Hotspot here
|
||||
- [ ] FeatureHighlight / NewBadge
|
||||
- [ ] Checklist (first-run tasks)
|
||||
- [ ] HelpTooltip
|
||||
- [ ] HelpPanel / DocsSidebar
|
||||
- [ ] KeyboardShortcutCheatsheet
|
||||
|
||||
Reference in new issue
Block a user