freya_components/
input.rs

1use std::{
2    borrow::Cow,
3    cell::{
4        Ref,
5        RefCell,
6    },
7    rc::Rc,
8};
9
10use dioxus::prelude::*;
11use freya_core::platform::CursorIcon;
12use freya_elements::{
13    self as dioxus_elements,
14    events::{
15        keyboard::Key,
16        KeyboardData,
17        MouseEvent,
18    },
19};
20use freya_hooks::{
21    use_applied_theme,
22    use_editable,
23    use_focus,
24    use_platform,
25    EditableConfig,
26    EditableEvent,
27    EditableMode,
28    InputTheme,
29    InputThemeWith,
30    TextEditor,
31};
32
33use crate::ScrollView;
34
35/// Enum to declare is [`Input`] hidden.
36#[derive(Default, Clone, PartialEq)]
37pub enum InputMode {
38    /// The input text is shown
39    #[default]
40    Shown,
41    /// The input text is obfuscated with a character
42    Hidden(char),
43}
44
45impl InputMode {
46    pub fn new_password() -> Self {
47        Self::Hidden('*')
48    }
49}
50
51/// Indicates the current status of the Input.
52#[derive(Debug, Default, PartialEq, Clone, Copy)]
53pub enum InputStatus {
54    /// Default state.
55    #[default]
56    Idle,
57    /// Mouse is hovering the input.
58    Hovering,
59}
60
61#[derive(Clone)]
62pub struct InputValidator {
63    valid: Rc<RefCell<bool>>,
64    text: Rc<RefCell<String>>,
65}
66
67impl InputValidator {
68    pub fn new(text: String) -> Self {
69        Self {
70            valid: Rc::new(RefCell::new(true)),
71            text: Rc::new(RefCell::new(text)),
72        }
73    }
74
75    /// Read the text to validate.
76    pub fn text(&self) -> Ref<String> {
77        self.text.borrow()
78    }
79
80    /// Mark the text as valid.
81    pub fn set_valid(&self, is_valid: bool) {
82        *self.valid.borrow_mut() = is_valid;
83    }
84
85    /// Check if the text was marked as valid.
86    pub fn is_valid(&self) -> bool {
87        *self.valid.borrow()
88    }
89}
90
91/// Properties for the [`Input`] component.
92#[derive(Props, Clone, PartialEq)]
93pub struct InputProps {
94    /// Theme override.
95    pub theme: Option<InputThemeWith>,
96    /// Text to show for when there is no value
97    pub placeholder: ReadOnlySignal<Option<String>>,
98    /// Current value of the Input.
99    pub value: ReadOnlySignal<String>,
100    /// Handler for the `onchange` event.
101    pub onchange: EventHandler<String>,
102    /// Display mode for Input. By default, input text is shown as it is provided.
103    #[props(default = InputMode::Shown, into)]
104    pub mode: InputMode,
105    /// Automatically focus this Input upon creation. Default `false`.
106    #[props(default = false)]
107    pub auto_focus: bool,
108    /// Handler for the `onvalidate` function.
109    pub onvalidate: Option<EventHandler<InputValidator>>,
110    #[props(default = "150".to_string())]
111    pub width: String,
112}
113
114/// Small box to edit text.
115///
116/// # Styling
117/// Inherits the [`InputTheme`](freya_hooks::InputTheme) theme.
118///
119/// # Example
120///
121/// ```rust
122/// # use freya::prelude::*;
123/// fn app() -> Element {
124///     let mut value = use_signal(String::new);
125///
126///     rsx!(
127///         label {
128///             "Value: {value}"
129///         }
130///         Input {
131///             value,
132///             onchange: move |e| {
133///                  value.set(e)
134///             }
135///         }
136///     )
137/// }
138/// # use freya_testing::prelude::*;
139/// # launch_doc(|| {
140/// #   rsx!(
141/// #       Preview {
142/// #           Input {
143/// #               value: "Some text...",
144/// #               onchange: move |_| { }
145/// #           }
146/// #       }
147/// #   )
148/// # }, (250., 250.).into(), "./images/gallery_input.png");
149/// ```
150/// # Preview
151/// ![Input Preview][input]
152#[cfg_attr(feature = "docs",
153    doc = embed_doc_image::embed_image!("input", "images/gallery_input.png")
154)]
155#[allow(non_snake_case)]
156pub fn Input(
157    InputProps {
158        theme,
159        value,
160        onchange,
161        mode,
162        placeholder,
163        auto_focus,
164        onvalidate,
165        width,
166    }: InputProps,
167) -> Element {
168    let platform = use_platform();
169    let mut status = use_signal(InputStatus::default);
170    let mut editable = use_editable(
171        || EditableConfig::new(value.to_string()),
172        EditableMode::MultipleLinesSingleEditor,
173    );
174    let InputTheme {
175        border_fill,
176        focus_border_fill,
177        margin,
178        corner_radius,
179        font_theme,
180        placeholder_font_theme,
181        shadow,
182        background,
183        hover_background,
184    } = use_applied_theme!(&theme, input);
185    let mut focus = use_focus();
186    let mut drag_origin = use_signal(|| None);
187
188    let value = value.read();
189    let placeholder = placeholder.read();
190    let display_placeholder = value.is_empty() && placeholder.is_some();
191
192    if &*value != editable.editor().read().rope() {
193        editable.editor_mut().write().set(&value);
194        editable.editor_mut().write().editor_history().clear();
195    }
196
197    use_drop(move || {
198        if *status.peek() == InputStatus::Hovering {
199            platform.set_cursor(CursorIcon::default());
200        }
201    });
202
203    use_effect(move || {
204        if !focus.is_focused() {
205            editable.editor_mut().write().clear_selection();
206        }
207    });
208
209    let onkeydown = move |e: Event<KeyboardData>| {
210        if e.data.key != Key::Enter && e.data.key != Key::Tab {
211            e.stop_propagation();
212            editable.process_event(&EditableEvent::KeyDown(e.data));
213            let text = editable.editor().peek().to_string();
214
215            let apply_change = if let Some(onvalidate) = onvalidate {
216                let editor = editable.editor_mut();
217                let mut editor = editor.write();
218                let validator = InputValidator::new(text.clone());
219                onvalidate(validator.clone());
220                let is_valid = validator.is_valid();
221
222                if !is_valid {
223                    // If it is not valid then undo the latest change and discard all the redos
224                    let undo_result = editor.undo();
225                    if let Some(idx) = undo_result {
226                        editor.set_cursor_pos(idx);
227                    }
228                    editor.editor_history().clear_redos();
229                }
230
231                is_valid
232            } else {
233                true
234            };
235
236            if apply_change {
237                onchange.call(text);
238            }
239        }
240    };
241
242    let onkeyup = move |e: Event<KeyboardData>| {
243        e.stop_propagation();
244        editable.process_event(&EditableEvent::KeyUp(e.data));
245    };
246
247    let oninputmousedown = move |e: MouseEvent| {
248        if !display_placeholder {
249            editable.process_event(&EditableEvent::MouseDown(e.data, 0));
250        }
251        focus.request_focus();
252    };
253
254    let onmousedown = move |e: MouseEvent| {
255        e.stop_propagation();
256        drag_origin.set(Some(e.get_screen_coordinates() - e.element_coordinates));
257        if !display_placeholder {
258            editable.process_event(&EditableEvent::MouseDown(e.data, 0));
259        }
260        focus.request_focus();
261    };
262
263    let onglobalmousemove = move |mut e: MouseEvent| {
264        if focus.is_focused() {
265            if let Some(drag_origin) = drag_origin() {
266                let data = Rc::get_mut(&mut e.data).unwrap();
267                data.element_coordinates.x -= drag_origin.x;
268                data.element_coordinates.y -= drag_origin.y;
269                editable.process_event(&EditableEvent::MouseMove(e.data, 0));
270            }
271        }
272    };
273
274    let onmouseenter = move |_| {
275        platform.set_cursor(CursorIcon::Text);
276        *status.write() = InputStatus::Hovering;
277    };
278
279    let onmouseleave = move |_| {
280        platform.set_cursor(CursorIcon::default());
281        *status.write() = InputStatus::default();
282    };
283
284    let onglobalclick = move |_| {
285        match *status.read() {
286            InputStatus::Idle if focus.is_focused() => {
287                editable.process_event(&EditableEvent::Click);
288            }
289            InputStatus::Hovering => {
290                editable.process_event(&EditableEvent::Click);
291            }
292            _ => {}
293        };
294
295        // Unfocus input when this:
296        // + is focused
297        // + it has not just being dragged
298        // + a global click happened
299        if focus.is_focused() {
300            if drag_origin.read().is_some() {
301                drag_origin.set(None);
302            } else {
303                focus.request_unfocus();
304            }
305        }
306    };
307
308    let a11y_id = focus.attribute();
309    let cursor_reference = editable.cursor_attr();
310    let highlights = editable.highlights_attr(0);
311
312    let (background, cursor_char) = if focus.is_focused() {
313        (
314            hover_background,
315            editable.editor().read().cursor_pos().to_string(),
316        )
317    } else {
318        (background, "none".to_string())
319    };
320    let border = if focus.is_focused_with_keyboard() {
321        format!("2 inner {focus_border_fill}")
322    } else {
323        format!("1 inner {border_fill}")
324    };
325
326    let color = if display_placeholder {
327        placeholder_font_theme.color
328    } else {
329        font_theme.color
330    };
331
332    let text = match (mode, &*placeholder) {
333        (_, Some(placeholder)) if display_placeholder => Cow::Borrowed(placeholder.as_str()),
334        (InputMode::Hidden(ch), _) => Cow::Owned(ch.to_string().repeat(value.len())),
335        (InputMode::Shown, _) => Cow::Borrowed(value.as_str()),
336    };
337
338    rsx!(
339        rect {
340            width,
341            direction: "vertical",
342            color: "{color}",
343            background: "{background}",
344            border,
345            shadow: "{shadow}",
346            corner_radius: "{corner_radius}",
347            margin: "{margin}",
348            main_align: "center",
349            a11y_id,
350            a11y_role: "text-input",
351            a11y_auto_focus: "{auto_focus}",
352            a11y_value: "{text}",
353            onkeydown,
354            onkeyup,
355            overflow: "clip",
356            onmousedown: oninputmousedown,
357            onmouseenter,
358            onmouseleave,
359            ScrollView {
360                height: "auto",
361                direction: "horizontal",
362                show_scrollbar: false,
363                paragraph {
364                    min_width: "calc(100% - 20)",
365                    margin: "6 10",
366                    onglobalclick,
367                    onmousedown,
368                    onglobalmousemove,
369                    cursor_reference,
370                    cursor_id: "0",
371                    cursor_index: "{cursor_char}",
372                    cursor_mode: "editable",
373                    cursor_color: "{color}",
374                    max_lines: "1",
375                    highlights,
376                    text {
377                        "{text}"
378                    }
379                }
380            }
381        }
382    )
383}
384
385#[cfg(test)]
386mod test {
387    use freya::prelude::*;
388    use freya_testing::prelude::*;
389
390    #[tokio::test]
391    pub async fn input() {
392        fn input_app() -> Element {
393            let mut value = use_signal(|| "Hello, Worl".to_string());
394
395            rsx!(Input {
396                value,
397                onchange: move |new_value| {
398                    value.set(new_value);
399                }
400            })
401        }
402
403        let mut utils = launch_test(input_app);
404        let root = utils.root();
405        let text = root.get(0).get(0).get(0).get(0).get(0).get(0);
406        utils.wait_for_update().await;
407
408        // Default value
409        assert_eq!(text.get(0).text(), Some("Hello, Worl"));
410
411        assert_eq!(utils.focus_id(), ACCESSIBILITY_ROOT_ID);
412
413        // Focus the input in the end of the text
414        utils.push_event(TestEvent::Mouse {
415            name: EventName::MouseDown,
416            cursor: (115., 25.).into(),
417            button: Some(MouseButton::Left),
418        });
419        utils.wait_for_update().await;
420        utils.wait_for_update().await;
421        utils.wait_for_update().await;
422
423        assert_ne!(utils.focus_id(), ACCESSIBILITY_ROOT_ID);
424
425        // Write "d"
426        utils.push_event(TestEvent::Keyboard {
427            name: EventName::KeyDown,
428            key: Key::Character("d".to_string()),
429            code: Code::KeyD,
430            modifiers: Modifiers::default(),
431        });
432        utils.wait_for_update().await;
433
434        // Check that "d" has been written into the input.
435        assert_eq!(text.get(0).text(), Some("Hello, World"));
436    }
437
438    #[tokio::test]
439    pub async fn validate() {
440        fn input_app() -> Element {
441            let mut value = use_signal(|| "A".to_string());
442
443            rsx!(Input {
444                value: value.read().clone(),
445                onvalidate: |validator: InputValidator| {
446                    if validator.text().len() > 3 {
447                        validator.set_valid(false)
448                    }
449                },
450                onchange: move |new_value| {
451                    value.set(new_value);
452                }
453            },)
454        }
455
456        let mut utils = launch_test(input_app);
457        let root = utils.root();
458        let text = root.get(0).get(0).get(0).get(0).get(0).get(0);
459        utils.wait_for_update().await;
460
461        // Default value
462        assert_eq!(text.get(0).text(), Some("A"));
463
464        // Focus the input in the end of the text
465        utils.push_event(TestEvent::Mouse {
466            name: EventName::MouseDown,
467            cursor: (115., 25.).into(),
468            button: Some(MouseButton::Left),
469        });
470        utils.wait_for_update().await;
471        utils.wait_for_update().await;
472        utils.wait_for_update().await;
473
474        assert_ne!(utils.focus_id(), ACCESSIBILITY_ROOT_ID);
475
476        // Try to write "BCDEFG"
477        for c in ['B', 'C', 'D', 'E', 'F', 'G'] {
478            utils.push_event(TestEvent::Keyboard {
479                name: EventName::KeyDown,
480                key: Key::Character(c.to_string()),
481                code: Code::Unidentified,
482                modifiers: Modifiers::default(),
483            });
484            utils.wait_for_update().await;
485        }
486
487        // Check that only "BC" was been written to the input.
488        assert_eq!(text.get(0).text(), Some("ABC"));
489    }
490}