1use ratatui::{
8 Frame,
9 layout::Rect,
10 style::{Modifier, Style},
11 text::{Line, Span},
12 widgets::Paragraph,
13};
14
15use crate::commands::CommandId;
16use crate::task_registry::{TaskRegistry, TaskStatus};
17
18#[derive(Debug, Clone)]
24pub enum StatusItem {
25 Label(String),
27 Menu { label: String, command: CommandId },
29 Action { label: String, command: CommandId },
31}
32
33#[derive(Debug, Default)]
35pub struct StatusBarBuilder {
36 items: Vec<StatusItem>,
37}
38
39impl StatusBarBuilder {
40 pub fn new() -> Self {
41 Self::default()
42 }
43
44 pub fn label(&mut self, text: impl Into<String>) -> &mut Self {
46 self.items.push(StatusItem::Label(text.into()));
47 self
48 }
49
50 pub fn menu(&mut self, label: impl Into<String>, command: CommandId) -> &mut Self {
52 self.items.push(StatusItem::Menu { label: label.into(), command });
53 self
54 }
55
56 pub fn action(&mut self, label: impl Into<String>, command: CommandId) -> &mut Self {
58 self.items.push(StatusItem::Action { label: label.into(), command });
59 self
60 }
61
62 pub fn build(self) -> Vec<StatusItem> {
64 self.items
65 }
66}
67
68#[derive(Debug, Default, Clone)]
77pub struct StatusBarClickState {
78 pub item_clicks: Vec<(Rect, CommandId)>,
80 pub cancel_clicks: Vec<(Rect, crate::task_registry::TaskId)>,
82 pub task_label_clicks: Vec<(Rect, crate::task_registry::TaskId)>,
84}
85
86impl StatusBarClickState {
87 pub fn hit_command(&self, col: u16, row: u16) -> Option<&CommandId> {
89 for (rect, cmd) in &self.item_clicks {
90 if col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height {
91 return Some(cmd);
92 }
93 }
94 None
95 }
96
97 pub fn hit_cancel(&self, col: u16, row: u16) -> Option<crate::task_registry::TaskId> {
99 for (rect, id) in &self.cancel_clicks {
100 if col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height {
101 return Some(*id);
102 }
103 }
104 None
105 }
106
107 pub fn hit_task_label(&self, col: u16, row: u16) -> Option<crate::task_registry::TaskId> {
109 for (rect, id) in &self.task_label_clicks {
110 if col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height {
111 return Some(*id);
112 }
113 }
114 None
115 }
116}
117
118pub struct StatusBarRenderer;
124
125impl StatusBarRenderer {
126 pub fn render(
131 frame: &mut Frame,
132 area: Rect,
133 items: Vec<StatusItem>,
134 task_registry: &TaskRegistry,
135 theme: &crate::theme::Theme,
136 ) -> StatusBarClickState {
137 if area.height == 0 {
138 return StatusBarClickState::default();
139 }
140
141 let bg = theme.status_bar_bg();
142 let fg = theme.status_bar_fg();
143 let base_style = Style::default().fg(fg).bg(bg);
144
145 let mut left_spans: Vec<Span> = Vec::new();
147 let mut click_state = StatusBarClickState::default();
148 let mut x_cursor = area.x + 1;
150
151 for item in &items {
152 if !left_spans.is_empty() {
154 left_spans.push(Span::styled(" ", base_style));
155 x_cursor += 2;
156 }
157
158 match item {
159 StatusItem::Label(text) => {
160 let text = truncate(text, 30);
161 x_cursor += unicode_width::UnicodeWidthStr::width(text.as_str()) as u16;
162 left_spans.push(Span::styled(text, base_style));
163 }
164 StatusItem::Menu { label, command } => {
165 let text = truncate(label, 28);
166 let full = format!("{} ▼", text);
167 let w = unicode_width::UnicodeWidthStr::width(full.as_str()) as u16;
168 let item_rect = Rect {
171 x: x_cursor,
172 y: area.y,
173 width: w + 1,
174 height: 1,
175 };
176 click_state.item_clicks.push((item_rect, command.clone()));
177 x_cursor += w + 1;
178 left_spans.push(Span::styled(
179 full,
180 base_style.add_modifier(Modifier::UNDERLINED),
181 ));
182 }
183 StatusItem::Action { label, command } => {
184 let text = truncate(label, 20);
185 let w = unicode_width::UnicodeWidthStr::width(text.as_str()) as u16;
186 let item_rect = Rect {
187 x: x_cursor,
188 y: area.y,
189 width: w + 1,
190 height: 1,
191 };
192 click_state.item_clicks.push((item_rect, command.clone()));
193 x_cursor += w + 1;
194 left_spans.push(Span::styled(
195 text,
196 base_style.add_modifier(Modifier::BOLD),
197 ));
198 }
199 }
200 }
201
202 struct RightEntry {
206 task_id: crate::task_registry::TaskId,
207 sep: String, ind: String, label: String,
210 cancel: Option<String>, style: Style,
212 }
213
214 let mut entries: Vec<RightEntry> = Vec::new();
215 let mut shown_queues: std::collections::HashSet<crate::task_registry::TaskQueueId> = std::collections::HashSet::new();
216
217 for (queue_id, &task_id) in task_registry.running_tasks() {
219 if let Some(task) = task_registry.get(task_id) {
220 let raw = format!("{}: {}", queue_id.0, task.command);
221 let (indicator, style) = task_status_style(task.status.clone(), theme);
222 entries.push(RightEntry {
223 task_id,
224 sep: " ".into(),
225 ind: format!("{} ", indicator),
226 label: truncate(&raw, 25),
227 cancel: Some(" ⊘".into()),
228 style,
229 });
230 shown_queues.insert(queue_id.clone());
231 }
232 }
233
234 for task_id in task_registry.recently_finished_tasks() {
237 if let Some(task) = task_registry.get(task_id) {
238 if shown_queues.contains(&task.key.queue) {
239 continue;
240 }
241 let raw = format!("{}: {}", task.key.queue.0, task.command);
242 let (indicator, style) = task_status_style(task.status.clone(), theme);
243 entries.push(RightEntry {
244 task_id,
245 sep: " ".into(),
246 ind: format!("{} ", indicator),
247 label: truncate(&raw, 25),
248 cancel: None,
249 style,
250 });
251 shown_queues.insert(task.key.queue.clone());
252 }
253 }
254
255 let right_total_w: u16 = entries.iter().map(|e| {
257 unicode_width::UnicodeWidthStr::width(e.sep.as_str())
258 + unicode_width::UnicodeWidthStr::width(e.ind.as_str())
259 + unicode_width::UnicodeWidthStr::width(e.label.as_str())
260 + e.cancel.as_deref().map(unicode_width::UnicodeWidthStr::width).unwrap_or(0)
261 }).sum::<usize>() as u16;
262
263 let min_right_x = x_cursor + 2;
266 let right_x_start = area.x.saturating_add(area.width.saturating_sub(right_total_w));
270 let right_fits = right_total_w > 0
271 && right_x_start >= min_right_x
272 && right_total_w <= area.width;
273
274 let mut right_spans: Vec<Span> = Vec::new();
275 if right_fits {
276 let mut rx = right_x_start;
277 for e in &entries {
278 right_spans.push(Span::styled(e.sep.clone(), base_style));
280 rx += unicode_width::UnicodeWidthStr::width(e.sep.as_str()) as u16;
281
282 right_spans.push(Span::styled(e.ind.clone(), e.style.bg(bg)));
284 rx += unicode_width::UnicodeWidthStr::width(e.ind.as_str()) as u16;
285
286 let label_w = unicode_width::UnicodeWidthStr::width(e.label.as_str()) as u16;
288 click_state.task_label_clicks.push((
289 Rect { x: rx, y: area.y, width: label_w, height: 1 },
290 e.task_id,
291 ));
292 right_spans.push(Span::styled(
293 e.label.clone(),
294 e.style.bg(bg).add_modifier(Modifier::UNDERLINED),
295 ));
296 rx += label_w;
297
298 if let Some(ref cancel_str) = e.cancel {
300 let cancel_w = unicode_width::UnicodeWidthStr::width(cancel_str.as_str()) as u16;
301 click_state.cancel_clicks.push((
302 Rect { x: rx, y: area.y, width: cancel_w, height: 1 },
303 e.task_id,
304 ));
305 right_spans.push(Span::styled(cancel_str.clone(), e.style.bg(bg)));
306 rx += cancel_w;
307 }
308 }
309 }
310
311 frame.render_widget(
314 Paragraph::new(Line::from(vec![Span::styled(
315 " ".repeat(area.width as usize),
316 base_style,
317 )])),
318 area,
319 );
320
321 if !left_spans.is_empty() {
323 let left_line = Line::from(left_spans);
324 let left_area = Rect { x: area.x + 1, width: area.width.saturating_sub(2), ..area };
325 frame.render_widget(Paragraph::new(left_line), left_area);
326 }
327
328 if !right_spans.is_empty() {
330 let right_area = Rect {
331 x: right_x_start,
332 y: area.y,
333 width: right_total_w,
334 height: 1,
335 };
336 frame.render_widget(Paragraph::new(Line::from(right_spans)), right_area);
337 }
338
339 click_state
340 }
341}
342
343fn truncate(s: &str, max_chars: usize) -> String {
348 let chars: Vec<char> = s.chars().collect();
349 if chars.len() <= max_chars {
350 s.to_owned()
351 } else {
352 chars[..max_chars.saturating_sub(1)].iter().collect::<String>() + "…"
353 }
354}
355
356fn task_status_style(status: TaskStatus, theme: &crate::theme::Theme) -> (&'static str, Style) {
357 match status {
358 TaskStatus::Running => ("●", Style::default().fg(theme.fg())),
359 TaskStatus::Success => ("✓", Style::default().fg(theme.level_success())),
360 TaskStatus::Warning => ("⚠", Style::default().fg(theme.level_warn())),
361 TaskStatus::Error => ("✗", Style::default().fg(theme.level_error())),
362 TaskStatus::Cancelled => ("—", Style::default().fg(theme.fg_dim())),
363 TaskStatus::Pending => ("○", Style::default().fg(theme.fg_dim())),
364 }
365}