tui_realm_stdlib/components/
list.rs

1//! ## List
2//!
3//! `List` represents a read-only textual list component which can be scrollable through arrows or inactive
4
5use tuirealm::command::{Cmd, CmdResult, Direction, Position};
6use tuirealm::props::{
7    Alignment, AttrValue, Attribute, Borders, Color, PropPayload, PropValue, Props, Style, Table,
8    TextModifiers,
9};
10use tuirealm::ratatui::text::Line as Spans;
11use tuirealm::ratatui::{
12    layout::Rect,
13    text::Span,
14    widgets::{List as TuiList, ListItem, ListState},
15};
16use tuirealm::{Frame, MockComponent, State, StateValue};
17
18// -- States
19
20#[derive(Default)]
21pub struct ListStates {
22    pub list_index: usize, // Index of selected item in list
23    pub list_len: usize,   // Lines in text area
24}
25
26impl ListStates {
27    /// ### set_list_len
28    ///
29    /// Set list length
30    pub fn set_list_len(&mut self, len: usize) {
31        self.list_len = len;
32    }
33
34    /// ### incr_list_index
35    ///
36    /// Incremenet list index
37    pub fn incr_list_index(&mut self, rewind: bool) {
38        // Check if index is at last element
39        if self.list_index + 1 < self.list_len {
40            self.list_index += 1;
41        } else if rewind {
42            self.list_index = 0;
43        }
44    }
45
46    /// ### decr_list_index
47    ///
48    /// Decrement list index
49    pub fn decr_list_index(&mut self, rewind: bool) {
50        // Check if index is bigger than 0
51        if self.list_index > 0 {
52            self.list_index -= 1;
53        } else if rewind && self.list_len > 0 {
54            self.list_index = self.list_len - 1;
55        }
56    }
57
58    /// ### fix_list_index
59    ///
60    /// Keep index if possible, otherwise set to lenght - 1
61    pub fn fix_list_index(&mut self) {
62        if self.list_index >= self.list_len && self.list_len > 0 {
63            self.list_index = self.list_len - 1;
64        } else if self.list_len == 0 {
65            self.list_index = 0;
66        }
67    }
68
69    /// ### list_index_at_first
70    ///
71    /// Set list index to the first item in the list
72    pub fn list_index_at_first(&mut self) {
73        self.list_index = 0;
74    }
75
76    /// ### list_index_at_last
77    ///
78    /// Set list index at the last item of the list
79    pub fn list_index_at_last(&mut self) {
80        if self.list_len > 0 {
81            self.list_index = self.list_len - 1;
82        } else {
83            self.list_index = 0;
84        }
85    }
86
87    /// ### calc_max_step_ahead
88    ///
89    /// Calculate the max step ahead to scroll list
90    #[must_use]
91    pub fn calc_max_step_ahead(&self, max: usize) -> usize {
92        let remaining: usize = match self.list_len {
93            0 => 0,
94            len => len - 1 - self.list_index,
95        };
96        if remaining > max { max } else { remaining }
97    }
98
99    /// ### calc_max_step_ahead
100    ///
101    /// Calculate the max step ahead to scroll list
102    #[must_use]
103    pub fn calc_max_step_behind(&self, max: usize) -> usize {
104        if self.list_index > max {
105            max
106        } else {
107            self.list_index
108        }
109    }
110}
111
112// -- Component
113
114/// ## List
115///
116/// represents a read-only text component without any container.
117#[derive(Default)]
118#[must_use]
119pub struct List {
120    props: Props,
121    pub states: ListStates,
122}
123
124impl List {
125    pub fn foreground(mut self, fg: Color) -> Self {
126        self.attr(Attribute::Foreground, AttrValue::Color(fg));
127        self
128    }
129
130    pub fn background(mut self, bg: Color) -> Self {
131        self.attr(Attribute::Background, AttrValue::Color(bg));
132        self
133    }
134
135    pub fn modifiers(mut self, m: TextModifiers) -> Self {
136        self.attr(Attribute::TextProps, AttrValue::TextModifiers(m));
137        self
138    }
139
140    pub fn borders(mut self, b: Borders) -> Self {
141        self.attr(Attribute::Borders, AttrValue::Borders(b));
142        self
143    }
144
145    pub fn title<S: Into<String>>(mut self, t: S, a: Alignment) -> Self {
146        self.attr(Attribute::Title, AttrValue::Title((t.into(), a)));
147        self
148    }
149
150    pub fn inactive(mut self, s: Style) -> Self {
151        self.attr(Attribute::FocusStyle, AttrValue::Style(s));
152        self
153    }
154
155    pub fn rewind(mut self, r: bool) -> Self {
156        self.attr(Attribute::Rewind, AttrValue::Flag(r));
157        self
158    }
159
160    pub fn step(mut self, step: usize) -> Self {
161        self.attr(Attribute::ScrollStep, AttrValue::Length(step));
162        self
163    }
164
165    pub fn scroll(mut self, scrollable: bool) -> Self {
166        self.attr(Attribute::Scroll, AttrValue::Flag(scrollable));
167        self
168    }
169
170    pub fn highlighted_str<S: Into<String>>(mut self, s: S) -> Self {
171        self.attr(Attribute::HighlightedStr, AttrValue::String(s.into()));
172        self
173    }
174
175    pub fn highlighted_color(mut self, c: Color) -> Self {
176        self.attr(Attribute::HighlightedColor, AttrValue::Color(c));
177        self
178    }
179
180    pub fn rows(mut self, rows: Table) -> Self {
181        self.attr(Attribute::Content, AttrValue::Table(rows));
182        self
183    }
184
185    /// Set initial selected line
186    /// This method must be called after `rows` and `scrollable` in order to work
187    pub fn selected_line(mut self, line: usize) -> Self {
188        self.attr(
189            Attribute::Value,
190            AttrValue::Payload(PropPayload::One(PropValue::Usize(line))),
191        );
192        self
193    }
194
195    fn scrollable(&self) -> bool {
196        self.props
197            .get_or(Attribute::Scroll, AttrValue::Flag(false))
198            .unwrap_flag()
199    }
200
201    fn rewindable(&self) -> bool {
202        self.props
203            .get_or(Attribute::Rewind, AttrValue::Flag(false))
204            .unwrap_flag()
205    }
206}
207
208impl MockComponent for List {
209    fn view(&mut self, render: &mut Frame, area: Rect) {
210        if self.props.get_or(Attribute::Display, AttrValue::Flag(true)) == AttrValue::Flag(true) {
211            let foreground = self
212                .props
213                .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset))
214                .unwrap_color();
215            let background = self
216                .props
217                .get_or(Attribute::Background, AttrValue::Color(Color::Reset))
218                .unwrap_color();
219            let modifiers = self
220                .props
221                .get_or(
222                    Attribute::TextProps,
223                    AttrValue::TextModifiers(TextModifiers::empty()),
224                )
225                .unwrap_text_modifiers();
226            let title = crate::utils::get_title_or_center(&self.props);
227            let borders = self
228                .props
229                .get_or(Attribute::Borders, AttrValue::Borders(Borders::default()))
230                .unwrap_borders();
231            let focus = self
232                .props
233                .get_or(Attribute::Focus, AttrValue::Flag(false))
234                .unwrap_flag();
235            let inactive_style = self
236                .props
237                .get(Attribute::FocusStyle)
238                .map(|x| x.unwrap_style());
239            let active: bool = if self.scrollable() { focus } else { true };
240            let div = crate::utils::get_block(borders, Some(&title), active, inactive_style);
241            // Make list entries
242            let list_items: Vec<ListItem> = match self
243                .props
244                .get_ref(Attribute::Content)
245                .and_then(|x| x.as_table())
246            {
247                Some(table) => table
248                    .iter()
249                    .map(|row| {
250                        let columns: Vec<Span> = row
251                            .iter()
252                            .map(|col| {
253                                let (fg, bg, modifiers) =
254                                    crate::utils::use_or_default_styles(&self.props, col);
255                                Span::styled(
256                                    &col.content,
257                                    Style::default().add_modifier(modifiers).fg(fg).bg(bg),
258                                )
259                            })
260                            .collect();
261                        ListItem::new(Spans::from(columns))
262                    })
263                    .collect(), // Make List item from TextSpan
264                _ => Vec::new(),
265            };
266            let highlighted_color = self
267                .props
268                .get(Attribute::HighlightedColor)
269                .map(|x| x.unwrap_color());
270            let modifiers = if focus {
271                modifiers | TextModifiers::REVERSED
272            } else {
273                modifiers
274            };
275            // Make list
276
277            let mut list = TuiList::new(list_items)
278                .block(div)
279                .style(Style::default().fg(foreground).bg(background))
280                .direction(tuirealm::ratatui::widgets::ListDirection::TopToBottom);
281            if let Some(highlighted_color) = highlighted_color {
282                list = list.highlight_style(
283                    Style::default()
284                        .fg(highlighted_color)
285                        .add_modifier(modifiers),
286                );
287            }
288            // Highlighted symbol
289            let hg_str = self
290                .props
291                .get_ref(Attribute::HighlightedStr)
292                .and_then(|x| x.as_string());
293            if let Some(hg_str) = hg_str {
294                list = list.highlight_symbol(hg_str);
295            }
296            if self.scrollable() {
297                let mut state: ListState = ListState::default();
298                state.select(Some(self.states.list_index));
299                render.render_stateful_widget(list, area, &mut state);
300            } else {
301                render.render_widget(list, area);
302            }
303        }
304    }
305
306    fn query(&self, attr: Attribute) -> Option<AttrValue> {
307        self.props.get(attr)
308    }
309
310    fn attr(&mut self, attr: Attribute, value: AttrValue) {
311        self.props.set(attr, value);
312        if matches!(attr, Attribute::Content) {
313            // Update list len and fix index
314            self.states.set_list_len(
315                match self.props.get(Attribute::Content).map(|x| x.unwrap_table()) {
316                    Some(spans) => spans.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                .map_or(0, |x| x.unwrap_payload().unwrap_one().unwrap_usize());
326            self.states.fix_list_index();
327        }
328    }
329
330    fn state(&self) -> State {
331        if self.scrollable() {
332            State::One(StateValue::Usize(self.states.list_index))
333        } else {
334            State::None
335        }
336    }
337
338    fn perform(&mut self, cmd: Cmd) -> CmdResult {
339        match cmd {
340            Cmd::Move(Direction::Down) => {
341                let prev = self.states.list_index;
342                self.states.incr_list_index(self.rewindable());
343                if prev == self.states.list_index {
344                    CmdResult::None
345                } else {
346                    CmdResult::Changed(self.state())
347                }
348            }
349            Cmd::Move(Direction::Up) => {
350                let prev = self.states.list_index;
351                self.states.decr_list_index(self.rewindable());
352                if prev == self.states.list_index {
353                    CmdResult::None
354                } else {
355                    CmdResult::Changed(self.state())
356                }
357            }
358            Cmd::Scroll(Direction::Down) => {
359                let prev = self.states.list_index;
360                let step = self
361                    .props
362                    .get_or(Attribute::ScrollStep, AttrValue::Length(8))
363                    .unwrap_length();
364                let step: usize = self.states.calc_max_step_ahead(step);
365                (0..step).for_each(|_| self.states.incr_list_index(false));
366                if prev == self.states.list_index {
367                    CmdResult::None
368                } else {
369                    CmdResult::Changed(self.state())
370                }
371            }
372            Cmd::Scroll(Direction::Up) => {
373                let prev = self.states.list_index;
374                let step = self
375                    .props
376                    .get_or(Attribute::ScrollStep, AttrValue::Length(8))
377                    .unwrap_length();
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::None
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::None
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::None
400                } else {
401                    CmdResult::Changed(self.state())
402                }
403            }
404            _ => CmdResult::None,
405        }
406    }
407}
408
409#[cfg(test)]
410mod tests {
411
412    use super::*;
413    use pretty_assertions::assert_eq;
414    use tuirealm::props::{TableBuilder, TextSpan};
415
416    #[test]
417    fn list_states() {
418        let mut states = ListStates::default();
419        assert_eq!(states.list_index, 0);
420        assert_eq!(states.list_len, 0);
421        states.set_list_len(5);
422        assert_eq!(states.list_index, 0);
423        assert_eq!(states.list_len, 5);
424        // Incr
425        states.incr_list_index(true);
426        assert_eq!(states.list_index, 1);
427        states.list_index = 4;
428        states.incr_list_index(false);
429        assert_eq!(states.list_index, 4);
430        states.incr_list_index(true);
431        assert_eq!(states.list_index, 0);
432        // Decr
433        states.decr_list_index(false);
434        assert_eq!(states.list_index, 0);
435        states.decr_list_index(true);
436        assert_eq!(states.list_index, 4);
437        states.decr_list_index(true);
438        assert_eq!(states.list_index, 3);
439        // Begin
440        states.list_index_at_first();
441        assert_eq!(states.list_index, 0);
442        states.list_index_at_last();
443        assert_eq!(states.list_index, 4);
444        // Fix
445        states.set_list_len(3);
446        states.fix_list_index();
447        assert_eq!(states.list_index, 2);
448    }
449
450    #[test]
451    fn test_components_list_scrollable() {
452        let mut component = List::default()
453            .foreground(Color::Red)
454            .background(Color::Blue)
455            .highlighted_color(Color::Yellow)
456            .highlighted_str("🚀")
457            .modifiers(TextModifiers::BOLD)
458            .scroll(true)
459            .step(4)
460            .borders(Borders::default())
461            .title("events", Alignment::Center)
462            .rewind(true)
463            .rows(
464                TableBuilder::default()
465                    .add_col(TextSpan::from("KeyCode::Down"))
466                    .add_col(TextSpan::from("OnKey"))
467                    .add_col(TextSpan::from("Move cursor down"))
468                    .add_row()
469                    .add_col(TextSpan::from("KeyCode::Up"))
470                    .add_col(TextSpan::from("OnKey"))
471                    .add_col(TextSpan::from("Move cursor up"))
472                    .add_row()
473                    .add_col(TextSpan::from("KeyCode::PageDown"))
474                    .add_col(TextSpan::from("OnKey"))
475                    .add_col(TextSpan::from("Move cursor down by 8"))
476                    .add_row()
477                    .add_col(TextSpan::from("KeyCode::PageUp"))
478                    .add_col(TextSpan::from("OnKey"))
479                    .add_col(TextSpan::from("ove cursor up by 8"))
480                    .add_row()
481                    .add_col(TextSpan::from("KeyCode::End"))
482                    .add_col(TextSpan::from("OnKey"))
483                    .add_col(TextSpan::from("Move cursor to last item"))
484                    .add_row()
485                    .add_col(TextSpan::from("KeyCode::Home"))
486                    .add_col(TextSpan::from("OnKey"))
487                    .add_col(TextSpan::from("Move cursor to first item"))
488                    .add_row()
489                    .add_col(TextSpan::from("KeyCode::Char(_)"))
490                    .add_col(TextSpan::from("OnKey"))
491                    .add_col(TextSpan::from("Return pressed key"))
492                    .add_col(TextSpan::from("4th mysterious columns"))
493                    .build(),
494            );
495        assert_eq!(component.states.list_len, 7);
496        assert_eq!(component.states.list_index, 0);
497        // Increment list index
498        component.states.list_index += 1;
499        assert_eq!(component.states.list_index, 1);
500        // Check messages
501        // Handle inputs
502        assert_eq!(
503            component.perform(Cmd::Move(Direction::Down)),
504            CmdResult::Changed(State::One(StateValue::Usize(2)))
505        );
506        // Index should be incremented
507        assert_eq!(component.states.list_index, 2);
508        // Index should be decremented
509        assert_eq!(
510            component.perform(Cmd::Move(Direction::Up)),
511            CmdResult::Changed(State::One(StateValue::Usize(1)))
512        );
513        // Index should be incremented
514        assert_eq!(component.states.list_index, 1);
515        // Index should be 2
516        assert_eq!(
517            component.perform(Cmd::Scroll(Direction::Down)),
518            CmdResult::Changed(State::One(StateValue::Usize(5)))
519        );
520        // Index should be incremented
521        assert_eq!(component.states.list_index, 5);
522        assert_eq!(
523            component.perform(Cmd::Scroll(Direction::Down)),
524            CmdResult::Changed(State::One(StateValue::Usize(6)))
525        );
526        // Index should be incremented
527        assert_eq!(component.states.list_index, 6);
528        // Index should be 0
529        assert_eq!(
530            component.perform(Cmd::Scroll(Direction::Up)),
531            CmdResult::Changed(State::One(StateValue::Usize(2)))
532        );
533        assert_eq!(component.states.list_index, 2);
534        assert_eq!(
535            component.perform(Cmd::Scroll(Direction::Up)),
536            CmdResult::Changed(State::One(StateValue::Usize(0)))
537        );
538        assert_eq!(component.states.list_index, 0);
539        // End
540        assert_eq!(
541            component.perform(Cmd::GoTo(Position::End)),
542            CmdResult::Changed(State::One(StateValue::Usize(6)))
543        );
544        assert_eq!(component.states.list_index, 6);
545        // Home
546        assert_eq!(
547            component.perform(Cmd::GoTo(Position::Begin)),
548            CmdResult::Changed(State::One(StateValue::Usize(0)))
549        );
550        assert_eq!(component.states.list_index, 0);
551        // Update
552        component.attr(
553            Attribute::Content,
554            AttrValue::Table(
555                TableBuilder::default()
556                    .add_col(TextSpan::from("name"))
557                    .add_col(TextSpan::from("age"))
558                    .add_col(TextSpan::from("birthdate"))
559                    .build(),
560            ),
561        );
562        assert_eq!(component.states.list_len, 1);
563        assert_eq!(component.states.list_index, 0);
564        // Get value
565        assert_eq!(component.state(), State::One(StateValue::Usize(0)));
566    }
567
568    #[test]
569    fn test_components_list() {
570        let component = List::default()
571            .foreground(Color::Red)
572            .background(Color::Blue)
573            .highlighted_color(Color::Yellow)
574            .highlighted_str("🚀")
575            .modifiers(TextModifiers::BOLD)
576            .borders(Borders::default())
577            .title("events", Alignment::Center)
578            .rows(
579                TableBuilder::default()
580                    .add_col(TextSpan::from("KeyCode::Down"))
581                    .add_col(TextSpan::from("OnKey"))
582                    .add_col(TextSpan::from("Move cursor down"))
583                    .add_row()
584                    .add_col(TextSpan::from("KeyCode::Up"))
585                    .add_col(TextSpan::from("OnKey"))
586                    .add_col(TextSpan::from("Move cursor up"))
587                    .add_row()
588                    .add_col(TextSpan::from("KeyCode::PageDown"))
589                    .add_col(TextSpan::from("OnKey"))
590                    .add_col(TextSpan::from("Move cursor down by 8"))
591                    .add_row()
592                    .add_col(TextSpan::from("KeyCode::PageUp"))
593                    .add_col(TextSpan::from("OnKey"))
594                    .add_col(TextSpan::from("ove cursor up by 8"))
595                    .add_row()
596                    .add_col(TextSpan::from("KeyCode::End"))
597                    .add_col(TextSpan::from("OnKey"))
598                    .add_col(TextSpan::from("Move cursor to last item"))
599                    .add_row()
600                    .add_col(TextSpan::from("KeyCode::Home"))
601                    .add_col(TextSpan::from("OnKey"))
602                    .add_col(TextSpan::from("Move cursor to first item"))
603                    .add_row()
604                    .add_col(TextSpan::from("KeyCode::Char(_)"))
605                    .add_col(TextSpan::from("OnKey"))
606                    .add_col(TextSpan::from("Return pressed key"))
607                    .build(),
608            );
609        // Get value (not scrollable)
610        assert_eq!(component.state(), State::None);
611    }
612
613    #[test]
614    fn should_init_list_value() {
615        let mut component = List::default()
616            .foreground(Color::Red)
617            .background(Color::Blue)
618            .highlighted_color(Color::Yellow)
619            .highlighted_str("🚀")
620            .modifiers(TextModifiers::BOLD)
621            .borders(Borders::default())
622            .title("events", Alignment::Center)
623            .rows(
624                TableBuilder::default()
625                    .add_col(TextSpan::from("KeyCode::Down"))
626                    .add_col(TextSpan::from("OnKey"))
627                    .add_col(TextSpan::from("Move cursor down"))
628                    .add_row()
629                    .add_col(TextSpan::from("KeyCode::Up"))
630                    .add_col(TextSpan::from("OnKey"))
631                    .add_col(TextSpan::from("Move cursor up"))
632                    .add_row()
633                    .add_col(TextSpan::from("KeyCode::PageDown"))
634                    .add_col(TextSpan::from("OnKey"))
635                    .add_col(TextSpan::from("Move cursor down by 8"))
636                    .add_row()
637                    .add_col(TextSpan::from("KeyCode::PageUp"))
638                    .add_col(TextSpan::from("OnKey"))
639                    .add_col(TextSpan::from("ove cursor up by 8"))
640                    .add_row()
641                    .add_col(TextSpan::from("KeyCode::End"))
642                    .add_col(TextSpan::from("OnKey"))
643                    .add_col(TextSpan::from("Move cursor to last item"))
644                    .add_row()
645                    .add_col(TextSpan::from("KeyCode::Home"))
646                    .add_col(TextSpan::from("OnKey"))
647                    .add_col(TextSpan::from("Move cursor to first item"))
648                    .add_row()
649                    .add_col(TextSpan::from("KeyCode::Char(_)"))
650                    .add_col(TextSpan::from("OnKey"))
651                    .add_col(TextSpan::from("Return pressed key"))
652                    .build(),
653            )
654            .scroll(true)
655            .selected_line(2);
656        assert_eq!(component.states.list_index, 2);
657        // Index out of bounds
658        component.attr(
659            Attribute::Value,
660            AttrValue::Payload(PropPayload::One(PropValue::Usize(50))),
661        );
662        assert_eq!(component.states.list_index, 6);
663    }
664}