返回 CodeWhale
heading.rs
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
219 lines RUST