raui_core/widget/component/interactive/
input_field.rs

1use crate::{
2    Integer, MessageData, PropsData, Scalar, UnsignedInteger, pre_hooks, unpack_named_slots,
3    view_model::ViewModelValue,
4    widget::{
5        WidgetId, WidgetIdOrRef,
6        component::interactive::{
7            button::{ButtonProps, use_button},
8            navigation::{NavSignal, NavTextChange, use_nav_item, use_nav_text_input},
9        },
10        context::{WidgetContext, WidgetMountOrChangeContext},
11        node::WidgetNode,
12        unit::area::AreaBoxNode,
13    },
14};
15use intuicio_data::managed::ManagedLazy;
16use serde::{Deserialize, Serialize};
17use std::str::FromStr;
18
19fn is_false(v: &bool) -> bool {
20    !*v
21}
22
23fn is_zero(v: &usize) -> bool {
24    *v == 0
25}
26
27pub trait TextInputProxy: Send + Sync {
28    fn get(&self) -> String;
29    fn set(&mut self, value: String);
30}
31
32impl<T> TextInputProxy for T
33where
34    T: ToString + FromStr + Send + Sync,
35{
36    fn get(&self) -> String {
37        self.to_string()
38    }
39
40    fn set(&mut self, value: String) {
41        if let Ok(value) = value.parse() {
42            *self = value;
43        }
44    }
45}
46
47impl<T> TextInputProxy for ViewModelValue<T>
48where
49    T: ToString + FromStr + Send + Sync,
50{
51    fn get(&self) -> String {
52        self.to_string()
53    }
54
55    fn set(&mut self, value: String) {
56        if let Ok(value) = value.parse() {
57            **self = value;
58        }
59    }
60}
61
62#[derive(Clone)]
63pub struct TextInput(ManagedLazy<dyn TextInputProxy>);
64
65impl TextInput {
66    pub fn new(data: ManagedLazy<impl TextInputProxy + 'static>) -> Self {
67        let (lifetime, data) = data.into_inner();
68        let data = data as *mut dyn TextInputProxy;
69        unsafe { Self(ManagedLazy::<dyn TextInputProxy>::new_raw(data, lifetime).unwrap()) }
70    }
71
72    pub fn into_inner(self) -> ManagedLazy<dyn TextInputProxy> {
73        self.0
74    }
75
76    pub fn get(&self) -> String {
77        self.0.read().map(|data| data.get()).unwrap_or_default()
78    }
79
80    pub fn set(&mut self, value: impl ToString) {
81        if let Some(mut data) = self.0.write() {
82            data.set(value.to_string());
83        }
84    }
85}
86
87impl std::fmt::Debug for TextInput {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.debug_tuple("TextInput")
90            .field(&self.0.read().map(|data| data.get()).unwrap_or_default())
91            .finish()
92    }
93}
94
95impl<T: TextInputProxy + 'static> From<ManagedLazy<T>> for TextInput {
96    fn from(value: ManagedLazy<T>) -> Self {
97        Self::new(value)
98    }
99}
100
101#[derive(PropsData, Debug, Default, Clone, Copy, Serialize, Deserialize)]
102#[props_data(crate::props::PropsData)]
103#[prefab(crate::Prefab)]
104pub enum TextInputMode {
105    #[default]
106    Text,
107    Number,
108    Integer,
109    UnsignedInteger,
110    #[serde(skip)]
111    Filter(fn(usize, char) -> bool),
112}
113
114impl TextInputMode {
115    pub fn is_text(&self) -> bool {
116        matches!(self, Self::Text)
117    }
118
119    pub fn is_number(&self) -> bool {
120        matches!(self, Self::Number)
121    }
122
123    pub fn is_integer(&self) -> bool {
124        matches!(self, Self::Integer)
125    }
126
127    pub fn is_unsigned_integer(&self) -> bool {
128        matches!(self, Self::UnsignedInteger)
129    }
130
131    pub fn is_filter(&self) -> bool {
132        matches!(self, Self::Filter(_))
133    }
134
135    pub fn process(&self, text: &str) -> Option<String> {
136        match self {
137            Self::Text => Some(text.to_owned()),
138            Self::Number => text.parse::<Scalar>().ok().map(|v| v.to_string()),
139            Self::Integer => text.parse::<Integer>().ok().map(|v| v.to_string()),
140            Self::UnsignedInteger => text.parse::<UnsignedInteger>().ok().map(|v| v.to_string()),
141            Self::Filter(f) => {
142                if text.char_indices().any(|(i, c)| !f(i, c)) {
143                    None
144                } else {
145                    Some(text.to_owned())
146                }
147            }
148        }
149    }
150
151    pub fn is_valid(&self, text: &str) -> bool {
152        match self {
153            Self::Text => true,
154            Self::Number => text.parse::<Scalar>().is_ok() || text == "-",
155            Self::Integer => text.parse::<Integer>().is_ok() || text == "-",
156            Self::UnsignedInteger => text.parse::<UnsignedInteger>().is_ok(),
157            Self::Filter(f) => text.char_indices().all(|(i, c)| f(i, c)),
158        }
159    }
160}
161
162#[derive(PropsData, Debug, Default, Clone, Copy, Serialize, Deserialize)]
163#[props_data(crate::props::PropsData)]
164#[prefab(crate::Prefab)]
165pub struct TextInputState {
166    #[serde(default)]
167    #[serde(skip_serializing_if = "is_false")]
168    pub focused: bool,
169    #[serde(default)]
170    #[serde(skip_serializing_if = "is_zero")]
171    pub cursor_position: usize,
172}
173
174#[derive(PropsData, Debug, Default, Clone, Serialize, Deserialize)]
175#[props_data(crate::props::PropsData)]
176#[prefab(crate::Prefab)]
177pub struct TextInputProps {
178    #[serde(default)]
179    #[serde(skip_serializing_if = "is_false")]
180    pub allow_new_line: bool,
181    #[serde(default)]
182    #[serde(skip)]
183    pub text: Option<TextInput>,
184}
185
186#[derive(PropsData, Debug, Default, Clone, Serialize, Deserialize)]
187#[props_data(crate::props::PropsData)]
188#[prefab(crate::Prefab)]
189pub struct TextInputNotifyProps(
190    #[serde(default)]
191    #[serde(skip_serializing_if = "WidgetIdOrRef::is_none")]
192    pub WidgetIdOrRef,
193);
194
195#[derive(PropsData, Debug, Default, Clone, Serialize, Deserialize)]
196#[props_data(crate::props::PropsData)]
197#[prefab(crate::Prefab)]
198pub struct TextInputControlNotifyProps(
199    #[serde(default)]
200    #[serde(skip_serializing_if = "WidgetIdOrRef::is_none")]
201    pub WidgetIdOrRef,
202);
203
204#[derive(MessageData, Debug, Clone)]
205#[message_data(crate::messenger::MessageData)]
206pub struct TextInputNotifyMessage {
207    pub sender: WidgetId,
208    pub state: TextInputState,
209    pub submitted: bool,
210}
211
212#[derive(MessageData, Debug, Clone)]
213#[message_data(crate::messenger::MessageData)]
214pub struct TextInputControlNotifyMessage {
215    pub sender: WidgetId,
216    pub character: char,
217}
218
219pub fn use_text_input_notified_state(context: &mut WidgetContext) {
220    context.life_cycle.change(|context| {
221        for msg in context.messenger.messages {
222            if let Some(msg) = msg.as_any().downcast_ref::<TextInputNotifyMessage>() {
223                let _ = context.state.write_with(msg.state.to_owned());
224            }
225        }
226    });
227}
228
229#[pre_hooks(use_nav_text_input)]
230pub fn use_text_input(context: &mut WidgetContext) {
231    fn notify(context: &WidgetMountOrChangeContext, data: TextInputNotifyMessage) {
232        if let Ok(notify) = context.props.read::<TextInputNotifyProps>() {
233            if let Some(to) = notify.0.read() {
234                context.messenger.write(to, data);
235            }
236        }
237    }
238
239    context.life_cycle.mount(|context| {
240        notify(
241            &context,
242            TextInputNotifyMessage {
243                sender: context.id.to_owned(),
244                state: Default::default(),
245                submitted: false,
246            },
247        );
248        let _ = context.state.write_with(TextInputState::default());
249    });
250
251    context.life_cycle.change(|context| {
252        let mode = context.props.read_cloned_or_default::<TextInputMode>();
253        let mut props = context.props.read_cloned_or_default::<TextInputProps>();
254        let mut state = context.state.read_cloned_or_default::<TextInputState>();
255        let mut text = props
256            .text
257            .as_ref()
258            .map(|text| text.get())
259            .unwrap_or_default();
260        let mut dirty_text = false;
261        let mut dirty_state = false;
262        let mut submitted = false;
263        for msg in context.messenger.messages {
264            if let Some(msg) = msg.as_any().downcast_ref() {
265                match msg {
266                    NavSignal::FocusTextInput(idref) => {
267                        state.focused = idref.is_some();
268                        dirty_state = true;
269                    }
270                    NavSignal::TextChange(change) => {
271                        if state.focused {
272                            match change {
273                                NavTextChange::InsertCharacter(c) => {
274                                    if c.is_control() {
275                                        if let Ok(notify) =
276                                            context.props.read::<TextInputControlNotifyProps>()
277                                        {
278                                            if let Some(to) = notify.0.read() {
279                                                context.messenger.write(
280                                                    to,
281                                                    TextInputControlNotifyMessage {
282                                                        sender: context.id.to_owned(),
283                                                        character: *c,
284                                                    },
285                                                );
286                                            }
287                                        }
288                                    } else {
289                                        state.cursor_position =
290                                            state.cursor_position.min(text.chars().count());
291                                        let mut iter = text.chars();
292                                        let mut new_text = iter
293                                            .by_ref()
294                                            .take(state.cursor_position)
295                                            .collect::<String>();
296                                        new_text.push(*c);
297                                        new_text.extend(iter);
298                                        if mode.is_valid(&new_text) {
299                                            state.cursor_position += 1;
300                                            text = new_text;
301                                            dirty_text = true;
302                                            dirty_state = true;
303                                        }
304                                    }
305                                }
306                                NavTextChange::MoveCursorLeft => {
307                                    if state.cursor_position > 0 {
308                                        state.cursor_position -= 1;
309                                        dirty_state = true;
310                                    }
311                                }
312                                NavTextChange::MoveCursorRight => {
313                                    if state.cursor_position < text.chars().count() {
314                                        state.cursor_position += 1;
315                                        dirty_state = true;
316                                    }
317                                }
318                                NavTextChange::MoveCursorStart => {
319                                    state.cursor_position = 0;
320                                    dirty_state = true;
321                                }
322                                NavTextChange::MoveCursorEnd => {
323                                    state.cursor_position = text.chars().count();
324                                    dirty_state = true;
325                                }
326                                NavTextChange::DeleteLeft => {
327                                    if state.cursor_position > 0 {
328                                        let mut iter = text.chars();
329                                        let mut new_text = iter
330                                            .by_ref()
331                                            .take(state.cursor_position - 1)
332                                            .collect::<String>();
333                                        iter.by_ref().next();
334                                        new_text.extend(iter);
335                                        if mode.is_valid(&new_text) {
336                                            state.cursor_position -= 1;
337                                            text = new_text;
338                                            dirty_text = true;
339                                            dirty_state = true;
340                                        }
341                                    }
342                                }
343                                NavTextChange::DeleteRight => {
344                                    let mut iter = text.chars();
345                                    let mut new_text = iter
346                                        .by_ref()
347                                        .take(state.cursor_position)
348                                        .collect::<String>();
349                                    iter.by_ref().next();
350                                    new_text.extend(iter);
351                                    if mode.is_valid(&new_text) {
352                                        text = new_text;
353                                        dirty_text = true;
354                                        dirty_state = true;
355                                    }
356                                }
357                                NavTextChange::NewLine => {
358                                    if props.allow_new_line {
359                                        let mut iter = text.chars();
360                                        let mut new_text = iter
361                                            .by_ref()
362                                            .take(state.cursor_position)
363                                            .collect::<String>();
364                                        new_text.push('\n');
365                                        new_text.extend(iter);
366                                        if mode.is_valid(&new_text) {
367                                            state.cursor_position += 1;
368                                            text = new_text;
369                                            dirty_text = true;
370                                            dirty_state = true;
371                                        }
372                                    } else {
373                                        submitted = true;
374                                        dirty_state = true;
375                                    }
376                                }
377                            }
378                        }
379                    }
380                    _ => {}
381                }
382            }
383        }
384        if dirty_state {
385            state.cursor_position = state.cursor_position.min(text.chars().count());
386            notify(
387                &context,
388                TextInputNotifyMessage {
389                    sender: context.id.to_owned(),
390                    state,
391                    submitted,
392                },
393            );
394            let _ = context.state.write_with(state);
395        }
396        if dirty_text {
397            if let Some(data) = props.text.as_mut() {
398                data.set(text);
399                context.messenger.write(context.id.to_owned(), ());
400            }
401        }
402        if submitted {
403            context.signals.write(NavSignal::FocusTextInput(().into()));
404        }
405    });
406}
407
408#[pre_hooks(use_button, use_text_input)]
409pub fn use_input_field(context: &mut WidgetContext) {
410    context.life_cycle.change(|context| {
411        let focused = context
412            .state
413            .map_or_default::<TextInputState, _, _>(|s| s.focused);
414        for msg in context.messenger.messages {
415            if let Some(msg) = msg.as_any().downcast_ref() {
416                match msg {
417                    NavSignal::Accept(true) => {
418                        if !focused {
419                            context
420                                .signals
421                                .write(NavSignal::FocusTextInput(context.id.to_owned().into()));
422                        }
423                    }
424                    NavSignal::Cancel(true) => {
425                        if focused {
426                            context.signals.write(NavSignal::FocusTextInput(().into()));
427                        }
428                    }
429                    _ => {}
430                }
431            }
432        }
433    });
434}
435
436#[pre_hooks(use_nav_item, use_text_input)]
437pub fn text_input(mut context: WidgetContext) -> WidgetNode {
438    let WidgetContext {
439        id,
440        props,
441        state,
442        named_slots,
443        ..
444    } = context;
445    unpack_named_slots!(named_slots => content);
446
447    if let Some(p) = content.props_mut() {
448        p.write(state.read_cloned_or_default::<TextInputState>());
449        p.write(props.read_cloned_or_default::<TextInputProps>());
450    }
451
452    AreaBoxNode {
453        id: id.to_owned(),
454        slot: Box::new(content),
455    }
456    .into()
457}
458
459#[pre_hooks(use_nav_item, use_input_field)]
460pub fn input_field(mut context: WidgetContext) -> WidgetNode {
461    let WidgetContext {
462        id,
463        props,
464        state,
465        named_slots,
466        ..
467    } = context;
468    unpack_named_slots!(named_slots => content);
469
470    if let Some(p) = content.props_mut() {
471        p.write(state.read_cloned_or_default::<ButtonProps>());
472        p.write(state.read_cloned_or_default::<TextInputState>());
473        p.write(props.read_cloned_or_default::<TextInputProps>());
474    }
475
476    AreaBoxNode {
477        id: id.to_owned(),
478        slot: Box::new(content),
479    }
480    .into()
481}
482
483pub fn input_text_with_cursor(text: &str, position: usize, cursor: char) -> String {
484    text.chars()
485        .take(position)
486        .chain(std::iter::once(cursor))
487        .chain(text.chars().skip(position))
488        .collect()
489}