Skip to main content

smudge_text/
text_area.rs

1//! The text area: multi-line editable text bound to a signal.
2//!
3//! A [`TextArea`] drives an [`Editor`](crate::Editor) the way a [`TextInput`](crate::TextInput)
4//! does, over text that spans paragraphs: Enter inserts a line break and is
5//! handled, so it does not bubble; a paste keeps its line breaks; Up, Down,
6//! Page Up and Page Down move by visual line with the horizontal position
7//! kept where the move can; Home and End move within the visual line; and
8//! the text wraps at the width the area is given, scrolling up and down
9//! inside itself so the caret stays visible, and under the wheel.
10//!
11//! Create one with [`text_area`] and shape it with the [`TextAreaExt`]
12//! builder methods. The area fills the width it is offered and, without a
13//! height from the style builders, grows with its wrapped text, never
14//! shorter than one line or [`TextAreaExt::min_lines`] lines. Give it a
15//! height to make it clip and scroll instead.
16
17use std::cell::{Cell, RefCell};
18use std::rc::Rc;
19
20use smudge_core::clipboard;
21use smudge_core::element::{Element, Value};
22use smudge_core::geometry::{Color, Corners, Point, Rect, Size};
23use smudge_core::input::{Handled, Key, KeyEvent};
24use smudge_core::paint::PaintOp;
25use smudge_core::reactive::{RwSignal, prelude::*};
26use smudge_core::semantics::{Role, Semantics};
27use smudge_core::style::Length;
28use smudge_core::theme::ColorToken;
29use smudge_core::tree::Dirty;
30use smudge_core::widget::{AvailableSpace, MeasureInput, PaintCx, Widget};
31use smudge_core::window::Cursor;
32
33use crate::chrome::{
34    Direction, InputChrome, ParagraphCache, Placeholder, SELECTION_ALPHA, apply_edit_count,
35    apply_move, apply_outside_write, caret_node, copy_selection, cut_selection, handle_commit,
36    handle_focus, handle_move, handle_preedit, handle_press, handle_release, handle_wheel,
37    is_shortcut, outside_write, paint_paragraphs, paint_selection, types_text,
38};
39use crate::style::{TextStyle, current_text_style};
40
41/// A block of editable text.
42///
43/// Build one with [`text_area`]. The area keeps the editor and the glyph
44/// runs of the paragraphs it shows, and rebuilds the runs only when the
45/// editor laid a paragraph out again, the area moved or scrolled, or the
46/// resolved text color changed.
47pub struct TextArea {
48    /// The state shared with the handlers and the caret node.
49    chrome: Rc<InputChrome>,
50    /// The text shown while the editor is empty.
51    placeholder: RefCell<Placeholder>,
52    /// The text style the editor lays out and the area paints with,
53    /// resolved from the ambient style at mount.
54    style: TextStyle,
55    /// The glyph runs of the editor's text, with everything they were built
56    /// from.
57    runs: RefCell<Option<ParagraphCache>>,
58    /// The height of the wrapped text as the body last measured it.
59    measured: Rc<Cell<f32>>,
60    /// Bumped when an edit changed the height of the wrapped text, which
61    /// lays the body out again in the same frame.
62    relayout: RwSignal<u64>,
63}
64
65impl TextArea {
66    /// Creates an area with an empty placeholder and the default style
67    /// until the mount reads the ambient one, sharing the measured height
68    /// and the relayout counter with its body.
69    fn new(chrome: Rc<InputChrome>, measured: Rc<Cell<f32>>, relayout: RwSignal<u64>) -> TextArea {
70        TextArea {
71            chrome,
72            placeholder: RefCell::new(Placeholder::new()),
73            style: TextStyle::default(),
74            runs: RefCell::new(None),
75            measured,
76            relayout,
77        }
78    }
79
80    /// Lays the body out again when the wrapped text no longer has the
81    /// height the body last measured, so an edit that adds or removes a
82    /// line resizes the area in the frame that shows it, and one that does
83    /// not runs no layout.
84    fn relayout_if_resized(&self) {
85        if self.chrome.editor.layout_size().height != self.measured.get() {
86            self.relayout.update(|count| *count += 1);
87        }
88    }
89
90    /// Returns the color the text and the caret draw with: the node's text
91    /// color when it sets one, otherwise the area's style color.
92    fn text_color(&self, cx: &PaintCx<'_>) -> Color {
93        match &cx.style.paint.text_color {
94            Some(color) => color.resolve(cx.theme),
95            None => self.style.color.resolve(cx.theme),
96        }
97    }
98}
99
100impl Widget for TextArea {
101    /// Reports the area's placeholder as the accessible name and its text
102    /// as the accessible value.
103    ///
104    /// An area with no placeholder reports no name, which leaves the
105    /// announcement to the area's surroundings.
106    fn semantics(&self) -> Option<Semantics> {
107        let label = (!self.placeholder.borrow().text().is_empty())
108            .then(|| self.placeholder.borrow().text().into());
109        Some(Semantics {
110            role: Role::MultilineTextInput,
111            label,
112            value: Some(self.chrome.editor.text()),
113        })
114    }
115
116    /// Appends the selection, the underline under a composing preedit, the
117    /// text or the placeholder, in that order, for the paragraphs the
118    /// content box shows.
119    ///
120    /// Painting also records the content box, which the pointer handlers
121    /// and the next edit read, and places the caret against it. An edit has placed the caret already,
122    /// so this only moves it when the box itself changed, which lands one
123    /// frame later like every size-driven change.
124    ///
125    /// # Panics
126    ///
127    /// Panics under the conditions of [`TextSystem::with`](crate::TextSystem::with),
128    /// or when the glyph run cache is already borrowed on this thread,
129    /// which the paint pass cannot do.
130    fn paint(&self, cx: &PaintCx<'_>, out: &mut Vec<PaintOp>) {
131        let color = self.text_color(cx);
132        let inset = Point {
133            x: cx.content.x0 - cx.rect.x0,
134            y: cx.content.y0 - cx.rect.y0,
135        };
136        self.chrome.painted.set(Some((cx.content, color, inset)));
137        // The body measured the text at this width already; a layout that
138        // gave the body another width than the content box still paints
139        // the text wrapped to the box it shows in.
140        self.chrome
141            .editor
142            .set_wrap_width(Some(cx.content.width().max(0.0)));
143        let (scroll_x, scroll_y) = self.chrome.place(cx.content, color);
144        self.chrome
145            .line_height
146            .set(self.style.size * self.style.line_height);
147        let text_origin = Point {
148            x: cx.content.x0 - scroll_x,
149            y: cx.content.y0 - scroll_y,
150        };
151
152        let selection_color = Color {
153            a: SELECTION_ALPHA,
154            ..cx.theme.color(ColorToken::ACCENT)
155        };
156        paint_selection(
157            &self.chrome,
158            text_origin,
159            (scroll_y, scroll_y + cx.content.height()),
160            selection_color,
161            out,
162        );
163
164        // The composing text is underlined so the user can tell it apart
165        // from text that is in the area for good.
166        for area in self.chrome.editor.compose_geometry() {
167            out.push(PaintOp::FillRect {
168                rect: Rect::new(
169                    text_origin.x + area.x0,
170                    text_origin.y + area.y0,
171                    text_origin.x + area.x1,
172                    text_origin.y + area.y1,
173                ),
174                radius: Corners::ZERO,
175                color,
176            });
177        }
178
179        if self.chrome.editor.is_empty()
180            && !self.chrome.editor.is_composing()
181            && !self.placeholder.borrow().text().is_empty()
182        {
183            let muted = cx.theme.color(ColorToken::TEXT_MUTED);
184            self.placeholder
185                .borrow_mut()
186                .paint(&self.style, text_origin, muted, out);
187        } else {
188            let top = scroll_y;
189            let bottom = scroll_y + cx.content.height();
190            paint_paragraphs(
191                &mut self.runs.borrow_mut(),
192                &self.chrome.editor,
193                text_origin,
194                color,
195                (top, bottom),
196                out,
197            );
198        }
199    }
200
201    /// Reports the area the input method anchors to while the area holds
202    /// the focus.
203    ///
204    /// The area is the text the user is editing, which is the preedit while
205    /// one is being composed and the selection otherwise, in paint
206    /// coordinates.
207    fn ime_area(&self) -> Option<Rect> {
208        let (content, _, _) = self.chrome.painted.get()?;
209        let area = self.chrome.editor.ime_area();
210        let scroll_x = self.chrome.scroll_x.get();
211        let scroll_y = self.chrome.scroll_y.get();
212        Some(Rect::new(
213            content.x0 + area.x0 - scroll_x,
214            content.y0 + area.y0 - scroll_y,
215            content.x0 + area.x1 - scroll_x,
216            content.y0 + area.y1 - scroll_y,
217        ))
218    }
219}
220
221/// Creates an area that edits `value`.
222///
223/// The area writes every edit to `value`, and a write to `value` from
224/// outside replaces the text and moves the caret to the end, so the two
225/// always agree:
226///
227/// ```
228/// use smudge_core::reactive::{RwSignal, prelude::*};
229/// use smudge_text::{TextAreaExt, text_area};
230///
231/// let draft = RwSignal::new(String::from("Dear reader,\n"));
232/// let field = text_area(draft.clone()).min_lines(4);
233///
234/// // An outside write replaces the text.
235/// draft.set(String::from("Dear editor,\n"));
236/// ```
237///
238/// Enter inserts a line break and is handled, so it does not reach a
239/// parent's key handler. With Control, Alt or the Meta key held, Enter
240/// bubbles instead and inserts nothing, so a parent can take it as a
241/// shortcut, such as one that submits the text.
242///
243/// Without a height from the style builders, the area is as tall as its
244/// text wrapped at the area's width, and grows and shrinks with it in the
245/// frame an edit lands, never below its one-line or
246/// [`TextAreaExt::min_lines`] floor. The layout engine measures the text at
247/// the width it gives the area, in the same pass, so the area shows the
248/// right height from its first frame and right after a width change. A
249/// height set with a style builder replaces that: the area keeps the
250/// height, and the text clips and scrolls inside it.
251///
252/// The wheel scrolls the text while the pointer is over the area, and the
253/// area takes a wheel event that moves its text, so a scroll view around the
254/// area does not scroll with it. A wheel that cannot move the text, because
255/// it fits or sits at its limit in the wheel's direction, passes on to the
256/// area's ancestors, so a page around the area keeps scrolling.
257pub fn text_area(value: RwSignal<String>) -> Element<TextArea> {
258    let chrome = Rc::new(InputChrome::new(value.clone(), true));
259    let measured = Rc::new(Cell::new(0.0));
260    let relayout = RwSignal::new(0u64);
261    let relayout_source = relayout.clone();
262
263    let key_chrome = Rc::clone(&chrome);
264    let press_chrome = Rc::clone(&chrome);
265    let move_chrome = Rc::clone(&chrome);
266    let up_chrome = Rc::clone(&chrome);
267    let wheel_chrome = Rc::clone(&chrome);
268    let focus_chrome = Rc::clone(&chrome);
269    let commit_chrome = Rc::clone(&chrome);
270    let preedit_chrome = Rc::clone(&chrome);
271    let edits_chrome = Rc::clone(&chrome);
272    let caret_chrome = Rc::clone(&chrome);
273    let value_chrome = Rc::clone(&chrome);
274
275    let body = Element::new(Body {
276        chrome: Rc::clone(&chrome),
277        measured: Rc::clone(&measured),
278    })
279    .width(Length::pct(100.0))
280    .bind(
281        RelayoutKey,
282        move || relayout_source.get(),
283        |_, _| Dirty::Layout,
284    );
285
286    Element::new(TextArea::new(Rc::clone(&chrome), measured, relayout))
287        .clip(true)
288        .cursor(Cursor::Text)
289        .column()
290        .min_height(|| line_floor(Value::Static(1)))
291        .bind(AmbientStyleKey, current_text_style, apply_ambient_style)
292        .bind(
293            ValueKey,
294            move || outside_write(&value_chrome, &value),
295            apply_value,
296        )
297        .bind(EditsKey, move || edits_chrome.edits.get(), apply_edits)
298        .on_key(move |event| handle_key(&key_chrome, event))
299        .on_pointer_down(move |position| handle_press(&press_chrome, position))
300        .on_pointer_move(move |position| handle_move(&move_chrome, position))
301        .on_pointer_up(move |position| handle_release(&up_chrome, position))
302        .on_wheel(move |delta| handle_wheel(&wheel_chrome, delta))
303        .on_focus(move |focused| handle_focus(&focus_chrome, focused))
304        .on_ime_commit(move |text| handle_commit(&commit_chrome, text))
305        .on_ime_preedit(move |text, cursor| handle_preedit(&preedit_chrome, text, cursor))
306        .child(body)
307        .child(caret_node(&caret_chrome))
308}
309
310/// The childless node inside an area that the layout engine measures.
311///
312/// The area itself carries its body and its caret, and the layout engine
313/// measures childless nodes only, so the body is what tells the engine how
314/// tall the text wraps at the width the area gives it, in the pass that
315/// gives that width. The area paints the text over its own content box;
316/// the body paints nothing.
317struct Body {
318    /// The state the area shares with its handlers and caret.
319    chrome: Rc<InputChrome>,
320    /// The height the last measure answered, which the area compares an
321    /// edit's height against.
322    measured: Rc<Cell<f32>>,
323}
324
325impl Widget for Body {
326    /// Wraps the editor at the offered width and returns the wrapped text's
327    /// width and height.
328    ///
329    /// A definite width re-breaks the text's lines without shaping them
330    /// again, and the same width as before changes nothing. A request for
331    /// the intrinsic width answers zero, since an area fills the width it is
332    /// offered, with the height the text has at the width it last wrapped
333    /// at.
334    ///
335    /// # Panics
336    ///
337    /// Panics under the conditions of [`TextSystem::with`](crate::TextSystem::with).
338    fn measure(&mut self, input: MeasureInput) -> Option<Size> {
339        let offered = input.known_width.or(match input.available_width {
340            AvailableSpace::Definite(width) => Some(width),
341            AvailableSpace::MinContent | AvailableSpace::MaxContent => None,
342        });
343        let editor = &self.chrome.editor;
344        if let Some(width) = offered {
345            editor.set_wrap_width(Some(width.max(0.0)));
346        }
347        let height = editor.layout_size().height;
348        self.measured.set(height);
349        Some(Size {
350            width: offered.unwrap_or(0.0).max(0.0),
351            height,
352        })
353    }
354
355    fn paint(&self, _cx: &PaintCx<'_>, _out: &mut Vec<PaintOp>) {}
356}
357
358/// Builder methods on a [`text_area`] element.
359///
360/// Import the trait to use them:
361///
362/// ```
363/// use smudge_core::reactive::RwSignal;
364/// use smudge_text::TextAreaExt;
365/// use smudge_text::text_area;
366///
367/// let field = text_area(RwSignal::new(String::new()))
368///     .placeholder("Notes")
369///     .min_lines(3);
370/// ```
371pub trait TextAreaExt: Sized {
372    /// Sets the text shown while the area is empty.
373    ///
374    /// The placeholder paints in the theme's muted text color, so a theme
375    /// switch repaints it.
376    fn placeholder(self, text: impl Into<Value<String>>) -> Self;
377
378    /// Sets the height floor of the area, in lines, which is one line until
379    /// this sets it.
380    ///
381    /// The area measures at least `lines` lines of its style tall, and
382    /// taller when its text or a style builder's height needs more. The
383    /// text scrolls inside whatever height the area ends up with.
384    fn min_lines(self, lines: impl Into<Value<u32>>) -> Self;
385}
386
387/// The key of the area's ambient text style binding.
388struct AmbientStyleKey;
389
390/// The key of the area's value binding.
391struct ValueKey;
392
393/// The key of the area's edit counter binding.
394struct EditsKey;
395
396/// The key of the area's placeholder binding.
397struct PlaceholderKey;
398
399/// The key of the body's relayout binding.
400struct RelayoutKey;
401
402impl TextAreaExt for Element<TextArea> {
403    fn placeholder(self, text: impl Into<Value<String>>) -> Self {
404        self.bind(PlaceholderKey, text, set_placeholder)
405    }
406
407    fn min_lines(self, lines: impl Into<Value<u32>>) -> Self {
408        let lines = lines.into();
409        self.min_height(move || line_floor(lines.clone()))
410    }
411}
412
413/// Returns the height floor `lines` measures, from the ambient text style.
414///
415/// The floor is read when the node mounts, the same moment the area reads
416/// the ambient style for its own text.
417fn line_floor(lines: Value<u32>) -> Length {
418    let lines = match lines {
419        Value::Static(lines) => lines,
420        Value::Dynamic(get) => get(),
421    };
422    let style = current_text_style();
423    Length::px(lines as f32 * style.size * style.line_height)
424}
425
426/// Applies the ambient text style to the editor and the placeholder.
427fn apply_ambient_style(area: &mut TextArea, ambient: TextStyle) -> Dirty {
428    if area.style == ambient {
429        return Dirty::Clean;
430    }
431    area.style = ambient;
432    area.chrome.editor.set_style(&area.style);
433    area.placeholder.borrow_mut().invalidate_style();
434    *area.runs.borrow_mut() = None;
435    area.relayout_if_resized();
436    Dirty::Layout
437}
438
439/// Applies an outside write to the bound signal, which arrives as `None`
440/// when the write was the editor's own.
441fn apply_value(area: &mut TextArea, value: Option<String>) -> Dirty {
442    match value {
443        Some(value) => {
444            let dirty = apply_outside_write(&area.chrome, &value);
445            area.relayout_if_resized();
446            dirty
447        }
448        None => Dirty::Clean,
449    }
450}
451
452/// Applies an editor state change the handlers made, and lays the body out
453/// again when the change resized the text, so an edit that adds or removes
454/// a line resizes the area in the frame that shows it.
455fn apply_edits(area: &mut TextArea, _edits: u64) -> Dirty {
456    let dirty = apply_edit_count(&area.chrome);
457    area.relayout_if_resized();
458    dirty
459}
460
461/// Writes the placeholder into the area.
462fn set_placeholder(area: &mut TextArea, text: String) -> Dirty {
463    let mut placeholder = area.placeholder.borrow_mut();
464    if placeholder.text() == text {
465        return Dirty::Clean;
466    }
467    placeholder.set_text(text);
468    Dirty::Paint
469}
470
471/// The number of lines one page of the area holds, from the content box and
472/// the line height the last paint measured.
473///
474/// A page is at least one line, so a page key moves even inside a squashed
475/// area or before the first paint.
476fn visible_lines(chrome: &InputChrome) -> isize {
477    let content = chrome
478        .painted
479        .get()
480        .map(|(content, _, _)| content.height())
481        .unwrap_or(0.0);
482    let line = chrome.line_height.get().max(1.0);
483    ((content / line) as isize).max(1)
484}
485
486/// Edits the keyboard makes while the area holds the focus.
487///
488/// Enter inserts a line break and is handled, so it does not bubble, and
489/// with Control, Alt or Meta held it bubbles without editing. A paste keeps
490/// the line breaks of its text, because the area holds several lines. Up
491/// and Down move by visual line with the horizontal position kept where the
492/// move can, Page Up and Page Down move a page of lines, Home and End move
493/// to the ends of the visual line, and with Control or Meta to the ends of
494/// the text, and the rest works as the single-line input's keys do.
495fn handle_key(chrome: &InputChrome, event: &KeyEvent) -> Handled {
496    if !event.pressed {
497        return Handled::No;
498    }
499    let modifiers = event.modifiers;
500    let by_word = modifiers.control || modifiers.alt;
501    match &event.key {
502        Key::Character(text) if is_shortcut(modifiers) && text.eq_ignore_ascii_case("a") => {
503            chrome.editor.select_all();
504            chrome.touched();
505            Handled::Yes
506        }
507        Key::Character(text) if is_shortcut(modifiers) && text.eq_ignore_ascii_case("c") => {
508            copy_selection(chrome);
509            Handled::Yes
510        }
511        Key::Character(text) if is_shortcut(modifiers) && text.eq_ignore_ascii_case("x") => {
512            if cut_selection(chrome) {
513                chrome.touched();
514            }
515            Handled::Yes
516        }
517        Key::Character(text) if is_shortcut(modifiers) && text.eq_ignore_ascii_case("v") => {
518            paste_from_clipboard(chrome);
519            Handled::Yes
520        }
521        Key::Character(text) if is_shortcut(modifiers) && text.eq_ignore_ascii_case("z") => {
522            if modifiers.shift {
523                chrome.editor.redo();
524            } else {
525                chrome.editor.undo();
526            }
527            chrome.touched();
528            Handled::Yes
529        }
530        Key::Character(text)
531            if cfg!(not(target_os = "macos"))
532                && is_shortcut(modifiers)
533                && text.eq_ignore_ascii_case("y") =>
534        {
535            chrome.editor.redo();
536            chrome.touched();
537            Handled::Yes
538        }
539        Key::Enter if modifiers.control || modifiers.alt || modifiers.meta => Handled::No,
540        Key::Enter => {
541            chrome.editor.insert("\n");
542            chrome.touched();
543            Handled::Yes
544        }
545        Key::Backspace => {
546            if by_word {
547                chrome.editor.backspace_word();
548            } else {
549                chrome.editor.backspace();
550            }
551            chrome.touched();
552            Handled::Yes
553        }
554        Key::Delete => {
555            if by_word {
556                chrome.editor.delete_word();
557            } else {
558                chrome.editor.delete();
559            }
560            chrome.touched();
561            Handled::Yes
562        }
563        Key::ArrowLeft => {
564            apply_move(chrome, modifiers.shift, by_word, Direction::Left);
565            Handled::Yes
566        }
567        Key::ArrowRight => {
568            apply_move(chrome, modifiers.shift, by_word, Direction::Right);
569            Handled::Yes
570        }
571        Key::ArrowUp => {
572            if modifiers.shift {
573                chrome.editor.extend_up();
574            } else {
575                chrome.editor.move_up();
576            }
577            chrome.touched();
578            Handled::Yes
579        }
580        Key::ArrowDown => {
581            if modifiers.shift {
582                chrome.editor.extend_down();
583            } else {
584                chrome.editor.move_down();
585            }
586            chrome.touched();
587            Handled::Yes
588        }
589        Key::PageUp => {
590            let lines = visible_lines(chrome);
591            if modifiers.shift {
592                chrome.editor.extend_lines(-lines);
593            } else {
594                chrome.editor.move_lines(-lines);
595            }
596            chrome.touched();
597            Handled::Yes
598        }
599        Key::PageDown => {
600            let lines = visible_lines(chrome);
601            if modifiers.shift {
602                chrome.editor.extend_lines(lines);
603            } else {
604                chrome.editor.move_lines(lines);
605            }
606            chrome.touched();
607            Handled::Yes
608        }
609        Key::Home => {
610            let editor = &chrome.editor;
611            match (modifiers.shift, modifiers.control || modifiers.meta) {
612                (false, false) => editor.move_to_line_start(),
613                (true, false) => editor.extend_to_line_start(),
614                (false, true) => editor.move_to_start(),
615                (true, true) => editor.extend_to_start(),
616            }
617            chrome.touched();
618            Handled::Yes
619        }
620        Key::End => {
621            let editor = &chrome.editor;
622            match (modifiers.shift, modifiers.control || modifiers.meta) {
623                (false, false) => editor.move_to_line_end(),
624                (true, false) => editor.extend_to_line_end(),
625                (false, true) => editor.move_to_end(),
626                (true, true) => editor.extend_to_end(),
627            }
628            chrome.touched();
629            Handled::Yes
630        }
631        Key::Character(text) => {
632            if !types_text(modifiers) {
633                return Handled::No;
634            }
635            chrome.editor.insert(text);
636            chrome.touched();
637            Handled::Yes
638        }
639        Key::Space => {
640            if !types_text(modifiers) {
641                return Handled::No;
642            }
643            chrome.editor.insert(" ");
644            chrome.touched();
645            Handled::Yes
646        }
647        _ => Handled::No,
648    }
649}
650
651/// Inserts the text on the clipboard of the running app.
652///
653/// An app without a clipboard pastes nothing. The line breaks of the pasted
654/// text are kept, because the area holds several lines.
655fn paste_from_clipboard(chrome: &InputChrome) {
656    if let Some(text) = clipboard::with(|slot| slot.get()).flatten() {
657        chrome.editor.paste(&text);
658        chrome.touched();
659    }
660}