Skip to main content

slt/context/widgets_input/
textarea_progress.rs

1use super::*;
2
3/// Test whether a grapheme cluster is "alphanumeric" for word-boundary
4/// navigation: its first scalar is alphanumeric (a cluster's base scalar
5/// determines its class; trailing combining marks do not change it).
6fn cluster_is_alphanumeric(cluster: &str) -> bool {
7    cluster.chars().next().is_some_and(|c| c.is_alphanumeric())
8}
9
10/// Move a logical column index backward to the start of the previous word.
11///
12/// Columns are **grapheme-cluster** indices. Word boundary: a run of
13/// one-or-more alphanumeric clusters. Leading non-alphanumeric clusters before
14/// the cursor are skipped first, then the run of alphanumerics is consumed.
15fn prev_word_col(line: &str, col: usize) -> usize {
16    let clusters: Vec<&str> = line.graphemes(true).collect();
17    let mut pos = col.min(clusters.len());
18    while pos > 0 && !cluster_is_alphanumeric(clusters[pos - 1]) {
19        pos -= 1;
20    }
21    while pos > 0 && cluster_is_alphanumeric(clusters[pos - 1]) {
22        pos -= 1;
23    }
24    pos
25}
26
27/// Move a logical column index forward past the end of the next word.
28///
29/// Columns are **grapheme-cluster** indices (see [`prev_word_col`]).
30fn next_word_col(line: &str, col: usize) -> usize {
31    let clusters: Vec<&str> = line.graphemes(true).collect();
32    let mut pos = col.min(clusters.len());
33    while pos < clusters.len() && !cluster_is_alphanumeric(clusters[pos]) {
34        pos += 1;
35    }
36    while pos < clusters.len() && cluster_is_alphanumeric(clusters[pos]) {
37        pos += 1;
38    }
39    pos
40}
41
42impl Context {
43    ///
44    /// When focused, handles character input, Enter (new line), Backspace,
45    /// arrow keys, Home, and End. The cursor is rendered as a block character.
46    ///
47    /// Set [`TextareaState::word_wrap`] to enable soft-wrapping at a given
48    /// display-column width. Up/Down then navigate visual lines.
49    ///
50    /// Editing shortcuts: `Ctrl+K` deletes from the cursor to the end of the
51    /// current line. `Ctrl+Left` / `Alt+Left` jumps to the previous word
52    /// boundary; `Ctrl+Right` / `Alt+Right` jumps past the next word end.
53    /// `Ctrl+Z` undoes the last edit and `Ctrl+Y` redoes it — see the
54    /// [`TextareaState`] docs for the snapshot policy.
55    pub fn textarea(&mut self, state: &mut TextareaState, visible_rows: u32) -> Response {
56        if state.lines.is_empty() {
57            state.lines.push(String::new());
58        }
59        state.cursor_row = state.cursor_row.min(state.lines.len().saturating_sub(1));
60        state.cursor_col = state
61            .cursor_col
62            .min(grapheme_count(&state.lines[state.cursor_row]));
63
64        let focused = self.register_focusable();
65        let wrap_w = state.wrap_width.unwrap_or(u32::MAX);
66        let wrapping = state.wrap_width.is_some();
67
68        let pre_lines = state.lines.clone();
69        let pre_vlines = textarea_build_visual_lines(&state.lines, wrap_w);
70
71        if focused {
72            let mut consumed_indices = Vec::new();
73            for (i, key) in self.available_key_presses() {
74                match key.code {
75                    KeyCode::Char('z') if key.modifiers.contains(KeyModifiers::CONTROL) => {
76                        state.undo();
77                        state.last_was_char_insert = false;
78                        consumed_indices.push(i);
79                    }
80                    KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
81                        state.redo();
82                        state.last_was_char_insert = false;
83                        consumed_indices.push(i);
84                    }
85                    KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
86                        let line_len = grapheme_count(&state.lines[state.cursor_row]);
87                        if state.cursor_col < line_len {
88                            state.push_history();
89                            let cut = byte_index_for_grapheme(
90                                &state.lines[state.cursor_row],
91                                state.cursor_col,
92                            );
93                            state.lines[state.cursor_row].truncate(cut);
94                        }
95                        state.last_was_char_insert = false;
96                        consumed_indices.push(i);
97                    }
98                    KeyCode::Left
99                        if key.modifiers.contains(KeyModifiers::CONTROL)
100                            || key.modifiers.contains(KeyModifiers::ALT) =>
101                    {
102                        if state.cursor_col > 0 {
103                            state.cursor_col =
104                                prev_word_col(&state.lines[state.cursor_row], state.cursor_col);
105                        } else if state.cursor_row > 0 {
106                            state.cursor_row -= 1;
107                            state.cursor_col = grapheme_count(&state.lines[state.cursor_row]);
108                        }
109                        state.last_was_char_insert = false;
110                        consumed_indices.push(i);
111                    }
112                    KeyCode::Right
113                        if key.modifiers.contains(KeyModifiers::CONTROL)
114                            || key.modifiers.contains(KeyModifiers::ALT) =>
115                    {
116                        let line_len = grapheme_count(&state.lines[state.cursor_row]);
117                        if state.cursor_col < line_len {
118                            state.cursor_col =
119                                next_word_col(&state.lines[state.cursor_row], state.cursor_col);
120                        } else if state.cursor_row + 1 < state.lines.len() {
121                            state.cursor_row += 1;
122                            state.cursor_col = 0;
123                        }
124                        state.last_was_char_insert = false;
125                        consumed_indices.push(i);
126                    }
127                    KeyCode::Char(ch) if !has_global_shortcut_modifier(key.modifiers) => {
128                        if let Some(max) = state.max_length
129                            && state.grapheme_len() >= max
130                        {
131                            continue;
132                        }
133                        // Coalesce a typing burst into one undoable batch:
134                        // only the first Char of the burst pushes a snapshot.
135                        if !state.last_was_char_insert {
136                            state.push_history();
137                        }
138                        let index = byte_index_for_grapheme(
139                            &state.lines[state.cursor_row],
140                            state.cursor_col,
141                        );
142                        state.lines[state.cursor_row].insert(index, ch);
143                        state.cursor_col += 1;
144                        state.last_was_char_insert = true;
145                        consumed_indices.push(i);
146                    }
147                    KeyCode::Enter => {
148                        if state
149                            .max_length
150                            .is_some_and(|max| state.grapheme_len() >= max)
151                        {
152                            continue;
153                        }
154                        state.push_history();
155                        let split_index = byte_index_for_grapheme(
156                            &state.lines[state.cursor_row],
157                            state.cursor_col,
158                        );
159                        let remainder = state.lines[state.cursor_row].split_off(split_index);
160                        state.cursor_row += 1;
161                        state.lines.insert(state.cursor_row, remainder);
162                        state.cursor_col = 0;
163                        state.last_was_char_insert = false;
164                        consumed_indices.push(i);
165                    }
166                    KeyCode::Backspace => {
167                        if state.cursor_col > 0 || state.cursor_row > 0 {
168                            state.push_history();
169                        }
170                        if state.cursor_col > 0 {
171                            let start = byte_index_for_grapheme(
172                                &state.lines[state.cursor_row],
173                                state.cursor_col - 1,
174                            );
175                            let end = byte_index_for_grapheme(
176                                &state.lines[state.cursor_row],
177                                state.cursor_col,
178                            );
179                            state.lines[state.cursor_row].replace_range(start..end, "");
180                            state.cursor_col -= 1;
181                        } else if state.cursor_row > 0 {
182                            let current = state.lines.remove(state.cursor_row);
183                            state.cursor_row -= 1;
184                            state.cursor_col = grapheme_count(&state.lines[state.cursor_row]);
185                            state.lines[state.cursor_row].push_str(&current);
186                        }
187                        state.last_was_char_insert = false;
188                        consumed_indices.push(i);
189                    }
190                    KeyCode::Left => {
191                        if state.cursor_col > 0 {
192                            state.cursor_col -= 1;
193                        } else if state.cursor_row > 0 {
194                            state.cursor_row -= 1;
195                            state.cursor_col = grapheme_count(&state.lines[state.cursor_row]);
196                        }
197                        state.last_was_char_insert = false;
198                        consumed_indices.push(i);
199                    }
200                    KeyCode::Right => {
201                        let line_len = grapheme_count(&state.lines[state.cursor_row]);
202                        if state.cursor_col < line_len {
203                            state.cursor_col += 1;
204                        } else if state.cursor_row + 1 < state.lines.len() {
205                            state.cursor_row += 1;
206                            state.cursor_col = 0;
207                        }
208                        state.last_was_char_insert = false;
209                        consumed_indices.push(i);
210                    }
211                    KeyCode::Up => {
212                        if wrapping {
213                            let (vrow, vcol) = textarea_logical_to_visual(
214                                &pre_vlines,
215                                state.cursor_row,
216                                state.cursor_col,
217                            );
218                            if vrow > 0 {
219                                let (lr, lc) =
220                                    textarea_visual_to_logical(&pre_vlines, vrow - 1, vcol);
221                                state.cursor_row = lr;
222                                state.cursor_col = lc;
223                            }
224                        } else if state.cursor_row > 0 {
225                            state.cursor_row -= 1;
226                            state.cursor_col = state
227                                .cursor_col
228                                .min(grapheme_count(&state.lines[state.cursor_row]));
229                        }
230                        state.last_was_char_insert = false;
231                        consumed_indices.push(i);
232                    }
233                    KeyCode::Down => {
234                        if wrapping {
235                            let (vrow, vcol) = textarea_logical_to_visual(
236                                &pre_vlines,
237                                state.cursor_row,
238                                state.cursor_col,
239                            );
240                            if vrow + 1 < pre_vlines.len() {
241                                let (lr, lc) =
242                                    textarea_visual_to_logical(&pre_vlines, vrow + 1, vcol);
243                                state.cursor_row = lr;
244                                state.cursor_col = lc;
245                            }
246                        } else if state.cursor_row + 1 < state.lines.len() {
247                            state.cursor_row += 1;
248                            state.cursor_col = state
249                                .cursor_col
250                                .min(grapheme_count(&state.lines[state.cursor_row]));
251                        }
252                        state.last_was_char_insert = false;
253                        consumed_indices.push(i);
254                    }
255                    KeyCode::Home => {
256                        state.cursor_col = 0;
257                        state.last_was_char_insert = false;
258                        consumed_indices.push(i);
259                    }
260                    KeyCode::Delete => {
261                        let line_len = grapheme_count(&state.lines[state.cursor_row]);
262                        let will_mutate =
263                            state.cursor_col < line_len || state.cursor_row + 1 < state.lines.len();
264                        if will_mutate {
265                            state.push_history();
266                        }
267                        if state.cursor_col < line_len {
268                            let start = byte_index_for_grapheme(
269                                &state.lines[state.cursor_row],
270                                state.cursor_col,
271                            );
272                            let end = byte_index_for_grapheme(
273                                &state.lines[state.cursor_row],
274                                state.cursor_col + 1,
275                            );
276                            state.lines[state.cursor_row].replace_range(start..end, "");
277                        } else if state.cursor_row + 1 < state.lines.len() {
278                            let next = state.lines.remove(state.cursor_row + 1);
279                            state.lines[state.cursor_row].push_str(&next);
280                        }
281                        state.last_was_char_insert = false;
282                        consumed_indices.push(i);
283                    }
284                    KeyCode::End => {
285                        state.cursor_col = grapheme_count(&state.lines[state.cursor_row]);
286                        state.last_was_char_insert = false;
287                        consumed_indices.push(i);
288                    }
289                    _ => {}
290                }
291            }
292            for (i, text) in self.available_pastes() {
293                let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
294                // A paste is one undoable unit — push a single snapshot
295                // before applying the burst.
296                if !normalized.is_empty() {
297                    state.push_history();
298                }
299                let mut total_chars = state.grapheme_len();
300                for cluster in normalized.graphemes(true) {
301                    if let Some(max) = state.max_length
302                        && total_chars >= max
303                    {
304                        break;
305                    }
306                    if cluster == "\n" {
307                        let split_index = byte_index_for_grapheme(
308                            &state.lines[state.cursor_row],
309                            state.cursor_col,
310                        );
311                        let remainder = state.lines[state.cursor_row].split_off(split_index);
312                        state.cursor_row += 1;
313                        state.lines.insert(state.cursor_row, remainder);
314                        state.cursor_col = 0;
315                        total_chars += 1;
316                    } else {
317                        let before = grapheme_count(&state.lines[state.cursor_row]);
318                        let index = byte_index_for_grapheme(
319                            &state.lines[state.cursor_row],
320                            state.cursor_col,
321                        );
322                        let inserted_end = index + cluster.len();
323                        state.lines[state.cursor_row].insert_str(index, cluster);
324                        state.cursor_col =
325                            grapheme_count(&state.lines[state.cursor_row][..inserted_end]);
326                        let after = grapheme_count(&state.lines[state.cursor_row]);
327                        total_chars = total_chars.saturating_sub(before).saturating_add(after);
328                    }
329                }
330                state.last_was_char_insert = false;
331                consumed_indices.push(i);
332            }
333
334            self.consume_indices(consumed_indices);
335        }
336
337        let vlines = if state.lines == pre_lines {
338            pre_vlines
339        } else {
340            textarea_build_visual_lines(&state.lines, wrap_w)
341        };
342        let (cursor_vrow, cursor_vcol) =
343            textarea_logical_to_visual(&vlines, state.cursor_row, state.cursor_col);
344
345        if cursor_vrow < state.scroll_offset {
346            state.scroll_offset = cursor_vrow;
347        }
348        if cursor_vrow >= state.scroll_offset + visible_rows as usize {
349            state.scroll_offset = cursor_vrow + 1 - visible_rows as usize;
350        }
351
352        let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
353        self.commands
354            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
355                direction: Direction::Column,
356                gap: 0,
357                align: Align::Start,
358                align_self: None,
359                justify: Justify::Start,
360                border: None,
361                border_sides: BorderSides::all(),
362                border_style: Style::new().fg(self.theme.border),
363                bg_color: None,
364                padding: Padding::default(),
365                margin: Margin::default(),
366                constraints: Constraints::default(),
367                title: None,
368                grow: 0,
369                group_name: None,
370            })));
371
372        for vi in 0..visible_rows as usize {
373            let actual_vi = state.scroll_offset + vi;
374            let (seg_text, is_cursor_line) = if let Some(vl) = vlines.get(actual_vi) {
375                let line = &state.lines[vl.logical_row];
376                // `char_start` / `char_count` are grapheme-cluster indices, so
377                // slice by cluster to keep each cluster whole on its segment.
378                let text: String = line
379                    .graphemes(true)
380                    .skip(vl.char_start)
381                    .take(vl.char_count)
382                    .collect();
383                (text, actual_vi == cursor_vrow)
384            } else {
385                (String::new(), false)
386            };
387
388            let mut rendered = seg_text.clone();
389            let mut cursor_offset = None;
390            let mut style = if seg_text.is_empty() {
391                Style::new().fg(self.theme.text_dim)
392            } else {
393                Style::new().fg(self.theme.text)
394            };
395
396            if is_cursor_line && focused {
397                rendered.clear();
398                // Iterate by cluster: `cursor_vcol` is a cluster index. The
399                // emitted `cursor_offset` is the *scalar* length of `rendered`
400                // before the cursor glyph, which is what the renderer consumes
401                // (`text.chars().take(cursor_offset)` in render.rs).
402                for (idx, g) in seg_text.graphemes(true).enumerate() {
403                    if idx == cursor_vcol {
404                        cursor_offset = Some(rendered.chars().count());
405                        rendered.push('▎');
406                    }
407                    rendered.push_str(g);
408                }
409                if cursor_vcol >= grapheme_count(&seg_text) {
410                    cursor_offset = Some(rendered.chars().count());
411                    rendered.push('▎');
412                }
413                style = Style::new().fg(self.theme.text);
414            }
415
416            self.styled_with_cursor(rendered, style, cursor_offset);
417        }
418        self.commands.push(Command::EndContainer);
419        self.rollback.last_text_idx = None;
420
421        response.changed = state.lines != pre_lines;
422        response
423    }
424
425    /// Render a progress bar (20 chars wide). `ratio` is clamped to `0.0..=1.0`.
426    ///
427    /// Uses block characters (`█` filled, `░` empty). For a custom width use
428    /// [`Context::progress_bar`]. For an inline label use [`Context::gauge`].
429    ///
430    /// Returns a [`Response`] so callers can detect hover, attach a tooltip,
431    /// or implement click-to-set scrubbers. Prior to v0.20.0 this returned
432    /// `&mut Self`; ignoring the return value still compiles but the
433    /// `#[must_use]` attribute on `Response` warns at the call site.
434    pub fn progress(&mut self, ratio: f64) -> Response {
435        self.progress_bar(ratio, 20)
436    }
437
438    /// Render a progress bar with a custom character width.
439    ///
440    /// `ratio` is clamped to `0.0..=1.0`. `width` is the total number of
441    /// characters rendered.
442    pub fn progress_bar(&mut self, ratio: f64, width: u32) -> Response {
443        self.progress_bar_colored(ratio, width, self.theme.primary)
444    }
445
446    /// Render a progress bar with a custom fill color.
447    pub fn progress_bar_colored(&mut self, ratio: f64, width: u32, color: Color) -> Response {
448        let response = self.interaction();
449        let clamped = ratio.clamp(0.0, 1.0);
450        let filled = (clamped * width as f64).round() as u32;
451        let empty = width.saturating_sub(filled);
452        let mut bar = String::with_capacity(width as usize * 3);
453        for _ in 0..filled {
454            bar.push('█');
455        }
456        for _ in 0..empty {
457            bar.push('░');
458        }
459        self.styled(bar, Style::new().fg(color));
460        response
461    }
462}