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(
214            div()
215              .text_size(px(font - 1.0))
216              .text_color(dimmed)
217              .child(SharedString::from(format!(
218                "{} / {}",
219                self.current + 1,
220                self.steps.len()
221              ))),
222          ),
223      )
224      .child(
225        div()
226          .text_size(px(font))
227          .text_color(dimmed)
228          .child(step.body.clone()),
229      )
230      .child(dots)
231      .child(controls);
232
233    let backdrop = div()
234      .id("guise-tour-backdrop")
235      .occlude()
236      .absolute()
237      .top(px(0.0))
238      .left(px(0.0))
239      .w(viewport.width)
240      .h(viewport.height)
241      .flex()
242      .items_center()
243      .justify_center()
244      .bg(scrim)
245      .track_focus(&self.focus)
246      .child(card);
247
248    deferred(backdrop)
249      .into_any_element()
250      .probe_any("Tour")
251      .into_any_element()
252  }
253}