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
204            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
205                direction: Direction::Column,
206                gap: 0,
207                align: Align::Start,
208                align_self: None,
209                justify: Justify::Start,
210                border: None,
211                border_sides: BorderSides::all(),
212                border_style: Style::new().fg(self.theme.border),
213                bg_color: None,
214                padding: Padding::default(),
215                margin: Margin::default(),
216                constraints: Constraints::default(),
217                title: None,
218                grow: 0,
219                group_name: None,
220            })));
221
222        for vi in 0..visible_rows as usize {
223            let actual_vi = state.scroll_offset + vi;
224            let (seg_text, is_cursor_line) = if let Some(vl) = vlines.get(actual_vi) {
225                let line = &state.lines[vl.logical_row];
226                let text: String = line
227                    .chars()
228                    .skip(vl.char_start)
229                    .take(vl.char_count)
230                    .collect();
231                (text, actual_vi == cursor_vrow)
232            } else {
233                (String::new(), false)
234            };
235
236            let mut rendered = seg_text.clone();
237            let mut cursor_offset = None;
238            let mut style = if seg_text.is_empty() {
239                Style::new().fg(self.theme.text_dim)
240            } else {
241                Style::new().fg(self.theme.text)
242            };
243
244            if is_cursor_line && focused {
245                rendered.clear();
246                for (idx, ch) in seg_text.chars().enumerate() {
247                    if idx == cursor_vcol {
248                        cursor_offset = Some(rendered.chars().count());
249                        rendered.push('▎');
250                    }
251                    rendered.push(ch);
252                }
253                if cursor_vcol >= seg_text.chars().count() {
254                    cursor_offset = Some(rendered.chars().count());
255                    rendered.push('▎');
256                }
257                style = Style::new().fg(self.theme.text);
258            }
259
260            self.styled_with_cursor(rendered, style, cursor_offset);
261        }
262        self.commands.push(Command::EndContainer);
263        self.rollback.last_text_idx = None;
264
265        response.changed = state.lines != old_lines;
266        response
267    }
268
269    /// Render a progress bar (20 chars wide). `ratio` is clamped to `0.0..=1.0`.
270    ///
271    /// Uses block characters (`█` filled, `░` empty). For a custom width use
272    /// [`Context::progress_bar`].
273    pub fn progress(&mut self, ratio: f64) -> &mut Self {
274        self.progress_bar(ratio, 20)
275    }
276
277    /// Render a progress bar with a custom character width.
278    ///
279    /// `ratio` is clamped to `0.0..=1.0`. `width` is the total number of
280    /// characters rendered.
281    /// Render a progress bar filled to the given ratio (0.0–1.0).
282    pub fn progress_bar(&mut self, ratio: f64, width: u32) -> &mut Self {
283        self.progress_bar_colored(ratio, width, self.theme.primary)
284    }
285
286    /// Render a progress bar with a custom fill color.
287    pub fn progress_bar_colored(&mut self, ratio: f64, width: u32, color: Color) -> &mut Self {
288        let clamped = ratio.clamp(0.0, 1.0);
289        let filled = (clamped * width as f64).round() as u32;
290        let empty = width.saturating_sub(filled);
291        let mut bar = String::new();
292        for _ in 0..filled {
293            bar.push('█');
294        }
295        for _ in 0..empty {
296            bar.push('░');
297        }
298        self.styled(bar, Style::new().fg(color))
299    }
300}