tty_form/step/
textblock.rs

1use crossterm::event::{KeyCode, KeyEvent};
2use tty_interface::{pos, Interface, Position};
3use tty_text::Key;
4
5use crate::{
6    dependency::DependencyState,
7    style::{error_style, help_style},
8    text::{set_segment_subset_style, DrawerContents, Segment, Text},
9    utility::render_segment,
10    Form,
11};
12
13use super::{InputResult, Step};
14
15/// A multi-line text input step.
16///
17/// # Examples
18/// ```
19/// use tty_form::{
20///     Form,
21///     step::{Step, TextBlockStep},
22/// };
23///
24/// let mut form = Form::new();
25///
26/// let mut step = TextBlockStep::new("Enter your story:");
27/// step.set_max_line_length(100);
28/// step.add_to(&mut form);
29/// ```
30pub struct TextBlockStep {
31    prompt: String,
32    text: tty_text::Text,
33    top_margin: Option<u16>,
34    bottom_margin: Option<u16>,
35    max_line_length: Option<u16>,
36    trim_trailing_whitespace: bool,
37}
38
39impl TextBlockStep {
40    /// Create a new, default text block step.
41    pub fn new(prompt: &str) -> Self {
42        Self {
43            prompt: prompt.to_string(),
44            text: tty_text::Text::new(true),
45            top_margin: None,
46            bottom_margin: None,
47            max_line_length: None,
48            trim_trailing_whitespace: true,
49        }
50    }
51
52    /// Set this text block's top and bottom margins.
53    pub fn set_margins(&mut self, top_margin: Option<u16>, bottom_margin: Option<u16>) {
54        self.top_margin = top_margin;
55        self.bottom_margin = bottom_margin;
56    }
57
58    /// Set this text block step's optional maximum line grapheme length.
59    pub fn set_max_line_length(&mut self, max_length: u16) {
60        self.max_line_length = Some(max_length);
61    }
62
63    /// Set whether this text block should trim trailing whitespace.
64    pub fn set_trim_trailing_whitespace(&mut self, trim: bool) {
65        self.trim_trailing_whitespace = trim;
66    }
67}
68
69impl Step for TextBlockStep {
70    fn initialize(&mut self, _dependency_state: &mut DependencyState, _index: usize) {}
71
72    fn render(
73        &self,
74        interface: &mut Interface,
75        _dependency_state: &DependencyState,
76        position: Position,
77        is_focused: bool,
78    ) -> u16 {
79        if !is_focused && self.text.value().is_empty() {
80            return 1;
81        }
82
83        let mut offset_y = 0;
84        if let Some(top_margin) = self.top_margin {
85            for line in 0..top_margin {
86                interface.clear_line(position.y() + line);
87            }
88
89            offset_y += top_margin;
90        }
91
92        let lines = self.text.lines();
93        for (line_index, line) in lines.iter().enumerate() {
94            let line_position = pos!(0, position.y() + line_index as u16 + offset_y);
95
96            // If the line exceeds the max length, render the tail as an error
97            if let Some(max_length) = self.max_line_length {
98                let line_length = line.len() as u16;
99                if line_length > max_length {
100                    let mut segment = Text::new(line.to_string()).as_segment();
101
102                    set_segment_subset_style(
103                        &mut segment,
104                        max_length.into(),
105                        line_length.into(),
106                        error_style(),
107                    );
108
109                    render_segment(interface, line_position, segment);
110                    continue;
111                }
112            }
113
114            interface.set(line_position, line);
115        }
116
117        if is_focused {
118            let cursor = self.text.cursor();
119            let (x, y) = (cursor.0 as u16, cursor.1 as u16);
120            interface.set_cursor(Some(pos!(x, y + position.y() + offset_y)));
121        }
122
123        if let Some(bottom_margin) = self.bottom_margin {
124            for line in 0..bottom_margin {
125                interface.clear_line(position.y() + line + offset_y + lines.len() as u16);
126            }
127
128            offset_y += bottom_margin;
129        }
130
131        lines.len() as u16 + offset_y
132    }
133
134    fn update(
135        &mut self,
136        _dependency_state: &mut DependencyState,
137        input: KeyEvent,
138    ) -> Option<InputResult> {
139        // If there are two empty lines, advance the form
140        if input.code == KeyCode::Enter || input.code == KeyCode::Tab {
141            let lines = self.text.lines().to_vec();
142            if lines.len() >= 2 {
143                let last_lines_empty =
144                    lines[lines.len() - 1].is_empty() && lines[lines.len() - 2].is_empty();
145
146                if last_lines_empty {
147                    // If we're trailing whitespace, delete the last two blank lines
148                    if self.trim_trailing_whitespace {
149                        self.text.handle_input(Key::Backspace);
150                        self.text.handle_input(Key::Backspace);
151                    }
152
153                    return Some(InputResult::AdvanceForm);
154                }
155            }
156        }
157
158        if input.code == KeyCode::Esc || input.code == KeyCode::BackTab {
159            return Some(InputResult::RetreatForm);
160        }
161
162        match input.code {
163            KeyCode::Enter => self.text.handle_input(Key::Enter),
164            KeyCode::Char(ch) => self.text.handle_input(Key::Char(ch)),
165            KeyCode::Backspace => self.text.handle_input(Key::Backspace),
166            KeyCode::Up => self.text.handle_input(Key::Up),
167            KeyCode::Down => self.text.handle_input(Key::Down),
168            KeyCode::Left => self.text.handle_input(Key::Left),
169            KeyCode::Right => self.text.handle_input(Key::Right),
170            _ => {}
171        };
172
173        None
174    }
175
176    fn help(&self) -> Segment {
177        Text::new_styled(self.prompt.to_string(), help_style()).as_segment()
178    }
179
180    fn drawer(&self) -> Option<DrawerContents> {
181        None
182    }
183
184    fn result(&self, _dependency_state: &DependencyState) -> String {
185        if self.text.value().is_empty() {
186            return "\n".to_string();
187        }
188
189        let mut result = String::new();
190
191        if let Some(top_margin) = self.top_margin {
192            for _ in 0..top_margin {
193                result.push('\n');
194            }
195        }
196
197        result.push_str(&self.text.value());
198
199        if let Some(bottom_margin) = self.bottom_margin {
200            for _ in 0..bottom_margin + 1 {
201                result.push('\n');
202            }
203        }
204
205        result
206    }
207
208    fn add_to(self, form: &mut Form) {
209        form.add_step(Box::new(self));
210    }
211}