Skip to main content

kimun_notes/components/
rich_row.rs

1//! The shared **rich list row** format every drawer list uses (spec §4):
2//!
3//! ```text
4//! ▤ Auth Flow Meeting              04-08
5//!   attendees: maria, david              ← optional secondary line
6//!   2026-04-08.md                        ← dim italic filename line
7//! ```
8//!
9//! A `RichRow` is a declarative description; `into_list_item` renders it with
10//! the theme's roles. Selection background is applied by the `SearchList`
11//! engine's highlight style — rows only choose foregrounds.
12//!
13//! `meta` currently renders inline after the title; right-aligning it needs
14//! the row width, which the `SearchRow` seam does not carry yet — that pass
15//! lands with the telescope alignment work (Phase 08).
16
17use 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    /// Optional colored date shown before the title as `date · title` (the Ask
30    /// Sources row's journal date; its own style so the date reads distinct
31    /// from the heading). Rendered only when the title is non-empty, so a
32    /// bare-date row leaves no dangling separator.
33    date: Option<(String, Option<Style>)>,
34    /// Dim metadata after the title (count, date, …).
35    meta: Option<String>,
36    /// Optional secondary line with its own style.
37    secondary: Option<(String, Option<Style>)>,
38    /// Dim italic filename line.
39    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    /// A colored date rendered before the title as `date · title`. Dropped when
62    /// the title is empty (no dangling separator on a bare-date row).
63    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    /// Terminal rows this row occupies when rendered.
84    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        // A colored date reads `date · title`; skipped for an empty title so a
101        // bare-date row shows just the date with no dangling separator.
102        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        // Renders without panicking.
146        let _ = RichRow::new("X", "t").meta("42").into_list_item(&theme);
147    }
148
149    /// Render a single RichRow into a TestBackend buffer and return its text.
150    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        // A bare-date row (empty title) must not render a dangling separator.
185        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}