Skip to main content

radicle_term/
element.rs

1use std::fmt;
2use std::io::IsTerminal;
3use std::ops::Deref;
4use std::{io, vec};
5
6use crate::cell::Cell;
7use crate::{Color, Filled, Label, Style, viewport};
8
9/// Rendering constraint.
10#[derive(Debug, Copy, Clone, PartialEq, Eq)]
11pub struct Constraint {
12    /// Minimum space the element can take.
13    pub min: Size,
14    /// Maximum space the element can take.
15    pub max: Size,
16}
17
18impl Default for Constraint {
19    fn default() -> Self {
20        Self::UNBOUNDED
21    }
22}
23
24impl Constraint {
25    /// Can satisfy any size of object.
26    pub const UNBOUNDED: Self = Self {
27        min: Size::MIN,
28        max: Size::MAX,
29    };
30
31    /// Create a new constraint.
32    pub fn new(min: Size, max: Size) -> Self {
33        assert!(min.cols <= max.cols && min.rows <= max.rows);
34
35        Self { min, max }
36    }
37
38    /// A constraint with only a maximum size.
39    pub fn max(max: Size) -> Self {
40        Self {
41            min: Size::MIN,
42            max,
43        }
44    }
45
46    /// A constraint that can only be satisfied by a single column size.
47    /// The rows are unconstrained.
48    pub fn tight(cols: usize) -> Self {
49        Self {
50            min: Size::new(cols, 1),
51            max: Size::new(cols, usize::MAX),
52        }
53    }
54
55    /// Create a constraint from the terminal environment.
56    /// Returns [`None`] if the output device is not a terminal.
57    pub fn from_env() -> Option<Self> {
58        if io::stdout().is_terminal() {
59            Some(Self::max(viewport().unwrap_or(Size::MAX)))
60        } else {
61            None
62        }
63    }
64}
65
66/// A text element that has a size and can be rendered to the terminal.
67pub trait Element: fmt::Debug + Send + Sync {
68    /// Get the size of the element, in rows and columns.
69    fn size(&self, parent: Constraint) -> Size;
70
71    #[must_use]
72    /// Render the element as lines of text that can be printed.
73    fn render(&self, parent: Constraint) -> Vec<Line>;
74
75    /// Get the number of columns occupied by this element.
76    fn columns(&self, parent: Constraint) -> usize {
77        self.size(parent).cols
78    }
79
80    /// Get the number of rows occupied by this element.
81    fn rows(&self, parent: Constraint) -> usize {
82        self.size(parent).rows
83    }
84
85    /// Print this element to stdout.
86    fn print(&self) {
87        use std::io::Write;
88
89        let mut stdout = io::stdout().lock();
90        for line in self.render(Constraint::from_env().unwrap_or_default()) {
91            let _ = writeln!(stdout, "{}", line.to_string().trim_end())
92                .or_else(crate::io::swallow_broken_pipe_stdout);
93        }
94    }
95
96    /// Write using the given constraints to `stdout`.
97    fn write(&self, constraints: Constraint) -> io::Result<()>
98    where
99        Self: Sized,
100    {
101        self::write_to(self, &mut io::stdout(), constraints)
102    }
103
104    #[must_use]
105    /// Return a string representation of this element.
106    fn display(&self, constraints: Constraint) -> String {
107        let mut out = String::new();
108        for line in self.render(constraints) {
109            out.extend(line.into_iter().map(|l| l.to_string()));
110            out.push('\n');
111        }
112        out
113    }
114}
115
116impl Element for Box<dyn Element + '_> {
117    fn size(&self, parent: Constraint) -> Size {
118        self.deref().size(parent)
119    }
120
121    fn render(&self, parent: Constraint) -> Vec<Line> {
122        self.deref().render(parent)
123    }
124
125    fn print(&self) {
126        self.deref().print()
127    }
128}
129
130impl<T: Element> Element for &T {
131    fn size(&self, parent: Constraint) -> Size {
132        (*self).size(parent)
133    }
134
135    fn render(&self, parent: Constraint) -> Vec<Line> {
136        (*self).render(parent)
137    }
138
139    fn print(&self) {
140        (*self).print()
141    }
142}
143
144/// Write using the given constraints, to a writer.
145pub fn write_to(
146    elem: &impl Element,
147    writer: &mut impl io::Write,
148    constraints: Constraint,
149) -> io::Result<()> {
150    for line in elem.render(constraints) {
151        writeln!(writer, "{}", line.to_string().trim_end())?;
152    }
153    Ok(())
154}
155
156/// A line of text that has styling and can be displayed.
157#[derive(Clone, Default, Debug)]
158pub struct Line {
159    items: Vec<Label>,
160}
161
162impl Line {
163    /// Create a new line.
164    pub fn new(item: impl Into<Label>) -> Self {
165        Self {
166            items: vec![item.into()],
167        }
168    }
169
170    /// Create a blank line.
171    pub fn blank() -> Self {
172        Self { items: vec![] }
173    }
174
175    /// Return a styled line by styling all its labels.
176    pub fn style(self, style: Style) -> Line {
177        Self {
178            items: self
179                .items
180                .into_iter()
181                .map(|l| {
182                    let style = l.paint().style().merge(style);
183                    l.style(style)
184                })
185                .collect(),
186        }
187    }
188
189    /// Return a line with a single space between the given labels.
190    // TODO: Make this impl trivial once [`Iterator::intersperse`] is stable.
191    pub fn spaced(items: impl IntoIterator<Item = Label>) -> Self {
192        let iter = items.into_iter();
193
194        let mut line = Self {
195            items: Vec::with_capacity({
196                let (min, max) = iter.size_hint();
197                let likely = max.unwrap_or(min);
198
199                // Technically (likely + (likely - 1)), but we push the last space before
200                // we pop it again, so we need that additional space anyways.
201                likely * 2
202            }),
203        };
204
205        // Don't create spaces around empty labels.
206        for item in iter.filter(|i| !i.is_blank()) {
207            line.push(item);
208            line.push(Label::space());
209        }
210        line.items.pop();
211        line
212    }
213
214    /// Add an item to this line.
215    pub fn item(mut self, item: impl Into<Label>) -> Self {
216        self.push(item);
217        self
218    }
219
220    /// Add multiple items to this line.
221    pub fn extend(mut self, items: impl IntoIterator<Item = Label>) -> Self {
222        self.items.extend(items);
223        self
224    }
225
226    /// Add an item to this line.
227    pub fn push(&mut self, item: impl Into<Label>) {
228        self.items.push(item.into());
229    }
230
231    /// Pad this line to occupy the given width.
232    pub fn pad(&mut self, width: usize) {
233        let w = self.width();
234
235        if width > w {
236            let pad = width - w;
237            let bg = if let Some(last) = self.items.last() {
238                last.background()
239            } else {
240                Color::Unset
241            };
242            self.items.push(Label::new(" ".repeat(pad).as_str()).bg(bg));
243        }
244    }
245
246    /// Truncate this line to the given width.
247    pub fn truncate(&mut self, width: usize, delim: &str) {
248        while self.width() > width {
249            let total = self.width();
250
251            if total - self.items.last().map_or(0, Cell::width) > width {
252                self.items.pop();
253            } else if let Some(item) = self.items.last_mut() {
254                *item = item.truncate(width - (total - Cell::width(item)), delim);
255            }
256        }
257    }
258
259    /// Get the actual column width of this line.
260    pub fn width(&self) -> usize {
261        self.items.iter().map(Cell::width).sum()
262    }
263
264    /// Create a line that contains a single space.
265    pub fn space(mut self) -> Self {
266        self.items.push(Label::space());
267        self
268    }
269
270    /// Box this line as an [`Element`].
271    pub fn boxed(self) -> Box<dyn Element> {
272        Box::new(self)
273    }
274
275    /// Return a filled line.
276    pub fn filled(self, color: Color) -> Filled<Self> {
277        Filled { item: self, color }
278    }
279}
280
281impl IntoIterator for Line {
282    type Item = Label;
283    type IntoIter = Box<dyn Iterator<Item = Label>>;
284
285    fn into_iter(self) -> Self::IntoIter {
286        Box::new(self.items.into_iter())
287    }
288}
289
290impl<T: Into<Label>> From<T> for Line {
291    fn from(value: T) -> Self {
292        Self::new(value)
293    }
294}
295
296impl From<Vec<Label>> for Line {
297    fn from(items: Vec<Label>) -> Self {
298        Self { items }
299    }
300}
301
302impl Element for Line {
303    fn size(&self, _parent: Constraint) -> Size {
304        Size::new(self.items.iter().map(Cell::width).sum(), 1)
305    }
306
307    fn render(&self, _parent: Constraint) -> Vec<Line> {
308        vec![self.clone()]
309    }
310}
311
312impl Element for Vec<Line> {
313    fn size(&self, parent: Constraint) -> Size {
314        let width = self
315            .iter()
316            .map(|e| e.columns(parent))
317            .max()
318            .unwrap_or_default();
319        let height = self.len();
320
321        Size::new(width, height)
322    }
323
324    fn render(&self, parent: Constraint) -> Vec<Line> {
325        self.iter()
326            .cloned()
327            .flat_map(|l| l.render(parent))
328            .collect()
329    }
330}
331
332impl fmt::Display for Line {
333    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
334        for item in &self.items {
335            write!(f, "{item}")?;
336        }
337        Ok(())
338    }
339}
340
341/// Size of a text element, in columns and rows.
342#[derive(Clone, Debug, Default, Copy, PartialEq, Eq)]
343pub struct Size {
344    /// Columns occupied.
345    pub cols: usize,
346    /// Rows occupied.
347    pub rows: usize,
348}
349
350impl Size {
351    /// Minimum size.
352    pub const MIN: Self = Self {
353        cols: usize::MIN,
354        rows: usize::MIN,
355    };
356    /// Maximum size.
357    pub const MAX: Self = Self {
358        cols: usize::MAX,
359        rows: usize::MAX,
360    };
361
362    /// Create a new [`Size`].
363    pub fn new(cols: usize, rows: usize) -> Self {
364        Self { cols, rows }
365    }
366
367    /// Constrain size.
368    pub fn constrain(self, c: Constraint) -> Self {
369        Self {
370            cols: self.cols.clamp(c.min.cols, c.max.cols),
371            rows: self.rows.clamp(c.min.rows, c.max.rows),
372        }
373    }
374}
375
376#[cfg(test)]
377mod test {
378    use super::*;
379
380    #[test]
381    fn test_truncate() {
382        let line = Line::default().item("banana").item("peach").item("apple");
383
384        let mut actual = line.clone();
385        actual = actual.truncate(9, "…");
386        assert_eq!(actual.to_string(), "bananape…");
387
388        let mut actual = line.clone();
389        actual = actual.truncate(7, "…");
390        assert_eq!(actual.to_string(), "banana…");
391
392        let mut actual = line.clone();
393        actual = actual.truncate(1, "…");
394        assert_eq!(actual.to_string(), "…");
395
396        let mut actual = line;
397        actual = actual.truncate(0, "…");
398        assert_eq!(actual.to_string(), "");
399    }
400
401    #[test]
402    fn test_width() {
403        // Nb. This might not display correctly in some editors or terminals.
404        let line = Line::new("Radicle Heartwood Protocol & Stack ❤️🪵");
405        assert_eq!(line.width(), 39, "{line}");
406        let line = Line::new("❤\u{fe0f}");
407        assert_eq!(line.width(), 2, "{line}");
408        let line = Line::new("❤️");
409        assert_eq!(line.width(), 2, "{line}");
410    }
411
412    #[test]
413    fn test_spaced() {
414        let line = Line::spaced(["banana", "peach", "apple"].into_iter().map(Label::new));
415
416        let iterated: Vec<_> = line.into_iter().collect();
417        assert_eq!(
418            iterated
419                .into_iter()
420                .map(|l| l.to_string())
421                .collect::<Vec<_>>(),
422            ["banana", " ", "peach", " ", "apple"]
423        );
424    }
425}