Skip to main content

telar_ui_core/
text_area.rs

1use std::rc::Rc;
2use std::sync::Arc;
3
4use geometry_core::Rect;
5use layout_core::{LayoutError, LayoutStyle};
6use platform_core::{Event, Key, ModifiersState, NamedKey, PointerButton};
7use reactive_core::{Effect, RwSignal, effect, signal};
8use renderer_core::{Color, Paint, RectStyle, ShapeStyle, TextStyle};
9use ui_tree::{Component, EventResult, RenderNode};
10
11use crate::context::{mark_dirty, new_measured_leaf};
12use crate::focus::{self, FocusId};
13use crate::impl_leaf_widget;
14use crate::layout_leaf::LayoutLeaf;
15
16/// Width of the caret, in logical px.
17const CARET_WIDTH: f32 = 1.5;
18/// A width so large the shaper never soft-wraps: only an explicit `\n` breaks a line (code-editor behavior),
19/// which keeps caret/line math exact — a line's visual row is its logical row.
20const NO_WRAP_WIDTH: f32 = 1.0e6;
21/// Inserted by the Tab key (soft tabs).
22const TAB_INSERT: &str = "    ";
23
24/// A multi-line editable text area bound to a `RwSignal<String>` — the multi-line sibling of [`Input`](crate::Input).
25/// A base primitive: unstyled (wrap it in a `box` for a border/background), keyboard-driven, no soft-wrap (only
26/// `\n` breaks lines, so long lines overflow horizontally). It requests focus on tap, positions the caret at
27/// the click, edits the bound signal from key events (typing, Enter for a newline, Backspace/Delete joining
28/// lines, arrows in all four directions, Home/End, Tab), and draws a caret. Its measured height grows with the
29/// line count, so wrapping it in a [`LayoutScrollArea`](crate::LayoutScrollArea) gives a scrolling editor.
30/// Selection (`Shift`+arrows, `Ctrl+A`, shift-click) with copy, cut and paste, newlines and all. IME is not
31/// yet supported.
32pub struct TextArea {
33    value: RwSignal<String>,
34    // Caret byte offset into `value`. Reactive so a bare caret move re-renders even when the text is unchanged.
35    caret: RwSignal<usize>,
36    // The other end of a selection, or `None` when there is none. Byte offset like `caret`, and either side
37    // of it.
38    anchor: RwSignal<Option<usize>>,
39    style: Rc<dyn Fn() -> TextStyle>,
40    id: FocusId,
41    leaf: LayoutLeaf,
42    placeholder: String,
43    // Re-measures the leaf's height whenever the bound value changes — from a keystroke or a programmatic set
44    // (e.g. loading a file) — so the line count drives the layout in both cases. Kept alive for the widget's life.
45    _remeasure: Effect,
46}
47
48impl TextArea {
49    pub fn new(
50        value: RwSignal<String>,
51        layout_style: LayoutStyle,
52        style_fn: impl Fn() -> TextStyle + 'static,
53    ) -> Result<Self, LayoutError> {
54        let style: Rc<dyn Fn() -> TextStyle> = Rc::new(style_fn);
55        // Height is measured from the line count at the current style; width is left to the parent (the field
56        // stretches to fill the pane), so a long line overflows to the right rather than widening the layout.
57        let measure_value = value.clone();
58        let measure_style = Rc::clone(&style);
59        let measure = Box::new(move |_max_width: f32| {
60            let s = (measure_style)();
61            let line_h = crate::text_metrics::line_height(s.font_size);
62            let lines = measure_value.with(|t| t.matches('\n').count() + 1);
63            (0.0, lines as f32 * line_h)
64        });
65        let (node, rect) = new_measured_leaf(layout_style.align_self_stretch(), measure)?;
66        let caret = value.with(|s| s.len());
67        let id = focus::next_id();
68        focus::register_with_role(
69            id,
70            focus::FocusKind::TextEntry,
71            node,
72            focus::Role::MultilineTextInput,
73        );
74        let remeasure = {
75            let value = value.clone();
76            effect(move || {
77                // Subscribe to the value (tracked read) without cloning it; re-measure on any change.
78                value.with(|_| {});
79                mark_dirty(node).ok();
80            })
81        };
82        Ok(Self {
83            value,
84            caret: signal(caret),
85            anchor: signal(None),
86            style,
87            id,
88            leaf: LayoutLeaf { node, rect },
89            placeholder: String::new(),
90            _remeasure: remeasure,
91        })
92    }
93
94    /// A muted hint shown while the value is empty.
95    pub fn placeholder(mut self, p: impl Into<String>) -> Self {
96        self.placeholder = p.into();
97        self
98    }
99
100    /// Gives keyboard focus to this area (as a tap would), leaving the caret where it was. For programmatic
101    /// focus — e.g. a container autofocusing the editor when its tab or window becomes active.
102    pub fn request_focus(&self) {
103        focus::request(self.id);
104    }
105
106    /// Whether this area currently holds keyboard focus.
107    pub fn focused(&self) -> bool {
108        focus::is_focused(self.id)
109    }
110
111    /// A `Copy` [`focus::FocusHandle`] to this area, so a caller that has moved it into a container can still
112    /// focus it later (e.g. autofocus on tab activation) without keeping a reference to the area itself.
113    pub fn focus_handle(&self) -> focus::FocusHandle {
114        focus::handle(self.id)
115    }
116
117    fn line_height(&self) -> f32 {
118        crate::text_metrics::line_height((self.style)().font_size)
119    }
120
121    /// The current caret byte offset, clamped to the text and snapped to a char boundary.
122    fn caret_at(&self, text: &str) -> usize {
123        let mut c = self.caret.get().min(text.len());
124        while c > 0 && !text.is_char_boundary(c) {
125            c -= 1;
126        }
127        c
128    }
129
130    /// The selected byte range, low end first, or `None` when nothing is selected.
131    fn selection(&self, text: &str) -> Option<(usize, usize)> {
132        let caret = self.caret_at(text);
133        let mut anchor = self.anchor.get()?.min(text.len());
134        while anchor > 0 && !text.is_char_boundary(anchor) {
135            anchor -= 1;
136        }
137        (anchor != caret).then(|| (anchor.min(caret), anchor.max(caret)))
138    }
139
140    fn selected_text(&self, text: &str) -> Option<String> {
141        self.selection(text)
142            .map(|(from, to)| text[from..to].to_string())
143    }
144
145    /// Removes the selection from `text` and reports where the caret lands. Every edit runs through this
146    /// first, so typing over a selection replaces it.
147    fn take_selection(&self, text: &mut String) -> Option<usize> {
148        let (from, to) = self.selection(text)?;
149        text.replace_range(from..to, "");
150        Some(from)
151    }
152
153    /// Applies a key while focused, editing the bound signal and/or moving the caret. Returns whether the key
154    /// was consumed. On a text change the leaf is marked dirty so the runner re-measures the (possibly new)
155    /// line count on the next frame.
156    fn edit(&mut self, key: &Key, mods: &ModifiersState, style: &TextStyle) -> EventResult {
157        let mut text = self.value.get();
158        let mut caret = self.caret_at(&text);
159        let mut changed = false;
160        // Where a movement key leaves the anchor: `Shift` keeps (or starts) a selection, anything else drops
161        // it. Resolved after the match so each arm can still read the selection it is replacing.
162        let anchor = if mods.is_shift {
163            Some(self.anchor.get().unwrap_or(caret))
164        } else {
165            None
166        };
167        match key {
168            Key::Char('a') | Key::Char('A') if mods.is_ctrl || mods.is_meta => {
169                self.anchor.set(Some(0));
170                self.caret.set(text.len());
171                return EventResult::Handled;
172            }
173            Key::Char('c') | Key::Char('C') if mods.is_ctrl || mods.is_meta => {
174                let Some(selected) = self.selected_text(&text) else {
175                    return EventResult::Ignored;
176                };
177                services_core::set_clipboard_text(&selected);
178                // The selection survives a copy, as it does everywhere else.
179                return EventResult::Handled;
180            }
181            Key::Char('x') | Key::Char('X') if mods.is_ctrl || mods.is_meta => {
182                let Some(selected) = self.selected_text(&text) else {
183                    return EventResult::Ignored;
184                };
185                services_core::set_clipboard_text(&selected);
186                caret = self.take_selection(&mut text).unwrap_or(caret);
187                changed = true;
188            }
189            // Paste replaces the selection, newlines and all — an editor is exactly where a multi-line paste
190            // belongs.
191            Key::Char('v') | Key::Char('V') if mods.is_ctrl || mods.is_meta => {
192                let Some(pasted) = services_core::clipboard_text() else {
193                    return EventResult::Ignored;
194                };
195                let had_selection = self.take_selection(&mut text);
196                if pasted.is_empty() && had_selection.is_none() {
197                    return EventResult::Ignored;
198                }
199                caret = had_selection.unwrap_or(caret);
200                text.insert_str(caret, &pasted);
201                caret += pasted.len();
202                changed = true;
203            }
204            // Any other chord (Ctrl/Meta) is a shortcut, not text — leave it for global handlers (save, …).
205            Key::Char(_) if mods.is_ctrl || mods.is_meta => return EventResult::Ignored,
206            Key::Char(c) if !c.is_control() => {
207                caret = self.take_selection(&mut text).unwrap_or(caret);
208                text.insert(caret, *c);
209                caret += c.len_utf8();
210                changed = true;
211            }
212            Key::Named(NamedKey::Space) => {
213                caret = self.take_selection(&mut text).unwrap_or(caret);
214                text.insert(caret, ' ');
215                caret += 1;
216                changed = true;
217            }
218            Key::Named(NamedKey::Enter) => {
219                caret = self.take_selection(&mut text).unwrap_or(caret);
220                text.insert(caret, '\n');
221                caret += 1;
222                changed = true;
223            }
224            Key::Named(NamedKey::Tab) => {
225                text.insert_str(caret, TAB_INSERT);
226                caret += TAB_INSERT.len();
227                changed = true;
228            }
229            Key::Named(NamedKey::Backspace) => {
230                if let Some(at) = self.take_selection(&mut text) {
231                    caret = at;
232                } else {
233                    if caret == 0 {
234                        return EventResult::Ignored;
235                    }
236                    let prev = prev_boundary(&text, caret);
237                    text.replace_range(prev..caret, "");
238                    caret = prev;
239                }
240                changed = true;
241            }
242            Key::Named(NamedKey::Delete) => {
243                if let Some(at) = self.take_selection(&mut text) {
244                    caret = at;
245                } else {
246                    if caret >= text.len() {
247                        return EventResult::Ignored;
248                    }
249                    let next = next_boundary(&text, caret);
250                    text.replace_range(caret..next, "");
251                }
252                changed = true;
253            }
254            Key::Named(NamedKey::ArrowLeft) => caret = prev_boundary(&text, caret),
255            Key::Named(NamedKey::ArrowRight) => caret = next_boundary(&text, caret),
256            Key::Named(NamedKey::ArrowUp) => {
257                let line = line_index(&text, caret);
258                if line > 0 {
259                    let x = caret_x(&text, caret, style);
260                    caret = offset_at_line_x(&text, style, line - 1, x);
261                } else {
262                    caret = 0;
263                }
264            }
265            Key::Named(NamedKey::ArrowDown) => {
266                let line = line_index(&text, caret);
267                let last = text.matches('\n').count();
268                if line < last {
269                    let x = caret_x(&text, caret, style);
270                    caret = offset_at_line_x(&text, style, line + 1, x);
271                } else {
272                    caret = text.len();
273                }
274            }
275            Key::Named(NamedKey::Home) => caret = line_bounds(&text, caret).0,
276            Key::Named(NamedKey::End) => caret = line_bounds(&text, caret).1,
277            Key::Named(NamedKey::Escape) => {
278                focus::release(self.id);
279                return EventResult::Handled;
280            }
281            _ => return EventResult::Ignored,
282        }
283        if changed {
284            // Setting the value fires the re-measure effect (registered in `new`), which marks the leaf dirty
285            // so the runner re-measures the (possibly new) line count next frame.
286            self.value.set(text);
287        }
288        self.caret.set(caret);
289        // An anchor that caught up with the caret is no selection at all.
290        self.anchor.set(anchor.filter(|a| *a != caret));
291        EventResult::Handled
292    }
293}
294
295impl Component for TextArea {
296    fn view(&self) -> RenderNode {
297        let r = self.leaf.rect.get();
298        let text = self.value.get();
299        let style = (self.style)();
300        let line_h = self.line_height();
301        // Render at a huge width so the shaper never soft-wraps (only `\n` breaks a line); long lines overflow
302        // to the right and are clipped by an ancestor (e.g. the scroll viewport).
303        let full = Rect {
304            x: 0.0,
305            y: 0.0,
306            width: NO_WRAP_WIDTH,
307            height: r.height.max(line_h),
308        };
309        let text_node = if text.is_empty() && !self.placeholder.is_empty() {
310            let muted = match style.paint {
311                Paint::Solid(c) => Paint::Solid(c.with_alpha(c.a * 0.5)),
312                _ => Paint::Solid(Color::rgba(0.5, 0.5, 0.55, 0.5)),
313            };
314            let mut ph_style = style;
315            ph_style.paint = muted;
316            RenderNode::text(self.placeholder.clone(), full, ph_style)
317        } else {
318            RenderNode::text(Arc::<str>::from(text.as_str()), full, style)
319        };
320
321        if focus::is_focused(self.id) {
322            let caret = self.caret_at(&text);
323            let line = line_index(&text, caret);
324            // One rect per line the selection spans: the first from its start to the line's end, the last from
325            // the line's start to its end, and every line between them whole. A selection that wraps lines is
326            // not one box — drawing it as one would paint over the margin the text does not occupy.
327            let highlight = self.selection(&text).map(|(from, to)| {
328                let (first, last) = (line_index(&text, from), line_index(&text, to));
329                let mut bands = Vec::with_capacity(last - first + 1);
330                for line in first..=last {
331                    let (start, end) = nth_line_bounds(&text, line);
332                    let x0 = if line == first {
333                        caret_x(&text, from, &style)
334                    } else {
335                        caret_x(&text, start, &style)
336                    };
337                    let x1 = if line == last {
338                        caret_x(&text, to, &style)
339                    } else {
340                        // An empty line still shows a sliver, so a selection running through it is continuous
341                        // rather than a gap the eye reads as the selection having ended.
342                        caret_x(&text, end, &style).max(x0 + line_h * 0.35)
343                    };
344                    let fill = match style.paint {
345                        Paint::Solid(c) => c.with_alpha(0.25),
346                        _ => Color::rgba(0.4, 0.6, 0.9, 0.3),
347                    };
348                    bands.push(RenderNode::rect(
349                        Rect {
350                            x: x0,
351                            y: line as f32 * line_h,
352                            width: (x1 - x0).max(1.0),
353                            height: line_h,
354                        },
355                        RectStyle::default().with_fill(Paint::Solid(fill)),
356                    ));
357                }
358                RenderNode::group(bands)
359            });
360            let x = caret_x(&text, caret, &style);
361            let caret_rect = Rect {
362                x,
363                y: line as f32 * line_h,
364                width: CARET_WIDTH,
365                height: line_h,
366            };
367            let caret_node =
368                RenderNode::rect(caret_rect, RectStyle::default().with_fill(style.paint));
369            let layers = match highlight {
370                Some(highlight) => vec![highlight, text_node, caret_node],
371                None => vec![text_node, caret_node],
372            };
373            self.leaf.at_layout_position(RenderNode::group(layers))
374        } else {
375            self.leaf.at_layout_position(text_node)
376        }
377    }
378
379    fn on_event(&mut self, event: &Event) -> EventResult {
380        let rect = self.leaf.rect.get();
381        match event {
382            Event::PointerPressed {
383                x,
384                y,
385                button: PointerButton::Primary,
386                ..
387            } => {
388                if rect.contains(*x as f32, *y as f32) {
389                    focus::request_from_pointer(self.id);
390                    let style = (self.style)();
391                    let line_h = crate::text_metrics::line_height(style.font_size);
392                    // Read the text out (borrow released) before setting the caret: `set` inside a `with`
393                    // closure would re-borrow the reactive runtime.
394                    let text = self.value.get();
395                    let local_y = (*y as f32 - rect.y).max(0.0);
396                    let local_x = (*x as f32 - rect.x).max(0.0);
397                    let last = text.matches('\n').count();
398                    let line = ((local_y / line_h).floor() as usize).min(last);
399                    let at = offset_at_line_x(&text, &style, line, local_x);
400                    // Shift-click extends from wherever the selection already starts, which is what every
401                    // editor does and the only pointer gesture available until a drag can be tracked.
402                    if crate::keyboard::modifiers().is_shift {
403                        let from = self.anchor.get().unwrap_or_else(|| self.caret.get());
404                        self.anchor.set(Some(from));
405                    } else {
406                        self.anchor.set(None);
407                    }
408                    self.caret.set(at);
409                    EventResult::Handled
410                } else {
411                    EventResult::Ignored
412                }
413            }
414            Event::KeyPressed { key, modifiers } if focus::is_focused(self.id) => {
415                let style = (self.style)();
416                self.edit(key, modifiers, &style)
417            }
418            _ => EventResult::Ignored,
419        }
420    }
421
422    fn debug_name(&self) -> &'static str {
423        "TextArea"
424    }
425}
426
427impl Drop for TextArea {
428    fn drop(&mut self) {
429        focus::unregister(self.id);
430    }
431}
432
433impl_leaf_widget!(TextArea);
434
435/// Byte range `[start, end)` of the line containing `caret` (bounded by the surrounding `\n`s or the text ends).
436fn line_bounds(text: &str, caret: usize) -> (usize, usize) {
437    let caret = caret.min(text.len());
438    let start = text[..caret].rfind('\n').map(|i| i + 1).unwrap_or(0);
439    let end = text[caret..]
440        .find('\n')
441        .map(|i| caret + i)
442        .unwrap_or(text.len());
443    (start, end)
444}
445
446/// Zero-based visual/logical line of `caret` (they are the same with no soft-wrap).
447fn line_index(text: &str, caret: usize) -> usize {
448    text[..caret.min(text.len())].matches('\n').count()
449}
450
451/// Byte range `[start, end)` of the `n`th line (clamped to the last line).
452fn nth_line_bounds(text: &str, n: usize) -> (usize, usize) {
453    let mut start = 0;
454    for _ in 0..n {
455        match text[start..].find('\n') {
456            Some(i) => start += i + 1,
457            None => return (text.len(), text.len()),
458        }
459    }
460    let end = text[start..]
461        .find('\n')
462        .map(|i| start + i)
463        .unwrap_or(text.len());
464    (start, end)
465}
466
467/// Pixel x of the caret within its line (the advance of the line's prefix up to `caret`).
468fn caret_x(text: &str, caret: usize, style: &TextStyle) -> f32 {
469    let (start, _) = line_bounds(text, caret);
470    crate::text_metrics::measure_text(&text[start..caret.min(text.len())], NO_WRAP_WIDTH, style).0
471}
472
473/// The byte offset within line `n` whose caret x is closest to `x` — used for click-to-position and vertical
474/// arrow moves (keeping the column).
475fn offset_at_line_x(text: &str, style: &TextStyle, n: usize, x: f32) -> usize {
476    let (start, end) = nth_line_bounds(text, n);
477    let line = &text[start..end];
478    let mut best = start;
479    let mut best_dx = f32::MAX;
480    let mut idx = 0;
481    loop {
482        let w = crate::text_metrics::measure_text(&line[..idx], NO_WRAP_WIDTH, style).0;
483        let dx = (w - x).abs();
484        if dx < best_dx {
485            best_dx = dx;
486            best = start + idx;
487        }
488        if idx >= line.len() {
489            break;
490        }
491        idx = next_boundary(line, idx);
492    }
493    best
494}
495
496/// The char boundary strictly before byte offset `i` (or 0).
497fn prev_boundary(s: &str, i: usize) -> usize {
498    let mut j = i.min(s.len());
499    if j == 0 {
500        return 0;
501    }
502    j -= 1;
503    while j > 0 && !s.is_char_boundary(j) {
504        j -= 1;
505    }
506    j
507}
508
509/// The char boundary strictly after byte offset `i` (or `s.len()`).
510fn next_boundary(s: &str, i: usize) -> usize {
511    let mut j = (i + 1).min(s.len());
512    while j < s.len() && !s.is_char_boundary(j) {
513        j += 1;
514    }
515    j
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use crate::context::{compute_layout, new_container, reset_layout_runtime};
522    use crate::layout_item::LayoutItem;
523    use layout_core::AvailableSpace;
524    use renderer_core::Color;
525
526    fn key(k: Key) -> Event {
527        Event::KeyPressed {
528            key: k,
529            modifiers: ModifiersState::default(),
530        }
531    }
532
533    fn chord(k: Key) -> Event {
534        Event::KeyPressed {
535            key: k,
536            modifiers: ModifiersState {
537                is_ctrl: true,
538                ..ModifiersState::default()
539            },
540        }
541    }
542
543    fn shifted(k: Key) -> Event {
544        Event::KeyPressed {
545            key: k,
546            modifiers: ModifiersState {
547                is_shift: true,
548                ..ModifiersState::default()
549            },
550        }
551    }
552
553    #[test]
554    fn typing_over_a_selection_replaces_it() {
555        let (mut area, value) = focused("one\ntwo");
556        area.on_event(&chord(Key::Char('a')));
557        area.on_event(&key(Key::Char('x')));
558        assert_eq!(value.get(), "x");
559    }
560
561    /// The case the notebook is: a selection that runs across a line break, cut whole.
562    #[test]
563    fn a_selection_across_lines_cuts_whole() {
564        let (mut area, value) = focused("one\ntwo\nthree");
565        area.on_event(&chord(Key::Char('a')));
566        area.on_event(&chord(Key::Char('x')));
567        assert_eq!(value.get(), "");
568    }
569
570    #[test]
571    fn shift_arrows_grow_a_selection_and_backspace_takes_it() {
572        let (mut area, value) = focused("hello");
573        area.on_event(&shifted(Key::Named(NamedKey::ArrowLeft)));
574        area.on_event(&shifted(Key::Named(NamedKey::ArrowLeft)));
575        assert_eq!(area.selection("hello"), Some((3, 5)));
576        area.on_event(&key(Key::Named(NamedKey::Backspace)));
577        assert_eq!(value.get(), "hel");
578    }
579
580    /// Enter with a selection replaces it with the break, rather than pushing the selected text down a line.
581    #[test]
582    fn enter_replaces_a_selection() {
583        let (mut area, value) = focused("abcd");
584        area.on_event(&chord(Key::Char('a')));
585        area.on_event(&key(Key::Named(NamedKey::Enter)));
586        assert_eq!(value.get(), "\n");
587    }
588
589    #[test]
590    fn copy_leaves_the_text_and_the_selection_alone() {
591        let (mut area, value) = focused("one\ntwo");
592        area.on_event(&chord(Key::Char('a')));
593        assert_eq!(area.on_event(&chord(Key::Char('c'))), EventResult::Handled);
594        assert_eq!(value.get(), "one\ntwo");
595        assert_eq!(area.selection("one\ntwo"), Some((0, 7)));
596    }
597    fn focused(initial: &str) -> (TextArea, RwSignal<String>) {
598        reset_layout_runtime();
599        let value = signal(initial.to_string());
600        let area = TextArea::new(value.clone(), LayoutStyle::new().width(400.0), || {
601            TextStyle::new(14.0, Color::BLACK)
602        })
603        .unwrap();
604        let root = new_container(
605            LayoutStyle::new().flex_column().width(400.0).height(400.0),
606            &[area.layout_node()],
607        )
608        .unwrap();
609        compute_layout(
610            root,
611            AvailableSpace::Definite(400.0),
612            AvailableSpace::Definite(400.0),
613        )
614        .unwrap();
615        focus::request(area.id);
616        (area, value)
617    }
618
619    /// The multi-line twin of `Input`'s guard test: this editor takes Enter and the vertical arrows as text,
620    /// so a global shortcut on any of them must stand aside while the caret is here.
621    #[test]
622    fn the_shortcut_guard_covers_every_key_this_editor_edits() {
623        let plain = ModifiersState::default();
624        let named = [
625            NamedKey::Space,
626            NamedKey::Backspace,
627            NamedKey::Delete,
628            NamedKey::ArrowLeft,
629            NamedKey::ArrowRight,
630            NamedKey::ArrowUp,
631            NamedKey::ArrowDown,
632            NamedKey::Home,
633            NamedKey::End,
634            NamedKey::Enter,
635            NamedKey::Escape,
636            NamedKey::Tab,
637            NamedKey::PageUp,
638            NamedKey::F5,
639        ];
640        let keys: Vec<Key> = std::iter::once(Key::Char('x'))
641            .chain(named.into_iter().map(Key::Named))
642            .collect();
643        let style = TextStyle::new(14.0, Color::BLACK);
644        for k in keys {
645            let (mut area, _value) = focused("one\ntwo");
646            area.caret.set(5);
647            // Asked first, as dispatch does — Escape answers by giving up the focus the guard reads.
648            let guarded = focus::text_entry_takes_key(&k, plain);
649            let edited = area.edit(&k, &plain, &style) == EventResult::Handled;
650            assert!(
651                !edited || guarded,
652                "{k:?} is edited by the editor but the shortcut guard lets it through"
653            );
654            focus::clear();
655        }
656    }
657
658    #[test]
659    fn enter_inserts_newline_and_typing_continues_on_new_line() {
660        let (mut area, value) = focused("ab");
661        area.on_event(&key(Key::Named(NamedKey::Enter)));
662        area.on_event(&key(Key::Char('c')));
663        assert_eq!(value.get(), "ab\nc");
664        assert_eq!(line_index(&value.get(), area.caret.get()), 1);
665    }
666
667    #[test]
668    fn backspace_at_line_start_joins_lines() {
669        let (mut area, value) = focused("ab\ncd");
670        // Caret starts at end (line 1). Home → line start (byte 3), Backspace removes the newline.
671        area.on_event(&key(Key::Named(NamedKey::Home)));
672        area.on_event(&key(Key::Named(NamedKey::Backspace)));
673        assert_eq!(value.get(), "abcd");
674    }
675
676    #[test]
677    fn arrow_up_down_moves_between_lines() {
678        let (mut area, value) = focused("aaaa\nbb");
679        // Caret at end of "bb" (line 1). Up → line 0, keeping column; Down → back to line 1.
680        area.on_event(&key(Key::Named(NamedKey::ArrowUp)));
681        assert_eq!(line_index(&value.get(), area.caret.get()), 0);
682        area.on_event(&key(Key::Named(NamedKey::ArrowDown)));
683        assert_eq!(line_index(&value.get(), area.caret.get()), 1);
684    }
685
686    #[test]
687    fn click_focuses_and_positions_caret_without_reborrow() {
688        use platform_core::{PointerButton, PointerSource};
689        let (mut area, _value) = focused("hello\nworld");
690        focus::clear();
691        // A press inside must focus the field and move the caret without re-borrowing the reactive runtime
692        // (the caret set must not run inside a `value.with` closure).
693        let r = area.leaf.rect.get();
694        let handled = area.on_event(&Event::PointerPressed {
695            x: (r.x + 5.0) as f64,
696            y: (r.y + 2.0) as f64,
697            button: PointerButton::Primary,
698            source: PointerSource::Mouse,
699        });
700        assert_eq!(handled, EventResult::Handled);
701        assert!(
702            focus::is_focused(area.id),
703            "a press inside focuses the area"
704        );
705    }
706
707    #[test]
708    fn ctrl_chord_is_ignored_as_shortcut() {
709        let (mut area, value) = focused("hi");
710        let save = Event::KeyPressed {
711            key: Key::Char('s'),
712            modifiers: ModifiersState {
713                is_ctrl: true,
714                ..Default::default()
715            },
716        };
717        assert_eq!(area.on_event(&save), EventResult::Ignored);
718        assert_eq!(value.get(), "hi");
719    }
720}