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//!   * word-boundary jumps (Ctrl+arrows) across wrapped visual lines;
38//!   * undo / redo;
39//!   * input-method composition;
40//!   * BiDi and RTL layout.
41
42use std::cell::{Cell, RefCell};
43use std::rc::Rc;
44use std::sync::Arc;
45
46use web_time::Instant;
47
48use crate::cursor::{set_cursor_icon, CursorIcon};
49use crate::draw_ctx::DrawCtx;
50use crate::event::{Event, EventResult, Key, Modifiers, MouseButton};
51use crate::focus::FocusId;
52use crate::geometry::{Point, Rect, Size};
53use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
54use crate::text::{measure_advance, measure_text_metrics, Font};
55use crate::widget::{BackbufferCache, BackbufferMode, Widget};
56use crate::widgets::scrollbar::ScrollbarAxis;
57use crate::widgets::multi_click::{MultiClickTracker, SelectGranularity};
58use crate::widgets::text_field_core::{
59    next_char_boundary, paragraph_range_at, prev_char_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 + border). `layout` compares
257/// the current sig against the last one and drops the LCD/RGBA cache on any
258/// difference, so typing / selecting / scrolling re-rasterise but an idle
259/// (blinking-only) frame just re-blits the cached pixels.
260///
261/// Deliberately excludes cursor-blink phase and the floating scrollbar — both
262/// paint in `paint_overlay` after the cache blit, so they never force a
263/// re-raster. Typography- and theme-driven invalidation (font swap, LCD/hinting
264/// toggle, dark/light flip) is handled by the framework via the epoch checks in
265/// `paint_subtree_backbuffered`, so this sig only tracks the widget's own state.
266#[derive(Clone, PartialEq)]
267struct TextAreaSig {
268    epoch: u64,
269    cursor: usize,
270    anchor: usize,
271    focused: bool,
272    hovered: bool,
273    offset_bits: u64,
274    w_bits: u64,
275    h_bits: u64,
276    h_align: TextHAlign,
277    v_align: TextVAlign,
278    font_size_bits: u64,
279}
280
281/// A multiline text editor that fills its available area.
282pub struct TextArea {
283    bounds: Rect,
284    children: Vec<Box<dyn Widget>>, // always empty
285    base: WidgetBase,
286
287    font: Arc<Font>,
288    font_size: f64,
289    padding: f64,
290
291    /// Placeholder shown (dimmed) while the buffer is empty. Mirrors egui's
292    /// `TextEdit::hint_text`.
293    hint: String,
294
295    /// Static content alignment. Ignored on the axis where a `*_align_cell`
296    /// binding is present (the cell wins so external toggles apply live).
297    content_h_align: TextHAlign,
298    content_v_align: TextVAlign,
299    /// Optional live bindings for content alignment — lets a caller flip
300    /// alignment from a segmented control without rebuilding the widget.
301    h_align_cell: Option<Rc<Cell<TextHAlign>>>,
302    v_align_cell: Option<Rc<Cell<TextVAlign>>>,
303
304    /// Stable id for the programmatic focus channel
305    /// ([`crate::focus::request_focus`]); `None` opts out.
306    focus_request_id: Option<FocusId>,
307
308    /// Pre-default key-chord interceptor. Invoked at the top of `KeyDown`
309    /// handling; when it returns `true` the event is consumed and the
310    /// built-in key handling is skipped.
311    on_key_chord: Option<Rc<RefCell<KeyIntercept>>>,
312
313    /// Optional per-line syntax highlighter (see [`LineHighlighter`]). When
314    /// present, each wrapped line is painted as coloured runs instead of a
315    /// single ambient-colour string.
316    highlighter: Option<Rc<LineHighlighter>>,
317
318    /// Fired after any text mutation (typing, delete, paste, cut, and
319    /// key-intercept edits that advance the content epoch). Mirrors
320    /// TextField's `on_change`. Builder + dispatcher live in
321    /// `text_area/callbacks.rs`.
322    on_change: Option<Box<dyn FnMut(&str)>>,
323
324    /// Live edit state.  Shared with future undo / clipboard wiring, and with
325    /// external callers via [`with_edit_state`](Self::with_edit_state).
326    edit: Rc<RefCell<TextEditState>>,
327
328    /// Cached layout — invalidated when text / font / width changes.
329    cached_wrap_width: f64,
330    cached_lines: Vec<WrappedLine>,
331    cached_line_h: f64,
332    /// `edit.epoch` observed when `cached_lines` was last (re)built. A
333    /// mismatch means the text was mutated through the shared handle by an
334    /// external owner, so the wrap cache is stale even at the same width.
335    cached_epoch: u64,
336
337    /// Internal vertical scroll state. Reuses `ScrollView`'s [`ScrollbarAxis`]
338    /// so overflow math, thumb geometry, drag/hover and painting stay
339    /// pixel-consistent with the app's other scroll bars. `offset` is the
340    /// scroll distance from the top (0 = first line visible); it is folded into
341    /// [`content_top_y`](Self::content_top_y). See `text_area/scroll.rs`.
342    vbar: ScrollbarAxis,
343
344    /// Cursor byte offset observed at the previous `layout`. `None` until the
345    /// first layout. A change between layouts that the widget's own edit funnel
346    /// didn't already scroll for (i.e. an *external* mutation of the shared
347    /// [`TextEditState`], as the demo's "start"/"end" buttons do) triggers
348    /// [`ensure_cursor_visible`](Self::ensure_cursor_visible) so programmatic
349    /// caret moves scroll into view just like typed navigation.
350    last_layout_cursor: Option<usize>,
351
352    /// Ephemeral input state.
353    focused: bool,
354    hovered: bool,
355    selecting_drag: bool,
356    focus_time: Option<Instant>,
357    blink_last_phase: Cell<u64>,
358
359    /// Multi-click (single / double / triple) detection and the granularity of
360    /// the active selection drag. A double-click selects the word, a
361    /// triple-click the logical line (paragraph), and dragging afterwards
362    /// extends by whole words / paragraphs. `select_pivot` is the byte range
363    /// the initiating click selected.
364    multi_click: MultiClickTracker,
365    select_granularity: SelectGranularity,
366    select_pivot: (usize, usize),
367
368    /// Per-widget CPU bitmap cache. Routes the whole editor (bg + selection +
369    /// text) through the same LCD-subpixel / grayscale pipeline as `Label` and
370    /// `TextField`: `paint_subtree_backbuffered` renders this subtree into an
371    /// `LcdBuffer` (LCD on) or RGBA `Framebuffer` (LCD off) and blits it, so the
372    /// editor gets the app's best text rendering by default and follows the
373    /// System settings live.
374    cache: BackbufferCache,
375    /// Last painted signature; a change invalidates [`Self::cache`] in `layout`.
376    last_sig: Option<TextAreaSig>,
377
378    /// Default-on right-click Cut/Copy/Paste/Select-All menu. Opt out with
379    /// [`with_context_menu(false)`](Self::with_context_menu).
380    context_menu: crate::widgets::text_context_menu::TextContextMenu,
381    context_menu_enabled: bool,
382}
383
384impl TextArea {
385    pub fn new(font: Arc<Font>) -> Self {
386        Self {
387            bounds: Rect::default(),
388            children: Vec::new(),
389            base: WidgetBase::new(),
390            font,
391            font_size: 13.0,
392            padding: 8.0,
393            hint: "Type here…".to_string(),
394            content_h_align: TextHAlign::Left,
395            content_v_align: TextVAlign::Top,
396            h_align_cell: None,
397            v_align_cell: None,
398            focus_request_id: None,
399            on_key_chord: None,
400            highlighter: None,
401            on_change: None,
402            edit: Rc::new(RefCell::new(TextEditState::default())),
403            cached_wrap_width: -1.0,
404            cached_lines: Vec::new(),
405            cached_line_h: 0.0,
406            cached_epoch: 0,
407            vbar: ScrollbarAxis {
408                enabled: true,
409                ..ScrollbarAxis::default()
410            },
411            last_layout_cursor: None,
412            focused: false,
413            hovered: false,
414            selecting_drag: false,
415            focus_time: None,
416            blink_last_phase: Cell::new(0),
417            multi_click: MultiClickTracker::default(),
418            select_granularity: SelectGranularity::default(),
419            select_pivot: (0, 0),
420            cache: BackbufferCache::default(),
421            last_sig: None,
422            context_menu: crate::widgets::text_context_menu::TextContextMenu::new(),
423            context_menu_enabled: true,
424        }
425    }
426
427    /// Build the backbuffer-cache invalidation signature from current state.
428    fn cache_sig(&self) -> TextAreaSig {
429        let st = self.edit.borrow();
430        TextAreaSig {
431            epoch: st.epoch,
432            cursor: st.cursor,
433            anchor: st.anchor,
434            focused: self.focused,
435            hovered: self.hovered,
436            offset_bits: self.vbar.offset.to_bits(),
437            w_bits: self.bounds.width.to_bits(),
438            h_bits: self.bounds.height.to_bits(),
439            h_align: self.resolved_h_align(),
440            v_align: self.resolved_v_align(),
441            font_size_bits: self.font_size.to_bits(),
442        }
443    }
444
445    pub fn with_text(self, text: impl Into<String>) -> Self {
446        let t: String = text.into();
447        let cursor = t.len();
448        let epoch = self.edit.borrow().epoch.wrapping_add(1);
449        *self.edit.borrow_mut() = TextEditState {
450            text: t,
451            cursor,
452            anchor: cursor,
453            epoch,
454        };
455        self
456    }
457
458    /// Placeholder text shown, dimmed, while the buffer is empty.
459    pub fn with_hint_text(mut self, hint: impl Into<String>) -> Self {
460        self.hint = hint.into();
461        self
462    }
463
464    /// Static horizontal alignment of the wrapped content.
465    pub fn with_content_h_align(mut self, a: TextHAlign) -> Self {
466        self.content_h_align = a;
467        self
468    }
469    /// Static vertical alignment of the wrapped content block.
470    pub fn with_content_v_align(mut self, a: TextVAlign) -> Self {
471        self.content_v_align = a;
472        self
473    }
474    /// Bind horizontal alignment to a live cell (wins over the static value).
475    pub fn with_h_align_cell(mut self, cell: Rc<Cell<TextHAlign>>) -> Self {
476        self.h_align_cell = Some(cell);
477        self
478    }
479    /// Bind vertical alignment to a live cell (wins over the static value).
480    pub fn with_v_align_cell(mut self, cell: Rc<Cell<TextVAlign>>) -> Self {
481        self.v_align_cell = Some(cell);
482        self
483    }
484
485    /// Adopt an externally-owned edit state so a caller can read the text /
486    /// selection and mutate it (clear, replace, move cursor) from outside the
487    /// widget tree. External text mutations must call
488    /// [`TextEditState::note_text_change`] so the wrap cache invalidates.
489    pub fn with_edit_state(mut self, state: Rc<RefCell<TextEditState>>) -> Self {
490        self.edit = state;
491        self.cached_wrap_width = -1.0;
492        self
493    }
494
495    /// Stable id for the programmatic focus channel
496    /// ([`crate::focus::request_focus`]).
497    pub fn with_focus_id(mut self, id: FocusId) -> Self {
498        self.focus_request_id = Some(id);
499        self
500    }
501
502    /// Install a pre-default key-chord interceptor. It runs before the widget's
503    /// built-in key handling on every `KeyDown`; returning `true` consumes the
504    /// event and suppresses the default action for that key.
505    pub fn with_key_intercept(
506        mut self,
507        cb: impl FnMut(&Key, &Modifiers) -> bool + 'static,
508    ) -> Self {
509        self.on_key_chord = Some(Rc::new(RefCell::new(cb)));
510        self
511    }
512
513    /// Install a per-visual-line syntax highlighter (see [`LineHighlighter`]).
514    pub fn with_highlighter(
515        mut self,
516        cb: impl Fn(&str) -> Vec<(usize, usize, crate::color::Color)> + 'static,
517    ) -> Self {
518        self.highlighter = Some(Rc::new(cb));
519        self
520    }
521
522    /// Clone of the shared edit-state handle, for selection/text readback and
523    /// external mutation.
524    pub fn edit_state(&self) -> Rc<RefCell<TextEditState>> {
525        Rc::clone(&self.edit)
526    }
527
528    /// Current selection as a sorted `[start, end)` byte range, or `None` when
529    /// nothing is selected.
530    pub fn selection(&self) -> Option<(usize, usize)> {
531        self.edit.borrow().selection_range()
532    }
533
534    /// The currently-selected substring (empty when there is no selection).
535    pub fn selected_text(&self) -> String {
536        let st = self.edit.borrow();
537        match st.selection_range() {
538            Some((lo, hi)) => st.text[lo..hi].to_string(),
539            None => String::new(),
540        }
541    }
542
543    /// Collapse the selection and place the cursor at the very start.
544    pub fn set_cursor_to_start(&mut self) {
545        let mut st = self.edit.borrow_mut();
546        st.cursor = 0;
547        st.anchor = 0;
548    }
549
550    /// Collapse the selection and place the cursor at the very end.
551    pub fn set_cursor_to_end(&mut self) {
552        let mut st = self.edit.borrow_mut();
553        let end = st.text.len();
554        st.cursor = end;
555        st.anchor = end;
556    }
557    pub fn with_font_size(mut self, size: f64) -> Self {
558        self.font_size = size;
559        self
560    }
561    pub fn with_padding(mut self, p: f64) -> Self {
562        self.padding = p;
563        self
564    }
565
566    pub fn with_margin(mut self, m: Insets) -> Self {
567        self.base.margin = m;
568        self
569    }
570    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
571        self.base.h_anchor = h;
572        self
573    }
574    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
575        self.base.v_anchor = v;
576        self
577    }
578    pub fn with_min_size(mut self, s: Size) -> Self {
579        self.base.min_size = s;
580        self
581    }
582    pub fn with_max_size(mut self, s: Size) -> Self {
583        self.base.max_size = s;
584        self
585    }
586
587    /// Current text.  Cheap — clones the underlying `String`.
588    pub fn text(&self) -> String {
589        self.edit.borrow().text.clone()
590    }
591
592    /// Current byte-offset cursor position (for tests and inspectors).
593    pub fn cursor(&self) -> usize {
594        self.edit.borrow().cursor
595    }
596
597    /// Count of visual lines at the last layout pass (cache).
598    pub fn visual_line_count(&self) -> usize {
599        self.cached_lines.len()
600    }
601
602    /// Ensure the wrap cache matches the current text + width.
603    fn refresh_wrap(&mut self, inner_w: f64) {
604        let st = self.edit.borrow();
605        let same_width = (self.cached_wrap_width - inner_w).abs() < 0.5;
606        // An external owner of the shared edit state may have replaced the
607        // text without touching our width or calling `mark_dirty`; the epoch
608        // catches that case so the cache never blits stale wrapping.
609        let same_epoch = self.cached_epoch == st.epoch;
610        if same_width && same_epoch && !self.cached_lines.is_empty() {
611            return;
612        }
613        let lines = wrap_text_indexed(&self.font, &st.text, self.font_size, inner_w.max(1.0));
614        self.cached_lines = lines;
615        self.cached_wrap_width = inner_w;
616        self.cached_epoch = st.epoch;
617        // Line height — a little slacker than tight metrics so
618        // descenders from line N don't kiss ascenders from N+1.
619        self.cached_line_h = self.font_size * 1.35;
620    }
621
622    /// Force a re-wrap on the next layout.
623    fn mark_dirty(&mut self) {
624        self.cached_wrap_width = -1.0;
625    }
626
627    // ── Content-alignment geometry ────────────────────────────────────────
628    //
629    // Alignment shifts where wrapped lines sit inside the padded inner rect.
630    // These helpers are the single source of truth for that offset math so
631    // paint, cursor overlay, hit-testing and cursor-position queries all agree.
632
633    /// Resolved horizontal alignment (cell binding wins over the static value).
634    fn resolved_h_align(&self) -> TextHAlign {
635        self.h_align_cell
636            .as_ref()
637            .map(|c| c.get())
638            .unwrap_or(self.content_h_align)
639    }
640
641    /// Resolved vertical alignment (cell binding wins over the static value).
642    fn resolved_v_align(&self) -> TextVAlign {
643        self.v_align_cell
644            .as_ref()
645            .map(|c| c.get())
646            .unwrap_or(self.content_v_align)
647    }
648
649    /// Inner content width (widget width minus horizontal padding).
650    fn inner_width(&self) -> f64 {
651        (self.bounds.width - self.padding * 2.0).max(0.0)
652    }
653
654    /// Vertical shift (downward, in the Y-up frame) applied to the whole line
655    /// block to honour [`TextVAlign`]. `0` for `Top`.
656    fn v_align_shift(&self) -> f64 {
657        let content_h = self.cached_lines.len() as f64 * self.cached_line_h;
658        let inner_h = (self.bounds.height - self.padding * 2.0).max(0.0);
659        let slack = (inner_h - content_h).max(0.0);
660        match self.resolved_v_align() {
661            TextVAlign::Top => 0.0,
662            TextVAlign::Center => slack * 0.5,
663            TextVAlign::Bottom => slack,
664        }
665    }
666
667    /// Y coordinate (Y-up) of the TOP edge of visual line 0.
668    ///
669    /// Folds in the internal vertical scroll offset: as the user scrolls down
670    /// (`vbar.offset` grows), line 0 rises above the visible top edge and lower
671    /// lines come into view. Every geometry query (paint, hit-test, cursor
672    /// overlay, scroll-to-cursor) routes through here, so they all agree.
673    fn content_top_y(&self) -> f64 {
674        self.bounds.height - self.padding - self.v_align_shift() + self.vbar.offset
675    }
676
677    /// Y coordinate (Y-up) of the top edge of visual line `i`.
678    fn line_top_y(&self, i: usize) -> f64 {
679        self.content_top_y() - i as f64 * self.cached_line_h
680    }
681
682    /// Horizontal start (x) of a line's rendered text, honouring [`TextHAlign`].
683    fn line_x_start(&self, line: &WrappedLine) -> f64 {
684        let line_w = measure_advance(&self.font, &line.text, self.font_size);
685        let slack = (self.inner_width() - line_w).max(0.0);
686        let shift = match self.resolved_h_align() {
687            TextHAlign::Left => 0.0,
688            TextHAlign::Center => slack * 0.5,
689            TextHAlign::Right => slack,
690        };
691        self.padding + shift
692    }
693
694    /// Locate the (line_index, byte_pos_in_text) that the given cursor
695    /// byte offset lives on.  Returns `(0, 0)` on empty content.
696    fn line_for_cursor(&self, byte_pos: usize) -> usize {
697        for (i, l) in self.cached_lines.iter().enumerate() {
698            if byte_pos >= l.start && byte_pos <= l.end {
699                return i;
700            }
701        }
702        self.cached_lines.len().saturating_sub(1)
703    }
704
705    /// Hit-test a widget-local point to a text byte offset.  Clamps to
706    /// `[0, text.len()]` at the edges.  `local` is Y-UP.
707    fn byte_offset_at(&self, local: Point) -> usize {
708        if self.cached_lines.is_empty() || self.cached_line_h <= 0.0 {
709            return 0;
710        }
711        // Visual lines stack top-to-bottom; Y-up flips their y coords.
712        // Line 0 sits at the top (high Y), line N at the bottom (low Y).
713        // `content_top_y` folds in the vertical-alignment shift.
714        let rel_from_top = self.content_top_y() - local.y;
715        let mut line_idx = (rel_from_top / self.cached_line_h).floor() as isize;
716        if line_idx < 0 {
717            line_idx = 0;
718        }
719        if line_idx as usize >= self.cached_lines.len() {
720            line_idx = self.cached_lines.len() as isize - 1;
721        }
722        let line = &self.cached_lines[line_idx as usize];
723        // X hit test: walk chars in the line's rendered text and pick
724        // the nearest grapheme boundary. The line's x start folds in the
725        // horizontal-alignment shift.
726        let pad_x = self.line_x_start(line);
727        let rel_x = (local.x - pad_x).max(0.0);
728        let txt = &line.text;
729        let mut best_byte = 0usize;
730        let mut best_delta = f64::INFINITY;
731        let mut acc = 0.0_f64;
732        let mut prev_byte = 0usize;
733        for (i, _c) in txt.char_indices().chain(std::iter::once((txt.len(), ' '))) {
734            let w_here = if i > prev_byte {
735                measure_advance(&self.font, &txt[prev_byte..i], self.font_size)
736            } else {
737                0.0
738            };
739            acc += w_here;
740            let d = (acc - rel_x).abs();
741            if d < best_delta {
742                best_delta = d;
743                best_byte = i;
744            }
745            prev_byte = i;
746        }
747        line.start + best_byte
748    }
749
750    /// Screen position (widget-local, Y-UP) of the given cursor byte
751    /// offset.  Returns the bottom-left corner of the cursor glyph
752    /// cell.
753    fn pos_for_cursor(&self, byte_pos: usize) -> Point {
754        if self.cached_lines.is_empty() {
755            return Point::ORIGIN;
756        }
757        let line_idx = self.line_for_cursor(byte_pos);
758        let line = &self.cached_lines[line_idx];
759        let offset = byte_pos.saturating_sub(line.start).min(line.text.len());
760        let x = self.line_x_start(line)
761            + measure_advance(&self.font, &line.text[..offset], self.font_size);
762        // Y-up: line i top-edge folds in the vertical-alignment shift.
763        let line_top = self.line_top_y(line_idx);
764        let line_bottom = line_top - self.cached_line_h;
765        Point::new(x, line_bottom)
766    }
767
768    /// Glyph baseline Y (Y-up) for visual line `i`, vertically centring the
769    /// font's full vertical extent within the 1.35× line cell.
770    ///
771    /// `descent` is a POSITIVE quantity here (see [`Font::descender_px`]), so
772    /// the text block's height is `ascent + descent`. Getting this wrong pushes
773    /// the block up and clips line 0's ascenders against the padded inner-rect
774    /// clip, so paint and the clip-safety test share this single helper.
775    fn line_baseline_y(&self, i: usize) -> f64 {
776        let ascent = self.font.ascender_px(self.font_size);
777        let descent = self.font.descender_px(self.font_size);
778        let line_top = self.line_top_y(i);
779        let line_bottom = line_top - self.cached_line_h;
780        let baseline = line_bottom + (self.cached_line_h - (ascent + descent)) * 0.5 + descent;
781        // Snap to the pixel grid when hinting is on so multi-line text lands
782        // stems on physical rows exactly like `Label` (no-op when hinting off).
783        crate::font_settings::snap_baseline_y(baseline)
784    }
785}
786
787mod callbacks;
788mod context_menu;
789mod edit_ops;
790mod scroll;
791mod widget_impl;
792
793#[cfg(test)]
794mod selection_tests;
795#[cfg(test)]
796mod tests;
797#[cfg(test)]
798mod redraw_tests;