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 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 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 (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 (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")); 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 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}"))); }
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 tabs.next(); for _ in 0..4 {
278 tabs.next(); }
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 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 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")); 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 assert_eq!(truncate("a-long-name", 6, "β¦"), "a-lonβ¦");
375 assert_eq!(truncate("a-long-name", 6, "..."), "a-l...");
376 }
377}