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