1#![allow(non_snake_case)]
2
3use std::cell::{Cell, RefCell};
4use std::rc::Rc;
5
6use repose_core::*;
7use repose_ui::{
8 BasicTextField, Box, Column, Row, Text, TextFieldConfig as BasicTextFieldConfig,
9 TextFieldState, TextStyle, ViewExt, ZStack,
10 anim::animate_f32,
11 textfield::{TextMeasureConfig, measure_text},
12};
13
14use super::*;
15
16#[allow(dead_code)]
19#[derive(Clone, Debug)]
20pub struct TextFieldColors {
21 pub focused_text_color: Color,
22 pub unfocused_text_color: Color,
23 pub disabled_text_color: Color,
24 pub error_text_color: Color,
25 pub focused_container_color: Color,
26 pub unfocused_container_color: Color,
27 pub disabled_container_color: Color,
28 pub error_container_color: Color,
29 pub cursor_color: Color,
30 pub error_cursor_color: Color,
31 pub focused_indicator_color: Color,
32 pub unfocused_indicator_color: Color,
33 pub disabled_indicator_color: Color,
34 pub error_indicator_color: Color,
35 pub focused_leading_icon_color: Color,
36 pub unfocused_leading_icon_color: Color,
37 pub disabled_leading_icon_color: Color,
38 pub error_leading_icon_color: Color,
39 pub focused_trailing_icon_color: Color,
40 pub unfocused_trailing_icon_color: Color,
41 pub disabled_trailing_icon_color: Color,
42 pub error_trailing_icon_color: Color,
43 pub focused_label_color: Color,
44 pub unfocused_label_color: Color,
45 pub disabled_label_color: Color,
46 pub error_label_color: Color,
47 pub focused_placeholder_color: Color,
48 pub unfocused_placeholder_color: Color,
49 pub disabled_placeholder_color: Color,
50 pub error_placeholder_color: Color,
51 pub focused_supporting_text_color: Color,
52 pub unfocused_supporting_text_color: Color,
53 pub disabled_supporting_text_color: Color,
54 pub error_supporting_text_color: Color,
55 pub focused_prefix_color: Color,
56 pub unfocused_prefix_color: Color,
57 pub disabled_prefix_color: Color,
58 pub error_prefix_color: Color,
59 pub focused_suffix_color: Color,
60 pub unfocused_suffix_color: Color,
61 pub disabled_suffix_color: Color,
62 pub error_suffix_color: Color,
63}
64
65#[allow(dead_code)]
66impl TextFieldColors {
67 pub fn text_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
68 if !enabled {
69 self.disabled_text_color
70 } else if is_error {
71 self.error_text_color
72 } else if focused {
73 self.focused_text_color
74 } else {
75 self.unfocused_text_color
76 }
77 }
78 pub fn container_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
79 if !enabled {
80 self.disabled_container_color
81 } else if is_error {
82 self.error_container_color
83 } else if focused {
84 self.focused_container_color
85 } else {
86 self.unfocused_container_color
87 }
88 }
89 pub fn cursor_color(&self, is_error: bool) -> Color {
90 if is_error {
91 self.error_cursor_color
92 } else {
93 self.cursor_color
94 }
95 }
96 pub fn indicator_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
97 if !enabled {
98 self.disabled_indicator_color
99 } else if is_error {
100 self.error_indicator_color
101 } else if focused {
102 self.focused_indicator_color
103 } else {
104 self.unfocused_indicator_color
105 }
106 }
107 pub fn leading_icon_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
108 if !enabled {
109 self.disabled_leading_icon_color
110 } else if is_error {
111 self.error_leading_icon_color
112 } else if focused {
113 self.focused_leading_icon_color
114 } else {
115 self.unfocused_leading_icon_color
116 }
117 }
118 pub fn trailing_icon_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
119 if !enabled {
120 self.disabled_trailing_icon_color
121 } else if is_error {
122 self.error_trailing_icon_color
123 } else if focused {
124 self.focused_trailing_icon_color
125 } else {
126 self.unfocused_trailing_icon_color
127 }
128 }
129 pub fn label_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
130 if !enabled {
131 self.disabled_label_color
132 } else if is_error {
133 self.error_label_color
134 } else if focused {
135 self.focused_label_color
136 } else {
137 self.unfocused_label_color
138 }
139 }
140 pub fn placeholder_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
141 if !enabled {
142 self.disabled_placeholder_color
143 } else if is_error {
144 self.error_placeholder_color
145 } else if focused {
146 self.focused_placeholder_color
147 } else {
148 self.unfocused_placeholder_color
149 }
150 }
151 pub fn supporting_text_color(&self, enabled: bool, is_error: bool, focused: bool) -> Color {
152 if !enabled {
153 self.disabled_supporting_text_color
154 } else if is_error {
155 self.error_supporting_text_color
156 } else if focused {
157 self.focused_supporting_text_color
158 } else {
159 self.unfocused_supporting_text_color
160 }
161 }
162}
163
164pub struct TextFieldDefaults;
166
167impl TextFieldDefaults {
168 pub const MIN_HEIGHT: f32 = 56.0;
170 pub const MIN_WIDTH: f32 = 280.0;
172
173 pub fn colors() -> TextFieldColors {
174 let th = theme();
175 TextFieldColors {
176 focused_text_color: th.on_surface,
177 unfocused_text_color: th.on_surface,
178 disabled_text_color: th.on_surface.with_alpha_f32(0.38),
179 error_text_color: th.on_surface,
180 focused_container_color: th.surface_container_highest,
181 unfocused_container_color: th.surface_container_highest,
182 disabled_container_color: th.on_surface.with_alpha_f32(0.04),
183 error_container_color: th.surface_container_highest,
184 cursor_color: th.primary,
185 error_cursor_color: th.error,
186 focused_indicator_color: th.primary,
187 unfocused_indicator_color: th.on_surface_variant,
188 disabled_indicator_color: th.on_surface.with_alpha_f32(0.12),
189 error_indicator_color: th.error,
190 focused_leading_icon_color: th.on_surface_variant,
191 unfocused_leading_icon_color: th.on_surface_variant,
192 disabled_leading_icon_color: th.on_surface.with_alpha_f32(0.38),
193 error_leading_icon_color: th.error,
194 focused_trailing_icon_color: th.on_surface_variant,
195 unfocused_trailing_icon_color: th.on_surface_variant,
196 disabled_trailing_icon_color: th.on_surface.with_alpha_f32(0.38),
197 error_trailing_icon_color: th.error,
198 focused_label_color: th.primary,
199 unfocused_label_color: th.on_surface_variant,
200 disabled_label_color: th.on_surface.with_alpha_f32(0.38),
201 error_label_color: th.error,
202 focused_placeholder_color: th.on_surface_variant,
203 unfocused_placeholder_color: th.on_surface_variant,
204 disabled_placeholder_color: th.on_surface.with_alpha_f32(0.38),
205 error_placeholder_color: th.error,
206 focused_supporting_text_color: th.on_surface_variant,
207 unfocused_supporting_text_color: th.on_surface_variant,
208 disabled_supporting_text_color: th.on_surface.with_alpha_f32(0.38),
209 error_supporting_text_color: th.error,
210 focused_prefix_color: th.on_surface,
211 unfocused_prefix_color: th.on_surface,
212 disabled_prefix_color: th.on_surface.with_alpha_f32(0.38),
213 error_prefix_color: th.on_surface,
214 focused_suffix_color: th.on_surface,
215 unfocused_suffix_color: th.on_surface,
216 disabled_suffix_color: th.on_surface.with_alpha_f32(0.38),
217 error_suffix_color: th.on_surface,
218 }
219 }
220}
221
222#[derive(Clone)]
224pub struct OutlinedTextFieldConfig {
225 pub label: Option<String>,
229 pub placeholder: Option<String>,
233 pub leading_icon: Option<View>,
235 pub trailing_icon: Option<View>,
237 pub single_line: bool,
239 pub is_error: bool,
241 pub enabled: bool,
243 pub on_submit: Option<Rc<dyn Fn(String)>>,
245 pub colors: Option<TextFieldColors>,
247 pub focus_tracker: Option<Rc<Cell<bool>>>,
251}
252
253impl Default for OutlinedTextFieldConfig {
254 fn default() -> Self {
255 Self {
256 label: None,
257 placeholder: None,
258 leading_icon: None,
259 trailing_icon: None,
260 single_line: true,
261 is_error: false,
262 enabled: true,
263 on_submit: None,
264 colors: None,
265 focus_tracker: None,
266 }
267 }
268}
269
270pub fn OutlinedTextField(
292 modifier: Modifier,
293 value: String,
294 on_value_change: impl Fn(String) + 'static,
295 config: OutlinedTextFieldConfig,
296) -> View {
297 let label_str: Option<Rc<str>> = config.label.clone().map(Rc::from);
298 let has_label = label_str.is_some();
299
300 let anim_key = match &label_str {
302 Some(l) => format!("otf_{}", &l[..l.len().min(32)]),
303 None => "otf_nolabel".into(),
304 };
305
306 let focus_tracker: Rc<Cell<bool>> = match config.focus_tracker.clone() {
310 Some(ft) => ft,
311 None => remember_with_key(format!("otf_focus_{}", anim_key), || Cell::new(false)),
312 };
313 let is_focused = focus_tracker.get();
314 let should_float = !value.is_empty() || is_focused;
315
316 let tf_placeholder = if has_label {
317 if should_float {
318 config.placeholder.clone().unwrap_or_default()
319 } else {
320 String::new()
321 }
322 } else {
323 config.placeholder.clone().unwrap_or_default()
324 };
325
326 let text_input = View::new(0, ViewKind::Box)
327 .modifier(
328 Modifier::new().flex_grow(1.0).text_input(TextInputConfig {
329 hint: tf_placeholder,
330 multiline: false,
331 on_change: Some(Rc::new(on_value_change) as _),
332 on_submit: config.on_submit.clone().map(|f| {
333 let f = f.clone();
334 Rc::new(move |s| f(s)) as Rc<dyn Fn(String)>
335 }),
336 focus_tracker: Some(focus_tracker),
337 value: value.clone(),
338 visual_transformation: None,
339 keyboard_type: Default::default(),
340 capitalization: Default::default(),
341 ime_action: Default::default(),
342 auto_correct_enabled: None,
343 enabled: config.enabled,
344 read_only: false,
345 max_lines: None,
346 min_lines: 1,
347 cursor_color: config
348 .colors
349 .as_ref()
350 .map(|c| c.cursor_color(config.is_error)),
351 on_text_layout: None,
352 text_style: None,
353 keyboard_actions: None,
354 interaction_source: None,
355 line_limits: None,
356 }),
357 )
358 .semantics(Semantics {
359 role: Role::TextField,
360 label: None,
361 focused: false,
362 enabled: true,
363 selectable_group: false,
364 });
365
366 outlined_field_decoration(
367 modifier,
368 anim_key,
369 label_str,
370 &config,
371 is_focused,
372 !value.is_empty(),
373 text_input,
374 )
375}
376
377pub fn OutlinedTextFieldState(
379 modifier: Modifier,
380 state: Rc<RefCell<TextFieldState>>,
381 on_value_change: impl Fn(String) + 'static,
382 config: OutlinedTextFieldConfig,
383) -> View {
384 let label_str: Option<Rc<str>> = config.label.clone().map(Rc::from);
385 let has_label = label_str.is_some();
386
387 let anim_key = match &label_str {
389 Some(l) => format!("otf_{}", &l[..l.len().min(32)]),
390 None => "otf_nolabel".into(),
391 };
392
393 let focus_tracker: Rc<Cell<bool>> = match config.focus_tracker.clone() {
394 Some(ft) => ft,
395 None => remember_with_key(format!("otf_focus_{}", anim_key), || Cell::new(false)),
396 };
397 let is_focused = focus_tracker.get();
398 let has_content = !state.borrow().text.is_empty();
399 let should_float = has_content || is_focused;
400
401 let tf_placeholder = if has_label {
403 if should_float {
404 config.placeholder.clone().unwrap_or_default()
405 } else {
406 String::new()
407 }
408 } else {
409 config.placeholder.clone().unwrap_or_default()
410 };
411
412 let text_input = BasicTextField(
413 state,
414 Modifier::new().flex_grow(1.0),
415 tf_placeholder,
416 BasicTextFieldConfig {
417 line_limits: if config.single_line {
418 TextFieldLineLimits::SingleLine
419 } else {
420 TextFieldLineLimits::MultiLine {
421 min_height_in_lines: 1,
422 max_height_in_lines: usize::MAX,
423 }
424 },
425 on_change: Some(Rc::new(on_value_change)),
426 on_submit: config.on_submit.clone(),
427 focus_tracker: Some(focus_tracker),
428 enabled: config.enabled,
429 ..Default::default()
430 },
431 );
432
433 outlined_field_decoration(
434 modifier,
435 anim_key,
436 label_str,
437 &config,
438 is_focused,
439 has_content,
440 text_input,
441 )
442}
443
444fn outlined_field_decoration(
445 modifier: Modifier,
446 anim_key: String,
447 label_str: Option<Rc<str>>,
448 config: &OutlinedTextFieldConfig,
449 is_focused: bool,
450 has_content: bool,
451 text_input: View,
452) -> View {
453 let th = theme();
454 let has_label = label_str.is_some();
455
456 let should_float = has_content || is_focused;
457 let float_t = animate_f32(
458 anim_key.clone(),
459 if should_float { 1.0 } else { 0.0 },
460 th.motion.color,
461 );
462
463 let target_border_w = if config.is_error || should_float {
464 OutlinedTextFieldDefaults::FOCUSED_BORDER_THICKNESS
465 } else {
466 OutlinedTextFieldDefaults::UNFOCUSED_BORDER_THICKNESS
467 };
468 let border_w = animate_f32(
469 format!("otf_bw_{}", anim_key),
470 target_border_w,
471 th.motion.color,
472 );
473
474 let (border_color, label_color, container_bg) = if let Some(ref tc) = config.colors {
475 (
476 tc.indicator_color(config.enabled, config.is_error, is_focused),
477 tc.label_color(config.enabled, config.is_error, is_focused),
478 tc.container_color(config.enabled, config.is_error, is_focused),
479 )
480 } else {
481 (
482 if config.is_error {
483 th.error
484 } else if is_focused {
485 th.primary
486 } else {
487 th.outline
488 },
489 if config.is_error {
490 th.error
491 } else if is_focused {
492 th.primary
493 } else {
494 th.on_surface_variant
495 },
496 th.surface,
497 )
498 };
499
500 let label_size = 16.0 - 4.0 * float_t;
502
503 let min_label_half_h: f32 = if has_label { 8.0 } else { 0.0 };
505
506 let label_start_y = (56.0 - 16.0) / 2.0;
508 let label_end_y = -min_label_half_h;
509 let label_y = label_start_y - (label_start_y - label_end_y) * float_t;
510
511 let label_start_x = if has_label { 24.0 } else { 0.0 };
513 let label_end_x = if has_label { 20.0 } else { 0.0 };
514 let label_x = label_start_x - (label_start_x - label_end_x) * float_t;
515
516 let (top_pad, bottom_pad) = if has_label { (8.0, 8.0) } else { (16.0, 16.0) };
518
519 let label_cutout = label_str.as_ref().map(|lbl| {
522 let font_px = dp_to_px(label_size) * repose_core::locals::text_scale().0;
523 let m = measure_text(lbl, font_px, TextMeasureConfig::default());
524 let text_width_px = m.positions.last().copied().unwrap_or(0.0);
525 let text_width_dp = px_to_dp(text_width_px);
526 let pad = 1.0;
527 let line_h = 16.0;
528 (
529 label_x - pad,
530 label_y - pad,
531 label_x + text_width_dp + pad,
532 label_y + line_h + pad,
533 )
534 });
535
536 ZStack(
537 modifier
538 .min_height(OutlinedTextFieldDefaults::MIN_HEIGHT)
539 .min_width(OutlinedTextFieldDefaults::MIN_WIDTH),
540 )
541 .child((
542 Box(Modifier::new()
544 .fill_max_size()
545 .clip_rounded(th.shapes.small)
546 .background(container_bg)),
547 if has_label {
549 let mut bm = Modifier::new()
550 .fill_max_size()
551 .clip_rounded(th.shapes.small)
552 .border(border_w, border_color, th.shapes.small);
553 if let Some((l, t, r, b)) = label_cutout {
554 bm = bm.clip_rect(l, t, r, b, ClipOp::Difference);
555 }
556 Box(bm)
557 } else {
558 Box(Modifier::new()
559 .fill_max_size()
560 .clip_rounded(th.shapes.small)
561 .border(border_w, border_color, th.shapes.small))
562 },
563 Row(Modifier::new()
565 .fill_max_size()
566 .padding_values(PaddingValues {
567 left: 16.0,
568 right: 16.0,
569 top: top_pad,
570 bottom: bottom_pad,
571 })
572 .align_items(AlignItems::CENTER))
573 .child((
574 config.leading_icon.clone().unwrap_or(Box(Modifier::new())),
575 text_input,
576 config.trailing_icon.clone().unwrap_or(Box(Modifier::new())),
577 )),
578 if let Some(lbl) = label_str {
580 Box(Modifier::new()
581 .min_width(200.0)
582 .padding_values(PaddingValues {
583 left: label_x,
584 right: 20.0,
585 top: 0.0,
586 bottom: 0.0,
587 })
588 .absolute()
589 .offset(Some(0.0), Some(label_y), None, None))
590 .child(
591 Text(lbl.as_ref().to_string())
592 .color(label_color)
593 .size(label_size),
594 )
595 } else {
596 Box(Modifier::new())
597 },
598 ))
599}
600
601#[derive(Clone)]
603pub struct TextFieldConfig {
604 pub label: Option<String>,
605 pub placeholder: Option<String>,
606 pub leading_icon: Option<View>,
607 pub trailing_icon: Option<View>,
608 pub single_line: bool,
609 pub is_error: bool,
610 pub enabled: bool,
611 pub on_submit: Option<Rc<dyn Fn(String)>>,
612 pub colors: Option<TextFieldColors>,
613}
614
615impl Default for TextFieldConfig {
616 fn default() -> Self {
617 Self {
618 label: None,
619 placeholder: None,
620 leading_icon: None,
621 trailing_icon: None,
622 single_line: true,
623 is_error: false,
624 enabled: true,
625 on_submit: None,
626 colors: None,
627 }
628 }
629}
630
631pub fn TextField(
638 modifier: Modifier,
639 value: String,
640 on_value_change: impl Fn(String) + 'static,
641 config: TextFieldConfig,
642) -> View {
643 let th = theme();
644 let label_str: Option<Rc<str>> = config.label.map(Rc::from);
645 let has_label = label_str.is_some();
646
647 let anim_key = match &label_str {
648 Some(l) => format!("tf_{}", &l[..l.len().min(32)]),
649 None => "tf_nolabel".into(),
650 };
651
652 let focus_tracker: Rc<Cell<bool>> =
653 remember_with_key(format!("tf_focus_{}", anim_key), || Cell::new(false));
654 let is_focused = focus_tracker.get();
655 let should_float = !value.is_empty() || is_focused;
656
657 let float_t = animate_f32(
658 anim_key.clone(),
659 if should_float { 1.0 } else { 0.0 },
660 th.motion.color,
661 );
662
663 let (indicator_color, label_color, container_bg) = if let Some(ref tc) = config.colors {
664 let enf = config.enabled && is_focused;
665 let ind = tc.indicator_color(config.enabled, config.is_error, enf);
666 let lb = tc.label_color(config.enabled, config.is_error, enf);
667 let bg = tc.container_color(config.enabled, config.is_error, enf);
668 (ind, lb, bg)
669 } else {
670 let ind = if config.is_error {
671 th.error
672 } else if float_t > 0.5 {
673 th.primary
674 } else {
675 th.on_surface_variant
676 };
677 let lb = if config.is_error {
678 th.error
679 } else if float_t > 0.5 {
680 th.primary
681 } else {
682 th.on_surface_variant
683 };
684 let bg = if config.enabled {
685 th.surface_container_highest
686 } else {
687 th.on_surface
688 .with_alpha_f32(0.04)
689 .composite_over(th.surface)
690 };
691 (ind, lb, bg)
692 };
693
694 let label_size = 16.0 - 4.0 * float_t;
695
696 let label_start_y = (56.0 - 16.0) / 2.0;
697 let label_end_y = if has_label { 8.0 } else { 0.0 };
698 let label_y = label_start_y - (label_start_y - label_end_y) * float_t;
699
700 let label_start_x = if has_label { 24.0 } else { 0.0 };
701 let label_end_x = if has_label { 20.0 } else { 0.0 };
702 let label_x = label_start_x - (label_start_x - label_end_x) * float_t;
703
704 let tf_placeholder = if has_label {
705 if should_float {
706 config.placeholder.unwrap_or_default()
707 } else {
708 String::new()
709 }
710 } else {
711 config.placeholder.unwrap_or_default()
712 };
713
714 let indicator_active = config.is_error || (config.enabled && is_focused);
715 let indicator_target_w = if indicator_active { 2.0 } else { 1.0 };
716 let indicator_w = animate_f32(
717 format!("tf_ind_w_{}", anim_key),
718 indicator_target_w,
719 th.motion.color,
720 );
721
722 let (top_pad, bottom_pad) = if has_label { (8.0, 8.0) } else { (16.0, 16.0) };
723
724 Column(
725 modifier
726 .min_height(TextFieldDefaults::MIN_HEIGHT)
727 .min_width(TextFieldDefaults::MIN_WIDTH),
728 )
729 .child((
730 Box(Modifier::new()
732 .fill_max_size()
733 .clip_rounded(th.shapes.extra_small)
734 .background(container_bg))
735 .child(
736 Column(Modifier::new().fill_max_size()).child((
737 Row(Modifier::new()
739 .fill_max_size()
740 .padding_values(PaddingValues {
741 left: 16.0,
742 right: 16.0,
743 top: top_pad,
744 bottom: bottom_pad,
745 })
746 .align_items(AlignItems::CENTER))
747 .child((
748 config.leading_icon.unwrap_or(Box(Modifier::new())),
749 View::new(0, ViewKind::Box)
750 .modifier(
751 Modifier::new().flex_grow(1.0).text_input(TextInputConfig {
752 hint: tf_placeholder,
753 multiline: !config.single_line,
754 on_change: Some(Rc::new(on_value_change) as _),
755 on_submit: config.on_submit.clone().map(|f| {
756 let f = f.clone();
757 Rc::new(move |s| f(s)) as Rc<dyn Fn(String)>
758 }),
759 focus_tracker: Some(focus_tracker.clone()),
760 value: value.clone(),
761 visual_transformation: None,
762 keyboard_type: Default::default(),
763 capitalization: Default::default(),
764 ime_action: Default::default(),
765 auto_correct_enabled: None,
766 enabled: config.enabled,
767 read_only: false,
768 max_lines: None,
769 min_lines: 1,
770 cursor_color: config
771 .colors
772 .as_ref()
773 .map(|c| c.cursor_color(config.is_error)),
774 on_text_layout: None,
775 text_style: None,
776 keyboard_actions: None,
777 interaction_source: None,
778 line_limits: None,
779 }),
780 )
781 .semantics(Semantics {
782 role: Role::TextField,
783 label: None,
784 focused: false,
785 enabled: true,
786 selectable_group: false,
787 }),
788 config.trailing_icon.unwrap_or(Box(Modifier::new())),
789 )),
790 Box(Modifier::new()
792 .fill_max_width()
793 .height(indicator_w)
794 .absolute()
795 .offset(None, None, None, Some(0.0))
796 .background(indicator_color)),
797 )),
798 ),
799 if let Some(lbl) = label_str {
801 Box(Modifier::new()
802 .min_width(200.0)
803 .padding_values(PaddingValues {
804 left: label_x,
805 right: 20.0,
806 top: 0.0,
807 bottom: 0.0,
808 })
809 .absolute()
810 .offset(Some(0.0), Some(label_y), None, None))
811 .child(
812 Text(lbl.as_ref().to_string())
813 .color(label_color)
814 .size(label_size),
815 )
816 } else {
817 Box(Modifier::new())
818 },
819 ))
820}