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 ontop 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 the rows of items the list should contain
188    pub fn rows<T>(mut self, rows: impl IntoIterator<Item = T>) -> Self
189    where
190        T: Into<LineStatic>,
191    {
192        self.attr(
193            Attribute::Text,
194            AttrValue::Payload(PropPayload::Vec(
195                rows.into_iter()
196                    .map(Into::into)
197                    .map(PropValue::TextLine)
198                    .collect(),
199            )),
200        );
201        self
202    }
203
204    /// Set the initially selected line.
205    pub fn selected_line(mut self, line: usize) -> Self {
206        self.attr(
207            Attribute::Value,
208            AttrValue::Payload(PropPayload::Single(PropValue::Usize(line))),
209        );
210        self
211    }
212
213    /// Set the current component to be always active (show highligh even if unfocused)
214    pub fn always_active(mut self) -> Self {
215        self.attr(Attribute::AlwaysActive, AttrValue::Flag(true));
216        self
217    }
218
219    fn scrollable(&self) -> bool {
220        self.props
221            .get(Attribute::Scroll)
222            .and_then(AttrValue::as_flag)
223            .unwrap_or_default()
224    }
225
226    fn rewindable(&self) -> bool {
227        self.props
228            .get(Attribute::Rewind)
229            .and_then(AttrValue::as_flag)
230            .unwrap_or_default()
231    }
232}
233
234impl Component for List {
235    fn view(&mut self, render: &mut Frame, area: Rect) {
236        if !self.common.display {
237            return;
238        }
239
240        // Make list entries
241        let payload = self.props.get(Attribute::Text).and_then(|x| x.as_payload());
242        let list_items: Vec<ListItem> = match payload {
243            Some(PropPayload::Vec(lines)) => {
244                lines
245                    .iter()
246                    // this will skip any "PropValue" that is not a "TextLine", instead of panicing
247                    .filter_map(|x| x.as_textline())
248                    .map(utils::borrow_clone_line)
249                    .map(ListItem::from)
250                    .collect()
251            }
252            _ => Vec::new(),
253        };
254
255        // Make the widget
256        let mut widget = TuiList::new(list_items)
257            .style(self.common.style)
258            .direction(tuirealm::ratatui::widgets::ListDirection::TopToBottom);
259
260        if self.common.is_active() {
261            widget = widget.highlight_style(self.common_hg.get_style(self.common.style));
262        }
263
264        if let Some(block) = self.common.get_block() {
265            widget = widget.block(block);
266        }
267
268        // Highlighted symbol
269        if let Some(symbol) = self.common_hg.get_symbol() {
270            widget = widget.highlight_symbol(symbol);
271        }
272
273        if self.scrollable() {
274            let mut state: ListState = ListState::default();
275            state.select(Some(self.states.list_index));
276            render.render_stateful_widget(widget, area, &mut state);
277        } else {
278            render.render_widget(widget, area);
279        }
280    }
281
282    fn query<'a>(&'a self, attr: Attribute) -> Option<QueryResult<'a>> {
283        if let Some(value) = self
284            .common
285            .get_for_query(attr)
286            .or_else(|| self.common_hg.get_for_query(attr))
287        {
288            return Some(value);
289        }
290
291        self.props.get_for_query(attr)
292    }
293
294    fn attr(&mut self, attr: Attribute, value: AttrValue) {
295        if let Some(value) = self
296            .common
297            .set(attr, value)
298            .and_then(|value| self.common_hg.set(attr, value))
299        {
300            self.props.set(attr, value);
301            if matches!(attr, Attribute::Text) {
302                // Update list len and fix index
303                self.states.set_list_len(
304                    match self
305                        .props
306                        .get(Attribute::Text)
307                        .and_then(AttrValue::as_payload)
308                        .and_then(PropPayload::as_vec)
309                    {
310                        Some(rows) => rows.len(),
311                        _ => 0,
312                    },
313                );
314                self.states.fix_list_index();
315            } else if matches!(attr, Attribute::Value) && self.scrollable() {
316                self.states.list_index = self
317                    .props
318                    .get(Attribute::Value)
319                    .and_then(AttrValue::as_payload)
320                    .and_then(PropPayload::as_single)
321                    .and_then(PropValue::as_usize)
322                    .unwrap_or_default();
323                self.states.fix_list_index();
324            }
325        }
326    }
327
328    fn state(&self) -> State {
329        if self.scrollable() {
330            State::Single(StateValue::Usize(self.states.list_index))
331        } else {
332            State::None
333        }
334    }
335
336    fn perform(&mut self, cmd: Cmd) -> CmdResult {
337        match cmd {
338            Cmd::Move(Direction::Down) => {
339                let prev = self.states.list_index;
340                self.states.incr_list_index(self.rewindable());
341                if prev == self.states.list_index {
342                    CmdResult::NoChange
343                } else {
344                    CmdResult::Changed(self.state())
345                }
346            }
347            Cmd::Move(Direction::Up) => {
348                let prev = self.states.list_index;
349                self.states.decr_list_index(self.rewindable());
350                if prev == self.states.list_index {
351                    CmdResult::NoChange
352                } else {
353                    CmdResult::Changed(self.state())
354                }
355            }
356            Cmd::Scroll(Direction::Down) => {
357                let prev = self.states.list_index;
358                let step = self
359                    .props
360                    .get(Attribute::ScrollStep)
361                    .and_then(AttrValue::as_length)
362                    .unwrap_or(8);
363                let step: usize = self.states.calc_max_step_ahead(step);
364                (0..step).for_each(|_| self.states.incr_list_index(false));
365                if prev == self.states.list_index {
366                    CmdResult::NoChange
367                } else {
368                    CmdResult::Changed(self.state())
369                }
370            }
371            Cmd::Scroll(Direction::Up) => {
372                let prev = self.states.list_index;
373                let step = self
374                    .props
375                    .get(Attribute::ScrollStep)
376                    .and_then(AttrValue::as_length)
377                    .unwrap_or(8);
378                let step: usize = self.states.calc_max_step_behind(step);
379                (0..step).for_each(|_| self.states.decr_list_index(false));
380                if prev == self.states.list_index {
381                    CmdResult::NoChange
382                } else {
383                    CmdResult::Changed(self.state())
384                }
385            }
386            Cmd::GoTo(Position::Begin) => {
387                let prev = self.states.list_index;
388                self.states.list_index_at_first();
389                if prev == self.states.list_index {
390                    CmdResult::NoChange
391                } else {
392                    CmdResult::Changed(self.state())
393                }
394            }
395            Cmd::GoTo(Position::End) => {
396                let prev = self.states.list_index;
397                self.states.list_index_at_last();
398                if prev == self.states.list_index {
399                    CmdResult::NoChange
400                } else {
401                    CmdResult::Changed(self.state())
402                }
403            }
404            _ => CmdResult::Invalid(cmd),
405        }
406    }
407}
408
409#[cfg(test)]
410mod tests {
411
412    use pretty_assertions::assert_eq;
413    use tuirealm::props::HorizontalAlignment;
414    use tuirealm::ratatui::text::{Line, Span};
415
416    use super::*;
417
418    #[test]
419    fn list_states() {
420        let mut states = ListStates::default();
421        assert_eq!(states.list_index, 0);
422        assert_eq!(states.list_len, 0);
423        states.set_list_len(5);
424        assert_eq!(states.list_index, 0);
425        assert_eq!(states.list_len, 5);
426        // Incr
427        states.incr_list_index(true);
428        assert_eq!(states.list_index, 1);
429        states.list_index = 4;
430        states.incr_list_index(false);
431        assert_eq!(states.list_index, 4);
432        states.incr_list_index(true);
433        assert_eq!(states.list_index, 0);
434        // Decr
435        states.decr_list_index(false);
436        assert_eq!(states.list_index, 0);
437        states.decr_list_index(true);
438        assert_eq!(states.list_index, 4);
439        states.decr_list_index(true);
440        assert_eq!(states.list_index, 3);
441        // Begin
442        states.list_index_at_first();
443        assert_eq!(states.list_index, 0);
444        states.list_index_at_last();
445        assert_eq!(states.list_index, 4);
446        // Fix
447        states.set_list_len(3);
448        states.fix_list_index();
449        assert_eq!(states.list_index, 2);
450    }
451
452    #[test]
453    fn test_components_list_scrollable() {
454        let mut component = List::default()
455            .foreground(Color::Red)
456            .background(Color::Blue)
457            .highlight_style(Style::new().fg(Color::Yellow))
458            .highlight_str("🚀")
459            .modifiers(TextModifiers::BOLD)
460            .scroll(true)
461            .step(4)
462            .borders(Borders::default())
463            .title(Title::from("events").alignment(HorizontalAlignment::Center))
464            .rewind(true)
465            .rows([
466                // Note: this could be improved if ratatui implements "From<[X; _]> for Line"
467                // will get automatically converted to lines
468                vec![
469                    Span::from("KeyCode::Down"),
470                    Span::from("OnKey"),
471                    Span::from("Move cursor down"),
472                ],
473                vec![
474                    Span::from("KeyCode::Up"),
475                    Span::from("OnKey"),
476                    Span::from("Move cursor up"),
477                ],
478                vec![
479                    Span::from("KeyCode::PageDown"),
480                    Span::from("OnKey"),
481                    Span::from("Move cursor down by 8"),
482                ],
483                vec![
484                    Span::from("KeyCode::PageUp"),
485                    Span::from("OnKey"),
486                    Span::from("ove cursor up by 8"),
487                ],
488                vec![
489                    Span::from("KeyCode::End"),
490                    Span::from("OnKey"),
491                    Span::from("Move cursor to last item"),
492                ],
493                vec![
494                    Span::from("KeyCode::Home"),
495                    Span::from("OnKey"),
496                    Span::from("Move cursor to first item"),
497                ],
498                vec![
499                    Span::from("KeyCode::Char(_)"),
500                    Span::from("OnKey"),
501                    Span::from("Return pressed key"),
502                    Span::from("4th mysterious columns"),
503                ],
504            ]);
505        assert_eq!(component.states.list_len, 7);
506        assert_eq!(component.states.list_index, 0);
507        // Increment list index
508        component.states.list_index += 1;
509        assert_eq!(component.states.list_index, 1);
510        // Check messages
511        // Handle inputs
512        assert_eq!(
513            component.perform(Cmd::Move(Direction::Down)),
514            CmdResult::Changed(State::Single(StateValue::Usize(2)))
515        );
516        // Index should be incremented
517        assert_eq!(component.states.list_index, 2);
518        // Index should be decremented
519        assert_eq!(
520            component.perform(Cmd::Move(Direction::Up)),
521            CmdResult::Changed(State::Single(StateValue::Usize(1)))
522        );
523        // Index should be incremented
524        assert_eq!(component.states.list_index, 1);
525        // Index should be 2
526        assert_eq!(
527            component.perform(Cmd::Scroll(Direction::Down)),
528            CmdResult::Changed(State::Single(StateValue::Usize(5)))
529        );
530        // Index should be incremented
531        assert_eq!(component.states.list_index, 5);
532        assert_eq!(
533            component.perform(Cmd::Scroll(Direction::Down)),
534            CmdResult::Changed(State::Single(StateValue::Usize(6)))
535        );
536        // Index should be incremented
537        assert_eq!(component.states.list_index, 6);
538        // Index should be 0
539        assert_eq!(
540            component.perform(Cmd::Scroll(Direction::Up)),
541            CmdResult::Changed(State::Single(StateValue::Usize(2)))
542        );
543        assert_eq!(component.states.list_index, 2);
544        assert_eq!(
545            component.perform(Cmd::Scroll(Direction::Up)),
546            CmdResult::Changed(State::Single(StateValue::Usize(0)))
547        );
548        assert_eq!(component.states.list_index, 0);
549        // End
550        assert_eq!(
551            component.perform(Cmd::GoTo(Position::End)),
552            CmdResult::Changed(State::Single(StateValue::Usize(6)))
553        );
554        assert_eq!(component.states.list_index, 6);
555        // Home
556        assert_eq!(
557            component.perform(Cmd::GoTo(Position::Begin)),
558            CmdResult::Changed(State::Single(StateValue::Usize(0)))
559        );
560        assert_eq!(component.states.list_index, 0);
561        // Update
562        component.attr(
563            Attribute::Text,
564            AttrValue::Payload(PropPayload::Vec(vec![PropValue::TextLine(Line::from(
565                "name age birthdate",
566            ))])),
567        );
568        assert_eq!(component.states.list_len, 1);
569        assert_eq!(component.states.list_index, 0);
570        // Get value
571        assert_eq!(component.state(), State::Single(StateValue::Usize(0)));
572    }
573
574    #[test]
575    fn test_components_list() {
576        let component = List::default()
577            .foreground(Color::Red)
578            .background(Color::Blue)
579            .highlight_style(Style::new().fg(Color::Yellow))
580            .highlight_str("🚀")
581            .modifiers(TextModifiers::BOLD)
582            .borders(Borders::default())
583            .title(Title::from("events").alignment(HorizontalAlignment::Center))
584            .rows([
585                Line::from("KeyCode::Down OnKey Move cursor down"),
586                Line::from("KeyCode::Up OnKey Move cursor up"),
587                Line::from("KeyCode::PageDown OnKey Move cursor down by 8"),
588                Line::from("KeyCode::PageUp OnKey Move cursor up by 8"),
589                Line::from("KeyCode::End OnKey Move cursor to last item"),
590                Line::from("KeyCode::Home OnKey Move cursor to first item"),
591                Line::from("KeyCode::Char(_) OnKey Return pressed key"),
592            ]);
593        // Get value (not scrollable)
594        assert_eq!(component.state(), State::None);
595    }
596
597    #[test]
598    fn should_init_list_value() {
599        let mut component = List::default()
600            .foreground(Color::Red)
601            .background(Color::Blue)
602            .highlight_style(Style::new().fg(Color::Yellow))
603            .highlight_str("🚀")
604            .modifiers(TextModifiers::BOLD)
605            .borders(Borders::default())
606            .title(Title::from("events").alignment(HorizontalAlignment::Center))
607            .rows([
608                "KeyCode::Down OnKey Move cursor down",
609                "KeyCode::Up OnKey Move cursor up",
610                "KeyCode::PageDown OnKey Move cursor down by 8",
611                "KeyCode::PageUp OnKey Move cursor up by 8",
612                "KeyCode::End OnKey Move cursor to last item",
613                "KeyCode::Home OnKey Move cursor to first item",
614                "KeyCode::Char(_) OnKey Return pressed key",
615            ])
616            .scroll(true)
617            .selected_line(2);
618        assert_eq!(component.states.list_index, 2);
619        // Index out of bounds
620        component.attr(
621            Attribute::Value,
622            AttrValue::Payload(PropPayload::Single(PropValue::Usize(50))),
623        );
624        assert_eq!(component.states.list_index, 6);
625    }
626}