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