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::{Color, 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::selectiontoolbar::{SelectionToolbar, ToolbarContext, ToolbarItem};
18use crate::theme::Theme;
19use crate::widget::Widget;
20
21/// Padding either side of the content, both variants.
22pub const FIELD_PADDING_X: f32 = 12.0;
23/// Padding above and below the content in a [`filled`](TextField::filled) field.
24///
25/// The reference's Material 3 content padding is `(12, 8, 12, 8)` filled and
26/// `(12, 20, 12, 12)` outlined. The asymmetry is not decoration: an outlined field's
27/// floating label sits **on** the top border, so the top has to give it room, while a
28/// filled one floats its label inside the box.
29pub const FIELD_PADDING_Y: f32 = 8.0;
30/// Padding above the content in an [`outlined`](TextField::outlined) field.
31pub const FIELD_OUTLINED_PADDING_TOP: f32 = 20.0;
32/// Padding below the content in an outlined field.
33pub const FIELD_OUTLINED_PADDING_BOTTOM: f32 = 12.0;
34
35/// Padding above and below the content in a [`dense`](TextField::dense) filled field —
36/// the reference's `(12, 4, 12, 4)`.
37pub const FIELD_DENSE_PADDING_Y: f32 = 4.0;
38/// Padding above the content in a dense **outlined** field: the reference's
39/// `(12, 16, 12, 8)`, which keeps room for the label on the border while giving back
40/// everything else.
41pub const FIELD_DENSE_OUTLINED_PADDING_TOP: f32 = 16.0;
42/// Padding below the content in a dense outlined field.
43pub const FIELD_DENSE_OUTLINED_PADDING_BOTTOM: f32 = 8.0;
44
45/// Size of the value and of the resting label — the reference's `body_large`.
46pub const FIELD_TEXT_SIZE: f32 = 16.0;
47/// What the label shrinks to once it floats. The reference scales it rather than naming
48/// a second size, so a field given a larger type keeps the same proportion.
49pub const FIELD_LABEL_SCALE: f32 = 0.75;
50/// Size of the helper and error line — the reference's `body_small`.
51pub const FIELD_SUB_SIZE: f32 = 12.0;
52/// Vertical gap between the box and the helper/error line, and above a floating label.
53pub const FIELD_GAP: f32 = 4.0;
54/// Margin either side of the floating label inside the border's **notch**, which is the
55/// reference's `OutlineInputBorder.gapPadding`.
56pub const FIELD_NOTCH_GAP: f32 = 4.0;
57
58/// Corner radius of the box, and of a filled field's two top corners.
59pub const FIELD_RADIUS: f32 = 4.0;
60/// Border weight at rest, and once focused — the reference widens rather than recolours
61/// alone, which is what makes focus readable without colour.
62pub const FIELD_BORDER_WIDTH: f32 = 1.0;
63/// Border weight of a focused field.
64pub const FIELD_FOCUSED_BORDER_WIDTH: f32 = 2.0;
65
66/// Side of a prefix/suffix icon (logical px) and the margin around it.
67pub const FIELD_ICON_SIZE: f32 = 24.0;
68const ICON_PAD: f32 = 6.0;
69/// What a disabled field keeps of its colours.
70///
71/// An alias, kept for the name: the number is the framework's one disabled content
72/// opacity, [`DISABLED_CONTENT_OPACITY`], and a field's 38 % moving independently of
73/// every other control's would be a bug rather than a setting.
74pub const FIELD_DISABLED_OPACITY: f32 = DISABLED_CONTENT_OPACITY;
75/// Default masking character of a password field.
76const OBSCURE_CHAR: char = '•';
77
78/// When a field's label floats above the value (milestone 656) — the reference's
79/// `FloatingLabelBehavior`.
80#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
81pub enum FloatingLabelBehavior {
82    /// Inside the field, in the value's place, while it is empty and unfocused; floated
83    /// once it is focused or holds something. The default.
84    #[default]
85    Auto,
86    /// Always floated, so the hint shows in an empty field even before it is focused.
87    Always,
88    /// Never floated: inside an empty field, in the value's place, and gone once it holds
89    /// something — a hint that a screen reader still reads as the field's name.
90    Never,
91}
92
93/// Where a field's floated label sits along its top (milestone 656) — the reference's
94/// `FloatingLabelAlignment`.
95#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
96pub enum FloatingLabelAlignment {
97    /// At the start, over the value. The default.
98    #[default]
99    Start,
100    /// Centred along the field.
101    Center,
102}
103
104/// Which of the reference's two fields this is.
105#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
106pub enum TextFieldVariant {
107    /// A box the caller can see all the way round, the floating label notching its top
108    /// border. The default here.
109    #[default]
110    Outlined,
111    /// A tinted container with a single line under it, the label floating **inside** the
112    /// box. The line carries the state; the fill carries the affordance.
113    Filled,
114    /// **No container at all** — the reference's `InputBorder.none`. No fill, no box, no
115    /// line: only the value, the hint and whatever icons were asked for.
116    ///
117    /// This is the field a widget puts *inside* something that is already a container. A
118    /// [`SearchBar`](crate::SearchBar) is a raised stadium with a field in it, and a field
119    /// that drew its own box inside that would be two containers deep with the outer one's
120    /// corners cut by the inner one's. The reference reaches for it in exactly those
121    /// places (`search_anchor.dart:1810`).
122    ///
123    /// It lays out like [`Filled`](Self::Filled) — the label floats inside rather than
124    /// notching a border, there being no border to notch — and simply paints nothing
125    /// behind the content.
126    None,
127}
128
129/// The caret's shape (milestone 659): what the caller said, each unset part the reference's.
130#[derive(Clone, Copy, Debug, PartialEq)]
131struct CursorShape {
132    /// Its width; the reference's 2.
133    width: f32,
134    /// Its height; the line's when `None`.
135    height: Option<f32>,
136    /// Its corners' radius; 2 on Apple's platforms and square elsewhere when `None`, as the
137    /// reference's are.
138    radius: Option<f32>,
139    /// Whether it shows at all.
140    shown: bool,
141}
142
143impl Default for CursorShape {
144    fn default() -> Self {
145        Self {
146            width: 2.0,
147            height: None,
148            radius: None,
149            shown: true,
150        }
151    }
152}
153
154/// Everything a [`TextField`] paints, each answer the caller's, the theme's, or the
155/// framework's — resolved in that order.
156#[derive(Clone, Copy, Debug, Default)]
157pub struct TextFieldStyle {
158    /// Container fill. A filled field defaults to the theme's high surface container; an
159    /// outlined one to nothing at all.
160    pub fill: Option<frus_core::Color>,
161    /// The border, or the underline, at rest.
162    pub border_color: Option<frus_core::Color>,
163    /// The border once focused.
164    pub focused_border_color: Option<frus_core::Color>,
165    /// Border, label and helper colour while an error is showing.
166    pub error_color: Option<frus_core::Color>,
167    /// The same, **under the pointer**: an errored field deepens on hover
168    /// (`input_decorator.dart:5981`). Unset, the scheme's `on_error_container`.
169    pub error_hover_color: Option<frus_core::Color>,
170    /// The value's colour.
171    pub text_color: Option<frus_core::Color>,
172    /// The label and the hint, at rest.
173    pub label_color: Option<frus_core::Color>,
174    /// The label once focused.
175    pub focused_label_color: Option<frus_core::Color>,
176    /// The helper line.
177    pub helper_color: Option<frus_core::Color>,
178    /// The prefix and suffix icons.
179    pub icon_color: Option<frus_core::Color>,
180    /// The handles under a touch selection. Unset, the scheme's `primary`
181    /// (`text_selection.dart:83`).
182    pub handle_color: Option<frus_core::Color>,
183    /// The caret (milestone 659). Unset, the theme's text selection's, then the scheme's
184    /// `primary`, as the reference's `cursorColor` is.
185    pub cursor_color: Option<frus_core::Color>,
186    /// The caret while an error is showing. Unset, the error colour.
187    pub cursor_error_color: Option<frus_core::Color>,
188    /// Corner radius.
189    pub radius: Option<f32>,
190    /// Border weight at rest.
191    pub border_width: Option<f32>,
192    /// Border weight once focused.
193    pub focused_border_width: Option<f32>,
194    /// Side of the prefix/suffix icons.
195    pub icon_size: Option<f32>,
196    /// Padding either side of the content.
197    pub padding_x: Option<f32>,
198}
199
200/// A single-line text input field, with optional **form decoration** (label, hint,
201/// helper text, error). **Validity** stays decided by the application (a pure
202/// function of the state); the field only displays its result through [`error`].
203///
204/// [`error`]: TextField::error
205pub struct TextField<Msg = crate::callback::Callback> {
206    value: String,
207    size: f32,
208    width: Dimension,
209    /// Whether the width was given; if not, the field takes the width on offer, as the
210    /// reference's does (milestone 590).
211    sized: bool,
212    on_input: Option<Box<dyn Fn(String) -> Msg>>,
213    on_submit: Option<Msg>,
214    /// Sent for a press outside the field while it is focused; see
215    /// [`TextField::on_tap_outside`].
216    on_tap_outside: Option<Msg>,
217    /// What to tell the screen reader when `on_submit` fires — see [`TextField::announce`].
218    announce: Option<String>,
219    /// Label displayed above the field.
220    label: Option<String>,
221    /// Hint displayed **inside** the field while the value is empty.
222    placeholder: Option<String>,
223    /// Helper text below the field (hidden when an error is present).
224    helper: Option<String>,
225    /// Error message: when present, the border and the label switch to the error
226    /// colour and this text replaces the helper below the field.
227    error: Option<String>,
228    /// Masks the value (a password field): every character is rendered as
229    /// [`OBSCURE_CHAR`]. Editing acts on the real value; only the display changes.
230    obscure: bool,
231    /// What a masked character shows as; see [`TextField::obscuring_character`].
232    obscuring: char,
233    /// The caret's shape; see [`TextField::cursor_width`] and the others.
234    cursor: CursorShape,
235    /// Whether the field takes the focus when it appears; see [`TextField::autofocus`].
236    autofocus: bool,
237    /// Where the text sits inside the field. Single-line only; see
238    /// [`TextField::text_align`].
239    text_align: TextAlign,
240    /// Which keys the software keyboard offers; `None` = worked out from the field
241    /// (a masked field is a secret, a multi-line one takes newlines).
242    keyboard: Option<KeyboardType>,
243    /// What its action key does; `None` = likewise.
244    action: Option<TextInputAction>,
245    /// An icon **outside** the box, before it; see [`TextField::icon`].
246    icon: Option<IconData>,
247    /// Decorative icon on the left inside the box.
248    prefix: Option<IconData>,
249    /// Text just before the value; see [`TextField::prefix_text`].
250    prefix_text: Option<String>,
251    /// Text just after the value; see [`TextField::suffix_text`].
252    suffix_text: Option<String>,
253    /// When the label floats; see [`TextField::floating_label_behavior`].
254    floating: Option<FloatingLabelBehavior>,
255    /// Where it floats to; see [`TextField::floating_label_alignment`].
256    floating_alignment: Option<FloatingLabelAlignment>,
257    /// What the counter says instead of the count; see [`TextField::counter_text`].
258    counter_text: Option<String>,
259    /// Decorative icon on the right inside the box.
260    suffix: Option<IconData>,
261    /// Message emitted on a **click on the suffix icon** (a clear / reveal button…). Makes
262    /// the suffix clickable: a click there emits this message instead of placing the caret.
263    suffix_action: Option<Msg>,
264    /// **Multi-line** field: Enter inserts a line break (instead of submitting), the box
265    /// is `rows` lines tall and scrolls vertically to follow the caret.
266    multiline: bool,
267    /// Number of visible lines in multi-line mode, at least: the box is never shorter.
268    rows: u16,
269    /// The most lines the box grows to as the value wraps, `None` without a limit; equal
270    /// to `rows` for a box that does not grow. See [`TextField::max_lines`].
271    max_rows: Option<u16>,
272    /// Whether `rows` was said, by [`TextField::rows`] or [`TextField::min_lines`]: a box
273    /// given only a most starts at one line, as the reference's does.
274    rows_told: bool,
275    /// Which of the reference's two fields to draw.
276    variant: TextFieldVariant,
277    /// A field that is shown but cannot be edited: greyed out, and inert. It still
278    /// displays its value — the reference's disabled field is readable, not hidden.
279    enabled: bool,
280    /// A **dense** field: the same shape, less room around the content. What a table's
281    /// inline cell editor wants, and what a form does not.
282    dense: bool,
283    /// The most characters the field will hold; see [`TextField::max_length`].
284    max_length: Option<usize>,
285    /// What a typed character becomes before it reaches the value; see
286    /// [`TextField::input_filter`].
287    input_filter: Option<Box<dyn Fn(char) -> Option<char>>>,
288    /// Which letters the software keyboard capitalises; see
289    /// [`TextField::capitalization`].
290    capitalization: Capitalization,
291    /// A field whose value can be read, selected and copied but not changed; see
292    /// [`TextField::read_only`].
293    read_only: bool,
294    /// Per-call overrides; everything unset falls to the theme, then the framework.
295    style: TextFieldStyle,
296    /// What the field is for, so the platform can fill it in; see
297    /// [`TextField::autofill`].
298    autofill: Vec<AutofillHint>,
299    /// The application's say over the bar shown above a selection; see
300    /// [`TextField::selection_toolbar`].
301    toolbar_build: Option<ToolbarBuild<Msg>>,
302    /// The bar for each context, built the first time it is asked for and kept: the walk
303    /// borrows the widget it floats for a whole frame, so it has to live as long as the
304    /// field does.
305    toolbars:
306        [std::cell::OnceCell<Option<Box<dyn Widget<Msg>>>>; ToolbarContext::VARIANTS as usize],
307}
308
309/// What an application gives [`TextField::selection_toolbar`]: the context, and the default
310/// list for it, answered with the list to show.
311pub(crate) type ToolbarBuild<Msg> =
312    Box<dyn Fn(&ToolbarContext, Vec<ToolbarItem<Msg>>) -> Vec<ToolbarItem<Msg>>>;
313
314impl TextField<crate::callback::Callback> {
315    /// Drives the field from `controller`: it shows the controller's text, and what is typed
316    /// in it is written to the controller.
317    ///
318    /// This sets the field's text and its `on_input`, so it replaces both. To do more when
319    /// the text changes, listen to the controller.
320    pub fn controller(mut self, controller: &crate::TextEditingController) -> Self {
321        self.value = controller.text();
322        let write = controller.clone();
323        self.on_input(move |text| write.set_text(text))
324    }
325}
326
327/// A "word" character (letter/digit/`_`) for word jumps (Ctrl+Arrow).
328fn is_word(c: char) -> bool {
329    c.is_alphanumeric() || c == '_'
330}
331
332/// Next word boundary **to the left** of `cursor`: skips the separators, then the word
333/// (editor behaviour — it stops at the **start** of the previous word).
334pub(crate) fn word_boundary_left(chars: &[char], cursor: usize) -> usize {
335    let mut i = cursor;
336    while i > 0 && !is_word(chars[i - 1]) {
337        i -= 1;
338    }
339    while i > 0 && is_word(chars[i - 1]) {
340        i -= 1;
341    }
342    i
343}
344
345/// Next word boundary **to the right**: skips the separators, then the word — it stops
346/// **after** the next word.
347pub(crate) fn word_boundary_right(chars: &[char], cursor: usize) -> usize {
348    let len = chars.len();
349    let mut i = cursor;
350    while i < len && !is_word(chars[i]) {
351        i += 1;
352    }
353    while i < len && is_word(chars[i]) {
354        i += 1;
355    }
356    i
357}
358
359/// Start of the **logical line** (after the previous `\n`, or 0) containing `cursor`.
360pub(crate) fn line_start(chars: &[char], cursor: usize) -> usize {
361    let mut i = cursor;
362    while i > 0 && chars[i - 1] != '\n' {
363        i -= 1;
364    }
365    i
366}
367
368/// End of the **logical line** (before the next `\n`, or the end) containing `cursor`.
369pub(crate) fn line_end(chars: &[char], cursor: usize) -> usize {
370    let len = chars.len();
371    let mut i = cursor;
372    while i < len && chars[i] != '\n' {
373        i += 1;
374    }
375    i
376}
377
378/// The side of a selection handle's box (`text_selection.dart:17`).
379pub(crate) const HANDLE_SIZE: f32 = 22.0;
380
381/// The two selection handles of `edit` over a text of `len` characters laid out as `layout`
382/// and drawn with its top-left corner at `origin`, in the same coordinates as `origin` — see
383/// [`Widget::selection_handles`]. Shared by every widget that shows a selection, so that a
384/// paragraph's handles hang exactly as a field's do.
385pub(crate) fn handles_in(
386    layout: &TextLayout,
387    origin: Point,
388    len: usize,
389    edit: &Edit,
390) -> Option<[crate::SelectionHandle; 2]> {
391    let (start, end) = edit.selection_range()?;
392    let (start, end) = (start.min(len), end.min(len));
393    if start >= end {
394        return None;
395    }
396    Some([
397        handle_at(layout, origin, start, true),
398        handle_at(layout, origin, end, false),
399    ])
400}
401
402/// One selection handle, hanging from character boundary `index` of a text laid out as
403/// `layout` and drawn at `origin`.
404pub(crate) fn handle_at(
405    layout: &TextLayout,
406    origin: Point,
407    index: usize,
408    start: bool,
409) -> crate::SelectionHandle {
410    let caret = layout.caret_rect(index);
411    let x = origin.x + caret.x;
412    let top = origin.y + caret.y;
413    crate::SelectionHandle {
414        // The start handle hangs to the left of its position and the end one to the right
415        // (`text_selection.dart:115`), so that both point in at the text between them.
416        rect: Rect::new(
417            if start { x - HANDLE_SIZE } else { x },
418            top + caret.height,
419            HANDLE_SIZE,
420            HANDLE_SIZE,
421        ),
422        line_center: Point::new(x, top + caret.height * 0.5),
423    }
424}
425
426/// Paints one selection handle, the start one when `start`, `offset` being where the widget's
427/// local origin is on the surface.
428pub(crate) fn paint_handle(
429    scene: &mut Scene,
430    handle: crate::SelectionHandle,
431    offset: Point,
432    color: Color,
433    start: bool,
434) {
435    scene.fill_path(
436        &handle_path(handle.rect.translate(offset.x, offset.y), start),
437        color,
438    );
439}
440
441/// The box a bar over the selection is placed against: the selection's, line by line joined
442/// into one box, or the caret's when nothing is selected — for a text laid out as `layout`
443/// and drawn at `origin`.
444pub(crate) fn selection_box_in(
445    layout: &TextLayout,
446    origin: Point,
447    len: usize,
448    edit: &Edit,
449) -> Rect {
450    let selected = edit
451        .selection_range()
452        .map(|(start, end)| layout.selection_rects(start.min(len), end.min(len)))
453        .unwrap_or_default();
454    let mut boxed = selected.first().copied();
455    for rect in selected.iter().skip(1) {
456        boxed = boxed.map(|so_far| so_far.union(*rect));
457    }
458    let boxed = boxed.unwrap_or_else(|| layout.caret_rect(edit.cursor.min(len)));
459    boxed.translate(origin.x, origin.y)
460}
461
462/// Paints `handles` — a touch selection's two grips — in `color`, `offset` being where the
463/// widget's local origin is on the surface.
464pub(crate) fn paint_handles(
465    scene: &mut Scene,
466    handles: [crate::SelectionHandle; 2],
467    offset: Point,
468    color: Color,
469) {
470    let [start, end] = handles;
471    let place = |r: Rect| r.translate(offset.x, offset.y);
472    scene.fill_path(&handle_path(place(start.rect), true), color);
473    scene.fill_path(&handle_path(place(end.rect), false), color);
474}
475
476/// The word around character `index` of `chars`, as a range — what a double click or a long
477/// press selects. A separator selects itself; an empty text, the empty range.
478pub(crate) fn word_range(chars: &[char], index: usize) -> Option<(usize, usize)> {
479    let len = chars.len();
480    if len == 0 {
481        return Some((0, 0));
482    }
483    let i = index.min(len - 1);
484    if !is_word(chars[i]) {
485        return Some((i, (i + 1).min(len)));
486    }
487    let mut start = i;
488    while start > 0 && is_word(chars[start - 1]) {
489        start -= 1;
490    }
491    let mut end = i + 1;
492    while end < len && is_word(chars[end]) {
493        end += 1;
494    }
495    Some((start, end))
496}
497
498/// The word around character `index` of `text`, as a range of characters — what a double click
499/// in a [`SelectionArea`](crate::SelectionArea) selects.
500pub fn word_bounds(text: &str, index: usize) -> (usize, usize) {
501    let chars: Vec<char> = text.chars().collect();
502    word_range(&chars, index).unwrap_or((0, 0))
503}
504
505/// Moves the caret as the arrow and home/end keys ask — a word at a time under Ctrl, to the
506/// ends of the line or of the text — extending the selection under Shift. Returns whether
507/// `key` was one of those. Shared by an editable field and a selectable paragraph, which move
508/// the caret the same way and differ only in what else a key may do.
509pub(crate) fn move_caret(
510    chars: &[char],
511    cursor: &mut usize,
512    anchor: &mut Option<usize>,
513    key: &Key,
514) -> bool {
515    let len = chars.len();
516    let target = match key {
517        Key::Left { word, .. } => {
518            if *word {
519                word_boundary_left(chars, *cursor)
520            } else {
521                cursor.saturating_sub(1)
522            }
523        }
524        Key::Right { word, .. } => {
525            if *word {
526                word_boundary_right(chars, *cursor)
527            } else {
528                (*cursor + 1).min(len)
529            }
530        }
531        // Ctrl (`doc`): the bounds of the whole text; otherwise those of the logical line.
532        Key::Home { doc, .. } => {
533            if *doc {
534                0
535            } else {
536                line_start(chars, *cursor)
537            }
538        }
539        Key::End { doc, .. } => {
540            if *doc {
541                len
542            } else {
543                line_end(chars, *cursor)
544            }
545        }
546        _ => return false,
547    };
548    let shift = matches!(
549        key,
550        Key::Left { shift: true, .. }
551            | Key::Right { shift: true, .. }
552            | Key::Home { shift: true, .. }
553            | Key::End { shift: true, .. }
554    );
555    move_cursor(cursor, anchor, target, shift);
556    true
557}
558
559/// A selection handle's outline in `rect`: a disc with the corner nearest the text
560/// squared off, so that it points at its end of the selection — up and right for the
561/// start handle, which hangs to the left of its position, up and left for the end one.
562fn handle_path(rect: Rect, start: bool) -> frus_core::Path {
563    use std::f32::consts::{FRAC_PI_2, PI};
564    let r = rect.width * 0.5;
565    let centre = Point::new(rect.x + r, rect.y + r);
566    if start {
567        // From the squared corner, top right, down to the disc's rightmost point, then
568        // round the bottom and the left to its top.
569        frus_core::Path::new()
570            .move_to(Point::new(rect.x + rect.width, rect.y))
571            .line_to(Point::new(rect.x + rect.width, centre.y))
572            .arc_to(centre, r, 0.0, 3.0 * FRAC_PI_2)
573            .close()
574    } else {
575        // From the squared corner, top left, along to the disc's top, then round the
576        // right and the bottom to its leftmost point.
577        frus_core::Path::new()
578            .move_to(Point::new(rect.x, rect.y))
579            .line_to(Point::new(centre.x, rect.y))
580            .arc_to(centre, r, -FRAC_PI_2, PI)
581            .close()
582    }
583}
584
585/// Moves the caret to `target`, handling the selection anchor according to Shift.
586fn move_cursor(cursor: &mut usize, anchor: &mut Option<usize>, target: usize, shift: bool) {
587    if shift {
588        if anchor.is_none() {
589            *anchor = Some(*cursor);
590        }
591    } else {
592        *anchor = None;
593    }
594    *cursor = target;
595}
596
597impl<Msg> TextField<Msg> {
598    /// Creates a field displaying `value`.
599    pub fn new(value: impl Into<String>) -> Self {
600        Self {
601            value: value.into(),
602            size: FIELD_TEXT_SIZE,
603            width: Dimension::Length(220.0),
604            sized: false,
605            on_input: None,
606            on_submit: None,
607            on_tap_outside: None,
608            announce: None,
609            label: None,
610            placeholder: None,
611            helper: None,
612            error: None,
613            obscure: false,
614            obscuring: OBSCURE_CHAR,
615            cursor: CursorShape::default(),
616            autofocus: false,
617            text_align: TextAlign::Start,
618            keyboard: None,
619            action: None,
620            icon: None,
621            prefix: None,
622            prefix_text: None,
623            suffix_text: None,
624            floating: None,
625            floating_alignment: None,
626            counter_text: None,
627            suffix: None,
628            suffix_action: None,
629            multiline: false,
630            rows: 3,
631            max_rows: Some(3),
632            rows_told: false,
633            variant: TextFieldVariant::Outlined,
634            enabled: true,
635            dense: false,
636            max_length: None,
637            input_filter: None,
638            capitalization: Capitalization::Auto,
639            read_only: false,
640            style: TextFieldStyle::default(),
641            autofill: Vec::new(),
642            toolbar_build: None,
643            toolbars: Default::default(),
644        }
645    }
646
647    /// **Outlined**: a box the caller can see all the way round, the floating label
648    /// notching its top border. The default.
649    pub fn outlined(mut self) -> Self {
650        self.variant = TextFieldVariant::Outlined;
651        self
652    }
653
654    /// **Filled**: a tinted container with a single line under it, the label floating
655    /// inside the box rather than on its edge.
656    /// **No container**: no fill, no box, no line — the reference's `InputBorder.none`.
657    /// For a field inside something that is already a container. See
658    /// [`TextFieldVariant::None`].
659    pub fn borderless(mut self) -> Self {
660        self.variant = TextFieldVariant::None;
661        self
662    }
663
664    /// The **filled** variant: a tinted container with a single line under it, the label
665    /// floating inside. See [`TextFieldVariant::Filled`].
666    pub fn filled(mut self) -> Self {
667        self.variant = TextFieldVariant::Filled;
668        self
669    }
670
671    /// Chooses the variant directly, for a caller holding one in a variable.
672    pub fn variant(mut self, variant: TextFieldVariant) -> Self {
673        self.variant = variant;
674        self
675    }
676
677    /// Greys the field out and makes it inert. A disabled field still **shows** its
678    /// value: the reference dims it rather than hiding it, since it is usually the answer
679    /// to why the rest of a form is the way it is.
680    pub fn enabled(mut self, enabled: bool) -> Self {
681        self.enabled = enabled;
682        self
683    }
684
685    /// Overrides part of the styling for this field alone. Anything left `None` falls to
686    /// the theme's [`TextFieldTheme`](crate::widgettheme::TextFieldTheme), then to the
687    /// framework's defaults.
688    pub fn style(mut self, style: TextFieldStyle) -> Self {
689        self.style = style;
690        self
691    }
692
693    /// Tightens the field: the reference's `isDense`, which trades the comfortable
694    /// padding for a control that fits inside a table row. The shape, the label and the
695    /// border are unchanged — only the room around the content.
696    pub fn dense(mut self, dense: bool) -> Self {
697        self.dense = dense;
698        self
699    }
700
701    /// Whether this field is outlined (a box) rather than filled (a container and a line).
702    fn is_outlined(&self) -> bool {
703        self.variant == TextFieldVariant::Outlined
704    }
705
706    /// Whether this field paints **no container** — see [`TextFieldVariant::None`].
707    fn is_borderless(&self) -> bool {
708        self.variant == TextFieldVariant::None
709    }
710
711    /// Padding either side of the content.
712    fn pad_x(&self) -> f32 {
713        self.style.padding_x.unwrap_or(FIELD_PADDING_X)
714    }
715
716    /// Padding above the content: an outlined field gives its floating label room on the
717    /// border, a filled one floats it inside the box and needs none.
718    fn pad_top(&self) -> f32 {
719        match (self.is_outlined(), self.dense) {
720            (true, false) => FIELD_OUTLINED_PADDING_TOP,
721            (true, true) => FIELD_DENSE_OUTLINED_PADDING_TOP,
722            (false, false) => FIELD_PADDING_Y,
723            (false, true) => FIELD_DENSE_PADDING_Y,
724        }
725    }
726
727    /// Padding below the content.
728    fn pad_bottom(&self) -> f32 {
729        match (self.is_outlined(), self.dense) {
730            (true, false) => FIELD_OUTLINED_PADDING_BOTTOM,
731            (true, true) => FIELD_DENSE_OUTLINED_PADDING_BOTTOM,
732            (false, false) => FIELD_PADDING_Y,
733            (false, true) => FIELD_DENSE_PADDING_Y,
734        }
735    }
736
737    /// The field's type, **resolved once**: the reader's font setting applied.
738    ///
739    /// Everything that measures, shapes, hit-tests, places a caret or paints the field's
740    /// text goes through this one number. A caret placed from an unresolved size and
741    /// glyphs drawn from a resolved one land in different places, and the field is then
742    /// wrong for exactly the readers who most needed it to be right.
743    fn text_style(&self) -> ResolvedTextStyle {
744        TextStyle::new(self.size).resolved()
745    }
746
747    /// The helper line under the field — helper text, error, counter. See [`Self::text_style`].
748    fn sub_style(&self) -> ResolvedTextStyle {
749        TextStyle::new(FIELD_SUB_SIZE).resolved()
750    }
751
752    /// Size the label shrinks to once it floats — a proportion of the field's own type,
753    /// not a second number, so a field given larger text keeps the relationship.
754    fn label_size(&self) -> f32 {
755        self.text_style().size * FIELD_LABEL_SCALE
756    }
757
758    /// Side of the prefix/suffix icons.
759    fn icon_size(&self) -> f32 {
760        self.style.icon_size.unwrap_or(FIELD_ICON_SIZE)
761    }
762
763    /// Distance from the top of the box to the first line of text: the padding, plus the
764    /// room a filled field reserves for its floating label.
765    fn text_top(&self) -> f32 {
766        self.pad_top() + self.floating_label_height()
767    }
768
769    /// Room a **filled** field reserves above its content for the floating label. The
770    /// reference computes it as `4 + 0.75 × label size` rather than reserving a band
771    /// outside the box, which is why a filled field is taller than its text.
772    fn floating_label_height(&self) -> f32 {
773        if self.is_outlined() || self.label.is_none() {
774            0.0
775        } else {
776            FIELD_GAP + self.label_size()
777        }
778    }
779
780    /// This field's settings, resolved `caller ?? theme ?? framework`.
781    fn resolved(&self, theme: &Theme) -> TextFieldStyle {
782        let t = theme.widgets.text_field;
783        let pick = |a: Option<frus_core::Color>,
784                    b: Option<frus_core::Color>,
785                    c: frus_core::Color| { Some(a.or(b).unwrap_or(c)) };
786        TextFieldStyle {
787            fill: pick(
788                self.style.fill,
789                t.fill,
790                if self.is_outlined() || self.is_borderless() {
791                    frus_core::Color::TRANSPARENT
792                } else {
793                    // `input_decorator.dart:5968` — a filled field takes the most
794                    // emphasis a container has.
795                    theme.scheme.surface_container_highest
796                },
797            ),
798            border_color: pick(
799                self.style.border_color,
800                t.border_color,
801                if self.is_outlined() {
802                    theme.scheme.outline
803                } else {
804                    theme.scheme.on_surface_variant
805                },
806            ),
807            focused_border_color: pick(
808                self.style.focused_border_color,
809                t.focused_border_color,
810                theme.scheme.primary,
811            ),
812            error_color: pick(self.style.error_color, t.error_color, theme.scheme.error),
813            error_hover_color: pick(
814                self.style.error_hover_color,
815                t.error_hover_color,
816                theme.scheme.on_error_container,
817            ),
818            text_color: pick(self.style.text_color, t.text_color, theme.scheme.on_surface),
819            label_color: pick(
820                self.style.label_color,
821                t.label_color,
822                theme.scheme.on_surface_variant,
823            ),
824            focused_label_color: pick(
825                self.style.focused_label_color,
826                t.focused_label_color,
827                theme.scheme.primary,
828            ),
829            helper_color: pick(
830                self.style.helper_color,
831                t.helper_color,
832                theme.scheme.on_surface_variant,
833            ),
834            icon_color: pick(
835                self.style.icon_color,
836                t.icon_color,
837                theme.scheme.on_surface_variant,
838            ),
839            handle_color: pick(
840                self.style.handle_color,
841                t.handle_color,
842                theme.scheme.primary,
843            ),
844            radius: Some(self.style.radius.or(t.radius).unwrap_or(FIELD_RADIUS)),
845            border_width: Some(
846                self.style
847                    .border_width
848                    .or(t.border_width)
849                    .unwrap_or(FIELD_BORDER_WIDTH),
850            ),
851            focused_border_width: Some(
852                self.style
853                    .focused_border_width
854                    .or(t.focused_border_width)
855                    .unwrap_or(FIELD_FOCUSED_BORDER_WIDTH),
856            ),
857            cursor_color: Some(
858                self.style
859                    .cursor_color
860                    .or(t.cursor_color)
861                    .or(theme.widgets.text_selection.cursor_color)
862                    .unwrap_or(theme.scheme.primary),
863            ),
864            cursor_error_color: Some(
865                self.style
866                    .cursor_error_color
867                    .or(t.cursor_error_color)
868                    .or(self.style.error_color)
869                    .or(t.error_color)
870                    .unwrap_or(theme.scheme.error),
871            ),
872            icon_size: Some(self.icon_size()),
873            padding_x: Some(self.pad_x()),
874        }
875    }
876
877    /// What the field is **for** — a username, a new password, a one-time code, a postal
878    /// code — so that the platform's autofill service can fill it, and save it once the
879    /// form is submitted. See [`AutofillHint`].
880    ///
881    /// A list, most telling first: a sign-in box that takes an email address is both
882    /// `[AutofillHint::Username, AutofillHint::Email]`. The fields of one form belong in
883    /// an [`AutofillGroup`](crate::AutofillGroup), or a password is shown to the service
884    /// with no username to save it under.
885    ///
886    /// ```ignore
887    /// TextField::new(&app.code).autofill([AutofillHint::OneTimeCode])
888    /// ```
889    pub fn autofill(mut self, hints: impl IntoIterator<Item = AutofillHint>) -> Self {
890        self.autofill = hints.into_iter().collect();
891        self
892    }
893
894    /// Switches the field to **multi-line**: Enter inserts a line break (instead of
895    /// submitting), and the box shows `rows` lines (see [`rows`](Self::rows)).
896    pub fn multiline(mut self) -> Self {
897        self.multiline = true;
898        self
899    }
900
901    /// Number of visible lines in multi-line mode (at least 1). Implies
902    /// [`multiline`](Self::multiline).
903    pub fn rows(mut self, rows: u16) -> Self {
904        self.multiline = true;
905        self.rows = rows.max(1);
906        self.max_rows = Some(self.rows);
907        self.rows_told = true;
908        self
909    }
910
911    /// The **fewest lines** the box shows — the reference's `minLines`. Implies
912    /// [`multiline`](Self::multiline); with a [`max_lines`](Self::max_lines), the box grows
913    /// from these to those as the value wraps.
914    pub fn min_lines(mut self, lines: u16) -> Self {
915        self.multiline = true;
916        self.rows = lines.max(1);
917        self.rows_told = true;
918        self.max_rows = self.max_rows.map(|max| max.max(self.rows));
919        self
920    }
921
922    /// The **most lines** the box grows to as the value wraps, then scrolls — the
923    /// reference's `maxLines` (milestone 657). `None` grows without a limit. Given alone, the
924    /// box starts at one line, as the reference's does; `1` is a single-line field.
925    pub fn max_lines(mut self, lines: impl Into<Option<u16>>) -> Self {
926        let lines = lines.into();
927        if lines == Some(1) {
928            self.multiline = false;
929            self.rows = 1;
930            self.max_rows = Some(1);
931            return self;
932        }
933        self.multiline = true;
934        if !self.rows_told {
935            self.rows = 1;
936        }
937        self.max_rows = lines.map(|max| max.max(self.rows));
938        self
939    }
940
941    /// Masks the value (a password field): every character becomes a dot. Editing
942    /// stays normal; only the display is masked.
943    ///
944    /// It also changes what the **software keyboard** is told, unless
945    /// [`keyboard_type`](Self::keyboard_type) says otherwise: a masked field is a
946    /// [`Password`](KeyboardType::Password), which is what stops the keyboard learning
947    /// what is typed into it. The dots are drawn by us and tell the keyboard nothing.
948    pub fn obscure(mut self, obscure: bool) -> Self {
949        self.obscure = obscure;
950        self
951    }
952
953    /// What each character of a masked value shows as — the reference's
954    /// `obscuringCharacter`. A dot unless told.
955    pub fn obscuring_character(mut self, character: char) -> Self {
956        self.obscuring = character;
957        self
958    }
959
960    /// The caret's colour (milestone 659). Unset, the theme's, then the scheme's `primary`.
961    pub fn cursor_color(mut self, color: Color) -> Self {
962        self.style.cursor_color = Some(color);
963        self
964    }
965
966    /// The caret's colour while an error is showing. Unset, the error colour.
967    pub fn cursor_error_color(mut self, color: Color) -> Self {
968        self.style.cursor_error_color = Some(color);
969        self
970    }
971
972    /// The caret's width, in logical pixels; the reference's 2 unless told.
973    pub fn cursor_width(mut self, width: f32) -> Self {
974        self.cursor.width = width.max(0.0);
975        self
976    }
977
978    /// The caret's height, centred on the line; the line's own unless told.
979    pub fn cursor_height(mut self, height: f32) -> Self {
980        self.cursor.height = Some(height.max(0.0));
981        self
982    }
983
984    /// The radius of the caret's corners. Unset, 2 on Apple's platforms and square
985    /// elsewhere, as the reference's are.
986    pub fn cursor_radius(mut self, radius: f32) -> Self {
987        self.cursor.radius = Some(radius.max(0.0));
988        self
989    }
990
991    /// Whether the caret shows while the field is focused — the reference's `showCursor`.
992    /// A field that hides it still takes keys and selections.
993    pub fn show_cursor(mut self, show: bool) -> Self {
994        self.cursor.shown = show;
995        self
996    }
997
998    /// Takes the keyboard's focus when the field appears — the reference's `autofocus`: the
999    /// field a screen exists to type into. Where the focus came from is given back when it
1000    /// goes (milestone 605).
1001    pub fn autofocus(mut self, autofocus: bool) -> Self {
1002        self.autofocus = autofocus;
1003        self
1004    }
1005
1006    /// Where the text sits inside the field: an amount to the right, a code centred.
1007    ///
1008    /// [`Start`](TextAlign::Start) — the default — follows the reading direction, so a
1009    /// field is left-aligned in English and right-aligned in Arabic.
1010    /// [`Left`](TextAlign::Left) and [`Right`](TextAlign::Right) do not: a column of
1011    /// figures wants the same edge whatever the prose around it is doing.
1012    ///
1013    /// **Single-line fields only.** A multi-line one stays at the start, and that is a
1014    /// boundary rather than an oversight: aligning wrapped text means moving each line
1015    /// by its own width, and the caret and the click would then have to be told about
1016    /// an offset that differs line by line. That belongs inside the text layout, where
1017    /// the caret and the hit test already live, and not in a widget nudging a block
1018    /// sideways behind their backs.
1019    pub fn text_align(mut self, align: TextAlign) -> Self {
1020        self.text_align = align;
1021        self
1022    }
1023
1024    /// How far the alignment pushes the text inside its content box.
1025    ///
1026    /// Zero unless the text is **narrower** than the box: there is nothing to
1027    /// distribute otherwise, and a right-aligned line longer than its field would be
1028    /// shoved off the left edge — the one edge whose text must stay put, since that
1029    /// is where reading starts and where the horizontal scroll brings the caret back to.
1030    ///
1031    /// One function, used by the paint, the caret and the hit test alike. Three copies
1032    /// of it would agree on every field until one of them did not, and the failure
1033    /// would be a click landing several characters from where it was aimed.
1034    ///
1035    /// It takes **no direction**, and that is deliberate rather than forgotten.
1036    /// [`Widget::cursor_at`] is handed a rectangle and nothing else — no theme, so no
1037    /// reading direction — so a push that depended on one could be applied by the paint
1038    /// and not by the click. Every field in a right-to-left application would then take
1039    /// its caret several characters from the tap, including every field that never asked
1040    /// to be aligned at all. A push both sides can compute is worth more than one that is
1041    /// right in a place the other cannot reach.
1042    ///
1043    /// So `Start` is the left edge here and `End` the right. Following the reading
1044    /// direction is the **text layout's** job, one layer down, where the caret and the
1045    /// hit test already live and cannot disagree with the glyphs.
1046    fn align_offset(&self, content_w: f32, text_w: f32) -> f32 {
1047        if self.multiline {
1048            return 0.0;
1049        }
1050        let slack = (content_w - text_w).max(0.0);
1051        match self.text_align {
1052            // Justification stretches the spaces between words, which a single line of
1053            // input has no business doing: it would move the glyphs under the caret
1054            // every time a space was typed.
1055            TextAlign::Start | TextAlign::Left | TextAlign::Justify => 0.0,
1056            TextAlign::End | TextAlign::Right => slack,
1057            TextAlign::Center => slack / 2.0,
1058        }
1059    }
1060
1061    /// Which keys the software keyboard should offer — a phone keypad, an email
1062    /// address, a number.
1063    ///
1064    /// Untold, it is worked out from the field: a masked field is a secret, a
1065    /// multi-line one takes newlines, and anything else is ordinary text.
1066    pub fn keyboard_type(mut self, keyboard: KeyboardType) -> Self {
1067        self.keyboard = Some(keyboard);
1068        self
1069    }
1070
1071    /// What the keyboard's action key does, and what it says — *Next* in a form,
1072    /// *Search* over a query, *Send* on a message.
1073    ///
1074    /// Untold, a multi-line field takes a newline and every other field says *Done*.
1075    pub fn action(mut self, action: TextInputAction) -> Self {
1076        self.action = Some(action);
1077        self
1078    }
1079
1080    /// The keyboard this field asks for, once the defaults have had their say.
1081    fn ime_options(&self) -> Ime {
1082        let keyboard = self.keyboard.unwrap_or({
1083            // The order matters and only one way round makes sense: a masked field is
1084            // a secret first and a text field second, and a multi-line secret is not a
1085            // thing anybody types.
1086            if self.obscure {
1087                KeyboardType::Password
1088            } else if self.multiline {
1089                KeyboardType::Multiline
1090            } else {
1091                KeyboardType::Text
1092            }
1093        });
1094        let action = self.action.unwrap_or(match keyboard {
1095            // A field that takes several lines needs the key to *insert* one. Saying
1096            // *Done* there is a keyboard that cannot type what the field is for.
1097            KeyboardType::Multiline => TextInputAction::Newline,
1098            _ => TextInputAction::Done,
1099        });
1100        Ime {
1101            keyboard,
1102            action,
1103            capitalization: self.capitalization,
1104        }
1105    }
1106
1107    /// Decorative icon on the left inside the field.
1108    pub fn prefix_icon(mut self, icon: IconData) -> Self {
1109        self.prefix = Some(icon);
1110        self
1111    }
1112
1113    /// Decorative icon on the right inside the field.
1114    pub fn suffix_icon(mut self, icon: IconData) -> Self {
1115        self.suffix = Some(icon);
1116        self
1117    }
1118
1119    /// An icon **outside** the field, before its box — the reference's `icon`: the box, its
1120    /// label and the line under it all start after it. It is 24 pixels (18 dense) with 16
1121    /// before the box, in the icon colour.
1122    pub fn icon(mut self, icon: IconData) -> Self {
1123        self.icon = Some(icon);
1124        self
1125    }
1126
1127    /// Text **just before the value**, in the hint's style — a currency sign, a country
1128    /// code, `https://` — the reference's `prefixText`. Its room is kept whether it shows or
1129    /// not, and it shows while the label is floated: once the field is focused or holds
1130    /// something, at once if the label always floats.
1131    pub fn prefix_text(mut self, text: impl Into<String>) -> Self {
1132        self.prefix_text = Some(text.into());
1133        self
1134    }
1135
1136    /// Text **just after the value**, in the hint's style — a unit, a domain — the
1137    /// reference's `suffixText`. Shown as [`prefix_text`](Self::prefix_text) is.
1138    pub fn suffix_text(mut self, text: impl Into<String>) -> Self {
1139        self.suffix_text = Some(text.into());
1140        self
1141    }
1142
1143    /// When the label floats above the value. Unset, the theme's, then
1144    /// [`FloatingLabelBehavior::Auto`].
1145    pub fn floating_label_behavior(mut self, behavior: FloatingLabelBehavior) -> Self {
1146        self.floating = Some(behavior);
1147        self
1148    }
1149
1150    /// Where the floated label sits along the field's top. Unset, the theme's, then
1151    /// [`FloatingLabelAlignment::Start`].
1152    pub fn floating_label_alignment(mut self, alignment: FloatingLabelAlignment) -> Self {
1153        self.floating_alignment = Some(alignment);
1154        self
1155    }
1156
1157    /// What the counter under the field says, in place of the count a
1158    /// [`max_length`](Self::max_length) puts there — the reference's `counterText`. An empty
1159    /// text hides the counter, limit or not.
1160    pub fn counter_text(mut self, text: impl Into<String>) -> Self {
1161        self.counter_text = Some(text.into());
1162        self
1163    }
1164
1165    /// Makes the **suffix icon clickable**: a click on it emits `message` (a clear button,
1166    /// a reveal-password button…) instead of placing the caret. Implies a suffix icon
1167    /// (to be set through [`suffix_icon`](Self::suffix_icon)).
1168    pub fn on_suffix(mut self, message: impl Into<Msg>) -> Self {
1169        self.suffix_action = Some(message.into());
1170        self
1171    }
1172
1173    /// Does the local point `(x, y)` land on the **suffix's clickable zone** (on the right of
1174    /// the input box)? Used both to route the click and to keep the caret out of it.
1175    fn suffix_hit(&self, local_x: f32, local_y: f32, width: f32) -> bool {
1176        if self.suffix.is_none() {
1177            return false;
1178        }
1179        let zone_left = width - (self.icon_size() + ICON_PAD * 2.0);
1180        let top = self.label_block();
1181        let bottom = top + self.field_height_at(width);
1182        local_x >= zone_left && local_y >= top && local_y <= bottom
1183    }
1184
1185    /// A message for a **press outside the field while it is focused** — the reference's
1186    /// `onTapOutside` (milestone 660), sent before the press moves the focus: a form that
1187    /// saves a draft, or closes a suggestion list, when the user taps away.
1188    pub fn on_tap_outside(mut self, message: impl Into<Msg>) -> Self {
1189        self.on_tap_outside = Some(message.into());
1190        self
1191    }
1192
1193    /// Label displayed above the field.
1194    pub fn label(mut self, label: impl Into<String>) -> Self {
1195        self.label = Some(label.into());
1196        self
1197    }
1198
1199    /// Hint displayed inside the field while it is empty — and, for a field with no
1200    /// [`label`](Self::label), the name a screen reader gives it.
1201    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
1202        self.placeholder = Some(placeholder.into());
1203        self
1204    }
1205
1206    /// Helper text below the field (replaced by the error, if present).
1207    pub fn helper(mut self, helper: impl Into<String>) -> Self {
1208        self.helper = Some(helper.into());
1209        self
1210    }
1211
1212    /// Marks the field **in error**: red border and label, `message` displayed below
1213    /// the field. Chain it only when the application's validation fails.
1214    pub fn error(mut self, message: impl Into<String>) -> Self {
1215        self.error = Some(message.into());
1216        self
1217    }
1218
1219    /// Sets the width, in logical pixels.
1220    pub fn width(mut self, width: f32) -> Self {
1221        self.width = Dimension::Length(width);
1222        self.sized = true;
1223        self
1224    }
1225
1226    /// Sets the font size, in pixels.
1227    pub fn size(mut self, size: f32) -> Self {
1228        self.size = size;
1229        self
1230    }
1231
1232    /// The most characters the field will hold.
1233    ///
1234    /// It is **enforced**, as the reference's default is: a keystroke past the limit does
1235    /// not reach the value, and neither does the tail of a paste that would cross it —
1236    /// the part that fits still lands, because dropping a whole paste for being one
1237    /// character too long loses work the user can see they had.
1238    ///
1239    /// It also puts a counter — `5/10` — at the end of the line below the box, where the
1240    /// reference puts one, and reserves that line even when there is no helper text to
1241    /// share it with.
1242    ///
1243    /// The limit is in **characters**, not bytes: "é" is one, and a name in an alphabet
1244    /// that is not Latin is counted the way its writer would count it.
1245    pub fn max_length(mut self, max: usize) -> Self {
1246        self.max_length = Some(max);
1247        self
1248    }
1249
1250    /// What each typed character becomes before it reaches the value: `None` drops it,
1251    /// `Some(c)` puts `c` in instead of what was typed.
1252    ///
1253    /// The reference's `inputFormatters` reshape the **whole value** after every
1254    /// keystroke, which buys grouping (spaces every four digits of a card number) and
1255    /// costs a caret: the formatter has to say where it went, and getting that wrong
1256    /// puts the cursor in the middle of a group the reader did not touch. A filter that
1257    /// works one character at a time cannot group, and cannot lose the caret either
1258    /// — a dropped character simply never arrives, and a substituted one takes the same
1259    /// place. That covers digits-only, letters-only, no-spaces and forced case, which is
1260    /// nearly every field that filters at all.
1261    ///
1262    /// It applies to what is **typed or pasted**, and a paste keeps whatever it can
1263    /// rather than being refused whole — the same rule
1264    /// [`max_length`](TextField::max_length) follows, for the same reason.
1265    ///
1266    /// A value the **caller** supplied is left alone: it is the application's state, not
1267    /// something typed, and rewriting it would be editing a value nobody edited.
1268    ///
1269    /// ```ignore
1270    /// TextField::new(&app.code).input_filter(|c| c.to_uppercase().next())
1271    /// TextField::new(&app.tag).input_filter(|c| (!c.is_whitespace()).then_some(c))
1272    /// ```
1273    pub fn input_filter(mut self, filter: impl Fn(char) -> Option<char> + 'static) -> Self {
1274        self.input_filter = Some(Box::new(filter));
1275        self
1276    }
1277
1278    /// Digits, and nothing else.
1279    ///
1280    /// It also asks for a **number keypad**, unless one was named already: a field that
1281    /// refuses everything but digits and then opens a QWERTY keyboard is a field whose
1282    /// keys mostly do nothing. Naming a keyboard before or after this leaves that
1283    /// choice standing, so the two builders can be written in either order.
1284    pub fn digits_only(mut self) -> Self {
1285        self.input_filter = Some(Box::new(|c| c.is_ascii_digit().then_some(c)));
1286        self.keyboard = self.keyboard.or(Some(KeyboardType::Number));
1287        self
1288    }
1289
1290    /// Which letters the software keyboard capitalises by itself.
1291    ///
1292    /// A **hint**: it is what the keyboard does to what it sends, and a reader who turns
1293    /// it off still types what they meant to. A field that must hold capitals wants
1294    /// [`input_filter`](TextField::input_filter) as well, which works whatever the
1295    /// keyboard does and works on a desktop, where there is no keyboard to hint to.
1296    pub fn capitalization(mut self, capitalization: Capitalization) -> Self {
1297        self.capitalization = capitalization;
1298        self
1299    }
1300
1301    /// A field whose value can be read, selected and copied — but not changed.
1302    ///
1303    /// It is **not** [`enabled(false)`](TextField::enabled), and the difference matters.
1304    /// A disabled field is greyed out and inert: out of the tab order, no caret, nothing
1305    /// to select. A read-only one looks and behaves like any other field except that
1306    /// typing does nothing — you can focus it, move the caret through it, select a
1307    /// reference number and copy it. That is what an identifier the application generated
1308    /// wants, and greying it out would suggest it is unavailable rather than fixed.
1309    pub fn read_only(mut self) -> Self {
1310        self.read_only = true;
1311        self
1312    }
1313
1314    /// **What the bar over a selection offers** — Cut, Copy, Paste, Select all — decided by
1315    /// the application (milestone 568, [#23]).
1316    ///
1317    /// `build` is handed the [`ToolbarContext`] (is something selected, is there text to
1318    /// paste, is everything selected already) and the field's **default list** for it, and
1319    /// returns the list to show. Drop an item, reorder them, add one of the application's own
1320    /// ([`ToolbarItem::custom`]) — or return an empty list, and no bar shows at all.
1321    ///
1322    /// ```
1323    /// use frus_widgets::{TextField, ToolbarItem};
1324    ///
1325    /// #[derive(Clone)]
1326    /// enum Msg { Translate }
1327    ///
1328    /// // A field that never lets its text be pasted over, and offers a way to translate it.
1329    /// let field: TextField<Msg> = TextField::new("Bonjour").selection_toolbar(|context, mut items| {
1330    ///     items.retain(|item| item.label() != "Paste");
1331    ///     if context.has_selection {
1332    ///         items.push(ToolbarItem::custom("Translate", Msg::Translate));
1333    ///     }
1334    ///     items
1335    /// });
1336    /// # let _ = field;
1337    /// ```
1338    ///
1339    /// The defaults already keep a secret: a masked field ([`obscure`](Self::obscure)) is
1340    /// given neither Cut nor Copy, and a [`read_only`](Self::read_only) one neither Cut nor
1341    /// Paste, so an application only has to change the list to depart from them.
1342    ///
1343    /// [#23]: https://github.com/KalybosPro/frus/issues/23
1344    pub fn selection_toolbar(
1345        mut self,
1346        build: impl Fn(&ToolbarContext, Vec<ToolbarItem<Msg>>) -> Vec<ToolbarItem<Msg>> + 'static,
1347    ) -> Self {
1348        self.toolbar_build = Some(Box::new(build));
1349        // Whatever was built for the old answer is not the answer any more.
1350        self.toolbars = Default::default();
1351        self
1352    }
1353
1354    /// Closure producing a message from the field's new value.
1355    pub fn on_input<R: crate::callback::IntoMsg<Msg>>(
1356        mut self,
1357        on_input: impl Fn(String) -> R + 'static,
1358    ) -> Self {
1359        self.on_input = Some(Box::new(crate::callback::handler1(on_input)));
1360        self
1361    }
1362
1363    /// Message emitted on submission (the Enter key), without changing the value.
1364    pub fn on_submit(mut self, message: impl Into<Msg>) -> Self {
1365        self.on_submit = Some(message.into());
1366        self
1367    }
1368
1369    /// Text to speak through the screen reader's live region when [`Self::on_submit`]
1370    /// fires — the field's own counterpart to a button's [`Widget::announce`](
1371    /// crate::Widget::announce), which only ever fires on a click or a plain Enter and
1372    /// so never sees a field's submit (#18). Compute it from the value the caller
1373    /// already has — `TextField::new(draft).announce(format!("{draft} added"))` — since
1374    /// by the time this fires, the widget it is read from is the one built **before**
1375    /// the keystroke, still holding what is about to be cleared.
1376    pub fn announce(mut self, message: impl Into<String>) -> Self {
1377        self.announce = Some(message.into());
1378        self
1379    }
1380
1381    /// The **displayed** string: masked (one dot per character) for a password field,
1382    /// the value as is otherwise. Same character count as the value → the caret and
1383    /// the hit-test stay aligned index for index.
1384    fn display(&self) -> String {
1385        if self.obscure {
1386            self.obscuring
1387                .to_string()
1388                .repeat(self.value.chars().count())
1389        } else {
1390            self.value.clone()
1391        }
1392    }
1393
1394    /// The **displayed** string shaped once: caret by index, hit-test, selection — one
1395    /// consistent geometry (kerning). `wrap_width` = the soft-wrap width (multi-line) or
1396    /// `None` (single-line: only explicit `\n` break).
1397    fn layout(&self, wrap_width: Option<f32>) -> TextLayout {
1398        let style = self.text_style();
1399        TextLayout::wrapped(
1400            &self.display(),
1401            style.size,
1402            style.weight,
1403            style.italic,
1404            wrap_width,
1405        )
1406    }
1407
1408    /// Width reserved for the prefix icon (0 if there is none).
1409    fn prefix_icon_w(&self) -> f32 {
1410        if self.prefix.is_some() {
1411            self.icon_size() + ICON_PAD
1412        } else {
1413            0.0
1414        }
1415    }
1416
1417    /// Width reserved for the suffix icon (0 if there is none).
1418    fn suffix_icon_w(&self) -> f32 {
1419        if self.suffix.is_some() {
1420            self.icon_size() + ICON_PAD
1421        } else {
1422            0.0
1423        }
1424    }
1425
1426    /// The width of an affix text, in the value's style.
1427    fn affix_w(&self, text: &Option<String>) -> f32 {
1428        text.as_ref().map_or(0.0, |text| {
1429            frus_text::measure_resolved(text, &self.text_style()).width
1430        })
1431    }
1432
1433    /// Width reserved before the value inside the box: the prefix icon and the prefix text.
1434    fn prefix_w(&self) -> f32 {
1435        self.prefix_icon_w() + self.affix_w(&self.prefix_text)
1436    }
1437
1438    /// Width reserved after the value inside the box: the suffix text and the suffix icon.
1439    fn suffix_w(&self) -> f32 {
1440        self.suffix_icon_w() + self.affix_w(&self.suffix_text)
1441    }
1442
1443    /// The side of the icon outside the box: the reference's 24, 18 dense.
1444    fn outer_icon_size(&self) -> f32 {
1445        if self.dense {
1446            18.0
1447        } else {
1448            24.0
1449        }
1450    }
1451
1452    /// The room before the box, for the icon outside it: its side and 16 more.
1453    fn gutter(&self) -> f32 {
1454        if self.icon.is_some() {
1455            self.outer_icon_size() + 16.0
1456        } else {
1457            0.0
1458        }
1459    }
1460
1461    /// Where the value starts, from the widget's left edge: the room for the outer icon,
1462    /// the padding, the prefix icon and the prefix text.
1463    fn lead(&self) -> f32 {
1464        self.gutter() + self.pad_x() + self.prefix_w()
1465    }
1466
1467    /// When the label floats, the field's own word over the theme's.
1468    fn floating_behavior(&self, theme: &Theme) -> FloatingLabelBehavior {
1469        self.floating
1470            .or(theme.widgets.text_field.floating_label_behavior)
1471            .unwrap_or_default()
1472    }
1473
1474    /// How far the label has floated, `0..=1`, for a focus at `fp`.
1475    fn float_progress(&self, fp: f32, theme: &Theme) -> f32 {
1476        match self.floating_behavior(theme) {
1477            FloatingLabelBehavior::Always => 1.0,
1478            FloatingLabelBehavior::Never => 0.0,
1479            FloatingLabelBehavior::Auto if self.value.is_empty() => fp,
1480            FloatingLabelBehavior::Auto => 1.0,
1481        }
1482    }
1483
1484    /// Text width (between the padding and the icons) for a given widget width.
1485    /// Where the text is drawn inside the field, for `edit`: its layout and the top-left
1486    /// corner of the text, in the field's **local** coordinates. The paint's own geometry —
1487    /// the content's insets, the alignment, the horizontal scroll that follows the caret and
1488    /// the retained vertical one — so that whatever is placed against the text (a handle, the
1489    /// bar over a selection) is where the text is drawn.
1490    fn text_frame(&self, width: f32, edit: &Edit, scroll_y: f32) -> (TextLayout, f32, f32) {
1491        let len = self.value.chars().count();
1492        let content_w = self.content_width(width);
1493        let layout = self.layout(self.multiline.then_some(content_w));
1494        let scroll = (layout.caret_rect(edit.cursor.min(len)).x - content_w).max(0.0);
1495        let align = self.align_offset(content_w, layout.size().width);
1496        let vscroll = if self.multiline {
1497            let visible = self.field_height_at(width) - self.text_top() - self.pad_bottom();
1498            scroll_y.clamp(0.0, (layout.size().height - visible).max(0.0))
1499        } else {
1500            0.0
1501        };
1502        let origin_x = self.lead() + align - scroll;
1503        let origin_y = self.label_block() + self.text_top() - vscroll;
1504        (layout, origin_x, origin_y)
1505    }
1506
1507    /// The two selection handles for `edit`, in local coordinates — see
1508    /// [`Widget::selection_handles`].
1509    fn handles(
1510        &self,
1511        width: f32,
1512        edit: &Edit,
1513        scroll_y: f32,
1514    ) -> Option<[crate::SelectionHandle; 2]> {
1515        let len = self.value.chars().count();
1516        edit.selection_range()?;
1517        let (layout, origin_x, origin_y) = self.text_frame(width, edit, scroll_y);
1518        handles_in(&layout, Point::new(origin_x, origin_y), len, edit)
1519    }
1520
1521    /// The box the bar over the selection is placed against: the selection's, line by line
1522    /// joined into one box, or the caret's when nothing is selected.
1523    fn selection_box(&self, width: f32, edit: &Edit, scroll_y: f32) -> Rect {
1524        let len = self.value.chars().count();
1525        let (layout, origin_x, origin_y) = self.text_frame(width, edit, scroll_y);
1526        selection_box_in(&layout, Point::new(origin_x, origin_y), len, edit)
1527    }
1528
1529    /// The bar's **default** list for `context`: what the reference offers, decided by the
1530    /// three conditions and by what this field allows. A masked field gives its text to
1531    /// nobody, so it offers neither Cut nor Copy; a read-only one keeps its text as it is,
1532    /// so it offers no Cut and no Paste.
1533    fn default_toolbar_items(&self, context: &ToolbarContext) -> Vec<ToolbarItem<Msg>> {
1534        let editable = self.enabled && !self.read_only;
1535        let mut items = Vec::new();
1536        if context.has_selection && editable && !self.obscure {
1537            items.push(ToolbarItem::cut());
1538        }
1539        if context.has_selection && !self.obscure {
1540            items.push(ToolbarItem::copy());
1541        }
1542        if context.can_paste && editable {
1543            items.push(ToolbarItem::paste());
1544        }
1545        if !context.all_selected {
1546            items.push(ToolbarItem::select_all());
1547        }
1548        items
1549    }
1550
1551    /// The value's width, between the paddings and the affixes, in a widget `width` wide.
1552    fn content_width(&self, width: f32) -> f32 {
1553        (width - self.lead() - self.pad_x() - self.suffix_w()).max(0.0)
1554    }
1555
1556    /// Height reserved for the label above the box (0 if there is no label). In
1557    /// `outlined` mode the floating label straddles the top border: only its **upper
1558    /// half** must be reserved (the rest bites into the box), instead of a full band.
1559    fn label_block(&self) -> f32 {
1560        if self.label.is_some() {
1561            if self.is_outlined() {
1562                (frus_text::line_height(self.label_size()) * 0.5).ceil()
1563            } else {
1564                frus_text::line_height(self.label_size()) + FIELD_GAP
1565            }
1566        } else {
1567            0.0
1568        }
1569    }
1570
1571    /// The counter shown at the end of the line below the box, when there is a limit.
1572    fn counter(&self) -> Option<String> {
1573        match &self.counter_text {
1574            Some(text) if text.is_empty() => None,
1575            Some(text) => Some(text.clone()),
1576            None => self
1577                .max_length
1578                .map(|max| format!("{}/{}", self.value.chars().count(), max)),
1579        }
1580    }
1581
1582    /// Height reserved for the helper/error line below the box (0 if there is none).
1583    fn sub_block(&self) -> f32 {
1584        if self.error.is_some() || self.helper.is_some() || self.counter().is_some() {
1585            // The helper style's **own** line, not one recomputed from its size: another
1586            // of milestone 412's survivors, written against a bare constant rather than a
1587            // style, which is the one formulation that sweep could not find.
1588            self.sub_style().line_height() + FIELD_GAP
1589        } else {
1590            0.0
1591        }
1592    }
1593
1594    /// Height of the input box itself (decoration excluded) before the value is known to
1595    /// wrap: one line in single-line mode, `rows` lines in multi-line mode.
1596    fn field_height(&self) -> f32 {
1597        let lines = if self.multiline {
1598            self.rows.max(1) as f32
1599        } else {
1600            1.0
1601        };
1602        self.box_height(lines)
1603    }
1604
1605    /// The box's height for `lines` lines of text.
1606    fn box_height(&self, lines: f32) -> f32 {
1607        (self.text_style().line_height() * lines + self.text_top() + self.pad_bottom()).ceil()
1608    }
1609
1610    /// Whether the box grows with the value (milestone 657): multi-line, with more lines
1611    /// allowed than it starts with.
1612    fn grows(&self) -> bool {
1613        self.multiline && self.max_rows != Some(self.rows)
1614    }
1615
1616    /// How many lines the box shows in a widget `width` wide: the value's own, wrapped at
1617    /// the content's width, held between the fewest and the most.
1618    fn lines_at(&self, width: f32) -> f32 {
1619        if !self.grows() {
1620            return if self.multiline {
1621                self.rows.max(1) as f32
1622            } else {
1623                1.0
1624            };
1625        }
1626        let layout = self.layout(Some(self.content_width(width)));
1627        let line_h = self.text_style().line_height();
1628        let wrapped = if line_h > 0.0 {
1629            (layout.size().height / line_h).round().max(1.0)
1630        } else {
1631            1.0
1632        };
1633        wrapped.clamp(
1634            self.rows.max(1) as f32,
1635            self.max_rows.map_or(f32::INFINITY, |max| max as f32),
1636        )
1637    }
1638
1639    /// The box's height in a widget `width` wide; see [`Self::lines_at`].
1640    fn field_height_at(&self, width: f32) -> f32 {
1641        self.box_height(self.lines_at(width))
1642    }
1643}
1644
1645impl<Msg: Clone + 'static> Widget<Msg> for TextField<Msg> {
1646    /// **All the room on offer** on an axis nobody sized, as the reference's takes
1647    /// (milestone 590) — unless it was told to grow, which says the same thing already.
1648    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
1649        if false {
1650            return crate::widget::FillAxes::NONE;
1651        }
1652        crate::widget::FillAxes {
1653            horizontal: !self.sized,
1654            vertical: false,
1655        }
1656    }
1657
1658    /// The default size is only a default: the room replaces it wherever the layout can
1659    /// give the room, and it stands where the reference would have nothing to give.
1660    fn soft_extent(&self) -> crate::widget::FillAxes {
1661        crate::widget::FillAxes {
1662            horizontal: !self.sized,
1663            vertical: false,
1664        }
1665    }
1666
1667    fn style(&self) -> Style {
1668        // A box that grows is as tall as its value makes it, which only the width says: it
1669        // is measured instead (milestone 657).
1670        let height = if self.grows() {
1671            Dimension::Auto
1672        } else {
1673            Dimension::Length(self.label_block() + self.field_height() + self.sub_block())
1674        };
1675        Style {
1676            width: self.width,
1677            height,
1678            ..Default::default()
1679        }
1680    }
1681
1682    fn measure(&self, _theme: &Theme) -> Option<frus_layout::MeasureFn<'_>> {
1683        if !self.grows() {
1684            return None;
1685        }
1686        Some(Box::new(move |width, _| {
1687            let width = width.unwrap_or(match self.width {
1688                Dimension::Length(w) => w,
1689                _ => 220.0,
1690            });
1691            // Whole pixels, rounded up: the label and helper bands are fractions of a
1692            // line, and a box rounded down is one the field no longer fits in.
1693            frus_core::Size::new(
1694                width,
1695                frus_core::fits(
1696                    self.label_block() + self.field_height_at(width) + self.sub_block(),
1697                ),
1698            )
1699        }))
1700    }
1701
1702    fn measure_key(&self, _theme: &Theme) -> Option<u64> {
1703        if !self.grows() {
1704            return None;
1705        }
1706        use std::hash::{Hash, Hasher};
1707        let mut hasher = std::collections::hash_map::DefaultHasher::new();
1708        self.display().hash(&mut hasher);
1709        self.rows.hash(&mut hasher);
1710        self.max_rows.hash(&mut hasher);
1711        self.size.to_bits().hash(&mut hasher);
1712        // Everything that moves the wrap or the bands around the box, as numbers: a key
1713        // listing the builders would miss the next one to change them.
1714        for part in [
1715            self.lead(),
1716            self.pad_x(),
1717            self.suffix_w(),
1718            self.text_top(),
1719            self.pad_bottom(),
1720            self.label_block(),
1721            self.sub_block(),
1722        ] {
1723            part.to_bits().hash(&mut hasher);
1724        }
1725        Some(hasher.finish())
1726    }
1727
1728    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1729        &[]
1730    }
1731
1732    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
1733        let s = self.resolved(theme);
1734        // A disabled field keeps 38% of everything, following the reference, and never
1735        // shows a focus: it cannot be focused, so a focus ring left over from before it
1736        // was disabled would be a lie about what Tab will do.
1737        let o = if self.enabled {
1738            status.opacity
1739        } else {
1740            status.opacity * FIELD_DISABLED_OPACITY
1741        };
1742        let has_error = self.error.is_some() && self.enabled;
1743        let fp = if self.enabled {
1744            status.focus_progress.clamp(0.0, 1.0)
1745        } else {
1746            0.0
1747        };
1748        // **An errored field deepens under the pointer** (`input_decorator.dart:5981`,
1749        // `:6004`, `:6053`): `error` at rest, `on_error_container` while hovered — and
1750        // back to `error` once focused, because the reference tests focus **before**
1751        // hover and a focused field is already saying everything it can.
1752        //
1753        // Continuous where the reference is discrete, which is this framework's habit
1754        // with the pointer: `hover_progress` is a progression, not a flag.
1755        let error_ink = s.error_color.unwrap().lerp(s.error_hover_color.unwrap(), {
1756            let hover = if self.enabled {
1757                status.hover_progress.clamp(0.0, 1.0)
1758            } else {
1759                0.0
1760            };
1761            hover * (1.0 - fp)
1762        });
1763
1764        // Decoration: label above, input box in the middle, helper/error below. The
1765        // box is the sub-rectangle where all the editing lives.
1766        let label_block = self.label_block();
1767        let gutter = self.gutter();
1768        let field = Rect::new(
1769            bounds.x + gutter,
1770            bounds.y + label_block,
1771            (bounds.width - gutter).max(0.0),
1772            self.field_height_at(bounds.width),
1773        );
1774        // The icon outside the box, centred on it.
1775        if let Some(icon) = self.icon {
1776            let side = self.outer_icon_size();
1777            let path = icon.placed(
1778                side,
1779                bounds.x,
1780                field.y + (field.height - side) * 0.5,
1781                theme.direction,
1782            );
1783            scene.fill_path(&path, s.icon_color.unwrap().fade(o));
1784        }
1785
1786        // **Floating** label: at rest (an empty, unfocused field) it occupies the box,
1787        // in the hint's place; focused OR filled, it floats above, shrunk. The
1788        // position/size progress follows that "floating" (`t`); the **colour** follows
1789        // the focus (`fp`) — a filled but unfocused field keeps a discreet label, not
1790        // yet accented.
1791        let float_t = self.float_progress(fp, theme);
1792        // A label that never floats is a hint: gone once the field holds something.
1793        let label_shown =
1794            self.floating_behavior(theme) != FloatingLabelBehavior::Never || self.value.is_empty();
1795        let content_left = bounds.x + self.lead();
1796        // The label at rest sits where the value starts, after the prefix icon; the prefix
1797        // text, hidden while it rests, is no part of it.
1798        let label_rest = field.x + self.pad_x() + self.prefix_icon_w();
1799        let centred = self
1800            .floating_alignment
1801            .or(theme.widgets.text_field.floating_label_alignment)
1802            .unwrap_or_default()
1803            == FloatingLabelAlignment::Center;
1804        // Geometry of the floating label, interpolated between **rest** (inside the box,
1805        // in the hint's place) and the **floated target**. That target differs with the
1806        // style: `outlined` → on the top border (with a notch); otherwise → a reserved band.
1807        let label_geom = self.label.as_ref().filter(|_| label_shown).map(|label| {
1808            let rest = Point::new(label_rest, field.y + self.text_top());
1809            let (fx, fy) = if self.is_outlined() {
1810                // On the top border, which the notch opens behind it.
1811                (
1812                    field.x + self.pad_x(),
1813                    field.y - frus_text::line_height(self.label_size()) * 0.5,
1814                )
1815            } else {
1816                // Inside the container, in the room reserved above the content — a filled
1817                // field never spills outside its own box.
1818                (label_rest, field.y + self.pad_top())
1819            };
1820            // Centred, the floated label is centred on the box, at its floated size.
1821            let fx = if centred {
1822                let floated = frus_text::measure_resolved(
1823                    label,
1824                    &ResolvedTextStyle::exact(self.label_size()),
1825                )
1826                .width;
1827                field.x + (field.width - floated) * 0.5
1828            } else {
1829                fx
1830            };
1831            let x = rest.x + (fx - rest.x) * float_t;
1832            let y = rest.y + (fy - rest.y) * float_t;
1833            let resolved = self.text_style().size;
1834            let size = resolved + (self.label_size() - resolved) * float_t;
1835            let color = if has_error {
1836                error_ink
1837            } else {
1838                s.label_color
1839                    .unwrap()
1840                    .lerp(s.focused_label_color.unwrap(), fp)
1841            };
1842            (label.clone(), x, y, size, color)
1843        });
1844        // Helper/error line below the box (the error takes precedence over the helper).
1845        let sub = self.error.as_ref().or(self.helper.as_ref());
1846        if let Some(sub) = sub {
1847            // The message itself does **not** deepen: `errorStyle` is `error` in every
1848            // state (`input_decorator.dart:6100`). It is a sentence, not a control, and
1849            // a sentence that changed colour under the pointer would be claiming to be
1850            // one.
1851            let color = if has_error {
1852                s.error_color.unwrap()
1853            } else {
1854                s.helper_color.unwrap()
1855            };
1856            scene.text(
1857                Point::new(field.x, field.y + field.height + FIELD_GAP),
1858                sub.clone(),
1859                &self.sub_style(),
1860                color.fade(o),
1861            );
1862        }
1863        // The counter shares that line, pushed to its far end — the reference's place for
1864        // it, and the one that leaves the helper text where it was. It takes the helper's
1865        // colour rather than the error's even while an error is showing: it is a fact
1866        // about length, not a second complaint.
1867        if let Some(counter) = self.counter() {
1868            let width = frus_text::measure_resolved(&counter, &self.sub_style()).width;
1869            scene.text(
1870                Point::new(
1871                    bounds.x + bounds.width - width,
1872                    field.y + field.height + FIELD_GAP,
1873                ),
1874                counter,
1875                &self.sub_style(),
1876                s.helper_color.unwrap().fade(o),
1877            );
1878        }
1879
1880        // The border follows the focus in **two** ways, as the reference does: the colour
1881        // moves to the accent and the weight goes from 1 to 2. Either alone is legible;
1882        // both together is what makes a focused field unmistakable without relying on
1883        // colour, which not every reader has.
1884        let border_color = if has_error {
1885            error_ink
1886        } else {
1887            s.border_color
1888                .unwrap()
1889                .lerp(s.focused_border_color.unwrap(), fp)
1890        }
1891        .fade(o);
1892        let rest_w = s.border_width.unwrap();
1893        let border_width = rest_w + (s.focused_border_width.unwrap() - rest_w) * fp;
1894        let radius = s.radius.unwrap();
1895        let fill = s.fill.unwrap();
1896        if self.is_borderless() {
1897            // **Nothing.** The container belongs to whatever this field was put inside.
1898        } else if self.is_outlined() {
1899            scene.draw_rect(field, fill.fade(o), radius, border_width, border_color);
1900        } else {
1901            // Filled: a container with its **top** corners rounded, and a single line
1902            // under it. A box all the way round would be the other variant wearing a fill.
1903            scene.draw_rect(
1904                field,
1905                fill.fade(o),
1906                frus_core::BorderRadius {
1907                    top_left: radius,
1908                    top_right: radius,
1909                    bottom_right: 0.0,
1910                    bottom_left: 0.0,
1911                },
1912                0.0,
1913                frus_core::Color::TRANSPARENT,
1914            );
1915            scene.fill_rect(
1916                Rect::new(
1917                    field.x,
1918                    field.y + field.height - border_width,
1919                    field.width,
1920                    border_width,
1921                ),
1922                border_color,
1923            );
1924        }
1925
1926        // The label goes **after** the box, in both styles. Floated, it sits above the
1927        // box and the order would not matter; at rest it sits *inside* it, in the
1928        // hint's place, over an opaque surface. It used to be painted first and
1929        // survived only because the renderer drew all text above everything — which it
1930        // stopped doing in milestone 295, and the golden went blank.
1931        if !self.is_outlined() {
1932            if let Some((label, x, y, size, color)) = &label_geom {
1933                // `exact`: `size` is already an interpolation between two **resolved**
1934                // numbers, so resolving it again would apply the reader's setting twice.
1935                let style = ResolvedTextStyle::exact(*size);
1936                scene.text(Point::new(*x, *y), label.clone(), &style, color.fade(o));
1937            }
1938        }
1939
1940        // Outlined: the label's **notch**. The border segment behind the floating label
1941        // is masked by a flat surface-coloured fill, then the label is painted on top.
1942        // The notch only opens as the label rises (`float_t`).
1943        if self.is_outlined() {
1944            if let Some((label, x, y, size, color)) = &label_geom {
1945                if float_t > 0.01 {
1946                    let label_w = frus_text::measure(label, self.label_size()).width;
1947                    let notch = Rect::new(
1948                        *x - FIELD_NOTCH_GAP,
1949                        field.y - (border_width + FIELD_NOTCH_GAP) * 0.5,
1950                        label_w + FIELD_NOTCH_GAP * 2.0,
1951                        border_width + FIELD_NOTCH_GAP,
1952                    );
1953                    scene.fill_rect(notch, theme.surface.fade(o * float_t));
1954                }
1955                scene.text(
1956                    Point::new(*x, *y),
1957                    label.clone(),
1958                    &ResolvedTextStyle::exact(*size),
1959                    color.fade(o),
1960                );
1961            }
1962        }
1963
1964        // Decorative icons, vertically centred in the box (a discreet colour).
1965        let icon_color = s.icon_color.unwrap().fade(o);
1966        let icon_y = field.y + (field.height - self.icon_size()) * 0.5;
1967        if let Some(prefix) = self.prefix {
1968            let path = prefix.placed(
1969                self.icon_size(),
1970                field.x + ICON_PAD,
1971                icon_y,
1972                theme.direction,
1973            );
1974            scene.fill_path(&path, icon_color);
1975        }
1976        if let Some(suffix) = self.suffix {
1977            let x = field.x + field.width - self.icon_size() - ICON_PAD;
1978            // Highlight (milestone 208): a discreet halo behind the **clickable** suffix when
1979            // the pointer hovers it (the absolute position is brought back to local through
1980            // `bounds`). Purely visual.
1981            if self.suffix_action.is_some() {
1982                if let Some(hc) = status.hover_cursor {
1983                    if self.suffix_hit(hc.x - bounds.x, hc.y - bounds.y, bounds.width) {
1984                        let halo = Rect::new(
1985                            x - 4.0,
1986                            icon_y - 4.0,
1987                            self.icon_size() + 8.0,
1988                            self.icon_size() + 8.0,
1989                        );
1990                        scene.draw_rect(
1991                            halo,
1992                            theme.muted.fade(o * 0.18),
1993                            halo.height * 0.5,
1994                            0.0,
1995                            frus_core::Color::TRANSPARENT,
1996                        );
1997                    }
1998                }
1999            }
2000            let path = suffix.placed(self.icon_size(), x, icon_y, theme.direction);
2001            scene.fill_path(&path, icon_color);
2002        }
2003
2004        let len = self.value.chars().count();
2005        // The content is inset between the prefix/suffix icons, where there are any.
2006        let content_x = content_left;
2007        let content_w = self.content_width(bounds.width);
2008        let text_y = field.y + self.text_top();
2009        // Multi-line: the text **wraps** at the content width — the same `max_width` for
2010        // the measure (caret/hit) and for the render → identical wraps.
2011        let wrap = if self.multiline {
2012            Some(content_w)
2013        } else {
2014            None
2015        };
2016        let layout = self.layout(wrap);
2017        // The alignment's push, worked out once and used by the placeholder, the text,
2018        // the selection, the underline and the caret — every one of which is drawn from
2019        // `text_x` below.
2020        let align = self.align_offset(content_w, layout.size().width);
2021
2022        // Hint (placeholder): displayed when the field is empty. If there is a label as
2023        // well, the hint only reveals itself (fading in) once the label has floated —
2024        // otherwise the two would overlap inside the box.
2025        if self.value.is_empty() {
2026            if let Some(placeholder) = &self.placeholder {
2027                let ph_alpha = if self.label.is_some() { o * float_t } else { o };
2028                if ph_alpha > 0.01 {
2029                    // The placeholder sits where the text will: a centred field whose
2030                    // hint hugs the left edge jumps the moment the first key lands.
2031                    let style = self.text_style();
2032                    let hint_w = frus_text::measure_resolved(placeholder, &style).width;
2033                    let hint_align = self.align_offset(content_w, hint_w);
2034                    scene.text(
2035                        Point::new(content_x + hint_align, text_y),
2036                        placeholder.clone(),
2037                        &style,
2038                        theme.muted.fade(ph_alpha),
2039                    );
2040                }
2041            }
2042        }
2043
2044        // The affixes, in the hint's colour, shown as the label floats — or, with no label,
2045        // once the field is focused or holds something (`input_decorator.dart:1830`).
2046        let affix_alpha = if self.label.is_some() {
2047            float_t
2048        } else if self.value.is_empty() {
2049            match self.floating_behavior(theme) {
2050                FloatingLabelBehavior::Always => 1.0,
2051                _ => fp,
2052            }
2053        } else {
2054            1.0
2055        } * o;
2056        if affix_alpha > 0.01 {
2057            let style = self.text_style();
2058            if let Some(prefix) = &self.prefix_text {
2059                scene.text(
2060                    Point::new(field.x + self.pad_x() + self.prefix_icon_w(), text_y),
2061                    prefix.clone(),
2062                    &style,
2063                    theme.muted.fade(affix_alpha),
2064                );
2065            }
2066            if let Some(suffix) = &self.suffix_text {
2067                let w = self.affix_w(&self.suffix_text);
2068                scene.text(
2069                    Point::new(
2070                        field.x + field.width - self.pad_x() - self.suffix_icon_w() - w,
2071                        text_y,
2072                    ),
2073                    suffix.clone(),
2074                    &style,
2075                    theme.muted.fade(affix_alpha),
2076                );
2077            }
2078        }
2079
2080        // Scrolling to keep the caret visible: horizontal (always) and vertical
2081        // (multi-line). Recomputed from the cursor, as on click (`cursor_at`).
2082        let cursor = status.cursor.unwrap_or(len).min(len);
2083        let caret = layout.caret_rect(cursor);
2084        let scroll = if status.focused {
2085            (caret.x - content_w).max(0.0)
2086        } else {
2087            0.0
2088        };
2089        let text_x = content_x + align - scroll;
2090        // **Retained** vertical scroll (wheel/scrollbar, caret-following by the shell);
2091        // clamped to how far the content overflows the box.
2092        let vscroll = if self.multiline {
2093            let overflow = (layout.size().height
2094                - (field.height - self.text_top() - self.pad_bottom()))
2095            .max(0.0);
2096            status.scroll_y.clamp(0.0, overflow)
2097        } else {
2098            0.0
2099        };
2100        // Vertical origin of the content (offset by the multi-line scroll).
2101        let text_top = text_y - vscroll;
2102
2103        // Clipped to the **content** frame — inside the padding, not the whole box.
2104        // Clipping to the box let scrolled multi-line text ride up onto the top border,
2105        // which is where an outlined field's floating label lives: the third line of a
2106        // scrolled field was painted straight over the label naming it. It only became
2107        // visible once the padding grew to the reference's, which is the useful kind of
2108        // regression — the old 6 px hid it rather than avoided it.
2109        let outer_clip = scene.current_clip();
2110        let content_clip = outer_clip.intersect(Rect::new(
2111            content_x,
2112            field.y + self.text_top(),
2113            content_w,
2114            (field.height - self.text_top() - self.pad_bottom()).max(0.0),
2115        ));
2116        scene.set_clip(content_clip);
2117
2118        // Selection highlight (below the text).
2119        if status.focused {
2120            if let Some((start, end)) = status.selection {
2121                for r in layout.selection_rects(start.min(len), end.min(len)) {
2122                    scene.fill_rect(
2123                        Rect::new(text_x + r.x, text_top + r.y, r.width, r.height),
2124                        theme.selection.fade(o),
2125                    );
2126                }
2127            }
2128        }
2129
2130        if !self.value.is_empty() {
2131            let pos = Point::new(text_x, text_top);
2132            let color = theme.on_surface.fade(o);
2133            match wrap {
2134                // Multi-line: the render wraps just as the measure did.
2135                Some(max_w) => {
2136                    scene.text_wrapped(pos, self.display(), &self.text_style(), color, max_w)
2137                }
2138                None => scene.text(pos, self.display(), &self.text_style(), color),
2139            }
2140        }
2141
2142        // The IME **composition** region: underlined (provisional text). Drawn over the
2143        // text, below the baseline.
2144        if status.focused {
2145            if let Some((start, end)) = status.composing {
2146                let (start, end) = (start.min(len), end.min(len));
2147                for r in layout.selection_rects(start, end) {
2148                    scene.fill_rect(
2149                        Rect::new(text_x + r.x, text_top + r.y + r.height - 1.5, r.width, 1.5),
2150                        theme.on_surface.fade(o * 0.7),
2151                    );
2152                }
2153            }
2154        }
2155
2156        // The caret, in the shown half of its blink (milestone 513).
2157        // In the reference's colour, `primary`, and the error's while one shows; its shape the
2158        // caller's or the platform's (milestone 659).
2159        if status.focused && !status.caret_hidden && self.cursor.shown {
2160            let color = if has_error {
2161                s.cursor_error_color.unwrap()
2162            } else {
2163                s.cursor_color.unwrap()
2164            };
2165            let height = self.cursor.height.unwrap_or(caret.height);
2166            let radius =
2167                self.cursor
2168                    .radius
2169                    .unwrap_or(if theme.platform.is_apple() { 2.0 } else { 0.0 });
2170            scene.draw_rect(
2171                Rect::new(
2172                    text_x + caret.x,
2173                    text_top + caret.y + (caret.height - height) * 0.5,
2174                    self.cursor.width,
2175                    height,
2176                ),
2177                color.fade(o),
2178                radius,
2179                0.0,
2180                Color::TRANSPARENT,
2181            );
2182        }
2183
2184        // The handles of a touch selection (milestone 511), outside the content clip:
2185        // they hang below their line, past the text and, on the last line, past the box.
2186        if status.focused && status.handles {
2187            scene.set_clip(outer_clip);
2188            let edit = Edit {
2189                cursor,
2190                anchor: status
2191                    .selection
2192                    .map(|(a, b)| if cursor == a { b } else { a }),
2193                composing: None,
2194            };
2195            if let Some(handles) = self.handles(bounds.width, &edit, status.scroll_y) {
2196                let color = s.handle_color.unwrap().fade(o);
2197                paint_handles(scene, handles, Point::new(bounds.x, bounds.y), color);
2198            }
2199        }
2200    }
2201
2202    fn on_click(&self) -> Option<Msg> {
2203        None
2204    }
2205
2206    fn positional_click(
2207        &self,
2208        local_x: f32,
2209        local_y: f32,
2210        width: f32,
2211        _height: f32,
2212    ) -> Option<Msg> {
2213        if !self.enabled {
2214            return None;
2215        }
2216        if self.suffix_action.is_some() && self.suffix_hit(local_x, local_y, width) {
2217            self.suffix_action.clone()
2218        } else {
2219            None
2220        }
2221    }
2222
2223    fn cursor_icon(
2224        &self,
2225        local_x: f32,
2226        local_y: f32,
2227        width: f32,
2228        _height: f32,
2229    ) -> Option<crate::interaction::Cursor> {
2230        // A hand over the **active** suffix icon (clear / reveal); elsewhere, no opinion.
2231        if self.suffix_action.is_some() && self.suffix_hit(local_x, local_y, width) {
2232            Some(crate::interaction::Cursor::Pointer)
2233        } else {
2234            None
2235        }
2236    }
2237
2238    fn on_edit(&self, edit: &mut Edit, key: &Key) -> Option<Msg> {
2239        // A disabled field cannot be focused, so this should be unreachable — but a key
2240        // arriving from a stale focus must not edit a value the caller has declared
2241        // untouchable.
2242        if !self.enabled {
2243            return None;
2244        }
2245        let mut chars: Vec<char> = self.value.chars().collect();
2246        let len = chars.len();
2247        let mut cursor = edit.cursor.min(len);
2248        let mut anchor = edit.anchor.map(|a| a.min(len));
2249        let selection = anchor
2250            .map(|a| (a.min(cursor), a.max(cursor)))
2251            .filter(|(s, e)| s < e);
2252
2253        let mut changed = false;
2254
2255        match key {
2256            Key::Text(text) => {
2257                // The filter, one character at a time: a rejected one never arrives and
2258                // a substituted one takes the same place, so the caret arithmetic below
2259                // is the arithmetic it always was.
2260                let inserted: Vec<char> = match &self.input_filter {
2261                    Some(filter) => text.chars().filter_map(filter).collect(),
2262                    None => text.chars().collect(),
2263                };
2264                // Nothing typed and nothing selected is nothing done: emitting the value
2265                // unchanged would rebuild the tree for a keystroke that was refused.
2266                // A selection **is** something done, even when what replaces it is
2267                // empty, since the reference filters the value after the replacement.
2268                if selection.is_some() || !inserted.is_empty() {
2269                    if let Some((s, e)) = selection {
2270                        chars.drain(s..e);
2271                        cursor = s;
2272                    }
2273                    let n = inserted.len();
2274                    chars.splice(cursor..cursor, inserted);
2275                    cursor += n;
2276                    anchor = None;
2277                    changed = true;
2278                }
2279            }
2280            Key::Backspace => {
2281                if let Some((s, e)) = selection {
2282                    chars.drain(s..e);
2283                    cursor = s;
2284                    changed = true;
2285                } else if cursor > 0 {
2286                    chars.remove(cursor - 1);
2287                    cursor -= 1;
2288                    changed = true;
2289                }
2290                anchor = None;
2291            }
2292            Key::Delete => {
2293                if let Some((s, e)) = selection {
2294                    chars.drain(s..e);
2295                    cursor = s;
2296                    changed = true;
2297                } else if cursor < len {
2298                    chars.remove(cursor);
2299                    changed = true;
2300                }
2301                anchor = None;
2302            }
2303            // The caret's own keys: an arrow, a word under Ctrl, the ends of the line — of the
2304            // whole field under Ctrl, which in a single-line field is the same.
2305            Key::Left { .. } | Key::Right { .. } | Key::Home { .. } | Key::End { .. } => {
2306                move_caret(&chars, &mut cursor, &mut anchor, key);
2307            }
2308            // Escape is none of editing's business (routed leaf→root by the shell).
2309            Key::Escape => {}
2310            Key::Enter if self.multiline => {
2311                // Multi-line: Enter **inserts a line break** (no submission).
2312                if let Some((s, e)) = selection {
2313                    chars.drain(s..e);
2314                    cursor = s;
2315                }
2316                chars.insert(cursor, '\n');
2317                cursor += 1;
2318                anchor = None;
2319                changed = true;
2320            }
2321            Key::Enter => {
2322                // Single-line: Enter submits — it does not alter the value, it emits the submission.
2323                edit.cursor = cursor;
2324                edit.anchor = anchor;
2325                return self.on_submit.clone();
2326            }
2327        }
2328
2329        // Read-only: everything that only **moves** has already happened -- the caret,
2330        // the selection, the word jumps -- and is kept. What is refused is the change,
2331        // which is the whole of the difference between this and a disabled field, where
2332        // there is no caret to move in the first place.
2333        if self.read_only && changed {
2334            return None;
2335        }
2336
2337        // The limit, enforced the way the reference enforces it: what fits lands and the
2338        // rest does not. Dropping a whole paste for being one character too long would
2339        // lose work the user can see they had, and refusing the keystroke that crosses
2340        // the limit is the same rule seen one character at a time.
2341        //
2342        // A value the **caller** supplied over the limit is left alone: it is the
2343        // application's state, not something typed, and silently shortening it would be
2344        // editing a value nobody edited. The counter shows it over.
2345        if changed {
2346            if let Some(max) = self.max_length {
2347                if chars.len() > max {
2348                    chars.truncate(max);
2349                    cursor = cursor.min(max);
2350                    anchor = anchor.map(|a| a.min(max));
2351                }
2352            }
2353        }
2354
2355        edit.cursor = cursor;
2356        edit.anchor = anchor;
2357
2358        if changed {
2359            let new_value: String = chars.into_iter().collect();
2360            self.on_input.as_ref().map(|make| make(new_value))
2361        } else {
2362            None
2363        }
2364    }
2365
2366    fn ime(&self) -> Ime {
2367        self.ime_options()
2368    }
2369
2370    fn cursor_at(
2371        &self,
2372        local_x: f32,
2373        local_y: f32,
2374        width: f32,
2375        scroll_cursor: usize,
2376    ) -> Option<usize> {
2377        // A disabled field takes no caret: it is readable, not editable, and a caret
2378        // sitting in it would promise otherwise.
2379        if !self.enabled {
2380            return None;
2381        }
2382        // A click on the **clickable suffix icon**: do not place a caret (the shell will
2383        // emit its message through `positional_click`).
2384        if self.suffix_action.is_some() && self.suffix_hit(local_x, local_y, width) {
2385            return None;
2386        }
2387        // Rebuilds the same content geometry as the render (icon and decoration insets,
2388        // wrapping, horizontal scroll), for an exact click. The **retained vertical
2389        // scroll** is already folded into `local_y` by the shell.
2390        let left = self.lead();
2391        let content_w = self.content_width(width);
2392        let layout = self.layout(if self.multiline {
2393            Some(content_w)
2394        } else {
2395            None
2396        });
2397        let scroll = (layout.caret_rect(scroll_cursor).x - content_w).max(0.0);
2398        // The same push the paint applied, from the same function. Without it a click on
2399        // centred or right-aligned text lands wherever the glyphs *would* have been
2400        // against the left edge, which is a caret appearing several characters from the
2401        // tap.
2402        let align = self.align_offset(content_w, layout.size().width);
2403        // `local_*` are relative to the **widget's** top-left corner (label included):
2404        // the label band and the padding are removed to land inside the text.
2405        let target_x = local_x - left - align + scroll;
2406        let target_y = local_y - self.label_block() - self.text_top();
2407        Some(layout.hit_test(Point::new(target_x, target_y)))
2408    }
2409
2410    fn text_metrics(&self, width: f32, cursor: usize) -> Option<(f32, f32, f32, f32)> {
2411        if !self.multiline {
2412            return None;
2413        }
2414        let layout = self.layout(Some(self.content_width(width)));
2415        let caret = layout.caret_rect(cursor);
2416        let visible = self.field_height_at(width) - self.text_top() - self.pad_bottom();
2417        Some((layout.size().height, visible, caret.y, caret.height))
2418    }
2419
2420    fn text_viewport(&self, rect: Rect) -> Option<Rect> {
2421        if !self.multiline {
2422            return None;
2423        }
2424        // The input box: below the label, as tall as the `rows`.
2425        Some(Rect::new(
2426            rect.x,
2427            rect.y + self.label_block(),
2428            rect.width,
2429            self.field_height_at(rect.width),
2430        ))
2431    }
2432
2433    fn caret_vertical(
2434        &self,
2435        width: f32,
2436        cursor: usize,
2437        down: bool,
2438        page: bool,
2439        goal_x: Option<f32>,
2440    ) -> Option<(usize, f32)> {
2441        if !self.multiline {
2442            return None;
2443        }
2444        let layout = self.layout(Some(self.content_width(width)));
2445        let caret = layout.caret_rect(cursor);
2446        let line_h = caret.height;
2447        // Wanted column: the remembered target, otherwise the current column.
2448        let x = goal_x.unwrap_or(caret.x);
2449        // A step of one line, or of one page (the field's visible height, at least 1 line).
2450        let step = if page {
2451            (self.field_height_at(width) - self.text_top() - self.pad_bottom()).max(line_h)
2452        } else {
2453            line_h
2454        };
2455        let center = caret.y + line_h * 0.5;
2456        let target_y = if down { center + step } else { center - step };
2457        let full = layout.size().height;
2458        if page {
2459            // Page: clamped to the field (it is never left).
2460            let clamped = target_y.clamp(0.0, (full - line_h * 0.5).max(0.0));
2461            Some((layout.hit_test(Point::new(x, clamped)), x))
2462        } else {
2463            // Line: already on the first/last one → the shell moves the focus.
2464            if target_y < 0.0 || target_y >= full {
2465                return None;
2466            }
2467            Some((layout.hit_test(Point::new(x, target_y)), x))
2468        }
2469    }
2470
2471    fn text_value(&self) -> Option<&str> {
2472        Some(&self.value)
2473    }
2474
2475    /// A field that is read-only, or that cannot be focused, takes no typing.
2476    fn takes_typing(&self) -> bool {
2477        self.enabled && !self.read_only
2478    }
2479
2480    fn replace_value(&self, value: String) -> Option<Msg> {
2481        // The same two refusals `on_edit` makes, for the same reason: a field the caller
2482        // has declared untouchable does not become touchable because the value being put
2483        // back came out of it. No filter and no length limit, though — this value passed
2484        // both on its way in, and running it through them again would let an undo land
2485        // somewhere the field has never been.
2486        if !self.enabled || self.read_only {
2487            return None;
2488        }
2489        self.on_input.as_ref().map(|make| make(value))
2490    }
2491
2492    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
2493        let mut s = frus_core::SemanticsProperties::new(frus_core::Role::TextInput)
2494            .value(self.value.clone());
2495        // The label names the field — the placeholder when there is none, as the reference
2496        // reads a hint out for a field that has no label, so a reader is never told only
2497        // "edit" (milestone 710); an error is appended so it gets read out.
2498        let name = self.label.as_ref().or(self.placeholder.as_ref());
2499        let label = match (name, &self.error) {
2500            (Some(l), Some(e)) => Some(format!("{l}, {e}")),
2501            (Some(l), None) => Some(l.clone()),
2502            (None, Some(e)) => Some(e.clone()),
2503            (None, None) => None,
2504        };
2505        if let Some(label) = label {
2506            s = s.label(label);
2507        }
2508        if !self.enabled {
2509            s = s.disabled(true);
2510        }
2511        Some(s)
2512    }
2513
2514    fn selected_text(&self, edit: &Edit) -> Option<String> {
2515        // A masked field gives its text to nobody: what is shown is dots, and what is
2516        // copied must not be the thing they hide.
2517        if self.obscure {
2518            return None;
2519        }
2520        let chars: Vec<char> = self.value.chars().collect();
2521        let len = chars.len();
2522        let cursor = edit.cursor.min(len);
2523        let anchor = edit.anchor?.min(len);
2524        let (start, end) = (anchor.min(cursor), anchor.max(cursor));
2525        if start < end {
2526            Some(chars[start..end].iter().collect())
2527        } else {
2528            None
2529        }
2530    }
2531
2532    fn word_at(&self, index: usize) -> Option<(usize, usize)> {
2533        let chars: Vec<char> = self.value.chars().collect();
2534        word_range(&chars, index)
2535    }
2536
2537    fn selection_handles(
2538        &self,
2539        width: f32,
2540        edit: &Edit,
2541        scroll_y: f32,
2542    ) -> Option<[crate::SelectionHandle; 2]> {
2543        self.handles(width, edit, scroll_y)
2544    }
2545
2546    fn selection_toolbar(&self, context: ToolbarContext) -> Option<&dyn Widget<Msg>> {
2547        self.toolbars[usize::from(context.variant())]
2548            .get_or_init(|| {
2549                let defaults = self.default_toolbar_items(&context);
2550                let items = match &self.toolbar_build {
2551                    Some(build) => build(&context, defaults),
2552                    None => defaults,
2553                };
2554                (!items.is_empty()).then(|| {
2555                    let bar: Box<dyn Widget<Msg>> = Box::new(SelectionToolbar::new(items));
2556                    bar
2557                })
2558            })
2559            .as_deref()
2560    }
2561
2562    fn selection_anchor(&self, width: f32, edit: &Edit, scroll_y: f32) -> Option<Rect> {
2563        Some(self.selection_box(width, edit, scroll_y))
2564    }
2565
2566    fn autofill_hints(&self) -> &[AutofillHint] {
2567        &self.autofill
2568    }
2569
2570    fn autofocus(&self) -> bool {
2571        self.autofocus && self.enabled
2572    }
2573
2574    fn on_tap_outside(&self) -> Option<Msg> {
2575        self.on_tap_outside.clone()
2576    }
2577
2578    fn focusable(&self) -> bool {
2579        self.enabled
2580    }
2581
2582    fn draws_own_focus(&self) -> bool {
2583        true
2584    }
2585
2586    fn announce(&self) -> Option<String> {
2587        self.announce.clone()
2588    }
2589}
2590
2591#[cfg(test)]
2592mod tests {
2593    use super::*;
2594    use crate::icons::Icons;
2595
2596    #[derive(Clone, Debug, PartialEq)]
2597    enum Msg {
2598        Changed(String),
2599        Submitted,
2600    }
2601
2602    /// **A field with no label is named by its placeholder** (milestone 710), as the reference
2603    /// reads a hint out; a label wins over it, and an error is still appended.
2604    #[test]
2605    fn a_field_with_no_label_is_named_by_its_placeholder() {
2606        let name = |field: TextField<Msg>| Widget::<Msg>::semantics(&field).and_then(|s| s.label);
2607        assert_eq!(
2608            name(TextField::new("").placeholder("New task")).as_deref(),
2609            Some("New task")
2610        );
2611        assert_eq!(
2612            name(TextField::new("").placeholder("e.g. Ada").label("Name")).as_deref(),
2613            Some("Name")
2614        );
2615        assert_eq!(
2616            name(TextField::new("").placeholder("New task").error("Required")).as_deref(),
2617            Some("New task, Required")
2618        );
2619        assert_eq!(name(TextField::new("")), None);
2620    }
2621
2622    fn input(value: &str) -> TextField<Msg> {
2623        TextField::new(value).on_input(Msg::Changed)
2624    }
2625
2626    /// Every rectangle a field paints, in order.
2627    fn rects(field: &TextField<Msg>, theme: &Theme, w: f32, h: f32) -> Vec<frus_core::Primitive> {
2628        let mut scene = Scene::new();
2629        Widget::<Msg>::paint(
2630            field,
2631            Rect::new(0.0, 0.0, w, h),
2632            Status::default(),
2633            theme,
2634            &mut scene,
2635        );
2636        scene
2637            .primitives()
2638            .iter()
2639            .filter(|p| matches!(p, frus_core::Primitive::Rect { .. }))
2640            .cloned()
2641            .collect()
2642    }
2643
2644    /// The reference's content padding is `(12, 8, 12, 8)` filled and `(12, 20, 12, 12)`
2645    /// outlined, and the outlined asymmetry is the room its floating label takes on the
2646    /// border. A field measured any other way is a field that does not line up beside
2647    /// anything else drawn to the same specification.
2648    #[test]
2649    fn a_field_is_the_references_size() {
2650        let line = input("x").text_style().line_height();
2651        let outlined = input("x");
2652        assert_eq!(
2653            outlined.field_height(),
2654            (line + FIELD_OUTLINED_PADDING_TOP + FIELD_OUTLINED_PADDING_BOTTOM).ceil()
2655        );
2656        // A filled field is tighter — until it carries a label, which it floats *inside*
2657        // the box and therefore has to make room for.
2658        let filled = input("x").filled();
2659        assert_eq!(filled.field_height(), (line + FIELD_PADDING_Y * 2.0).ceil());
2660        let labelled = input("x").filled().label("Name");
2661        assert_eq!(
2662            labelled.field_height() - filled.field_height(),
2663            (FIELD_GAP + FIELD_TEXT_SIZE * FIELD_LABEL_SCALE).ceil()
2664        );
2665        // The label shrinks by a proportion, not to a fixed size: a field given larger
2666        // text keeps the relationship.
2667        assert_eq!(input("x").size(32.0).label_size(), 24.0);
2668    }
2669
2670    /// A field is 56 px tall, which is right for a form and wrong inside a table row.
2671    /// `dense` is the reference's answer, and it gives back the padding without changing
2672    /// the shape, the label or the border.
2673    #[test]
2674    fn a_dense_field_gives_back_its_padding() {
2675        let line = input("x").text_style().line_height();
2676        assert_eq!(
2677            input("x").dense(true).field_height(),
2678            (line + FIELD_DENSE_OUTLINED_PADDING_TOP + FIELD_DENSE_OUTLINED_PADDING_BOTTOM).ceil()
2679        );
2680        assert_eq!(
2681            input("x").filled().dense(true).field_height(),
2682            (line + FIELD_DENSE_PADDING_Y * 2.0).ceil()
2683        );
2684        // It is only the room: a dense field is shorter than its roomy twin in both
2685        // variants, and never taller.
2686        for dense in [input("x").dense(true), input("x").filled().dense(true)] {
2687            let roomy = if dense.is_outlined() {
2688                input("x")
2689            } else {
2690                input("x").filled()
2691            };
2692            assert!(dense.field_height() < roomy.field_height());
2693        }
2694    }
2695
2696    /// A filled field is a container with **one line under it**; an outlined one is a box.
2697    /// Drawing a fill inside a stroked box on all four sides would be the other variant
2698    /// wearing a tint, which is the mistake this variant exists to avoid.
2699    #[test]
2700    fn a_filled_field_has_a_line_not_a_box() {
2701        let theme = Theme::default();
2702        let stroked = |field: &TextField<Msg>| {
2703            rects(field, &theme, 220.0, 80.0)
2704                .iter()
2705                .filter(|p| {
2706                    matches!(p, frus_core::Primitive::Rect { border_width, .. } if *border_width > 0.0)
2707                })
2708                .count()
2709        };
2710        assert_eq!(
2711            stroked(&input("x").outlined()),
2712            1,
2713            "outlined: one stroked box"
2714        );
2715        assert_eq!(stroked(&input("x").filled()), 0, "filled: nothing stroked");
2716        // ...and the line is a filled rectangle the width of the field, at its foot.
2717        let filled = input("x").filled();
2718        let height = filled.field_height();
2719        let has_underline = rects(&filled, &theme, 220.0, 80.0).iter().any(|p| {
2720            matches!(
2721                p,
2722                frus_core::Primitive::Rect { rect, border_width, .. }
2723                    if *border_width == 0.0
2724                        && (rect.width - 220.0).abs() < 0.5
2725                        && (rect.height - FIELD_BORDER_WIDTH).abs() < 0.5
2726                        && (rect.y + rect.height - height).abs() < 0.5
2727            )
2728        });
2729        assert!(has_underline, "a filled field is underlined");
2730    }
2731
2732    /// A disabled field keeps 38% of its colours and shows no focus — it cannot be
2733    /// focused, so a ring left over from before would say the wrong thing about Tab.
2734    #[test]
2735    fn a_disabled_field_dims_and_never_looks_focused() {
2736        let theme = Theme::default();
2737        let focused = Status {
2738            focus_progress: 1.0,
2739            ..Default::default()
2740        };
2741        let border = |field: &TextField<Msg>, status: Status| {
2742            let mut scene = Scene::new();
2743            Widget::<Msg>::paint(
2744                field,
2745                Rect::new(0.0, 0.0, 220.0, 80.0),
2746                status,
2747                &theme,
2748                &mut scene,
2749            );
2750            scene.primitives().iter().find_map(|p| match p {
2751                frus_core::Primitive::Rect {
2752                    border_width,
2753                    border_color,
2754                    ..
2755                } if *border_width > 0.0 => Some((*border_width, *border_color)),
2756                _ => None,
2757            })
2758        };
2759        let (live_w, live_c) = border(&input("x"), focused).unwrap();
2760        assert_eq!(
2761            live_w, FIELD_FOCUSED_BORDER_WIDTH,
2762            "focus widens the border"
2763        );
2764        assert_eq!(live_c.a, 1.0, "and it is fully opaque");
2765        let (dead_w, dead_c) = border(&input("x").enabled(false), focused).unwrap();
2766        assert_eq!(dead_w, FIELD_BORDER_WIDTH, "disabled: no focus weight");
2767        assert!(
2768            (dead_c.a - FIELD_DISABLED_OPACITY).abs() < 0.01,
2769            "disabled: dimmed to the reference's 38%, got {}",
2770            dead_c.a
2771        );
2772    }
2773
2774    /// Scrolled multi-line text must stay **inside the padding**, not merely inside the
2775    /// box: the top border is where an outlined field's floating label lives, and text
2776    /// clipped only to the box was painted straight over the label naming it.
2777    #[test]
2778    fn scrolled_text_is_clipped_below_the_label() {
2779        let theme = Theme::default();
2780        let field = input("one\ntwo\nthree\nfour\nfive\nsix")
2781            .rows(3)
2782            .label("Notes");
2783        let mut scene = Scene::new();
2784        Widget::<Msg>::paint(
2785            &field,
2786            Rect::new(0.0, 0.0, 220.0, 140.0),
2787            Status {
2788                scroll_y: 40.0,
2789                ..Default::default()
2790            },
2791            &theme,
2792            &mut scene,
2793        );
2794        let box_top = field.label_block();
2795        let clip = scene
2796            .primitives()
2797            .iter()
2798            .find_map(|p| match p {
2799                // The label and the helper are painted unclipped; the value is not, and
2800                // its clip is the one under test.
2801                frus_core::Primitive::Text { clip, .. } if clip.y > -1.0e6 => Some(*clip),
2802                _ => None,
2803            })
2804            .expect("the value is clipped");
2805        assert!(
2806            clip.y >= box_top + field.text_top() - 0.5,
2807            "the clip starts below the padding, not at the border: {} < {}",
2808            clip.y,
2809            box_top + field.text_top()
2810        );
2811    }
2812
2813    /// Greying a field out is not enough: it has to be out of the tab order, refuse a
2814    /// caret, and ignore a key that reaches it from a focus set before it was disabled.
2815    #[test]
2816    fn a_disabled_field_is_inert() {
2817        let live = input("hello");
2818        let dead = input("hello").enabled(false);
2819        assert!(Widget::<Msg>::focusable(&live));
2820        assert!(!Widget::<Msg>::focusable(&dead), "out of the tab order");
2821        assert!(
2822            Widget::<Msg>::cursor_at(&live, 40.0, 30.0, 220.0, 0).is_some(),
2823            "a live field takes a caret"
2824        );
2825        assert_eq!(
2826            Widget::<Msg>::cursor_at(&dead, 40.0, 30.0, 220.0, 0),
2827            None,
2828            "a disabled field takes none"
2829        );
2830        let mut edit = Edit::default();
2831        assert!(
2832            Widget::on_edit(&dead, &mut edit, &Key::Text("x".into())).is_none(),
2833            "and no key edits it"
2834        );
2835        let semantics = Widget::<Msg>::semantics(&dead).unwrap();
2836        assert!(semantics.disabled, "a reader is told it is disabled");
2837    }
2838
2839    /// `caller ?? theme ?? framework`, on a field that has all three to choose from.
2840    #[test]
2841    fn the_caller_outranks_the_theme_which_outranks_the_framework() {
2842        let radius = |field: &TextField<Msg>, theme: &Theme| {
2843            rects(field, theme, 220.0, 80.0)
2844                .iter()
2845                .find_map(|p| match p {
2846                    frus_core::Primitive::Rect {
2847                        border_width,
2848                        radius,
2849                        ..
2850                    } if *border_width > 0.0 => Some(radius.top_left),
2851                    _ => None,
2852                })
2853                .unwrap()
2854        };
2855        let plain = Theme::default();
2856        assert_eq!(radius(&input("x"), &plain), FIELD_RADIUS);
2857        let mut themed = Theme::default();
2858        themed.widgets.text_field.radius = Some(10.0);
2859        assert_eq!(radius(&input("x"), &themed), 10.0);
2860        let overridden = input("x").style(TextFieldStyle {
2861            radius: Some(2.0),
2862            ..Default::default()
2863        });
2864        assert_eq!(radius(&overridden, &themed), 2.0);
2865    }
2866
2867    #[test]
2868    fn text_is_clipped_to_content_box() {
2869        // Text longer than the field must be clipped to its content width (otherwise it
2870        // overflows onto the neighbouring widgets).
2871        let inp = input("a very long value that overflows the field width").width(100.0);
2872        let mut scene = Scene::new();
2873        Widget::<Msg>::paint(
2874            &inp,
2875            Rect::new(0.0, 0.0, 100.0, 30.0),
2876            Status::default(),
2877            &Theme::default(),
2878            &mut scene,
2879        );
2880        let clip = scene
2881            .primitives()
2882            .iter()
2883            .find_map(|p| match p {
2884                frus_core::Primitive::Text { clip, .. } => Some(*clip),
2885                _ => None,
2886            })
2887            .expect("text primitive");
2888        assert!(
2889            (clip.width - (100.0 - FIELD_PADDING_X * 2.0)).abs() < 0.5,
2890            "clip = {clip:?}"
2891        );
2892    }
2893
2894    #[test]
2895    fn composing_region_draws_an_underline() {
2896        // The composed region adds thin rectangles (the underline) below the text,
2897        // absent when no composition is in progress.
2898        let inp = input("konnichiwa");
2899        let base = Status {
2900            focused: true,
2901            cursor: Some(5),
2902            ..Default::default()
2903        };
2904        let composing = Status {
2905            composing: Some((0, 5)),
2906            ..base
2907        };
2908
2909        let count_thin_rects = |status: Status| {
2910            let mut scene = Scene::new();
2911            Widget::<Msg>::paint(
2912                &inp,
2913                Rect::new(0.0, 0.0, 220.0, 30.0),
2914                status,
2915                &Theme::default(),
2916                &mut scene,
2917            );
2918            scene
2919                .primitives()
2920                .iter()
2921                .filter(|p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.height <= 2.0 && rect.width > 2.0))
2922                .count()
2923        };
2924        let plain = count_thin_rects(base);
2925        let underlined = count_thin_rects(composing);
2926        assert!(
2927            underlined > plain,
2928            "the composition must underline ({plain} → {underlined})"
2929        );
2930    }
2931
2932    #[test]
2933    fn text_value_exposes_the_field_content() {
2934        // The input context (IME suggestions) reads the field's value.
2935        let inp = input("hello");
2936        assert_eq!(Widget::<Msg>::text_value(&inp), Some("hello"));
2937    }
2938
2939    #[test]
2940    fn cursor_at_accounts_for_scroll() {
2941        // A narrow field + long text → scrolling once the caret is at the end.
2942        let inp = input("0123456789 abcdefghij");
2943        // Without scrolling (cursor 0): a click on the left edge → index 0.
2944        assert_eq!(
2945            Widget::<Msg>::cursor_at(&inp, FIELD_PADDING_X, FIELD_OUTLINED_PADDING_TOP, 80.0, 0),
2946            Some(0)
2947        );
2948        // Scrolled (caret at the end): a click on the right lands on a larger index than
2949        // a click on the left (the offset is indeed taken into account).
2950        let end = inp.value.chars().count();
2951        let at_left =
2952            Widget::<Msg>::cursor_at(&inp, FIELD_PADDING_X, FIELD_OUTLINED_PADDING_TOP, 80.0, end)
2953                .unwrap();
2954        let at_right =
2955            Widget::<Msg>::cursor_at(&inp, 76.0, FIELD_OUTLINED_PADDING_TOP, 80.0, end).unwrap();
2956        assert!(at_left > 0, "scrolled: the left edge is no longer index 0");
2957        assert!(at_right > at_left, "a click on the right → a larger index");
2958    }
2959
2960    #[test]
2961    fn insert_at_cursor() {
2962        let inp = input("ac");
2963        let mut edit = Edit {
2964            cursor: 1,
2965            anchor: None,
2966            composing: None,
2967        };
2968        assert_eq!(
2969            inp.on_edit(&mut edit, &Key::Text("b".to_string())),
2970            Some(Msg::Changed("abc".to_string()))
2971        );
2972        assert_eq!(edit.cursor, 2);
2973    }
2974
2975    #[test]
2976    fn shift_arrow_selects_then_delete() {
2977        let inp = input("hello");
2978        // Caret at the end, Shift+Left twice -> selects "lo".
2979        let mut edit = Edit {
2980            cursor: 5,
2981            anchor: None,
2982            composing: None,
2983        };
2984        inp.on_edit(
2985            &mut edit,
2986            &Key::Left {
2987                shift: true,
2988                word: false,
2989            },
2990        );
2991        inp.on_edit(
2992            &mut edit,
2993            &Key::Left {
2994                shift: true,
2995                word: false,
2996            },
2997        );
2998        assert_eq!(edit.selection_range(), Some((3, 5)));
2999        // Backspace deletes the selection.
3000        assert_eq!(
3001            inp.on_edit(&mut edit, &Key::Backspace),
3002            Some(Msg::Changed("hel".to_string()))
3003        );
3004        assert_eq!(edit.cursor, 3);
3005        assert_eq!(edit.anchor, None);
3006    }
3007
3008    #[test]
3009    fn home_end_bounds() {
3010        let inp = input("abc");
3011        let mut edit = Edit {
3012            cursor: 1,
3013            anchor: None,
3014            composing: None,
3015        };
3016        inp.on_edit(
3017            &mut edit,
3018            &Key::End {
3019                shift: false,
3020                doc: false,
3021            },
3022        );
3023        assert_eq!(edit.cursor, 3);
3024        inp.on_edit(
3025            &mut edit,
3026            &Key::Home {
3027                shift: false,
3028                doc: false,
3029            },
3030        );
3031        assert_eq!(edit.cursor, 0);
3032    }
3033
3034    #[test]
3035    fn ctrl_arrow_jumps_by_word() {
3036        let inp = input("foo bar baz");
3037        // From the end, Ctrl+Left jumps to the start of "baz", then "bar", then "foo".
3038        let mut edit = Edit {
3039            cursor: 11,
3040            anchor: None,
3041            composing: None,
3042        };
3043        inp.on_edit(
3044            &mut edit,
3045            &Key::Left {
3046                shift: false,
3047                word: true,
3048            },
3049        );
3050        assert_eq!(edit.cursor, 8, "start of \"baz\"");
3051        inp.on_edit(
3052            &mut edit,
3053            &Key::Left {
3054                shift: false,
3055                word: true,
3056            },
3057        );
3058        assert_eq!(edit.cursor, 4, "start of \"bar\"");
3059        // Ctrl+Right jumps to the end of "bar", then "baz".
3060        inp.on_edit(
3061            &mut edit,
3062            &Key::Right {
3063                shift: false,
3064                word: true,
3065            },
3066        );
3067        assert_eq!(edit.cursor, 7, "fin de \"bar\"");
3068        inp.on_edit(
3069            &mut edit,
3070            &Key::Right {
3071                shift: false,
3072                word: true,
3073            },
3074        );
3075        assert_eq!(edit.cursor, 11, "fin de \"baz\"");
3076    }
3077
3078    #[test]
3079    fn home_end_are_line_relative_but_ctrl_spans_the_field() {
3080        // "ab\ncd\nef": caret in the middle of the 2nd line (index 4, between c and d).
3081        let inp = TextField::<Msg>::new("ab\ncd\nef")
3082            .on_input(Msg::Changed)
3083            .rows(3);
3084        let mut edit = Edit {
3085            cursor: 4,
3086            anchor: None,
3087            composing: None,
3088        };
3089        // Plain Home → the start of the current line (index 3), not of the field.
3090        inp.on_edit(
3091            &mut edit,
3092            &Key::Home {
3093                shift: false,
3094                doc: false,
3095            },
3096        );
3097        assert_eq!(edit.cursor, 3, "start of the 2nd line");
3098        // Plain End → the end of the current line (index 5).
3099        inp.on_edit(
3100            &mut edit,
3101            &Key::End {
3102                shift: false,
3103                doc: false,
3104            },
3105        );
3106        assert_eq!(edit.cursor, 5, "end of the 2nd line");
3107        // Ctrl+Home / Ctrl+End → the bounds of the whole field.
3108        inp.on_edit(
3109            &mut edit,
3110            &Key::Home {
3111                shift: false,
3112                doc: true,
3113            },
3114        );
3115        assert_eq!(edit.cursor, 0, "start of the field");
3116        inp.on_edit(
3117            &mut edit,
3118            &Key::End {
3119                shift: false,
3120                doc: true,
3121            },
3122        );
3123        assert_eq!(edit.cursor, 8, "end of the field");
3124    }
3125
3126    #[test]
3127    fn selected_text_reads_range() {
3128        let inp = input("hello");
3129        let edit = Edit {
3130            cursor: 5,
3131            anchor: Some(2),
3132            composing: None,
3133        };
3134        assert_eq!(inp.selected_text(&edit), Some("llo".to_string()));
3135    }
3136
3137    /// The words on the buttons of the bar a field shows for `context`, in order — `None`
3138    /// when it shows none.
3139    fn bar_words(field: &TextField<Msg>, context: ToolbarContext) -> Option<Vec<String>> {
3140        let bar = Widget::<Msg>::selection_toolbar(field, context)?;
3141        Some(
3142            bar.children()
3143                .iter()
3144                .map(|button| {
3145                    button
3146                        .semantics()
3147                        .and_then(|semantics| semantics.label)
3148                        .unwrap_or_default()
3149                })
3150                .collect(),
3151        )
3152    }
3153
3154    fn context(has_selection: bool, can_paste: bool, all_selected: bool) -> ToolbarContext {
3155        ToolbarContext {
3156            has_selection,
3157            can_paste,
3158            all_selected,
3159        }
3160    }
3161
3162    /// **The default bar** is decided by the three conditions (milestone 568): Cut and Copy
3163    /// want a selection, Paste wants something to paste, Select all wants something left
3164    /// unselected — in the reference's order.
3165    #[test]
3166    fn the_default_bar_follows_the_three_conditions() {
3167        let field = input("hello world");
3168        let words = |ctx| bar_words(&field, ctx);
3169        assert_eq!(
3170            words(context(true, true, false)),
3171            Some(vec![
3172                "Cut".into(),
3173                "Copy".into(),
3174                "Paste".into(),
3175                "Select all".into()
3176            ])
3177        );
3178        assert_eq!(
3179            words(context(false, true, false)),
3180            Some(vec!["Paste".into(), "Select all".into()])
3181        );
3182        assert_eq!(
3183            words(context(true, false, true)),
3184            Some(vec!["Cut".into(), "Copy".into()])
3185        );
3186        assert_eq!(words(context(false, false, true)), None, "nothing to offer");
3187    }
3188
3189    /// **A field that keeps a secret gives none away**: a masked field offers neither Cut
3190    /// nor Copy, and its selected text is not readable for the clipboard either — Ctrl+C
3191    /// used to put the password itself on the clipboard.
3192    #[test]
3193    fn a_masked_field_offers_neither_cut_nor_copy_and_copies_nothing() {
3194        let field = input("hunter2").obscure(true);
3195        assert_eq!(
3196            bar_words(&field, context(true, true, false)),
3197            Some(vec!["Paste".into(), "Select all".into()])
3198        );
3199        let selected = Edit {
3200            cursor: 7,
3201            anchor: Some(0),
3202            composing: None,
3203        };
3204        assert_eq!(field.selected_text(&selected), None);
3205        // The same field unmasked copies as before.
3206        assert_eq!(
3207            input("hunter2").selected_text(&selected),
3208            Some("hunter2".to_string())
3209        );
3210    }
3211
3212    /// A read-only field keeps its text as it is: it can be copied from and selected, not
3213    /// cut from and not pasted into.
3214    #[test]
3215    fn a_read_only_field_offers_copy_and_select_all_only() {
3216        let field = input("REF-0042").read_only();
3217        assert_eq!(
3218            bar_words(&field, context(true, true, false)),
3219            Some(vec!["Copy".into(), "Select all".into()])
3220        );
3221    }
3222
3223    /// **The application has the last word** on the list: it is handed the context and the
3224    /// defaults, and what it returns is what shows — an item dropped, one of its own added,
3225    /// or an empty list for no bar.
3226    #[test]
3227    fn the_application_rewrites_the_list() {
3228        let field = input("hello").selection_toolbar(|context, mut items| {
3229            items.retain(|item| item.label() != "Paste");
3230            if context.has_selection {
3231                items.push(ToolbarItem::custom("Translate", Msg::Submitted));
3232            }
3233            items
3234        });
3235        assert_eq!(
3236            bar_words(&field, context(true, true, false)),
3237            Some(vec![
3238                "Cut".into(),
3239                "Copy".into(),
3240                "Select all".into(),
3241                "Translate".into()
3242            ])
3243        );
3244        assert_eq!(
3245            bar_words(&field, context(false, true, false)),
3246            Some(vec!["Select all".into()]),
3247            "the custom item only for a selection"
3248        );
3249        let none = input("hello").selection_toolbar(|_, _| Vec::new());
3250        assert_eq!(bar_words(&none, context(true, true, false)), None);
3251    }
3252
3253    /// The application's own item sends its message, and a built-in one sends nothing (the
3254    /// shell performs it).
3255    #[test]
3256    fn an_application_item_sends_its_message_and_a_built_in_one_does_not() {
3257        let field = input("hello").selection_toolbar(|_, mut items| {
3258            items.push(ToolbarItem::custom("Translate", Msg::Submitted));
3259            items
3260        });
3261        let bar =
3262            Widget::<Msg>::selection_toolbar(&field, context(true, false, false)).expect("a bar");
3263        let buttons = bar.children();
3264        let clicks: Vec<Option<Msg>> = buttons.iter().map(|b| b.on_click()).collect();
3265        let actions: Vec<Option<crate::EditAction>> =
3266            buttons.iter().map(|b| b.edit_action()).collect();
3267        assert_eq!(clicks, vec![None, None, None, Some(Msg::Submitted)]);
3268        assert_eq!(
3269            actions,
3270            vec![
3271                Some(crate::EditAction::Cut),
3272                Some(crate::EditAction::Copy),
3273                Some(crate::EditAction::SelectAll),
3274                None
3275            ]
3276        );
3277    }
3278
3279    /// The bar is placed against the **selection's** box, in the field's own coordinates:
3280    /// wider for a longer selection, at the caret (no width) when nothing is selected.
3281    #[test]
3282    fn the_bar_is_anchored_on_the_selection_or_the_caret() {
3283        let field = input("hello world");
3284        let word = Edit {
3285            cursor: 5,
3286            anchor: Some(0),
3287            composing: None,
3288        };
3289        let all = Edit {
3290            cursor: 11,
3291            anchor: Some(0),
3292            composing: None,
3293        };
3294        let caret = Edit {
3295            cursor: 5,
3296            anchor: None,
3297            composing: None,
3298        };
3299        let anchor =
3300            |edit: &Edit| Widget::<Msg>::selection_anchor(&field, 200.0, edit, 0.0).unwrap();
3301        assert!(anchor(&all).width > anchor(&word).width);
3302        assert_eq!(anchor(&caret).width, 0.0);
3303        assert!(anchor(&caret).height > 0.0);
3304        // Where the selection's own handles stand is where its box begins and ends.
3305        let handles = Widget::<Msg>::selection_handles(&field, 200.0, &word, 0.0).unwrap();
3306        let word_box = anchor(&word);
3307        assert!((word_box.x - handles[0].line_center.x).abs() < 0.5);
3308        assert!((word_box.x + word_box.width - handles[1].line_center.x).abs() < 0.5);
3309    }
3310
3311    #[test]
3312    fn enter_submits_without_changing_value() {
3313        let inp = input("acheter du lait").on_submit(Msg::Submitted);
3314        let mut edit = Edit {
3315            cursor: 3,
3316            anchor: None,
3317            composing: None,
3318        };
3319        // Enter: emits the submission, returns no value change.
3320        assert_eq!(inp.on_edit(&mut edit, &Key::Enter), Some(Msg::Submitted));
3321        assert_eq!(edit.cursor, 3); // caret unchanged
3322    }
3323
3324    #[test]
3325    fn enter_without_submit_is_noop() {
3326        let inp = input("x");
3327        let mut edit = Edit {
3328            cursor: 1,
3329            anchor: None,
3330            composing: None,
3331        };
3332        assert_eq!(inp.on_edit(&mut edit, &Key::Enter), None);
3333    }
3334
3335    /// Where the text was actually drawn: the x of the field's own text primitive.
3336    fn drawn_text_x(field: &TextField<()>, width: f32) -> f32 {
3337        let mut scene = Scene::new();
3338        Widget::<()>::paint(
3339            field,
3340            Rect::new(0.0, 0.0, width, 56.0),
3341            Status {
3342                focused: false,
3343                ..Default::default()
3344            },
3345            &Theme::default(),
3346            &mut scene,
3347        );
3348        scene
3349            .primitives()
3350            .iter()
3351            .find_map(|p| match p {
3352                frus_core::Primitive::Text { position, text, .. } if text == "42" => {
3353                    Some(position.x)
3354                }
3355                _ => None,
3356            })
3357            .expect("the value is drawn")
3358    }
3359
3360    /// A field says nothing, and the text starts where it always did.
3361    #[test]
3362    fn a_field_that_says_nothing_starts_at_the_left() {
3363        let plain: TextField<()> = TextField::new("42");
3364        let started: TextField<()> = TextField::new("42").text_align(TextAlign::Start);
3365        assert_eq!(drawn_text_x(&plain, 300.0), drawn_text_x(&started, 300.0));
3366    }
3367
3368    /// Centred and right-aligned text moves, and moves the right way round.
3369    #[test]
3370    fn the_text_sits_where_it_was_told_to() {
3371        let left = drawn_text_x(&TextField::<()>::new("42"), 300.0);
3372        let centre = drawn_text_x(
3373            &TextField::<()>::new("42").text_align(TextAlign::Center),
3374            300.0,
3375        );
3376        let right = drawn_text_x(
3377            &TextField::<()>::new("42").text_align(TextAlign::Right),
3378            300.0,
3379        );
3380        assert!(left < centre, "centred is further right than left-aligned");
3381        assert!(centre < right, "right-aligned is further right again");
3382        // Centred is halfway: the two gaps either side match.
3383        assert!(
3384            ((centre - left) - (right - centre)).abs() < 0.5,
3385            "halfway: {left} {centre} {right}"
3386        );
3387    }
3388
3389    /// **The click has to agree with the paint.** A caret placed from the unaligned
3390    /// geometry would appear several characters from the tap, and it is the kind of
3391    /// wrongness nobody reports precisely — it just feels broken.
3392    #[test]
3393    fn a_click_lands_where_the_glyphs_are() {
3394        let width = 300.0;
3395        for align in [TextAlign::Left, TextAlign::Center, TextAlign::Right] {
3396            let field: TextField<()> = TextField::new("42").text_align(align);
3397            let x = drawn_text_x(&field, width);
3398            // A tap a hair to the left of the first glyph belongs before it\u2026
3399            let before = Widget::<()>::cursor_at(&field, x - 1.0, 20.0, width, 0);
3400            assert_eq!(before, Some(0), "{align:?}: before the first character");
3401            // \u2026and one past the last glyph belongs after it.
3402            let text_w = frus_text::measure("42", field.size).width;
3403            let after = Widget::<()>::cursor_at(&field, x + text_w + 1.0, 20.0, width, 0);
3404            assert_eq!(after, Some(2), "{align:?}: after the last character");
3405        }
3406    }
3407
3408    /// Nothing to distribute, nothing to push. A line wider than its field keeps its
3409    /// left edge, which is where reading starts and where the horizontal scroll brings
3410    /// the caret back to.
3411    #[test]
3412    fn text_wider_than_the_field_is_not_pushed_off_it() {
3413        let long: TextField<()> = TextField::new("42").text_align(TextAlign::Right);
3414        // A box far too narrow for the value: the slack is negative, so the push is nil.
3415        assert_eq!(long.align_offset(4.0, 40.0), 0.0);
3416        assert_eq!(long.align_offset(40.0, 40.0), 0.0);
3417    }
3418
3419    /// A multi-line field stays at the start. Aligning wrapped text means moving each
3420    /// line by its own width, which the caret and the click would have to be told about
3421    /// line by line — that belongs inside the text layout, not in a widget nudging a
3422    /// block sideways behind their backs.
3423    #[test]
3424    fn a_multiline_field_is_not_aligned() {
3425        let note: TextField<()> = TextField::new("42")
3426            .multiline()
3427            .text_align(TextAlign::Center);
3428        assert_eq!(note.align_offset(300.0, 40.0), 0.0);
3429        // The same field on one line would have moved.
3430        let one_line: TextField<()> = TextField::new("42").text_align(TextAlign::Center);
3431        assert_eq!(one_line.align_offset(300.0, 40.0), 130.0);
3432    }
3433
3434    /// The placeholder sits where the text will. A centred field whose hint hugs the
3435    /// left edge jumps the moment the first key lands.
3436    #[test]
3437    fn the_placeholder_is_aligned_like_the_value() {
3438        let hint_x = |align: TextAlign| {
3439            let field: TextField<()> = TextField::new("").placeholder("Amount").text_align(align);
3440            let mut scene = Scene::new();
3441            Widget::<()>::paint(
3442                &field,
3443                Rect::new(0.0, 0.0, 300.0, 56.0),
3444                Status::default(),
3445                &Theme::default(),
3446                &mut scene,
3447            );
3448            scene
3449                .primitives()
3450                .iter()
3451                .find_map(|p| match p {
3452                    frus_core::Primitive::Text { position, text, .. } if text == "Amount" => {
3453                        Some(position.x)
3454                    }
3455                    _ => None,
3456                })
3457                .expect("the hint is drawn")
3458        };
3459        assert!(hint_x(TextAlign::Right) > hint_x(TextAlign::Center));
3460        assert!(hint_x(TextAlign::Center) > hint_x(TextAlign::Left));
3461    }
3462
3463    /// A filtered field drops what it will not take, and keeps the rest of a paste.
3464    #[test]
3465    fn a_filter_drops_what_it_will_not_take() {
3466        let field: TextField<String> = TextField::new("").digits_only().on_input(|v| v);
3467        let mut edit = Edit::default();
3468        // A paste of a formatted number: the digits land, the spaces and dashes do not.
3469        let out = Widget::on_edit(&field, &mut edit, &Key::Text("12 34-56".into()));
3470        assert_eq!(out.as_deref(), Some("123456"));
3471        assert_eq!(edit.cursor, 6, "the caret is past what actually arrived");
3472    }
3473
3474    /// A refused keystroke is not an edit: emitting the value unchanged would rebuild
3475    /// the tree for a key that did nothing.
3476    #[test]
3477    fn a_refused_keystroke_says_nothing() {
3478        let field: TextField<String> = TextField::new("42").digits_only().on_input(|v| v);
3479        let mut edit = Edit {
3480            cursor: 2,
3481            ..Default::default()
3482        };
3483        assert_eq!(
3484            Widget::on_edit(&field, &mut edit, &Key::Text("x".into())),
3485            None
3486        );
3487        assert_eq!(edit.cursor, 2, "and the caret has not moved");
3488    }
3489
3490    /// A **selection** replaced by nothing is still an edit: the selection is gone.
3491    #[test]
3492    fn a_refused_keystroke_over_a_selection_still_clears_it() {
3493        let field: TextField<String> = TextField::new("1234").digits_only().on_input(|v| v);
3494        let mut edit = Edit {
3495            cursor: 3,
3496            anchor: Some(1),
3497            ..Default::default()
3498        };
3499        let out = Widget::on_edit(&field, &mut edit, &Key::Text("x".into()));
3500        assert_eq!(out.as_deref(), Some("14"));
3501        assert_eq!(edit.cursor, 1);
3502    }
3503
3504    /// A filter can **substitute** as well as drop, and the caret arithmetic is the same
3505    /// because a substituted character takes the place of the one typed.
3506    #[test]
3507    fn a_filter_can_substitute() {
3508        let field: TextField<String> = TextField::new("")
3509            .input_filter(|c| c.to_uppercase().next())
3510            .on_input(|v| v);
3511        let mut edit = Edit::default();
3512        let out = Widget::on_edit(&field, &mut edit, &Key::Text("ab".into()));
3513        assert_eq!(out.as_deref(), Some("AB"));
3514        assert_eq!(edit.cursor, 2);
3515    }
3516
3517    /// A value the **caller** supplied is left alone: it is the application's state, not
3518    /// something typed.
3519    #[test]
3520    fn a_filter_does_not_rewrite_what_the_caller_gave() {
3521        let field: TextField<String> = TextField::new("a1b2").digits_only().on_input(|v| v);
3522        let mut edit = Edit {
3523            cursor: 4,
3524            ..Default::default()
3525        };
3526        let out = Widget::on_edit(&field, &mut edit, &Key::Text("3".into()));
3527        assert_eq!(
3528            out.as_deref(),
3529            Some("a1b23"),
3530            "only the keystroke is filtered"
3531        );
3532    }
3533
3534    /// Digits ask for a keypad, and a keyboard named either side of it stands.
3535    #[test]
3536    fn digits_only_asks_for_a_keypad_without_overruling_one() {
3537        let plain: TextField<()> = TextField::new("").digits_only();
3538        assert_eq!(Widget::<()>::ime(&plain).keyboard, KeyboardType::Number);
3539        let before: TextField<()> = TextField::new("")
3540            .keyboard_type(KeyboardType::Phone)
3541            .digits_only();
3542        assert_eq!(Widget::<()>::ime(&before).keyboard, KeyboardType::Phone);
3543        let after: TextField<()> = TextField::new("")
3544            .digits_only()
3545            .keyboard_type(KeyboardType::Phone);
3546        assert_eq!(Widget::<()>::ime(&after).keyboard, KeyboardType::Phone);
3547    }
3548
3549    /// The capitalisation reaches the keyboard the field asks for.
3550    #[test]
3551    fn the_capitalisation_travels_with_the_field() {
3552        let field: TextField<()> = TextField::new("").capitalization(Capitalization::Characters);
3553        let ime = Widget::<()>::ime(&field);
3554        assert_eq!(ime.capitalization, Capitalization::Characters);
3555        assert_eq!(ime.android_input_type() & 0x0000_7000, 0x0000_1000);
3556    }
3557
3558    /// A plain field asks for the plain keyboard, which is what every field used to
3559    /// get whether it suited or not.
3560    #[test]
3561    fn a_plain_field_asks_for_the_plain_keyboard() {
3562        let field: TextField<()> = TextField::new("hello");
3563        assert_eq!(
3564            Widget::<()>::ime(&field),
3565            Ime {
3566                keyboard: KeyboardType::Text,
3567                action: TextInputAction::Done,
3568                capitalization: Capitalization::Auto,
3569            }
3570        );
3571    }
3572
3573    /// **A masked field is a secret, and the dots cannot say so.** `obscure` draws
3574    /// dots on our side; the keyboard, told nothing, treated the field as ordinary
3575    /// prose — learning the password into its personal dictionary and offering it
3576    /// back as a suggestion later, on whatever screen came next.
3577    #[test]
3578    fn an_obscured_field_tells_the_keyboard_it_is_a_secret() {
3579        let field: TextField<()> = TextField::new("hunter2").obscure(true);
3580        let ime = Widget::<()>::ime(&field);
3581        assert_eq!(ime.keyboard, KeyboardType::Password);
3582        assert!(ime.keyboard.is_secret());
3583        // TYPE_TEXT_VARIATION_PASSWORD, which is what stops the learning.
3584        assert_eq!(ime.keyboard.android_input_type() & 0x0000_0ff0, 0x80);
3585    }
3586
3587    /// A field that takes several lines needs the action key to **insert** one.
3588    /// *Done* there is a keyboard that cannot type what the field is for.
3589    #[test]
3590    fn a_multiline_field_takes_a_newline_key() {
3591        let field: TextField<()> = TextField::new("a note").multiline();
3592        let ime = Widget::<()>::ime(&field);
3593        assert_eq!(ime.keyboard, KeyboardType::Multiline);
3594        assert_eq!(ime.action, TextInputAction::Newline);
3595        // `rows` implies multiline, so it implies the same keyboard.
3596        let rowed: TextField<()> = TextField::new("a note").rows(4);
3597        assert_eq!(Widget::<()>::ime(&rowed).keyboard, KeyboardType::Multiline);
3598    }
3599
3600    /// What the caller says beats what the field guessed — both halves, separately.
3601    #[test]
3602    fn what_the_caller_says_wins() {
3603        let field: TextField<()> = TextField::new("")
3604            .obscure(true)
3605            .keyboard_type(KeyboardType::VisiblePassword);
3606        assert_eq!(
3607            Widget::<()>::ime(&field).keyboard,
3608            KeyboardType::VisiblePassword
3609        );
3610
3611        // A search box asks for Search without ceasing to be a text field.
3612        let query: TextField<()> = TextField::new("").action(TextInputAction::Search);
3613        let ime = Widget::<()>::ime(&query);
3614        assert_eq!(ime.keyboard, KeyboardType::Text);
3615        assert_eq!(ime.action, TextInputAction::Search);
3616
3617        // And a multi-line field can still be told to say Done.
3618        let note: TextField<()> = TextField::new("").multiline().action(TextInputAction::Done);
3619        assert_eq!(Widget::<()>::ime(&note).action, TextInputAction::Done);
3620    }
3621
3622    /// A field that says what it is gets the keyboard for it — a keypad for a phone
3623    /// number, an `@` for an address.
3624    #[test]
3625    fn a_typed_field_gets_the_keyboard_for_it() {
3626        let phone: TextField<()> = TextField::new("").keyboard_type(KeyboardType::Phone);
3627        // TYPE_CLASS_PHONE
3628        assert_eq!(Widget::<()>::ime(&phone).keyboard.android_input_type(), 3);
3629
3630        let email: TextField<()> = TextField::new("").keyboard_type(KeyboardType::Email);
3631        let bits = Widget::<()>::ime(&email).keyboard.android_input_type();
3632        // TYPE_CLASS_TEXT | TYPE_TEXT_VARIATION_EMAIL_ADDRESS, and no capitalisation:
3633        // `Someone@` is an address that does not work.
3634        assert_eq!(bits, 1 | 0x20);
3635    }
3636
3637    #[test]
3638    fn obscure_masks_the_displayed_text_but_not_the_value() {
3639        // A password field: the render never contains the value in the clear, only dots;
3640        // the real value stays reachable (editing, IME).
3641        let theme = Theme::default();
3642        let field = TextField::<Msg>::new("secret").obscure(true);
3643        let mut scene = Scene::new();
3644        let status = Status {
3645            focused: true,
3646            cursor: Some(6),
3647            ..Default::default()
3648        };
3649        Widget::<Msg>::paint(
3650            &field,
3651            Rect::new(0.0, 0.0, 220.0, 30.0),
3652            status,
3653            &theme,
3654            &mut scene,
3655        );
3656        let drawn: Vec<&str> = scene
3657            .primitives()
3658            .iter()
3659            .filter_map(|p| match p {
3660                frus_core::Primitive::Text { text, .. } => Some(text.as_str()),
3661                _ => None,
3662            })
3663            .collect();
3664        assert!(
3665            drawn.iter().all(|t| !t.contains("secret")),
3666            "the value must not leak"
3667        );
3668        assert!(
3669            drawn.iter().any(|t| t.chars().all(|c| c == '•')),
3670            "dots are drawn"
3671        );
3672        // The real value stays exposed to the input context.
3673        assert_eq!(Widget::<Msg>::text_value(&field), Some("secret"));
3674    }
3675
3676    #[test]
3677    fn prefix_icon_draws_a_path_and_shifts_the_hit_test() {
3678        // A prefix icon draws a path and offsets the content to the right: the same
3679        // click lands on a smaller index than it would without one.
3680        let theme = Theme::default();
3681        let with_icon = input("hello world").prefix_icon(Icons::STAR);
3682        let mut scene = Scene::new();
3683        Widget::<Msg>::paint(
3684            &with_icon,
3685            Rect::new(0.0, 0.0, 220.0, 30.0),
3686            Status::default(),
3687            &theme,
3688            &mut scene,
3689        );
3690        assert!(
3691            scene
3692                .primitives()
3693                .iter()
3694                .any(|p| matches!(p, frus_core::Primitive::Path { .. })),
3695            "the prefix icon draws a path"
3696        );
3697        let plain = input("hello world");
3698        let at_plain =
3699            Widget::<Msg>::cursor_at(&plain, 60.0, FIELD_OUTLINED_PADDING_TOP, 220.0, 0).unwrap();
3700        let at_icon =
3701            Widget::<Msg>::cursor_at(&with_icon, 60.0, FIELD_OUTLINED_PADDING_TOP, 220.0, 0)
3702                .unwrap();
3703        assert!(
3704            at_icon < at_plain,
3705            "the prefix offsets the content ({at_plain} → {at_icon})"
3706        );
3707    }
3708
3709    #[test]
3710    fn multiline_enter_inserts_a_newline_instead_of_submitting() {
3711        // In multi-line mode, Enter inserts a "\n" (and emits no submission).
3712        let inp = TextField::<Msg>::new("ab")
3713            .on_input(Msg::Changed)
3714            .multiline();
3715        let mut edit = Edit {
3716            cursor: 2,
3717            anchor: None,
3718            composing: None,
3719        };
3720        assert_eq!(
3721            inp.on_edit(&mut edit, &Key::Enter),
3722            Some(Msg::Changed("ab\n".to_string()))
3723        );
3724        assert_eq!(edit.cursor, 3);
3725        // Single line: Enter submits (unchanged behaviour).
3726        let single = input("ab").on_submit(Msg::Submitted);
3727        let mut edit = Edit {
3728            cursor: 2,
3729            anchor: None,
3730            composing: None,
3731        };
3732        assert_eq!(single.on_edit(&mut edit, &Key::Enter), Some(Msg::Submitted));
3733    }
3734
3735    #[test]
3736    fn multiline_wraps_long_lines_to_the_width() {
3737        // A long line **without** any `\n` wraps onto several visual lines: a click well
3738        // below the 1st line places the caret further into the text (a wrapped line under
3739        // the first one), not at index 0.
3740        let long = "word ".repeat(30); // 150 characters, no explicit break
3741        let inp = TextField::<Msg>::new(long.trim_end())
3742            .on_input(Msg::Changed)
3743            .rows(4)
3744            .width(160.0);
3745        let line_h = inp.text_style().line_height();
3746        let top = Widget::<Msg>::cursor_at(
3747            &inp,
3748            FIELD_PADDING_X + 2.0,
3749            FIELD_OUTLINED_PADDING_TOP + 1.0,
3750            160.0,
3751            0,
3752        )
3753        .unwrap();
3754        let wrapped = Widget::<Msg>::cursor_at(
3755            &inp,
3756            FIELD_PADDING_X + 2.0,
3757            FIELD_OUTLINED_PADDING_TOP + line_h * 2.0 + 1.0,
3758            160.0,
3759            0,
3760        )
3761        .unwrap();
3762        assert!(top < 10, "1st line: {top}");
3763        assert!(
3764            wrapped > top,
3765            "a wrapped line further down → an index further on ({top} → {wrapped})"
3766        );
3767    }
3768
3769    #[test]
3770    fn multiline_reports_overflow_and_scrolls_content() {
3771        // Five lines in a box of two: the content overflows…
3772        let inp = TextField::<Msg>::new("l1\nl2\nl3\nl4\nl5")
3773            .on_input(Msg::Changed)
3774            .rows(2)
3775            .width(200.0);
3776        let (content_h, visible_h, _, _) =
3777            Widget::<Msg>::text_metrics(&inp, 200.0, 0).expect("a multi-line field");
3778        assert!(
3779            content_h > visible_h + 1.0,
3780            "5 lines > a box of 2 ({content_h} vs {visible_h})"
3781        );
3782
3783        // …and a retained scroll shifts the text upwards (a smaller position.y).
3784        let text_top = |scroll: f32| {
3785            let mut scene = Scene::new();
3786            let status = Status {
3787                focused: true,
3788                cursor: Some(0),
3789                scroll_y: scroll,
3790                ..Default::default()
3791            };
3792            Widget::<Msg>::paint(
3793                &inp,
3794                Rect::new(0.0, 0.0, 200.0, 80.0),
3795                status,
3796                &Theme::default(),
3797                &mut scene,
3798            );
3799            scene
3800                .primitives()
3801                .iter()
3802                .find_map(|p| match p {
3803                    frus_core::Primitive::Text { text, position, .. } if text.contains("l1") => {
3804                        Some(position.y)
3805                    }
3806                    _ => None,
3807                })
3808                .expect("the field's text")
3809        };
3810        assert!(
3811            text_top(20.0) < text_top(0.0),
3812            "scrolling raises the content"
3813        );
3814    }
3815
3816    #[test]
3817    fn multiline_arrows_move_the_caret_between_lines() {
3818        // "abc\ndefg\nhi": line 0 = indices 0..3, line 1 = 4..8, line 2 = 9..11.
3819        let inp = TextField::<Msg>::new("abc\ndefg\nhi")
3820            .on_input(Msg::Changed)
3821            .rows(3)
3822            .width(200.0);
3823        // From the 1st line (index 1), moving down lands on the 2nd line.
3824        let (down, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 1, true, false, None).unwrap();
3825        assert!(
3826            (4..=8).contains(&down),
3827            "moves down to the 2nd line: {down}"
3828        );
3829        // 1st line, moving up → impossible (the shell moves the focus).
3830        assert_eq!(
3831            Widget::<Msg>::caret_vertical(&inp, 200.0, 1, false, false, None),
3832            None
3833        );
3834        // Last line (index 10), moving down → impossible.
3835        assert_eq!(
3836            Widget::<Msg>::caret_vertical(&inp, 200.0, 10, true, false, None),
3837            None
3838        );
3839        // 2nd line (index 5), moving up lands on the 1st.
3840        let (up, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 5, false, false, None).unwrap();
3841        assert!(up <= 3, "moves up to the 1st line: {up}");
3842        // A single-line field: never any vertical movement.
3843        assert_eq!(
3844            Widget::<Msg>::caret_vertical(&input("abc"), 200.0, 1, true, false, None),
3845            None
3846        );
3847    }
3848
3849    #[test]
3850    fn multiline_goal_column_survives_a_short_line() {
3851        // "hello\nhi\nworld": line 0 = 0..5, line 1 = 6..8 (short), line 2 = 9..14.
3852        // Starting from column 5 (the end of "hello") and moving down: the 2nd line "hi"
3853        // is too short (so the caret is clamped to its end), but the goal column returned
3854        // stays ~the starting one, so moving down again lands back on column 5.
3855        let inp = TextField::<Msg>::new("hello\nhi\nworld")
3856            .on_input(Msg::Changed)
3857            .rows(3)
3858            .width(200.0);
3859        let (mid, goal) = Widget::<Msg>::caret_vertical(&inp, 200.0, 5, true, false, None).unwrap();
3860        assert!(
3861            (6..=8).contains(&mid),
3862            "a short line, clamped to its end: {mid}"
3863        );
3864        // A second jump reusing the goal column: without memory the caret would have
3865        // stayed clamped to the end of "hi" (col. ~2); here it lands far into "world"
3866        // (col. ~5), proving the original column survives the short line.
3867        let (low, _) =
3868            Widget::<Msg>::caret_vertical(&inp, 200.0, mid, true, false, Some(goal)).unwrap();
3869        assert!(
3870            (12..=14).contains(&low),
3871            "goal column preserved into \"world\": {low}"
3872        );
3873    }
3874
3875    #[test]
3876    fn multiline_page_jump_is_clamped_to_the_field() {
3877        // Page up/down never leave the multi-line field: at the bounds the caret settles
3878        // at the start / at the end and returns `Some` (not `None` → the field is kept).
3879        let inp = TextField::<Msg>::new("a\nb\nc\nd\ne")
3880            .on_input(Msg::Changed)
3881            .rows(2)
3882            .width(200.0);
3883        // From the last line, PgDn settles at the bottom (the field is not left).
3884        let (bottom, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 8, true, true, None).unwrap();
3885        assert!(
3886            bottom >= 7,
3887            "PgDn clamped to the bottom of the field: {bottom}"
3888        );
3889        // From the 1st line, PgUp settles at the top.
3890        let (top, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 0, false, true, None).unwrap();
3891        assert!(top <= 1, "PgUp clamped to the top of the field: {top}");
3892    }
3893
3894    #[test]
3895    fn multiline_reserves_rows_of_height() {
3896        // `rows(n)` reserves n lines; that is taller than a one-line field.
3897        let one = match Widget::<Msg>::style(&input("x")).height {
3898            Dimension::Length(h) => h,
3899            _ => panic!("a fixed height"),
3900        };
3901        let four = match Widget::<Msg>::style(&TextField::<Msg>::new("x").rows(4)).height {
3902            Dimension::Length(h) => h,
3903            _ => panic!("a fixed height"),
3904        };
3905        assert!(
3906            four > one * 2.0,
3907            "4 lines much taller than one ({one} → {four})"
3908        );
3909    }
3910
3911    #[test]
3912    fn multiline_hit_test_uses_the_click_line() {
3913        // A click on the 2nd line places the caret inside "cd" (indices ≥ 3), not inside
3914        // the "ab" of the 1st line.
3915        let inp = TextField::<Msg>::new("ab\ncd")
3916            .on_input(Msg::Changed)
3917            .rows(3);
3918        let line_h = inp.text_style().line_height();
3919        // A top-left click → the 1st line (index ≤ 2).
3920        let top = Widget::<Msg>::cursor_at(
3921            &inp,
3922            FIELD_PADDING_X + 1.0,
3923            FIELD_OUTLINED_PADDING_TOP + 1.0,
3924            220.0,
3925            0,
3926        )
3927        .unwrap();
3928        // A click one line lower → the 2nd line (index ≥ 3).
3929        let below = Widget::<Msg>::cursor_at(
3930            &inp,
3931            FIELD_PADDING_X + 1.0,
3932            FIELD_OUTLINED_PADDING_TOP + line_h + 1.0,
3933            220.0,
3934            0,
3935        )
3936        .unwrap();
3937        assert!(top <= 2, "1st line: {top}");
3938        assert!(below >= 3, "2nd line: {below}");
3939    }
3940
3941    #[test]
3942    fn floating_label_rests_in_box_then_floats_up() {
3943        // The label rests inside the box (large, low) while the field is empty and
3944        // unfocused, and floats above it (small, high) once focused.
3945        let theme = Theme::default();
3946        let field = TextField::<Msg>::new("").label("Name");
3947        let label_geo = |status: Status| -> (f32, f32) {
3948            let mut scene = Scene::new();
3949            Widget::<Msg>::paint(
3950                &field,
3951                Rect::new(0.0, 30.0, 220.0, 60.0),
3952                status,
3953                &theme,
3954                &mut scene,
3955            );
3956            scene
3957                .primitives()
3958                .iter()
3959                .find_map(|p| match p {
3960                    frus_core::Primitive::Text {
3961                        text,
3962                        size,
3963                        position,
3964                        ..
3965                    } if text == "Name" => Some((*size, position.y)),
3966                    _ => None,
3967                })
3968                .expect("a painted label")
3969        };
3970        let (rest_size, rest_y) = label_geo(Status::default());
3971        let (float_size, float_y) = label_geo(Status {
3972            focused: true,
3973            focus_progress: 1.0,
3974            ..Default::default()
3975        });
3976        assert!(
3977            rest_size > float_size,
3978            "at rest the label is larger ({rest_size} → {float_size})"
3979        );
3980        assert!(
3981            float_y < rest_y,
3982            "once focused the label rises ({rest_y} → {float_y})"
3983        );
3984    }
3985
3986    /// **An errored field deepens under the pointer** (milestone 439).
3987    ///
3988    /// `error` at rest, `on_error_container` while hovered, and `error` again once
3989    /// focused — the reference tests focus **before** hover
3990    /// (`input_decorator.dart:5977`), because a focused field is already saying
3991    /// everything it can. The message below it does not move: `errorStyle` is `error` in
3992    /// every state (`:6100`), it being a sentence rather than a control.
3993    ///
3994    /// It is the first thing here to ask the scheme for `on_error_container`, which
3995    /// arrived in milestone 429 with nothing wanting it.
3996    #[test]
3997    fn an_errored_field_deepens_under_the_pointer() {
3998        let theme = Theme::default();
3999        let field = input("x").label("Name").error("Required").outlined();
4000        let painted = |status: Status| {
4001            let mut scene = Scene::new();
4002            Widget::<Msg>::paint(
4003                &field,
4004                Rect::new(0.0, 0.0, 220.0, 90.0),
4005                status,
4006                &theme,
4007                &mut scene,
4008            );
4009            scene
4010        };
4011        // The border is the only stroked rectangle a field paints.
4012        let border = |status: Status| {
4013            painted(status)
4014                .primitives()
4015                .iter()
4016                .find_map(|p| match p {
4017                    frus_core::Primitive::Rect {
4018                        border_color,
4019                        border_width,
4020                        ..
4021                    } if *border_width > 0.0 => Some(*border_color),
4022                    _ => None,
4023                })
4024                .expect("an outlined field draws a border")
4025        };
4026        let message = |status: Status| {
4027            painted(status)
4028                .primitives()
4029                .iter()
4030                .find_map(|p| match p {
4031                    frus_core::Primitive::Text { text, color, .. } if text == "Required" => {
4032                        Some(*color)
4033                    }
4034                    _ => None,
4035                })
4036                .expect("the message is painted")
4037        };
4038        let rest = Status {
4039            opacity: 1.0,
4040            ..Default::default()
4041        };
4042        let hovered = Status {
4043            hover_progress: 1.0,
4044            ..rest
4045        };
4046        let both = Status {
4047            focused: true,
4048            focus_progress: 1.0,
4049            ..hovered
4050        };
4051
4052        assert_eq!(border(rest), theme.scheme.error);
4053        assert_eq!(
4054            border(hovered),
4055            theme.scheme.on_error_container,
4056            "it deepens under the pointer"
4057        );
4058        assert_eq!(
4059            border(both),
4060            theme.scheme.error,
4061            "and comes back once focused, which the reference tests first"
4062        );
4063        assert_ne!(
4064            theme.scheme.error, theme.scheme.on_error_container,
4065            "the two have to differ for any of the above to mean anything"
4066        );
4067
4068        for status in [rest, hovered, both] {
4069            assert_eq!(
4070                message(status),
4071                theme.scheme.error,
4072                "the message is a sentence, not a control"
4073            );
4074        }
4075    }
4076
4077    #[test]
4078    fn decoration_grows_height_for_label_and_error() {
4079        // A bare field reserves only the box; a label + an error add one line above and
4080        // one below → the style is taller.
4081        let bare_h = match Widget::<Msg>::style(&input("x")).height {
4082            Dimension::Length(h) => h,
4083            _ => panic!("a fixed height was expected"),
4084        };
4085        let decorated = input("x").label("Email").error("Required");
4086        let deco_h = match Widget::<Msg>::style(&decorated).height {
4087            Dimension::Length(h) => h,
4088            _ => panic!("a fixed height was expected"),
4089        };
4090        assert!(
4091            deco_h > bare_h,
4092            "label + error grow the field ({bare_h} → {deco_h})"
4093        );
4094    }
4095
4096    #[test]
4097    fn error_paints_the_border_in_the_error_color() {
4098        // In error, the box's border switches to the theme's error colour.
4099        let theme = Theme::default();
4100        let field = input("x").error("bad");
4101        let mut scene = Scene::new();
4102        Widget::<Msg>::paint(
4103            &field,
4104            Rect::new(0.0, 0.0, 220.0, 60.0),
4105            Status::default(),
4106            &theme,
4107            &mut scene,
4108        );
4109        let has_error_border = scene.primitives().iter().any(|p| {
4110            matches!(
4111                p,
4112                frus_core::Primitive::Rect { border_color, border_width, .. }
4113                    if *border_width > 0.0 && *border_color == theme.error
4114            )
4115        });
4116        assert!(has_error_border, "the border must be in the error colour");
4117    }
4118
4119    #[test]
4120    fn placeholder_shows_only_when_empty() {
4121        // The hint only appears while the value is empty.
4122        let theme = Theme::default();
4123        let count_texts = |value: &str| {
4124            let field = TextField::<Msg>::new(value).placeholder("Type here");
4125            let mut scene = Scene::new();
4126            Widget::<Msg>::paint(
4127                &field,
4128                Rect::new(0.0, 0.0, 220.0, 30.0),
4129                Status::default(),
4130                &theme,
4131                &mut scene,
4132            );
4133            scene
4134                .primitives()
4135                .iter()
4136                .filter(
4137                    |p| matches!(p, frus_core::Primitive::Text { text, .. } if text == "Type here"),
4138                )
4139                .count()
4140        };
4141        assert_eq!(count_texts(""), 1, "empty: the hint shows");
4142        assert_eq!(count_texts("hi"), 0, "rempli : pas d'indice");
4143    }
4144
4145    #[test]
4146    fn clickable_suffix_emits_and_blocks_caret() {
4147        let field = TextField::new("hello")
4148            .suffix_icon(Icons::CLOSE)
4149            .on_suffix(Msg::Submitted)
4150            .width(220.0);
4151        let (w, y) = (220.0, 12.0);
4152        let x_suffix = w - 8.0; // near the right edge (the suffix's zone)
4153
4154        // A click on the suffix: emits the message, and does NOT place a caret.
4155        assert_eq!(
4156            Widget::<Msg>::positional_click(&field, x_suffix, y, w, 40.0),
4157            Some(Msg::Submitted)
4158        );
4159        assert_eq!(Widget::<Msg>::cursor_at(&field, x_suffix, y, w, 0), None);
4160        // A click in the body: no suffix, a caret is placed.
4161        assert_eq!(
4162            Widget::<Msg>::positional_click(&field, 20.0, y, w, 40.0),
4163            None
4164        );
4165        assert!(Widget::<Msg>::cursor_at(&field, 20.0, y, w, 0).is_some());
4166        // Without `on_suffix` the icon stays decorative (no positional click).
4167        let deco = TextField::<Msg>::new("hello")
4168            .suffix_icon(Icons::CLOSE)
4169            .width(220.0);
4170        assert_eq!(
4171            Widget::<Msg>::positional_click(&deco, x_suffix, y, w, 40.0),
4172            None
4173        );
4174    }
4175
4176    #[test]
4177    fn hovering_active_suffix_paints_a_halo() {
4178        let field = TextField::new("hello")
4179            .suffix_icon(Icons::CLOSE)
4180            .on_suffix(Msg::Submitted)
4181            .width(220.0);
4182        let bounds = Rect::new(0.0, 0.0, 220.0, 40.0);
4183        // Counts the ~28x28 rectangles (the halo behind the suffix icon).
4184        let halos = |status: Status| {
4185            let mut scene = Scene::new();
4186            Widget::<Msg>::paint(&field, bounds, status, &Theme::default(), &mut scene);
4187            scene
4188                .primitives()
4189                .iter()
4190                .filter(|p| match p {
4191                    frus_core::Primitive::Rect { rect, .. } => {
4192                        (rect.width - (FIELD_ICON_SIZE + 8.0)).abs() < 0.5
4193                            && (rect.height - (FIELD_ICON_SIZE + 8.0)).abs() < 0.5
4194                    }
4195                    _ => false,
4196                })
4197                .count()
4198        };
4199        // Pointer over the suffix → one halo; elsewhere (the body) or without hover → none.
4200        let over_suffix = Status {
4201            hover_cursor: Some(Point::new(212.0, 20.0)),
4202            ..Default::default()
4203        };
4204        let over_body = Status {
4205            hover_cursor: Some(Point::new(20.0, 20.0)),
4206            ..Default::default()
4207        };
4208        assert_eq!(halos(over_suffix), 1, "a halo on the hovered suffix");
4209        assert_eq!(halos(over_body), 0, "no halo in the body");
4210        assert_eq!(halos(Status::default()), 0, "no halo without hover");
4211    }
4212
4213    #[test]
4214    fn cursor_icon_is_pointer_over_active_suffix() {
4215        use crate::interaction::Cursor;
4216        let field = TextField::new("hello")
4217            .suffix_icon(Icons::CLOSE)
4218            .on_suffix(Msg::Submitted)
4219            .width(220.0);
4220        let (w, h, y) = (220.0, 40.0, 12.0);
4221        // A hand over the suffix, nothing in the body.
4222        assert_eq!(
4223            Widget::<Msg>::cursor_icon(&field, w - 8.0, y, w, h),
4224            Some(Cursor::Pointer)
4225        );
4226        assert_eq!(Widget::<Msg>::cursor_icon(&field, 20.0, y, w, h), None);
4227        // A decorative suffix (no on_suffix): no hand.
4228        let deco = TextField::<Msg>::new("hello")
4229            .suffix_icon(Icons::CLOSE)
4230            .width(220.0);
4231        assert_eq!(Widget::<Msg>::cursor_icon(&deco, w - 8.0, y, w, h), None);
4232    }
4233
4234    /// **Each handle stands for its own end of the selection** (milestone 511): the point
4235    /// it carries, handed back to the field's own hit test, is that end — with a label and
4236    /// an icon moving the text in, and with text scrolled sideways, which is where a
4237    /// handle and its end would part company first. The shell drags a handle through
4238    /// exactly this round trip.
4239    #[test]
4240    fn each_handle_stands_for_its_own_end_of_the_selection() {
4241        let width = 220.0;
4242        let fields = [
4243            TextField::<Msg>::new("hello world").width(width),
4244            TextField::<Msg>::new("hello world")
4245                .label("Name")
4246                .prefix_icon(Icons::CLOSE)
4247                .width(width),
4248            // Outlined, the label sits on the border and the box starts below it.
4249            TextField::<Msg>::new("hello world")
4250                .label("Name")
4251                .outlined()
4252                .width(width),
4253            // Wider than its box, the caret at the far end: scrolled.
4254            TextField::<Msg>::new("a sentence long enough to be scrolled along its box")
4255                .width(width),
4256        ];
4257        for field in &fields {
4258            let len = field.value.chars().count();
4259            let edit = Edit {
4260                cursor: len,
4261                anchor: Some(len - 5),
4262                composing: None,
4263            };
4264            let [start, end] = Widget::<Msg>::selection_handles(field, width, &edit, 0.0)
4265                .expect("a selection has handles");
4266            for (handle, index) in [(start, len - 5), (end, len)] {
4267                let at = handle.line_center;
4268                assert_eq!(
4269                    Widget::<Msg>::cursor_at(field, at.x, at.y, width, edit.cursor),
4270                    Some(index),
4271                    "{:?}",
4272                    field.value
4273                );
4274            }
4275            // Each hangs below its line and points in at the text: the corner the start
4276            // one has squared off is its position, and the end one's likewise.
4277            assert_eq!(start.rect.x + start.rect.width, start.line_center.x);
4278            assert_eq!(end.rect.x, end.line_center.x);
4279            assert!(start.rect.y > start.line_center.y && end.rect.y > end.line_center.y);
4280            // And on the line the paint draws: the text's top, the line's middle, then the
4281            // handle hanging from its bottom. A single line's hit test clamps the height, so
4282            // only the paint can say the handles are at the right one.
4283            let mut scene = Scene::new();
4284            let status = Status {
4285                focused: true,
4286                cursor: Some(len),
4287                selection: Some((len - 5, len)),
4288                ..Status::default()
4289            };
4290            Widget::<Msg>::paint(
4291                field,
4292                Rect::new(0.0, 0.0, width, 120.0),
4293                status,
4294                &Theme::default(),
4295                &mut scene,
4296            );
4297            let top = scene
4298                .primitives()
4299                .iter()
4300                .find_map(|p| match p {
4301                    frus_core::Primitive::Text { position, text, .. } if *text == field.value => {
4302                        Some(position.y)
4303                    }
4304                    _ => None,
4305                })
4306                .expect("the value is painted");
4307            let half = start.line_center.y - top;
4308            assert!(
4309                half > 0.0 && (start.rect.y - top - 2.0 * half).abs() < 0.01,
4310                "{:?}: text at {top}, line centre at {}, handle at {}",
4311                field.value,
4312                start.line_center.y,
4313                start.rect.y
4314            );
4315        }
4316    }
4317
4318    /// A multi-line field scrolled down carries its handles up with the text, and the
4319    /// shell, folding the scroll back in as it does for a press, lands on the same ends.
4320    #[test]
4321    fn a_handle_rises_with_the_lines_a_field_scrolls_away() {
4322        let width = 200.0;
4323        let field = TextField::<Msg>::new("one\ntwo\nthree\nfour\nfive\nsix")
4324            .multiline()
4325            .rows(3)
4326            .width(width);
4327        // "five".
4328        let edit = Edit {
4329            cursor: 23,
4330            anchor: Some(19),
4331            composing: None,
4332        };
4333        let scroll = 20.0;
4334        let still = Widget::<Msg>::selection_handles(&field, width, &edit, 0.0).unwrap();
4335        let [start, end] = Widget::<Msg>::selection_handles(&field, width, &edit, scroll).unwrap();
4336        assert!((still[0].line_center.y - start.line_center.y - scroll).abs() < 0.01);
4337        for (handle, index) in [(start, 19), (end, 23)] {
4338            let at = handle.line_center;
4339            assert_eq!(
4340                Widget::<Msg>::cursor_at(&field, at.x, at.y + scroll, width, edit.cursor),
4341                Some(index)
4342            );
4343        }
4344    }
4345
4346    /// Handles are painted for a selection the shell marks as a touch one, and for no
4347    /// other: not for one made with a mouse, not in a field that has lost the focus. In
4348    /// the scheme's primary, unless the field says otherwise.
4349    #[test]
4350    fn the_handles_are_painted_for_a_touch_selection_only() {
4351        let theme = Theme::default();
4352        let fills = |field: &TextField<Msg>, handles: bool, focused: bool| {
4353            let mut scene = Scene::new();
4354            let status = Status {
4355                focused,
4356                cursor: Some(11),
4357                selection: Some((6, 11)),
4358                handles,
4359                ..Status::default()
4360            };
4361            Widget::<Msg>::paint(
4362                field,
4363                Rect::new(10.0, 20.0, 220.0, 56.0),
4364                status,
4365                &theme,
4366                &mut scene,
4367            );
4368            scene
4369                .primitives()
4370                .iter()
4371                .filter_map(|p| match p {
4372                    frus_core::Primitive::Path { fill: Some(c), .. } => Some(*c),
4373                    _ => None,
4374                })
4375                .collect::<Vec<_>>()
4376        };
4377        let field = TextField::<Msg>::new("hello world").width(220.0);
4378        assert_eq!(fills(&field, true, true), vec![theme.scheme.primary; 2]);
4379        assert!(fills(&field, false, true).is_empty(), "a mouse selection");
4380        assert!(fills(&field, true, false).is_empty(), "a field left behind");
4381        let styled = TextField::<Msg>::new("hello world")
4382            .width(220.0)
4383            .style(TextFieldStyle {
4384                handle_color: Some(frus_core::Color::WHITE),
4385                ..TextFieldStyle::default()
4386            });
4387        assert_eq!(fills(&styled, true, true), vec![frus_core::Color::WHITE; 2]);
4388    }
4389
4390    /// The caret's hidden half paints no caret, and its shown half does (milestone 513).
4391    #[test]
4392    fn a_hidden_caret_is_not_painted() {
4393        let theme = Theme::default();
4394        let carets = |hidden: bool| {
4395            let mut scene = Scene::new();
4396            let status = Status {
4397                focused: true,
4398                cursor: Some(2),
4399                caret_hidden: hidden,
4400                ..Status::default()
4401            };
4402            Widget::<Msg>::paint(
4403                &TextField::<Msg>::new("hello").width(220.0),
4404                Rect::new(0.0, 0.0, 220.0, 56.0),
4405                status,
4406                &theme,
4407                &mut scene,
4408            );
4409            scene
4410                .primitives()
4411                .iter()
4412                .filter(
4413                    |p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.width == 2.0),
4414                )
4415                .count()
4416        };
4417        assert_eq!(carets(false), 1, "shown");
4418        assert_eq!(carets(true), 0, "hidden");
4419    }
4420
4421    #[test]
4422    fn word_at_finds_word_bounds() {
4423        let inp = input("hello there world");
4424        // Any index inside a word yields that word's bounds; separators are excluded.
4425        assert_eq!(inp.word_at(2), Some((0, 5))); // "hello"
4426        assert_eq!(inp.word_at(13), Some((12, 17))); // "world"
4427    }
4428}
4429
4430#[cfg(test)]
4431mod limit_tests {
4432    use super::*;
4433    use crate::runtime::Edit;
4434
4435    #[derive(Clone, Debug, PartialEq)]
4436    enum Msg {
4437        Changed(String),
4438    }
4439
4440    fn field(value: &str) -> TextField<Msg> {
4441        TextField::new(value).on_input(Msg::Changed)
4442    }
4443
4444    /// Types `text` at the end of the field's value and returns what came back.
4445    fn typed(field: &TextField<Msg>, text: &str) -> (Option<Msg>, Edit) {
4446        let end = field.value.chars().count();
4447        let mut edit = Edit {
4448            cursor: end,
4449            anchor: None,
4450            ..Default::default()
4451        };
4452        let msg = Widget::on_edit(field, &mut edit, &Key::Text(text.to_string()));
4453        (msg, edit)
4454    }
4455
4456    fn value_of(msg: Option<Msg>) -> Option<String> {
4457        msg.map(|Msg::Changed(v)| v)
4458    }
4459
4460    /// A keystroke past the limit does not reach the value.
4461    #[test]
4462    fn a_keystroke_past_the_limit_goes_nowhere() {
4463        let full = field("abcde").max_length(5);
4464        assert_eq!(value_of(typed(&full, "f").0), Some("abcde".into()));
4465        let room = field("abcd").max_length(5);
4466        assert_eq!(value_of(typed(&room, "e").0), Some("abcde".into()));
4467    }
4468
4469    /// A paste that crosses the limit lands the part that fits rather than being dropped
4470    /// whole: losing work the user can see they had is the worse of the two answers.
4471    #[test]
4472    fn a_paste_that_crosses_the_limit_lands_what_fits() {
4473        let f = field("ab").max_length(5);
4474        let (msg, edit) = typed(&f, "cdefgh");
4475        assert_eq!(value_of(msg), Some("abcde".into()));
4476        assert_eq!(edit.cursor, 5, "the caret comes to rest at the end");
4477    }
4478
4479    /// The limit is in **characters**, not bytes.
4480    #[test]
4481    fn the_limit_counts_characters_not_bytes() {
4482        let f = field("").max_length(3);
4483        assert_eq!(value_of(typed(&f, "éàü").0), Some("éàü".into()));
4484        let full = field("éàü").max_length(3);
4485        assert_eq!(value_of(typed(&full, "e").0), Some("éàü".into()));
4486    }
4487
4488    /// A value the caller supplied over the limit is left alone — it is the application's
4489    /// state, not something typed — and the counter says so.
4490    #[test]
4491    fn a_value_the_caller_set_over_the_limit_is_left_alone() {
4492        let over = field("abcdefg").max_length(5);
4493        assert_eq!(over.counter().as_deref(), Some("7/5"));
4494        assert_eq!(over.value, "abcdefg");
4495    }
4496
4497    /// The counter reserves the line below the box even with no helper to share it with.
4498    #[test]
4499    fn the_counter_reserves_the_line_it_sits_on() {
4500        assert_eq!(
4501            field("ab").sub_block(),
4502            0.0,
4503            "nothing to say, no room taken"
4504        );
4505        assert!(field("ab").max_length(5).sub_block() > 0.0);
4506        assert_eq!(field("ab").max_length(5).counter().as_deref(), Some("2/5"));
4507    }
4508
4509    /// Read-only refuses the **change** and keeps everything that only moves.
4510    #[test]
4511    fn read_only_refuses_the_change_and_keeps_the_caret() {
4512        let f = field("hello").read_only();
4513        assert_eq!(typed(&f, "x").0, None, "typing goes nowhere");
4514
4515        let mut edit = Edit {
4516            cursor: 5,
4517            anchor: None,
4518            ..Default::default()
4519        };
4520        assert_eq!(
4521            Widget::on_edit(
4522                &f,
4523                &mut edit,
4524                &Key::Left {
4525                    shift: false,
4526                    word: false
4527                }
4528            ),
4529            None
4530        );
4531        assert_eq!(edit.cursor, 4, "but the caret still moves");
4532
4533        let mut edit = Edit {
4534            cursor: 5,
4535            anchor: None,
4536            ..Default::default()
4537        };
4538        Widget::on_edit(
4539            &f,
4540            &mut edit,
4541            &Key::Home {
4542                shift: true,
4543                doc: false,
4544            },
4545        );
4546        assert_eq!(
4547            (edit.cursor, edit.anchor),
4548            (0, Some(5)),
4549            "and a selection can still be made, so it can be copied"
4550        );
4551    }
4552
4553    /// Backspace is a change, so it is refused too — and leaves the caret alone.
4554    #[test]
4555    fn read_only_refuses_a_deletion() {
4556        let f = field("hello").read_only();
4557        let mut edit = Edit {
4558            cursor: 5,
4559            anchor: None,
4560            ..Default::default()
4561        };
4562        assert_eq!(Widget::on_edit(&f, &mut edit, &Key::Backspace), None);
4563        assert_eq!(edit.cursor, 5);
4564    }
4565
4566    /// It is not a disabled field: it keeps its caret and its place in the tab order.
4567    #[test]
4568    fn read_only_is_not_disabled() {
4569        let read = field("REF-4417").read_only();
4570        assert!(Widget::<Msg>::focusable(&read), "still in the tab order");
4571        assert!(
4572            Widget::<Msg>::cursor_at(&read, 20.0, 10.0, 200.0, 0).is_some(),
4573            "and it still takes a caret, so a reference can be selected and copied"
4574        );
4575
4576        let dead = field("REF-4417").enabled(false);
4577        assert!(!Widget::<Msg>::focusable(&dead));
4578        assert!(Widget::<Msg>::cursor_at(&dead, 20.0, 10.0, 200.0, 0).is_none());
4579    }
4580}
4581
4582/// The field's decoration, against the reference's (milestone 656).
4583#[cfg(test)]
4584mod decoration_tests {
4585    use super::*;
4586    use crate::Icons;
4587
4588    const W: f32 = 300.0;
4589
4590    /// Every text a field paints: what, where, in what colour.
4591    fn texts(field: &TextField<()>, status: Status) -> Vec<(String, Point, Color)> {
4592        let mut scene = Scene::new();
4593        Widget::<()>::paint(
4594            field,
4595            Rect::new(0.0, 0.0, W, 80.0),
4596            status,
4597            &Theme::default(),
4598            &mut scene,
4599        );
4600        scene
4601            .primitives()
4602            .iter()
4603            .filter_map(|p| match p {
4604                frus_core::Primitive::Text {
4605                    text,
4606                    position,
4607                    color,
4608                    ..
4609                } => Some((text.clone(), *position, *color)),
4610                _ => None,
4611            })
4612            .collect()
4613    }
4614
4615    fn find(texts: &[(String, Point, Color)], what: &str) -> Option<(Point, Color)> {
4616        texts
4617            .iter()
4618            .find(|(text, ..)| text == what)
4619            .map(|(_, at, color)| (*at, *color))
4620    }
4621
4622    fn focused() -> Status {
4623        Status {
4624            focused: true,
4625            focus_progress: 1.0,
4626            ..Default::default()
4627        }
4628    }
4629
4630    /// **The label's three behaviours**: auto rests inside an empty field and floats once
4631    /// it holds something; always floats at once, so the hint shows; never rests inside an
4632    /// empty field and is gone once it holds something.
4633    #[test]
4634    fn the_label_floats_as_it_is_told() {
4635        let label_y =
4636            |field: &TextField<()>, status| find(&texts(field, status), "Name").map(|(at, _)| at.y);
4637        let auto = TextField::<()>::new("").label("Name");
4638        let rest = label_y(&auto, Status::default()).unwrap();
4639        let floated = label_y(&TextField::new("Ada").label("Name"), Status::default()).unwrap();
4640        assert!(
4641            floated < rest,
4642            "auto floats once filled: {floated} < {rest}"
4643        );
4644
4645        let always = TextField::<()>::new("")
4646            .label("Name")
4647            .placeholder("e.g. Ada")
4648            .floating_label_behavior(FloatingLabelBehavior::Always);
4649        let shown = texts(&always, Status::default());
4650        assert_eq!(
4651            find(&shown, "Name").unwrap().0.y,
4652            floated,
4653            "floated at rest"
4654        );
4655        assert!(find(&shown, "e.g. Ada").is_some(), "and the hint shows");
4656
4657        let never = |value: &str| {
4658            TextField::<()>::new(value)
4659                .label("Name")
4660                .placeholder("e.g. Ada")
4661                .floating_label_behavior(FloatingLabelBehavior::Never)
4662        };
4663        assert_eq!(
4664            label_y(&never(""), focused()),
4665            Some(rest),
4666            "inside, even focused"
4667        );
4668        assert!(
4669            find(&texts(&never(""), focused()), "e.g. Ada").is_none(),
4670            "no hint"
4671        );
4672        assert_eq!(
4673            label_y(&never("Ada"), Status::default()),
4674            None,
4675            "gone once filled"
4676        );
4677
4678        let mut theme = Theme::default();
4679        theme.widgets.text_field.floating_label_behavior = Some(FloatingLabelBehavior::Always);
4680        let mut scene = Scene::new();
4681        Widget::<()>::paint(
4682            &TextField::<()>::new("").label("Name"),
4683            Rect::new(0.0, 0.0, W, 80.0),
4684            Status::default(),
4685            &theme,
4686            &mut scene,
4687        );
4688        let themed = scene.primitives().iter().find_map(|p| match p {
4689            frus_core::Primitive::Text { text, position, .. } if text == "Name" => Some(position.y),
4690            _ => None,
4691        });
4692        assert_eq!(themed, Some(floated), "the theme's word");
4693    }
4694
4695    /// **A centred label floats to the middle** of the box, and rests at the start.
4696    #[test]
4697    fn a_centred_label_floats_to_the_middle() {
4698        let x = |value: &str| {
4699            let field = TextField::<()>::new(value)
4700                .label("Name")
4701                .floating_label_alignment(FloatingLabelAlignment::Center);
4702            find(&texts(&field, Status::default()), "Name").unwrap().0.x
4703        };
4704        let width = frus_text::measure_resolved(
4705            "Name",
4706            &ResolvedTextStyle::exact(FIELD_TEXT_SIZE * FIELD_LABEL_SCALE),
4707        )
4708        .width;
4709        assert!(((x("Ada") + width / 2.0) - W / 2.0).abs() < 0.5, "centred");
4710        let rest = find(
4711            &texts(&TextField::<()>::new("").label("Name"), Status::default()),
4712            "Name",
4713        )
4714        .unwrap()
4715        .0
4716        .x;
4717        assert_eq!(x(""), rest, "at rest, where the value starts");
4718    }
4719
4720    /// **The affixes sit either side of the value**, in the hint's colour, and the value and
4721    /// the caret move over for the prefix. They show once the label floats.
4722    #[test]
4723    fn the_affixes_sit_either_side_of_the_value() {
4724        let theme = Theme::default();
4725        let plain = TextField::<()>::new("42");
4726        let priced = TextField::<()>::new("42")
4727            .prefix_text("$")
4728            .suffix_text("kg");
4729        let shown = texts(&priced, Status::default());
4730        let (dollar, colour) = find(&shown, "$").expect("the prefix");
4731        let (value, _) = find(&shown, "42").unwrap();
4732        let (unit, _) = find(&shown, "kg").expect("the suffix");
4733        assert_eq!(colour, theme.muted, "the hint's colour");
4734        let plain_x = find(&texts(&plain, Status::default()), "42").unwrap().0.x;
4735        let style = TextStyle::new(FIELD_TEXT_SIZE).resolved();
4736        let dollar_w = frus_text::measure_resolved("$", &style).width;
4737        assert_eq!(dollar.x, plain_x, "the prefix where the value was");
4738        assert!(
4739            (value.x - (plain_x + dollar_w)).abs() < 1e-3,
4740            "the value after it"
4741        );
4742        let unit_w = frus_text::measure_resolved("kg", &style).width;
4743        assert!(
4744            unit.x + unit_w <= W && unit.x > value.x,
4745            "the suffix at the end"
4746        );
4747
4748        // A click just after the prefix lands at the value's start.
4749        let at = Widget::<()>::cursor_at(&priced, value.x + 0.5, 30.0, W, 0);
4750        assert_eq!(at, Some(0));
4751
4752        // Empty and unfocused under a resting label, they wait; focused, they show.
4753        let labelled = TextField::<()>::new("").label("Price").prefix_text("$");
4754        assert!(find(&texts(&labelled, Status::default()), "$").is_none());
4755        assert!(find(&texts(&labelled, focused()), "$").is_some());
4756        // The resting label sits before the prefix, as the reference lays it out
4757        // (`input_decorator.dart:1520`): the prefix is no part of where it rests.
4758        let without = TextField::<()>::new("").label("Price");
4759        assert_eq!(
4760            find(&texts(&labelled, Status::default()), "Price")
4761                .unwrap()
4762                .0
4763                .x,
4764            find(&texts(&without, Status::default()), "Price")
4765                .unwrap()
4766                .0
4767                .x
4768        );
4769        let bare = TextField::<()>::new("").prefix_text("$");
4770        assert!(
4771            find(&texts(&bare, Status::default()), "$").is_none(),
4772            "no label: focus"
4773        );
4774        assert!(find(&texts(&bare, focused()), "$").is_some());
4775    }
4776
4777    /// **The icon outside the box**: the box, its value and the line under it all start
4778    /// after it, and a click is measured from there.
4779    #[test]
4780    fn the_outer_icon_moves_the_box_over() {
4781        let plain = TextField::<()>::new("42").helper("Help");
4782        let iconed = TextField::<()>::new("42").helper("Help").icon(Icons::STAR);
4783        let before = texts(&plain, Status::default());
4784        let after = texts(&iconed, Status::default());
4785        for what in ["42", "Help"] {
4786            let shift = find(&after, what).unwrap().0.x - find(&before, what).unwrap().0.x;
4787            assert_eq!(shift, 40.0, "{what} moves over by 24 and 16");
4788        }
4789        let dense = TextField::<()>::new("42").dense(true).icon(Icons::STAR);
4790        let dense_plain = TextField::<()>::new("42").dense(true);
4791        let shift = find(&texts(&dense, Status::default()), "42").unwrap().0.x
4792            - find(&texts(&dense_plain, Status::default()), "42")
4793                .unwrap()
4794                .0
4795                .x;
4796        assert_eq!(shift, 34.0, "18 and 16, dense");
4797        let x = find(&after, "42").unwrap().0.x;
4798        assert_eq!(
4799            Widget::<()>::cursor_at(&iconed, x + 0.5, 30.0, W, 0),
4800            Some(0)
4801        );
4802        // The box itself starts after the icon.
4803        let boxes: Vec<Rect> = {
4804            let mut scene = Scene::new();
4805            Widget::<()>::paint(
4806                &iconed,
4807                Rect::new(0.0, 0.0, W, 80.0),
4808                Status::default(),
4809                &Theme::default(),
4810                &mut scene,
4811            );
4812            scene
4813                .primitives()
4814                .iter()
4815                .filter_map(|p| match p {
4816                    frus_core::Primitive::Rect { rect, .. } => Some(*rect),
4817                    _ => None,
4818                })
4819                .collect()
4820        };
4821        assert_eq!(boxes[0].x, 40.0);
4822        assert_eq!(boxes[0].width, W - 40.0);
4823    }
4824
4825    /// **The counter's text**: said instead of the count, and an empty one hides it.
4826    #[test]
4827    fn the_counter_says_what_it_is_told() {
4828        let counted = TextField::<()>::new("abc").max_length(10);
4829        assert!(find(&texts(&counted, Status::default()), "3/10").is_some());
4830        let told = TextField::<()>::new("abc")
4831            .max_length(10)
4832            .counter_text("3 of 10");
4833        let shown = texts(&told, Status::default());
4834        assert!(find(&shown, "3 of 10").is_some() && find(&shown, "3/10").is_none());
4835        let hidden = TextField::<()>::new("abc").max_length(10).counter_text("");
4836        assert!(find(&texts(&hidden, Status::default()), "3/10").is_none());
4837        assert_eq!(hidden.sub_block(), 0.0, "and its line is not kept");
4838        let alone = TextField::<()>::new("abc").counter_text("Optional");
4839        assert!(find(&texts(&alone, Status::default()), "Optional").is_some());
4840        assert!(alone.sub_block() > 0.0);
4841    }
4842}
4843
4844/// A box that grows with its value (milestone 657).
4845#[cfg(test)]
4846mod lines_tests {
4847    use super::*;
4848
4849    const W: f32 = 300.0;
4850
4851    /// A value of `n` short words: enough of them wrap.
4852    fn words(n: usize) -> String {
4853        vec!["word"; n].join(" ")
4854    }
4855
4856    fn lines(field: &TextField<()>) -> f32 {
4857        field.lines_at(W)
4858    }
4859
4860    /// **Given only a most, the box starts at one line** and grows as the value wraps, up
4861    /// to the most, as the reference's `maxLines` does.
4862    #[test]
4863    fn a_box_grows_from_one_line_to_the_most() {
4864        let empty = TextField::<()>::new("").max_lines(4);
4865        assert!(empty.grows());
4866        assert_eq!(lines(&empty), 1.0, "one line, empty");
4867        let short = TextField::<()>::new(words(2)).max_lines(4);
4868        assert_eq!(lines(&short), 1.0);
4869        let wrapped = TextField::<()>::new(words(40)).max_lines(4);
4870        assert_eq!(lines(&wrapped), 4.0, "held at the most");
4871        let some = TextField::<()>::new(words(14)).max_lines(4);
4872        let n = lines(&some);
4873        assert!(n > 1.0 && n < 4.0, "in between: {n}");
4874        let unbounded = TextField::<()>::new(words(160)).max_lines(None);
4875        assert!(lines(&unbounded) > 12.0, "no most: {}", lines(&unbounded));
4876    }
4877
4878    /// **The fewest lines hold the box open**, and the most is never below them.
4879    #[test]
4880    fn the_fewest_lines_hold_the_box_open() {
4881        let held = TextField::<()>::new("").min_lines(2).max_lines(5);
4882        assert_eq!(lines(&held), 2.0);
4883        let either_order = TextField::<()>::new("").max_lines(5).min_lines(2);
4884        assert_eq!(lines(&either_order), 2.0, "said after the most");
4885        let inverted = TextField::<()>::new(words(40)).min_lines(6).max_lines(3);
4886        assert_eq!(
4887            lines(&inverted),
4888            6.0,
4889            "a most below the fewest is the fewest"
4890        );
4891        assert!(!inverted.grows());
4892        let raised = TextField::<()>::new(words(40)).max_lines(3).min_lines(6);
4893        assert_eq!(lines(&raised), 6.0, "the fewest raise a most said first");
4894        assert!(!raised.grows());
4895    }
4896
4897    /// **One line at most is a single-line field**, and `rows` still fixes the box.
4898    #[test]
4899    fn one_line_and_fixed_rows_do_not_grow() {
4900        let single = TextField::<()>::new(words(40)).max_lines(1);
4901        assert!(!single.multiline && !single.grows());
4902        assert_eq!(lines(&single), 1.0);
4903        let fixed = TextField::<()>::new(words(40)).rows(3);
4904        assert!(!fixed.grows());
4905        assert_eq!(lines(&fixed), 3.0);
4906        assert!(Widget::<()>::measure(&fixed, &Theme::default()).is_none());
4907        assert!(matches!(
4908            Widget::<()>::style(&fixed).height,
4909            Dimension::Length(_)
4910        ));
4911    }
4912
4913    /// **The layout, the paint and the click agree on the height**: a growing box is
4914    /// measured from its width, painted that tall, and its suffix answers down its whole
4915    /// height.
4916    #[test]
4917    fn the_layout_and_the_paint_agree() {
4918        let field = TextField::<()>::new(words(14))
4919            .max_lines(6)
4920            .label("Notes")
4921            .suffix_icon(crate::Icons::CLOSE)
4922            .on_suffix(());
4923        let style = Widget::<()>::style(&field);
4924        assert_eq!(style.height, Dimension::Auto);
4925        let theme = Theme::default();
4926        let measure = Widget::<()>::measure(&field, &theme).expect("measured");
4927        let size = measure(Some(W), None);
4928        let expected =
4929            frus_core::fits(field.label_block() + field.field_height_at(W) + field.sub_block());
4930        assert_eq!(size.height, expected, "in whole pixels");
4931        assert_eq!(size.height.fract(), 0.0);
4932        assert!(
4933            field.field_height_at(W) > field.field_height(),
4934            "taller than one line"
4935        );
4936        let narrow = measure(Some(200.0), None);
4937        assert!(narrow.height > size.height, "narrower wraps more");
4938
4939        let mut scene = Scene::new();
4940        Widget::<()>::paint(
4941            &field,
4942            Rect::new(0.0, 0.0, W, size.height),
4943            Status::default(),
4944            &theme,
4945            &mut scene,
4946        );
4947        let tallest = scene
4948            .primitives()
4949            .iter()
4950            .filter_map(|p| match p {
4951                frus_core::Primitive::Rect { rect, .. } => Some(rect.height),
4952                _ => None,
4953            })
4954            .fold(0.0, f32::max);
4955        assert_eq!(tallest, field.field_height_at(W), "the box is that tall");
4956        let low = field.label_block() + field.field_height_at(W) - 4.0;
4957        assert!(
4958            field.suffix_hit(W - 10.0, low, W),
4959            "the suffix answers low in the box"
4960        );
4961
4962        let key = |f: &TextField<()>| Widget::<()>::measure_key(f, &theme);
4963        assert_ne!(
4964            key(&TextField::<()>::new(words(14)).max_lines(6).label("Notes")),
4965            key(&TextField::<()>::new(words(30)).max_lines(6).label("Notes")),
4966            "another value, and nothing else, another measurement"
4967        );
4968        assert_ne!(
4969            key(&TextField::<()>::new(words(14)).max_lines(6)),
4970            key(&TextField::<()>::new(words(14))
4971                .max_lines(6)
4972                .prefix_text("$")),
4973            "a prefix narrows the wrap"
4974        );
4975    }
4976}
4977
4978/// The caret, the mask and the focus a field asks for (milestone 659).
4979#[cfg(test)]
4980mod cursor_tests {
4981    use super::*;
4982    use frus_core::{BorderRadius, Primitive, TargetPlatform};
4983
4984    /// Every rectangle a focused field paints: where, in what colour, how round.
4985    fn rects(field: &TextField<()>, theme: &Theme) -> Vec<(Rect, Color, BorderRadius)> {
4986        let mut scene = Scene::new();
4987        Widget::<()>::paint(
4988            field,
4989            Rect::new(0.0, 0.0, 300.0, 80.0),
4990            Status {
4991                focused: true,
4992                focus_progress: 1.0,
4993                cursor: Some(2),
4994                ..Default::default()
4995            },
4996            theme,
4997            &mut scene,
4998        );
4999        scene
5000            .primitives()
5001            .iter()
5002            .filter_map(|p| match p {
5003                Primitive::Rect {
5004                    rect,
5005                    color,
5006                    radius,
5007                    ..
5008                } => Some((*rect, *color, *radius)),
5009                _ => None,
5010            })
5011            .collect()
5012    }
5013
5014    /// The caret: the rectangle in `color`.
5015    fn caret(field: &TextField<()>, theme: &Theme, color: Color) -> Option<(Rect, BorderRadius)> {
5016        rects(field, theme)
5017            .into_iter()
5018            .find(|(_, c, _)| *c == color)
5019            .map(|(r, _, radius)| (r, radius))
5020    }
5021
5022    /// **The caret is `primary`**, as the reference's is, and the error's colour while one
5023    /// shows; the field's colour over the theme's, the field theme's over the text
5024    /// selection's.
5025    #[test]
5026    fn the_caret_takes_the_reference_s_colours() {
5027        let theme = Theme::default();
5028        let field = TextField::<()>::new("ab");
5029        let (rect, _) = caret(&field, &theme, theme.scheme.primary).expect("in primary");
5030        assert_eq!(rect.width, 2.0, "the reference's two pixels");
5031
5032        let errored = TextField::<()>::new("ab").error("Wrong");
5033        assert!(
5034            caret(&errored, &theme, theme.scheme.error).is_some(),
5035            "in error"
5036        );
5037        assert!(caret(&errored, &theme, theme.scheme.primary).is_none());
5038
5039        let red = Color::rgb(1.0, 0.0, 0.0);
5040        let told = TextField::<()>::new("ab").cursor_color(red);
5041        assert!(caret(&told, &theme, red).is_some(), "the field's");
5042        let blue = Color::rgb(0.0, 0.0, 1.0);
5043        let told_error = TextField::<()>::new("ab")
5044            .error("Wrong")
5045            .cursor_error_color(blue);
5046        assert!(
5047            caret(&told_error, &theme, blue).is_some(),
5048            "the field's error colour"
5049        );
5050
5051        let mut selection = Theme::default();
5052        selection.widgets.text_selection.cursor_color = Some(red);
5053        assert!(
5054            caret(&field, &selection, red).is_some(),
5055            "the text selection's"
5056        );
5057        let mut both = selection.clone();
5058        both.widgets.text_field.cursor_color = Some(blue);
5059        assert!(
5060            caret(&field, &both, blue).is_some(),
5061            "the field theme's over it"
5062        );
5063    }
5064
5065    /// **Its shape**: the width and height told, the height centred on the line; rounded on
5066    /// Apple's platforms and square elsewhere unless told; hidden when told.
5067    #[test]
5068    fn the_caret_takes_its_shape() {
5069        let theme = Theme::default().with_platform(TargetPlatform::Android);
5070        let primary = theme.scheme.primary;
5071        let (line, square) = caret(&TextField::new("ab"), &theme, primary).unwrap();
5072        assert_eq!(square, BorderRadius::uniform(0.0), "square on Android");
5073
5074        let shaped = TextField::<()>::new("ab")
5075            .cursor_width(3.0)
5076            .cursor_height(10.0);
5077        let (rect, _) = caret(&shaped, &theme, primary).unwrap();
5078        assert_eq!((rect.width, rect.height), (3.0, 10.0));
5079        let centre = |r: Rect| r.y + r.height / 2.0;
5080        assert!(
5081            (centre(rect) - centre(line)).abs() < 1e-3,
5082            "centred on the line"
5083        );
5084
5085        let ios = Theme::default().with_platform(TargetPlatform::Ios);
5086        let (_, round) = caret(&TextField::new("ab"), &ios, ios.scheme.primary).unwrap();
5087        assert_eq!(round, BorderRadius::uniform(2.0), "rounded on iOS");
5088        let told = TextField::<()>::new("ab").cursor_radius(1.0);
5089        assert_eq!(
5090            caret(&told, &theme, primary).unwrap().1,
5091            BorderRadius::uniform(1.0)
5092        );
5093
5094        let hidden = TextField::<()>::new("ab").show_cursor(false);
5095        assert!(caret(&hidden, &theme, primary).is_none(), "not shown");
5096    }
5097
5098    /// **A masked value shows the character told**, a dot unless told.
5099    #[test]
5100    fn a_masked_value_shows_the_character_told() {
5101        assert_eq!(TextField::<()>::new("abc").obscure(true).display(), "•••");
5102        let starred = TextField::<()>::new("abc")
5103            .obscure(true)
5104            .obscuring_character('*');
5105        assert_eq!(starred.display(), "***");
5106        assert_eq!(
5107            TextField::<()>::new("abc")
5108                .obscuring_character('*')
5109                .display(),
5110            "abc",
5111            "only a masked field"
5112        );
5113    }
5114
5115    /// **A field asks for the focus when told**, and a disabled one never does.
5116    #[test]
5117    fn a_field_asks_for_the_focus_when_told() {
5118        assert!(!Widget::<()>::autofocus(&TextField::new("")));
5119        assert!(Widget::<()>::autofocus(&TextField::new("").autofocus(true)));
5120        assert!(!Widget::<()>::autofocus(
5121            &TextField::new("").autofocus(true).enabled(false)
5122        ));
5123    }
5124}