use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::ListItem;
use crate::settings::themes::Theme;
#[derive(Default)]
pub struct RichRow {
glyph: String,
glyph_style: Option<Style>,
title: String,
title_style: Option<Style>,
date: Option<(String, Option<Style>)>,
meta: Option<String>,
secondary: Option<(String, Option<Style>)>,
filename: Option<String>,
}
impl RichRow {
pub fn new(glyph: impl Into<String>, title: impl Into<String>) -> Self {
Self {
glyph: glyph.into(),
title: title.into(),
..Self::default()
}
}
pub fn glyph_style(mut self, style: Style) -> Self {
self.glyph_style = Some(style);
self
}
pub fn title_style(mut self, style: Style) -> Self {
self.title_style = Some(style);
self
}
pub fn date(mut self, date: impl Into<String>, style: Option<Style>) -> Self {
self.date = Some((date.into(), style));
self
}
pub fn meta(mut self, meta: impl Into<String>) -> Self {
self.meta = Some(meta.into());
self
}
pub fn secondary(mut self, text: impl Into<String>, style: Option<Style>) -> Self {
self.secondary = Some((text.into(), style));
self
}
pub fn filename(mut self, filename: impl Into<String>) -> Self {
self.filename = Some(filename.into());
self
}
pub fn height(&self) -> u16 {
1 + u16::from(self.secondary.is_some()) + u16::from(self.filename.is_some())
}
pub fn into_list_item(self, theme: &Theme) -> ListItem<'static> {
let fg = Style::default().fg(theme.fg.to_ratatui());
let gray = Style::default().fg(theme.gray.to_ratatui());
let secondary_default = Style::default()
.fg(theme.fg_secondary.to_ratatui())
.add_modifier(Modifier::ITALIC);
let date_default = Style::default().fg(theme.gray.to_ratatui());
let mut main = vec![Span::styled(
format!("{} ", self.glyph),
self.glyph_style.unwrap_or(fg),
)];
if let Some((date, style)) = self.date.filter(|_| !self.title.is_empty()) {
main.push(Span::styled(
format!("{date} \u{00b7} "),
style.unwrap_or(date_default),
));
}
main.push(Span::styled(self.title, self.title_style.unwrap_or(fg)));
if let Some(meta) = self.meta {
main.push(Span::styled(format!(" {meta}"), gray));
}
let mut lines = vec![Line::from(main)];
if let Some((text, style)) = self.secondary {
lines.push(Line::from(Span::styled(
format!(" {text}"),
style.unwrap_or(secondary_default),
)));
}
if let Some(filename) = self.filename {
lines.push(Line::from(Span::styled(
format!(" {filename}"),
secondary_default,
)));
}
ListItem::new(Text::from(lines))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn height_counts_optional_lines() {
let theme = Theme::default();
let row = RichRow::new("X", "title");
assert_eq!(row.height(), 1);
let row = RichRow::new("X", "title").filename("a.md");
assert_eq!(row.height(), 2);
let row = RichRow::new("X", "title")
.secondary("sub", None)
.filename("a.md");
assert_eq!(row.height(), 3);
let _ = RichRow::new("X", "t").meta("42").into_list_item(&theme);
}
fn render_row(row: RichRow, theme: &Theme) -> String {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::widgets::List;
let item = row.into_list_item(theme);
let mut term = Terminal::new(TestBackend::new(40, 4)).unwrap();
term.draw(|f| f.render_widget(List::new(vec![item]), f.area()))
.unwrap();
let buf = term.backend().buffer().clone();
(0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn date_renders_before_the_title_with_separator() {
let theme = Theme::default();
let text = render_row(
RichRow::new("1", "Afternoon").date("2026-04-08", None),
&theme,
);
assert!(text.contains("2026-04-08"), "date present: {text}");
assert!(text.contains('\u{00b7}'), "separator present: {text}");
assert!(text.contains("Afternoon"), "heading present: {text}");
}
#[test]
fn date_is_dropped_for_an_empty_title() {
let theme = Theme::default();
let text = render_row(RichRow::new("1", "").date("2026-04-08", None), &theme);
assert!(
!text.contains('\u{00b7}'),
"no separator when the title is empty: {text}"
);
}
}