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