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 = ¶[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;