tui_realm_stdlib/components/
textarea.rs

1//! ## Textarea
2//!
3//! `Textarea` represents a read-only text component inside a container, the text is wrapped inside the container automatically
4//! using the [textwrap](https://docs.rs/textwrap/0.13.4/textwrap/) crate.
5//! The textarea supports multi-style spans and it is scrollable with arrows.
6
7extern crate unicode_width;
8
9use tuirealm::command::{Cmd, CmdResult, Direction, Position};
10use tuirealm::props::{
11    Alignment, AttrValue, Attribute, Borders, Color, PropPayload, PropValue, Props, Style,
12    TextModifiers, TextSpan,
13};
14use tuirealm::ratatui::{
15    layout::Rect,
16    widgets::{List, ListItem, ListState},
17};
18use tuirealm::{Frame, MockComponent, State};
19use unicode_width::UnicodeWidthStr;
20
21// -- States
22
23#[derive(Default)]
24pub struct TextareaStates {
25    pub list_index: usize, // Index of selected item in textarea
26    pub list_len: usize,   // Lines in text area
27}
28
29impl TextareaStates {
30    /// ### set_list_len
31    ///
32    /// Set list length and fix list index
33    pub fn set_list_len(&mut self, len: usize) {
34        self.list_len = len;
35        self.fix_list_index();
36    }
37
38    /// ### incr_list_index
39    ///
40    /// Incremenet list index
41    pub fn incr_list_index(&mut self) {
42        // Check if index is at last element
43        if self.list_index + 1 < self.list_len {
44            self.list_index += 1;
45        }
46    }
47
48    /// ### decr_list_index
49    ///
50    /// Decrement list index
51    pub fn decr_list_index(&mut self) {
52        // Check if index is bigger than 0
53        if self.list_index > 0 {
54            self.list_index -= 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    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 { max } else { remaining }
96    }
97
98    /// ### calc_max_step_ahead
99    ///
100    /// Calculate the max step ahead to scroll list
101    fn calc_max_step_behind(&self, max: usize) -> usize {
102        if self.list_index > max {
103            max
104        } else {
105            self.list_index
106        }
107    }
108}
109
110// -- Component
111
112/// ## Textarea
113///
114/// represents a read-only text component without any container.
115#[derive(Default)]
116#[must_use]
117pub struct Textarea {
118    props: Props,
119    pub states: TextareaStates,
120}
121
122impl Textarea {
123    pub fn foreground(mut self, fg: Color) -> Self {
124        self.attr(Attribute::Foreground, AttrValue::Color(fg));
125        self
126    }
127
128    pub fn background(mut self, bg: Color) -> Self {
129        self.attr(Attribute::Background, AttrValue::Color(bg));
130        self
131    }
132
133    pub fn inactive(mut self, s: Style) -> Self {
134        self.attr(Attribute::FocusStyle, AttrValue::Style(s));
135        self
136    }
137
138    pub fn modifiers(mut self, m: TextModifiers) -> Self {
139        self.attr(Attribute::TextProps, AttrValue::TextModifiers(m));
140        self
141    }
142
143    pub fn borders(mut self, b: Borders) -> Self {
144        self.attr(Attribute::Borders, AttrValue::Borders(b));
145        self
146    }
147
148    pub fn title<S: Into<String>>(mut self, t: S, a: Alignment) -> Self {
149        self.attr(Attribute::Title, AttrValue::Title((t.into(), a)));
150        self
151    }
152
153    pub fn step(mut self, step: usize) -> Self {
154        self.attr(Attribute::ScrollStep, AttrValue::Length(step));
155        self
156    }
157
158    pub fn highlighted_str<S: Into<String>>(mut self, s: S) -> Self {
159        self.attr(Attribute::HighlightedStr, AttrValue::String(s.into()));
160        self
161    }
162
163    pub fn text_rows(mut self, s: impl IntoIterator<Item = TextSpan>) -> Self {
164        let rows: Vec<PropValue> = s.into_iter().map(PropValue::TextSpan).collect();
165        self.states.set_list_len(rows.len());
166        self.attr(Attribute::Text, AttrValue::Payload(PropPayload::Vec(rows)));
167        self
168    }
169}
170
171impl MockComponent for Textarea {
172    fn view(&mut self, render: &mut Frame, area: Rect) {
173        // Make a Span
174        if self.props.get_or(Attribute::Display, AttrValue::Flag(true)) == AttrValue::Flag(true) {
175            // Make text items
176            // Highlighted symbol
177            let hg_str = self
178                .props
179                .get_ref(Attribute::HighlightedStr)
180                .and_then(|x| x.as_string());
181            // NOTE: wrap width is width of area minus 2 (block) minus width of highlighting string
182            let wrap_width = (area.width as usize) - hg_str.as_ref().map_or(0, |x| x.width()) - 2;
183            let lines: Vec<ListItem> = match self
184                .props
185                .get_ref(Attribute::Text)
186                .and_then(|x| x.as_payload())
187            {
188                Some(PropPayload::Vec(spans)) => spans
189                    .iter()
190                    // this will skip any "PropValue" that is not a "TextSpan", instead of panicing
191                    .filter_map(|x| x.as_text_span())
192                    .map(|x| crate::utils::wrap_spans(&[x], wrap_width, &self.props))
193                    .map(ListItem::new)
194                    .collect(),
195                _ => Vec::new(),
196            };
197            let foreground = self
198                .props
199                .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset))
200                .unwrap_color();
201            let background = self
202                .props
203                .get_or(Attribute::Background, AttrValue::Color(Color::Reset))
204                .unwrap_color();
205            let modifiers = self
206                .props
207                .get_or(
208                    Attribute::TextProps,
209                    AttrValue::TextModifiers(TextModifiers::empty()),
210                )
211                .unwrap_text_modifiers();
212            let title = crate::utils::get_title_or_center(&self.props);
213            let borders = self
214                .props
215                .get_or(Attribute::Borders, AttrValue::Borders(Borders::default()))
216                .unwrap_borders();
217            let focus = self
218                .props
219                .get_or(Attribute::Focus, AttrValue::Flag(false))
220                .unwrap_flag();
221            let inactive_style = self
222                .props
223                .get(Attribute::FocusStyle)
224                .map(|x| x.unwrap_style());
225            let mut state: ListState = ListState::default();
226            state.select(Some(self.states.list_index));
227            // Make component
228
229            let mut list = List::new(lines)
230                .block(crate::utils::get_block(
231                    borders,
232                    Some(&title),
233                    focus,
234                    inactive_style,
235                ))
236                .direction(tuirealm::ratatui::widgets::ListDirection::TopToBottom)
237                .style(
238                    Style::default()
239                        .fg(foreground)
240                        .bg(background)
241                        .add_modifier(modifiers),
242                );
243
244            if let Some(hg_str) = hg_str {
245                list = list.highlight_symbol(hg_str);
246            }
247            render.render_stateful_widget(list, area, &mut state);
248        }
249    }
250
251    fn query(&self, attr: Attribute) -> Option<AttrValue> {
252        self.props.get(attr)
253    }
254
255    fn attr(&mut self, attr: Attribute, value: AttrValue) {
256        self.props.set(attr, value);
257        // Update list len and fix index
258        self.states.set_list_len(
259            match self.props.get(Attribute::Text).map(|x| x.unwrap_payload()) {
260                Some(PropPayload::Vec(spans)) => spans.len(),
261                _ => 0,
262            },
263        );
264        self.states.fix_list_index();
265    }
266
267    fn state(&self) -> State {
268        State::None
269    }
270
271    fn perform(&mut self, cmd: Cmd) -> CmdResult {
272        match cmd {
273            Cmd::Move(Direction::Down) => {
274                self.states.incr_list_index();
275            }
276            Cmd::Move(Direction::Up) => {
277                self.states.decr_list_index();
278            }
279            Cmd::Scroll(Direction::Down) => {
280                let step = self
281                    .props
282                    .get_or(Attribute::ScrollStep, AttrValue::Length(8))
283                    .unwrap_length();
284                let step = self.states.calc_max_step_ahead(step);
285                (0..step).for_each(|_| self.states.incr_list_index());
286            }
287            Cmd::Scroll(Direction::Up) => {
288                let step = self
289                    .props
290                    .get_or(Attribute::ScrollStep, AttrValue::Length(8))
291                    .unwrap_length();
292                let step = self.states.calc_max_step_behind(step);
293                (0..step).for_each(|_| self.states.decr_list_index());
294            }
295            Cmd::GoTo(Position::Begin) => {
296                self.states.list_index_at_first();
297            }
298            Cmd::GoTo(Position::End) => {
299                self.states.list_index_at_last();
300            }
301            _ => {}
302        }
303        CmdResult::None
304    }
305}
306
307#[cfg(test)]
308mod tests {
309
310    use super::*;
311
312    use pretty_assertions::assert_eq;
313
314    #[test]
315    fn test_components_textarea() {
316        // Make component
317        let mut component = Textarea::default()
318            .foreground(Color::Red)
319            .background(Color::Blue)
320            .modifiers(TextModifiers::BOLD)
321            .borders(Borders::default())
322            .highlighted_str("🚀")
323            .step(4)
324            .title("textarea", Alignment::Center)
325            .text_rows([TextSpan::from("welcome to "), TextSpan::from("tui-realm")]);
326        // Increment list index
327        component.states.list_index += 1;
328        assert_eq!(component.states.list_index, 1);
329        // Add one row
330        component.attr(
331            Attribute::Text,
332            AttrValue::Payload(PropPayload::Vec(vec![
333                PropValue::TextSpan(TextSpan::from("welcome")),
334                PropValue::TextSpan(TextSpan::from("to")),
335                PropValue::TextSpan(TextSpan::from("tui-realm")),
336            ])),
337        );
338        // Verify states
339        assert_eq!(component.states.list_index, 1); // Kept
340        assert_eq!(component.states.list_len, 3);
341        // get value
342        assert_eq!(component.state(), State::None);
343        // Render
344        assert_eq!(component.states.list_index, 1);
345        // Handle inputs
346        assert_eq!(
347            component.perform(Cmd::Move(Direction::Down)),
348            CmdResult::None
349        );
350        // Index should be incremented
351        assert_eq!(component.states.list_index, 2);
352        // Index should be decremented
353        assert_eq!(component.perform(Cmd::Move(Direction::Up)), CmdResult::None);
354        // Index should be incremented
355        assert_eq!(component.states.list_index, 1);
356        // Index should be 2
357        assert_eq!(
358            component.perform(Cmd::Scroll(Direction::Down)),
359            CmdResult::None
360        );
361        // Index should be incremented
362        assert_eq!(component.states.list_index, 2);
363        // Index should be 0
364        assert_eq!(
365            component.perform(Cmd::Scroll(Direction::Up)),
366            CmdResult::None
367        );
368        // End
369        assert_eq!(component.perform(Cmd::GoTo(Position::End)), CmdResult::None);
370        assert_eq!(component.states.list_index, 2);
371        // Home
372        assert_eq!(
373            component.perform(Cmd::GoTo(Position::Begin)),
374            CmdResult::None
375        );
376        // Index should be incremented
377        assert_eq!(component.states.list_index, 0);
378        // On key
379        assert_eq!(component.perform(Cmd::Delete), CmdResult::None);
380    }
381
382    #[test]
383    fn various_textrows_types() {
384        // Vec
385        let _ = Textarea::default().text_rows(vec![TextSpan::new("hello")]);
386        // static array
387        let _ = Textarea::default().text_rows([TextSpan::new("hello")]);
388        // boxed array
389        let _ = Textarea::default().text_rows(vec![TextSpan::new("hello")].into_boxed_slice());
390        // already a iterator
391        let _ = Textarea::default().text_rows(["Hello"].map(TextSpan::new));
392    }
393}