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//! Beyond W5, this widget also backs the standalone **TextEdit demo**
21//! (`demo-ui`'s `text_edit_demo.rs`, mirroring egui's `text_edit.rs`), so it
22//! grew the following egui-parity capabilities:
23//!   * configurable hint / placeholder text ([`with_hint_text`](TextArea::with_hint_text));
24//!   * content alignment — horizontal [`TextHAlign`] and vertical [`TextVAlign`],
25//!     settable statically or bound to a live `Rc<Cell<_>>`;
26//!   * selection introspection ([`selection`](TextArea::selection) /
27//!     [`selected_text`](TextArea::selected_text));
28//!   * a shared, externally-mutable edit state ([`with_edit_state`](TextArea::with_edit_state)
29//!     / [`edit_state`](TextArea::edit_state)) with content-epoch cache
30//!     invalidation, so a caller can clear/replace the text or move the cursor
31//!     from outside the widget tree;
32//!   * a programmatic focus id + cursor-to-start/end helpers;
33//!   * a pre-default key-chord interceptor ([`with_key_intercept`](TextArea::with_key_intercept)),
34//!     used by the demo for the Ctrl/Cmd+Y "toggle case of selection" shortcut.
35//!
36//! Deferred (known gaps, filed for later polish):
37//!   * undo / redo;
38//!   * input-method composition;
39//!   * BiDi and RTL layout.
40
41use std::cell::{Cell, RefCell};
42use std::rc::Rc;
43use std::sync::Arc;
44
45use web_time::Instant;
46
47use crate::cursor::{set_cursor_icon, CursorIcon};
48use crate::draw_ctx::DrawCtx;
49use crate::event::{Event, EventResult, Key, Modifiers, MouseButton};
50use crate::focus::FocusId;
51use crate::geometry::{Point, Rect, Size};
52use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
53use crate::text::{measure_advance, measure_text_metrics, Font};
54use crate::widget::{BackbufferCache, BackbufferMode, Widget};
55use crate::widgets::scrollbar::ScrollbarAxis;
56use crate::widgets::multi_click::{MultiClickTracker, SelectGranularity};
57use crate::widgets::text_field_core::{
58    next_char_boundary, next_word_boundary, paragraph_range_at, prev_char_boundary,
59    prev_word_boundary, word_range_at, TextEditState,
60};
61
62/// Horizontal alignment of the wrapped text content inside a [`TextArea`]'s
63/// padded inner rect. Mirrors egui's `TextEdit::horizontal_align`.
64#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
65pub enum TextHAlign {
66    #[default]
67    Left,
68    Center,
69    Right,
70}
71
72/// Vertical alignment of the wrapped text block inside a [`TextArea`]'s padded
73/// inner rect. Mirrors egui's `TextEdit::vertical_align`.
74#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
75pub enum TextVAlign {
76    #[default]
77    Top,
78    Center,
79    Bottom,
80}
81
82/// Signature of a pre-default key-chord interceptor. Returns `true` to consume
83/// the event and suppress the widget's built-in handling for that key.
84pub type KeyIntercept = dyn FnMut(&Key, &Modifiers) -> bool;
85
86/// Signature of a per-visual-line syntax highlighter. Given one wrapped line's
87/// rendered text, it returns a list of `(start_byte, end_byte, color)` runs
88/// (byte offsets relative to that line). Bytes not covered by any run are
89/// drawn in the ambient text colour. Runs are expected to be non-overlapping
90/// and sorted; overlaps simply repaint. Keeping it line-oriented sidesteps
91/// span-across-wrap bookkeeping and matches the simple fallback highlighter in
92/// egui's Code Editor demo.
93pub type LineHighlighter = dyn Fn(&str) -> Vec<(usize, usize, crate::color::Color)>;
94
95fn clipboard_get() -> Option<String> {
96    crate::clipboard::get_text()
97}
98
99fn clipboard_set(text: &str) {
100    crate::clipboard::set_text(text);
101}
102
103// ─── Wrapping helper ─────────────────────────────────────────────────────────
104
105/// A single visual line produced by [`wrap_text_indexed`].
106#[derive(Clone, Debug)]
107struct WrappedLine {
108    /// Inclusive byte offset into the source `text` where this visual
109    /// line's content begins.
110    start: usize,
111    /// Exclusive byte offset where this visual line's content ends
112    /// (not including a trailing newline).
113    end: usize,
114    /// Rendered text for this visual line (a substring of the source).
115    text: String,
116    /// Whether this visual line ended because of an explicit `\n` in
117    /// the source (vs. a soft wrap at word boundary).  Used to choose
118    /// whether moving the cursor past the end of the line lands on
119    /// the next visual line or just past the newline character.
120    hard_break: bool,
121}
122
123/// Wrap `text` at `max_width` and return the visual lines along with
124/// byte-offset ranges back into the source.  Explicit `\n` always
125/// produces a line break; between newlines, word-boundary soft wraps
126/// keep each visual line ≤ `max_width`.  An empty source still returns
127/// one empty line (so the cursor has somewhere to sit).
128fn wrap_text_indexed(
129    font: &Arc<Font>,
130    text: &str,
131    font_size: f64,
132    max_width: f64,
133) -> Vec<WrappedLine> {
134    let mut out: Vec<WrappedLine> = Vec::new();
135    let mut para_start = 0usize;
136    for (rel_end, chunk) in split_keep_newlines(text).enumerate() {
137        let _ = rel_end;
138        let para = chunk;
139        let para_abs_start = para_start;
140        let para_abs_end = para_abs_start + para.len();
141        // Each paragraph soft-wraps independently.  Walk its char
142        // byte indices and fill lines up to `max_width`.
143        let mut cursor = 0usize; // byte offset within `para`
144        let last_boundary = 0usize;
145        while cursor < para.len() {
146            // Find the longest prefix of `para[line_start..]` that
147            // fits in `max_width`.  Use word boundaries — fall back
148            // to the full prefix when no boundary is available (long
149            // unbroken token).
150            let line_start = cursor;
151            let mut fit_end = line_start;
152            let mut last_word_end: Option<usize> = None;
153            let mut idx = line_start;
154            while idx < para.len() {
155                let next = next_char_boundary(para, idx);
156                let candidate = &para[line_start..next];
157                let w = measure_text_metrics(font, candidate, font_size).width;
158                if w > max_width && fit_end > line_start {
159                    break;
160                }
161                fit_end = next;
162                // Record word boundaries as we pass them.
163                if next < para.len() {
164                    let next_ch = para[next..].chars().next().unwrap_or(' ');
165                    if next_ch.is_whitespace() {
166                        last_word_end = Some(next);
167                    }
168                }
169                idx = next;
170            }
171            // Decide where to break: the last word boundary if we have
172            // one AND we're not at the end of the paragraph; else just
173            // at `fit_end`.
174            let break_at = if fit_end < para.len() && last_word_end.is_some() {
175                last_word_end.unwrap()
176            } else {
177                fit_end.max(next_char_boundary(para, line_start))
178            };
179            let _ = last_boundary; // reserved for future hyphenation
180            let line_text = para[line_start..break_at].trim_end().to_string();
181            let abs_start = para_abs_start + line_start;
182            let abs_end = para_abs_start + break_at;
183            out.push(WrappedLine {
184                start: abs_start,
185                end: abs_end,
186                text: line_text,
187                hard_break: false,
188            });
189            // Skip over the whitespace we just consumed as a separator.
190            let mut next_line_start = break_at;
191            while next_line_start < para.len() {
192                let ch = para[next_line_start..].chars().next().unwrap_or('x');
193                if !ch.is_whitespace() || ch == '\n' {
194                    break;
195                }
196                next_line_start = next_char_boundary(para, next_line_start);
197            }
198            cursor = next_line_start;
199            if cursor >= para.len() {
200                break;
201            }
202        }
203        // Emit at least one line for an empty paragraph (blank line
204        // between \n\n, or a fresh doc with no content).
205        if out.is_empty() || out.last().map(|l| l.end).unwrap_or(0) != para_abs_end {
206            if para.is_empty() {
207                out.push(WrappedLine {
208                    start: para_abs_start,
209                    end: para_abs_end,
210                    text: String::new(),
211                    hard_break: false,
212                });
213            }
214        }
215        // Mark the paragraph's last visual line as ending with a hard
216        // break if the source had a trailing newline (see
217        // `split_keep_newlines` contract below).
218        let source_end = para_abs_end + 1; // +1 for the consumed '\n', if any
219        let had_newline =
220            source_end <= text.len() && text.as_bytes().get(para_abs_end) == Some(&b'\n');
221        if had_newline {
222            if let Some(last) = out.last_mut() {
223                last.hard_break = true;
224            }
225        }
226        para_start = if had_newline {
227            source_end
228        } else {
229            para_abs_end
230        };
231    }
232    if out.is_empty() {
233        out.push(WrappedLine {
234            start: 0,
235            end: 0,
236            text: String::new(),
237            hard_break: false,
238        });
239    }
240    out
241}
242
243/// Iterator over paragraph chunks — everything between `\n` boundaries
244/// (newline is NOT included in the yielded chunk, but the caller can
245/// detect its presence by comparing chunk byte-ranges to the source).
246fn split_keep_newlines(text: &str) -> impl Iterator<Item = &str> + '_ {
247    // `split('\n')` already gives the right semantics: consecutive \n's
248    // yield empty strings so cursor can sit on blank lines, and a
249    // trailing \n produces a final empty string (a blank final line).
250    text.split('\n')
251}
252
253// ─── TextArea widget ─────────────────────────────────────────────────────────
254
255/// Snapshot of every input that affects the cached backbuffer bitmap
256/// (background + selection band + wrapped text). `layout` compares the current
257/// sig against the last one and drops the LCD/RGBA cache on any difference, so
258/// typing / selecting re-rasterise but an idle (blinking-only) frame just
259/// re-blits the cached pixels.
260///
261/// Deliberately excludes cursor-blink phase, the floating scrollbar, and the
262/// border — all paint in `paint_overlay` after the cache blit, so they never
263/// force a re-raster. It also excludes the *raw* scroll offset: the widget
264/// rasters an over-scan band anchored in content space, so plain scrolling
265/// within the band only moves the blit offset. Only the band *anchor* and its
266/// margins are tracked here, so re-anchoring (offset left the band) still
267/// re-rasters exactly once. Typography- and theme-driven invalidation (font
268/// swap, LCD/hinting toggle, dark/light flip) is handled by the framework via
269/// the epoch checks in `paint_subtree_backbuffered`, so this sig only tracks the
270/// widget's own state.
271#[derive(Clone, PartialEq)]
272struct TextAreaSig {
273    epoch: u64,
274    cursor: usize,
275    anchor: usize,
276    focused: bool,
277    hovered: bool,
278    /// The band anchor (content-space offset the backbuffer is rastered at) and
279    /// its over-scan extents — NOT the live scroll offset. Scrolling within the
280    /// band leaves these unchanged, so the cache stays valid and only the blit
281    /// offset moves; re-anchoring (offset left the band) changes the anchor and
282    /// forces one re-raster. `band_active` distinguishes the bounds-sized path.
283    band_active: bool,
284    band_anchor_bits: u64,
285    band_over_top_bits: u64,
286    band_over_bottom_bits: u64,
287    w_bits: u64,
288    h_bits: u64,
289    h_align: TextHAlign,
290    v_align: TextVAlign,
291    font_size_bits: u64,
292}
293
294/// A multiline text editor that fills its available area.
295pub struct TextArea {
296    bounds: Rect,
297    children: Vec<Box<dyn Widget>>, // always empty
298    base: WidgetBase,
299
300    font: Arc<Font>,
301    font_size: f64,
302    padding: f64,
303
304    /// Placeholder shown (dimmed) while the buffer is empty. Mirrors egui's
305    /// `TextEdit::hint_text`.
306    hint: String,
307
308    /// Static content alignment. Ignored on the axis where a `*_align_cell`
309    /// binding is present (the cell wins so external toggles apply live).
310    content_h_align: TextHAlign,
311    content_v_align: TextVAlign,
312    /// Optional live bindings for content alignment — lets a caller flip
313    /// alignment from a segmented control without rebuilding the widget.
314    h_align_cell: Option<Rc<Cell<TextHAlign>>>,
315    v_align_cell: Option<Rc<Cell<TextVAlign>>>,
316
317    /// Stable id for the programmatic focus channel
318    /// ([`crate::focus::request_focus`]); `None` opts out.
319    focus_request_id: Option<FocusId>,
320
321    /// Pre-default key-chord interceptor. Invoked at the top of `KeyDown`
322    /// handling; when it returns `true` the event is consumed and the
323    /// built-in key handling is skipped.
324    on_key_chord: Option<Rc<RefCell<KeyIntercept>>>,
325
326    /// Optional per-line syntax highlighter (see [`LineHighlighter`]). When
327    /// present, each wrapped line is painted as coloured runs instead of a
328    /// single ambient-colour string.
329    highlighter: Option<Rc<LineHighlighter>>,
330
331    /// Fired after any text mutation (typing, delete, paste, cut, and
332    /// key-intercept edits that advance the content epoch). Mirrors
333    /// TextField's `on_change`. Builder + dispatcher live in
334    /// `text_area/callbacks.rs`.
335    on_change: Option<Box<dyn FnMut(&str)>>,
336
337    /// Live edit state.  Shared with future undo / clipboard wiring, and with
338    /// external callers via [`with_edit_state`](Self::with_edit_state).
339    edit: Rc<RefCell<TextEditState>>,
340
341    /// Cached layout — invalidated when text / font / width changes.
342    cached_wrap_width: f64,
343    cached_lines: Vec<WrappedLine>,
344    cached_line_h: f64,
345    /// `edit.epoch` observed when `cached_lines` was last (re)built. A
346    /// mismatch means the text was mutated through the shared handle by an
347    /// external owner, so the wrap cache is stale even at the same width.
348    cached_epoch: u64,
349
350    /// Internal vertical scroll state. Reuses `ScrollView`'s [`ScrollbarAxis`]
351    /// so overflow math, thumb geometry, drag/hover and painting stay
352    /// pixel-consistent with the app's other scroll bars. `offset` is the
353    /// scroll distance from the top (0 = first line visible); it is folded into
354    /// [`content_top_y`](Self::content_top_y). See `text_area/scroll.rs`.
355    vbar: ScrollbarAxis,
356
357    /// Optional publish channel mirroring `vbar.offset`. Sibling widgets that
358    /// live outside this widget's subtree — e.g. the Code Editor demo's
359    /// line-number gutter — have no access to the internal scroll state, yet
360    /// must follow the viewport pixel-for-pixel. Whenever the offset moves the
361    /// widget writes it here so the sibling can read the same value it paints
362    /// with. See [`with_scroll_watch`](Self::with_scroll_watch).
363    scroll_watch: Option<Rc<Cell<f64>>>,
364
365    /// Cursor byte offset observed at the previous `layout`. `None` until the
366    /// first layout. A change between layouts that the widget's own edit funnel
367    /// didn't already scroll for (i.e. an *external* mutation of the shared
368    /// [`TextEditState`], as the demo's "start"/"end" buttons do) triggers
369    /// [`ensure_cursor_visible`](Self::ensure_cursor_visible) so programmatic
370    /// caret moves scroll into view just like typed navigation.
371    last_layout_cursor: Option<usize>,
372
373    /// Ephemeral input state.
374    focused: bool,
375    hovered: bool,
376    selecting_drag: bool,
377    focus_time: Option<Instant>,
378    blink_last_phase: Cell<u64>,
379
380    /// Multi-click (single / double / triple) detection and the granularity of
381    /// the active selection drag. A double-click selects the word, a
382    /// triple-click the logical line (paragraph), and dragging afterwards
383    /// extends by whole words / paragraphs. `select_pivot` is the byte range
384    /// the initiating click selected.
385    multi_click: MultiClickTracker,
386    select_granularity: SelectGranularity,
387    select_pivot: (usize, usize),
388
389    /// Per-widget CPU bitmap cache. Routes the whole editor (bg + selection +
390    /// text) through the same LCD-subpixel / grayscale pipeline as `Label` and
391    /// `TextField`: `paint_subtree_backbuffered` renders this subtree into an
392    /// `LcdBuffer` (LCD on) or RGBA `Framebuffer` (LCD off) and blits it, so the
393    /// editor gets the app's best text rendering by default and follows the
394    /// System settings live.
395    cache: BackbufferCache,
396    /// Last painted signature; a change invalidates [`Self::cache`] in `layout`.
397    last_sig: Option<TextAreaSig>,
398
399    /// Over-scan band state (anchor + margins) so scrolling within the band is a
400    /// pure blit offset instead of a re-raster. Recomputed each `layout`; see
401    /// `text_area/band.rs`.
402    band: band::BandState,
403    /// `Some(anchor)` only while painting into the band buffer, so content
404    /// geometry uses the band's fixed anchor there while hit-test / caret /
405    /// scroll math outside paint keep using the live offset. See
406    /// [`Self::content_top_y`].
407    render_band_offset: Cell<Option<f64>>,
408    /// Count of real backbuffer re-rasters (bumped in `paint`). Introspection
409    /// hook for the band tests — scrolling within a band must not bump it.
410    raster_count: Cell<u64>,
411    /// Count of *partial* (dirty-line-strip) re-rasters (bumped in `paint` when
412    /// it repaints only the edited strip). Introspection hook for the edit
413    /// tests — a localized edit must bump this, a re-anchor must not.
414    strip_raster_count: Cell<u64>,
415    /// Visual-line index range `(first, last)` that changed in the last
416    /// `refresh_wrap` re-wrap, plus whether the total line count changed.
417    /// `None` when nothing re-wrapped or the width changed (full re-flow).
418    /// Feeds [`Self::plan_dirty_strip`]. Transient (recomputed each layout).
419    wrap_change: Option<(usize, usize, bool)>,
420    /// Inclusive visual-line range to repaint into the retained band buffer for
421    /// a strip-only edit; `None` means repaint the whole band. Set in `layout`
422    /// (see [`Self::plan_dirty_strip`]), consumed + cleared in `paint`.
423    render_dirty_lines: Cell<Option<(usize, usize)>>,
424
425    /// Default-on right-click Cut/Copy/Paste/Select-All menu. Opt out with
426    /// [`with_context_menu(false)`](Self::with_context_menu).
427    context_menu: crate::widgets::text_context_menu::TextContextMenu,
428    context_menu_enabled: bool,
429}
430
431impl TextArea {
432    pub fn new(font: Arc<Font>) -> Self {
433        Self {
434            bounds: Rect::default(),
435            children: Vec::new(),
436            base: WidgetBase::new(),
437            font,
438            font_size: 13.0,
439            padding: 8.0,
440            hint: "Type here…".to_string(),
441            content_h_align: TextHAlign::Left,
442            content_v_align: TextVAlign::Top,
443            h_align_cell: None,
444            v_align_cell: None,
445            focus_request_id: None,
446            on_key_chord: None,
447            highlighter: None,
448            on_change: None,
449            edit: Rc::new(RefCell::new(TextEditState::default())),
450            cached_wrap_width: -1.0,
451            cached_lines: Vec::new(),
452            cached_line_h: 0.0,
453            cached_epoch: 0,
454            vbar: ScrollbarAxis {
455                enabled: true,
456                ..ScrollbarAxis::default()
457            },
458            scroll_watch: None,
459            last_layout_cursor: None,
460            focused: false,
461            hovered: false,
462            selecting_drag: false,
463            focus_time: None,
464            blink_last_phase: Cell::new(0),
465            multi_click: MultiClickTracker::default(),
466            select_granularity: SelectGranularity::default(),
467            select_pivot: (0, 0),
468            cache: BackbufferCache::default(),
469            last_sig: None,
470            band: band::BandState::default(),
471            render_band_offset: Cell::new(None),
472            raster_count: Cell::new(0),
473            strip_raster_count: Cell::new(0),
474            wrap_change: None,
475            render_dirty_lines: Cell::new(None),
476            context_menu: crate::widgets::text_context_menu::TextContextMenu::new(),
477            context_menu_enabled: true,
478        }
479    }
480
481    /// Build the backbuffer-cache invalidation signature from current state.
482    fn cache_sig(&self) -> TextAreaSig {
483        let st = self.edit.borrow();
484        TextAreaSig {
485            epoch: st.epoch,
486            cursor: st.cursor,
487            anchor: st.anchor,
488            focused: self.focused,
489            hovered: self.hovered,
490            band_active: self.band.active,
491            band_anchor_bits: self.band.anchor.to_bits(),
492            band_over_top_bits: self.band.over_top.to_bits(),
493            band_over_bottom_bits: self.band.over_bottom.to_bits(),
494            w_bits: self.bounds.width.to_bits(),
495            h_bits: self.bounds.height.to_bits(),
496            h_align: self.resolved_h_align(),
497            v_align: self.resolved_v_align(),
498            font_size_bits: self.font_size.to_bits(),
499        }
500    }
501
502    pub fn with_text(self, text: impl Into<String>) -> Self {
503        let t: String = text.into();
504        let cursor = t.len();
505        let epoch = self.edit.borrow().epoch.wrapping_add(1);
506        *self.edit.borrow_mut() = TextEditState {
507            text: t,
508            cursor,
509            anchor: cursor,
510            epoch,
511        };
512        self
513    }
514
515    /// Placeholder text shown, dimmed, while the buffer is empty.
516    pub fn with_hint_text(mut self, hint: impl Into<String>) -> Self {
517        self.hint = hint.into();
518        self
519    }
520
521    /// Static horizontal alignment of the wrapped content.
522    pub fn with_content_h_align(mut self, a: TextHAlign) -> Self {
523        self.content_h_align = a;
524        self
525    }
526    /// Static vertical alignment of the wrapped content block.
527    pub fn with_content_v_align(mut self, a: TextVAlign) -> Self {
528        self.content_v_align = a;
529        self
530    }
531    /// Bind horizontal alignment to a live cell (wins over the static value).
532    pub fn with_h_align_cell(mut self, cell: Rc<Cell<TextHAlign>>) -> Self {
533        self.h_align_cell = Some(cell);
534        self
535    }
536    /// Bind vertical alignment to a live cell (wins over the static value).
537    pub fn with_v_align_cell(mut self, cell: Rc<Cell<TextVAlign>>) -> Self {
538        self.v_align_cell = Some(cell);
539        self
540    }
541
542    /// Adopt an externally-owned edit state so a caller can read the text /
543    /// selection and mutate it (clear, replace, move cursor) from outside the
544    /// widget tree. External text mutations must call
545    /// [`TextEditState::note_text_change`] so the wrap cache invalidates.
546    pub fn with_edit_state(mut self, state: Rc<RefCell<TextEditState>>) -> Self {
547        self.edit = state;
548        self.cached_wrap_width = -1.0;
549        self
550    }
551
552    /// Stable id for the programmatic focus channel
553    /// ([`crate::focus::request_focus`]).
554    pub fn with_focus_id(mut self, id: FocusId) -> Self {
555        self.focus_request_id = Some(id);
556        self
557    }
558
559    /// Install a pre-default key-chord interceptor. It runs before the widget's
560    /// built-in key handling on every `KeyDown`; returning `true` consumes the
561    /// event and suppresses the default action for that key.
562    pub fn with_key_intercept(
563        mut self,
564        cb: impl FnMut(&Key, &Modifiers) -> bool + 'static,
565    ) -> Self {
566        self.on_key_chord = Some(Rc::new(RefCell::new(cb)));
567        self
568    }
569
570    /// Install a per-visual-line syntax highlighter (see [`LineHighlighter`]).
571    pub fn with_highlighter(
572        mut self,
573        cb: impl Fn(&str) -> Vec<(usize, usize, crate::color::Color)> + 'static,
574    ) -> Self {
575        self.highlighter = Some(Rc::new(cb));
576        self
577    }
578
579    /// Clone of the shared edit-state handle, for selection/text readback and
580    /// external mutation.
581    pub fn edit_state(&self) -> Rc<RefCell<TextEditState>> {
582        Rc::clone(&self.edit)
583    }
584
585    /// Current selection as a sorted `[start, end)` byte range, or `None` when
586    /// nothing is selected.
587    pub fn selection(&self) -> Option<(usize, usize)> {
588        self.edit.borrow().selection_range()
589    }
590
591    /// The currently-selected substring (empty when there is no selection).
592    pub fn selected_text(&self) -> String {
593        let st = self.edit.borrow();
594        match st.selection_range() {
595            Some((lo, hi)) => st.text[lo..hi].to_string(),
596            None => String::new(),
597        }
598    }
599
600    /// Collapse the selection and place the cursor at the very start.
601    pub fn set_cursor_to_start(&mut self) {
602        let mut st = self.edit.borrow_mut();
603        st.cursor = 0;
604        st.anchor = 0;
605    }
606
607    /// Collapse the selection and place the cursor at the very end.
608    pub fn set_cursor_to_end(&mut self) {
609        let mut st = self.edit.borrow_mut();
610        let end = st.text.len();
611        st.cursor = end;
612        st.anchor = end;
613    }
614    pub fn with_font_size(mut self, size: f64) -> Self {
615        self.font_size = size;
616        self
617    }
618    pub fn with_padding(mut self, p: f64) -> Self {
619        self.padding = p;
620        self
621    }
622
623    pub fn with_margin(mut self, m: Insets) -> Self {
624        self.base.margin = m;
625        self
626    }
627    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
628        self.base.h_anchor = h;
629        self
630    }
631    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
632        self.base.v_anchor = v;
633        self
634    }
635    pub fn with_min_size(mut self, s: Size) -> Self {
636        self.base.min_size = s;
637        self
638    }
639    pub fn with_max_size(mut self, s: Size) -> Self {
640        self.base.max_size = s;
641        self
642    }
643
644    /// Current text.  Cheap — clones the underlying `String`.
645    pub fn text(&self) -> String {
646        self.edit.borrow().text.clone()
647    }
648
649    /// Current byte-offset cursor position (for tests and inspectors).
650    pub fn cursor(&self) -> usize {
651        self.edit.borrow().cursor
652    }
653
654    /// Count of visual lines at the last layout pass (cache).
655    pub fn visual_line_count(&self) -> usize {
656        self.cached_lines.len()
657    }
658
659    /// Ensure the wrap cache matches the current text + width.
660    fn refresh_wrap(&mut self, inner_w: f64) {
661        let st = self.edit.borrow();
662        let same_width = (self.cached_wrap_width - inner_w).abs() < 0.5;
663        // An external owner of the shared edit state may have replaced the
664        // text without touching our width or calling `mark_dirty`; the epoch
665        // catches that case so the cache never blits stale wrapping.
666        let same_epoch = self.cached_epoch == st.epoch;
667        if same_width && same_epoch && !self.cached_lines.is_empty() {
668            // No re-wrap: leave `wrap_change` as `layout` reset it, so a second
669            // `refresh_wrap` in the same pass (via `sync_scroll`) can't clobber
670            // the change range the first, re-wrapping call recorded.
671            return;
672        }
673        let lines = wrap_text_indexed(&self.font, &st.text, self.font_size, inner_w.max(1.0));
674        // Diff old vs new visual lines to find the changed range, so an in-place
675        // edit can re-raster just that strip. Runs whenever we re-wrap over an
676        // existing wrap (a fresh build has nothing to diff). The re-wrap flag
677        // `mark_dirty` sets clears `cached_wrap_width`, so we can't tell an edit
678        // from a genuine width change here — but `plan_dirty_strip` rejects the
679        // strip when the width actually changed (its `w_bits` guard), so a
680        // width re-flow safely falls back to a full band repaint.
681        self.wrap_change = if self.cached_lines.is_empty() {
682            None
683        } else {
684            band::diff_line_range(&self.cached_lines, &lines)
685        };
686        self.cached_lines = lines;
687        self.cached_wrap_width = inner_w;
688        self.cached_epoch = st.epoch;
689        // Line height — a little slacker than tight metrics so
690        // descenders from line N don't kiss ascenders from N+1.
691        self.cached_line_h = self.font_size * 1.35;
692    }
693
694    /// Force a re-wrap on the next layout.
695    fn mark_dirty(&mut self) {
696        self.cached_wrap_width = -1.0;
697    }
698
699    // ── Content-alignment geometry ────────────────────────────────────────
700    //
701    // Alignment shifts where wrapped lines sit inside the padded inner rect.
702    // These helpers are the single source of truth for that offset math so
703    // paint, cursor overlay, hit-testing and cursor-position queries all agree.
704
705    /// Resolved horizontal alignment (cell binding wins over the static value).
706    fn resolved_h_align(&self) -> TextHAlign {
707        self.h_align_cell
708            .as_ref()
709            .map(|c| c.get())
710            .unwrap_or(self.content_h_align)
711    }
712
713    /// Resolved vertical alignment (cell binding wins over the static value).
714    fn resolved_v_align(&self) -> TextVAlign {
715        self.v_align_cell
716            .as_ref()
717            .map(|c| c.get())
718            .unwrap_or(self.content_v_align)
719    }
720
721    /// Inner content width (widget width minus horizontal padding).
722    fn inner_width(&self) -> f64 {
723        (self.bounds.width - self.padding * 2.0).max(0.0)
724    }
725
726    /// Vertical shift (downward, in the Y-up frame) applied to the whole line
727    /// block to honour [`TextVAlign`]. `0` for `Top`.
728    fn v_align_shift(&self) -> f64 {
729        let content_h = self.cached_lines.len() as f64 * self.cached_line_h;
730        let inner_h = (self.bounds.height - self.padding * 2.0).max(0.0);
731        let slack = (inner_h - content_h).max(0.0);
732        match self.resolved_v_align() {
733            TextVAlign::Top => 0.0,
734            TextVAlign::Center => slack * 0.5,
735            TextVAlign::Bottom => slack,
736        }
737    }
738
739    /// Y coordinate (Y-up) of the TOP edge of visual line 0.
740    ///
741    /// Folds in the internal vertical scroll offset: as the user scrolls down
742    /// (`vbar.offset` grows), line 0 rises above the visible top edge and lower
743    /// lines come into view. Every geometry query (paint, hit-test, cursor
744    /// overlay, scroll-to-cursor) routes through here, so they all agree.
745    fn content_top_y(&self) -> f64 {
746        // While rastering the over-scan band, positioning uses the band's fixed
747        // anchor (set in `paint`), so the cached bitmap is stable as the user
748        // scrolls and only the blit offset moves. Everywhere else (hit-test,
749        // caret overlay, scroll math) uses the live offset.
750        let offset = self.render_band_offset.get().unwrap_or(self.vbar.offset);
751        self.bounds.height - self.padding - self.v_align_shift() + offset
752    }
753
754    /// Y coordinate (Y-up) of the top edge of visual line `i`.
755    fn line_top_y(&self, i: usize) -> f64 {
756        self.content_top_y() - i as f64 * self.cached_line_h
757    }
758
759    /// Horizontal start (x) of a line's rendered text, honouring [`TextHAlign`].
760    fn line_x_start(&self, line: &WrappedLine) -> f64 {
761        let line_w = measure_advance(&self.font, &line.text, self.font_size);
762        let slack = (self.inner_width() - line_w).max(0.0);
763        let shift = match self.resolved_h_align() {
764            TextHAlign::Left => 0.0,
765            TextHAlign::Center => slack * 0.5,
766            TextHAlign::Right => slack,
767        };
768        self.padding + shift
769    }
770
771}
772
773mod band;
774mod callbacks;
775mod context_menu;
776mod edit_ops;
777mod geometry;
778mod scroll;
779mod widget_impl;
780
781#[cfg(test)]
782mod band_tests;
783#[cfg(test)]
784mod selection_tests;
785#[cfg(test)]
786mod tests;
787#[cfg(test)]
788mod redraw_tests;
789#[cfg(test)]
790mod scroll_watch_tests;