Skip to main content

slt/context/widgets_input/
textarea_progress.rs

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