1use std::rc::Rc;
13
14use gpui::{
15 AnyElement, App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
16 StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px,
17};
18use gpui_kit_assets::{Icon, icon};
19use gpui_kit_semantics::{NodeSpec, Role, Semantic};
20use gpui_kit_theme::{ActiveTheme, ControlSize, Radius, Space, TextTone, Theme, TypeScale};
21
22use crate::controls::button::Button;
23use crate::display::badge::Tone;
24use crate::foundation::stepping::bounded_step;
25use crate::foundation::{
26 Disableable, FocusRing, Ident, Pressable, Sizable, StyledExt, text as foundation_text,
27};
28use crate::motion;
29use crate::strings::{ActiveStrings, StringKey};
30
31const MARKER: f32 = 20.0;
33
34type NavigateHandler = Rc<dyn Fn(&WizardIntent, &mut Window, &mut App)>;
35
36#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum StepStatus {
42 Complete,
43 Current,
44 Upcoming,
45 Blocked(SharedString),
47 Failed(SharedString),
49}
50
51impl StepStatus {
52 pub fn as_str(&self) -> &'static str {
55 match self {
56 Self::Complete => "complete",
57 Self::Current => "current",
58 Self::Upcoming => "upcoming",
59 Self::Blocked(_) => "blocked",
60 Self::Failed(_) => "failed",
61 }
62 }
63
64 pub fn reason(&self) -> Option<&SharedString> {
66 match self {
67 Self::Blocked(reason) | Self::Failed(reason) => Some(reason),
68 _ => None,
69 }
70 }
71
72 fn tone(&self) -> Tone {
73 match self {
74 Self::Complete => Tone::Success,
75 Self::Current => Tone::Accent,
76 Self::Upcoming => Tone::Neutral,
77 Self::Blocked(_) => Tone::Warning,
78 Self::Failed(_) => Tone::Danger,
79 }
80 }
81
82 fn glyph(&self) -> Option<Icon> {
83 match self {
84 Self::Complete => Some(Icon::Check),
85 Self::Blocked(_) => Some(Icon::Key),
86 Self::Failed(_) => Some(Icon::Danger),
87 _ => None,
88 }
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct WizardStep {
95 id: SharedString,
96 title: SharedString,
97 description: Option<SharedString>,
98 status: StepStatus,
99 reachable: Option<bool>,
102}
103
104impl WizardStep {
105 pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
106 Self {
107 id: id.into(),
108 title: title.into(),
109 description: None,
110 status: StepStatus::Upcoming,
111 reachable: None,
112 }
113 }
114
115 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
116 self.description = Some(description.into());
117 self
118 }
119
120 pub fn status(mut self, status: StepStatus) -> Self {
121 self.status = status;
122 self
123 }
124
125 pub fn complete(self) -> Self {
126 self.status(StepStatus::Complete)
127 }
128
129 pub fn current(self) -> Self {
130 self.status(StepStatus::Current)
131 }
132
133 pub fn upcoming(self) -> Self {
134 self.status(StepStatus::Upcoming)
135 }
136
137 pub fn blocked(self, reason: impl Into<SharedString>) -> Self {
138 self.status(StepStatus::Blocked(reason.into()))
139 }
140
141 pub fn failed(self, reason: impl Into<SharedString>) -> Self {
142 self.status(StepStatus::Failed(reason.into()))
143 }
144
145 pub fn reachable(mut self, reachable: bool) -> Self {
151 self.reachable = Some(reachable);
152 self
153 }
154
155 pub fn id(&self) -> &SharedString {
156 &self.id
157 }
158
159 fn is_current(&self) -> bool {
160 self.status == StepStatus::Current
161 }
162
163 fn is_reachable(&self) -> bool {
164 self.reachable
165 .unwrap_or(matches!(self.status, StepStatus::Complete))
166 }
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
171pub enum WizardLayout {
172 #[default]
173 Horizontal,
174 Vertical,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum WizardIntent {
180 Step(SharedString),
182 Back,
184 Next,
186 Finish,
188}
189
190impl WizardIntent {
191 pub fn as_str(&self) -> &'static str {
192 match self {
193 Self::Step(_) => "step",
194 Self::Back => "back",
195 Self::Next => "next",
196 Self::Finish => "finish",
197 }
198 }
199}
200
201#[derive(IntoElement)]
203pub struct Wizard {
204 ident: Ident,
205 steps: Vec<WizardStep>,
206 layout: WizardLayout,
207 body: Option<AnyElement>,
208 back_to: Option<SharedString>,
209 finish: bool,
210 can_advance: bool,
211 back_label: Option<SharedString>,
212 next_label: Option<SharedString>,
213 finish_label: Option<SharedString>,
214 size: ControlSize,
215 disabled: bool,
216 on_navigate: Option<NavigateHandler>,
217}
218
219impl std::fmt::Debug for Wizard {
220 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221 formatter
222 .debug_struct("Wizard")
223 .field("ident", &self.ident)
224 .field("steps", &self.steps.len())
225 .field("layout", &self.layout)
226 .field("back_to", &self.back_to)
227 .field("finish", &self.finish)
228 .field("disabled", &self.disabled)
229 .field("has_handler", &self.on_navigate.is_some())
230 .finish()
231 }
232}
233
234impl Wizard {
235 pub fn new(ident: impl Into<Ident>) -> Self {
236 Self {
237 ident: ident.into(),
238 steps: Vec::new(),
239 layout: WizardLayout::default(),
240 body: None,
241 back_to: None,
242 finish: false,
243 can_advance: true,
244 back_label: None,
245 next_label: None,
246 finish_label: None,
247 size: ControlSize::Md,
248 disabled: false,
249 on_navigate: None,
250 }
251 }
252
253 pub fn step(mut self, step: WizardStep) -> Self {
254 self.steps.push(step);
255 self
256 }
257
258 pub fn steps(mut self, steps: impl IntoIterator<Item = WizardStep>) -> Self {
259 self.steps.extend(steps);
260 self
261 }
262
263 pub fn layout(mut self, layout: WizardLayout) -> Self {
264 self.layout = layout;
265 self
266 }
267
268 pub fn vertical(self) -> Self {
269 self.layout(WizardLayout::Vertical)
270 }
271
272 pub fn body(mut self, body: impl IntoElement) -> Self {
274 self.body = Some(body.into_any_element());
275 self
276 }
277
278 pub fn back_to(mut self, step: impl Into<SharedString>) -> Self {
281 self.back_to = Some(step.into());
282 self
283 }
284
285 pub fn finish(mut self, finish: bool) -> Self {
288 self.finish = finish;
289 self
290 }
291
292 pub fn can_advance(mut self, can_advance: bool) -> Self {
294 self.can_advance = can_advance;
295 self
296 }
297
298 pub fn back_label(mut self, label: impl Into<SharedString>) -> Self {
299 self.back_label = Some(label.into());
300 self
301 }
302
303 pub fn next_label(mut self, label: impl Into<SharedString>) -> Self {
304 self.next_label = Some(label.into());
305 self
306 }
307
308 pub fn finish_label(mut self, label: impl Into<SharedString>) -> Self {
309 self.finish_label = Some(label.into());
310 self
311 }
312
313 pub fn on_navigate(
314 mut self,
315 handler: impl Fn(&WizardIntent, &mut Window, &mut App) + 'static,
316 ) -> Self {
317 self.on_navigate = Some(Rc::new(handler));
318 self
319 }
320
321 fn handler(&self) -> Option<NavigateHandler> {
322 self.on_navigate.clone().filter(|_| !self.disabled)
323 }
324
325 #[allow(clippy::too_many_arguments)]
326 fn step_element(
327 &self,
328 step: &WizardStep,
329 theme: &Theme,
330 window: &mut Window,
331 cx: &mut App,
332 ) -> AnyElement {
333 let ident = self.ident.child(step.id.as_ref());
334 let current = step.is_current();
335 let actionable = !current && step.is_reachable() && self.handler().is_some();
337 let tone = step.status.tone();
338 let color = tone.color(theme);
339 let vertical = self.layout == WizardLayout::Vertical;
340
341 let filled = motion::tracked(
342 &ident.semantic_id(),
343 f32::from(u8::from(current || step.status == StepStatus::Complete)),
344 motion::state_change(theme),
345 window,
346 cx,
347 );
348
349 let marker = div()
350 .size(px(MARKER))
351 .flex_none()
352 .flex()
353 .items_center()
354 .justify_center()
355 .rounded_full()
356 .border(px(theme.borders.hairline))
357 .border_color(color.opacity(0.2 + 0.8 * filled))
358 .bg(color.opacity(0.12 + 0.16 * filled))
359 .text_color(color)
360 .children(
361 step.status
362 .glyph()
363 .map(|glyph| icon(glyph).size(px(MARKER * 0.55)).text_color(color)),
364 )
365 .when(step.status.glyph().is_none(), |element| {
366 element.child(
367 div()
368 .size(px(MARKER * 0.3 * filled.max(0.5)))
369 .rounded_full()
370 .bg(color.opacity(0.3 + 0.7 * filled)),
371 )
372 });
373
374 let reason = step.status.reason().map(|reason| {
375 let failed = matches!(step.status, StepStatus::Failed(_));
376 foundation_text(theme, TypeScale::Caption, reason.clone())
377 .text_color(color)
378 .semantic_in(
379 cx,
380 NodeSpec::new(ident.child("reason").semantic_id(), Role::Status)
381 .parent(ident.semantic_id())
382 .invalid(failed)
383 .text(reason.clone()),
384 )
385 });
386
387 let text_element = div()
388 .column()
389 .min_w_0()
390 .gap(px(2.0))
391 .child(
392 foundation_text(theme, TypeScale::Label, step.title.clone()).text_tone(
393 theme,
394 if current {
395 TextTone::Primary
396 } else {
397 TextTone::Muted
398 },
399 ),
400 )
401 .children(step.description.clone().map(|description| {
402 foundation_text(theme, TypeScale::Caption, description)
403 .text_tone(theme, TextTone::Faint)
404 }))
405 .children(reason);
406
407 let mut element = div()
408 .id(ident.element_id())
409 .row()
410 .items_start()
411 .gap_token(theme, Space::Sm)
412 .p_token(theme, Space::Xs)
413 .radius(theme, Radius::Control)
414 .when(!vertical, |element| element.flex_1().min_w_0())
415 .when(self.disabled, |element| {
416 element.opacity(theme.opacity.disabled)
417 })
418 .when(actionable, |element| {
419 element
420 .cursor_pointer()
421 .tab_index(0)
422 .pressable(cx)
423 .hover(|style| style.bg(theme.colors.hover))
424 .focus_ring(theme)
425 })
426 .child(marker)
427 .child(text_element);
428
429 if let (true, Some(handler)) = (actionable, self.handler()) {
430 let id = step.id.clone();
431 let click = Rc::clone(&handler);
432 let clicked = id.clone();
433 element = element
434 .on_click(move |_, window, cx| {
435 click(&WizardIntent::Step(clicked.clone()), window, cx)
436 })
437 .on_key_down(move |event, window, cx| {
438 if matches!(event.keystroke.key.as_str(), "enter" | "space") {
439 handler(&WizardIntent::Step(id.clone()), window, cx);
440 cx.stop_propagation();
441 }
442 });
443 }
444
445 element
446 .semantic_in(
447 cx,
448 NodeSpec::new(ident.semantic_id(), Role::Tab)
449 .parent(self.ident.semantic_id())
450 .text(step.title.clone())
451 .selected(current)
452 .disabled(self.disabled || !actionable)
453 .value(step.status.as_str()),
454 )
455 .into_any_element()
456 }
457}
458
459impl Disableable for Wizard {
460 fn disabled(mut self, disabled: bool) -> Self {
463 self.disabled = disabled;
464 self
465 }
466}
467
468impl Sizable for Wizard {
469 fn control_size(mut self, size: ControlSize) -> Self {
470 self.size = size;
471 self
472 }
473}
474
475impl RenderOnce for Wizard {
476 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
477 let theme = cx.theme().clone();
478 let vertical = self.layout == WizardLayout::Vertical;
479 let count = self.steps.len();
480
481 let mut strip = div()
482 .w_full()
483 .gap_token(&theme, Space::Sm)
484 .when(vertical, |element| element.column())
485 .when(!vertical, |element| element.row().items_start());
486
487 if let Some(handler) = self.handler() {
488 let steps = self.steps.clone();
489 strip = strip.on_key_down(move |event, window, cx| {
490 let keys: [&str; 4] = if vertical {
491 ["up", "down", "home", "end"]
492 } else {
493 ["left", "right", "home", "end"]
494 };
495 let key = event.keystroke.key.as_str();
496 let from = steps.iter().position(WizardStep::is_current);
497 let next = if key == keys[0] {
498 step_toward(&steps, from, -1)
499 } else if key == keys[1] {
500 step_toward(&steps, from, 1)
501 } else if key == keys[2] {
502 step_toward(&steps, None, 1)
503 } else if key == keys[3] {
504 step_toward(&steps, None, -1)
505 } else {
506 return;
507 };
508 let Some(next) = next else {
509 return;
510 };
511 handler(&WizardIntent::Step(next), window, cx);
512 cx.stop_propagation();
513 });
514 }
515
516 for step in &self.steps {
517 strip = strip.child(self.step_element(step, &theme, window, cx));
518 }
519
520 let ident = self.ident.clone();
521 let handler = self.handler();
522 let body = self.body.map(|body| {
523 div().w_full().child(body).semantic_in(
524 cx,
525 NodeSpec::new(ident.child("body").semantic_id(), Role::Group)
526 .parent(ident.semantic_id()),
527 )
528 });
529
530 let back = handler
531 .as_ref()
532 .zip(self.back_to.clone())
533 .map(|(handler, target)| {
534 let handler = Rc::clone(handler);
535 div()
536 .child(
537 Button::new(ident.child("back"))
538 .label(
539 self.back_label
540 .clone()
541 .unwrap_or_else(|| cx.strings().text(StringKey::WizardBack)),
542 )
543 .secondary()
544 .control_size(self.size)
545 .semantic_parent(ident.semantic_id())
546 .on_click(move |window, cx| handler(&WizardIntent::Back, window, cx)),
547 )
548 .semantic_in(
549 cx,
550 NodeSpec::new(ident.child("back-target").semantic_id(), Role::Status)
551 .parent(ident.semantic_id())
552 .text(cx.strings().text(StringKey::WizardReturnsTo))
553 .value(target),
554 )
555 });
556
557 let advance = handler.as_ref().map(|handler| {
558 let handler = Rc::clone(handler);
559 let finish = self.finish;
560 let button = ident.child(if finish { "finish" } else { "next" });
561 let label = if finish {
562 self.finish_label
563 .clone()
564 .unwrap_or_else(|| cx.strings().text(StringKey::WizardFinish))
565 } else {
566 self.next_label
567 .clone()
568 .unwrap_or_else(|| cx.strings().text(StringKey::WizardNext))
569 };
570 Button::new(button)
571 .label(label)
572 .primary()
573 .control_size(self.size)
574 .semantic_parent(ident.semantic_id())
575 .disabled(!self.can_advance)
576 .on_click(move |window, cx| {
577 handler(
578 if finish {
579 &WizardIntent::Finish
580 } else {
581 &WizardIntent::Next
582 },
583 window,
584 cx,
585 )
586 })
587 });
588
589 div()
590 .column()
591 .w_full()
592 .gap_token(&theme, Space::Md)
593 .child(strip)
594 .children(body)
595 .child(
596 div()
597 .row()
598 .w_full()
599 .gap_token(&theme, Space::Sm)
600 .children(back)
601 .child(div().flex_1())
602 .children(advance),
603 )
604 .semantic_in(
605 cx,
606 NodeSpec::new(ident.semantic_id(), Role::List)
607 .disabled(self.disabled)
608 .value(count.to_string()),
609 )
610 }
611}
612
613fn step_toward(steps: &[WizardStep], from: Option<usize>, delta: isize) -> Option<SharedString> {
618 bounded_step(steps.len(), from, delta, |index| {
619 !steps[index].is_reachable()
620 })
621 .map(|index| steps[index].id.clone())
622}