Skip to main content

pito_tui/
head.rs

1use ratatui::{buffer::Buffer, layout::Rect, style::Style};
2use unicode_segmentation::UnicodeSegmentation;
3
4use crate::label::{Label, Span};
5use crate::text::cells;
6
7const RULE: &str = "─";
8const ELLIPSIS: &str = "…";
9const SIDE_MIN: u16 = 6;
10const GROUP_GAP: u16 = 2;
11const SECTION_GAP: u16 = 0;
12const TAIL_GAP: u16 = 2;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum Row {
17    Title,
18    Groups,
19    Caption,
20    Sections,
21    Facts,
22    Crumbs,
23    Blank,
24}
25
26#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
27#[non_exhaustive]
28pub enum Numbers {
29    #[default]
30    Across,
31    InGroup,
32    Off,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36#[non_exhaustive]
37pub struct Head {
38    pub rows: Vec<Row>,
39    pub numbers: Numbers,
40    pub lone: bool,
41}
42
43impl Head {
44    pub fn new(rows: impl IntoIterator<Item = Row>) -> Self {
45        Head {
46            rows: rows.into_iter().collect(),
47            numbers: Numbers::Across,
48            lone: false,
49        }
50    }
51
52    pub fn numbers(mut self, numbers: Numbers) -> Self {
53        self.numbers = numbers;
54        self
55    }
56
57    pub fn lone(mut self, lone: bool) -> Self {
58        self.lone = lone;
59        self
60    }
61}
62
63impl Default for Head {
64    fn default() -> Self {
65        Head::new([
66            Row::Title,
67            Row::Groups,
68            Row::Sections,
69            Row::Facts,
70            Row::Crumbs,
71        ])
72    }
73}
74
75#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
76#[non_exhaustive]
77pub enum Focus {
78    #[default]
79    Content,
80    Groups,
81    Sections,
82    Slot,
83}
84
85fn width(text: &str) -> u16 {
86    u16::try_from(cells(text)).unwrap_or(u16::MAX)
87}
88
89fn spans_width<T: AsRef<str>>(spans: &[(T, Style)]) -> u16 {
90    spans.iter().fold(0u16, |total, (text, _)| {
91        total.saturating_add(width(text.as_ref()))
92    })
93}
94
95fn digits(number: usize) -> String {
96    let shown = if number == 10 { 0 } else { number };
97    shown.to_string()
98}
99
100pub(crate) struct Pen<'a> {
101    buf: &'a mut Buffer,
102    pub(crate) x: u16,
103    y: u16,
104    right: u16,
105}
106
107impl<'a> Pen<'a> {
108    pub(crate) fn new(buf: &'a mut Buffer, line: Rect, x: u16) -> Option<Self> {
109        let area = line.intersection(buf.area);
110        if area.is_empty() {
111            return None;
112        }
113        Some(Pen {
114            buf,
115            x: x.clamp(area.left(), area.right()),
116            y: area.y,
117            right: area.right(),
118        })
119    }
120
121    fn room(&self) -> u16 {
122        self.right.saturating_sub(self.x)
123    }
124
125    pub(crate) fn put(&mut self, text: &str, style: Style) {
126        self.putn(text, style, self.room());
127    }
128
129    fn putn(&mut self, text: &str, style: Style, room: u16) {
130        let room = room.min(self.room());
131        if room == 0 || text.is_empty() {
132            return;
133        }
134        let (end, _) = self
135            .buf
136            .set_stringn(self.x, self.y, text, usize::from(room), style);
137        self.x = end;
138    }
139
140    pub(crate) fn fill_to(&mut self, to: u16, symbol: &str, style: Style) {
141        let to = to.min(self.right);
142        while self.x < to {
143            self.buf[(self.x, self.y)]
144                .set_symbol(symbol)
145                .set_style(style);
146            self.x += 1;
147        }
148    }
149
150    pub(crate) fn fill(&mut self, symbol: &str, style: Style) {
151        self.fill_to(self.right, symbol, style);
152    }
153
154    fn spans<T: AsRef<str>>(&mut self, spans: &[(T, Style)], base: Style, room: u16) {
155        let room = room.min(self.room());
156        if spans_width(spans) <= room {
157            for (text, style) in spans {
158                self.put(text.as_ref(), base.patch(*style));
159            }
160            return;
161        }
162        if room == 0 {
163            return;
164        }
165        let stop = self.x + room - 1;
166        let mut last = base;
167        for (text, style) in spans {
168            let style = base.patch(*style);
169            if self.x >= stop {
170                break;
171            }
172            last = style;
173            self.putn(text.as_ref(), style, stop - self.x);
174        }
175        self.x = self.x.min(stop);
176        self.put(ELLIPSIS, last);
177    }
178}
179
180fn label<T: AsRef<str>>(pen: &mut Pen, spans: &[(T, Style)], base: Style, room: u16) {
181    if spans.is_empty() {
182        return;
183    }
184    let first = base.patch(spans[0].1);
185    let last = base.patch(spans[spans.len() - 1].1);
186    pen.put(" ", first);
187    pen.spans(spans, base, room);
188    pen.put(" ", last);
189}
190
191pub(crate) struct Rule<'a> {
192    pub(crate) rule: Style,
193    pub(crate) base: Style,
194    pub(crate) middle: &'a [Span],
195    pub(crate) left: &'a [(String, Style)],
196    pub(crate) right: &'a [(String, Style)],
197}
198
199impl Rule<'_> {
200    pub(crate) fn draw(&self, buf: &mut Buffer, line: Rect) {
201        let Some(mut pen) = Pen::new(buf, line, line.x) else {
202            return;
203        };
204        let wide = spans_width(self.middle).saturating_add(2).min(line.width);
205        let start = line.x.saturating_add((line.width - wide) / 2);
206        let side = start.saturating_sub(line.x).saturating_sub(2);
207        let sides = side >= SIDE_MIN;
208        let present = |parts: &[(String, Style)]| spans_width(parts) > 0;
209        if sides && present(self.left) {
210            pen.fill_to(line.x.saturating_add(1), RULE, self.rule);
211            label(&mut pen, self.left, Style::new(), side - 2);
212        }
213        pen.fill_to(start, RULE, self.rule);
214        if spans_width(self.middle) > 0 {
215            label(&mut pen, self.middle, self.base, wide.saturating_sub(2));
216        }
217        if sides && present(self.right) {
218            let wide = spans_width(self.right).min(side - 2).saturating_add(2);
219            pen.fill_to(line.right().saturating_sub(1 + wide), RULE, self.rule);
220            label(&mut pen, self.right, Style::new(), wide - 2);
221        }
222        pen.fill(RULE, self.rule);
223    }
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub(crate) enum Kind {
228    Groups,
229    Sections,
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233enum Form {
234    Full,
235    Short,
236    Lone,
237    Bare,
238}
239
240const FORMS: [Form; 4] = [Form::Full, Form::Short, Form::Lone, Form::Bare];
241
242#[derive(Debug, Clone, Copy)]
243pub(crate) struct Item<'a> {
244    pub(crate) label: &'a Label,
245    pub(crate) number: Option<usize>,
246    pub(crate) on: bool,
247}
248
249fn first(spans: &[Span]) -> Option<(&str, Style)> {
250    let (text, style) = spans.iter().find(|(text, _)| !text.is_empty())?;
251    Some((text.graphemes(true).next()?, *style))
252}
253
254enum Body<'a> {
255    Spans(&'a [Span]),
256    Glyph(Option<(&'a str, Style)>),
257}
258
259impl Body<'_> {
260    fn width(&self) -> u16 {
261        match self {
262            Body::Spans(spans) => spans_width(spans),
263            Body::Glyph(glyph) => glyph.map_or(0, |(text, _)| width(text)),
264        }
265    }
266}
267
268impl<'a> Item<'a> {
269    fn body(&self, kind: Kind, form: Form) -> Body<'a> {
270        let short = self.label.short_spans_or_full();
271        let initial = || Body::Glyph(first(short));
272        let numbered = self.number.is_some() && kind == Kind::Sections;
273        match form {
274            Form::Full => Body::Spans(self.label.full()),
275            Form::Short => Body::Spans(short),
276            Form::Lone if self.on => Body::Spans(short),
277            Form::Lone | Form::Bare if numbered => Body::Glyph(None),
278            Form::Lone | Form::Bare => initial(),
279        }
280    }
281
282    fn width(&self, kind: Kind, form: Form) -> u16 {
283        let body = self.body(kind, form).width();
284        let number = self.number.map_or(0, |number| {
285            width(&digits(number)).saturating_add(u16::from(body > 0))
286        });
287        number.saturating_add(2).saturating_add(body)
288    }
289
290    fn draw(&self, pen: &mut Pen, kind: Kind, form: Form, style: Style) {
291        let body = self.body(kind, form);
292        pen.put(" ", style);
293        if let Some(number) = self.number {
294            pen.put(&digits(number), style);
295            if body.width() > 0 {
296                pen.put(" ", style);
297            }
298        }
299        match body {
300            Body::Spans(spans) => {
301                for (text, span) in spans {
302                    pen.put(text, style.patch(*span));
303                }
304            }
305            Body::Glyph(Some((text, span))) => pen.put(text, style.patch(span)),
306            Body::Glyph(None) => {}
307        }
308        pen.put(" ", style);
309    }
310}
311
312pub(crate) struct Labels<'a> {
313    pub(crate) items: Vec<Item<'a>>,
314    pub(crate) kind: Kind,
315    pub(crate) on: Style,
316    pub(crate) off: Style,
317    pub(crate) tail: &'a [(String, Style)],
318    pub(crate) tail_style: Style,
319}
320
321impl Labels<'_> {
322    fn gap(&self) -> u16 {
323        match self.kind {
324            Kind::Groups => GROUP_GAP,
325            Kind::Sections => SECTION_GAP,
326        }
327    }
328
329    fn total(&self, form: Form, tail: bool) -> u16 {
330        let gap = self.gap();
331        let labels = self
332            .items
333            .iter()
334            .enumerate()
335            .fold(0u16, |total, (index, item)| {
336                let gap = if index > 0 { gap } else { 0 };
337                total
338                    .saturating_add(gap)
339                    .saturating_add(item.width(self.kind, form))
340            });
341        let tail = if tail { spans_width(self.tail) } else { 0 };
342        if tail == 0 {
343            return labels;
344        }
345        let gap = if labels > 0 { TAIL_GAP } else { 0 };
346        labels.saturating_add(gap).saturating_add(tail)
347    }
348
349    fn fit(&self, line: Rect) -> (Form, u16) {
350        for form in FORMS {
351            let total = self.total(form, true);
352            if total <= line.width {
353                return (form, line.x + (line.width - total) / 2);
354            }
355        }
356        (Form::Bare, line.x)
357    }
358
359    pub(crate) fn draw(&self, buf: &mut Buffer, line: Rect) {
360        let (form, start) = self.fit(line);
361        let Some(mut pen) = Pen::new(buf, line, start) else {
362            return;
363        };
364        let gap = self.gap();
365        for (index, item) in self.items.iter().enumerate() {
366            if index > 0 {
367                pen.x = pen.x.saturating_add(gap).min(pen.right);
368            }
369            let style = if item.on { self.on } else { self.off };
370            item.draw(&mut pen, self.kind, form, style);
371        }
372        if spans_width(self.tail) == 0 {
373            return;
374        }
375        if !self.items.is_empty() {
376            pen.x = pen.x.saturating_add(TAIL_GAP).min(pen.right);
377        }
378        let room = pen.room();
379        pen.spans(self.tail, self.tail_style, room);
380    }
381
382    pub(crate) fn hit(&self, line: Rect, column: u16) -> Option<usize> {
383        if column < line.x || column >= line.right() {
384            return None;
385        }
386        let (form, start) = self.fit(line);
387        let mut x = start;
388        for (index, item) in self.items.iter().enumerate() {
389            if index > 0 {
390                x = x.saturating_add(self.gap());
391            }
392            let end = x.saturating_add(item.width(self.kind, form));
393            if (x..end).contains(&column) {
394                return Some(index);
395            }
396            x = end;
397        }
398        None
399    }
400}
401
402pub(crate) fn blank(buf: &mut Buffer, line: Rect, style: Style) {
403    if let Some(mut pen) = Pen::new(buf, line, line.x) {
404        pen.fill(" ", style);
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use ratatui::style::Modifier;
412
413    fn row(buf: &Buffer, y: u16) -> String {
414        (0..buf.area.width)
415            .map(|x| buf[(x, y)].symbol().to_string())
416            .collect()
417    }
418
419    #[test]
420    fn numbers_restart_in_a_group_and_a_lone_tab_shows_none_with_its_slot_after() {
421        let home = Label::new("Home");
422        let next = Label::new("Next");
423        let accel = Label::spans([
424            ("M", Style::new().add_modifier(Modifier::UNDERLINED)),
425            ("ail", Style::new()),
426        ]);
427        let labels = Labels {
428            items: vec![
429                Item {
430                    label: &home,
431                    number: Some(1),
432                    on: true,
433                },
434                Item {
435                    label: &next,
436                    number: Some(2),
437                    on: false,
438                },
439            ],
440            kind: Kind::Sections,
441            on: Style::new().add_modifier(Modifier::BOLD),
442            off: Style::new(),
443            tail: &[("‹ all ›".to_string(), Style::new())],
444            tail_style: Style::new(),
445        };
446        let mut buf = Buffer::empty(Rect::new(0, 0, 30, 1));
447        labels.draw(&mut buf, Rect::new(0, 0, 30, 1));
448        assert_eq!(row(&buf, 0), "   1 Home  2 Next   ‹ all ›   ");
449        assert_eq!(labels.hit(Rect::new(0, 0, 30, 1), 10), Some(1));
450        let lone = Labels {
451            items: vec![Item {
452                label: &accel,
453                number: None,
454                on: true,
455            }],
456            kind: Kind::Sections,
457            on: Style::new(),
458            off: Style::new(),
459            tail: &[],
460            tail_style: Style::new(),
461        };
462        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 1));
463        lone.draw(&mut buf, Rect::new(0, 0, 10, 1));
464        assert_eq!(row(&buf, 0), "   Mail   ");
465        assert!(buf[(3, 0)].modifier.contains(Modifier::UNDERLINED));
466        assert!(!buf[(4, 0)].modifier.contains(Modifier::UNDERLINED));
467    }
468}