ttygrid/
lib.rs

1//! ttygrid provides functionality for displaying table-ized text to users with
2//! appropriate padding and width management. With ttygrid, you merely need to feed it your data,
3//! some information about the precedence (called a "priority") of each column, and it will
4//! automatically determine what to show to the user based on the available display width. See
5//! [crate::grid!] for more information.
6//!
7//! [Here](https://asciinema.org/a/609115) is a demo to see the results in action.
8//!
9//! It is not intended for streaming (aka, not tty) situations. It probably only works on unix
10//! right now too.
11//!
12//! The [`demo example`]
13//! some basic capabilities and should be reviewed for understanding this library; as well as
14//! learning the macros.
15//!
16//! [`demo example`]: https://github.com/erikh/ttygrid/blob/main/examples/demo.rs
17//!
18//! Much of this library relies on the macros, not the types directly. Please review those for the
19//! most comprehensive documentation.
20use anyhow::{anyhow, Result};
21use crossterm::{
22    execute,
23    style::{Color, Colors, Print, SetColors},
24};
25use std::{cell::RefCell, fmt, rc::Rc, usize::MAX};
26
27mod macros;
28pub use macros::*;
29
30pub type SafeGridHeader = Rc<RefCell<GridHeader>>;
31
32/// HeaderList defines a list of headers. This is typically composed as a part of the process from
33/// [crate::header!] and [crate::grid!] and is not constructed directly.
34#[derive(Default, Clone, Eq, PartialEq, PartialOrd, Ord)]
35pub struct HeaderList(Vec<SafeGridHeader>);
36
37impl HeaderList {
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    pub fn is_empty(&self) -> bool {
43        self.0.len() == 0
44    }
45
46    pub fn len(&self) -> usize {
47        self.0.len()
48    }
49
50    pub fn get(&self, idx: usize) -> Option<&SafeGridHeader> {
51        self.0.get(idx)
52    }
53}
54
55impl fmt::Display for HeaderList {
56    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
57        for header in self.0.clone() {
58            write!(
59                formatter,
60                "{:<width$}",
61                header.borrow().text,
62                width = header
63                    .borrow()
64                    .max_len
65                    .unwrap_or(header.borrow().text.len() + 2)
66            )?
67        }
68        Ok(())
69    }
70}
71
72/// GridHeader encapsulates the properties of a header, such as priority and padding information.
73/// This is typically constructed by [crate::header!] and is not constructed directly.
74///
75/// Several methods can adjust the content of the header after the fact, and should be reviewed.
76#[derive(Clone, PartialEq, Eq, Debug)]
77pub struct GridHeader {
78    index: Option<usize>,
79    text: &'static str,
80    min_size: Option<usize>,
81    max_pad: Option<usize>,
82    priority: usize,
83    max_len: Option<usize>,
84}
85
86impl Default for GridHeader {
87    fn default() -> Self {
88        Self {
89            index: None,
90            text: "",
91            min_size: None,
92            max_pad: Some(4),
93            priority: 0,
94            max_len: None,
95        }
96    }
97}
98
99impl PartialOrd for GridHeader {
100    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
101        if self.index.is_some() {
102            Some(
103                self.priority
104                    .cmp(&other.priority)
105                    .then(self.index.cmp(&other.index)),
106            )
107        } else {
108            Some(self.priority.cmp(&other.priority))
109        }
110    }
111}
112
113impl Ord for GridHeader {
114    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
115        if self.index.is_some() {
116            self.priority
117                .cmp(&other.priority)
118                .then(self.index.cmp(&other.index))
119        } else {
120            self.priority.cmp(&other.priority)
121        }
122    }
123}
124
125impl GridHeader {
126    /// Set the maximum length of items belonging to this header.
127    pub fn set_max_len(&mut self, len: usize) {
128        self.max_len = Some(len)
129    }
130
131    /// Set the text of this header.
132    pub fn set_text(mut self, text: &'static str) -> Self {
133        self.text = text;
134        self
135    }
136
137    /// Set the priority of this header. Higher priority items will be more likely to be shown on
138    /// smaller terminal sizes.
139    pub fn set_priority(mut self, priority: usize) -> Self {
140        self.priority = priority;
141        self
142    }
143
144    /// Set the position this header lives within the column list. 0 is the first position.
145    pub fn set_index(&mut self, idx: usize) {
146        self.index = Some(idx);
147    }
148
149    pub fn text(&self) -> &str {
150        self.text.clone()
151    }
152
153    pub fn priority(&self) -> usize {
154        self.priority
155    }
156}
157
158/// GridItem is the encapsulation of a piece of content. It is usually created by invoking
159/// [crate::add_line!] and is not instantiated directly.
160#[derive(Clone, Debug, Default)]
161pub struct GridItem {
162    header: SafeGridHeader,
163    contents: String,
164    max_len: Option<usize>,
165}
166
167impl GridItem {
168    pub fn new(header: SafeGridHeader, contents: String) -> Self {
169        Self {
170            header,
171            contents,
172            max_len: None,
173        }
174    }
175
176    fn len(&self) -> usize {
177        self.contents.len() + 1 // right padding
178    }
179
180    fn set_max_len(&mut self, max_len: usize) {
181        self.max_len = Some(max_len)
182    }
183}
184
185impl fmt::Display for GridItem {
186    fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
187        write!(
188            formatter,
189            "{:<max_len$}",
190            self.contents,
191            max_len = self.max_len.unwrap_or(self.len())
192        )
193    }
194}
195
196/// Usually constructed by [crate::grid!], this is the outer object of the whole library, all
197/// things are held by it in some form. Please review the impl for methods which can be used to
198/// adjust the properties of the grid once created.
199#[derive(Clone)]
200pub struct TTYGrid {
201    headers: HeaderList,
202    selected: HeaderList,
203    lines: Vec<GridLine>,
204    width: usize,
205    header_color: Colors,
206    delimiter_color: Colors,
207    primary_color: Colors,
208    secondary_color: Colors,
209}
210
211impl TTYGrid {
212    pub fn new(headers: Vec<SafeGridHeader>) -> Result<Self> {
213        let (w, _) = crossterm::terminal::size()?;
214        let width = w as usize;
215
216        Ok(Self {
217            selected: HeaderList::new(),
218            headers: HeaderList(headers),
219            lines: Vec::new(),
220            width,
221            header_color: Colors::new(Color::Reset, Color::Reset),
222            delimiter_color: Colors::new(Color::Reset, Color::Reset),
223            primary_color: Colors::new(Color::Reset, Color::Reset),
224            secondary_color: Colors::new(Color::Reset, Color::Reset),
225        })
226    }
227
228    /// Sets the delimiter color; the dashes between the header and the content.
229    pub fn set_delimiter_color(&mut self, colors: Colors) {
230        self.delimiter_color = colors
231    }
232
233    /// Sets the header color.
234    pub fn set_header_color(&mut self, colors: Colors) {
235        self.header_color = colors
236    }
237
238    /// Sets the primary color; colors will alternate between primary and secondary per row as the
239    /// table is built.
240    pub fn set_primary_color(&mut self, colors: Colors) {
241        self.primary_color = colors
242    }
243
244    /// Sets the secondary color; colors will alternate between primary and secondary per row as
245    /// the table is built.
246    pub fn set_secondary_color(&mut self, colors: Colors) {
247        self.secondary_color = colors
248    }
249
250    pub fn add_line(&mut self, item: GridLine) {
251        self.lines.push(item)
252    }
253
254    pub fn clear_lines(&mut self) {
255        self.lines.clear()
256    }
257
258    pub fn headers(&self) -> HeaderList {
259        self.headers.clone()
260    }
261
262    pub fn select(&mut self, header: SafeGridHeader, idx: usize) {
263        // update index (still an issue)
264        header.borrow_mut().set_index(idx);
265        self.selected.0.push(header)
266    }
267
268    pub fn is_selected(&self, header: SafeGridHeader) -> bool {
269        self.selected.0.contains(&header)
270    }
271
272    pub fn select_all_headers(&mut self) {
273        self.selected = self.headers.clone()
274    }
275
276    pub fn deselect_all_headers(&mut self) {
277        self.selected.0.clear()
278    }
279
280    fn set_grid_max_len(&mut self, len_map: &LengthMapper) -> Result<()> {
281        let mut cached_columns = Vec::new();
282
283        for (idx, header) in self.headers.0.iter_mut().enumerate() {
284            let max_len = len_map.max_len_for_column(&header.borrow())?;
285            header.borrow_mut().set_max_len(max_len);
286            cached_columns.insert(idx, header.borrow().max_len);
287        }
288
289        for line in self.lines.iter_mut() {
290            for (idx, item) in line.0.iter_mut().enumerate() {
291                if let Some(column) = cached_columns.get(idx) {
292                    item.set_max_len(column.unwrap());
293                }
294            }
295        }
296
297        Ok(())
298    }
299
300    fn determine_headers(&mut self) -> Result<()> {
301        let mut len_map = LengthMapper::default();
302        len_map.map_lines(self.lines.clone());
303
304        self.set_grid_max_len(&len_map)?; // this has to happen before any return occurs
305        let last = len_map.max_len_for_headers(self.headers.clone())?;
306
307        if last <= self.width {
308            self.select_all_headers();
309            return Ok(());
310        }
311
312        let mut prio_map: Vec<(usize, (HeaderList, usize))> = Vec::new();
313        self.deselect_all_headers();
314
315        let mut len = self.headers.0.len();
316
317        while len > 0 {
318            let mut headers = HeaderList::new();
319            for header in self.headers.0.iter().take(len) {
320                headers.0.push(header.clone())
321            }
322
323            let mut max_len = len_map.max_len_for_headers(headers.clone())?;
324
325            while max_len > self.width {
326                let mut new_headers = headers.clone();
327                let mut lowest_prio_index = MAX;
328                let mut to_remove = None;
329
330                for (idx, header) in new_headers.0.iter().enumerate() {
331                    let priority = header.borrow().priority;
332                    if priority < lowest_prio_index {
333                        to_remove = Some(idx);
334                        lowest_prio_index = priority;
335                    }
336                }
337
338                if let Some(to_remove) = to_remove {
339                    new_headers.0.remove(to_remove);
340                    max_len = len_map.max_len_for_headers(new_headers.clone())?;
341                    headers = new_headers;
342                } else {
343                    max_len = 0 // bury it
344                }
345            }
346
347            let index = headers.0.iter().fold(0, |acc, x| acc + x.borrow().priority);
348            prio_map.push((index, (headers, max_len)));
349            len -= 1;
350        }
351
352        if prio_map.is_empty() {
353            return Err(anyhow!("your terminal is too small"));
354        }
355
356        prio_map.sort();
357
358        let (_, (max_headers, _)) = prio_map.iter().last().unwrap();
359
360        for (idx, header) in max_headers.0.iter().enumerate() {
361            self.select(header.clone(), idx);
362        }
363
364        Ok(())
365    }
366
367    /// Yield a string which is suitable for passing to [println!], but does not make any attempt
368    /// to add terminal styling, which may be better for situations where data is piped. Unlike
369    /// [std::fmt::Display], this display method returns `Result<String, anyhow::Error>`.
370    pub fn display(&mut self) -> Result<String> {
371        self.determine_headers()?;
372        Ok(format!("{}", self))
373    }
374
375    /// Write to the writer, typically [std::io::stdout]. Terminal colors will be set.
376    pub fn write(&mut self, mut writer: impl std::io::Write) -> Result<()> {
377        self.determine_headers()?;
378        execute!(
379            writer,
380            SetColors(self.header_color),
381            Print(&format!("{}\n", self.selected))
382        )?;
383        execute!(
384            writer,
385            SetColors(self.delimiter_color),
386            Print(&format!("{:-<width$}\n", "-", width = self.width))
387        )?;
388
389        for (idx, line) in self.lines.iter().enumerate() {
390            if idx % 2 == 0 {
391                execute!(writer, SetColors(self.primary_color))?;
392            } else {
393                execute!(writer, SetColors(self.secondary_color))?;
394            }
395            execute!(writer, Print(&format!("{}\n", line.selected(self))))?;
396        }
397
398        Ok(())
399    }
400}
401
402impl fmt::Display for TTYGrid {
403    fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
404        writeln!(formatter, "{}", self.selected)?;
405        writeln!(formatter, "{:-<width$}", "-", width = self.width)?;
406
407        for line in self.lines.clone() {
408            writeln!(formatter, "{}", line.selected(self))?
409        }
410
411        Ok(())
412    }
413}
414
415/// A collection of grid items. Usually instantiated by [crate::add_line!].
416#[derive(Clone, Default, Debug)]
417pub struct GridLine(pub Vec<GridItem>);
418
419impl GridLine {
420    fn selected(&self, grid: &TTYGrid) -> Self {
421        let mut ret = Vec::new();
422        for item in self.0.iter() {
423            if grid.is_selected(item.header.clone()) {
424                ret.push(item.clone())
425            }
426        }
427
428        GridLine(ret)
429    }
430}
431
432impl fmt::Display for GridLine {
433    fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
434        for contents in self.0.clone() {
435            write!(formatter, "{}", contents)?
436        }
437
438        Ok(())
439    }
440}
441
442#[derive(Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
443struct LengthMapper(Vec<Vec<(SafeGridHeader, usize)>>);
444
445impl LengthMapper {
446    fn map_lines(&mut self, lines: Vec<GridLine>) {
447        for line in lines.clone() {
448            let len = self.0.len();
449            self.0.push(Vec::new()); // now len is equal to index
450            for item in line.0 {
451                self.0
452                    .get_mut(len)
453                    .unwrap()
454                    .push((item.header.clone(), item.len()));
455            }
456        }
457    }
458
459    fn max_len_for_column(&self, header: &GridHeader) -> Result<usize> {
460        let mut max_len = 0;
461        for line in self.0.clone() {
462            let found = line.iter().find(|i| i.0.borrow().eq(header));
463
464            if found.is_none() {
465                return Err(anyhow!(
466                    "panic: cannot find pre-existing column in line, report this bug"
467                ));
468            }
469
470            if max_len < found.unwrap().1 {
471                max_len = found.unwrap().1
472            }
473        }
474
475        Ok(max_len + header.max_pad.unwrap_or(0) + 2)
476    }
477
478    fn max_len_for_headers(&mut self, headers: HeaderList) -> Result<usize> {
479        Ok(headers.0.iter().fold(0, |x, h| {
480            x + self
481                .max_len_for_column(&h.clone().borrow())
482                .unwrap_or_default()
483        }))
484    }
485}