1use crossterm::style::{Attribute, Color as CtColor, ContentStyle};
7
8pub const POWERLINE_RIGHT: char = '\u{E0B0}'; pub const POWERLINE_RIGHT_THIN: char = '\u{E0B1}'; pub const POWERLINE_LEFT: char = '\u{E0B2}'; pub const POWERLINE_LEFT_THIN: char = '\u{E0B3}'; pub const FALLBACK_SEP: char = '│';
16
17#[derive(Debug, Clone)]
19pub struct Segment {
20 pub text: String,
21 pub fg: CtColor,
22 pub bg: CtColor,
23 pub bold: bool,
24}
25
26impl Segment {
27 pub fn new(text: impl Into<String>, fg: CtColor, bg: CtColor) -> Self {
28 Self {
29 text: text.into(),
30 fg,
31 bg,
32 bold: false,
33 }
34 }
35
36 pub fn bold(mut self) -> Self {
37 self.bold = true;
38 self
39 }
40}
41
42#[derive(Debug, Clone)]
44pub struct StatusBarInfo {
45 pub session_name: String,
47 pub tabs: Vec<TabInfo>,
49 pub active_tab: usize,
51 pub notification_count: usize,
53 pub hostname: String,
55 pub powerline: bool,
57}
58
59#[derive(Debug, Clone)]
61pub struct TabInfo {
62 pub name: String,
63 pub index: usize,
64 pub has_notification: bool,
65 pub pane_count: usize,
66}
67
68#[derive(Debug, Clone)]
70pub struct StatusBarTheme {
71 pub bar_bg: CtColor,
73 pub session_fg: CtColor,
75 pub session_bg: CtColor,
76 pub active_tab_fg: CtColor,
78 pub active_tab_bg: CtColor,
79 pub inactive_tab_fg: CtColor,
81 pub inactive_tab_bg: CtColor,
82 pub notify_tab_fg: CtColor,
84 pub right1_fg: CtColor,
86 pub right1_bg: CtColor,
87 pub right2_fg: CtColor,
89 pub right2_bg: CtColor,
90 pub right3_fg: CtColor,
92 pub right3_bg: CtColor,
93 pub border_active: CtColor,
95 pub border_inactive: CtColor,
96}
97
98impl Default for StatusBarTheme {
99 fn default() -> Self {
100 Self {
101 bar_bg: CtColor::Rgb {
102 r: 0x08,
103 g: 0x08,
104 b: 0x08,
105 },
106 session_fg: CtColor::Rgb {
108 r: 0x08,
109 g: 0x08,
110 b: 0x08,
111 },
112 session_bg: CtColor::Rgb {
113 r: 0xFF,
114 g: 0xFF,
115 b: 0x00,
116 },
117 active_tab_fg: CtColor::Rgb {
119 r: 0x08,
120 g: 0x08,
121 b: 0x08,
122 },
123 active_tab_bg: CtColor::Rgb {
124 r: 0x00,
125 g: 0xAF,
126 b: 0xFF,
127 },
128 inactive_tab_fg: CtColor::Rgb {
130 r: 0x8A,
131 g: 0x8A,
132 b: 0x8A,
133 },
134 inactive_tab_bg: CtColor::Rgb {
135 r: 0x08,
136 g: 0x08,
137 b: 0x08,
138 },
139 notify_tab_fg: CtColor::Rgb {
141 r: 0xFF,
142 g: 0xFF,
143 b: 0x00,
144 },
145 right1_fg: CtColor::Rgb {
147 r: 0x8A,
148 g: 0x8A,
149 b: 0x8A,
150 },
151 right1_bg: CtColor::Rgb {
152 r: 0x08,
153 g: 0x08,
154 b: 0x08,
155 },
156 right2_fg: CtColor::Rgb {
158 r: 0xE4,
159 g: 0xE4,
160 b: 0xE4,
161 },
162 right2_bg: CtColor::Rgb {
163 r: 0xD7,
164 g: 0x00,
165 b: 0x00,
166 },
167 right3_fg: CtColor::Rgb {
169 r: 0x08,
170 g: 0x08,
171 b: 0x08,
172 },
173 right3_bg: CtColor::Rgb {
174 r: 0xE4,
175 g: 0xE4,
176 b: 0xE4,
177 },
178 border_active: CtColor::Rgb {
180 r: 0x00,
181 g: 0xAF,
182 b: 0xFF,
183 },
184 border_inactive: CtColor::Rgb {
185 r: 0x30,
186 g: 0x30,
187 b: 0x30,
188 },
189 }
190 }
191}
192
193pub fn render_statusbar(
197 info: &StatusBarInfo,
198 theme: &StatusBarTheme,
199 width: usize,
200) -> Vec<(ContentStyle, String)> {
201 let mut left_segments = Vec::new();
202 let mut right_segments = Vec::new();
203
204 left_segments.push(
206 Segment::new(
207 format!(" ❐ {} ", info.session_name),
208 theme.session_fg,
209 theme.session_bg,
210 )
211 .bold(),
212 );
213
214 for tab in &info.tabs {
216 let is_active = tab.index == info.active_tab;
217 let (fg, bg) = if is_active {
218 (theme.active_tab_fg, theme.active_tab_bg)
219 } else if tab.has_notification {
220 (theme.notify_tab_fg, theme.inactive_tab_bg)
221 } else {
222 (theme.inactive_tab_fg, theme.inactive_tab_bg)
223 };
224
225 let marker = if tab.has_notification { "●" } else { "" };
226 let text = format!(" {}{} {} ", tab.index + 1, marker, tab.name);
227 let seg = Segment::new(text, fg, bg);
228 left_segments.push(if is_active { seg.bold() } else { seg });
229 }
230
231 if info.notification_count > 0 {
233 right_segments.push(Segment::new(
234 format!(" !{} ", info.notification_count),
235 theme.notify_tab_fg,
236 theme.right1_bg,
237 ));
238 }
239
240 let now = chrono_free_time();
242 right_segments.push(Segment::new(
243 format!(" {} ", now),
244 theme.right2_fg,
245 theme.right2_bg,
246 ));
247
248 right_segments.push(
250 Segment::new(
251 format!(" {} ", info.hostname),
252 theme.right3_fg,
253 theme.right3_bg,
254 )
255 .bold(),
256 );
257
258 assemble_bar(
260 &left_segments,
261 &right_segments,
262 theme,
263 width,
264 info.powerline,
265 )
266}
267
268fn assemble_bar(
270 left: &[Segment],
271 right: &[Segment],
272 theme: &StatusBarTheme,
273 width: usize,
274 powerline: bool,
275) -> Vec<(ContentStyle, String)> {
276 let mut spans = Vec::new();
277
278 for (i, seg) in left.iter().enumerate() {
280 let mut style = ContentStyle::new();
281 style.foreground_color = Some(seg.fg);
282 style.background_color = Some(seg.bg);
283 if seg.bold {
284 style.attributes.set(Attribute::Bold);
285 }
286 spans.push((style, seg.text.clone()));
287
288 if powerline && i + 1 < left.len() {
290 let next_bg = left[i + 1].bg;
291 let mut sep_style = ContentStyle::new();
292 sep_style.foreground_color = Some(seg.bg);
293 sep_style.background_color = Some(next_bg);
294 spans.push((sep_style, POWERLINE_RIGHT.to_string()));
295 }
296 }
297
298 if powerline && !left.is_empty() {
300 let last_bg = left.last().unwrap().bg;
301 let mut sep_style = ContentStyle::new();
302 sep_style.foreground_color = Some(last_bg);
303 sep_style.background_color = Some(theme.bar_bg);
304 spans.push((sep_style, POWERLINE_RIGHT.to_string()));
305 }
306
307 let left_width: usize = left.iter().map(|s| display_width(&s.text)).sum::<usize>()
309 + if powerline { left.len() } else { 0 }; let right_width: usize = right.iter().map(|s| display_width(&s.text)).sum::<usize>()
311 + if powerline { right.len() } else { 0 };
312
313 let fill = width.saturating_sub(left_width + right_width);
314
315 if fill > 0 {
317 let mut fill_style = ContentStyle::new();
318 fill_style.background_color = Some(theme.bar_bg);
319 fill_style.foreground_color = Some(theme.bar_bg);
320 spans.push((fill_style, " ".repeat(fill)));
321 }
322
323 for (i, seg) in right.iter().enumerate() {
325 if powerline {
327 let prev_bg = if i == 0 {
328 theme.bar_bg
329 } else {
330 right[i - 1].bg
331 };
332 let mut sep_style = ContentStyle::new();
333 sep_style.foreground_color = Some(seg.bg);
334 sep_style.background_color = Some(prev_bg);
335 spans.push((sep_style, POWERLINE_LEFT.to_string()));
336 }
337
338 let mut style = ContentStyle::new();
339 style.foreground_color = Some(seg.fg);
340 style.background_color = Some(seg.bg);
341 if seg.bold {
342 style.attributes.set(Attribute::Bold);
343 }
344 spans.push((style, seg.text.clone()));
345 }
346
347 spans
348}
349
350fn chrono_free_time() -> String {
352 #[cfg(unix)]
354 {
355 use std::ffi::CStr;
356 unsafe {
357 let mut t: libc::time_t = 0;
358 libc::time(&mut t);
359 let tm = libc::localtime(&t);
360 if tm.is_null() {
361 return "??:??".into();
362 }
363 let mut buf = [0u8; 32];
364 let fmt = b"%H:%M\0";
365 let len = libc::strftime(
366 buf.as_mut_ptr() as *mut libc::c_char,
367 buf.len(),
368 fmt.as_ptr() as *const libc::c_char,
369 tm,
370 );
371 if len == 0 {
372 return "??:??".into();
373 }
374 CStr::from_ptr(buf.as_ptr() as *const libc::c_char)
375 .to_string_lossy()
376 .into_owned()
377 }
378 }
379 #[cfg(not(unix))]
380 {
381 use std::time::SystemTime;
382 let now = SystemTime::now()
383 .duration_since(SystemTime::UNIX_EPOCH)
384 .unwrap_or_default()
385 .as_secs();
386 let hours = (now % 86400) / 3600;
387 let minutes = (now % 3600) / 60;
388 format!("{:02}:{:02}", hours, minutes)
389 }
390}
391
392fn display_width(s: &str) -> usize {
394 s.chars()
395 .map(|c| {
396 if c.is_ascii() {
397 1
398 } else if ('\u{1100}'..='\u{115F}').contains(&c) || ('\u{2E80}'..='\u{303E}').contains(&c) || ('\u{3040}'..='\u{33BF}').contains(&c) || ('\u{3400}'..='\u{4DBF}').contains(&c) || ('\u{4E00}'..='\u{9FFF}').contains(&c) || ('\u{AC00}'..='\u{D7AF}').contains(&c) || ('\u{F900}'..='\u{FAFF}').contains(&c) || ('\u{FE30}'..='\u{FE6F}').contains(&c) || ('\u{FF01}'..='\u{FF60}').contains(&c) || ('\u{1F000}'..='\u{1FFFF}').contains(&c) || ('\u{20000}'..='\u{2FFFF}').contains(&c)
409 {
411 2
412 } else {
413 1
414 }
415 })
416 .sum()
417}
418
419pub fn render_border(
421 width: usize,
422 active: bool,
423 theme: &StatusBarTheme,
424) -> Vec<(ContentStyle, String)> {
425 let color = if active {
426 theme.border_active
427 } else {
428 theme.border_inactive
429 };
430 let mut style = ContentStyle::new();
431 style.foreground_color = Some(color);
432 vec![(style, "─".repeat(width))]
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 fn sample_info() -> StatusBarInfo {
440 StatusBarInfo {
441 session_name: "dev".into(),
442 tabs: vec![
443 TabInfo {
444 name: "bash".into(),
445 index: 0,
446 has_notification: false,
447 pane_count: 1,
448 },
449 TabInfo {
450 name: "vim".into(),
451 index: 1,
452 has_notification: false,
453 pane_count: 1,
454 },
455 TabInfo {
456 name: "htop".into(),
457 index: 2,
458 has_notification: true,
459 pane_count: 2,
460 },
461 ],
462 active_tab: 1,
463 notification_count: 1,
464 hostname: "myhost".into(),
465 powerline: false,
466 }
467 }
468
469 #[test]
470 fn statusbar_renders_to_exact_width() {
471 let info = sample_info();
472 let theme = StatusBarTheme::default();
473 let spans = render_statusbar(&info, &theme, 120);
474 let total: usize = spans.iter().map(|(_, t)| display_width(t)).sum();
475 assert_eq!(total, 120);
476 }
477
478 #[test]
479 fn statusbar_contains_session_name() {
480 let info = sample_info();
481 let theme = StatusBarTheme::default();
482 let spans = render_statusbar(&info, &theme, 120);
483 let text: String = spans.iter().map(|(_, t)| t.as_str()).collect();
484 assert!(text.contains("dev"));
485 }
486
487 #[test]
488 fn statusbar_contains_active_tab() {
489 let info = sample_info();
490 let theme = StatusBarTheme::default();
491 let spans = render_statusbar(&info, &theme, 120);
492 let text: String = spans.iter().map(|(_, t)| t.as_str()).collect();
493 assert!(text.contains("vim"));
494 }
495
496 #[test]
497 fn statusbar_contains_hostname() {
498 let info = sample_info();
499 let theme = StatusBarTheme::default();
500 let spans = render_statusbar(&info, &theme, 120);
501 let text: String = spans.iter().map(|(_, t)| t.as_str()).collect();
502 assert!(text.contains("myhost"));
503 }
504
505 #[test]
506 fn statusbar_shows_notification_indicator() {
507 let info = sample_info();
508 let theme = StatusBarTheme::default();
509 let spans = render_statusbar(&info, &theme, 120);
510 let text: String = spans.iter().map(|(_, t)| t.as_str()).collect();
511 assert!(text.contains("●"));
513 assert!(text.contains("!1"));
515 }
516
517 #[test]
518 fn statusbar_no_notification_when_zero() {
519 let mut info = sample_info();
520 info.notification_count = 0;
521 info.tabs[2].has_notification = false;
522 let theme = StatusBarTheme::default();
523 let spans = render_statusbar(&info, &theme, 120);
524 let text: String = spans.iter().map(|(_, t)| t.as_str()).collect();
525 assert!(!text.contains(" !"));
527 }
528
529 #[test]
530 fn statusbar_powerline_separators() {
531 let mut info = sample_info();
532 info.powerline = true;
533 let theme = StatusBarTheme::default();
534 let spans = render_statusbar(&info, &theme, 120);
535 let text: String = spans.iter().map(|(_, t)| t.as_str()).collect();
536 assert!(text.contains(POWERLINE_RIGHT) || text.contains(POWERLINE_LEFT));
537 }
538
539 #[test]
540 fn statusbar_narrow_width_does_not_panic() {
541 let info = sample_info();
542 let theme = StatusBarTheme::default();
543 let spans = render_statusbar(&info, &theme, 20);
545 assert!(!spans.is_empty());
546 }
547
548 #[test]
549 fn border_renders_correct_width() {
550 let theme = StatusBarTheme::default();
551 let spans = render_border(80, true, &theme);
552 let total: usize = spans.iter().map(|(_, t)| t.chars().count()).sum();
553 assert_eq!(total, 80);
554 }
555
556 #[test]
557 fn border_active_uses_active_color() {
558 let theme = StatusBarTheme::default();
559 let spans = render_border(10, true, &theme);
560 assert_eq!(spans[0].0.foreground_color, Some(theme.border_active));
561 }
562
563 #[test]
564 fn border_inactive_uses_inactive_color() {
565 let theme = StatusBarTheme::default();
566 let spans = render_border(10, false, &theme);
567 assert_eq!(spans[0].0.foreground_color, Some(theme.border_inactive));
568 }
569
570 #[test]
571 fn segment_bold_flag() {
572 let seg = Segment::new("test", CtColor::White, CtColor::Black).bold();
573 assert!(seg.bold);
574 }
575
576 #[test]
577 fn display_width_basic() {
578 assert_eq!(display_width("hello"), 5);
579 assert_eq!(display_width(""), 0);
580 }
581
582 #[test]
583 fn display_width_wide_chars() {
584 assert_eq!(display_width("\u{1F514}"), 2);
586 assert_eq!(display_width("\u{4E2D}"), 2);
588 assert_eq!(display_width("A\u{4E2D}B"), 4);
590 }
591
592 #[test]
593 fn default_theme_has_distinct_colors() {
594 let theme = StatusBarTheme::default();
595 assert_ne!(theme.session_bg, theme.active_tab_bg);
596 assert_ne!(theme.active_tab_bg, theme.inactive_tab_bg);
597 assert_ne!(theme.right2_bg, theme.right3_bg);
598 }
599}