use std::rc::Rc;
use gpui::{
AnyElement, App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px,
};
use gpui_kit_assets::{Icon, icon};
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{ActiveTheme, ControlSize, Radius, Space, TextTone, Theme, TypeScale};
use crate::controls::button::Button;
use crate::display::badge::Tone;
use crate::foundation::stepping::bounded_step;
use crate::foundation::{
Disableable, FocusRing, Ident, Pressable, Sizable, StyledExt, text as foundation_text,
};
use crate::motion;
use crate::strings::{ActiveStrings, StringKey};
const MARKER: f32 = 20.0;
type NavigateHandler = Rc<dyn Fn(&WizardIntent, &mut Window, &mut App)>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StepStatus {
Complete,
Current,
Upcoming,
Blocked(SharedString),
Failed(SharedString),
}
impl StepStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Complete => "complete",
Self::Current => "current",
Self::Upcoming => "upcoming",
Self::Blocked(_) => "blocked",
Self::Failed(_) => "failed",
}
}
pub fn reason(&self) -> Option<&SharedString> {
match self {
Self::Blocked(reason) | Self::Failed(reason) => Some(reason),
_ => None,
}
}
fn tone(&self) -> Tone {
match self {
Self::Complete => Tone::Success,
Self::Current => Tone::Accent,
Self::Upcoming => Tone::Neutral,
Self::Blocked(_) => Tone::Warning,
Self::Failed(_) => Tone::Danger,
}
}
fn glyph(&self) -> Option<Icon> {
match self {
Self::Complete => Some(Icon::Check),
Self::Blocked(_) => Some(Icon::Key),
Self::Failed(_) => Some(Icon::Danger),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WizardStep {
id: SharedString,
title: SharedString,
description: Option<SharedString>,
status: StepStatus,
reachable: Option<bool>,
}
impl WizardStep {
pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
title: title.into(),
description: None,
status: StepStatus::Upcoming,
reachable: None,
}
}
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
self.description = Some(description.into());
self
}
pub fn status(mut self, status: StepStatus) -> Self {
self.status = status;
self
}
pub fn complete(self) -> Self {
self.status(StepStatus::Complete)
}
pub fn current(self) -> Self {
self.status(StepStatus::Current)
}
pub fn upcoming(self) -> Self {
self.status(StepStatus::Upcoming)
}
pub fn blocked(self, reason: impl Into<SharedString>) -> Self {
self.status(StepStatus::Blocked(reason.into()))
}
pub fn failed(self, reason: impl Into<SharedString>) -> Self {
self.status(StepStatus::Failed(reason.into()))
}
pub fn reachable(mut self, reachable: bool) -> Self {
self.reachable = Some(reachable);
self
}
pub fn id(&self) -> &SharedString {
&self.id
}
fn is_current(&self) -> bool {
self.status == StepStatus::Current
}
fn is_reachable(&self) -> bool {
self.reachable
.unwrap_or(matches!(self.status, StepStatus::Complete))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WizardLayout {
#[default]
Horizontal,
Vertical,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WizardIntent {
Step(SharedString),
Back,
Next,
Finish,
}
impl WizardIntent {
pub fn as_str(&self) -> &'static str {
match self {
Self::Step(_) => "step",
Self::Back => "back",
Self::Next => "next",
Self::Finish => "finish",
}
}
}
#[derive(IntoElement)]
pub struct Wizard {
ident: Ident,
steps: Vec<WizardStep>,
layout: WizardLayout,
body: Option<AnyElement>,
back_to: Option<SharedString>,
finish: bool,
can_advance: bool,
back_label: Option<SharedString>,
next_label: Option<SharedString>,
finish_label: Option<SharedString>,
size: ControlSize,
disabled: bool,
on_navigate: Option<NavigateHandler>,
}
impl std::fmt::Debug for Wizard {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("Wizard")
.field("ident", &self.ident)
.field("steps", &self.steps.len())
.field("layout", &self.layout)
.field("back_to", &self.back_to)
.field("finish", &self.finish)
.field("disabled", &self.disabled)
.field("has_handler", &self.on_navigate.is_some())
.finish()
}
}
impl Wizard {
pub fn new(ident: impl Into<Ident>) -> Self {
Self {
ident: ident.into(),
steps: Vec::new(),
layout: WizardLayout::default(),
body: None,
back_to: None,
finish: false,
can_advance: true,
back_label: None,
next_label: None,
finish_label: None,
size: ControlSize::Md,
disabled: false,
on_navigate: None,
}
}
pub fn step(mut self, step: WizardStep) -> Self {
self.steps.push(step);
self
}
pub fn steps(mut self, steps: impl IntoIterator<Item = WizardStep>) -> Self {
self.steps.extend(steps);
self
}
pub fn layout(mut self, layout: WizardLayout) -> Self {
self.layout = layout;
self
}
pub fn vertical(self) -> Self {
self.layout(WizardLayout::Vertical)
}
pub fn body(mut self, body: impl IntoElement) -> Self {
self.body = Some(body.into_any_element());
self
}
pub fn back_to(mut self, step: impl Into<SharedString>) -> Self {
self.back_to = Some(step.into());
self
}
pub fn finish(mut self, finish: bool) -> Self {
self.finish = finish;
self
}
pub fn can_advance(mut self, can_advance: bool) -> Self {
self.can_advance = can_advance;
self
}
pub fn back_label(mut self, label: impl Into<SharedString>) -> Self {
self.back_label = Some(label.into());
self
}
pub fn next_label(mut self, label: impl Into<SharedString>) -> Self {
self.next_label = Some(label.into());
self
}
pub fn finish_label(mut self, label: impl Into<SharedString>) -> Self {
self.finish_label = Some(label.into());
self
}
pub fn on_navigate(
mut self,
handler: impl Fn(&WizardIntent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_navigate = Some(Rc::new(handler));
self
}
fn handler(&self) -> Option<NavigateHandler> {
self.on_navigate.clone().filter(|_| !self.disabled)
}
#[allow(clippy::too_many_arguments)]
fn step_element(
&self,
step: &WizardStep,
theme: &Theme,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let ident = self.ident.child(step.id.as_ref());
let current = step.is_current();
let actionable = !current && step.is_reachable() && self.handler().is_some();
let tone = step.status.tone();
let color = tone.color(theme);
let vertical = self.layout == WizardLayout::Vertical;
let filled = motion::tracked(
&ident.semantic_id(),
f32::from(u8::from(current || step.status == StepStatus::Complete)),
motion::state_change(theme),
window,
cx,
);
let marker = div()
.size(px(MARKER))
.flex_none()
.flex()
.items_center()
.justify_center()
.rounded_full()
.border(px(theme.borders.hairline))
.border_color(color.opacity(0.2 + 0.8 * filled))
.bg(color.opacity(0.12 + 0.16 * filled))
.text_color(color)
.children(
step.status
.glyph()
.map(|glyph| icon(glyph).size(px(MARKER * 0.55)).text_color(color)),
)
.when(step.status.glyph().is_none(), |element| {
element.child(
div()
.size(px(MARKER * 0.3 * filled.max(0.5)))
.rounded_full()
.bg(color.opacity(0.3 + 0.7 * filled)),
)
});
let reason = step.status.reason().map(|reason| {
let failed = matches!(step.status, StepStatus::Failed(_));
foundation_text(theme, TypeScale::Caption, reason.clone())
.text_color(color)
.semantic_in(
cx,
NodeSpec::new(ident.child("reason").semantic_id(), Role::Status)
.parent(ident.semantic_id())
.invalid(failed)
.text(reason.clone()),
)
});
let text_element = div()
.column()
.min_w_0()
.gap(px(2.0))
.child(
foundation_text(theme, TypeScale::Label, step.title.clone()).text_tone(
theme,
if current {
TextTone::Primary
} else {
TextTone::Muted
},
),
)
.children(step.description.clone().map(|description| {
foundation_text(theme, TypeScale::Caption, description)
.text_tone(theme, TextTone::Faint)
}))
.children(reason);
let mut element = div()
.id(ident.element_id())
.row()
.items_start()
.gap_token(theme, Space::Sm)
.p_token(theme, Space::Xs)
.radius(theme, Radius::Control)
.when(!vertical, |element| element.flex_1().min_w_0())
.when(self.disabled, |element| {
element.opacity(theme.opacity.disabled)
})
.when(actionable, |element| {
element
.cursor_pointer()
.tab_index(0)
.pressable(cx)
.hover(|style| style.bg(theme.colors.hover))
.focus_ring(theme)
})
.child(marker)
.child(text_element);
if let (true, Some(handler)) = (actionable, self.handler()) {
let id = step.id.clone();
let click = Rc::clone(&handler);
let clicked = id.clone();
element = element
.on_click(move |_, window, cx| {
click(&WizardIntent::Step(clicked.clone()), window, cx)
})
.on_key_down(move |event, window, cx| {
if matches!(event.keystroke.key.as_str(), "enter" | "space") {
handler(&WizardIntent::Step(id.clone()), window, cx);
cx.stop_propagation();
}
});
}
element
.semantic_in(
cx,
NodeSpec::new(ident.semantic_id(), Role::Tab)
.parent(self.ident.semantic_id())
.text(step.title.clone())
.selected(current)
.disabled(self.disabled || !actionable)
.value(step.status.as_str()),
)
.into_any_element()
}
}
impl Disableable for Wizard {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Sizable for Wizard {
fn control_size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
}
impl RenderOnce for Wizard {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme().clone();
let vertical = self.layout == WizardLayout::Vertical;
let count = self.steps.len();
let mut strip = div()
.w_full()
.gap_token(&theme, Space::Sm)
.when(vertical, |element| element.column())
.when(!vertical, |element| element.row().items_start());
if let Some(handler) = self.handler() {
let steps = self.steps.clone();
strip = strip.on_key_down(move |event, window, cx| {
let keys: [&str; 4] = if vertical {
["up", "down", "home", "end"]
} else {
["left", "right", "home", "end"]
};
let key = event.keystroke.key.as_str();
let from = steps.iter().position(WizardStep::is_current);
let next = if key == keys[0] {
step_toward(&steps, from, -1)
} else if key == keys[1] {
step_toward(&steps, from, 1)
} else if key == keys[2] {
step_toward(&steps, None, 1)
} else if key == keys[3] {
step_toward(&steps, None, -1)
} else {
return;
};
let Some(next) = next else {
return;
};
handler(&WizardIntent::Step(next), window, cx);
cx.stop_propagation();
});
}
for step in &self.steps {
strip = strip.child(self.step_element(step, &theme, window, cx));
}
let ident = self.ident.clone();
let handler = self.handler();
let body = self.body.map(|body| {
div().w_full().child(body).semantic_in(
cx,
NodeSpec::new(ident.child("body").semantic_id(), Role::Group)
.parent(ident.semantic_id()),
)
});
let back = handler
.as_ref()
.zip(self.back_to.clone())
.map(|(handler, target)| {
let handler = Rc::clone(handler);
div()
.child(
Button::new(ident.child("back"))
.label(
self.back_label
.clone()
.unwrap_or_else(|| cx.strings().text(StringKey::WizardBack)),
)
.secondary()
.control_size(self.size)
.semantic_parent(ident.semantic_id())
.on_click(move |window, cx| handler(&WizardIntent::Back, window, cx)),
)
.semantic_in(
cx,
NodeSpec::new(ident.child("back-target").semantic_id(), Role::Status)
.parent(ident.semantic_id())
.text(cx.strings().text(StringKey::WizardReturnsTo))
.value(target),
)
});
let advance = handler.as_ref().map(|handler| {
let handler = Rc::clone(handler);
let finish = self.finish;
let button = ident.child(if finish { "finish" } else { "next" });
let label = if finish {
self.finish_label
.clone()
.unwrap_or_else(|| cx.strings().text(StringKey::WizardFinish))
} else {
self.next_label
.clone()
.unwrap_or_else(|| cx.strings().text(StringKey::WizardNext))
};
Button::new(button)
.label(label)
.primary()
.control_size(self.size)
.semantic_parent(ident.semantic_id())
.disabled(!self.can_advance)
.on_click(move |window, cx| {
handler(
if finish {
&WizardIntent::Finish
} else {
&WizardIntent::Next
},
window,
cx,
)
})
});
div()
.column()
.w_full()
.gap_token(&theme, Space::Md)
.child(strip)
.children(body)
.child(
div()
.row()
.w_full()
.gap_token(&theme, Space::Sm)
.children(back)
.child(div().flex_1())
.children(advance),
)
.semantic_in(
cx,
NodeSpec::new(ident.semantic_id(), Role::List)
.disabled(self.disabled)
.value(count.to_string()),
)
}
}
fn step_toward(steps: &[WizardStep], from: Option<usize>, delta: isize) -> Option<SharedString> {
bounded_step(steps.len(), from, delta, |index| {
!steps[index].is_reachable()
})
.map(|index| steps[index].id.clone())
}