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