Skip to main content

tui_realm_stdlib/components/
textarea.rs

1use tuirealm::command::{Cmd, CmdResult, Direction, Position};
2use tuirealm::component::Component;
3use tuirealm::props::{
4    AttrValue, Attribute, Borders, Color, LineStatic, Props, QueryResult, Style, TextModifiers,
5    TextStatic, Title,
6};
7use tuirealm::ratatui::Frame;
8use tuirealm::ratatui::layout::Rect;
9use tuirealm::ratatui::widgets::{List, ListItem, ListState};
10use tuirealm::state::{State, StateValue};
11
12use crate::prop_ext::CommonProps;
13use crate::utils::borrow_clone_line;
14
15// -- States
16
17/// The state that has to be kept for the [`Textarea`] component.
18#[derive(Default)]
19pub struct TextareaStates {
20    /// Index of selected item in textarea
21    pub list_index: usize,
22    /// Lines in text area
23    pub list_len: usize,
24}
25
26impl TextareaStates {
27    /// Set list length and fix list index.
28    pub fn set_list_len(&mut self, len: usize) {
29        self.list_len = len;
30        self.fix_list_index();
31    }
32
33    /// Incremenet list index.
34    pub fn incr_list_index(&mut self) {
35        // Check if index is at last element
36        if self.list_index + 1 < self.list_len {
37            self.list_index += 1;
38        }
39    }
40
41    /// Decrement list index.
42    pub fn decr_list_index(&mut self) {
43        // Check if index is bigger than 0
44        if self.list_index > 0 {
45            self.list_index -= 1;
46        }
47    }
48
49    /// Keep index if possible, otherwise set to `lenght - 1`.
50    pub fn fix_list_index(&mut self) {
51        if self.list_index >= self.list_len && self.list_len > 0 {
52            self.list_index = self.list_len - 1;
53        } else if self.list_len == 0 {
54            self.list_index = 0;
55        }
56    }
57
58    /// Set list index to the first item in the list.
59    pub fn list_index_at_first(&mut self) {
60        self.list_index = 0;
61    }
62
63    /// Set list index at the last item of the list.
64    pub fn list_index_at_last(&mut self) {
65        if self.list_len > 0 {
66            self.list_index = self.list_len - 1;
67        } else {
68            self.list_index = 0;
69        }
70    }
71
72    /// Calculate the max step ahead to scroll list.
73    fn calc_max_step_ahead(&self, max: usize) -> usize {
74        let remaining: usize = match self.list_len {
75            0 => 0,
76            len => len - 1 - self.list_index,
77        };
78        if remaining > max { max } else { remaining }
79    }
80
81    /// Calculate the max step ahead to scroll list.
82    fn calc_max_step_behind(&self, max: usize) -> usize {
83        if self.list_index > max {
84            max
85        } else {
86            self.list_index
87        }
88    }
89}
90
91/// A Textarea represents multi-line, multi-style, automatically wrapped text, with container and scroll support.
92///
93/// If scroll is not necessary, use [`Paragrapg`](super::Paragraph) instead.
94///
95/// If single-style, single-line text is wanted, use [`Label`](super::Label).
96/// If multi-style, single-line text is wanted, use [`Span`](super::Span).
97#[derive(Default)]
98#[must_use]
99pub struct Textarea {
100    common: CommonProps,
101    props: Props,
102    pub states: TextareaStates,
103}
104
105impl Textarea {
106    /// Set the main foreground color. This may get overwritten by individual text styles.
107    pub fn foreground(mut self, fg: Color) -> Self {
108        self.attr(Attribute::Foreground, AttrValue::Color(fg));
109        self
110    }
111
112    /// Set the main background color. This may get overwritten by individual text styles.
113    pub fn background(mut self, bg: Color) -> Self {
114        self.attr(Attribute::Background, AttrValue::Color(bg));
115        self
116    }
117
118    /// Set the main text modifiers. This may get overwritten by individual text styles.
119    pub fn modifiers(mut self, m: TextModifiers) -> Self {
120        self.attr(Attribute::TextProps, AttrValue::TextModifiers(m));
121        self
122    }
123
124    /// Set the main style. This may get overwritten by individual text styles.
125    ///
126    /// This option will overwrite any previous [`foreground`](Self::foreground), [`background`](Self::background) and [`modifiers`](Self::modifiers)!
127    pub fn style(mut self, style: Style) -> Self {
128        self.attr(Attribute::Style, AttrValue::Style(style));
129        self
130    }
131
132    /// Set a custom style for the border when the component is unfocused.
133    pub fn inactive(mut self, s: Style) -> Self {
134        self.attr(Attribute::UnfocusedBorderStyle, AttrValue::Style(s));
135        self
136    }
137
138    /// Add a border to the component.
139    pub fn borders(mut self, b: Borders) -> Self {
140        self.attr(Attribute::Borders, AttrValue::Borders(b));
141        self
142    }
143
144    /// Add a title to the component.
145    pub fn title<T: Into<Title>>(mut self, title: T) -> Self {
146        self.attr(Attribute::Title, AttrValue::Title(title.into()));
147        self
148    }
149
150    /// Set the scroll stepping to use on `Cmd::Scroll(Direction::Up)` or `Cmd::Scroll(Direction::Down)`.
151    pub fn step(mut self, step: usize) -> Self {
152        self.attr(Attribute::ScrollStep, AttrValue::Length(step));
153        self
154    }
155
156    /// Set the Symbol and Style for the indicator of the current line.
157    pub fn highlight_str<S: Into<LineStatic>>(mut self, s: S) -> Self {
158        self.attr(Attribute::HighlightedStr, AttrValue::TextLine(s.into()));
159        self
160    }
161
162    /// Set the Text content via a array or iterator.
163    ///
164    /// # Example
165    ///
166    /// ```
167    /// # use tui_realm_stdlib::components::Textarea;
168    /// # use tuirealm::ratatui::text::Line;
169    /// Textarea::default()
170    ///     .text_rows([
171    ///         Line::raw("line1"),
172    ///         Line::raw("line2")
173    ///     ]);
174    /// ```
175    pub fn text_rows<T>(self, text: impl IntoIterator<Item = T>) -> Self
176    where
177        T: Into<LineStatic>,
178    {
179        let text = TextStatic::from_iter(text);
180        self.text(text)
181    }
182
183    /// Set the Text content via a single struct.
184    ///
185    /// # Example
186    ///
187    /// ```
188    /// # use tui_realm_stdlib::components::Textarea;
189    /// # use tuirealm::ratatui::text::{Line, Text};
190    /// Textarea::default()
191    ///     .text(Line::raw("line"));
192    /// Textarea::default()
193    ///     .text(Text::raw("another line"));
194    /// ```
195    pub fn text(mut self, text: impl Into<TextStatic>) -> Self {
196        let text = text.into();
197        self.states.set_list_len(text.lines.len());
198        self.attr(Attribute::Text, AttrValue::Text(text));
199        self
200    }
201}
202
203impl Component for Textarea {
204    fn view(&mut self, render: &mut Frame, area: Rect) {
205        if !self.common.display {
206            return;
207        }
208
209        // Highlighted symbol
210        let hg_str = self
211            .props
212            .get(Attribute::HighlightedStr)
213            .and_then(|x| x.as_textline());
214        // NOTE: wrap width is width of area minus 2 (block) minus width of highlighting string
215        let wrap_width = (area.width as usize) - hg_str.as_ref().map_or(0, |x| x.width()) - 2;
216        let lines: Vec<ListItem> = self
217            .props
218            .get(Attribute::Text)
219            .and_then(AttrValue::as_text)
220            .map(|text| {
221                text.iter()
222                    .map(|x| crate::utils::wrap_lines(&[x], wrap_width))
223                    .map(ListItem::new)
224                    .collect()
225            })
226            .unwrap_or_default();
227
228        let mut state: ListState = ListState::default();
229        state.select(Some(self.states.list_index));
230        // Make component
231
232        let mut list = List::new(lines)
233            .direction(tuirealm::ratatui::widgets::ListDirection::TopToBottom)
234            .style(self.common.style);
235
236        if let Some(block) = self.common.get_block() {
237            list = list.block(block);
238        }
239        if let Some(hg_str) = hg_str {
240            list = list.highlight_symbol(borrow_clone_line(hg_str));
241        }
242
243        render.render_stateful_widget(list, area, &mut state);
244    }
245
246    fn query<'a>(&'a self, attr: Attribute) -> Option<QueryResult<'a>> {
247        if let Some(value) = self.common.get_for_query(attr) {
248            return Some(value);
249        }
250
251        self.props.get_for_query(attr)
252    }
253
254    fn attr(&mut self, attr: Attribute, value: AttrValue) {
255        if let Some(value) = self.common.set(attr, value) {
256            self.props.set(attr, value);
257            // Update list len and fix index
258            self.states.set_list_len(
259                self.props
260                    .get(Attribute::Text)
261                    .and_then(AttrValue::as_text)
262                    .map_or(0, |text| text.lines.len()),
263            );
264            self.states.fix_list_index();
265        }
266    }
267
268    fn state(&self) -> State {
269        State::Single(StateValue::Usize(self.states.list_index))
270    }
271
272    fn perform(&mut self, cmd: Cmd) -> CmdResult {
273        let prev = self.states.list_index;
274        match cmd {
275            Cmd::Move(Direction::Down) => {
276                self.states.incr_list_index();
277            }
278            Cmd::Move(Direction::Up) => {
279                self.states.decr_list_index();
280            }
281            Cmd::Scroll(Direction::Down) => {
282                let step = self
283                    .props
284                    .get(Attribute::ScrollStep)
285                    .and_then(AttrValue::as_length)
286                    .unwrap_or(8);
287                let step = self.states.calc_max_step_ahead(step);
288                (0..step).for_each(|_| self.states.incr_list_index());
289            }
290            Cmd::Scroll(Direction::Up) => {
291                let step = self
292                    .props
293                    .get(Attribute::ScrollStep)
294                    .and_then(AttrValue::as_length)
295                    .unwrap_or(8);
296                let step = self.states.calc_max_step_behind(step);
297                (0..step).for_each(|_| self.states.decr_list_index());
298            }
299            Cmd::GoTo(Position::Begin) => {
300                self.states.list_index_at_first();
301            }
302            Cmd::GoTo(Position::End) => {
303                self.states.list_index_at_last();
304            }
305            _ => return CmdResult::Invalid(cmd),
306        }
307        if prev != self.states.list_index {
308            CmdResult::Changed(self.state())
309        } else {
310            CmdResult::NoChange
311        }
312    }
313}
314
315#[cfg(test)]
316mod tests {
317
318    use pretty_assertions::assert_eq;
319    use tuirealm::props::HorizontalAlignment;
320    use tuirealm::ratatui::text::{Line, Span, Text};
321    use tuirealm::state::StateValue;
322
323    use super::*;
324
325    #[test]
326    fn test_components_textarea() {
327        // Make component
328        let mut component = Textarea::default()
329            .foreground(Color::Red)
330            .background(Color::Blue)
331            .modifiers(TextModifiers::BOLD)
332            .borders(Borders::default())
333            .highlight_str("🚀")
334            .step(4)
335            .title(Title::from("textarea").alignment(HorizontalAlignment::Center))
336            .text_rows([Line::from("welcome to "), Line::from("tui-realm")]);
337        // Increment list index
338        component.states.list_index += 1;
339        assert_eq!(component.states.list_index, 1);
340        // Add one row
341        component.attr(
342            Attribute::Text,
343            AttrValue::Text(TextStatic::from_iter([
344                Line::from("welcome"),
345                Line::from("to"),
346                Line::from("tui-realm"),
347            ])),
348        );
349        // Verify states
350        assert_eq!(component.states.list_index, 1); // Kept
351        assert_eq!(component.states.list_len, 3);
352        // get value
353        assert_eq!(component.state(), State::Single(StateValue::Usize(1)));
354        // Render
355        assert_eq!(component.states.list_index, 1);
356        // Handle inputs
357        assert_eq!(
358            component.perform(Cmd::Move(Direction::Down)),
359            CmdResult::Changed(State::Single(StateValue::Usize(2)))
360        );
361        // Index should be incremented
362        assert_eq!(component.states.list_index, 2);
363        // Index should be decremented
364        assert_eq!(
365            component.perform(Cmd::Move(Direction::Up)),
366            CmdResult::Changed(State::Single(StateValue::Usize(1)))
367        );
368        // Index should be decremented
369        assert_eq!(component.states.list_index, 1);
370        // Index should be 2
371        assert_eq!(
372            component.perform(Cmd::Scroll(Direction::Down)),
373            CmdResult::Changed(State::Single(StateValue::Usize(2)))
374        );
375        // Index should be incremented
376        assert_eq!(component.states.list_index, 2);
377        // Index should be 0
378        assert_eq!(
379            component.perform(Cmd::Scroll(Direction::Up)),
380            CmdResult::Changed(State::Single(StateValue::Usize(0)))
381        );
382        assert_eq!(component.states.list_index, 0);
383        // End
384        assert_eq!(
385            component.perform(Cmd::GoTo(Position::End)),
386            CmdResult::Changed(State::Single(StateValue::Usize(2)))
387        );
388        assert_eq!(component.states.list_index, 2);
389        // Home
390        assert_eq!(
391            component.perform(Cmd::GoTo(Position::Begin)),
392            CmdResult::Changed(State::Single(StateValue::Usize(0)))
393        );
394        assert_eq!(component.states.list_index, 0);
395        // No-op when already at beginning
396        assert_eq!(
397            component.perform(Cmd::GoTo(Position::Begin)),
398            CmdResult::NoChange
399        );
400        // Unhandled command
401        assert_eq!(
402            component.perform(Cmd::Delete),
403            CmdResult::Invalid(Cmd::Delete)
404        );
405    }
406
407    #[test]
408    fn various_textrows_types() {
409        // Vec
410        let _ = Textarea::default().text_rows(vec![Span::raw("hello")]);
411        // static array
412        let _ = Textarea::default().text_rows([Span::raw("hello")]);
413        // boxed array
414        let _ = Textarea::default().text_rows(vec![Span::raw("hello")].into_boxed_slice());
415        // already a iterator
416        let _ = Textarea::default().text_rows(["Hello"].map(Span::raw));
417
418        // Vec
419        let _ = Textarea::default().text_rows(vec![Line::raw("hello")]);
420        // static array
421        let _ = Textarea::default().text_rows([Line::raw("hello")]);
422        // boxed array
423        let _ = Textarea::default().text_rows(vec![Line::raw("hello")].into_boxed_slice());
424        // already a iterator
425        let _ = Textarea::default().text_rows(["Hello"].map(Line::raw));
426    }
427
428    #[test]
429    fn various_text_types() {
430        // Line
431        let _ = Textarea::default().text(Text::raw("hello"));
432        // Line
433        let _ = Textarea::default().text(Line::raw("hello"));
434        // Span
435        let _ = Textarea::default().text(Span::raw("hello"));
436        // str
437        let _ = Textarea::default().text("hello");
438        // String
439        let _ = Textarea::default().text("hello".to_string());
440    }
441}