use crate::render::{Cell, Modifier};
use crate::style::Color;
use crate::widget::theme::{DISABLED_FG, SEPARATOR_COLOR, SUBTLE_GRAY};
use crate::widget::traits::{RenderContext, View, WidgetProps};
use crate::{impl_props_builders, impl_styled_view};
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub enum StepStatus {
#[default]
Pending,
Active,
Completed,
Error,
Skipped,
}
impl StepStatus {
fn icon(&self) -> char {
match self {
StepStatus::Pending => '○',
StepStatus::Active => '●',
StepStatus::Completed => '✓',
StepStatus::Error => '✗',
StepStatus::Skipped => '⊘',
}
}
}
#[derive(Clone, Debug)]
pub struct Step {
pub title: String,
pub description: Option<String>,
pub status: StepStatus,
pub icon: Option<char>,
}
impl Step {
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
description: None,
status: StepStatus::Pending,
icon: None,
}
}
pub fn description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
pub fn status(mut self, status: StepStatus) -> Self {
self.status = status;
self
}
pub fn icon(mut self, icon: char) -> Self {
self.icon = Some(icon);
self
}
pub fn complete(mut self) -> Self {
self.status = StepStatus::Completed;
self
}
pub fn active(mut self) -> Self {
self.status = StepStatus::Active;
self
}
fn display_icon(&self) -> char {
self.icon.unwrap_or_else(|| self.status.icon())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub enum StepperOrientation {
#[default]
Horizontal,
Vertical,
}
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub enum StepperStyle {
#[default]
Dots,
Numbered,
Connected,
Progress,
}
#[derive(Clone, Debug)]
pub struct Stepper {
steps: Vec<Step>,
current: usize,
orientation: StepperOrientation,
style: StepperStyle,
show_descriptions: bool,
active_color: Color,
completed_color: Color,
pending_color: Color,
error_color: Color,
connector_color: Color,
show_numbers: bool,
props: WidgetProps,
}
impl Stepper {
pub fn new() -> Self {
Self {
steps: Vec::new(),
current: 0,
orientation: StepperOrientation::Horizontal,
style: StepperStyle::Connected,
show_descriptions: true,
active_color: Color::CYAN,
completed_color: Color::GREEN,
pending_color: DISABLED_FG,
error_color: Color::RED,
connector_color: SEPARATOR_COLOR,
show_numbers: true,
props: WidgetProps::new(),
}
}
pub fn step(mut self, step: Step) -> Self {
self.steps.push(step);
self
}
pub fn add_step(mut self, title: impl Into<String>) -> Self {
self.steps.push(Step::new(title));
self
}
pub fn steps(mut self, steps: Vec<Step>) -> Self {
self.steps = steps;
self
}
pub fn current(mut self, index: usize) -> Self {
self.current = index.min(self.steps.len().saturating_sub(1));
self.update_statuses();
self
}
pub fn orientation(mut self, orientation: StepperOrientation) -> Self {
self.orientation = orientation;
self
}
pub fn horizontal(mut self) -> Self {
self.orientation = StepperOrientation::Horizontal;
self
}
pub fn vertical(mut self) -> Self {
self.orientation = StepperOrientation::Vertical;
self
}
pub fn style(mut self, style: StepperStyle) -> Self {
self.style = style;
self
}
pub fn descriptions(mut self, show: bool) -> Self {
self.show_descriptions = show;
self
}
pub fn numbers(mut self, show: bool) -> Self {
self.show_numbers = show;
self
}
pub fn active_color(mut self, color: Color) -> Self {
self.active_color = color;
self
}
pub fn completed_color(mut self, color: Color) -> Self {
self.completed_color = color;
self
}
fn update_statuses(&mut self) {
for (i, step) in self.steps.iter_mut().enumerate() {
if step.status != StepStatus::Error && step.status != StepStatus::Skipped {
step.status = if i < self.current {
StepStatus::Completed
} else if i == self.current {
StepStatus::Active
} else {
StepStatus::Pending
};
}
}
}
pub fn next_step(&mut self) -> bool {
if self.current < self.steps.len().saturating_sub(1) {
self.current += 1;
self.update_statuses();
true
} else {
false
}
}
pub fn prev(&mut self) -> bool {
if self.current > 0 {
self.current -= 1;
self.update_statuses();
true
} else {
false
}
}
pub fn go_to(&mut self, index: usize) {
if index < self.steps.len() {
self.current = index;
self.update_statuses();
}
}
pub fn complete_current(&mut self) {
if let Some(step) = self.steps.get_mut(self.current) {
step.status = StepStatus::Completed;
}
self.next_step();
}
pub fn mark_error(&mut self, index: usize) {
if let Some(step) = self.steps.get_mut(index) {
step.status = StepStatus::Error;
}
}
pub fn skip(&mut self, index: usize) {
if let Some(step) = self.steps.get_mut(index) {
step.status = StepStatus::Skipped;
}
}
pub fn current_step(&self) -> Option<&Step> {
self.steps.get(self.current)
}
pub fn len(&self) -> usize {
self.steps.len()
}
pub fn is_empty(&self) -> bool {
self.steps.is_empty()
}
pub fn is_completed(&self) -> bool {
self.steps
.last()
.is_some_and(|s| s.status == StepStatus::Completed)
}
pub fn progress(&self) -> f64 {
if self.steps.is_empty() {
return 0.0;
}
let completed = self
.steps
.iter()
.filter(|s| s.status == StepStatus::Completed)
.count();
completed as f64 / self.steps.len() as f64
}
fn step_color(&self, step: &Step) -> Color {
match step.status {
StepStatus::Active => self.active_color,
StepStatus::Completed => self.completed_color,
StepStatus::Error => self.error_color,
StepStatus::Pending | StepStatus::Skipped => self.pending_color,
}
}
}
impl Default for Stepper {
fn default() -> Self {
Self::new()
}
}
impl View for Stepper {
crate::impl_view_meta!("Stepper");
fn render(&self, ctx: &mut RenderContext) {
let area = ctx.area;
if area.width < 3 || area.height < 1 || self.steps.is_empty() {
return;
}
match self.orientation {
StepperOrientation::Horizontal => self.render_horizontal(ctx),
StepperOrientation::Vertical => self.render_vertical(ctx),
}
}
}
impl Stepper {
fn render_horizontal(&self, ctx: &mut RenderContext) {
let area = ctx.area;
let step_count = self.steps.len();
let available_width = area.width as usize;
let step_width = available_width / step_count.max(1);
let y: u16 = 0;
for (i, step) in self.steps.iter().enumerate() {
let x = (i * step_width) as u16;
let color = self.step_color(step);
match self.style {
StepperStyle::Numbered => {
let num = format!("{}", i + 1);
for (j, ch) in num.chars().enumerate() {
let mut cell = Cell::new(ch);
cell.fg = Some(color);
if step.status == StepStatus::Active {
cell.modifier |= Modifier::BOLD;
}
ctx.set(x + j as u16, y, cell);
}
}
_ => {
let mut cell = Cell::new(step.display_icon());
cell.fg = Some(color);
if step.status == StepStatus::Active {
cell.modifier |= Modifier::BOLD;
}
ctx.set(x, y, cell);
}
}
if matches!(self.style, StepperStyle::Connected | StepperStyle::Progress)
&& i < step_count - 1
{
let connector_start = x + 2;
let connector_end = ((i + 1) * step_width) as u16;
for cx in connector_start..connector_end {
let ch = if matches!(self.style, StepperStyle::Progress)
&& step.status == StepStatus::Completed
{
'━'
} else {
'─'
};
let mut cell = Cell::new(ch);
cell.fg = Some(if step.status == StepStatus::Completed {
self.completed_color
} else {
self.connector_color
});
ctx.set(cx, y, cell);
}
}
if y + 1 < area.height {
let max_title_len = step_width.saturating_sub(1);
let title = if step.title.len() > max_title_len {
format!("{}…", &step.title[..max_title_len.saturating_sub(1)])
} else {
step.title.clone()
};
for (j, ch) in title.chars().enumerate() {
if x + j as u16 >= area.width {
break;
}
let mut cell = Cell::new(ch);
cell.fg = Some(color);
if step.status == StepStatus::Active {
cell.modifier |= Modifier::BOLD;
}
ctx.set(x + j as u16, y + 1, cell);
}
}
if self.show_descriptions && y + 2 < area.height {
if let Some(ref desc) = step.description {
let max_desc_len = step_width.saturating_sub(1);
let desc_str = if desc.len() > max_desc_len {
format!("{}…", &desc[..max_desc_len.saturating_sub(1)])
} else {
desc.clone()
};
for (j, ch) in desc_str.chars().enumerate() {
if x + j as u16 >= area.width {
break;
}
let mut cell = Cell::new(ch);
cell.fg = Some(SUBTLE_GRAY);
ctx.set(x + j as u16, y + 2, cell);
}
}
}
}
}
fn render_vertical(&self, ctx: &mut RenderContext) {
let area = ctx.area;
let mut y: u16 = 0;
for (i, step) in self.steps.iter().enumerate() {
if y >= area.height {
break;
}
let color = self.step_color(step);
let x: u16 = 0;
let indicator = if self.show_numbers {
format!("{}", i + 1)
} else {
step.display_icon().to_string()
};
for (j, ch) in indicator.chars().enumerate() {
let mut cell = Cell::new(ch);
cell.fg = Some(color);
if step.status == StepStatus::Active {
cell.modifier |= Modifier::BOLD;
}
ctx.set(x + j as u16, y, cell);
}
let title_x = x + 3;
for (j, ch) in step.title.chars().enumerate() {
if title_x + j as u16 >= area.width {
break;
}
let mut cell = Cell::new(ch);
cell.fg = Some(color);
if step.status == StepStatus::Active {
cell.modifier |= Modifier::BOLD;
}
ctx.set(title_x + j as u16, y, cell);
}
y += 1;
if self.show_descriptions {
if let Some(ref desc) = step.description {
if y < area.height {
let desc_x = x + 3;
for (j, ch) in desc.chars().enumerate() {
if desc_x + j as u16 >= area.width {
break;
}
let mut cell = Cell::new(ch);
cell.fg = Some(SUBTLE_GRAY);
ctx.set(desc_x + j as u16, y, cell);
}
y += 1;
}
}
}
if matches!(self.style, StepperStyle::Connected)
&& i < self.steps.len() - 1
&& y < area.height
{
let mut cell = Cell::new('│');
cell.fg = Some(if step.status == StepStatus::Completed {
self.completed_color
} else {
self.connector_color
});
ctx.set(x, y, cell);
y += 1;
}
}
}
}
impl_styled_view!(Stepper);
impl_props_builders!(Stepper);
pub fn stepper() -> Stepper {
Stepper::new()
}
pub fn step(title: impl Into<String>) -> Step {
Step::new(title)
}