Skip to main content

material_ui_rs/widget/component/
text_input.rs

1//! Material 3 outlined text field constructors with floating label support.
2
3use super::*;
4use iced_widget::core::text::Paragraph;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7enum LabelMode {
8    Placeholder,
9    Floating,
10}
11
12fn input_layer_style_alpha(
13    theme: &Theme,
14    status: iced_text_input::Status,
15    content_alpha: f32,
16) -> iced_text_input::Style {
17    let mut style = text_input_style::default(theme, status);
18
19    style.background = Background::Color(Color::TRANSPARENT);
20    style.border.width = 0.0;
21    style.border.color = Color::TRANSPARENT;
22    style.icon = alpha_color(style.icon, content_alpha);
23    style.placeholder = Color::TRANSPARENT;
24    style.value = alpha_color(style.value, content_alpha);
25    style.selection = alpha_color(style.selection, content_alpha);
26
27    style
28}
29
30fn status_style(
31    theme: &Theme,
32    is_enabled: bool,
33    is_error: bool,
34    is_focused: bool,
35    is_hovered: bool,
36) -> (Color, f32, Color) {
37    let colors = theme.colors();
38
39    if !is_enabled {
40        (
41            alpha_color(
42                colors.surface.text,
43                tokens::component::text_field::DISABLED_OUTLINE_OPACITY,
44            ),
45            tokens::component::text_field::OUTLINE_WIDTH,
46            alpha_color(
47                colors.surface.text,
48                tokens::component::text_field::DISABLED_LABEL_TEXT_OPACITY,
49            ),
50        )
51    } else if is_error {
52        let outline_width = if is_focused {
53            tokens::component::text_field::FOCUS_OUTLINE_WIDTH
54        } else if is_hovered {
55            tokens::component::text_field::HOVER_OUTLINE_WIDTH
56        } else {
57            tokens::component::text_field::OUTLINE_WIDTH
58        };
59
60        (colors.error.color, outline_width, colors.error.color)
61    } else if is_focused {
62        (
63            colors.primary.color,
64            tokens::component::text_field::FOCUS_OUTLINE_WIDTH,
65            colors.primary.color,
66        )
67    } else if is_hovered {
68        (
69            colors.surface.text,
70            tokens::component::text_field::HOVER_OUTLINE_WIDTH,
71            colors.surface.text,
72        )
73    } else {
74        (
75            colors.outline.color,
76            tokens::component::text_field::OUTLINE_WIDTH,
77            colors.surface.text_variant,
78        )
79    }
80}
81
82/// A Material 3 outlined text field with an animated floating label.
83pub struct TextInput<'a, Message, Renderer = iced_widget::Renderer>
84where
85    Renderer: iced_widget::core::Renderer + core_text::Renderer,
86{
87    label: text::Fragment<'a>,
88    value: String,
89    is_populated: bool,
90    is_enabled: bool,
91    is_secure: bool,
92    is_error: bool,
93    content_alpha: f32,
94    width: Length,
95    font: Option<Renderer::Font>,
96    label_mode: LabelMode,
97    input: IcedTextInput<'a, Message, Theme, Renderer>,
98}
99
100impl<Message, Renderer> std::fmt::Debug for TextInput<'_, Message, Renderer>
101where
102    Renderer: iced_widget::core::Renderer + core_text::Renderer,
103{
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        f.debug_struct("TextInput")
106            .field("is_populated", &self.is_populated)
107            .field("is_enabled", &self.is_enabled)
108            .field("is_secure", &self.is_secure)
109            .field("is_error", &self.is_error)
110            .field("content_alpha", &self.content_alpha)
111            .field("width", &self.width)
112            .field("label_mode", &self.label_mode)
113            .finish_non_exhaustive()
114    }
115}
116
117impl<'a, Message, Renderer> TextInput<'a, Message, Renderer>
118where
119    Message: Clone + 'a,
120    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
121{
122    pub fn new(label: impl text::IntoFragment<'a>, value: &str) -> Self {
123        Self::with_mode(label, value, LabelMode::Floating)
124    }
125
126    fn placeholder(label: impl text::IntoFragment<'a>, value: &str) -> Self {
127        Self::with_mode(label, value, LabelMode::Placeholder)
128    }
129
130    fn with_mode(label: impl text::IntoFragment<'a>, value: &str, label_mode: LabelMode) -> Self {
131        let input = IcedTextInput::new("", value)
132            .width(Length::Fill)
133            .padding(Padding {
134                top: tokens::component::text_field::TOP_SPACE,
135                right: tokens::component::text_field::TRAILING_SPACE,
136                bottom: tokens::component::text_field::BOTTOM_SPACE,
137                left: tokens::component::text_field::LEADING_SPACE,
138            })
139            .size(tokens::component::text_field::INPUT_TEXT_SIZE)
140            .line_height(absolute_line_height(
141                tokens::component::text_field::INPUT_TEXT_LINE_HEIGHT,
142            ))
143            .style(|theme, status| input_layer_style_alpha(theme, status, 1.0));
144
145        Self {
146            label: label.into_fragment(),
147            value: value.to_owned(),
148            is_populated: !value.is_empty(),
149            is_enabled: false,
150            is_secure: false,
151            is_error: false,
152            content_alpha: 1.0,
153            width: Length::Fill,
154            font: None,
155            label_mode,
156            input,
157        }
158    }
159
160    pub fn id(mut self, id: impl Into<core_widget::Id>) -> Self {
161        self.input = self.input.id(id);
162        self
163    }
164
165    pub fn secure(mut self, is_secure: bool) -> Self {
166        self.is_secure = is_secure;
167        self.input = self.input.secure(is_secure);
168        self
169    }
170
171    pub fn on_input(mut self, on_input: impl Fn(String) -> Message + 'a) -> Self {
172        self.is_enabled = true;
173        self.input = self.input.on_input(on_input);
174        self
175    }
176
177    pub fn on_input_maybe(mut self, on_input: Option<impl Fn(String) -> Message + 'a>) -> Self {
178        self.is_enabled = on_input.is_some();
179        self.input = self.input.on_input_maybe(on_input);
180        self
181    }
182
183    pub fn on_submit(mut self, message: Message) -> Self {
184        self.input = self.input.on_submit(message);
185        self
186    }
187
188    pub fn on_paste(mut self, on_paste: impl Fn(String) -> Message + 'a) -> Self {
189        self.input = self.input.on_paste(on_paste);
190        self
191    }
192
193    pub fn error(mut self, is_error: bool) -> Self {
194        self.is_error = is_error;
195        self
196    }
197
198    pub fn alpha(mut self, content_alpha: f32) -> Self {
199        let content_alpha = content_alpha.clamp(0.0, 1.0);
200
201        self.content_alpha = content_alpha;
202        self.input = self
203            .input
204            .style(move |theme, status| input_layer_style_alpha(theme, status, content_alpha));
205
206        self
207    }
208
209    pub fn width(mut self, width: impl Into<Length>) -> Self {
210        self.width = width.into();
211        self.input = self.input.width(self.width);
212        self
213    }
214
215    pub fn font(mut self, font: impl Into<Renderer::Font>) -> Self {
216        let font = font.into();
217        self.font = Some(font);
218        self.input = self.input.font(font);
219        self
220    }
221}
222
223impl<Message, Renderer> Widget<Message, Theme, Renderer> for TextInput<'_, Message, Renderer>
224where
225    Message: Clone,
226    Renderer: iced_widget::core::Renderer + core_text::Renderer,
227{
228    fn tag(&self) -> tree::Tag {
229        tree::Tag::of::<TextFieldState<Renderer::Paragraph>>()
230    }
231
232    fn state(&self) -> tree::State {
233        tree::State::new(TextFieldState::<Renderer::Paragraph>::new(
234            self.label_mode == LabelMode::Floating && self.is_populated,
235        ))
236    }
237
238    fn children(&self) -> Vec<Tree> {
239        let input: &dyn Widget<Message, Theme, Renderer> = &self.input;
240
241        vec![Tree::new(input)]
242    }
243
244    fn diff(&self, tree: &mut Tree) {
245        let state = tree
246            .state
247            .downcast_mut::<TextFieldState<Renderer::Paragraph>>();
248
249        let target = if self.label_mode == LabelMode::Floating {
250            bool_value(self.is_populated || state.is_focused)
251        } else {
252            0.0
253        };
254
255        state.label_float.set_target(
256            target,
257            Instant::now(),
258            duration_ms(tokens::component::text_field::LABEL_TRANSITION_DURATION_MS),
259            tokens::component::text_field::LABEL_TRANSITION_EASING,
260        );
261
262        if tree.children.is_empty() {
263            tree.children = self.children();
264        } else {
265            self.input.diff(&mut tree.children[0]);
266            tree.children.truncate(1);
267        }
268    }
269
270    fn size(&self) -> Size<Length> {
271        Size {
272            width: self.width,
273            height: Length::Fixed(tokens::component::text_field::CONTAINER_HEIGHT),
274        }
275    }
276
277    fn layout(
278        &mut self,
279        tree: &mut Tree,
280        renderer: &Renderer,
281        limits: &layout::Limits,
282    ) -> layout::Node {
283        let state = tree
284            .state
285            .downcast_mut::<TextFieldState<Renderer::Paragraph>>();
286
287        let label_size = tokens::component::text_field::LABEL_TEXT_SIZE;
288        let label_line_height = tokens::component::text_field::LABEL_TEXT_LINE_HEIGHT;
289        let floating_label_size = tokens::component::text_field::LABEL_TEXT_POPULATED_SIZE;
290        let floating_label_line_height =
291            tokens::component::text_field::LABEL_TEXT_POPULATED_LINE_HEIGHT;
292
293        let label_node = core_widget::text::layout(
294            &mut state.label,
295            renderer,
296            &layout::Limits::NONE,
297            self.label.as_ref(),
298            core_widget::text::Format {
299                width: Length::Shrink,
300                height: Length::Shrink,
301                line_height: absolute_line_height(label_line_height),
302                size: Some(Pixels(label_size)),
303                font: self.font,
304                align_x: text::Alignment::Default,
305                align_y: alignment::Vertical::Top,
306                shaping: text::Shaping::Advanced,
307                wrapping: text::Wrapping::None,
308            },
309        );
310
311        let floating_label_node = core_widget::text::layout(
312            &mut state.floating_label,
313            renderer,
314            &layout::Limits::NONE,
315            self.label.as_ref(),
316            core_widget::text::Format {
317                width: Length::Shrink,
318                height: Length::Shrink,
319                line_height: absolute_line_height(floating_label_line_height),
320                size: Some(Pixels(floating_label_size)),
321                font: self.font,
322                align_x: text::Alignment::Default,
323                align_y: alignment::Vertical::Top,
324                shaping: text::Shaping::Advanced,
325                wrapping: text::Wrapping::None,
326            },
327        );
328
329        let intrinsic = Size::new(
330            label_node
331                .size()
332                .width
333                .max(floating_label_node.size().width)
334                + tokens::component::text_field::LEADING_SPACE
335                + tokens::component::text_field::TRAILING_SPACE,
336            tokens::component::text_field::CONTAINER_HEIGHT,
337        );
338        let size = limits.resolve(
339            self.width,
340            Length::Fixed(tokens::component::text_field::CONTAINER_HEIGHT),
341            intrinsic,
342        );
343        let child_limits = layout::Limits::new(size, size);
344        let input = <IcedTextInput<'_, Message, Theme, Renderer> as Widget<
345            Message,
346            Theme,
347            Renderer,
348        >>::layout(
349            &mut self.input,
350            &mut tree.children[0],
351            renderer,
352            &child_limits,
353        );
354
355        layout::Node::with_children(size, vec![input.move_to(Point::ORIGIN)])
356    }
357
358    fn operate(
359        &mut self,
360        tree: &mut Tree,
361        layout: Layout<'_>,
362        renderer: &Renderer,
363        operation: &mut dyn core_widget::Operation,
364    ) {
365        operation.text(None, layout.bounds(), self.label.as_ref());
366        operation.traverse(&mut |operation| {
367            self.input.operate(
368                &mut tree.children[0],
369                layout.children().next().unwrap(),
370                renderer,
371                operation,
372            );
373        });
374    }
375
376    fn update(
377        &mut self,
378        tree: &mut Tree,
379        event: &Event,
380        layout: Layout<'_>,
381        cursor: mouse::Cursor,
382        renderer: &Renderer,
383        clipboard: &mut dyn Clipboard,
384        shell: &mut Shell<'_, Message>,
385        viewport: &Rectangle,
386    ) {
387        let bounds = layout.bounds();
388        let visible_bounds = bounds.intersection(viewport);
389        let input_layout = layout.children().next().unwrap();
390
391        let was_focused = {
392            let state = tree
393                .state
394                .downcast_ref::<TextFieldState<Renderer::Paragraph>>();
395
396            state.is_focused
397        };
398
399        let activation = {
400            let state = tree
401                .state
402                .downcast_mut::<TextFieldState<Renderer::Paragraph>>();
403            let activation = text_input_activation(
404                self.is_enabled,
405                &mut state.touch_activation,
406                &mut state.web_input_position,
407                event,
408                visible_bounds,
409                cursor,
410            );
411
412            match event {
413                Event::Keyboard(iced_widget::core::keyboard::Event::KeyPressed { key, .. })
414                    if matches!(
415                        key.as_ref(),
416                        iced_widget::core::keyboard::Key::Named(
417                            iced_widget::core::keyboard::key::Named::Escape
418                        )
419                    ) =>
420                {
421                    state.is_focused = false;
422                    if state.clear_ime_preedit() {
423                        shell.request_redraw();
424                    }
425                }
426                Event::InputMethod(input_method::Event::Preedit(content, _))
427                    if state.set_ime_preedit(content) =>
428                {
429                    shell.request_redraw();
430                }
431                Event::InputMethod(
432                    input_method::Event::Opened
433                    | input_method::Event::Closed
434                    | input_method::Event::Commit(_),
435                ) if state.clear_ime_preedit() => {
436                    shell.request_redraw();
437                }
438                Event::Window(window::Event::RedrawRequested(now))
439                    if state.label_float.advance(*now) =>
440                {
441                    shell.request_redraw();
442                }
443                _ => {}
444            }
445
446            activation
447        };
448
449        update_mobile_text_input(
450            &mut self.input,
451            &mut tree.children[0],
452            event,
453            input_layout,
454            activation,
455            TextInputUpdateContext {
456                renderer,
457                clipboard,
458                shell,
459                viewport,
460            },
461        );
462
463        normalize_windows_ime_request(shell.input_method_mut(), bounds);
464
465        let is_focused = {
466            let input_state = tree.children[0]
467                .state
468                .downcast_ref::<iced_text_input::State<Renderer::Paragraph>>();
469
470            input_state.is_focused()
471        };
472        let input_anchor = web_input_anchor(
473            shell.input_method(),
474            visible_bounds,
475            activation,
476            was_focused,
477            is_focused,
478        );
479
480        {
481            let state = tree
482                .state
483                .downcast_mut::<TextFieldState<Renderer::Paragraph>>();
484            state.is_focused = is_focused;
485
486            if was_focused != is_focused {
487                shell.request_redraw();
488            }
489
490            let target = if self.label_mode == LabelMode::Floating {
491                bool_value(self.is_populated || state.is_focused)
492            } else {
493                0.0
494            };
495            state.label_float.set_target(
496                target,
497                Instant::now(),
498                duration_ms(tokens::component::text_field::LABEL_TRANSITION_DURATION_MS),
499                tokens::component::text_field::LABEL_TRANSITION_EASING,
500            );
501
502            if state.is_animating() {
503                shell.request_redraw();
504            }
505        }
506
507        sync_mobile_keyboard(
508            was_focused,
509            is_focused,
510            activation.request_mobile_keyboard,
511            input_anchor,
512        );
513    }
514
515    fn mouse_interaction(
516        &self,
517        tree: &Tree,
518        layout: Layout<'_>,
519        cursor: mouse::Cursor,
520        viewport: &Rectangle,
521        renderer: &Renderer,
522    ) -> mouse::Interaction {
523        self.input.mouse_interaction(
524            &tree.children[0],
525            layout.children().next().unwrap(),
526            cursor,
527            viewport,
528            renderer,
529        )
530    }
531
532    fn draw(
533        &self,
534        tree: &Tree,
535        renderer: &mut Renderer,
536        theme: &Theme,
537        defaults: &renderer::Style,
538        layout: Layout<'_>,
539        cursor: mouse::Cursor,
540        viewport: &Rectangle,
541    ) {
542        let state = tree
543            .state
544            .downcast_ref::<TextFieldState<Renderer::Paragraph>>();
545        let bounds = layout.bounds();
546        register_mobile_text_region(self.is_enabled, bounds, viewport);
547
548        let progress = if self.label_mode == LabelMode::Floating {
549            state.label_float.value.clamp(0.0, 1.0)
550        } else {
551            0.0
552        };
553        let is_hovered = cursor.is_over(bounds);
554        let (outline_color, outline_width, label_color) = status_style(
555            theme,
556            self.is_enabled,
557            self.is_error,
558            state.is_focused,
559            is_hovered,
560        );
561        let outline_color = alpha_color(outline_color, self.content_alpha);
562        let label_color = alpha_color(label_color, self.content_alpha);
563        let label_width = state.label.raw().min_bounds().width;
564        let floating_label_width = state.floating_label.raw().min_bounds().width;
565        let label_line_height = tokens::component::text_field::LABEL_TEXT_LINE_HEIGHT;
566        let floating_label_line_height =
567            tokens::component::text_field::LABEL_TEXT_POPULATED_LINE_HEIGHT;
568        let floating_label_y = -floating_label_line_height / 2.0;
569        let label_y = bounds.y
570            + lerp(
571                tokens::component::text_field::TOP_SPACE,
572                floating_label_y,
573                progress,
574            );
575        let label_x = bounds.x + tokens::component::text_field::LEADING_SPACE;
576        let label_notch = text_field_floating_label_notch(
577            bounds,
578            label_x,
579            label_width,
580            floating_label_width,
581            progress,
582        );
583
584        draw_text_field_outline(
585            renderer,
586            bounds,
587            Background::Color(Color::TRANSPARENT),
588            Border {
589                color: outline_color,
590                width: outline_width,
591                radius: tokens::component::text_field::CONTAINER_SHAPE.into(),
592            },
593            label_notch,
594        );
595
596        let input_layout = layout.children().next().unwrap();
597        let caretless_input;
598        let input = if state.ime_preedit_active && self.is_enabled && should_suppress_ime_caret() {
599            let content_alpha = self.content_alpha;
600            // Keep iced_winit's IME preedit overlay, but suppress iced's own
601            // blinking caret so composition does not show two insertion marks.
602            let mut input = IcedTextInput::new("", self.value.as_str())
603                .width(Length::Fill)
604                .padding(Padding {
605                    top: tokens::component::text_field::TOP_SPACE,
606                    right: tokens::component::text_field::TRAILING_SPACE,
607                    bottom: tokens::component::text_field::BOTTOM_SPACE,
608                    left: tokens::component::text_field::LEADING_SPACE,
609                })
610                .size(tokens::component::text_field::INPUT_TEXT_SIZE)
611                .line_height(absolute_line_height(
612                    tokens::component::text_field::INPUT_TEXT_LINE_HEIGHT,
613                ))
614                .style(move |theme, status| input_layer_style_alpha(theme, status, content_alpha));
615
616            if self.is_secure {
617                input = input.secure(true);
618            }
619
620            if let Some(font) = self.font {
621                input = input.font(font);
622            }
623
624            caretless_input = input;
625            &caretless_input
626        } else {
627            &self.input
628        };
629
630        <IcedTextInput<'_, Message, Theme, Renderer> as Widget<Message, Theme, Renderer>>::draw(
631            input,
632            &tree.children[0],
633            renderer,
634            theme,
635            defaults,
636            input_layout,
637            cursor,
638            viewport,
639        );
640
641        if self.label_mode == LabelMode::Placeholder && self.is_populated {
642            return;
643        }
644
645        if progress < 0.99 {
646            core_widget::text::draw(
647                renderer,
648                defaults,
649                Rectangle {
650                    x: label_x,
651                    y: label_y,
652                    width: label_width,
653                    height: label_line_height,
654                },
655                state.label.raw(),
656                core_widget::text::Style {
657                    color: Some(alpha_color(label_color, 1.0 - progress)),
658                },
659                viewport,
660            );
661        }
662
663        if progress > 0.01 {
664            core_widget::text::draw(
665                renderer,
666                defaults,
667                Rectangle {
668                    x: label_x,
669                    y: label_y,
670                    width: floating_label_width,
671                    height: floating_label_line_height,
672                },
673                state.floating_label.raw(),
674                core_widget::text::Style {
675                    color: Some(alpha_color(label_color, progress)),
676                },
677                viewport,
678            );
679        }
680    }
681}
682
683impl<'a, Message, Renderer> From<TextInput<'a, Message, Renderer>>
684    for Element<'a, Message, Theme, Renderer>
685where
686    Message: Clone + 'a,
687    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
688{
689    fn from(text_input: TextInput<'a, Message, Renderer>) -> Self {
690        Element::new(text_input)
691    }
692}
693
694pub fn outlined<'a, Message, Renderer>(
695    label: impl text::IntoFragment<'a>,
696    value: &str,
697) -> TextInput<'a, Message, Renderer>
698where
699    Message: Clone + 'a,
700    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
701{
702    TextInput::new(label, value)
703}
704
705pub fn outlined_floating<'a, Message, Renderer>(
706    label: impl text::IntoFragment<'a>,
707    value: &str,
708) -> TextInput<'a, Message, Renderer>
709where
710    Message: Clone + 'a,
711    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
712{
713    TextInput::new(label, value)
714}
715
716pub fn outlined_placeholder<'a, Message, Renderer>(
717    label: impl text::IntoFragment<'a>,
718    value: &str,
719) -> TextInput<'a, Message, Renderer>
720where
721    Message: Clone + 'a,
722    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
723{
724    TextInput::placeholder(label, value)
725}
726
727#[cfg(test)]
728#[path = "../../../tests/widget/component/text_input.rs"]
729mod tests;