Skip to main content

emux_render/
statusbar.rs

1//! Status bar renderer inspired by gpakosz/.tmux.
2//!
3//! Layout: `[session]▸[tab1 tab2 tab3]         [notifications][time][host]`
4//! Uses Powerline-style separators when available.
5
6use crossterm::style::{Attribute, Color as CtColor, ContentStyle};
7
8/// Powerline separator characters (require patched font / Nerd Font).
9pub const POWERLINE_RIGHT: char = '\u{E0B0}'; //
10pub const POWERLINE_RIGHT_THIN: char = '\u{E0B1}'; //
11pub const POWERLINE_LEFT: char = '\u{E0B2}'; //
12pub const POWERLINE_LEFT_THIN: char = '\u{E0B3}'; //
13
14/// Fallback separators when Powerline fonts are not available.
15pub const FALLBACK_SEP: char = '│';
16
17/// A styled segment of the status bar.
18#[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/// Information needed to render the status bar.
43#[derive(Debug, Clone)]
44pub struct StatusBarInfo {
45    /// Current session name.
46    pub session_name: String,
47    /// Tab names with active index.
48    pub tabs: Vec<TabInfo>,
49    /// Index of the currently active tab.
50    pub active_tab: usize,
51    /// Number of unread notifications.
52    pub notification_count: usize,
53    /// Hostname.
54    pub hostname: String,
55    /// Whether to use Powerline separators.
56    pub powerline: bool,
57}
58
59/// Information about a single tab for status bar display.
60#[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/// Color palette for the status bar (gpakosz/.tmux inspired).
69#[derive(Debug, Clone)]
70pub struct StatusBarTheme {
71    /// Status bar background.
72    pub bar_bg: CtColor,
73    /// Session section: fg, bg.
74    pub session_fg: CtColor,
75    pub session_bg: CtColor,
76    /// Active tab: fg, bg.
77    pub active_tab_fg: CtColor,
78    pub active_tab_bg: CtColor,
79    /// Inactive tab: fg, bg.
80    pub inactive_tab_fg: CtColor,
81    pub inactive_tab_bg: CtColor,
82    /// Tab with notification: fg.
83    pub notify_tab_fg: CtColor,
84    /// Right section 1 (notifications/info): fg, bg.
85    pub right1_fg: CtColor,
86    pub right1_bg: CtColor,
87    /// Right section 2 (time): fg, bg.
88    pub right2_fg: CtColor,
89    pub right2_bg: CtColor,
90    /// Right section 3 (host): fg, bg.
91    pub right3_fg: CtColor,
92    pub right3_bg: CtColor,
93    /// Pane border: active, inactive.
94    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: dark on yellow (bold)
107            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: dark on blue (bold)
118            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: gray on dark
129            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            // Notification tab: yellow blink
140            notify_tab_fg: CtColor::Rgb {
141                r: 0xFF,
142                g: 0xFF,
143                b: 0x00,
144            },
145            // Right 1: gray on dark (info)
146            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            // Right 2: white on red (time)
157            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            // Right 3: dark on white (host)
168            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            // Borders
179            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
193/// Render the status bar into a list of (ContentStyle, String) spans.
194///
195/// The total width of all spans equals exactly `width` columns.
196pub 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: session name ──
205    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    // ── Left: tabs ──
215    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    // ── Right: notifications ──
232    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    // ── Right: time ──
241    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: hostname ──
249    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 into spans ──
259    assemble_bar(
260        &left_segments,
261        &right_segments,
262        theme,
263        width,
264        info.powerline,
265    )
266}
267
268/// Assemble left and right segments into a full-width status bar.
269fn 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    // Render left segments with separators
279    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        // Separator between segments
289        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    // Separator after last left segment to bar background
299    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    // Calculate widths
308    let left_width: usize = left.iter().map(|s| display_width(&s.text)).sum::<usize>()
309        + if powerline { left.len() } else { 0 }; // separators
310    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    // Fill middle with bar background
316    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    // Render right segments with separators
324    for (i, seg) in right.iter().enumerate() {
325        // Separator before segment
326        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
350/// Simple time formatting without chrono dependency.
351fn chrono_free_time() -> String {
352    // Use a fixed format via libc or fallback
353    #[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
392/// Approximate display width of a string, accounting for wide characters.
393fn 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) // Hangul Jamo
399                || ('\u{2E80}'..='\u{303E}').contains(&c)    // CJK Radicals / Kangxi
400                || ('\u{3040}'..='\u{33BF}').contains(&c)    // Japanese Hiragana/Katakana
401                || ('\u{3400}'..='\u{4DBF}').contains(&c)    // CJK Unified Ext A
402                || ('\u{4E00}'..='\u{9FFF}').contains(&c)    // CJK Unified Ideographs
403                || ('\u{AC00}'..='\u{D7AF}').contains(&c)    // Hangul Syllables
404                || ('\u{F900}'..='\u{FAFF}').contains(&c)    // CJK Compatibility Ideographs
405                || ('\u{FE30}'..='\u{FE6F}').contains(&c)    // CJK Compatibility Forms
406                || ('\u{FF01}'..='\u{FF60}').contains(&c)    // Fullwidth Forms
407                || ('\u{1F000}'..='\u{1FFFF}').contains(&c)  // Emoji and symbols
408                || ('\u{20000}'..='\u{2FFFF}').contains(&c)
409            // CJK Unified Ext B+
410            {
411                2
412            } else {
413                1
414            }
415        })
416        .sum()
417}
418
419/// Render a horizontal pane border line.
420pub 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        // Notification tab should have the dot marker
512        assert!(text.contains("●"));
513        // Notification count
514        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        // The notification segment " !N " should not be present
526        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        // Very narrow — should not panic
544        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        // Emoji (U+1F514 BELL) should be 2 columns wide
585        assert_eq!(display_width("\u{1F514}"), 2);
586        // CJK ideograph should be 2 columns wide
587        assert_eq!(display_width("\u{4E2D}"), 2);
588        // Mixed ASCII and wide
589        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}