Skip to main content

slt/context/widgets_input/
textarea_progress.rs

1use super::*;
2
3impl Context {
4    ///
5    /// When focused, handles character input, Enter (new line), Backspace,
6    /// arrow keys, Home, and End. The cursor is rendered as a block character.
7    ///
8    /// Set [`TextareaState::word_wrap`] to enable soft-wrapping at a given
9    /// display-column width. Up/Down then navigate visual lines.
10    pub fn textarea(&mut self, state: &mut TextareaState, visible_rows: u32) -> Response {
11        if state.lines.is_empty() {
12            state.lines.push(String::new());
13        }
14        let old_lines = state.lines.clone();
15        state.cursor_row = state.cursor_row.min(state.lines.len().saturating_sub(1));
16        state.cursor_col = state
17            .cursor_col
18            .min(state.lines[state.cursor_row].chars().count());
19
20        let focused = self.register_focusable();
21        let wrap_w = state.wrap_width.unwrap_or(u32::MAX);
22        let wrapping = state.wrap_width.is_some();
23
24        let pre_vlines = textarea_build_visual_lines(&state.lines, wrap_w);
25
26        if focused {
27            let mut consumed_indices = Vec::new();
28            for (i, key) in self.available_key_presses() {
29                match key.code {
30                    KeyCode::Char(ch) => {
31                        if let Some(max) = state.max_length {
32                            let total: usize =
33                                state.lines.iter().map(|line| line.chars().count()).sum();
34                            if total >= max {
35                                continue;
36                            }
37                        }
38                        let index =
39                            byte_index_for_char(&state.lines[state.cursor_row], state.cursor_col);
40                        state.lines[state.cursor_row].insert(index, ch);
41                        state.cursor_col += 1;
42                        consumed_indices.push(i);
43                    }
44                    KeyCode::Enter => {
45                        let split_index =
46                            byte_index_for_char(&state.lines[state.cursor_row], state.cursor_col);
47                        let remainder = state.lines[state.cursor_row].split_off(split_index);
48                        state.cursor_row += 1;
49                        state.lines.insert(state.cursor_row, remainder);
50                        state.cursor_col = 0;
51                        consumed_indices.push(i);
52                    }
53                    KeyCode::Backspace => {
54                        if state.cursor_col > 0 {
55                            let start = byte_index_for_char(
56                                &state.lines[state.cursor_row],
57                                state.cursor_col - 1,
58                            );
59                            let end = byte_index_for_char(
60                                &state.lines[state.cursor_row],
61                                state.cursor_col,
62                            );
63                            state.lines[state.cursor_row].replace_range(start..end, "");
64                            state.cursor_col -= 1;
65                        } else if state.cursor_row > 0 {
66                            let current = state.lines.remove(state.cursor_row);
67                            state.cursor_row -= 1;
68                            state.cursor_col = state.lines[state.cursor_row].chars().count();
69                            state.lines[state.cursor_row].push_str(&current);
70                        }
71                        consumed_indices.push(i);
72                    }
73                    KeyCode::Left => {
74                        if state.cursor_col > 0 {
75                            state.cursor_col -= 1;
76                        } else if state.cursor_row > 0 {
77                            state.cursor_row -= 1;
78                            state.cursor_col = state.lines[state.cursor_row].chars().count();
79                        }
80                        consumed_indices.push(i);
81                    }
82                    KeyCode::Right => {
83                        let line_len = state.lines[state.cursor_row].chars().count();
84                        if state.cursor_col < line_len {
85                            state.cursor_col += 1;
86                        } else if state.cursor_row + 1 < state.lines.len() {
87                            state.cursor_row += 1;
88                            state.cursor_col = 0;
89                        }
90                        consumed_indices.push(i);
91                    }
92                    KeyCode::Up => {
93                        if wrapping {
94                            let (vrow, vcol) = textarea_logical_to_visual(
95                                &pre_vlines,
96                                state.cursor_row,
97                                state.cursor_col,
98                            );
99                            if vrow > 0 {
100                                let (lr, lc) =
101                                    textarea_visual_to_logical(&pre_vlines, vrow - 1, vcol);
102                                state.cursor_row = lr;
103                                state.cursor_col = lc;
104                            }
105                        } else if state.cursor_row > 0 {
106                            state.cursor_row -= 1;
107                            state.cursor_col = state
108                                .cursor_col
109                                .min(state.lines[state.cursor_row].chars().count());
110                        }
111                        consumed_indices.push(i);
112                    }
113                    KeyCode::Down => {
114                        if wrapping {
115                            let (vrow, vcol) = textarea_logical_to_visual(
116                                &pre_vlines,
117                                state.cursor_row,
118                                state.cursor_col,
119                            );
120                            if vrow + 1 < pre_vlines.len() {
121                                let (lr, lc) =
122                                    textarea_visual_to_logical(&pre_vlines, vrow + 1, vcol);
123                                state.cursor_row = lr;
124                                state.cursor_col = lc;
125                            }
126                        } else if state.cursor_row + 1 < state.lines.len() {
127                            state.cursor_row += 1;
128                            state.cursor_col = state
129                                .cursor_col
130                                .min(state.lines[state.cursor_row].chars().count());
131                        }
132                        consumed_indices.push(i);
133                    }
134                    KeyCode::Home => {
135                        state.cursor_col = 0;
136                        consumed_indices.push(i);
137                    }
138                    KeyCode::Delete => {
139                        let line_len = state.lines[state.cursor_row].chars().count();
140                        if state.cursor_col < line_len {
141                            let start = byte_index_for_char(
142                                &state.lines[state.cursor_row],
143                                state.cursor_col,
144                            );
145                            let end = byte_index_for_char(
146                                &state.lines[state.cursor_row],
147                                state.cursor_col + 1,
148                            );
149                            state.lines[state.cursor_row].replace_range(start..end, "");
150                        } else if state.cursor_row + 1 < state.lines.len() {
151                            let next = state.lines.remove(state.cursor_row + 1);
152                            state.lines[state.cursor_row].push_str(&next);
153                        }
154                        consumed_indices.push(i);
155                    }
156                    KeyCode::End => {
157                        state.cursor_col = state.lines[state.cursor_row].chars().count();
158                        consumed_indices.push(i);
159                    }
160                    _ => {}
161                }
162            }
163            for (i, text) in self.available_pastes() {
164                for ch in text.chars() {
165                    if ch == '\n' || ch == '\r' {
166                        let split_index =
167                            byte_index_for_char(&state.lines[state.cursor_row], state.cursor_col);
168                        let remainder = state.lines[state.cursor_row].split_off(split_index);
169                        state.cursor_row += 1;
170                        state.lines.insert(state.cursor_row, remainder);
171                        state.cursor_col = 0;
172                    } else {
173                        if let Some(max) = state.max_length {
174                            let total: usize = state.lines.iter().map(|l| l.chars().count()).sum();
175                            if total >= max {
176                                break;
177                            }
178                        }
179                        let index =
180                            byte_index_for_char(&state.lines[state.cursor_row], state.cursor_col);
181                        state.lines[state.cursor_row].insert(index, ch);
182                        state.cursor_col += 1;
183                    }
184                }
185                consumed_indices.push(i);
186            }
187
188            self.consume_indices(consumed_indices);
189        }
190
191        let vlines = textarea_build_visual_lines(&state.lines, wrap_w);
192        let (cursor_vrow, cursor_vcol) =
193            textarea_logical_to_visual(&vlines, state.cursor_row, state.cursor_col);
194
195        if cursor_vrow < state.scroll_offset {
196            state.scroll_offset = cursor_vrow;
197        }
198        if cursor_vrow >= state.scroll_offset + visible_rows as usize {
199            state.scroll_offset = cursor_vrow + 1 - visible_rows as usize;
200        }
201
202        let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
203        self.commands.push(Command::BeginContainer {
204            direction: Direction::Column,
205            gap: 0,
206            align: Align::Start,
207            align_self: None,
208            justify: Justify::Start,
209            border: None,
210            border_sides: BorderSides::all(),
211            border_style: Style::new().fg(self.theme.border),
212            bg_color: None,
213            padding: Padding::default(),
214            margin: Margin::default(),
215            constraints: Constraints::default(),
216            title: None,
217            grow: 0,
218            group_name: None,
219        });
220
221        for vi in 0..visible_rows as usize {
222            let actual_vi = state.scroll_offset + vi;
223            let (seg_text, is_cursor_line) = if let Some(vl) = vlines.get(actual_vi) {
224                let line = &state.lines[vl.logical_row];
225                let text: String = line
226                    .chars()
227                    .skip(vl.char_start)
228                    .take(vl.char_count)
229                    .collect();
230                (text, actual_vi == cursor_vrow)
231            } else {
232                (String::new(), false)
233            };
234
235            let mut rendered = seg_text.clone();
236            let mut cursor_offset = None;
237            let mut style = if seg_text.is_empty() {
238                Style::new().fg(self.theme.text_dim)
239            } else {
240                Style::new().fg(self.theme.text)
241            };
242
243            if is_cursor_line && focused {
244                rendered.clear();
245                for (idx, ch) in seg_text.chars().enumerate() {
246                    if idx == cursor_vcol {
247                        cursor_offset = Some(rendered.chars().count());
248                        rendered.push('▎');
249                    }
250                    rendered.push(ch);
251                }
252                if cursor_vcol >= seg_text.chars().count() {
253                    cursor_offset = Some(rendered.chars().count());
254                    rendered.push('▎');
255                }
256                style = Style::new().fg(self.theme.text);
257            }
258
259            self.styled_with_cursor(rendered, style, cursor_offset);
260        }
261        self.commands.push(Command::EndContainer);
262        self.rollback.last_text_idx = None;
263
264        response.changed = state.lines != old_lines;
265        response
266    }
267
268    /// Render a progress bar (20 chars wide). `ratio` is clamped to `0.0..=1.0`.
269    ///
270    /// Uses block characters (`█` filled, `░` empty). For a custom width use
271    /// [`Context::progress_bar`].
272    pub fn progress(&mut self, ratio: f64) -> &mut Self {
273        self.progress_bar(ratio, 20)
274    }
275
276    /// Render a progress bar with a custom character width.
277    ///
278    /// `ratio` is clamped to `0.0..=1.0`. `width` is the total number of
279    /// characters rendered.
280    /// Render a progress bar filled to the given ratio (0.0–1.0).
281    pub fn progress_bar(&mut self, ratio: f64, width: u32) -> &mut Self {
282        self.progress_bar_colored(ratio, width, self.theme.primary)
283    }
284
285    /// Render a progress bar with a custom fill color.
286    pub fn progress_bar_colored(&mut self, ratio: f64, width: u32, color: Color) -> &mut Self {
287        let clamped = ratio.clamp(0.0, 1.0);
288        let filled = (clamped * width as f64).round() as u32;
289        let empty = width.saturating_sub(filled);
290        let mut bar = String::new();
291        for _ in 0..filled {
292            bar.push('█');
293        }
294        for _ in 0..empty {
295            bar.push('░');
296        }
297        self.styled(bar, Style::new().fg(color))
298    }
299}