Skip to main content

densa_widgets/
text_input.rs

1use densa_core::{Color, Point, Rect, Size};
2use densa_draw::Canvas;
3use densa_paragraph::{
4    Motion, ParagraphError, ParagraphStyle, TextEditor, TextSelection, TextSystem,
5};
6use densa_ui::{
7    AccessAction, AccessNode, AccessRequest, ActionKind, Constraints, Cursor, Event, EventCx,
8    Handled, Ime, Key, KeyEvent, LayoutCx, NamedKey, PaintCx, PointerButton, PointerKind, Role,
9    Widget,
10};
11
12use crate::paint::outline;
13use crate::text::Text;
14
15/// Space between the frame and the text, in logical pixels.
16const PADDING_X: f32 = 6.0;
17const PADDING_Y: f32 = 5.0;
18/// The width a text input asks for, in logical pixels.
19const WIDTH: f32 = 180.0;
20
21/// A single line of editable text. Reports [`ActionKind::Changed`] after every edit and
22/// [`ActionKind::Submitted`] on Enter.
23///
24/// Clicks place the caret (two select a word, three everything), dragging selects, the arrow
25/// keys and Home/End move (with Shift: select; with Ctrl, or Cmd on macOS: by word),
26/// Backspace and Delete remove characters or words, and input methods compose in place.
27///
28/// Ctrl (Cmd on macOS) with X, C and V cuts, copies and pastes through the
29/// [`Ui`](densa_ui::Ui)'s clipboard, as do Shift+Delete, Ctrl+Insert and Shift+Insert; pasted
30/// line breaks become spaces. Ctrl+Z undoes, Ctrl+Y or Ctrl+Shift+Z redoes (see
31/// [`TextEditor`] for what one step is). An application's Edit menu calls the same
32/// [`cut`](Self::cut), [`copy`](Self::copy), [`paste`](Self::paste), [`undo`](Self::undo) and
33/// [`redo`](Self::redo) from an [`Ui::update`](densa_ui::Ui::update) on the input.
34#[derive(Debug)]
35pub struct TextInput {
36    /// The text until the editor exists (it is created at the first layout).
37    initial: String,
38    placeholder: Text,
39    editor: Option<TextEditor>,
40    /// The size and color the editor's text was shaped with.
41    shaped: Option<(f32, Color)>,
42    /// How far the text is scrolled left to keep the caret in view.
43    scroll: f32,
44}
45
46impl TextInput {
47    /// An input holding `text`.
48    pub fn new(text: impl Into<String>) -> Self {
49        Self {
50            initial: text.into(),
51            placeholder: Text::new(""),
52            editor: None,
53            shaped: None,
54            scroll: 0.0,
55        }
56    }
57
58    /// The input showing `placeholder`, dimmed, while it is empty.
59    #[must_use]
60    pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
61        self.placeholder = Text::new(placeholder);
62        self
63    }
64
65    /// The text.
66    pub fn text(&self) -> &str {
67        self.editor
68            .as_ref()
69            .map_or(self.initial.as_str(), TextEditor::text)
70    }
71
72    /// Replaces the text, the caret at its end.
73    ///
74    /// # Errors
75    /// [`ParagraphError::UnreadableFont`] if a font for `text` cannot be read; nothing
76    /// changes.
77    pub fn set_text(
78        &mut self,
79        cx: &mut EventCx<'_>,
80        text: impl Into<String>,
81    ) -> Result<(), ParagraphError> {
82        let text = text.into();
83        match &mut self.editor {
84            Some(editor) => editor.set_text(cx.text_system(), &text)?,
85            None => self.initial = text,
86        }
87        self.follow_caret(cx);
88        Ok(())
89    }
90
91    /// Whether text is selected, to cut or copy.
92    pub fn has_selection(&self) -> bool {
93        self.editor
94            .as_ref()
95            .is_some_and(|e| !e.selection().is_caret())
96    }
97
98    /// Whether there is an edit to [`undo`](Self::undo).
99    pub fn can_undo(&self) -> bool {
100        self.editor.as_ref().is_some_and(TextEditor::can_undo)
101    }
102
103    /// Whether there is an edit to [`redo`](Self::redo).
104    pub fn can_redo(&self) -> bool {
105        self.editor.as_ref().is_some_and(TextEditor::can_redo)
106    }
107
108    /// Puts the selected text on the clipboard; `false` if none is selected, an input
109    /// method is composing, or the clipboard would not take it.
110    pub fn copy(&mut self, cx: &mut EventCx<'_>) -> bool {
111        let Some(editor) = self.editor.as_ref().filter(|e| e.composition().is_none()) else {
112            return false;
113        };
114        let selected = editor.selected_text();
115        !selected.is_empty() && cx.clipboard().set_text(selected).is_ok()
116    }
117
118    /// Copies the selected text and deletes it; nothing is deleted unless it was copied.
119    /// The edit methods return `false` when the text did not change, including when it could
120    /// not be laid out again (a font that cannot be read).
121    pub fn cut(&mut self, cx: &mut EventCx<'_>) -> bool {
122        self.copy(cx) && self.edit(cx, |e, s| e.delete(s, Motion::Previous).map(|()| true))
123    }
124
125    /// Replaces the selection with the clipboard's text, its line breaks made spaces; `false`
126    /// if the clipboard holds no text.
127    pub fn paste(&mut self, cx: &mut EventCx<'_>) -> bool {
128        let Ok(text) = cx.clipboard().text() else {
129            return false;
130        };
131        let line = one_line(&text);
132        !line.is_empty() && self.edit(cx, |e, s| e.paste(s, &line).map(|()| true))
133    }
134
135    /// Takes back the last edit; `false` if there is none.
136    pub fn undo(&mut self, cx: &mut EventCx<'_>) -> bool {
137        self.edit(cx, TextEditor::undo)
138    }
139
140    /// Makes the last undone edit again; `false` if there is none.
141    pub fn redo(&mut self, cx: &mut EventCx<'_>) -> bool {
142        self.edit(cx, TextEditor::redo)
143    }
144
145    /// Runs `edit` on the editor, reporting [`ActionKind::Changed`] if it changed the text.
146    fn edit(
147        &mut self,
148        cx: &mut EventCx<'_>,
149        edit: impl FnOnce(&mut TextEditor, &mut TextSystem) -> Result<bool, ParagraphError>,
150    ) -> bool {
151        let Some(editor) = &mut self.editor else {
152            return false;
153        };
154        let changed = edit(editor, cx.text_system()).unwrap_or(false);
155        if changed {
156            cx.emit(ActionKind::Changed);
157        }
158        self.follow_caret(cx);
159        changed
160    }
161
162    /// Runs the clipboard or undo command `key` asks for, if any.
163    fn command(&mut self, cx: &mut EventCx<'_>, key: &KeyEvent<'_>) -> Option<()> {
164        let m = key.modifiers;
165        let command = match key.key {
166            Key::Character(c) if m.command() && !m.alt() => {
167                let letters = [
168                    ("x", Command::Cut),
169                    ("c", Command::Copy),
170                    ("v", Command::Paste),
171                    ("z", Command::Undo),
172                    ("y", Command::Redo),
173                ];
174                match letters
175                    .into_iter()
176                    .find(|(l, _)| c.eq_ignore_ascii_case(l))?
177                {
178                    (_, Command::Undo) if m.shift() => Command::Redo,
179                    (_, command) => command,
180                }
181            }
182            // Without a selection, Shift+Delete deletes as Delete does.
183            Key::Named(NamedKey::Delete) if m.shift() && !m.control() && self.has_selection() => {
184                Command::Cut
185            }
186            Key::Named(NamedKey::Insert) if m.control() && !m.shift() => Command::Copy,
187            Key::Named(NamedKey::Insert) if m.shift() && !m.control() => Command::Paste,
188            _ => return None,
189        };
190        let _ = match command {
191            Command::Cut => self.cut(cx),
192            Command::Copy => self.copy(cx),
193            Command::Paste => self.paste(cx),
194            Command::Undo => self.undo(cx),
195            Command::Redo => self.redo(cx),
196        };
197        Some(())
198    }
199
200    /// The editor, created or restyled for the theme's text size and color.
201    fn prepare(&mut self, cx: &mut LayoutCx<'_>) {
202        let px = cx.px(cx.theme().font_size);
203        let color = cx.theme().text;
204        if self.shaped == Some((px, color)) && self.editor.is_some() {
205            return;
206        }
207        let style = ParagraphStyle::new(px).with_color(color);
208        match &mut self.editor {
209            Some(editor) => {
210                if editor.set_style(cx.text_system(), style).is_err() {
211                    return;
212                }
213            }
214            None => {
215                let text = std::mem::take(&mut self.initial);
216                match TextEditor::new(cx.text_system(), text.clone(), style) {
217                    Ok(editor) => self.editor = Some(editor),
218                    Err(_) => {
219                        self.initial = text;
220                        return;
221                    }
222                }
223            }
224        }
225        self.shaped = Some((px, color));
226    }
227
228    /// The rectangle the text is shown in: inside the frame's padding.
229    fn inner(content: Rect, scale: f32) -> Rect {
230        let pad = PADDING_X * scale;
231        Rect::from_edges(
232            content.left() + pad,
233            content.top(),
234            content.right() - pad,
235            content.bottom(),
236        )
237    }
238
239    /// Where the paragraph's top-left corner is drawn.
240    fn origin(&self, inner: Rect) -> Point {
241        let height = self.editor.as_ref().map_or(0.0, |e| e.paragraph().height());
242        Point::new(
243            inner.left() - self.scroll,
244            (inner.top() + (inner.height() - height) / 2.0).round(),
245        )
246    }
247
248    /// Scrolls so the caret is in view, and tells the input method where it is.
249    fn follow_caret(&mut self, cx: &mut EventCx<'_>) {
250        let Some(editor) = &self.editor else {
251            return;
252        };
253        let inner = Self::inner(cx.content_rect(), cx.scale());
254        let paragraph = editor.paragraph();
255        let caret = editor.selection().caret_rect(paragraph, cx.px(1.0));
256        let room = inner.width().max(0.0);
257        if caret.left() < self.scroll {
258            self.scroll = caret.left();
259        } else if caret.right() > self.scroll + room {
260            self.scroll = caret.right() - room;
261        }
262        let longest = (paragraph.width() + caret.width() - room).max(0.0);
263        self.scroll = self.scroll.clamp(0.0, longest);
264        let origin = self.origin(inner);
265        cx.set_ime_area(Some(editor.ime_area().translate(origin)));
266        cx.request_paint();
267    }
268
269    fn pointer(
270        &mut self,
271        cx: &mut EventCx<'_>,
272        kind: PointerKind,
273        at: Point,
274        clicks: u8,
275        shift: bool,
276    ) -> Handled {
277        let inner = Self::inner(cx.content_rect(), cx.scale());
278        let origin = self.origin(inner);
279        let Some(editor) = &mut self.editor else {
280            return Handled::No;
281        };
282        let local = Point::new(at.x - origin.x, at.y - origin.y);
283        let paragraph = editor.paragraph();
284        let selection = match kind {
285            PointerKind::Down(PointerButton::Primary) => match clicks {
286                1 if shift => editor.selection().shift_click(paragraph, local),
287                1 => TextSelection::at_point(paragraph, local),
288                2 => TextSelection::word_at_point(paragraph, local),
289                _ => TextSelection::select_all(paragraph),
290            },
291            PointerKind::Move if cx.is_pressed() => {
292                editor.selection().extend_to_point(paragraph, local)
293            }
294            _ => return Handled::No,
295        };
296        editor.set_selection(selection);
297        self.follow_caret(cx);
298        Handled::Yes
299    }
300
301    fn key(&mut self, cx: &mut EventCx<'_>, key: &KeyEvent<'_>) -> Handled {
302        if self.editor.is_none() {
303            return Handled::No;
304        }
305        // Handled even when there is nothing to do: the keys are the input's while it has
306        // focus, not shortcuts for the application.
307        if self.command(cx, key).is_some() {
308            return Handled::Yes;
309        }
310        let Some(editor) = &mut self.editor else {
311            return Handled::No;
312        };
313        let (command, shift) = (key.modifiers.command(), key.modifiers.shift());
314        let by_word = |word: Motion, single: Motion| if command { word } else { single };
315        // Ctrl+Alt is AltGr on Windows: it types (@, €) rather than commands.
316        let types = !command || key.modifiers.alt();
317        let edited = match key.key {
318            Key::Named(NamedKey::Backspace) => editor.delete(
319                cx.text_system(),
320                by_word(Motion::PreviousWord, Motion::Previous),
321            ),
322            Key::Named(NamedKey::Delete) => {
323                editor.delete(cx.text_system(), by_word(Motion::NextWord, Motion::Next))
324            }
325            Key::Named(named) => {
326                let motion = match named {
327                    NamedKey::ArrowLeft => by_word(Motion::WordLeft, Motion::Left),
328                    NamedKey::ArrowRight => by_word(Motion::WordRight, Motion::Right),
329                    NamedKey::Home => Motion::LineStart,
330                    NamedKey::End => Motion::LineEnd,
331                    NamedKey::Enter => {
332                        cx.emit(ActionKind::Submitted);
333                        return Handled::Yes;
334                    }
335                    // Space and other named keys that type something.
336                    _ => match key.text.filter(|_| types) {
337                        Some(text) => {
338                            let edited = editor.insert(cx.text_system(), text);
339                            if edited.is_ok() {
340                                cx.emit(ActionKind::Changed);
341                            }
342                            self.follow_caret(cx);
343                            return Handled::Yes;
344                        }
345                        None => return Handled::No,
346                    },
347                };
348                editor.move_caret(motion, shift);
349                self.follow_caret(cx);
350                return Handled::Yes;
351            }
352            Key::Character(c) if command && c.eq_ignore_ascii_case("a") => {
353                let all = TextSelection::select_all(editor.paragraph());
354                editor.set_selection(all);
355                self.follow_caret(cx);
356                return Handled::Yes;
357            }
358            _ => match key.text.filter(|_| types) {
359                Some(text) => editor.insert(cx.text_system(), text),
360                None => return Handled::No,
361            },
362        };
363        if edited.is_ok() {
364            cx.emit(ActionKind::Changed);
365        }
366        self.follow_caret(cx);
367        Handled::Yes
368    }
369
370    fn ime(&mut self, cx: &mut EventCx<'_>, ime: &Ime<'_>) -> Handled {
371        let Some(editor) = &mut self.editor else {
372            return Handled::No;
373        };
374        // A composition is not part of the text yet: only a commit changes it. A composition
375        // the editor refuses (a cursor inside a character) leaves it as it was.
376        let changed = match *ime {
377            Ime::Preedit { text, cursor } => {
378                let cursor = cursor.map(|(start, end)| start..end);
379                let _ = editor.set_composition(cx.text_system(), text, cursor);
380                false
381            }
382            Ime::Commit(text) => editor.insert(cx.text_system(), text).is_ok(),
383            Ime::Disabled => {
384                let _ = editor.set_composition(cx.text_system(), "", None);
385                false
386            }
387            _ => false,
388        };
389        if changed {
390            cx.emit(ActionKind::Changed);
391        }
392        self.follow_caret(cx);
393        Handled::Yes
394    }
395}
396
397/// What a clipboard or undo key asks for.
398#[derive(Debug, Clone, Copy)]
399enum Command {
400    Cut,
401    Copy,
402    Paste,
403    Undo,
404    Redo,
405}
406
407/// `text` on one line: trailing line breaks dropped, the others (CR LF counting as one)
408/// made spaces.
409fn one_line(text: &str) -> String {
410    let text = text.trim_end_matches(['\r', '\n']);
411    text.replace("\r\n", " ").replace(['\r', '\n'], " ")
412}
413
414impl Widget for TextInput {
415    fn accessibility(&self, _: &PaintCx<'_>, node: &mut AccessNode) {
416        node.set_role(Role::TextInput);
417        node.set_value(self.text());
418        if !self.placeholder.as_str().is_empty() {
419            node.set_placeholder(self.placeholder.as_str());
420        }
421        node.add_action(AccessAction::SetValue);
422    }
423
424    fn access_action(&mut self, cx: &mut EventCx<'_>, request: &AccessRequest<'_>) -> Handled {
425        let (AccessAction::SetValue, Some(value)) = (request.action, request.value) else {
426            return Handled::No;
427        };
428        if self.set_text(cx, value).is_err() {
429            return Handled::No;
430        }
431        cx.emit(ActionKind::Changed);
432        Handled::Yes
433    }
434
435    fn measure(&mut self, cx: &mut LayoutCx<'_>, _: Constraints) -> Size {
436        self.prepare(cx);
437        let line = self
438            .editor
439            .as_ref()
440            .map_or(0.0, |e| e.paragraph().height())
441            .max(cx.px(cx.theme().font_size) * 1.2);
442        Size::new(cx.px(WIDTH), (line + 2.0 * cx.px(PADDING_Y)).ceil())
443    }
444
445    fn layout(&mut self, cx: &mut LayoutCx<'_>, size: Size) {
446        self.prepare(cx);
447        // A wider input, or shorter text, leaves less to scroll.
448        if let Some(editor) = &self.editor {
449            let room = (size.width - 2.0 * cx.px(PADDING_X)).max(0.0);
450            let longest = (editor.paragraph().width() + cx.px(1.0) - room).max(0.0);
451            self.scroll = self.scroll.clamp(0.0, longest);
452        }
453        let dim = cx.theme().text_dim;
454        self.placeholder.fit(cx, f32::INFINITY, dim);
455    }
456
457    fn paint(&mut self, cx: &PaintCx<'_>, canvas: &mut Canvas<'_>) {
458        let theme = cx.theme();
459        let rect = cx.rect();
460        canvas.fill_rect(rect, theme.raised);
461        let border = if cx.is_focused() {
462            theme.focus
463        } else {
464            theme.border
465        };
466        outline(canvas, rect, cx.px(1.0).round(), border);
467        let inner = Self::inner(cx.content_rect(), cx.scale());
468        let origin = self.origin(inner);
469        let Some(editor) = &self.editor else {
470            return;
471        };
472        let saved = canvas.state();
473        canvas.clip(inner);
474        if editor.text().is_empty() && editor.composition().is_none() {
475            let size = self.placeholder.size();
476            let y = (inner.top() + (inner.height() - size.height) / 2.0).round();
477            self.placeholder.paint(canvas, Point::new(inner.left(), y));
478        }
479        let paragraph = editor.paragraph();
480        let selection = editor.selection();
481        if cx.is_focused() && !selection.is_caret() {
482            selection.for_each_rect(paragraph, |r| {
483                canvas.fill_rect(r.translate(origin), theme.selection);
484            });
485        }
486        let underline = cx.px(1.0).round();
487        editor.for_each_composition_rect(|r| {
488            let r = r.translate(origin);
489            canvas.fill_rect(
490                Rect::from_edges(r.left(), r.bottom() - underline, r.right(), r.bottom()),
491                theme.text,
492            );
493        });
494        for run in paragraph.runs() {
495            canvas.draw_run(run, origin);
496        }
497        if cx.is_focused() && editor.shows_caret() {
498            let caret = selection.caret_rect(paragraph, cx.px(1.0).round());
499            canvas.fill_rect(caret.translate(origin), theme.text);
500        }
501        canvas.restore(saved);
502    }
503
504    fn event(&mut self, cx: &mut EventCx<'_>, event: &Event<'_>) -> Handled {
505        match event {
506            Event::Pointer(pointer) => match pointer.kind {
507                PointerKind::Enter => {
508                    cx.set_cursor(Some(Cursor::Text));
509                    Handled::Yes
510                }
511                PointerKind::Up(PointerButton::Primary) => Handled::Yes,
512                kind => {
513                    let shift = pointer.modifiers.shift();
514                    self.pointer(cx, kind, pointer.position, pointer.clicks, shift)
515                }
516            },
517            Event::Key(key) if key.pressed => self.key(cx, key),
518            Event::Ime(ime) => self.ime(cx, ime),
519            Event::FocusGained | Event::FocusLost => {
520                self.follow_caret(cx);
521                Handled::Yes
522            }
523            _ => Handled::No,
524        }
525    }
526
527    fn accepts_focus(&self) -> bool {
528        true
529    }
530}