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, clipboard, and IME are not yet supported (a single-caret MVP, like `Input`).
31pub struct TextArea {
32    value: RwSignal<String>,
33    // Caret byte offset into `value`. Reactive so a bare caret move re-renders even when the text is unchanged.
34    caret: RwSignal<usize>,
35    style: Rc<dyn Fn() -> TextStyle>,
36    id: FocusId,
37    leaf: LayoutLeaf,
38    placeholder: String,
39    // Re-measures the leaf's height whenever the bound value changes — from a keystroke or a programmatic set
40    // (e.g. loading a file) — so the line count drives the layout in both cases. Kept alive for the widget's life.
41    _remeasure: Effect,
42}
43
44impl TextArea {
45    pub fn new(
46        value: RwSignal<String>,
47        layout_style: LayoutStyle,
48        style_fn: impl Fn() -> TextStyle + 'static,
49    ) -> Result<Self, LayoutError> {
50        let style: Rc<dyn Fn() -> TextStyle> = Rc::new(style_fn);
51        // Height is measured from the line count at the current style; width is left to the parent (the field
52        // stretches to fill the pane), so a long line overflows to the right rather than widening the layout.
53        let measure_value = value.clone();
54        let measure_style = Rc::clone(&style);
55        let measure = Box::new(move |_max_width: f32| {
56            let s = (measure_style)();
57            let line_h = crate::text_metrics::line_height(s.font_size);
58            let lines = measure_value.with(|t| t.matches('\n').count() + 1);
59            (0.0, lines as f32 * line_h)
60        });
61        let (node, rect) = new_measured_leaf(layout_style.align_self_stretch(), measure)?;
62        let caret = value.with(|s| s.len());
63        let id = focus::next_id();
64        focus::register_with_role(
65            id,
66            focus::FocusKind::TextEntry,
67            node,
68            focus::Role::MultilineTextInput,
69        );
70        let remeasure = {
71            let value = value.clone();
72            effect(move || {
73                // Subscribe to the value (tracked read) without cloning it; re-measure on any change.
74                value.with(|_| {});
75                mark_dirty(node).ok();
76            })
77        };
78        Ok(Self {
79            value,
80            caret: signal(caret),
81            style,
82            id,
83            leaf: LayoutLeaf { node, rect },
84            placeholder: String::new(),
85            _remeasure: remeasure,
86        })
87    }
88
89    /// A muted hint shown while the value is empty.
90    pub fn placeholder(mut self, p: impl Into<String>) -> Self {
91        self.placeholder = p.into();
92        self
93    }
94
95    /// Gives keyboard focus to this area (as a tap would), leaving the caret where it was. For programmatic
96    /// focus — e.g. a container autofocusing the editor when its tab or window becomes active.
97    pub fn request_focus(&self) {
98        focus::request(self.id);
99    }
100
101    /// Whether this area currently holds keyboard focus.
102    pub fn focused(&self) -> bool {
103        focus::is_focused(self.id)
104    }
105
106    /// A `Copy` [`focus::FocusHandle`] to this area, so a caller that has moved it into a container can still
107    /// focus it later (e.g. autofocus on tab activation) without keeping a reference to the area itself.
108    pub fn focus_handle(&self) -> focus::FocusHandle {
109        focus::handle(self.id)
110    }
111
112    fn line_height(&self) -> f32 {
113        crate::text_metrics::line_height((self.style)().font_size)
114    }
115
116    /// The current caret byte offset, clamped to the text and snapped to a char boundary.
117    fn caret_at(&self, text: &str) -> usize {
118        let mut c = self.caret.get().min(text.len());
119        while c > 0 && !text.is_char_boundary(c) {
120            c -= 1;
121        }
122        c
123    }
124
125    /// Applies a key while focused, editing the bound signal and/or moving the caret. Returns whether the key
126    /// was consumed. On a text change the leaf is marked dirty so the runner re-measures the (possibly new)
127    /// line count on the next frame.
128    fn edit(&mut self, key: &Key, mods: &ModifiersState, style: &TextStyle) -> EventResult {
129        let mut text = self.value.get();
130        let mut caret = self.caret_at(&text);
131        let mut changed = false;
132        match key {
133            // A chord (Ctrl/Meta) is a shortcut, not text — leave it for global handlers (save, copy/paste TBD).
134            Key::Char(_) if mods.is_ctrl || mods.is_meta => return EventResult::Ignored,
135            Key::Char(c) if !c.is_control() => {
136                text.insert(caret, *c);
137                caret += c.len_utf8();
138                changed = true;
139            }
140            Key::Named(NamedKey::Space) => {
141                text.insert(caret, ' ');
142                caret += 1;
143                changed = true;
144            }
145            Key::Named(NamedKey::Enter) => {
146                text.insert(caret, '\n');
147                caret += 1;
148                changed = true;
149            }
150            Key::Named(NamedKey::Tab) => {
151                text.insert_str(caret, TAB_INSERT);
152                caret += TAB_INSERT.len();
153                changed = true;
154            }
155            Key::Named(NamedKey::Backspace) => {
156                if caret == 0 {
157                    return EventResult::Ignored;
158                }
159                let prev = prev_boundary(&text, caret);
160                text.replace_range(prev..caret, "");
161                caret = prev;
162                changed = true;
163            }
164            Key::Named(NamedKey::Delete) => {
165                if caret >= text.len() {
166                    return EventResult::Ignored;
167                }
168                let next = next_boundary(&text, caret);
169                text.replace_range(caret..next, "");
170                changed = true;
171            }
172            Key::Named(NamedKey::ArrowLeft) => caret = prev_boundary(&text, caret),
173            Key::Named(NamedKey::ArrowRight) => caret = next_boundary(&text, caret),
174            Key::Named(NamedKey::ArrowUp) => {
175                let line = line_index(&text, caret);
176                if line > 0 {
177                    let x = caret_x(&text, caret, style);
178                    caret = offset_at_line_x(&text, style, line - 1, x);
179                } else {
180                    caret = 0;
181                }
182            }
183            Key::Named(NamedKey::ArrowDown) => {
184                let line = line_index(&text, caret);
185                let last = text.matches('\n').count();
186                if line < last {
187                    let x = caret_x(&text, caret, style);
188                    caret = offset_at_line_x(&text, style, line + 1, x);
189                } else {
190                    caret = text.len();
191                }
192            }
193            Key::Named(NamedKey::Home) => caret = line_bounds(&text, caret).0,
194            Key::Named(NamedKey::End) => caret = line_bounds(&text, caret).1,
195            Key::Named(NamedKey::Escape) => {
196                focus::release(self.id);
197                return EventResult::Handled;
198            }
199            _ => return EventResult::Ignored,
200        }
201        if changed {
202            // Setting the value fires the re-measure effect (registered in `new`), which marks the leaf dirty
203            // so the runner re-measures the (possibly new) line count next frame.
204            self.value.set(text);
205        }
206        self.caret.set(caret);
207        EventResult::Handled
208    }
209}
210
211impl Component for TextArea {
212    fn view(&self) -> RenderNode {
213        let r = self.leaf.rect.get();
214        let text = self.value.get();
215        let style = (self.style)();
216        let line_h = self.line_height();
217        // Render at a huge width so the shaper never soft-wraps (only `\n` breaks a line); long lines overflow
218        // to the right and are clipped by an ancestor (e.g. the scroll viewport).
219        let full = Rect {
220            x: 0.0,
221            y: 0.0,
222            width: NO_WRAP_WIDTH,
223            height: r.height.max(line_h),
224        };
225        let text_node = if text.is_empty() && !self.placeholder.is_empty() {
226            let muted = match style.paint {
227                Paint::Solid(c) => Paint::Solid(c.with_alpha(c.a * 0.5)),
228                _ => Paint::Solid(Color::rgba(0.5, 0.5, 0.55, 0.5)),
229            };
230            let mut ph_style = style;
231            ph_style.paint = muted;
232            RenderNode::text(self.placeholder.clone(), full, ph_style)
233        } else {
234            RenderNode::text(Arc::<str>::from(text.as_str()), full, style)
235        };
236
237        if focus::is_focused(self.id) {
238            let caret = self.caret_at(&text);
239            let line = line_index(&text, caret);
240            let x = caret_x(&text, caret, &style);
241            let caret_rect = Rect {
242                x,
243                y: line as f32 * line_h,
244                width: CARET_WIDTH,
245                height: line_h,
246            };
247            let caret_node =
248                RenderNode::rect(caret_rect, RectStyle::default().with_fill(style.paint));
249            self.leaf
250                .at_layout_position(RenderNode::group([text_node, caret_node]))
251        } else {
252            self.leaf.at_layout_position(text_node)
253        }
254    }
255
256    fn on_event(&mut self, event: &Event) -> EventResult {
257        let rect = self.leaf.rect.get();
258        match event {
259            Event::PointerPressed {
260                x,
261                y,
262                button: PointerButton::Primary,
263                ..
264            } => {
265                if rect.contains(*x as f32, *y as f32) {
266                    focus::request_from_pointer(self.id);
267                    let style = (self.style)();
268                    let line_h = crate::text_metrics::line_height(style.font_size);
269                    // Read the text out (borrow released) before setting the caret: `set` inside a `with`
270                    // closure would re-borrow the reactive runtime.
271                    let text = self.value.get();
272                    let local_y = (*y as f32 - rect.y).max(0.0);
273                    let local_x = (*x as f32 - rect.x).max(0.0);
274                    let last = text.matches('\n').count();
275                    let line = ((local_y / line_h).floor() as usize).min(last);
276                    self.caret
277                        .set(offset_at_line_x(&text, &style, line, local_x));
278                    EventResult::Handled
279                } else {
280                    EventResult::Ignored
281                }
282            }
283            Event::KeyPressed { key, modifiers } if focus::is_focused(self.id) => {
284                let style = (self.style)();
285                self.edit(key, modifiers, &style)
286            }
287            _ => EventResult::Ignored,
288        }
289    }
290
291    fn debug_name(&self) -> &'static str {
292        "TextArea"
293    }
294}
295
296impl Drop for TextArea {
297    fn drop(&mut self) {
298        focus::unregister(self.id);
299    }
300}
301
302impl_leaf_widget!(TextArea);
303
304/// Byte range `[start, end)` of the line containing `caret` (bounded by the surrounding `\n`s or the text ends).
305fn line_bounds(text: &str, caret: usize) -> (usize, usize) {
306    let caret = caret.min(text.len());
307    let start = text[..caret].rfind('\n').map(|i| i + 1).unwrap_or(0);
308    let end = text[caret..]
309        .find('\n')
310        .map(|i| caret + i)
311        .unwrap_or(text.len());
312    (start, end)
313}
314
315/// Zero-based visual/logical line of `caret` (they are the same with no soft-wrap).
316fn line_index(text: &str, caret: usize) -> usize {
317    text[..caret.min(text.len())].matches('\n').count()
318}
319
320/// Byte range `[start, end)` of the `n`th line (clamped to the last line).
321fn nth_line_bounds(text: &str, n: usize) -> (usize, usize) {
322    let mut start = 0;
323    for _ in 0..n {
324        match text[start..].find('\n') {
325            Some(i) => start += i + 1,
326            None => return (text.len(), text.len()),
327        }
328    }
329    let end = text[start..]
330        .find('\n')
331        .map(|i| start + i)
332        .unwrap_or(text.len());
333    (start, end)
334}
335
336/// Pixel x of the caret within its line (the advance of the line's prefix up to `caret`).
337fn caret_x(text: &str, caret: usize, style: &TextStyle) -> f32 {
338    let (start, _) = line_bounds(text, caret);
339    crate::text_metrics::measure_text(&text[start..caret.min(text.len())], NO_WRAP_WIDTH, style).0
340}
341
342/// The byte offset within line `n` whose caret x is closest to `x` — used for click-to-position and vertical
343/// arrow moves (keeping the column).
344fn offset_at_line_x(text: &str, style: &TextStyle, n: usize, x: f32) -> usize {
345    let (start, end) = nth_line_bounds(text, n);
346    let line = &text[start..end];
347    let mut best = start;
348    let mut best_dx = f32::MAX;
349    let mut idx = 0;
350    loop {
351        let w = crate::text_metrics::measure_text(&line[..idx], NO_WRAP_WIDTH, style).0;
352        let dx = (w - x).abs();
353        if dx < best_dx {
354            best_dx = dx;
355            best = start + idx;
356        }
357        if idx >= line.len() {
358            break;
359        }
360        idx = next_boundary(line, idx);
361    }
362    best
363}
364
365/// The char boundary strictly before byte offset `i` (or 0).
366fn prev_boundary(s: &str, i: usize) -> usize {
367    let mut j = i.min(s.len());
368    if j == 0 {
369        return 0;
370    }
371    j -= 1;
372    while j > 0 && !s.is_char_boundary(j) {
373        j -= 1;
374    }
375    j
376}
377
378/// The char boundary strictly after byte offset `i` (or `s.len()`).
379fn next_boundary(s: &str, i: usize) -> usize {
380    let mut j = (i + 1).min(s.len());
381    while j < s.len() && !s.is_char_boundary(j) {
382        j += 1;
383    }
384    j
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::context::{compute_layout, new_container, reset_layout_runtime};
391    use crate::layout_item::LayoutItem;
392    use layout_core::AvailableSpace;
393    use renderer_core::Color;
394
395    fn key(k: Key) -> Event {
396        Event::KeyPressed {
397            key: k,
398            modifiers: ModifiersState::default(),
399        }
400    }
401
402    fn focused(initial: &str) -> (TextArea, RwSignal<String>) {
403        reset_layout_runtime();
404        let value = signal(initial.to_string());
405        let area = TextArea::new(value.clone(), LayoutStyle::new().width(400.0), || {
406            TextStyle::new(14.0, Color::BLACK)
407        })
408        .unwrap();
409        let root = new_container(
410            LayoutStyle::new().flex_column().width(400.0).height(400.0),
411            &[area.layout_node()],
412        )
413        .unwrap();
414        compute_layout(
415            root,
416            AvailableSpace::Definite(400.0),
417            AvailableSpace::Definite(400.0),
418        )
419        .unwrap();
420        focus::request(area.id);
421        (area, value)
422    }
423
424    /// The multi-line twin of `Input`'s guard test: this editor takes Enter and the vertical arrows as text,
425    /// so a global shortcut on any of them must stand aside while the caret is here.
426    #[test]
427    fn the_shortcut_guard_covers_every_key_this_editor_edits() {
428        let plain = ModifiersState::default();
429        let named = [
430            NamedKey::Space,
431            NamedKey::Backspace,
432            NamedKey::Delete,
433            NamedKey::ArrowLeft,
434            NamedKey::ArrowRight,
435            NamedKey::ArrowUp,
436            NamedKey::ArrowDown,
437            NamedKey::Home,
438            NamedKey::End,
439            NamedKey::Enter,
440            NamedKey::Escape,
441            NamedKey::Tab,
442            NamedKey::PageUp,
443            NamedKey::F5,
444        ];
445        let keys: Vec<Key> = std::iter::once(Key::Char('x'))
446            .chain(named.into_iter().map(Key::Named))
447            .collect();
448        let style = TextStyle::new(14.0, Color::BLACK);
449        for k in keys {
450            let (mut area, _value) = focused("one\ntwo");
451            area.caret.set(5);
452            // Asked first, as dispatch does — Escape answers by giving up the focus the guard reads.
453            let guarded = focus::text_entry_takes_key(&k, plain);
454            let edited = area.edit(&k, &plain, &style) == EventResult::Handled;
455            assert!(
456                !edited || guarded,
457                "{k:?} is edited by the editor but the shortcut guard lets it through"
458            );
459            focus::clear();
460        }
461    }
462
463    #[test]
464    fn enter_inserts_newline_and_typing_continues_on_new_line() {
465        let (mut area, value) = focused("ab");
466        area.on_event(&key(Key::Named(NamedKey::Enter)));
467        area.on_event(&key(Key::Char('c')));
468        assert_eq!(value.get(), "ab\nc");
469        assert_eq!(line_index(&value.get(), area.caret.get()), 1);
470    }
471
472    #[test]
473    fn backspace_at_line_start_joins_lines() {
474        let (mut area, value) = focused("ab\ncd");
475        // Caret starts at end (line 1). Home → line start (byte 3), Backspace removes the newline.
476        area.on_event(&key(Key::Named(NamedKey::Home)));
477        area.on_event(&key(Key::Named(NamedKey::Backspace)));
478        assert_eq!(value.get(), "abcd");
479    }
480
481    #[test]
482    fn arrow_up_down_moves_between_lines() {
483        let (mut area, value) = focused("aaaa\nbb");
484        // Caret at end of "bb" (line 1). Up → line 0, keeping column; Down → back to line 1.
485        area.on_event(&key(Key::Named(NamedKey::ArrowUp)));
486        assert_eq!(line_index(&value.get(), area.caret.get()), 0);
487        area.on_event(&key(Key::Named(NamedKey::ArrowDown)));
488        assert_eq!(line_index(&value.get(), area.caret.get()), 1);
489    }
490
491    #[test]
492    fn click_focuses_and_positions_caret_without_reborrow() {
493        use platform_core::{PointerButton, PointerSource};
494        let (mut area, _value) = focused("hello\nworld");
495        focus::clear();
496        // A press inside must focus the field and move the caret without re-borrowing the reactive runtime
497        // (the caret set must not run inside a `value.with` closure).
498        let r = area.leaf.rect.get();
499        let handled = area.on_event(&Event::PointerPressed {
500            x: (r.x + 5.0) as f64,
501            y: (r.y + 2.0) as f64,
502            button: PointerButton::Primary,
503            source: PointerSource::Mouse,
504        });
505        assert_eq!(handled, EventResult::Handled);
506        assert!(
507            focus::is_focused(area.id),
508            "a press inside focuses the area"
509        );
510    }
511
512    #[test]
513    fn ctrl_chord_is_ignored_as_shortcut() {
514        let (mut area, value) = focused("hi");
515        let save = Event::KeyPressed {
516            key: Key::Char('s'),
517            modifiers: ModifiersState {
518                is_ctrl: true,
519                ..Default::default()
520            },
521        };
522        assert_eq!(area.on_event(&save), EventResult::Ignored);
523        assert_eq!(value.get(), "hi");
524    }
525}