Skip to main content

smudge_text/
text_input.rs

1//! The text input: one line of editable text bound to a signal.
2//!
3//! A [`TextInput`] drives an [`Editor`](crate::Editor), paints the selection, the text and
4//! a placeholder, and keeps a caret node that blinks on its own. The caret
5//! is a separate node so that a blink, which changes two pixels' worth of
6//! caret, damages the caret's rectangle alone instead of the whole input.
7//!
8//! Create one with [`text_input`] and shape it with the [`TextInputExt`]
9//! builder methods. Editing is by grapheme, and with control or alt by word;
10//! a mouse press places the caret, a drag selects and a double click selects
11//! a word. Every edit writes the bound signal, and an outside write to the
12//! signal replaces the text and moves the caret to its end. The input holds
13//! one line: a line break in an outside write, a paste or an input method's
14//! commit becomes a space, and the signal is written with the line the
15//! input kept. Undo and redo
16//! walk back and forth through the edits, a typed word at a time. While an
17//! input method composes, the preedit is drawn underlined and reaches the
18//! value only when it is committed, and the platform's command modifier
19//! with C, X and V copies, cuts and pastes.
20//!
21//! For text that spans several lines, use [`TextArea`](crate::TextArea)
22//! instead.
23
24use std::cell::RefCell;
25use std::rc::Rc;
26
27use smudge_core::element::{Element, Value};
28use smudge_core::geometry::{Color, Corners, Point, Rect, Size};
29use smudge_core::input::{Handled, Key, KeyEvent};
30use smudge_core::paint::PaintOp;
31use smudge_core::reactive::{RwSignal, prelude::*};
32use smudge_core::semantics::{Role, Semantics};
33use smudge_core::style::Length;
34use smudge_core::theme::ColorToken;
35use smudge_core::tree::Dirty;
36use smudge_core::widget::{AvailableSpace, MeasureInput, PaintCx, Widget};
37use smudge_core::window::Cursor;
38
39use crate::chrome::{
40    Direction, InputChrome, ParagraphCache, Placeholder, SELECTION_ALPHA, apply_edit_count,
41    apply_move, apply_outside_write, caret_node, copy_selection, cut_selection, handle_commit,
42    handle_focus, handle_move, handle_preedit, handle_press, handle_release, is_shortcut,
43    outside_write, paint_paragraphs, paint_selection, types_text,
44};
45use crate::style::{TextStyle, current_text_style};
46
47/// One line of editable text.
48///
49/// Build one with [`text_input`]. The input keeps the editor and the glyph
50/// runs of its current text, and rebuilds the runs only when the editor laid
51/// its text out again, the node moved or the resolved text color changed.
52pub struct TextInput {
53    /// The state shared with the handlers and the caret node.
54    chrome: Rc<InputChrome>,
55    /// The text shown while the editor is empty.
56    placeholder: RefCell<Placeholder>,
57    /// The text style the editor lays out and the input paints with,
58    /// resolved from the ambient style at mount.
59    style: TextStyle,
60    /// The glyph runs of the editor's text, with everything they were built
61    /// from.
62    runs: RefCell<Option<ParagraphCache>>,
63}
64
65impl TextInput {
66    /// Creates an input with an empty placeholder and the default style
67    /// until the mount reads the ambient one.
68    fn new(chrome: Rc<InputChrome>) -> TextInput {
69        TextInput {
70            chrome,
71            placeholder: RefCell::new(Placeholder::new()),
72            style: TextStyle::default(),
73            runs: RefCell::new(None),
74        }
75    }
76
77    /// Returns the color the text and the caret draw with: the node's text
78    /// color when it sets one, otherwise the input's style color.
79    fn text_color(&self, cx: &PaintCx<'_>) -> Color {
80        match &cx.style.paint.text_color {
81            Some(color) => color.resolve(cx.theme),
82            None => self.style.color.resolve(cx.theme),
83        }
84    }
85}
86
87impl Widget for TextInput {
88    /// Reports the field's placeholder as the accessible name and its text
89    /// as the accessible value.
90    ///
91    /// An input with no placeholder reports no name, which leaves the
92    /// announcement to the field's surroundings.
93    fn semantics(&self) -> Option<Semantics> {
94        let label = (!self.placeholder.borrow().text().is_empty())
95            .then(|| self.placeholder.borrow().text().into());
96        Some(Semantics {
97            role: Role::TextInput,
98            label,
99            value: Some(self.chrome.editor.text()),
100        })
101    }
102
103    /// Returns the size of one line of text.
104    ///
105    /// Offered space is filled on the x axis, which is what an input field
106    /// conventionally does, and the intrinsic width is zero, so a field that
107    /// is not stretched by its parent needs a width or a grow factor. The
108    /// height is one line of the input's style. Neither depends on the text,
109    /// so an edit repaints the input without laying it out again.
110    ///
111    /// That height also rides on the min-height style the constructor sets,
112    /// because the layout engine measures childless nodes only and the input
113    /// always carries its caret child.
114    fn measure(&mut self, input: MeasureInput) -> Option<Size> {
115        let width = match input.available_width {
116            AvailableSpace::Definite(width) => width.max(0.0),
117            AvailableSpace::MinContent | AvailableSpace::MaxContent => 0.0,
118        };
119        Some(Size {
120            width,
121            height: self.style.size * self.style.line_height,
122        })
123    }
124
125    /// Appends the selection, the underline under a composing preedit, the
126    /// text or the placeholder, in that order.
127    ///
128    /// The caret is a child node of the input and paints itself after this,
129    /// so the paint order is selection, then preedit underline, then glyphs,
130    /// then caret. Painting also records the content box, which the pointer
131    /// handlers and the next edit read, and places the caret against it. An
132    /// edit has placed the caret already, so this only moves it when the box
133    /// itself changed, which lands one frame later like every size-driven
134    /// change.
135    ///
136    /// # Panics
137    ///
138    /// Panics under the conditions of [`TextSystem::with`](crate::TextSystem::with),
139    /// or when the glyph run cache is already borrowed on this thread,
140    /// which the paint pass cannot do.
141    fn paint(&self, cx: &PaintCx<'_>, out: &mut Vec<PaintOp>) {
142        let color = self.text_color(cx);
143        let inset = Point {
144            x: cx.content.x0 - cx.rect.x0,
145            y: cx.content.y0 - cx.rect.y0,
146        };
147        self.chrome.painted.set(Some((cx.content, color, inset)));
148        let (scroll_x, scroll_y) = self.chrome.place(cx.content, color);
149        self.chrome
150            .line_height
151            .set(self.style.size * self.style.line_height);
152        let text_origin = Point {
153            x: cx.content.x0 - scroll_x,
154            y: cx.content.y0 - scroll_y,
155        };
156
157        let selection_color = Color {
158            a: SELECTION_ALPHA,
159            ..cx.theme.color(ColorToken::ACCENT)
160        };
161        paint_selection(
162            &self.chrome,
163            text_origin,
164            (f32::NEG_INFINITY, f32::INFINITY),
165            selection_color,
166            out,
167        );
168
169        // The composing text is underlined so the user can tell it apart
170        // from text that is in the field for good.
171        for area in self.chrome.editor.compose_geometry() {
172            out.push(PaintOp::FillRect {
173                rect: Rect::new(
174                    text_origin.x + area.x0,
175                    text_origin.y + area.y0,
176                    text_origin.x + area.x1,
177                    text_origin.y + area.y1,
178                ),
179                radius: Corners::ZERO,
180                color,
181            });
182        }
183
184        if self.chrome.editor.is_empty()
185            && !self.chrome.editor.is_composing()
186            && !self.placeholder.borrow().text().is_empty()
187        {
188            let muted = cx.theme.color(ColorToken::TEXT_MUTED);
189            self.placeholder
190                .borrow_mut()
191                .paint(&self.style, text_origin, muted, out);
192        } else {
193            paint_paragraphs(
194                &mut self.runs.borrow_mut(),
195                &self.chrome.editor,
196                text_origin,
197                color,
198                (f32::NEG_INFINITY, f32::INFINITY),
199                out,
200            );
201        }
202    }
203
204    /// Reports the area the input method anchors to while the input holds
205    /// the focus.
206    ///
207    /// The area is the text the user is editing, which is the preedit while
208    /// one is being composed and the selection otherwise, in paint
209    /// coordinates.
210    fn ime_area(&self) -> Option<Rect> {
211        let (content, _, _) = self.chrome.painted.get()?;
212        let area = self.chrome.editor.ime_area();
213        let scroll_x = self.chrome.scroll_x.get();
214        let scroll_y = self.chrome.scroll_y.get();
215        Some(Rect::new(
216            content.x0 + area.x0 - scroll_x,
217            content.y0 + area.y0 - scroll_y,
218            content.x0 + area.x1 - scroll_x,
219            content.y0 + area.y1 - scroll_y,
220        ))
221    }
222}
223
224/// Creates an input that edits `value`.
225///
226/// The input writes every edit to `value`, and a write to `value` from
227/// outside replaces the text and moves the caret to the end, so the two
228/// always agree:
229///
230/// ```
231/// use smudge_core::reactive::{RwSignal, prelude::*};
232/// use smudge_text::text_input;
233///
234/// let name = RwSignal::new(String::from("Ada"));
235/// let field = text_input(name.clone());
236///
237/// // An outside write replaces the text.
238/// name.set(String::from("Grace"));
239/// ```
240///
241/// The input is one line tall by its content and fills the width it is
242/// offered, so give it a height through the style builders when the layout
243/// needs one.
244pub fn text_input(value: RwSignal<String>) -> Element<TextInput> {
245    let chrome = Rc::new(InputChrome::new(value.clone(), false));
246
247    let key_chrome = Rc::clone(&chrome);
248    let press_chrome = Rc::clone(&chrome);
249    let move_chrome = Rc::clone(&chrome);
250    let up_chrome = Rc::clone(&chrome);
251    let focus_chrome = Rc::clone(&chrome);
252    let commit_chrome = Rc::clone(&chrome);
253    let preedit_chrome = Rc::clone(&chrome);
254    let edits_chrome = Rc::clone(&chrome);
255    let caret_chrome = Rc::clone(&chrome);
256    let value_chrome = Rc::clone(&chrome);
257
258    Element::new(TextInput::new(Rc::clone(&chrome)))
259        .clip(true)
260        .cursor(Cursor::Text)
261        .min_height(one_line)
262        .bind(AmbientStyleKey, current_text_style, apply_ambient_style)
263        .bind(
264            ValueKey,
265            move || outside_write(&value_chrome, &value),
266            apply_value,
267        )
268        .bind(EditsKey, move || edits_chrome.edits.get(), apply_edits)
269        .on_key(move |event| handle_key(&key_chrome, event))
270        .on_pointer_down(move |position| handle_press(&press_chrome, position))
271        .on_pointer_move(move |position| handle_move(&move_chrome, position))
272        .on_pointer_up(move |position| handle_release(&up_chrome, position))
273        .on_focus(move |focused| handle_focus(&focus_chrome, focused))
274        .on_ime_commit(move |text| handle_commit(&commit_chrome, text))
275        .on_ime_preedit(move |text, cursor| handle_preedit(&preedit_chrome, text, cursor))
276        .child(caret_node(&caret_chrome))
277}
278
279/// Builder methods on a [`text_input`] element.
280///
281/// Import the trait to use them:
282///
283/// ```
284/// use smudge_text::TextInputExt;
285/// use smudge_text::text_input;
286/// use smudge_core::reactive::RwSignal;
287///
288/// let field = text_input(RwSignal::new(String::new())).placeholder("Name");
289/// ```
290pub trait TextInputExt: Sized {
291    /// Sets the text shown while the input is empty.
292    ///
293    /// The placeholder paints in the theme's muted text color, so a theme
294    /// switch repaints it.
295    fn placeholder(self, text: impl Into<Value<String>>) -> Self;
296}
297
298/// The key of the input's ambient text style binding.
299struct AmbientStyleKey;
300
301/// The key of the input's value binding.
302struct ValueKey;
303
304/// The key of the input's edit counter binding.
305struct EditsKey;
306
307/// The key of the input's placeholder binding.
308struct PlaceholderKey;
309
310impl TextInputExt for Element<TextInput> {
311    fn placeholder(self, text: impl Into<Value<String>>) -> Self {
312        self.bind(PlaceholderKey, text, set_placeholder)
313    }
314}
315
316/// Returns the height of one line of the ambient text style.
317///
318/// The floor is read when the node mounts, the same moment the input reads
319/// the ambient style for its own text.
320fn one_line() -> Length {
321    let style = current_text_style();
322    Length::px(style.size * style.line_height)
323}
324
325/// Applies the ambient text style to the editor and the placeholder.
326fn apply_ambient_style(input: &mut TextInput, ambient: TextStyle) -> Dirty {
327    if input.style == ambient {
328        return Dirty::Clean;
329    }
330    input.style = ambient;
331    input.chrome.editor.set_style(&input.style);
332    input.placeholder.borrow_mut().invalidate_style();
333    *input.runs.borrow_mut() = None;
334    Dirty::Layout
335}
336
337/// Applies an outside write to the bound signal, which arrives as `None`
338/// when the write was the editor's own.
339fn apply_value(input: &mut TextInput, value: Option<String>) -> Dirty {
340    match value {
341        Some(value) => apply_outside_write(&input.chrome, &value),
342        None => Dirty::Clean,
343    }
344}
345
346/// Applies an editor state change the handlers made.
347fn apply_edits(input: &mut TextInput, _edits: u64) -> Dirty {
348    apply_edit_count(&input.chrome)
349}
350
351/// Writes the placeholder into the input.
352fn set_placeholder(input: &mut TextInput, text: String) -> Dirty {
353    let mut placeholder = input.placeholder.borrow_mut();
354    if placeholder.text() == text {
355        return Dirty::Clean;
356    }
357    placeholder.set_text(text);
358    Dirty::Paint
359}
360
361/// Edits the keyboard makes while the input holds the focus.
362///
363/// Printable text inserts; Backspace and Delete remove by grapheme, or by
364/// word with control or alt held; Left and Right move by grapheme, or by
365/// word; Home and End jump to the ends; Shift extends instead of moving; and
366/// the platform's command modifier with A selects everything, with Z undoes,
367/// with Shift and Z redoes, as Control and Y also does off macOS, and with
368/// C, X and V copies, cuts and pastes. Keys the input does not take, among
369/// them Enter and Tab, bubble to the ancestors.
370fn handle_key(chrome: &InputChrome, event: &KeyEvent) -> Handled {
371    if !event.pressed {
372        return Handled::No;
373    }
374    let modifiers = event.modifiers;
375    let by_word = modifiers.control || modifiers.alt;
376    match &event.key {
377        Key::Character(text) if is_shortcut(modifiers) && text.eq_ignore_ascii_case("a") => {
378            chrome.editor.select_all();
379            chrome.touched();
380            Handled::Yes
381        }
382        Key::Character(text) if is_shortcut(modifiers) && text.eq_ignore_ascii_case("c") => {
383            copy_selection(chrome);
384            Handled::Yes
385        }
386        Key::Character(text) if is_shortcut(modifiers) && text.eq_ignore_ascii_case("x") => {
387            if cut_selection(chrome) {
388                chrome.touched();
389            }
390            Handled::Yes
391        }
392        Key::Character(text) if is_shortcut(modifiers) && text.eq_ignore_ascii_case("v") => {
393            paste_from_clipboard(chrome);
394            Handled::Yes
395        }
396        Key::Character(text) if is_shortcut(modifiers) && text.eq_ignore_ascii_case("z") => {
397            if modifiers.shift {
398                chrome.editor.redo();
399            } else {
400                chrome.editor.undo();
401            }
402            chrome.touched();
403            Handled::Yes
404        }
405        Key::Character(text)
406            if cfg!(not(target_os = "macos"))
407                && is_shortcut(modifiers)
408                && text.eq_ignore_ascii_case("y") =>
409        {
410            chrome.editor.redo();
411            chrome.touched();
412            Handled::Yes
413        }
414        Key::Backspace => {
415            if by_word {
416                chrome.editor.backspace_word();
417            } else {
418                chrome.editor.backspace();
419            }
420            chrome.touched();
421            Handled::Yes
422        }
423        Key::Delete => {
424            if by_word {
425                chrome.editor.delete_word();
426            } else {
427                chrome.editor.delete();
428            }
429            chrome.touched();
430            Handled::Yes
431        }
432        Key::ArrowLeft => {
433            apply_move(chrome, modifiers.shift, by_word, Direction::Left);
434            Handled::Yes
435        }
436        Key::ArrowRight => {
437            apply_move(chrome, modifiers.shift, by_word, Direction::Right);
438            Handled::Yes
439        }
440        Key::Home => {
441            if modifiers.shift {
442                chrome.editor.extend_to_start();
443            } else {
444                chrome.editor.move_to_start();
445            }
446            chrome.touched();
447            Handled::Yes
448        }
449        Key::End => {
450            if modifiers.shift {
451                chrome.editor.extend_to_end();
452            } else {
453                chrome.editor.move_to_end();
454            }
455            chrome.touched();
456            Handled::Yes
457        }
458        Key::Character(text) => {
459            if !types_text(modifiers) {
460                return Handled::No;
461            }
462            chrome.editor.insert(text);
463            chrome.touched();
464            Handled::Yes
465        }
466        Key::Space => {
467            if !types_text(modifiers) {
468                return Handled::No;
469            }
470            chrome.editor.insert(" ");
471            chrome.touched();
472            Handled::Yes
473        }
474        _ => Handled::No,
475    }
476}
477
478/// Inserts the text on the clipboard of the running app.
479///
480/// An app without a clipboard pastes nothing. Every line break in the
481/// pasted text becomes one space, because the input's editor holds one
482/// line: a paste from a multi-line source pastes its text in one line. A carriage return
483/// and line feed together count as one break, so a Windows paste gains no
484/// extra space where its text had one break.
485fn paste_from_clipboard(chrome: &InputChrome) {
486    if let Some(text) = smudge_core::clipboard::with(|slot| slot.get()).flatten() {
487        chrome.editor.paste(&text);
488        chrome.touched();
489    }
490}