use crate::ComponentTheme;
use crate::button::{Button, ButtonSize, ButtonVariant};
use crate::progress::{Progress, ProgressSize, ProgressVariant};
use crate::theme::ThemeExt;
use gpui::prelude::*;
use gpui::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StepStatus {
#[default]
NotVisited,
Active,
Completed,
Error,
Skipped,
}
#[derive(Debug, Clone, ComponentTheme)]
pub struct WizardTheme {
#[theme(default = 0x2a2a2aff, from = surface)]
pub step_bg: Rgba,
#[theme(default = 0x22c55eff, from = success)]
pub step_completed_bg: Rgba,
#[theme(default = 0x007accff, from = accent)]
pub step_active_bg: Rgba,
#[theme(default = 0xef4444ff, from = error)]
pub step_error_bg: Rgba,
#[theme(default = 0xffffffff, from = text_primary)]
pub step_text: Rgba,
#[theme(default = 0x888888ff, from = text_muted)]
pub label_text: Rgba,
#[theme(default = 0xffffffff, from = text_primary)]
pub label_active_text: Rgba,
#[theme(default = 0x3a3a3aff, from = border)]
pub connector_color: Rgba,
#[theme(default = 0x22c55eff, from = success)]
pub connector_completed_color: Rgba,
#[theme(default = 0x3a3a3aff, from = border)]
pub step_border: Rgba,
}
#[derive(Clone)]
pub struct WizardStep {
pub id: SharedString,
pub label: SharedString,
pub description: Option<SharedString>,
pub icon: Option<SharedString>,
pub can_skip: bool,
pub disabled: bool,
}
impl WizardStep {
pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
label: label.into(),
description: None,
icon: None,
can_skip: false,
disabled: false,
}
}
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
self.description = Some(description.into());
self
}
pub fn icon(mut self, icon: impl Into<SharedString>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn can_skip(mut self, can_skip: bool) -> Self {
self.can_skip = can_skip;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WizardVariant {
#[default]
Horizontal,
Vertical,
}
pub struct Wizard {
steps: Vec<WizardStep>,
step_statuses: Vec<StepStatus>,
current_step: usize,
variant: WizardVariant,
theme: Option<WizardTheme>,
is_busy: bool,
progress: Option<f32>,
status_message: Option<SharedString>,
show_cancel: bool,
back_label: Option<SharedString>,
next_label: Option<SharedString>,
finish_label: Option<SharedString>,
cancel_label: Option<SharedString>,
on_step_change: Option<Box<dyn Fn(usize, &mut Window, &mut App) + 'static>>,
on_validate: Option<Box<dyn Fn(usize) -> bool + 'static>>,
on_finish: Option<Box<dyn Fn(&mut Window, &mut App) + 'static>>,
on_cancel: Option<Box<dyn Fn(&mut Window, &mut App) + 'static>>,
on_back: Option<Box<dyn Fn(usize, &mut Window, &mut App) + 'static>>,
on_next: Option<Box<dyn Fn(usize, &mut Window, &mut App) + 'static>>,
}
impl Wizard {
pub fn new() -> Self {
Self {
steps: Vec::new(),
step_statuses: Vec::new(),
current_step: 0,
variant: WizardVariant::default(),
theme: None,
is_busy: false,
progress: None,
status_message: None,
show_cancel: true,
back_label: None,
next_label: None,
finish_label: None,
cancel_label: None,
on_step_change: None,
on_validate: None,
on_finish: None,
on_cancel: None,
on_back: None,
on_next: None,
}
}
pub fn steps(mut self, steps: Vec<WizardStep>) -> Self {
let count = steps.len();
self.steps = steps;
self.step_statuses = vec![StepStatus::NotVisited; count];
if count > 0 {
self.step_statuses[0] = StepStatus::Active;
}
self
}
pub fn step_statuses(mut self, statuses: Vec<StepStatus>) -> Self {
self.step_statuses = statuses;
self
}
pub fn current_step(mut self, step: usize) -> Self {
self.current_step = step;
self
}
pub fn variant(mut self, variant: WizardVariant) -> Self {
self.variant = variant;
self
}
pub fn theme(mut self, theme: WizardTheme) -> Self {
self.theme = Some(theme);
self
}
pub fn is_busy(mut self, busy: bool) -> Self {
self.is_busy = busy;
self
}
pub fn progress(mut self, progress: f32) -> Self {
self.progress = Some(progress);
self
}
pub fn status_message(mut self, message: impl Into<SharedString>) -> Self {
self.status_message = Some(message.into());
self
}
pub fn show_cancel(mut self, show: bool) -> Self {
self.show_cancel = show;
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 cancel_label(mut self, label: impl Into<SharedString>) -> Self {
self.cancel_label = Some(label.into());
self
}
pub fn on_step_change(
mut self,
handler: impl Fn(usize, &mut Window, &mut App) + 'static,
) -> Self {
self.on_step_change = Some(Box::new(handler));
self
}
pub fn on_validate(mut self, handler: impl Fn(usize) -> bool + 'static) -> Self {
self.on_validate = Some(Box::new(handler));
self
}
pub fn on_finish(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_finish = Some(Box::new(handler));
self
}
pub fn on_cancel(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_cancel = Some(Box::new(handler));
self
}
pub fn on_back(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_back = Some(Box::new(handler));
self
}
pub fn on_next(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_next = Some(Box::new(handler));
self
}
fn build_step_indicators(&self, theme: &WizardTheme) -> Div {
let mut container = div().flex().items_center().gap_2();
for (index, step) in self.steps.iter().enumerate() {
let status = self
.step_statuses
.get(index)
.copied()
.unwrap_or(StepStatus::NotVisited);
let is_current = index == self.current_step;
let (bg_color, text_color, border_color) = match status {
StepStatus::NotVisited => (theme.step_bg, theme.label_text, theme.step_border),
StepStatus::Active => (theme.step_active_bg, theme.step_text, theme.step_active_bg),
StepStatus::Completed => (
theme.step_completed_bg,
theme.step_text,
theme.step_completed_bg,
),
StepStatus::Error => (theme.step_error_bg, theme.step_text, theme.step_error_bg),
StepStatus::Skipped => (theme.step_bg, theme.label_text, theme.step_border),
};
let step_number = format!("{}", index + 1);
let step_icon = if status == StepStatus::Completed {
"✓".to_string()
} else if status == StepStatus::Error {
"✗".to_string()
} else if let Some(icon) = &step.icon {
icon.to_string()
} else {
step_number
};
let step_circle = div()
.w(px(28.0))
.h(px(28.0))
.rounded_full()
.bg(bg_color)
.border_2()
.border_color(border_color)
.flex()
.items_center()
.justify_center()
.child(
div()
.text_sm()
.font_weight(if is_current {
FontWeight::BOLD
} else {
FontWeight::NORMAL
})
.text_color(text_color)
.child(step_icon),
);
let label_color = if is_current {
theme.label_active_text
} else {
theme.label_text
};
let label = div()
.text_sm()
.font_weight(if is_current {
FontWeight::SEMIBOLD
} else {
FontWeight::NORMAL
})
.text_color(label_color)
.child(step.label.clone());
let step_item = div()
.flex()
.items_center()
.gap_2()
.child(step_circle)
.child(label);
container = container.child(step_item);
if index < self.steps.len() - 1 {
let connector_color = if status == StepStatus::Completed {
theme.connector_completed_color
} else {
theme.connector_color
};
let connector = div().w(px(32.0)).h(px(2.0)).bg(connector_color);
container = container.child(connector);
}
}
container
}
fn build_navigation(&self, _theme: &WizardTheme) -> Div {
let is_first_step = self.current_step == 0;
let is_last_step = self.current_step >= self.steps.len().saturating_sub(1);
let back_label = self.back_label.clone().unwrap_or_else(|| {
if is_first_step {
"Close".into()
} else {
"Back".into()
}
});
let next_label = if is_last_step {
self.finish_label.clone().unwrap_or_else(|| "Finish".into())
} else {
self.next_label.clone().unwrap_or_else(|| "Next".into())
};
let cancel_label = self.cancel_label.clone().unwrap_or_else(|| "Cancel".into());
let mut buttons = div().flex().items_center().gap_3();
if self.show_cancel && self.on_cancel.is_some() {
let on_cancel: Option<*const dyn Fn(&mut Window, &mut App)> =
self.on_cancel.as_ref().map(|f| f.as_ref() as *const _);
let mut cancel_btn = Button::new("wizard-cancel", cancel_label)
.variant(ButtonVariant::Ghost)
.size(ButtonSize::Md)
.disabled(self.is_busy);
if let Some(handler_ptr) = on_cancel {
cancel_btn = cancel_btn.on_click(move |window, cx| unsafe {
(*handler_ptr)(window, cx);
});
}
buttons = buttons.child(cancel_btn);
}
buttons = buttons.child(div().flex_1());
let on_back: Option<*const dyn Fn(usize, &mut Window, &mut App)> =
self.on_back.as_ref().map(|f| f.as_ref() as *const _);
let current_step = self.current_step;
let mut back_btn = Button::new("wizard-back", back_label)
.variant(ButtonVariant::Secondary)
.size(ButtonSize::Md)
.disabled(self.is_busy);
if let Some(handler_ptr) = on_back {
back_btn = back_btn.on_click(move |window, cx| unsafe {
(*handler_ptr)(current_step, window, cx);
});
}
buttons = buttons.child(back_btn);
let on_next: Option<*const dyn Fn(usize, &mut Window, &mut App)> =
self.on_next.as_ref().map(|f| f.as_ref() as *const _);
let on_finish: Option<*const dyn Fn(&mut Window, &mut App)> =
self.on_finish.as_ref().map(|f| f.as_ref() as *const _);
let mut next_btn = Button::new("wizard-next", next_label)
.variant(ButtonVariant::Primary)
.size(ButtonSize::Md)
.disabled(self.is_busy);
if is_last_step {
if let Some(handler_ptr) = on_finish {
next_btn = next_btn.on_click(move |window, cx| unsafe {
(*handler_ptr)(window, cx);
});
}
} else if let Some(handler_ptr) = on_next {
next_btn = next_btn.on_click(move |window, cx| unsafe {
(*handler_ptr)(current_step, window, cx);
});
}
buttons = buttons.child(next_btn);
buttons
}
pub fn build_with_theme(self, global_theme: &WizardTheme) -> Div {
let theme = self.theme.as_ref().unwrap_or(global_theme);
let mut container = div().flex().flex_col().gap_4().w_full();
let indicators = self.build_step_indicators(theme);
container = container.child(indicators);
if let Some(progress_value) = self.progress {
let progress_bar = Progress::new(progress_value)
.size(ProgressSize::Sm)
.variant(ProgressVariant::Default);
container = container.child(progress_bar);
}
if let Some(message) = &self.status_message {
container = container.child(
div()
.text_sm()
.text_color(theme.label_text)
.child(message.clone()),
);
}
let navigation = self.build_navigation(theme);
container = container.child(navigation);
container
}
}
impl Default for Wizard {
fn default() -> Self {
Self::new()
}
}
impl RenderOnce for Wizard {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let global_theme = cx.theme();
let wizard_theme = WizardTheme::from(&global_theme);
self.build_with_theme(&wizard_theme)
}
}
impl IntoElement for Wizard {
type Element = gpui::Component<Self>;
fn into_element(self) -> Self::Element {
gpui::Component::new(self)
}
}
pub struct WizardHeader {
steps: Vec<WizardStep>,
step_statuses: Vec<StepStatus>,
current_step: usize,
title: Option<SharedString>,
theme: Option<WizardTheme>,
}
impl WizardHeader {
pub fn new() -> Self {
Self {
steps: Vec::new(),
step_statuses: Vec::new(),
current_step: 0,
title: None,
theme: None,
}
}
pub fn steps(mut self, steps: Vec<WizardStep>) -> Self {
let count = steps.len();
self.steps = steps;
self.step_statuses = vec![StepStatus::NotVisited; count];
if count > 0 {
self.step_statuses[0] = StepStatus::Active;
}
self
}
pub fn step_statuses(mut self, statuses: Vec<StepStatus>) -> Self {
self.step_statuses = statuses;
self
}
pub fn current_step(mut self, step: usize) -> Self {
self.current_step = step;
self
}
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = Some(title.into());
self
}
pub fn theme(mut self, theme: WizardTheme) -> Self {
self.theme = Some(theme);
self
}
fn build_step_indicators(&self, theme: &WizardTheme) -> Div {
let mut container = div().flex().items_center().gap_2();
for (index, step) in self.steps.iter().enumerate() {
let status = self
.step_statuses
.get(index)
.copied()
.unwrap_or(StepStatus::NotVisited);
let is_current = index == self.current_step;
let (bg_color, text_color, border_color) = match status {
StepStatus::NotVisited => (theme.step_bg, theme.label_text, theme.step_border),
StepStatus::Active => (theme.step_active_bg, theme.step_text, theme.step_active_bg),
StepStatus::Completed => (
theme.step_completed_bg,
theme.step_text,
theme.step_completed_bg,
),
StepStatus::Error => (theme.step_error_bg, theme.step_text, theme.step_error_bg),
StepStatus::Skipped => (theme.step_bg, theme.label_text, theme.step_border),
};
let step_icon = if status == StepStatus::Completed {
"✓".to_string()
} else if status == StepStatus::Error {
"✗".to_string()
} else if let Some(icon) = &step.icon {
icon.to_string()
} else {
format!("{}", index + 1)
};
let step_circle = div()
.w(px(28.0))
.h(px(28.0))
.rounded_full()
.bg(bg_color)
.border_2()
.border_color(border_color)
.flex()
.items_center()
.justify_center()
.child(
div()
.text_sm()
.font_weight(if is_current {
FontWeight::BOLD
} else {
FontWeight::NORMAL
})
.text_color(text_color)
.child(step_icon),
);
let label_color = if is_current {
theme.label_active_text
} else {
theme.label_text
};
let label = div()
.text_sm()
.font_weight(if is_current {
FontWeight::SEMIBOLD
} else {
FontWeight::NORMAL
})
.text_color(label_color)
.child(step.label.clone());
let step_item = div()
.flex()
.items_center()
.gap_2()
.child(step_circle)
.child(label);
container = container.child(step_item);
if index < self.steps.len() - 1 {
let connector_color = if status == StepStatus::Completed {
theme.connector_completed_color
} else {
theme.connector_color
};
container = container.child(div().w(px(32.0)).h(px(2.0)).bg(connector_color));
}
}
container
}
pub fn build_with_theme(self, global_theme: &WizardTheme) -> Div {
let theme = self.theme.as_ref().unwrap_or(global_theme);
let mut container = div().flex().items_center().gap_4();
if let Some(title) = &self.title {
container = container.child(
div()
.text_xl()
.font_weight(FontWeight::BOLD)
.text_color(theme.label_active_text)
.child(title.clone()),
);
container = container.child(div().w(px(1.0)).h(px(24.0)).bg(theme.step_border));
}
container = container.child(self.build_step_indicators(theme));
container
}
}
impl Default for WizardHeader {
fn default() -> Self {
Self::new()
}
}
impl RenderOnce for WizardHeader {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let global_theme = cx.theme();
let wizard_theme = WizardTheme::from(&global_theme);
self.build_with_theme(&wizard_theme)
}
}
impl IntoElement for WizardHeader {
type Element = gpui::Component<Self>;
fn into_element(self) -> Self::Element {
gpui::Component::new(self)
}
}
pub struct WizardNavigation {
current_step: usize,
total_steps: usize,
is_busy: bool,
progress: Option<f32>,
status_message: Option<SharedString>,
show_cancel: bool,
back_label: Option<SharedString>,
next_label: Option<SharedString>,
finish_label: Option<SharedString>,
cancel_label: Option<SharedString>,
back_disabled: bool,
next_disabled: bool,
on_back: Option<Box<dyn Fn(usize, &mut Window, &mut App) + 'static>>,
on_next: Option<Box<dyn Fn(usize, &mut Window, &mut App) + 'static>>,
on_finish: Option<Box<dyn Fn(&mut Window, &mut App) + 'static>>,
on_cancel: Option<Box<dyn Fn(&mut Window, &mut App) + 'static>>,
theme: Option<WizardTheme>,
}
impl WizardNavigation {
pub fn new(current_step: usize, total_steps: usize) -> Self {
Self {
current_step,
total_steps,
is_busy: false,
progress: None,
status_message: None,
show_cancel: false,
back_label: None,
next_label: None,
finish_label: None,
cancel_label: None,
back_disabled: false,
next_disabled: false,
on_back: None,
on_next: None,
on_finish: None,
on_cancel: None,
theme: None,
}
}
pub fn is_busy(mut self, busy: bool) -> Self {
self.is_busy = busy;
self
}
pub fn progress(mut self, progress: f32) -> Self {
self.progress = Some(progress);
self
}
pub fn status_message(mut self, message: impl Into<SharedString>) -> Self {
self.status_message = Some(message.into());
self
}
pub fn show_cancel(mut self, show: bool) -> Self {
self.show_cancel = show;
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 cancel_label(mut self, label: impl Into<SharedString>) -> Self {
self.cancel_label = Some(label.into());
self
}
pub fn back_disabled(mut self, disabled: bool) -> Self {
self.back_disabled = disabled;
self
}
pub fn next_disabled(mut self, disabled: bool) -> Self {
self.next_disabled = disabled;
self
}
pub fn on_back(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_back = Some(Box::new(handler));
self
}
pub fn on_next(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_next = Some(Box::new(handler));
self
}
pub fn on_finish(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_finish = Some(Box::new(handler));
self
}
pub fn on_cancel(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_cancel = Some(Box::new(handler));
self
}
pub fn theme(mut self, theme: WizardTheme) -> Self {
self.theme = Some(theme);
self
}
pub fn build_with_theme(self, global_theme: &WizardTheme) -> Div {
let theme = self.theme.as_ref().unwrap_or(global_theme);
let is_first_step = self.current_step == 0;
let is_last_step = self.current_step >= self.total_steps.saturating_sub(1);
let back_label = self.back_label.clone().unwrap_or_else(|| {
if is_first_step {
"Close".into()
} else {
"Back".into()
}
});
let next_label = if is_last_step {
self.finish_label.clone().unwrap_or_else(|| "Finish".into())
} else {
self.next_label.clone().unwrap_or_else(|| "Next".into())
};
let cancel_label = self.cancel_label.clone().unwrap_or_else(|| "Cancel".into());
let mut container = div().flex().flex_col().gap_3().w_full();
if let Some(progress_value) = self.progress {
container = container.child(
Progress::new(progress_value)
.size(ProgressSize::Sm)
.variant(ProgressVariant::Default),
);
}
if let Some(message) = &self.status_message {
container = container.child(
div()
.text_sm()
.text_color(theme.label_text)
.child(message.clone()),
);
}
let mut buttons = div().flex().items_center().gap_3();
if self.show_cancel {
let on_cancel: Option<*const dyn Fn(&mut Window, &mut App)> =
self.on_cancel.as_ref().map(|f| f.as_ref() as *const _);
let mut cancel_btn = Button::new("wizard-nav-cancel", cancel_label)
.variant(ButtonVariant::Ghost)
.size(ButtonSize::Md)
.disabled(self.is_busy);
if let Some(handler_ptr) = on_cancel {
cancel_btn = cancel_btn.on_click(move |window, cx| unsafe {
(*handler_ptr)(window, cx);
});
}
buttons = buttons.child(cancel_btn);
}
buttons = buttons.child(div().flex_1());
let on_back: Option<*const dyn Fn(usize, &mut Window, &mut App)> =
self.on_back.as_ref().map(|f| f.as_ref() as *const _);
let current_step = self.current_step;
let mut back_btn = Button::new("wizard-nav-back", back_label)
.variant(ButtonVariant::Secondary)
.size(ButtonSize::Md)
.disabled(self.is_busy || self.back_disabled);
if let Some(handler_ptr) = on_back {
back_btn = back_btn.on_click(move |window, cx| unsafe {
(*handler_ptr)(current_step, window, cx);
});
}
buttons = buttons.child(back_btn);
let on_next: Option<*const dyn Fn(usize, &mut Window, &mut App)> =
self.on_next.as_ref().map(|f| f.as_ref() as *const _);
let on_finish: Option<*const dyn Fn(&mut Window, &mut App)> =
self.on_finish.as_ref().map(|f| f.as_ref() as *const _);
let mut next_btn = Button::new("wizard-nav-next", next_label)
.variant(ButtonVariant::Primary)
.size(ButtonSize::Md)
.disabled(self.is_busy || self.next_disabled);
if is_last_step {
if let Some(handler_ptr) = on_finish {
next_btn = next_btn.on_click(move |window, cx| unsafe {
(*handler_ptr)(window, cx);
});
}
} else if let Some(handler_ptr) = on_next {
next_btn = next_btn.on_click(move |window, cx| unsafe {
(*handler_ptr)(current_step, window, cx);
});
}
buttons = buttons.child(next_btn);
container = container.child(buttons);
container
}
}
impl RenderOnce for WizardNavigation {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let global_theme = cx.theme();
let wizard_theme = WizardTheme::from(&global_theme);
self.build_with_theme(&wizard_theme)
}
}
impl IntoElement for WizardNavigation {
type Element = gpui::Component<Self>;
fn into_element(self) -> Self::Element {
gpui::Component::new(self)
}
}