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                event,
407                visible_bounds,
408                cursor,
409            );
410
411            match event {
412                Event::Keyboard(iced_widget::core::keyboard::Event::KeyPressed { key, .. })
413                    if matches!(
414                        key.as_ref(),
415                        iced_widget::core::keyboard::Key::Named(
416                            iced_widget::core::keyboard::key::Named::Escape
417                        )
418                    ) =>
419                {
420                    state.is_focused = false;
421                    if state.clear_ime_preedit() {
422                        shell.request_redraw();
423                    }
424                }
425                Event::InputMethod(input_method::Event::Preedit(content, _)) => {
426                    if state.set_ime_preedit(content) {
427                        shell.request_redraw();
428                    }
429                }
430                Event::InputMethod(
431                    input_method::Event::Opened
432                    | input_method::Event::Closed
433                    | input_method::Event::Commit(_),
434                ) => {
435                    if state.clear_ime_preedit() {
436                        shell.request_redraw();
437                    }
438                }
439                Event::Window(window::Event::RedrawRequested(now)) => {
440                    if state.label_float.advance(*now) {
441                        shell.request_redraw();
442                    }
443                }
444                _ => {}
445            }
446
447            activation
448        };
449
450        update_mobile_text_input(
451            &mut self.input,
452            &mut tree.children[0],
453            event,
454            input_layout,
455            activation,
456            renderer,
457            clipboard,
458            shell,
459            viewport,
460        );
461
462        normalize_windows_ime_request(shell.input_method_mut(), bounds);
463
464        let is_focused = {
465            let input_state = tree.children[0]
466                .state
467                .downcast_ref::<iced_text_input::State<Renderer::Paragraph>>();
468
469            input_state.is_focused()
470        };
471
472        {
473            let state = tree
474                .state
475                .downcast_mut::<TextFieldState<Renderer::Paragraph>>();
476            state.is_focused = is_focused;
477
478            if was_focused != is_focused {
479                sync_mobile_keyboard(was_focused, is_focused, activation.request_mobile_keyboard);
480
481                shell.request_redraw();
482            }
483
484            let target = if self.label_mode == LabelMode::Floating {
485                bool_value(self.is_populated || state.is_focused)
486            } else {
487                0.0
488            };
489            state.label_float.set_target(
490                target,
491                Instant::now(),
492                duration_ms(tokens::component::text_field::LABEL_TRANSITION_DURATION_MS),
493                tokens::component::text_field::LABEL_TRANSITION_EASING,
494            );
495
496            if state.is_animating() {
497                shell.request_redraw();
498            }
499        }
500
501        if was_focused == is_focused {
502            sync_mobile_keyboard(was_focused, is_focused, activation.request_mobile_keyboard);
503        }
504    }
505
506    fn mouse_interaction(
507        &self,
508        tree: &Tree,
509        layout: Layout<'_>,
510        cursor: mouse::Cursor,
511        viewport: &Rectangle,
512        renderer: &Renderer,
513    ) -> mouse::Interaction {
514        self.input.mouse_interaction(
515            &tree.children[0],
516            layout.children().next().unwrap(),
517            cursor,
518            viewport,
519            renderer,
520        )
521    }
522
523    fn draw(
524        &self,
525        tree: &Tree,
526        renderer: &mut Renderer,
527        theme: &Theme,
528        defaults: &renderer::Style,
529        layout: Layout<'_>,
530        cursor: mouse::Cursor,
531        viewport: &Rectangle,
532    ) {
533        let state = tree
534            .state
535            .downcast_ref::<TextFieldState<Renderer::Paragraph>>();
536        let bounds = layout.bounds();
537        register_mobile_text_region(self.is_enabled, bounds, viewport);
538
539        let progress = if self.label_mode == LabelMode::Floating {
540            state.label_float.value.clamp(0.0, 1.0)
541        } else {
542            0.0
543        };
544        let is_hovered = cursor.is_over(bounds);
545        let (outline_color, outline_width, label_color) = status_style(
546            theme,
547            self.is_enabled,
548            self.is_error,
549            state.is_focused,
550            is_hovered,
551        );
552        let outline_color = alpha_color(outline_color, self.content_alpha);
553        let label_color = alpha_color(label_color, self.content_alpha);
554        let label_width = state.label.raw().min_bounds().width;
555        let floating_label_width = state.floating_label.raw().min_bounds().width;
556        let label_line_height = tokens::component::text_field::LABEL_TEXT_LINE_HEIGHT;
557        let floating_label_line_height =
558            tokens::component::text_field::LABEL_TEXT_POPULATED_LINE_HEIGHT;
559        let floating_label_y = -floating_label_line_height / 2.0;
560        let label_y = bounds.y
561            + lerp(
562                tokens::component::text_field::TOP_SPACE,
563                floating_label_y,
564                progress,
565            );
566        let label_x = bounds.x + tokens::component::text_field::LEADING_SPACE;
567        let label_notch = text_field_floating_label_notch(
568            bounds,
569            label_x,
570            label_width,
571            floating_label_width,
572            progress,
573        );
574
575        draw_text_field_outline(
576            renderer,
577            bounds,
578            Background::Color(Color::TRANSPARENT),
579            Border {
580                color: outline_color,
581                width: outline_width,
582                radius: tokens::component::text_field::CONTAINER_SHAPE.into(),
583            },
584            label_notch,
585        );
586
587        let input_layout = layout.children().next().unwrap();
588        let caretless_input;
589        let input = if state.ime_preedit_active && self.is_enabled && should_suppress_ime_caret() {
590            let content_alpha = self.content_alpha;
591            // Keep iced_winit's IME preedit overlay, but suppress iced's own
592            // blinking caret so composition does not show two insertion marks.
593            let mut input = IcedTextInput::new("", self.value.as_str())
594                .width(Length::Fill)
595                .padding(Padding {
596                    top: tokens::component::text_field::TOP_SPACE,
597                    right: tokens::component::text_field::TRAILING_SPACE,
598                    bottom: tokens::component::text_field::BOTTOM_SPACE,
599                    left: tokens::component::text_field::LEADING_SPACE,
600                })
601                .size(tokens::component::text_field::INPUT_TEXT_SIZE)
602                .line_height(absolute_line_height(
603                    tokens::component::text_field::INPUT_TEXT_LINE_HEIGHT,
604                ))
605                .style(move |theme, status| input_layer_style_alpha(theme, status, content_alpha));
606
607            if self.is_secure {
608                input = input.secure(true);
609            }
610
611            if let Some(font) = self.font {
612                input = input.font(font);
613            }
614
615            caretless_input = input;
616            &caretless_input
617        } else {
618            &self.input
619        };
620
621        <IcedTextInput<'_, Message, Theme, Renderer> as Widget<Message, Theme, Renderer>>::draw(
622            input,
623            &tree.children[0],
624            renderer,
625            theme,
626            defaults,
627            input_layout,
628            cursor,
629            viewport,
630        );
631
632        if self.label_mode == LabelMode::Placeholder && self.is_populated {
633            return;
634        }
635
636        if progress < 0.99 {
637            core_widget::text::draw(
638                renderer,
639                defaults,
640                Rectangle {
641                    x: label_x,
642                    y: label_y,
643                    width: label_width,
644                    height: label_line_height,
645                },
646                state.label.raw(),
647                core_widget::text::Style {
648                    color: Some(alpha_color(label_color, 1.0 - progress)),
649                },
650                viewport,
651            );
652        }
653
654        if progress > 0.01 {
655            core_widget::text::draw(
656                renderer,
657                defaults,
658                Rectangle {
659                    x: label_x,
660                    y: label_y,
661                    width: floating_label_width,
662                    height: floating_label_line_height,
663                },
664                state.floating_label.raw(),
665                core_widget::text::Style {
666                    color: Some(alpha_color(label_color, progress)),
667                },
668                viewport,
669            );
670        }
671    }
672}
673
674impl<'a, Message, Renderer> From<TextInput<'a, Message, Renderer>>
675    for Element<'a, Message, Theme, Renderer>
676where
677    Message: Clone + 'a,
678    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
679{
680    fn from(text_input: TextInput<'a, Message, Renderer>) -> Self {
681        Element::new(text_input)
682    }
683}
684
685pub fn outlined<'a, Message, Renderer>(
686    label: impl text::IntoFragment<'a>,
687    value: &str,
688) -> TextInput<'a, Message, Renderer>
689where
690    Message: Clone + 'a,
691    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
692{
693    TextInput::new(label, value)
694}
695
696pub fn outlined_floating<'a, Message, Renderer>(
697    label: impl text::IntoFragment<'a>,
698    value: &str,
699) -> TextInput<'a, Message, Renderer>
700where
701    Message: Clone + 'a,
702    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
703{
704    TextInput::new(label, value)
705}
706
707pub fn outlined_placeholder<'a, Message, Renderer>(
708    label: impl text::IntoFragment<'a>,
709    value: &str,
710) -> TextInput<'a, Message, Renderer>
711where
712    Message: Clone + 'a,
713    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
714{
715    TextInput::placeholder(label, value)
716}
717
718#[cfg(test)]
719#[path = "../../../tests/widget/component/text_input.rs"]
720mod tests;