use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::Modifier;
use ratatui::text::Line;
use ratatui::widgets::{Paragraph, Widget};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use super::block::{Element, Text, TextSpan};
use super::config::UI;
use super::controller::settings::theme::groups::{scheme, Groups, HLGroup};
pub enum StatusLineEvent {
Show {
alignment: Alignment,
spans: Vec<TextSpan>,
},
}
struct StatusMessage {
alignment: Alignment,
spans: Vec<TextSpan>,
}
#[derive(Default)]
pub struct StatusLine {
message: Option<StatusMessage>,
}
pub struct StatusLocation {
pub scheme: String,
pub directory: String,
}
#[derive(Default)]
pub struct StatusLineContext {
pub location: Option<StatusLocation>,
pub idle_left: Option<TextSpan>,
pub center: Option<Vec<TextSpan>>,
pub right: Vec<TextSpan>,
}
impl StatusLineContext {
const SEPARATOR: &str = " ยท ";
fn idle_left(&self, width: usize) -> Vec<TextSpan> {
let Some(location) = &self.location else {
return self.idle_left.clone().into_iter().collect();
};
let separator = || TextSpan::new(Self::SEPARATOR, HLGroup::StatuslineSeparator);
let state = self
.idle_left
.iter()
.flat_map(|state| [separator(), state.clone()])
.collect::<Vec<_>>();
let fixed = [&location.scheme, Self::SEPARATOR]
.into_iter()
.chain(state.iter().map(|span| &span.content[..]))
.map(UnicodeWidthStr::width)
.sum::<usize>();
let directory = trimmed(&location.directory, (width / 2).saturating_sub(fixed));
let mut spans = vec![TextSpan::new(
location.scheme.clone(),
HLGroup::StatuslineScheme,
)];
if !directory.is_empty() {
spans.extend([separator(), TextSpan::new(directory, HLGroup::Statusline1)]);
}
spans.extend(state);
spans
}
}
fn trimmed(path: &str, width: usize) -> String {
if path.width() <= width {
return path.to_string();
}
let glyph = UI.glyphs.trimmed.current();
let room = width.saturating_sub(glyph.width());
if room == 0 {
return String::new();
}
let kept = match path
.match_indices('/')
.map(|(slash, _)| &path[slash..])
.find(|rest| rest.width() <= room)
{
Some(rest) => rest.to_string(),
None => {
let mut used = 0;
let mut tail = path
.chars()
.rev()
.take_while(|character| {
used += character.width().unwrap_or(0);
used <= room
})
.collect::<Vec<_>>();
tail.reverse();
tail.into_iter().collect()
}
};
format!("{glyph}{kept}")
}
impl StatusLine {
pub fn handle(&mut self, event: StatusLineEvent) {
self.message = match event {
StatusLineEvent::Show { alignment, spans } => Some(StatusMessage { alignment, spans }),
};
}
fn line(spans: &[TextSpan], groups: &Groups, group: HLGroup) -> Line<'static> {
let mut line = spans.line(groups);
for (source, span) in spans.iter().zip(&mut line.spans) {
if !matches!(
source.kind,
HLGroup::KeymapHint
| HLGroup::StatuslineHintLabel
| HLGroup::StatuslineSeparator
| HLGroup::StatuslineScheme
| HLGroup::StatuslineState
| HLGroup::ViewLabelActive
| HLGroup::ViewLabelInactive
| HLGroup::Muted
| HLGroup::StatuslineError
| HLGroup::StatuslineWarning
) {
span.style = groups.style(group);
}
span.style = span.style.remove_modifier(Modifier::UNDERLINED);
span.style.underline_color = None;
}
line
}
}
impl Element for StatusLine {
type Context = StatusLineContext;
fn render(&self, context: &StatusLineContext, area: Rect, buf: &mut Buffer) {
let area = Rect::new(
area.x.saturating_add(UI.statusline_margin),
area.y,
area.width.saturating_sub(2 * UI.statusline_margin),
area.height,
);
if area.width == 0 {
return;
}
let width = usize::from(area.width);
let (left, center, right) = match &self.message {
None => (
context.idle_left(width),
context.center.clone(),
context.right.clone(),
),
Some(message) => match message.alignment {
Alignment::Left => (message.spans.clone(), context.center.clone(), Vec::new()),
Alignment::Center => (
context.idle_left(width),
Some(message.spans.clone()),
Vec::new(),
),
Alignment::Right => (
context.idle_left(width),
context.center.clone(),
message.spans.clone(),
),
},
};
let groups = scheme();
let left = Self::line(&left, groups, HLGroup::Statusline1);
let center = center.map(|spans| Self::line(&spans, groups, HLGroup::Statusline2));
let right = Self::line(&right, groups, HLGroup::Statusline3);
let center_range = center.as_ref().map(|line| {
let start = width.saturating_sub(line.width()) / 2;
start..start + line.width().min(width)
});
if !left.spans.is_empty()
&& center_range
.as_ref()
.is_none_or(|center| left.width() < center.start)
&& (center_range.is_some()
|| right.spans.is_empty()
|| left.width() + right.width() <= width)
{
Paragraph::new(left).render(area, buf);
}
if !right.spans.is_empty()
&& center_range
.as_ref()
.is_none_or(|center| width.saturating_sub(right.width()) > center.end)
{
Paragraph::new(right)
.alignment(Alignment::Right)
.render(area, buf);
}
if let Some(center) = center {
Paragraph::new(center)
.alignment(Alignment::Center)
.render(area, buf);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::controller::settings::theme::groups::HLGroup;
#[test]
fn rendered_text_never_keeps_an_underline() {
let groups = *scheme();
let context = StatusLineContext {
center: Some(vec![TextSpan::new("status", HLGroup::Documentation)]),
..StatusLineContext::default()
};
let area = Rect::new(0, 0, 20, 1);
let mut buffer = Buffer::empty(area);
StatusLine::default().render(&context, area, &mut buffer);
let cell = &buffer[(7, 0)];
assert_eq!(cell.fg, groups.style(HLGroup::Statusline2).fg.unwrap());
assert!(!cell.modifier.contains(Modifier::UNDERLINED));
assert_eq!(cell.underline_color, ratatui::style::Color::Reset);
}
fn left_side(directory: &str, width: u16) -> String {
let context = StatusLineContext {
location: Some(StatusLocation {
scheme: "everforest".into(),
directory: directory.into(),
}),
..StatusLineContext::default()
};
let area = Rect::new(0, 0, width, 1);
let mut buffer = Buffer::empty(area);
StatusLine::default().render(&context, area, &mut buffer);
(0..width)
.map(|x| buffer[(x, 0)].symbol())
.collect::<String>()
.trim()
.to_string()
}
#[test]
fn the_left_side_names_the_scheme_and_directory() {
assert_eq!(
left_side("~/m2/spectral-m2", 80),
format!("everforest{}~/m2/spectral-m2", StatusLineContext::SEPARATOR)
);
}
#[test]
fn a_long_directory_loses_its_leading_components_to_stay_within_half_the_line() {
let width = 60;
let shown = left_side("~/projects/algebra/m2/spectral-m2", width);
let inner = usize::from(width - 2 * UI.statusline_margin);
assert!(shown.width() <= inner / 2, "{shown}");
assert!(shown.starts_with("everforest"));
assert!(
shown.ends_with(&format!("{}/spectral-m2", UI.glyphs.trimmed.current())),
"{shown}"
);
}
#[test]
fn trimming_keeps_whole_trailing_components_then_the_last_ones_tail() {
let glyph = UI.glyphs.trimmed.current();
assert_eq!(trimmed("~/a/bc", 10), "~/a/bc");
assert_eq!(
trimmed("~/abc/de", 3 + glyph.width()),
format!("{glyph}/de")
);
assert_eq!(
trimmed("~/abcdef", 3 + glyph.width()),
format!("{glyph}def")
);
assert_eq!(trimmed("~/abcdef", glyph.width()), "");
}
#[test]
fn key_hints_keep_their_accent_while_labels_stay_muted() {
let groups = Groups::default();
let line = StatusLine::line(
&[
TextSpan::new("ctrl+/ ", HLGroup::KeymapHint),
TextSpan::new("commands", HLGroup::Muted),
],
&groups,
HLGroup::Statusline3,
);
assert_eq!(line.spans[0].style.fg, groups.style(HLGroup::KeymapHint).fg);
assert_eq!(line.spans[1].style.fg, groups.style(HLGroup::Muted).fg);
}
}