Skip to main content

basalt_tui/
header.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::{Constraint, Layout, Rect},
4    style::{Color, Style, Stylize},
5    text::{Line, Span},
6    widgets::{Block, Widget},
7};
8use unicode_width::UnicodeWidthStr;
9
10use crate::{
11    config::{symbol::Preset, Symbols, Theme},
12    tabs::Tabs,
13};
14
15pub struct Header<'a, 'b> {
16    symbols: &'a Symbols,
17    theme: &'a Theme,
18    tabs: &'a Tabs<'b>,
19}
20
21const MAX_TAB_WIDTH: usize = 24;
22const MIN_TAB_WIDTH: usize = 12;
23
24fn truncate(label: &str, max_width: usize, ellipsis: &str) -> String {
25    if label.width() <= max_width {
26        return label.to_string();
27    }
28
29    // Leave room for the ellipsis
30    let max_width = max_width.saturating_sub(ellipsis.width());
31
32    let mut truncated = label
33        .char_indices()
34        .take_while(|(i, _)| *i < max_width)
35        .map(|(_, c)| c)
36        .collect::<String>();
37
38    truncated.push_str(ellipsis);
39    truncated
40}
41
42impl<'a, 'b> Header<'a, 'b> {
43    pub fn new(symbols: &'a Symbols, theme: &'a Theme, tabs: &'a Tabs<'b>) -> Self {
44        Self {
45            symbols,
46            theme,
47            tabs,
48        }
49    }
50}
51
52impl Widget for Header<'_, '_> {
53    fn render(self, area: Rect, buf: &mut Buffer) {
54        Block::new()
55            .style(Style::new().bg(self.theme.background))
56            .render(area, buf);
57
58        let brand = Line::from(Span::from(" β‹…π•­π–†π–˜π–†π–‘π–™β‹… ").fg(self.theme.accent).bold());
59
60        let brand_width = (brand.width() as u16).min(area.width);
61        let [brand_area, tabs_area] =
62            Layout::horizontal([Constraint::Length(brand_width), Constraint::Fill(1)]).areas(area);
63        brand.render(brand_area, buf);
64
65        // A theme that owns a real background (any colour scheme) gets the
66        // bufferline style: an accent left bar and a raised surface highlight.
67        // The default theme keeps the terminal palette, so it renders the
68        // classic reversed tabs instead.
69        let themed = self.theme.background != Color::Reset;
70
71        let unicode = self.symbols.preset != Preset::Ascii;
72        let (separator, active_bar, modified_glyph, ellipsis) = if unicode {
73            ("▍", "β–Ž", "●", "…")
74        } else {
75            ("|", "|", "*", "...")
76        };
77
78        let titles = self.tabs.titles();
79        if titles.is_empty() {
80            return;
81        }
82
83        let available = tabs_area.width as usize;
84        let tab_width = (available / titles.len()).clamp(MIN_TAB_WIDTH, MAX_TAB_WIDTH);
85        let inner = tab_width.saturating_sub(1);
86
87        let background = self.theme.background;
88        let muted = self.theme.muted;
89        let text = self.theme.text;
90        let accent = self.theme.accent;
91        let highlight = self.theme.code_bg;
92
93        let mut tabs: Vec<Vec<Span>> = Vec::new();
94        let mut active_index = 0;
95        for (index, (name, active, modified)) in titles.iter().enumerate() {
96            let suffix = if *modified {
97                format!(" {modified_glyph}")
98            } else {
99                String::new()
100            };
101            let name = truncate(name, inner.saturating_sub(suffix.width()), ellipsis);
102            let padding = inner.saturating_sub(name.width() + suffix.width());
103            let left = padding / 2;
104            let right = padding - left;
105            let lead = if index > 0 { separator } else { " " };
106
107            if *active {
108                active_index = index;
109            }
110
111            let tab = match (themed, *active) {
112                // Bufferline: accent left bar + raised surface + brighter text.
113                (true, true) => vec![
114                    Span::from(active_bar).fg(accent).bg(highlight),
115                    Span::from(" ".repeat(left)).bg(highlight),
116                    Span::from(name).fg(text).bg(highlight).bold(),
117                    Span::from(suffix).fg(accent).bg(highlight),
118                    Span::from(" ".repeat(right)).bg(highlight),
119                ],
120                (true, false) => vec![
121                    Span::from(lead).fg(muted),
122                    Span::from(" ".repeat(left)),
123                    Span::from(name).fg(muted),
124                    Span::from(suffix).fg(muted),
125                    Span::from(" ".repeat(right)),
126                ],
127                // Original reversed look for the terminal-default theme.
128                (false, true) => vec![
129                    Span::from(lead).bg(background).reversed(),
130                    Span::from(" ".repeat(left)).reversed(),
131                    Span::from(name).bold().reversed(),
132                    Span::from(suffix).reversed(),
133                    Span::from(" ".repeat(right)).reversed(),
134                ],
135                (false, false) => vec![
136                    Span::from(lead).fg(muted).reversed(),
137                    Span::from(" ".repeat(left)).bg(muted),
138                    Span::from(name).fg(text).reversed().fg(muted),
139                    Span::from(suffix).fg(text).reversed().fg(muted),
140                    Span::from(" ".repeat(right)).bg(muted),
141                ],
142            };
143            tabs.push(tab);
144        }
145
146        let fit_count = (available / tab_width).clamp(1, tabs.len());
147        let start = active_index.saturating_sub(fit_count / 2);
148        let end = (start + fit_count).min(tabs.len());
149        let start = end.saturating_sub(fit_count);
150
151        let visible: Vec<Span> = tabs[start..end]
152            .iter()
153            .flat_map(|spans| spans.iter().cloned())
154            .collect();
155        Line::from(visible).render(tabs_area, buf);
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use std::path::PathBuf;
162
163    use super::*;
164    use crate::{
165        app::SelectedNote, config::Symbols, note_editor::state::NoteEditorState, tabs::Tab,
166    };
167
168    fn tab(name: &str) -> Tab<'static> {
169        tab_in("", name)
170    }
171
172    fn tab_in(dir: &str, name: &str) -> Tab<'static> {
173        let path = PathBuf::from(format!("/vault/{dir}/{name}.md"));
174        let editor = NoteEditorState::new("", name, &path, &Symbols::unicode());
175        Tab {
176            note: SelectedNote::new(name, &path, ""),
177            editor,
178        }
179    }
180
181    #[test]
182    fn header_highlights_active_tab() {
183        use ratatui::{
184            backend::TestBackend,
185            style::{Color, Modifier},
186            Terminal,
187        };
188
189        let mut tabs = Tabs::default();
190        tabs.open(tab("alpha"));
191        tabs.open(tab("beta")); // active
192
193        let symbols = Symbols::unicode();
194        let mut terminal = Terminal::new(TestBackend::new(60, 1)).unwrap();
195        terminal
196            .draw(|frame| {
197                Header::new(&symbols, &Theme::default(), &tabs)
198                    .render(frame.area(), frame.buffer_mut())
199            })
200            .unwrap();
201
202        let buffer = terminal.backend().buffer();
203        type CellStyle = (Color, Color, Modifier);
204        let cells: Vec<(String, CellStyle)> = (0..60)
205            .map(|x| {
206                let cell = buffer.cell((x, 0)).unwrap();
207                (cell.symbol().to_string(), (cell.fg, cell.bg, cell.modifier))
208            })
209            .collect();
210        let row: String = cells.iter().map(|(symbol, _)| symbol.as_str()).collect();
211        assert!(row.contains("alpha") && row.contains("beta"), "got {row:?}");
212
213        // The active tab renders in a visibly different style from the inactive
214        // ones. Names are ASCII: one cell per char.
215        let style_of = |needle: &str| {
216            let start = cells
217                .windows(needle.len())
218                .position(|window| {
219                    window
220                        .iter()
221                        .map(|(symbol, _)| symbol.as_str())
222                        .collect::<String>()
223                        == needle
224                })
225                .expect("tab name present");
226            cells[start].1
227        };
228        assert_ne!(
229            style_of("beta"),
230            style_of("alpha"),
231            "active tab must render distinctly from inactive tabs"
232        );
233    }
234
235    #[test]
236    fn header_scrolls_active_tab_into_view() {
237        use ratatui::{backend::TestBackend, Terminal};
238
239        let mut tabs = Tabs::default();
240        for index in 0..8 {
241            tabs.open(tab(&format!("note{index}"))); // last opened stays active
242        }
243
244        let symbols = Symbols::unicode();
245        let mut terminal = Terminal::new(TestBackend::new(30, 1)).unwrap();
246        terminal
247            .draw(|frame| {
248                Header::new(&symbols, &Theme::default(), &tabs)
249                    .render(frame.area(), frame.buffer_mut())
250            })
251            .unwrap();
252
253        let buffer = terminal.backend().buffer();
254        let row: String = (0..30)
255            .map(|x| buffer.cell((x, 0)).unwrap().symbol())
256            .collect();
257        assert!(
258            row.contains("note7"),
259            "active tab scrolled into view, got {row:?}"
260        );
261        assert!(
262            !row.contains("note0"),
263            "earlier tabs scrolled off, got {row:?}"
264        );
265    }
266
267    #[test]
268    fn header_keeps_middle_active_tab_whole() {
269        use ratatui::{backend::TestBackend, Terminal};
270
271        let mut tabs = Tabs::default();
272        for index in 0..8 {
273            tabs.open(tab(&format!("note{index}")));
274        }
275        // Focus a middle tab so tabs overflow on both sides.
276        tabs.next(); // wraps 7 -> 0
277        for _ in 0..4 {
278            tabs.next(); // 0 -> 4
279        }
280
281        let symbols = Symbols::unicode();
282        let mut terminal = Terminal::new(TestBackend::new(30, 1)).unwrap();
283        terminal
284            .draw(|frame| {
285                Header::new(&symbols, &Theme::default(), &tabs)
286                    .render(frame.area(), frame.buffer_mut())
287            })
288            .unwrap();
289
290        let buffer = terminal.backend().buffer();
291        let row: String = (0..30)
292            .map(|x| buffer.cell((x, 0)).unwrap().symbol())
293            .collect();
294        assert!(
295            row.contains("note4"),
296            "active tab is shown whole, got {row:?}"
297        );
298    }
299
300    #[test]
301    fn tabs_render_at_uniform_width() {
302        use ratatui::{backend::TestBackend, Terminal};
303
304        let mut tabs = Tabs::default();
305        tabs.open(tab("x"));
306        tabs.open(tab("a-much-longer-name"));
307        tabs.open(tab("y"));
308
309        let symbols = Symbols::unicode();
310        let mut terminal = Terminal::new(TestBackend::new(120, 1)).unwrap();
311        terminal
312            .draw(|frame| {
313                Header::new(&symbols, &Theme::default(), &tabs)
314                    .render(frame.area(), frame.buffer_mut())
315            })
316            .unwrap();
317
318        // Separators sit at the start of every tab but the first, so the gap
319        // between them is exactly one tab width β€” the same for every tab.
320        let buffer = terminal.backend().buffer();
321        let separators: Vec<usize> = (0..120)
322            .filter(|&x| buffer.cell((x, 0)).unwrap().symbol() == "▍")
323            .map(|x| x as usize)
324            .collect();
325        assert_eq!(separators.len(), 2, "separators between the three tabs");
326        assert_eq!(
327            separators[1] - separators[0],
328            MAX_TAB_WIDTH,
329            "tabs are the same (max) width regardless of name length"
330        );
331    }
332
333    #[test]
334    fn themed_active_tab_uses_highlight_background() {
335        use ratatui::{backend::TestBackend, style::Color, Terminal};
336
337        // A theme that owns a background switches the tabs to the bufferline
338        // style: the active tab paints its raised `code_bg` surface.
339        let highlight = Color::Rgb(10, 20, 30);
340        let theme = Theme {
341            background: Color::Rgb(1, 2, 3),
342            code_bg: highlight,
343            accent: Color::Rgb(200, 100, 50),
344            ..Theme::default()
345        };
346
347        let mut tabs = Tabs::default();
348        tabs.open(tab("alpha"));
349        tabs.open(tab("beta")); // active
350
351        let symbols = Symbols::unicode();
352        let mut terminal = Terminal::new(TestBackend::new(60, 1)).unwrap();
353        terminal
354            .draw(|frame| {
355                Header::new(&symbols, &theme, &tabs).render(frame.area(), frame.buffer_mut())
356            })
357            .unwrap();
358
359        let buffer = terminal.backend().buffer();
360        let active_on_highlight = (0..60).any(|x| {
361            let cell = buffer.cell((x, 0)).unwrap();
362            cell.symbol() == "b" && cell.bg == highlight
363        });
364        assert!(
365            active_on_highlight,
366            "themed active tab renders on the code_bg highlight"
367        );
368    }
369
370    #[test]
371    fn truncate_reserves_room_for_the_ellipsis() {
372        assert_eq!(truncate("short", 10, "…"), "short");
373        // The unicode ellipsis is one column; the ASCII fallback is three.
374        assert_eq!(truncate("a-long-name", 6, "…"), "a-lon…");
375        assert_eq!(truncate("a-long-name", 6, "..."), "a-l...");
376    }
377}