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, Default)]
24pub struct GitStatus {
25 pub branch: Option<String>,
27 pub ahead: u32,
29 pub behind: u32,
31 pub dirty: bool,
33}
34
35struct GitBranchPoller {
37 status: Arc<Mutex<GitStatus>>,
39 cwd: Arc<Mutex<Option<String>>>,
41 running: Arc<AtomicBool>,
43 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 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 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 fn signal_stop(&self) {
93 self.running.store(false, Ordering::SeqCst);
94 }
95
96 fn stop(&self) {
98 self.signal_stop();
99 if let Some(handle) = self.thread.lock().take() {
100 let _ = handle.join();
101 }
102 }
103
104 fn set_cwd(&self, new_cwd: Option<&str>) {
106 *self.cwd.lock() = new_cwd.map(String::from);
107 }
108
109 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
119fn poll_git_status(dir: &str) -> GitStatus {
121 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 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 None
160 }
161 })
162 .unwrap_or((0, 0));
163
164 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
186pub struct StatusBarUI {
188 system_monitor: SystemMonitor,
190 git_poller: GitBranchPoller,
192 last_mouse_activity: Instant,
194 visible: bool,
196 last_valid_time_format: String,
198}
199
200impl StatusBarUI {
201 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 pub fn signal_shutdown(&self) {
215 self.system_monitor.signal_stop();
216 self.git_poller.signal_stop();
217 }
218
219 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 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 pub fn on_mouse_activity(&mut self) {
249 self.last_mouse_activity = Instant::now();
250 self.visible = true;
251 }
252
253 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 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 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 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 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 {
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 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 let h_margin: f32 = 8.0; let v_margin: f32 = 2.0; let scrollbar_reserved = config.scrollbar_width + 2.0;
354 let viewport = ctx.input(|i| i.viewport_rect());
355 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 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 ui.set_clip_rect(ui.max_rect());
400
401 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 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 ¢er_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 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}