Skip to main content

frus_widgets/
textinput.rs

1//! [`TextField`]: a single-line input field, **controlled** (its value comes from
2//! the application state), with a caret, navigation and selection.
3//!
4//! The value is controlled; the **caret / selection** are edit state retained at
5//! runtime ([`Edit`]), keyed by widget identity.
6
7use frus_core::{Point, Rect, ResolvedTextStyle, Scene, TextAlign, TextStyle};
8use frus_layout::{Dimension, Style};
9use frus_text::TextLayout;
10
11use crate::autofill::AutofillHint;
12use crate::disabled::DISABLED_CONTENT_OPACITY;
13use crate::icons::IconData;
14use crate::ime::{Capitalization, Ime, KeyboardType, TextInputAction};
15use crate::interaction::{Key, Status};
16use crate::runtime::Edit;
17use crate::theme::Theme;
18use crate::widget::Widget;
19
20/// Padding either side of the content, both variants.
21pub const FIELD_PADDING_X: f32 = 12.0;
22/// Padding above and below the content in a [`filled`](TextField::filled) field.
23///
24/// The reference's Material 3 content padding is `(12, 8, 12, 8)` filled and
25/// `(12, 20, 12, 12)` outlined. The asymmetry is not decoration: an outlined field's
26/// floating label sits **on** the top border, so the top has to give it room, while a
27/// filled one floats its label inside the box.
28pub const FIELD_PADDING_Y: f32 = 8.0;
29/// Padding above the content in an [`outlined`](TextField::outlined) field.
30pub const FIELD_OUTLINED_PADDING_TOP: f32 = 20.0;
31/// Padding below the content in an outlined field.
32pub const FIELD_OUTLINED_PADDING_BOTTOM: f32 = 12.0;
33
34/// Padding above and below the content in a [`dense`](TextField::dense) filled field —
35/// the reference's `(12, 4, 12, 4)`.
36pub const FIELD_DENSE_PADDING_Y: f32 = 4.0;
37/// Padding above the content in a dense **outlined** field: the reference's
38/// `(12, 16, 12, 8)`, which keeps room for the label on the border while giving back
39/// everything else.
40pub const FIELD_DENSE_OUTLINED_PADDING_TOP: f32 = 16.0;
41/// Padding below the content in a dense outlined field.
42pub const FIELD_DENSE_OUTLINED_PADDING_BOTTOM: f32 = 8.0;
43
44/// Size of the value and of the resting label — the reference's `body_large`.
45pub const FIELD_TEXT_SIZE: f32 = 16.0;
46/// What the label shrinks to once it floats. The reference scales it rather than naming
47/// a second size, so a field given a larger type keeps the same proportion.
48pub const FIELD_LABEL_SCALE: f32 = 0.75;
49/// Size of the helper and error line — the reference's `body_small`.
50pub const FIELD_SUB_SIZE: f32 = 12.0;
51/// Vertical gap between the box and the helper/error line, and above a floating label.
52pub const FIELD_GAP: f32 = 4.0;
53/// Margin either side of the floating label inside the border's **notch**, which is the
54/// reference's `OutlineInputBorder.gapPadding`.
55pub const FIELD_NOTCH_GAP: f32 = 4.0;
56
57/// Corner radius of the box, and of a filled field's two top corners.
58pub const FIELD_RADIUS: f32 = 4.0;
59/// Border weight at rest, and once focused — the reference widens rather than recolours
60/// alone, which is what makes focus readable without colour.
61pub const FIELD_BORDER_WIDTH: f32 = 1.0;
62/// Border weight of a focused field.
63pub const FIELD_FOCUSED_BORDER_WIDTH: f32 = 2.0;
64
65/// Side of a prefix/suffix icon (logical px) and the margin around it.
66pub const FIELD_ICON_SIZE: f32 = 24.0;
67const ICON_PAD: f32 = 6.0;
68/// What a disabled field keeps of its colours.
69///
70/// An alias, kept for the name: the number is the framework's one disabled content
71/// opacity, [`DISABLED_CONTENT_OPACITY`], and a field's 38 % moving independently of
72/// every other control's would be a bug rather than a setting.
73pub const FIELD_DISABLED_OPACITY: f32 = DISABLED_CONTENT_OPACITY;
74/// Default masking character of a password field.
75const OBSCURE_CHAR: char = '•';
76
77/// Which of the reference's two fields this is.
78#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
79pub enum TextFieldVariant {
80    /// A box the caller can see all the way round, the floating label notching its top
81    /// border. The default here.
82    #[default]
83    Outlined,
84    /// A tinted container with a single line under it, the label floating **inside** the
85    /// box. The line carries the state; the fill carries the affordance.
86    Filled,
87    /// **No container at all** — the reference's `InputBorder.none`. No fill, no box, no
88    /// line: only the value, the hint and whatever icons were asked for.
89    ///
90    /// This is the field a widget puts *inside* something that is already a container. A
91    /// [`SearchBar`](crate::SearchBar) is a raised stadium with a field in it, and a field
92    /// that drew its own box inside that would be two containers deep with the outer one's
93    /// corners cut by the inner one's. The reference reaches for it in exactly those
94    /// places (`search_anchor.dart:1810`).
95    ///
96    /// It lays out like [`Filled`](Self::Filled) — the label floats inside rather than
97    /// notching a border, there being no border to notch — and simply paints nothing
98    /// behind the content.
99    None,
100}
101
102/// Everything a [`TextField`] paints, each answer the caller's, the theme's, or the
103/// framework's — resolved in that order.
104#[derive(Clone, Copy, Debug, Default)]
105pub struct TextFieldStyle {
106    /// Container fill. A filled field defaults to the theme's high surface container; an
107    /// outlined one to nothing at all.
108    pub fill: Option<frus_core::Color>,
109    /// The border, or the underline, at rest.
110    pub border_color: Option<frus_core::Color>,
111    /// The border once focused.
112    pub focused_border_color: Option<frus_core::Color>,
113    /// Border, label and helper colour while an error is showing.
114    pub error_color: Option<frus_core::Color>,
115    /// The same, **under the pointer**: an errored field deepens on hover
116    /// (`input_decorator.dart:5981`). Unset, the scheme's `on_error_container`.
117    pub error_hover_color: Option<frus_core::Color>,
118    /// The value's colour.
119    pub text_color: Option<frus_core::Color>,
120    /// The label and the hint, at rest.
121    pub label_color: Option<frus_core::Color>,
122    /// The label once focused.
123    pub focused_label_color: Option<frus_core::Color>,
124    /// The helper line.
125    pub helper_color: Option<frus_core::Color>,
126    /// The prefix and suffix icons.
127    pub icon_color: Option<frus_core::Color>,
128    /// The handles under a touch selection. Unset, the scheme's `primary`
129    /// (`text_selection.dart:83`).
130    pub handle_color: Option<frus_core::Color>,
131    /// Corner radius.
132    pub radius: Option<f32>,
133    /// Border weight at rest.
134    pub border_width: Option<f32>,
135    /// Border weight once focused.
136    pub focused_border_width: Option<f32>,
137    /// Side of the prefix/suffix icons.
138    pub icon_size: Option<f32>,
139    /// Padding either side of the content.
140    pub padding_x: Option<f32>,
141}
142
143/// A single-line text input field, with optional **form decoration** (label, hint,
144/// helper text, error). **Validity** stays decided by the application (a pure
145/// function of the state); the field only displays its result through [`error`].
146///
147/// [`error`]: TextField::error
148pub struct TextField<Msg = crate::callback::Callback> {
149    value: String,
150    size: f32,
151    width: Dimension,
152    on_input: Option<Box<dyn Fn(String) -> Msg>>,
153    on_submit: Option<Msg>,
154    /// What to tell the screen reader when `on_submit` fires — see [`TextField::announce`].
155    announce: Option<String>,
156    /// Label displayed above the field.
157    label: Option<String>,
158    /// Hint displayed **inside** the field while the value is empty.
159    placeholder: Option<String>,
160    /// Helper text below the field (hidden when an error is present).
161    helper: Option<String>,
162    /// Error message: when present, the border and the label switch to the error
163    /// colour and this text replaces the helper below the field.
164    error: Option<String>,
165    /// Masks the value (a password field): every character is rendered as
166    /// [`OBSCURE_CHAR`]. Editing acts on the real value; only the display changes.
167    obscure: bool,
168    /// Where the text sits inside the field. Single-line only; see
169    /// [`TextField::text_align`].
170    text_align: TextAlign,
171    /// Which keys the software keyboard offers; `None` = worked out from the field
172    /// (a masked field is a secret, a multi-line one takes newlines).
173    keyboard: Option<KeyboardType>,
174    /// What its action key does; `None` = likewise.
175    action: Option<TextInputAction>,
176    /// Decorative icon on the left inside the box.
177    prefix: Option<IconData>,
178    /// Decorative icon on the right inside the box.
179    suffix: Option<IconData>,
180    /// Message emitted on a **click on the suffix icon** (a clear / reveal button…). Makes
181    /// the suffix clickable: a click there emits this message instead of placing the caret.
182    suffix_action: Option<Msg>,
183    /// **Multi-line** field: Enter inserts a line break (instead of submitting), the box
184    /// is `rows` lines tall and scrolls vertically to follow the caret.
185    multiline: bool,
186    /// Number of visible lines in multi-line mode.
187    rows: u16,
188    /// Which of the reference's two fields to draw.
189    variant: TextFieldVariant,
190    /// A field that is shown but cannot be edited: greyed out, and inert. It still
191    /// displays its value — the reference's disabled field is readable, not hidden.
192    enabled: bool,
193    /// A **dense** field: the same shape, less room around the content. What a table's
194    /// inline cell editor wants, and what a form does not.
195    dense: bool,
196    /// The most characters the field will hold; see [`TextField::max_length`].
197    max_length: Option<usize>,
198    /// What a typed character becomes before it reaches the value; see
199    /// [`TextField::input_filter`].
200    input_filter: Option<Box<dyn Fn(char) -> Option<char>>>,
201    /// Which letters the software keyboard capitalises; see
202    /// [`TextField::capitalization`].
203    capitalization: Capitalization,
204    /// A field whose value can be read, selected and copied but not changed; see
205    /// [`TextField::read_only`].
206    read_only: bool,
207    /// Per-call overrides; everything unset falls to the theme, then the framework.
208    style: TextFieldStyle,
209    /// What the field is for, so the platform can fill it in; see
210    /// [`TextField::autofill`].
211    autofill: Vec<AutofillHint>,
212}
213
214impl TextField<crate::callback::Callback> {
215    /// Drives the field from `controller`: it shows the controller's text, and what is typed
216    /// in it is written to the controller.
217    ///
218    /// This sets the field's text and its `on_input`, so it replaces both. To do more when
219    /// the text changes, listen to the controller.
220    pub fn controller(mut self, controller: &crate::TextEditingController) -> Self {
221        self.value = controller.text();
222        let write = controller.clone();
223        self.on_input(move |text| write.set_text(text))
224    }
225}
226
227/// A "word" character (letter/digit/`_`) for word jumps (Ctrl+Arrow).
228fn is_word(c: char) -> bool {
229    c.is_alphanumeric() || c == '_'
230}
231
232/// Next word boundary **to the left** of `cursor`: skips the separators, then the word
233/// (editor behaviour — it stops at the **start** of the previous word).
234fn word_boundary_left(chars: &[char], cursor: usize) -> usize {
235    let mut i = cursor;
236    while i > 0 && !is_word(chars[i - 1]) {
237        i -= 1;
238    }
239    while i > 0 && is_word(chars[i - 1]) {
240        i -= 1;
241    }
242    i
243}
244
245/// Next word boundary **to the right**: skips the separators, then the word — it stops
246/// **after** the next word.
247fn word_boundary_right(chars: &[char], cursor: usize) -> usize {
248    let len = chars.len();
249    let mut i = cursor;
250    while i < len && !is_word(chars[i]) {
251        i += 1;
252    }
253    while i < len && is_word(chars[i]) {
254        i += 1;
255    }
256    i
257}
258
259/// Start of the **logical line** (after the previous `\n`, or 0) containing `cursor`.
260fn line_start(chars: &[char], cursor: usize) -> usize {
261    let mut i = cursor;
262    while i > 0 && chars[i - 1] != '\n' {
263        i -= 1;
264    }
265    i
266}
267
268/// End of the **logical line** (before the next `\n`, or the end) containing `cursor`.
269fn line_end(chars: &[char], cursor: usize) -> usize {
270    let len = chars.len();
271    let mut i = cursor;
272    while i < len && chars[i] != '\n' {
273        i += 1;
274    }
275    i
276}
277
278/// The side of a selection handle's box (`text_selection.dart:17`).
279const HANDLE_SIZE: f32 = 22.0;
280
281/// A selection handle's outline in `rect`: a disc with the corner nearest the text
282/// squared off, so that it points at its end of the selection — up and right for the
283/// start handle, which hangs to the left of its position, up and left for the end one.
284fn handle_path(rect: Rect, start: bool) -> frus_core::Path {
285    use std::f32::consts::{FRAC_PI_2, PI};
286    let r = rect.width * 0.5;
287    let centre = Point::new(rect.x + r, rect.y + r);
288    if start {
289        // From the squared corner, top right, down to the disc's rightmost point, then
290        // round the bottom and the left to its top.
291        frus_core::Path::new()
292            .move_to(Point::new(rect.x + rect.width, rect.y))
293            .line_to(Point::new(rect.x + rect.width, centre.y))
294            .arc_to(centre, r, 0.0, 3.0 * FRAC_PI_2)
295            .close()
296    } else {
297        // From the squared corner, top left, along to the disc's top, then round the
298        // right and the bottom to its leftmost point.
299        frus_core::Path::new()
300            .move_to(Point::new(rect.x, rect.y))
301            .line_to(Point::new(centre.x, rect.y))
302            .arc_to(centre, r, -FRAC_PI_2, PI)
303            .close()
304    }
305}
306
307/// Moves the caret to `target`, handling the selection anchor according to Shift.
308fn move_cursor(cursor: &mut usize, anchor: &mut Option<usize>, target: usize, shift: bool) {
309    if shift {
310        if anchor.is_none() {
311            *anchor = Some(*cursor);
312        }
313    } else {
314        *anchor = None;
315    }
316    *cursor = target;
317}
318
319impl<Msg> TextField<Msg> {
320    /// Creates a field displaying `value`.
321    pub fn new(value: impl Into<String>) -> Self {
322        Self {
323            value: value.into(),
324            size: FIELD_TEXT_SIZE,
325            width: Dimension::Length(220.0),
326            on_input: None,
327            on_submit: None,
328            announce: None,
329            label: None,
330            placeholder: None,
331            helper: None,
332            error: None,
333            obscure: false,
334            text_align: TextAlign::Start,
335            keyboard: None,
336            action: None,
337            prefix: None,
338            suffix: None,
339            suffix_action: None,
340            multiline: false,
341            rows: 3,
342            variant: TextFieldVariant::Outlined,
343            enabled: true,
344            dense: false,
345            max_length: None,
346            input_filter: None,
347            capitalization: Capitalization::Auto,
348            read_only: false,
349            style: TextFieldStyle::default(),
350            autofill: Vec::new(),
351        }
352    }
353
354    /// **Outlined**: a box the caller can see all the way round, the floating label
355    /// notching its top border. The default.
356    pub fn outlined(mut self) -> Self {
357        self.variant = TextFieldVariant::Outlined;
358        self
359    }
360
361    /// **Filled**: a tinted container with a single line under it, the label floating
362    /// inside the box rather than on its edge.
363    /// **No container**: no fill, no box, no line — the reference's `InputBorder.none`.
364    /// For a field inside something that is already a container. See
365    /// [`TextFieldVariant::None`].
366    pub fn borderless(mut self) -> Self {
367        self.variant = TextFieldVariant::None;
368        self
369    }
370
371    /// The **filled** variant: a tinted container with a single line under it, the label
372    /// floating inside. See [`TextFieldVariant::Filled`].
373    pub fn filled(mut self) -> Self {
374        self.variant = TextFieldVariant::Filled;
375        self
376    }
377
378    /// Chooses the variant directly, for a caller holding one in a variable.
379    pub fn variant(mut self, variant: TextFieldVariant) -> Self {
380        self.variant = variant;
381        self
382    }
383
384    /// Greys the field out and makes it inert. A disabled field still **shows** its
385    /// value: the reference dims it rather than hiding it, since it is usually the answer
386    /// to why the rest of a form is the way it is.
387    pub fn enabled(mut self, enabled: bool) -> Self {
388        self.enabled = enabled;
389        self
390    }
391
392    /// Overrides part of the styling for this field alone. Anything left `None` falls to
393    /// the theme's [`TextFieldTheme`](crate::widgettheme::TextFieldTheme), then to the
394    /// framework's defaults.
395    pub fn style(mut self, style: TextFieldStyle) -> Self {
396        self.style = style;
397        self
398    }
399
400    /// Tightens the field: the reference's `isDense`, which trades the comfortable
401    /// padding for a control that fits inside a table row. The shape, the label and the
402    /// border are unchanged — only the room around the content.
403    pub fn dense(mut self, dense: bool) -> Self {
404        self.dense = dense;
405        self
406    }
407
408    /// Whether this field is outlined (a box) rather than filled (a container and a line).
409    fn is_outlined(&self) -> bool {
410        self.variant == TextFieldVariant::Outlined
411    }
412
413    /// Whether this field paints **no container** — see [`TextFieldVariant::None`].
414    fn is_borderless(&self) -> bool {
415        self.variant == TextFieldVariant::None
416    }
417
418    /// Padding either side of the content.
419    fn pad_x(&self) -> f32 {
420        self.style.padding_x.unwrap_or(FIELD_PADDING_X)
421    }
422
423    /// Padding above the content: an outlined field gives its floating label room on the
424    /// border, a filled one floats it inside the box and needs none.
425    fn pad_top(&self) -> f32 {
426        match (self.is_outlined(), self.dense) {
427            (true, false) => FIELD_OUTLINED_PADDING_TOP,
428            (true, true) => FIELD_DENSE_OUTLINED_PADDING_TOP,
429            (false, false) => FIELD_PADDING_Y,
430            (false, true) => FIELD_DENSE_PADDING_Y,
431        }
432    }
433
434    /// Padding below the content.
435    fn pad_bottom(&self) -> f32 {
436        match (self.is_outlined(), self.dense) {
437            (true, false) => FIELD_OUTLINED_PADDING_BOTTOM,
438            (true, true) => FIELD_DENSE_OUTLINED_PADDING_BOTTOM,
439            (false, false) => FIELD_PADDING_Y,
440            (false, true) => FIELD_DENSE_PADDING_Y,
441        }
442    }
443
444    /// The field's type, **resolved once**: the reader's font setting applied.
445    ///
446    /// Everything that measures, shapes, hit-tests, places a caret or paints the field's
447    /// text goes through this one number. A caret placed from an unresolved size and
448    /// glyphs drawn from a resolved one land in different places, and the field is then
449    /// wrong for exactly the readers who most needed it to be right.
450    fn text_style(&self) -> ResolvedTextStyle {
451        TextStyle::new(self.size).resolved()
452    }
453
454    /// The helper line under the field — helper text, error, counter. See [`Self::text_style`].
455    fn sub_style(&self) -> ResolvedTextStyle {
456        TextStyle::new(FIELD_SUB_SIZE).resolved()
457    }
458
459    /// Size the label shrinks to once it floats — a proportion of the field's own type,
460    /// not a second number, so a field given larger text keeps the relationship.
461    fn label_size(&self) -> f32 {
462        self.text_style().size * FIELD_LABEL_SCALE
463    }
464
465    /// Side of the prefix/suffix icons.
466    fn icon_size(&self) -> f32 {
467        self.style.icon_size.unwrap_or(FIELD_ICON_SIZE)
468    }
469
470    /// Distance from the top of the box to the first line of text: the padding, plus the
471    /// room a filled field reserves for its floating label.
472    fn text_top(&self) -> f32 {
473        self.pad_top() + self.floating_label_height()
474    }
475
476    /// Room a **filled** field reserves above its content for the floating label. The
477    /// reference computes it as `4 + 0.75 × label size` rather than reserving a band
478    /// outside the box, which is why a filled field is taller than its text.
479    fn floating_label_height(&self) -> f32 {
480        if self.is_outlined() || self.label.is_none() {
481            0.0
482        } else {
483            FIELD_GAP + self.label_size()
484        }
485    }
486
487    /// This field's settings, resolved `caller ?? theme ?? framework`.
488    fn resolved(&self, theme: &Theme) -> TextFieldStyle {
489        let t = theme.widgets.text_field;
490        let pick = |a: Option<frus_core::Color>,
491                    b: Option<frus_core::Color>,
492                    c: frus_core::Color| { Some(a.or(b).unwrap_or(c)) };
493        TextFieldStyle {
494            fill: pick(
495                self.style.fill,
496                t.fill,
497                if self.is_outlined() || self.is_borderless() {
498                    frus_core::Color::TRANSPARENT
499                } else {
500                    // `input_decorator.dart:5968` — a filled field takes the most
501                    // emphasis a container has.
502                    theme.scheme.surface_container_highest
503                },
504            ),
505            border_color: pick(
506                self.style.border_color,
507                t.border_color,
508                if self.is_outlined() {
509                    theme.scheme.outline
510                } else {
511                    theme.scheme.on_surface_variant
512                },
513            ),
514            focused_border_color: pick(
515                self.style.focused_border_color,
516                t.focused_border_color,
517                theme.scheme.primary,
518            ),
519            error_color: pick(self.style.error_color, t.error_color, theme.scheme.error),
520            error_hover_color: pick(
521                self.style.error_hover_color,
522                t.error_hover_color,
523                theme.scheme.on_error_container,
524            ),
525            text_color: pick(self.style.text_color, t.text_color, theme.scheme.on_surface),
526            label_color: pick(
527                self.style.label_color,
528                t.label_color,
529                theme.scheme.on_surface_variant,
530            ),
531            focused_label_color: pick(
532                self.style.focused_label_color,
533                t.focused_label_color,
534                theme.scheme.primary,
535            ),
536            helper_color: pick(
537                self.style.helper_color,
538                t.helper_color,
539                theme.scheme.on_surface_variant,
540            ),
541            icon_color: pick(
542                self.style.icon_color,
543                t.icon_color,
544                theme.scheme.on_surface_variant,
545            ),
546            handle_color: pick(
547                self.style.handle_color,
548                t.handle_color,
549                theme.scheme.primary,
550            ),
551            radius: Some(self.style.radius.or(t.radius).unwrap_or(FIELD_RADIUS)),
552            border_width: Some(
553                self.style
554                    .border_width
555                    .or(t.border_width)
556                    .unwrap_or(FIELD_BORDER_WIDTH),
557            ),
558            focused_border_width: Some(
559                self.style
560                    .focused_border_width
561                    .or(t.focused_border_width)
562                    .unwrap_or(FIELD_FOCUSED_BORDER_WIDTH),
563            ),
564            icon_size: Some(self.icon_size()),
565            padding_x: Some(self.pad_x()),
566        }
567    }
568
569    /// What the field is **for** — a username, a new password, a one-time code, a postal
570    /// code — so that the platform's autofill service can fill it, and save it once the
571    /// form is submitted. See [`AutofillHint`].
572    ///
573    /// A list, most telling first: a sign-in box that takes an email address is both
574    /// `[AutofillHint::Username, AutofillHint::Email]`. The fields of one form belong in
575    /// an [`AutofillGroup`](crate::AutofillGroup), or a password is shown to the service
576    /// with no username to save it under.
577    ///
578    /// ```ignore
579    /// TextField::new(&app.code).autofill([AutofillHint::OneTimeCode])
580    /// ```
581    pub fn autofill(mut self, hints: impl IntoIterator<Item = AutofillHint>) -> Self {
582        self.autofill = hints.into_iter().collect();
583        self
584    }
585
586    /// Switches the field to **multi-line**: Enter inserts a line break (instead of
587    /// submitting), and the box shows `rows` lines (see [`rows`](Self::rows)).
588    pub fn multiline(mut self) -> Self {
589        self.multiline = true;
590        self
591    }
592
593    /// Number of visible lines in multi-line mode (at least 1). Implies
594    /// [`multiline`](Self::multiline).
595    pub fn rows(mut self, rows: u16) -> Self {
596        self.multiline = true;
597        self.rows = rows.max(1);
598        self
599    }
600
601    /// Masks the value (a password field): every character becomes a dot. Editing
602    /// stays normal; only the display is masked.
603    ///
604    /// It also changes what the **software keyboard** is told, unless
605    /// [`keyboard_type`](Self::keyboard_type) says otherwise: a masked field is a
606    /// [`Password`](KeyboardType::Password), which is what stops the keyboard learning
607    /// what is typed into it. The dots are drawn by us and tell the keyboard nothing.
608    pub fn obscure(mut self, obscure: bool) -> Self {
609        self.obscure = obscure;
610        self
611    }
612
613    /// Where the text sits inside the field: an amount to the right, a code centred.
614    ///
615    /// [`Start`](TextAlign::Start) — the default — follows the reading direction, so a
616    /// field is left-aligned in English and right-aligned in Arabic.
617    /// [`Left`](TextAlign::Left) and [`Right`](TextAlign::Right) do not: a column of
618    /// figures wants the same edge whatever the prose around it is doing.
619    ///
620    /// **Single-line fields only.** A multi-line one stays at the start, and that is a
621    /// boundary rather than an oversight: aligning wrapped text means moving each line
622    /// by its own width, and the caret and the click would then have to be told about
623    /// an offset that differs line by line. That belongs inside the text layout, where
624    /// the caret and the hit test already live, and not in a widget nudging a block
625    /// sideways behind their backs.
626    pub fn text_align(mut self, align: TextAlign) -> Self {
627        self.text_align = align;
628        self
629    }
630
631    /// How far the alignment pushes the text inside its content box.
632    ///
633    /// Zero unless the text is **narrower** than the box: there is nothing to
634    /// distribute otherwise, and a right-aligned line longer than its field would be
635    /// shoved off the left edge — the one edge whose text must stay put, since that
636    /// is where reading starts and where the horizontal scroll brings the caret back to.
637    ///
638    /// One function, used by the paint, the caret and the hit test alike. Three copies
639    /// of it would agree on every field until one of them did not, and the failure
640    /// would be a click landing several characters from where it was aimed.
641    ///
642    /// It takes **no direction**, and that is deliberate rather than forgotten.
643    /// [`Widget::cursor_at`] is handed a rectangle and nothing else — no theme, so no
644    /// reading direction — so a push that depended on one could be applied by the paint
645    /// and not by the click. Every field in a right-to-left application would then take
646    /// its caret several characters from the tap, including every field that never asked
647    /// to be aligned at all. A push both sides can compute is worth more than one that is
648    /// right in a place the other cannot reach.
649    ///
650    /// So `Start` is the left edge here and `End` the right. Following the reading
651    /// direction is the **text layout's** job, one layer down, where the caret and the
652    /// hit test already live and cannot disagree with the glyphs.
653    fn align_offset(&self, content_w: f32, text_w: f32) -> f32 {
654        if self.multiline {
655            return 0.0;
656        }
657        let slack = (content_w - text_w).max(0.0);
658        match self.text_align {
659            // Justification stretches the spaces between words, which a single line of
660            // input has no business doing: it would move the glyphs under the caret
661            // every time a space was typed.
662            TextAlign::Start | TextAlign::Left | TextAlign::Justify => 0.0,
663            TextAlign::End | TextAlign::Right => slack,
664            TextAlign::Center => slack / 2.0,
665        }
666    }
667
668    /// Which keys the software keyboard should offer — a phone keypad, an email
669    /// address, a number.
670    ///
671    /// Untold, it is worked out from the field: a masked field is a secret, a
672    /// multi-line one takes newlines, and anything else is ordinary text.
673    pub fn keyboard_type(mut self, keyboard: KeyboardType) -> Self {
674        self.keyboard = Some(keyboard);
675        self
676    }
677
678    /// What the keyboard's action key does, and what it says — *Next* in a form,
679    /// *Search* over a query, *Send* on a message.
680    ///
681    /// Untold, a multi-line field takes a newline and every other field says *Done*.
682    pub fn action(mut self, action: TextInputAction) -> Self {
683        self.action = Some(action);
684        self
685    }
686
687    /// The keyboard this field asks for, once the defaults have had their say.
688    fn ime_options(&self) -> Ime {
689        let keyboard = self.keyboard.unwrap_or({
690            // The order matters and only one way round makes sense: a masked field is
691            // a secret first and a text field second, and a multi-line secret is not a
692            // thing anybody types.
693            if self.obscure {
694                KeyboardType::Password
695            } else if self.multiline {
696                KeyboardType::Multiline
697            } else {
698                KeyboardType::Text
699            }
700        });
701        let action = self.action.unwrap_or(match keyboard {
702            // A field that takes several lines needs the key to *insert* one. Saying
703            // *Done* there is a keyboard that cannot type what the field is for.
704            KeyboardType::Multiline => TextInputAction::Newline,
705            _ => TextInputAction::Done,
706        });
707        Ime {
708            keyboard,
709            action,
710            capitalization: self.capitalization,
711        }
712    }
713
714    /// Decorative icon on the left inside the field.
715    pub fn prefix_icon(mut self, icon: IconData) -> Self {
716        self.prefix = Some(icon);
717        self
718    }
719
720    /// Decorative icon on the right inside the field.
721    pub fn suffix_icon(mut self, icon: IconData) -> Self {
722        self.suffix = Some(icon);
723        self
724    }
725
726    /// Makes the **suffix icon clickable**: a click on it emits `message` (a clear button,
727    /// a reveal-password button…) instead of placing the caret. Implies a suffix icon
728    /// (to be set through [`suffix_icon`](Self::suffix_icon)).
729    pub fn on_suffix(mut self, message: impl Into<Msg>) -> Self {
730        self.suffix_action = Some(message.into());
731        self
732    }
733
734    /// Does the local point `(x, y)` land on the **suffix's clickable zone** (on the right of
735    /// the input box)? Used both to route the click and to keep the caret out of it.
736    fn suffix_hit(&self, local_x: f32, local_y: f32, width: f32) -> bool {
737        if self.suffix.is_none() {
738            return false;
739        }
740        let zone_left = width - (self.icon_size() + ICON_PAD * 2.0);
741        let top = self.label_block();
742        let bottom = top + self.field_height();
743        local_x >= zone_left && local_y >= top && local_y <= bottom
744    }
745
746    /// Label displayed above the field.
747    pub fn label(mut self, label: impl Into<String>) -> Self {
748        self.label = Some(label.into());
749        self
750    }
751
752    /// Hint displayed inside the field while it is empty.
753    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
754        self.placeholder = Some(placeholder.into());
755        self
756    }
757
758    /// Helper text below the field (replaced by the error, if present).
759    pub fn helper(mut self, helper: impl Into<String>) -> Self {
760        self.helper = Some(helper.into());
761        self
762    }
763
764    /// Marks the field **in error**: red border and label, `message` displayed below
765    /// the field. Chain it only when the application's validation fails.
766    pub fn error(mut self, message: impl Into<String>) -> Self {
767        self.error = Some(message.into());
768        self
769    }
770
771    /// Sets the width, in logical pixels.
772    pub fn width(mut self, width: f32) -> Self {
773        self.width = Dimension::Length(width);
774        self
775    }
776
777    /// Sets the font size, in pixels.
778    pub fn size(mut self, size: f32) -> Self {
779        self.size = size;
780        self
781    }
782
783    /// The most characters the field will hold.
784    ///
785    /// It is **enforced**, as the reference's default is: a keystroke past the limit does
786    /// not reach the value, and neither does the tail of a paste that would cross it —
787    /// the part that fits still lands, because dropping a whole paste for being one
788    /// character too long loses work the user can see they had.
789    ///
790    /// It also puts a counter — `5/10` — at the end of the line below the box, where the
791    /// reference puts one, and reserves that line even when there is no helper text to
792    /// share it with.
793    ///
794    /// The limit is in **characters**, not bytes: "é" is one, and a name in an alphabet
795    /// that is not Latin is counted the way its writer would count it.
796    pub fn max_length(mut self, max: usize) -> Self {
797        self.max_length = Some(max);
798        self
799    }
800
801    /// What each typed character becomes before it reaches the value: `None` drops it,
802    /// `Some(c)` puts `c` in instead of what was typed.
803    ///
804    /// The reference's `inputFormatters` reshape the **whole value** after every
805    /// keystroke, which buys grouping (spaces every four digits of a card number) and
806    /// costs a caret: the formatter has to say where it went, and getting that wrong
807    /// puts the cursor in the middle of a group the reader did not touch. A filter that
808    /// works one character at a time cannot group, and cannot lose the caret either
809    /// — a dropped character simply never arrives, and a substituted one takes the same
810    /// place. That covers digits-only, letters-only, no-spaces and forced case, which is
811    /// nearly every field that filters at all.
812    ///
813    /// It applies to what is **typed or pasted**, and a paste keeps whatever it can
814    /// rather than being refused whole — the same rule
815    /// [`max_length`](TextField::max_length) follows, for the same reason.
816    ///
817    /// A value the **caller** supplied is left alone: it is the application's state, not
818    /// something typed, and rewriting it would be editing a value nobody edited.
819    ///
820    /// ```ignore
821    /// TextField::new(&app.code).input_filter(|c| c.to_uppercase().next())
822    /// TextField::new(&app.tag).input_filter(|c| (!c.is_whitespace()).then_some(c))
823    /// ```
824    pub fn input_filter(mut self, filter: impl Fn(char) -> Option<char> + 'static) -> Self {
825        self.input_filter = Some(Box::new(filter));
826        self
827    }
828
829    /// Digits, and nothing else.
830    ///
831    /// It also asks for a **number keypad**, unless one was named already: a field that
832    /// refuses everything but digits and then opens a QWERTY keyboard is a field whose
833    /// keys mostly do nothing. Naming a keyboard before or after this leaves that
834    /// choice standing, so the two builders can be written in either order.
835    pub fn digits_only(mut self) -> Self {
836        self.input_filter = Some(Box::new(|c| c.is_ascii_digit().then_some(c)));
837        self.keyboard = self.keyboard.or(Some(KeyboardType::Number));
838        self
839    }
840
841    /// Which letters the software keyboard capitalises by itself.
842    ///
843    /// A **hint**: it is what the keyboard does to what it sends, and a reader who turns
844    /// it off still types what they meant to. A field that must hold capitals wants
845    /// [`input_filter`](TextField::input_filter) as well, which works whatever the
846    /// keyboard does and works on a desktop, where there is no keyboard to hint to.
847    pub fn capitalization(mut self, capitalization: Capitalization) -> Self {
848        self.capitalization = capitalization;
849        self
850    }
851
852    /// A field whose value can be read, selected and copied — but not changed.
853    ///
854    /// It is **not** [`enabled(false)`](TextField::enabled), and the difference matters.
855    /// A disabled field is greyed out and inert: out of the tab order, no caret, nothing
856    /// to select. A read-only one looks and behaves like any other field except that
857    /// typing does nothing — you can focus it, move the caret through it, select a
858    /// reference number and copy it. That is what an identifier the application generated
859    /// wants, and greying it out would suggest it is unavailable rather than fixed.
860    pub fn read_only(mut self) -> Self {
861        self.read_only = true;
862        self
863    }
864
865    /// Closure producing a message from the field's new value.
866    pub fn on_input<R: crate::callback::IntoMsg<Msg>>(
867        mut self,
868        on_input: impl Fn(String) -> R + 'static,
869    ) -> Self {
870        self.on_input = Some(Box::new(crate::callback::handler1(on_input)));
871        self
872    }
873
874    /// Message emitted on submission (the Enter key), without changing the value.
875    pub fn on_submit(mut self, message: impl Into<Msg>) -> Self {
876        self.on_submit = Some(message.into());
877        self
878    }
879
880    /// Text to speak through the screen reader's live region when [`Self::on_submit`]
881    /// fires — the field's own counterpart to a button's [`Widget::announce`](
882    /// crate::Widget::announce), which only ever fires on a click or a plain Enter and
883    /// so never sees a field's submit (#18). Compute it from the value the caller
884    /// already has — `TextField::new(draft).announce(format!("{draft} added"))` — since
885    /// by the time this fires, the widget it is read from is the one built **before**
886    /// the keystroke, still holding what is about to be cleared.
887    pub fn announce(mut self, message: impl Into<String>) -> Self {
888        self.announce = Some(message.into());
889        self
890    }
891
892    /// The **displayed** string: masked (one dot per character) for a password field,
893    /// the value as is otherwise. Same character count as the value → the caret and
894    /// the hit-test stay aligned index for index.
895    fn display(&self) -> String {
896        if self.obscure {
897            OBSCURE_CHAR.to_string().repeat(self.value.chars().count())
898        } else {
899            self.value.clone()
900        }
901    }
902
903    /// The **displayed** string shaped once: caret by index, hit-test, selection — one
904    /// consistent geometry (kerning). `wrap_width` = the soft-wrap width (multi-line) or
905    /// `None` (single-line: only explicit `\n` break).
906    fn layout(&self, wrap_width: Option<f32>) -> TextLayout {
907        let style = self.text_style();
908        TextLayout::wrapped(
909            &self.display(),
910            style.size,
911            style.weight,
912            style.italic,
913            wrap_width,
914        )
915    }
916
917    /// Width reserved for the prefix icon (0 if there is none).
918    fn prefix_w(&self) -> f32 {
919        if self.prefix.is_some() {
920            self.icon_size() + ICON_PAD
921        } else {
922            0.0
923        }
924    }
925
926    /// Width reserved for the suffix icon (0 if there is none).
927    fn suffix_w(&self) -> f32 {
928        if self.suffix.is_some() {
929            self.icon_size() + ICON_PAD
930        } else {
931            0.0
932        }
933    }
934
935    /// Text width (between the padding and the icons) for a given widget width.
936    /// The two selection handles for `edit`, in local coordinates — see
937    /// [`Widget::selection_handles`]. The paint's own geometry: the content's insets, the
938    /// alignment, the horizontal scroll that follows the caret and the retained vertical
939    /// one, so that a handle is taken exactly where it is drawn.
940    fn handles(
941        &self,
942        width: f32,
943        edit: &Edit,
944        scroll_y: f32,
945    ) -> Option<[crate::SelectionHandle; 2]> {
946        let len = self.value.chars().count();
947        let (start, end) = edit.selection_range()?;
948        let (start, end) = (start.min(len), end.min(len));
949        if start >= end {
950            return None;
951        }
952        let content_w = self.content_width(width);
953        let layout = self.layout(self.multiline.then_some(content_w));
954        let scroll = (layout.caret_rect(edit.cursor.min(len)).x - content_w).max(0.0);
955        let align = self.align_offset(content_w, layout.size().width);
956        let vscroll = if self.multiline {
957            let visible = self.field_height() - self.text_top() - self.pad_bottom();
958            scroll_y.clamp(0.0, (layout.size().height - visible).max(0.0))
959        } else {
960            0.0
961        };
962        let origin_x = self.pad_x() + self.prefix_w() + align - scroll;
963        let origin_y = self.label_block() + self.text_top() - vscroll;
964        let at = |index: usize, start: bool| {
965            let caret = layout.caret_rect(index);
966            let x = origin_x + caret.x;
967            let top = origin_y + caret.y;
968            crate::SelectionHandle {
969                // The start handle hangs to the left of its position and the end one to
970                // the right (`text_selection.dart:115`), so that both point in at the
971                // text between them.
972                rect: Rect::new(
973                    if start { x - HANDLE_SIZE } else { x },
974                    top + caret.height,
975                    HANDLE_SIZE,
976                    HANDLE_SIZE,
977                ),
978                line_center: Point::new(x, top + caret.height * 0.5),
979            }
980        };
981        Some([at(start, true), at(end, false)])
982    }
983
984    fn content_width(&self, width: f32) -> f32 {
985        (width - (self.pad_x() + self.prefix_w()) - self.pad_x() - self.suffix_w()).max(0.0)
986    }
987
988    /// Height reserved for the label above the box (0 if there is no label). In
989    /// `outlined` mode the floating label straddles the top border: only its **upper
990    /// half** must be reserved (the rest bites into the box), instead of a full band.
991    fn label_block(&self) -> f32 {
992        if self.label.is_some() {
993            if self.is_outlined() {
994                (frus_text::line_height(self.label_size()) * 0.5).ceil()
995            } else {
996                frus_text::line_height(self.label_size()) + FIELD_GAP
997            }
998        } else {
999            0.0
1000        }
1001    }
1002
1003    /// The counter shown at the end of the line below the box, when there is a limit.
1004    fn counter(&self) -> Option<String> {
1005        self.max_length
1006            .map(|max| format!("{}/{}", self.value.chars().count(), max))
1007    }
1008
1009    /// Height reserved for the helper/error line below the box (0 if there is none).
1010    fn sub_block(&self) -> f32 {
1011        if self.error.is_some() || self.helper.is_some() || self.max_length.is_some() {
1012            // The helper style's **own** line, not one recomputed from its size: another
1013            // of milestone 412's survivors, written against a bare constant rather than a
1014            // style, which is the one formulation that sweep could not find.
1015            self.sub_style().line_height() + FIELD_GAP
1016        } else {
1017            0.0
1018        }
1019    }
1020
1021    /// Height of the input box itself (decoration excluded): one line in single-line
1022    /// mode, `rows` lines in multi-line mode.
1023    fn field_height(&self) -> f32 {
1024        let lines = if self.multiline {
1025            self.rows.max(1) as f32
1026        } else {
1027            1.0
1028        };
1029        (self.text_style().line_height() * lines + self.text_top() + self.pad_bottom()).ceil()
1030    }
1031}
1032
1033impl<Msg: Clone> Widget<Msg> for TextField<Msg> {
1034    fn style(&self) -> Style {
1035        let height = self.label_block() + self.field_height() + self.sub_block();
1036        Style {
1037            width: self.width,
1038            height: Dimension::Length(height),
1039            ..Default::default()
1040        }
1041    }
1042
1043    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1044        &[]
1045    }
1046
1047    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
1048        let s = self.resolved(theme);
1049        // A disabled field keeps 38% of everything, following the reference, and never
1050        // shows a focus: it cannot be focused, so a focus ring left over from before it
1051        // was disabled would be a lie about what Tab will do.
1052        let o = if self.enabled {
1053            status.opacity
1054        } else {
1055            status.opacity * FIELD_DISABLED_OPACITY
1056        };
1057        let has_error = self.error.is_some() && self.enabled;
1058        let fp = if self.enabled {
1059            status.focus_progress.clamp(0.0, 1.0)
1060        } else {
1061            0.0
1062        };
1063        // **An errored field deepens under the pointer** (`input_decorator.dart:5981`,
1064        // `:6004`, `:6053`): `error` at rest, `on_error_container` while hovered — and
1065        // back to `error` once focused, because the reference tests focus **before**
1066        // hover and a focused field is already saying everything it can.
1067        //
1068        // Continuous where the reference is discrete, which is this framework's habit
1069        // with the pointer: `hover_progress` is a progression, not a flag.
1070        let error_ink = s.error_color.unwrap().lerp(s.error_hover_color.unwrap(), {
1071            let hover = if self.enabled {
1072                status.hover_progress.clamp(0.0, 1.0)
1073            } else {
1074                0.0
1075            };
1076            hover * (1.0 - fp)
1077        });
1078
1079        // Decoration: label above, input box in the middle, helper/error below. The
1080        // box is the sub-rectangle where all the editing lives.
1081        let label_block = self.label_block();
1082        let field = Rect::new(
1083            bounds.x,
1084            bounds.y + label_block,
1085            bounds.width,
1086            self.field_height(),
1087        );
1088
1089        // **Floating** label: at rest (an empty, unfocused field) it occupies the box,
1090        // in the hint's place; focused OR filled, it floats above, shrunk. The
1091        // position/size progress follows that "floating" (`t`); the **colour** follows
1092        // the focus (`fp`) — a filled but unfocused field keeps a discreet label, not
1093        // yet accented.
1094        let float_t = if self.value.is_empty() { fp } else { 1.0 };
1095        let content_left = field.x + self.pad_x() + self.prefix_w();
1096        // Geometry of the floating label, interpolated between **rest** (inside the box,
1097        // in the hint's place) and the **floated target**. That target differs with the
1098        // style: `outlined` → on the top border (with a notch); otherwise → a reserved band.
1099        let label_geom = self.label.as_ref().map(|label| {
1100            let rest = Point::new(content_left, field.y + self.text_top());
1101            let (fx, fy) = if self.is_outlined() {
1102                // On the top border, which the notch opens behind it.
1103                (
1104                    field.x + self.pad_x(),
1105                    field.y - frus_text::line_height(self.label_size()) * 0.5,
1106                )
1107            } else {
1108                // Inside the container, in the room reserved above the content — a filled
1109                // field never spills outside its own box.
1110                (content_left, field.y + self.pad_top())
1111            };
1112            let x = rest.x + (fx - rest.x) * float_t;
1113            let y = rest.y + (fy - rest.y) * float_t;
1114            let resolved = self.text_style().size;
1115            let size = resolved + (self.label_size() - resolved) * float_t;
1116            let color = if has_error {
1117                error_ink
1118            } else {
1119                s.label_color
1120                    .unwrap()
1121                    .lerp(s.focused_label_color.unwrap(), fp)
1122            };
1123            (label.clone(), x, y, size, color)
1124        });
1125        // Helper/error line below the box (the error takes precedence over the helper).
1126        let sub = self.error.as_ref().or(self.helper.as_ref());
1127        if let Some(sub) = sub {
1128            // The message itself does **not** deepen: `errorStyle` is `error` in every
1129            // state (`input_decorator.dart:6100`). It is a sentence, not a control, and
1130            // a sentence that changed colour under the pointer would be claiming to be
1131            // one.
1132            let color = if has_error {
1133                s.error_color.unwrap()
1134            } else {
1135                s.helper_color.unwrap()
1136            };
1137            scene.text(
1138                Point::new(bounds.x, field.y + field.height + FIELD_GAP),
1139                sub.clone(),
1140                &self.sub_style(),
1141                color.fade(o),
1142            );
1143        }
1144        // The counter shares that line, pushed to its far end — the reference's place for
1145        // it, and the one that leaves the helper text where it was. It takes the helper's
1146        // colour rather than the error's even while an error is showing: it is a fact
1147        // about length, not a second complaint.
1148        if let Some(counter) = self.counter() {
1149            let width = frus_text::measure_resolved(&counter, &self.sub_style()).width;
1150            scene.text(
1151                Point::new(
1152                    bounds.x + bounds.width - width,
1153                    field.y + field.height + FIELD_GAP,
1154                ),
1155                counter,
1156                &self.sub_style(),
1157                s.helper_color.unwrap().fade(o),
1158            );
1159        }
1160
1161        // The border follows the focus in **two** ways, as the reference does: the colour
1162        // moves to the accent and the weight goes from 1 to 2. Either alone is legible;
1163        // both together is what makes a focused field unmistakable without relying on
1164        // colour, which not every reader has.
1165        let border_color = if has_error {
1166            error_ink
1167        } else {
1168            s.border_color
1169                .unwrap()
1170                .lerp(s.focused_border_color.unwrap(), fp)
1171        }
1172        .fade(o);
1173        let rest_w = s.border_width.unwrap();
1174        let border_width = rest_w + (s.focused_border_width.unwrap() - rest_w) * fp;
1175        let radius = s.radius.unwrap();
1176        let fill = s.fill.unwrap();
1177        if self.is_borderless() {
1178            // **Nothing.** The container belongs to whatever this field was put inside.
1179        } else if self.is_outlined() {
1180            scene.draw_rect(field, fill.fade(o), radius, border_width, border_color);
1181        } else {
1182            // Filled: a container with its **top** corners rounded, and a single line
1183            // under it. A box all the way round would be the other variant wearing a fill.
1184            scene.draw_rect(
1185                field,
1186                fill.fade(o),
1187                frus_core::BorderRadius {
1188                    top_left: radius,
1189                    top_right: radius,
1190                    bottom_right: 0.0,
1191                    bottom_left: 0.0,
1192                },
1193                0.0,
1194                frus_core::Color::TRANSPARENT,
1195            );
1196            scene.fill_rect(
1197                Rect::new(
1198                    field.x,
1199                    field.y + field.height - border_width,
1200                    field.width,
1201                    border_width,
1202                ),
1203                border_color,
1204            );
1205        }
1206
1207        // The label goes **after** the box, in both styles. Floated, it sits above the
1208        // box and the order would not matter; at rest it sits *inside* it, in the
1209        // hint's place, over an opaque surface. It used to be painted first and
1210        // survived only because the renderer drew all text above everything — which it
1211        // stopped doing in milestone 295, and the golden went blank.
1212        if !self.is_outlined() {
1213            if let Some((label, x, y, size, color)) = &label_geom {
1214                // `exact`: `size` is already an interpolation between two **resolved**
1215                // numbers, so resolving it again would apply the reader's setting twice.
1216                let style = ResolvedTextStyle::exact(*size);
1217                scene.text(Point::new(*x, *y), label.clone(), &style, color.fade(o));
1218            }
1219        }
1220
1221        // Outlined: the label's **notch**. The border segment behind the floating label
1222        // is masked by a flat surface-coloured fill, then the label is painted on top.
1223        // The notch only opens as the label rises (`float_t`).
1224        if self.is_outlined() {
1225            if let Some((label, x, y, size, color)) = &label_geom {
1226                if float_t > 0.01 {
1227                    let label_w = frus_text::measure(label, self.label_size()).width;
1228                    let notch = Rect::new(
1229                        *x - FIELD_NOTCH_GAP,
1230                        field.y - (border_width + FIELD_NOTCH_GAP) * 0.5,
1231                        label_w + FIELD_NOTCH_GAP * 2.0,
1232                        border_width + FIELD_NOTCH_GAP,
1233                    );
1234                    scene.fill_rect(notch, theme.surface.fade(o * float_t));
1235                }
1236                scene.text(
1237                    Point::new(*x, *y),
1238                    label.clone(),
1239                    &ResolvedTextStyle::exact(*size),
1240                    color.fade(o),
1241                );
1242            }
1243        }
1244
1245        // Decorative icons, vertically centred in the box (a discreet colour).
1246        let icon_color = s.icon_color.unwrap().fade(o);
1247        let icon_y = field.y + (field.height - self.icon_size()) * 0.5;
1248        if let Some(prefix) = self.prefix {
1249            let path = prefix.placed(
1250                self.icon_size(),
1251                field.x + ICON_PAD,
1252                icon_y,
1253                theme.direction,
1254            );
1255            scene.fill_path(&path, icon_color);
1256        }
1257        if let Some(suffix) = self.suffix {
1258            let x = field.x + field.width - self.icon_size() - ICON_PAD;
1259            // Highlight (milestone 208): a discreet halo behind the **clickable** suffix when
1260            // the pointer hovers it (the absolute position is brought back to local through
1261            // `bounds`). Purely visual.
1262            if self.suffix_action.is_some() {
1263                if let Some(hc) = status.hover_cursor {
1264                    if self.suffix_hit(hc.x - bounds.x, hc.y - bounds.y, bounds.width) {
1265                        let halo = Rect::new(
1266                            x - 4.0,
1267                            icon_y - 4.0,
1268                            self.icon_size() + 8.0,
1269                            self.icon_size() + 8.0,
1270                        );
1271                        scene.draw_rect(
1272                            halo,
1273                            theme.muted.fade(o * 0.18),
1274                            halo.height * 0.5,
1275                            0.0,
1276                            frus_core::Color::TRANSPARENT,
1277                        );
1278                    }
1279                }
1280            }
1281            let path = suffix.placed(self.icon_size(), x, icon_y, theme.direction);
1282            scene.fill_path(&path, icon_color);
1283        }
1284
1285        let len = self.value.chars().count();
1286        // The content is inset between the prefix/suffix icons, where there are any.
1287        let left = self.pad_x() + self.prefix_w();
1288        let content_x = field.x + left;
1289        let content_w = self.content_width(field.width);
1290        let text_y = field.y + self.text_top();
1291        // Multi-line: the text **wraps** at the content width — the same `max_width` for
1292        // the measure (caret/hit) and for the render → identical wraps.
1293        let wrap = if self.multiline {
1294            Some(content_w)
1295        } else {
1296            None
1297        };
1298        let layout = self.layout(wrap);
1299        // The alignment's push, worked out once and used by the placeholder, the text,
1300        // the selection, the underline and the caret — every one of which is drawn from
1301        // `text_x` below.
1302        let align = self.align_offset(content_w, layout.size().width);
1303
1304        // Hint (placeholder): displayed when the field is empty. If there is a label as
1305        // well, the hint only reveals itself (fading in) once the label has floated —
1306        // otherwise the two would overlap inside the box.
1307        if self.value.is_empty() {
1308            if let Some(placeholder) = &self.placeholder {
1309                let ph_alpha = if self.label.is_some() { o * fp } else { o };
1310                if ph_alpha > 0.01 {
1311                    // The placeholder sits where the text will: a centred field whose
1312                    // hint hugs the left edge jumps the moment the first key lands.
1313                    let style = self.text_style();
1314                    let hint_w = frus_text::measure_resolved(placeholder, &style).width;
1315                    let hint_align = self.align_offset(content_w, hint_w);
1316                    scene.text(
1317                        Point::new(content_x + hint_align, text_y),
1318                        placeholder.clone(),
1319                        &style,
1320                        theme.muted.fade(ph_alpha),
1321                    );
1322                }
1323            }
1324        }
1325
1326        // Scrolling to keep the caret visible: horizontal (always) and vertical
1327        // (multi-line). Recomputed from the cursor, as on click (`cursor_at`).
1328        let cursor = status.cursor.unwrap_or(len).min(len);
1329        let caret = layout.caret_rect(cursor);
1330        let scroll = if status.focused {
1331            (caret.x - content_w).max(0.0)
1332        } else {
1333            0.0
1334        };
1335        let text_x = content_x + align - scroll;
1336        // **Retained** vertical scroll (wheel/scrollbar, caret-following by the shell);
1337        // clamped to how far the content overflows the box.
1338        let vscroll = if self.multiline {
1339            let overflow = (layout.size().height
1340                - (field.height - self.text_top() - self.pad_bottom()))
1341            .max(0.0);
1342            status.scroll_y.clamp(0.0, overflow)
1343        } else {
1344            0.0
1345        };
1346        // Vertical origin of the content (offset by the multi-line scroll).
1347        let text_top = text_y - vscroll;
1348
1349        // Clipped to the **content** frame — inside the padding, not the whole box.
1350        // Clipping to the box let scrolled multi-line text ride up onto the top border,
1351        // which is where an outlined field's floating label lives: the third line of a
1352        // scrolled field was painted straight over the label naming it. It only became
1353        // visible once the padding grew to the reference's, which is the useful kind of
1354        // regression — the old 6 px hid it rather than avoided it.
1355        let outer_clip = scene.current_clip();
1356        let content_clip = outer_clip.intersect(Rect::new(
1357            content_x,
1358            field.y + self.text_top(),
1359            content_w,
1360            (field.height - self.text_top() - self.pad_bottom()).max(0.0),
1361        ));
1362        scene.set_clip(content_clip);
1363
1364        // Selection highlight (below the text).
1365        if status.focused {
1366            if let Some((start, end)) = status.selection {
1367                for r in layout.selection_rects(start.min(len), end.min(len)) {
1368                    scene.fill_rect(
1369                        Rect::new(text_x + r.x, text_top + r.y, r.width, r.height),
1370                        theme.selection.fade(o),
1371                    );
1372                }
1373            }
1374        }
1375
1376        if !self.value.is_empty() {
1377            let pos = Point::new(text_x, text_top);
1378            let color = theme.on_surface.fade(o);
1379            match wrap {
1380                // Multi-line: the render wraps just as the measure did.
1381                Some(max_w) => {
1382                    scene.text_wrapped(pos, self.display(), &self.text_style(), color, max_w)
1383                }
1384                None => scene.text(pos, self.display(), &self.text_style(), color),
1385            }
1386        }
1387
1388        // The IME **composition** region: underlined (provisional text). Drawn over the
1389        // text, below the baseline.
1390        if status.focused {
1391            if let Some((start, end)) = status.composing {
1392                let (start, end) = (start.min(len), end.min(len));
1393                for r in layout.selection_rects(start, end) {
1394                    scene.fill_rect(
1395                        Rect::new(text_x + r.x, text_top + r.y + r.height - 1.5, r.width, 1.5),
1396                        theme.on_surface.fade(o * 0.7),
1397                    );
1398                }
1399            }
1400        }
1401
1402        // The caret, in the shown half of its blink (milestone 513).
1403        if status.focused && !status.caret_hidden {
1404            scene.fill_rect(
1405                Rect::new(text_x + caret.x, text_top + caret.y, 2.0, caret.height),
1406                theme.on_surface.fade(o),
1407            );
1408        }
1409
1410        // The handles of a touch selection (milestone 511), outside the content clip:
1411        // they hang below their line, past the text and, on the last line, past the box.
1412        if status.focused && status.handles {
1413            scene.set_clip(outer_clip);
1414            let edit = Edit {
1415                cursor,
1416                anchor: status
1417                    .selection
1418                    .map(|(a, b)| if cursor == a { b } else { a }),
1419                composing: None,
1420            };
1421            if let Some([start, end]) = self.handles(bounds.width, &edit, status.scroll_y) {
1422                let color = s.handle_color.unwrap().fade(o);
1423                let place = |r: Rect| r.translate(bounds.x, bounds.y);
1424                scene.fill_path(&handle_path(place(start.rect), true), color);
1425                scene.fill_path(&handle_path(place(end.rect), false), color);
1426            }
1427        }
1428    }
1429
1430    fn on_click(&self) -> Option<Msg> {
1431        None
1432    }
1433
1434    fn positional_click(
1435        &self,
1436        local_x: f32,
1437        local_y: f32,
1438        width: f32,
1439        _height: f32,
1440    ) -> Option<Msg> {
1441        if !self.enabled {
1442            return None;
1443        }
1444        if self.suffix_action.is_some() && self.suffix_hit(local_x, local_y, width) {
1445            self.suffix_action.clone()
1446        } else {
1447            None
1448        }
1449    }
1450
1451    fn cursor_icon(
1452        &self,
1453        local_x: f32,
1454        local_y: f32,
1455        width: f32,
1456        _height: f32,
1457    ) -> Option<crate::interaction::Cursor> {
1458        // A hand over the **active** suffix icon (clear / reveal); elsewhere, no opinion.
1459        if self.suffix_action.is_some() && self.suffix_hit(local_x, local_y, width) {
1460            Some(crate::interaction::Cursor::Pointer)
1461        } else {
1462            None
1463        }
1464    }
1465
1466    fn on_edit(&self, edit: &mut Edit, key: &Key) -> Option<Msg> {
1467        // A disabled field cannot be focused, so this should be unreachable — but a key
1468        // arriving from a stale focus must not edit a value the caller has declared
1469        // untouchable.
1470        if !self.enabled {
1471            return None;
1472        }
1473        let mut chars: Vec<char> = self.value.chars().collect();
1474        let len = chars.len();
1475        let mut cursor = edit.cursor.min(len);
1476        let mut anchor = edit.anchor.map(|a| a.min(len));
1477        let selection = anchor
1478            .map(|a| (a.min(cursor), a.max(cursor)))
1479            .filter(|(s, e)| s < e);
1480
1481        let mut changed = false;
1482
1483        match key {
1484            Key::Text(text) => {
1485                // The filter, one character at a time: a rejected one never arrives and
1486                // a substituted one takes the same place, so the caret arithmetic below
1487                // is the arithmetic it always was.
1488                let inserted: Vec<char> = match &self.input_filter {
1489                    Some(filter) => text.chars().filter_map(filter).collect(),
1490                    None => text.chars().collect(),
1491                };
1492                // Nothing typed and nothing selected is nothing done: emitting the value
1493                // unchanged would rebuild the tree for a keystroke that was refused.
1494                // A selection **is** something done, even when what replaces it is
1495                // empty, since the reference filters the value after the replacement.
1496                if selection.is_some() || !inserted.is_empty() {
1497                    if let Some((s, e)) = selection {
1498                        chars.drain(s..e);
1499                        cursor = s;
1500                    }
1501                    let n = inserted.len();
1502                    chars.splice(cursor..cursor, inserted);
1503                    cursor += n;
1504                    anchor = None;
1505                    changed = true;
1506                }
1507            }
1508            Key::Backspace => {
1509                if let Some((s, e)) = selection {
1510                    chars.drain(s..e);
1511                    cursor = s;
1512                    changed = true;
1513                } else if cursor > 0 {
1514                    chars.remove(cursor - 1);
1515                    cursor -= 1;
1516                    changed = true;
1517                }
1518                anchor = None;
1519            }
1520            Key::Delete => {
1521                if let Some((s, e)) = selection {
1522                    chars.drain(s..e);
1523                    cursor = s;
1524                    changed = true;
1525                } else if cursor < len {
1526                    chars.remove(cursor);
1527                    changed = true;
1528                }
1529                anchor = None;
1530            }
1531            Key::Left { shift, word } => {
1532                let target = if *word {
1533                    word_boundary_left(&chars, cursor)
1534                } else {
1535                    cursor.saturating_sub(1)
1536                };
1537                move_cursor(&mut cursor, &mut anchor, target, *shift);
1538            }
1539            Key::Right { shift, word } => {
1540                let target = if *word {
1541                    word_boundary_right(&chars, cursor)
1542                } else {
1543                    (cursor + 1).min(len)
1544                };
1545                move_cursor(&mut cursor, &mut anchor, target, *shift);
1546            }
1547            // Ctrl (`doc`): the bounds of the whole field; otherwise the bounds of the
1548            // logical line (identical in a single-line field).
1549            Key::Home { shift, doc } => {
1550                let target = if *doc { 0 } else { line_start(&chars, cursor) };
1551                move_cursor(&mut cursor, &mut anchor, target, *shift);
1552            }
1553            Key::End { shift, doc } => {
1554                let target = if *doc { len } else { line_end(&chars, cursor) };
1555                move_cursor(&mut cursor, &mut anchor, target, *shift);
1556            }
1557            // Escape is none of editing's business (routed leaf→root by the shell).
1558            Key::Escape => {}
1559            Key::Enter if self.multiline => {
1560                // Multi-line: Enter **inserts a line break** (no submission).
1561                if let Some((s, e)) = selection {
1562                    chars.drain(s..e);
1563                    cursor = s;
1564                }
1565                chars.insert(cursor, '\n');
1566                cursor += 1;
1567                anchor = None;
1568                changed = true;
1569            }
1570            Key::Enter => {
1571                // Single-line: Enter submits — it does not alter the value, it emits the submission.
1572                edit.cursor = cursor;
1573                edit.anchor = anchor;
1574                return self.on_submit.clone();
1575            }
1576        }
1577
1578        // Read-only: everything that only **moves** has already happened -- the caret,
1579        // the selection, the word jumps -- and is kept. What is refused is the change,
1580        // which is the whole of the difference between this and a disabled field, where
1581        // there is no caret to move in the first place.
1582        if self.read_only && changed {
1583            return None;
1584        }
1585
1586        // The limit, enforced the way the reference enforces it: what fits lands and the
1587        // rest does not. Dropping a whole paste for being one character too long would
1588        // lose work the user can see they had, and refusing the keystroke that crosses
1589        // the limit is the same rule seen one character at a time.
1590        //
1591        // A value the **caller** supplied over the limit is left alone: it is the
1592        // application's state, not something typed, and silently shortening it would be
1593        // editing a value nobody edited. The counter shows it over.
1594        if changed {
1595            if let Some(max) = self.max_length {
1596                if chars.len() > max {
1597                    chars.truncate(max);
1598                    cursor = cursor.min(max);
1599                    anchor = anchor.map(|a| a.min(max));
1600                }
1601            }
1602        }
1603
1604        edit.cursor = cursor;
1605        edit.anchor = anchor;
1606
1607        if changed {
1608            let new_value: String = chars.into_iter().collect();
1609            self.on_input.as_ref().map(|make| make(new_value))
1610        } else {
1611            None
1612        }
1613    }
1614
1615    fn ime(&self) -> Ime {
1616        self.ime_options()
1617    }
1618
1619    fn cursor_at(
1620        &self,
1621        local_x: f32,
1622        local_y: f32,
1623        width: f32,
1624        scroll_cursor: usize,
1625    ) -> Option<usize> {
1626        // A disabled field takes no caret: it is readable, not editable, and a caret
1627        // sitting in it would promise otherwise.
1628        if !self.enabled {
1629            return None;
1630        }
1631        // A click on the **clickable suffix icon**: do not place a caret (the shell will
1632        // emit its message through `positional_click`).
1633        if self.suffix_action.is_some() && self.suffix_hit(local_x, local_y, width) {
1634            return None;
1635        }
1636        // Rebuilds the same content geometry as the render (icon and decoration insets,
1637        // wrapping, horizontal scroll), for an exact click. The **retained vertical
1638        // scroll** is already folded into `local_y` by the shell.
1639        let left = self.pad_x() + self.prefix_w();
1640        let content_w = self.content_width(width);
1641        let layout = self.layout(if self.multiline {
1642            Some(content_w)
1643        } else {
1644            None
1645        });
1646        let scroll = (layout.caret_rect(scroll_cursor).x - content_w).max(0.0);
1647        // The same push the paint applied, from the same function. Without it a click on
1648        // centred or right-aligned text lands wherever the glyphs *would* have been
1649        // against the left edge, which is a caret appearing several characters from the
1650        // tap.
1651        let align = self.align_offset(content_w, layout.size().width);
1652        // `local_*` are relative to the **widget's** top-left corner (label included):
1653        // the label band and the padding are removed to land inside the text.
1654        let target_x = local_x - left - align + scroll;
1655        let target_y = local_y - self.label_block() - self.text_top();
1656        Some(layout.hit_test(Point::new(target_x, target_y)))
1657    }
1658
1659    fn text_metrics(&self, width: f32, cursor: usize) -> Option<(f32, f32, f32, f32)> {
1660        if !self.multiline {
1661            return None;
1662        }
1663        let layout = self.layout(Some(self.content_width(width)));
1664        let caret = layout.caret_rect(cursor);
1665        let visible = self.field_height() - self.text_top() - self.pad_bottom();
1666        Some((layout.size().height, visible, caret.y, caret.height))
1667    }
1668
1669    fn text_viewport(&self, rect: Rect) -> Option<Rect> {
1670        if !self.multiline {
1671            return None;
1672        }
1673        // The input box: below the label, as tall as the `rows`.
1674        Some(Rect::new(
1675            rect.x,
1676            rect.y + self.label_block(),
1677            rect.width,
1678            self.field_height(),
1679        ))
1680    }
1681
1682    fn caret_vertical(
1683        &self,
1684        width: f32,
1685        cursor: usize,
1686        down: bool,
1687        page: bool,
1688        goal_x: Option<f32>,
1689    ) -> Option<(usize, f32)> {
1690        if !self.multiline {
1691            return None;
1692        }
1693        let layout = self.layout(Some(self.content_width(width)));
1694        let caret = layout.caret_rect(cursor);
1695        let line_h = caret.height;
1696        // Wanted column: the remembered target, otherwise the current column.
1697        let x = goal_x.unwrap_or(caret.x);
1698        // A step of one line, or of one page (the field's visible height, at least 1 line).
1699        let step = if page {
1700            (self.field_height() - self.text_top() - self.pad_bottom()).max(line_h)
1701        } else {
1702            line_h
1703        };
1704        let center = caret.y + line_h * 0.5;
1705        let target_y = if down { center + step } else { center - step };
1706        let full = layout.size().height;
1707        if page {
1708            // Page: clamped to the field (it is never left).
1709            let clamped = target_y.clamp(0.0, (full - line_h * 0.5).max(0.0));
1710            Some((layout.hit_test(Point::new(x, clamped)), x))
1711        } else {
1712            // Line: already on the first/last one → the shell moves the focus.
1713            if target_y < 0.0 || target_y >= full {
1714                return None;
1715            }
1716            Some((layout.hit_test(Point::new(x, target_y)), x))
1717        }
1718    }
1719
1720    fn text_value(&self) -> Option<&str> {
1721        Some(&self.value)
1722    }
1723
1724    fn replace_value(&self, value: String) -> Option<Msg> {
1725        // The same two refusals `on_edit` makes, for the same reason: a field the caller
1726        // has declared untouchable does not become touchable because the value being put
1727        // back came out of it. No filter and no length limit, though — this value passed
1728        // both on its way in, and running it through them again would let an undo land
1729        // somewhere the field has never been.
1730        if !self.enabled || self.read_only {
1731            return None;
1732        }
1733        self.on_input.as_ref().map(|make| make(value))
1734    }
1735
1736    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
1737        let mut s = frus_core::SemanticsProperties::new(frus_core::Role::TextInput)
1738            .value(self.value.clone());
1739        // The label names the field; an error is appended to it so it gets read out.
1740        let label = match (&self.label, &self.error) {
1741            (Some(l), Some(e)) => Some(format!("{l}, {e}")),
1742            (Some(l), None) => Some(l.clone()),
1743            (None, Some(e)) => Some(e.clone()),
1744            (None, None) => None,
1745        };
1746        if let Some(label) = label {
1747            s = s.label(label);
1748        }
1749        if !self.enabled {
1750            s = s.disabled(true);
1751        }
1752        Some(s)
1753    }
1754
1755    fn selected_text(&self, edit: &Edit) -> Option<String> {
1756        let chars: Vec<char> = self.value.chars().collect();
1757        let len = chars.len();
1758        let cursor = edit.cursor.min(len);
1759        let anchor = edit.anchor?.min(len);
1760        let (start, end) = (anchor.min(cursor), anchor.max(cursor));
1761        if start < end {
1762            Some(chars[start..end].iter().collect())
1763        } else {
1764            None
1765        }
1766    }
1767
1768    fn word_at(&self, index: usize) -> Option<(usize, usize)> {
1769        let chars: Vec<char> = self.value.chars().collect();
1770        let len = chars.len();
1771        if len == 0 {
1772            return Some((0, 0));
1773        }
1774        let i = index.min(len - 1);
1775        let is_word = |c: char| c.is_alphanumeric() || c == '_';
1776        if !is_word(chars[i]) {
1777            return Some((i, (i + 1).min(len)));
1778        }
1779        let mut start = i;
1780        while start > 0 && is_word(chars[start - 1]) {
1781            start -= 1;
1782        }
1783        let mut end = i + 1;
1784        while end < len && is_word(chars[end]) {
1785            end += 1;
1786        }
1787        Some((start, end))
1788    }
1789
1790    fn selection_handles(
1791        &self,
1792        width: f32,
1793        edit: &Edit,
1794        scroll_y: f32,
1795    ) -> Option<[crate::SelectionHandle; 2]> {
1796        self.handles(width, edit, scroll_y)
1797    }
1798
1799    fn autofill_hints(&self) -> &[AutofillHint] {
1800        &self.autofill
1801    }
1802
1803    fn focusable(&self) -> bool {
1804        self.enabled
1805    }
1806
1807    fn draws_own_focus(&self) -> bool {
1808        true
1809    }
1810
1811    fn announce(&self) -> Option<String> {
1812        self.announce.clone()
1813    }
1814}
1815
1816#[cfg(test)]
1817mod tests {
1818    use super::*;
1819    use crate::icons::Icons;
1820
1821    #[derive(Clone, Debug, PartialEq)]
1822    enum Msg {
1823        Changed(String),
1824        Submitted,
1825    }
1826
1827    fn input(value: &str) -> TextField<Msg> {
1828        TextField::new(value).on_input(Msg::Changed)
1829    }
1830
1831    /// Every rectangle a field paints, in order.
1832    fn rects(field: &TextField<Msg>, theme: &Theme, w: f32, h: f32) -> Vec<frus_core::Primitive> {
1833        let mut scene = Scene::new();
1834        Widget::<Msg>::paint(
1835            field,
1836            Rect::new(0.0, 0.0, w, h),
1837            Status::default(),
1838            theme,
1839            &mut scene,
1840        );
1841        scene
1842            .primitives()
1843            .iter()
1844            .filter(|p| matches!(p, frus_core::Primitive::Rect { .. }))
1845            .cloned()
1846            .collect()
1847    }
1848
1849    /// The reference's content padding is `(12, 8, 12, 8)` filled and `(12, 20, 12, 12)`
1850    /// outlined, and the outlined asymmetry is the room its floating label takes on the
1851    /// border. A field measured any other way is a field that does not line up beside
1852    /// anything else drawn to the same specification.
1853    #[test]
1854    fn a_field_is_the_references_size() {
1855        let line = input("x").text_style().line_height();
1856        let outlined = input("x");
1857        assert_eq!(
1858            outlined.field_height(),
1859            (line + FIELD_OUTLINED_PADDING_TOP + FIELD_OUTLINED_PADDING_BOTTOM).ceil()
1860        );
1861        // A filled field is tighter — until it carries a label, which it floats *inside*
1862        // the box and therefore has to make room for.
1863        let filled = input("x").filled();
1864        assert_eq!(filled.field_height(), (line + FIELD_PADDING_Y * 2.0).ceil());
1865        let labelled = input("x").filled().label("Name");
1866        assert_eq!(
1867            labelled.field_height() - filled.field_height(),
1868            (FIELD_GAP + FIELD_TEXT_SIZE * FIELD_LABEL_SCALE).ceil()
1869        );
1870        // The label shrinks by a proportion, not to a fixed size: a field given larger
1871        // text keeps the relationship.
1872        assert_eq!(input("x").size(32.0).label_size(), 24.0);
1873    }
1874
1875    /// A field is 56 px tall, which is right for a form and wrong inside a table row.
1876    /// `dense` is the reference's answer, and it gives back the padding without changing
1877    /// the shape, the label or the border.
1878    #[test]
1879    fn a_dense_field_gives_back_its_padding() {
1880        let line = input("x").text_style().line_height();
1881        assert_eq!(
1882            input("x").dense(true).field_height(),
1883            (line + FIELD_DENSE_OUTLINED_PADDING_TOP + FIELD_DENSE_OUTLINED_PADDING_BOTTOM).ceil()
1884        );
1885        assert_eq!(
1886            input("x").filled().dense(true).field_height(),
1887            (line + FIELD_DENSE_PADDING_Y * 2.0).ceil()
1888        );
1889        // It is only the room: a dense field is shorter than its roomy twin in both
1890        // variants, and never taller.
1891        for dense in [input("x").dense(true), input("x").filled().dense(true)] {
1892            let roomy = if dense.is_outlined() {
1893                input("x")
1894            } else {
1895                input("x").filled()
1896            };
1897            assert!(dense.field_height() < roomy.field_height());
1898        }
1899    }
1900
1901    /// A filled field is a container with **one line under it**; an outlined one is a box.
1902    /// Drawing a fill inside a stroked box on all four sides would be the other variant
1903    /// wearing a tint, which is the mistake this variant exists to avoid.
1904    #[test]
1905    fn a_filled_field_has_a_line_not_a_box() {
1906        let theme = Theme::default();
1907        let stroked = |field: &TextField<Msg>| {
1908            rects(field, &theme, 220.0, 80.0)
1909                .iter()
1910                .filter(|p| {
1911                    matches!(p, frus_core::Primitive::Rect { border_width, .. } if *border_width > 0.0)
1912                })
1913                .count()
1914        };
1915        assert_eq!(
1916            stroked(&input("x").outlined()),
1917            1,
1918            "outlined: one stroked box"
1919        );
1920        assert_eq!(stroked(&input("x").filled()), 0, "filled: nothing stroked");
1921        // ...and the line is a filled rectangle the width of the field, at its foot.
1922        let filled = input("x").filled();
1923        let height = filled.field_height();
1924        let has_underline = rects(&filled, &theme, 220.0, 80.0).iter().any(|p| {
1925            matches!(
1926                p,
1927                frus_core::Primitive::Rect { rect, border_width, .. }
1928                    if *border_width == 0.0
1929                        && (rect.width - 220.0).abs() < 0.5
1930                        && (rect.height - FIELD_BORDER_WIDTH).abs() < 0.5
1931                        && (rect.y + rect.height - height).abs() < 0.5
1932            )
1933        });
1934        assert!(has_underline, "a filled field is underlined");
1935    }
1936
1937    /// A disabled field keeps 38% of its colours and shows no focus — it cannot be
1938    /// focused, so a ring left over from before would say the wrong thing about Tab.
1939    #[test]
1940    fn a_disabled_field_dims_and_never_looks_focused() {
1941        let theme = Theme::default();
1942        let focused = Status {
1943            focus_progress: 1.0,
1944            ..Default::default()
1945        };
1946        let border = |field: &TextField<Msg>, status: Status| {
1947            let mut scene = Scene::new();
1948            Widget::<Msg>::paint(
1949                field,
1950                Rect::new(0.0, 0.0, 220.0, 80.0),
1951                status,
1952                &theme,
1953                &mut scene,
1954            );
1955            scene.primitives().iter().find_map(|p| match p {
1956                frus_core::Primitive::Rect {
1957                    border_width,
1958                    border_color,
1959                    ..
1960                } if *border_width > 0.0 => Some((*border_width, *border_color)),
1961                _ => None,
1962            })
1963        };
1964        let (live_w, live_c) = border(&input("x"), focused).unwrap();
1965        assert_eq!(
1966            live_w, FIELD_FOCUSED_BORDER_WIDTH,
1967            "focus widens the border"
1968        );
1969        assert_eq!(live_c.a, 1.0, "and it is fully opaque");
1970        let (dead_w, dead_c) = border(&input("x").enabled(false), focused).unwrap();
1971        assert_eq!(dead_w, FIELD_BORDER_WIDTH, "disabled: no focus weight");
1972        assert!(
1973            (dead_c.a - FIELD_DISABLED_OPACITY).abs() < 0.01,
1974            "disabled: dimmed to the reference's 38%, got {}",
1975            dead_c.a
1976        );
1977    }
1978
1979    /// Scrolled multi-line text must stay **inside the padding**, not merely inside the
1980    /// box: the top border is where an outlined field's floating label lives, and text
1981    /// clipped only to the box was painted straight over the label naming it.
1982    #[test]
1983    fn scrolled_text_is_clipped_below_the_label() {
1984        let theme = Theme::default();
1985        let field = input("one\ntwo\nthree\nfour\nfive\nsix")
1986            .rows(3)
1987            .label("Notes");
1988        let mut scene = Scene::new();
1989        Widget::<Msg>::paint(
1990            &field,
1991            Rect::new(0.0, 0.0, 220.0, 140.0),
1992            Status {
1993                scroll_y: 40.0,
1994                ..Default::default()
1995            },
1996            &theme,
1997            &mut scene,
1998        );
1999        let box_top = field.label_block();
2000        let clip = scene
2001            .primitives()
2002            .iter()
2003            .find_map(|p| match p {
2004                // The label and the helper are painted unclipped; the value is not, and
2005                // its clip is the one under test.
2006                frus_core::Primitive::Text { clip, .. } if clip.y > -1.0e6 => Some(*clip),
2007                _ => None,
2008            })
2009            .expect("the value is clipped");
2010        assert!(
2011            clip.y >= box_top + field.text_top() - 0.5,
2012            "the clip starts below the padding, not at the border: {} < {}",
2013            clip.y,
2014            box_top + field.text_top()
2015        );
2016    }
2017
2018    /// Greying a field out is not enough: it has to be out of the tab order, refuse a
2019    /// caret, and ignore a key that reaches it from a focus set before it was disabled.
2020    #[test]
2021    fn a_disabled_field_is_inert() {
2022        let live = input("hello");
2023        let dead = input("hello").enabled(false);
2024        assert!(Widget::<Msg>::focusable(&live));
2025        assert!(!Widget::<Msg>::focusable(&dead), "out of the tab order");
2026        assert!(
2027            Widget::<Msg>::cursor_at(&live, 40.0, 30.0, 220.0, 0).is_some(),
2028            "a live field takes a caret"
2029        );
2030        assert_eq!(
2031            Widget::<Msg>::cursor_at(&dead, 40.0, 30.0, 220.0, 0),
2032            None,
2033            "a disabled field takes none"
2034        );
2035        let mut edit = Edit::default();
2036        assert!(
2037            Widget::on_edit(&dead, &mut edit, &Key::Text("x".into())).is_none(),
2038            "and no key edits it"
2039        );
2040        let semantics = Widget::<Msg>::semantics(&dead).unwrap();
2041        assert!(semantics.disabled, "a reader is told it is disabled");
2042    }
2043
2044    /// `caller ?? theme ?? framework`, on a field that has all three to choose from.
2045    #[test]
2046    fn the_caller_outranks_the_theme_which_outranks_the_framework() {
2047        let radius = |field: &TextField<Msg>, theme: &Theme| {
2048            rects(field, theme, 220.0, 80.0)
2049                .iter()
2050                .find_map(|p| match p {
2051                    frus_core::Primitive::Rect {
2052                        border_width,
2053                        radius,
2054                        ..
2055                    } if *border_width > 0.0 => Some(radius.top_left),
2056                    _ => None,
2057                })
2058                .unwrap()
2059        };
2060        let plain = Theme::default();
2061        assert_eq!(radius(&input("x"), &plain), FIELD_RADIUS);
2062        let mut themed = Theme::default();
2063        themed.widgets.text_field.radius = Some(10.0);
2064        assert_eq!(radius(&input("x"), &themed), 10.0);
2065        let overridden = input("x").style(TextFieldStyle {
2066            radius: Some(2.0),
2067            ..Default::default()
2068        });
2069        assert_eq!(radius(&overridden, &themed), 2.0);
2070    }
2071
2072    #[test]
2073    fn text_is_clipped_to_content_box() {
2074        // Text longer than the field must be clipped to its content width (otherwise it
2075        // overflows onto the neighbouring widgets).
2076        let inp = input("a very long value that overflows the field width").width(100.0);
2077        let mut scene = Scene::new();
2078        Widget::<Msg>::paint(
2079            &inp,
2080            Rect::new(0.0, 0.0, 100.0, 30.0),
2081            Status::default(),
2082            &Theme::default(),
2083            &mut scene,
2084        );
2085        let clip = scene
2086            .primitives()
2087            .iter()
2088            .find_map(|p| match p {
2089                frus_core::Primitive::Text { clip, .. } => Some(*clip),
2090                _ => None,
2091            })
2092            .expect("text primitive");
2093        assert!(
2094            (clip.width - (100.0 - FIELD_PADDING_X * 2.0)).abs() < 0.5,
2095            "clip = {clip:?}"
2096        );
2097    }
2098
2099    #[test]
2100    fn composing_region_draws_an_underline() {
2101        // The composed region adds thin rectangles (the underline) below the text,
2102        // absent when no composition is in progress.
2103        let inp = input("konnichiwa");
2104        let base = Status {
2105            focused: true,
2106            cursor: Some(5),
2107            ..Default::default()
2108        };
2109        let composing = Status {
2110            composing: Some((0, 5)),
2111            ..base
2112        };
2113
2114        let count_thin_rects = |status: Status| {
2115            let mut scene = Scene::new();
2116            Widget::<Msg>::paint(
2117                &inp,
2118                Rect::new(0.0, 0.0, 220.0, 30.0),
2119                status,
2120                &Theme::default(),
2121                &mut scene,
2122            );
2123            scene
2124                .primitives()
2125                .iter()
2126                .filter(|p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.height <= 2.0 && rect.width > 2.0))
2127                .count()
2128        };
2129        let plain = count_thin_rects(base);
2130        let underlined = count_thin_rects(composing);
2131        assert!(
2132            underlined > plain,
2133            "the composition must underline ({plain} → {underlined})"
2134        );
2135    }
2136
2137    #[test]
2138    fn text_value_exposes_the_field_content() {
2139        // The input context (IME suggestions) reads the field's value.
2140        let inp = input("hello");
2141        assert_eq!(Widget::<Msg>::text_value(&inp), Some("hello"));
2142    }
2143
2144    #[test]
2145    fn cursor_at_accounts_for_scroll() {
2146        // A narrow field + long text → scrolling once the caret is at the end.
2147        let inp = input("0123456789 abcdefghij");
2148        // Without scrolling (cursor 0): a click on the left edge → index 0.
2149        assert_eq!(
2150            Widget::<Msg>::cursor_at(&inp, FIELD_PADDING_X, FIELD_OUTLINED_PADDING_TOP, 80.0, 0),
2151            Some(0)
2152        );
2153        // Scrolled (caret at the end): a click on the right lands on a larger index than
2154        // a click on the left (the offset is indeed taken into account).
2155        let end = inp.value.chars().count();
2156        let at_left =
2157            Widget::<Msg>::cursor_at(&inp, FIELD_PADDING_X, FIELD_OUTLINED_PADDING_TOP, 80.0, end)
2158                .unwrap();
2159        let at_right =
2160            Widget::<Msg>::cursor_at(&inp, 76.0, FIELD_OUTLINED_PADDING_TOP, 80.0, end).unwrap();
2161        assert!(at_left > 0, "scrolled: the left edge is no longer index 0");
2162        assert!(at_right > at_left, "a click on the right → a larger index");
2163    }
2164
2165    #[test]
2166    fn insert_at_cursor() {
2167        let inp = input("ac");
2168        let mut edit = Edit {
2169            cursor: 1,
2170            anchor: None,
2171            composing: None,
2172        };
2173        assert_eq!(
2174            inp.on_edit(&mut edit, &Key::Text("b".to_string())),
2175            Some(Msg::Changed("abc".to_string()))
2176        );
2177        assert_eq!(edit.cursor, 2);
2178    }
2179
2180    #[test]
2181    fn shift_arrow_selects_then_delete() {
2182        let inp = input("hello");
2183        // Caret at the end, Shift+Left twice -> selects "lo".
2184        let mut edit = Edit {
2185            cursor: 5,
2186            anchor: None,
2187            composing: None,
2188        };
2189        inp.on_edit(
2190            &mut edit,
2191            &Key::Left {
2192                shift: true,
2193                word: false,
2194            },
2195        );
2196        inp.on_edit(
2197            &mut edit,
2198            &Key::Left {
2199                shift: true,
2200                word: false,
2201            },
2202        );
2203        assert_eq!(edit.selection_range(), Some((3, 5)));
2204        // Backspace deletes the selection.
2205        assert_eq!(
2206            inp.on_edit(&mut edit, &Key::Backspace),
2207            Some(Msg::Changed("hel".to_string()))
2208        );
2209        assert_eq!(edit.cursor, 3);
2210        assert_eq!(edit.anchor, None);
2211    }
2212
2213    #[test]
2214    fn home_end_bounds() {
2215        let inp = input("abc");
2216        let mut edit = Edit {
2217            cursor: 1,
2218            anchor: None,
2219            composing: None,
2220        };
2221        inp.on_edit(
2222            &mut edit,
2223            &Key::End {
2224                shift: false,
2225                doc: false,
2226            },
2227        );
2228        assert_eq!(edit.cursor, 3);
2229        inp.on_edit(
2230            &mut edit,
2231            &Key::Home {
2232                shift: false,
2233                doc: false,
2234            },
2235        );
2236        assert_eq!(edit.cursor, 0);
2237    }
2238
2239    #[test]
2240    fn ctrl_arrow_jumps_by_word() {
2241        let inp = input("foo bar baz");
2242        // From the end, Ctrl+Left jumps to the start of "baz", then "bar", then "foo".
2243        let mut edit = Edit {
2244            cursor: 11,
2245            anchor: None,
2246            composing: None,
2247        };
2248        inp.on_edit(
2249            &mut edit,
2250            &Key::Left {
2251                shift: false,
2252                word: true,
2253            },
2254        );
2255        assert_eq!(edit.cursor, 8, "start of \"baz\"");
2256        inp.on_edit(
2257            &mut edit,
2258            &Key::Left {
2259                shift: false,
2260                word: true,
2261            },
2262        );
2263        assert_eq!(edit.cursor, 4, "start of \"bar\"");
2264        // Ctrl+Right jumps to the end of "bar", then "baz".
2265        inp.on_edit(
2266            &mut edit,
2267            &Key::Right {
2268                shift: false,
2269                word: true,
2270            },
2271        );
2272        assert_eq!(edit.cursor, 7, "fin de \"bar\"");
2273        inp.on_edit(
2274            &mut edit,
2275            &Key::Right {
2276                shift: false,
2277                word: true,
2278            },
2279        );
2280        assert_eq!(edit.cursor, 11, "fin de \"baz\"");
2281    }
2282
2283    #[test]
2284    fn home_end_are_line_relative_but_ctrl_spans_the_field() {
2285        // "ab\ncd\nef": caret in the middle of the 2nd line (index 4, between c and d).
2286        let inp = TextField::<Msg>::new("ab\ncd\nef")
2287            .on_input(Msg::Changed)
2288            .rows(3);
2289        let mut edit = Edit {
2290            cursor: 4,
2291            anchor: None,
2292            composing: None,
2293        };
2294        // Plain Home → the start of the current line (index 3), not of the field.
2295        inp.on_edit(
2296            &mut edit,
2297            &Key::Home {
2298                shift: false,
2299                doc: false,
2300            },
2301        );
2302        assert_eq!(edit.cursor, 3, "start of the 2nd line");
2303        // Plain End → the end of the current line (index 5).
2304        inp.on_edit(
2305            &mut edit,
2306            &Key::End {
2307                shift: false,
2308                doc: false,
2309            },
2310        );
2311        assert_eq!(edit.cursor, 5, "end of the 2nd line");
2312        // Ctrl+Home / Ctrl+End → the bounds of the whole field.
2313        inp.on_edit(
2314            &mut edit,
2315            &Key::Home {
2316                shift: false,
2317                doc: true,
2318            },
2319        );
2320        assert_eq!(edit.cursor, 0, "start of the field");
2321        inp.on_edit(
2322            &mut edit,
2323            &Key::End {
2324                shift: false,
2325                doc: true,
2326            },
2327        );
2328        assert_eq!(edit.cursor, 8, "end of the field");
2329    }
2330
2331    #[test]
2332    fn selected_text_reads_range() {
2333        let inp = input("hello");
2334        let edit = Edit {
2335            cursor: 5,
2336            anchor: Some(2),
2337            composing: None,
2338        };
2339        assert_eq!(inp.selected_text(&edit), Some("llo".to_string()));
2340    }
2341
2342    #[test]
2343    fn enter_submits_without_changing_value() {
2344        let inp = input("acheter du lait").on_submit(Msg::Submitted);
2345        let mut edit = Edit {
2346            cursor: 3,
2347            anchor: None,
2348            composing: None,
2349        };
2350        // Enter: emits the submission, returns no value change.
2351        assert_eq!(inp.on_edit(&mut edit, &Key::Enter), Some(Msg::Submitted));
2352        assert_eq!(edit.cursor, 3); // caret unchanged
2353    }
2354
2355    #[test]
2356    fn enter_without_submit_is_noop() {
2357        let inp = input("x");
2358        let mut edit = Edit {
2359            cursor: 1,
2360            anchor: None,
2361            composing: None,
2362        };
2363        assert_eq!(inp.on_edit(&mut edit, &Key::Enter), None);
2364    }
2365
2366    /// Where the text was actually drawn: the x of the field's own text primitive.
2367    fn drawn_text_x(field: &TextField<()>, width: f32) -> f32 {
2368        let mut scene = Scene::new();
2369        Widget::<()>::paint(
2370            field,
2371            Rect::new(0.0, 0.0, width, 56.0),
2372            Status {
2373                focused: false,
2374                ..Default::default()
2375            },
2376            &Theme::default(),
2377            &mut scene,
2378        );
2379        scene
2380            .primitives()
2381            .iter()
2382            .find_map(|p| match p {
2383                frus_core::Primitive::Text { position, text, .. } if text == "42" => {
2384                    Some(position.x)
2385                }
2386                _ => None,
2387            })
2388            .expect("the value is drawn")
2389    }
2390
2391    /// A field says nothing, and the text starts where it always did.
2392    #[test]
2393    fn a_field_that_says_nothing_starts_at_the_left() {
2394        let plain: TextField<()> = TextField::new("42");
2395        let started: TextField<()> = TextField::new("42").text_align(TextAlign::Start);
2396        assert_eq!(drawn_text_x(&plain, 300.0), drawn_text_x(&started, 300.0));
2397    }
2398
2399    /// Centred and right-aligned text moves, and moves the right way round.
2400    #[test]
2401    fn the_text_sits_where_it_was_told_to() {
2402        let left = drawn_text_x(&TextField::<()>::new("42"), 300.0);
2403        let centre = drawn_text_x(
2404            &TextField::<()>::new("42").text_align(TextAlign::Center),
2405            300.0,
2406        );
2407        let right = drawn_text_x(
2408            &TextField::<()>::new("42").text_align(TextAlign::Right),
2409            300.0,
2410        );
2411        assert!(left < centre, "centred is further right than left-aligned");
2412        assert!(centre < right, "right-aligned is further right again");
2413        // Centred is halfway: the two gaps either side match.
2414        assert!(
2415            ((centre - left) - (right - centre)).abs() < 0.5,
2416            "halfway: {left} {centre} {right}"
2417        );
2418    }
2419
2420    /// **The click has to agree with the paint.** A caret placed from the unaligned
2421    /// geometry would appear several characters from the tap, and it is the kind of
2422    /// wrongness nobody reports precisely — it just feels broken.
2423    #[test]
2424    fn a_click_lands_where_the_glyphs_are() {
2425        let width = 300.0;
2426        for align in [TextAlign::Left, TextAlign::Center, TextAlign::Right] {
2427            let field: TextField<()> = TextField::new("42").text_align(align);
2428            let x = drawn_text_x(&field, width);
2429            // A tap a hair to the left of the first glyph belongs before it\u2026
2430            let before = Widget::<()>::cursor_at(&field, x - 1.0, 20.0, width, 0);
2431            assert_eq!(before, Some(0), "{align:?}: before the first character");
2432            // \u2026and one past the last glyph belongs after it.
2433            let text_w = frus_text::measure("42", field.size).width;
2434            let after = Widget::<()>::cursor_at(&field, x + text_w + 1.0, 20.0, width, 0);
2435            assert_eq!(after, Some(2), "{align:?}: after the last character");
2436        }
2437    }
2438
2439    /// Nothing to distribute, nothing to push. A line wider than its field keeps its
2440    /// left edge, which is where reading starts and where the horizontal scroll brings
2441    /// the caret back to.
2442    #[test]
2443    fn text_wider_than_the_field_is_not_pushed_off_it() {
2444        let long: TextField<()> = TextField::new("42").text_align(TextAlign::Right);
2445        // A box far too narrow for the value: the slack is negative, so the push is nil.
2446        assert_eq!(long.align_offset(4.0, 40.0), 0.0);
2447        assert_eq!(long.align_offset(40.0, 40.0), 0.0);
2448    }
2449
2450    /// A multi-line field stays at the start. Aligning wrapped text means moving each
2451    /// line by its own width, which the caret and the click would have to be told about
2452    /// line by line — that belongs inside the text layout, not in a widget nudging a
2453    /// block sideways behind their backs.
2454    #[test]
2455    fn a_multiline_field_is_not_aligned() {
2456        let note: TextField<()> = TextField::new("42")
2457            .multiline()
2458            .text_align(TextAlign::Center);
2459        assert_eq!(note.align_offset(300.0, 40.0), 0.0);
2460        // The same field on one line would have moved.
2461        let one_line: TextField<()> = TextField::new("42").text_align(TextAlign::Center);
2462        assert_eq!(one_line.align_offset(300.0, 40.0), 130.0);
2463    }
2464
2465    /// The placeholder sits where the text will. A centred field whose hint hugs the
2466    /// left edge jumps the moment the first key lands.
2467    #[test]
2468    fn the_placeholder_is_aligned_like_the_value() {
2469        let hint_x = |align: TextAlign| {
2470            let field: TextField<()> = TextField::new("").placeholder("Amount").text_align(align);
2471            let mut scene = Scene::new();
2472            Widget::<()>::paint(
2473                &field,
2474                Rect::new(0.0, 0.0, 300.0, 56.0),
2475                Status::default(),
2476                &Theme::default(),
2477                &mut scene,
2478            );
2479            scene
2480                .primitives()
2481                .iter()
2482                .find_map(|p| match p {
2483                    frus_core::Primitive::Text { position, text, .. } if text == "Amount" => {
2484                        Some(position.x)
2485                    }
2486                    _ => None,
2487                })
2488                .expect("the hint is drawn")
2489        };
2490        assert!(hint_x(TextAlign::Right) > hint_x(TextAlign::Center));
2491        assert!(hint_x(TextAlign::Center) > hint_x(TextAlign::Left));
2492    }
2493
2494    /// A filtered field drops what it will not take, and keeps the rest of a paste.
2495    #[test]
2496    fn a_filter_drops_what_it_will_not_take() {
2497        let field: TextField<String> = TextField::new("").digits_only().on_input(|v| v);
2498        let mut edit = Edit::default();
2499        // A paste of a formatted number: the digits land, the spaces and dashes do not.
2500        let out = Widget::on_edit(&field, &mut edit, &Key::Text("12 34-56".into()));
2501        assert_eq!(out.as_deref(), Some("123456"));
2502        assert_eq!(edit.cursor, 6, "the caret is past what actually arrived");
2503    }
2504
2505    /// A refused keystroke is not an edit: emitting the value unchanged would rebuild
2506    /// the tree for a key that did nothing.
2507    #[test]
2508    fn a_refused_keystroke_says_nothing() {
2509        let field: TextField<String> = TextField::new("42").digits_only().on_input(|v| v);
2510        let mut edit = Edit {
2511            cursor: 2,
2512            ..Default::default()
2513        };
2514        assert_eq!(
2515            Widget::on_edit(&field, &mut edit, &Key::Text("x".into())),
2516            None
2517        );
2518        assert_eq!(edit.cursor, 2, "and the caret has not moved");
2519    }
2520
2521    /// A **selection** replaced by nothing is still an edit: the selection is gone.
2522    #[test]
2523    fn a_refused_keystroke_over_a_selection_still_clears_it() {
2524        let field: TextField<String> = TextField::new("1234").digits_only().on_input(|v| v);
2525        let mut edit = Edit {
2526            cursor: 3,
2527            anchor: Some(1),
2528            ..Default::default()
2529        };
2530        let out = Widget::on_edit(&field, &mut edit, &Key::Text("x".into()));
2531        assert_eq!(out.as_deref(), Some("14"));
2532        assert_eq!(edit.cursor, 1);
2533    }
2534
2535    /// A filter can **substitute** as well as drop, and the caret arithmetic is the same
2536    /// because a substituted character takes the place of the one typed.
2537    #[test]
2538    fn a_filter_can_substitute() {
2539        let field: TextField<String> = TextField::new("")
2540            .input_filter(|c| c.to_uppercase().next())
2541            .on_input(|v| v);
2542        let mut edit = Edit::default();
2543        let out = Widget::on_edit(&field, &mut edit, &Key::Text("ab".into()));
2544        assert_eq!(out.as_deref(), Some("AB"));
2545        assert_eq!(edit.cursor, 2);
2546    }
2547
2548    /// A value the **caller** supplied is left alone: it is the application's state, not
2549    /// something typed.
2550    #[test]
2551    fn a_filter_does_not_rewrite_what_the_caller_gave() {
2552        let field: TextField<String> = TextField::new("a1b2").digits_only().on_input(|v| v);
2553        let mut edit = Edit {
2554            cursor: 4,
2555            ..Default::default()
2556        };
2557        let out = Widget::on_edit(&field, &mut edit, &Key::Text("3".into()));
2558        assert_eq!(
2559            out.as_deref(),
2560            Some("a1b23"),
2561            "only the keystroke is filtered"
2562        );
2563    }
2564
2565    /// Digits ask for a keypad, and a keyboard named either side of it stands.
2566    #[test]
2567    fn digits_only_asks_for_a_keypad_without_overruling_one() {
2568        let plain: TextField<()> = TextField::new("").digits_only();
2569        assert_eq!(Widget::<()>::ime(&plain).keyboard, KeyboardType::Number);
2570        let before: TextField<()> = TextField::new("")
2571            .keyboard_type(KeyboardType::Phone)
2572            .digits_only();
2573        assert_eq!(Widget::<()>::ime(&before).keyboard, KeyboardType::Phone);
2574        let after: TextField<()> = TextField::new("")
2575            .digits_only()
2576            .keyboard_type(KeyboardType::Phone);
2577        assert_eq!(Widget::<()>::ime(&after).keyboard, KeyboardType::Phone);
2578    }
2579
2580    /// The capitalisation reaches the keyboard the field asks for.
2581    #[test]
2582    fn the_capitalisation_travels_with_the_field() {
2583        let field: TextField<()> = TextField::new("").capitalization(Capitalization::Characters);
2584        let ime = Widget::<()>::ime(&field);
2585        assert_eq!(ime.capitalization, Capitalization::Characters);
2586        assert_eq!(ime.android_input_type() & 0x0000_7000, 0x0000_1000);
2587    }
2588
2589    /// A plain field asks for the plain keyboard, which is what every field used to
2590    /// get whether it suited or not.
2591    #[test]
2592    fn a_plain_field_asks_for_the_plain_keyboard() {
2593        let field: TextField<()> = TextField::new("hello");
2594        assert_eq!(
2595            Widget::<()>::ime(&field),
2596            Ime {
2597                keyboard: KeyboardType::Text,
2598                action: TextInputAction::Done,
2599                capitalization: Capitalization::Auto,
2600            }
2601        );
2602    }
2603
2604    /// **A masked field is a secret, and the dots cannot say so.** `obscure` draws
2605    /// dots on our side; the keyboard, told nothing, treated the field as ordinary
2606    /// prose — learning the password into its personal dictionary and offering it
2607    /// back as a suggestion later, on whatever screen came next.
2608    #[test]
2609    fn an_obscured_field_tells_the_keyboard_it_is_a_secret() {
2610        let field: TextField<()> = TextField::new("hunter2").obscure(true);
2611        let ime = Widget::<()>::ime(&field);
2612        assert_eq!(ime.keyboard, KeyboardType::Password);
2613        assert!(ime.keyboard.is_secret());
2614        // TYPE_TEXT_VARIATION_PASSWORD, which is what stops the learning.
2615        assert_eq!(ime.keyboard.android_input_type() & 0x0000_0ff0, 0x80);
2616    }
2617
2618    /// A field that takes several lines needs the action key to **insert** one.
2619    /// *Done* there is a keyboard that cannot type what the field is for.
2620    #[test]
2621    fn a_multiline_field_takes_a_newline_key() {
2622        let field: TextField<()> = TextField::new("a note").multiline();
2623        let ime = Widget::<()>::ime(&field);
2624        assert_eq!(ime.keyboard, KeyboardType::Multiline);
2625        assert_eq!(ime.action, TextInputAction::Newline);
2626        // `rows` implies multiline, so it implies the same keyboard.
2627        let rowed: TextField<()> = TextField::new("a note").rows(4);
2628        assert_eq!(Widget::<()>::ime(&rowed).keyboard, KeyboardType::Multiline);
2629    }
2630
2631    /// What the caller says beats what the field guessed — both halves, separately.
2632    #[test]
2633    fn what_the_caller_says_wins() {
2634        let field: TextField<()> = TextField::new("")
2635            .obscure(true)
2636            .keyboard_type(KeyboardType::VisiblePassword);
2637        assert_eq!(
2638            Widget::<()>::ime(&field).keyboard,
2639            KeyboardType::VisiblePassword
2640        );
2641
2642        // A search box asks for Search without ceasing to be a text field.
2643        let query: TextField<()> = TextField::new("").action(TextInputAction::Search);
2644        let ime = Widget::<()>::ime(&query);
2645        assert_eq!(ime.keyboard, KeyboardType::Text);
2646        assert_eq!(ime.action, TextInputAction::Search);
2647
2648        // And a multi-line field can still be told to say Done.
2649        let note: TextField<()> = TextField::new("").multiline().action(TextInputAction::Done);
2650        assert_eq!(Widget::<()>::ime(&note).action, TextInputAction::Done);
2651    }
2652
2653    /// A field that says what it is gets the keyboard for it — a keypad for a phone
2654    /// number, an `@` for an address.
2655    #[test]
2656    fn a_typed_field_gets_the_keyboard_for_it() {
2657        let phone: TextField<()> = TextField::new("").keyboard_type(KeyboardType::Phone);
2658        // TYPE_CLASS_PHONE
2659        assert_eq!(Widget::<()>::ime(&phone).keyboard.android_input_type(), 3);
2660
2661        let email: TextField<()> = TextField::new("").keyboard_type(KeyboardType::Email);
2662        let bits = Widget::<()>::ime(&email).keyboard.android_input_type();
2663        // TYPE_CLASS_TEXT | TYPE_TEXT_VARIATION_EMAIL_ADDRESS, and no capitalisation:
2664        // `Someone@` is an address that does not work.
2665        assert_eq!(bits, 1 | 0x20);
2666    }
2667
2668    #[test]
2669    fn obscure_masks_the_displayed_text_but_not_the_value() {
2670        // A password field: the render never contains the value in the clear, only dots;
2671        // the real value stays reachable (editing, IME).
2672        let theme = Theme::default();
2673        let field = TextField::<Msg>::new("secret").obscure(true);
2674        let mut scene = Scene::new();
2675        let status = Status {
2676            focused: true,
2677            cursor: Some(6),
2678            ..Default::default()
2679        };
2680        Widget::<Msg>::paint(
2681            &field,
2682            Rect::new(0.0, 0.0, 220.0, 30.0),
2683            status,
2684            &theme,
2685            &mut scene,
2686        );
2687        let drawn: Vec<&str> = scene
2688            .primitives()
2689            .iter()
2690            .filter_map(|p| match p {
2691                frus_core::Primitive::Text { text, .. } => Some(text.as_str()),
2692                _ => None,
2693            })
2694            .collect();
2695        assert!(
2696            drawn.iter().all(|t| !t.contains("secret")),
2697            "the value must not leak"
2698        );
2699        assert!(
2700            drawn.iter().any(|t| t.chars().all(|c| c == '•')),
2701            "dots are drawn"
2702        );
2703        // The real value stays exposed to the input context.
2704        assert_eq!(Widget::<Msg>::text_value(&field), Some("secret"));
2705    }
2706
2707    #[test]
2708    fn prefix_icon_draws_a_path_and_shifts_the_hit_test() {
2709        // A prefix icon draws a path and offsets the content to the right: the same
2710        // click lands on a smaller index than it would without one.
2711        let theme = Theme::default();
2712        let with_icon = input("hello world").prefix_icon(Icons::STAR);
2713        let mut scene = Scene::new();
2714        Widget::<Msg>::paint(
2715            &with_icon,
2716            Rect::new(0.0, 0.0, 220.0, 30.0),
2717            Status::default(),
2718            &theme,
2719            &mut scene,
2720        );
2721        assert!(
2722            scene
2723                .primitives()
2724                .iter()
2725                .any(|p| matches!(p, frus_core::Primitive::Path { .. })),
2726            "the prefix icon draws a path"
2727        );
2728        let plain = input("hello world");
2729        let at_plain =
2730            Widget::<Msg>::cursor_at(&plain, 60.0, FIELD_OUTLINED_PADDING_TOP, 220.0, 0).unwrap();
2731        let at_icon =
2732            Widget::<Msg>::cursor_at(&with_icon, 60.0, FIELD_OUTLINED_PADDING_TOP, 220.0, 0)
2733                .unwrap();
2734        assert!(
2735            at_icon < at_plain,
2736            "the prefix offsets the content ({at_plain} → {at_icon})"
2737        );
2738    }
2739
2740    #[test]
2741    fn multiline_enter_inserts_a_newline_instead_of_submitting() {
2742        // In multi-line mode, Enter inserts a "\n" (and emits no submission).
2743        let inp = TextField::<Msg>::new("ab")
2744            .on_input(Msg::Changed)
2745            .multiline();
2746        let mut edit = Edit {
2747            cursor: 2,
2748            anchor: None,
2749            composing: None,
2750        };
2751        assert_eq!(
2752            inp.on_edit(&mut edit, &Key::Enter),
2753            Some(Msg::Changed("ab\n".to_string()))
2754        );
2755        assert_eq!(edit.cursor, 3);
2756        // Single line: Enter submits (unchanged behaviour).
2757        let single = input("ab").on_submit(Msg::Submitted);
2758        let mut edit = Edit {
2759            cursor: 2,
2760            anchor: None,
2761            composing: None,
2762        };
2763        assert_eq!(single.on_edit(&mut edit, &Key::Enter), Some(Msg::Submitted));
2764    }
2765
2766    #[test]
2767    fn multiline_wraps_long_lines_to_the_width() {
2768        // A long line **without** any `\n` wraps onto several visual lines: a click well
2769        // below the 1st line places the caret further into the text (a wrapped line under
2770        // the first one), not at index 0.
2771        let long = "word ".repeat(30); // 150 characters, no explicit break
2772        let inp = TextField::<Msg>::new(long.trim_end())
2773            .on_input(Msg::Changed)
2774            .rows(4)
2775            .width(160.0);
2776        let line_h = inp.text_style().line_height();
2777        let top = Widget::<Msg>::cursor_at(
2778            &inp,
2779            FIELD_PADDING_X + 2.0,
2780            FIELD_OUTLINED_PADDING_TOP + 1.0,
2781            160.0,
2782            0,
2783        )
2784        .unwrap();
2785        let wrapped = Widget::<Msg>::cursor_at(
2786            &inp,
2787            FIELD_PADDING_X + 2.0,
2788            FIELD_OUTLINED_PADDING_TOP + line_h * 2.0 + 1.0,
2789            160.0,
2790            0,
2791        )
2792        .unwrap();
2793        assert!(top < 10, "1st line: {top}");
2794        assert!(
2795            wrapped > top,
2796            "a wrapped line further down → an index further on ({top} → {wrapped})"
2797        );
2798    }
2799
2800    #[test]
2801    fn multiline_reports_overflow_and_scrolls_content() {
2802        // Five lines in a box of two: the content overflows…
2803        let inp = TextField::<Msg>::new("l1\nl2\nl3\nl4\nl5")
2804            .on_input(Msg::Changed)
2805            .rows(2)
2806            .width(200.0);
2807        let (content_h, visible_h, _, _) =
2808            Widget::<Msg>::text_metrics(&inp, 200.0, 0).expect("a multi-line field");
2809        assert!(
2810            content_h > visible_h + 1.0,
2811            "5 lines > a box of 2 ({content_h} vs {visible_h})"
2812        );
2813
2814        // …and a retained scroll shifts the text upwards (a smaller position.y).
2815        let text_top = |scroll: f32| {
2816            let mut scene = Scene::new();
2817            let status = Status {
2818                focused: true,
2819                cursor: Some(0),
2820                scroll_y: scroll,
2821                ..Default::default()
2822            };
2823            Widget::<Msg>::paint(
2824                &inp,
2825                Rect::new(0.0, 0.0, 200.0, 80.0),
2826                status,
2827                &Theme::default(),
2828                &mut scene,
2829            );
2830            scene
2831                .primitives()
2832                .iter()
2833                .find_map(|p| match p {
2834                    frus_core::Primitive::Text { text, position, .. } if text.contains("l1") => {
2835                        Some(position.y)
2836                    }
2837                    _ => None,
2838                })
2839                .expect("the field's text")
2840        };
2841        assert!(
2842            text_top(20.0) < text_top(0.0),
2843            "scrolling raises the content"
2844        );
2845    }
2846
2847    #[test]
2848    fn multiline_arrows_move_the_caret_between_lines() {
2849        // "abc\ndefg\nhi": line 0 = indices 0..3, line 1 = 4..8, line 2 = 9..11.
2850        let inp = TextField::<Msg>::new("abc\ndefg\nhi")
2851            .on_input(Msg::Changed)
2852            .rows(3)
2853            .width(200.0);
2854        // From the 1st line (index 1), moving down lands on the 2nd line.
2855        let (down, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 1, true, false, None).unwrap();
2856        assert!(
2857            (4..=8).contains(&down),
2858            "moves down to the 2nd line: {down}"
2859        );
2860        // 1st line, moving up → impossible (the shell moves the focus).
2861        assert_eq!(
2862            Widget::<Msg>::caret_vertical(&inp, 200.0, 1, false, false, None),
2863            None
2864        );
2865        // Last line (index 10), moving down → impossible.
2866        assert_eq!(
2867            Widget::<Msg>::caret_vertical(&inp, 200.0, 10, true, false, None),
2868            None
2869        );
2870        // 2nd line (index 5), moving up lands on the 1st.
2871        let (up, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 5, false, false, None).unwrap();
2872        assert!(up <= 3, "moves up to the 1st line: {up}");
2873        // A single-line field: never any vertical movement.
2874        assert_eq!(
2875            Widget::<Msg>::caret_vertical(&input("abc"), 200.0, 1, true, false, None),
2876            None
2877        );
2878    }
2879
2880    #[test]
2881    fn multiline_goal_column_survives_a_short_line() {
2882        // "hello\nhi\nworld": line 0 = 0..5, line 1 = 6..8 (short), line 2 = 9..14.
2883        // Starting from column 5 (the end of "hello") and moving down: the 2nd line "hi"
2884        // is too short (so the caret is clamped to its end), but the goal column returned
2885        // stays ~the starting one, so moving down again lands back on column 5.
2886        let inp = TextField::<Msg>::new("hello\nhi\nworld")
2887            .on_input(Msg::Changed)
2888            .rows(3)
2889            .width(200.0);
2890        let (mid, goal) = Widget::<Msg>::caret_vertical(&inp, 200.0, 5, true, false, None).unwrap();
2891        assert!(
2892            (6..=8).contains(&mid),
2893            "a short line, clamped to its end: {mid}"
2894        );
2895        // A second jump reusing the goal column: without memory the caret would have
2896        // stayed clamped to the end of "hi" (col. ~2); here it lands far into "world"
2897        // (col. ~5), proving the original column survives the short line.
2898        let (low, _) =
2899            Widget::<Msg>::caret_vertical(&inp, 200.0, mid, true, false, Some(goal)).unwrap();
2900        assert!(
2901            (12..=14).contains(&low),
2902            "goal column preserved into \"world\": {low}"
2903        );
2904    }
2905
2906    #[test]
2907    fn multiline_page_jump_is_clamped_to_the_field() {
2908        // Page up/down never leave the multi-line field: at the bounds the caret settles
2909        // at the start / at the end and returns `Some` (not `None` → the field is kept).
2910        let inp = TextField::<Msg>::new("a\nb\nc\nd\ne")
2911            .on_input(Msg::Changed)
2912            .rows(2)
2913            .width(200.0);
2914        // From the last line, PgDn settles at the bottom (the field is not left).
2915        let (bottom, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 8, true, true, None).unwrap();
2916        assert!(
2917            bottom >= 7,
2918            "PgDn clamped to the bottom of the field: {bottom}"
2919        );
2920        // From the 1st line, PgUp settles at the top.
2921        let (top, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 0, false, true, None).unwrap();
2922        assert!(top <= 1, "PgUp clamped to the top of the field: {top}");
2923    }
2924
2925    #[test]
2926    fn multiline_reserves_rows_of_height() {
2927        // `rows(n)` reserves n lines; that is taller than a one-line field.
2928        let one = match Widget::<Msg>::style(&input("x")).height {
2929            Dimension::Length(h) => h,
2930            _ => panic!("a fixed height"),
2931        };
2932        let four = match Widget::<Msg>::style(&TextField::<Msg>::new("x").rows(4)).height {
2933            Dimension::Length(h) => h,
2934            _ => panic!("a fixed height"),
2935        };
2936        assert!(
2937            four > one * 2.0,
2938            "4 lines much taller than one ({one} → {four})"
2939        );
2940    }
2941
2942    #[test]
2943    fn multiline_hit_test_uses_the_click_line() {
2944        // A click on the 2nd line places the caret inside "cd" (indices ≥ 3), not inside
2945        // the "ab" of the 1st line.
2946        let inp = TextField::<Msg>::new("ab\ncd")
2947            .on_input(Msg::Changed)
2948            .rows(3);
2949        let line_h = inp.text_style().line_height();
2950        // A top-left click → the 1st line (index ≤ 2).
2951        let top = Widget::<Msg>::cursor_at(
2952            &inp,
2953            FIELD_PADDING_X + 1.0,
2954            FIELD_OUTLINED_PADDING_TOP + 1.0,
2955            220.0,
2956            0,
2957        )
2958        .unwrap();
2959        // A click one line lower → the 2nd line (index ≥ 3).
2960        let below = Widget::<Msg>::cursor_at(
2961            &inp,
2962            FIELD_PADDING_X + 1.0,
2963            FIELD_OUTLINED_PADDING_TOP + line_h + 1.0,
2964            220.0,
2965            0,
2966        )
2967        .unwrap();
2968        assert!(top <= 2, "1st line: {top}");
2969        assert!(below >= 3, "2nd line: {below}");
2970    }
2971
2972    #[test]
2973    fn floating_label_rests_in_box_then_floats_up() {
2974        // The label rests inside the box (large, low) while the field is empty and
2975        // unfocused, and floats above it (small, high) once focused.
2976        let theme = Theme::default();
2977        let field = TextField::<Msg>::new("").label("Name");
2978        let label_geo = |status: Status| -> (f32, f32) {
2979            let mut scene = Scene::new();
2980            Widget::<Msg>::paint(
2981                &field,
2982                Rect::new(0.0, 30.0, 220.0, 60.0),
2983                status,
2984                &theme,
2985                &mut scene,
2986            );
2987            scene
2988                .primitives()
2989                .iter()
2990                .find_map(|p| match p {
2991                    frus_core::Primitive::Text {
2992                        text,
2993                        size,
2994                        position,
2995                        ..
2996                    } if text == "Name" => Some((*size, position.y)),
2997                    _ => None,
2998                })
2999                .expect("a painted label")
3000        };
3001        let (rest_size, rest_y) = label_geo(Status::default());
3002        let (float_size, float_y) = label_geo(Status {
3003            focused: true,
3004            focus_progress: 1.0,
3005            ..Default::default()
3006        });
3007        assert!(
3008            rest_size > float_size,
3009            "at rest the label is larger ({rest_size} → {float_size})"
3010        );
3011        assert!(
3012            float_y < rest_y,
3013            "once focused the label rises ({rest_y} → {float_y})"
3014        );
3015    }
3016
3017    /// **An errored field deepens under the pointer** (milestone 439).
3018    ///
3019    /// `error` at rest, `on_error_container` while hovered, and `error` again once
3020    /// focused — the reference tests focus **before** hover
3021    /// (`input_decorator.dart:5977`), because a focused field is already saying
3022    /// everything it can. The message below it does not move: `errorStyle` is `error` in
3023    /// every state (`:6100`), it being a sentence rather than a control.
3024    ///
3025    /// It is the first thing here to ask the scheme for `on_error_container`, which
3026    /// arrived in milestone 429 with nothing wanting it.
3027    #[test]
3028    fn an_errored_field_deepens_under_the_pointer() {
3029        let theme = Theme::default();
3030        let field = input("x").label("Name").error("Required").outlined();
3031        let painted = |status: Status| {
3032            let mut scene = Scene::new();
3033            Widget::<Msg>::paint(
3034                &field,
3035                Rect::new(0.0, 0.0, 220.0, 90.0),
3036                status,
3037                &theme,
3038                &mut scene,
3039            );
3040            scene
3041        };
3042        // The border is the only stroked rectangle a field paints.
3043        let border = |status: Status| {
3044            painted(status)
3045                .primitives()
3046                .iter()
3047                .find_map(|p| match p {
3048                    frus_core::Primitive::Rect {
3049                        border_color,
3050                        border_width,
3051                        ..
3052                    } if *border_width > 0.0 => Some(*border_color),
3053                    _ => None,
3054                })
3055                .expect("an outlined field draws a border")
3056        };
3057        let message = |status: Status| {
3058            painted(status)
3059                .primitives()
3060                .iter()
3061                .find_map(|p| match p {
3062                    frus_core::Primitive::Text { text, color, .. } if text == "Required" => {
3063                        Some(*color)
3064                    }
3065                    _ => None,
3066                })
3067                .expect("the message is painted")
3068        };
3069        let rest = Status {
3070            opacity: 1.0,
3071            ..Default::default()
3072        };
3073        let hovered = Status {
3074            hover_progress: 1.0,
3075            ..rest
3076        };
3077        let both = Status {
3078            focused: true,
3079            focus_progress: 1.0,
3080            ..hovered
3081        };
3082
3083        assert_eq!(border(rest), theme.scheme.error);
3084        assert_eq!(
3085            border(hovered),
3086            theme.scheme.on_error_container,
3087            "it deepens under the pointer"
3088        );
3089        assert_eq!(
3090            border(both),
3091            theme.scheme.error,
3092            "and comes back once focused, which the reference tests first"
3093        );
3094        assert_ne!(
3095            theme.scheme.error, theme.scheme.on_error_container,
3096            "the two have to differ for any of the above to mean anything"
3097        );
3098
3099        for status in [rest, hovered, both] {
3100            assert_eq!(
3101                message(status),
3102                theme.scheme.error,
3103                "the message is a sentence, not a control"
3104            );
3105        }
3106    }
3107
3108    #[test]
3109    fn decoration_grows_height_for_label_and_error() {
3110        // A bare field reserves only the box; a label + an error add one line above and
3111        // one below → the style is taller.
3112        let bare_h = match Widget::<Msg>::style(&input("x")).height {
3113            Dimension::Length(h) => h,
3114            _ => panic!("a fixed height was expected"),
3115        };
3116        let decorated = input("x").label("Email").error("Required");
3117        let deco_h = match Widget::<Msg>::style(&decorated).height {
3118            Dimension::Length(h) => h,
3119            _ => panic!("a fixed height was expected"),
3120        };
3121        assert!(
3122            deco_h > bare_h,
3123            "label + error grow the field ({bare_h} → {deco_h})"
3124        );
3125    }
3126
3127    #[test]
3128    fn error_paints_the_border_in_the_error_color() {
3129        // In error, the box's border switches to the theme's error colour.
3130        let theme = Theme::default();
3131        let field = input("x").error("bad");
3132        let mut scene = Scene::new();
3133        Widget::<Msg>::paint(
3134            &field,
3135            Rect::new(0.0, 0.0, 220.0, 60.0),
3136            Status::default(),
3137            &theme,
3138            &mut scene,
3139        );
3140        let has_error_border = scene.primitives().iter().any(|p| {
3141            matches!(
3142                p,
3143                frus_core::Primitive::Rect { border_color, border_width, .. }
3144                    if *border_width > 0.0 && *border_color == theme.error
3145            )
3146        });
3147        assert!(has_error_border, "the border must be in the error colour");
3148    }
3149
3150    #[test]
3151    fn placeholder_shows_only_when_empty() {
3152        // The hint only appears while the value is empty.
3153        let theme = Theme::default();
3154        let count_texts = |value: &str| {
3155            let field = TextField::<Msg>::new(value).placeholder("Type here");
3156            let mut scene = Scene::new();
3157            Widget::<Msg>::paint(
3158                &field,
3159                Rect::new(0.0, 0.0, 220.0, 30.0),
3160                Status::default(),
3161                &theme,
3162                &mut scene,
3163            );
3164            scene
3165                .primitives()
3166                .iter()
3167                .filter(
3168                    |p| matches!(p, frus_core::Primitive::Text { text, .. } if text == "Type here"),
3169                )
3170                .count()
3171        };
3172        assert_eq!(count_texts(""), 1, "empty: the hint shows");
3173        assert_eq!(count_texts("hi"), 0, "rempli : pas d'indice");
3174    }
3175
3176    #[test]
3177    fn clickable_suffix_emits_and_blocks_caret() {
3178        let field = TextField::new("hello")
3179            .suffix_icon(Icons::CLOSE)
3180            .on_suffix(Msg::Submitted)
3181            .width(220.0);
3182        let (w, y) = (220.0, 12.0);
3183        let x_suffix = w - 8.0; // near the right edge (the suffix's zone)
3184
3185        // A click on the suffix: emits the message, and does NOT place a caret.
3186        assert_eq!(
3187            Widget::<Msg>::positional_click(&field, x_suffix, y, w, 40.0),
3188            Some(Msg::Submitted)
3189        );
3190        assert_eq!(Widget::<Msg>::cursor_at(&field, x_suffix, y, w, 0), None);
3191        // A click in the body: no suffix, a caret is placed.
3192        assert_eq!(
3193            Widget::<Msg>::positional_click(&field, 20.0, y, w, 40.0),
3194            None
3195        );
3196        assert!(Widget::<Msg>::cursor_at(&field, 20.0, y, w, 0).is_some());
3197        // Without `on_suffix` the icon stays decorative (no positional click).
3198        let deco = TextField::<Msg>::new("hello")
3199            .suffix_icon(Icons::CLOSE)
3200            .width(220.0);
3201        assert_eq!(
3202            Widget::<Msg>::positional_click(&deco, x_suffix, y, w, 40.0),
3203            None
3204        );
3205    }
3206
3207    #[test]
3208    fn hovering_active_suffix_paints_a_halo() {
3209        let field = TextField::new("hello")
3210            .suffix_icon(Icons::CLOSE)
3211            .on_suffix(Msg::Submitted)
3212            .width(220.0);
3213        let bounds = Rect::new(0.0, 0.0, 220.0, 40.0);
3214        // Counts the ~28x28 rectangles (the halo behind the suffix icon).
3215        let halos = |status: Status| {
3216            let mut scene = Scene::new();
3217            Widget::<Msg>::paint(&field, bounds, status, &Theme::default(), &mut scene);
3218            scene
3219                .primitives()
3220                .iter()
3221                .filter(|p| match p {
3222                    frus_core::Primitive::Rect { rect, .. } => {
3223                        (rect.width - (FIELD_ICON_SIZE + 8.0)).abs() < 0.5
3224                            && (rect.height - (FIELD_ICON_SIZE + 8.0)).abs() < 0.5
3225                    }
3226                    _ => false,
3227                })
3228                .count()
3229        };
3230        // Pointer over the suffix → one halo; elsewhere (the body) or without hover → none.
3231        let over_suffix = Status {
3232            hover_cursor: Some(Point::new(212.0, 20.0)),
3233            ..Default::default()
3234        };
3235        let over_body = Status {
3236            hover_cursor: Some(Point::new(20.0, 20.0)),
3237            ..Default::default()
3238        };
3239        assert_eq!(halos(over_suffix), 1, "a halo on the hovered suffix");
3240        assert_eq!(halos(over_body), 0, "no halo in the body");
3241        assert_eq!(halos(Status::default()), 0, "no halo without hover");
3242    }
3243
3244    #[test]
3245    fn cursor_icon_is_pointer_over_active_suffix() {
3246        use crate::interaction::Cursor;
3247        let field = TextField::new("hello")
3248            .suffix_icon(Icons::CLOSE)
3249            .on_suffix(Msg::Submitted)
3250            .width(220.0);
3251        let (w, h, y) = (220.0, 40.0, 12.0);
3252        // A hand over the suffix, nothing in the body.
3253        assert_eq!(
3254            Widget::<Msg>::cursor_icon(&field, w - 8.0, y, w, h),
3255            Some(Cursor::Pointer)
3256        );
3257        assert_eq!(Widget::<Msg>::cursor_icon(&field, 20.0, y, w, h), None);
3258        // A decorative suffix (no on_suffix): no hand.
3259        let deco = TextField::<Msg>::new("hello")
3260            .suffix_icon(Icons::CLOSE)
3261            .width(220.0);
3262        assert_eq!(Widget::<Msg>::cursor_icon(&deco, w - 8.0, y, w, h), None);
3263    }
3264
3265    /// **Each handle stands for its own end of the selection** (milestone 511): the point
3266    /// it carries, handed back to the field's own hit test, is that end — with a label and
3267    /// an icon moving the text in, and with text scrolled sideways, which is where a
3268    /// handle and its end would part company first. The shell drags a handle through
3269    /// exactly this round trip.
3270    #[test]
3271    fn each_handle_stands_for_its_own_end_of_the_selection() {
3272        let width = 220.0;
3273        let fields = [
3274            TextField::<Msg>::new("hello world").width(width),
3275            TextField::<Msg>::new("hello world")
3276                .label("Name")
3277                .prefix_icon(Icons::CLOSE)
3278                .width(width),
3279            // Outlined, the label sits on the border and the box starts below it.
3280            TextField::<Msg>::new("hello world")
3281                .label("Name")
3282                .outlined()
3283                .width(width),
3284            // Wider than its box, the caret at the far end: scrolled.
3285            TextField::<Msg>::new("a sentence long enough to be scrolled along its box")
3286                .width(width),
3287        ];
3288        for field in &fields {
3289            let len = field.value.chars().count();
3290            let edit = Edit {
3291                cursor: len,
3292                anchor: Some(len - 5),
3293                composing: None,
3294            };
3295            let [start, end] = Widget::<Msg>::selection_handles(field, width, &edit, 0.0)
3296                .expect("a selection has handles");
3297            for (handle, index) in [(start, len - 5), (end, len)] {
3298                let at = handle.line_center;
3299                assert_eq!(
3300                    Widget::<Msg>::cursor_at(field, at.x, at.y, width, edit.cursor),
3301                    Some(index),
3302                    "{:?}",
3303                    field.value
3304                );
3305            }
3306            // Each hangs below its line and points in at the text: the corner the start
3307            // one has squared off is its position, and the end one's likewise.
3308            assert_eq!(start.rect.x + start.rect.width, start.line_center.x);
3309            assert_eq!(end.rect.x, end.line_center.x);
3310            assert!(start.rect.y > start.line_center.y && end.rect.y > end.line_center.y);
3311            // And on the line the paint draws: the text's top, the line's middle, then the
3312            // handle hanging from its bottom. A single line's hit test clamps the height, so
3313            // only the paint can say the handles are at the right one.
3314            let mut scene = Scene::new();
3315            let status = Status {
3316                focused: true,
3317                cursor: Some(len),
3318                selection: Some((len - 5, len)),
3319                ..Status::default()
3320            };
3321            Widget::<Msg>::paint(
3322                field,
3323                Rect::new(0.0, 0.0, width, 120.0),
3324                status,
3325                &Theme::default(),
3326                &mut scene,
3327            );
3328            let top = scene
3329                .primitives()
3330                .iter()
3331                .find_map(|p| match p {
3332                    frus_core::Primitive::Text { position, text, .. } if *text == field.value => {
3333                        Some(position.y)
3334                    }
3335                    _ => None,
3336                })
3337                .expect("the value is painted");
3338            let half = start.line_center.y - top;
3339            assert!(
3340                half > 0.0 && (start.rect.y - top - 2.0 * half).abs() < 0.01,
3341                "{:?}: text at {top}, line centre at {}, handle at {}",
3342                field.value,
3343                start.line_center.y,
3344                start.rect.y
3345            );
3346        }
3347    }
3348
3349    /// A multi-line field scrolled down carries its handles up with the text, and the
3350    /// shell, folding the scroll back in as it does for a press, lands on the same ends.
3351    #[test]
3352    fn a_handle_rises_with_the_lines_a_field_scrolls_away() {
3353        let width = 200.0;
3354        let field = TextField::<Msg>::new("one\ntwo\nthree\nfour\nfive\nsix")
3355            .multiline()
3356            .rows(3)
3357            .width(width);
3358        // "five".
3359        let edit = Edit {
3360            cursor: 23,
3361            anchor: Some(19),
3362            composing: None,
3363        };
3364        let scroll = 20.0;
3365        let still = Widget::<Msg>::selection_handles(&field, width, &edit, 0.0).unwrap();
3366        let [start, end] = Widget::<Msg>::selection_handles(&field, width, &edit, scroll).unwrap();
3367        assert!((still[0].line_center.y - start.line_center.y - scroll).abs() < 0.01);
3368        for (handle, index) in [(start, 19), (end, 23)] {
3369            let at = handle.line_center;
3370            assert_eq!(
3371                Widget::<Msg>::cursor_at(&field, at.x, at.y + scroll, width, edit.cursor),
3372                Some(index)
3373            );
3374        }
3375    }
3376
3377    /// Handles are painted for a selection the shell marks as a touch one, and for no
3378    /// other: not for one made with a mouse, not in a field that has lost the focus. In
3379    /// the scheme's primary, unless the field says otherwise.
3380    #[test]
3381    fn the_handles_are_painted_for_a_touch_selection_only() {
3382        let theme = Theme::default();
3383        let fills = |field: &TextField<Msg>, handles: bool, focused: bool| {
3384            let mut scene = Scene::new();
3385            let status = Status {
3386                focused,
3387                cursor: Some(11),
3388                selection: Some((6, 11)),
3389                handles,
3390                ..Status::default()
3391            };
3392            Widget::<Msg>::paint(
3393                field,
3394                Rect::new(10.0, 20.0, 220.0, 56.0),
3395                status,
3396                &theme,
3397                &mut scene,
3398            );
3399            scene
3400                .primitives()
3401                .iter()
3402                .filter_map(|p| match p {
3403                    frus_core::Primitive::Path { fill: Some(c), .. } => Some(*c),
3404                    _ => None,
3405                })
3406                .collect::<Vec<_>>()
3407        };
3408        let field = TextField::<Msg>::new("hello world").width(220.0);
3409        assert_eq!(fills(&field, true, true), vec![theme.scheme.primary; 2]);
3410        assert!(fills(&field, false, true).is_empty(), "a mouse selection");
3411        assert!(fills(&field, true, false).is_empty(), "a field left behind");
3412        let styled = TextField::<Msg>::new("hello world")
3413            .width(220.0)
3414            .style(TextFieldStyle {
3415                handle_color: Some(frus_core::Color::WHITE),
3416                ..TextFieldStyle::default()
3417            });
3418        assert_eq!(fills(&styled, true, true), vec![frus_core::Color::WHITE; 2]);
3419    }
3420
3421    /// The caret's hidden half paints no caret, and its shown half does (milestone 513).
3422    #[test]
3423    fn a_hidden_caret_is_not_painted() {
3424        let theme = Theme::default();
3425        let carets = |hidden: bool| {
3426            let mut scene = Scene::new();
3427            let status = Status {
3428                focused: true,
3429                cursor: Some(2),
3430                caret_hidden: hidden,
3431                ..Status::default()
3432            };
3433            Widget::<Msg>::paint(
3434                &TextField::<Msg>::new("hello").width(220.0),
3435                Rect::new(0.0, 0.0, 220.0, 56.0),
3436                status,
3437                &theme,
3438                &mut scene,
3439            );
3440            scene
3441                .primitives()
3442                .iter()
3443                .filter(
3444                    |p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.width == 2.0),
3445                )
3446                .count()
3447        };
3448        assert_eq!(carets(false), 1, "shown");
3449        assert_eq!(carets(true), 0, "hidden");
3450    }
3451
3452    #[test]
3453    fn word_at_finds_word_bounds() {
3454        let inp = input("hello there world");
3455        // Any index inside a word yields that word's bounds; separators are excluded.
3456        assert_eq!(inp.word_at(2), Some((0, 5))); // "hello"
3457        assert_eq!(inp.word_at(13), Some((12, 17))); // "world"
3458    }
3459}
3460
3461#[cfg(test)]
3462mod limit_tests {
3463    use super::*;
3464    use crate::runtime::Edit;
3465
3466    #[derive(Clone, Debug, PartialEq)]
3467    enum Msg {
3468        Changed(String),
3469    }
3470
3471    fn field(value: &str) -> TextField<Msg> {
3472        TextField::new(value).on_input(Msg::Changed)
3473    }
3474
3475    /// Types `text` at the end of the field's value and returns what came back.
3476    fn typed(field: &TextField<Msg>, text: &str) -> (Option<Msg>, Edit) {
3477        let end = field.value.chars().count();
3478        let mut edit = Edit {
3479            cursor: end,
3480            anchor: None,
3481            ..Default::default()
3482        };
3483        let msg = Widget::on_edit(field, &mut edit, &Key::Text(text.to_string()));
3484        (msg, edit)
3485    }
3486
3487    fn value_of(msg: Option<Msg>) -> Option<String> {
3488        msg.map(|Msg::Changed(v)| v)
3489    }
3490
3491    /// A keystroke past the limit does not reach the value.
3492    #[test]
3493    fn a_keystroke_past_the_limit_goes_nowhere() {
3494        let full = field("abcde").max_length(5);
3495        assert_eq!(value_of(typed(&full, "f").0), Some("abcde".into()));
3496        let room = field("abcd").max_length(5);
3497        assert_eq!(value_of(typed(&room, "e").0), Some("abcde".into()));
3498    }
3499
3500    /// A paste that crosses the limit lands the part that fits rather than being dropped
3501    /// whole: losing work the user can see they had is the worse of the two answers.
3502    #[test]
3503    fn a_paste_that_crosses_the_limit_lands_what_fits() {
3504        let f = field("ab").max_length(5);
3505        let (msg, edit) = typed(&f, "cdefgh");
3506        assert_eq!(value_of(msg), Some("abcde".into()));
3507        assert_eq!(edit.cursor, 5, "the caret comes to rest at the end");
3508    }
3509
3510    /// The limit is in **characters**, not bytes.
3511    #[test]
3512    fn the_limit_counts_characters_not_bytes() {
3513        let f = field("").max_length(3);
3514        assert_eq!(value_of(typed(&f, "éàü").0), Some("éàü".into()));
3515        let full = field("éàü").max_length(3);
3516        assert_eq!(value_of(typed(&full, "e").0), Some("éàü".into()));
3517    }
3518
3519    /// A value the caller supplied over the limit is left alone — it is the application's
3520    /// state, not something typed — and the counter says so.
3521    #[test]
3522    fn a_value_the_caller_set_over_the_limit_is_left_alone() {
3523        let over = field("abcdefg").max_length(5);
3524        assert_eq!(over.counter().as_deref(), Some("7/5"));
3525        assert_eq!(over.value, "abcdefg");
3526    }
3527
3528    /// The counter reserves the line below the box even with no helper to share it with.
3529    #[test]
3530    fn the_counter_reserves_the_line_it_sits_on() {
3531        assert_eq!(
3532            field("ab").sub_block(),
3533            0.0,
3534            "nothing to say, no room taken"
3535        );
3536        assert!(field("ab").max_length(5).sub_block() > 0.0);
3537        assert_eq!(field("ab").max_length(5).counter().as_deref(), Some("2/5"));
3538    }
3539
3540    /// Read-only refuses the **change** and keeps everything that only moves.
3541    #[test]
3542    fn read_only_refuses_the_change_and_keeps_the_caret() {
3543        let f = field("hello").read_only();
3544        assert_eq!(typed(&f, "x").0, None, "typing goes nowhere");
3545
3546        let mut edit = Edit {
3547            cursor: 5,
3548            anchor: None,
3549            ..Default::default()
3550        };
3551        assert_eq!(
3552            Widget::on_edit(
3553                &f,
3554                &mut edit,
3555                &Key::Left {
3556                    shift: false,
3557                    word: false
3558                }
3559            ),
3560            None
3561        );
3562        assert_eq!(edit.cursor, 4, "but the caret still moves");
3563
3564        let mut edit = Edit {
3565            cursor: 5,
3566            anchor: None,
3567            ..Default::default()
3568        };
3569        Widget::on_edit(
3570            &f,
3571            &mut edit,
3572            &Key::Home {
3573                shift: true,
3574                doc: false,
3575            },
3576        );
3577        assert_eq!(
3578            (edit.cursor, edit.anchor),
3579            (0, Some(5)),
3580            "and a selection can still be made, so it can be copied"
3581        );
3582    }
3583
3584    /// Backspace is a change, so it is refused too — and leaves the caret alone.
3585    #[test]
3586    fn read_only_refuses_a_deletion() {
3587        let f = field("hello").read_only();
3588        let mut edit = Edit {
3589            cursor: 5,
3590            anchor: None,
3591            ..Default::default()
3592        };
3593        assert_eq!(Widget::on_edit(&f, &mut edit, &Key::Backspace), None);
3594        assert_eq!(edit.cursor, 5);
3595    }
3596
3597    /// It is not a disabled field: it keeps its caret and its place in the tab order.
3598    #[test]
3599    fn read_only_is_not_disabled() {
3600        let read = field("REF-4417").read_only();
3601        assert!(Widget::<Msg>::focusable(&read), "still in the tab order");
3602        assert!(
3603            Widget::<Msg>::cursor_at(&read, 20.0, 10.0, 200.0, 0).is_some(),
3604            "and it still takes a caret, so a reference can be selected and copied"
3605        );
3606
3607        let dead = field("REF-4417").enabled(false);
3608        assert!(!Widget::<Msg>::focusable(&dead));
3609        assert!(Widget::<Msg>::cursor_at(&dead, 20.0, 10.0, 200.0, 0).is_none());
3610    }
3611}