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) => {
128                        if let Some(max) = state.max_length {
129                            let total: usize =
130                                state.lines.iter().map(|line| grapheme_count(line)).sum();
131                            if total >= max {
132                                continue;
133                            }
134                        }
135                        // Coalesce a typing burst into one undoable batch:
136                        // only the first Char of the burst pushes a snapshot.
137                        if !state.last_was_char_insert {
138                            state.push_history();
139                        }
140                        let index = byte_index_for_grapheme(
141                            &state.lines[state.cursor_row],
142                            state.cursor_col,
143                        );
144                        state.lines[state.cursor_row].insert(index, ch);
145                        state.cursor_col += 1;
146                        state.last_was_char_insert = true;
147                        consumed_indices.push(i);
148                    }
149                    KeyCode::Enter => {
150                        state.push_history();
151                        let split_index = byte_index_for_grapheme(
152                            &state.lines[state.cursor_row],
153                            state.cursor_col,
154                        );
155                        let remainder = state.lines[state.cursor_row].split_off(split_index);
156                        state.cursor_row += 1;
157                        state.lines.insert(state.cursor_row, remainder);
158                        state.cursor_col = 0;
159                        state.last_was_char_insert = false;
160                        consumed_indices.push(i);
161                    }
162                    KeyCode::Backspace => {
163                        if state.cursor_col > 0 || state.cursor_row > 0 {
164                            state.push_history();
165                        }
166                        if state.cursor_col > 0 {
167                            let start = byte_index_for_grapheme(
168                                &state.lines[state.cursor_row],
169                                state.cursor_col - 1,
170                            );
171                            let end = byte_index_for_grapheme(
172                                &state.lines[state.cursor_row],
173                                state.cursor_col,
174                            );
175                            state.lines[state.cursor_row].replace_range(start..end, "");
176                            state.cursor_col -= 1;
177                        } else if state.cursor_row > 0 {
178                            let current = state.lines.remove(state.cursor_row);
179                            state.cursor_row -= 1;
180                            state.cursor_col = grapheme_count(&state.lines[state.cursor_row]);
181                            state.lines[state.cursor_row].push_str(&current);
182                        }
183                        state.last_was_char_insert = false;
184                        consumed_indices.push(i);
185                    }
186                    KeyCode::Left => {
187                        if state.cursor_col > 0 {
188                            state.cursor_col -= 1;
189                        } else if state.cursor_row > 0 {
190                            state.cursor_row -= 1;
191                            state.cursor_col = grapheme_count(&state.lines[state.cursor_row]);
192                        }
193                        state.last_was_char_insert = false;
194                        consumed_indices.push(i);
195                    }
196                    KeyCode::Right => {
197                        let line_len = grapheme_count(&state.lines[state.cursor_row]);
198                        if state.cursor_col < line_len {
199                            state.cursor_col += 1;
200                        } else if state.cursor_row + 1 < state.lines.len() {
201                            state.cursor_row += 1;
202                            state.cursor_col = 0;
203                        }
204                        state.last_was_char_insert = false;
205                        consumed_indices.push(i);
206                    }
207                    KeyCode::Up => {
208                        if wrapping {
209                            let (vrow, vcol) = textarea_logical_to_visual(
210                                &pre_vlines,
211                                state.cursor_row,
212                                state.cursor_col,
213                            );
214                            if vrow > 0 {
215                                let (lr, lc) =
216                                    textarea_visual_to_logical(&pre_vlines, vrow - 1, vcol);
217                                state.cursor_row = lr;
218                                state.cursor_col = lc;
219                            }
220                        } else if state.cursor_row > 0 {
221                            state.cursor_row -= 1;
222                            state.cursor_col = state
223                                .cursor_col
224                                .min(grapheme_count(&state.lines[state.cursor_row]));
225                        }
226                        state.last_was_char_insert = false;
227                        consumed_indices.push(i);
228                    }
229                    KeyCode::Down => {
230                        if wrapping {
231                            let (vrow, vcol) = textarea_logical_to_visual(
232                                &pre_vlines,
233                                state.cursor_row,
234                                state.cursor_col,
235                            );
236                            if vrow + 1 < pre_vlines.len() {
237                                let (lr, lc) =
238                                    textarea_visual_to_logical(&pre_vlines, vrow + 1, vcol);
239                                state.cursor_row = lr;
240                                state.cursor_col = lc;
241                            }
242                        } else if state.cursor_row + 1 < state.lines.len() {
243                            state.cursor_row += 1;
244                            state.cursor_col = state
245                                .cursor_col
246                                .min(grapheme_count(&state.lines[state.cursor_row]));
247                        }
248                        state.last_was_char_insert = false;
249                        consumed_indices.push(i);
250                    }
251                    KeyCode::Home => {
252                        state.cursor_col = 0;
253                        state.last_was_char_insert = false;
254                        consumed_indices.push(i);
255                    }
256                    KeyCode::Delete => {
257                        let line_len = grapheme_count(&state.lines[state.cursor_row]);
258                        let will_mutate =
259                            state.cursor_col < line_len || state.cursor_row + 1 < state.lines.len();
260                        if will_mutate {
261                            state.push_history();
262                        }
263                        if state.cursor_col < line_len {
264                            let start = byte_index_for_grapheme(
265                                &state.lines[state.cursor_row],
266                                state.cursor_col,
267                            );
268                            let end = byte_index_for_grapheme(
269                                &state.lines[state.cursor_row],
270                                state.cursor_col + 1,
271                            );
272                            state.lines[state.cursor_row].replace_range(start..end, "");
273                        } else if state.cursor_row + 1 < state.lines.len() {
274                            let next = state.lines.remove(state.cursor_row + 1);
275                            state.lines[state.cursor_row].push_str(&next);
276                        }
277                        state.last_was_char_insert = false;
278                        consumed_indices.push(i);
279                    }
280                    KeyCode::End => {
281                        state.cursor_col = grapheme_count(&state.lines[state.cursor_row]);
282                        state.last_was_char_insert = false;
283                        consumed_indices.push(i);
284                    }
285                    _ => {}
286                }
287            }
288            for (i, text) in self.available_pastes() {
289                // A paste is one undoable unit — push a single snapshot
290                // before applying the burst.
291                if !text.is_empty() {
292                    state.push_history();
293                }
294                // Hoist total char count once per paste event and update
295                // incrementally — recomputing via `.iter().map(...).sum()`
296                // inside the loop would be O(n²) on large pastes.
297                let mut total_chars: usize = state.lines.iter().map(|l| grapheme_count(l)).sum();
298                for ch in text.chars() {
299                    if let Some(max) = state.max_length
300                        && total_chars >= max
301                    {
302                        break;
303                    }
304                    if ch == '\n' || ch == '\r' {
305                        let split_index = byte_index_for_grapheme(
306                            &state.lines[state.cursor_row],
307                            state.cursor_col,
308                        );
309                        let remainder = state.lines[state.cursor_row].split_off(split_index);
310                        state.cursor_row += 1;
311                        state.lines.insert(state.cursor_row, remainder);
312                        state.cursor_col = 0;
313                        total_chars += 1;
314                    } else {
315                        let index = byte_index_for_grapheme(
316                            &state.lines[state.cursor_row],
317                            state.cursor_col,
318                        );
319                        state.lines[state.cursor_row].insert(index, ch);
320                        state.cursor_col += 1;
321                        total_chars += 1;
322                    }
323                }
324                state.last_was_char_insert = false;
325                consumed_indices.push(i);
326            }
327
328            self.consume_indices(consumed_indices);
329        }
330
331        let vlines = if state.lines == pre_lines {
332            pre_vlines
333        } else {
334            textarea_build_visual_lines(&state.lines, wrap_w)
335        };
336        let (cursor_vrow, cursor_vcol) =
337            textarea_logical_to_visual(&vlines, state.cursor_row, state.cursor_col);
338
339        if cursor_vrow < state.scroll_offset {
340            state.scroll_offset = cursor_vrow;
341        }
342        if cursor_vrow >= state.scroll_offset + visible_rows as usize {
343            state.scroll_offset = cursor_vrow + 1 - visible_rows as usize;
344        }
345
346        let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
347        self.commands
348            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
349                direction: Direction::Column,
350                gap: 0,
351                align: Align::Start,
352                align_self: None,
353                justify: Justify::Start,
354                border: None,
355                border_sides: BorderSides::all(),
356                border_style: Style::new().fg(self.theme.border),
357                bg_color: None,
358                padding: Padding::default(),
359                margin: Margin::default(),
360                constraints: Constraints::default(),
361                title: None,
362                grow: 0,
363                group_name: None,
364            })));
365
366        for vi in 0..visible_rows as usize {
367            let actual_vi = state.scroll_offset + vi;
368            let (seg_text, is_cursor_line) = if let Some(vl) = vlines.get(actual_vi) {
369                let line = &state.lines[vl.logical_row];
370                // `char_start` / `char_count` are grapheme-cluster indices, so
371                // slice by cluster to keep each cluster whole on its segment.
372                let text: String = line
373                    .graphemes(true)
374                    .skip(vl.char_start)
375                    .take(vl.char_count)
376                    .collect();
377                (text, actual_vi == cursor_vrow)
378            } else {
379                (String::new(), false)
380            };
381
382            let mut rendered = seg_text.clone();
383            let mut cursor_offset = None;
384            let mut style = if seg_text.is_empty() {
385                Style::new().fg(self.theme.text_dim)
386            } else {
387                Style::new().fg(self.theme.text)
388            };
389
390            if is_cursor_line && focused {
391                rendered.clear();
392                // Iterate by cluster: `cursor_vcol` is a cluster index. The
393                // emitted `cursor_offset` is the *scalar* length of `rendered`
394                // before the cursor glyph, which is what the renderer consumes
395                // (`text.chars().take(cursor_offset)` in render.rs).
396                for (idx, g) in seg_text.graphemes(true).enumerate() {
397                    if idx == cursor_vcol {
398                        cursor_offset = Some(rendered.chars().count());
399                        rendered.push('▎');
400                    }
401                    rendered.push_str(g);
402                }
403                if cursor_vcol >= grapheme_count(&seg_text) {
404                    cursor_offset = Some(rendered.chars().count());
405                    rendered.push('▎');
406                }
407                style = Style::new().fg(self.theme.text);
408            }
409
410            self.styled_with_cursor(rendered, style, cursor_offset);
411        }
412        self.commands.push(Command::EndContainer);
413        self.rollback.last_text_idx = None;
414
415        response.changed = state.lines != pre_lines;
416        response
417    }
418
419    /// Render a progress bar (20 chars wide). `ratio` is clamped to `0.0..=1.0`.
420    ///
421    /// Uses block characters (`█` filled, `░` empty). For a custom width use
422    /// [`Context::progress_bar`]. For an inline label use [`Context::gauge`].
423    ///
424    /// Returns a [`Response`] so callers can detect hover, attach a tooltip,
425    /// or implement click-to-set scrubbers. Prior to v0.20.0 this returned
426    /// `&mut Self`; ignoring the return value still compiles but the
427    /// `#[must_use]` attribute on `Response` warns at the call site.
428    pub fn progress(&mut self, ratio: f64) -> Response {
429        self.progress_bar(ratio, 20)
430    }
431
432    /// Render a progress bar with a custom character width.
433    ///
434    /// `ratio` is clamped to `0.0..=1.0`. `width` is the total number of
435    /// characters rendered.
436    pub fn progress_bar(&mut self, ratio: f64, width: u32) -> Response {
437        self.progress_bar_colored(ratio, width, self.theme.primary)
438    }
439
440    /// Render a progress bar with a custom fill color.
441    pub fn progress_bar_colored(&mut self, ratio: f64, width: u32, color: Color) -> Response {
442        let response = self.interaction();
443        let clamped = ratio.clamp(0.0, 1.0);
444        let filled = (clamped * width as f64).round() as u32;
445        let empty = width.saturating_sub(filled);
446        let mut bar = String::with_capacity(width as usize * 3);
447        for _ in 0..filled {
448            bar.push('█');
449        }
450        for _ in 0..empty {
451            bar.push('░');
452        }
453        self.styled(bar, Style::new().fg(color));
454        response
455    }
456}