Skip to main content

ferroui_themes_material/
text_field_layout.rs

1// Copyright 2026 FerroUI contributors.
2// SPDX-License-Identifier: Apache-2.0
3//! Material text-field layout, based on the component geometry in AndroidX
4//! `internal/TextFieldImpl.kt`, revision e4d933c98df7baa7ea2b83eea6e086915961e0ff.
5//! Layout remains in logical start/end coordinates; FerroUI's normal flow
6//! direction policy mirrors the panel and counter-mirrors text for RTL.
7
8use ferroui_base::input::InputElementImpl;
9use ferroui_base::interactivity::InteractiveImpl;
10use ferroui_base::layout::{Layoutable, LayoutableImpl};
11use ferroui_base::{
12    AttachedProperty, FerroObjectImpl, FerroProperty, Rect, Ref, Size, StyledElementImpl,
13    StyledProperty, Thickness, VisualImpl, ferro_class, ferro_class_info, ferro_impl_classes,
14    ferro_properties, instantiate,
15};
16use ferroui_controls::{Control, ControlImpl, Panel, PanelImpl, TextBlock};
17
18/// A slot of [`MaterialTextFieldLayout`]. Each slot has at most one direct child.
19#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
20pub enum MaterialTextFieldSlot {
21    Container,
22    Label,
23    Leading,
24    Trailing,
25    Prefix,
26    Suffix,
27    #[default]
28    Editor,
29    Placeholder,
30    Supporting,
31    InnerLeading,
32    InnerTrailing,
33    Clear,
34    Reveal,
35}
36ferroui_base::ferro_markup_enum!(
37    MaterialTextFieldSlot {
38        Container, Label, Leading, Trailing, Prefix, Suffix, Editor, Placeholder,
39        Supporting, InnerLeading, InnerTrailing, Clear, Reveal
40    },
41    { namespace: "FerroUI.Themes.Material" }
42);
43
44/// Arranges a real text editor together with Material decoration. In particular,
45/// outlined labels interpolate from the editor's start edge to the 16dp outline
46/// inset, independently of leading-icon and prefix widths. Supporting content
47/// contributes to the control's height without stretching the painted container.
48#[repr(C)]
49pub struct MaterialTextFieldLayout {
50    base: Panel,
51}
52ferro_class!(MaterialTextFieldLayout: Panel);
53ferro_class_info!(MaterialTextFieldLayout {
54    new: MaterialTextFieldLayout::new
55});
56ferro_impl_classes!(MaterialTextFieldLayout:
57    FerroObjectImpl, StyledElementImpl, VisualImpl, InteractiveImpl, InputElementImpl,
58    ControlImpl, PanelImpl
59);
60
61ferro_properties! { impl MaterialTextFieldLayout {
62    pub fn slot_property() -> AttachedProperty<MaterialTextFieldSlot> {
63        FerroProperty::register_attached::<MaterialTextFieldLayout, Control, _>(
64            "Slot", MaterialTextFieldSlot::Editor)
65    }
66    /// 0 is the resting label and 1 is minimized. A spatial spring may overshoot.
67    pub fn label_progress_property() -> StyledProperty<f64> {
68        FerroProperty::register::<MaterialTextFieldLayout, _>("LabelProgress", 0.0)
69    }
70    pub fn is_outlined_property() -> StyledProperty<bool> {
71        FerroProperty::register::<MaterialTextFieldLayout, _>("IsOutlined", false)
72    }
73    pub fn single_line_property() -> StyledProperty<bool> {
74        FerroProperty::register::<MaterialTextFieldLayout, _>("SingleLine", true)
75    }
76    pub fn content_padding_property() -> StyledProperty<Thickness> {
77        FerroProperty::register::<MaterialTextFieldLayout, _>("ContentPadding", Thickness::uniform(16.0))
78    }
79    pub fn container_min_height_property() -> StyledProperty<f64> {
80        FerroProperty::register::<MaterialTextFieldLayout, _>("ContainerMinHeight", 56.0)
81    }
82} }
83
84type Slots = [Option<Ref<Control>>; 13];
85
86impl MaterialTextFieldLayout {
87    fn static_constructor() {
88        Layoutable::affects_measure::<Self>(&[
89            Self::label_progress_property().as_property(),
90            Self::is_outlined_property().as_property(),
91            Self::single_line_property().as_property(),
92            Self::content_padding_property().as_property(),
93            Self::container_min_height_property().as_property(),
94        ]);
95        Panel::affects_parent_measure::<Self>(&[Self::slot_property().as_property()]);
96    }
97    pub fn construct() -> Self {
98        Self {
99            base: Panel::construct(),
100        }
101    }
102    pub fn new() -> Ref<Self> {
103        instantiate(Self::construct())
104    }
105    pub fn get_slot(child: &Control) -> MaterialTextFieldSlot {
106        child.get_value(Self::slot_property())
107    }
108    pub fn set_slot(child: &Control, value: MaterialTextFieldSlot) {
109        child.set_value(Self::slot_property(), value);
110    }
111    pub fn label_progress(&self) -> f64 {
112        self.get_value(Self::label_progress_property())
113    }
114    pub fn set_label_progress(&self, value: f64) {
115        self.set_value(Self::label_progress_property(), value);
116    }
117    pub fn is_outlined(&self) -> bool {
118        self.get_value(Self::is_outlined_property())
119    }
120    pub fn set_is_outlined(&self, value: bool) {
121        self.set_value(Self::is_outlined_property(), value);
122    }
123    pub fn single_line(&self) -> bool {
124        self.get_value(Self::single_line_property())
125    }
126    pub fn set_single_line(&self, value: bool) {
127        self.set_value(Self::single_line_property(), value);
128    }
129    pub fn content_padding(&self) -> Thickness {
130        self.get_value(Self::content_padding_property())
131    }
132    pub fn set_content_padding(&self, value: Thickness) {
133        self.set_value(Self::content_padding_property(), value);
134    }
135    pub fn container_min_height(&self) -> f64 {
136        self.get_value(Self::container_min_height_property())
137    }
138    pub fn set_container_min_height(&self, value: f64) {
139        self.set_value(Self::container_min_height_property(), value);
140    }
141
142    fn slots(&self) -> Slots {
143        let mut slots = std::array::from_fn(|_| None);
144        for child in self.children().snapshot().iter() {
145            let slot = Self::get_slot(&child) as usize;
146            slots[slot] = Some(child.clone());
147        }
148        slots
149    }
150
151    fn progress(&self) -> f64 {
152        let p = self.label_progress();
153        if p.is_finite() {
154            p.clamp(-0.25, 1.25)
155        } else {
156            0.0
157        }
158    }
159
160    fn label_typography(&self, slots: &Slots) {
161        if let Some(label) = slots[MaterialTextFieldSlot::Label as usize]
162            .as_ref()
163            .and_then(|label| label.cast::<TextBlock>())
164        {
165            let p = self.progress();
166            label.set_current_value(TextBlock::font_size_property(), 16.0 - 4.0 * p);
167            label.set_current_value(TextBlock::line_height_property(), 24.0 - 8.0 * p);
168            label.set_current_value(TextBlock::letter_spacing_property(), 0.5 - 0.1 * p);
169        }
170    }
171
172    fn padding(&self, slots: &Slots) -> Thickness {
173        use MaterialTextFieldSlot::*;
174        let p = self.content_padding();
175        let label_inset = if visible(slots, Label) && !self.is_outlined() {
176            8.0 * self.progress().clamp(0.0, 1.0)
177        } else {
178            0.0
179        };
180        Thickness::new(
181            (positive(p.left) - if visible(slots, Leading) { 12.0 } else { 0.0 }).max(0.0),
182            positive(p.top) + label_inset,
183            (positive(p.right) - if visible(slots, Trailing) { 12.0 } else { 0.0 }).max(0.0),
184            (positive(p.bottom) - label_inset).max(0.0),
185        )
186    }
187}
188
189impl LayoutableImpl for MaterialTextFieldLayout {
190    fn measure_override(this: &Self, available: Size) -> Size {
191        use MaterialTextFieldSlot::*;
192        let slots = this.slots();
193        this.label_typography(&slots);
194        let padding = this.padding(&slots);
195        let mut remaining = available.width.max(0.0);
196        let mut icons_width = 0.0_f64;
197        let mut icons_height = 0.0_f64;
198        for slot in [
199            InnerLeading,
200            Leading,
201            InnerTrailing,
202            Trailing,
203            Reveal,
204            Clear,
205        ] {
206            measure(&slots, slot, Size::new(remaining, available.height));
207            let size = desired(&slots, slot);
208            icons_width += size.width;
209            icons_height = icons_height.max(size.height);
210            remaining = (remaining - size.width).max(0.0);
211        }
212        let expanded_label_width = (remaining - padding.left - padding.right).max(0.0);
213        let label_width = if this.is_outlined() && available.width.is_finite() {
214            let minimized_width = (available.width
215                - positive(this.content_padding().left)
216                - positive(this.content_padding().right))
217            .max(0.0);
218            expanded_label_width
219                + (minimized_width - expanded_label_width) * this.progress().clamp(0.0, 1.0)
220        } else {
221            expanded_label_width
222        };
223        measure(&slots, Label, Size::new(label_width, available.height));
224        remaining = (remaining - padding.left - padding.right).max(0.0);
225        let mut affix_width = 0.0_f64;
226        for slot in [Prefix, Suffix] {
227            measure(&slots, slot, Size::new(remaining, available.height));
228            if visible(&slots, slot) {
229                let width = desired(&slots, slot).width + 2.0;
230                affix_width += width;
231                remaining = (remaining - width).max(0.0);
232            }
233        }
234        let editor_available = Size::new(
235            remaining,
236            (available.height - padding.top - padding.bottom).max(0.0),
237        );
238        measure(&slots, Editor, editor_available);
239        measure(&slots, Placeholder, editor_available);
240        let editor = desired(&slots, Editor);
241        let placeholder = desired(&slots, Placeholder);
242        let line_height = editor
243            .height
244            .max(placeholder.height)
245            .max(desired(&slots, Prefix).height)
246            .max(desired(&slots, Suffix).height)
247            .max(24.0);
248        let field_height = positive(this.container_min_height())
249            .max(icons_height)
250            .max(line_height + padding.top + padding.bottom);
251        let text_width = icons_width
252            + affix_width
253            + editor.width.max(placeholder.width)
254            + padding.left
255            + padding.right;
256        let label_width = desired(&slots, Label).width
257            + if this.is_outlined() {
258                positive(this.content_padding().left) + positive(this.content_padding().right)
259            } else {
260                icons_width + padding.left + padding.right
261            };
262        measure(&slots, Container, Size::new(available.width, field_height));
263        let width = text_width
264            .max(label_width)
265            .max(desired(&slots, Container).width);
266        let support_width = (available.width - 32.0).max(0.0);
267        measure(
268            &slots,
269            Supporting,
270            Size::new(support_width, available.height),
271        );
272        let supporting = desired(&slots, Supporting);
273        // Native cutout labels reserve half the minimized line height above the
274        // outline, even while resting, so adjacent controls never cover the label.
275        let top = if this.is_outlined() && visible(&slots, Label) {
276            8.0
277        } else {
278            0.0
279        };
280        let bottom = if visible(&slots, Supporting) {
281            supporting.height.max(16.0) + 4.0
282        } else {
283            0.0
284        };
285        Size::new(
286            width.max(if visible(&slots, Supporting) {
287                supporting.width + 32.0
288            } else {
289                0.0
290            }),
291            field_height + top + bottom,
292        )
293    }
294
295    fn arrange_override(this: &Self, final_size: Size) -> Size {
296        use MaterialTextFieldSlot::*;
297        let slots = this.slots();
298        let padding = this.padding(&slots);
299        let has_label = visible(&slots, Label);
300        let top = if this.is_outlined() && has_label {
301            8.0
302        } else {
303            0.0
304        };
305        let support_height = if visible(&slots, Supporting) {
306            desired(&slots, Supporting).height.max(16.0)
307        } else {
308            0.0
309        };
310        let support_gap = if visible(&slots, Supporting) {
311            4.0
312        } else {
313            0.0
314        };
315        let field_height = (final_size.height - top - support_height - support_gap).max(0.0);
316        arrange(
317            &slots,
318            Container,
319            Rect::new(0.0, top, final_size.width, field_height),
320        );
321        let mut start = 0.0;
322        let mut end = final_size.width;
323        for slot in [InnerLeading, Leading] {
324            let size = desired(&slots, slot);
325            arrange(
326                &slots,
327                slot,
328                Rect::new(
329                    start,
330                    top + (field_height - size.height) / 2.0,
331                    size.width,
332                    size.height,
333                ),
334            );
335            start += size.width;
336        }
337        for slot in [InnerTrailing, Trailing, Reveal, Clear] {
338            let size = desired(&slots, slot);
339            end -= size.width;
340            arrange(
341                &slots,
342                slot,
343                Rect::new(
344                    end,
345                    top + (field_height - size.height) / 2.0,
346                    size.width,
347                    size.height,
348                ),
349            );
350        }
351        let label_start = start + padding.left;
352        start += padding.left;
353        end -= padding.right;
354        let content_height = (field_height - padding.top - padding.bottom).max(0.0);
355        let y = top + padding.top;
356        for slot in [Prefix, Suffix] {
357            let size = desired(&slots, slot);
358            let slot_x = if slot == Prefix {
359                start
360            } else {
361                end - size.width
362            };
363            let slot_y = if this.single_line() {
364                y + (content_height - size.height) / 2.0
365            } else {
366                y
367            };
368            arrange(
369                &slots,
370                slot,
371                Rect::new(slot_x, slot_y, size.width, size.height),
372            );
373            if visible(&slots, slot) {
374                if slot == Prefix {
375                    start += size.width + 2.0;
376                } else {
377                    end -= size.width + 2.0;
378                }
379            }
380        }
381        let editor_rect = Rect::new(start, y, (end - start).max(0.0), content_height);
382        arrange(&slots, Editor, editor_rect);
383        arrange(&slots, Placeholder, editor_rect);
384        let label = desired(&slots, Label);
385        let p = this.progress();
386        let start_y = if this.single_line() {
387            (field_height - label.height) / 2.0
388        } else {
389            positive(this.content_padding().top)
390        };
391        let target_y = if this.is_outlined() {
392            -label.height / 2.0
393        } else {
394            8.0
395        };
396        let target_x = if this.is_outlined() {
397            positive(this.content_padding().left)
398        } else {
399            label_start
400        };
401        let label_rect = Rect::new(
402            label_start + (target_x - label_start) * p,
403            top + start_y + (target_y - start_y) * p,
404            label.width,
405            label.height,
406        );
407        arrange(&slots, Label, label_rect);
408        if let Some(border) = slots[Container as usize]
409            .as_ref()
410            .and_then(|control| control.cast::<crate::MaterialTextFieldBorder>())
411        {
412            border.set_notch_start(label_rect.x);
413            border.set_notch_width(label_rect.width);
414            border.set_notch_open(if this.is_outlined() && has_label {
415                p
416            } else {
417                0.0
418            });
419        }
420        arrange(
421            &slots,
422            Supporting,
423            Rect::new(
424                16.0,
425                top + field_height + support_gap,
426                (final_size.width - 32.0).max(0.0),
427                support_height,
428            ),
429        );
430        final_size
431    }
432}
433
434fn positive(value: f64) -> f64 {
435    if value.is_finite() {
436        value.max(0.0)
437    } else {
438        0.0
439    }
440}
441fn visible(slots: &Slots, slot: MaterialTextFieldSlot) -> bool {
442    slots[slot as usize]
443        .as_ref()
444        .is_some_and(|child| child.is_visible())
445}
446fn desired(slots: &Slots, slot: MaterialTextFieldSlot) -> Size {
447    slots[slot as usize]
448        .as_ref()
449        .map_or(Size::default(), |child| child.desired_size())
450}
451fn measure(slots: &Slots, slot: MaterialTextFieldSlot, available: Size) {
452    if let Some(child) = &slots[slot as usize] {
453        child.measure(available);
454    }
455}
456fn arrange(slots: &Slots, slot: MaterialTextFieldSlot, rect: Rect) {
457    if let Some(child) = &slots[slot as usize] {
458        child.arrange(rect);
459    }
460}