agg-gui 0.3.0

Immediate-mode Rust GUI library with AGG rendering, Y-up layout, widgets, text, SVG, and native/WASM adapters
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
//! `TextArea` — a multiline text editor.
//!
//! Built for W5 of the Window Resize Test (egui's "↔ resizable with
//! TextEdit") — a widget that **fills its available area** and lets
//! the user edit a paragraph of text across many wrapped visual
//! lines.  Shares the underlying `TextEditState` with `TextField` so
//! the same keyboard shortcuts / undo semantics are in reach later.
//!
//! # Scope (Stage 4)
//!
//! Covers the behaviour W5 actually needs and what a mobile user
//! would expect from an editable paragraph:
//!   * word-wrap to the widget's inner width;
//!   * typing / backspace / delete / Enter produce visible edits;
//!   * arrow keys navigate by char or visual line;
//!   * click positions cursor; drag selects;
//!   * cursor blink with focus state;
//!   * copy / cut / paste via the standard clipboard shortcuts.
//!
//! Beyond W5, this widget also backs the standalone **TextEdit demo**
//! (`demo-ui`'s `text_edit_demo.rs`, mirroring egui's `text_edit.rs`), so it
//! grew the following egui-parity capabilities:
//!   * configurable hint / placeholder text ([`with_hint_text`](TextArea::with_hint_text));
//!   * content alignment — horizontal [`TextHAlign`] and vertical [`TextVAlign`],
//!     settable statically or bound to a live `Rc<Cell<_>>`;
//!   * selection introspection ([`selection`](TextArea::selection) /
//!     [`selected_text`](TextArea::selected_text));
//!   * a shared, externally-mutable edit state ([`with_edit_state`](TextArea::with_edit_state)
//!     / [`edit_state`](TextArea::edit_state)) with content-epoch cache
//!     invalidation, so a caller can clear/replace the text or move the cursor
//!     from outside the widget tree;
//!   * a programmatic focus id + cursor-to-start/end helpers;
//!   * a pre-default key-chord interceptor ([`with_key_intercept`](TextArea::with_key_intercept)),
//!     used by the demo for the Ctrl/Cmd+Y "toggle case of selection" shortcut.
//!
//! Deferred (known gaps, filed for later polish):
//!   * word-boundary jumps (Ctrl+arrows) across wrapped visual lines;
//!   * undo / redo;
//!   * input-method composition;
//!   * BiDi and RTL layout.

use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::Arc;

use web_time::Instant;

use crate::cursor::{set_cursor_icon, CursorIcon};
use crate::draw_ctx::DrawCtx;
use crate::event::{Event, EventResult, Key, Modifiers, MouseButton};
use crate::focus::FocusId;
use crate::geometry::{Point, Rect, Size};
use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
use crate::text::{measure_advance, measure_text_metrics, Font};
use crate::widget::{BackbufferCache, BackbufferMode, Widget};
use crate::widgets::scrollbar::ScrollbarAxis;
use crate::widgets::multi_click::{MultiClickTracker, SelectGranularity};
use crate::widgets::text_field_core::{
    next_char_boundary, paragraph_range_at, prev_char_boundary, word_range_at, TextEditState,
};

/// Horizontal alignment of the wrapped text content inside a [`TextArea`]'s
/// padded inner rect. Mirrors egui's `TextEdit::horizontal_align`.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
pub enum TextHAlign {
    #[default]
    Left,
    Center,
    Right,
}

/// Vertical alignment of the wrapped text block inside a [`TextArea`]'s padded
/// inner rect. Mirrors egui's `TextEdit::vertical_align`.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
pub enum TextVAlign {
    #[default]
    Top,
    Center,
    Bottom,
}

/// Signature of a pre-default key-chord interceptor. Returns `true` to consume
/// the event and suppress the widget's built-in handling for that key.
pub type KeyIntercept = dyn FnMut(&Key, &Modifiers) -> bool;

/// Signature of a per-visual-line syntax highlighter. Given one wrapped line's
/// rendered text, it returns a list of `(start_byte, end_byte, color)` runs
/// (byte offsets relative to that line). Bytes not covered by any run are
/// drawn in the ambient text colour. Runs are expected to be non-overlapping
/// and sorted; overlaps simply repaint. Keeping it line-oriented sidesteps
/// span-across-wrap bookkeeping and matches the simple fallback highlighter in
/// egui's Code Editor demo.
pub type LineHighlighter = dyn Fn(&str) -> Vec<(usize, usize, crate::color::Color)>;

fn clipboard_get() -> Option<String> {
    crate::clipboard::get_text()
}

fn clipboard_set(text: &str) {
    crate::clipboard::set_text(text);
}

// ─── Wrapping helper ─────────────────────────────────────────────────────────

/// A single visual line produced by [`wrap_text_indexed`].
#[derive(Clone, Debug)]
struct WrappedLine {
    /// Inclusive byte offset into the source `text` where this visual
    /// line's content begins.
    start: usize,
    /// Exclusive byte offset where this visual line's content ends
    /// (not including a trailing newline).
    end: usize,
    /// Rendered text for this visual line (a substring of the source).
    text: String,
    /// Whether this visual line ended because of an explicit `\n` in
    /// the source (vs. a soft wrap at word boundary).  Used to choose
    /// whether moving the cursor past the end of the line lands on
    /// the next visual line or just past the newline character.
    hard_break: bool,
}

/// Wrap `text` at `max_width` and return the visual lines along with
/// byte-offset ranges back into the source.  Explicit `\n` always
/// produces a line break; between newlines, word-boundary soft wraps
/// keep each visual line ≤ `max_width`.  An empty source still returns
/// one empty line (so the cursor has somewhere to sit).
fn wrap_text_indexed(
    font: &Arc<Font>,
    text: &str,
    font_size: f64,
    max_width: f64,
) -> Vec<WrappedLine> {
    let mut out: Vec<WrappedLine> = Vec::new();
    let mut para_start = 0usize;
    for (rel_end, chunk) in split_keep_newlines(text).enumerate() {
        let _ = rel_end;
        let para = chunk;
        let para_abs_start = para_start;
        let para_abs_end = para_abs_start + para.len();
        // Each paragraph soft-wraps independently.  Walk its char
        // byte indices and fill lines up to `max_width`.
        let mut cursor = 0usize; // byte offset within `para`
        let last_boundary = 0usize;
        while cursor < para.len() {
            // Find the longest prefix of `para[line_start..]` that
            // fits in `max_width`.  Use word boundaries — fall back
            // to the full prefix when no boundary is available (long
            // unbroken token).
            let line_start = cursor;
            let mut fit_end = line_start;
            let mut last_word_end: Option<usize> = None;
            let mut idx = line_start;
            while idx < para.len() {
                let next = next_char_boundary(para, idx);
                let candidate = &para[line_start..next];
                let w = measure_text_metrics(font, candidate, font_size).width;
                if w > max_width && fit_end > line_start {
                    break;
                }
                fit_end = next;
                // Record word boundaries as we pass them.
                if next < para.len() {
                    let next_ch = para[next..].chars().next().unwrap_or(' ');
                    if next_ch.is_whitespace() {
                        last_word_end = Some(next);
                    }
                }
                idx = next;
            }
            // Decide where to break: the last word boundary if we have
            // one AND we're not at the end of the paragraph; else just
            // at `fit_end`.
            let break_at = if fit_end < para.len() && last_word_end.is_some() {
                last_word_end.unwrap()
            } else {
                fit_end.max(next_char_boundary(para, line_start))
            };
            let _ = last_boundary; // reserved for future hyphenation
            let line_text = para[line_start..break_at].trim_end().to_string();
            let abs_start = para_abs_start + line_start;
            let abs_end = para_abs_start + break_at;
            out.push(WrappedLine {
                start: abs_start,
                end: abs_end,
                text: line_text,
                hard_break: false,
            });
            // Skip over the whitespace we just consumed as a separator.
            let mut next_line_start = break_at;
            while next_line_start < para.len() {
                let ch = para[next_line_start..].chars().next().unwrap_or('x');
                if !ch.is_whitespace() || ch == '\n' {
                    break;
                }
                next_line_start = next_char_boundary(para, next_line_start);
            }
            cursor = next_line_start;
            if cursor >= para.len() {
                break;
            }
        }
        // Emit at least one line for an empty paragraph (blank line
        // between \n\n, or a fresh doc with no content).
        if out.is_empty() || out.last().map(|l| l.end).unwrap_or(0) != para_abs_end {
            if para.is_empty() {
                out.push(WrappedLine {
                    start: para_abs_start,
                    end: para_abs_end,
                    text: String::new(),
                    hard_break: false,
                });
            }
        }
        // Mark the paragraph's last visual line as ending with a hard
        // break if the source had a trailing newline (see
        // `split_keep_newlines` contract below).
        let source_end = para_abs_end + 1; // +1 for the consumed '\n', if any
        let had_newline =
            source_end <= text.len() && text.as_bytes().get(para_abs_end) == Some(&b'\n');
        if had_newline {
            if let Some(last) = out.last_mut() {
                last.hard_break = true;
            }
        }
        para_start = if had_newline {
            source_end
        } else {
            para_abs_end
        };
    }
    if out.is_empty() {
        out.push(WrappedLine {
            start: 0,
            end: 0,
            text: String::new(),
            hard_break: false,
        });
    }
    out
}

/// Iterator over paragraph chunks — everything between `\n` boundaries
/// (newline is NOT included in the yielded chunk, but the caller can
/// detect its presence by comparing chunk byte-ranges to the source).
fn split_keep_newlines(text: &str) -> impl Iterator<Item = &str> + '_ {
    // `split('\n')` already gives the right semantics: consecutive \n's
    // yield empty strings so cursor can sit on blank lines, and a
    // trailing \n produces a final empty string (a blank final line).
    text.split('\n')
}

// ─── TextArea widget ─────────────────────────────────────────────────────────

/// Snapshot of every input that affects the cached backbuffer bitmap
/// (background + selection band + wrapped text + border). `layout` compares
/// the current sig against the last one and drops the LCD/RGBA cache on any
/// difference, so typing / selecting / scrolling re-rasterise but an idle
/// (blinking-only) frame just re-blits the cached pixels.
///
/// Deliberately excludes cursor-blink phase and the floating scrollbar — both
/// paint in `paint_overlay` after the cache blit, so they never force a
/// re-raster. Typography- and theme-driven invalidation (font swap, LCD/hinting
/// toggle, dark/light flip) is handled by the framework via the epoch checks in
/// `paint_subtree_backbuffered`, so this sig only tracks the widget's own state.
#[derive(Clone, PartialEq)]
struct TextAreaSig {
    epoch: u64,
    cursor: usize,
    anchor: usize,
    focused: bool,
    hovered: bool,
    offset_bits: u64,
    w_bits: u64,
    h_bits: u64,
    h_align: TextHAlign,
    v_align: TextVAlign,
    font_size_bits: u64,
}

/// A multiline text editor that fills its available area.
pub struct TextArea {
    bounds: Rect,
    children: Vec<Box<dyn Widget>>, // always empty
    base: WidgetBase,

    font: Arc<Font>,
    font_size: f64,
    padding: f64,

    /// Placeholder shown (dimmed) while the buffer is empty. Mirrors egui's
    /// `TextEdit::hint_text`.
    hint: String,

    /// Static content alignment. Ignored on the axis where a `*_align_cell`
    /// binding is present (the cell wins so external toggles apply live).
    content_h_align: TextHAlign,
    content_v_align: TextVAlign,
    /// Optional live bindings for content alignment — lets a caller flip
    /// alignment from a segmented control without rebuilding the widget.
    h_align_cell: Option<Rc<Cell<TextHAlign>>>,
    v_align_cell: Option<Rc<Cell<TextVAlign>>>,

    /// Stable id for the programmatic focus channel
    /// ([`crate::focus::request_focus`]); `None` opts out.
    focus_request_id: Option<FocusId>,

    /// Pre-default key-chord interceptor. Invoked at the top of `KeyDown`
    /// handling; when it returns `true` the event is consumed and the
    /// built-in key handling is skipped.
    on_key_chord: Option<Rc<RefCell<KeyIntercept>>>,

    /// Optional per-line syntax highlighter (see [`LineHighlighter`]). When
    /// present, each wrapped line is painted as coloured runs instead of a
    /// single ambient-colour string.
    highlighter: Option<Rc<LineHighlighter>>,

    /// Fired after any text mutation (typing, delete, paste, cut, and
    /// key-intercept edits that advance the content epoch). Mirrors
    /// TextField's `on_change`. Builder + dispatcher live in
    /// `text_area/callbacks.rs`.
    on_change: Option<Box<dyn FnMut(&str)>>,

    /// Live edit state.  Shared with future undo / clipboard wiring, and with
    /// external callers via [`with_edit_state`](Self::with_edit_state).
    edit: Rc<RefCell<TextEditState>>,

    /// Cached layout — invalidated when text / font / width changes.
    cached_wrap_width: f64,
    cached_lines: Vec<WrappedLine>,
    cached_line_h: f64,
    /// `edit.epoch` observed when `cached_lines` was last (re)built. A
    /// mismatch means the text was mutated through the shared handle by an
    /// external owner, so the wrap cache is stale even at the same width.
    cached_epoch: u64,

    /// Internal vertical scroll state. Reuses `ScrollView`'s [`ScrollbarAxis`]
    /// so overflow math, thumb geometry, drag/hover and painting stay
    /// pixel-consistent with the app's other scroll bars. `offset` is the
    /// scroll distance from the top (0 = first line visible); it is folded into
    /// [`content_top_y`](Self::content_top_y). See `text_area/scroll.rs`.
    vbar: ScrollbarAxis,

    /// Cursor byte offset observed at the previous `layout`. `None` until the
    /// first layout. A change between layouts that the widget's own edit funnel
    /// didn't already scroll for (i.e. an *external* mutation of the shared
    /// [`TextEditState`], as the demo's "start"/"end" buttons do) triggers
    /// [`ensure_cursor_visible`](Self::ensure_cursor_visible) so programmatic
    /// caret moves scroll into view just like typed navigation.
    last_layout_cursor: Option<usize>,

    /// Ephemeral input state.
    focused: bool,
    hovered: bool,
    selecting_drag: bool,
    focus_time: Option<Instant>,
    blink_last_phase: Cell<u64>,

    /// Multi-click (single / double / triple) detection and the granularity of
    /// the active selection drag. A double-click selects the word, a
    /// triple-click the logical line (paragraph), and dragging afterwards
    /// extends by whole words / paragraphs. `select_pivot` is the byte range
    /// the initiating click selected.
    multi_click: MultiClickTracker,
    select_granularity: SelectGranularity,
    select_pivot: (usize, usize),

    /// Per-widget CPU bitmap cache. Routes the whole editor (bg + selection +
    /// text) through the same LCD-subpixel / grayscale pipeline as `Label` and
    /// `TextField`: `paint_subtree_backbuffered` renders this subtree into an
    /// `LcdBuffer` (LCD on) or RGBA `Framebuffer` (LCD off) and blits it, so the
    /// editor gets the app's best text rendering by default and follows the
    /// System settings live.
    cache: BackbufferCache,
    /// Last painted signature; a change invalidates [`Self::cache`] in `layout`.
    last_sig: Option<TextAreaSig>,

    /// Default-on right-click Cut/Copy/Paste/Select-All menu. Opt out with
    /// [`with_context_menu(false)`](Self::with_context_menu).
    context_menu: crate::widgets::text_context_menu::TextContextMenu,
    context_menu_enabled: bool,
}

impl TextArea {
    pub fn new(font: Arc<Font>) -> Self {
        Self {
            bounds: Rect::default(),
            children: Vec::new(),
            base: WidgetBase::new(),
            font,
            font_size: 13.0,
            padding: 8.0,
            hint: "Type here…".to_string(),
            content_h_align: TextHAlign::Left,
            content_v_align: TextVAlign::Top,
            h_align_cell: None,
            v_align_cell: None,
            focus_request_id: None,
            on_key_chord: None,
            highlighter: None,
            on_change: None,
            edit: Rc::new(RefCell::new(TextEditState::default())),
            cached_wrap_width: -1.0,
            cached_lines: Vec::new(),
            cached_line_h: 0.0,
            cached_epoch: 0,
            vbar: ScrollbarAxis {
                enabled: true,
                ..ScrollbarAxis::default()
            },
            last_layout_cursor: None,
            focused: false,
            hovered: false,
            selecting_drag: false,
            focus_time: None,
            blink_last_phase: Cell::new(0),
            multi_click: MultiClickTracker::default(),
            select_granularity: SelectGranularity::default(),
            select_pivot: (0, 0),
            cache: BackbufferCache::default(),
            last_sig: None,
            context_menu: crate::widgets::text_context_menu::TextContextMenu::new(),
            context_menu_enabled: true,
        }
    }

    /// Build the backbuffer-cache invalidation signature from current state.
    fn cache_sig(&self) -> TextAreaSig {
        let st = self.edit.borrow();
        TextAreaSig {
            epoch: st.epoch,
            cursor: st.cursor,
            anchor: st.anchor,
            focused: self.focused,
            hovered: self.hovered,
            offset_bits: self.vbar.offset.to_bits(),
            w_bits: self.bounds.width.to_bits(),
            h_bits: self.bounds.height.to_bits(),
            h_align: self.resolved_h_align(),
            v_align: self.resolved_v_align(),
            font_size_bits: self.font_size.to_bits(),
        }
    }

    pub fn with_text(self, text: impl Into<String>) -> Self {
        let t: String = text.into();
        let cursor = t.len();
        let epoch = self.edit.borrow().epoch.wrapping_add(1);
        *self.edit.borrow_mut() = TextEditState {
            text: t,
            cursor,
            anchor: cursor,
            epoch,
        };
        self
    }

    /// Placeholder text shown, dimmed, while the buffer is empty.
    pub fn with_hint_text(mut self, hint: impl Into<String>) -> Self {
        self.hint = hint.into();
        self
    }

    /// Static horizontal alignment of the wrapped content.
    pub fn with_content_h_align(mut self, a: TextHAlign) -> Self {
        self.content_h_align = a;
        self
    }
    /// Static vertical alignment of the wrapped content block.
    pub fn with_content_v_align(mut self, a: TextVAlign) -> Self {
        self.content_v_align = a;
        self
    }
    /// Bind horizontal alignment to a live cell (wins over the static value).
    pub fn with_h_align_cell(mut self, cell: Rc<Cell<TextHAlign>>) -> Self {
        self.h_align_cell = Some(cell);
        self
    }
    /// Bind vertical alignment to a live cell (wins over the static value).
    pub fn with_v_align_cell(mut self, cell: Rc<Cell<TextVAlign>>) -> Self {
        self.v_align_cell = Some(cell);
        self
    }

    /// Adopt an externally-owned edit state so a caller can read the text /
    /// selection and mutate it (clear, replace, move cursor) from outside the
    /// widget tree. External text mutations must call
    /// [`TextEditState::note_text_change`] so the wrap cache invalidates.
    pub fn with_edit_state(mut self, state: Rc<RefCell<TextEditState>>) -> Self {
        self.edit = state;
        self.cached_wrap_width = -1.0;
        self
    }

    /// Stable id for the programmatic focus channel
    /// ([`crate::focus::request_focus`]).
    pub fn with_focus_id(mut self, id: FocusId) -> Self {
        self.focus_request_id = Some(id);
        self
    }

    /// Install a pre-default key-chord interceptor. It runs before the widget's
    /// built-in key handling on every `KeyDown`; returning `true` consumes the
    /// event and suppresses the default action for that key.
    pub fn with_key_intercept(
        mut self,
        cb: impl FnMut(&Key, &Modifiers) -> bool + 'static,
    ) -> Self {
        self.on_key_chord = Some(Rc::new(RefCell::new(cb)));
        self
    }

    /// Install a per-visual-line syntax highlighter (see [`LineHighlighter`]).
    pub fn with_highlighter(
        mut self,
        cb: impl Fn(&str) -> Vec<(usize, usize, crate::color::Color)> + 'static,
    ) -> Self {
        self.highlighter = Some(Rc::new(cb));
        self
    }

    /// Clone of the shared edit-state handle, for selection/text readback and
    /// external mutation.
    pub fn edit_state(&self) -> Rc<RefCell<TextEditState>> {
        Rc::clone(&self.edit)
    }

    /// Current selection as a sorted `[start, end)` byte range, or `None` when
    /// nothing is selected.
    pub fn selection(&self) -> Option<(usize, usize)> {
        self.edit.borrow().selection_range()
    }

    /// The currently-selected substring (empty when there is no selection).
    pub fn selected_text(&self) -> String {
        let st = self.edit.borrow();
        match st.selection_range() {
            Some((lo, hi)) => st.text[lo..hi].to_string(),
            None => String::new(),
        }
    }

    /// Collapse the selection and place the cursor at the very start.
    pub fn set_cursor_to_start(&mut self) {
        let mut st = self.edit.borrow_mut();
        st.cursor = 0;
        st.anchor = 0;
    }

    /// Collapse the selection and place the cursor at the very end.
    pub fn set_cursor_to_end(&mut self) {
        let mut st = self.edit.borrow_mut();
        let end = st.text.len();
        st.cursor = end;
        st.anchor = end;
    }
    pub fn with_font_size(mut self, size: f64) -> Self {
        self.font_size = size;
        self
    }
    pub fn with_padding(mut self, p: f64) -> Self {
        self.padding = p;
        self
    }

    pub fn with_margin(mut self, m: Insets) -> Self {
        self.base.margin = m;
        self
    }
    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
        self.base.h_anchor = h;
        self
    }
    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
        self.base.v_anchor = v;
        self
    }
    pub fn with_min_size(mut self, s: Size) -> Self {
        self.base.min_size = s;
        self
    }
    pub fn with_max_size(mut self, s: Size) -> Self {
        self.base.max_size = s;
        self
    }

    /// Current text.  Cheap — clones the underlying `String`.
    pub fn text(&self) -> String {
        self.edit.borrow().text.clone()
    }

    /// Current byte-offset cursor position (for tests and inspectors).
    pub fn cursor(&self) -> usize {
        self.edit.borrow().cursor
    }

    /// Count of visual lines at the last layout pass (cache).
    pub fn visual_line_count(&self) -> usize {
        self.cached_lines.len()
    }

    /// Ensure the wrap cache matches the current text + width.
    fn refresh_wrap(&mut self, inner_w: f64) {
        let st = self.edit.borrow();
        let same_width = (self.cached_wrap_width - inner_w).abs() < 0.5;
        // An external owner of the shared edit state may have replaced the
        // text without touching our width or calling `mark_dirty`; the epoch
        // catches that case so the cache never blits stale wrapping.
        let same_epoch = self.cached_epoch == st.epoch;
        if same_width && same_epoch && !self.cached_lines.is_empty() {
            return;
        }
        let lines = wrap_text_indexed(&self.font, &st.text, self.font_size, inner_w.max(1.0));
        self.cached_lines = lines;
        self.cached_wrap_width = inner_w;
        self.cached_epoch = st.epoch;
        // Line height — a little slacker than tight metrics so
        // descenders from line N don't kiss ascenders from N+1.
        self.cached_line_h = self.font_size * 1.35;
    }

    /// Force a re-wrap on the next layout.
    fn mark_dirty(&mut self) {
        self.cached_wrap_width = -1.0;
    }

    // ── Content-alignment geometry ────────────────────────────────────────
    //
    // Alignment shifts where wrapped lines sit inside the padded inner rect.
    // These helpers are the single source of truth for that offset math so
    // paint, cursor overlay, hit-testing and cursor-position queries all agree.

    /// Resolved horizontal alignment (cell binding wins over the static value).
    fn resolved_h_align(&self) -> TextHAlign {
        self.h_align_cell
            .as_ref()
            .map(|c| c.get())
            .unwrap_or(self.content_h_align)
    }

    /// Resolved vertical alignment (cell binding wins over the static value).
    fn resolved_v_align(&self) -> TextVAlign {
        self.v_align_cell
            .as_ref()
            .map(|c| c.get())
            .unwrap_or(self.content_v_align)
    }

    /// Inner content width (widget width minus horizontal padding).
    fn inner_width(&self) -> f64 {
        (self.bounds.width - self.padding * 2.0).max(0.0)
    }

    /// Vertical shift (downward, in the Y-up frame) applied to the whole line
    /// block to honour [`TextVAlign`]. `0` for `Top`.
    fn v_align_shift(&self) -> f64 {
        let content_h = self.cached_lines.len() as f64 * self.cached_line_h;
        let inner_h = (self.bounds.height - self.padding * 2.0).max(0.0);
        let slack = (inner_h - content_h).max(0.0);
        match self.resolved_v_align() {
            TextVAlign::Top => 0.0,
            TextVAlign::Center => slack * 0.5,
            TextVAlign::Bottom => slack,
        }
    }

    /// Y coordinate (Y-up) of the TOP edge of visual line 0.
    ///
    /// Folds in the internal vertical scroll offset: as the user scrolls down
    /// (`vbar.offset` grows), line 0 rises above the visible top edge and lower
    /// lines come into view. Every geometry query (paint, hit-test, cursor
    /// overlay, scroll-to-cursor) routes through here, so they all agree.
    fn content_top_y(&self) -> f64 {
        self.bounds.height - self.padding - self.v_align_shift() + self.vbar.offset
    }

    /// Y coordinate (Y-up) of the top edge of visual line `i`.
    fn line_top_y(&self, i: usize) -> f64 {
        self.content_top_y() - i as f64 * self.cached_line_h
    }

    /// Horizontal start (x) of a line's rendered text, honouring [`TextHAlign`].
    fn line_x_start(&self, line: &WrappedLine) -> f64 {
        let line_w = measure_advance(&self.font, &line.text, self.font_size);
        let slack = (self.inner_width() - line_w).max(0.0);
        let shift = match self.resolved_h_align() {
            TextHAlign::Left => 0.0,
            TextHAlign::Center => slack * 0.5,
            TextHAlign::Right => slack,
        };
        self.padding + shift
    }

    /// Locate the (line_index, byte_pos_in_text) that the given cursor
    /// byte offset lives on.  Returns `(0, 0)` on empty content.
    fn line_for_cursor(&self, byte_pos: usize) -> usize {
        for (i, l) in self.cached_lines.iter().enumerate() {
            if byte_pos >= l.start && byte_pos <= l.end {
                return i;
            }
        }
        self.cached_lines.len().saturating_sub(1)
    }

    /// Hit-test a widget-local point to a text byte offset.  Clamps to
    /// `[0, text.len()]` at the edges.  `local` is Y-UP.
    fn byte_offset_at(&self, local: Point) -> usize {
        if self.cached_lines.is_empty() || self.cached_line_h <= 0.0 {
            return 0;
        }
        // Visual lines stack top-to-bottom; Y-up flips their y coords.
        // Line 0 sits at the top (high Y), line N at the bottom (low Y).
        // `content_top_y` folds in the vertical-alignment shift.
        let rel_from_top = self.content_top_y() - local.y;
        let mut line_idx = (rel_from_top / self.cached_line_h).floor() as isize;
        if line_idx < 0 {
            line_idx = 0;
        }
        if line_idx as usize >= self.cached_lines.len() {
            line_idx = self.cached_lines.len() as isize - 1;
        }
        let line = &self.cached_lines[line_idx as usize];
        // X hit test: walk chars in the line's rendered text and pick
        // the nearest grapheme boundary. The line's x start folds in the
        // horizontal-alignment shift.
        let pad_x = self.line_x_start(line);
        let rel_x = (local.x - pad_x).max(0.0);
        let txt = &line.text;
        let mut best_byte = 0usize;
        let mut best_delta = f64::INFINITY;
        let mut acc = 0.0_f64;
        let mut prev_byte = 0usize;
        for (i, _c) in txt.char_indices().chain(std::iter::once((txt.len(), ' '))) {
            let w_here = if i > prev_byte {
                measure_advance(&self.font, &txt[prev_byte..i], self.font_size)
            } else {
                0.0
            };
            acc += w_here;
            let d = (acc - rel_x).abs();
            if d < best_delta {
                best_delta = d;
                best_byte = i;
            }
            prev_byte = i;
        }
        line.start + best_byte
    }

    /// Screen position (widget-local, Y-UP) of the given cursor byte
    /// offset.  Returns the bottom-left corner of the cursor glyph
    /// cell.
    fn pos_for_cursor(&self, byte_pos: usize) -> Point {
        if self.cached_lines.is_empty() {
            return Point::ORIGIN;
        }
        let line_idx = self.line_for_cursor(byte_pos);
        let line = &self.cached_lines[line_idx];
        let offset = byte_pos.saturating_sub(line.start).min(line.text.len());
        let x = self.line_x_start(line)
            + measure_advance(&self.font, &line.text[..offset], self.font_size);
        // Y-up: line i top-edge folds in the vertical-alignment shift.
        let line_top = self.line_top_y(line_idx);
        let line_bottom = line_top - self.cached_line_h;
        Point::new(x, line_bottom)
    }

    /// Glyph baseline Y (Y-up) for visual line `i`, vertically centring the
    /// font's full vertical extent within the 1.35× line cell.
    ///
    /// `descent` is a POSITIVE quantity here (see [`Font::descender_px`]), so
    /// the text block's height is `ascent + descent`. Getting this wrong pushes
    /// the block up and clips line 0's ascenders against the padded inner-rect
    /// clip, so paint and the clip-safety test share this single helper.
    fn line_baseline_y(&self, i: usize) -> f64 {
        let ascent = self.font.ascender_px(self.font_size);
        let descent = self.font.descender_px(self.font_size);
        let line_top = self.line_top_y(i);
        let line_bottom = line_top - self.cached_line_h;
        let baseline = line_bottom + (self.cached_line_h - (ascent + descent)) * 0.5 + descent;
        // Snap to the pixel grid when hinting is on so multi-line text lands
        // stems on physical rows exactly like `Label` (no-op when hinting off).
        crate::font_settings::snap_baseline_y(baseline)
    }
}

mod callbacks;
mod context_menu;
mod edit_ops;
mod scroll;
mod widget_impl;

#[cfg(test)]
mod selection_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod redraw_tests;