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

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

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

No files matched your search

+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)
}),
),
)
},
)
}
}