#![forbid(unsafe_code)]
use std::borrow::Cow;
pub use hjkl_theme::Color;
pub use hjkl_theme::Modifiers;
pub use hjkl_theme::StyleSpec as Style;
pub trait StyleExt: Sized {
fn default_style() -> Self;
fn fg(self, fg: Color) -> Self;
fn bg(self, bg: Color) -> Self;
fn bold(self) -> Self;
fn italic(self) -> Self;
}
impl StyleExt for Style {
fn default_style() -> Self {
Self::default()
}
fn fg(self, fg: Color) -> Self {
Self {
fg: Some(fg),
..self
}
}
fn bg(self, bg: Color) -> Self {
Self {
bg: Some(bg),
..self
}
}
fn bold(self) -> Self {
Self {
modifiers: Modifiers {
bold: true,
..self.modifiers
},
..self
}
}
fn italic(self) -> Self {
Self {
modifiers: Modifiers {
italic: true,
..self.modifiers
},
..self
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub enum Segment {
Text {
content: Cow<'static, str>,
style: Style,
},
}
impl Segment {
pub fn len(&self) -> usize {
match self {
Segment::Text { content, .. } => content.chars().count(),
}
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[derive(Debug, Clone, Default)]
pub struct Bar {
pub left: Vec<Segment>,
pub right: Vec<Segment>,
pub fill_style: Style,
}
impl Bar {
pub fn layout(&self, width: u16) -> Vec<Segment> {
let w = width as usize;
let left_len: usize = self.left.iter().map(|s| s.len()).sum();
let right_len: usize = self.right.iter().map(|s| s.len()).sum();
let total = left_len + right_len;
let mut out: Vec<Segment> = Vec::with_capacity(self.left.len() + self.right.len() + 1);
if total <= w {
let spacer_w = w.saturating_sub(total);
out.extend(self.left.iter().cloned());
out.push(Segment::Text {
content: " ".repeat(spacer_w).into(),
style: self.fill_style,
});
out.extend(self.right.iter().cloned());
} else {
let avail_for_left = w.saturating_sub(right_len);
let mut used = 0usize;
for seg in self.left.iter() {
let seg_len = seg.len();
if used + seg_len <= avail_for_left {
out.push(seg.clone());
used += seg_len;
} else {
let remaining = avail_for_left.saturating_sub(used);
if remaining > 1 {
let Segment::Text { content, style } = seg;
let truncated: String =
content.chars().take(remaining.saturating_sub(1)).collect();
out.push(Segment::Text {
content: format!("{truncated}\u{2026}").into(),
style: *style,
});
} else if remaining == 1 {
let Segment::Text { style, .. } = seg;
out.push(Segment::Text {
content: Cow::Borrowed("\u{2026}"),
style: *style,
});
}
break;
}
}
out.push(Segment::Text {
content: Cow::Borrowed(""),
style: self.fill_style,
});
out.extend(self.right.iter().cloned());
}
out
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy)]
pub struct StatusTheme {
pub bg: Color,
pub fg: Color,
pub fill_bg: Color,
pub mode_normal_bg: Color,
pub mode_normal_fg: Color,
pub mode_insert_bg: Color,
pub mode_insert_fg: Color,
pub mode_visual_bg: Color,
pub mode_visual_fg: Color,
pub dirty_fg: Color,
pub readonly_fg: Color,
pub new_file_fg: Color,
pub recording_bg: Color,
pub recording_fg: Color,
pub diag_error_fg: Color,
pub diag_warning_fg: Color,
pub diag_info_fg: Color,
pub diag_hint_fg: Color,
}
impl Default for StatusTheme {
fn default() -> Self {
let grey = Color::rgb(0xaa, 0xaa, 0xaa);
let dark = Color::rgb(0x2e, 0x34, 0x40);
Self {
bg: Color::rgb(0x2a, 0x32, 0x40),
fg: Color::rgb(0xe5, 0xe9, 0xf0),
fill_bg: Color::rgb(0x1e, 0x22, 0x2a),
mode_normal_bg: Color::rgb(0x5e, 0x81, 0xac),
mode_normal_fg: dark,
mode_insert_bg: Color::rgb(0x7e, 0xe7, 0x87),
mode_insert_fg: dark,
mode_visual_bg: Color::rgb(0xd0, 0x8e, 0x4b),
mode_visual_fg: dark,
dirty_fg: Color::rgb(0xeb, 0xcb, 0x8b),
readonly_fg: grey,
new_file_fg: grey,
recording_bg: Color::rgb(0xbf, 0x61, 0x6a),
recording_fg: dark,
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), }
}
}
impl StatusTheme {
pub fn new(bg: Color, fg: Color) -> Self {
Self {
bg,
fg,
..Self::default()
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModeKind {
Normal,
Insert,
Visual,
VisualLine,
VisualBlock,
Replace,
Select,
Operator,
Terminal,
}
impl ModeKind {
pub fn from_label(label: &str) -> Self {
match label {
"INSERT" => ModeKind::Insert,
"REPLACE" => ModeKind::Replace,
"VISUAL" => ModeKind::Visual,
"VISUAL LINE" => ModeKind::VisualLine,
"VISUAL BLOCK" => ModeKind::VisualBlock,
"SELECT" => ModeKind::Select,
"TERMINAL" => ModeKind::Terminal,
_ => ModeKind::Normal,
}
}
}
pub fn mode_segment(label: &str, theme: &StatusTheme) -> Segment {
let kind = ModeKind::from_label(label);
let (bg, fg) = match kind {
ModeKind::Insert => (theme.mode_insert_bg, theme.mode_insert_fg),
ModeKind::Visual | ModeKind::VisualLine | ModeKind::VisualBlock => {
(theme.mode_visual_bg, theme.mode_visual_fg)
}
_ => (theme.mode_normal_bg, theme.mode_normal_fg),
};
Segment::Text {
content: format!(" {label} ").into(),
style: Style::default_style().bg(bg).fg(fg).bold(),
}
}
pub fn filename_segment(name: &str, suffix: &str, theme: &StatusTheme) -> Segment {
let style = Style::default_style().bg(theme.bg).fg(theme.fg);
Segment::Text {
content: format!(" {name}{suffix} ").into(),
style,
}
}
pub fn dirty_segment(dirty: bool, theme: &StatusTheme) -> Option<Segment> {
if dirty {
Some(Segment::Text {
content: Cow::Borrowed(" \u{25cf} "),
style: Style::default_style().bg(theme.bg).fg(theme.dirty_fg),
})
} else {
None
}
}
pub fn cursor_segment(row: usize, col: usize, theme: &StatusTheme) -> Segment {
Segment::Text {
content: format!(" {}:{} ", row + 1, col + 1).into(),
style: Style::default_style().bg(theme.bg).fg(theme.fg),
}
}
pub fn percent_segment(
row: usize,
total_lines: usize,
mode: ModeKind,
theme: &StatusTheme,
) -> Segment {
let pct = ((row + 1) * 100).checked_div(total_lines).unwrap_or(0);
let (bg, fg) = match mode {
ModeKind::Insert => (theme.mode_insert_bg, theme.mode_insert_fg),
ModeKind::Visual | ModeKind::VisualLine | ModeKind::VisualBlock => {
(theme.mode_visual_bg, theme.mode_visual_fg)
}
_ => (theme.mode_normal_bg, theme.mode_normal_fg),
};
Segment::Text {
content: format!(" {pct}% ").into(),
style: Style::default_style().bg(bg).fg(fg).bold(),
}
}
pub fn recording_segment(reg: char, theme: &StatusTheme) -> Segment {
Segment::Text {
content: format!(" REC @{reg} ").into(),
style: Style::default_style()
.bg(theme.recording_bg)
.fg(theme.recording_fg)
.bold(),
}
}
pub fn pending_segment(
count: Option<u64>,
op: Option<&str>,
theme: &StatusTheme,
) -> Option<Segment> {
let content: Cow<'static, str> = match (count, op) {
(Some(n), Some(o)) => format!(" {n}{o} ").into(),
(Some(n), None) => format!(" {n} ").into(),
(None, Some(o)) => format!(" {o} ").into(),
(None, None) => return None,
};
Some(Segment::Text {
content,
style: Style::default_style().bg(theme.bg).fg(theme.fg).italic(),
})
}
pub fn search_count_segment(idx: usize, total: usize, theme: &StatusTheme) -> Segment {
Segment::Text {
content: format!(" [{idx}/{total}] ").into(),
style: Style::default_style().bg(theme.bg).fg(theme.fg),
}
}
pub fn loading_segment(spinner_frame: &str, label: &str, theme: &StatusTheme) -> Segment {
Segment::Text {
content: format!(" {spinner_frame} {label} ").into(),
style: Style::default_style().bg(theme.bg).fg(theme.fg).italic(),
}
}
pub fn truncate_filename(filename: &str, avail: usize) -> String {
if filename.chars().count() <= avail {
filename.to_owned()
} else if avail <= 1 {
String::new()
} else {
let keep = avail.saturating_sub(1); let start_byte = filename
.char_indices()
.rev()
.nth(keep.saturating_sub(1))
.map(|(byte_idx, _)| byte_idx)
.unwrap_or(0);
format!("\u{2026}{}", &filename[start_byte..])
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_theme() -> StatusTheme {
StatusTheme {
bg: Color::rgb(0x2a, 0x32, 0x40),
fg: Color::rgb(0xe5, 0xe9, 0xf0),
fill_bg: Color::rgb(0x1e, 0x22, 0x2a),
mode_normal_bg: Color::rgb(0x5e, 0x81, 0xac),
mode_normal_fg: Color::rgb(0x2e, 0x34, 0x40),
mode_insert_bg: Color::rgb(0x7e, 0xe7, 0x87),
mode_insert_fg: Color::rgb(0x2e, 0x34, 0x40),
mode_visual_bg: Color::rgb(0xd0, 0x8e, 0x4b),
mode_visual_fg: Color::rgb(0x2e, 0x34, 0x40),
dirty_fg: Color::rgb(0xeb, 0xcb, 0x8b),
readonly_fg: Color::rgb(0xbf, 0x61, 0x6a),
new_file_fg: Color::rgb(0xa3, 0xbe, 0x8c),
recording_bg: Color::rgb(0xbf, 0x61, 0x6a),
recording_fg: Color::rgb(0x2e, 0x34, 0x40),
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),
}
}
#[test]
fn bar_layout_left_only_fits_width() {
let theme = test_theme();
let mut bar = Bar {
fill_style: Style::default_style().bg(theme.fill_bg).fg(theme.fg),
..Default::default()
};
bar.left.push(Segment::Text {
content: Cow::Borrowed(" NORMAL "),
style: Style::default_style(),
});
let segments = bar.layout(40);
let total_chars: usize = segments.iter().map(|s| s.len()).sum();
assert_eq!(total_chars, 40, "total rendered width must equal bar width");
}
#[test]
fn bar_layout_left_plus_right_basic() {
let theme = test_theme();
let mut bar = Bar {
fill_style: Style::default_style().bg(theme.fill_bg).fg(theme.fg),
..Default::default()
};
bar.left.push(Segment::Text {
content: Cow::Borrowed(" NORMAL "),
style: Style::default_style(),
});
bar.right.push(Segment::Text {
content: Cow::Borrowed(" 1:1 "),
style: Style::default_style(),
});
let segments = bar.layout(40);
let total_chars: usize = segments.iter().map(|s| s.len()).sum();
assert_eq!(total_chars, 40);
}
#[test]
fn bar_layout_left_truncated_with_ellipsis() {
let theme = test_theme();
let long_name = "some/very/long/path/to/a/deeply/nested/file.rs";
let mut bar = Bar {
fill_style: Style::default_style().bg(theme.fill_bg).fg(theme.fg),
..Default::default()
};
bar.left.push(Segment::Text {
content: Cow::Borrowed(" NORMAL "),
style: Style::default_style(),
});
bar.left.push(Segment::Text {
content: format!(" {long_name} ").into(),
style: Style::default_style(),
});
bar.right.push(Segment::Text {
content: Cow::Borrowed(" 1:1 "),
style: Style::default_style(),
});
let segments = bar.layout(30);
let all_content: String = segments
.iter()
.map(|s| match s {
Segment::Text { content, .. } => content.as_ref(),
})
.collect();
assert!(
all_content.contains('\u{2026}'),
"truncated segment must contain ellipsis"
);
}
#[test]
fn bar_layout_right_pinned_to_edge() {
let theme = test_theme();
let mut bar = Bar {
fill_style: Style::default_style().bg(theme.fill_bg).fg(theme.fg),
..Default::default()
};
bar.left.push(Segment::Text {
content: Cow::Borrowed(" NORMAL "),
style: Style::default_style(),
});
bar.right.push(Segment::Text {
content: Cow::Borrowed(" 1:1 "),
style: Style::default_style(),
});
bar.right.push(Segment::Text {
content: Cow::Borrowed(" 100% "),
style: Style::default_style(),
});
let width: u16 = 60;
let segments = bar.layout(width);
let total_chars: usize = segments.iter().map(|s| s.len()).sum();
assert_eq!(
total_chars, 60,
"right segments must be pinned to the right edge"
);
}
#[test]
fn mode_segment_normal_uses_normal_bg() {
let theme = test_theme();
let seg = mode_segment("NORMAL", &theme);
match seg {
Segment::Text { style, .. } => {
assert_eq!(
style.bg,
Some(theme.mode_normal_bg),
"NORMAL mode segment must use mode_normal_bg"
);
}
}
}
#[test]
fn mode_segment_insert_uses_insert_bg() {
let theme = test_theme();
let seg = mode_segment("INSERT", &theme);
match seg {
Segment::Text { style, .. } => {
assert_eq!(style.bg, Some(theme.mode_insert_bg));
}
}
}
#[test]
fn cursor_segment_formats_row_col() {
let theme = test_theme();
let seg = cursor_segment(42, 10, &theme);
match seg {
Segment::Text { content, .. } => {
assert!(content.contains("43:11"), "cursor segment: {content:?}");
}
}
}
#[test]
fn percent_segment_formats_percent() {
let theme = test_theme();
let seg = percent_segment(42, 100, ModeKind::Normal, &theme);
match seg {
Segment::Text { content, .. } => {
assert!(content.contains("43%"), "percent segment: {content:?}");
}
}
}
#[test]
fn truncate_filename_short_unchanged() {
let s = truncate_filename("foo.rs", 20);
assert_eq!(s, "foo.rs");
}
#[test]
fn truncate_filename_long_has_ellipsis() {
let long = "some/very/long/path/to/a/deeply/nested/file.rs";
let s = truncate_filename(long, 10);
assert!(s.starts_with('\u{2026}'), "must start with ellipsis: {s:?}");
assert!(s.chars().count() <= 10);
}
#[test]
fn status_theme_default_is_sensible() {
let t = StatusTheme::default();
assert_eq!(t.bg.a, 255, "default bg alpha must be 255");
assert_eq!(t.fg.a, 255, "default fg alpha must be 255");
}
#[test]
fn status_theme_new_sets_bg_fg() {
let bg = Color::rgb(0x10, 0x20, 0x30);
let fg = Color::rgb(0xe0, 0xd0, 0xc0);
let t = StatusTheme::new(bg, fg);
assert_eq!(t.bg, bg);
assert_eq!(t.fg, fg);
assert_eq!(t.recording_bg, StatusTheme::default().recording_bg);
}
#[test]
fn readonly_and_dirty_both_shown() {
let theme = test_theme();
let mut bar = Bar {
fill_style: Style::default_style().bg(theme.fill_bg).fg(theme.fg),
..Default::default()
};
bar.left
.push(filename_segment("README.md", " [RO]", &theme));
if let Some(seg) = dirty_segment(true, &theme) {
bar.left.push(seg);
}
let segments = bar.layout(60);
let all_content: String = segments
.iter()
.map(|s| match s {
Segment::Text { content, .. } => content.as_ref(),
})
.collect();
assert!(
all_content.contains("[RO]"),
"readonly tag missing: {all_content:?}"
);
assert!(
all_content.contains('\u{25cf}'),
"dirty marker (●) missing: {all_content:?}"
);
}
#[test]
fn percent_segment_empty_buffer_no_panic() {
let theme = test_theme();
let seg = percent_segment(0, 0, ModeKind::Normal, &theme);
match seg {
Segment::Text { content, .. } => {
assert!(
content.contains("0%"),
"expected 0% for empty buffer: {content:?}"
);
}
}
}
#[test]
fn bar_layout_right_alone_exceeds_width_no_panic() {
let theme = test_theme();
let mut bar = Bar {
fill_style: Style::default_style().bg(theme.fill_bg).fg(theme.fg),
..Default::default()
};
bar.right.push(Segment::Text {
content: Cow::Borrowed(" 1:1 "),
style: Style::default_style(),
});
bar.right.push(Segment::Text {
content: Cow::Borrowed(" 100% "),
style: Style::default_style(),
});
let segments = bar.layout(10);
assert!(
!segments.is_empty(),
"layout must return at least one segment"
);
}
#[test]
fn recording_segment_shows_register() {
let theme = test_theme();
let seg = recording_segment('q', &theme);
match &seg {
Segment::Text { content, style } => {
assert!(
content.contains("REC"),
"recording segment must contain REC: {content:?}"
);
assert!(
content.contains('@'),
"recording segment must contain @: {content:?}"
);
assert!(
content.contains('q'),
"recording segment must contain register name: {content:?}"
);
assert_eq!(
style.bg,
Some(theme.recording_bg),
"recording segment must use recording_bg"
);
}
}
let mut bar = Bar {
fill_style: Style::default_style().bg(theme.fill_bg).fg(theme.fg),
..Default::default()
};
bar.left.push(seg);
let segments = bar.layout(40);
let all_content: String = segments
.iter()
.map(|s| match s {
Segment::Text { content, .. } => content.as_ref(),
})
.collect();
assert!(
all_content.contains("REC @q"),
"recording indicator missing from bar: {all_content:?}"
);
}
}