1#![forbid(unsafe_code)]
10
11use std::borrow::Cow;
12
13pub use hjkl_theme::Color;
19pub use hjkl_theme::Modifiers;
20pub use hjkl_theme::StyleSpec as Style;
22
23pub trait StyleExt: Sized {
30 fn default_style() -> Self;
32 fn fg(self, fg: Color) -> Self;
34 fn bg(self, bg: Color) -> Self;
36 fn bold(self) -> Self;
38 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#[non_exhaustive]
84#[derive(Debug, Clone)]
85pub enum Segment {
86 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#[derive(Debug, Clone, Default)]
114pub struct Bar {
115 pub left: Vec<Segment>,
116 pub right: Vec<Segment>,
117 pub fill_style: Style,
119}
120
121impl Bar {
122 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 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 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 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 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#[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 pub diag_error_fg: Color,
211 pub diag_warning_fg: Color,
213 pub diag_info_fg: Color,
215 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 diag_error_fg: Color::rgb(0xff, 0x00, 0x00), diag_warning_fg: Color::rgb(0xff, 0xc0, 0x00), diag_info_fg: Color::rgb(0x00, 0x7a, 0xff), diag_hint_fg: Color::rgb(0x00, 0xd7, 0xd7), }
244 }
245}
246
247impl StatusTheme {
248 pub fn new(bg: Color, fg: Color) -> Self {
251 Self {
252 bg,
253 fg,
254 ..Self::default()
255 }
256 }
257}
258
259#[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 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
296pub 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
314pub 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
323pub 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
335pub 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
343pub 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
369pub 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
380pub 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
398pub 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
406pub 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
414pub 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); 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 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 assert_eq!(t.recording_bg, StatusTheme::default().recording_bg);
647 }
648
649 #[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 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 #[test]
685 fn percent_segment_empty_buffer_no_panic() {
686 let theme = test_theme();
687 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 #[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 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 let segments = bar.layout(10);
726 assert!(
727 !segments.is_empty(),
728 "layout must return at least one segment"
729 );
730 }
731
732 #[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 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}