kimun_notes/components/
rich_row.rs1use ratatui::style::{Modifier, Style};
18use ratatui::text::{Line, Span, Text};
19use ratatui::widgets::ListItem;
20
21use crate::settings::themes::Theme;
22
23#[derive(Default)]
24pub struct RichRow {
25 glyph: String,
26 glyph_style: Option<Style>,
27 title: String,
28 title_style: Option<Style>,
29 date: Option<(String, Option<Style>)>,
34 meta: Option<String>,
36 secondary: Option<(String, Option<Style>)>,
38 filename: Option<String>,
40}
41
42impl RichRow {
43 pub fn new(glyph: impl Into<String>, title: impl Into<String>) -> Self {
44 Self {
45 glyph: glyph.into(),
46 title: title.into(),
47 ..Self::default()
48 }
49 }
50
51 pub fn glyph_style(mut self, style: Style) -> Self {
52 self.glyph_style = Some(style);
53 self
54 }
55
56 pub fn title_style(mut self, style: Style) -> Self {
57 self.title_style = Some(style);
58 self
59 }
60
61 pub fn date(mut self, date: impl Into<String>, style: Option<Style>) -> Self {
64 self.date = Some((date.into(), style));
65 self
66 }
67
68 pub fn meta(mut self, meta: impl Into<String>) -> Self {
69 self.meta = Some(meta.into());
70 self
71 }
72
73 pub fn secondary(mut self, text: impl Into<String>, style: Option<Style>) -> Self {
74 self.secondary = Some((text.into(), style));
75 self
76 }
77
78 pub fn filename(mut self, filename: impl Into<String>) -> Self {
79 self.filename = Some(filename.into());
80 self
81 }
82
83 pub fn height(&self) -> u16 {
85 1 + u16::from(self.secondary.is_some()) + u16::from(self.filename.is_some())
86 }
87
88 pub fn into_list_item(self, theme: &Theme) -> ListItem<'static> {
89 let fg = Style::default().fg(theme.fg.to_ratatui());
90 let gray = Style::default().fg(theme.gray.to_ratatui());
91 let secondary_default = Style::default()
92 .fg(theme.fg_secondary.to_ratatui())
93 .add_modifier(Modifier::ITALIC);
94
95 let date_default = Style::default().fg(theme.gray.to_ratatui());
96 let mut main = vec![Span::styled(
97 format!("{} ", self.glyph),
98 self.glyph_style.unwrap_or(fg),
99 )];
100 if let Some((date, style)) = self.date.filter(|_| !self.title.is_empty()) {
103 main.push(Span::styled(
104 format!("{date} \u{00b7} "),
105 style.unwrap_or(date_default),
106 ));
107 }
108 main.push(Span::styled(self.title, self.title_style.unwrap_or(fg)));
109 if let Some(meta) = self.meta {
110 main.push(Span::styled(format!(" {meta}"), gray));
111 }
112
113 let mut lines = vec![Line::from(main)];
114 if let Some((text, style)) = self.secondary {
115 lines.push(Line::from(Span::styled(
116 format!(" {text}"),
117 style.unwrap_or(secondary_default),
118 )));
119 }
120 if let Some(filename) = self.filename {
121 lines.push(Line::from(Span::styled(
122 format!(" {filename}"),
123 secondary_default,
124 )));
125 }
126 ListItem::new(Text::from(lines))
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 #[test]
135 fn height_counts_optional_lines() {
136 let theme = Theme::default();
137 let row = RichRow::new("X", "title");
138 assert_eq!(row.height(), 1);
139 let row = RichRow::new("X", "title").filename("a.md");
140 assert_eq!(row.height(), 2);
141 let row = RichRow::new("X", "title")
142 .secondary("sub", None)
143 .filename("a.md");
144 assert_eq!(row.height(), 3);
145 let _ = RichRow::new("X", "t").meta("42").into_list_item(&theme);
147 }
148
149 fn render_row(row: RichRow, theme: &Theme) -> String {
151 use ratatui::Terminal;
152 use ratatui::backend::TestBackend;
153 use ratatui::widgets::List;
154 let item = row.into_list_item(theme);
155 let mut term = Terminal::new(TestBackend::new(40, 4)).unwrap();
156 term.draw(|f| f.render_widget(List::new(vec![item]), f.area()))
157 .unwrap();
158 let buf = term.backend().buffer().clone();
159 (0..buf.area.height)
160 .map(|y| {
161 (0..buf.area.width)
162 .map(|x| buf[(x, y)].symbol())
163 .collect::<String>()
164 })
165 .collect::<Vec<_>>()
166 .join("\n")
167 }
168
169 #[test]
170 fn date_renders_before_the_title_with_separator() {
171 let theme = Theme::default();
172 let text = render_row(
173 RichRow::new("1", "Afternoon").date("2026-04-08", None),
174 &theme,
175 );
176 assert!(text.contains("2026-04-08"), "date present: {text}");
177 assert!(text.contains('\u{00b7}'), "separator present: {text}");
178 assert!(text.contains("Afternoon"), "heading present: {text}");
179 }
180
181 #[test]
182 fn date_is_dropped_for_an_empty_title() {
183 let theme = Theme::default();
184 let text = render_row(RichRow::new("1", "").date("2026-04-08", None), &theme);
186 assert!(
187 !text.contains('\u{00b7}'),
188 "no separator when the title is empty: {text}"
189 );
190 }
191}