use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
};
use crate::commands::CommandId;
use crate::task_registry::{TaskRegistry, TaskStatus};
#[derive(Debug, Clone)]
pub enum StatusItem {
Label(String),
Menu { label: String, command: CommandId },
Action { label: String, command: CommandId },
FilePath { path: std::path::PathBuf, suffix: String },
}
#[derive(Debug, Default)]
pub struct StatusBarBuilder {
items: Vec<StatusItem>,
}
impl StatusBarBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn label(&mut self, text: impl Into<String>) -> &mut Self {
self.items.push(StatusItem::Label(text.into()));
self
}
pub fn menu(&mut self, label: impl Into<String>, command: CommandId) -> &mut Self {
self.items.push(StatusItem::Menu { label: label.into(), command });
self
}
pub fn action(&mut self, label: impl Into<String>, command: CommandId) -> &mut Self {
self.items.push(StatusItem::Action { label: label.into(), command });
self
}
pub fn file_path(&mut self, path: std::path::PathBuf, dirty: bool) -> &mut Self {
let suffix = if dirty { " [+]".to_owned() } else { String::new() };
self.items.push(StatusItem::FilePath { path, suffix });
self
}
pub fn build(self) -> Vec<StatusItem> {
self.items
}
}
#[derive(Debug, Default, Clone)]
pub struct StatusBarClickState {
pub item_clicks: Vec<(Rect, CommandId)>,
pub cancel_clicks: Vec<(Rect, crate::task_registry::TaskId)>,
pub task_label_clicks: Vec<(Rect, crate::task_registry::TaskId)>,
}
impl StatusBarClickState {
pub fn hit_command(&self, col: u16, row: u16) -> Option<&CommandId> {
for (rect, cmd) in &self.item_clicks {
if col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height {
return Some(cmd);
}
}
None
}
pub fn hit_cancel(&self, col: u16, row: u16) -> Option<crate::task_registry::TaskId> {
for (rect, id) in &self.cancel_clicks {
if col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height {
return Some(*id);
}
}
None
}
pub fn hit_task_label(&self, col: u16, row: u16) -> Option<crate::task_registry::TaskId> {
for (rect, id) in &self.task_label_clicks {
if col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height {
return Some(*id);
}
}
None
}
}
pub struct StatusBarRenderer;
impl StatusBarRenderer {
pub fn render(
frame: &mut Frame,
area: Rect,
items: Vec<StatusItem>,
task_registry: &TaskRegistry,
theme: &crate::theme::Theme,
) -> StatusBarClickState {
if area.height == 0 {
return StatusBarClickState::default();
}
let bg = theme.status_bar_bg();
let fg = theme.status_bar_fg();
let base_style = Style::default().fg(fg).bg(bg);
let mut click_state = StatusBarClickState::default();
struct RightEntry {
task_id: crate::task_registry::TaskId,
sep: String, ind: String, label: String,
cancel: Option<String>, style: Style,
}
let mut entries: Vec<RightEntry> = Vec::new();
let mut shown_queues: std::collections::HashSet<crate::task_registry::TaskQueueId> =
std::collections::HashSet::new();
for (queue_id, &task_id) in task_registry.running_tasks() {
if let Some(task) = task_registry.get(task_id) {
let raw = format!("{}: {}", queue_id.0, task.command);
let (indicator, style) = task_status_style(task.status.clone(), theme);
entries.push(RightEntry {
task_id,
sep: " ".into(),
ind: format!("{} ", indicator),
label: truncate(&raw, 25),
cancel: Some(" ⊘".into()),
style,
});
shown_queues.insert(queue_id.clone());
}
}
for task_id in task_registry.recently_finished_tasks() {
if let Some(task) = task_registry.get(task_id) {
if shown_queues.contains(&task.key.queue) {
continue;
}
let raw = format!("{}: {}", task.key.queue.0, task.command);
let (indicator, style) = task_status_style(task.status.clone(), theme);
entries.push(RightEntry {
task_id,
sep: " ".into(),
ind: format!("{} ", indicator),
label: truncate(&raw, 25),
cancel: None,
style,
});
shown_queues.insert(task.key.queue.clone());
}
}
let right_total_w: u16 = entries
.iter()
.map(|e| {
uw(e.sep.as_str())
+ uw(e.ind.as_str())
+ uw(e.label.as_str())
+ e.cancel.as_deref().map(uw).unwrap_or(0)
})
.sum::<usize>() as u16;
const MIN_LEFT_W: u16 = 4;
let right_w = if right_total_w > 0
&& right_total_w <= area.width.saturating_sub(MIN_LEFT_W)
{
right_total_w
} else {
0
};
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(right_w)])
.split(area);
let left_chunk = chunks[0];
let right_chunk = chunks[1];
let path_spans_avail: usize = {
let n = items.len() as u16;
let non_fp_w: u16 = items
.iter()
.map(|i| match i {
StatusItem::Label(t) => uw(&truncate(t, 30)) as u16,
StatusItem::Menu { label, .. } => {
uw(&format!("{} ▼", truncate(label, 28))) as u16
}
StatusItem::Action { label, .. } => uw(&truncate(label, 20)) as u16,
StatusItem::FilePath { suffix, .. } => uw(suffix.as_str()) as u16,
})
.sum();
let seps_w = 2u16 * n.saturating_sub(1);
(left_chunk.width as isize - 1 - non_fp_w as isize - seps_w as isize).max(0) as usize
};
let mut left_spans: Vec<Span> = Vec::new();
let mut x_cursor = left_chunk.x + 1;
for item in &items {
if !left_spans.is_empty() {
left_spans.push(Span::styled(" ", base_style));
x_cursor += 2;
}
match item {
StatusItem::Label(text) => {
let text = truncate(text, 30);
x_cursor += uw(text.as_str()) as u16;
left_spans.push(Span::styled(text, base_style));
}
StatusItem::Menu { label, command } => {
let text = truncate(label, 28);
let full = format!("{} ▼", text);
let w = uw(full.as_str()) as u16;
click_state.item_clicks.push((
Rect { x: x_cursor, y: area.y, width: w + 1, height: 1 },
command.clone(),
));
x_cursor += w + 1;
left_spans.push(Span::styled(
full,
base_style.add_modifier(Modifier::UNDERLINED),
));
}
StatusItem::Action { label, command } => {
let text = truncate(label, 20);
let w = uw(text.as_str()) as u16;
click_state.item_clicks.push((
Rect { x: x_cursor, y: area.y, width: w + 1, height: 1 },
command.clone(),
));
x_cursor += w + 1;
left_spans.push(Span::styled(
text,
base_style.add_modifier(Modifier::BOLD),
));
}
StatusItem::FilePath { path, suffix } => {
let path_cfg = crate::path_format::StyleConfig {
filename: base_style.add_modifier(Modifier::BOLD),
path: base_style,
dim: base_style.add_modifier(Modifier::DIM),
separator: base_style.add_modifier(Modifier::DIM),
};
let path_spans = crate::path_format::format_path_spans_with_min_tail(
path.as_path(),
path_spans_avail,
path_cfg,
1,
);
let path_w: u16 = path_spans
.iter()
.map(|s| uw(s.content.as_ref()) as u16)
.sum();
x_cursor += path_w;
left_spans.extend(path_spans);
if !suffix.is_empty() {
x_cursor += uw(suffix.as_str()) as u16;
left_spans.push(Span::styled(suffix.clone(), base_style));
}
}
}
}
let mut right_spans: Vec<Span> = Vec::new();
if right_chunk.width > 0 {
let mut rx = right_chunk.x;
for e in &entries {
right_spans.push(Span::styled(e.sep.clone(), base_style));
rx += uw(e.sep.as_str()) as u16;
right_spans.push(Span::styled(e.ind.clone(), e.style.bg(bg)));
rx += uw(e.ind.as_str()) as u16;
let label_w = uw(e.label.as_str()) as u16;
click_state.task_label_clicks.push((
Rect { x: rx, y: area.y, width: label_w, height: 1 },
e.task_id,
));
right_spans.push(Span::styled(
e.label.clone(),
e.style.bg(bg).add_modifier(Modifier::UNDERLINED),
));
rx += label_w;
if let Some(ref cancel_str) = e.cancel {
let cancel_w = uw(cancel_str.as_str()) as u16;
click_state.cancel_clicks.push((
Rect { x: rx, y: area.y, width: cancel_w, height: 1 },
e.task_id,
));
right_spans.push(Span::styled(cancel_str.clone(), e.style.bg(bg)));
rx += cancel_w;
}
}
}
frame.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
" ".repeat(area.width as usize),
base_style,
)])),
area,
);
if !left_spans.is_empty() {
let left_area = Rect {
x: left_chunk.x + 1,
width: left_chunk.width.saturating_sub(1),
..left_chunk
};
frame.render_widget(Paragraph::new(Line::from(left_spans)), left_area);
}
if !right_spans.is_empty() {
frame.render_widget(Paragraph::new(Line::from(right_spans)), right_chunk);
}
click_state
}
}
fn uw(s: &str) -> usize {
unicode_width::UnicodeWidthStr::width(s)
}
fn truncate(s: &str, max_chars: usize) -> String {
let chars: Vec<char> = s.chars().collect();
if chars.len() <= max_chars {
s.to_owned()
} else {
chars[..max_chars.saturating_sub(1)].iter().collect::<String>() + "…"
}
}
fn task_status_style(status: TaskStatus, theme: &crate::theme::Theme) -> (&'static str, Style) {
match status {
TaskStatus::Running => ("●", Style::default().fg(theme.fg())),
TaskStatus::Success => ("✓", Style::default().fg(theme.level_success())),
TaskStatus::Warning => ("⚠", Style::default().fg(theme.level_warn())),
TaskStatus::Error => ("✗", Style::default().fg(theme.level_error())),
TaskStatus::Cancelled => ("—", Style::default().fg(theme.fg_dim())),
TaskStatus::Pending => ("○", Style::default().fg(theme.fg_dim())),
}
}