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::{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// ---------------------------------------------------------------------------
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    /// File path rendered with `format_path_spans_with_min_tail`: filename
32    /// first, then elided directory context.  The `suffix` (e.g. `" [+]"`) is
33    /// appended after the path spans.  The renderer allocates all remaining
34    /// left-chunk space to this item so the path grows with the terminal width.
35    FilePath { path: std::path::PathBuf, suffix: String },
36}
37
38/// Collects left-side status bar items from a view.
39#[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    /// Append a non-interactive text label.
50    pub fn label(&mut self, text: impl Into<String>) -> &mut Self {
51        self.items.push(StatusItem::Label(text.into()));
52        self
53    }
54
55    /// Append a clickable menu item (shows a dropdown indicator `▼`).
56    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    /// Append a clickable action button.
62    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    /// Append a file-path item rendered with filename-first elision.
68    ///
69    /// The renderer gives this item all remaining left-side space, so the
70    /// displayed path grows as the terminal widens.  `dirty` appends `" [+]"`.
71    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    /// Consume the builder and return the collected items.
78    pub fn build(self) -> Vec<StatusItem> {
79        self.items
80    }
81}
82
83// ---------------------------------------------------------------------------
84// Click state
85// ---------------------------------------------------------------------------
86
87/// Records the clickable regions produced by the last render call.
88///
89/// Stored on `AppState` so that `handle_mouse` can dispatch the right command
90/// or cancel-task operation without re-running layout logic.
91#[derive(Debug, Default, Clone)]
92pub struct StatusBarClickState {
93    /// `(rect, command_id)` for each interactive status item.
94    pub item_clicks: Vec<(Rect, CommandId)>,
95    /// `(rect, task_id)` for each rendered cancel button.
96    pub cancel_clicks: Vec<(Rect, crate::task_registry::TaskId)>,
97    /// `(rect, task_id)` for each rendered task label (click → open log view).
98    pub task_label_clicks: Vec<(Rect, crate::task_registry::TaskId)>,
99}
100
101impl StatusBarClickState {
102    /// Returns the `CommandId` to dispatch if `(col, row)` hit a menu/action item.
103    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    /// Returns the `TaskId` to cancel if `(col, row)` hit a cancel button.
113    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    /// Returns the `TaskId` whose log view to open if `(col, row)` hit a task label.
123    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
133// ---------------------------------------------------------------------------
134// Renderer
135// ---------------------------------------------------------------------------
136
137/// Renders the global status bar.
138pub struct StatusBarRenderer;
139
140impl StatusBarRenderer {
141    /// Draw the status bar into `area` (expected to be 1 row tall).
142    ///
143    /// Returns a [`StatusBarClickState`] recording the clickable regions so
144    /// the caller can wire up mouse dispatch.
145    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        // --- Build right side first (needed to know the width for layout) ---
162
163        struct RightEntry {
164            task_id: crate::task_registry::TaskId,
165            sep:     String, // "  "
166            ind:     String, // "● " / "✓ " etc.
167            label:   String,
168            cancel:  Option<String>, // " ⊘" for running tasks only
169            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        // Running tasks first; mark their queues as shown.
177        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        // Recently finished tasks: at most one per queue, skipping running queues.
194        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        // Exact column width of all right-side content.
214        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        // Only allocate right space when content exists and leaves room for the
225        // left side (minimum 4 columns).
226        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        // Split the row with Layout: left takes all remaining space, right gets
236        // exactly the measured content width (or 0).  The layout system computes
237        // the right chunk's x position — no manual formula needed.
238        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        // Pre-measure: compute the column budget available for a FilePath item.
246        //
247        // The left chunk holds:
248        //   1 col padding + all item widths + 2-col separators between items
249        //
250        // FilePath items contribute only their suffix to `non_fp_w`; the path
251        // spans themselves take whatever is left over.
252        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                    // Only the suffix consumes fixed width; path spans are flexible.
263                    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        // --- Build left spans ---
271        let mut left_spans: Vec<Span> = Vec::new();
272        // One column of left padding; x_cursor tracks click-region positions.
273        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                    // Extend the hit region by one cell so the arrow and its
292                    // surrounding space are reliably clickable.
293                    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                    // Build a StyleConfig that inherits the status bar background.
318                    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                    // Advance cursor by the actual rendered width of the path spans.
331                    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        // --- Build right spans ---
346        // `rx` is seeded from the layout-computed chunk origin rather than a
347        // manual right_x_start formula, so click regions always match rendering.
348        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        // --- Render ---
382        // Fill the entire row with the background colour first.
383        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        // Left side (1-col inset padding).
392        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        // Right side — ratatui clips content to right_chunk automatically.
402        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
410// ---------------------------------------------------------------------------
411// Helpers
412// ---------------------------------------------------------------------------
413
414/// Shorthand for `unicode_width::UnicodeWidthStr::width`.
415fn 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}