mod types;
pub use types::*;
use ratatui::widgets::{Block, Borders, List, ListItem};
use super::{Component, EventContext, RenderContext};
use crate::input::{Event, Key};
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct MultiProgressState {
items: Vec<ProgressItem>,
max_visible: usize,
scroll_offset: usize,
selected: Option<usize>,
auto_remove_completed: bool,
title: Option<String>,
show_percentages: bool,
}
impl Default for MultiProgressState {
fn default() -> Self {
Self {
items: Vec::new(),
max_visible: 8,
scroll_offset: 0,
selected: None,
auto_remove_completed: false,
title: None,
show_percentages: true,
}
}
}
impl MultiProgressState {
pub fn new() -> Self {
Self::default()
}
pub fn with_max_visible(mut self, max: usize) -> Self {
self.max_visible = max;
self
}
pub fn with_auto_remove_completed(mut self, auto_remove: bool) -> Self {
self.auto_remove_completed = auto_remove;
self
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_show_percentages(mut self, show: bool) -> Self {
self.show_percentages = show;
self
}
pub fn add(&mut self, id: impl Into<String>, label: impl Into<String>) -> bool {
let id = id.into();
if self.items.iter().any(|i| i.id == id) {
return false;
}
self.items.push(ProgressItem::new(id, label));
if self.selected.is_none() {
self.selected = Some(0);
}
true
}
pub fn items(&self) -> &[ProgressItem] {
&self.items
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn completed_count(&self) -> usize {
self.items
.iter()
.filter(|i| i.status == ProgressItemStatus::Completed)
.count()
}
pub fn failed_count(&self) -> usize {
self.items
.iter()
.filter(|i| i.status == ProgressItemStatus::Failed)
.count()
}
pub fn active_count(&self) -> usize {
self.items
.iter()
.filter(|i| i.status == ProgressItemStatus::Active)
.count()
}
pub fn overall_progress(&self) -> f32 {
if self.items.is_empty() {
return 0.0;
}
let sum: f32 = self.items.iter().map(|i| i.progress).sum();
sum / self.items.len() as f32
}
pub fn find(&self, id: &str) -> Option<&ProgressItem> {
self.items.iter().find(|i| i.id == id)
}
pub fn find_mut(&mut self, id: &str) -> Option<&mut ProgressItem> {
self.items.iter_mut().find(|i| i.id == id)
}
pub fn remove(&mut self, id: &str) -> bool {
let len_before = self.items.len();
self.items.retain(|i| i.id != id);
self.items.len() < len_before
}
pub fn clear(&mut self) {
self.items.clear();
self.scroll_offset = 0;
}
pub fn max_visible(&self) -> usize {
self.max_visible
}
pub fn set_max_visible(&mut self, max: usize) {
self.max_visible = max;
}
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
pub fn selected(&self) -> Option<usize> {
self.selected
}
pub fn selected_item(&self) -> Option<&ProgressItem> {
self.selected.and_then(|i| self.items.get(i))
}
pub fn set_selected(&mut self, index: Option<usize>) {
self.selected = index.map(|i| i.min(self.items.len().saturating_sub(1)));
}
pub fn set_scroll_offset(&mut self, offset: usize) {
self.scroll_offset = offset.min(self.items.len().saturating_sub(1));
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn set_title(&mut self, title: Option<String>) {
self.title = title;
}
pub fn show_percentages(&self) -> bool {
self.show_percentages
}
pub fn set_show_percentages(&mut self, show: bool) {
self.show_percentages = show;
}
pub fn auto_remove_completed(&self) -> bool {
self.auto_remove_completed
}
pub fn set_auto_remove_completed(&mut self, auto_remove: bool) {
self.auto_remove_completed = auto_remove;
}
pub fn update(&mut self, msg: MultiProgressMessage) -> Option<MultiProgressOutput> {
MultiProgress::update(self, msg)
}
}
pub struct MultiProgress;
impl Component for MultiProgress {
type State = MultiProgressState;
type Message = MultiProgressMessage;
type Output = MultiProgressOutput;
fn init() -> Self::State {
MultiProgressState::default()
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
MultiProgressMessage::Add { id, label } => {
if state.add(&id, label) {
Some(MultiProgressOutput::Added(id))
} else {
None
}
}
MultiProgressMessage::SetProgress { id, progress } => {
if let Some(item) = state.find_mut(&id) {
item.progress = progress.clamp(0.0, 1.0);
if item.status == ProgressItemStatus::Pending && progress > 0.0 {
item.status = ProgressItemStatus::Active;
}
}
None
}
MultiProgressMessage::SetStatus { id, status } => {
if let Some(item) = state.find_mut(&id) {
item.status = status;
}
None
}
MultiProgressMessage::SetMessage { id, message } => {
if let Some(item) = state.find_mut(&id) {
item.message = message;
}
None
}
MultiProgressMessage::Complete(id) => {
if let Some(item) = state.find_mut(&id) {
item.progress = 1.0;
item.status = ProgressItemStatus::Completed;
if state.auto_remove_completed {
state.remove(&id);
return Some(MultiProgressOutput::Removed(id));
}
return Some(MultiProgressOutput::Completed(id));
}
None
}
MultiProgressMessage::Fail { id, message } => {
if let Some(item) = state.find_mut(&id) {
item.status = ProgressItemStatus::Failed;
item.message = message;
return Some(MultiProgressOutput::Failed(id));
}
None
}
MultiProgressMessage::Remove(id) => {
if state.remove(&id) {
Some(MultiProgressOutput::Removed(id))
} else {
None
}
}
MultiProgressMessage::Clear => {
if state.items.is_empty() {
None
} else {
state.clear();
Some(MultiProgressOutput::Cleared)
}
}
MultiProgressMessage::Select => {
if let Some(index) = state.selected {
if index < state.items.len() {
return Some(MultiProgressOutput::Selected(index));
}
}
None
}
MultiProgressMessage::ScrollUp => {
let current = state.selected.unwrap_or(0);
if current > 0 {
state.selected = Some(current - 1);
if state.selected.unwrap_or(0) < state.scroll_offset {
state.scroll_offset = state.selected.unwrap_or(0);
}
}
None
}
MultiProgressMessage::ScrollDown => {
let current = state.selected.unwrap_or(0);
let max = state.items.len().saturating_sub(1);
if current < max {
state.selected = Some(current + 1);
let sel = state.selected.unwrap_or(0);
if sel >= state.scroll_offset + state.max_visible {
state.scroll_offset =
sel.saturating_sub(state.max_visible.saturating_sub(1));
}
}
None
}
MultiProgressMessage::ScrollToTop => {
state.selected = if state.items.is_empty() {
None
} else {
Some(0)
};
state.scroll_offset = 0;
None
}
MultiProgressMessage::ScrollToBottom => {
let last = state.items.len().saturating_sub(1);
state.selected = if state.items.is_empty() {
None
} else {
Some(last)
};
state.scroll_offset = state.items.len().saturating_sub(state.max_visible);
None
}
}
}
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::Up | Key::Char('k') => Some(MultiProgressMessage::ScrollUp),
Key::Down | Key::Char('j') => Some(MultiProgressMessage::ScrollDown),
Key::Enter => Some(MultiProgressMessage::Select),
_ => None,
}
} else {
None
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
if ctx.area.width == 0 || ctx.area.height == 0 {
return;
}
crate::annotation::with_registry(|reg| {
reg.register(
ctx.area,
crate::annotation::Annotation::new(crate::annotation::WidgetType::MultiProgress)
.with_id("multi_progress")
.with_meta("item_count", state.items.len().to_string()),
);
});
let block = if let Some(title) = &state.title {
Block::default().borders(Borders::ALL).title(title.as_str())
} else {
Block::default().borders(Borders::ALL)
};
let inner = block.inner(ctx.area);
ctx.frame.render_widget(block, ctx.area);
if state.items.is_empty() || inner.height == 0 {
return;
}
let visible_count = (inner.height as usize).min(state.items.len() - state.scroll_offset);
let items: Vec<ListItem> = state
.items
.iter()
.skip(state.scroll_offset)
.take(visible_count)
.map(|item| {
let symbol = item.status.symbol();
let style = if ctx.disabled {
ctx.theme.disabled_style()
} else {
item.status.style(ctx.theme)
};
let content = if item.status == ProgressItemStatus::Failed {
if let Some(msg) = &item.message {
format!("{} {} Error: {}", symbol, item.label, msg)
} else {
format!("{} {} Error", symbol, item.label)
}
} else if state.show_percentages {
let bar_width = inner.width as usize - item.label.len() - 12; let filled = ((item.progress * bar_width as f32) as usize).min(bar_width);
let empty = bar_width.saturating_sub(filled);
format!(
"{} {} {:>3}% {}{}",
symbol,
item.label,
item.percentage(),
"█".repeat(filled),
"░".repeat(empty)
)
} else {
format!("{} {}", symbol, item.label)
};
ListItem::new(content).style(style)
})
.collect();
let list = List::new(items);
ctx.frame.render_widget(list, inner);
}
}
#[cfg(test)]
mod snapshot_tests;
#[cfg(test)]
mod tests;