raui_core/widget/component/interactive/
input_field.rs1use 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}