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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-27 14:20:45 -04:00
1 parent ebf8955556
commit 996daed6e4
16 files changed
+1288 -27

No files matched your search

+1
View File
@@ -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`).
+2 -1
View File
@@ -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
+2
View File
@@ -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,
];
+267
View File
@@ -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)))
}
+38
View File
@@ -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()
}
+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),
}
}
}
+9 -6
View File
@@ -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