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.
1200    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
1201        self.placeholder = Some(placeholder.into());
1202        self
1203    }
1204
1205    /// Helper text below the field (replaced by the error, if present).
1206    pub fn helper(mut self, helper: impl Into<String>) -> Self {
1207        self.helper = Some(helper.into());
1208        self
1209    }
1210
1211    /// Marks the field **in error**: red border and label, `message` displayed below
1212    /// the field. Chain it only when the application's validation fails.
1213    pub fn error(mut self, message: impl Into<String>) -> Self {
1214        self.error = Some(message.into());
1215        self
1216    }
1217
1218    /// Sets the width, in logical pixels.
1219    pub fn width(mut self, width: f32) -> Self {
1220        self.width = Dimension::Length(width);
1221        self.sized = true;
1222        self
1223    }
1224
1225    /// Sets the font size, in pixels.
1226    pub fn size(mut self, size: f32) -> Self {
1227        self.size = size;
1228        self
1229    }
1230
1231    /// The most characters the field will hold.
1232    ///
1233    /// It is **enforced**, as the reference's default is: a keystroke past the limit does
1234    /// not reach the value, and neither does the tail of a paste that would cross it —
1235    /// the part that fits still lands, because dropping a whole paste for being one
1236    /// character too long loses work the user can see they had.
1237    ///
1238    /// It also puts a counter — `5/10` — at the end of the line below the box, where the
1239    /// reference puts one, and reserves that line even when there is no helper text to
1240    /// share it with.
1241    ///
1242    /// The limit is in **characters**, not bytes: "é" is one, and a name in an alphabet
1243    /// that is not Latin is counted the way its writer would count it.
1244    pub fn max_length(mut self, max: usize) -> Self {
1245        self.max_length = Some(max);
1246        self
1247    }
1248
1249    /// What each typed character becomes before it reaches the value: `None` drops it,
1250    /// `Some(c)` puts `c` in instead of what was typed.
1251    ///
1252    /// The reference's `inputFormatters` reshape the **whole value** after every
1253    /// keystroke, which buys grouping (spaces every four digits of a card number) and
1254    /// costs a caret: the formatter has to say where it went, and getting that wrong
1255    /// puts the cursor in the middle of a group the reader did not touch. A filter that
1256    /// works one character at a time cannot group, and cannot lose the caret either
1257    /// — a dropped character simply never arrives, and a substituted one takes the same
1258    /// place. That covers digits-only, letters-only, no-spaces and forced case, which is
1259    /// nearly every field that filters at all.
1260    ///
1261    /// It applies to what is **typed or pasted**, and a paste keeps whatever it can
1262    /// rather than being refused whole — the same rule
1263    /// [`max_length`](TextField::max_length) follows, for the same reason.
1264    ///
1265    /// A value the **caller** supplied is left alone: it is the application's state, not
1266    /// something typed, and rewriting it would be editing a value nobody edited.
1267    ///
1268    /// ```ignore
1269    /// TextField::new(&app.code).input_filter(|c| c.to_uppercase().next())
1270    /// TextField::new(&app.tag).input_filter(|c| (!c.is_whitespace()).then_some(c))
1271    /// ```
1272    pub fn input_filter(mut self, filter: impl Fn(char) -> Option<char> + 'static) -> Self {
1273        self.input_filter = Some(Box::new(filter));
1274        self
1275    }
1276
1277    /// Digits, and nothing else.
1278    ///
1279    /// It also asks for a **number keypad**, unless one was named already: a field that
1280    /// refuses everything but digits and then opens a QWERTY keyboard is a field whose
1281    /// keys mostly do nothing. Naming a keyboard before or after this leaves that
1282    /// choice standing, so the two builders can be written in either order.
1283    pub fn digits_only(mut self) -> Self {
1284        self.input_filter = Some(Box::new(|c| c.is_ascii_digit().then_some(c)));
1285        self.keyboard = self.keyboard.or(Some(KeyboardType::Number));
1286        self
1287    }
1288
1289    /// Which letters the software keyboard capitalises by itself.
1290    ///
1291    /// A **hint**: it is what the keyboard does to what it sends, and a reader who turns
1292    /// it off still types what they meant to. A field that must hold capitals wants
1293    /// [`input_filter`](TextField::input_filter) as well, which works whatever the
1294    /// keyboard does and works on a desktop, where there is no keyboard to hint to.
1295    pub fn capitalization(mut self, capitalization: Capitalization) -> Self {
1296        self.capitalization = capitalization;
1297        self
1298    }
1299
1300    /// A field whose value can be read, selected and copied — but not changed.
1301    ///
1302    /// It is **not** [`enabled(false)`](TextField::enabled), and the difference matters.
1303    /// A disabled field is greyed out and inert: out of the tab order, no caret, nothing
1304    /// to select. A read-only one looks and behaves like any other field except that
1305    /// typing does nothing — you can focus it, move the caret through it, select a
1306    /// reference number and copy it. That is what an identifier the application generated
1307    /// wants, and greying it out would suggest it is unavailable rather than fixed.
1308    pub fn read_only(mut self) -> Self {
1309        self.read_only = true;
1310        self
1311    }
1312
1313    /// **What the bar over a selection offers** — Cut, Copy, Paste, Select all — decided by
1314    /// the application (milestone 568, [#23]).
1315    ///
1316    /// `build` is handed the [`ToolbarContext`] (is something selected, is there text to
1317    /// paste, is everything selected already) and the field's **default list** for it, and
1318    /// returns the list to show. Drop an item, reorder them, add one of the application's own
1319    /// ([`ToolbarItem::custom`]) — or return an empty list, and no bar shows at all.
1320    ///
1321    /// ```
1322    /// use frus_widgets::{TextField, ToolbarItem};
1323    ///
1324    /// #[derive(Clone)]
1325    /// enum Msg { Translate }
1326    ///
1327    /// // A field that never lets its text be pasted over, and offers a way to translate it.
1328    /// let field: TextField<Msg> = TextField::new("Bonjour").selection_toolbar(|context, mut items| {
1329    ///     items.retain(|item| item.label() != "Paste");
1330    ///     if context.has_selection {
1331    ///         items.push(ToolbarItem::custom("Translate", Msg::Translate));
1332    ///     }
1333    ///     items
1334    /// });
1335    /// # let _ = field;
1336    /// ```
1337    ///
1338    /// The defaults already keep a secret: a masked field ([`obscure`](Self::obscure)) is
1339    /// given neither Cut nor Copy, and a [`read_only`](Self::read_only) one neither Cut nor
1340    /// Paste, so an application only has to change the list to depart from them.
1341    ///
1342    /// [#23]: https://github.com/KalybosPro/frus/issues/23
1343    pub fn selection_toolbar(
1344        mut self,
1345        build: impl Fn(&ToolbarContext, Vec<ToolbarItem<Msg>>) -> Vec<ToolbarItem<Msg>> + 'static,
1346    ) -> Self {
1347        self.toolbar_build = Some(Box::new(build));
1348        // Whatever was built for the old answer is not the answer any more.
1349        self.toolbars = Default::default();
1350        self
1351    }
1352
1353    /// Closure producing a message from the field's new value.
1354    pub fn on_input<R: crate::callback::IntoMsg<Msg>>(
1355        mut self,
1356        on_input: impl Fn(String) -> R + 'static,
1357    ) -> Self {
1358        self.on_input = Some(Box::new(crate::callback::handler1(on_input)));
1359        self
1360    }
1361
1362    /// Message emitted on submission (the Enter key), without changing the value.
1363    pub fn on_submit(mut self, message: impl Into<Msg>) -> Self {
1364        self.on_submit = Some(message.into());
1365        self
1366    }
1367
1368    /// Text to speak through the screen reader's live region when [`Self::on_submit`]
1369    /// fires — the field's own counterpart to a button's [`Widget::announce`](
1370    /// crate::Widget::announce), which only ever fires on a click or a plain Enter and
1371    /// so never sees a field's submit (#18). Compute it from the value the caller
1372    /// already has — `TextField::new(draft).announce(format!("{draft} added"))` — since
1373    /// by the time this fires, the widget it is read from is the one built **before**
1374    /// the keystroke, still holding what is about to be cleared.
1375    pub fn announce(mut self, message: impl Into<String>) -> Self {
1376        self.announce = Some(message.into());
1377        self
1378    }
1379
1380    /// The **displayed** string: masked (one dot per character) for a password field,
1381    /// the value as is otherwise. Same character count as the value → the caret and
1382    /// the hit-test stay aligned index for index.
1383    fn display(&self) -> String {
1384        if self.obscure {
1385            self.obscuring
1386                .to_string()
1387                .repeat(self.value.chars().count())
1388        } else {
1389            self.value.clone()
1390        }
1391    }
1392
1393    /// The **displayed** string shaped once: caret by index, hit-test, selection — one
1394    /// consistent geometry (kerning). `wrap_width` = the soft-wrap width (multi-line) or
1395    /// `None` (single-line: only explicit `\n` break).
1396    fn layout(&self, wrap_width: Option<f32>) -> TextLayout {
1397        let style = self.text_style();
1398        TextLayout::wrapped(
1399            &self.display(),
1400            style.size,
1401            style.weight,
1402            style.italic,
1403            wrap_width,
1404        )
1405    }
1406
1407    /// Width reserved for the prefix icon (0 if there is none).
1408    fn prefix_icon_w(&self) -> f32 {
1409        if self.prefix.is_some() {
1410            self.icon_size() + ICON_PAD
1411        } else {
1412            0.0
1413        }
1414    }
1415
1416    /// Width reserved for the suffix icon (0 if there is none).
1417    fn suffix_icon_w(&self) -> f32 {
1418        if self.suffix.is_some() {
1419            self.icon_size() + ICON_PAD
1420        } else {
1421            0.0
1422        }
1423    }
1424
1425    /// The width of an affix text, in the value's style.
1426    fn affix_w(&self, text: &Option<String>) -> f32 {
1427        text.as_ref().map_or(0.0, |text| {
1428            frus_text::measure_resolved(text, &self.text_style()).width
1429        })
1430    }
1431
1432    /// Width reserved before the value inside the box: the prefix icon and the prefix text.
1433    fn prefix_w(&self) -> f32 {
1434        self.prefix_icon_w() + self.affix_w(&self.prefix_text)
1435    }
1436
1437    /// Width reserved after the value inside the box: the suffix text and the suffix icon.
1438    fn suffix_w(&self) -> f32 {
1439        self.suffix_icon_w() + self.affix_w(&self.suffix_text)
1440    }
1441
1442    /// The side of the icon outside the box: the reference's 24, 18 dense.
1443    fn outer_icon_size(&self) -> f32 {
1444        if self.dense {
1445            18.0
1446        } else {
1447            24.0
1448        }
1449    }
1450
1451    /// The room before the box, for the icon outside it: its side and 16 more.
1452    fn gutter(&self) -> f32 {
1453        if self.icon.is_some() {
1454            self.outer_icon_size() + 16.0
1455        } else {
1456            0.0
1457        }
1458    }
1459
1460    /// Where the value starts, from the widget's left edge: the room for the outer icon,
1461    /// the padding, the prefix icon and the prefix text.
1462    fn lead(&self) -> f32 {
1463        self.gutter() + self.pad_x() + self.prefix_w()
1464    }
1465
1466    /// When the label floats, the field's own word over the theme's.
1467    fn floating_behavior(&self, theme: &Theme) -> FloatingLabelBehavior {
1468        self.floating
1469            .or(theme.widgets.text_field.floating_label_behavior)
1470            .unwrap_or_default()
1471    }
1472
1473    /// How far the label has floated, `0..=1`, for a focus at `fp`.
1474    fn float_progress(&self, fp: f32, theme: &Theme) -> f32 {
1475        match self.floating_behavior(theme) {
1476            FloatingLabelBehavior::Always => 1.0,
1477            FloatingLabelBehavior::Never => 0.0,
1478            FloatingLabelBehavior::Auto if self.value.is_empty() => fp,
1479            FloatingLabelBehavior::Auto => 1.0,
1480        }
1481    }
1482
1483    /// Text width (between the padding and the icons) for a given widget width.
1484    /// Where the text is drawn inside the field, for `edit`: its layout and the top-left
1485    /// corner of the text, in the field's **local** coordinates. The paint's own geometry —
1486    /// the content's insets, the alignment, the horizontal scroll that follows the caret and
1487    /// the retained vertical one — so that whatever is placed against the text (a handle, the
1488    /// bar over a selection) is where the text is drawn.
1489    fn text_frame(&self, width: f32, edit: &Edit, scroll_y: f32) -> (TextLayout, f32, f32) {
1490        let len = self.value.chars().count();
1491        let content_w = self.content_width(width);
1492        let layout = self.layout(self.multiline.then_some(content_w));
1493        let scroll = (layout.caret_rect(edit.cursor.min(len)).x - content_w).max(0.0);
1494        let align = self.align_offset(content_w, layout.size().width);
1495        let vscroll = if self.multiline {
1496            let visible = self.field_height_at(width) - self.text_top() - self.pad_bottom();
1497            scroll_y.clamp(0.0, (layout.size().height - visible).max(0.0))
1498        } else {
1499            0.0
1500        };
1501        let origin_x = self.lead() + align - scroll;
1502        let origin_y = self.label_block() + self.text_top() - vscroll;
1503        (layout, origin_x, origin_y)
1504    }
1505
1506    /// The two selection handles for `edit`, in local coordinates — see
1507    /// [`Widget::selection_handles`].
1508    fn handles(
1509        &self,
1510        width: f32,
1511        edit: &Edit,
1512        scroll_y: f32,
1513    ) -> Option<[crate::SelectionHandle; 2]> {
1514        let len = self.value.chars().count();
1515        edit.selection_range()?;
1516        let (layout, origin_x, origin_y) = self.text_frame(width, edit, scroll_y);
1517        handles_in(&layout, Point::new(origin_x, origin_y), len, edit)
1518    }
1519
1520    /// The box the bar over the selection is placed against: the selection's, line by line
1521    /// joined into one box, or the caret's when nothing is selected.
1522    fn selection_box(&self, width: f32, edit: &Edit, scroll_y: f32) -> Rect {
1523        let len = self.value.chars().count();
1524        let (layout, origin_x, origin_y) = self.text_frame(width, edit, scroll_y);
1525        selection_box_in(&layout, Point::new(origin_x, origin_y), len, edit)
1526    }
1527
1528    /// The bar's **default** list for `context`: what the reference offers, decided by the
1529    /// three conditions and by what this field allows. A masked field gives its text to
1530    /// nobody, so it offers neither Cut nor Copy; a read-only one keeps its text as it is,
1531    /// so it offers no Cut and no Paste.
1532    fn default_toolbar_items(&self, context: &ToolbarContext) -> Vec<ToolbarItem<Msg>> {
1533        let editable = self.enabled && !self.read_only;
1534        let mut items = Vec::new();
1535        if context.has_selection && editable && !self.obscure {
1536            items.push(ToolbarItem::cut());
1537        }
1538        if context.has_selection && !self.obscure {
1539            items.push(ToolbarItem::copy());
1540        }
1541        if context.can_paste && editable {
1542            items.push(ToolbarItem::paste());
1543        }
1544        if !context.all_selected {
1545            items.push(ToolbarItem::select_all());
1546        }
1547        items
1548    }
1549
1550    /// The value's width, between the paddings and the affixes, in a widget `width` wide.
1551    fn content_width(&self, width: f32) -> f32 {
1552        (width - self.lead() - self.pad_x() - self.suffix_w()).max(0.0)
1553    }
1554
1555    /// Height reserved for the label above the box (0 if there is no label). In
1556    /// `outlined` mode the floating label straddles the top border: only its **upper
1557    /// half** must be reserved (the rest bites into the box), instead of a full band.
1558    fn label_block(&self) -> f32 {
1559        if self.label.is_some() {
1560            if self.is_outlined() {
1561                (frus_text::line_height(self.label_size()) * 0.5).ceil()
1562            } else {
1563                frus_text::line_height(self.label_size()) + FIELD_GAP
1564            }
1565        } else {
1566            0.0
1567        }
1568    }
1569
1570    /// The counter shown at the end of the line below the box, when there is a limit.
1571    fn counter(&self) -> Option<String> {
1572        match &self.counter_text {
1573            Some(text) if text.is_empty() => None,
1574            Some(text) => Some(text.clone()),
1575            None => self
1576                .max_length
1577                .map(|max| format!("{}/{}", self.value.chars().count(), max)),
1578        }
1579    }
1580
1581    /// Height reserved for the helper/error line below the box (0 if there is none).
1582    fn sub_block(&self) -> f32 {
1583        if self.error.is_some() || self.helper.is_some() || self.counter().is_some() {
1584            // The helper style's **own** line, not one recomputed from its size: another
1585            // of milestone 412's survivors, written against a bare constant rather than a
1586            // style, which is the one formulation that sweep could not find.
1587            self.sub_style().line_height() + FIELD_GAP
1588        } else {
1589            0.0
1590        }
1591    }
1592
1593    /// Height of the input box itself (decoration excluded) before the value is known to
1594    /// wrap: one line in single-line mode, `rows` lines in multi-line mode.
1595    fn field_height(&self) -> f32 {
1596        let lines = if self.multiline {
1597            self.rows.max(1) as f32
1598        } else {
1599            1.0
1600        };
1601        self.box_height(lines)
1602    }
1603
1604    /// The box's height for `lines` lines of text.
1605    fn box_height(&self, lines: f32) -> f32 {
1606        (self.text_style().line_height() * lines + self.text_top() + self.pad_bottom()).ceil()
1607    }
1608
1609    /// Whether the box grows with the value (milestone 657): multi-line, with more lines
1610    /// allowed than it starts with.
1611    fn grows(&self) -> bool {
1612        self.multiline && self.max_rows != Some(self.rows)
1613    }
1614
1615    /// How many lines the box shows in a widget `width` wide: the value's own, wrapped at
1616    /// the content's width, held between the fewest and the most.
1617    fn lines_at(&self, width: f32) -> f32 {
1618        if !self.grows() {
1619            return if self.multiline {
1620                self.rows.max(1) as f32
1621            } else {
1622                1.0
1623            };
1624        }
1625        let layout = self.layout(Some(self.content_width(width)));
1626        let line_h = self.text_style().line_height();
1627        let wrapped = if line_h > 0.0 {
1628            (layout.size().height / line_h).round().max(1.0)
1629        } else {
1630            1.0
1631        };
1632        wrapped.clamp(
1633            self.rows.max(1) as f32,
1634            self.max_rows.map_or(f32::INFINITY, |max| max as f32),
1635        )
1636    }
1637
1638    /// The box's height in a widget `width` wide; see [`Self::lines_at`].
1639    fn field_height_at(&self, width: f32) -> f32 {
1640        self.box_height(self.lines_at(width))
1641    }
1642}
1643
1644impl<Msg: Clone + 'static> Widget<Msg> for TextField<Msg> {
1645    /// **All the room on offer** on an axis nobody sized, as the reference's takes
1646    /// (milestone 590) — unless it was told to grow, which says the same thing already.
1647    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
1648        if false {
1649            return crate::widget::FillAxes::NONE;
1650        }
1651        crate::widget::FillAxes {
1652            horizontal: !self.sized,
1653            vertical: false,
1654        }
1655    }
1656
1657    /// The default size is only a default: the room replaces it wherever the layout can
1658    /// give the room, and it stands where the reference would have nothing to give.
1659    fn soft_extent(&self) -> crate::widget::FillAxes {
1660        crate::widget::FillAxes {
1661            horizontal: !self.sized,
1662            vertical: false,
1663        }
1664    }
1665
1666    fn style(&self) -> Style {
1667        // A box that grows is as tall as its value makes it, which only the width says: it
1668        // is measured instead (milestone 657).
1669        let height = if self.grows() {
1670            Dimension::Auto
1671        } else {
1672            Dimension::Length(self.label_block() + self.field_height() + self.sub_block())
1673        };
1674        Style {
1675            width: self.width,
1676            height,
1677            ..Default::default()
1678        }
1679    }
1680
1681    fn measure(&self, _theme: &Theme) -> Option<frus_layout::MeasureFn<'_>> {
1682        if !self.grows() {
1683            return None;
1684        }
1685        Some(Box::new(move |width, _| {
1686            let width = width.unwrap_or(match self.width {
1687                Dimension::Length(w) => w,
1688                _ => 220.0,
1689            });
1690            // Whole pixels, rounded up: the label and helper bands are fractions of a
1691            // line, and a box rounded down is one the field no longer fits in.
1692            frus_core::Size::new(
1693                width,
1694                frus_core::fits(
1695                    self.label_block() + self.field_height_at(width) + self.sub_block(),
1696                ),
1697            )
1698        }))
1699    }
1700
1701    fn measure_key(&self, _theme: &Theme) -> Option<u64> {
1702        if !self.grows() {
1703            return None;
1704        }
1705        use std::hash::{Hash, Hasher};
1706        let mut hasher = std::collections::hash_map::DefaultHasher::new();
1707        self.display().hash(&mut hasher);
1708        self.rows.hash(&mut hasher);
1709        self.max_rows.hash(&mut hasher);
1710        self.size.to_bits().hash(&mut hasher);
1711        // Everything that moves the wrap or the bands around the box, as numbers: a key
1712        // listing the builders would miss the next one to change them.
1713        for part in [
1714            self.lead(),
1715            self.pad_x(),
1716            self.suffix_w(),
1717            self.text_top(),
1718            self.pad_bottom(),
1719            self.label_block(),
1720            self.sub_block(),
1721        ] {
1722            part.to_bits().hash(&mut hasher);
1723        }
1724        Some(hasher.finish())
1725    }
1726
1727    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1728        &[]
1729    }
1730
1731    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
1732        let s = self.resolved(theme);
1733        // A disabled field keeps 38% of everything, following the reference, and never
1734        // shows a focus: it cannot be focused, so a focus ring left over from before it
1735        // was disabled would be a lie about what Tab will do.
1736        let o = if self.enabled {
1737            status.opacity
1738        } else {
1739            status.opacity * FIELD_DISABLED_OPACITY
1740        };
1741        let has_error = self.error.is_some() && self.enabled;
1742        let fp = if self.enabled {
1743            status.focus_progress.clamp(0.0, 1.0)
1744        } else {
1745            0.0
1746        };
1747        // **An errored field deepens under the pointer** (`input_decorator.dart:5981`,
1748        // `:6004`, `:6053`): `error` at rest, `on_error_container` while hovered — and
1749        // back to `error` once focused, because the reference tests focus **before**
1750        // hover and a focused field is already saying everything it can.
1751        //
1752        // Continuous where the reference is discrete, which is this framework's habit
1753        // with the pointer: `hover_progress` is a progression, not a flag.
1754        let error_ink = s.error_color.unwrap().lerp(s.error_hover_color.unwrap(), {
1755            let hover = if self.enabled {
1756                status.hover_progress.clamp(0.0, 1.0)
1757            } else {
1758                0.0
1759            };
1760            hover * (1.0 - fp)
1761        });
1762
1763        // Decoration: label above, input box in the middle, helper/error below. The
1764        // box is the sub-rectangle where all the editing lives.
1765        let label_block = self.label_block();
1766        let gutter = self.gutter();
1767        let field = Rect::new(
1768            bounds.x + gutter,
1769            bounds.y + label_block,
1770            (bounds.width - gutter).max(0.0),
1771            self.field_height_at(bounds.width),
1772        );
1773        // The icon outside the box, centred on it.
1774        if let Some(icon) = self.icon {
1775            let side = self.outer_icon_size();
1776            let path = icon.placed(
1777                side,
1778                bounds.x,
1779                field.y + (field.height - side) * 0.5,
1780                theme.direction,
1781            );
1782            scene.fill_path(&path, s.icon_color.unwrap().fade(o));
1783        }
1784
1785        // **Floating** label: at rest (an empty, unfocused field) it occupies the box,
1786        // in the hint's place; focused OR filled, it floats above, shrunk. The
1787        // position/size progress follows that "floating" (`t`); the **colour** follows
1788        // the focus (`fp`) — a filled but unfocused field keeps a discreet label, not
1789        // yet accented.
1790        let float_t = self.float_progress(fp, theme);
1791        // A label that never floats is a hint: gone once the field holds something.
1792        let label_shown = !(self.floating_behavior(theme) == FloatingLabelBehavior::Never
1793            && !self.value.is_empty());
1794        let content_left = bounds.x + self.lead();
1795        // The label at rest sits where the value starts, after the prefix icon; the prefix
1796        // text, hidden while it rests, is no part of it.
1797        let label_rest = field.x + self.pad_x() + self.prefix_icon_w();
1798        let centred = self
1799            .floating_alignment
1800            .or(theme.widgets.text_field.floating_label_alignment)
1801            .unwrap_or_default()
1802            == FloatingLabelAlignment::Center;
1803        // Geometry of the floating label, interpolated between **rest** (inside the box,
1804        // in the hint's place) and the **floated target**. That target differs with the
1805        // style: `outlined` → on the top border (with a notch); otherwise → a reserved band.
1806        let label_geom = self.label.as_ref().filter(|_| label_shown).map(|label| {
1807            let rest = Point::new(label_rest, field.y + self.text_top());
1808            let (fx, fy) = if self.is_outlined() {
1809                // On the top border, which the notch opens behind it.
1810                (
1811                    field.x + self.pad_x(),
1812                    field.y - frus_text::line_height(self.label_size()) * 0.5,
1813                )
1814            } else {
1815                // Inside the container, in the room reserved above the content — a filled
1816                // field never spills outside its own box.
1817                (label_rest, field.y + self.pad_top())
1818            };
1819            // Centred, the floated label is centred on the box, at its floated size.
1820            let fx = if centred {
1821                let floated = frus_text::measure_resolved(
1822                    label,
1823                    &ResolvedTextStyle::exact(self.label_size()),
1824                )
1825                .width;
1826                field.x + (field.width - floated) * 0.5
1827            } else {
1828                fx
1829            };
1830            let x = rest.x + (fx - rest.x) * float_t;
1831            let y = rest.y + (fy - rest.y) * float_t;
1832            let resolved = self.text_style().size;
1833            let size = resolved + (self.label_size() - resolved) * float_t;
1834            let color = if has_error {
1835                error_ink
1836            } else {
1837                s.label_color
1838                    .unwrap()
1839                    .lerp(s.focused_label_color.unwrap(), fp)
1840            };
1841            (label.clone(), x, y, size, color)
1842        });
1843        // Helper/error line below the box (the error takes precedence over the helper).
1844        let sub = self.error.as_ref().or(self.helper.as_ref());
1845        if let Some(sub) = sub {
1846            // The message itself does **not** deepen: `errorStyle` is `error` in every
1847            // state (`input_decorator.dart:6100`). It is a sentence, not a control, and
1848            // a sentence that changed colour under the pointer would be claiming to be
1849            // one.
1850            let color = if has_error {
1851                s.error_color.unwrap()
1852            } else {
1853                s.helper_color.unwrap()
1854            };
1855            scene.text(
1856                Point::new(field.x, field.y + field.height + FIELD_GAP),
1857                sub.clone(),
1858                &self.sub_style(),
1859                color.fade(o),
1860            );
1861        }
1862        // The counter shares that line, pushed to its far end — the reference's place for
1863        // it, and the one that leaves the helper text where it was. It takes the helper's
1864        // colour rather than the error's even while an error is showing: it is a fact
1865        // about length, not a second complaint.
1866        if let Some(counter) = self.counter() {
1867            let width = frus_text::measure_resolved(&counter, &self.sub_style()).width;
1868            scene.text(
1869                Point::new(
1870                    bounds.x + bounds.width - width,
1871                    field.y + field.height + FIELD_GAP,
1872                ),
1873                counter,
1874                &self.sub_style(),
1875                s.helper_color.unwrap().fade(o),
1876            );
1877        }
1878
1879        // The border follows the focus in **two** ways, as the reference does: the colour
1880        // moves to the accent and the weight goes from 1 to 2. Either alone is legible;
1881        // both together is what makes a focused field unmistakable without relying on
1882        // colour, which not every reader has.
1883        let border_color = if has_error {
1884            error_ink
1885        } else {
1886            s.border_color
1887                .unwrap()
1888                .lerp(s.focused_border_color.unwrap(), fp)
1889        }
1890        .fade(o);
1891        let rest_w = s.border_width.unwrap();
1892        let border_width = rest_w + (s.focused_border_width.unwrap() - rest_w) * fp;
1893        let radius = s.radius.unwrap();
1894        let fill = s.fill.unwrap();
1895        if self.is_borderless() {
1896            // **Nothing.** The container belongs to whatever this field was put inside.
1897        } else if self.is_outlined() {
1898            scene.draw_rect(field, fill.fade(o), radius, border_width, border_color);
1899        } else {
1900            // Filled: a container with its **top** corners rounded, and a single line
1901            // under it. A box all the way round would be the other variant wearing a fill.
1902            scene.draw_rect(
1903                field,
1904                fill.fade(o),
1905                frus_core::BorderRadius {
1906                    top_left: radius,
1907                    top_right: radius,
1908                    bottom_right: 0.0,
1909                    bottom_left: 0.0,
1910                },
1911                0.0,
1912                frus_core::Color::TRANSPARENT,
1913            );
1914            scene.fill_rect(
1915                Rect::new(
1916                    field.x,
1917                    field.y + field.height - border_width,
1918                    field.width,
1919                    border_width,
1920                ),
1921                border_color,
1922            );
1923        }
1924
1925        // The label goes **after** the box, in both styles. Floated, it sits above the
1926        // box and the order would not matter; at rest it sits *inside* it, in the
1927        // hint's place, over an opaque surface. It used to be painted first and
1928        // survived only because the renderer drew all text above everything — which it
1929        // stopped doing in milestone 295, and the golden went blank.
1930        if !self.is_outlined() {
1931            if let Some((label, x, y, size, color)) = &label_geom {
1932                // `exact`: `size` is already an interpolation between two **resolved**
1933                // numbers, so resolving it again would apply the reader's setting twice.
1934                let style = ResolvedTextStyle::exact(*size);
1935                scene.text(Point::new(*x, *y), label.clone(), &style, color.fade(o));
1936            }
1937        }
1938
1939        // Outlined: the label's **notch**. The border segment behind the floating label
1940        // is masked by a flat surface-coloured fill, then the label is painted on top.
1941        // The notch only opens as the label rises (`float_t`).
1942        if self.is_outlined() {
1943            if let Some((label, x, y, size, color)) = &label_geom {
1944                if float_t > 0.01 {
1945                    let label_w = frus_text::measure(label, self.label_size()).width;
1946                    let notch = Rect::new(
1947                        *x - FIELD_NOTCH_GAP,
1948                        field.y - (border_width + FIELD_NOTCH_GAP) * 0.5,
1949                        label_w + FIELD_NOTCH_GAP * 2.0,
1950                        border_width + FIELD_NOTCH_GAP,
1951                    );
1952                    scene.fill_rect(notch, theme.surface.fade(o * float_t));
1953                }
1954                scene.text(
1955                    Point::new(*x, *y),
1956                    label.clone(),
1957                    &ResolvedTextStyle::exact(*size),
1958                    color.fade(o),
1959                );
1960            }
1961        }
1962
1963        // Decorative icons, vertically centred in the box (a discreet colour).
1964        let icon_color = s.icon_color.unwrap().fade(o);
1965        let icon_y = field.y + (field.height - self.icon_size()) * 0.5;
1966        if let Some(prefix) = self.prefix {
1967            let path = prefix.placed(
1968                self.icon_size(),
1969                field.x + ICON_PAD,
1970                icon_y,
1971                theme.direction,
1972            );
1973            scene.fill_path(&path, icon_color);
1974        }
1975        if let Some(suffix) = self.suffix {
1976            let x = field.x + field.width - self.icon_size() - ICON_PAD;
1977            // Highlight (milestone 208): a discreet halo behind the **clickable** suffix when
1978            // the pointer hovers it (the absolute position is brought back to local through
1979            // `bounds`). Purely visual.
1980            if self.suffix_action.is_some() {
1981                if let Some(hc) = status.hover_cursor {
1982                    if self.suffix_hit(hc.x - bounds.x, hc.y - bounds.y, bounds.width) {
1983                        let halo = Rect::new(
1984                            x - 4.0,
1985                            icon_y - 4.0,
1986                            self.icon_size() + 8.0,
1987                            self.icon_size() + 8.0,
1988                        );
1989                        scene.draw_rect(
1990                            halo,
1991                            theme.muted.fade(o * 0.18),
1992                            halo.height * 0.5,
1993                            0.0,
1994                            frus_core::Color::TRANSPARENT,
1995                        );
1996                    }
1997                }
1998            }
1999            let path = suffix.placed(self.icon_size(), x, icon_y, theme.direction);
2000            scene.fill_path(&path, icon_color);
2001        }
2002
2003        let len = self.value.chars().count();
2004        // The content is inset between the prefix/suffix icons, where there are any.
2005        let content_x = content_left;
2006        let content_w = self.content_width(bounds.width);
2007        let text_y = field.y + self.text_top();
2008        // Multi-line: the text **wraps** at the content width — the same `max_width` for
2009        // the measure (caret/hit) and for the render → identical wraps.
2010        let wrap = if self.multiline {
2011            Some(content_w)
2012        } else {
2013            None
2014        };
2015        let layout = self.layout(wrap);
2016        // The alignment's push, worked out once and used by the placeholder, the text,
2017        // the selection, the underline and the caret — every one of which is drawn from
2018        // `text_x` below.
2019        let align = self.align_offset(content_w, layout.size().width);
2020
2021        // Hint (placeholder): displayed when the field is empty. If there is a label as
2022        // well, the hint only reveals itself (fading in) once the label has floated —
2023        // otherwise the two would overlap inside the box.
2024        if self.value.is_empty() {
2025            if let Some(placeholder) = &self.placeholder {
2026                let ph_alpha = if self.label.is_some() { o * float_t } else { o };
2027                if ph_alpha > 0.01 {
2028                    // The placeholder sits where the text will: a centred field whose
2029                    // hint hugs the left edge jumps the moment the first key lands.
2030                    let style = self.text_style();
2031                    let hint_w = frus_text::measure_resolved(placeholder, &style).width;
2032                    let hint_align = self.align_offset(content_w, hint_w);
2033                    scene.text(
2034                        Point::new(content_x + hint_align, text_y),
2035                        placeholder.clone(),
2036                        &style,
2037                        theme.muted.fade(ph_alpha),
2038                    );
2039                }
2040            }
2041        }
2042
2043        // The affixes, in the hint's colour, shown as the label floats — or, with no label,
2044        // once the field is focused or holds something (`input_decorator.dart:1830`).
2045        let affix_alpha = if self.label.is_some() {
2046            float_t
2047        } else if self.value.is_empty() {
2048            match self.floating_behavior(theme) {
2049                FloatingLabelBehavior::Always => 1.0,
2050                _ => fp,
2051            }
2052        } else {
2053            1.0
2054        } * o;
2055        if affix_alpha > 0.01 {
2056            let style = self.text_style();
2057            if let Some(prefix) = &self.prefix_text {
2058                scene.text(
2059                    Point::new(field.x + self.pad_x() + self.prefix_icon_w(), text_y),
2060                    prefix.clone(),
2061                    &style,
2062                    theme.muted.fade(affix_alpha),
2063                );
2064            }
2065            if let Some(suffix) = &self.suffix_text {
2066                let w = self.affix_w(&self.suffix_text);
2067                scene.text(
2068                    Point::new(
2069                        field.x + field.width - self.pad_x() - self.suffix_icon_w() - w,
2070                        text_y,
2071                    ),
2072                    suffix.clone(),
2073                    &style,
2074                    theme.muted.fade(affix_alpha),
2075                );
2076            }
2077        }
2078
2079        // Scrolling to keep the caret visible: horizontal (always) and vertical
2080        // (multi-line). Recomputed from the cursor, as on click (`cursor_at`).
2081        let cursor = status.cursor.unwrap_or(len).min(len);
2082        let caret = layout.caret_rect(cursor);
2083        let scroll = if status.focused {
2084            (caret.x - content_w).max(0.0)
2085        } else {
2086            0.0
2087        };
2088        let text_x = content_x + align - scroll;
2089        // **Retained** vertical scroll (wheel/scrollbar, caret-following by the shell);
2090        // clamped to how far the content overflows the box.
2091        let vscroll = if self.multiline {
2092            let overflow = (layout.size().height
2093                - (field.height - self.text_top() - self.pad_bottom()))
2094            .max(0.0);
2095            status.scroll_y.clamp(0.0, overflow)
2096        } else {
2097            0.0
2098        };
2099        // Vertical origin of the content (offset by the multi-line scroll).
2100        let text_top = text_y - vscroll;
2101
2102        // Clipped to the **content** frame — inside the padding, not the whole box.
2103        // Clipping to the box let scrolled multi-line text ride up onto the top border,
2104        // which is where an outlined field's floating label lives: the third line of a
2105        // scrolled field was painted straight over the label naming it. It only became
2106        // visible once the padding grew to the reference's, which is the useful kind of
2107        // regression — the old 6 px hid it rather than avoided it.
2108        let outer_clip = scene.current_clip();
2109        let content_clip = outer_clip.intersect(Rect::new(
2110            content_x,
2111            field.y + self.text_top(),
2112            content_w,
2113            (field.height - self.text_top() - self.pad_bottom()).max(0.0),
2114        ));
2115        scene.set_clip(content_clip);
2116
2117        // Selection highlight (below the text).
2118        if status.focused {
2119            if let Some((start, end)) = status.selection {
2120                for r in layout.selection_rects(start.min(len), end.min(len)) {
2121                    scene.fill_rect(
2122                        Rect::new(text_x + r.x, text_top + r.y, r.width, r.height),
2123                        theme.selection.fade(o),
2124                    );
2125                }
2126            }
2127        }
2128
2129        if !self.value.is_empty() {
2130            let pos = Point::new(text_x, text_top);
2131            let color = theme.on_surface.fade(o);
2132            match wrap {
2133                // Multi-line: the render wraps just as the measure did.
2134                Some(max_w) => {
2135                    scene.text_wrapped(pos, self.display(), &self.text_style(), color, max_w)
2136                }
2137                None => scene.text(pos, self.display(), &self.text_style(), color),
2138            }
2139        }
2140
2141        // The IME **composition** region: underlined (provisional text). Drawn over the
2142        // text, below the baseline.
2143        if status.focused {
2144            if let Some((start, end)) = status.composing {
2145                let (start, end) = (start.min(len), end.min(len));
2146                for r in layout.selection_rects(start, end) {
2147                    scene.fill_rect(
2148                        Rect::new(text_x + r.x, text_top + r.y + r.height - 1.5, r.width, 1.5),
2149                        theme.on_surface.fade(o * 0.7),
2150                    );
2151                }
2152            }
2153        }
2154
2155        // The caret, in the shown half of its blink (milestone 513).
2156        // In the reference's colour, `primary`, and the error's while one shows; its shape the
2157        // caller's or the platform's (milestone 659).
2158        if status.focused && !status.caret_hidden && self.cursor.shown {
2159            let color = if has_error {
2160                s.cursor_error_color.unwrap()
2161            } else {
2162                s.cursor_color.unwrap()
2163            };
2164            let height = self.cursor.height.unwrap_or(caret.height);
2165            let radius =
2166                self.cursor
2167                    .radius
2168                    .unwrap_or(if theme.platform.is_apple() { 2.0 } else { 0.0 });
2169            scene.draw_rect(
2170                Rect::new(
2171                    text_x + caret.x,
2172                    text_top + caret.y + (caret.height - height) * 0.5,
2173                    self.cursor.width,
2174                    height,
2175                ),
2176                color.fade(o),
2177                radius,
2178                0.0,
2179                Color::TRANSPARENT,
2180            );
2181        }
2182
2183        // The handles of a touch selection (milestone 511), outside the content clip:
2184        // they hang below their line, past the text and, on the last line, past the box.
2185        if status.focused && status.handles {
2186            scene.set_clip(outer_clip);
2187            let edit = Edit {
2188                cursor,
2189                anchor: status
2190                    .selection
2191                    .map(|(a, b)| if cursor == a { b } else { a }),
2192                composing: None,
2193            };
2194            if let Some(handles) = self.handles(bounds.width, &edit, status.scroll_y) {
2195                let color = s.handle_color.unwrap().fade(o);
2196                paint_handles(scene, handles, Point::new(bounds.x, bounds.y), color);
2197            }
2198        }
2199    }
2200
2201    fn on_click(&self) -> Option<Msg> {
2202        None
2203    }
2204
2205    fn positional_click(
2206        &self,
2207        local_x: f32,
2208        local_y: f32,
2209        width: f32,
2210        _height: f32,
2211    ) -> Option<Msg> {
2212        if !self.enabled {
2213            return None;
2214        }
2215        if self.suffix_action.is_some() && self.suffix_hit(local_x, local_y, width) {
2216            self.suffix_action.clone()
2217        } else {
2218            None
2219        }
2220    }
2221
2222    fn cursor_icon(
2223        &self,
2224        local_x: f32,
2225        local_y: f32,
2226        width: f32,
2227        _height: f32,
2228    ) -> Option<crate::interaction::Cursor> {
2229        // A hand over the **active** suffix icon (clear / reveal); elsewhere, no opinion.
2230        if self.suffix_action.is_some() && self.suffix_hit(local_x, local_y, width) {
2231            Some(crate::interaction::Cursor::Pointer)
2232        } else {
2233            None
2234        }
2235    }
2236
2237    fn on_edit(&self, edit: &mut Edit, key: &Key) -> Option<Msg> {
2238        // A disabled field cannot be focused, so this should be unreachable — but a key
2239        // arriving from a stale focus must not edit a value the caller has declared
2240        // untouchable.
2241        if !self.enabled {
2242            return None;
2243        }
2244        let mut chars: Vec<char> = self.value.chars().collect();
2245        let len = chars.len();
2246        let mut cursor = edit.cursor.min(len);
2247        let mut anchor = edit.anchor.map(|a| a.min(len));
2248        let selection = anchor
2249            .map(|a| (a.min(cursor), a.max(cursor)))
2250            .filter(|(s, e)| s < e);
2251
2252        let mut changed = false;
2253
2254        match key {
2255            Key::Text(text) => {
2256                // The filter, one character at a time: a rejected one never arrives and
2257                // a substituted one takes the same place, so the caret arithmetic below
2258                // is the arithmetic it always was.
2259                let inserted: Vec<char> = match &self.input_filter {
2260                    Some(filter) => text.chars().filter_map(filter).collect(),
2261                    None => text.chars().collect(),
2262                };
2263                // Nothing typed and nothing selected is nothing done: emitting the value
2264                // unchanged would rebuild the tree for a keystroke that was refused.
2265                // A selection **is** something done, even when what replaces it is
2266                // empty, since the reference filters the value after the replacement.
2267                if selection.is_some() || !inserted.is_empty() {
2268                    if let Some((s, e)) = selection {
2269                        chars.drain(s..e);
2270                        cursor = s;
2271                    }
2272                    let n = inserted.len();
2273                    chars.splice(cursor..cursor, inserted);
2274                    cursor += n;
2275                    anchor = None;
2276                    changed = true;
2277                }
2278            }
2279            Key::Backspace => {
2280                if let Some((s, e)) = selection {
2281                    chars.drain(s..e);
2282                    cursor = s;
2283                    changed = true;
2284                } else if cursor > 0 {
2285                    chars.remove(cursor - 1);
2286                    cursor -= 1;
2287                    changed = true;
2288                }
2289                anchor = None;
2290            }
2291            Key::Delete => {
2292                if let Some((s, e)) = selection {
2293                    chars.drain(s..e);
2294                    cursor = s;
2295                    changed = true;
2296                } else if cursor < len {
2297                    chars.remove(cursor);
2298                    changed = true;
2299                }
2300                anchor = None;
2301            }
2302            // The caret's own keys: an arrow, a word under Ctrl, the ends of the line — of the
2303            // whole field under Ctrl, which in a single-line field is the same.
2304            Key::Left { .. } | Key::Right { .. } | Key::Home { .. } | Key::End { .. } => {
2305                move_caret(&chars, &mut cursor, &mut anchor, key);
2306            }
2307            // Escape is none of editing's business (routed leaf→root by the shell).
2308            Key::Escape => {}
2309            Key::Enter if self.multiline => {
2310                // Multi-line: Enter **inserts a line break** (no submission).
2311                if let Some((s, e)) = selection {
2312                    chars.drain(s..e);
2313                    cursor = s;
2314                }
2315                chars.insert(cursor, '\n');
2316                cursor += 1;
2317                anchor = None;
2318                changed = true;
2319            }
2320            Key::Enter => {
2321                // Single-line: Enter submits — it does not alter the value, it emits the submission.
2322                edit.cursor = cursor;
2323                edit.anchor = anchor;
2324                return self.on_submit.clone();
2325            }
2326        }
2327
2328        // Read-only: everything that only **moves** has already happened -- the caret,
2329        // the selection, the word jumps -- and is kept. What is refused is the change,
2330        // which is the whole of the difference between this and a disabled field, where
2331        // there is no caret to move in the first place.
2332        if self.read_only && changed {
2333            return None;
2334        }
2335
2336        // The limit, enforced the way the reference enforces it: what fits lands and the
2337        // rest does not. Dropping a whole paste for being one character too long would
2338        // lose work the user can see they had, and refusing the keystroke that crosses
2339        // the limit is the same rule seen one character at a time.
2340        //
2341        // A value the **caller** supplied over the limit is left alone: it is the
2342        // application's state, not something typed, and silently shortening it would be
2343        // editing a value nobody edited. The counter shows it over.
2344        if changed {
2345            if let Some(max) = self.max_length {
2346                if chars.len() > max {
2347                    chars.truncate(max);
2348                    cursor = cursor.min(max);
2349                    anchor = anchor.map(|a| a.min(max));
2350                }
2351            }
2352        }
2353
2354        edit.cursor = cursor;
2355        edit.anchor = anchor;
2356
2357        if changed {
2358            let new_value: String = chars.into_iter().collect();
2359            self.on_input.as_ref().map(|make| make(new_value))
2360        } else {
2361            None
2362        }
2363    }
2364
2365    fn ime(&self) -> Ime {
2366        self.ime_options()
2367    }
2368
2369    fn cursor_at(
2370        &self,
2371        local_x: f32,
2372        local_y: f32,
2373        width: f32,
2374        scroll_cursor: usize,
2375    ) -> Option<usize> {
2376        // A disabled field takes no caret: it is readable, not editable, and a caret
2377        // sitting in it would promise otherwise.
2378        if !self.enabled {
2379            return None;
2380        }
2381        // A click on the **clickable suffix icon**: do not place a caret (the shell will
2382        // emit its message through `positional_click`).
2383        if self.suffix_action.is_some() && self.suffix_hit(local_x, local_y, width) {
2384            return None;
2385        }
2386        // Rebuilds the same content geometry as the render (icon and decoration insets,
2387        // wrapping, horizontal scroll), for an exact click. The **retained vertical
2388        // scroll** is already folded into `local_y` by the shell.
2389        let left = self.lead();
2390        let content_w = self.content_width(width);
2391        let layout = self.layout(if self.multiline {
2392            Some(content_w)
2393        } else {
2394            None
2395        });
2396        let scroll = (layout.caret_rect(scroll_cursor).x - content_w).max(0.0);
2397        // The same push the paint applied, from the same function. Without it a click on
2398        // centred or right-aligned text lands wherever the glyphs *would* have been
2399        // against the left edge, which is a caret appearing several characters from the
2400        // tap.
2401        let align = self.align_offset(content_w, layout.size().width);
2402        // `local_*` are relative to the **widget's** top-left corner (label included):
2403        // the label band and the padding are removed to land inside the text.
2404        let target_x = local_x - left - align + scroll;
2405        let target_y = local_y - self.label_block() - self.text_top();
2406        Some(layout.hit_test(Point::new(target_x, target_y)))
2407    }
2408
2409    fn text_metrics(&self, width: f32, cursor: usize) -> Option<(f32, f32, f32, f32)> {
2410        if !self.multiline {
2411            return None;
2412        }
2413        let layout = self.layout(Some(self.content_width(width)));
2414        let caret = layout.caret_rect(cursor);
2415        let visible = self.field_height_at(width) - self.text_top() - self.pad_bottom();
2416        Some((layout.size().height, visible, caret.y, caret.height))
2417    }
2418
2419    fn text_viewport(&self, rect: Rect) -> Option<Rect> {
2420        if !self.multiline {
2421            return None;
2422        }
2423        // The input box: below the label, as tall as the `rows`.
2424        Some(Rect::new(
2425            rect.x,
2426            rect.y + self.label_block(),
2427            rect.width,
2428            self.field_height_at(rect.width),
2429        ))
2430    }
2431
2432    fn caret_vertical(
2433        &self,
2434        width: f32,
2435        cursor: usize,
2436        down: bool,
2437        page: bool,
2438        goal_x: Option<f32>,
2439    ) -> Option<(usize, f32)> {
2440        if !self.multiline {
2441            return None;
2442        }
2443        let layout = self.layout(Some(self.content_width(width)));
2444        let caret = layout.caret_rect(cursor);
2445        let line_h = caret.height;
2446        // Wanted column: the remembered target, otherwise the current column.
2447        let x = goal_x.unwrap_or(caret.x);
2448        // A step of one line, or of one page (the field's visible height, at least 1 line).
2449        let step = if page {
2450            (self.field_height_at(width) - self.text_top() - self.pad_bottom()).max(line_h)
2451        } else {
2452            line_h
2453        };
2454        let center = caret.y + line_h * 0.5;
2455        let target_y = if down { center + step } else { center - step };
2456        let full = layout.size().height;
2457        if page {
2458            // Page: clamped to the field (it is never left).
2459            let clamped = target_y.clamp(0.0, (full - line_h * 0.5).max(0.0));
2460            Some((layout.hit_test(Point::new(x, clamped)), x))
2461        } else {
2462            // Line: already on the first/last one → the shell moves the focus.
2463            if target_y < 0.0 || target_y >= full {
2464                return None;
2465            }
2466            Some((layout.hit_test(Point::new(x, target_y)), x))
2467        }
2468    }
2469
2470    fn text_value(&self) -> Option<&str> {
2471        Some(&self.value)
2472    }
2473
2474    /// A field that is read-only, or that cannot be focused, takes no typing.
2475    fn takes_typing(&self) -> bool {
2476        self.enabled && !self.read_only
2477    }
2478
2479    fn replace_value(&self, value: String) -> Option<Msg> {
2480        // The same two refusals `on_edit` makes, for the same reason: a field the caller
2481        // has declared untouchable does not become touchable because the value being put
2482        // back came out of it. No filter and no length limit, though — this value passed
2483        // both on its way in, and running it through them again would let an undo land
2484        // somewhere the field has never been.
2485        if !self.enabled || self.read_only {
2486            return None;
2487        }
2488        self.on_input.as_ref().map(|make| make(value))
2489    }
2490
2491    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
2492        let mut s = frus_core::SemanticsProperties::new(frus_core::Role::TextInput)
2493            .value(self.value.clone());
2494        // The label names the field; an error is appended to it so it gets read out.
2495        let label = match (&self.label, &self.error) {
2496            (Some(l), Some(e)) => Some(format!("{l}, {e}")),
2497            (Some(l), None) => Some(l.clone()),
2498            (None, Some(e)) => Some(e.clone()),
2499            (None, None) => None,
2500        };
2501        if let Some(label) = label {
2502            s = s.label(label);
2503        }
2504        if !self.enabled {
2505            s = s.disabled(true);
2506        }
2507        Some(s)
2508    }
2509
2510    fn selected_text(&self, edit: &Edit) -> Option<String> {
2511        // A masked field gives its text to nobody: what is shown is dots, and what is
2512        // copied must not be the thing they hide.
2513        if self.obscure {
2514            return None;
2515        }
2516        let chars: Vec<char> = self.value.chars().collect();
2517        let len = chars.len();
2518        let cursor = edit.cursor.min(len);
2519        let anchor = edit.anchor?.min(len);
2520        let (start, end) = (anchor.min(cursor), anchor.max(cursor));
2521        if start < end {
2522            Some(chars[start..end].iter().collect())
2523        } else {
2524            None
2525        }
2526    }
2527
2528    fn word_at(&self, index: usize) -> Option<(usize, usize)> {
2529        let chars: Vec<char> = self.value.chars().collect();
2530        word_range(&chars, index)
2531    }
2532
2533    fn selection_handles(
2534        &self,
2535        width: f32,
2536        edit: &Edit,
2537        scroll_y: f32,
2538    ) -> Option<[crate::SelectionHandle; 2]> {
2539        self.handles(width, edit, scroll_y)
2540    }
2541
2542    fn selection_toolbar(&self, context: ToolbarContext) -> Option<&dyn Widget<Msg>> {
2543        self.toolbars[usize::from(context.variant())]
2544            .get_or_init(|| {
2545                let defaults = self.default_toolbar_items(&context);
2546                let items = match &self.toolbar_build {
2547                    Some(build) => build(&context, defaults),
2548                    None => defaults,
2549                };
2550                (!items.is_empty()).then(|| {
2551                    let bar: Box<dyn Widget<Msg>> = Box::new(SelectionToolbar::new(items));
2552                    bar
2553                })
2554            })
2555            .as_deref()
2556    }
2557
2558    fn selection_anchor(&self, width: f32, edit: &Edit, scroll_y: f32) -> Option<Rect> {
2559        Some(self.selection_box(width, edit, scroll_y))
2560    }
2561
2562    fn autofill_hints(&self) -> &[AutofillHint] {
2563        &self.autofill
2564    }
2565
2566    fn autofocus(&self) -> bool {
2567        self.autofocus && self.enabled
2568    }
2569
2570    fn on_tap_outside(&self) -> Option<Msg> {
2571        self.on_tap_outside.clone()
2572    }
2573
2574    fn focusable(&self) -> bool {
2575        self.enabled
2576    }
2577
2578    fn draws_own_focus(&self) -> bool {
2579        true
2580    }
2581
2582    fn announce(&self) -> Option<String> {
2583        self.announce.clone()
2584    }
2585}
2586
2587#[cfg(test)]
2588mod tests {
2589    use super::*;
2590    use crate::icons::Icons;
2591
2592    #[derive(Clone, Debug, PartialEq)]
2593    enum Msg {
2594        Changed(String),
2595        Submitted,
2596    }
2597
2598    fn input(value: &str) -> TextField<Msg> {
2599        TextField::new(value).on_input(Msg::Changed)
2600    }
2601
2602    /// Every rectangle a field paints, in order.
2603    fn rects(field: &TextField<Msg>, theme: &Theme, w: f32, h: f32) -> Vec<frus_core::Primitive> {
2604        let mut scene = Scene::new();
2605        Widget::<Msg>::paint(
2606            field,
2607            Rect::new(0.0, 0.0, w, h),
2608            Status::default(),
2609            theme,
2610            &mut scene,
2611        );
2612        scene
2613            .primitives()
2614            .iter()
2615            .filter(|p| matches!(p, frus_core::Primitive::Rect { .. }))
2616            .cloned()
2617            .collect()
2618    }
2619
2620    /// The reference's content padding is `(12, 8, 12, 8)` filled and `(12, 20, 12, 12)`
2621    /// outlined, and the outlined asymmetry is the room its floating label takes on the
2622    /// border. A field measured any other way is a field that does not line up beside
2623    /// anything else drawn to the same specification.
2624    #[test]
2625    fn a_field_is_the_references_size() {
2626        let line = input("x").text_style().line_height();
2627        let outlined = input("x");
2628        assert_eq!(
2629            outlined.field_height(),
2630            (line + FIELD_OUTLINED_PADDING_TOP + FIELD_OUTLINED_PADDING_BOTTOM).ceil()
2631        );
2632        // A filled field is tighter — until it carries a label, which it floats *inside*
2633        // the box and therefore has to make room for.
2634        let filled = input("x").filled();
2635        assert_eq!(filled.field_height(), (line + FIELD_PADDING_Y * 2.0).ceil());
2636        let labelled = input("x").filled().label("Name");
2637        assert_eq!(
2638            labelled.field_height() - filled.field_height(),
2639            (FIELD_GAP + FIELD_TEXT_SIZE * FIELD_LABEL_SCALE).ceil()
2640        );
2641        // The label shrinks by a proportion, not to a fixed size: a field given larger
2642        // text keeps the relationship.
2643        assert_eq!(input("x").size(32.0).label_size(), 24.0);
2644    }
2645
2646    /// A field is 56 px tall, which is right for a form and wrong inside a table row.
2647    /// `dense` is the reference's answer, and it gives back the padding without changing
2648    /// the shape, the label or the border.
2649    #[test]
2650    fn a_dense_field_gives_back_its_padding() {
2651        let line = input("x").text_style().line_height();
2652        assert_eq!(
2653            input("x").dense(true).field_height(),
2654            (line + FIELD_DENSE_OUTLINED_PADDING_TOP + FIELD_DENSE_OUTLINED_PADDING_BOTTOM).ceil()
2655        );
2656        assert_eq!(
2657            input("x").filled().dense(true).field_height(),
2658            (line + FIELD_DENSE_PADDING_Y * 2.0).ceil()
2659        );
2660        // It is only the room: a dense field is shorter than its roomy twin in both
2661        // variants, and never taller.
2662        for dense in [input("x").dense(true), input("x").filled().dense(true)] {
2663            let roomy = if dense.is_outlined() {
2664                input("x")
2665            } else {
2666                input("x").filled()
2667            };
2668            assert!(dense.field_height() < roomy.field_height());
2669        }
2670    }
2671
2672    /// A filled field is a container with **one line under it**; an outlined one is a box.
2673    /// Drawing a fill inside a stroked box on all four sides would be the other variant
2674    /// wearing a tint, which is the mistake this variant exists to avoid.
2675    #[test]
2676    fn a_filled_field_has_a_line_not_a_box() {
2677        let theme = Theme::default();
2678        let stroked = |field: &TextField<Msg>| {
2679            rects(field, &theme, 220.0, 80.0)
2680                .iter()
2681                .filter(|p| {
2682                    matches!(p, frus_core::Primitive::Rect { border_width, .. } if *border_width > 0.0)
2683                })
2684                .count()
2685        };
2686        assert_eq!(
2687            stroked(&input("x").outlined()),
2688            1,
2689            "outlined: one stroked box"
2690        );
2691        assert_eq!(stroked(&input("x").filled()), 0, "filled: nothing stroked");
2692        // ...and the line is a filled rectangle the width of the field, at its foot.
2693        let filled = input("x").filled();
2694        let height = filled.field_height();
2695        let has_underline = rects(&filled, &theme, 220.0, 80.0).iter().any(|p| {
2696            matches!(
2697                p,
2698                frus_core::Primitive::Rect { rect, border_width, .. }
2699                    if *border_width == 0.0
2700                        && (rect.width - 220.0).abs() < 0.5
2701                        && (rect.height - FIELD_BORDER_WIDTH).abs() < 0.5
2702                        && (rect.y + rect.height - height).abs() < 0.5
2703            )
2704        });
2705        assert!(has_underline, "a filled field is underlined");
2706    }
2707
2708    /// A disabled field keeps 38% of its colours and shows no focus — it cannot be
2709    /// focused, so a ring left over from before would say the wrong thing about Tab.
2710    #[test]
2711    fn a_disabled_field_dims_and_never_looks_focused() {
2712        let theme = Theme::default();
2713        let focused = Status {
2714            focus_progress: 1.0,
2715            ..Default::default()
2716        };
2717        let border = |field: &TextField<Msg>, status: Status| {
2718            let mut scene = Scene::new();
2719            Widget::<Msg>::paint(
2720                field,
2721                Rect::new(0.0, 0.0, 220.0, 80.0),
2722                status,
2723                &theme,
2724                &mut scene,
2725            );
2726            scene.primitives().iter().find_map(|p| match p {
2727                frus_core::Primitive::Rect {
2728                    border_width,
2729                    border_color,
2730                    ..
2731                } if *border_width > 0.0 => Some((*border_width, *border_color)),
2732                _ => None,
2733            })
2734        };
2735        let (live_w, live_c) = border(&input("x"), focused).unwrap();
2736        assert_eq!(
2737            live_w, FIELD_FOCUSED_BORDER_WIDTH,
2738            "focus widens the border"
2739        );
2740        assert_eq!(live_c.a, 1.0, "and it is fully opaque");
2741        let (dead_w, dead_c) = border(&input("x").enabled(false), focused).unwrap();
2742        assert_eq!(dead_w, FIELD_BORDER_WIDTH, "disabled: no focus weight");
2743        assert!(
2744            (dead_c.a - FIELD_DISABLED_OPACITY).abs() < 0.01,
2745            "disabled: dimmed to the reference's 38%, got {}",
2746            dead_c.a
2747        );
2748    }
2749
2750    /// Scrolled multi-line text must stay **inside the padding**, not merely inside the
2751    /// box: the top border is where an outlined field's floating label lives, and text
2752    /// clipped only to the box was painted straight over the label naming it.
2753    #[test]
2754    fn scrolled_text_is_clipped_below_the_label() {
2755        let theme = Theme::default();
2756        let field = input("one\ntwo\nthree\nfour\nfive\nsix")
2757            .rows(3)
2758            .label("Notes");
2759        let mut scene = Scene::new();
2760        Widget::<Msg>::paint(
2761            &field,
2762            Rect::new(0.0, 0.0, 220.0, 140.0),
2763            Status {
2764                scroll_y: 40.0,
2765                ..Default::default()
2766            },
2767            &theme,
2768            &mut scene,
2769        );
2770        let box_top = field.label_block();
2771        let clip = scene
2772            .primitives()
2773            .iter()
2774            .find_map(|p| match p {
2775                // The label and the helper are painted unclipped; the value is not, and
2776                // its clip is the one under test.
2777                frus_core::Primitive::Text { clip, .. } if clip.y > -1.0e6 => Some(*clip),
2778                _ => None,
2779            })
2780            .expect("the value is clipped");
2781        assert!(
2782            clip.y >= box_top + field.text_top() - 0.5,
2783            "the clip starts below the padding, not at the border: {} < {}",
2784            clip.y,
2785            box_top + field.text_top()
2786        );
2787    }
2788
2789    /// Greying a field out is not enough: it has to be out of the tab order, refuse a
2790    /// caret, and ignore a key that reaches it from a focus set before it was disabled.
2791    #[test]
2792    fn a_disabled_field_is_inert() {
2793        let live = input("hello");
2794        let dead = input("hello").enabled(false);
2795        assert!(Widget::<Msg>::focusable(&live));
2796        assert!(!Widget::<Msg>::focusable(&dead), "out of the tab order");
2797        assert!(
2798            Widget::<Msg>::cursor_at(&live, 40.0, 30.0, 220.0, 0).is_some(),
2799            "a live field takes a caret"
2800        );
2801        assert_eq!(
2802            Widget::<Msg>::cursor_at(&dead, 40.0, 30.0, 220.0, 0),
2803            None,
2804            "a disabled field takes none"
2805        );
2806        let mut edit = Edit::default();
2807        assert!(
2808            Widget::on_edit(&dead, &mut edit, &Key::Text("x".into())).is_none(),
2809            "and no key edits it"
2810        );
2811        let semantics = Widget::<Msg>::semantics(&dead).unwrap();
2812        assert!(semantics.disabled, "a reader is told it is disabled");
2813    }
2814
2815    /// `caller ?? theme ?? framework`, on a field that has all three to choose from.
2816    #[test]
2817    fn the_caller_outranks_the_theme_which_outranks_the_framework() {
2818        let radius = |field: &TextField<Msg>, theme: &Theme| {
2819            rects(field, theme, 220.0, 80.0)
2820                .iter()
2821                .find_map(|p| match p {
2822                    frus_core::Primitive::Rect {
2823                        border_width,
2824                        radius,
2825                        ..
2826                    } if *border_width > 0.0 => Some(radius.top_left),
2827                    _ => None,
2828                })
2829                .unwrap()
2830        };
2831        let plain = Theme::default();
2832        assert_eq!(radius(&input("x"), &plain), FIELD_RADIUS);
2833        let mut themed = Theme::default();
2834        themed.widgets.text_field.radius = Some(10.0);
2835        assert_eq!(radius(&input("x"), &themed), 10.0);
2836        let overridden = input("x").style(TextFieldStyle {
2837            radius: Some(2.0),
2838            ..Default::default()
2839        });
2840        assert_eq!(radius(&overridden, &themed), 2.0);
2841    }
2842
2843    #[test]
2844    fn text_is_clipped_to_content_box() {
2845        // Text longer than the field must be clipped to its content width (otherwise it
2846        // overflows onto the neighbouring widgets).
2847        let inp = input("a very long value that overflows the field width").width(100.0);
2848        let mut scene = Scene::new();
2849        Widget::<Msg>::paint(
2850            &inp,
2851            Rect::new(0.0, 0.0, 100.0, 30.0),
2852            Status::default(),
2853            &Theme::default(),
2854            &mut scene,
2855        );
2856        let clip = scene
2857            .primitives()
2858            .iter()
2859            .find_map(|p| match p {
2860                frus_core::Primitive::Text { clip, .. } => Some(*clip),
2861                _ => None,
2862            })
2863            .expect("text primitive");
2864        assert!(
2865            (clip.width - (100.0 - FIELD_PADDING_X * 2.0)).abs() < 0.5,
2866            "clip = {clip:?}"
2867        );
2868    }
2869
2870    #[test]
2871    fn composing_region_draws_an_underline() {
2872        // The composed region adds thin rectangles (the underline) below the text,
2873        // absent when no composition is in progress.
2874        let inp = input("konnichiwa");
2875        let base = Status {
2876            focused: true,
2877            cursor: Some(5),
2878            ..Default::default()
2879        };
2880        let composing = Status {
2881            composing: Some((0, 5)),
2882            ..base
2883        };
2884
2885        let count_thin_rects = |status: Status| {
2886            let mut scene = Scene::new();
2887            Widget::<Msg>::paint(
2888                &inp,
2889                Rect::new(0.0, 0.0, 220.0, 30.0),
2890                status,
2891                &Theme::default(),
2892                &mut scene,
2893            );
2894            scene
2895                .primitives()
2896                .iter()
2897                .filter(|p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.height <= 2.0 && rect.width > 2.0))
2898                .count()
2899        };
2900        let plain = count_thin_rects(base);
2901        let underlined = count_thin_rects(composing);
2902        assert!(
2903            underlined > plain,
2904            "the composition must underline ({plain} → {underlined})"
2905        );
2906    }
2907
2908    #[test]
2909    fn text_value_exposes_the_field_content() {
2910        // The input context (IME suggestions) reads the field's value.
2911        let inp = input("hello");
2912        assert_eq!(Widget::<Msg>::text_value(&inp), Some("hello"));
2913    }
2914
2915    #[test]
2916    fn cursor_at_accounts_for_scroll() {
2917        // A narrow field + long text → scrolling once the caret is at the end.
2918        let inp = input("0123456789 abcdefghij");
2919        // Without scrolling (cursor 0): a click on the left edge → index 0.
2920        assert_eq!(
2921            Widget::<Msg>::cursor_at(&inp, FIELD_PADDING_X, FIELD_OUTLINED_PADDING_TOP, 80.0, 0),
2922            Some(0)
2923        );
2924        // Scrolled (caret at the end): a click on the right lands on a larger index than
2925        // a click on the left (the offset is indeed taken into account).
2926        let end = inp.value.chars().count();
2927        let at_left =
2928            Widget::<Msg>::cursor_at(&inp, FIELD_PADDING_X, FIELD_OUTLINED_PADDING_TOP, 80.0, end)
2929                .unwrap();
2930        let at_right =
2931            Widget::<Msg>::cursor_at(&inp, 76.0, FIELD_OUTLINED_PADDING_TOP, 80.0, end).unwrap();
2932        assert!(at_left > 0, "scrolled: the left edge is no longer index 0");
2933        assert!(at_right > at_left, "a click on the right → a larger index");
2934    }
2935
2936    #[test]
2937    fn insert_at_cursor() {
2938        let inp = input("ac");
2939        let mut edit = Edit {
2940            cursor: 1,
2941            anchor: None,
2942            composing: None,
2943        };
2944        assert_eq!(
2945            inp.on_edit(&mut edit, &Key::Text("b".to_string())),
2946            Some(Msg::Changed("abc".to_string()))
2947        );
2948        assert_eq!(edit.cursor, 2);
2949    }
2950
2951    #[test]
2952    fn shift_arrow_selects_then_delete() {
2953        let inp = input("hello");
2954        // Caret at the end, Shift+Left twice -> selects "lo".
2955        let mut edit = Edit {
2956            cursor: 5,
2957            anchor: None,
2958            composing: None,
2959        };
2960        inp.on_edit(
2961            &mut edit,
2962            &Key::Left {
2963                shift: true,
2964                word: false,
2965            },
2966        );
2967        inp.on_edit(
2968            &mut edit,
2969            &Key::Left {
2970                shift: true,
2971                word: false,
2972            },
2973        );
2974        assert_eq!(edit.selection_range(), Some((3, 5)));
2975        // Backspace deletes the selection.
2976        assert_eq!(
2977            inp.on_edit(&mut edit, &Key::Backspace),
2978            Some(Msg::Changed("hel".to_string()))
2979        );
2980        assert_eq!(edit.cursor, 3);
2981        assert_eq!(edit.anchor, None);
2982    }
2983
2984    #[test]
2985    fn home_end_bounds() {
2986        let inp = input("abc");
2987        let mut edit = Edit {
2988            cursor: 1,
2989            anchor: None,
2990            composing: None,
2991        };
2992        inp.on_edit(
2993            &mut edit,
2994            &Key::End {
2995                shift: false,
2996                doc: false,
2997            },
2998        );
2999        assert_eq!(edit.cursor, 3);
3000        inp.on_edit(
3001            &mut edit,
3002            &Key::Home {
3003                shift: false,
3004                doc: false,
3005            },
3006        );
3007        assert_eq!(edit.cursor, 0);
3008    }
3009
3010    #[test]
3011    fn ctrl_arrow_jumps_by_word() {
3012        let inp = input("foo bar baz");
3013        // From the end, Ctrl+Left jumps to the start of "baz", then "bar", then "foo".
3014        let mut edit = Edit {
3015            cursor: 11,
3016            anchor: None,
3017            composing: None,
3018        };
3019        inp.on_edit(
3020            &mut edit,
3021            &Key::Left {
3022                shift: false,
3023                word: true,
3024            },
3025        );
3026        assert_eq!(edit.cursor, 8, "start of \"baz\"");
3027        inp.on_edit(
3028            &mut edit,
3029            &Key::Left {
3030                shift: false,
3031                word: true,
3032            },
3033        );
3034        assert_eq!(edit.cursor, 4, "start of \"bar\"");
3035        // Ctrl+Right jumps to the end of "bar", then "baz".
3036        inp.on_edit(
3037            &mut edit,
3038            &Key::Right {
3039                shift: false,
3040                word: true,
3041            },
3042        );
3043        assert_eq!(edit.cursor, 7, "fin de \"bar\"");
3044        inp.on_edit(
3045            &mut edit,
3046            &Key::Right {
3047                shift: false,
3048                word: true,
3049            },
3050        );
3051        assert_eq!(edit.cursor, 11, "fin de \"baz\"");
3052    }
3053
3054    #[test]
3055    fn home_end_are_line_relative_but_ctrl_spans_the_field() {
3056        // "ab\ncd\nef": caret in the middle of the 2nd line (index 4, between c and d).
3057        let inp = TextField::<Msg>::new("ab\ncd\nef")
3058            .on_input(Msg::Changed)
3059            .rows(3);
3060        let mut edit = Edit {
3061            cursor: 4,
3062            anchor: None,
3063            composing: None,
3064        };
3065        // Plain Home → the start of the current line (index 3), not of the field.
3066        inp.on_edit(
3067            &mut edit,
3068            &Key::Home {
3069                shift: false,
3070                doc: false,
3071            },
3072        );
3073        assert_eq!(edit.cursor, 3, "start of the 2nd line");
3074        // Plain End → the end of the current line (index 5).
3075        inp.on_edit(
3076            &mut edit,
3077            &Key::End {
3078                shift: false,
3079                doc: false,
3080            },
3081        );
3082        assert_eq!(edit.cursor, 5, "end of the 2nd line");
3083        // Ctrl+Home / Ctrl+End → the bounds of the whole field.
3084        inp.on_edit(
3085            &mut edit,
3086            &Key::Home {
3087                shift: false,
3088                doc: true,
3089            },
3090        );
3091        assert_eq!(edit.cursor, 0, "start of the field");
3092        inp.on_edit(
3093            &mut edit,
3094            &Key::End {
3095                shift: false,
3096                doc: true,
3097            },
3098        );
3099        assert_eq!(edit.cursor, 8, "end of the field");
3100    }
3101
3102    #[test]
3103    fn selected_text_reads_range() {
3104        let inp = input("hello");
3105        let edit = Edit {
3106            cursor: 5,
3107            anchor: Some(2),
3108            composing: None,
3109        };
3110        assert_eq!(inp.selected_text(&edit), Some("llo".to_string()));
3111    }
3112
3113    /// The words on the buttons of the bar a field shows for `context`, in order — `None`
3114    /// when it shows none.
3115    fn bar_words(field: &TextField<Msg>, context: ToolbarContext) -> Option<Vec<String>> {
3116        let bar = Widget::<Msg>::selection_toolbar(field, context)?;
3117        Some(
3118            bar.children()
3119                .iter()
3120                .map(|button| {
3121                    button
3122                        .semantics()
3123                        .and_then(|semantics| semantics.label)
3124                        .unwrap_or_default()
3125                })
3126                .collect(),
3127        )
3128    }
3129
3130    fn context(has_selection: bool, can_paste: bool, all_selected: bool) -> ToolbarContext {
3131        ToolbarContext {
3132            has_selection,
3133            can_paste,
3134            all_selected,
3135        }
3136    }
3137
3138    /// **The default bar** is decided by the three conditions (milestone 568): Cut and Copy
3139    /// want a selection, Paste wants something to paste, Select all wants something left
3140    /// unselected — in the reference's order.
3141    #[test]
3142    fn the_default_bar_follows_the_three_conditions() {
3143        let field = input("hello world");
3144        let words = |ctx| bar_words(&field, ctx);
3145        assert_eq!(
3146            words(context(true, true, false)),
3147            Some(vec![
3148                "Cut".into(),
3149                "Copy".into(),
3150                "Paste".into(),
3151                "Select all".into()
3152            ])
3153        );
3154        assert_eq!(
3155            words(context(false, true, false)),
3156            Some(vec!["Paste".into(), "Select all".into()])
3157        );
3158        assert_eq!(
3159            words(context(true, false, true)),
3160            Some(vec!["Cut".into(), "Copy".into()])
3161        );
3162        assert_eq!(words(context(false, false, true)), None, "nothing to offer");
3163    }
3164
3165    /// **A field that keeps a secret gives none away**: a masked field offers neither Cut
3166    /// nor Copy, and its selected text is not readable for the clipboard either — Ctrl+C
3167    /// used to put the password itself on the clipboard.
3168    #[test]
3169    fn a_masked_field_offers_neither_cut_nor_copy_and_copies_nothing() {
3170        let field = input("hunter2").obscure(true);
3171        assert_eq!(
3172            bar_words(&field, context(true, true, false)),
3173            Some(vec!["Paste".into(), "Select all".into()])
3174        );
3175        let selected = Edit {
3176            cursor: 7,
3177            anchor: Some(0),
3178            composing: None,
3179        };
3180        assert_eq!(field.selected_text(&selected), None);
3181        // The same field unmasked copies as before.
3182        assert_eq!(
3183            input("hunter2").selected_text(&selected),
3184            Some("hunter2".to_string())
3185        );
3186    }
3187
3188    /// A read-only field keeps its text as it is: it can be copied from and selected, not
3189    /// cut from and not pasted into.
3190    #[test]
3191    fn a_read_only_field_offers_copy_and_select_all_only() {
3192        let field = input("REF-0042").read_only();
3193        assert_eq!(
3194            bar_words(&field, context(true, true, false)),
3195            Some(vec!["Copy".into(), "Select all".into()])
3196        );
3197    }
3198
3199    /// **The application has the last word** on the list: it is handed the context and the
3200    /// defaults, and what it returns is what shows — an item dropped, one of its own added,
3201    /// or an empty list for no bar.
3202    #[test]
3203    fn the_application_rewrites_the_list() {
3204        let field = input("hello").selection_toolbar(|context, mut items| {
3205            items.retain(|item| item.label() != "Paste");
3206            if context.has_selection {
3207                items.push(ToolbarItem::custom("Translate", Msg::Submitted));
3208            }
3209            items
3210        });
3211        assert_eq!(
3212            bar_words(&field, context(true, true, false)),
3213            Some(vec![
3214                "Cut".into(),
3215                "Copy".into(),
3216                "Select all".into(),
3217                "Translate".into()
3218            ])
3219        );
3220        assert_eq!(
3221            bar_words(&field, context(false, true, false)),
3222            Some(vec!["Select all".into()]),
3223            "the custom item only for a selection"
3224        );
3225        let none = input("hello").selection_toolbar(|_, _| Vec::new());
3226        assert_eq!(bar_words(&none, context(true, true, false)), None);
3227    }
3228
3229    /// The application's own item sends its message, and a built-in one sends nothing (the
3230    /// shell performs it).
3231    #[test]
3232    fn an_application_item_sends_its_message_and_a_built_in_one_does_not() {
3233        let field = input("hello").selection_toolbar(|_, mut items| {
3234            items.push(ToolbarItem::custom("Translate", Msg::Submitted));
3235            items
3236        });
3237        let bar =
3238            Widget::<Msg>::selection_toolbar(&field, context(true, false, false)).expect("a bar");
3239        let buttons = bar.children();
3240        let clicks: Vec<Option<Msg>> = buttons.iter().map(|b| b.on_click()).collect();
3241        let actions: Vec<Option<crate::EditAction>> =
3242            buttons.iter().map(|b| b.edit_action()).collect();
3243        assert_eq!(clicks, vec![None, None, None, Some(Msg::Submitted)]);
3244        assert_eq!(
3245            actions,
3246            vec![
3247                Some(crate::EditAction::Cut),
3248                Some(crate::EditAction::Copy),
3249                Some(crate::EditAction::SelectAll),
3250                None
3251            ]
3252        );
3253    }
3254
3255    /// The bar is placed against the **selection's** box, in the field's own coordinates:
3256    /// wider for a longer selection, at the caret (no width) when nothing is selected.
3257    #[test]
3258    fn the_bar_is_anchored_on_the_selection_or_the_caret() {
3259        let field = input("hello world");
3260        let word = Edit {
3261            cursor: 5,
3262            anchor: Some(0),
3263            composing: None,
3264        };
3265        let all = Edit {
3266            cursor: 11,
3267            anchor: Some(0),
3268            composing: None,
3269        };
3270        let caret = Edit {
3271            cursor: 5,
3272            anchor: None,
3273            composing: None,
3274        };
3275        let anchor =
3276            |edit: &Edit| Widget::<Msg>::selection_anchor(&field, 200.0, edit, 0.0).unwrap();
3277        assert!(anchor(&all).width > anchor(&word).width);
3278        assert_eq!(anchor(&caret).width, 0.0);
3279        assert!(anchor(&caret).height > 0.0);
3280        // Where the selection's own handles stand is where its box begins and ends.
3281        let handles = Widget::<Msg>::selection_handles(&field, 200.0, &word, 0.0).unwrap();
3282        let word_box = anchor(&word);
3283        assert!((word_box.x - handles[0].line_center.x).abs() < 0.5);
3284        assert!((word_box.x + word_box.width - handles[1].line_center.x).abs() < 0.5);
3285    }
3286
3287    #[test]
3288    fn enter_submits_without_changing_value() {
3289        let inp = input("acheter du lait").on_submit(Msg::Submitted);
3290        let mut edit = Edit {
3291            cursor: 3,
3292            anchor: None,
3293            composing: None,
3294        };
3295        // Enter: emits the submission, returns no value change.
3296        assert_eq!(inp.on_edit(&mut edit, &Key::Enter), Some(Msg::Submitted));
3297        assert_eq!(edit.cursor, 3); // caret unchanged
3298    }
3299
3300    #[test]
3301    fn enter_without_submit_is_noop() {
3302        let inp = input("x");
3303        let mut edit = Edit {
3304            cursor: 1,
3305            anchor: None,
3306            composing: None,
3307        };
3308        assert_eq!(inp.on_edit(&mut edit, &Key::Enter), None);
3309    }
3310
3311    /// Where the text was actually drawn: the x of the field's own text primitive.
3312    fn drawn_text_x(field: &TextField<()>, width: f32) -> f32 {
3313        let mut scene = Scene::new();
3314        Widget::<()>::paint(
3315            field,
3316            Rect::new(0.0, 0.0, width, 56.0),
3317            Status {
3318                focused: false,
3319                ..Default::default()
3320            },
3321            &Theme::default(),
3322            &mut scene,
3323        );
3324        scene
3325            .primitives()
3326            .iter()
3327            .find_map(|p| match p {
3328                frus_core::Primitive::Text { position, text, .. } if text == "42" => {
3329                    Some(position.x)
3330                }
3331                _ => None,
3332            })
3333            .expect("the value is drawn")
3334    }
3335
3336    /// A field says nothing, and the text starts where it always did.
3337    #[test]
3338    fn a_field_that_says_nothing_starts_at_the_left() {
3339        let plain: TextField<()> = TextField::new("42");
3340        let started: TextField<()> = TextField::new("42").text_align(TextAlign::Start);
3341        assert_eq!(drawn_text_x(&plain, 300.0), drawn_text_x(&started, 300.0));
3342    }
3343
3344    /// Centred and right-aligned text moves, and moves the right way round.
3345    #[test]
3346    fn the_text_sits_where_it_was_told_to() {
3347        let left = drawn_text_x(&TextField::<()>::new("42"), 300.0);
3348        let centre = drawn_text_x(
3349            &TextField::<()>::new("42").text_align(TextAlign::Center),
3350            300.0,
3351        );
3352        let right = drawn_text_x(
3353            &TextField::<()>::new("42").text_align(TextAlign::Right),
3354            300.0,
3355        );
3356        assert!(left < centre, "centred is further right than left-aligned");
3357        assert!(centre < right, "right-aligned is further right again");
3358        // Centred is halfway: the two gaps either side match.
3359        assert!(
3360            ((centre - left) - (right - centre)).abs() < 0.5,
3361            "halfway: {left} {centre} {right}"
3362        );
3363    }
3364
3365    /// **The click has to agree with the paint.** A caret placed from the unaligned
3366    /// geometry would appear several characters from the tap, and it is the kind of
3367    /// wrongness nobody reports precisely — it just feels broken.
3368    #[test]
3369    fn a_click_lands_where_the_glyphs_are() {
3370        let width = 300.0;
3371        for align in [TextAlign::Left, TextAlign::Center, TextAlign::Right] {
3372            let field: TextField<()> = TextField::new("42").text_align(align);
3373            let x = drawn_text_x(&field, width);
3374            // A tap a hair to the left of the first glyph belongs before it\u2026
3375            let before = Widget::<()>::cursor_at(&field, x - 1.0, 20.0, width, 0);
3376            assert_eq!(before, Some(0), "{align:?}: before the first character");
3377            // \u2026and one past the last glyph belongs after it.
3378            let text_w = frus_text::measure("42", field.size).width;
3379            let after = Widget::<()>::cursor_at(&field, x + text_w + 1.0, 20.0, width, 0);
3380            assert_eq!(after, Some(2), "{align:?}: after the last character");
3381        }
3382    }
3383
3384    /// Nothing to distribute, nothing to push. A line wider than its field keeps its
3385    /// left edge, which is where reading starts and where the horizontal scroll brings
3386    /// the caret back to.
3387    #[test]
3388    fn text_wider_than_the_field_is_not_pushed_off_it() {
3389        let long: TextField<()> = TextField::new("42").text_align(TextAlign::Right);
3390        // A box far too narrow for the value: the slack is negative, so the push is nil.
3391        assert_eq!(long.align_offset(4.0, 40.0), 0.0);
3392        assert_eq!(long.align_offset(40.0, 40.0), 0.0);
3393    }
3394
3395    /// A multi-line field stays at the start. Aligning wrapped text means moving each
3396    /// line by its own width, which the caret and the click would have to be told about
3397    /// line by line — that belongs inside the text layout, not in a widget nudging a
3398    /// block sideways behind their backs.
3399    #[test]
3400    fn a_multiline_field_is_not_aligned() {
3401        let note: TextField<()> = TextField::new("42")
3402            .multiline()
3403            .text_align(TextAlign::Center);
3404        assert_eq!(note.align_offset(300.0, 40.0), 0.0);
3405        // The same field on one line would have moved.
3406        let one_line: TextField<()> = TextField::new("42").text_align(TextAlign::Center);
3407        assert_eq!(one_line.align_offset(300.0, 40.0), 130.0);
3408    }
3409
3410    /// The placeholder sits where the text will. A centred field whose hint hugs the
3411    /// left edge jumps the moment the first key lands.
3412    #[test]
3413    fn the_placeholder_is_aligned_like_the_value() {
3414        let hint_x = |align: TextAlign| {
3415            let field: TextField<()> = TextField::new("").placeholder("Amount").text_align(align);
3416            let mut scene = Scene::new();
3417            Widget::<()>::paint(
3418                &field,
3419                Rect::new(0.0, 0.0, 300.0, 56.0),
3420                Status::default(),
3421                &Theme::default(),
3422                &mut scene,
3423            );
3424            scene
3425                .primitives()
3426                .iter()
3427                .find_map(|p| match p {
3428                    frus_core::Primitive::Text { position, text, .. } if text == "Amount" => {
3429                        Some(position.x)
3430                    }
3431                    _ => None,
3432                })
3433                .expect("the hint is drawn")
3434        };
3435        assert!(hint_x(TextAlign::Right) > hint_x(TextAlign::Center));
3436        assert!(hint_x(TextAlign::Center) > hint_x(TextAlign::Left));
3437    }
3438
3439    /// A filtered field drops what it will not take, and keeps the rest of a paste.
3440    #[test]
3441    fn a_filter_drops_what_it_will_not_take() {
3442        let field: TextField<String> = TextField::new("").digits_only().on_input(|v| v);
3443        let mut edit = Edit::default();
3444        // A paste of a formatted number: the digits land, the spaces and dashes do not.
3445        let out = Widget::on_edit(&field, &mut edit, &Key::Text("12 34-56".into()));
3446        assert_eq!(out.as_deref(), Some("123456"));
3447        assert_eq!(edit.cursor, 6, "the caret is past what actually arrived");
3448    }
3449
3450    /// A refused keystroke is not an edit: emitting the value unchanged would rebuild
3451    /// the tree for a key that did nothing.
3452    #[test]
3453    fn a_refused_keystroke_says_nothing() {
3454        let field: TextField<String> = TextField::new("42").digits_only().on_input(|v| v);
3455        let mut edit = Edit {
3456            cursor: 2,
3457            ..Default::default()
3458        };
3459        assert_eq!(
3460            Widget::on_edit(&field, &mut edit, &Key::Text("x".into())),
3461            None
3462        );
3463        assert_eq!(edit.cursor, 2, "and the caret has not moved");
3464    }
3465
3466    /// A **selection** replaced by nothing is still an edit: the selection is gone.
3467    #[test]
3468    fn a_refused_keystroke_over_a_selection_still_clears_it() {
3469        let field: TextField<String> = TextField::new("1234").digits_only().on_input(|v| v);
3470        let mut edit = Edit {
3471            cursor: 3,
3472            anchor: Some(1),
3473            ..Default::default()
3474        };
3475        let out = Widget::on_edit(&field, &mut edit, &Key::Text("x".into()));
3476        assert_eq!(out.as_deref(), Some("14"));
3477        assert_eq!(edit.cursor, 1);
3478    }
3479
3480    /// A filter can **substitute** as well as drop, and the caret arithmetic is the same
3481    /// because a substituted character takes the place of the one typed.
3482    #[test]
3483    fn a_filter_can_substitute() {
3484        let field: TextField<String> = TextField::new("")
3485            .input_filter(|c| c.to_uppercase().next())
3486            .on_input(|v| v);
3487        let mut edit = Edit::default();
3488        let out = Widget::on_edit(&field, &mut edit, &Key::Text("ab".into()));
3489        assert_eq!(out.as_deref(), Some("AB"));
3490        assert_eq!(edit.cursor, 2);
3491    }
3492
3493    /// A value the **caller** supplied is left alone: it is the application's state, not
3494    /// something typed.
3495    #[test]
3496    fn a_filter_does_not_rewrite_what_the_caller_gave() {
3497        let field: TextField<String> = TextField::new("a1b2").digits_only().on_input(|v| v);
3498        let mut edit = Edit {
3499            cursor: 4,
3500            ..Default::default()
3501        };
3502        let out = Widget::on_edit(&field, &mut edit, &Key::Text("3".into()));
3503        assert_eq!(
3504            out.as_deref(),
3505            Some("a1b23"),
3506            "only the keystroke is filtered"
3507        );
3508    }
3509
3510    /// Digits ask for a keypad, and a keyboard named either side of it stands.
3511    #[test]
3512    fn digits_only_asks_for_a_keypad_without_overruling_one() {
3513        let plain: TextField<()> = TextField::new("").digits_only();
3514        assert_eq!(Widget::<()>::ime(&plain).keyboard, KeyboardType::Number);
3515        let before: TextField<()> = TextField::new("")
3516            .keyboard_type(KeyboardType::Phone)
3517            .digits_only();
3518        assert_eq!(Widget::<()>::ime(&before).keyboard, KeyboardType::Phone);
3519        let after: TextField<()> = TextField::new("")
3520            .digits_only()
3521            .keyboard_type(KeyboardType::Phone);
3522        assert_eq!(Widget::<()>::ime(&after).keyboard, KeyboardType::Phone);
3523    }
3524
3525    /// The capitalisation reaches the keyboard the field asks for.
3526    #[test]
3527    fn the_capitalisation_travels_with_the_field() {
3528        let field: TextField<()> = TextField::new("").capitalization(Capitalization::Characters);
3529        let ime = Widget::<()>::ime(&field);
3530        assert_eq!(ime.capitalization, Capitalization::Characters);
3531        assert_eq!(ime.android_input_type() & 0x0000_7000, 0x0000_1000);
3532    }
3533
3534    /// A plain field asks for the plain keyboard, which is what every field used to
3535    /// get whether it suited or not.
3536    #[test]
3537    fn a_plain_field_asks_for_the_plain_keyboard() {
3538        let field: TextField<()> = TextField::new("hello");
3539        assert_eq!(
3540            Widget::<()>::ime(&field),
3541            Ime {
3542                keyboard: KeyboardType::Text,
3543                action: TextInputAction::Done,
3544                capitalization: Capitalization::Auto,
3545            }
3546        );
3547    }
3548
3549    /// **A masked field is a secret, and the dots cannot say so.** `obscure` draws
3550    /// dots on our side; the keyboard, told nothing, treated the field as ordinary
3551    /// prose — learning the password into its personal dictionary and offering it
3552    /// back as a suggestion later, on whatever screen came next.
3553    #[test]
3554    fn an_obscured_field_tells_the_keyboard_it_is_a_secret() {
3555        let field: TextField<()> = TextField::new("hunter2").obscure(true);
3556        let ime = Widget::<()>::ime(&field);
3557        assert_eq!(ime.keyboard, KeyboardType::Password);
3558        assert!(ime.keyboard.is_secret());
3559        // TYPE_TEXT_VARIATION_PASSWORD, which is what stops the learning.
3560        assert_eq!(ime.keyboard.android_input_type() & 0x0000_0ff0, 0x80);
3561    }
3562
3563    /// A field that takes several lines needs the action key to **insert** one.
3564    /// *Done* there is a keyboard that cannot type what the field is for.
3565    #[test]
3566    fn a_multiline_field_takes_a_newline_key() {
3567        let field: TextField<()> = TextField::new("a note").multiline();
3568        let ime = Widget::<()>::ime(&field);
3569        assert_eq!(ime.keyboard, KeyboardType::Multiline);
3570        assert_eq!(ime.action, TextInputAction::Newline);
3571        // `rows` implies multiline, so it implies the same keyboard.
3572        let rowed: TextField<()> = TextField::new("a note").rows(4);
3573        assert_eq!(Widget::<()>::ime(&rowed).keyboard, KeyboardType::Multiline);
3574    }
3575
3576    /// What the caller says beats what the field guessed — both halves, separately.
3577    #[test]
3578    fn what_the_caller_says_wins() {
3579        let field: TextField<()> = TextField::new("")
3580            .obscure(true)
3581            .keyboard_type(KeyboardType::VisiblePassword);
3582        assert_eq!(
3583            Widget::<()>::ime(&field).keyboard,
3584            KeyboardType::VisiblePassword
3585        );
3586
3587        // A search box asks for Search without ceasing to be a text field.
3588        let query: TextField<()> = TextField::new("").action(TextInputAction::Search);
3589        let ime = Widget::<()>::ime(&query);
3590        assert_eq!(ime.keyboard, KeyboardType::Text);
3591        assert_eq!(ime.action, TextInputAction::Search);
3592
3593        // And a multi-line field can still be told to say Done.
3594        let note: TextField<()> = TextField::new("").multiline().action(TextInputAction::Done);
3595        assert_eq!(Widget::<()>::ime(&note).action, TextInputAction::Done);
3596    }
3597
3598    /// A field that says what it is gets the keyboard for it — a keypad for a phone
3599    /// number, an `@` for an address.
3600    #[test]
3601    fn a_typed_field_gets_the_keyboard_for_it() {
3602        let phone: TextField<()> = TextField::new("").keyboard_type(KeyboardType::Phone);
3603        // TYPE_CLASS_PHONE
3604        assert_eq!(Widget::<()>::ime(&phone).keyboard.android_input_type(), 3);
3605
3606        let email: TextField<()> = TextField::new("").keyboard_type(KeyboardType::Email);
3607        let bits = Widget::<()>::ime(&email).keyboard.android_input_type();
3608        // TYPE_CLASS_TEXT | TYPE_TEXT_VARIATION_EMAIL_ADDRESS, and no capitalisation:
3609        // `Someone@` is an address that does not work.
3610        assert_eq!(bits, 1 | 0x20);
3611    }
3612
3613    #[test]
3614    fn obscure_masks_the_displayed_text_but_not_the_value() {
3615        // A password field: the render never contains the value in the clear, only dots;
3616        // the real value stays reachable (editing, IME).
3617        let theme = Theme::default();
3618        let field = TextField::<Msg>::new("secret").obscure(true);
3619        let mut scene = Scene::new();
3620        let status = Status {
3621            focused: true,
3622            cursor: Some(6),
3623            ..Default::default()
3624        };
3625        Widget::<Msg>::paint(
3626            &field,
3627            Rect::new(0.0, 0.0, 220.0, 30.0),
3628            status,
3629            &theme,
3630            &mut scene,
3631        );
3632        let drawn: Vec<&str> = scene
3633            .primitives()
3634            .iter()
3635            .filter_map(|p| match p {
3636                frus_core::Primitive::Text { text, .. } => Some(text.as_str()),
3637                _ => None,
3638            })
3639            .collect();
3640        assert!(
3641            drawn.iter().all(|t| !t.contains("secret")),
3642            "the value must not leak"
3643        );
3644        assert!(
3645            drawn.iter().any(|t| t.chars().all(|c| c == '•')),
3646            "dots are drawn"
3647        );
3648        // The real value stays exposed to the input context.
3649        assert_eq!(Widget::<Msg>::text_value(&field), Some("secret"));
3650    }
3651
3652    #[test]
3653    fn prefix_icon_draws_a_path_and_shifts_the_hit_test() {
3654        // A prefix icon draws a path and offsets the content to the right: the same
3655        // click lands on a smaller index than it would without one.
3656        let theme = Theme::default();
3657        let with_icon = input("hello world").prefix_icon(Icons::STAR);
3658        let mut scene = Scene::new();
3659        Widget::<Msg>::paint(
3660            &with_icon,
3661            Rect::new(0.0, 0.0, 220.0, 30.0),
3662            Status::default(),
3663            &theme,
3664            &mut scene,
3665        );
3666        assert!(
3667            scene
3668                .primitives()
3669                .iter()
3670                .any(|p| matches!(p, frus_core::Primitive::Path { .. })),
3671            "the prefix icon draws a path"
3672        );
3673        let plain = input("hello world");
3674        let at_plain =
3675            Widget::<Msg>::cursor_at(&plain, 60.0, FIELD_OUTLINED_PADDING_TOP, 220.0, 0).unwrap();
3676        let at_icon =
3677            Widget::<Msg>::cursor_at(&with_icon, 60.0, FIELD_OUTLINED_PADDING_TOP, 220.0, 0)
3678                .unwrap();
3679        assert!(
3680            at_icon < at_plain,
3681            "the prefix offsets the content ({at_plain} → {at_icon})"
3682        );
3683    }
3684
3685    #[test]
3686    fn multiline_enter_inserts_a_newline_instead_of_submitting() {
3687        // In multi-line mode, Enter inserts a "\n" (and emits no submission).
3688        let inp = TextField::<Msg>::new("ab")
3689            .on_input(Msg::Changed)
3690            .multiline();
3691        let mut edit = Edit {
3692            cursor: 2,
3693            anchor: None,
3694            composing: None,
3695        };
3696        assert_eq!(
3697            inp.on_edit(&mut edit, &Key::Enter),
3698            Some(Msg::Changed("ab\n".to_string()))
3699        );
3700        assert_eq!(edit.cursor, 3);
3701        // Single line: Enter submits (unchanged behaviour).
3702        let single = input("ab").on_submit(Msg::Submitted);
3703        let mut edit = Edit {
3704            cursor: 2,
3705            anchor: None,
3706            composing: None,
3707        };
3708        assert_eq!(single.on_edit(&mut edit, &Key::Enter), Some(Msg::Submitted));
3709    }
3710
3711    #[test]
3712    fn multiline_wraps_long_lines_to_the_width() {
3713        // A long line **without** any `\n` wraps onto several visual lines: a click well
3714        // below the 1st line places the caret further into the text (a wrapped line under
3715        // the first one), not at index 0.
3716        let long = "word ".repeat(30); // 150 characters, no explicit break
3717        let inp = TextField::<Msg>::new(long.trim_end())
3718            .on_input(Msg::Changed)
3719            .rows(4)
3720            .width(160.0);
3721        let line_h = inp.text_style().line_height();
3722        let top = Widget::<Msg>::cursor_at(
3723            &inp,
3724            FIELD_PADDING_X + 2.0,
3725            FIELD_OUTLINED_PADDING_TOP + 1.0,
3726            160.0,
3727            0,
3728        )
3729        .unwrap();
3730        let wrapped = Widget::<Msg>::cursor_at(
3731            &inp,
3732            FIELD_PADDING_X + 2.0,
3733            FIELD_OUTLINED_PADDING_TOP + line_h * 2.0 + 1.0,
3734            160.0,
3735            0,
3736        )
3737        .unwrap();
3738        assert!(top < 10, "1st line: {top}");
3739        assert!(
3740            wrapped > top,
3741            "a wrapped line further down → an index further on ({top} → {wrapped})"
3742        );
3743    }
3744
3745    #[test]
3746    fn multiline_reports_overflow_and_scrolls_content() {
3747        // Five lines in a box of two: the content overflows…
3748        let inp = TextField::<Msg>::new("l1\nl2\nl3\nl4\nl5")
3749            .on_input(Msg::Changed)
3750            .rows(2)
3751            .width(200.0);
3752        let (content_h, visible_h, _, _) =
3753            Widget::<Msg>::text_metrics(&inp, 200.0, 0).expect("a multi-line field");
3754        assert!(
3755            content_h > visible_h + 1.0,
3756            "5 lines > a box of 2 ({content_h} vs {visible_h})"
3757        );
3758
3759        // …and a retained scroll shifts the text upwards (a smaller position.y).
3760        let text_top = |scroll: f32| {
3761            let mut scene = Scene::new();
3762            let status = Status {
3763                focused: true,
3764                cursor: Some(0),
3765                scroll_y: scroll,
3766                ..Default::default()
3767            };
3768            Widget::<Msg>::paint(
3769                &inp,
3770                Rect::new(0.0, 0.0, 200.0, 80.0),
3771                status,
3772                &Theme::default(),
3773                &mut scene,
3774            );
3775            scene
3776                .primitives()
3777                .iter()
3778                .find_map(|p| match p {
3779                    frus_core::Primitive::Text { text, position, .. } if text.contains("l1") => {
3780                        Some(position.y)
3781                    }
3782                    _ => None,
3783                })
3784                .expect("the field's text")
3785        };
3786        assert!(
3787            text_top(20.0) < text_top(0.0),
3788            "scrolling raises the content"
3789        );
3790    }
3791
3792    #[test]
3793    fn multiline_arrows_move_the_caret_between_lines() {
3794        // "abc\ndefg\nhi": line 0 = indices 0..3, line 1 = 4..8, line 2 = 9..11.
3795        let inp = TextField::<Msg>::new("abc\ndefg\nhi")
3796            .on_input(Msg::Changed)
3797            .rows(3)
3798            .width(200.0);
3799        // From the 1st line (index 1), moving down lands on the 2nd line.
3800        let (down, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 1, true, false, None).unwrap();
3801        assert!(
3802            (4..=8).contains(&down),
3803            "moves down to the 2nd line: {down}"
3804        );
3805        // 1st line, moving up → impossible (the shell moves the focus).
3806        assert_eq!(
3807            Widget::<Msg>::caret_vertical(&inp, 200.0, 1, false, false, None),
3808            None
3809        );
3810        // Last line (index 10), moving down → impossible.
3811        assert_eq!(
3812            Widget::<Msg>::caret_vertical(&inp, 200.0, 10, true, false, None),
3813            None
3814        );
3815        // 2nd line (index 5), moving up lands on the 1st.
3816        let (up, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 5, false, false, None).unwrap();
3817        assert!(up <= 3, "moves up to the 1st line: {up}");
3818        // A single-line field: never any vertical movement.
3819        assert_eq!(
3820            Widget::<Msg>::caret_vertical(&input("abc"), 200.0, 1, true, false, None),
3821            None
3822        );
3823    }
3824
3825    #[test]
3826    fn multiline_goal_column_survives_a_short_line() {
3827        // "hello\nhi\nworld": line 0 = 0..5, line 1 = 6..8 (short), line 2 = 9..14.
3828        // Starting from column 5 (the end of "hello") and moving down: the 2nd line "hi"
3829        // is too short (so the caret is clamped to its end), but the goal column returned
3830        // stays ~the starting one, so moving down again lands back on column 5.
3831        let inp = TextField::<Msg>::new("hello\nhi\nworld")
3832            .on_input(Msg::Changed)
3833            .rows(3)
3834            .width(200.0);
3835        let (mid, goal) = Widget::<Msg>::caret_vertical(&inp, 200.0, 5, true, false, None).unwrap();
3836        assert!(
3837            (6..=8).contains(&mid),
3838            "a short line, clamped to its end: {mid}"
3839        );
3840        // A second jump reusing the goal column: without memory the caret would have
3841        // stayed clamped to the end of "hi" (col. ~2); here it lands far into "world"
3842        // (col. ~5), proving the original column survives the short line.
3843        let (low, _) =
3844            Widget::<Msg>::caret_vertical(&inp, 200.0, mid, true, false, Some(goal)).unwrap();
3845        assert!(
3846            (12..=14).contains(&low),
3847            "goal column preserved into \"world\": {low}"
3848        );
3849    }
3850
3851    #[test]
3852    fn multiline_page_jump_is_clamped_to_the_field() {
3853        // Page up/down never leave the multi-line field: at the bounds the caret settles
3854        // at the start / at the end and returns `Some` (not `None` → the field is kept).
3855        let inp = TextField::<Msg>::new("a\nb\nc\nd\ne")
3856            .on_input(Msg::Changed)
3857            .rows(2)
3858            .width(200.0);
3859        // From the last line, PgDn settles at the bottom (the field is not left).
3860        let (bottom, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 8, true, true, None).unwrap();
3861        assert!(
3862            bottom >= 7,
3863            "PgDn clamped to the bottom of the field: {bottom}"
3864        );
3865        // From the 1st line, PgUp settles at the top.
3866        let (top, _) = Widget::<Msg>::caret_vertical(&inp, 200.0, 0, false, true, None).unwrap();
3867        assert!(top <= 1, "PgUp clamped to the top of the field: {top}");
3868    }
3869
3870    #[test]
3871    fn multiline_reserves_rows_of_height() {
3872        // `rows(n)` reserves n lines; that is taller than a one-line field.
3873        let one = match Widget::<Msg>::style(&input("x")).height {
3874            Dimension::Length(h) => h,
3875            _ => panic!("a fixed height"),
3876        };
3877        let four = match Widget::<Msg>::style(&TextField::<Msg>::new("x").rows(4)).height {
3878            Dimension::Length(h) => h,
3879            _ => panic!("a fixed height"),
3880        };
3881        assert!(
3882            four > one * 2.0,
3883            "4 lines much taller than one ({one} → {four})"
3884        );
3885    }
3886
3887    #[test]
3888    fn multiline_hit_test_uses_the_click_line() {
3889        // A click on the 2nd line places the caret inside "cd" (indices ≥ 3), not inside
3890        // the "ab" of the 1st line.
3891        let inp = TextField::<Msg>::new("ab\ncd")
3892            .on_input(Msg::Changed)
3893            .rows(3);
3894        let line_h = inp.text_style().line_height();
3895        // A top-left click → the 1st line (index ≤ 2).
3896        let top = Widget::<Msg>::cursor_at(
3897            &inp,
3898            FIELD_PADDING_X + 1.0,
3899            FIELD_OUTLINED_PADDING_TOP + 1.0,
3900            220.0,
3901            0,
3902        )
3903        .unwrap();
3904        // A click one line lower → the 2nd line (index ≥ 3).
3905        let below = Widget::<Msg>::cursor_at(
3906            &inp,
3907            FIELD_PADDING_X + 1.0,
3908            FIELD_OUTLINED_PADDING_TOP + line_h + 1.0,
3909            220.0,
3910            0,
3911        )
3912        .unwrap();
3913        assert!(top <= 2, "1st line: {top}");
3914        assert!(below >= 3, "2nd line: {below}");
3915    }
3916
3917    #[test]
3918    fn floating_label_rests_in_box_then_floats_up() {
3919        // The label rests inside the box (large, low) while the field is empty and
3920        // unfocused, and floats above it (small, high) once focused.
3921        let theme = Theme::default();
3922        let field = TextField::<Msg>::new("").label("Name");
3923        let label_geo = |status: Status| -> (f32, f32) {
3924            let mut scene = Scene::new();
3925            Widget::<Msg>::paint(
3926                &field,
3927                Rect::new(0.0, 30.0, 220.0, 60.0),
3928                status,
3929                &theme,
3930                &mut scene,
3931            );
3932            scene
3933                .primitives()
3934                .iter()
3935                .find_map(|p| match p {
3936                    frus_core::Primitive::Text {
3937                        text,
3938                        size,
3939                        position,
3940                        ..
3941                    } if text == "Name" => Some((*size, position.y)),
3942                    _ => None,
3943                })
3944                .expect("a painted label")
3945        };
3946        let (rest_size, rest_y) = label_geo(Status::default());
3947        let (float_size, float_y) = label_geo(Status {
3948            focused: true,
3949            focus_progress: 1.0,
3950            ..Default::default()
3951        });
3952        assert!(
3953            rest_size > float_size,
3954            "at rest the label is larger ({rest_size} → {float_size})"
3955        );
3956        assert!(
3957            float_y < rest_y,
3958            "once focused the label rises ({rest_y} → {float_y})"
3959        );
3960    }
3961
3962    /// **An errored field deepens under the pointer** (milestone 439).
3963    ///
3964    /// `error` at rest, `on_error_container` while hovered, and `error` again once
3965    /// focused — the reference tests focus **before** hover
3966    /// (`input_decorator.dart:5977`), because a focused field is already saying
3967    /// everything it can. The message below it does not move: `errorStyle` is `error` in
3968    /// every state (`:6100`), it being a sentence rather than a control.
3969    ///
3970    /// It is the first thing here to ask the scheme for `on_error_container`, which
3971    /// arrived in milestone 429 with nothing wanting it.
3972    #[test]
3973    fn an_errored_field_deepens_under_the_pointer() {
3974        let theme = Theme::default();
3975        let field = input("x").label("Name").error("Required").outlined();
3976        let painted = |status: Status| {
3977            let mut scene = Scene::new();
3978            Widget::<Msg>::paint(
3979                &field,
3980                Rect::new(0.0, 0.0, 220.0, 90.0),
3981                status,
3982                &theme,
3983                &mut scene,
3984            );
3985            scene
3986        };
3987        // The border is the only stroked rectangle a field paints.
3988        let border = |status: Status| {
3989            painted(status)
3990                .primitives()
3991                .iter()
3992                .find_map(|p| match p {
3993                    frus_core::Primitive::Rect {
3994                        border_color,
3995                        border_width,
3996                        ..
3997                    } if *border_width > 0.0 => Some(*border_color),
3998                    _ => None,
3999                })
4000                .expect("an outlined field draws a border")
4001        };
4002        let message = |status: Status| {
4003            painted(status)
4004                .primitives()
4005                .iter()
4006                .find_map(|p| match p {
4007                    frus_core::Primitive::Text { text, color, .. } if text == "Required" => {
4008                        Some(*color)
4009                    }
4010                    _ => None,
4011                })
4012                .expect("the message is painted")
4013        };
4014        let rest = Status {
4015            opacity: 1.0,
4016            ..Default::default()
4017        };
4018        let hovered = Status {
4019            hover_progress: 1.0,
4020            ..rest
4021        };
4022        let both = Status {
4023            focused: true,
4024            focus_progress: 1.0,
4025            ..hovered
4026        };
4027
4028        assert_eq!(border(rest), theme.scheme.error);
4029        assert_eq!(
4030            border(hovered),
4031            theme.scheme.on_error_container,
4032            "it deepens under the pointer"
4033        );
4034        assert_eq!(
4035            border(both),
4036            theme.scheme.error,
4037            "and comes back once focused, which the reference tests first"
4038        );
4039        assert_ne!(
4040            theme.scheme.error, theme.scheme.on_error_container,
4041            "the two have to differ for any of the above to mean anything"
4042        );
4043
4044        for status in [rest, hovered, both] {
4045            assert_eq!(
4046                message(status),
4047                theme.scheme.error,
4048                "the message is a sentence, not a control"
4049            );
4050        }
4051    }
4052
4053    #[test]
4054    fn decoration_grows_height_for_label_and_error() {
4055        // A bare field reserves only the box; a label + an error add one line above and
4056        // one below → the style is taller.
4057        let bare_h = match Widget::<Msg>::style(&input("x")).height {
4058            Dimension::Length(h) => h,
4059            _ => panic!("a fixed height was expected"),
4060        };
4061        let decorated = input("x").label("Email").error("Required");
4062        let deco_h = match Widget::<Msg>::style(&decorated).height {
4063            Dimension::Length(h) => h,
4064            _ => panic!("a fixed height was expected"),
4065        };
4066        assert!(
4067            deco_h > bare_h,
4068            "label + error grow the field ({bare_h} → {deco_h})"
4069        );
4070    }
4071
4072    #[test]
4073    fn error_paints_the_border_in_the_error_color() {
4074        // In error, the box's border switches to the theme's error colour.
4075        let theme = Theme::default();
4076        let field = input("x").error("bad");
4077        let mut scene = Scene::new();
4078        Widget::<Msg>::paint(
4079            &field,
4080            Rect::new(0.0, 0.0, 220.0, 60.0),
4081            Status::default(),
4082            &theme,
4083            &mut scene,
4084        );
4085        let has_error_border = scene.primitives().iter().any(|p| {
4086            matches!(
4087                p,
4088                frus_core::Primitive::Rect { border_color, border_width, .. }
4089                    if *border_width > 0.0 && *border_color == theme.error
4090            )
4091        });
4092        assert!(has_error_border, "the border must be in the error colour");
4093    }
4094
4095    #[test]
4096    fn placeholder_shows_only_when_empty() {
4097        // The hint only appears while the value is empty.
4098        let theme = Theme::default();
4099        let count_texts = |value: &str| {
4100            let field = TextField::<Msg>::new(value).placeholder("Type here");
4101            let mut scene = Scene::new();
4102            Widget::<Msg>::paint(
4103                &field,
4104                Rect::new(0.0, 0.0, 220.0, 30.0),
4105                Status::default(),
4106                &theme,
4107                &mut scene,
4108            );
4109            scene
4110                .primitives()
4111                .iter()
4112                .filter(
4113                    |p| matches!(p, frus_core::Primitive::Text { text, .. } if text == "Type here"),
4114                )
4115                .count()
4116        };
4117        assert_eq!(count_texts(""), 1, "empty: the hint shows");
4118        assert_eq!(count_texts("hi"), 0, "rempli : pas d'indice");
4119    }
4120
4121    #[test]
4122    fn clickable_suffix_emits_and_blocks_caret() {
4123        let field = TextField::new("hello")
4124            .suffix_icon(Icons::CLOSE)
4125            .on_suffix(Msg::Submitted)
4126            .width(220.0);
4127        let (w, y) = (220.0, 12.0);
4128        let x_suffix = w - 8.0; // near the right edge (the suffix's zone)
4129
4130        // A click on the suffix: emits the message, and does NOT place a caret.
4131        assert_eq!(
4132            Widget::<Msg>::positional_click(&field, x_suffix, y, w, 40.0),
4133            Some(Msg::Submitted)
4134        );
4135        assert_eq!(Widget::<Msg>::cursor_at(&field, x_suffix, y, w, 0), None);
4136        // A click in the body: no suffix, a caret is placed.
4137        assert_eq!(
4138            Widget::<Msg>::positional_click(&field, 20.0, y, w, 40.0),
4139            None
4140        );
4141        assert!(Widget::<Msg>::cursor_at(&field, 20.0, y, w, 0).is_some());
4142        // Without `on_suffix` the icon stays decorative (no positional click).
4143        let deco = TextField::<Msg>::new("hello")
4144            .suffix_icon(Icons::CLOSE)
4145            .width(220.0);
4146        assert_eq!(
4147            Widget::<Msg>::positional_click(&deco, x_suffix, y, w, 40.0),
4148            None
4149        );
4150    }
4151
4152    #[test]
4153    fn hovering_active_suffix_paints_a_halo() {
4154        let field = TextField::new("hello")
4155            .suffix_icon(Icons::CLOSE)
4156            .on_suffix(Msg::Submitted)
4157            .width(220.0);
4158        let bounds = Rect::new(0.0, 0.0, 220.0, 40.0);
4159        // Counts the ~28x28 rectangles (the halo behind the suffix icon).
4160        let halos = |status: Status| {
4161            let mut scene = Scene::new();
4162            Widget::<Msg>::paint(&field, bounds, status, &Theme::default(), &mut scene);
4163            scene
4164                .primitives()
4165                .iter()
4166                .filter(|p| match p {
4167                    frus_core::Primitive::Rect { rect, .. } => {
4168                        (rect.width - (FIELD_ICON_SIZE + 8.0)).abs() < 0.5
4169                            && (rect.height - (FIELD_ICON_SIZE + 8.0)).abs() < 0.5
4170                    }
4171                    _ => false,
4172                })
4173                .count()
4174        };
4175        // Pointer over the suffix → one halo; elsewhere (the body) or without hover → none.
4176        let over_suffix = Status {
4177            hover_cursor: Some(Point::new(212.0, 20.0)),
4178            ..Default::default()
4179        };
4180        let over_body = Status {
4181            hover_cursor: Some(Point::new(20.0, 20.0)),
4182            ..Default::default()
4183        };
4184        assert_eq!(halos(over_suffix), 1, "a halo on the hovered suffix");
4185        assert_eq!(halos(over_body), 0, "no halo in the body");
4186        assert_eq!(halos(Status::default()), 0, "no halo without hover");
4187    }
4188
4189    #[test]
4190    fn cursor_icon_is_pointer_over_active_suffix() {
4191        use crate::interaction::Cursor;
4192        let field = TextField::new("hello")
4193            .suffix_icon(Icons::CLOSE)
4194            .on_suffix(Msg::Submitted)
4195            .width(220.0);
4196        let (w, h, y) = (220.0, 40.0, 12.0);
4197        // A hand over the suffix, nothing in the body.
4198        assert_eq!(
4199            Widget::<Msg>::cursor_icon(&field, w - 8.0, y, w, h),
4200            Some(Cursor::Pointer)
4201        );
4202        assert_eq!(Widget::<Msg>::cursor_icon(&field, 20.0, y, w, h), None);
4203        // A decorative suffix (no on_suffix): no hand.
4204        let deco = TextField::<Msg>::new("hello")
4205            .suffix_icon(Icons::CLOSE)
4206            .width(220.0);
4207        assert_eq!(Widget::<Msg>::cursor_icon(&deco, w - 8.0, y, w, h), None);
4208    }
4209
4210    /// **Each handle stands for its own end of the selection** (milestone 511): the point
4211    /// it carries, handed back to the field's own hit test, is that end — with a label and
4212    /// an icon moving the text in, and with text scrolled sideways, which is where a
4213    /// handle and its end would part company first. The shell drags a handle through
4214    /// exactly this round trip.
4215    #[test]
4216    fn each_handle_stands_for_its_own_end_of_the_selection() {
4217        let width = 220.0;
4218        let fields = [
4219            TextField::<Msg>::new("hello world").width(width),
4220            TextField::<Msg>::new("hello world")
4221                .label("Name")
4222                .prefix_icon(Icons::CLOSE)
4223                .width(width),
4224            // Outlined, the label sits on the border and the box starts below it.
4225            TextField::<Msg>::new("hello world")
4226                .label("Name")
4227                .outlined()
4228                .width(width),
4229            // Wider than its box, the caret at the far end: scrolled.
4230            TextField::<Msg>::new("a sentence long enough to be scrolled along its box")
4231                .width(width),
4232        ];
4233        for field in &fields {
4234            let len = field.value.chars().count();
4235            let edit = Edit {
4236                cursor: len,
4237                anchor: Some(len - 5),
4238                composing: None,
4239            };
4240            let [start, end] = Widget::<Msg>::selection_handles(field, width, &edit, 0.0)
4241                .expect("a selection has handles");
4242            for (handle, index) in [(start, len - 5), (end, len)] {
4243                let at = handle.line_center;
4244                assert_eq!(
4245                    Widget::<Msg>::cursor_at(field, at.x, at.y, width, edit.cursor),
4246                    Some(index),
4247                    "{:?}",
4248                    field.value
4249                );
4250            }
4251            // Each hangs below its line and points in at the text: the corner the start
4252            // one has squared off is its position, and the end one's likewise.
4253            assert_eq!(start.rect.x + start.rect.width, start.line_center.x);
4254            assert_eq!(end.rect.x, end.line_center.x);
4255            assert!(start.rect.y > start.line_center.y && end.rect.y > end.line_center.y);
4256            // And on the line the paint draws: the text's top, the line's middle, then the
4257            // handle hanging from its bottom. A single line's hit test clamps the height, so
4258            // only the paint can say the handles are at the right one.
4259            let mut scene = Scene::new();
4260            let status = Status {
4261                focused: true,
4262                cursor: Some(len),
4263                selection: Some((len - 5, len)),
4264                ..Status::default()
4265            };
4266            Widget::<Msg>::paint(
4267                field,
4268                Rect::new(0.0, 0.0, width, 120.0),
4269                status,
4270                &Theme::default(),
4271                &mut scene,
4272            );
4273            let top = scene
4274                .primitives()
4275                .iter()
4276                .find_map(|p| match p {
4277                    frus_core::Primitive::Text { position, text, .. } if *text == field.value => {
4278                        Some(position.y)
4279                    }
4280                    _ => None,
4281                })
4282                .expect("the value is painted");
4283            let half = start.line_center.y - top;
4284            assert!(
4285                half > 0.0 && (start.rect.y - top - 2.0 * half).abs() < 0.01,
4286                "{:?}: text at {top}, line centre at {}, handle at {}",
4287                field.value,
4288                start.line_center.y,
4289                start.rect.y
4290            );
4291        }
4292    }
4293
4294    /// A multi-line field scrolled down carries its handles up with the text, and the
4295    /// shell, folding the scroll back in as it does for a press, lands on the same ends.
4296    #[test]
4297    fn a_handle_rises_with_the_lines_a_field_scrolls_away() {
4298        let width = 200.0;
4299        let field = TextField::<Msg>::new("one\ntwo\nthree\nfour\nfive\nsix")
4300            .multiline()
4301            .rows(3)
4302            .width(width);
4303        // "five".
4304        let edit = Edit {
4305            cursor: 23,
4306            anchor: Some(19),
4307            composing: None,
4308        };
4309        let scroll = 20.0;
4310        let still = Widget::<Msg>::selection_handles(&field, width, &edit, 0.0).unwrap();
4311        let [start, end] = Widget::<Msg>::selection_handles(&field, width, &edit, scroll).unwrap();
4312        assert!((still[0].line_center.y - start.line_center.y - scroll).abs() < 0.01);
4313        for (handle, index) in [(start, 19), (end, 23)] {
4314            let at = handle.line_center;
4315            assert_eq!(
4316                Widget::<Msg>::cursor_at(&field, at.x, at.y + scroll, width, edit.cursor),
4317                Some(index)
4318            );
4319        }
4320    }
4321
4322    /// Handles are painted for a selection the shell marks as a touch one, and for no
4323    /// other: not for one made with a mouse, not in a field that has lost the focus. In
4324    /// the scheme's primary, unless the field says otherwise.
4325    #[test]
4326    fn the_handles_are_painted_for_a_touch_selection_only() {
4327        let theme = Theme::default();
4328        let fills = |field: &TextField<Msg>, handles: bool, focused: bool| {
4329            let mut scene = Scene::new();
4330            let status = Status {
4331                focused,
4332                cursor: Some(11),
4333                selection: Some((6, 11)),
4334                handles,
4335                ..Status::default()
4336            };
4337            Widget::<Msg>::paint(
4338                field,
4339                Rect::new(10.0, 20.0, 220.0, 56.0),
4340                status,
4341                &theme,
4342                &mut scene,
4343            );
4344            scene
4345                .primitives()
4346                .iter()
4347                .filter_map(|p| match p {
4348                    frus_core::Primitive::Path { fill: Some(c), .. } => Some(*c),
4349                    _ => None,
4350                })
4351                .collect::<Vec<_>>()
4352        };
4353        let field = TextField::<Msg>::new("hello world").width(220.0);
4354        assert_eq!(fills(&field, true, true), vec![theme.scheme.primary; 2]);
4355        assert!(fills(&field, false, true).is_empty(), "a mouse selection");
4356        assert!(fills(&field, true, false).is_empty(), "a field left behind");
4357        let styled = TextField::<Msg>::new("hello world")
4358            .width(220.0)
4359            .style(TextFieldStyle {
4360                handle_color: Some(frus_core::Color::WHITE),
4361                ..TextFieldStyle::default()
4362            });
4363        assert_eq!(fills(&styled, true, true), vec![frus_core::Color::WHITE; 2]);
4364    }
4365
4366    /// The caret's hidden half paints no caret, and its shown half does (milestone 513).
4367    #[test]
4368    fn a_hidden_caret_is_not_painted() {
4369        let theme = Theme::default();
4370        let carets = |hidden: bool| {
4371            let mut scene = Scene::new();
4372            let status = Status {
4373                focused: true,
4374                cursor: Some(2),
4375                caret_hidden: hidden,
4376                ..Status::default()
4377            };
4378            Widget::<Msg>::paint(
4379                &TextField::<Msg>::new("hello").width(220.0),
4380                Rect::new(0.0, 0.0, 220.0, 56.0),
4381                status,
4382                &theme,
4383                &mut scene,
4384            );
4385            scene
4386                .primitives()
4387                .iter()
4388                .filter(
4389                    |p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.width == 2.0),
4390                )
4391                .count()
4392        };
4393        assert_eq!(carets(false), 1, "shown");
4394        assert_eq!(carets(true), 0, "hidden");
4395    }
4396
4397    #[test]
4398    fn word_at_finds_word_bounds() {
4399        let inp = input("hello there world");
4400        // Any index inside a word yields that word's bounds; separators are excluded.
4401        assert_eq!(inp.word_at(2), Some((0, 5))); // "hello"
4402        assert_eq!(inp.word_at(13), Some((12, 17))); // "world"
4403    }
4404}
4405
4406#[cfg(test)]
4407mod limit_tests {
4408    use super::*;
4409    use crate::runtime::Edit;
4410
4411    #[derive(Clone, Debug, PartialEq)]
4412    enum Msg {
4413        Changed(String),
4414    }
4415
4416    fn field(value: &str) -> TextField<Msg> {
4417        TextField::new(value).on_input(Msg::Changed)
4418    }
4419
4420    /// Types `text` at the end of the field's value and returns what came back.
4421    fn typed(field: &TextField<Msg>, text: &str) -> (Option<Msg>, Edit) {
4422        let end = field.value.chars().count();
4423        let mut edit = Edit {
4424            cursor: end,
4425            anchor: None,
4426            ..Default::default()
4427        };
4428        let msg = Widget::on_edit(field, &mut edit, &Key::Text(text.to_string()));
4429        (msg, edit)
4430    }
4431
4432    fn value_of(msg: Option<Msg>) -> Option<String> {
4433        msg.map(|Msg::Changed(v)| v)
4434    }
4435
4436    /// A keystroke past the limit does not reach the value.
4437    #[test]
4438    fn a_keystroke_past_the_limit_goes_nowhere() {
4439        let full = field("abcde").max_length(5);
4440        assert_eq!(value_of(typed(&full, "f").0), Some("abcde".into()));
4441        let room = field("abcd").max_length(5);
4442        assert_eq!(value_of(typed(&room, "e").0), Some("abcde".into()));
4443    }
4444
4445    /// A paste that crosses the limit lands the part that fits rather than being dropped
4446    /// whole: losing work the user can see they had is the worse of the two answers.
4447    #[test]
4448    fn a_paste_that_crosses_the_limit_lands_what_fits() {
4449        let f = field("ab").max_length(5);
4450        let (msg, edit) = typed(&f, "cdefgh");
4451        assert_eq!(value_of(msg), Some("abcde".into()));
4452        assert_eq!(edit.cursor, 5, "the caret comes to rest at the end");
4453    }
4454
4455    /// The limit is in **characters**, not bytes.
4456    #[test]
4457    fn the_limit_counts_characters_not_bytes() {
4458        let f = field("").max_length(3);
4459        assert_eq!(value_of(typed(&f, "éàü").0), Some("éàü".into()));
4460        let full = field("éàü").max_length(3);
4461        assert_eq!(value_of(typed(&full, "e").0), Some("éàü".into()));
4462    }
4463
4464    /// A value the caller supplied over the limit is left alone — it is the application's
4465    /// state, not something typed — and the counter says so.
4466    #[test]
4467    fn a_value_the_caller_set_over_the_limit_is_left_alone() {
4468        let over = field("abcdefg").max_length(5);
4469        assert_eq!(over.counter().as_deref(), Some("7/5"));
4470        assert_eq!(over.value, "abcdefg");
4471    }
4472
4473    /// The counter reserves the line below the box even with no helper to share it with.
4474    #[test]
4475    fn the_counter_reserves_the_line_it_sits_on() {
4476        assert_eq!(
4477            field("ab").sub_block(),
4478            0.0,
4479            "nothing to say, no room taken"
4480        );
4481        assert!(field("ab").max_length(5).sub_block() > 0.0);
4482        assert_eq!(field("ab").max_length(5).counter().as_deref(), Some("2/5"));
4483    }
4484
4485    /// Read-only refuses the **change** and keeps everything that only moves.
4486    #[test]
4487    fn read_only_refuses_the_change_and_keeps_the_caret() {
4488        let f = field("hello").read_only();
4489        assert_eq!(typed(&f, "x").0, None, "typing goes nowhere");
4490
4491        let mut edit = Edit {
4492            cursor: 5,
4493            anchor: None,
4494            ..Default::default()
4495        };
4496        assert_eq!(
4497            Widget::on_edit(
4498                &f,
4499                &mut edit,
4500                &Key::Left {
4501                    shift: false,
4502                    word: false
4503                }
4504            ),
4505            None
4506        );
4507        assert_eq!(edit.cursor, 4, "but the caret still moves");
4508
4509        let mut edit = Edit {
4510            cursor: 5,
4511            anchor: None,
4512            ..Default::default()
4513        };
4514        Widget::on_edit(
4515            &f,
4516            &mut edit,
4517            &Key::Home {
4518                shift: true,
4519                doc: false,
4520            },
4521        );
4522        assert_eq!(
4523            (edit.cursor, edit.anchor),
4524            (0, Some(5)),
4525            "and a selection can still be made, so it can be copied"
4526        );
4527    }
4528
4529    /// Backspace is a change, so it is refused too — and leaves the caret alone.
4530    #[test]
4531    fn read_only_refuses_a_deletion() {
4532        let f = field("hello").read_only();
4533        let mut edit = Edit {
4534            cursor: 5,
4535            anchor: None,
4536            ..Default::default()
4537        };
4538        assert_eq!(Widget::on_edit(&f, &mut edit, &Key::Backspace), None);
4539        assert_eq!(edit.cursor, 5);
4540    }
4541
4542    /// It is not a disabled field: it keeps its caret and its place in the tab order.
4543    #[test]
4544    fn read_only_is_not_disabled() {
4545        let read = field("REF-4417").read_only();
4546        assert!(Widget::<Msg>::focusable(&read), "still in the tab order");
4547        assert!(
4548            Widget::<Msg>::cursor_at(&read, 20.0, 10.0, 200.0, 0).is_some(),
4549            "and it still takes a caret, so a reference can be selected and copied"
4550        );
4551
4552        let dead = field("REF-4417").enabled(false);
4553        assert!(!Widget::<Msg>::focusable(&dead));
4554        assert!(Widget::<Msg>::cursor_at(&dead, 20.0, 10.0, 200.0, 0).is_none());
4555    }
4556}
4557
4558/// The field's decoration, against the reference's (milestone 656).
4559#[cfg(test)]
4560mod decoration_tests {
4561    use super::*;
4562    use crate::Icons;
4563
4564    const W: f32 = 300.0;
4565
4566    /// Every text a field paints: what, where, in what colour.
4567    fn texts(field: &TextField<()>, status: Status) -> Vec<(String, Point, Color)> {
4568        let mut scene = Scene::new();
4569        Widget::<()>::paint(
4570            field,
4571            Rect::new(0.0, 0.0, W, 80.0),
4572            status,
4573            &Theme::default(),
4574            &mut scene,
4575        );
4576        scene
4577            .primitives()
4578            .iter()
4579            .filter_map(|p| match p {
4580                frus_core::Primitive::Text {
4581                    text,
4582                    position,
4583                    color,
4584                    ..
4585                } => Some((text.clone(), *position, *color)),
4586                _ => None,
4587            })
4588            .collect()
4589    }
4590
4591    fn find(texts: &[(String, Point, Color)], what: &str) -> Option<(Point, Color)> {
4592        texts
4593            .iter()
4594            .find(|(text, ..)| text == what)
4595            .map(|(_, at, color)| (*at, *color))
4596    }
4597
4598    fn focused() -> Status {
4599        Status {
4600            focused: true,
4601            focus_progress: 1.0,
4602            ..Default::default()
4603        }
4604    }
4605
4606    /// **The label's three behaviours**: auto rests inside an empty field and floats once
4607    /// it holds something; always floats at once, so the hint shows; never rests inside an
4608    /// empty field and is gone once it holds something.
4609    #[test]
4610    fn the_label_floats_as_it_is_told() {
4611        let label_y =
4612            |field: &TextField<()>, status| find(&texts(field, status), "Name").map(|(at, _)| at.y);
4613        let auto = TextField::<()>::new("").label("Name");
4614        let rest = label_y(&auto, Status::default()).unwrap();
4615        let floated = label_y(&TextField::new("Ada").label("Name"), Status::default()).unwrap();
4616        assert!(
4617            floated < rest,
4618            "auto floats once filled: {floated} < {rest}"
4619        );
4620
4621        let always = TextField::<()>::new("")
4622            .label("Name")
4623            .placeholder("e.g. Ada")
4624            .floating_label_behavior(FloatingLabelBehavior::Always);
4625        let shown = texts(&always, Status::default());
4626        assert_eq!(
4627            find(&shown, "Name").unwrap().0.y,
4628            floated,
4629            "floated at rest"
4630        );
4631        assert!(find(&shown, "e.g. Ada").is_some(), "and the hint shows");
4632
4633        let never = |value: &str| {
4634            TextField::<()>::new(value)
4635                .label("Name")
4636                .placeholder("e.g. Ada")
4637                .floating_label_behavior(FloatingLabelBehavior::Never)
4638        };
4639        assert_eq!(
4640            label_y(&never(""), focused()),
4641            Some(rest),
4642            "inside, even focused"
4643        );
4644        assert!(
4645            find(&texts(&never(""), focused()), "e.g. Ada").is_none(),
4646            "no hint"
4647        );
4648        assert_eq!(
4649            label_y(&never("Ada"), Status::default()),
4650            None,
4651            "gone once filled"
4652        );
4653
4654        let mut theme = Theme::default();
4655        theme.widgets.text_field.floating_label_behavior = Some(FloatingLabelBehavior::Always);
4656        let mut scene = Scene::new();
4657        Widget::<()>::paint(
4658            &TextField::<()>::new("").label("Name"),
4659            Rect::new(0.0, 0.0, W, 80.0),
4660            Status::default(),
4661            &theme,
4662            &mut scene,
4663        );
4664        let themed = scene.primitives().iter().find_map(|p| match p {
4665            frus_core::Primitive::Text { text, position, .. } if text == "Name" => Some(position.y),
4666            _ => None,
4667        });
4668        assert_eq!(themed, Some(floated), "the theme's word");
4669    }
4670
4671    /// **A centred label floats to the middle** of the box, and rests at the start.
4672    #[test]
4673    fn a_centred_label_floats_to_the_middle() {
4674        let x = |value: &str| {
4675            let field = TextField::<()>::new(value)
4676                .label("Name")
4677                .floating_label_alignment(FloatingLabelAlignment::Center);
4678            find(&texts(&field, Status::default()), "Name").unwrap().0.x
4679        };
4680        let width = frus_text::measure_resolved(
4681            "Name",
4682            &ResolvedTextStyle::exact(FIELD_TEXT_SIZE * FIELD_LABEL_SCALE),
4683        )
4684        .width;
4685        assert!(((x("Ada") + width / 2.0) - W / 2.0).abs() < 0.5, "centred");
4686        let rest = find(
4687            &texts(&TextField::<()>::new("").label("Name"), Status::default()),
4688            "Name",
4689        )
4690        .unwrap()
4691        .0
4692        .x;
4693        assert_eq!(x(""), rest, "at rest, where the value starts");
4694    }
4695
4696    /// **The affixes sit either side of the value**, in the hint's colour, and the value and
4697    /// the caret move over for the prefix. They show once the label floats.
4698    #[test]
4699    fn the_affixes_sit_either_side_of_the_value() {
4700        let theme = Theme::default();
4701        let plain = TextField::<()>::new("42");
4702        let priced = TextField::<()>::new("42")
4703            .prefix_text("$")
4704            .suffix_text("kg");
4705        let shown = texts(&priced, Status::default());
4706        let (dollar, colour) = find(&shown, "$").expect("the prefix");
4707        let (value, _) = find(&shown, "42").unwrap();
4708        let (unit, _) = find(&shown, "kg").expect("the suffix");
4709        assert_eq!(colour, theme.muted, "the hint's colour");
4710        let plain_x = find(&texts(&plain, Status::default()), "42").unwrap().0.x;
4711        let style = TextStyle::new(FIELD_TEXT_SIZE).resolved();
4712        let dollar_w = frus_text::measure_resolved("$", &style).width;
4713        assert_eq!(dollar.x, plain_x, "the prefix where the value was");
4714        assert!(
4715            (value.x - (plain_x + dollar_w)).abs() < 1e-3,
4716            "the value after it"
4717        );
4718        let unit_w = frus_text::measure_resolved("kg", &style).width;
4719        assert!(
4720            unit.x + unit_w <= W && unit.x > value.x,
4721            "the suffix at the end"
4722        );
4723
4724        // A click just after the prefix lands at the value's start.
4725        let at = Widget::<()>::cursor_at(&priced, value.x + 0.5, 30.0, W, 0);
4726        assert_eq!(at, Some(0));
4727
4728        // Empty and unfocused under a resting label, they wait; focused, they show.
4729        let labelled = TextField::<()>::new("").label("Price").prefix_text("$");
4730        assert!(find(&texts(&labelled, Status::default()), "$").is_none());
4731        assert!(find(&texts(&labelled, focused()), "$").is_some());
4732        // The resting label sits before the prefix, as the reference lays it out
4733        // (`input_decorator.dart:1520`): the prefix is no part of where it rests.
4734        let without = TextField::<()>::new("").label("Price");
4735        assert_eq!(
4736            find(&texts(&labelled, Status::default()), "Price")
4737                .unwrap()
4738                .0
4739                .x,
4740            find(&texts(&without, Status::default()), "Price")
4741                .unwrap()
4742                .0
4743                .x
4744        );
4745        let bare = TextField::<()>::new("").prefix_text("$");
4746        assert!(
4747            find(&texts(&bare, Status::default()), "$").is_none(),
4748            "no label: focus"
4749        );
4750        assert!(find(&texts(&bare, focused()), "$").is_some());
4751    }
4752
4753    /// **The icon outside the box**: the box, its value and the line under it all start
4754    /// after it, and a click is measured from there.
4755    #[test]
4756    fn the_outer_icon_moves_the_box_over() {
4757        let plain = TextField::<()>::new("42").helper("Help");
4758        let iconed = TextField::<()>::new("42").helper("Help").icon(Icons::STAR);
4759        let before = texts(&plain, Status::default());
4760        let after = texts(&iconed, Status::default());
4761        for what in ["42", "Help"] {
4762            let shift = find(&after, what).unwrap().0.x - find(&before, what).unwrap().0.x;
4763            assert_eq!(shift, 40.0, "{what} moves over by 24 and 16");
4764        }
4765        let dense = TextField::<()>::new("42").dense(true).icon(Icons::STAR);
4766        let dense_plain = TextField::<()>::new("42").dense(true);
4767        let shift = find(&texts(&dense, Status::default()), "42").unwrap().0.x
4768            - find(&texts(&dense_plain, Status::default()), "42")
4769                .unwrap()
4770                .0
4771                .x;
4772        assert_eq!(shift, 34.0, "18 and 16, dense");
4773        let x = find(&after, "42").unwrap().0.x;
4774        assert_eq!(
4775            Widget::<()>::cursor_at(&iconed, x + 0.5, 30.0, W, 0),
4776            Some(0)
4777        );
4778        // The box itself starts after the icon.
4779        let boxes: Vec<Rect> = {
4780            let mut scene = Scene::new();
4781            Widget::<()>::paint(
4782                &iconed,
4783                Rect::new(0.0, 0.0, W, 80.0),
4784                Status::default(),
4785                &Theme::default(),
4786                &mut scene,
4787            );
4788            scene
4789                .primitives()
4790                .iter()
4791                .filter_map(|p| match p {
4792                    frus_core::Primitive::Rect { rect, .. } => Some(*rect),
4793                    _ => None,
4794                })
4795                .collect()
4796        };
4797        assert_eq!(boxes[0].x, 40.0);
4798        assert_eq!(boxes[0].width, W - 40.0);
4799    }
4800
4801    /// **The counter's text**: said instead of the count, and an empty one hides it.
4802    #[test]
4803    fn the_counter_says_what_it_is_told() {
4804        let counted = TextField::<()>::new("abc").max_length(10);
4805        assert!(find(&texts(&counted, Status::default()), "3/10").is_some());
4806        let told = TextField::<()>::new("abc")
4807            .max_length(10)
4808            .counter_text("3 of 10");
4809        let shown = texts(&told, Status::default());
4810        assert!(find(&shown, "3 of 10").is_some() && find(&shown, "3/10").is_none());
4811        let hidden = TextField::<()>::new("abc").max_length(10).counter_text("");
4812        assert!(find(&texts(&hidden, Status::default()), "3/10").is_none());
4813        assert_eq!(hidden.sub_block(), 0.0, "and its line is not kept");
4814        let alone = TextField::<()>::new("abc").counter_text("Optional");
4815        assert!(find(&texts(&alone, Status::default()), "Optional").is_some());
4816        assert!(alone.sub_block() > 0.0);
4817    }
4818}
4819
4820/// A box that grows with its value (milestone 657).
4821#[cfg(test)]
4822mod lines_tests {
4823    use super::*;
4824
4825    const W: f32 = 300.0;
4826
4827    /// A value of `n` short words: enough of them wrap.
4828    fn words(n: usize) -> String {
4829        vec!["word"; n].join(" ")
4830    }
4831
4832    fn lines(field: &TextField<()>) -> f32 {
4833        field.lines_at(W)
4834    }
4835
4836    /// **Given only a most, the box starts at one line** and grows as the value wraps, up
4837    /// to the most, as the reference's `maxLines` does.
4838    #[test]
4839    fn a_box_grows_from_one_line_to_the_most() {
4840        let empty = TextField::<()>::new("").max_lines(4);
4841        assert!(empty.grows());
4842        assert_eq!(lines(&empty), 1.0, "one line, empty");
4843        let short = TextField::<()>::new(words(2)).max_lines(4);
4844        assert_eq!(lines(&short), 1.0);
4845        let wrapped = TextField::<()>::new(words(40)).max_lines(4);
4846        assert_eq!(lines(&wrapped), 4.0, "held at the most");
4847        let some = TextField::<()>::new(words(14)).max_lines(4);
4848        let n = lines(&some);
4849        assert!(n > 1.0 && n < 4.0, "in between: {n}");
4850        let unbounded = TextField::<()>::new(words(160)).max_lines(None);
4851        assert!(lines(&unbounded) > 12.0, "no most: {}", lines(&unbounded));
4852    }
4853
4854    /// **The fewest lines hold the box open**, and the most is never below them.
4855    #[test]
4856    fn the_fewest_lines_hold_the_box_open() {
4857        let held = TextField::<()>::new("").min_lines(2).max_lines(5);
4858        assert_eq!(lines(&held), 2.0);
4859        let either_order = TextField::<()>::new("").max_lines(5).min_lines(2);
4860        assert_eq!(lines(&either_order), 2.0, "said after the most");
4861        let inverted = TextField::<()>::new(words(40)).min_lines(6).max_lines(3);
4862        assert_eq!(
4863            lines(&inverted),
4864            6.0,
4865            "a most below the fewest is the fewest"
4866        );
4867        assert!(!inverted.grows());
4868        let raised = TextField::<()>::new(words(40)).max_lines(3).min_lines(6);
4869        assert_eq!(lines(&raised), 6.0, "the fewest raise a most said first");
4870        assert!(!raised.grows());
4871    }
4872
4873    /// **One line at most is a single-line field**, and `rows` still fixes the box.
4874    #[test]
4875    fn one_line_and_fixed_rows_do_not_grow() {
4876        let single = TextField::<()>::new(words(40)).max_lines(1);
4877        assert!(!single.multiline && !single.grows());
4878        assert_eq!(lines(&single), 1.0);
4879        let fixed = TextField::<()>::new(words(40)).rows(3);
4880        assert!(!fixed.grows());
4881        assert_eq!(lines(&fixed), 3.0);
4882        assert!(Widget::<()>::measure(&fixed, &Theme::default()).is_none());
4883        assert!(matches!(
4884            Widget::<()>::style(&fixed).height,
4885            Dimension::Length(_)
4886        ));
4887    }
4888
4889    /// **The layout, the paint and the click agree on the height**: a growing box is
4890    /// measured from its width, painted that tall, and its suffix answers down its whole
4891    /// height.
4892    #[test]
4893    fn the_layout_and_the_paint_agree() {
4894        let field = TextField::<()>::new(words(14))
4895            .max_lines(6)
4896            .label("Notes")
4897            .suffix_icon(crate::Icons::CLOSE)
4898            .on_suffix(());
4899        let style = Widget::<()>::style(&field);
4900        assert_eq!(style.height, Dimension::Auto);
4901        let theme = Theme::default();
4902        let measure = Widget::<()>::measure(&field, &theme).expect("measured");
4903        let size = measure(Some(W), None);
4904        let expected =
4905            frus_core::fits(field.label_block() + field.field_height_at(W) + field.sub_block());
4906        assert_eq!(size.height, expected, "in whole pixels");
4907        assert_eq!(size.height.fract(), 0.0);
4908        assert!(
4909            field.field_height_at(W) > field.field_height(),
4910            "taller than one line"
4911        );
4912        let narrow = measure(Some(200.0), None);
4913        assert!(narrow.height > size.height, "narrower wraps more");
4914
4915        let mut scene = Scene::new();
4916        Widget::<()>::paint(
4917            &field,
4918            Rect::new(0.0, 0.0, W, size.height),
4919            Status::default(),
4920            &theme,
4921            &mut scene,
4922        );
4923        let tallest = scene
4924            .primitives()
4925            .iter()
4926            .filter_map(|p| match p {
4927                frus_core::Primitive::Rect { rect, .. } => Some(rect.height),
4928                _ => None,
4929            })
4930            .fold(0.0, f32::max);
4931        assert_eq!(tallest, field.field_height_at(W), "the box is that tall");
4932        let low = field.label_block() + field.field_height_at(W) - 4.0;
4933        assert!(
4934            field.suffix_hit(W - 10.0, low, W),
4935            "the suffix answers low in the box"
4936        );
4937
4938        let key = |f: &TextField<()>| Widget::<()>::measure_key(f, &theme);
4939        assert_ne!(
4940            key(&TextField::<()>::new(words(14)).max_lines(6).label("Notes")),
4941            key(&TextField::<()>::new(words(30)).max_lines(6).label("Notes")),
4942            "another value, and nothing else, another measurement"
4943        );
4944        assert_ne!(
4945            key(&TextField::<()>::new(words(14)).max_lines(6)),
4946            key(&TextField::<()>::new(words(14))
4947                .max_lines(6)
4948                .prefix_text("$")),
4949            "a prefix narrows the wrap"
4950        );
4951    }
4952}
4953
4954/// The caret, the mask and the focus a field asks for (milestone 659).
4955#[cfg(test)]
4956mod cursor_tests {
4957    use super::*;
4958    use frus_core::{BorderRadius, Primitive, TargetPlatform};
4959
4960    /// Every rectangle a focused field paints: where, in what colour, how round.
4961    fn rects(field: &TextField<()>, theme: &Theme) -> Vec<(Rect, Color, BorderRadius)> {
4962        let mut scene = Scene::new();
4963        Widget::<()>::paint(
4964            field,
4965            Rect::new(0.0, 0.0, 300.0, 80.0),
4966            Status {
4967                focused: true,
4968                focus_progress: 1.0,
4969                cursor: Some(2),
4970                ..Default::default()
4971            },
4972            theme,
4973            &mut scene,
4974        );
4975        scene
4976            .primitives()
4977            .iter()
4978            .filter_map(|p| match p {
4979                Primitive::Rect {
4980                    rect,
4981                    color,
4982                    radius,
4983                    ..
4984                } => Some((*rect, *color, *radius)),
4985                _ => None,
4986            })
4987            .collect()
4988    }
4989
4990    /// The caret: the rectangle in `color`.
4991    fn caret(field: &TextField<()>, theme: &Theme, color: Color) -> Option<(Rect, BorderRadius)> {
4992        rects(field, theme)
4993            .into_iter()
4994            .find(|(_, c, _)| *c == color)
4995            .map(|(r, _, radius)| (r, radius))
4996    }
4997
4998    /// **The caret is `primary`**, as the reference's is, and the error's colour while one
4999    /// shows; the field's colour over the theme's, the field theme's over the text
5000    /// selection's.
5001    #[test]
5002    fn the_caret_takes_the_reference_s_colours() {
5003        let theme = Theme::default();
5004        let field = TextField::<()>::new("ab");
5005        let (rect, _) = caret(&field, &theme, theme.scheme.primary).expect("in primary");
5006        assert_eq!(rect.width, 2.0, "the reference's two pixels");
5007
5008        let errored = TextField::<()>::new("ab").error("Wrong");
5009        assert!(
5010            caret(&errored, &theme, theme.scheme.error).is_some(),
5011            "in error"
5012        );
5013        assert!(caret(&errored, &theme, theme.scheme.primary).is_none());
5014
5015        let red = Color::rgb(1.0, 0.0, 0.0);
5016        let told = TextField::<()>::new("ab").cursor_color(red);
5017        assert!(caret(&told, &theme, red).is_some(), "the field's");
5018        let blue = Color::rgb(0.0, 0.0, 1.0);
5019        let told_error = TextField::<()>::new("ab")
5020            .error("Wrong")
5021            .cursor_error_color(blue);
5022        assert!(
5023            caret(&told_error, &theme, blue).is_some(),
5024            "the field's error colour"
5025        );
5026
5027        let mut selection = Theme::default();
5028        selection.widgets.text_selection.cursor_color = Some(red);
5029        assert!(
5030            caret(&field, &selection, red).is_some(),
5031            "the text selection's"
5032        );
5033        let mut both = selection.clone();
5034        both.widgets.text_field.cursor_color = Some(blue);
5035        assert!(
5036            caret(&field, &both, blue).is_some(),
5037            "the field theme's over it"
5038        );
5039    }
5040
5041    /// **Its shape**: the width and height told, the height centred on the line; rounded on
5042    /// Apple's platforms and square elsewhere unless told; hidden when told.
5043    #[test]
5044    fn the_caret_takes_its_shape() {
5045        let theme = Theme::default().with_platform(TargetPlatform::Android);
5046        let primary = theme.scheme.primary;
5047        let (line, square) = caret(&TextField::new("ab"), &theme, primary).unwrap();
5048        assert_eq!(square, BorderRadius::uniform(0.0), "square on Android");
5049
5050        let shaped = TextField::<()>::new("ab")
5051            .cursor_width(3.0)
5052            .cursor_height(10.0);
5053        let (rect, _) = caret(&shaped, &theme, primary).unwrap();
5054        assert_eq!((rect.width, rect.height), (3.0, 10.0));
5055        let centre = |r: Rect| r.y + r.height / 2.0;
5056        assert!(
5057            (centre(rect) - centre(line)).abs() < 1e-3,
5058            "centred on the line"
5059        );
5060
5061        let ios = Theme::default().with_platform(TargetPlatform::Ios);
5062        let (_, round) = caret(&TextField::new("ab"), &ios, ios.scheme.primary).unwrap();
5063        assert_eq!(round, BorderRadius::uniform(2.0), "rounded on iOS");
5064        let told = TextField::<()>::new("ab").cursor_radius(1.0);
5065        assert_eq!(
5066            caret(&told, &theme, primary).unwrap().1,
5067            BorderRadius::uniform(1.0)
5068        );
5069
5070        let hidden = TextField::<()>::new("ab").show_cursor(false);
5071        assert!(caret(&hidden, &theme, primary).is_none(), "not shown");
5072    }
5073
5074    /// **A masked value shows the character told**, a dot unless told.
5075    #[test]
5076    fn a_masked_value_shows_the_character_told() {
5077        assert_eq!(TextField::<()>::new("abc").obscure(true).display(), "•••");
5078        let starred = TextField::<()>::new("abc")
5079            .obscure(true)
5080            .obscuring_character('*');
5081        assert_eq!(starred.display(), "***");
5082        assert_eq!(
5083            TextField::<()>::new("abc")
5084                .obscuring_character('*')
5085                .display(),
5086            "abc",
5087            "only a masked field"
5088        );
5089    }
5090
5091    /// **A field asks for the focus when told**, and a disabled one never does.
5092    #[test]
5093    fn a_field_asks_for_the_focus_when_told() {
5094        assert!(!Widget::<()>::autofocus(&TextField::new("")));
5095        assert!(Widget::<()>::autofocus(&TextField::new("").autofocus(true)));
5096        assert!(!Widget::<()>::autofocus(
5097            &TextField::new("").autofocus(true).enabled(false)
5098        ));
5099    }
5100}