1pub 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#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum StatusBarAction {
25 ShowUpdateDialog,
27}
28
29#[derive(Debug, Clone, Default)]
31pub struct GitStatus {
32 pub branch: Option<String>,
34 pub ahead: u32,
36 pub behind: u32,
38 pub dirty: bool,
40}
41
42struct GitBranchPoller {
44 status: Arc<Mutex<GitStatus>>,
46 cwd: Arc<Mutex<Option<String>>>,
48 running: Arc<AtomicBool>,
50 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 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 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 fn signal_stop(&self) {
100 self.running.store(false, Ordering::SeqCst);
101 }
102
103 fn stop(&self) {
105 self.signal_stop();
106 if let Some(handle) = self.thread.lock().take() {
107 let _ = handle.join();
108 }
109 }
110
111 fn set_cwd(&self, new_cwd: Option<&str>) {
113 *self.cwd.lock() = new_cwd.map(String::from);
114 }
115
116 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
126fn poll_git_status(dir: &str) -> GitStatus {
128 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 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 None
167 }
168 })
169 .unwrap_or((0, 0));
170
171 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
193pub struct StatusBarUI {
195 system_monitor: SystemMonitor,
197 git_poller: GitBranchPoller,
199 last_mouse_activity: Instant,
201 visible: bool,
203 last_valid_time_format: String,
205 pub update_available_version: Option<String>,
207}
208
209impl StatusBarUI {
210 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 pub fn signal_shutdown(&self) {
225 self.system_monitor.signal_stop();
226 self.git_poller.signal_stop();
227 }
228
229 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 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 pub fn on_mouse_activity(&mut self) {
259 self.last_mouse_activity = Instant::now();
260 self.visible = true;
261 }
262
263 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 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 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 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 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 {
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 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 let h_margin: f32 = 8.0; let v_margin: f32 = 2.0; let scrollbar_reserved = config.scrollbar_width + 2.0;
366 let viewport = ctx.input(|i| i.viewport_rect());
367 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 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 ui.set_clip_rect(ui.max_rect());
414
415 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 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 ¢er_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 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}