Skip to main content

guise/overlay/
tour.rs

1//! `Tour` — a step-by-step onboarding overlay (gpui entity).
2//!
3//! A sequence of titled steps shown as a centered card over a scrim, with
4//! Back/Next/Skip and progress dots. Emits [`TourEvent`] as the user moves
5//! through it. Anchoring to specific UI elements is left to the host (pair
6//! a step's text with highlighting in your own chrome if needed).
7
8use gpui::prelude::*;
9use gpui::{
10    deferred, div, px, Context, EventEmitter, FocusHandle, FontWeight, IntoElement, SharedString,
11    Window,
12};
13
14use crate::devtools::ProbedAny;
15use crate::theme::{theme, Size};
16
17/// Tour progress events.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum TourEvent {
20    /// Moved to this step index.
21    Step(usize),
22    /// Finished the last step.
23    Finished,
24    /// Dismissed early.
25    Skipped,
26}
27
28struct TourStep {
29    title: SharedString,
30    body: SharedString,
31}
32
33/// An onboarding walkthrough. Create with
34/// `cx.new(|cx| Tour::new(cx).step("Welcome", "…").step("Panels", "…"))`,
35/// then `tour.update(cx, |t, cx| t.start(cx))`.
36pub struct Tour {
37    steps: Vec<TourStep>,
38    current: usize,
39    open: bool,
40    focus: FocusHandle,
41}
42
43impl EventEmitter<TourEvent> for Tour {}
44
45impl Tour {
46    pub fn new(cx: &mut Context<Self>) -> Self {
47        Tour {
48            steps: Vec::new(),
49            current: 0,
50            open: false,
51            focus: cx.focus_handle(),
52        }
53    }
54
55    pub fn step(mut self, title: impl Into<SharedString>, body: impl Into<SharedString>) -> Self {
56        self.steps.push(TourStep {
57            title: title.into(),
58            body: body.into(),
59        });
60        self
61    }
62
63    pub fn is_open(&self) -> bool {
64        self.open
65    }
66
67    pub fn current(&self) -> usize {
68        self.current
69    }
70
71    /// Show the tour from the first step.
72    pub fn start(&mut self, cx: &mut Context<Self>) {
73        if !self.steps.is_empty() {
74            self.current = 0;
75            self.open = true;
76            cx.emit(TourEvent::Step(0));
77            cx.notify();
78        }
79    }
80
81    pub fn next(&mut self, cx: &mut Context<Self>) {
82        if self.current + 1 < self.steps.len() {
83            self.current += 1;
84            cx.emit(TourEvent::Step(self.current));
85        } else {
86            self.open = false;
87            cx.emit(TourEvent::Finished);
88        }
89        cx.notify();
90    }
91
92    pub fn back(&mut self, cx: &mut Context<Self>) {
93        if self.current > 0 {
94            self.current -= 1;
95            cx.emit(TourEvent::Step(self.current));
96            cx.notify();
97        }
98    }
99
100    pub fn skip(&mut self, cx: &mut Context<Self>) {
101        if self.open {
102            self.open = false;
103            cx.emit(TourEvent::Skipped);
104            cx.notify();
105        }
106    }
107}
108
109impl Render for Tour {
110    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
111        if !self.open || self.steps.is_empty() {
112            return div().into_any_element();
113        }
114
115        let t = theme(cx);
116        let radius = t.radius(Size::Md);
117        let surface = t.surface().hsla();
118        let surface_hover = t.surface_hover().hsla();
119        let border = t.border().hsla();
120        let text_color = t.text().hsla();
121        let dimmed = t.dimmed().hsla();
122        let accent = t.primary();
123        let accent_bg = accent.hsla();
124        let accent_fg = accent.contrasting().hsla();
125        let scrim = t.black.alpha(0.55);
126        let font = t.font_size(Size::Sm);
127
128        let step = &self.steps[self.current];
129        let last = self.current + 1 == self.steps.len();
130        let viewport = window.viewport_size();
131
132        let mut dots = div().flex().gap(px(5.0));
133        for i in 0..self.steps.len() {
134            dots = dots.child(
135                div()
136                    .w(px(7.0))
137                    .h(px(7.0))
138                    .rounded_full()
139                    .bg(if i == self.current { accent_bg } else { border }),
140            );
141        }
142
143        let button = |id: &'static str, label: &'static str, filled: bool| {
144            let mut b = div()
145                .id(id)
146                .px(px(12.0))
147                .py(px(5.0))
148                .rounded(px(t.radius(Size::Sm)))
149                .text_size(px(font));
150            if filled {
151                b = b.bg(accent_bg).text_color(accent_fg);
152            } else {
153                b = b
154                    .border_1()
155                    .border_color(border)
156                    .text_color(text_color)
157                    .hover(move |s| s.bg(surface_hover));
158            }
159            b.child(SharedString::new_static(label))
160        };
161
162        let mut controls = div().flex().items_center().justify_between().pt(px(4.0));
163        controls = controls.child(
164            div()
165                .id("guise-tour-skip")
166                .text_size(px(font))
167                .text_color(dimmed)
168                .hover(move |s| s.text_color(text_color))
169                .child(SharedString::new_static("Skip"))
170                .on_click(cx.listener(|this, _ev, _window, cx| this.skip(cx))),
171        );
172        let mut actions = div().flex().gap(px(8.0));
173        if self.current > 0 {
174            actions = actions.child(
175                button("guise-tour-back", "Back", false)
176                    .on_click(cx.listener(|this, _ev, _window, cx| this.back(cx))),
177            );
178        }
179        actions = actions.child(
180            button(
181                "guise-tour-next",
182                if last { "Finish" } else { "Next" },
183                true,
184            )
185            .on_click(cx.listener(|this, _ev, _window, cx| this.next(cx))),
186        );
187        controls = controls.child(actions);
188
189        let card = div()
190            .id("guise-tour-card")
191            .occlude()
192            .flex()
193            .flex_col()
194            .gap(px(10.0))
195            .w(px(360.0))
196            .p(px(t.spacing(Size::Md)))
197            .rounded(px(radius))
198            .bg(surface)
199            .border_1()
200            .border_color(border)
201            .shadow_xl()
202            .child(
203                div()
204                    .flex()
205                    .items_center()
206                    .justify_between()
207                    .child(
208                        div()
209                            .font_weight(FontWeight::BOLD)
210                            .text_color(text_color)
211                            .child(step.title.clone()),
212                    )
213                    .child(div().text_size(px(font - 1.0)).text_color(dimmed).child(
214                        SharedString::from(format!("{} / {}", self.current + 1, self.steps.len())),
215                    )),
216            )
217            .child(
218                div()
219                    .text_size(px(font))
220                    .text_color(dimmed)
221                    .child(step.body.clone()),
222            )
223            .child(dots)
224            .child(controls);
225
226        let backdrop = div()
227            .id("guise-tour-backdrop")
228            .occlude()
229            .absolute()
230            .top(px(0.0))
231            .left(px(0.0))
232            .w(viewport.width)
233            .h(viewport.height)
234            .flex()
235            .items_center()
236            .justify_center()
237            .bg(scrim)
238            .track_focus(&self.focus)
239            .child(card);
240
241        deferred(backdrop)
242            .into_any_element()
243            .probe_any("Tour")
244            .into_any_element()
245    }
246}