raui_core/widget/component/interactive/
input_field.rs

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