tui_additions/widgets/
textlist.rs

1use std::{error::Error, fmt::Display};
2
3use ratatui::{
4    layout::Rect,
5    style::Style,
6    widgets::{Block, BorderType, Borders, Paragraph, Widget},
7};
8use unicode_segmentation::UnicodeSegmentation;
9
10/// A widget for selecting from a list of items
11///
12/// Copy & paste examples can be found
13/// [here](https://github.com/siriusmart/tui-additions/tree/master/examples/textlist)
14///
15/// The requirement for the text list widget to render are:
16/// * Minimal height of 3
17/// * Height should be updated with `self.set_height()` before rendering
18
19#[derive(Clone)]
20pub struct TextList {
21    /// Items that are in the list, set by `.items()` or `.set_items()` function
22    pub items: Vec<String>,
23    /// The selected item, should be updated using provided functions. `0` should be the first item
24    pub selected: usize,
25    /// How many items to scroll down from the first item, should auto update if `selected` is
26    /// changed with provided functions.
27    pub scroll: usize,
28    /// The style of the entire text list including unselected (normal) items
29    pub style: Style,
30    /// Cursor style is the style of the box around the selected item
31    pub cursor_style: Style,
32    /// Style of the selected item
33    pub selected_style: Style,
34    /// The border type of cursor
35    pub border_type: BorderType,
36    /// Height avaliable for the widget, should be updated before rendering the widget
37    pub height: Option<u16>,
38    /// Only allow ASCII characters to prevent unicode length issues
39    pub ascii_only: bool,
40    /// Character to replace non ASCII characters with, only useful when `ascii_only` is `true`
41    pub non_ascii_replace: char,
42    /// How to handle items that got a longer length than the width which the widget can render
43    pub trim_type: TrimType,
44}
45
46/// Movement related functions
47impl TextList {
48    /// Should run this function after `scoll` of `selected` is updated to ensure that the cursor
49    /// is on screen
50    pub fn update(&mut self) -> Result<(), TextListError> {
51        let height = if let Some(h) = self.height {
52            h as i32 - 2
53        } else {
54            return Err(TextListError::UnknownHeight);
55        };
56
57        if height <= 0 {
58            return Err(TextListError::NotEnoughHeight);
59        }
60
61        if self.selected < self.scroll {
62            self.scroll = self.selected;
63        } else if self.scroll + height as usize <= self.selected {
64            self.scroll = self.selected - height as usize + 1;
65        }
66        Ok(())
67    }
68
69    /// Move cursor up by 1 item (if there is)
70    pub fn up(&mut self) -> Result<(), TextListError> {
71        if self.selected != 0 {
72            self.selected -= 1;
73            self.update()?;
74        }
75        Ok(())
76    }
77
78    /// Move cursor down by 1 item (if there is)
79    pub fn down(&mut self) -> Result<(), TextListError> {
80        if self.items.is_empty() {
81            return Ok(());
82        }
83
84        if self.selected < self.items.len() - 1 {
85            self.selected += 1;
86            self.update()?;
87        }
88        Ok(())
89    }
90
91    /// Go up 1 page without changing the cursor position on screen
92    pub fn pageup(&mut self) -> Result<(), TextListError> {
93        let height = match self.height {
94            Some(h) => h as usize,
95            None => return Err(TextListError::UnknownHeight),
96        };
97
98        if self.selected == 0 {
99            return Ok(());
100        }
101
102        let shift_by = height - 2;
103
104        if self.selected < shift_by {
105            self.selected = 0;
106        } else {
107            self.selected -= shift_by;
108
109            if self.scroll > shift_by {
110                self.scroll -= shift_by;
111            } else {
112                self.scroll = 0;
113            }
114        }
115
116        self.update()?;
117
118        Ok(())
119    }
120
121    /// Go down 1 page without changing the cursor position on screen
122    pub fn pagedown(&mut self) -> Result<(), TextListError> {
123        let height = match self.height {
124            Some(h) => h as usize,
125            None => return Err(TextListError::UnknownHeight),
126        };
127
128        if self.selected >= self.items.len() - 1 {
129            return Ok(());
130        }
131
132        let shift_by = height - 2;
133
134        if self.selected + shift_by > self.items.len() - 1 {
135            self.selected = self.items.len() - 1;
136        } else {
137            self.selected += shift_by;
138
139            if self.scroll + shift_by + height - 2 < self.items.len() {
140                self.scroll += shift_by;
141            } else {
142                self.scroll = self.items.len() - 1 - height + 2;
143            }
144        }
145
146        self.update()?;
147
148        Ok(())
149    }
150
151    /// Go to the first item
152    pub fn first(&mut self) -> Result<(), TextListError> {
153        if self.selected == 0 {
154            return Ok(());
155        }
156
157        self.selected = 0;
158        self.update()?;
159        Ok(())
160    }
161
162    /// Go to the last item
163    pub fn last(&mut self) -> Result<(), TextListError> {
164        if self.selected == self.items.len() - 1 {
165            return Ok(());
166        }
167
168        self.selected = self.items.len() - 1;
169        self.update()?;
170        Ok(())
171    }
172}
173
174/// Setters
175///
176/// * `set_{feature}()` takes ownership of self and returns self
177/// * `{feature}()` takes a mutable reference to self
178impl TextList {
179    pub fn ascii_only(mut self, ascii_only: bool) -> Self {
180        self.set_ascii_only(ascii_only);
181        self
182    }
183
184    pub fn set_ascii_only(&mut self, ascii_only: bool) {
185        self.ascii_only = ascii_only;
186    }
187
188    pub fn border_type(mut self, border_type: BorderType) -> Self {
189        self.set_border_type(border_type);
190        self
191    }
192
193    pub fn set_border_type(&mut self, border_type: BorderType) {
194        self.border_type = border_type;
195    }
196
197    pub fn cursor_style(mut self, cursor_style: Style) -> Self {
198        self.set_cursor_style(cursor_style);
199        self
200    }
201
202    pub fn set_cursor_style(&mut self, cursor_style: Style) {
203        self.cursor_style = cursor_style;
204    }
205
206    pub fn height(mut self, height: u16) -> Self {
207        self.set_height(height);
208        self
209    }
210
211    pub fn set_height(&mut self, height: u16) {
212        self.height = Some(height);
213    }
214
215    pub fn items<D: Display>(mut self, items: &[D]) -> Result<Self, Box<dyn Error>> {
216        self.set_items(items)?;
217        Ok(self)
218    }
219
220    pub fn set_items<D: Display>(&mut self, items: &[D]) -> Result<(), Box<dyn Error>> {
221        self.items = items.iter().map(|item| format!("{}", item)).collect();
222        if self.height.is_some() {
223            self.update()?;
224        }
225        Ok(())
226    }
227
228    pub fn selected(mut self, index: usize) -> Result<Self, TextListError> {
229        self.set_selected(index)?;
230        Ok(self)
231    }
232
233    pub fn set_selected(&mut self, index: usize) -> Result<(), TextListError> {
234        self.selected = index;
235        self.update()?;
236        Ok(())
237    }
238
239    pub fn non_ascii_replace(mut self, non_ascii_replace: char) -> Self {
240        self.set_non_ascii_replace(non_ascii_replace);
241        self
242    }
243
244    pub fn set_non_ascii_replace(&mut self, non_ascii_replace: char) {
245        self.non_ascii_replace = non_ascii_replace;
246    }
247
248    pub fn selected_style(mut self, selected_style: Style) -> Self {
249        self.set_selected_style(selected_style);
250        self
251    }
252
253    pub fn set_selected_style(&mut self, selected_style: Style) {
254        self.selected_style = selected_style;
255    }
256
257    pub fn style(mut self, style: Style) -> Self {
258        self.set_style(style);
259        self
260    }
261
262    pub fn set_style(&mut self, style: Style) {
263        self.style = style;
264    }
265
266    pub fn trim_type(mut self, trim_type: TrimType) -> Self {
267        self.set_trim_type(trim_type);
268        self
269    }
270
271    pub fn set_trim_type(&mut self, trim_type: TrimType) {
272        self.trim_type = trim_type;
273    }
274}
275
276/// Default (blank) text list
277impl Default for TextList {
278    fn default() -> Self {
279        Self {
280            items: Vec::new(),
281            selected: 0,
282            scroll: 0,
283            style: Style::default(),
284            cursor_style: Style::default(),
285            selected_style: Style::default(),
286            border_type: BorderType::Plain,
287            height: None,
288            ascii_only: false,
289            non_ascii_replace: '?',
290            trim_type: TrimType::FullTripleDot,
291        }
292    }
293}
294
295/// `ratatui::widget::Widget` implementation
296impl Widget for TextList {
297    /// Note that if `self.height` does not match the actualy height, it will panic instead because
298    /// there is no way to return a `Result<T, E>` out of this function
299    fn render(mut self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) {
300        let height = self.height.expect("unknown height");
301        if height != area.height {
302            panic!("height mismatch");
303        }
304
305        if area.height < 3 {
306            // panic!("insufficient height");
307            return;
308        }
309
310        self.items = self
311            .items
312            .into_iter()
313            .skip(self.scroll)
314            .take(height as usize - 2)
315            .collect();
316
317        // remove non ascii character
318
319        if self.ascii_only {
320            self.items.iter_mut().for_each(|item| {
321                *item = item
322                    .chars()
323                    .map(|c| {
324                        if c.is_ascii() {
325                            c
326                        } else {
327                            self.non_ascii_replace
328                        }
329                    })
330                    .collect();
331            });
332        }
333
334        // check if item is too long
335
336        let width_from = area.width as usize - 2;
337        let (width_after, end_with) = match self.trim_type {
338            TrimType::None => (width_from, ""),
339            TrimType::FullTripleDot => (width_from - 3, "..."),
340            TrimType::ShortTripleDot => (width_from - 1, "…"),
341        };
342
343        if area.width as usize - 2 < end_with.chars().count() {
344            panic!("width too small");
345        }
346
347        self.items.iter_mut().for_each(|item| {
348            let chars = UnicodeSegmentation::graphemes(item.as_str(), true).collect::<Vec<_>>();
349            if chars.len() > width_from {
350                *item = format!(
351                    "{}{}",
352                    chars.into_iter().take(width_after).collect::<String>(),
353                    end_with
354                );
355            }
356        });
357
358        // setting background style for rect
359
360        buf.set_style(area, self.style);
361
362        // render items
363
364        let mut y = area.y;
365        self.items
366            .into_iter()
367            .zip(self.scroll..)
368            .for_each(|(item, index)| {
369                if index == self.selected {
370                    let block = Block::default()
371                        .border_type(self.border_type)
372                        .border_style(self.cursor_style)
373                        .borders(Borders::ALL);
374                    let paragraph = Paragraph::new(item).style(self.selected_style).block(block);
375
376                    let select_area = Rect {
377                        x: area.x,
378                        y,
379                        height: 3,
380                        width: area.width,
381                    };
382
383                    paragraph.render(select_area, buf);
384                    y += 3;
385                } else {
386                    buf.set_string(area.x + 1, y, item, Style::default());
387                    y += 1;
388                }
389            })
390    }
391}
392
393/// Errors that the text list functions may return
394#[derive(Debug)]
395pub enum TextListError {
396    /// `self.height` is not initialized (is_none)
397    UnknownHeight,
398    /// Not enough height to draw the text list widget (the minimal height is 3)
399    NotEnoughHeight,
400}
401
402impl Display for TextListError {
403    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
404        f.write_str(&format!("{:?}", self))
405    }
406}
407
408impl Error for TextListError {}
409
410/// How to handle items that are longer than the avaliable width
411#[derive(Debug, Clone, Copy)]
412pub enum TrimType {
413    /// Add `'…'` to the end of item
414    ShortTripleDot,
415    /// Add `'...'` to the end of item
416    FullTripleDot,
417    /// Add nothing to the end of item
418    r#None,
419}