1use ratatui::{
8 Frame,
9 layout::{Constraint, Direction, 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 FilePath { path: std::path::PathBuf, suffix: String },
36}
37
38#[derive(Debug, Default)]
40pub struct StatusBarBuilder {
41 items: Vec<StatusItem>,
42}
43
44impl StatusBarBuilder {
45 pub fn new() -> Self {
46 Self::default()
47 }
48
49 pub fn label(&mut self, text: impl Into<String>) -> &mut Self {
51 self.items.push(StatusItem::Label(text.into()));
52 self
53 }
54
55 pub fn menu(&mut self, label: impl Into<String>, command: CommandId) -> &mut Self {
57 self.items.push(StatusItem::Menu { label: label.into(), command });
58 self
59 }
60
61 pub fn action(&mut self, label: impl Into<String>, command: CommandId) -> &mut Self {
63 self.items.push(StatusItem::Action { label: label.into(), command });
64 self
65 }
66
67 pub fn file_path(&mut self, path: std::path::PathBuf, dirty: bool) -> &mut Self {
72 let suffix = if dirty { " [+]".to_owned() } else { String::new() };
73 self.items.push(StatusItem::FilePath { path, suffix });
74 self
75 }
76
77 pub fn build(self) -> Vec<StatusItem> {
79 self.items
80 }
81}
82
83#[derive(Debug, Default, Clone)]
92pub struct StatusBarClickState {
93 pub item_clicks: Vec<(Rect, CommandId)>,
95 pub cancel_clicks: Vec<(Rect, crate::task_registry::TaskId)>,
97 pub task_label_clicks: Vec<(Rect, crate::task_registry::TaskId)>,
99}
100
101impl StatusBarClickState {
102 pub fn hit_command(&self, col: u16, row: u16) -> Option<&CommandId> {
104 for (rect, cmd) in &self.item_clicks {
105 if col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height {
106 return Some(cmd);
107 }
108 }
109 None
110 }
111
112 pub fn hit_cancel(&self, col: u16, row: u16) -> Option<crate::task_registry::TaskId> {
114 for (rect, id) in &self.cancel_clicks {
115 if col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height {
116 return Some(*id);
117 }
118 }
119 None
120 }
121
122 pub fn hit_task_label(&self, col: u16, row: u16) -> Option<crate::task_registry::TaskId> {
124 for (rect, id) in &self.task_label_clicks {
125 if col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height {
126 return Some(*id);
127 }
128 }
129 None
130 }
131}
132
133pub struct StatusBarRenderer;
139
140impl StatusBarRenderer {
141 pub fn render(
146 frame: &mut Frame,
147 area: Rect,
148 items: Vec<StatusItem>,
149 task_registry: &TaskRegistry,
150 theme: &crate::theme::Theme,
151 ) -> StatusBarClickState {
152 if area.height == 0 {
153 return StatusBarClickState::default();
154 }
155
156 let bg = theme.status_bar_bg();
157 let fg = theme.status_bar_fg();
158 let base_style = Style::default().fg(fg).bg(bg);
159 let mut click_state = StatusBarClickState::default();
160
161 struct RightEntry {
164 task_id: crate::task_registry::TaskId,
165 sep: String, ind: String, label: String,
168 cancel: Option<String>, style: Style,
170 }
171
172 let mut entries: Vec<RightEntry> = Vec::new();
173 let mut shown_queues: std::collections::HashSet<crate::task_registry::TaskQueueId> =
174 std::collections::HashSet::new();
175
176 for (queue_id, &task_id) in task_registry.running_tasks() {
178 if let Some(task) = task_registry.get(task_id) {
179 let raw = format!("{}: {}", queue_id.0, task.command);
180 let (indicator, style) = task_status_style(task.status.clone(), theme);
181 entries.push(RightEntry {
182 task_id,
183 sep: " ".into(),
184 ind: format!("{} ", indicator),
185 label: truncate(&raw, 25),
186 cancel: Some(" ⊘".into()),
187 style,
188 });
189 shown_queues.insert(queue_id.clone());
190 }
191 }
192
193 for task_id in task_registry.recently_finished_tasks() {
195 if let Some(task) = task_registry.get(task_id) {
196 if shown_queues.contains(&task.key.queue) {
197 continue;
198 }
199 let raw = format!("{}: {}", task.key.queue.0, task.command);
200 let (indicator, style) = task_status_style(task.status.clone(), theme);
201 entries.push(RightEntry {
202 task_id,
203 sep: " ".into(),
204 ind: format!("{} ", indicator),
205 label: truncate(&raw, 25),
206 cancel: None,
207 style,
208 });
209 shown_queues.insert(task.key.queue.clone());
210 }
211 }
212
213 let right_total_w: u16 = entries
215 .iter()
216 .map(|e| {
217 uw(e.sep.as_str())
218 + uw(e.ind.as_str())
219 + uw(e.label.as_str())
220 + e.cancel.as_deref().map(uw).unwrap_or(0)
221 })
222 .sum::<usize>() as u16;
223
224 const MIN_LEFT_W: u16 = 4;
227 let right_w = if right_total_w > 0
228 && right_total_w <= area.width.saturating_sub(MIN_LEFT_W)
229 {
230 right_total_w
231 } else {
232 0
233 };
234
235 let chunks = Layout::default()
239 .direction(Direction::Horizontal)
240 .constraints([Constraint::Min(0), Constraint::Length(right_w)])
241 .split(area);
242 let left_chunk = chunks[0];
243 let right_chunk = chunks[1];
244
245 let path_spans_avail: usize = {
253 let n = items.len() as u16;
254 let non_fp_w: u16 = items
255 .iter()
256 .map(|i| match i {
257 StatusItem::Label(t) => uw(&truncate(t, 30)) as u16,
258 StatusItem::Menu { label, .. } => {
259 uw(&format!("{} ▼", truncate(label, 28))) as u16
260 }
261 StatusItem::Action { label, .. } => uw(&truncate(label, 20)) as u16,
262 StatusItem::FilePath { suffix, .. } => uw(suffix.as_str()) as u16,
264 })
265 .sum();
266 let seps_w = 2u16 * n.saturating_sub(1);
267 (left_chunk.width as isize - 1 - non_fp_w as isize - seps_w as isize).max(0) as usize
268 };
269
270 let mut left_spans: Vec<Span> = Vec::new();
272 let mut x_cursor = left_chunk.x + 1;
274
275 for item in &items {
276 if !left_spans.is_empty() {
277 left_spans.push(Span::styled(" ", base_style));
278 x_cursor += 2;
279 }
280
281 match item {
282 StatusItem::Label(text) => {
283 let text = truncate(text, 30);
284 x_cursor += uw(text.as_str()) as u16;
285 left_spans.push(Span::styled(text, base_style));
286 }
287 StatusItem::Menu { label, command } => {
288 let text = truncate(label, 28);
289 let full = format!("{} ▼", text);
290 let w = uw(full.as_str()) as u16;
291 click_state.item_clicks.push((
294 Rect { x: x_cursor, y: area.y, width: w + 1, height: 1 },
295 command.clone(),
296 ));
297 x_cursor += w + 1;
298 left_spans.push(Span::styled(
299 full,
300 base_style.add_modifier(Modifier::UNDERLINED),
301 ));
302 }
303 StatusItem::Action { label, command } => {
304 let text = truncate(label, 20);
305 let w = uw(text.as_str()) as u16;
306 click_state.item_clicks.push((
307 Rect { x: x_cursor, y: area.y, width: w + 1, height: 1 },
308 command.clone(),
309 ));
310 x_cursor += w + 1;
311 left_spans.push(Span::styled(
312 text,
313 base_style.add_modifier(Modifier::BOLD),
314 ));
315 }
316 StatusItem::FilePath { path, suffix } => {
317 let path_cfg = crate::path_format::StyleConfig {
319 filename: base_style.add_modifier(Modifier::BOLD),
320 path: base_style,
321 dim: base_style.add_modifier(Modifier::DIM),
322 separator: base_style.add_modifier(Modifier::DIM),
323 };
324 let path_spans = crate::path_format::format_path_spans_with_min_tail(
325 path.as_path(),
326 path_spans_avail,
327 path_cfg,
328 1,
329 );
330 let path_w: u16 = path_spans
332 .iter()
333 .map(|s| uw(s.content.as_ref()) as u16)
334 .sum();
335 x_cursor += path_w;
336 left_spans.extend(path_spans);
337 if !suffix.is_empty() {
338 x_cursor += uw(suffix.as_str()) as u16;
339 left_spans.push(Span::styled(suffix.clone(), base_style));
340 }
341 }
342 }
343 }
344
345 let mut right_spans: Vec<Span> = Vec::new();
349 if right_chunk.width > 0 {
350 let mut rx = right_chunk.x;
351 for e in &entries {
352 right_spans.push(Span::styled(e.sep.clone(), base_style));
353 rx += uw(e.sep.as_str()) as u16;
354
355 right_spans.push(Span::styled(e.ind.clone(), e.style.bg(bg)));
356 rx += uw(e.ind.as_str()) as u16;
357
358 let label_w = uw(e.label.as_str()) as u16;
359 click_state.task_label_clicks.push((
360 Rect { x: rx, y: area.y, width: label_w, height: 1 },
361 e.task_id,
362 ));
363 right_spans.push(Span::styled(
364 e.label.clone(),
365 e.style.bg(bg).add_modifier(Modifier::UNDERLINED),
366 ));
367 rx += label_w;
368
369 if let Some(ref cancel_str) = e.cancel {
370 let cancel_w = uw(cancel_str.as_str()) as u16;
371 click_state.cancel_clicks.push((
372 Rect { x: rx, y: area.y, width: cancel_w, height: 1 },
373 e.task_id,
374 ));
375 right_spans.push(Span::styled(cancel_str.clone(), e.style.bg(bg)));
376 rx += cancel_w;
377 }
378 }
379 }
380
381 frame.render_widget(
384 Paragraph::new(Line::from(vec![Span::styled(
385 " ".repeat(area.width as usize),
386 base_style,
387 )])),
388 area,
389 );
390
391 if !left_spans.is_empty() {
393 let left_area = Rect {
394 x: left_chunk.x + 1,
395 width: left_chunk.width.saturating_sub(1),
396 ..left_chunk
397 };
398 frame.render_widget(Paragraph::new(Line::from(left_spans)), left_area);
399 }
400
401 if !right_spans.is_empty() {
403 frame.render_widget(Paragraph::new(Line::from(right_spans)), right_chunk);
404 }
405
406 click_state
407 }
408}
409
410fn uw(s: &str) -> usize {
416 unicode_width::UnicodeWidthStr::width(s)
417}
418
419fn truncate(s: &str, max_chars: usize) -> String {
420 let chars: Vec<char> = s.chars().collect();
421 if chars.len() <= max_chars {
422 s.to_owned()
423 } else {
424 chars[..max_chars.saturating_sub(1)].iter().collect::<String>() + "…"
425 }
426}
427
428fn task_status_style(status: TaskStatus, theme: &crate::theme::Theme) -> (&'static str, Style) {
429 match status {
430 TaskStatus::Running => ("●", Style::default().fg(theme.fg())),
431 TaskStatus::Success => ("✓", Style::default().fg(theme.level_success())),
432 TaskStatus::Warning => ("⚠", Style::default().fg(theme.level_warn())),
433 TaskStatus::Error => ("✗", Style::default().fg(theme.level_error())),
434 TaskStatus::Cancelled => ("—", Style::default().fg(theme.fg_dim())),
435 TaskStatus::Pending => ("○", Style::default().fg(theme.fg_dim())),
436 }
437}