Skip to main content

densa_widgets/
text_input.rs

1use densa_core::{Color, Point, Rect, Size};
2use densa_draw::Canvas;
3use densa_paragraph::{Motion, ParagraphError, ParagraphStyle, TextEditor, TextSelection};
4use densa_ui::{
5    AccessAction, AccessNode, AccessRequest, ActionKind, Constraints, Cursor, Event, EventCx,
6    Handled, Ime, Key, KeyEvent, LayoutCx, NamedKey, PaintCx, PointerButton, PointerKind, Role,
7    Widget,
8};
9
10use crate::paint::outline;
11use crate::text::Text;
12
13/// Space between the frame and the text, in logical pixels.
14const PADDING_X: f32 = 6.0;
15const PADDING_Y: f32 = 5.0;
16/// The width a text input asks for, in logical pixels.
17const WIDTH: f32 = 180.0;
18
19/// A single line of editable text. Reports [`ActionKind::Changed`] after every edit and
20/// [`ActionKind::Submitted`] on Enter.
21///
22/// Clicks place the caret (two select a word, three everything), dragging selects, the arrow
23/// keys and Home/End move (with Shift: select; with Ctrl, or Cmd on macOS: by word),
24/// Backspace and Delete remove characters or words, and input methods compose in place.
25#[derive(Debug)]
26pub struct TextInput {
27    /// The text until the editor exists (it is created at the first layout).
28    initial: String,
29    placeholder: Text,
30    editor: Option<TextEditor>,
31    /// The size and color the editor's text was shaped with.
32    shaped: Option<(f32, Color)>,
33    /// How far the text is scrolled left to keep the caret in view.
34    scroll: f32,
35}
36
37impl TextInput {
38    /// An input holding `text`.
39    pub fn new(text: impl Into<String>) -> Self {
40        Self {
41            initial: text.into(),
42            placeholder: Text::new(""),
43            editor: None,
44            shaped: None,
45            scroll: 0.0,
46        }
47    }
48
49    /// The input showing `placeholder`, dimmed, while it is empty.
50    #[must_use]
51    pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
52        self.placeholder = Text::new(placeholder);
53        self
54    }
55
56    /// The text.
57    pub fn text(&self) -> &str {
58        self.editor
59            .as_ref()
60            .map_or(self.initial.as_str(), TextEditor::text)
61    }
62
63    /// Replaces the text, the caret at its end.
64    ///
65    /// # Errors
66    /// [`ParagraphError::UnreadableFont`] if a font for `text` cannot be read; nothing
67    /// changes.
68    pub fn set_text(
69        &mut self,
70        cx: &mut EventCx<'_>,
71        text: impl Into<String>,
72    ) -> Result<(), ParagraphError> {
73        let text = text.into();
74        match &mut self.editor {
75            Some(editor) => editor.set_text(cx.text_system(), &text)?,
76            None => self.initial = text,
77        }
78        self.follow_caret(cx);
79        Ok(())
80    }
81
82    /// The editor, created or restyled for the theme's text size and color.
83    fn prepare(&mut self, cx: &mut LayoutCx<'_>) {
84        let px = cx.px(cx.theme().font_size);
85        let color = cx.theme().text;
86        if self.shaped == Some((px, color)) && self.editor.is_some() {
87            return;
88        }
89        let style = ParagraphStyle::new(px).with_color(color);
90        match &mut self.editor {
91            Some(editor) => {
92                if editor.set_style(cx.text_system(), style).is_err() {
93                    return;
94                }
95            }
96            None => {
97                let text = std::mem::take(&mut self.initial);
98                match TextEditor::new(cx.text_system(), text.clone(), style) {
99                    Ok(editor) => self.editor = Some(editor),
100                    Err(_) => {
101                        self.initial = text;
102                        return;
103                    }
104                }
105            }
106        }
107        self.shaped = Some((px, color));
108    }
109
110    /// The rectangle the text is shown in: inside the frame's padding.
111    fn inner(content: Rect, scale: f32) -> Rect {
112        let pad = PADDING_X * scale;
113        Rect::from_edges(
114            content.left() + pad,
115            content.top(),
116            content.right() - pad,
117            content.bottom(),
118        )
119    }
120
121    /// Where the paragraph's top-left corner is drawn.
122    fn origin(&self, inner: Rect) -> Point {
123        let height = self.editor.as_ref().map_or(0.0, |e| e.paragraph().height());
124        Point::new(
125            inner.left() - self.scroll,
126            (inner.top() + (inner.height() - height) / 2.0).round(),
127        )
128    }
129
130    /// Scrolls so the caret is in view, and tells the input method where it is.
131    fn follow_caret(&mut self, cx: &mut EventCx<'_>) {
132        let Some(editor) = &self.editor else {
133            return;
134        };
135        let inner = Self::inner(cx.content_rect(), cx.scale());
136        let paragraph = editor.paragraph();
137        let caret = editor.selection().caret_rect(paragraph, cx.px(1.0));
138        let room = inner.width().max(0.0);
139        if caret.left() < self.scroll {
140            self.scroll = caret.left();
141        } else if caret.right() > self.scroll + room {
142            self.scroll = caret.right() - room;
143        }
144        let longest = (paragraph.width() + caret.width() - room).max(0.0);
145        self.scroll = self.scroll.clamp(0.0, longest);
146        let origin = self.origin(inner);
147        cx.set_ime_area(Some(editor.ime_area().translate(origin)));
148        cx.request_paint();
149    }
150
151    fn pointer(
152        &mut self,
153        cx: &mut EventCx<'_>,
154        kind: PointerKind,
155        at: Point,
156        clicks: u8,
157        shift: bool,
158    ) -> Handled {
159        let inner = Self::inner(cx.content_rect(), cx.scale());
160        let origin = self.origin(inner);
161        let Some(editor) = &mut self.editor else {
162            return Handled::No;
163        };
164        let local = Point::new(at.x - origin.x, at.y - origin.y);
165        let paragraph = editor.paragraph();
166        let selection = match kind {
167            PointerKind::Down(PointerButton::Primary) => match clicks {
168                1 if shift => editor.selection().shift_click(paragraph, local),
169                1 => TextSelection::at_point(paragraph, local),
170                2 => TextSelection::word_at_point(paragraph, local),
171                _ => TextSelection::select_all(paragraph),
172            },
173            PointerKind::Move if cx.is_pressed() => {
174                editor.selection().extend_to_point(paragraph, local)
175            }
176            _ => return Handled::No,
177        };
178        editor.set_selection(selection);
179        self.follow_caret(cx);
180        Handled::Yes
181    }
182
183    fn key(&mut self, cx: &mut EventCx<'_>, key: &KeyEvent<'_>) -> Handled {
184        let Some(editor) = &mut self.editor else {
185            return Handled::No;
186        };
187        let (command, shift) = (key.modifiers.command(), key.modifiers.shift());
188        let by_word = |word: Motion, single: Motion| if command { word } else { single };
189        // Ctrl+Alt is AltGr on Windows: it types (@, €) rather than commands.
190        let types = !command || key.modifiers.alt();
191        let edited = match key.key {
192            Key::Named(NamedKey::Backspace) => editor.delete(
193                cx.text_system(),
194                by_word(Motion::PreviousWord, Motion::Previous),
195            ),
196            Key::Named(NamedKey::Delete) => {
197                editor.delete(cx.text_system(), by_word(Motion::NextWord, Motion::Next))
198            }
199            Key::Named(named) => {
200                let motion = match named {
201                    NamedKey::ArrowLeft => by_word(Motion::WordLeft, Motion::Left),
202                    NamedKey::ArrowRight => by_word(Motion::WordRight, Motion::Right),
203                    NamedKey::Home => Motion::LineStart,
204                    NamedKey::End => Motion::LineEnd,
205                    NamedKey::Enter => {
206                        cx.emit(ActionKind::Submitted);
207                        return Handled::Yes;
208                    }
209                    // Space and other named keys that type something.
210                    _ => match key.text.filter(|_| types) {
211                        Some(text) => {
212                            let edited = editor.insert(cx.text_system(), text);
213                            if edited.is_ok() {
214                                cx.emit(ActionKind::Changed);
215                            }
216                            self.follow_caret(cx);
217                            return Handled::Yes;
218                        }
219                        None => return Handled::No,
220                    },
221                };
222                editor.move_caret(motion, shift);
223                self.follow_caret(cx);
224                return Handled::Yes;
225            }
226            Key::Character(c) if command && c.eq_ignore_ascii_case("a") => {
227                let all = TextSelection::select_all(editor.paragraph());
228                editor.set_selection(all);
229                self.follow_caret(cx);
230                return Handled::Yes;
231            }
232            _ => match key.text.filter(|_| types) {
233                Some(text) => editor.insert(cx.text_system(), text),
234                None => return Handled::No,
235            },
236        };
237        if edited.is_ok() {
238            cx.emit(ActionKind::Changed);
239        }
240        self.follow_caret(cx);
241        Handled::Yes
242    }
243
244    fn ime(&mut self, cx: &mut EventCx<'_>, ime: &Ime<'_>) -> Handled {
245        let Some(editor) = &mut self.editor else {
246            return Handled::No;
247        };
248        // A composition is not part of the text yet: only a commit changes it. A composition
249        // the editor refuses (a cursor inside a character) leaves it as it was.
250        let changed = match *ime {
251            Ime::Preedit { text, cursor } => {
252                let cursor = cursor.map(|(start, end)| start..end);
253                let _ = editor.set_composition(cx.text_system(), text, cursor);
254                false
255            }
256            Ime::Commit(text) => editor.insert(cx.text_system(), text).is_ok(),
257            Ime::Disabled => {
258                let _ = editor.set_composition(cx.text_system(), "", None);
259                false
260            }
261            _ => false,
262        };
263        if changed {
264            cx.emit(ActionKind::Changed);
265        }
266        self.follow_caret(cx);
267        Handled::Yes
268    }
269}
270
271impl Widget for TextInput {
272    fn accessibility(&self, _: &PaintCx<'_>, node: &mut AccessNode) {
273        node.set_role(Role::TextInput);
274        node.set_value(self.text());
275        if !self.placeholder.as_str().is_empty() {
276            node.set_placeholder(self.placeholder.as_str());
277        }
278        node.add_action(AccessAction::SetValue);
279    }
280
281    fn access_action(&mut self, cx: &mut EventCx<'_>, request: &AccessRequest<'_>) -> Handled {
282        let (AccessAction::SetValue, Some(value)) = (request.action, request.value) else {
283            return Handled::No;
284        };
285        if self.set_text(cx, value).is_err() {
286            return Handled::No;
287        }
288        cx.emit(ActionKind::Changed);
289        Handled::Yes
290    }
291
292    fn measure(&mut self, cx: &mut LayoutCx<'_>, _: Constraints) -> Size {
293        self.prepare(cx);
294        let line = self
295            .editor
296            .as_ref()
297            .map_or(0.0, |e| e.paragraph().height())
298            .max(cx.px(cx.theme().font_size) * 1.2);
299        Size::new(cx.px(WIDTH), (line + 2.0 * cx.px(PADDING_Y)).ceil())
300    }
301
302    fn layout(&mut self, cx: &mut LayoutCx<'_>, size: Size) {
303        self.prepare(cx);
304        // A wider input, or shorter text, leaves less to scroll.
305        if let Some(editor) = &self.editor {
306            let room = (size.width - 2.0 * cx.px(PADDING_X)).max(0.0);
307            let longest = (editor.paragraph().width() + cx.px(1.0) - room).max(0.0);
308            self.scroll = self.scroll.clamp(0.0, longest);
309        }
310        let dim = cx.theme().text_dim;
311        self.placeholder.fit(cx, f32::INFINITY, dim);
312    }
313
314    fn paint(&mut self, cx: &PaintCx<'_>, canvas: &mut Canvas<'_>) {
315        let theme = cx.theme();
316        let rect = cx.rect();
317        canvas.fill_rect(rect, theme.raised);
318        let border = if cx.is_focused() {
319            theme.focus
320        } else {
321            theme.border
322        };
323        outline(canvas, rect, cx.px(1.0).round(), border);
324        let inner = Self::inner(cx.content_rect(), cx.scale());
325        let origin = self.origin(inner);
326        let Some(editor) = &self.editor else {
327            return;
328        };
329        let saved = canvas.state();
330        canvas.clip(inner);
331        if editor.text().is_empty() && editor.composition().is_none() {
332            let size = self.placeholder.size();
333            let y = (inner.top() + (inner.height() - size.height) / 2.0).round();
334            self.placeholder.paint(canvas, Point::new(inner.left(), y));
335        }
336        let paragraph = editor.paragraph();
337        let selection = editor.selection();
338        if cx.is_focused() && !selection.is_caret() {
339            selection.for_each_rect(paragraph, |r| {
340                canvas.fill_rect(r.translate(origin), theme.selection);
341            });
342        }
343        let underline = cx.px(1.0).round();
344        editor.for_each_composition_rect(|r| {
345            let r = r.translate(origin);
346            canvas.fill_rect(
347                Rect::from_edges(r.left(), r.bottom() - underline, r.right(), r.bottom()),
348                theme.text,
349            );
350        });
351        for run in paragraph.runs() {
352            canvas.draw_run(run, origin);
353        }
354        if cx.is_focused() && editor.shows_caret() {
355            let caret = selection.caret_rect(paragraph, cx.px(1.0).round());
356            canvas.fill_rect(caret.translate(origin), theme.text);
357        }
358        canvas.restore(saved);
359    }
360
361    fn event(&mut self, cx: &mut EventCx<'_>, event: &Event<'_>) -> Handled {
362        match event {
363            Event::Pointer(pointer) => match pointer.kind {
364                PointerKind::Enter => {
365                    cx.set_cursor(Some(Cursor::Text));
366                    Handled::Yes
367                }
368                PointerKind::Up(PointerButton::Primary) => Handled::Yes,
369                kind => {
370                    let shift = pointer.modifiers.shift();
371                    self.pointer(cx, kind, pointer.position, pointer.clicks, shift)
372                }
373            },
374            Event::Key(key) if key.pressed => self.key(cx, key),
375            Event::Ime(ime) => self.ime(cx, ime),
376            Event::FocusGained | Event::FocusLost => {
377                self.follow_caret(cx);
378                Handled::Yes
379            }
380            _ => Handled::No,
381        }
382    }
383
384    fn accepts_focus(&self) -> bool {
385        true
386    }
387}