Skip to main content

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        editable.editor_mut().write().clear_selection();
196    }
197
198    use_drop(move || {
199        if *status.peek() == InputStatus::Hovering {
200            platform.set_cursor(CursorIcon::default());
201        }
202    });
203
204    use_effect(move || {
205        if !focus.is_focused() {
206            editable.editor_mut().write().clear_selection();
207        }
208    });
209
210    let onkeydown = move |e: Event<KeyboardData>| {
211        if e.data.key != Key::Enter && e.data.key != Key::Tab {
212            e.stop_propagation();
213            editable.process_event(&EditableEvent::KeyDown(e.data));
214            let text = editable.editor().peek().to_string();
215
216            let apply_change = if let Some(onvalidate) = onvalidate {
217                let editor = editable.editor_mut();
218                let mut editor = editor.write();
219                let validator = InputValidator::new(text.clone());
220                onvalidate(validator.clone());
221                let is_valid = validator.is_valid();
222
223                if !is_valid {
224                    // If it is not valid then undo the latest change and discard all the redos
225                    let undo_result = editor.undo();
226                    if let Some(idx) = undo_result {
227                        editor.set_cursor_pos(idx);
228                    }
229                    editor.editor_history().clear_redos();
230                }
231
232                is_valid
233            } else {
234                true
235            };
236
237            if apply_change {
238                onchange.call(text);
239            }
240        }
241    };
242
243    let onkeyup = move |e: Event<KeyboardData>| {
244        e.stop_propagation();
245        editable.process_event(&EditableEvent::KeyUp(e.data));
246    };
247
248    let oninputmousedown = move |e: MouseEvent| {
249        if !display_placeholder {
250            editable.process_event(&EditableEvent::MouseDown(e.data, 0));
251        }
252        focus.request_focus();
253    };
254
255    let onmousedown = move |e: MouseEvent| {
256        e.stop_propagation();
257        drag_origin.set(Some(e.get_screen_coordinates() - e.element_coordinates));
258        if !display_placeholder {
259            editable.process_event(&EditableEvent::MouseDown(e.data, 0));
260        }
261        focus.request_focus();
262    };
263
264    let onglobalmousemove = move |mut e: MouseEvent| {
265        if focus.is_focused() {
266            if let Some(drag_origin) = drag_origin() {
267                let data = Rc::get_mut(&mut e.data).unwrap();
268                data.element_coordinates.x -= drag_origin.x;
269                data.element_coordinates.y -= drag_origin.y;
270                editable.process_event(&EditableEvent::MouseMove(e.data, 0));
271            }
272        }
273    };
274
275    let onmouseenter = move |_| {
276        platform.set_cursor(CursorIcon::Text);
277        *status.write() = InputStatus::Hovering;
278    };
279
280    let onmouseleave = move |_| {
281        platform.set_cursor(CursorIcon::default());
282        *status.write() = InputStatus::default();
283    };
284
285    let onglobalclick = move |_| {
286        match *status.read() {
287            InputStatus::Idle if focus.is_focused() => {
288                editable.process_event(&EditableEvent::Click);
289            }
290            InputStatus::Hovering => {
291                editable.process_event(&EditableEvent::Click);
292            }
293            _ => {}
294        };
295
296        // Unfocus input when this:
297        // + is focused
298        // + it has not just being dragged
299        // + a global click happened
300        if focus.is_focused() {
301            if drag_origin.read().is_some() {
302                drag_origin.set(None);
303            } else {
304                focus.request_unfocus();
305            }
306        }
307    };
308
309    let a11y_id = focus.attribute();
310    let cursor_reference = editable.cursor_attr();
311    let highlights = editable.highlights_attr(0);
312
313    let (background, cursor_char) = if focus.is_focused() {
314        (
315            hover_background,
316            editable.editor().read().cursor_pos().to_string(),
317        )
318    } else {
319        (background, "none".to_string())
320    };
321    let border = if focus.is_focused_with_keyboard() {
322        format!("2 inner {focus_border_fill}")
323    } else {
324        format!("1 inner {border_fill}")
325    };
326
327    let color = if display_placeholder {
328        placeholder_font_theme.color
329    } else {
330        font_theme.color
331    };
332
333    let text = match (mode, &*placeholder) {
334        (_, Some(placeholder)) if display_placeholder => Cow::Borrowed(placeholder.as_str()),
335        (InputMode::Hidden(ch), _) => Cow::Owned(ch.to_string().repeat(value.len())),
336        (InputMode::Shown, _) => Cow::Borrowed(value.as_str()),
337    };
338
339    rsx!(
340        rect {
341            width,
342            direction: "vertical",
343            color: "{color}",
344            background: "{background}",
345            border,
346            shadow: "{shadow}",
347            corner_radius: "{corner_radius}",
348            margin: "{margin}",
349            main_align: "center",
350            a11y_id,
351            a11y_role: "text-input",
352            a11y_auto_focus: "{auto_focus}",
353            a11y_value: "{text}",
354            onkeydown,
355            onkeyup,
356            overflow: "clip",
357            onmousedown: oninputmousedown,
358            onmouseenter,
359            onmouseleave,
360            ScrollView {
361                height: "auto",
362                direction: "horizontal",
363                show_scrollbar: false,
364                paragraph {
365                    min_width: "calc(100% - 20)",
366                    margin: "6 10",
367                    onglobalclick,
368                    onmousedown,
369                    onglobalmousemove,
370                    cursor_reference,
371                    cursor_id: "0",
372                    cursor_index: "{cursor_char}",
373                    cursor_mode: "editable",
374                    cursor_color: "{color}",
375                    max_lines: "1",
376                    highlights,
377                    text {
378                        "{text}"
379                    }
380                }
381            }
382        }
383    )
384}
385
386#[cfg(test)]
387mod test {
388    use freya::prelude::*;
389    use freya_testing::prelude::*;
390
391    #[tokio::test]
392    pub async fn input() {
393        fn input_app() -> Element {
394            let mut value = use_signal(|| "Hello, Worl".to_string());
395
396            rsx!(Input {
397                value,
398                onchange: move |new_value| {
399                    value.set(new_value);
400                }
401            })
402        }
403
404        let mut utils = launch_test(input_app);
405        let root = utils.root();
406        let text = root.get(0).get(0).get(0).get(0).get(0).get(0);
407        utils.wait_for_update().await;
408
409        // Default value
410        assert_eq!(text.get(0).text(), Some("Hello, Worl"));
411
412        assert_eq!(utils.focus_id(), ACCESSIBILITY_ROOT_ID);
413
414        // Focus the input in the end of the text
415        utils.push_event(TestEvent::Mouse {
416            name: EventName::MouseDown,
417            cursor: (115., 25.).into(),
418            button: Some(MouseButton::Left),
419        });
420        utils.wait_for_update().await;
421        utils.wait_for_update().await;
422        utils.wait_for_update().await;
423
424        assert_ne!(utils.focus_id(), ACCESSIBILITY_ROOT_ID);
425
426        // Write "d"
427        utils.push_event(TestEvent::Keyboard {
428            name: EventName::KeyDown,
429            key: Key::Character("d".to_string()),
430            code: Code::KeyD,
431            modifiers: Modifiers::default(),
432        });
433        utils.wait_for_update().await;
434
435        // Check that "d" has been written into the input.
436        assert_eq!(text.get(0).text(), Some("Hello, World"));
437    }
438
439    #[tokio::test]
440    pub async fn validate() {
441        fn input_app() -> Element {
442            let mut value = use_signal(|| "A".to_string());
443
444            rsx!(Input {
445                value: value.read().clone(),
446                onvalidate: |validator: InputValidator| {
447                    if validator.text().len() > 3 {
448                        validator.set_valid(false)
449                    }
450                },
451                onchange: move |new_value| {
452                    value.set(new_value);
453                }
454            },)
455        }
456
457        let mut utils = launch_test(input_app);
458        let root = utils.root();
459        let text = root.get(0).get(0).get(0).get(0).get(0).get(0);
460        utils.wait_for_update().await;
461
462        // Default value
463        assert_eq!(text.get(0).text(), Some("A"));
464
465        // Focus the input in the end of the text
466        utils.push_event(TestEvent::Mouse {
467            name: EventName::MouseDown,
468            cursor: (115., 25.).into(),
469            button: Some(MouseButton::Left),
470        });
471        utils.wait_for_update().await;
472        utils.wait_for_update().await;
473        utils.wait_for_update().await;
474
475        assert_ne!(utils.focus_id(), ACCESSIBILITY_ROOT_ID);
476
477        // Try to write "BCDEFG"
478        for c in ['B', 'C', 'D', 'E', 'F', 'G'] {
479            utils.push_event(TestEvent::Keyboard {
480                name: EventName::KeyDown,
481                key: Key::Character(c.to_string()),
482                code: Code::Unidentified,
483                modifiers: Modifiers::default(),
484            });
485            utils.wait_for_update().await;
486        }
487
488        // Check that only "BC" was been written to the input.
489        assert_eq!(text.get(0).text(), Some("ABC"));
490    }
491}