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:
1 parent
ebf8955556
commit
996daed6e4
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 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 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 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.
|
- 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.
|
- 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`).
|
- 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] T36a Account · Sign in — `tasks/ch31-43.md`
|
||||||
- [x] T36b Account · Profile and access — `tasks/ch31-43.md`
|
- [x] T36b Account · Profile and access — `tasks/ch31-43.md`
|
||||||
- [x] T36c Account · Billing and team — `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
|
- [ ] T38 Interaction
|
||||||
- [ ] T39 Theme
|
- [ ] T39 Theme
|
||||||
- [ ] T40 i18n & a11y
|
- [ ] T40 i18n & a11y
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ mod menus;
|
|||||||
mod messaging;
|
mod messaging;
|
||||||
mod motion;
|
mod motion;
|
||||||
mod navigation;
|
mod navigation;
|
||||||
|
mod onboarding;
|
||||||
mod overlays;
|
mod overlays;
|
||||||
mod primitives;
|
mod primitives;
|
||||||
mod project;
|
mod project;
|
||||||
@@ -87,6 +88,7 @@ pub const ALL: &[Page] = &[
|
|||||||
dashboard::PAGE,
|
dashboard::PAGE,
|
||||||
settings::PAGE,
|
settings::PAGE,
|
||||||
account::PAGE,
|
account::PAGE,
|
||||||
|
onboarding::PAGE,
|
||||||
theme::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 messaging;
|
||||||
pub mod motion;
|
pub mod motion;
|
||||||
pub mod navigation;
|
pub mod navigation;
|
||||||
|
pub mod onboarding;
|
||||||
pub mod overlays;
|
pub mod overlays;
|
||||||
pub mod primitives;
|
pub mod primitives;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
|
|||||||
+40
-20
@@ -62,7 +62,8 @@ impl Steps {
|
|||||||
|
|
||||||
impl RenderOnce for Steps {
|
impl RenderOnce for Steps {
|
||||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
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 (current, vertical, count) = (self.current, self.vertical, self.steps.len());
|
||||||
let duration = motion::duration(motion::SLOW, cx);
|
let duration = motion::duration(motion::SLOW, cx);
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
@@ -79,7 +80,7 @@ impl RenderOnce for Steps {
|
|||||||
});
|
});
|
||||||
let fill = if filled && ix + 1 == current && turn > 0 {
|
let fill = if filled && ix + 1 == current && turn > 0 {
|
||||||
fill.with_animation(
|
fill.with_animation(
|
||||||
("step-fill", turn),
|
(id.clone(), format!("fill-{turn}")),
|
||||||
Animation::new(duration).with_easing(motion::ease_out_cubic),
|
Animation::new(duration).with_easing(motion::ease_out_cubic),
|
||||||
move |fill, t| {
|
move |fill, t| {
|
||||||
if vertical {
|
if vertical {
|
||||||
@@ -155,7 +156,7 @@ impl RenderOnce for Steps {
|
|||||||
});
|
});
|
||||||
let select = self.on_select.clone().filter(|_| done);
|
let select = self.on_select.clone().filter(|_| done);
|
||||||
let head = div()
|
let head = div()
|
||||||
.id(("step", ix))
|
.id((id.clone(), format!("step-{ix}")))
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
@@ -215,6 +216,7 @@ pub struct Wizard {
|
|||||||
current: usize,
|
current: usize,
|
||||||
content: Option<AnyElement>,
|
content: Option<AnyElement>,
|
||||||
ready: bool,
|
ready: bool,
|
||||||
|
headless: bool,
|
||||||
on_step: Option<OnStep>,
|
on_step: Option<OnStep>,
|
||||||
on_finish: Option<Run>,
|
on_finish: Option<Run>,
|
||||||
}
|
}
|
||||||
@@ -225,17 +227,30 @@ impl Wizard {
|
|||||||
steps: impl IntoIterator<Item = Choice>,
|
steps: impl IntoIterator<Item = Choice>,
|
||||||
current: usize,
|
current: usize,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let steps: Vec<Choice> = steps.into_iter().collect();
|
||||||
|
assert!(
|
||||||
|
current < steps.len(),
|
||||||
|
"step {current} is past the last of {}",
|
||||||
|
steps.len()
|
||||||
|
);
|
||||||
Self {
|
Self {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
steps: steps.into_iter().collect(),
|
steps,
|
||||||
current,
|
current,
|
||||||
content: None,
|
content: None,
|
||||||
ready: true,
|
ready: true,
|
||||||
|
headless: false,
|
||||||
on_step: None,
|
on_step: None,
|
||||||
on_finish: 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.
|
/// The current step's content.
|
||||||
pub fn content(mut self, content: impl IntoElement) -> Self {
|
pub fn content(mut self, content: impl IntoElement) -> Self {
|
||||||
self.content = Some(content.into_any_element());
|
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 {
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
let (current, last) = (self.current, self.steps.len().saturating_sub(1));
|
let (current, last) = (self.current, self.steps.len().saturating_sub(1));
|
||||||
let turn = motion::changes((self.id.clone(), "content"), current, window, cx);
|
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 step: OnStep = {
|
||||||
let (id, on_step) = (self.id.clone(), self.on_step);
|
let id = id.clone();
|
||||||
Rc::new(move |to, window, cx| {
|
Rc::new(move |to, window, cx| {
|
||||||
log::info!("wizard {id:?}: step {to}");
|
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 (back, next, jump) = (step.clone(), step.clone(), step);
|
||||||
let finish = {
|
let finish = {
|
||||||
let (id, on_finish) = (self.id.clone(), self.on_finish);
|
let id = id.clone();
|
||||||
move |window: &mut Window, cx: &mut App| {
|
move |window: &mut Window, cx: &mut App| {
|
||||||
log::info!("wizard {id:?}: finished");
|
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| {
|
let content = self.content.map(|content| {
|
||||||
div().child(content).with_animation(
|
div().child(content).with_animation(
|
||||||
("wizard-step", turn),
|
(id.clone(), format!("step-{turn}")),
|
||||||
Animation::new(motion::duration(motion::BASE, cx))
|
Animation::new(motion::duration(motion::BASE, cx))
|
||||||
.with_easing(motion::ease_out_cubic),
|
.with_easing(motion::ease_out_cubic),
|
||||||
move |content, t| {
|
move |content, t| {
|
||||||
@@ -298,12 +316,12 @@ impl RenderOnce for Wizard {
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
let advance = if current == last {
|
let advance = if current == last {
|
||||||
Button::new("wizard-finish", "Finish")
|
Button::new((id.clone(), "finish"), "Finish")
|
||||||
.primary()
|
.primary()
|
||||||
.disabled(!self.ready)
|
.disabled(!self.ready)
|
||||||
.on_click(move |_, window, cx| finish(window, cx))
|
.on_click(move |_, window, cx| finish(window, cx))
|
||||||
} else {
|
} else {
|
||||||
Button::new("wizard-next", "Next")
|
Button::new((id.clone(), "next"), "Next")
|
||||||
.primary()
|
.primary()
|
||||||
.disabled(!self.ready)
|
.disabled(!self.ready)
|
||||||
.on_click(move |_, window, cx| next(current + 1, window, cx))
|
.on_click(move |_, window, cx| next(current + 1, window, cx))
|
||||||
@@ -314,10 +332,12 @@ impl RenderOnce for Wizard {
|
|||||||
.flex_col()
|
.flex_col()
|
||||||
.gap_6()
|
.gap_6()
|
||||||
.w_full()
|
.w_full()
|
||||||
.child(
|
.when(!self.headless, |wizard| {
|
||||||
Steps::new((self.id, "steps"), self.steps, current)
|
wizard.child(
|
||||||
.on_select(move |to, window, cx| jump(to, window, cx)),
|
Steps::new((id.clone(), "steps"), self.steps, current)
|
||||||
)
|
.on_select(move |to, window, cx| jump(to, window, cx)),
|
||||||
|
)
|
||||||
|
})
|
||||||
.children(content)
|
.children(content)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
@@ -325,7 +345,7 @@ impl RenderOnce for Wizard {
|
|||||||
.items_center()
|
.items_center()
|
||||||
.justify_between()
|
.justify_between()
|
||||||
.child(
|
.child(
|
||||||
Button::new("wizard-back", "Back")
|
Button::new((id.clone(), "back"), "Back")
|
||||||
.variant(ButtonVariant::Ghost)
|
.variant(ButtonVariant::Ghost)
|
||||||
.disabled(current == 0)
|
.disabled(current == 0)
|
||||||
.on_click(move |_, window, cx| back(current - 1, window, cx)),
|
.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 generative;
|
||||||
mod media;
|
mod media;
|
||||||
mod messaging;
|
mod messaging;
|
||||||
|
mod onboarding;
|
||||||
mod palette;
|
mod palette;
|
||||||
mod project;
|
mod project;
|
||||||
mod tokens;
|
mod tokens;
|
||||||
@@ -23,6 +24,7 @@ pub use files::FileSizes;
|
|||||||
pub use generative::GenerativeSizes;
|
pub use generative::GenerativeSizes;
|
||||||
pub use media::MediaSizes;
|
pub use media::MediaSizes;
|
||||||
pub use messaging::MessagingSizes;
|
pub use messaging::MessagingSizes;
|
||||||
|
pub use onboarding::OnboardingSizes;
|
||||||
pub use palette::{HUE_NAMES, Mix, Palette, Syntax};
|
pub use palette::{HUE_NAMES, Mix, Palette, Syntax};
|
||||||
pub use project::ProjectSizes;
|
pub use project::ProjectSizes;
|
||||||
pub use tokens::{
|
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] RoleSelector
|
||||||
- [x] InvitationList
|
- [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
|
- [ ] HelpTooltip
|
||||||
- [ ] HelpPanel / DocsSidebar
|
- [ ] HelpPanel / DocsSidebar
|
||||||
- [ ] KeyboardShortcutCheatsheet
|
- [ ] KeyboardShortcutCheatsheet
|
||||||
|
|||||||
Reference in new issue
Block a user