Skip to main content

tui_realm_stdlib/components/
list.rs

1//! `List` represents a read-only textual list component which can be scrollable through arrows or inactive.
2
3use tuirealm::command::{Cmd, CmdResult, Direction, Position};
4use tuirealm::component::Component;
5use tuirealm::props::{
6    AttrValue, Attribute, Borders, Color, LineStatic, PropPayload, PropValue, Props, QueryResult,
7    Style, TextModifiers, Title,
8};
9use tuirealm::ratatui::Frame;
10use tuirealm::ratatui::layout::Rect;
11use tuirealm::ratatui::widgets::{List as TuiList, ListItem, ListState};
12use tuirealm::state::{State, StateValue};
13
14use crate::prop_ext::{CommonHighlight, CommonProps};
15use crate::utils;
16
17// -- States
18
19/// The state that needs to be kept for the [`List`] componennt:
20#[derive(Default)]
21pub struct ListStates {
22    /// Index of selected item in list
23    pub list_index: usize,
24    /// Lines in text area
25    pub list_len: usize,
26}
27
28impl ListStates {
29    /// Set the list length.
30    pub fn set_list_len(&mut self, len: usize) {
31        self.list_len = len;
32    }
33
34    /// Incremenet the list index.
35    pub fn incr_list_index(&mut self, rewind: bool) {
36        // Check if index is at last element
37        if self.list_index + 1 < self.list_len {
38            self.list_index += 1;
39        } else if rewind {
40            self.list_index = 0;
41        }
42    }
43
44    /// Decrement the list index.
45    pub fn decr_list_index(&mut self, rewind: bool) {
46        // Check if index is bigger than 0
47        if self.list_index > 0 {
48            self.list_index -= 1;
49        } else if rewind && self.list_len > 0 {
50            self.list_index = self.list_len - 1;
51        }
52    }
53
54    /// Keep index if possible, otherwise set to `lenght - 1`.
55    pub fn fix_list_index(&mut self) {
56        if self.list_index >= self.list_len && self.list_len > 0 {
57            self.list_index = self.list_len - 1;
58        } else if self.list_len == 0 {
59            self.list_index = 0;
60        }
61    }
62
63    /// Set the list index to the first item in the list.
64    pub fn list_index_at_first(&mut self) {
65        self.list_index = 0;
66    }
67
68    /// Set the list index at the last item of the list.
69    pub fn list_index_at_last(&mut self) {
70        if self.list_len > 0 {
71            self.list_index = self.list_len - 1;
72        } else {
73            self.list_index = 0;
74        }
75    }
76
77    /// Calculate the max step ahead to scroll the list.
78    #[must_use]
79    pub fn calc_max_step_ahead(&self, max: usize) -> usize {
80        let remaining: usize = match self.list_len {
81            0 => 0,
82            len => len - 1 - self.list_index,
83        };
84        if remaining > max { max } else { remaining }
85    }
86
87    /// Calculate the max step ahead to scroll the list.
88    #[must_use]
89    pub fn calc_max_step_behind(&self, max: usize) -> usize {
90        if self.list_index > max {
91            max
92        } else {
93            self.list_index
94        }
95    }
96}
97
98// -- Component
99
100/// `List` represents a read-only textual list component which can be scrollable through arrows or inactive.
101#[derive(Default)]
102#[must_use]
103pub struct List {
104    common: CommonProps,
105    common_hg: CommonHighlight,
106    props: Props,
107    pub states: ListStates,
108}
109
110impl List {
111    /// Set the main foreground color. This may get overwritten by individual text styles.
112    pub fn foreground(mut self, fg: Color) -> Self {
113        self.attr(Attribute::Foreground, AttrValue::Color(fg));
114        self
115    }
116
117    /// Set the main background color. This may get overwritten by individual text styles.
118    pub fn background(mut self, bg: Color) -> Self {
119        self.attr(Attribute::Background, AttrValue::Color(bg));
120        self
121    }
122
123    /// Set the main text modifiers. This may get overwritten by individual text styles.
124    pub fn modifiers(mut self, m: TextModifiers) -> Self {
125        self.attr(Attribute::TextProps, AttrValue::TextModifiers(m));
126        self
127    }
128
129    /// Set the main style. This may get overwritten by individual text styles.
130    ///
131    /// This option will overwrite any previous [`foreground`](Self::foreground), [`background`](Self::background) and [`modifiers`](Self::modifiers)!
132    pub fn style(mut self, style: Style) -> Self {
133        self.attr(Attribute::Style, AttrValue::Style(style));
134        self
135    }
136
137    /// Set a custom style for the border when the component is unfocused.
138    pub fn inactive(mut self, s: Style) -> Self {
139        self.attr(Attribute::UnfocusedBorderStyle, AttrValue::Style(s));
140        self
141    }
142
143    /// Add a border to the component.
144    pub fn borders(mut self, b: Borders) -> Self {
145        self.attr(Attribute::Borders, AttrValue::Borders(b));
146        self
147    }
148
149    /// Add a title to the component.
150    pub fn title<T: Into<Title>>(mut self, title: T) -> Self {
151        self.attr(Attribute::Title, AttrValue::Title(title.into()));
152        self
153    }
154
155    /// Set whether wraparound should be possible (down on the last choice wraps around to 0, and the other way around).
156    pub fn rewind(mut self, r: bool) -> Self {
157        self.attr(Attribute::Rewind, AttrValue::Flag(r));
158        self
159    }
160
161    /// Set the scroll stepping to use on `Cmd::Scroll(Direction::Up)` or `Cmd::Scroll(Direction::Down)`.
162    pub fn step(mut self, step: usize) -> Self {
163        self.attr(Attribute::ScrollStep, AttrValue::Length(step));
164        self
165    }
166
167    /// Should the list be scrollable or always show only the top (0th) element?
168    pub fn scroll(mut self, scrollable: bool) -> Self {
169        self.attr(Attribute::Scroll, AttrValue::Flag(scrollable));
170        self
171    }
172
173    /// Set the Symbol and Style for the indicator of the current line.
174    pub fn highlight_str<S: Into<LineStatic>>(mut self, s: S) -> Self {
175        self.attr(Attribute::HighlightedStr, AttrValue::TextLine(s.into()));
176        self
177    }
178
179    /// Set a custom highlight style that is patched on-top of the normal style.
180    ///
181    /// By default the highlight style is just `Style::new().add_modifier(Modifier::REVERSED)`.
182    pub fn highlight_style(mut self, s: Style) -> Self {
183        self.attr(Attribute::HighlightStyle, AttrValue::Style(s));
184        self
185    }
186
187    /// Set a custom highlight style that is patched on-top of the highlight style when unfocused.
188    pub fn highlight_style_inactive(mut self, s: Style) -> Self {
189        self.attr(Attribute::HighlightStyleUnfocused, AttrValue::Style(s));
190        self
191    }
192
193    /// Set the rows of items the list should contain
194    pub fn rows<T>(mut self, rows: impl IntoIterator<Item = T>) -> Self
195    where
196        T: Into<LineStatic>,
197    {
198        self.attr(
199            Attribute::Text,
200            AttrValue::Payload(PropPayload::Vec(
201                rows.into_iter()
202                    .map(Into::into)
203                    .map(PropValue::TextLine)
204                    .collect(),
205            )),
206        );
207        self
208    }
209
210    /// Set the initially selected line.
211    pub fn selected_line(mut self, line: usize) -> Self {
212        self.attr(
213            Attribute::Value,
214            AttrValue::Payload(PropPayload::Single(PropValue::Usize(line))),
215        );
216        self
217    }
218
219    /// Set the current component to be always active (show highligh even if unfocused)
220    pub fn always_active(mut self) -> Self {
221        self.attr(Attribute::AlwaysActive, AttrValue::Flag(true));
222        self
223    }
224
225    fn scrollable(&self) -> bool {
226        self.props
227            .get(Attribute::Scroll)
228            .and_then(AttrValue::as_flag)
229            .unwrap_or_default()
230    }
231
232    fn rewindable(&self) -> bool {
233        self.props
234            .get(Attribute::Rewind)
235            .and_then(AttrValue::as_flag)
236            .unwrap_or_default()
237    }
238}
239
240impl Component for List {
241    fn view(&mut self, render: &mut Frame, area: Rect) {
242        if !self.common.display {
243            return;
244        }
245
246        // Make list entries
247        let payload = self.props.get(Attribute::Text).and_then(|x| x.as_payload());
248        let list_items: Vec<ListItem> = match payload {
249            Some(PropPayload::Vec(lines)) => {
250                lines
251                    .iter()
252                    // this will skip any "PropValue" that is not a "TextLine", instead of panicing
253                    .filter_map(|x| x.as_textline())
254                    .map(utils::borrow_clone_line)
255                    .map(ListItem::from)
256                    .collect()
257            }
258            _ => Vec::new(),
259        };
260
261        // Make the widget
262        let mut widget = TuiList::new(list_items)
263            .style(self.common.style)
264            .direction(tuirealm::ratatui::widgets::ListDirection::TopToBottom)
265            .highlight_style(
266                self.common_hg
267                    .get_style_focus(self.common.style, self.common.is_active()),
268            );
269
270        if let Some(block) = self.common.get_block() {
271            widget = widget.block(block);
272        }
273
274        // Highlighted symbol
275        if let Some(symbol) = self.common_hg.get_symbol() {
276            widget = widget.highlight_symbol(symbol);
277        }
278
279        if self.scrollable() {
280            let mut state: ListState = ListState::default();
281            state.select(Some(self.states.list_index));
282            render.render_stateful_widget(widget, area, &mut state);
283        } else {
284            render.render_widget(widget, area);
285        }
286    }
287
288    fn query<'a>(&'a self, attr: Attribute) -> Option<QueryResult<'a>> {
289        if let Some(value) = self
290            .common
291            .get_for_query(attr)
292            .or_else(|| self.common_hg.get_for_query(attr))
293        {
294            return Some(value);
295        }
296
297        self.props.get_for_query(attr)
298    }
299
300    fn attr(&mut self, attr: Attribute, value: AttrValue) {
301        if let Some(value) = self
302            .common
303            .set(attr, value)
304            .and_then(|value| self.common_hg.set(attr, value))
305        {
306            self.props.set(attr, value);
307            if matches!(attr, Attribute::Text) {
308                // Update list len and fix index
309                self.states.set_list_len(
310                    match self
311                        .props
312                        .get(Attribute::Text)
313                        .and_then(AttrValue::as_payload)
314                        .and_then(PropPayload::as_vec)
315                    {
316                        Some(rows) => rows.len(),
317                        _ => 0,
318                    },
319                );
320                self.states.fix_list_index();
321            } else if matches!(attr, Attribute::Value) && self.scrollable() {
322                self.states.list_index = self
323                    .props
324                    .get(Attribute::Value)
325                    .and_then(AttrValue::as_payload)
326                    .and_then(PropPayload::as_single)
327                    .and_then(PropValue::as_usize)
328                    .unwrap_or_default();
329                self.states.fix_list_index();
330            }
331        }
332    }
333
334    fn state(&self) -> State {
335        if self.scrollable() {
336            State::Single(StateValue::Usize(self.states.list_index))
337        } else {
338            State::None
339        }
340    }
341
342    fn perform(&mut self, cmd: Cmd) -> CmdResult {
343        match cmd {
344            Cmd::Move(Direction::Down) => {
345                let prev = self.states.list_index;
346                self.states.incr_list_index(self.rewindable());
347                if prev == self.states.list_index {
348                    CmdResult::NoChange
349                } else {
350                    CmdResult::Changed(self.state())
351                }
352            }
353            Cmd::Move(Direction::Up) => {
354                let prev = self.states.list_index;
355                self.states.decr_list_index(self.rewindable());
356                if prev == self.states.list_index {
357                    CmdResult::NoChange
358                } else {
359                    CmdResult::Changed(self.state())
360                }
361            }
362            Cmd::Scroll(Direction::Down) => {
363                let prev = self.states.list_index;
364                let step = self
365                    .props
366                    .get(Attribute::ScrollStep)
367                    .and_then(AttrValue::as_length)
368                    .unwrap_or(8);
369                let step: usize = self.states.calc_max_step_ahead(step);
370                (0..step).for_each(|_| self.states.incr_list_index(false));
371                if prev == self.states.list_index {
372                    CmdResult::NoChange
373                } else {
374                    CmdResult::Changed(self.state())
375                }
376            }
377            Cmd::Scroll(Direction::Up) => {
378                let prev = self.states.list_index;
379                let step = self
380                    .props
381                    .get(Attribute::ScrollStep)
382                    .and_then(AttrValue::as_length)
383                    .unwrap_or(8);
384                let step: usize = self.states.calc_max_step_behind(step);
385                (0..step).for_each(|_| self.states.decr_list_index(false));
386                if prev == self.states.list_index {
387                    CmdResult::NoChange
388                } else {
389                    CmdResult::Changed(self.state())
390                }
391            }
392            Cmd::GoTo(Position::Begin) => {
393                let prev = self.states.list_index;
394                self.states.list_index_at_first();
395                if prev == self.states.list_index {
396                    CmdResult::NoChange
397                } else {
398                    CmdResult::Changed(self.state())
399                }
400            }
401            Cmd::GoTo(Position::End) => {
402                let prev = self.states.list_index;
403                self.states.list_index_at_last();
404                if prev == self.states.list_index {
405                    CmdResult::NoChange
406                } else {
407                    CmdResult::Changed(self.state())
408                }
409            }
410            _ => CmdResult::Invalid(cmd),
411        }
412    }
413}
414
415#[cfg(test)]
416mod tests {
417
418    use pretty_assertions::assert_eq;
419    use tuirealm::props::HorizontalAlignment;
420    use tuirealm::ratatui::text::{Line, Span};
421
422    use super::*;
423
424    #[test]
425    fn list_states() {
426        let mut states = ListStates::default();
427        assert_eq!(states.list_index, 0);
428        assert_eq!(states.list_len, 0);
429        states.set_list_len(5);
430        assert_eq!(states.list_index, 0);
431        assert_eq!(states.list_len, 5);
432        // Incr
433        states.incr_list_index(true);
434        assert_eq!(states.list_index, 1);
435        states.list_index = 4;
436        states.incr_list_index(false);
437        assert_eq!(states.list_index, 4);
438        states.incr_list_index(true);
439        assert_eq!(states.list_index, 0);
440        // Decr
441        states.decr_list_index(false);
442        assert_eq!(states.list_index, 0);
443        states.decr_list_index(true);
444        assert_eq!(states.list_index, 4);
445        states.decr_list_index(true);
446        assert_eq!(states.list_index, 3);
447        // Begin
448        states.list_index_at_first();
449        assert_eq!(states.list_index, 0);
450        states.list_index_at_last();
451        assert_eq!(states.list_index, 4);
452        // Fix
453        states.set_list_len(3);
454        states.fix_list_index();
455        assert_eq!(states.list_index, 2);
456    }
457
458    #[test]
459    fn test_components_list_scrollable() {
460        let mut component = List::default()
461            .foreground(Color::Red)
462            .background(Color::Blue)
463            .highlight_style(Style::new().fg(Color::Yellow))
464            .highlight_str("🚀")
465            .modifiers(TextModifiers::BOLD)
466            .scroll(true)
467            .step(4)
468            .borders(Borders::default())
469            .title(Title::from("events").alignment(HorizontalAlignment::Center))
470            .rewind(true)
471            .rows([
472                // Note: this could be improved if ratatui implements "From<[X; _]> for Line"
473                // will get automatically converted to lines
474                vec![
475                    Span::from("KeyCode::Down"),
476                    Span::from("OnKey"),
477                    Span::from("Move cursor down"),
478                ],
479                vec![
480                    Span::from("KeyCode::Up"),
481                    Span::from("OnKey"),
482                    Span::from("Move cursor up"),
483                ],
484                vec![
485                    Span::from("KeyCode::PageDown"),
486                    Span::from("OnKey"),
487                    Span::from("Move cursor down by 8"),
488                ],
489                vec![
490                    Span::from("KeyCode::PageUp"),
491                    Span::from("OnKey"),
492                    Span::from("ove cursor up by 8"),
493                ],
494                vec![
495                    Span::from("KeyCode::End"),
496                    Span::from("OnKey"),
497                    Span::from("Move cursor to last item"),
498                ],
499                vec![
500                    Span::from("KeyCode::Home"),
501                    Span::from("OnKey"),
502                    Span::from("Move cursor to first item"),
503                ],
504                vec![
505                    Span::from("KeyCode::Char(_)"),
506                    Span::from("OnKey"),
507                    Span::from("Return pressed key"),
508                    Span::from("4th mysterious columns"),
509                ],
510            ]);
511        assert_eq!(component.states.list_len, 7);
512        assert_eq!(component.states.list_index, 0);
513        // Increment list index
514        component.states.list_index += 1;
515        assert_eq!(component.states.list_index, 1);
516        // Check messages
517        // Handle inputs
518        assert_eq!(
519            component.perform(Cmd::Move(Direction::Down)),
520            CmdResult::Changed(State::Single(StateValue::Usize(2)))
521        );
522        // Index should be incremented
523        assert_eq!(component.states.list_index, 2);
524        // Index should be decremented
525        assert_eq!(
526            component.perform(Cmd::Move(Direction::Up)),
527            CmdResult::Changed(State::Single(StateValue::Usize(1)))
528        );
529        // Index should be incremented
530        assert_eq!(component.states.list_index, 1);
531        // Index should be 2
532        assert_eq!(
533            component.perform(Cmd::Scroll(Direction::Down)),
534            CmdResult::Changed(State::Single(StateValue::Usize(5)))
535        );
536        // Index should be incremented
537        assert_eq!(component.states.list_index, 5);
538        assert_eq!(
539            component.perform(Cmd::Scroll(Direction::Down)),
540            CmdResult::Changed(State::Single(StateValue::Usize(6)))
541        );
542        // Index should be incremented
543        assert_eq!(component.states.list_index, 6);
544        // Index should be 0
545        assert_eq!(
546            component.perform(Cmd::Scroll(Direction::Up)),
547            CmdResult::Changed(State::Single(StateValue::Usize(2)))
548        );
549        assert_eq!(component.states.list_index, 2);
550        assert_eq!(
551            component.perform(Cmd::Scroll(Direction::Up)),
552            CmdResult::Changed(State::Single(StateValue::Usize(0)))
553        );
554        assert_eq!(component.states.list_index, 0);
555        // End
556        assert_eq!(
557            component.perform(Cmd::GoTo(Position::End)),
558            CmdResult::Changed(State::Single(StateValue::Usize(6)))
559        );
560        assert_eq!(component.states.list_index, 6);
561        // Home
562        assert_eq!(
563            component.perform(Cmd::GoTo(Position::Begin)),
564            CmdResult::Changed(State::Single(StateValue::Usize(0)))
565        );
566        assert_eq!(component.states.list_index, 0);
567        // Update
568        component.attr(
569            Attribute::Text,
570            AttrValue::Payload(PropPayload::Vec(vec![PropValue::TextLine(Line::from(
571                "name age birthdate",
572            ))])),
573        );
574        assert_eq!(component.states.list_len, 1);
575        assert_eq!(component.states.list_index, 0);
576        // Get value
577        assert_eq!(component.state(), State::Single(StateValue::Usize(0)));
578    }
579
580    #[test]
581    fn test_components_list() {
582        let component = List::default()
583            .foreground(Color::Red)
584            .background(Color::Blue)
585            .highlight_style(Style::new().fg(Color::Yellow))
586            .highlight_str("🚀")
587            .modifiers(TextModifiers::BOLD)
588            .borders(Borders::default())
589            .title(Title::from("events").alignment(HorizontalAlignment::Center))
590            .rows([
591                Line::from("KeyCode::Down OnKey Move cursor down"),
592                Line::from("KeyCode::Up OnKey Move cursor up"),
593                Line::from("KeyCode::PageDown OnKey Move cursor down by 8"),
594                Line::from("KeyCode::PageUp OnKey Move cursor up by 8"),
595                Line::from("KeyCode::End OnKey Move cursor to last item"),
596                Line::from("KeyCode::Home OnKey Move cursor to first item"),
597                Line::from("KeyCode::Char(_) OnKey Return pressed key"),
598            ]);
599        // Get value (not scrollable)
600        assert_eq!(component.state(), State::None);
601    }
602
603    #[test]
604    fn should_init_list_value() {
605        let mut component = List::default()
606            .foreground(Color::Red)
607            .background(Color::Blue)
608            .highlight_style(Style::new().fg(Color::Yellow))
609            .highlight_str("🚀")
610            .modifiers(TextModifiers::BOLD)
611            .borders(Borders::default())
612            .title(Title::from("events").alignment(HorizontalAlignment::Center))
613            .rows([
614                "KeyCode::Down OnKey Move cursor down",
615                "KeyCode::Up OnKey Move cursor up",
616                "KeyCode::PageDown OnKey Move cursor down by 8",
617                "KeyCode::PageUp OnKey Move cursor up by 8",
618                "KeyCode::End OnKey Move cursor to last item",
619                "KeyCode::Home OnKey Move cursor to first item",
620                "KeyCode::Char(_) OnKey Return pressed key",
621            ])
622            .scroll(true)
623            .selected_line(2);
624        assert_eq!(component.states.list_index, 2);
625        // Index out of bounds
626        component.attr(
627            Attribute::Value,
628            AttrValue::Payload(PropPayload::Single(PropValue::Usize(50))),
629        );
630        assert_eq!(component.states.list_index, 6);
631    }
632}