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