mod state;
use std::collections::HashMap;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph};
use super::{Component, EventContext, RenderContext};
use crate::input::{Event, Key};
use crate::theme::Theme;
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum StepStatus {
Pending,
Active,
Completed,
Failed,
Skipped,
}
impl StepStatus {
fn icon(&self) -> &'static str {
match self {
StepStatus::Pending => "○",
StepStatus::Active => "●",
StepStatus::Completed => "✓",
StepStatus::Failed => "✗",
StepStatus::Skipped => "⊘",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum StepOrientation {
Horizontal,
Vertical,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct Step {
label: String,
status: StepStatus,
description: Option<String>,
}
impl Step {
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
status: StepStatus::Pending,
description: None,
}
}
pub fn with_status(mut self, status: StepStatus) -> Self {
self.status = status;
self
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn label(&self) -> &str {
&self.label
}
pub fn status(&self) -> &StepStatus {
&self.status
}
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum StepIndicatorMessage {
SetStatus {
index: usize,
status: StepStatus,
},
ActivateNext,
CompleteActive,
FailActive,
Skip(usize),
Reset,
FocusNext,
FocusPrev,
Select,
First,
Last,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum StepIndicatorOutput {
StatusChanged {
index: usize,
status: StepStatus,
},
AllCompleted,
Selected(usize),
FocusChanged(usize),
Reset,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct StepIndicatorState {
steps: Vec<Step>,
orientation: StepOrientation,
focused_index: usize,
show_descriptions: bool,
title: Option<String>,
connector: String,
show_border: bool,
#[cfg_attr(feature = "serialization", serde(skip, default))]
status_style_overrides: HashMap<StepStatus, Style>,
#[cfg_attr(feature = "serialization", serde(skip, default))]
step_style_overrides: HashMap<usize, Style>,
}
impl Default for StepIndicatorState {
fn default() -> Self {
Self {
steps: Vec::new(),
orientation: StepOrientation::Horizontal,
focused_index: 0,
show_descriptions: false,
title: None,
connector: "───".to_string(),
show_border: true,
status_style_overrides: HashMap::new(),
step_style_overrides: HashMap::new(),
}
}
}
pub struct StepIndicator;
impl Component for StepIndicator {
type State = StepIndicatorState;
type Message = StepIndicatorMessage;
type Output = StepIndicatorOutput;
fn init() -> Self::State {
StepIndicatorState::default()
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
StepIndicatorMessage::SetStatus { index, status } => {
if let Some(step) = state.steps.get_mut(index) {
step.status = status.clone();
let output = StepIndicatorOutput::StatusChanged { index, status };
if state.is_all_completed() {
return Some(StepIndicatorOutput::AllCompleted);
}
return Some(output);
}
None
}
StepIndicatorMessage::ActivateNext => {
let next = state
.steps
.iter()
.position(|s| s.status == StepStatus::Pending);
if let Some(index) = next {
state.steps[index].status = StepStatus::Active;
Some(StepIndicatorOutput::StatusChanged {
index,
status: StepStatus::Active,
})
} else {
None
}
}
StepIndicatorMessage::CompleteActive => {
if let Some(index) = state.active_step_index() {
state.steps[index].status = StepStatus::Completed;
if state.is_all_completed() {
return Some(StepIndicatorOutput::AllCompleted);
}
Some(StepIndicatorOutput::StatusChanged {
index,
status: StepStatus::Completed,
})
} else {
None
}
}
StepIndicatorMessage::FailActive => {
if let Some(index) = state.active_step_index() {
state.steps[index].status = StepStatus::Failed;
Some(StepIndicatorOutput::StatusChanged {
index,
status: StepStatus::Failed,
})
} else {
None
}
}
StepIndicatorMessage::Skip(index) => {
if let Some(step) = state.steps.get_mut(index) {
step.status = StepStatus::Skipped;
if state.is_all_completed() {
return Some(StepIndicatorOutput::AllCompleted);
}
Some(StepIndicatorOutput::StatusChanged {
index,
status: StepStatus::Skipped,
})
} else {
None
}
}
StepIndicatorMessage::Reset => {
for step in &mut state.steps {
step.status = StepStatus::Pending;
}
state.focused_index = 0;
Some(StepIndicatorOutput::Reset)
}
StepIndicatorMessage::FocusNext => {
if state.steps.is_empty() {
return None;
}
state.focused_index = (state.focused_index + 1) % state.steps.len();
Some(StepIndicatorOutput::FocusChanged(state.focused_index))
}
StepIndicatorMessage::FocusPrev => {
if state.steps.is_empty() {
return None;
}
state.focused_index = state
.focused_index
.checked_sub(1)
.unwrap_or(state.steps.len() - 1);
Some(StepIndicatorOutput::FocusChanged(state.focused_index))
}
StepIndicatorMessage::Select => {
if state.steps.is_empty() {
return None;
}
Some(StepIndicatorOutput::Selected(state.focused_index))
}
StepIndicatorMessage::First => {
if state.steps.is_empty() {
return None;
}
state.focused_index = 0;
Some(StepIndicatorOutput::FocusChanged(0))
}
StepIndicatorMessage::Last => {
if state.steps.is_empty() {
return None;
}
state.focused_index = state.steps.len() - 1;
Some(StepIndicatorOutput::FocusChanged(state.focused_index))
}
}
}
fn handle_event(
_state: &Self::State,
event: &Event,
ctx: &EventContext,
) -> Option<Self::Message> {
if !ctx.focused || ctx.disabled {
return None;
}
if let Some(key) = event.as_key() {
match key.code {
Key::Left | Key::Char('h') => Some(StepIndicatorMessage::FocusPrev),
Key::Right | Key::Char('l') => Some(StepIndicatorMessage::FocusNext),
Key::Home => Some(StepIndicatorMessage::First),
Key::End => Some(StepIndicatorMessage::Last),
Key::Enter => Some(StepIndicatorMessage::Select),
_ => None,
}
} else {
None
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
crate::annotation::with_registry(|reg| {
reg.register(
ctx.area,
crate::annotation::Annotation::new(crate::annotation::WidgetType::StepIndicator)
.with_id("step_indicator")
.with_focus(ctx.focused)
.with_disabled(ctx.disabled),
);
});
let inner = if state.show_border {
let mut block = Block::default()
.borders(Borders::ALL)
.border_style(if ctx.focused {
ctx.theme.focused_border_style()
} else {
ctx.theme.border_style()
});
if let Some(title) = &state.title {
block = block.title(format!(" {} ", title));
}
let inner = block.inner(ctx.area);
ctx.frame.render_widget(block, ctx.area);
inner
} else {
ctx.area
};
if state.steps.is_empty() {
return;
}
match state.orientation {
StepOrientation::Horizontal => {
render_horizontal(state, ctx.frame, inner, ctx.theme, ctx.focused);
}
StepOrientation::Vertical => {
render_vertical(state, ctx.frame, inner, ctx.theme, ctx.focused);
}
}
}
}
fn step_style(
index: usize,
status: &StepStatus,
is_focused_step: bool,
theme: &Theme,
step_overrides: &HashMap<usize, Style>,
status_overrides: &HashMap<StepStatus, Style>,
) -> Style {
let base = step_overrides
.get(&index)
.or_else(|| status_overrides.get(status))
.copied()
.unwrap_or_else(|| match status {
StepStatus::Completed => theme.success_style(),
StepStatus::Active => theme.info_style(),
StepStatus::Failed => theme.error_style(),
StepStatus::Skipped => theme.disabled_style(),
StepStatus::Pending => theme.normal_style(),
});
if is_focused_step {
base.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
} else {
base
}
}
fn render_horizontal(
state: &StepIndicatorState,
frame: &mut Frame,
area: Rect,
theme: &Theme,
focused: bool,
) {
let mut spans = Vec::new();
for (i, step) in state.steps.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(
format!(" {} ", state.connector),
theme.normal_style(),
));
}
let is_focused_step = focused && i == state.focused_index;
let style = step_style(
i,
&step.status,
is_focused_step,
theme,
&state.step_style_overrides,
&state.status_style_overrides,
);
spans.push(Span::styled(
format!("{} {}", step.status.icon(), step.label),
style,
));
}
let line = Line::from(spans);
let paragraph = Paragraph::new(line);
frame.render_widget(paragraph, area);
}
fn render_vertical(
state: &StepIndicatorState,
frame: &mut Frame,
area: Rect,
theme: &Theme,
focused: bool,
) {
let mut lines = Vec::new();
for (i, step) in state.steps.iter().enumerate() {
if i > 0 {
lines.push(Line::from(Span::styled("│", theme.normal_style())));
}
let is_focused_step = focused && i == state.focused_index;
let style = step_style(
i,
&step.status,
is_focused_step,
theme,
&state.step_style_overrides,
&state.status_style_overrides,
);
lines.push(Line::from(Span::styled(
format!("{} {}", step.status.icon(), step.label),
style,
)));
if state.show_descriptions {
if let Some(desc) = &step.description {
lines.push(Line::from(Span::styled(
format!(" {}", desc),
theme.normal_style(),
)));
}
}
}
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, area);
}
#[cfg(test)]
mod tests;