Skip to main content

oo_ide/widgets/
status_bar.rs

1//! Global status bar widget.
2//!
3//! Views populate a [`StatusBarBuilder`] with left-side items; the
4//! [`StatusBarRenderer`] then draws the full status bar — left items from the
5//! view on the left, task-status on the right — into a one-row [`Rect`].
6
7use 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// ---------------------------------------------------------------------------
19// Public types
20// ---------------------------------------------------------------------------
21
22/// A single item on the left side of the status bar.
23#[derive(Debug, Clone)]
24pub enum StatusItem {
25    /// Non-interactive text label.
26    Label(String),
27    /// Clickable item that dispatches a command when selected.
28    Menu { label: String, command: CommandId },
29    /// Clickable action button.
30    Action { label: String, command: CommandId },
31}
32
33/// Collects left-side status bar items from a view.
34#[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    /// Append a non-interactive text label.
45    pub fn label(&mut self, text: impl Into<String>) -> &mut Self {
46        self.items.push(StatusItem::Label(text.into()));
47        self
48    }
49
50    /// Append a clickable menu item (shows a dropdown indicator `▼`).
51    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    /// Append a clickable action button.
57    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    /// Consume the builder and return the collected items.
63    pub fn build(self) -> Vec<StatusItem> {
64        self.items
65    }
66}
67
68// ---------------------------------------------------------------------------
69// Click state
70// ---------------------------------------------------------------------------
71
72/// Records the clickable regions produced by the last render call.
73///
74/// Stored on `AppState` so that `handle_mouse` can dispatch the right command
75/// or cancel-task operation without re-running layout logic.
76#[derive(Debug, Default, Clone)]
77pub struct StatusBarClickState {
78    /// `(rect, command_id)` for each interactive status item.
79    pub item_clicks: Vec<(Rect, CommandId)>,
80    /// `(rect, task_id)` for each rendered cancel button.
81    pub cancel_clicks: Vec<(Rect, crate::task_registry::TaskId)>,
82    /// `(rect, task_id)` for each rendered task label (click → open log view).
83    pub task_label_clicks: Vec<(Rect, crate::task_registry::TaskId)>,
84}
85
86impl StatusBarClickState {
87    /// Returns the `CommandId` to dispatch if `(col, row)` hit a menu/action item.
88    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    /// Returns the `TaskId` to cancel if `(col, row)` hit a cancel button.
98    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    /// Returns the `TaskId` whose log view to open if `(col, row)` hit a task label.
108    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
118// ---------------------------------------------------------------------------
119// Renderer
120// ---------------------------------------------------------------------------
121
122/// Renders the global status bar.
123pub struct StatusBarRenderer;
124
125impl StatusBarRenderer {
126    /// Draw the status bar into `area` (expected to be 1 row tall).
127    ///
128    /// Returns a [`StatusBarClickState`] recording the clickable regions so
129    /// the caller can wire up mouse dispatch.
130    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        // --- Build left spans ---
146        let mut left_spans: Vec<Span> = Vec::new();
147        let mut click_state = StatusBarClickState::default();
148        // Left content is rendered starting at area.x + 1 (one char padding).
149        let mut x_cursor = area.x + 1;
150
151        for item in &items {
152            // Double-space separator between items (skip before first).
153            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                    // Add a small padding to the clickable region so the arrow glyph
169                    // and adjacent spacing are reliably clickable across terminals.
170                    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        // --- Build right side: task status ---
203        // Strategy: pre-build all strings first, measure actual char widths,
204        // then compute positions in a single pass — no manual width accounting.
205        struct RightEntry {
206            task_id: crate::task_registry::TaskId,
207            sep:     String, // "  "
208            ind:     String, // "● " / "✓ " etc.
209            label:   String,
210            cancel:  Option<String>, // " ⊘" for running tasks only
211            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        // 1. Currently running tasks: prefer these and mark their queues as shown.
218        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        // 2. Recently finished tasks: show at most one per queue, skipping queues
235        // already represented by a running task.
236        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        // Total rendered width (columns).
256        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        // Left side occupies up to x_cursor; leave a minimum 2-col gap.
264        // Skip right content entirely if there isn't enough room.
265        let min_right_x = x_cursor + 2;
266        // Use area.x + area.width - right_total_w to compute the left edge.
267        // area.right() is x + width - 1 which causes an off-by-one when
268        // subtracting the total width.  Use saturating_add/saturating_sub for safety.
269        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                // Separator.
279                right_spans.push(Span::styled(e.sep.clone(), base_style));
280                rx += unicode_width::UnicodeWidthStr::width(e.sep.as_str()) as u16;
281
282                // Indicator.
283                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                // Label — clickable.
287                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                // Cancel button (running only).
299                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        // --- Render ---
312        // Fill the whole row with the background first.
313        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        // Left side.
322        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        // Right side (task status).
329        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
343// ---------------------------------------------------------------------------
344// Helpers
345// ---------------------------------------------------------------------------
346
347fn 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}