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//!
6//! # Widget Architecture
7//!
8//! The current architecture uses a **data-driven, direct-call approach** rather
9//! than a formal registration mechanism:
10//!
11//! - Widget identifiers (`WidgetId`) and their configuration are defined in
12//!   `par-term-config/src/status_bar.rs`.
13//! - The `widgets` submodule (`src/status_bar/widgets/`) contains the rendering
14//!   logic: `widget_text()` dispatches on `WidgetId` to produce the display string,
15//!   and `sorted_widgets_for_section()` filters and orders widgets for each bar section.
16//! - `StatusBarUI::render()` in this file drives the egui layout for left/center/right
17//!   sections and calls into the widgets module.
18//! - Background data (system metrics, git status) is polled on dedicated threads
19//!   (`SystemMonitor`, `GitBranchPoller`) and surfaced via `WidgetContext`.
20//!
21//! # Adding a New Widget
22//!
23//! 1. Add a variant to `WidgetId` in `par-term-config/src/status_bar.rs`.
24//! 2. Add a `widget_text()` arm in `src/status_bar/widgets/`.
25//! 3. If the widget needs background data, extend `WidgetContext` and add
26//!    a poller in this file following the `GitBranchPoller` pattern.
27//! 4. Update `default_widgets()` in `par-term-config` to include the new widget.
28//! 5. Update search keywords in `par-term-settings-ui/src/settings_ui/sidebar.rs`.
29//!
30//! # Future: Formal Widget Registry
31//!
32//! The current dispatch-on-enum approach scales well to ~20 widgets but becomes
33//! harder to extend as the widget set grows. A future improvement could introduce
34//! a `StatusBarWidget` trait and a registry (e.g., `HashMap<WidgetId, Box<dyn StatusBarWidget>>`)
35//! so that third-party or plugin-style widgets can be registered without modifying
36//! the central dispatch function. This is tracked as ARC-009 in AUDIT.md.
37
38pub mod disk_monitor;
39pub mod git_poller;
40pub mod system_monitor;
41pub mod widgets;
42
43use std::time::Instant;
44
45use crate::badge::SessionVariables;
46use crate::config::{Config, StatusBarPosition, StatusBarSection};
47use disk_monitor::DiskMonitor;
48use git_poller::GitBranchPoller;
49use system_monitor::SystemMonitor;
50use widgets::{WidgetContext, sorted_widgets_for_section, widget_text};
51
52pub use git_poller::GitStatus;
53
54/// Actions that the status bar can request from the window.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum StatusBarAction {
57    /// User clicked the update-available widget.
58    ShowUpdateDialog,
59}
60
61/// Status bar UI state and renderer.
62pub struct StatusBarUI {
63    /// Background system resource monitor.
64    system_monitor: SystemMonitor,
65    /// Background disk free-space monitor.
66    disk_monitor: DiskMonitor,
67    /// Git branch poller.
68    git_poller: GitBranchPoller,
69    /// Timestamp of the last mouse activity (for auto-hide).
70    last_mouse_activity: Instant,
71    /// Whether the status bar is currently visible.
72    visible: bool,
73    /// Last valid time format string (for fallback when user is mid-edit).
74    last_valid_time_format: String,
75    /// Available update version (set by WindowManager when update is detected)
76    pub update_available_version: Option<String>,
77}
78
79impl StatusBarUI {
80    /// Create a new status bar UI.
81    pub fn new() -> Self {
82        Self {
83            system_monitor: SystemMonitor::new(),
84            disk_monitor: DiskMonitor::new(),
85            git_poller: GitBranchPoller::new(),
86            last_mouse_activity: Instant::now(),
87            visible: true,
88            last_valid_time_format: "%H:%M:%S".to_string(),
89            update_available_version: None,
90        }
91    }
92
93    /// Signal all background threads to stop without waiting.
94    /// Call early during shutdown so threads have time to notice before the Drop join.
95    pub fn signal_shutdown(&self) {
96        self.system_monitor.signal_stop();
97        self.disk_monitor.signal_stop();
98        self.git_poller.signal_stop();
99    }
100
101    /// Compute the effective height consumed by the status bar.
102    ///
103    /// Returns 0 if the status bar is hidden or disabled.
104    pub fn height(&self, config: &Config, is_fullscreen: bool) -> f32 {
105        if !config.status_bar.status_bar_enabled || self.should_hide(config, is_fullscreen) {
106            0.0
107        } else {
108            config.status_bar.status_bar_height
109        }
110    }
111
112    /// Determine whether the status bar should be hidden right now.
113    pub fn should_hide(&self, config: &Config, is_fullscreen: bool) -> bool {
114        if !config.status_bar.status_bar_enabled {
115            return true;
116        }
117        if config.status_bar.status_bar_auto_hide_fullscreen && is_fullscreen {
118            return true;
119        }
120        if config.status_bar.status_bar_auto_hide_mouse_inactive {
121            let elapsed = self.last_mouse_activity.elapsed().as_secs_f32();
122            if elapsed > config.status_bar.status_bar_mouse_inactive_timeout {
123                return true;
124            }
125        }
126        false
127    }
128
129    /// Record mouse activity (resets auto-hide timer).
130    pub fn on_mouse_activity(&mut self) {
131        self.last_mouse_activity = Instant::now();
132        self.visible = true;
133    }
134
135    /// Start or stop the system monitor and git poller based on enabled widgets.
136    pub fn sync_monitor_state(&self, config: &Config) {
137        if !config.status_bar.status_bar_enabled {
138            if self.system_monitor.is_running() {
139                self.system_monitor.stop();
140            }
141            if self.disk_monitor.is_running() {
142                self.disk_monitor.stop();
143            }
144            if self.git_poller.is_running() {
145                self.git_poller.stop();
146            }
147            return;
148        }
149
150        // System monitor
151        let needs_monitor = config
152            .status_bar
153            .status_bar_widgets
154            .iter()
155            .any(|w| w.enabled && w.id.needs_system_monitor());
156
157        if needs_monitor && !self.system_monitor.is_running() {
158            self.system_monitor
159                .start(config.status_bar.status_bar_system_poll_interval);
160        } else if !needs_monitor && self.system_monitor.is_running() {
161            self.system_monitor.stop();
162        }
163
164        // Disk free-space monitor
165        let needs_disk = config
166            .status_bar
167            .status_bar_widgets
168            .iter()
169            .any(|w| w.enabled && w.id.needs_disk_monitor());
170
171        self.disk_monitor
172            .set_follow_cwd(config.status_bar.status_bar_disk_follow_cwd);
173
174        if needs_disk && !self.disk_monitor.is_running() {
175            self.disk_monitor
176                .start(config.status_bar.status_bar_disk_poll_interval);
177        } else if !needs_disk && self.disk_monitor.is_running() {
178            self.disk_monitor.stop();
179        }
180
181        // Git branch poller
182        let needs_git = config
183            .status_bar
184            .status_bar_widgets
185            .iter()
186            .any(|w| w.enabled && w.id == crate::config::WidgetId::GitBranch);
187
188        if needs_git && !self.git_poller.is_running() {
189            self.git_poller
190                .start(config.status_bar.status_bar_git_poll_interval);
191        } else if !needs_git && self.git_poller.is_running() {
192            self.git_poller.stop();
193        }
194    }
195
196    /// Render the status bar.
197    ///
198    /// Returns the height consumed by the status bar (0 if hidden) and an
199    /// optional action requested by the user (e.g. clicking the update widget).
200    pub fn render(
201        &mut self,
202        ctx: &egui::Context,
203        config: &Config,
204        session_vars: &SessionVariables,
205        is_fullscreen: bool,
206    ) -> (f32, Option<StatusBarAction>) {
207        if !config.status_bar.status_bar_enabled || self.should_hide(config, is_fullscreen) {
208            return (0.0, None);
209        }
210
211        // Update git poller cwd from active tab's path
212        let cwd = if session_vars.path.is_empty() {
213            None
214        } else {
215            Some(session_vars.path.as_str())
216        };
217        self.git_poller.set_cwd(cwd);
218        self.disk_monitor.set_cwd(cwd);
219
220        // Validate time format — update last-known-good on success, fall back on failure
221        {
222            use chrono::format::strftime::StrftimeItems;
223            let valid = !config.status_bar.status_bar_time_format.is_empty()
224                && StrftimeItems::new(&config.status_bar.status_bar_time_format)
225                    .all(|item| !matches!(item, chrono::format::Item::Error));
226            if valid {
227                self.last_valid_time_format = config.status_bar.status_bar_time_format.clone();
228            }
229        }
230
231        // Build widget context
232        let git_status = self.git_poller.status();
233        let disk_data = self.disk_monitor.data();
234        let widget_ctx = WidgetContext {
235            session_vars: session_vars.clone(),
236            system_data: self.system_monitor.data(),
237            git_branch: git_status.branch,
238            git_ahead: git_status.ahead,
239            git_behind: git_status.behind,
240            git_dirty: git_status.dirty,
241            git_show_status: config.status_bar.status_bar_git_show_status,
242            time_format: self.last_valid_time_format.clone(),
243            update_available_version: self.update_available_version.clone(),
244            disk_free_percent: disk_data.free_percent,
245            disk_free_bytes: disk_data.free_bytes,
246            disk_total_bytes: disk_data.total_bytes,
247        };
248
249        let bar_height = config.status_bar.status_bar_height;
250        let [bg_r, bg_g, bg_b] = config.status_bar.status_bar_bg_color;
251        let bg_alpha = (config.status_bar.status_bar_bg_alpha * 255.0) as u8;
252        let bg_color = egui::Color32::from_rgba_unmultiplied(bg_r, bg_g, bg_b, bg_alpha);
253
254        let [fg_r, fg_g, fg_b] = config.status_bar.status_bar_fg_color;
255        let fg_color = egui::Color32::from_rgb(fg_r, fg_g, fg_b);
256        let font_size = config.status_bar.status_bar_font_size;
257        let separator = &config.status_bar.status_bar_separator;
258        let sep_color = fg_color.linear_multiply(0.4);
259
260        // Use an egui::Area with a fixed size so the status bar stops before
261        // the scrollbar column.  TopBottomPanel always spans the full window
262        // width and ignores every attempt to narrow it.
263        let h_margin: f32 = 8.0; // left + right inner margin per side
264        let v_margin: f32 = 2.0; // top + bottom inner margin per side
265        let scrollbar_reserved = config.scrollbar.scrollbar_width + 2.0;
266        let viewport = ctx.input(|i| i.viewport_rect());
267        // Content width is the frame width minus both horizontal margins.
268        let content_width = (viewport.width() - scrollbar_reserved - h_margin * 2.0).max(0.0);
269        let content_height = (bar_height - v_margin * 2.0).max(0.0);
270
271        let bar_pos = match config.status_bar.status_bar_position {
272            StatusBarPosition::Top => egui::pos2(0.0, 0.0),
273            StatusBarPosition::Bottom => egui::pos2(0.0, viewport.height() - bar_height),
274        };
275
276        let frame = egui::Frame::NONE
277            .fill(bg_color)
278            .inner_margin(egui::Margin::symmetric(h_margin as i8, v_margin as i8));
279
280        let make_rich_text = |text: &str| -> egui::RichText {
281            egui::RichText::new(text)
282                .color(fg_color)
283                .size(font_size)
284                .monospace()
285        };
286
287        let make_sep = |sep: &str| -> egui::RichText {
288            egui::RichText::new(sep)
289                .color(sep_color)
290                .size(font_size)
291                .monospace()
292        };
293
294        let mut action: Option<StatusBarAction> = None;
295
296        egui::Area::new(egui::Id::new("status_bar"))
297            .fixed_pos(bar_pos)
298            .order(egui::Order::Background)
299            .interactable(true)
300            .show(ctx, |ui| {
301                // Constrain the outer UI so the frame cannot grow beyond the
302                // intended total width (content + margins).
303                ui.set_max_width(content_width + h_margin * 2.0);
304                ui.set_max_height(bar_height);
305
306                frame.show(ui, |ui| {
307                    ui.set_min_size(egui::vec2(content_width, content_height));
308                    ui.set_max_size(egui::vec2(content_width, content_height));
309
310                    ui.horizontal_centered(|ui| {
311                        // Clip widgets to the available content width so
312                        // right-to-left layouts cannot expand past the bar edge.
313                        ui.set_clip_rect(ui.max_rect());
314
315                        // === Left section ===
316                        let left_widgets = sorted_widgets_for_section(
317                            &config.status_bar.status_bar_widgets,
318                            StatusBarSection::Left,
319                        );
320                        let mut first = true;
321                        for w in &left_widgets {
322                            let text = widget_text(&w.id, &widget_ctx, w.format.as_deref());
323                            if text.is_empty() {
324                                continue;
325                            }
326                            if !first {
327                                ui.label(make_sep(separator));
328                            }
329                            first = false;
330                            ui.label(make_rich_text(&text));
331                        }
332
333                        // === Center section ===
334                        let center_widgets = sorted_widgets_for_section(
335                            &config.status_bar.status_bar_widgets,
336                            StatusBarSection::Center,
337                        );
338                        if !center_widgets.is_empty() {
339                            ui.with_layout(
340                                egui::Layout::centered_and_justified(egui::Direction::LeftToRight),
341                                |ui| {
342                                    let mut first = true;
343                                    for w in &center_widgets {
344                                        let text =
345                                            widget_text(&w.id, &widget_ctx, w.format.as_deref());
346                                        if text.is_empty() {
347                                            continue;
348                                        }
349                                        if !first {
350                                            ui.label(make_sep(separator));
351                                        }
352                                        first = false;
353                                        ui.label(make_rich_text(&text));
354                                    }
355                                },
356                            );
357                        }
358
359                        // === Right section ===
360                        let right_widgets = sorted_widgets_for_section(
361                            &config.status_bar.status_bar_widgets,
362                            StatusBarSection::Right,
363                        );
364                        if !right_widgets.is_empty() {
365                            ui.with_layout(
366                                egui::Layout::right_to_left(egui::Align::Center),
367                                |ui| {
368                                    let mut first = true;
369                                    for w in right_widgets.iter().rev() {
370                                        let text =
371                                            widget_text(&w.id, &widget_ctx, w.format.as_deref());
372                                        if text.is_empty() {
373                                            continue;
374                                        }
375                                        if !first {
376                                            ui.label(make_sep(separator));
377                                        }
378                                        first = false;
379                                        if w.id == crate::config::WidgetId::UpdateAvailable {
380                                            let update_text = egui::RichText::new(&text)
381                                                .color(egui::Color32::from_rgb(255, 200, 50))
382                                                .size(font_size)
383                                                .monospace();
384                                            if ui
385                                                .add(
386                                                    egui::Label::new(update_text)
387                                                        .sense(egui::Sense::click()),
388                                                )
389                                                .clicked()
390                                            {
391                                                action = Some(StatusBarAction::ShowUpdateDialog);
392                                            }
393                                        } else {
394                                            ui.label(make_rich_text(&text));
395                                        }
396                                    }
397                                },
398                            );
399                        }
400                    });
401                });
402            });
403
404        (bar_height, action)
405    }
406}
407
408impl Default for StatusBarUI {
409    fn default() -> Self {
410        Self::new()
411    }
412}
413
414impl Drop for StatusBarUI {
415    fn drop(&mut self) {
416        self.system_monitor.stop();
417        self.disk_monitor.stop();
418    }
419}