| 1 | //! Heading: the title of a page, a section or a group. |
| 2 | //! |
| 3 | //! Hierarchy comes from weight and ink, never from color or capitals: page |
| 4 | //! and section titles are bold `Foreground`, a sub-heading is `Muted` and |
| 5 | //! not bold. A section is followed by a blank row (its [`Paint::height`] is |
| 6 | //! two). Trailing meta text (a count, "changed 2 min ago") sits right |
| 7 | //! aligned in `Muted`, and gives way before the title does. Text that does |
| 8 | //! not fit ends in `…`, never `...` (the ASCII form is the honest `...`). |
| 9 | |
| 10 | use std::borrow::Cow; |
| 11 | |
| 12 | use ratatui::{ |
| 13 | buffer::Buffer, |
| 14 | layout::Rect, |
| 15 | style::Modifier, |
| 16 | text::{Line, Span}, |
| 17 | widgets::Widget, |
| 18 | }; |
| 19 | |
| 20 | use crate::{Paint, Role, Theme, text}; |
| 21 | |
| 22 | /// How much a heading heads. |
| 23 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] |
| 24 | pub enum HeadingLevel { |
| 25 | /// The name of the whole view: bold `Foreground`. |
| 26 | #[default] |
| 27 | Page, |
| 28 | /// A section of a view: bold `Foreground`, then a blank row. |
| 29 | Section, |
| 30 | /// A group inside a section: `Muted`, not bold. |
| 31 | Sub, |
| 32 | } |
| 33 | |
| 34 | impl HeadingLevel { |
| 35 | /// Rows a heading of this level takes: its own and the blank row that |
| 36 | /// follows a section. |
| 37 | #[must_use] |
| 38 | pub const fn rows(self) -> u16 { |
| 39 | match self { |
| 40 | HeadingLevel::Section => 2, |
| 41 | HeadingLevel::Page | HeadingLevel::Sub => 1, |
| 42 | } |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | /// A heading with optional right-aligned meta text. |
| 47 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 48 | pub struct Heading<'a> { |
| 49 | pub text: Cow<'a, str>, |
| 50 | pub level: HeadingLevel, |
| 51 | /// Muted text at the right edge: a count, "changed 2 min ago". |
| 52 | pub meta: Option<Cow<'a, str>>, |
| 53 | } |
| 54 | |
| 55 | impl<'a> Heading<'a> { |
| 56 | /// The narrowest row that still shows meta beside a title. |
| 57 | const META_MIN_ROW: u16 = 12; |
| 58 | /// Words a truncated meta must keep to be worth showing. |
| 59 | const META_MIN: usize = 4; |
| 60 | |
| 61 | #[must_use] |
| 62 | pub fn new(text: impl Into<Cow<'a, str>>) -> Self { |
| 63 | Self { |
| 64 | text: text.into(), |
| 65 | level: HeadingLevel::Page, |
| 66 | meta: None, |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | #[must_use] |
| 71 | pub fn level(mut self, level: HeadingLevel) -> Self { |
| 72 | self.level = level; |
| 73 | self |
| 74 | } |
| 75 | |
| 76 | #[must_use] |
| 77 | pub fn page(self) -> Self { |
| 78 | self.level(HeadingLevel::Page) |
| 79 | } |
| 80 | |
| 81 | #[must_use] |
| 82 | pub fn section(self) -> Self { |
| 83 | self.level(HeadingLevel::Section) |
| 84 | } |
| 85 | |
| 86 | #[must_use] |
| 87 | pub fn sub(self) -> Self { |
| 88 | self.level(HeadingLevel::Sub) |
| 89 | } |
| 90 | |
| 91 | #[must_use] |
| 92 | pub fn meta(mut self, meta: impl Into<Cow<'a, str>>) -> Self { |
| 93 | self.meta = Some(meta.into()); |
| 94 | self |
| 95 | } |
| 96 | |
| 97 | /// The title and meta as they will be painted in a row `width` cells |
| 98 | /// wide: the title keeps its room, and meta takes what is left, at most |
| 99 | /// a third of the row. |
| 100 | fn fit(&self, width: u16, ascii: bool) -> (String, Option<String>) { |
| 101 | let width = usize::from(width); |
| 102 | let title = text::display_safe(&self.text); |
| 103 | let meta = self |
| 104 | .meta |
| 105 | .as_deref() |
| 106 | .map(text::display_safe) |
| 107 | .filter(|m| !m.is_empty()); |
| 108 | let Some(meta) = meta else { |
| 109 | return (text::truncate(&title, width, ascii).into_owned(), None); |
| 110 | }; |
| 111 | let gap = 2; |
| 112 | if text::width(&title) + gap + text::width(&meta) <= width { |
| 113 | return (title.into_owned(), Some(meta.into_owned())); |
| 114 | } |
| 115 | let budget = if width >= usize::from(Self::META_MIN_ROW) { |
| 116 | text::width(&meta).min(width / 3) |
| 117 | } else { |
| 118 | 0 |
| 119 | }; |
| 120 | let meta = text::truncate(&meta, budget, ascii); |
| 121 | if budget < Self::META_MIN || meta.is_empty() { |
| 122 | return (text::truncate(&title, width, ascii).into_owned(), None); |
| 123 | } |
| 124 | let room = width.saturating_sub(text::width(&meta) + gap); |
| 125 | ( |
| 126 | text::truncate(&title, room, ascii).into_owned(), |
| 127 | Some(meta.into_owned()), |
| 128 | ) |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | impl Paint for Heading<'_> { |
| 133 | fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 134 | let area = area.intersection(buf.area); |
| 135 | if area.is_empty() { |
| 136 | return; |
| 137 | } |
| 138 | let row = Rect { height: 1, ..area }; |
| 139 | let style = match self.level { |
| 140 | HeadingLevel::Page | HeadingLevel::Section => { |
| 141 | theme.fg(Role::Foreground).add_modifier(Modifier::BOLD) |
| 142 | } |
| 143 | HeadingLevel::Sub => theme.fg(Role::Muted), |
| 144 | }; |
| 145 | let (title, meta) = self.fit(row.width, theme.ascii()); |
| 146 | Line::from(Span::styled(title, style)).render(row, buf); |
| 147 | if let Some(meta) = meta { |
| 148 | Line::from(Span::styled(meta, theme.fg(Role::Muted))) |
| 149 | .right_aligned() |
| 150 | .render(row, buf); |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | fn height(&self, _width: u16, _theme: &Theme) -> u16 { |
| 155 | self.level.rows() |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | #[cfg(test)] |
| 160 | mod tests { |
| 161 | use super::*; |
| 162 | use crate::testing::{Profile, render, text}; |
| 163 | |
| 164 | fn row(heading: &Heading<'_>, width: u16) -> String { |
| 165 | let theme = Profile::DarkTrue.theme(); |
| 166 | let buf = render(width, 1, |area, buf| heading.paint(area, buf, &theme)); |
| 167 | text(&buf) |
| 168 | } |
| 169 | |
| 170 | #[test] |
| 171 | fn meta_sits_at_the_right_edge() { |
| 172 | let h = Heading::new("Agents").section().meta("3 running"); |
| 173 | let line = row(&h, 40); |
| 174 | assert!(line.starts_with("Agents"), "{line:?}"); |
| 175 | assert!(line.ends_with("3 running"), "{line:?}"); |
| 176 | assert_eq!(text::width(&line), 40); |
| 177 | } |
| 178 | |
| 179 | #[test] |
| 180 | fn the_title_keeps_its_room_and_meta_gives_way() { |
| 181 | let h = Heading::new("Background workflows").meta("changed 2 minutes ago"); |
| 182 | let line = row(&h, 34); |
| 183 | assert!(line.starts_with("Background workflows"), "{line:?}"); |
| 184 | assert!(line.contains('…'), "meta is cut with an ellipsis: {line:?}"); |
| 185 | assert!(text::width(&line) <= 34); |
| 186 | // Too narrow for both: the title wins and meta is gone. |
| 187 | let line = row(&h, 10); |
| 188 | assert!(!line.contains("changed"), "{line:?}"); |
| 189 | assert!(line.starts_with("Backgroun"), "{line:?}"); |
| 190 | } |
| 191 | |
| 192 | #[test] |
| 193 | fn a_long_title_ends_in_an_ellipsis_not_three_dots() { |
| 194 | let line = row(&Heading::new("An extremely long page title indeed"), 12); |
| 195 | assert_eq!(text::width(&line), 12); |
| 196 | assert!(line.ends_with('…') && !line.contains("..."), "{line:?}"); |
| 197 | } |
| 198 | |
| 199 | #[test] |
| 200 | fn rows_follow_the_level_and_tiny_areas_do_not_panic() { |
| 201 | let theme = Profile::DarkTrue.theme(); |
| 202 | assert_eq!(Heading::new("A").height(40, &theme), 1); |
| 203 | assert_eq!(Heading::new("A").section().height(40, &theme), 2); |
| 204 | assert_eq!(Heading::new("A").sub().height(40, &theme), 1); |
| 205 | for (w, h) in [(0, 0), (1, 1), (2, 1), (1, 0)] { |
| 206 | let buf = render(w, h, |area, buf| { |
| 207 | Heading::new("Title").meta("meta").paint(area, buf, &theme) |
| 208 | }); |
| 209 | assert_eq!(buf.area.width, w); |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | #[test] |
| 214 | fn bidi_overrides_cannot_reorder_a_title() { |
| 215 | let line = row(&Heading::new("rm -rf ~/\u{202E}txt.exe"), 40); |
| 216 | assert!(!line.contains('\u{202E}')); |
| 217 | } |
| 218 | } |
| 219 |