Skip to main content

par_term/status_bar/
mod.rs

1//! Status bar system for displaying session and system information.
2//!
3//! The status bar is a configurable panel that can display widgets such as
4//! the current time, username, git branch, CPU/memory usage, and more.
5
6pub mod config;
7pub mod system_monitor;
8pub mod widgets;
9
10use parking_lot::Mutex;
11use std::process::Command;
12use std::sync::Arc;
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::time::{Duration, Instant};
15
16use crate::badge::SessionVariables;
17use crate::config::{Config, StatusBarPosition};
18use config::StatusBarSection;
19use system_monitor::SystemMonitor;
20use widgets::{WidgetContext, sorted_widgets_for_section, widget_text};
21
22/// Snapshot of git repository status.
23#[derive(Debug, Clone, Default)]
24pub struct GitStatus {
25    /// Current branch name.
26    pub branch: Option<String>,
27    /// Commits ahead of upstream.
28    pub ahead: u32,
29    /// Commits behind upstream.
30    pub behind: u32,
31    /// Whether the working tree has uncommitted changes.
32    pub dirty: bool,
33}
34
35/// Git branch poller that runs on a background thread.
36struct GitBranchPoller {
37    /// Shared git status (read from render thread, written by poll thread).
38    status: Arc<Mutex<GitStatus>>,
39    /// Current working directory to poll in.
40    cwd: Arc<Mutex<Option<String>>>,
41    /// Whether the poller is running.
42    running: Arc<AtomicBool>,
43    /// Handle to the polling thread.
44    thread: Mutex<Option<std::thread::JoinHandle<()>>>,
45}
46
47impl GitBranchPoller {
48    fn new() -> Self {
49        Self {
50            status: Arc::new(Mutex::new(GitStatus::default())),
51            cwd: Arc::new(Mutex::new(None)),
52            running: Arc::new(AtomicBool::new(false)),
53            thread: Mutex::new(None),
54        }
55    }
56
57    /// Start the background polling thread.
58    fn start(&self, poll_interval_secs: f32) {
59        if self
60            .running
61            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
62            .is_err()
63        {
64            return;
65        }
66
67        let status = Arc::clone(&self.status);
68        let cwd = Arc::clone(&self.cwd);
69        let running = Arc::clone(&self.running);
70        let interval = Duration::from_secs_f32(poll_interval_secs.max(1.0));
71
72        let handle = std::thread::Builder::new()
73            .name("status-bar-git".into())
74            .spawn(move || {
75                while running.load(Ordering::SeqCst) {
76                    let dir = cwd.lock().clone();
77                    let result = dir.map(|d| poll_git_status(&d)).unwrap_or_default();
78                    *status.lock() = result;
79                    // Sleep in short increments so stop() returns quickly
80                    let deadline = Instant::now() + interval;
81                    while Instant::now() < deadline && running.load(Ordering::Relaxed) {
82                        std::thread::sleep(Duration::from_millis(50));
83                    }
84                }
85            })
86            .expect("Failed to spawn git branch poller thread");
87
88        *self.thread.lock() = Some(handle);
89    }
90
91    /// Signal the background thread to stop without waiting for it to finish.
92    fn signal_stop(&self) {
93        self.running.store(false, Ordering::SeqCst);
94    }
95
96    /// Stop the background polling thread and wait for it to finish.
97    fn stop(&self) {
98        self.signal_stop();
99        if let Some(handle) = self.thread.lock().take() {
100            let _ = handle.join();
101        }
102    }
103
104    /// Update the working directory to poll in.
105    fn set_cwd(&self, new_cwd: Option<&str>) {
106        *self.cwd.lock() = new_cwd.map(String::from);
107    }
108
109    /// Get the current git status snapshot.
110    fn status(&self) -> GitStatus {
111        self.status.lock().clone()
112    }
113
114    fn is_running(&self) -> bool {
115        self.running.load(Ordering::SeqCst)
116    }
117}
118
119/// Poll git for branch name, ahead/behind counts, and dirty status.
120fn poll_git_status(dir: &str) -> GitStatus {
121    // Get branch name
122    let branch = Command::new("git")
123        .args(["rev-parse", "--abbrev-ref", "HEAD"])
124        .current_dir(dir)
125        .output()
126        .ok()
127        .and_then(|out| {
128            if out.status.success() {
129                let b = String::from_utf8_lossy(&out.stdout).trim().to_string();
130                if b.is_empty() { None } else { Some(b) }
131            } else {
132                None
133            }
134        });
135
136    if branch.is_none() {
137        return GitStatus::default();
138    }
139
140    // Get ahead/behind counts via rev-list
141    let (ahead, behind) = Command::new("git")
142        .args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
143        .current_dir(dir)
144        .output()
145        .ok()
146        .and_then(|out| {
147            if out.status.success() {
148                let text = String::from_utf8_lossy(&out.stdout);
149                let parts: Vec<&str> = text.trim().split('\t').collect();
150                if parts.len() == 2 {
151                    let a = parts[0].parse::<u32>().unwrap_or(0);
152                    let b = parts[1].parse::<u32>().unwrap_or(0);
153                    Some((a, b))
154                } else {
155                    None
156                }
157            } else {
158                // No upstream configured
159                None
160            }
161        })
162        .unwrap_or((0, 0));
163
164    // Check dirty status (fast: just check if there are any changes)
165    let dirty = Command::new("git")
166        .args(["status", "--porcelain", "-uno"])
167        .current_dir(dir)
168        .output()
169        .ok()
170        .is_some_and(|out| out.status.success() && !out.stdout.is_empty());
171
172    GitStatus {
173        branch,
174        ahead,
175        behind,
176        dirty,
177    }
178}
179
180impl Drop for GitBranchPoller {
181    fn drop(&mut self) {
182        self.stop();
183    }
184}
185
186/// Status bar UI state and renderer.
187pub struct StatusBarUI {
188    /// Background system resource monitor.
189    system_monitor: SystemMonitor,
190    /// Git branch poller.
191    git_poller: GitBranchPoller,
192    /// Timestamp of the last mouse activity (for auto-hide).
193    last_mouse_activity: Instant,
194    /// Whether the status bar is currently visible.
195    visible: bool,
196    /// Last valid time format string (for fallback when user is mid-edit).
197    last_valid_time_format: String,
198}
199
200impl StatusBarUI {
201    /// Create a new status bar UI.
202    pub fn new() -> Self {
203        Self {
204            system_monitor: SystemMonitor::new(),
205            git_poller: GitBranchPoller::new(),
206            last_mouse_activity: Instant::now(),
207            visible: true,
208            last_valid_time_format: "%H:%M:%S".to_string(),
209        }
210    }
211
212    /// Signal all background threads to stop without waiting.
213    /// Call early during shutdown so threads have time to notice before the Drop join.
214    pub fn signal_shutdown(&self) {
215        self.system_monitor.signal_stop();
216        self.git_poller.signal_stop();
217    }
218
219    /// Compute the effective height consumed by the status bar.
220    ///
221    /// Returns 0 if the status bar is hidden or disabled.
222    pub fn height(&self, config: &Config, is_fullscreen: bool) -> f32 {
223        if !config.status_bar_enabled || self.should_hide(config, is_fullscreen) {
224            0.0
225        } else {
226            config.status_bar_height
227        }
228    }
229
230    /// Determine whether the status bar should be hidden right now.
231    fn should_hide(&self, config: &Config, is_fullscreen: bool) -> bool {
232        if !config.status_bar_enabled {
233            return true;
234        }
235        if config.status_bar_auto_hide_fullscreen && is_fullscreen {
236            return true;
237        }
238        if config.status_bar_auto_hide_mouse_inactive {
239            let elapsed = self.last_mouse_activity.elapsed().as_secs_f32();
240            if elapsed > config.status_bar_mouse_inactive_timeout {
241                return true;
242            }
243        }
244        false
245    }
246
247    /// Record mouse activity (resets auto-hide timer).
248    pub fn on_mouse_activity(&mut self) {
249        self.last_mouse_activity = Instant::now();
250        self.visible = true;
251    }
252
253    /// Start or stop the system monitor and git poller based on enabled widgets.
254    pub fn sync_monitor_state(&self, config: &Config) {
255        if !config.status_bar_enabled {
256            if self.system_monitor.is_running() {
257                self.system_monitor.stop();
258            }
259            if self.git_poller.is_running() {
260                self.git_poller.stop();
261            }
262            return;
263        }
264
265        // System monitor
266        let needs_monitor = config
267            .status_bar_widgets
268            .iter()
269            .any(|w| w.enabled && w.id.needs_system_monitor());
270
271        if needs_monitor && !self.system_monitor.is_running() {
272            self.system_monitor
273                .start(config.status_bar_system_poll_interval);
274        } else if !needs_monitor && self.system_monitor.is_running() {
275            self.system_monitor.stop();
276        }
277
278        // Git branch poller
279        let needs_git = config
280            .status_bar_widgets
281            .iter()
282            .any(|w| w.enabled && w.id == config::WidgetId::GitBranch);
283
284        if needs_git && !self.git_poller.is_running() {
285            self.git_poller.start(config.status_bar_git_poll_interval);
286        } else if !needs_git && self.git_poller.is_running() {
287            self.git_poller.stop();
288        }
289    }
290
291    /// Render the status bar.
292    ///
293    /// Returns the height consumed by the status bar (0 if hidden).
294    pub fn render(
295        &mut self,
296        ctx: &egui::Context,
297        config: &Config,
298        session_vars: &SessionVariables,
299        is_fullscreen: bool,
300    ) -> f32 {
301        if !config.status_bar_enabled || self.should_hide(config, is_fullscreen) {
302            return 0.0;
303        }
304
305        // Update git poller cwd from active tab's path
306        let cwd = if session_vars.path.is_empty() {
307            None
308        } else {
309            Some(session_vars.path.as_str())
310        };
311        self.git_poller.set_cwd(cwd);
312
313        // Validate time format — update last-known-good on success, fall back on failure
314        {
315            use chrono::format::strftime::StrftimeItems;
316            let valid = !config.status_bar_time_format.is_empty()
317                && StrftimeItems::new(&config.status_bar_time_format)
318                    .all(|item| !matches!(item, chrono::format::Item::Error));
319            if valid {
320                self.last_valid_time_format = config.status_bar_time_format.clone();
321            }
322        }
323
324        // Build widget context
325        let git_status = self.git_poller.status();
326        let widget_ctx = WidgetContext {
327            session_vars: session_vars.clone(),
328            system_data: self.system_monitor.data(),
329            git_branch: git_status.branch,
330            git_ahead: git_status.ahead,
331            git_behind: git_status.behind,
332            git_dirty: git_status.dirty,
333            git_show_status: config.status_bar_git_show_status,
334            time_format: self.last_valid_time_format.clone(),
335        };
336
337        let bar_height = config.status_bar_height;
338        let [bg_r, bg_g, bg_b] = config.status_bar_bg_color;
339        let bg_alpha = (config.status_bar_bg_alpha * 255.0) as u8;
340        let bg_color = egui::Color32::from_rgba_unmultiplied(bg_r, bg_g, bg_b, bg_alpha);
341
342        let [fg_r, fg_g, fg_b] = config.status_bar_fg_color;
343        let fg_color = egui::Color32::from_rgb(fg_r, fg_g, fg_b);
344        let font_size = config.status_bar_font_size;
345        let separator = &config.status_bar_separator;
346        let sep_color = fg_color.linear_multiply(0.4);
347
348        // Use an egui::Area with a fixed size so the status bar stops before
349        // the scrollbar column.  TopBottomPanel always spans the full window
350        // width and ignores every attempt to narrow it.
351        let h_margin: f32 = 8.0; // left + right inner margin per side
352        let v_margin: f32 = 2.0; // top + bottom inner margin per side
353        let scrollbar_reserved = config.scrollbar_width + 2.0;
354        let viewport = ctx.input(|i| i.viewport_rect());
355        // Content width is the frame width minus both horizontal margins.
356        let content_width = (viewport.width() - scrollbar_reserved - h_margin * 2.0).max(0.0);
357        let content_height = (bar_height - v_margin * 2.0).max(0.0);
358
359        let bar_pos = match config.status_bar_position {
360            StatusBarPosition::Top => egui::pos2(0.0, 0.0),
361            StatusBarPosition::Bottom => egui::pos2(0.0, viewport.height() - bar_height),
362        };
363
364        let frame = egui::Frame::NONE
365            .fill(bg_color)
366            .inner_margin(egui::Margin::symmetric(h_margin as i8, v_margin as i8));
367
368        let make_rich_text = |text: &str| -> egui::RichText {
369            egui::RichText::new(text)
370                .color(fg_color)
371                .size(font_size)
372                .monospace()
373        };
374
375        let make_sep = |sep: &str| -> egui::RichText {
376            egui::RichText::new(sep)
377                .color(sep_color)
378                .size(font_size)
379                .monospace()
380        };
381
382        egui::Area::new(egui::Id::new("status_bar"))
383            .fixed_pos(bar_pos)
384            .order(egui::Order::Background)
385            .interactable(false)
386            .show(ctx, |ui| {
387                // Constrain the outer UI so the frame cannot grow beyond the
388                // intended total width (content + margins).
389                ui.set_max_width(content_width + h_margin * 2.0);
390                ui.set_max_height(bar_height);
391
392                frame.show(ui, |ui| {
393                    ui.set_min_size(egui::vec2(content_width, content_height));
394                    ui.set_max_size(egui::vec2(content_width, content_height));
395
396                    ui.horizontal_centered(|ui| {
397                        // Clip widgets to the available content width so
398                        // right-to-left layouts cannot expand past the bar edge.
399                        ui.set_clip_rect(ui.max_rect());
400
401                        // === Left section ===
402                        let left_widgets = sorted_widgets_for_section(
403                            &config.status_bar_widgets,
404                            StatusBarSection::Left,
405                        );
406                        let mut first = true;
407                        for w in &left_widgets {
408                            let text = widget_text(&w.id, &widget_ctx, w.format.as_deref());
409                            if text.is_empty() {
410                                continue;
411                            }
412                            if !first {
413                                ui.label(make_sep(separator));
414                            }
415                            first = false;
416                            ui.label(make_rich_text(&text));
417                        }
418
419                        // === Center section ===
420                        let center_widgets = sorted_widgets_for_section(
421                            &config.status_bar_widgets,
422                            StatusBarSection::Center,
423                        );
424                        if !center_widgets.is_empty() {
425                            ui.with_layout(
426                                egui::Layout::centered_and_justified(egui::Direction::LeftToRight),
427                                |ui| {
428                                    let mut first = true;
429                                    for w in &center_widgets {
430                                        let text =
431                                            widget_text(&w.id, &widget_ctx, w.format.as_deref());
432                                        if text.is_empty() {
433                                            continue;
434                                        }
435                                        if !first {
436                                            ui.label(make_sep(separator));
437                                        }
438                                        first = false;
439                                        ui.label(make_rich_text(&text));
440                                    }
441                                },
442                            );
443                        }
444
445                        // === Right section ===
446                        let right_widgets = sorted_widgets_for_section(
447                            &config.status_bar_widgets,
448                            StatusBarSection::Right,
449                        );
450                        if !right_widgets.is_empty() {
451                            ui.with_layout(
452                                egui::Layout::right_to_left(egui::Align::Center),
453                                |ui| {
454                                    let mut first = true;
455                                    for w in right_widgets.iter().rev() {
456                                        let text =
457                                            widget_text(&w.id, &widget_ctx, w.format.as_deref());
458                                        if text.is_empty() {
459                                            continue;
460                                        }
461                                        if !first {
462                                            ui.label(make_sep(separator));
463                                        }
464                                        first = false;
465                                        ui.label(make_rich_text(&text));
466                                    }
467                                },
468                            );
469                        }
470                    });
471                });
472            });
473
474        bar_height
475    }
476}
477
478impl Default for StatusBarUI {
479    fn default() -> Self {
480        Self::new()
481    }
482}
483
484impl Drop for StatusBarUI {
485    fn drop(&mut self) {
486        self.system_monitor.stop();
487    }
488}