Skip to main content

hjkl_statusline/
lib.rs

1//! Renderer-agnostic statusline data model.
2//!
3//! Build a [`Bar`] from host state, then hand it to a renderer adapter
4//! (e.g. `hjkl-statusline-tui` for ratatui, `hjkl-statusline-gui` for floem).
5//!
6//! Naming follows the vim convention (`:help statusline`, lualine,
7//! vim-airline, lightline).
8
9#![forbid(unsafe_code)]
10
11use std::borrow::Cow;
12
13// ── Re-export hjkl-theme types ──────────────────────────────────────────────
14//
15// `Color`, `Modifiers`, and `Style` (aliased from hjkl-theme's `StyleSpec`)
16// are the canonical types shared across the hjkl crate stack. Consumers that
17// hold both a `Theme` and a `Bar` no longer need conversion shims.
18pub use hjkl_theme::Color;
19pub use hjkl_theme::Modifiers;
20/// Alias for [`hjkl_theme::StyleSpec`]: foreground, background, modifiers.
21pub use hjkl_theme::StyleSpec as Style;
22
23// ── Style builder helpers ────────────────────────────────────────────────────
24//
25// `StyleSpec` from hjkl-theme carries only fields; builder methods live here
26// as a local extension so callers can chain `.fg()`, `.bg()`, `.bold()`, etc.
27
28/// Extension methods for building a [`Style`] (`hjkl_theme::StyleSpec`) by chaining.
29pub trait StyleExt: Sized {
30    /// Return a default (all-None / all-false) style.
31    fn default_style() -> Self;
32    /// Set foreground color.
33    fn fg(self, fg: Color) -> Self;
34    /// Set background color.
35    fn bg(self, bg: Color) -> Self;
36    /// Enable bold.
37    fn bold(self) -> Self;
38    /// Enable italic.
39    fn italic(self) -> Self;
40}
41
42impl StyleExt for Style {
43    fn default_style() -> Self {
44        Self::default()
45    }
46
47    fn fg(self, fg: Color) -> Self {
48        Self {
49            fg: Some(fg),
50            ..self
51        }
52    }
53
54    fn bg(self, bg: Color) -> Self {
55        Self {
56            bg: Some(bg),
57            ..self
58        }
59    }
60
61    fn bold(self) -> Self {
62        Self {
63            modifiers: Modifiers {
64                bold: true,
65                ..self.modifiers
66            },
67            ..self
68        }
69    }
70
71    fn italic(self) -> Self {
72        Self {
73            modifiers: Modifiers {
74                italic: true,
75                ..self.modifiers
76            },
77            ..self
78        }
79    }
80}
81
82/// A single horizontal segment in the statusline.
83#[non_exhaustive]
84#[derive(Debug, Clone)]
85pub enum Segment {
86    /// Pre-styled text. The renderer paints it verbatim.
87    ///
88    /// `content` is a [`Cow<'static, str>`] so static labels (e.g. `" NORMAL "`)
89    /// are stored as borrowed `&'static str` with zero allocation, while
90    /// dynamically-built strings (e.g. ` 42:7 `) use the owned `String` path.
91    Text {
92        content: Cow<'static, str>,
93        style: Style,
94    },
95}
96
97impl Segment {
98    pub fn len(&self) -> usize {
99        match self {
100            Segment::Text { content, .. } => content.chars().count(),
101        }
102    }
103
104    pub fn is_empty(&self) -> bool {
105        self.len() == 0
106    }
107}
108
109/// A fully-laid-out statusline: left segments + spacer + right segments.
110///
111/// Build with [`Bar`], then call [`Bar::layout`] to get the final flat
112/// segment list (suitable for a single-row renderer).
113#[derive(Debug, Clone, Default)]
114pub struct Bar {
115    pub left: Vec<Segment>,
116    pub right: Vec<Segment>,
117    /// Style used to fill the spacer gap between left and right.
118    pub fill_style: Style,
119}
120
121impl Bar {
122    /// Compute the final left-to-right segment list for the given terminal
123    /// `width`. Inserts a padding spacer so right-aligned segments reach
124    /// the right edge. Truncates the last left segment with `…` if the
125    /// combined content is too wide.
126    pub fn layout(&self, width: u16) -> Vec<Segment> {
127        let w = width as usize;
128
129        let left_len: usize = self.left.iter().map(|s| s.len()).sum();
130        let right_len: usize = self.right.iter().map(|s| s.len()).sum();
131        let total = left_len + right_len;
132
133        let mut out: Vec<Segment> = Vec::with_capacity(self.left.len() + self.right.len() + 1);
134
135        if total <= w {
136            // Everything fits — compute spacer width.
137            let spacer_w = w.saturating_sub(total);
138            out.extend(self.left.iter().cloned());
139            out.push(Segment::Text {
140                content: " ".repeat(spacer_w).into(),
141                style: self.fill_style,
142            });
143            out.extend(self.right.iter().cloned());
144        } else {
145            // Left side needs truncation. Right is always preserved fully.
146            let avail_for_left = w.saturating_sub(right_len);
147            let mut used = 0usize;
148            for seg in self.left.iter() {
149                let seg_len = seg.len();
150                if used + seg_len <= avail_for_left {
151                    out.push(seg.clone());
152                    used += seg_len;
153                } else {
154                    // Truncate this segment to fit.
155                    let remaining = avail_for_left.saturating_sub(used);
156                    if remaining > 1 {
157                        let Segment::Text { content, style } = seg;
158                        let truncated: String =
159                            content.chars().take(remaining.saturating_sub(1)).collect();
160                        out.push(Segment::Text {
161                            content: format!("{truncated}\u{2026}").into(),
162                            style: *style,
163                        });
164                    } else if remaining == 1 {
165                        let Segment::Text { style, .. } = seg;
166                        out.push(Segment::Text {
167                            content: Cow::Borrowed("\u{2026}"),
168                            style: *style,
169                        });
170                    }
171                    break;
172                }
173            }
174            // Zero-width spacer in truncated layout.
175            out.push(Segment::Text {
176                content: Cow::Borrowed(""),
177                style: self.fill_style,
178            });
179            out.extend(self.right.iter().cloned());
180        }
181
182        out
183    }
184}
185
186// ── Theme ──────────────────────────────────────────────────────────────────
187
188/// Theme colours the status row needs. Caller populates from its own theme.
189///
190/// `#[non_exhaustive]` allows adding new colour slots (e.g. per-severity
191/// diagnostic colours, #135) without a breaking semver bump for consumers.
192#[non_exhaustive]
193#[derive(Debug, Clone, Copy)]
194pub struct StatusTheme {
195    pub bg: Color,
196    pub fg: Color,
197    pub fill_bg: Color,
198    pub mode_normal_bg: Color,
199    pub mode_normal_fg: Color,
200    pub mode_insert_bg: Color,
201    pub mode_insert_fg: Color,
202    pub mode_visual_bg: Color,
203    pub mode_visual_fg: Color,
204    pub dirty_fg: Color,
205    pub readonly_fg: Color,
206    pub new_file_fg: Color,
207    pub recording_bg: Color,
208    pub recording_fg: Color,
209    /// Foreground color for Error-severity diagnostics in the statusline.
210    pub diag_error_fg: Color,
211    /// Foreground color for Warning-severity diagnostics in the statusline.
212    pub diag_warning_fg: Color,
213    /// Foreground color for Info-severity diagnostics in the statusline.
214    pub diag_info_fg: Color,
215    /// Foreground color for Hint-severity diagnostics in the statusline.
216    pub diag_hint_fg: Color,
217}
218
219impl Default for StatusTheme {
220    fn default() -> Self {
221        let grey = Color::rgb(0xaa, 0xaa, 0xaa);
222        let dark = Color::rgb(0x2e, 0x34, 0x40);
223        Self {
224            bg: Color::rgb(0x2a, 0x32, 0x40),
225            fg: Color::rgb(0xe5, 0xe9, 0xf0),
226            fill_bg: Color::rgb(0x1e, 0x22, 0x2a),
227            mode_normal_bg: Color::rgb(0x5e, 0x81, 0xac),
228            mode_normal_fg: dark,
229            mode_insert_bg: Color::rgb(0x7e, 0xe7, 0x87),
230            mode_insert_fg: dark,
231            mode_visual_bg: Color::rgb(0xd0, 0x8e, 0x4b),
232            mode_visual_fg: dark,
233            dirty_fg: Color::rgb(0xeb, 0xcb, 0x8b),
234            readonly_fg: grey,
235            new_file_fg: grey,
236            recording_bg: Color::rgb(0xbf, 0x61, 0x6a),
237            recording_fg: dark,
238            // Standard ANSI-named colors: adapt to the terminal palette.
239            diag_error_fg: Color::rgb(0xff, 0x00, 0x00), // ANSI Red
240            diag_warning_fg: Color::rgb(0xff, 0xc0, 0x00), // ANSI Yellow
241            diag_info_fg: Color::rgb(0x00, 0x7a, 0xff),  // ANSI Blue
242            diag_hint_fg: Color::rgb(0x00, 0xd7, 0xd7),  // ANSI Cyan
243        }
244    }
245}
246
247impl StatusTheme {
248    /// Construct with explicit `bg` and `fg`; remaining slots filled with
249    /// sensible Nord-palette greys so callers can mutate only what they need.
250    pub fn new(bg: Color, fg: Color) -> Self {
251        Self {
252            bg,
253            fg,
254            ..Self::default()
255        }
256    }
257}
258
259// ── Mode ────────────────────────────────────────────────────────────────────
260
261/// High-level mode classification for color selection.
262///
263/// `#[non_exhaustive]` so future variants (Command, ExSearch, IncSearch,
264/// Macro, Terminal sub-kinds, …) can be added without breaking exhaustive
265/// matches in downstream code.
266#[non_exhaustive]
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268pub enum ModeKind {
269    Normal,
270    Insert,
271    Visual,
272    VisualLine,
273    VisualBlock,
274    Replace,
275    Select,
276    Operator,
277    Terminal,
278}
279
280impl ModeKind {
281    /// Derive from a mode label string (as returned by the engine).
282    pub fn from_label(label: &str) -> Self {
283        match label {
284            "INSERT" => ModeKind::Insert,
285            "REPLACE" => ModeKind::Replace,
286            "VISUAL" => ModeKind::Visual,
287            "VISUAL LINE" => ModeKind::VisualLine,
288            "VISUAL BLOCK" => ModeKind::VisualBlock,
289            "SELECT" => ModeKind::Select,
290            "TERMINAL" => ModeKind::Terminal,
291            _ => ModeKind::Normal,
292        }
293    }
294}
295
296// ── Segment builders ────────────────────────────────────────────────────────
297
298/// Build the mode badge segment (e.g. ` NORMAL `).
299pub fn mode_segment(label: &str, theme: &StatusTheme) -> Segment {
300    let kind = ModeKind::from_label(label);
301    let (bg, fg) = match kind {
302        ModeKind::Insert => (theme.mode_insert_bg, theme.mode_insert_fg),
303        ModeKind::Visual | ModeKind::VisualLine | ModeKind::VisualBlock => {
304            (theme.mode_visual_bg, theme.mode_visual_fg)
305        }
306        _ => (theme.mode_normal_bg, theme.mode_normal_fg),
307    };
308    Segment::Text {
309        content: format!(" {label} ").into(),
310        style: Style::default_style().bg(bg).fg(fg).bold(),
311    }
312}
313
314/// Build the filename segment including suffix tags (e.g. `[RO]`, `[New File]`).
315pub fn filename_segment(name: &str, suffix: &str, theme: &StatusTheme) -> Segment {
316    let style = Style::default_style().bg(theme.bg).fg(theme.fg);
317    Segment::Text {
318        content: format!(" {name}{suffix} ").into(),
319        style,
320    }
321}
322
323/// Build a dirty-marker segment (` ● ` when dirty, empty otherwise).
324pub fn dirty_segment(dirty: bool, theme: &StatusTheme) -> Option<Segment> {
325    if dirty {
326        Some(Segment::Text {
327            content: Cow::Borrowed(" \u{25cf} "),
328            style: Style::default_style().bg(theme.bg).fg(theme.dirty_fg),
329        })
330    } else {
331        None
332    }
333}
334
335/// Build the cursor position segment (` row:col `).
336pub fn cursor_segment(row: usize, col: usize, theme: &StatusTheme) -> Segment {
337    Segment::Text {
338        content: format!(" {}:{} ", row + 1, col + 1).into(),
339        style: Style::default_style().bg(theme.bg).fg(theme.fg),
340    }
341}
342
343/// Build the percentage segment (` N% `).
344///
345/// `mode` controls which mode colors are used for the badge background so
346/// the segment visually echoes the active mode (e.g. green in INSERT, orange
347/// in VISUAL). Pass [`ModeKind::Normal`] when the caller does not know the
348/// mode or wants the default Normal styling.
349pub fn percent_segment(
350    row: usize,
351    total_lines: usize,
352    mode: ModeKind,
353    theme: &StatusTheme,
354) -> Segment {
355    let pct = ((row + 1) * 100).checked_div(total_lines).unwrap_or(0);
356    let (bg, fg) = match mode {
357        ModeKind::Insert => (theme.mode_insert_bg, theme.mode_insert_fg),
358        ModeKind::Visual | ModeKind::VisualLine | ModeKind::VisualBlock => {
359            (theme.mode_visual_bg, theme.mode_visual_fg)
360        }
361        _ => (theme.mode_normal_bg, theme.mode_normal_fg),
362    };
363    Segment::Text {
364        content: format!(" {pct}% ").into(),
365        style: Style::default_style().bg(bg).fg(fg).bold(),
366    }
367}
368
369/// Build a recording-register segment (` REC @r `).
370pub fn recording_segment(reg: char, theme: &StatusTheme) -> Segment {
371    Segment::Text {
372        content: format!(" REC @{reg} ").into(),
373        style: Style::default_style()
374            .bg(theme.recording_bg)
375            .fg(theme.recording_fg)
376            .bold(),
377    }
378}
379
380/// Build a pending-count/operator segment (` {count}{op} `). Returns `None` when empty.
381pub fn pending_segment(
382    count: Option<u64>,
383    op: Option<&str>,
384    theme: &StatusTheme,
385) -> Option<Segment> {
386    let content: Cow<'static, str> = match (count, op) {
387        (Some(n), Some(o)) => format!(" {n}{o} ").into(),
388        (Some(n), None) => format!(" {n} ").into(),
389        (None, Some(o)) => format!(" {o} ").into(),
390        (None, None) => return None,
391    };
392    Some(Segment::Text {
393        content,
394        style: Style::default_style().bg(theme.bg).fg(theme.fg).italic(),
395    })
396}
397
398/// Build a search-count segment (` [idx/total] `).
399pub fn search_count_segment(idx: usize, total: usize, theme: &StatusTheme) -> Segment {
400    Segment::Text {
401        content: format!(" [{idx}/{total}] ").into(),
402        style: Style::default_style().bg(theme.bg).fg(theme.fg),
403    }
404}
405
406/// Build a loading/spinner segment (` ⠋ label `).
407pub fn loading_segment(spinner_frame: &str, label: &str, theme: &StatusTheme) -> Segment {
408    Segment::Text {
409        content: format!(" {spinner_frame} {label} ").into(),
410        style: Style::default_style().bg(theme.bg).fg(theme.fg).italic(),
411    }
412}
413
414// ── Helpers ─────────────────────────────────────────────────────────────────
415
416/// Truncate `filename` so it fits in `avail` display columns, prepending `…`
417/// when truncation occurs. Returns the (possibly truncated) string.
418///
419/// Uses `char_indices()` to find a valid UTF-8 char boundary at or before the
420/// computed byte offset, avoiding panics on multibyte (non-ASCII) filenames.
421pub fn truncate_filename(filename: &str, avail: usize) -> String {
422    if filename.chars().count() <= avail {
423        filename.to_owned()
424    } else if avail <= 1 {
425        String::new()
426    } else {
427        let keep = avail.saturating_sub(1); // one char reserved for '…'
428        // Walk from the end: collect the last `keep` chars' byte start offsets.
429        let start_byte = filename
430            .char_indices()
431            .rev()
432            .nth(keep.saturating_sub(1))
433            .map(|(byte_idx, _)| byte_idx)
434            .unwrap_or(0);
435        format!("\u{2026}{}", &filename[start_byte..])
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    fn test_theme() -> StatusTheme {
444        StatusTheme {
445            bg: Color::rgb(0x2a, 0x32, 0x40),
446            fg: Color::rgb(0xe5, 0xe9, 0xf0),
447            fill_bg: Color::rgb(0x1e, 0x22, 0x2a),
448            mode_normal_bg: Color::rgb(0x5e, 0x81, 0xac),
449            mode_normal_fg: Color::rgb(0x2e, 0x34, 0x40),
450            mode_insert_bg: Color::rgb(0x7e, 0xe7, 0x87),
451            mode_insert_fg: Color::rgb(0x2e, 0x34, 0x40),
452            mode_visual_bg: Color::rgb(0xd0, 0x8e, 0x4b),
453            mode_visual_fg: Color::rgb(0x2e, 0x34, 0x40),
454            dirty_fg: Color::rgb(0xeb, 0xcb, 0x8b),
455            readonly_fg: Color::rgb(0xbf, 0x61, 0x6a),
456            new_file_fg: Color::rgb(0xa3, 0xbe, 0x8c),
457            recording_bg: Color::rgb(0xbf, 0x61, 0x6a),
458            recording_fg: Color::rgb(0x2e, 0x34, 0x40),
459            diag_error_fg: Color::rgb(0xff, 0x00, 0x00),
460            diag_warning_fg: Color::rgb(0xff, 0xc0, 0x00),
461            diag_info_fg: Color::rgb(0x00, 0x7a, 0xff),
462            diag_hint_fg: Color::rgb(0x00, 0xd7, 0xd7),
463        }
464    }
465
466    #[test]
467    fn bar_layout_left_only_fits_width() {
468        let theme = test_theme();
469        let mut bar = Bar {
470            fill_style: Style::default_style().bg(theme.fill_bg).fg(theme.fg),
471            ..Default::default()
472        };
473        bar.left.push(Segment::Text {
474            content: Cow::Borrowed(" NORMAL "),
475            style: Style::default_style(),
476        });
477
478        let segments = bar.layout(40);
479        let total_chars: usize = segments.iter().map(|s| s.len()).sum();
480        assert_eq!(total_chars, 40, "total rendered width must equal bar width");
481    }
482
483    #[test]
484    fn bar_layout_left_plus_right_basic() {
485        let theme = test_theme();
486        let mut bar = Bar {
487            fill_style: Style::default_style().bg(theme.fill_bg).fg(theme.fg),
488            ..Default::default()
489        };
490        bar.left.push(Segment::Text {
491            content: Cow::Borrowed(" NORMAL "),
492            style: Style::default_style(),
493        });
494        bar.right.push(Segment::Text {
495            content: Cow::Borrowed(" 1:1 "),
496            style: Style::default_style(),
497        });
498
499        let segments = bar.layout(40);
500        let total_chars: usize = segments.iter().map(|s| s.len()).sum();
501        assert_eq!(total_chars, 40);
502    }
503
504    #[test]
505    fn bar_layout_left_truncated_with_ellipsis() {
506        let theme = test_theme();
507        let long_name = "some/very/long/path/to/a/deeply/nested/file.rs";
508        let mut bar = Bar {
509            fill_style: Style::default_style().bg(theme.fill_bg).fg(theme.fg),
510            ..Default::default()
511        };
512        bar.left.push(Segment::Text {
513            content: Cow::Borrowed(" NORMAL "),
514            style: Style::default_style(),
515        });
516        bar.left.push(Segment::Text {
517            content: format!(" {long_name} ").into(),
518            style: Style::default_style(),
519        });
520        bar.right.push(Segment::Text {
521            content: Cow::Borrowed(" 1:1 "),
522            style: Style::default_style(),
523        });
524
525        let segments = bar.layout(30);
526        let all_content: String = segments
527            .iter()
528            .map(|s| match s {
529                Segment::Text { content, .. } => content.as_ref(),
530            })
531            .collect();
532        assert!(
533            all_content.contains('\u{2026}'),
534            "truncated segment must contain ellipsis"
535        );
536    }
537
538    #[test]
539    fn bar_layout_right_pinned_to_edge() {
540        let theme = test_theme();
541        let mut bar = Bar {
542            fill_style: Style::default_style().bg(theme.fill_bg).fg(theme.fg),
543            ..Default::default()
544        };
545        bar.left.push(Segment::Text {
546            content: Cow::Borrowed(" NORMAL "),
547            style: Style::default_style(),
548        });
549        bar.right.push(Segment::Text {
550            content: Cow::Borrowed(" 1:1 "),
551            style: Style::default_style(),
552        });
553        bar.right.push(Segment::Text {
554            content: Cow::Borrowed(" 100% "),
555            style: Style::default_style(),
556        });
557
558        let width: u16 = 60;
559        let segments = bar.layout(width);
560        let total_chars: usize = segments.iter().map(|s| s.len()).sum();
561        assert_eq!(
562            total_chars, 60,
563            "right segments must be pinned to the right edge"
564        );
565    }
566
567    #[test]
568    fn mode_segment_normal_uses_normal_bg() {
569        let theme = test_theme();
570        let seg = mode_segment("NORMAL", &theme);
571        match seg {
572            Segment::Text { style, .. } => {
573                assert_eq!(
574                    style.bg,
575                    Some(theme.mode_normal_bg),
576                    "NORMAL mode segment must use mode_normal_bg"
577                );
578            }
579        }
580    }
581
582    #[test]
583    fn mode_segment_insert_uses_insert_bg() {
584        let theme = test_theme();
585        let seg = mode_segment("INSERT", &theme);
586        match seg {
587            Segment::Text { style, .. } => {
588                assert_eq!(style.bg, Some(theme.mode_insert_bg));
589            }
590        }
591    }
592
593    #[test]
594    fn cursor_segment_formats_row_col() {
595        let theme = test_theme();
596        let seg = cursor_segment(42, 10, &theme);
597        match seg {
598            Segment::Text { content, .. } => {
599                assert!(content.contains("43:11"), "cursor segment: {content:?}");
600            }
601        }
602    }
603
604    #[test]
605    fn percent_segment_formats_percent() {
606        let theme = test_theme();
607        let seg = percent_segment(42, 100, ModeKind::Normal, &theme);
608        match seg {
609            Segment::Text { content, .. } => {
610                assert!(content.contains("43%"), "percent segment: {content:?}");
611            }
612        }
613    }
614
615    #[test]
616    fn truncate_filename_short_unchanged() {
617        let s = truncate_filename("foo.rs", 20);
618        assert_eq!(s, "foo.rs");
619    }
620
621    #[test]
622    fn truncate_filename_long_has_ellipsis() {
623        let long = "some/very/long/path/to/a/deeply/nested/file.rs";
624        let s = truncate_filename(long, 10);
625        assert!(s.starts_with('\u{2026}'), "must start with ellipsis: {s:?}");
626        assert!(s.chars().count() <= 10);
627    }
628
629    #[test]
630    fn status_theme_default_is_sensible() {
631        let t = StatusTheme::default();
632        // All RGB channels must be in range (trivially true for u8, but assert
633        // that the struct doesn't have any zero-alpha trap).
634        assert_eq!(t.bg.a, 255, "default bg alpha must be 255");
635        assert_eq!(t.fg.a, 255, "default fg alpha must be 255");
636    }
637
638    #[test]
639    fn status_theme_new_sets_bg_fg() {
640        let bg = Color::rgb(0x10, 0x20, 0x30);
641        let fg = Color::rgb(0xe0, 0xd0, 0xc0);
642        let t = StatusTheme::new(bg, fg);
643        assert_eq!(t.bg, bg);
644        assert_eq!(t.fg, fg);
645        // Other slots come from default — spot-check one.
646        assert_eq!(t.recording_bg, StatusTheme::default().recording_bg);
647    }
648
649    // ── New tests for issue #135 ─────────────────────────────────────────────
650
651    /// Both `[RO]` and `[+]` (dirty marker) appear together in the bar.
652    #[test]
653    fn readonly_and_dirty_both_shown() {
654        let theme = test_theme();
655        let mut bar = Bar {
656            fill_style: Style::default_style().bg(theme.fill_bg).fg(theme.fg),
657            ..Default::default()
658        };
659        bar.left
660            .push(filename_segment("README.md", " [RO]", &theme));
661        // dirty_segment returns Some when dirty=true
662        if let Some(seg) = dirty_segment(true, &theme) {
663            bar.left.push(seg);
664        }
665
666        let segments = bar.layout(60);
667        let all_content: String = segments
668            .iter()
669            .map(|s| match s {
670                Segment::Text { content, .. } => content.as_ref(),
671            })
672            .collect();
673        assert!(
674            all_content.contains("[RO]"),
675            "readonly tag missing: {all_content:?}"
676        );
677        assert!(
678            all_content.contains('\u{25cf}'),
679            "dirty marker (●) missing: {all_content:?}"
680        );
681    }
682
683    /// `percent_segment` with `total_lines = 0` must not panic and should show 0%.
684    #[test]
685    fn percent_segment_empty_buffer_no_panic() {
686        let theme = test_theme();
687        // row=0, total_lines=0: checked_div returns None → 0%
688        let seg = percent_segment(0, 0, ModeKind::Normal, &theme);
689        match seg {
690            Segment::Text { content, .. } => {
691                assert!(
692                    content.contains("0%"),
693                    "expected 0% for empty buffer: {content:?}"
694                );
695            }
696        }
697    }
698
699    /// When right segments alone exceed the bar width, `Bar::layout` must not
700    /// panic and must return segments whose total width equals the requested width.
701    ///
702    /// In the current implementation right segments are always preserved fully;
703    /// when they exceed `width` the spacer collapses to zero and the left side
704    /// gets zero budget. The total width therefore equals `right_len`, which may
705    /// be > `width`. This test asserts the function completes without panicking
706    /// and produces a non-empty segment list.
707    #[test]
708    fn bar_layout_right_alone_exceeds_width_no_panic() {
709        let theme = test_theme();
710        let mut bar = Bar {
711            fill_style: Style::default_style().bg(theme.fill_bg).fg(theme.fg),
712            ..Default::default()
713        };
714        // Right segments total 20 chars, bar width is only 10.
715        bar.right.push(Segment::Text {
716            content: Cow::Borrowed(" 1:1 "),
717            style: Style::default_style(),
718        });
719        bar.right.push(Segment::Text {
720            content: Cow::Borrowed(" 100% "),
721            style: Style::default_style(),
722        });
723
724        // Must not panic.
725        let segments = bar.layout(10);
726        assert!(
727            !segments.is_empty(),
728            "layout must return at least one segment"
729        );
730    }
731
732    /// When `recording_register = Some('q')` the bar contains the recording indicator.
733    #[test]
734    fn recording_segment_shows_register() {
735        let theme = test_theme();
736        let seg = recording_segment('q', &theme);
737        match &seg {
738            Segment::Text { content, style } => {
739                assert!(
740                    content.contains("REC"),
741                    "recording segment must contain REC: {content:?}"
742                );
743                assert!(
744                    content.contains('@'),
745                    "recording segment must contain @: {content:?}"
746                );
747                assert!(
748                    content.contains('q'),
749                    "recording segment must contain register name: {content:?}"
750                );
751                assert_eq!(
752                    style.bg,
753                    Some(theme.recording_bg),
754                    "recording segment must use recording_bg"
755                );
756            }
757        }
758
759        // Verify the segment appears in a bar layout.
760        let mut bar = Bar {
761            fill_style: Style::default_style().bg(theme.fill_bg).fg(theme.fg),
762            ..Default::default()
763        };
764        bar.left.push(seg);
765        let segments = bar.layout(40);
766        let all_content: String = segments
767            .iter()
768            .map(|s| match s {
769                Segment::Text { content, .. } => content.as_ref(),
770            })
771            .collect();
772        assert!(
773            all_content.contains("REC @q"),
774            "recording indicator missing from bar: {all_content:?}"
775        );
776    }
777}