Skip to main content

agg_gui/widgets/
text_area.rs

1//! `TextArea` — a multiline text editor.
2//!
3//! Built for W5 of the Window Resize Test (egui's "↔ resizable with
4//! TextEdit") — a widget that **fills its available area** and lets
5//! the user edit a paragraph of text across many wrapped visual
6//! lines.  Shares the underlying `TextEditState` with `TextField` so
7//! the same keyboard shortcuts / undo semantics are in reach later.
8//!
9//! # Scope (Stage 4)
10//!
11//! Covers the behaviour W5 actually needs and what a mobile user
12//! would expect from an editable paragraph:
13//!   * word-wrap to the widget's inner width;
14//!   * typing / backspace / delete / Enter produce visible edits;
15//!   * arrow keys navigate by char or visual line;
16//!   * click positions cursor; drag selects;
17//!   * cursor blink with focus state;
18//!   * copy / cut / paste via the standard clipboard shortcuts.
19//!
20//! Deferred (known gaps, filed for Stage 5 polish):
21//!   * word-boundary jumps (Ctrl+arrows) across wrapped visual lines;
22//!   * undo / redo;
23//!   * input-method composition;
24//!   * BiDi and RTL layout.
25
26use std::cell::{Cell, RefCell};
27use std::rc::Rc;
28use std::sync::Arc;
29
30use web_time::Instant;
31
32use crate::cursor::{set_cursor_icon, CursorIcon};
33use crate::draw_ctx::DrawCtx;
34use crate::event::{Event, EventResult, Key, MouseButton};
35use crate::geometry::{Point, Rect, Size};
36use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
37use crate::text::{measure_advance, measure_text_metrics, Font};
38use crate::widget::Widget;
39use crate::widgets::text_field_core::{next_char_boundary, prev_char_boundary, TextEditState};
40
41fn clipboard_get() -> Option<String> {
42    crate::clipboard::get_text()
43}
44
45fn clipboard_set(text: &str) {
46    crate::clipboard::set_text(text);
47}
48
49// ─── Wrapping helper ─────────────────────────────────────────────────────────
50
51/// A single visual line produced by [`wrap_text_indexed`].
52#[derive(Clone, Debug)]
53struct WrappedLine {
54    /// Inclusive byte offset into the source `text` where this visual
55    /// line's content begins.
56    start: usize,
57    /// Exclusive byte offset where this visual line's content ends
58    /// (not including a trailing newline).
59    end: usize,
60    /// Rendered text for this visual line (a substring of the source).
61    text: String,
62    /// Whether this visual line ended because of an explicit `\n` in
63    /// the source (vs. a soft wrap at word boundary).  Used to choose
64    /// whether moving the cursor past the end of the line lands on
65    /// the next visual line or just past the newline character.
66    hard_break: bool,
67}
68
69/// Wrap `text` at `max_width` and return the visual lines along with
70/// byte-offset ranges back into the source.  Explicit `\n` always
71/// produces a line break; between newlines, word-boundary soft wraps
72/// keep each visual line ≤ `max_width`.  An empty source still returns
73/// one empty line (so the cursor has somewhere to sit).
74fn wrap_text_indexed(
75    font: &Arc<Font>,
76    text: &str,
77    font_size: f64,
78    max_width: f64,
79) -> Vec<WrappedLine> {
80    let mut out: Vec<WrappedLine> = Vec::new();
81    let mut para_start = 0usize;
82    for (rel_end, chunk) in split_keep_newlines(text).enumerate() {
83        let _ = rel_end;
84        let para = chunk;
85        let para_abs_start = para_start;
86        let para_abs_end = para_abs_start + para.len();
87        // Each paragraph soft-wraps independently.  Walk its char
88        // byte indices and fill lines up to `max_width`.
89        let mut cursor = 0usize; // byte offset within `para`
90        let last_boundary = 0usize;
91        while cursor < para.len() {
92            // Find the longest prefix of `para[line_start..]` that
93            // fits in `max_width`.  Use word boundaries — fall back
94            // to the full prefix when no boundary is available (long
95            // unbroken token).
96            let line_start = cursor;
97            let mut fit_end = line_start;
98            let mut last_word_end: Option<usize> = None;
99            let mut idx = line_start;
100            while idx < para.len() {
101                let next = next_char_boundary(para, idx);
102                let candidate = &para[line_start..next];
103                let w = measure_text_metrics(font, candidate, font_size).width;
104                if w > max_width && fit_end > line_start {
105                    break;
106                }
107                fit_end = next;
108                // Record word boundaries as we pass them.
109                if next < para.len() {
110                    let next_ch = para[next..].chars().next().unwrap_or(' ');
111                    if next_ch.is_whitespace() {
112                        last_word_end = Some(next);
113                    }
114                }
115                idx = next;
116            }
117            // Decide where to break: the last word boundary if we have
118            // one AND we're not at the end of the paragraph; else just
119            // at `fit_end`.
120            let break_at = if fit_end < para.len() && last_word_end.is_some() {
121                last_word_end.unwrap()
122            } else {
123                fit_end.max(next_char_boundary(para, line_start))
124            };
125            let _ = last_boundary; // reserved for future hyphenation
126            let line_text = para[line_start..break_at].trim_end().to_string();
127            let abs_start = para_abs_start + line_start;
128            let abs_end = para_abs_start + break_at;
129            out.push(WrappedLine {
130                start: abs_start,
131                end: abs_end,
132                text: line_text,
133                hard_break: false,
134            });
135            // Skip over the whitespace we just consumed as a separator.
136            let mut next_line_start = break_at;
137            while next_line_start < para.len() {
138                let ch = para[next_line_start..].chars().next().unwrap_or('x');
139                if !ch.is_whitespace() || ch == '\n' {
140                    break;
141                }
142                next_line_start = next_char_boundary(para, next_line_start);
143            }
144            cursor = next_line_start;
145            if cursor >= para.len() {
146                break;
147            }
148        }
149        // Emit at least one line for an empty paragraph (blank line
150        // between \n\n, or a fresh doc with no content).
151        if out.is_empty() || out.last().map(|l| l.end).unwrap_or(0) != para_abs_end {
152            if para.is_empty() {
153                out.push(WrappedLine {
154                    start: para_abs_start,
155                    end: para_abs_end,
156                    text: String::new(),
157                    hard_break: false,
158                });
159            }
160        }
161        // Mark the paragraph's last visual line as ending with a hard
162        // break if the source had a trailing newline (see
163        // `split_keep_newlines` contract below).
164        let source_end = para_abs_end + 1; // +1 for the consumed '\n', if any
165        let had_newline =
166            source_end <= text.len() && text.as_bytes().get(para_abs_end) == Some(&b'\n');
167        if had_newline {
168            if let Some(last) = out.last_mut() {
169                last.hard_break = true;
170            }
171        }
172        para_start = if had_newline {
173            source_end
174        } else {
175            para_abs_end
176        };
177    }
178    if out.is_empty() {
179        out.push(WrappedLine {
180            start: 0,
181            end: 0,
182            text: String::new(),
183            hard_break: false,
184        });
185    }
186    out
187}
188
189/// Iterator over paragraph chunks — everything between `\n` boundaries
190/// (newline is NOT included in the yielded chunk, but the caller can
191/// detect its presence by comparing chunk byte-ranges to the source).
192fn split_keep_newlines(text: &str) -> impl Iterator<Item = &str> + '_ {
193    // `split('\n')` already gives the right semantics: consecutive \n's
194    // yield empty strings so cursor can sit on blank lines, and a
195    // trailing \n produces a final empty string (a blank final line).
196    text.split('\n')
197}
198
199// ─── TextArea widget ─────────────────────────────────────────────────────────
200
201/// A multiline text editor that fills its available area.
202pub struct TextArea {
203    bounds: Rect,
204    children: Vec<Box<dyn Widget>>, // always empty
205    base: WidgetBase,
206
207    font: Arc<Font>,
208    font_size: f64,
209    padding: f64,
210
211    /// Live edit state.  Shared with future undo / clipboard wiring.
212    edit: Rc<RefCell<TextEditState>>,
213
214    /// Cached layout — invalidated when text / font / width changes.
215    cached_wrap_width: f64,
216    cached_lines: Vec<WrappedLine>,
217    cached_line_h: f64,
218
219    /// Ephemeral input state.
220    focused: bool,
221    hovered: bool,
222    selecting_drag: bool,
223    focus_time: Option<Instant>,
224    blink_last_phase: Cell<u64>,
225}
226
227impl TextArea {
228    pub fn new(font: Arc<Font>) -> Self {
229        Self {
230            bounds: Rect::default(),
231            children: Vec::new(),
232            base: WidgetBase::new(),
233            font,
234            font_size: 13.0,
235            padding: 8.0,
236            edit: Rc::new(RefCell::new(TextEditState::default())),
237            cached_wrap_width: -1.0,
238            cached_lines: Vec::new(),
239            cached_line_h: 0.0,
240            focused: false,
241            hovered: false,
242            selecting_drag: false,
243            focus_time: None,
244            blink_last_phase: Cell::new(0),
245        }
246    }
247
248    pub fn with_text(self, text: impl Into<String>) -> Self {
249        let t: String = text.into();
250        let cursor = t.len();
251        *self.edit.borrow_mut() = TextEditState {
252            text: t,
253            cursor,
254            anchor: cursor,
255        };
256        self
257    }
258    pub fn with_font_size(mut self, size: f64) -> Self {
259        self.font_size = size;
260        self
261    }
262    pub fn with_padding(mut self, p: f64) -> Self {
263        self.padding = p;
264        self
265    }
266
267    pub fn with_margin(mut self, m: Insets) -> Self {
268        self.base.margin = m;
269        self
270    }
271    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
272        self.base.h_anchor = h;
273        self
274    }
275    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
276        self.base.v_anchor = v;
277        self
278    }
279    pub fn with_min_size(mut self, s: Size) -> Self {
280        self.base.min_size = s;
281        self
282    }
283    pub fn with_max_size(mut self, s: Size) -> Self {
284        self.base.max_size = s;
285        self
286    }
287
288    /// Current text.  Cheap — clones the underlying `String`.
289    pub fn text(&self) -> String {
290        self.edit.borrow().text.clone()
291    }
292
293    /// Current byte-offset cursor position (for tests and inspectors).
294    pub fn cursor(&self) -> usize {
295        self.edit.borrow().cursor
296    }
297
298    /// Count of visual lines at the last layout pass (cache).
299    pub fn visual_line_count(&self) -> usize {
300        self.cached_lines.len()
301    }
302
303    /// Ensure the wrap cache matches the current text + width.
304    fn refresh_wrap(&mut self, inner_w: f64) {
305        let st = self.edit.borrow();
306        let same_width = (self.cached_wrap_width - inner_w).abs() < 0.5;
307        if same_width && !self.cached_lines.is_empty() {
308            // Wrap is expensive; skip when nothing that affects it
309            // changed.  `text` changes go through `mark_dirty` which
310            // resets `cached_wrap_width` to −1.
311            return;
312        }
313        let lines = wrap_text_indexed(&self.font, &st.text, self.font_size, inner_w.max(1.0));
314        self.cached_lines = lines;
315        self.cached_wrap_width = inner_w;
316        // Line height — a little slacker than tight metrics so
317        // descenders from line N don't kiss ascenders from N+1.
318        self.cached_line_h = self.font_size * 1.35;
319    }
320
321    /// Force a re-wrap on the next layout.
322    fn mark_dirty(&mut self) {
323        self.cached_wrap_width = -1.0;
324    }
325
326    /// Locate the (line_index, byte_pos_in_text) that the given cursor
327    /// byte offset lives on.  Returns `(0, 0)` on empty content.
328    fn line_for_cursor(&self, byte_pos: usize) -> usize {
329        for (i, l) in self.cached_lines.iter().enumerate() {
330            if byte_pos >= l.start && byte_pos <= l.end {
331                return i;
332            }
333        }
334        self.cached_lines.len().saturating_sub(1)
335    }
336
337    /// Hit-test a widget-local point to a text byte offset.  Clamps to
338    /// `[0, text.len()]` at the edges.  `local` is Y-UP.
339    fn byte_offset_at(&self, local: Point) -> usize {
340        if self.cached_lines.is_empty() || self.cached_line_h <= 0.0 {
341            return 0;
342        }
343        // Visual lines stack top-to-bottom; Y-up flips their y coords.
344        // Line 0 sits at the top (high Y), line N at the bottom (low Y).
345        let inner_top_y = self.bounds.height - self.padding;
346        let rel_from_top = inner_top_y - local.y;
347        let mut line_idx = (rel_from_top / self.cached_line_h).floor() as isize;
348        if line_idx < 0 {
349            line_idx = 0;
350        }
351        if line_idx as usize >= self.cached_lines.len() {
352            line_idx = self.cached_lines.len() as isize - 1;
353        }
354        let line = &self.cached_lines[line_idx as usize];
355        // X hit test: walk chars in the line's rendered text and pick
356        // the nearest grapheme boundary.
357        let pad_x = self.padding;
358        let rel_x = (local.x - pad_x).max(0.0);
359        let txt = &line.text;
360        let mut best_byte = 0usize;
361        let mut best_delta = f64::INFINITY;
362        let mut acc = 0.0_f64;
363        let mut prev_byte = 0usize;
364        for (i, _c) in txt.char_indices().chain(std::iter::once((txt.len(), ' '))) {
365            let w_here = if i > prev_byte {
366                measure_advance(&self.font, &txt[prev_byte..i], self.font_size)
367            } else {
368                0.0
369            };
370            acc += w_here;
371            let d = (acc - rel_x).abs();
372            if d < best_delta {
373                best_delta = d;
374                best_byte = i;
375            }
376            prev_byte = i;
377        }
378        line.start + best_byte
379    }
380
381    /// Screen position (widget-local, Y-UP) of the given cursor byte
382    /// offset.  Returns the bottom-left corner of the cursor glyph
383    /// cell.
384    fn pos_for_cursor(&self, byte_pos: usize) -> Point {
385        if self.cached_lines.is_empty() {
386            return Point::ORIGIN;
387        }
388        let line_idx = self.line_for_cursor(byte_pos);
389        let line = &self.cached_lines[line_idx];
390        let offset = byte_pos.saturating_sub(line.start).min(line.text.len());
391        let x = self.padding + measure_advance(&self.font, &line.text[..offset], self.font_size);
392        // Y-up: line i top-edge = inner_top - i * line_h.
393        let inner_top_y = self.bounds.height - self.padding;
394        let line_top = inner_top_y - line_idx as f64 * self.cached_line_h;
395        let line_bottom = line_top - self.cached_line_h;
396        Point::new(x, line_bottom)
397    }
398
399    /// Insert a string at the cursor, replacing any active selection.
400    fn insert_str(&mut self, s: &str) {
401        let mut st = self.edit.borrow_mut();
402        let (lo, hi) = (st.cursor.min(st.anchor), st.cursor.max(st.anchor));
403        // Make sure we slice at grapheme boundaries.
404        let lo = lo.min(st.text.len());
405        let hi = hi.min(st.text.len());
406        st.text.replace_range(lo..hi, s);
407        st.cursor = lo + s.len();
408        st.anchor = st.cursor;
409        drop(st);
410        self.mark_dirty();
411    }
412
413    /// Delete the current selection, or (if empty) `dir` chars toward
414    /// the supplied side.  `-1` = backspace, `+1` = delete, `0` = just
415    /// collapse the selection (cut path).
416    fn delete(&mut self, dir: i32) {
417        let mut st = self.edit.borrow_mut();
418        let (lo, hi) = (st.cursor.min(st.anchor), st.cursor.max(st.anchor));
419        if lo != hi {
420            st.text.replace_range(lo..hi, "");
421            st.cursor = lo;
422            st.anchor = lo;
423        } else if dir < 0 && st.cursor > 0 {
424            let cur = st.cursor;
425            let prev = prev_char_boundary(&st.text, cur);
426            st.text.replace_range(prev..cur, "");
427            st.cursor = prev;
428            st.anchor = prev;
429        } else if dir > 0 && st.cursor < st.text.len() {
430            let cur = st.cursor;
431            let next = next_char_boundary(&st.text, cur);
432            st.text.replace_range(cur..next, "");
433        }
434        drop(st);
435        self.mark_dirty();
436    }
437
438    /// Move cursor to an absolute byte offset.  `with_selection=false`
439    /// collapses anchor with cursor; `true` leaves the anchor alone
440    /// so a selection is extended.
441    fn move_cursor_to(&mut self, pos: usize, with_selection: bool) {
442        let mut st = self.edit.borrow_mut();
443        let p = pos.min(st.text.len());
444        st.cursor = p;
445        if !with_selection {
446            st.anchor = p;
447        }
448    }
449
450    /// Cursor one char left / right.
451    fn move_char(&mut self, dir: i32, with_selection: bool) {
452        let st = self.edit.borrow();
453        let p = if dir < 0 {
454            prev_char_boundary(&st.text, st.cursor)
455        } else {
456            next_char_boundary(&st.text, st.cursor)
457        };
458        drop(st);
459        self.move_cursor_to(p, with_selection);
460    }
461
462    /// Cursor one visual line up / down.  `dir` = −1 for up, +1 for down.
463    fn move_line(&mut self, dir: i32, with_selection: bool) {
464        if self.cached_lines.is_empty() {
465            return;
466        }
467        let cursor = self.edit.borrow().cursor;
468        let cur_line = self.line_for_cursor(cursor);
469        let target_line = if dir < 0 {
470            cur_line.saturating_sub(1)
471        } else {
472            (cur_line + 1).min(self.cached_lines.len() - 1)
473        };
474        if target_line == cur_line {
475            return;
476        }
477        // Preserve horizontal position (pixel column, not byte column).
478        let cur_x = self.pos_for_cursor(cursor).x - self.padding;
479        // Find byte offset in target_line closest to `cur_x`.
480        let line = &self.cached_lines[target_line];
481        let txt = &line.text;
482        let mut best_byte = 0usize;
483        let mut best_delta = f64::INFINITY;
484        let mut acc = 0.0_f64;
485        let mut prev_byte = 0usize;
486        for (i, _) in txt.char_indices().chain(std::iter::once((txt.len(), ' '))) {
487            let w = if i > prev_byte {
488                measure_advance(&self.font, &txt[prev_byte..i], self.font_size)
489            } else {
490                0.0
491            };
492            acc += w;
493            let d = (acc - cur_x).abs();
494            if d < best_delta {
495                best_delta = d;
496                best_byte = i;
497            }
498            prev_byte = i;
499        }
500        let target = line.start + best_byte;
501        self.move_cursor_to(target, with_selection);
502    }
503}
504
505mod widget_impl;