1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum TourEvent {
20 Step(usize),
22 Finished,
24 Skipped,
26}
27
28struct TourStep {
29 title: SharedString,
30 body: SharedString,
31}
32
33pub 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 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}