Skip to main content

gpui_kit/navigation/
wizard.rs

1//! A multi-step flow that reports where the typist asked to go.
2//!
3//! Which step is current, which are done, and which cannot be reached are all
4//! caller-owned. The wizard reports a navigation intent and moves nothing
5//! itself, so a host that refuses to advance keeps showing the step that still
6//! holds.
7//!
8//! A step that is blocked or has failed carries the reason it was given and
9//! shows it. There is no bare grey dot standing in for "you cannot go here":
10//! a refusal nobody can read is a refusal nobody can act on.
11
12use 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
31/// How wide the marker beside a step is.
32const MARKER: f32 = 20.0;
33
34type NavigateHandler = Rc<dyn Fn(&WizardIntent, &mut Window, &mut App)>;
35
36/// Where a step stands, as the caller reports it.
37///
38/// The wizard never derives this from position: a step is only current, done,
39/// or refused because the host says so.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum StepStatus {
42    Complete,
43    Current,
44    Upcoming,
45    /// Something has to happen elsewhere first, and this is what.
46    Blocked(SharedString),
47    /// The step was attempted and did not succeed, in the host's own words.
48    Failed(SharedString),
49}
50
51impl StepStatus {
52    /// The name a semantic node publishes, so a test asserts the state a step
53    /// reported rather than the glyph it drew.
54    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    /// Why the step cannot be taken, when that is a thing the host said.
65    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/// One step of a flow, identified by what it is rather than where it sits.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct WizardStep {
95    id: SharedString,
96    title: SharedString,
97    description: Option<SharedString>,
98    status: StepStatus,
99    /// Whether the caller says this step may be jumped to. Unset means a
100    /// completed step may be revisited and nothing else may be entered early.
101    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    /// Whether the typist may jump straight to this step.
146    ///
147    /// Left unsaid, a completed step may be revisited and every other step may
148    /// not, because a step nobody has reached is not a place the flow can
149    /// honestly offer.
150    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/// Which way the steps are laid out.
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
171pub enum WizardLayout {
172    #[default]
173    Horizontal,
174    Vertical,
175}
176
177/// What a gesture asked the flow to do. The wizard applies none of it.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum WizardIntent {
180    /// Go straight to a named step.
181    Step(SharedString),
182    /// Return to the step the caller named as revisitable.
183    Back,
184    /// Move on from the current step.
185    Next,
186    /// End the flow, which is a different thing from moving on.
187    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/// A multi-step flow: the steps, the current step's body, and the way on.
202#[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    /// The current step's content, which belongs entirely to the caller.
273    pub fn body(mut self, body: impl IntoElement) -> Self {
274        self.body = Some(body.into_any_element());
275        self
276    }
277
278    /// The earlier step the caller says may be returned to. Without one there
279    /// is no back control at all.
280    pub fn back_to(mut self, step: impl Into<SharedString>) -> Self {
281        self.back_to = Some(step.into());
282        self
283    }
284
285    /// Whether moving on from here ends the flow. Finishing is a different
286    /// report from advancing, so it is a different control.
287    pub fn finish(mut self, finish: bool) -> Self {
288        self.finish = finish;
289        self
290    }
291
292    /// Whether the flow may move on from the current step at all.
293    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        // A step nobody may jump to gets no handler, whatever it looks like.
336        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    /// Refuses the whole flow: no step, no back, and no way on installs a
461    /// handler.
462    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
613/// The next step in `delta`'s direction that may actually be jumped to.
614///
615/// Movement stops at the ends rather than wrapping, because a flow has a
616/// beginning and an end.
617fn 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}