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