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