Skip to main content

frust_widgets/
textinput.rs

1//! The `TextInput` interactive widget: an editable text field, single-line by
2//! default and optionally wrapped multi-line.
3//!
4//! [`text_input`] produces a [`TextInputView`] carrying the current `value`, a
5//! `placeholder`, an `on_change` callback, and an optional `on_submit`. Like the
6//! other interactive widgets it is a **controlled component**
7//! (`docs/CODE_STANDARDS.md`'s Interaction Semantics): it never owns the durable
8//! value. Each edit reports the *requested* text through `on_change`, and the
9//! next `rebuild` reconciles the app-confirmed `value` back into the underlying
10//! [`TextEditor`] — set-if-different, preserving the selection while the text is
11//! unchanged.
12//!
13//! [`TextInputView::text_style`] sets the content text's style (family/weight/
14//! style/size/letter-spacing/line-height, plus color when set explicitly) and
15//! never touches the chrome colors or geometry (see *Chrome*). Mirroring
16//! [`Text`](crate::TextView)'s [`effective_style`](TextInputWidget::effective_style),
17//! a field that did not call `.text_style(...)` gets `on_surface` resolved at LAYOUT
18//! time — where `TextInput`, like `Text`, bakes color into the shaped editor
19//! state — falling back to black with no theme threaded: explicit > theme >
20//! black. Baked-at-layout resolution is safe only under the `set_theme` →
21//! `ChangeFlags::LAYOUT` contract (`docs/CODE_STANDARDS.md` Theming), which
22//! `RenderRoot::set_theme` guarantees.
23//!
24//! # Text context ownership
25//!
26//! Unlike the [`Text`](crate::TextView) leaf (which shapes against the shared
27//! `TextContext` threaded through `LayoutCtx`), a `TextInput` must apply edits
28//! *synchronously during the event pass*, where no context is threaded, so it owns
29//! its own [`TextContext`]: `on_change` and the published [`ImeState`] observe the
30//! fresh editing value at once, and layout needs no threaded one.
31//!
32//! **App fonts still reach it.** `TextContext::new` seeds from `frust-text`'s
33//! process-wide app-font record, where every `frust::register_app_fonts` payload
34//! lands when the shell drains it. A font registered *later* is picked up by
35//! `TextContext::sync_app_fonts` at the top of [`Widget::layout`], which rebuilds
36//! the editor so its retained parley layout re-shapes too — the shape cache
37//! alone would not cover it.
38//!
39//! # Focus, IME and blink
40//!
41//! A `Down` inside the field requests focus, places the caret, and publishes an
42//! [`ImeState`] so the shell can drive the platform input method — the focus/IME
43//! channel `docs/CORE_ARCHITECTURE.md`'s Focus/IME Lifecycle owns. Keyboard
44//! editing (`Key`), IME composition/state-sync (`Ime`) and clipboard commands
45//! ([`EditCommand`]) all route down that focus path, never a hit test; after any
46//! edit the widget fires `on_change`, resets the caret to visible, and
47//! republishes the IME surface.
48//!
49//! The caret blinks while focused, its phase measured in `paint` from the shell
50//! frame clock ([`PaintCtx::frame_time`] — no wall-clock reads in widget code).
51//! Its continuation frame is a paced (`CosmeticLoop`) request at the blink's own
52//! [`BLINK_MS`] half-period ([`PaintCtx::request_frame_paced_at`]) — its own
53//! slower cadence rather than the theme's cap rate, one paint per visibility
54//! toggle being all a 500ms blink needs. A concurrent cap-rate request repaints it
55//! more often through the MIN-lattice with no visible effect, safe at any cadence
56//! because the phase is `frame_time - blink_epoch`
57//! ([`caret_visible_at`](TextInputWidget::caret_visible_at)) — a pure function of
58//! this frame's own timestamp, not of a delta between painted frames. An
59//! edit/focus during the (clockless) event pass flags the blink for reset, the
60//! next paint recording the epoch from `frame_time`.
61//!
62//! `reduce_motion` freezes the caret **visible** (lit, not hidden) rather than
63//! mid-blink, and stops requesting blink frames while focused: unlike a purely
64//! decorative loop the caret is also the edit-point cue, so freezing it dark
65//! would hide where typing lands. The IME surface republishes every painted frame
66//! regardless, and blinking resumes once the token clears.
67//!
68//! # Clipboard and selection commands
69//!
70//! Copy, cut, paste and select-all reach the field two ways, and both end in the
71//! same [`handle_command`](TextInputWidget::handle_command):
72//!
73//! * as a decoded [`EditCommand`] on [`InputEvent::EditCommand`] — what a shell
74//!   dispatches for a platform edit menu, an Android `ACTION_PROCESS_TEXT`, a
75//!   hardware clipboard key, or a chord it decided to decode itself;
76//! * as a chord this widget decodes from a plain `Key`, because a desktop shell
77//!   forwards `Cmd+C` as a keystroke like any other. `ctrl` **or** `meta` plus
78//!   `c`/`x`/`v`/`a` (ASCII case-insensitive) covers every desktop platform
79//!   uniformly — Flutter is platform-strict here (meta on macOS/iOS, ctrl
80//!   elsewhere) and a shell wanting that strictness decodes the chord and sends
81//!   an [`EditCommand`] instead. [`NamedKey::Copy`]/[`Cut`](NamedKey::Cut)/
82//!   [`Paste`](NamedKey::Paste), `Ctrl+Insert`, `Shift+Insert` and
83//!   `Shift+Delete` are the legacy spellings of the same three verbs; `alt` is
84//!   never a chord modifier, and any other chorded character is consumed rather
85//!   than typed.
86//!
87//! **The clipboard itself lives in the shell**, so the two directions are
88//! asymmetric (see [`EditCommand`]): a copy/cut answers by writing into the
89//! pass's clipboard slot ([`EventCtx::write_clipboard`]), while a paste is
90//! *asked for* ([`EventCtx::request_paste`]) and arrives on a later pass with
91//! its text already read. Nothing here touches a host clipboard.
92//!
93//! **Refusals are the field's own call.** An obscured field copies and cuts
94//! nothing — neither the real buffer nor its bullet mirror is worth handing out
95//! — a collapsed selection makes copy and cut no-ops, and a paste sanitised down
96//! to nothing ([`frust_text::sanitize_paste`], which denies newlines in a
97//! single-line field and normalises CRLF in a multi-line one) inserts nothing.
98//! Each is still *handled*: the verb was understood and answered with "nothing".
99//! A disabled field never sees a command at all, because it never holds focus.
100//! A **read-only** field does see them: it is focusable precisely so its content
101//! can be selected and copied, so it answers copy and select-all in full and
102//! answers cut and paste with nothing (see "Read-only mode").
103//!
104//! # Selection gestures and the toolbar
105//!
106//! Four gestures reach the selection, and only two of them raise the toolbar:
107//!
108//! * a **stationary long-press** ([`LONG_PRESS_MS`](crate::gesture) held within
109//!   [`TOUCH_SLOP`]) selects the word under the press point and opens the
110//!   toolbar — Android's gesture, and the only one a touch-only device has;
111//! * a **double-tap** (a second press within
112//!   [`DOUBLE_TAP_MS`](crate::gesture) and [`TOUCH_SLOP`] of the last one)
113//!   selects the word and deliberately opens **nothing**: it is a selection
114//!   gesture, and a bar appearing under a word the user is about to type over
115//!   is in the way;
116//! * a **tap inside an existing selection** keeps that selection and toggles
117//!   the toolbar on the release — fire-on-up-inside like every other baseline
118//!   widget, so a drag that starts inside a selection is still an ordinary
119//!   caret drag;
120//! * a **secondary press** claims focus (starting a session if there was none —
121//!   the desktop context-menu gesture; no mobile shell delivers `Secondary`),
122//!   moves no caret, and toggles the toolbar.
123//!
124//! Everything else puts it away: any text change, a blur or focus release, a
125//! scroll, Escape, any primary `Down`, and any applied [`EditCommand`]. A
126//! `Cancel` is the one exception — it clears the in-flight gesture and touches
127//! neither the selection nor the toolbar, per the never-mutate-on-cancel
128//! convention.
129//!
130//! **The long-press timer is measured across paints**, exactly as
131//! [`crate::gesture`]'s is and for the same reason: only the paint pass carries
132//! a clock ([`PaintCtx::frame_time`]). A press records its epoch on the first
133//! paint after the `Down`, and the paint that observes the threshold crossed
134//! marks the hold elapsed, latches
135//! [`frust_core::mark_pending_result_flush`] and requests a plain
136//! continuation frame — plain, not the blink's paced one, because a long-press
137//! must fire at its threshold in wall time even on a frame-gated shell, and
138//! even while `reduce_motion` has frozen the blink. The word is actually
139//! selected on the next pass carrying an [`EventCtx`]: the
140//! [`InputEvent::Housekeeping`] broadcast that flush produces, or an in-slop
141//! `Move`/`Up` that arrives first.
142//!
143//! **The press runs through explicit phases** ([`Gesture`]), because a press
144//! that a long-press already resolved is neither a tap nor a drag and must not
145//! be mistaken for either. A primary `Down` starts it as `Tap`; wandering past
146//! [`TOUCH_SLOP`] turns it into `Drag`; firing the hold turns it into
147//! `HoldFired`, which **keeps the press point** so the slop guard still has
148//! something to measure against while the finger stays down. That last part is
149//! the whole reason the phase exists: with the point simply forgotten at the
150//! fire, the very next `Move` — even a sub-pixel one — fell through to the
151//! caret-drag path and re-resolved the selection from wherever the pointer
152//! now was. `TOUCH_SLOP` is 18 logical px, several characters at a normal text
153//! size, so that reached across a word boundary: a finger the user was holding
154//! deliberately still could silently widen its own selection while the toolbar
155//! stood over it advertising verbs computed from the narrower one.
156//!
157//! **Post-hold drag semantics are deliberate**, not inherited from the
158//! caret-drag fall-through. A finger still down after a long-press:
159//!
160//! * **within** [`TOUCH_SLOP`] of the press point — does *nothing*. The word
161//!   the hold selected stays exactly as it is, however much the finger jitters.
162//! * **past** [`TOUCH_SLOP`] — extends the selection **by whole words**, and
163//!   keeps tracking the finger in both directions (dragging back toward the
164//!   press point shrinks it again rather than sticking at its widest). This is
165//!   Android's long-press-drag behaviour.
166//!
167//! That word granularity is the *editor's* retained selection anchor doing the
168//! work, not an op this widget picks per move: `EditOp::SelectWordAtPoint`
169//! leaves the selection word-anchored, and the `EditOp::MoveToPoint { select:
170//! true }` each later move issues extends from that anchor at the granularity
171//! it was anchored with — the same op after a plain caret press extends by
172//! cluster instead. It is therefore an assumption about the text engine rather
173//! than a local invariant, and it is pinned by a test
174//! (`a_drag_out_of_a_fired_hold_extends_by_word_and_tracks_back`) so a text-engine
175//! change that dropped it would fail loudly here instead of quietly truncating
176//! every long-press drag to the cluster under the pointer.
177//!
178//! **The double-tap window keeps its own clock alive under `reduce_motion`.**
179//! Both halves of that window are dated from the paint clock
180//! ([`PaintCtx::frame_time`], cached as the event pass's only clock), which
181//! advances only while something is painting — normally the caret blink's own
182//! paced request. `reduce_motion` stops those requests, so an idle focused
183//! field would leave the clock frozen at the moment the tap completed and the
184//! window would never elapse: a press arriving any amount of wall time later
185//! would still measure zero and resolve as a double-tap. While a tap is still
186//! inside its window and the blink is frozen, `paint` therefore requests plain
187//! continuation frames of its own, exactly as the long-press timer does and for
188//! the same reason. Outside `reduce_motion` the blink already pumps the clock,
189//! so the window is quantized to the blink's 500ms cadence rather than measured
190//! to the millisecond — deliberate coarseness, not a second stall.
191//!
192//! **The toolbar itself is somebody else's widget.** The field hosts an
193//! [`OverlaySlot`] and fills it from the process-wide
194//! [`selection_toolbar_builder`], so `frust-widgets` never names the view that
195//! floats: no builder installed means no pod and no toolbar. The pod is mounted
196//! and dropped in `rebuild` (the only pass with a `BuildCtx`) and only when the
197//! *action set* changes, placed in `paint` against the selection's bounding box
198//! — the union of the displayed
199//! [`selection_rects`](frust_text::TextEditor::selection_rects), or the caret
200//! rect when the selection is collapsed. Its verbs are the field's own call:
201//! copy/cut need a selection and a field that is not
202//! [`obscured`](TextInputView::obscured), cut and paste need an interactive
203//! one, and select-all needs text that is not already wholly selected.
204//!
205//! Under [`SelectionToolbarPolicy::Native`] the field floats nothing and only
206//! publishes the request ([`PaintCtx::publish_selection_toolbar`]) for the
207//! shell to hand to the platform's own edit menu — but it publishes that
208//! request under **both** policies, so the two routes diverge downstream of one
209//! code path. The publish happens on **every** paint of a focused field, bar or
210//! no bar: the verbs are a level a platform responder chain reads whenever it
211//! likes (a hardware Cmd+C never raises a bar first), and
212//! [`SelectionToolbarRequest::present_menu`] is the single edge inside it that
213//! says a menu is wanted now. A verb the pod dispatches
214//! ([`EventCtx::dispatch_edit_command`]) is drained in the same pass by
215//! [`EventCtx::take_edit_commands`] and applied through the same
216//! [`handle_command`](TextInputWidget::handle_command) a keyboard chord takes,
217//! under the same focus gate the shell-delivered route enforces — the pod is
218//! only ever mounted over a focused field, and the two routes into
219//! `handle_command` must not disagree about when a verb may land.
220//!
221//! # The clipboard verbs and assistive technology
222//!
223//! The floated toolbar is a **pointer affordance**. It appears only after a
224//! long-press or a tap inside a selection, and the portal deliberately
225//! contributes no semantics for the pod it floats, so nothing about it is
226//! reachable by a screen reader. The verbs are therefore published on the
227//! field's **own** [`Widget::semantics`] node instead, as accesskit *custom*
228//! actions (`accesskit::Action` has no Copy/Cut/Paste/SelectAll of its own —
229//! each verb is a stable id plus a label the client reads out, see
230//! [`A11Y_CUT_ID`]).
231//!
232//! Two rules make that route trustworthy:
233//!
234//! * **It never depends on the bar being up.** The actions are published from
235//!   [`toolbar_actions`](TextInputWidget::toolbar_actions) alone — the same
236//!   predicates the bar is built from — and not from `toolbar_open`. Gating
237//!   them on the bar would mean the verbs existed only for a user who had
238//!   already performed the pointer gesture that raises it. The platform
239//!   edit-menu route obeys the same rule, for the same reason and by the same
240//!   means (see `paint`'s publish).
241//! * **Only enabled verbs are offered.** An obscured field publishes no
242//!   copy/cut, a non-interactive one no cut/paste, and a fully-selected or
243//!   empty field no select-all, exactly as the bar refuses them. An action
244//!   offered and then refused is worse than one never offered.
245//!
246//! **The selection range itself is not published.** accesskit models a text
247//! selection as a pair of `TextPosition`s, and a `TextPosition` must name a
248//! node whose role is `Role::TextRun` — this field contributes a single leaf
249//! node carrying its text as a plain value, with no per-run child nodes for
250//! those positions to point at. Publishing a range would mean restructuring the
251//! field's semantics into a text-run subtree, which is a larger change than
252//! this one and is not attempted here; the gap is stated rather than papered
253//! over with an invented range.
254//!
255//! **What is still missing is the dispatch, and it does not live here.** A
256//! platform adapter reports an invoked custom action as an
257//! `accesskit::ActionRequest` carrying `Action::CustomAction` plus the id in
258//! its `data`, but the shell-to-core seam
259//! (`AppTree::perform_accessibility_action`) forwards only `(node_id, action)`
260//! and drops `data`, and `RenderRoot::perform_accessibility_action` models only
261//! `Click` and `Focus`. Until both are widened, these actions are advertised
262//! but cannot be delivered. The field's own half is complete: every verb has a
263//! route into [`handle_command`](TextInputWidget::handle_command) the moment
264//! one arrives as an [`InputEvent::EditCommand`], which is exactly what a shell
265//! already dispatches for a platform edit menu.
266//!
267//! # Multi-line mode
268//!
269//! [`TextInputView::multiline(max_visible_lines)`](TextInputView::multiline)
270//! feeds the layout width from the incoming constraints to the editor as a
271//! soft-wrap width (`TextEditor::set_wrap_width`), so the field grows vertically
272//! one line at a time as content wraps or newlines are inserted, capped at
273//! `max_visible_lines`. Past the cap the box height freezes and the text scrolls
274//! vertically by a **keep-caret-in-view** paint offset recomputed statelessly
275//! each pass from the caret's line rect (momentum and a scrollbar are out of
276//! scope), a rectangular clip keeping the overflow inside.
277//!
278//! Enter is governed by [`submit_on_enter`](TextInputView::submit_on_enter):
279//! `true` (the single-line default) fires `on_submit`, `false` (the multi-line
280//! default) inserts a literal newline, and **Shift+Enter always does the opposite
281//! of the mode's default** (a single-line field ignores the knob and always
282//! submits). On the mobile IME path a Return arrives as a `Commit` of a newline,
283//! which submit-on-enter fields submit and a newline-inserting one inserts.
284//!
285//! # Disabled mode
286//!
287//! [`TextInputView::enabled(false)`](TextInputView::enabled) makes the field
288//! inert and dims it. Inertness hangs off **one** hook, the focus gate: a `Down`
289//! in a disabled field neither requests focus nor captures the pointer, and since
290//! `Key`/`Ime` reach a widget only along the recorded focus path, refusing focus
291//! makes keyboard and IME editing impossible with no per-handler guard. A field
292//! disabled *while* focused releases the focus path on the first event reaching
293//! it and stops behaving as focused (no caret, no blink frame, no active IME
294//! surface) from the very next paint.
295//!
296//! Dimming multiplies the *resolved* role color's alpha rather than swapping in a
297//! dedicated "disabled" token, at **both** resolution points (the layout-baked
298//! glyph color in [`effective_style`](TextInputWidget::effective_style) and the
299//! paint-time [`Chrome`]), so it behaves identically under every catalog and the
300//! unthemed fallbacks: `on_surface_variant` is opaque under Material/Glyph but
301//! translucent under Cupertino (`frust-theme`'s `ColorScheme` Cupertino arm,
302//! `secondaryLabel` at alpha 153), so it is *not* a portable disabled token, and
303//! it is keyed **strictly** off [`TextInputView::enabled`] at both points.
304//!
305//! # Read-only mode
306//!
307//! [`TextInputView::read_only(true)`](TextInputView::read_only) makes the field
308//! uneditable **without** dimming it — the presentation `enabled(false)` cannot
309//! express, since disabled conflates two orthogonal questions (editable? /
310//! dimmed?) a static-but-live-styled mock must keep apart (a splash screen's
311//! frozen preview must not pop to full alpha when it goes live).
312//!
313//! **A read-only field is focusable and copyable** — Material 3's and Apple's
314//! HIG's convention, and the plain reading of a value on screen: text a user can
315//! read is text they can select and copy. Two hooks say so where there used to
316//! be one: [`TextInputWidget::focusable`] is `enabled` (the top of
317//! [`Widget::event`], the focused-while-painting check) and
318//! [`TextInputWidget::editable`] is `enabled && !read_only` (every path that
319//! changes the text, plus the keyboard hint below). So a read-only field takes
320//! focus on a press, places its caret, drag-selects, long-presses to a toolbar
321//! offering copy and select-all, and answers the copy/select-all chords in full,
322//! while it refuses typed characters, IME composition and commits, and the
323//! editing and caret-motion keys. Cut and paste it answers with nothing — and a
324//! paste chord it answers *without* asking the shell to read the host clipboard
325//! at all: the ask itself reaches the host, and on iOS it is one of the gestures
326//! that can raise the system's paste prompt, which a field that would discard
327//! the answer has no business provoking. Escape still ends the session, a field
328//! that can hold one needing a keyboard way out of it. Its caret is drawn but
329//! does **not** blink: a blink advertises an insertion point, and this field
330//! takes no insertion.
331//!
332//! **The keyboard.** A focused read-only field publishes
333//! `ImeState { active: true, suppress_soft_keyboard: true, .. }`. Active,
334//! because every shell's clipboard route hangs off the platform surface (the web
335//! overlay `<input>`'s DOM `copy` listener, Android's `InputConnection`, iOS's
336//! first responder) and dies with it; suppressed, because there is nothing here
337//! to type into.
338//!
339//! The hint is an **obligation on the shell**, and it is the shell's half that
340//! makes the pair mean anything: a shell that raises an on-screen keyboard must
341//! read `suppress_soft_keyboard` and, when it is set, keep the platform input
342//! surface it would build for `active` while leaving that keyboard down. A shell
343//! with no on-screen keyboard of its own (desktop/winit) has nothing to do for
344//! it.
345//!
346//! **No shell reads it yet.** `active` alone still drives the platform keyboard
347//! on both mobile shells, and neither `ime_state_to_json` puts this field on the
348//! wire at all, so it cannot reach Kotlin or Swift even in principle. Until each
349//! shell is taught the hint, tapping a read-only field on Android or iOS raises
350//! the soft keyboard — where before this field took no focus, it raised nothing.
351//! That is a known, tracked gap in the shells, not a contract this module is
352//! quietly failing to keep: everything above the seam publishes the hint
353//! correctly, and a test pins it.
354//!
355//! `enabled(false)` remains the stronger claim, unchanged: it refuses focus
356//! outright, so none of the above reaches a disabled field, and a field disabled
357//! while focused still releases focus and deactivates the IME. **Dimming stays
358//! keyed to `enabled` alone**, never to either hook: both resolution points
359//! ([`Chrome::resolve`] and
360//! [`effective_style`](TextInputWidget::effective_style)) branch on `enabled`.
361//!
362//! **Semantics.** Read-only is a real accessibility distinction from disabled —
363//! a screen reader announces them differently — so [`Widget::semantics`] reports
364//! `set_disabled()` only when `!enabled` and `set_read_only()` when
365//! `enabled && read_only`, never both, and never by omitting the node
366//! (`docs/CODE_STANDARDS.md` Semantics: a node with something to say keeps it).
367//!
368//! Read-only is orthogonal to [`obscured`](TextInputView::obscured) — never
369//! touching masking, the mirror or the published `ImeContentType` hint, so a
370//! read-only obscured field still reports `Role::PasswordInput` with a masked
371//! a11y value — and to the *Chrome* geometry setters. The two refusals compose
372//! rather than cancel: an obscured field hands out neither its secret nor its
373//! bullet mirror, so a read-only obscured one is focusable and copies nothing.
374//!
375//! # Obscured (password) mode
376//!
377//! [`TextInputView::obscured(true)`](TextInputView::obscured) masks the rendered
378//! glyphs with U+2022 BULLET. The [`TextEditor`]'s model text stays **real** —
379//! every edit, selection, IME sync and `on_change` runs against the true buffer —
380//! while the masked mirror lives in a parallel [`TextEditor`] (`mask_editor`).
381//! That mirror, not the real editor, is measured, painted and hit-tested, since
382//! masking only at glyph-emission time would leave the layout (and so the caret
383//! rect, the field width and the pointer hit test) measured from the real text.
384//! The mirror is a pure function of the real editing state, recomputed after every
385//! edit, so it cannot drift; the mask is 1:1 per `char`, so offsets map with a
386//! two-string walk ([`real_to_masked`]/[`masked_to_real`]) and a multi-byte
387//! grapheme masks to exactly one bullet.
388//!
389//! **Scope boundary.** Obscuring is visual masking plus a [`Role::PasswordInput`]
390//! semantics node plus an IME content-type hint: `obscured(true)` publishes
391//! [`ImeContentType::Password`] on [`ImeState::content_type`], computed live on
392//! every publication (event pass, paint-pass republish, disabled-while-focused
393//! release), so a newly focused obscured field never has a window where it
394//! publishes `Normal`. The published [`ImeState`] still carries the **real** text
395//! (the platform IME mirror requires it), and the hint, not redaction, is what is
396//! supposed to keep the platform's suggestion strip and learned-word dictionary
397//! from seeing it — **a guarantee only as good as the shells honouring the
398//! hint**, which this widget can ask for but never prove. `obscured` is the
399//! field's sole content-type signal (see [`ImeContentType`] on why a shell must
400//! not fall back to non-secret behaviour for an unrecognized variant).
401//!
402//! # Chrome
403//!
404//! The field's chrome **colors** (background/border/focus-accent/placeholder/
405//! selection/caret) resolve from the active [`Theme`]'s `ColorScheme` (see
406//! [`Chrome::resolve`]), so an app restyles them by installing a different
407//! `Theme`. Its **geometry** ([`PAD_X`]/[`PAD_Y`] inner padding, [`BORDER_W`]
408//! border thickness, [`RADIUS`] corner radius, [`CARET_W`] caret width) is
409//! instead reachable per-instance, through [`TextInputView::padding`] and its
410//! `border_width`/`corner_radius`/`caret_width` siblings; each defaults to the
411//! constant it overrides, so a field calling none renders exactly as the defaults
412//! do. Colors stay theme-only; geometry is builder-set rather than tokenized
413//! because
414//! [`PAD_X`]/[`CARET_W`] are read from the **event pass**
415//! ([`TextInputWidget::editor_point`], [`TextInputWidget::current_ime_state`]),
416//! which threads no `Theme` — `docs/CODE_STANDARDS.md` Theming cites these very
417//! constants as that precedent — so a token would duplicate them anyway.
418//!
419//! **Focus treatment.** The focused state is an accent-colored border, no
420//! separate halo/glow. [`TextInputView::focus_ring_width`] is the one escape hatch
421//! on top — an optional border width used only while focused, defaulting to the
422//! idle width — so an app reaches the "thicker, differently-colored focus outline"
423//! look via a theme's `primary` role without a soft-glow primitive here.
424
425use std::rc::Rc;
426use std::time::Duration;
427
428use frust_core::accesskit::{Action, CustomAction, Role};
429use frust_core::{
430    AnyView, BoxConstraints, BuildCtx, ChangeFlags, EditCommand, EditingState, EventCtx,
431    EventResult, FrameTime, ImeContentType, ImeEvent, ImeState, InputEvent, Key, LayoutCtx,
432    NamedKey, OutsideTap, OverlayBand, OverlayInput, PaintCtx, PaintScene, PointerPhase,
433    SelectionToolbarActions, SelectionToolbarPolicy, SelectionToolbarRequest, SemanticsCtx,
434    TOUCH_SLOP, View, Widget, selection_toolbar_builder, selection_toolbar_policy,
435};
436use frust_text::{
437    EditOp, EditingStateBytes, TextContext, TextEditor, TextStyle, sanitize_paste, utf16_to_byte,
438};
439use frust_theme::Theme;
440use kurbo::{Point, Rect, Size, Vec2};
441use peniko::Color;
442
443use crate::authoring::presses;
444use crate::gesture::{DOUBLE_TAP_MS, LONG_PRESS_MS};
445use crate::overlay::{OverlayAlign, OverlayAnchor, OverlayPlacement, OverlaySide, OverlaySlot};
446
447/// Default corner radius of the field chrome, in logical px. Overridable
448/// per-instance with [`TextInputView::corner_radius`] — see the module docs'
449/// "Chrome" section for why this is a builder default rather than a `Theme`
450/// token.
451const RADIUS: f64 = 6.0;
452/// Default border thickness, in logical px. Overridable per-instance with
453/// [`TextInputView::border_width`] (see the module docs' "Chrome" section);
454/// the focused border additionally honors [`TextInputView::focus_ring_width`].
455const BORDER_W: f64 = 1.5;
456/// Default horizontal inner padding (chrome edge to text), in logical px.
457/// Overridable per-instance with [`TextInputView::padding`] (see the module
458/// docs' "Chrome" section). Read from the event pass as well as layout/paint
459/// (`docs/CODE_STANDARDS.md`'s Theming conventions cite this constant as the
460/// precedent for why such a metric stays a plain value, not a `Theme` token).
461const PAD_X: f64 = 8.0;
462/// Default vertical inner padding (chrome edge to text), in logical px.
463/// Overridable per-instance with [`TextInputView::padding`] — see [`PAD_X`].
464const PAD_Y: f64 = 6.0;
465/// Default caret width, in logical px. Overridable per-instance with
466/// [`TextInputView::caret_width`] — see [`PAD_X`] for why it stays a plain
467/// value rather than a `Theme` token.
468const CARET_W: f32 = 1.5;
469/// Blink half-period: caret visible 500 ms, hidden 500 ms.
470const BLINK_MS: f64 = 500.0;
471/// Default field width when the incoming constraints are horizontally unbounded.
472const DEFAULT_WIDTH: f64 = 200.0;
473/// Gap between the selection's bounding box and the floated toolbar, in logical
474/// px — wider than [`crate::DEFAULT_OFFSET`]'s neutral 4px because this anchor
475/// is a run of text rather than a widget's own edge, and a bar sitting 4px off
476/// a line of glyphs reads as touching it.
477const TOOLBAR_GAP: f64 = 8.0;
478
479/// Field background (unthemed fallback; a theme resolves this from `surface`).
480const BG: Color = Color::WHITE;
481/// Idle (unfocused) border color (unthemed fallback; themed from `outline`).
482const BORDER: Color = Color::from_rgb8(0xD1, 0xD5, 0xDB);
483/// Focused border color (unthemed fallback; themed from `primary`).
484const ACCENT: Color = Color::from_rgb8(0x3B, 0x82, 0xF6);
485/// Placeholder text color (unthemed fallback; themed from `on_surface_variant`).
486const PLACEHOLDER: Color = Color::from_rgb8(0x9C, 0xA3, 0xAF);
487/// Selection highlight color (unthemed fallback; themed from `primary` at alpha).
488const SELECTION: Color = Color::from_rgb8(0xBF, 0xDB, 0xFE);
489/// Caret color (unthemed fallback; themed from `primary`).
490const CARET: Color = Color::from_rgb8(0x1D, 0x4E, 0xD8);
491/// Alpha applied to `primary` for the themed selection highlight (a v1
492/// simplification — a translucent primary stands in for a dedicated selection
493/// role).
494const SELECTION_ALPHA: f32 = 0.30;
495
496/// Alpha multiplier applied to every resolved **content** color (glyphs,
497/// placeholder, caret, selection) while the field is disabled.
498///
499/// Material 3's disabled state puts content at 38% of its enabled role color
500/// (`m3.material.io` — *Styles → Color → Roles*, disabled-content opacity;
501/// retrieved 2026-07-31). Applied as a *multiplier on the already-resolved
502/// role*, never as a swap to a different token: `on_surface_variant` is opaque
503/// under Material/Glyph but translucent under Cupertino (alpha 153 — see
504/// `frust-theme`'s `ColorScheme` Cupertino arm), so a token swap would dim by
505/// different amounts per design language while this multiplier does not.
506const DISABLED_CONTENT_ALPHA: f32 = 0.38;
507/// Alpha multiplier applied to the disabled field's **container** chrome (its
508/// outline/accent border). Material 3 puts a disabled container/outline at 12%
509/// (same source and retrieval date as [`DISABLED_CONTENT_ALPHA`]).
510const DISABLED_CONTAINER_ALPHA: f32 = 0.12;
511
512/// The glyph every character is replaced with in obscured (password) mode.
513///
514/// U+2022 BULLET is Android's `inputType=textPassword` default mask; the web
515/// varies by browser and Apple's HIG names no glyph, so this follows the one
516/// platform that publishes a specific character.
517const MASK_CHAR: char = '\u{2022}';
518
519/// The resolved text-field chrome colors. Themed (v1 simplification): background
520/// `surface`, border `outline`, focus accent/caret `primary`, placeholder
521/// `on_surface_variant`, selection `primary` at [`SELECTION_ALPHA`]. Unthemed:
522/// the [`BG`]/[`BORDER`]/[`ACCENT`]/[`PLACEHOLDER`]/[`SELECTION`]/[`CARET`]
523/// constants exactly, so a pre-theme app renders unchanged.
524///
525/// A disabled field dims the *resolved* values (see
526/// [`DISABLED_CONTENT_ALPHA`]), so the themed and unthemed paths dim by the
527/// same rule.
528struct Chrome {
529    bg: Color,
530    border: Color,
531    accent: Color,
532    placeholder: Color,
533    selection: Color,
534    caret: Color,
535}
536
537impl Chrome {
538    fn resolve(theme: Option<&Theme>, enabled: bool) -> Self {
539        let mut chrome = match theme {
540            Some(theme) => {
541                let s = theme.scheme();
542                Chrome {
543                    bg: s.surface,
544                    border: s.outline,
545                    accent: s.primary,
546                    placeholder: s.on_surface_variant,
547                    selection: s.primary.with_alpha(SELECTION_ALPHA),
548                    caret: s.primary,
549                }
550            }
551            None => Chrome {
552                bg: BG,
553                border: BORDER,
554                accent: ACCENT,
555                placeholder: PLACEHOLDER,
556                selection: SELECTION,
557                caret: CARET,
558            },
559        };
560        if !enabled {
561            // `bg` is deliberately left opaque: it is this field's own
562            // background painted over an arbitrary parent, so thinning it to
563            // M3's 12% container value would show the parent through the field
564            // rather than reading as "dimmed". The disabled cue is carried by
565            // the outline and the content instead.
566            chrome.border = chrome.border.multiply_alpha(DISABLED_CONTAINER_ALPHA);
567            chrome.accent = chrome.accent.multiply_alpha(DISABLED_CONTAINER_ALPHA);
568            chrome.placeholder = chrome.placeholder.multiply_alpha(DISABLED_CONTENT_ALPHA);
569            chrome.selection = chrome.selection.multiply_alpha(DISABLED_CONTENT_ALPHA);
570            chrome.caret = chrome.caret.multiply_alpha(DISABLED_CONTENT_ALPHA);
571        }
572        chrome
573    }
574}
575
576/// The masked mirror of `text`: one [`MASK_CHAR`] per `char`, with `'\n'`
577/// preserved so a multi-line field keeps its line structure (and therefore its
578/// height) under masking.
579fn mask_text(text: &str) -> String {
580    text.chars().map(mask_char).collect()
581}
582
583/// The single character `ch` renders as while obscured.
584fn mask_char(ch: char) -> char {
585    if ch == '\n' { '\n' } else { MASK_CHAR }
586}
587
588/// Byte offset in the masked mirror of `text` corresponding to byte offset
589/// `byte` in `text` itself.
590///
591/// An offset landing inside a multi-byte char snaps back to that char's start
592/// (mirroring `frust_text`'s `byte_to_utf16`), so caret arithmetic across a
593/// multi-byte grapheme stays exact.
594fn real_to_masked(text: &str, byte: usize) -> usize {
595    let mut masked = 0usize;
596    for (off, ch) in text.char_indices() {
597        if byte < off + ch.len_utf8() {
598            return masked;
599        }
600        masked += mask_char(ch).len_utf8();
601    }
602    masked
603}
604
605/// The inverse of [`real_to_masked`]: a byte offset in the masked mirror mapped
606/// back onto `text`. An offset inside a mask glyph snaps back to the start of
607/// the char it stands for.
608fn masked_to_real(text: &str, masked_byte: usize) -> usize {
609    let mut masked = 0usize;
610    for (off, ch) in text.char_indices() {
611        let next = masked + mask_char(ch).len_utf8();
612        if masked_byte < next {
613            return off;
614        }
615        masked = next;
616    }
617    text.len()
618}
619
620/// A view-held, typed text callback (erased on build).
621type OnText<State> = Rc<dyn Fn(&mut State, String)>;
622
623/// A declarative text field, single-line by default. See the [module docs](self).
624pub struct TextInputView<State: 'static> {
625    value: String,
626    placeholder: String,
627    text_style: TextStyle,
628    /// Whether the app set the content style explicitly (via
629    /// [`TextInputView::text_style`]). When `false` and a theme is active, the
630    /// glyph color resolves from the theme's `on_surface` role; an explicit
631    /// style always wins (explicit > theme > black fallback).
632    text_style_explicit: bool,
633    /// `None` = single-line; `Some(n)` = wrapped multi-line capped at `n`
634    /// visible lines (see [`TextInputView::multiline`]).
635    max_visible_lines: Option<usize>,
636    /// Whether Enter submits (vs. inserts a newline). `None` = use the
637    /// mode-default (true single-line, false multi-line); `Some(_)` = an
638    /// explicit [`TextInputView::submit_on_enter`] override.
639    submit_on_enter: Option<bool>,
640    /// Whether the field accepts input. `false` refuses focus (making keyboard
641    /// and IME editing unreachable) and dims the chrome — see
642    /// [`TextInputView::enabled`].
643    enabled: bool,
644    /// Whether the field refuses *editing* (never focus) without dimming — see
645    /// [`TextInputView::read_only`]. Independent of `enabled`: dimming stays
646    /// keyed to `enabled` alone (see the [module docs](self)' "Read-only mode"
647    /// section).
648    read_only: bool,
649    /// Whether the rendered glyphs are masked (password mode) — see
650    /// [`TextInputView::obscured`].
651    obscured: bool,
652    /// Horizontal inner padding — see [`TextInputView::padding`]. Defaults to
653    /// [`PAD_X`].
654    pad_x: f64,
655    /// Vertical inner padding — see [`TextInputView::padding`]. Defaults to
656    /// [`PAD_Y`].
657    pad_y: f64,
658    /// Border thickness — see [`TextInputView::border_width`]. Defaults to
659    /// [`BORDER_W`].
660    border_width: f64,
661    /// Corner radius — see [`TextInputView::corner_radius`]. Defaults to
662    /// [`RADIUS`].
663    corner_radius: f64,
664    /// Caret width — see [`TextInputView::caret_width`]. Defaults to
665    /// [`CARET_W`].
666    caret_width: f32,
667    /// Focused-only border width override — see
668    /// [`TextInputView::focus_ring_width`]. `None` = use `border_width` while
669    /// focused too (unchanged appearance).
670    focus_ring_width: Option<f64>,
671    on_change: OnText<State>,
672    on_submit: Option<OnText<State>>,
673}
674
675/// Create a controlled text field showing `value` that fires
676/// `on_change(state, new_text)` on every edit.
677///
678/// The field is a controlled component: it reports the requested text through
679/// `on_change` and adopts the app-confirmed `value` on the next rebuild — it is
680/// never its own source of truth. Add a submit handler with
681/// [`TextInputView::on_submit`] and a placeholder with
682/// [`TextInputView::placeholder`].
683pub fn text_input<State: 'static, F: Fn(&mut State, String) + 'static>(
684    value: impl Into<String>,
685    on_change: F,
686) -> TextInputView<State> {
687    TextInputView {
688        value: value.into(),
689        placeholder: String::new(),
690        text_style: TextStyle::default(),
691        text_style_explicit: false,
692        max_visible_lines: None,
693        submit_on_enter: None,
694        enabled: true,
695        read_only: false,
696        obscured: false,
697        pad_x: PAD_X,
698        pad_y: PAD_Y,
699        border_width: BORDER_W,
700        corner_radius: RADIUS,
701        caret_width: CARET_W,
702        focus_ring_width: None,
703        on_change: Rc::new(on_change),
704        on_submit: None,
705    }
706}
707
708/// PascalCase alias for [`text_input`].
709#[allow(non_snake_case)]
710pub fn TextInput<State: 'static, F: Fn(&mut State, String) + 'static>(
711    value: impl Into<String>,
712    on_change: F,
713) -> TextInputView<State> {
714    text_input(value, on_change)
715}
716
717impl<State: 'static> TextInputView<State> {
718    /// Set the placeholder shown when the field is empty and unfocused.
719    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
720        self.placeholder = placeholder.into();
721        self
722    }
723
724    /// Set the submit handler fired on Enter (with the current text); focus is
725    /// kept.
726    pub fn on_submit<F: Fn(&mut State, String) + 'static>(mut self, on_submit: F) -> Self {
727        self.on_submit = Some(Rc::new(on_submit));
728        self
729    }
730
731    /// Set the content text's style (family/weight/style/size/color/
732    /// letter-spacing/line-height), applied to the field's [`TextEditor`].
733    /// Marks the color as explicitly set, so it wins over the themed default
734    /// (explicit > theme > black fallback — see the [module docs](self)).
735    /// Default (no call) resolves the color from the active theme's
736    /// `on_surface` role, falling back to black with no theme threaded. Does
737    /// not affect the chrome colors or padding/caret constants.
738    pub fn text_style(mut self, text_style: TextStyle) -> Self {
739        self.text_style = text_style;
740        self.text_style_explicit = true;
741        self
742    }
743
744    /// Make this a wrapped multi-line field that grows up to `max_visible_lines`
745    /// lines tall, then scrolls internally to keep the caret in view (see the
746    /// [module docs](self)). `max_visible_lines` is clamped to at least 1.
747    ///
748    /// Switches the default Enter behavior to insert a newline rather than
749    /// submit; override with [`submit_on_enter`](Self::submit_on_enter).
750    pub fn multiline(mut self, max_visible_lines: usize) -> Self {
751        self.max_visible_lines = Some(max_visible_lines.max(1));
752        self
753    }
754
755    /// Set whether Enter submits (`true`) or inserts a newline (`false`),
756    /// overriding the mode default (submit single-line, newline multi-line).
757    /// Shift+Enter always does the opposite. A single-line field always submits
758    /// on Enter regardless of this setting.
759    pub fn submit_on_enter(mut self, submit_on_enter: bool) -> Self {
760        self.submit_on_enter = Some(submit_on_enter);
761        self
762    }
763
764    /// Set whether the field accepts input (default `true`).
765    ///
766    /// A disabled field refuses focus, so a tap neither places the caret nor
767    /// raises the keyboard and keyboard/IME editing cannot reach it at all; it
768    /// paints no caret and dims its text, placeholder and outline (see the
769    /// [module docs](self)). A field disabled while focused drops its focus.
770    ///
771    /// This is **disabled**, not *read-only*: a read-only field stays focusable
772    /// and copyable (Material 3's and Apple's HIG's convention — see
773    /// [`read_only`](Self::read_only)), where this option refuses focus
774    /// outright.
775    pub fn enabled(mut self, enabled: bool) -> Self {
776        self.enabled = enabled;
777        self
778    }
779
780    /// Set whether the field is read-only (default `false`): its text cannot be
781    /// changed, but it is still focusable and copyable, and it paints at **full
782    /// alpha** rather than dimmed.
783    ///
784    /// This is the presentation `enabled(false)` cannot express: an undimmed
785    /// field that takes no edits, e.g. a static mock that should look identical
786    /// before and after a live handoff. Unlike
787    /// [`enabled(false)`](Self::enabled) it suppresses only editing — a press
788    /// focuses it, a drag selects, copy and select-all work, and cut and paste
789    /// are answered with nothing. A focused read-only field asks the shell for
790    /// the platform input surface (its clipboard route) with the on-screen
791    /// keyboard suppressed. See the [module docs](self)' "Read-only mode"
792    /// section for the full contract, the semantics distinction from disabled,
793    /// and the interaction with `obscured`/the chrome geometry setters.
794    pub fn read_only(mut self, read_only: bool) -> Self {
795        self.read_only = read_only;
796        self
797    }
798
799    /// Mask the rendered text with U+2022 BULLET (password mode, default
800    /// `false`).
801    ///
802    /// The underlying value is untouched — `on_change` and the published
803    /// `ImeState` still carry the real text, and caret/selection/editing
804    /// arithmetic is unchanged, including across multi-byte graphemes. The
805    /// field reports itself to accessibility as
806    /// [`Role::PasswordInput`]. See the [module docs](self) for what obscuring
807    /// deliberately does *not* do (no platform password keyboard, no autofill).
808    pub fn obscured(mut self, obscured: bool) -> Self {
809        self.obscured = obscured;
810        self
811    }
812
813    /// Override the chrome's inner padding (chrome edge to text), in logical
814    /// px. Defaults to [`PAD_X`]/[`PAD_Y`] (8×6) — a field that never calls
815    /// this renders identically to before this setter existed. Independent of
816    /// [`text_style`](Self::text_style)'s glyph metrics; see the module docs'
817    /// "Chrome" section for why this is a per-instance value rather than a
818    /// `Theme` token.
819    pub fn padding(mut self, x: f64, y: f64) -> Self {
820        self.pad_x = x;
821        self.pad_y = y;
822        self
823    }
824
825    /// Override the chrome's border thickness, in logical px. Defaults to
826    /// [`BORDER_W`] (1.5). Affects only the idle/unfocused border unless
827    /// [`focus_ring_width`](Self::focus_ring_width) is left unset, in which
828    /// case the focused border uses this width too (unchanged relative
829    /// behavior). See the module docs' "Chrome" section.
830    pub fn border_width(mut self, width: f64) -> Self {
831        self.border_width = width;
832        self
833    }
834
835    /// Override the chrome's corner radius, in logical px. Defaults to
836    /// [`RADIUS`] (6.0). See the module docs' "Chrome" section.
837    pub fn corner_radius(mut self, radius: f64) -> Self {
838        self.corner_radius = radius;
839        self
840    }
841
842    /// Override the caret width, in logical px. Defaults to [`CARET_W`]
843    /// (1.5). See the module docs' "Chrome" section.
844    pub fn caret_width(mut self, width: f32) -> Self {
845        self.caret_width = width;
846        self
847    }
848
849    /// Use `width` as the border thickness only while the field is focused,
850    /// instead of [`border_width`](Self::border_width). Unset by default, so
851    /// a focused field's border is the same width as its idle border,
852    /// matching the current behavior exactly. This is the narrow focus-ring
853    /// escape hatch described in the module docs' "Chrome" section — combined
854    /// with a theme's accent color, it reaches a thicker/differently-colored
855    /// focus outline without this widget growing a second rendering
856    /// primitive (a halo) it does not otherwise have.
857    pub fn focus_ring_width(mut self, width: f64) -> Self {
858        self.focus_ring_width = Some(width);
859        self
860    }
861}
862
863/// Stable accesskit custom-action ids for the four clipboard verbs the field
864/// publishes on its own semantics node (see [`TextInputWidget::semantics`]).
865///
866/// **Stable is the whole point.** An assistive-technology client holds on to
867/// the id it was offered and sends that number back when the user picks the
868/// action, so renumbering these would silently re-point a remembered "Copy" at
869/// some other verb. They are ordinary small integers because that is what
870/// [`accesskit::ActionData::CustomAction`] carries.
871const A11Y_CUT_ID: i32 = 1;
872/// See [`A11Y_CUT_ID`].
873const A11Y_COPY_ID: i32 = 2;
874/// See [`A11Y_CUT_ID`].
875const A11Y_PASTE_ID: i32 = 3;
876/// See [`A11Y_CUT_ID`].
877const A11Y_SELECT_ALL_ID: i32 = 4;
878
879/// The live long-press timer for one press — the field's own copy of
880/// [`crate::gesture`]'s paint-clock recogniser, in the one shape a text field
881/// needs (see the module docs' "Selection gestures and the toolbar").
882///
883/// `Copy` so an event arm can read it by value rather than holding a borrow of
884/// the widget it is about to reassign, exactly as `gesture.rs`'s own recogniser
885/// state does.
886#[derive(Clone, Copy)]
887struct HoldState {
888    /// The frame time the press was first painted at, seeded by that paint
889    /// because the event pass that armed the hold carries no clock. `None`
890    /// until the press has been painted once.
891    started_at: Option<FrameTime>,
892    /// Set by the paint that observes `frame_time - started_at` reaching
893    /// [`LONG_PRESS_MS`]; the word is selected on the next pass that carries an
894    /// [`EventCtx`].
895    elapsed: bool,
896}
897
898/// Which phase the live primary press is in — the gesture state machine's own
899/// word for what the next `Move` is allowed to do.
900///
901/// This is an explicit phase because "where the press landed" and "is this
902/// press still a tap" are two different questions, and one `Option<Point>`
903/// used to answer both: taking the point to say *the hold already fired* also
904/// said *this press became a drag*, which disarmed the slop guard for the rest
905/// of the gesture. [`Gesture::HoldFired`] carries the press point precisely so
906/// that guard outlives the fire.
907///
908/// `Copy` for [`HoldState`]'s reason: an event arm reads the phase by value
909/// rather than holding a borrow of the widget it is about to reassign.
910#[derive(Clone, Copy, PartialEq, Debug)]
911enum Gesture {
912    /// No live primary press.
913    None,
914    /// A press is down and has stayed within [`TOUCH_SLOP`] of `at`: still a
915    /// *tap*, and a tap must not drag the selection around. A release while
916    /// the press is in this phase is what seeds the double-tap window and what
917    /// resolves the tap-in-selection toolbar toggle.
918    Tap {
919        /// Widget-local position the press landed at.
920        at: Point,
921    },
922    /// The press wandered past [`TOUCH_SLOP`]: every later `Move` extends the
923    /// selection to the pointer. *How far* each move extends is the editor's
924    /// call rather than this phase's — see the module docs' "Selection
925    /// gestures and the toolbar" on the retained selection granularity.
926    Drag,
927    /// The long-press fired and the finger is **still down**. The gesture has
928    /// resolved: it is no longer a tap (it seeds no double-tap and toggles no
929    /// toolbar on release), but it is not a drag either — a finger holding
930    /// still within [`TOUCH_SLOP`] of `at` must leave the word it just
931    /// selected exactly as it is, however much it jitters.
932    HoldFired {
933        /// Widget-local position the press landed at — the point the word was
934        /// selected from, and what the surviving slop guard measures against.
935        at: Point,
936    },
937}
938
939impl Gesture {
940    /// Where the press landed while it is still a *tap*, and `None` in every
941    /// other phase.
942    ///
943    /// The double-tap window and the tap-in-selection toggle both key off this
944    /// rather than off a bare stored point: a press that wandered, and one a
945    /// long-press already resolved, are not taps and must seed neither.
946    fn tap_point(self) -> Option<Point> {
947        match self {
948            Gesture::Tap { at } => Some(at),
949            Gesture::None | Gesture::Drag | Gesture::HoldFired { .. } => None,
950        }
951    }
952}
953
954/// The retained widget for a [`TextInputView`].
955pub struct TextInputWidget {
956    /// The editing engine, always holding the **real** (never masked) text.
957    /// Driven through the widget-owned `text_ctx`.
958    editor: TextEditor,
959    /// The masked mirror of `editor`, present only while obscured. A pure
960    /// function of `editor`'s editing state (re-derived by
961    /// [`sync_mask`](Self::sync_mask) after every edit, so it cannot drift),
962    /// and the editor every *display* read goes through — see
963    /// [`display`](Self::display) and the [module docs](self).
964    mask_editor: Option<TextEditor>,
965    /// The widget's own font/layout context — see the [module docs](self).
966    text_ctx: TextContext,
967    /// The declared (builder) style — family/weight/style/size/letter-spacing/
968    /// line-height, plus the app's own color when [`text_style_explicit`] is
969    /// `true`. Layout metrics (`content_height`/`text_top`/`line_height`) read
970    /// this directly; the *color* actually installed on `editor` may differ —
971    /// see [`applied_style`](Self::applied_style).
972    style: TextStyle,
973    /// Whether the app set the content style explicitly — see [`TextInputView`].
974    text_style_explicit: bool,
975    /// The style last installed on `editor` (the resolved effective style —
976    /// see [`effective_style`](Self::effective_style)). Compared against the
977    /// freshly-resolved style at each `layout` to detect a theme swap or a
978    /// rebuilt `style`/`text_style_explicit`, in which case [`apply_style`]
979    /// rebuilds `editor` with the new style.
980    ///
981    /// [`apply_style`]: Self::apply_style
982    applied_style: TextStyle,
983    placeholder: String,
984    /// `None` = single-line; `Some(n)` = wrapped multi-line capped at `n`
985    /// visible lines. Drives the wrap-width feed in `layout`, the height cap,
986    /// and the keep-caret-in-view scroll offset (see the [module docs](self)).
987    max_visible_lines: Option<usize>,
988    /// The soft-wrap width last installed on `editor` (`None` sentinel = not
989    /// yet applied / editor just rebuilt). `layout` only calls
990    /// [`TextEditor::set_wrap_width`] when the desired width differs from this —
991    /// mirroring `Text`'s cached-shape reuse. parley's
992    /// `PlainEditor::set_width` unconditionally marks its layout dirty and the
993    /// next `refresh_layout` re-shapes, so an unconditional per-pass call
994    /// re-shaped every frame; guarding it here reshapes only on an actual
995    /// width/mode change (edits still reshape via their own `apply`).
996    applied_wrap_width: Option<Option<f32>>,
997    /// Resolved Enter behavior: `true` submits, `false` inserts a newline.
998    /// Defaults to true single-line / false multi-line; Shift+Enter inverts it
999    /// (multi-line only — a single-line field always submits).
1000    submit_on_enter: bool,
1001    /// Whether the field accepts input (see [`TextInputView::enabled`]). It
1002    /// alone is [`focusable`](Self::focusable) (the gate for the whole `event()`
1003    /// pass) and it alone drives both theme-resolution points' dimming — see
1004    /// [`Chrome::resolve`]/[`effective_style`](Self::effective_style).
1005    enabled: bool,
1006    /// Whether the field is read-only (see [`TextInputView::read_only`]).
1007    /// Withholds [`editable`](Self::editable) alongside `enabled` — but never
1008    /// focus, and never dimming, which is the whole point of the flag (see the
1009    /// [module docs](self)' "Read-only mode" section).
1010    read_only: bool,
1011    /// Whether the rendered glyphs are masked (see [`TextInputView::obscured`]).
1012    /// Kept alongside `mask_editor` (which it decides) so `rebuild` can compare
1013    /// it and `semantics` can pick its role without inspecting the mirror.
1014    obscured: bool,
1015    /// Set by a `rebuild` that disabled a field holding focus: the focus path
1016    /// lives on the widget's *pod*, which `rebuild` cannot reach (`BuildCtx`
1017    /// carries no focus seam), so the release is deferred to the first `event()`
1018    /// that arrives — meanwhile `paint` already refuses to behave as focused (no
1019    /// caret, no blink frame, an inactive IME surface). Read-only does not set
1020    /// it: that field keeps its session (see the [module docs](self)' "Read-only
1021    /// mode" section).
1022    release_focus_pending: bool,
1023    /// The widget's event-pass view of its focus: set on a `Down` inside,
1024    /// cleared on Escape / a blur `Down` this widget observes. NOT authoritative
1025    /// for painting — `paint` reads `PaintCtx::has_focus()` (the pod-recorded
1026    /// focus path, ancestor-composed) and self-corrects this flag when a
1027    /// container-routed blur never reached `event()` (one-paint convergence).
1028    focused: bool,
1029    /// Armed by a `Down` inside (alongside `capture_pointer`) to drive
1030    /// drag-selection; cleared on `Up`/`Cancel`.
1031    captured: bool,
1032    /// The frame time the caret was last reset to visible (the blink phase's
1033    /// epoch). Recorded from the paint-pass [`PaintCtx::frame_time`], since the
1034    /// event pass carries no clock — see `blink_reset_pending`.
1035    blink_epoch: FrameTime,
1036    /// Set when an edit/focus during the (clockless) event pass requests a blink
1037    /// reset; the next paint records `blink_epoch` from `frame_time` and clears
1038    /// this. `true` initially so the first painted frame seeds the epoch.
1039    blink_reset_pending: bool,
1040    /// Resolved horizontal inner padding — see [`TextInputView::padding`].
1041    /// Read from both the event pass (`editor_point`/`current_ime_state`) and
1042    /// layout/paint (see [`PAD_X`]).
1043    pad_x: f64,
1044    /// Resolved vertical inner padding — see [`TextInputView::padding`] and
1045    /// [`PAD_Y`].
1046    pad_y: f64,
1047    /// Resolved border thickness — see [`TextInputView::border_width`] and
1048    /// [`BORDER_W`]. The idle border width; `paint` widens it to
1049    /// `focus_ring_width` instead while focused, when set.
1050    border_width: f64,
1051    /// Resolved corner radius — see [`TextInputView::corner_radius`] and
1052    /// [`RADIUS`].
1053    corner_radius: f64,
1054    /// Resolved caret width — see [`TextInputView::caret_width`] and
1055    /// [`CARET_W`].
1056    caret_width: f32,
1057    /// Focused-only border width override — see
1058    /// [`TextInputView::focus_ring_width`]. `None` = `paint` uses
1059    /// `border_width` while focused too (unchanged appearance).
1060    focus_ring_width: Option<f64>,
1061    /// The portal slot the selection toolbar floats through — see the [module
1062    /// docs](self)' "Selection gestures and the toolbar". `()`-stated: the pod
1063    /// is built by a process-global builder that knows nothing about this
1064    /// field's application state, and speaks back through the edit-command
1065    /// queue rather than a callback.
1066    toolbar: OverlaySlot<()>,
1067    /// The view currently mounted in `toolbar`, kept so the next `rebuild` has
1068    /// something to reconcile (or tear down) against — the builder hands back a
1069    /// fresh view each call, so there is nothing else to diff with.
1070    toolbar_view: Option<AnyView<()>>,
1071    /// The action set `toolbar_view` was built for, and the whole rebuild
1072    /// guard: an anchor that moved or a selection that grew within the same
1073    /// verbs re-places the pod without rebuilding it. `None` = nothing is
1074    /// wanted (which is also the state a wanted-but-unbuildable toolbar records,
1075    /// so a missing builder is complained about once per state change rather
1076    /// than once per frame — see [`TextInputWidget::sync_toolbar`]).
1077    toolbar_view_actions: Option<SelectionToolbarActions>,
1078    /// Whether the field currently wants its selection toolbar shown. The
1079    /// gestures raise it, the hide rules drop it, and `rebuild` turns it into a
1080    /// mounted pod (see the [module docs](self)).
1081    toolbar_open: bool,
1082    /// The selection's bounding box in **absolute window space** as of the last
1083    /// paint — what was published, and what the request handed to the builder
1084    /// on the next `rebuild` carries. A rebuild runs before the paint that
1085    /// would refresh it, so this is deliberately one frame behind; the pod's
1086    /// actual placement is recomputed from the live anchor every paint.
1087    toolbar_anchor: Rect,
1088    /// The window size the last `layout` saw — the second half of the builder's
1089    /// argument pair (a toolbar may size or clamp itself against the window it
1090    /// floats in), recorded here because `View::rebuild` sees no `LayoutCtx`.
1091    window_size: Size,
1092    /// Which phase the live primary press is in — see [`Gesture`]. The slop
1093    /// guard, the double-tap window and the tap-in-selection toggle all read
1094    /// it, and it is what keeps a fired long-press distinguishable from a
1095    /// press that wandered into a drag.
1096    gesture: Gesture,
1097    /// The live long-press timer, armed by a primary `Down` inside and cleared
1098    /// when it fires, when the press drags past the slop, or when it ends.
1099    hold: Option<HoldState>,
1100    /// Set when the live press landed inside an existing non-collapsed
1101    /// selection, carrying whether the toolbar was open at that moment — the
1102    /// toggle's memory, since the press itself already applied the hide rule.
1103    /// The release opens the toolbar iff it was closed.
1104    tap_in_selection: Option<bool>,
1105    /// Set when the light-dismiss notification closed the toolbar for a press
1106    /// that is *about to* arrive here as well.
1107    ///
1108    /// A press outside every floated surface reaches this field **twice**: the
1109    /// root delivers [`OutsideTap::Notify`] first, as an overlay broadcast, and
1110    /// the press itself second (the registration does not consume it). By the
1111    /// time the `Down` arm runs, `toolbar_open` has therefore already been
1112    /// cleared — so the tap-in-selection toggle reads this instead, and a
1113    /// second tap on a selection closes the bar rather than re-opening it.
1114    /// Taken by that arm, and cleared by any blur, so it cannot go stale across
1115    /// a press that landed on a sibling and never reached this field at all.
1116    outside_press_dismissed: bool,
1117    /// The last completed in-slop tap: where it was, and the frame time of the
1118    /// last paint before it (the event pass has no clock of its own). A press
1119    /// within [`DOUBLE_TAP_MS`] and [`TOUCH_SLOP`] of it is a double-tap.
1120    last_tap: Option<(Point, FrameTime)>,
1121    /// The frame time of the most recent paint — the event pass's only clock,
1122    /// and what both halves of `last_tap` are measured with.
1123    last_frame_time: FrameTime,
1124    on_change: crate::authoring::ErasedArgCallback<String>,
1125    on_submit: Option<crate::authoring::ErasedArgCallback<String>>,
1126}
1127
1128/// A closed portal slot configured the way a selection toolbar wants it.
1129///
1130/// `Floating` (a toolbar is not a tooltip: it is the thing the user aims at),
1131/// `Interactive` (its whole purpose is being tapped), and
1132/// [`OutsideTap::Notify`] **without** consuming — a press elsewhere dismisses
1133/// the bar *and* still lands, so the tap that puts it away also moves the caret
1134/// where the user pointed. Placement is above the selection, centred, at
1135/// [`TOOLBAR_GAP`], flipping below and clamping inside the window when there is
1136/// no room (the [`OverlayPlacement`] defaults for both).
1137fn new_toolbar_slot() -> OverlaySlot<()> {
1138    let mut slot = OverlaySlot::new();
1139    slot.set_band(OverlayBand::Floating);
1140    slot.set_input(OverlayInput::Interactive);
1141    slot.set_outside_tap(OutsideTap::Notify { consume: false });
1142    slot.set_placement(
1143        OverlayPlacement::on(OverlaySide::Top)
1144            .align(OverlayAlign::Center)
1145            .offset(TOOLBAR_GAP),
1146    );
1147    slot
1148}
1149
1150/// Whether `pos` (widget-local) lies within a `size`-sized field.
1151fn inside(pos: Point, size: Size) -> bool {
1152    pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
1153}
1154
1155/// Which named keys a focusable-but-not-editable field still answers.
1156///
1157/// The clipboard verbs, and Escape — the keyboard's way out of a session such a
1158/// field can now hold. Everything else either changes the text or moves the
1159/// caret through `apply_edit`, and stays refused exactly as it was while a
1160/// read-only field refused focus outright. Cut and paste are answered *here* and
1161/// refused further down (`handle_command`, `request_paste_if_editable`), so the
1162/// verb is understood and answered with nothing rather than ignored.
1163///
1164/// Matched exhaustively (no `_` arm) on purpose: a named key added to
1165/// [`NamedKey`] is a compile error here until it is classified.
1166fn answered_while_read_only(named: NamedKey, modifiers: frust_core::Modifiers) -> bool {
1167    match named {
1168        NamedKey::Copy | NamedKey::Cut | NamedKey::Paste | NamedKey::Insert | NamedKey::Escape => {
1169            true
1170        }
1171        // Shift+Delete is the legacy cut chord; a plain (or otherwise chorded)
1172        // Delete is a forward delete, which is an edit.
1173        NamedKey::Delete => modifiers.shift && !modifiers.ctrl && !modifiers.meta,
1174        NamedKey::Enter
1175        | NamedKey::Backspace
1176        | NamedKey::ArrowLeft
1177        | NamedKey::ArrowRight
1178        | NamedKey::ArrowUp
1179        | NamedKey::ArrowDown
1180        | NamedKey::Home
1181        | NamedKey::End
1182        | NamedKey::Tab => false,
1183    }
1184}
1185
1186impl TextInputWidget {
1187    /// Whether the field can take focus — `enabled` alone, so a **read-only**
1188    /// field passes.
1189    ///
1190    /// [`Widget::event`]'s top gate and `paint`'s `focused` computation read
1191    /// this: a read-only field is focusable so its content can be selected and
1192    /// copied (what Material 3 and Apple's HIG both keep), and the clipboard
1193    /// verbs reach it the way every focus-routed event does — along the focus
1194    /// path it is now allowed to hold. What read-only withholds is
1195    /// [`editable`](Self::editable), not this.
1196    ///
1197    /// Dimming deliberately uses neither hook — see [`Chrome::resolve`] and
1198    /// [`effective_style`](Self::effective_style), which key off `enabled`
1199    /// alone (the [module docs](self)' "Read-only mode" section).
1200    fn focusable(&self) -> bool {
1201        self.enabled
1202    }
1203
1204    /// Whether the field's text may actually change — `false` if disabled *or*
1205    /// read-only.
1206    ///
1207    /// Every mutating path keys off this rather than off focus: typing, IME
1208    /// composition and commits, the editing and caret-motion named keys, and
1209    /// the `Cut`/`Paste` halves of [`handle_command`](Self::handle_command)
1210    /// (answered, with nothing, rather than left for someone else). So does
1211    /// [`ImeState::suppress_soft_keyboard`], which is how a focused read-only
1212    /// field keeps the platform surface its copy route needs without the
1213    /// on-screen keyboard it has no use for.
1214    fn editable(&self) -> bool {
1215        self.enabled && !self.read_only
1216    }
1217
1218    /// The style to shape the editor with: `style` unchanged when the color was
1219    /// set explicitly (or no theme is active), otherwise `style` with its color
1220    /// replaced by the theme's `on_surface` role. Mirrors
1221    /// [`crate::TextWidget`]'s `effective_style` — resolving the color here at
1222    /// LAYOUT time (where `TextInput`, like `Text`, bakes the glyph brush into
1223    /// the editor's shaped state) keeps the unthemed path pixel-identical to
1224    /// before this retrofit.
1225    ///
1226    /// While disabled, whatever color that resolution produced is dimmed by
1227    /// [`DISABLED_CONTENT_ALPHA`] — the layout half of the two-point dimming
1228    /// (the other is [`Chrome::resolve`]), and the reason a change to `enabled`
1229    /// must report `ChangeFlags::LAYOUT`.
1230    fn effective_style(&self, theme: Option<&Theme>) -> TextStyle {
1231        let mut style = if self.text_style_explicit {
1232            self.style.clone()
1233        } else {
1234            match theme {
1235                Some(theme) => {
1236                    let mut style = self.style.clone();
1237                    style.color = theme.scheme().on_surface;
1238                    style
1239                }
1240                None => self.style.clone(),
1241            }
1242        };
1243        if !self.enabled {
1244            style.color = style.color.multiply_alpha(DISABLED_CONTENT_ALPHA);
1245        }
1246        style
1247    }
1248
1249    /// The editor every *display* read goes through: the masked mirror while
1250    /// obscured, the real editor otherwise. Layout metrics, glyph runs,
1251    /// selection rects, the caret rect and the pointer hit test all key off
1252    /// this, so masking changes what is measured and not just what is drawn
1253    /// (see the [module docs](self)).
1254    fn display(&self) -> &TextEditor {
1255        self.mask_editor.as_ref().unwrap_or(&self.editor)
1256    }
1257
1258    /// (Re)create the masked mirror to match `obscured`, then seed it from the
1259    /// current editing state. Resets `applied_wrap_width` so the next `layout`
1260    /// re-installs the soft-wrap width on both editors (a freshly built
1261    /// [`TextEditor`] carries none, and that install is also what refreshes the
1262    /// new mirror's layout).
1263    fn rebuild_mask_editor(&mut self) {
1264        let style = self.applied_style.clone();
1265        self.mask_editor = self.obscured.then(|| TextEditor::new(&style));
1266        self.applied_wrap_width = None;
1267        self.sync_mask();
1268    }
1269
1270    /// Re-derive the masked mirror from the real editing state. A no-op when
1271    /// not obscured, and (via [`EditOp::ApplyEditingState`]'s value-equality
1272    /// short-circuit) when nothing changed. Because the mirror is *derived*
1273    /// rather than edited in parallel, it can never drift from the real buffer.
1274    fn sync_mask(&mut self) {
1275        let Some(mask) = self.mask_editor.as_mut() else {
1276            return;
1277        };
1278        let real = self.editor.editing_state_bytes();
1279        let state = EditingStateBytes {
1280            text: mask_text(&real.text),
1281            base: real_to_masked(&real.text, real.base),
1282            extent: real_to_masked(&real.text, real.extent),
1283            composing: real
1284                .composing
1285                .map(|r| real_to_masked(&real.text, r.start)..real_to_masked(&real.text, r.end)),
1286        };
1287        mask.apply(EditOp::ApplyEditingState(state), &mut self.text_ctx);
1288    }
1289
1290    /// Rebuild `editor` with `style`, preserving the current editing state
1291    /// (text/selection/composing) across the reconstruction — `TextEditor`
1292    /// exposes no post-construction style setter, so a style change (an app
1293    /// rebuild with a different `.text_style(...)`, or a theme swap re-resolving
1294    /// the themed color at the next `layout`) reconstructs the editor rather
1295    /// than mutating it in place.
1296    ///
1297    /// A desktop-path IME preedit (parley's own `raw_compose`) does not survive
1298    /// a mid-composition style swap — `ApplyEditingState` re-seeds it as a
1299    /// platform-tracked composing region instead (still reported correctly by
1300    /// `editing_state_utf16`/`editing_state_bytes`), a narrow, acceptable edge
1301    /// case since changing `text_style` mid-keystroke-composition is not a
1302    /// realistic app pattern.
1303    fn apply_style(&mut self, style: TextStyle) {
1304        let state = self.editor.editing_state_bytes();
1305        self.editor = TextEditor::new(&style);
1306        self.editor
1307            .apply(EditOp::ApplyEditingState(state), &mut self.text_ctx);
1308        self.applied_style = style;
1309        // The freshly built editor carries no wrap width (`TextEditor::new`
1310        // resets it to single-line); force `layout` to re-install the desired
1311        // width on the next pass. (`rebuild_mask_editor` re-asserts this too —
1312        // the masked mirror must be rebuilt with the same new style.)
1313        self.applied_wrap_width = None;
1314        self.rebuild_mask_editor();
1315    }
1316
1317    /// The single-line text height from the editor's refreshed metrics, floored
1318    /// to a sensible line height for an empty field.
1319    fn content_height(&self) -> f64 {
1320        self.display()
1321            .layout_size()
1322            .height
1323            .max(self.style.size as f64 * 1.25)
1324    }
1325
1326    /// The top-left of the text content within a `height`-tall field (vertically
1327    /// centered, never above the top padding). Single-line placement.
1328    fn text_top(&self, height: f64) -> f64 {
1329        ((height - self.content_height()) / 2.0).max(self.pad_y)
1330    }
1331
1332    /// Height of one text line from the editor's own metrics, falling back to a
1333    /// sensible line height before the first layout / when the field is empty.
1334    fn line_height(&self) -> f64 {
1335        let h = self.display().layout_size().height;
1336        let n = self.display().line_count();
1337        if h > 0.0 && n > 0 {
1338            h / n as f64
1339        } else {
1340            self.style.size as f64 * 1.25
1341        }
1342    }
1343
1344    /// The vertical scroll offset (content shifted up, in logical px) that keeps
1345    /// the caret's line in view once the content outgrows the visible box. Zero
1346    /// in single-line mode or while the content fits. Recomputed statelessly
1347    /// each pass from the caret rect — a v1 keep-caret-in-view stand-in for a
1348    /// full scroll composition (see the [module docs](self)).
1349    fn scroll_y(&self, field_height: f64) -> f64 {
1350        if self.max_visible_lines.is_none() {
1351            return 0.0;
1352        }
1353        let visible = (field_height - 2.0 * self.pad_y).max(0.0);
1354        let content = self.display().layout_size().height;
1355        if content <= visible {
1356            return 0.0;
1357        }
1358        let max_off = content - visible;
1359        let (y0, y1) = match self.display().cursor_rect(self.caret_width) {
1360            Some(c) => (c.y0, c.y1),
1361            None => (0.0, 0.0),
1362        };
1363        // Reveal the caret's bottom edge, clamp to the scrollable range, then
1364        // pull back up if that hid the caret's top edge (caret taller motion).
1365        let mut off = if y1 > visible { y1 - visible } else { 0.0 };
1366        off = off.clamp(0.0, max_off);
1367        if y0 < off {
1368            off = y0.clamp(0.0, max_off);
1369        }
1370        off
1371    }
1372
1373    /// The y of the text content's top within a `height`-tall field: the
1374    /// single-line centered placement, or the multi-line top-padded placement
1375    /// shifted up by the keep-caret-in-view scroll offset.
1376    fn content_origin_y(&self, height: f64) -> f64 {
1377        if self.max_visible_lines.is_some() {
1378            self.pad_y - self.scroll_y(height)
1379        } else {
1380            self.text_top(height)
1381        }
1382    }
1383
1384    /// Reset the blink so the caret is visible from the next painted frame
1385    /// (called on any edit / focus, during the clockless event pass). The actual
1386    /// epoch is recorded from `frame_time` on the next paint.
1387    fn reset_blink(&mut self) {
1388        self.blink_reset_pending = true;
1389    }
1390
1391    /// Whether the caret is in its visible half-cycle at frame time `now`, phase
1392    /// measured from `blink_epoch`.
1393    fn caret_visible_at(&self, now: FrameTime) -> bool {
1394        let elapsed_ms = now.saturating_sub(self.blink_epoch).as_secs_f64() * 1000.0;
1395        ((elapsed_ms / BLINK_MS) as u64).is_multiple_of(2)
1396    }
1397
1398    /// Replace the whole editing value (controlled reconcile / initial seed),
1399    /// placing the caret at the end. No callback fires.
1400    fn set_controlled_value(&mut self, value: &str) {
1401        let op = EditOp::ApplyEditingState(EditingStateBytes {
1402            text: value.to_string(),
1403            base: value.len(),
1404            extent: value.len(),
1405            composing: None,
1406        });
1407        self.editor.apply(op, &mut self.text_ctx);
1408        self.sync_mask();
1409    }
1410
1411    /// Apply one editing op, then run the after-edit bookkeeping: reset the
1412    /// blink, fire `on_change` if the text actually changed, and republish the
1413    /// IME surface. Used for both text edits and selection-only moves.
1414    fn apply_edit(&mut self, ctx: &mut EventCtx, op: EditOp) {
1415        let before = self.editor.text().to_string();
1416        self.editor.apply(op, &mut self.text_ctx);
1417        self.finish_edit(ctx, before);
1418    }
1419
1420    /// Shared after-edit bookkeeping (see [`apply_edit`](Self::apply_edit)),
1421    /// factored out so a multi-op edit (an IME commit) reports once.
1422    fn finish_edit(&mut self, ctx: &mut EventCtx, before: String) {
1423        // Re-derive the masked mirror first: the IME surface published below
1424        // (and any metric read this pass) takes its caret rect from it.
1425        self.sync_mask();
1426        self.reset_blink();
1427        let after = self.editor.text().to_string();
1428        if after != before {
1429            (self.on_change)(ctx, after);
1430            // Hide rule: a toolbar offers verbs for a *selection*, and an edit
1431            // is what makes that selection stale — it either replaced the run
1432            // the bar was pointing at or moved it. Selection-only edits (a
1433            // caret move, a drag, a word select) leave it alone, which is what
1434            // lets a long-press select and open in the same pass.
1435            self.hide_toolbar(ctx);
1436        }
1437        self.publish(ctx);
1438        ctx.request_redraw();
1439    }
1440
1441    /// Build the current editing state + caret (window coordinates) for the
1442    /// widget laid out at `origin`/`size`, so the shell can drive the platform
1443    /// IME. Shared by the event-pass [`publish`](Self::publish) and the
1444    /// paint-pass republish (see [`Widget::paint`]).
1445    fn current_ime_state(&self, origin: Point, size: Size) -> ImeState {
1446        let es = self.editor.editing_state_utf16();
1447        let editing = EditingState {
1448            text: es.text,
1449            selection_base: es.selection_base,
1450            selection_extent: es.selection_extent,
1451            composing_base: es.composing_base,
1452            composing_extent: es.composing_extent,
1453        };
1454        let offset = origin.to_vec2() + Vec2::new(self.pad_x, self.content_origin_y(size.height));
1455        // The caret rect is a *screen* placement hint, so it comes from the
1456        // displayed (possibly masked) layout — while `editing` above stays the
1457        // real text the platform IME mirror needs.
1458        let caret = self.display().cursor_rect(self.caret_width).map(|c| {
1459            Rect::new(
1460                c.x0 + offset.x,
1461                c.y0 + offset.y,
1462                c.x1 + offset.x,
1463                c.y1 + offset.y,
1464            )
1465        });
1466        ImeState {
1467            active: true,
1468            editing,
1469            caret,
1470            // `obscured` is the field's *only* content-type signal for now (no
1471            // builder exposes an override — see the module docs' rationale).
1472            // Computed live from `self.obscured` on every call, so there is no
1473            // window where a masked field publishes `Normal`: the very first
1474            // `ImeState` a newly focused obscured field emits already carries
1475            // `Password`, which is what actually closes the suggestion-strip
1476            // leak (a field that starts `Normal` and flips a frame later has
1477            // already leaked to the IME).
1478            content_type: if self.obscured {
1479                ImeContentType::Password
1480            } else {
1481                ImeContentType::Normal
1482            },
1483            // A read-only field publishes an *active* surface like any other —
1484            // the shells' clipboard routes (the web overlay's DOM `copy`
1485            // listener, Android's `InputConnection`, iOS's first responder) all
1486            // hang off it — and asks only that the on-screen keyboard stay down,
1487            // having nothing to type into.
1488            suppress_soft_keyboard: !self.editable(),
1489        }
1490    }
1491
1492    /// Publish the current editing state + caret during the event pass so the
1493    /// shell can drive the platform IME.
1494    fn publish(&self, ctx: &mut EventCtx) {
1495        ctx.publish_ime_state(self.current_ime_state(ctx.origin(), ctx.size()));
1496    }
1497
1498    /// Translate a widget-local pointer position into the editor's layout-local
1499    /// coordinate space (used for caret placement / drag-selection).
1500    fn editor_point(&self, pos: Point, height: f64) -> (f32, f32) {
1501        (
1502            (pos.x - self.pad_x) as f32,
1503            (pos.y - self.content_origin_y(height)) as f32,
1504        )
1505    }
1506
1507    /// Apply a **pointer-resolved** edit op — one whose coordinates address the
1508    /// layout the user is looking at rather than the buffer.
1509    ///
1510    /// Unobscured this is just `op` on the real editor. Obscured, the point has
1511    /// to be resolved against the *masked* layout — the glyphs actually on
1512    /// screen, whose advances differ from the real text's — and the resulting
1513    /// offsets mapped back onto the real buffer, so a press lands on the same
1514    /// character it visually points at. Both pointer gestures that reach the
1515    /// editor ([`move_to_point`](Self::move_to_point) and
1516    /// [`select_word_at_point`](Self::select_word_at_point)) need exactly that
1517    /// translation, which is why it lives here once.
1518    fn apply_display_op(&mut self, ctx: &mut EventCtx, op: EditOp) {
1519        if self.mask_editor.is_none() {
1520            self.apply_edit(ctx, op);
1521            return;
1522        }
1523        let (masked_base, masked_extent) = {
1524            let mask = self
1525                .mask_editor
1526                .as_mut()
1527                .expect("obscured field has a mask editor");
1528            mask.apply(op, &mut self.text_ctx);
1529            let m = mask.editing_state_bytes();
1530            (m.base, m.extent)
1531        };
1532        let real = self.editor.editing_state_bytes();
1533        let before = real.text.clone();
1534        let state = EditingStateBytes {
1535            base: masked_to_real(&real.text, masked_base),
1536            extent: masked_to_real(&real.text, masked_extent),
1537            ..real
1538        };
1539        self.editor
1540            .apply(EditOp::ApplyEditingState(state), &mut self.text_ctx);
1541        self.finish_edit(ctx, before);
1542    }
1543
1544    /// Place (or extend the selection to) the caret nearest a widget-local
1545    /// pointer position. Masking-aware — see
1546    /// [`apply_display_op`](Self::apply_display_op).
1547    fn move_to_point(&mut self, ctx: &mut EventCtx, x: f32, y: f32, select: bool) {
1548        self.apply_display_op(ctx, EditOp::MoveToPoint { x, y, select });
1549    }
1550
1551    /// Select the whole word under a widget-local pointer position — what a
1552    /// long-press and a double-tap both resolve to. Masking-aware for
1553    /// [`move_to_point`](Self::move_to_point)'s reason: an obscured field's
1554    /// "word" is a run of bullets, and it is the mask's advances that decide
1555    /// which run the press is inside.
1556    fn select_word_at_point(&mut self, ctx: &mut EventCtx, x: f32, y: f32) {
1557        self.apply_display_op(ctx, EditOp::SelectWordAtPoint { x, y });
1558    }
1559
1560    /// Put the toolbar away, repainting only if it was actually up — every hide
1561    /// rule in the module docs funnels through here.
1562    fn hide_toolbar(&mut self, ctx: &mut EventCtx) {
1563        if self.toolbar_open {
1564            self.toolbar_open = false;
1565            ctx.request_redraw();
1566        }
1567    }
1568
1569    /// Forget the in-flight gesture: the hold timer, the press phase and the
1570    /// tap-in-selection candidate. Touches neither the selection nor the
1571    /// toolbar, which is what makes it the whole of a `Cancel`'s work.
1572    fn clear_gesture(&mut self) {
1573        self.hold = None;
1574        self.gesture = Gesture::None;
1575        self.tap_in_selection = None;
1576    }
1577
1578    /// Whether a press at `pos` continues the last tap into a double-tap:
1579    /// within [`TOUCH_SLOP`] of it and within [`DOUBLE_TAP_MS`] of when it
1580    /// landed, both measured against the paint clock (`last_frame_time`),
1581    /// since the event pass carries none.
1582    fn is_double_tap(&self, pos: Point) -> bool {
1583        self.last_tap.is_some_and(|(prev, at)| {
1584            (pos - prev).hypot() <= TOUCH_SLOP
1585                && self.last_frame_time.saturating_sub(at).as_secs_f64() * 1000.0 <= DOUBLE_TAP_MS
1586        })
1587    }
1588
1589    /// The offset from the field's own origin to the text content's — what a
1590    /// layout-local rect is translated by to become widget-local.
1591    fn content_offset(&self, height: f64) -> Vec2 {
1592        Vec2::new(self.pad_x, self.content_origin_y(height))
1593    }
1594
1595    /// Whether a widget-local `pos` lands inside the current selection.
1596    ///
1597    /// Tested against the *displayed* selection rects (the masked mirror's,
1598    /// while obscured) for [`move_to_point`](Self::move_to_point)'s reason: the
1599    /// user is pointing at glyphs, not at byte offsets. Always false for a
1600    /// collapsed selection, which has no rects at all.
1601    fn point_in_selection(&self, pos: Point, height: f64) -> bool {
1602        let off = self.content_offset(height);
1603        self.display()
1604            .selection_rects()
1605            .iter()
1606            .any(|r| (*r + off).contains(pos))
1607    }
1608
1609    /// Where the toolbar is anchored, in **widget-local** space: the bounding
1610    /// box of the displayed selection, or the caret rect while the selection is
1611    /// collapsed (a secondary press with no selection still needs somewhere to
1612    /// hang the bar).
1613    fn selection_anchor(&self, height: f64) -> Rect {
1614        let rects = self.display().selection_rects();
1615        let bounds = rects
1616            .into_iter()
1617            .reduce(|a, b| a.union(b))
1618            .or_else(|| self.display().cursor_rect(self.caret_width))
1619            .unwrap_or(Rect::ZERO);
1620        bounds + self.content_offset(height)
1621    }
1622
1623    /// Which verbs the toolbar may offer for the current state.
1624    ///
1625    /// The field's own call, not the toolbar's (see
1626    /// [`SelectionToolbarActions`]): an obscured field hands out neither the
1627    /// real buffer nor its bullet mirror, so copy and cut are refused there
1628    /// exactly as [`clipboard_selection`](Self::clipboard_selection) refuses
1629    /// them; cut and paste additionally need an [`editable`](Self::editable)
1630    /// field, which is what leaves a read-only one offering copy and select-all
1631    /// alone; and select-all is pointless with no text or with all of it
1632    /// already selected.
1633    fn toolbar_actions(&self) -> SelectionToolbarActions {
1634        let text = self.editor.text();
1635        let selected = self.editor.selected_text();
1636        let has_selection = selected.is_some();
1637        // A selection is one contiguous slice of the buffer, so covering its
1638        // whole length is the same statement as covering all of it.
1639        let all_selected = selected.is_some_and(|s| s.len() == text.len());
1640        SelectionToolbarActions {
1641            copy: has_selection && !self.obscured,
1642            cut: has_selection && !self.obscured && self.editable(),
1643            paste: self.editable(),
1644            select_all: !text.is_empty() && !all_selected,
1645        }
1646    }
1647
1648    /// Fire a long-press whose threshold a paint already observed, reporting
1649    /// whether it fired.
1650    ///
1651    /// Called from the first pass that carries an [`EventCtx`] — the
1652    /// [`InputEvent::Housekeeping`] broadcast the marking paint latched, or an
1653    /// in-slop `Move`/`Up` that arrived first, whichever wins the race (the
1654    /// other finds the hold already cleared and is a no-op).
1655    fn fire_hold(&mut self, ctx: &mut EventCtx) -> bool {
1656        if !self.hold.is_some_and(|hold| hold.elapsed) {
1657            return false;
1658        }
1659        self.hold = None;
1660        // The gesture resolved as a long-press: it is no longer a tap (so it
1661        // seeds no double-tap) and no longer a toggle candidate (so the release
1662        // does not close what this just opened).
1663        self.tap_in_selection = None;
1664        let Some(pos) = self.gesture.tap_point() else {
1665            return false;
1666        };
1667        // Resolved, *not* forgotten: the press point stays so the `Move` arm's
1668        // slop guard still has something to measure against while the finger
1669        // is down. Dropping it here is what used to let the very next in-slop
1670        // move re-resolve the selection from the pointer.
1671        self.gesture = Gesture::HoldFired { at: pos };
1672        let (x, y) = self.editor_point(pos, ctx.size().height);
1673        // Selects first, opens second: the selection is what the toolbar's own
1674        // verbs are computed from, and `finish_edit` inside here resets the
1675        // blink and republishes the IME surface as any other edit does.
1676        self.select_word_at_point(ctx, x, y);
1677        self.toolbar_open = true;
1678        ctx.request_redraw();
1679        true
1680    }
1681
1682    /// Mount, reconcile or drop the toolbar pod — the `View::rebuild` half of
1683    /// hosting it (the only pass carrying a [`BuildCtx`]).
1684    ///
1685    /// Keyed on the **action set** alone: a moved anchor or a selection that
1686    /// grew within the same verbs re-places the existing pod rather than
1687    /// rebuilding it, so the toolbar does not flicker while a drag extends a
1688    /// selection. A wanted-but-unbuildable toolbar (nothing installed in the
1689    /// process-wide builder slot) records the want anyway, so the diagnostic
1690    /// below fires once per state change rather than once per frame.
1691    fn sync_toolbar(&mut self, ctx: &mut BuildCtx<'_>) -> ChangeFlags {
1692        // Under the Native policy the platform draws it, so the field floats
1693        // nothing and only keeps publishing the request from `paint`.
1694        // Wanted by any *focusable* field, read-only included: on a touch
1695        // device the bar is the only copy affordance there is, and
1696        // `toolbar_actions` is what withholds the verbs a read-only field must
1697        // not offer.
1698        let wanted = (self.toolbar_open
1699            && self.focused
1700            && self.focusable()
1701            && selection_toolbar_policy() == SelectionToolbarPolicy::Framework)
1702            .then(|| self.toolbar_actions());
1703        if wanted == self.toolbar_view_actions {
1704            return ChangeFlags::NONE;
1705        }
1706        let next = wanted.and_then(|actions| match selection_toolbar_builder() {
1707            Some(builder) => Some(builder(
1708                &SelectionToolbarRequest {
1709                    anchor: self.toolbar_anchor,
1710                    actions,
1711                    // Always true here: a builder is called only for a bar that
1712                    // is wanted, so the flag the platform route reads as an edge
1713                    // carries no information on this side of the seam.
1714                    present_menu: true,
1715                },
1716                self.window_size,
1717            )),
1718            None => {
1719                // Nothing to float: no design system (and no app) installed a
1720                // builder, so the framework route has no view to draw. Said
1721                // once, in a debug build only — it is a wiring gap worth
1722                // hearing about, not an error a release build can act on.
1723                #[cfg(debug_assertions)]
1724                eprintln!(
1725                    "frust-widgets: a text field wants a selection toolbar but no builder is \
1726                     installed; nothing will float (install one with \
1727                     frust_core::set_selection_toolbar_builder)"
1728                );
1729                None
1730            }
1731        });
1732        let prev = self.toolbar_view.take();
1733        let flags = self.toolbar.rebuild(prev.as_ref(), next.as_ref(), ctx);
1734        self.toolbar_view = next;
1735        self.toolbar_view_actions = wanted;
1736        flags
1737    }
1738
1739    /// Handle a keyboard key event (already focus-gated by the caller).
1740    fn handle_key(
1741        &mut self,
1742        ctx: &mut EventCtx,
1743        key: &Key,
1744        modifiers: frust_core::Modifiers,
1745    ) -> EventResult {
1746        match key {
1747            Key::Character(s) => {
1748                if modifiers.ctrl || modifiers.meta {
1749                    // Chord decoding is **platform-uniform**: ctrl OR meta arms
1750                    // the clipboard verbs on every OS, so a Mac keyboard's Cmd+C
1751                    // works under Linux and a terminal habit's Ctrl+C works on
1752                    // macOS. Flutter is platform-strict instead (meta on
1753                    // macOS/iOS, ctrl elsewhere); a shell that wants exactly that
1754                    // decodes the chord itself and dispatches an
1755                    // [`EditCommand`] — the route the platform edit menus and the
1756                    // hardware clipboard keys already take, and the one that
1757                    // always wins, since this branch only sees what a shell chose
1758                    // to forward as a key. Alt is deliberately not a chord
1759                    // modifier: it composes characters.
1760                    if s.eq_ignore_ascii_case("c") {
1761                        return self.handle_command(ctx, &EditCommand::Copy);
1762                    }
1763                    if s.eq_ignore_ascii_case("x") {
1764                        return self.handle_command(ctx, &EditCommand::Cut);
1765                    }
1766                    if s.eq_ignore_ascii_case("v") {
1767                        self.request_paste_if_editable(ctx);
1768                        return EventResult::Handled;
1769                    }
1770                    if s.eq_ignore_ascii_case("a") {
1771                        return self.handle_command(ctx, &EditCommand::SelectAll);
1772                    }
1773                    // Every other chorded character (Cmd+Z, Ctrl+B, …) stays
1774                    // consumed rather than typed: a chord is never literal text,
1775                    // and swallowing it here keeps an unimplemented verb from
1776                    // inserting a stray letter.
1777                    return EventResult::Handled;
1778                }
1779                if !self.editable() {
1780                    // Focusable but not editable: a read-only field takes no
1781                    // text from the keyboard, and refuses it unconsumed exactly
1782                    // as it did when it refused focus outright.
1783                    return EventResult::Ignored;
1784                }
1785                self.apply_edit(ctx, EditOp::Insert(s.clone()));
1786                EventResult::Handled
1787            }
1788            Key::Named(named) => {
1789                if !self.editable() && !answered_while_read_only(*named, modifiers) {
1790                    return EventResult::Ignored;
1791                }
1792                let select = modifiers.shift;
1793                match named {
1794                    NamedKey::Backspace => self.apply_edit(ctx, EditOp::Backdelete),
1795                    NamedKey::Delete => {
1796                        // Shift+Delete is the legacy cut chord (Windows/Linux/
1797                        // X11), but only on its own: Ctrl+Delete and
1798                        // Ctrl+Shift+Delete are OS/browser-level gestures, never
1799                        // a field cut, so anything chorded past shift falls
1800                        // through to the plain forward-delete.
1801                        if modifiers.shift && !modifiers.ctrl && !modifiers.meta {
1802                            return self.handle_command(ctx, &EditCommand::Cut);
1803                        }
1804                        self.apply_edit(ctx, EditOp::Delete)
1805                    }
1806                    NamedKey::ArrowLeft => self.apply_edit(ctx, EditOp::MoveLeft { select }),
1807                    NamedKey::ArrowRight => self.apply_edit(ctx, EditOp::MoveRight { select }),
1808                    NamedKey::Home => self.apply_edit(ctx, EditOp::Home { select }),
1809                    NamedKey::End => self.apply_edit(ctx, EditOp::End { select }),
1810                    NamedKey::Enter => {
1811                        // Single-line always submits (Enter is never a newline).
1812                        // Multi-line: the resolved `submit_on_enter`, inverted by
1813                        // Shift, decides submit vs. insert-newline.
1814                        let submit = if self.max_visible_lines.is_some() {
1815                            self.submit_on_enter ^ modifiers.shift
1816                        } else {
1817                            true
1818                        };
1819                        if submit {
1820                            let text = self.editor.text().to_string();
1821                            if let Some(cb) = &mut self.on_submit {
1822                                cb(ctx, text);
1823                            }
1824                            ctx.request_redraw();
1825                        } else {
1826                            self.apply_edit(ctx, EditOp::InsertNewline);
1827                        }
1828                    }
1829                    NamedKey::Escape => {
1830                        ctx.release_focus();
1831                        self.focused = false;
1832                        // Escape dismisses the session, and the toolbar with it
1833                        // — it is the field's, not a surface of its own.
1834                        self.hide_toolbar(ctx);
1835                        ctx.request_redraw();
1836                    }
1837                    // Vertical motion drives the caret across lines in multi-line
1838                    // mode; in single-line mode there is only one line, so it (and
1839                    // Tab traversal) are no-ops.
1840                    NamedKey::ArrowUp => {
1841                        if self.max_visible_lines.is_some() {
1842                            self.apply_edit(ctx, EditOp::MoveUp { select });
1843                        } else {
1844                            return EventResult::Ignored;
1845                        }
1846                    }
1847                    NamedKey::ArrowDown => {
1848                        if self.max_visible_lines.is_some() {
1849                            self.apply_edit(ctx, EditOp::MoveDown { select });
1850                        } else {
1851                            return EventResult::Ignored;
1852                        }
1853                    }
1854                    NamedKey::Tab => {
1855                        return EventResult::Ignored;
1856                    }
1857                    // The dedicated hardware clipboard keys arrive already
1858                    // decoded, so they need no chord to read — they resolve to
1859                    // exactly the verbs the chords above do.
1860                    NamedKey::Copy => return self.handle_command(ctx, &EditCommand::Copy),
1861                    NamedKey::Cut => return self.handle_command(ctx, &EditCommand::Cut),
1862                    NamedKey::Paste => self.request_paste_if_editable(ctx),
1863                    NamedKey::Insert => {
1864                        // The legacy Insert chords: Shift+Insert pastes,
1865                        // Ctrl+Insert copies (shift wins when both are held).
1866                        // A bare Insert would toggle overtype, which this field
1867                        // does not implement, so it is left unconsumed rather
1868                        // than silently swallowed.
1869                        if modifiers.shift {
1870                            self.request_paste_if_editable(ctx);
1871                        } else if modifiers.ctrl {
1872                            return self.handle_command(ctx, &EditCommand::Copy);
1873                        } else {
1874                            return EventResult::Ignored;
1875                        }
1876                    }
1877                }
1878                EventResult::Handled
1879            }
1880        }
1881    }
1882
1883    /// Ask the shell to read the host clipboard, but only for a field that
1884    /// could act on the answer.
1885    ///
1886    /// Only the shell can read it, so a paste is *asked for* here and arrives
1887    /// on a later pass as [`EditCommand::Paste`] (`EventCtx::request_paste`).
1888    /// The asking is not free — it reaches the host clipboard, and on iOS it is
1889    /// one of the gestures that can raise the system's paste prompt — so a
1890    /// read-only field, whose `EditCommand::Paste` would change nothing anyway,
1891    /// consumes its paste chord and asks for nothing.
1892    fn request_paste_if_editable(&self, ctx: &mut EventCtx) {
1893        if self.editable() {
1894            ctx.request_paste();
1895        }
1896    }
1897
1898    /// The text a copy or a cut may hand the host clipboard: the current
1899    /// selection, or `None` when the selection is collapsed **or** the field is
1900    /// obscured.
1901    ///
1902    /// The obscured refusal reads neither editor: not the real one (the secret
1903    /// is not this widget's to hand out — the whole point of the mode) and not
1904    /// the masked mirror either, since a run of bullets is a worse answer than
1905    /// no answer at all — it looks like a successful copy and pastes garbage.
1906    fn clipboard_selection(&self) -> Option<String> {
1907        if self.obscured {
1908            return None;
1909        }
1910        self.editor.selected_text().map(str::to_owned)
1911    }
1912
1913    /// Handle a decoded clipboard/selection command (already focus-gated by the
1914    /// caller) — see the [module docs](self)' "Clipboard and selection commands".
1915    ///
1916    /// Every arm funnels through one [`finish_edit`](Self::finish_edit), so the
1917    /// after-edit bookkeeping is a keystroke's: the blink resets, the masked
1918    /// mirror re-derives, the IME surface republishes, a redraw is requested, and
1919    /// `on_change` fires **exactly once and only if the text actually changed** —
1920    /// which is what keeps a copy (and a refused cut, and a paste emptied by
1921    /// sanitising) from reporting an edit that never happened. The controlled-
1922    /// value contract is untouched: like every other edit here, a command reports
1923    /// a *requested* value and the app's own `on_change` value still wins on the
1924    /// next `rebuild`.
1925    ///
1926    /// # Refusals
1927    ///
1928    /// Each one still returns [`EventResult::Handled`]: the command was
1929    /// understood and answered with "nothing", which is not the same as leaving
1930    /// it for someone else.
1931    ///
1932    /// * **Read-only** — cut and paste change nothing and write nothing, since
1933    ///   both would rewrite a buffer the field does not hand out for rewriting;
1934    ///   copy and select-all run exactly as on an editable field, which is the
1935    ///   whole point of keeping a read-only field focusable.
1936    /// * **Obscured** — copy and cut write nothing and change nothing
1937    ///   ([`clipboard_selection`](Self::clipboard_selection)). Paste is
1938    ///   unaffected: writing *into* a password field is ordinary.
1939    /// * **Collapsed selection** — copy and cut are no-ops. A cut in particular
1940    ///   must not fall back to deleting a grapheme the way its `Backdelete`
1941    ///   would if the selection were empty.
1942    /// * **Empty after sanitising** — a paste of nothing but newlines into a
1943    ///   single-line field inserts nothing rather than applying an empty edit.
1944    fn handle_command(&mut self, ctx: &mut EventCtx, cmd: &EditCommand) -> EventResult {
1945        // A disabled field holds no focus path for this command to route along
1946        // ([`Widget::event`]'s top gate releases it) — this pins that invariant
1947        // rather than re-testing it. A read-only field *does* hold one, which is
1948        // why the mutating arms below carry their own `editable` check.
1949        debug_assert!(self.focusable(), "an EditCommand reached a disabled field");
1950        let before = self.editor.text().to_string();
1951        match cmd {
1952            EditCommand::Copy => {
1953                if let Some(text) = self.clipboard_selection() {
1954                    ctx.write_clipboard(text);
1955                }
1956            }
1957            EditCommand::Cut => {
1958                // Understood and answered with nothing on a read-only field:
1959                // neither half of a cut (the clipboard write *or* the delete)
1960                // may happen, since handing out the text while failing to
1961                // remove it would be a copy wearing a cut's name.
1962                if self.editable()
1963                    && let Some(text) = self.clipboard_selection()
1964                {
1965                    ctx.write_clipboard(text);
1966                    // `Backdelete` over a non-collapsed selection removes the
1967                    // selection itself, so the write and the delete describe the
1968                    // same run of text.
1969                    self.editor.apply(EditOp::Backdelete, &mut self.text_ctx);
1970                }
1971            }
1972            EditCommand::Paste(text) => {
1973                // A single-line field denies newlines outright and a multi-line
1974                // one normalises CRLF/CR — `frust_text::sanitize_paste` owns both
1975                // rules, and `max_visible_lines` is what says which field this is.
1976                let text = sanitize_paste(text, self.max_visible_lines.is_none());
1977                if self.editable() && !text.is_empty() {
1978                    // `Insert` replaces the selection, exactly like typing does.
1979                    self.editor
1980                        .apply(EditOp::Insert(text.into_owned()), &mut self.text_ctx);
1981                }
1982            }
1983            EditCommand::SelectAll => {
1984                self.editor.apply(EditOp::SelectAll, &mut self.text_ctx);
1985            }
1986        }
1987        self.finish_edit(ctx, before);
1988        // A verb answered is a toolbar spent, whichever route delivered it (a
1989        // chord, a platform edit menu, or the floated toolbar's own item): the
1990        // bar offered these four and one of them has now been taken.
1991        self.hide_toolbar(ctx);
1992        EventResult::Handled
1993    }
1994
1995    /// Handle an IME event (already focus-gated by the caller).
1996    ///
1997    /// # Empty text is a retraction, not a composition
1998    ///
1999    /// Every shell spells "drop the preedit, insert nothing" as a `Compose`
2000    /// (or, from a platform that reports a commit for it, a `Commit`) with
2001    /// empty text: a cancelled composition, a field blurred mid-composition,
2002    /// an input method that ended a session with nothing to show for it. The
2003    /// editor has a primitive for exactly that — [`EditOp::ClearCompose`] —
2004    /// and it is the one that must be used, because `EditOp::Compose` is a
2005    /// *set-the-marked-text* operation whose backing editor asserts the text
2006    /// is non-empty. Routing an empty retraction through it panics a debug
2007    /// build on every platform that produces one.
2008    fn handle_ime(&mut self, ctx: &mut EventCtx, event: &ImeEvent) -> EventResult {
2009        match event {
2010            // The caret a retraction carries is meaningless — there is no
2011            // marked text left to place it in — so it is deliberately unread.
2012            ImeEvent::Compose { text, .. } if text.is_empty() => {
2013                self.apply_edit(ctx, EditOp::ClearCompose);
2014                EventResult::Handled
2015            }
2016            ImeEvent::Compose { text, cursor } => {
2017                self.apply_edit(
2018                    ctx,
2019                    EditOp::Compose {
2020                        text: text.clone(),
2021                        cursor: *cursor,
2022                    },
2023                );
2024                EventResult::Handled
2025            }
2026            ImeEvent::Commit(s) => {
2027                // iOS Return contract: the Return key on a
2028                // UITextInput arrives as `insertText("\n")` → `Commit("\n")`. On a
2029                // single-line (or submit-on-enter) widget a lone newline commit means
2030                // SUBMIT, exactly like `NamedKey::Enter` — it must never insert a
2031                // literal '\n'. A newline-inserting multi-line field instead lands
2032                // the literal newline, matching its `NamedKey::Enter` behavior.
2033                if s == "\n" || s == "\r" || s == "\r\n" {
2034                    if self.max_visible_lines.is_some() && !self.submit_on_enter {
2035                        self.apply_edit(ctx, EditOp::InsertNewline);
2036                        return EventResult::Handled;
2037                    }
2038                    let text = self.editor.text().to_string();
2039                    if let Some(cb) = &mut self.on_submit {
2040                        cb(ctx, text);
2041                    }
2042                    ctx.request_redraw();
2043                    return EventResult::Handled;
2044                }
2045                // An empty commit inserts nothing, so it is the same retraction
2046                // an empty `Compose` is — and reaches the same assertion if it
2047                // goes through the compose machinery below.
2048                if s.is_empty() {
2049                    self.apply_edit(ctx, EditOp::ClearCompose);
2050                    return EventResult::Handled;
2051                }
2052                // Commit the given text via the compose machinery so it replaces
2053                // any active preedit and lands at the caret in one edit.
2054                let before = self.editor.text().to_string();
2055                self.editor.apply(
2056                    EditOp::Compose {
2057                        text: s.clone(),
2058                        cursor: None,
2059                    },
2060                    &mut self.text_ctx,
2061                );
2062                self.editor.apply(EditOp::FinishCompose, &mut self.text_ctx);
2063                self.finish_edit(ctx, before);
2064                EventResult::Handled
2065            }
2066            ImeEvent::ApplyEditingState(state) => {
2067                self.apply_edit(ctx, editing_state_to_op(state));
2068                EventResult::Handled
2069            }
2070            // Bracket a composition session: nothing to mutate here.
2071            ImeEvent::Enabled | ImeEvent::Disabled => EventResult::Handled,
2072        }
2073    }
2074}
2075
2076/// Resolve the effective Enter-submits behavior: an explicit
2077/// [`TextInputView::submit_on_enter`] wins, otherwise the mode default (submit
2078/// single-line, insert-newline multi-line).
2079fn resolve_submit_on_enter(
2080    max_visible_lines: Option<usize>,
2081    submit_on_enter: Option<bool>,
2082) -> bool {
2083    submit_on_enter.unwrap_or(max_visible_lines.is_none())
2084}
2085
2086/// Convert a shell-facing (UTF-16-indexed) [`EditingState`] into the byte-indexed
2087/// [`EditOp::ApplyEditingState`] the editor consumes.
2088fn editing_state_to_op(state: &EditingState) -> EditOp {
2089    let text = &state.text;
2090    let base = utf16_to_byte(text, state.selection_base.max(0) as usize);
2091    let extent = utf16_to_byte(text, state.selection_extent.max(0) as usize);
2092    let composing = if state.composing_base >= 0 && state.composing_extent >= 0 {
2093        Some(
2094            utf16_to_byte(text, state.composing_base as usize)
2095                ..utf16_to_byte(text, state.composing_extent as usize),
2096        )
2097    } else {
2098        None
2099    };
2100    EditOp::ApplyEditingState(EditingStateBytes {
2101        text: text.clone(),
2102        base,
2103        extent,
2104        composing,
2105    })
2106}
2107
2108impl<State: 'static> View<State> for TextInputView<State> {
2109    type Element = TextInputWidget;
2110
2111    fn build(&self, _ctx: &mut BuildCtx<'_>) -> TextInputWidget {
2112        let style = self.text_style.clone();
2113        let mut text_ctx = TextContext::new();
2114        let mut editor = TextEditor::new(&style);
2115        // Seed the initial controlled value (and refresh the layout metrics).
2116        editor.apply(
2117            EditOp::ApplyEditingState(EditingStateBytes {
2118                text: self.value.clone(),
2119                base: self.value.len(),
2120                extent: self.value.len(),
2121                composing: None,
2122            }),
2123            &mut text_ctx,
2124        );
2125        let mut widget = TextInputWidget {
2126            editor,
2127            mask_editor: None,
2128            text_ctx,
2129            style: style.clone(),
2130            text_style_explicit: self.text_style_explicit,
2131            applied_style: style,
2132            placeholder: self.placeholder.clone(),
2133            max_visible_lines: self.max_visible_lines,
2134            applied_wrap_width: None,
2135            submit_on_enter: resolve_submit_on_enter(self.max_visible_lines, self.submit_on_enter),
2136            enabled: self.enabled,
2137            read_only: self.read_only,
2138            obscured: self.obscured,
2139            release_focus_pending: false,
2140            focused: false,
2141            captured: false,
2142            blink_epoch: FrameTime::ZERO,
2143            blink_reset_pending: true,
2144            pad_x: self.pad_x,
2145            pad_y: self.pad_y,
2146            border_width: self.border_width,
2147            corner_radius: self.corner_radius,
2148            caret_width: self.caret_width,
2149            focus_ring_width: self.focus_ring_width,
2150            toolbar: new_toolbar_slot(),
2151            toolbar_view: None,
2152            toolbar_view_actions: None,
2153            toolbar_open: false,
2154            toolbar_anchor: Rect::ZERO,
2155            window_size: Size::ZERO,
2156            gesture: Gesture::None,
2157            hold: None,
2158            tap_in_selection: None,
2159            outside_press_dismissed: false,
2160            last_tap: None,
2161            last_frame_time: FrameTime::ZERO,
2162            on_change: crate::authoring::erase_callback_arg(&self.on_change),
2163            on_submit: self
2164                .on_submit
2165                .as_ref()
2166                .map(crate::authoring::erase_callback_arg::<State, String>),
2167        };
2168        // Seeds the masked mirror when built obscured (a no-op otherwise).
2169        widget.rebuild_mask_editor();
2170        widget
2171    }
2172
2173    fn rebuild(
2174        &self,
2175        prev: &Self,
2176        element: &mut TextInputWidget,
2177        ctx: &mut BuildCtx<'_>,
2178    ) -> ChangeFlags {
2179        // Closures aren't comparable; reinstall the erased adapters unconditionally.
2180        element.on_change = crate::authoring::erase_callback_arg(&self.on_change);
2181        element.on_submit = self
2182            .on_submit
2183            .as_ref()
2184            .map(crate::authoring::erase_callback_arg::<State, String>);
2185
2186        let mut flags = ChangeFlags::NONE;
2187        if prev.placeholder != self.placeholder {
2188            element.placeholder = self.placeholder.clone();
2189            flags |= ChangeFlags::PAINT;
2190        }
2191        // Reconcile the multi-line configuration. A change to the visible-line
2192        // cap changes the height clamp (relayout), and either knob can change
2193        // the resolved Enter behavior.
2194        if prev.max_visible_lines != self.max_visible_lines {
2195            element.max_visible_lines = self.max_visible_lines;
2196            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
2197        }
2198        element.submit_on_enter =
2199            resolve_submit_on_enter(self.max_visible_lines, self.submit_on_enter);
2200        // Enabled reconcile. LAYOUT (not just PAINT) because the disabled dim
2201        // is baked into the glyph color at layout time — the same
2202        // `set_theme` -> `ChangeFlags::LAYOUT` contract `text_style` rides.
2203        // A field disabled *while focused* must not be stranded focused: clear
2204        // the widget's own view of it now and flag the pod-level release for
2205        // the first event that reaches us (`BuildCtx` has no focus seam).
2206        if prev.enabled != self.enabled {
2207            element.enabled = self.enabled;
2208            if !self.enabled {
2209                element.release_focus_pending = element.focused;
2210                element.focused = false;
2211                element.captured = false;
2212                // A field that stops being interactive mid-press keeps no
2213                // gesture in flight either: the hold timer is capture-gated, so
2214                // it could not fire anyway, and leaving it armed would hand the
2215                // next press a stale epoch.
2216                element.clear_gesture();
2217                element.toolbar_open = false;
2218            }
2219            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
2220        }
2221        // Read-only reconcile: unlike `enabled`, PAINT only — `read_only`
2222        // never dims (see the module docs' "Read-only mode" section), so no
2223        // glyph color is baked differently at layout time. And unlike a field
2224        // disabled while focused, a field made read-only while focused keeps
2225        // its session: it is still `focusable`, and that session is what its
2226        // content is selected and copied through. Nothing is unwound here —
2227        // the next paint publishes a keyboard-suppressed surface (so the soft
2228        // keyboard goes away) and `sync_toolbar` below recomputes the verbs,
2229        // both from the flag this line just moved across.
2230        if prev.read_only != self.read_only {
2231            element.read_only = self.read_only;
2232            flags |= ChangeFlags::PAINT;
2233        }
2234        // Obscured reconcile: the masked mirror is what gets *measured*, and a
2235        // mask glyph's advance differs from the character it replaces, so this
2236        // resizes the field as well as repainting it.
2237        if prev.obscured != self.obscured {
2238            element.obscured = self.obscured;
2239            element.rebuild_mask_editor();
2240            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
2241        }
2242        // Text style reconcile: a changed declared style or explicit-
2243        // flag invalidates the style actually installed on the editor. The
2244        // editor itself is only rebuilt in `layout` (`effective_style`/
2245        // `apply_style`), mirroring `Text::rebuild`'s cache-invalidate-now,
2246        // resolve-at-layout split — this is also what makes a bare theme swap
2247        // (no view change at all, so `rebuild` never runs) still pick up the
2248        // new color, since `layout` always re-resolves against `applied_style`.
2249        if prev.text_style != self.text_style
2250            || prev.text_style_explicit != self.text_style_explicit
2251        {
2252            element.style = self.text_style.clone();
2253            element.text_style_explicit = self.text_style_explicit;
2254            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
2255        }
2256        // Controlled reconcile: adopt the app-confirmed value only when it differs
2257        // from what the editor currently holds, so an accepted edit leaves the
2258        // selection untouched and a rejected/normalized one is pulled back in.
2259        if self.value != element.editor.text() {
2260            element.set_controlled_value(&self.value);
2261            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
2262        }
2263        // Chrome geometry reconcile (see the module docs' "Chrome" section).
2264        // Padding feeds the resolved field height and the multi-line wrap
2265        // width, so it needs a relayout; the rest (border width, corner
2266        // radius, caret width, the focus-ring override) are paint-only — none
2267        // of them change the `Size` `layout` returns.
2268        if prev.pad_x != self.pad_x || prev.pad_y != self.pad_y {
2269            element.pad_x = self.pad_x;
2270            element.pad_y = self.pad_y;
2271            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
2272        }
2273        if prev.border_width != self.border_width {
2274            element.border_width = self.border_width;
2275            flags |= ChangeFlags::PAINT;
2276        }
2277        if prev.corner_radius != self.corner_radius {
2278            element.corner_radius = self.corner_radius;
2279            flags |= ChangeFlags::PAINT;
2280        }
2281        if prev.caret_width != self.caret_width {
2282            element.caret_width = self.caret_width;
2283            flags |= ChangeFlags::PAINT;
2284        }
2285        if prev.focus_ring_width != self.focus_ring_width {
2286            element.focus_ring_width = self.focus_ring_width;
2287            flags |= ChangeFlags::PAINT;
2288        }
2289        // Last: the toolbar's want is computed from the state everything above
2290        // may have just changed (a field turned disabled wants no toolbar, one
2291        // turned read-only wants a shorter set of verbs, and a controlled
2292        // reconcile changes which verbs apply).
2293        flags |= element.sync_toolbar(ctx);
2294        flags
2295    }
2296
2297    fn teardown(&self, element: &mut TextInputWidget, ctx: &mut BuildCtx<'_>) {
2298        // The pod is a widget the field mounted; an unmounted field has to tear
2299        // it down itself, since nothing else holds a view to tear it through.
2300        let prev = element.toolbar_view.take();
2301        element.toolbar.rebuild(prev.as_ref(), None, ctx);
2302        element.toolbar_view_actions = None;
2303    }
2304}
2305
2306impl Widget for TextInputWidget {
2307    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2308        // Late-registered app fonts (a shell's per-frame font drain, after this
2309        // widget's private context was built) — a lock and a length compare
2310        // when nothing is pending. On an actual registration the editor's own
2311        // retained parley layout is still shaped against the old faces, so it
2312        // has to be rebuilt: `apply_style` with the unchanged style does
2313        // exactly that (and takes the masked mirror with it), carrying the same
2314        // narrow mid-composition caveat a theme swap does — see `apply_style`.
2315        let fonts_changed = self.text_ctx.sync_app_fonts();
2316        // Resolve the themed style and rebuild the editor if it drifted from
2317        // what's currently installed (a theme swap, or a rebuild-invalidated
2318        // `style`/`text_style_explicit` — see `effective_style`/`apply_style`).
2319        let effective = self.effective_style(Theme::from_layout_ctx(ctx));
2320        if fonts_changed || effective != self.applied_style {
2321            self.apply_style(effective);
2322        }
2323        // Catch-all mirror refresh: a rebuild-driven value/obscured change lands
2324        // before this pass, and everything measured below reads `display()`.
2325        // Cheap when already in sync (value-equality short-circuit).
2326        self.sync_mask();
2327
2328        let width = if bc.max().width.is_finite() {
2329            bc.max().width
2330        } else {
2331            DEFAULT_WIDTH
2332        };
2333
2334        // Desired soft-wrap width: `Some(px)` reflows the content in multi-line
2335        // mode; `None` restores the single-line default (so `content_height`/
2336        // `content_origin_y` agree with the centered single-line placement,
2337        // resetting any stale wrap left by a dropped `.multiline(..)`). Only
2338        // re-install it when it actually changed — parley re-shapes on every
2339        // `set_width` regardless, so an unconditional per-pass call was the
2340        // TextInput mirror of `Text`'s re-shape-every-frame defect.
2341        let desired_wrap: Option<f32> = self
2342            .max_visible_lines
2343            .map(|_| (width - 2.0 * self.pad_x).max(0.0) as f32);
2344        if self.applied_wrap_width != Some(desired_wrap) {
2345            self.editor.set_wrap_width(desired_wrap, &mut self.text_ctx);
2346            // The masked mirror wraps at the same width (and this is also the
2347            // call that refreshes a freshly rebuilt mirror's layout).
2348            if let Some(mask) = self.mask_editor.as_mut() {
2349                mask.set_wrap_width(desired_wrap, &mut self.text_ctx);
2350            }
2351            self.applied_wrap_width = Some(desired_wrap);
2352        }
2353
2354        let height = match self.max_visible_lines {
2355            Some(max_lines) => {
2356                // Clamp the reported content height to the [1, max_lines] line
2357                // band (+ padding); overflow scrolls in paint.
2358                let line_h = self.line_height();
2359                let content = self.display().layout_size().height.max(line_h);
2360                let capped = content.min(line_h * max_lines as f64);
2361                capped + 2.0 * self.pad_y
2362            }
2363            None => self.content_height() + 2.0 * self.pad_y,
2364        };
2365        // The floated toolbar is laid out against the **window**, never this
2366        // field's own constraints — it escapes the field's box entirely (see
2367        // `OverlaySlot::layout`). The window size is recorded for the builder,
2368        // which `View::rebuild` calls with no `LayoutCtx` in reach.
2369        self.window_size = ctx.window_size();
2370        self.toolbar.layout(ctx);
2371        bc.constrain(Size::new(width, height))
2372    }
2373
2374    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
2375        let origin = ctx.origin();
2376        let size = ctx.size();
2377        let theme = Theme::from_paint_ctx(ctx);
2378        let reduce_motion = theme.map(|t| t.motion.reduce_motion).unwrap_or(false);
2379        let chrome = Chrome::resolve(theme, self.enabled);
2380
2381        // Record the blink epoch from the shared frame clock once per pending
2382        // reset (a focus/edit flagged it during the clockless event pass).
2383        let now = ctx.frame_time();
2384        if self.blink_reset_pending {
2385            self.blink_epoch = now;
2386            self.blink_reset_pending = false;
2387        }
2388        // The event pass has no clock of its own, so it reads the last painted
2389        // frame's — that is what dates a completed tap for the double-tap
2390        // window (see the module docs' "Selection gestures and the toolbar").
2391        self.last_frame_time = now;
2392
2393        // The long-press timer, measured across paints exactly as
2394        // `crate::gesture`'s is: seed the epoch on the press's first painted
2395        // frame, mark it elapsed on the frame that crosses the threshold, and
2396        // latch the deferred-callback flush so the very next `RenderRoot::rebuild`
2397        // dispatches the `Housekeeping` broadcast the fire rides out on.
2398        //
2399        // The continuation frames are **plain** requests, not the caret blink's
2400        // paced one: a long-press has to fire at its threshold in wall time, so
2401        // it must not be throttled by a frame gate, and it must keep running
2402        // under `reduce_motion`, which stops the blink's requests entirely.
2403        if self.captured
2404            && let Some(hold) = self.hold.as_mut()
2405        {
2406            let start = *hold.started_at.get_or_insert(now);
2407            if !hold.elapsed {
2408                let held_ms = now.saturating_sub(start).as_secs_f64() * 1000.0;
2409                if held_ms >= LONG_PRESS_MS {
2410                    hold.elapsed = true;
2411                    frust_core::mark_pending_result_flush();
2412                }
2413                // Requested on the crossing frame too: on a dirty-driven
2414                // desktop shell that second request is what actually reaches
2415                // the rebuild that drains the latch, with no further input.
2416                ctx.request_frame();
2417            }
2418        }
2419
2420        // The double-tap window is measured against this same paint clock, and
2421        // that clock only advances while something is painting. While focused
2422        // the caret blink normally keeps it moving on its own — but
2423        // `reduce_motion` freezes the caret and drops its frame requests
2424        // entirely, and an otherwise idle field then leaves `last_frame_time`
2425        // standing exactly where the completed tap dated itself. The window
2426        // would never elapse: a press arriving any amount of wall time later
2427        // would still measure zero and resolve as a double-tap.
2428        //
2429        // So while a tap is still within its window and nothing else is
2430        // pumping the clock, the field asks for its own continuation frames —
2431        // the same plain (unpaced) request the long-press timer above makes,
2432        // for the same reason. A gesture threshold has to be measured in wall
2433        // time even with every animation switched off. Bounded by the window
2434        // itself: once it has elapsed, the condition stops holding and the
2435        // field goes back to rest.
2436        if reduce_motion
2437            && let Some((_, at)) = self.last_tap
2438            && now.saturating_sub(at).as_secs_f64() * 1000.0 <= DOUBLE_TAP_MS
2439        {
2440            ctx.request_frame();
2441        }
2442
2443        // The pod's recorded focus path (threaded in via `PaintCtx::has_focus`)
2444        // is authoritative — not our own `self.focused`, which lags after a
2445        // *container-routed* blur (a sibling tap clears the pod's focus without
2446        // ever calling our `event()`). Observe that here and self-correct so the
2447        // widget converges one frame after the blur: the accent border, caret,
2448        // caret-blink continuation frame, and IME republish below all key off
2449        // `focused`, so they stop together and the field stops resurrecting the
2450        // IME surface the blur cleared.
2451        // A disabled field never *behaves* as focused, even if the pod's focus
2452        // path is still recorded (a `rebuild` that disabled a focused field
2453        // cannot reach it — see `release_focus_pending`). Read-only is
2454        // deliberately not part of this test: such a field holds a real
2455        // session, it just holds one with no keyboard over it.
2456        let focused = ctx.has_focus() && self.focusable();
2457        if self.focused && !focused {
2458            self.focused = false;
2459            // The toolbar belongs to the session that was just blurred out from
2460            // under us, so it goes with it: the pod is dropped on the next
2461            // rebuild, and nothing is registered from this paint on. The
2462            // toggle's memory goes too — a press that dismissed the bar and
2463            // then landed on a sibling never reaches the `Down` arm that would
2464            // otherwise take it, and this is where that press ends up observed.
2465            self.toolbar_open = false;
2466            self.outside_press_dismissed = false;
2467        }
2468
2469        // Chrome: a border-colored rounded rect with an inset background fills in
2470        // as the frame (there is no stroke-rect primitive on `PaintScene`).
2471        // The border width itself widens to `focus_ring_width` while focused,
2472        // when set — the seam's narrow focus-ring escape hatch (see the module
2473        // docs' "Chrome" section); unset, it stays `border_width` either way,
2474        // matching the pre-seam behavior exactly.
2475        let border_color = if focused {
2476            chrome.accent
2477        } else {
2478            chrome.border
2479        };
2480        let border_w = if focused {
2481            self.focus_ring_width.unwrap_or(self.border_width)
2482        } else {
2483            self.border_width
2484        };
2485        scene.fill_rounded_rect(origin, size, self.corner_radius, border_color);
2486        scene.fill_rounded_rect(
2487            Point::new(origin.x + border_w, origin.y + border_w),
2488            Size::new(
2489                (size.width - 2.0 * border_w).max(0.0),
2490                (size.height - 2.0 * border_w).max(0.0),
2491            ),
2492            (self.corner_radius - border_w).max(0.0),
2493            chrome.bg,
2494        );
2495
2496        let text_origin = Point::new(
2497            origin.x + self.pad_x,
2498            origin.y + self.content_origin_y(size.height),
2499        );
2500
2501        // Multi-line content can overflow the capped box; clip the text band so
2502        // scrolled-out lines stay inside the field. Popped at the end of paint.
2503        let clip_content = self.max_visible_lines.is_some();
2504        if clip_content {
2505            scene.push_clip(
2506                Point::new(origin.x + self.border_width, origin.y + self.pad_y),
2507                Size::new(
2508                    (size.width - 2.0 * self.border_width).max(0.0),
2509                    (size.height - 2.0 * self.pad_y).max(0.0),
2510                ),
2511            );
2512        }
2513
2514        if self.editor.text().is_empty() && !focused {
2515            // Placeholder: shaped on demand through the widget-owned context.
2516            if !self.placeholder.is_empty() {
2517                let mut ph_style = self.style.clone();
2518                ph_style.color = chrome.placeholder;
2519                let layout = self.text_ctx.layout(&self.placeholder, &ph_style, None);
2520                for run in layout.to_scene_runs(text_origin) {
2521                    scene.draw_glyph_run(run);
2522                }
2523            }
2524        } else {
2525            let off = text_origin.to_vec2();
2526            // Selection highlights sit behind the glyphs, gated on `focused`
2527            // the same way the caret below is — a blurred field keeps its
2528            // selection model untouched (so a later refocus restores the
2529            // highlight unchanged) but must stop painting it, just like the
2530            // caret goes dark on blur. Both come from the displayed layout —
2531            // the masked mirror while obscured.
2532            if focused {
2533                for r in self.display().selection_rects() {
2534                    scene.fill_rect(
2535                        Point::new(r.x0 + off.x, r.y0 + off.y),
2536                        Size::new(r.width(), r.height()),
2537                        chrome.selection,
2538                    );
2539                }
2540            }
2541            for run in self.display().to_scene_runs(text_origin) {
2542                scene.draw_glyph_run(run);
2543            }
2544        }
2545
2546        // Caret: blink while focused. A paced (CosmeticLoop) continuation
2547        // request at the blink's own [`BLINK_MS`] half-period keeps the desktop
2548        // shell's wait-loop scheduling paints so the blink animates (the mobile
2549        // shells' continuous loops already do), while letting the mobile frame
2550        // gate throttle the cadence — the blink is an indefinite decorative
2551        // toggle with no endpoint, the same classification as the
2552        // design-system skeleton/progress/dots/toast/spinner loops, but naming
2553        // its own slower cadence instead of the theme's cap rate (module docs'
2554        // "Focus, IME and blink" section). At rest (unfocused) we stop
2555        // signalling. `reduce_motion` freezes the caret **visible** and stops
2556        // requesting blink frames entirely — it is a position cue, not a purely
2557        // decorative loop, so it is the one exception among the paced loops
2558        // that freezes lit rather than dark.
2559        if focused {
2560            // A read-only field's caret is drawn steady. The blink advertises an
2561            // insertion point, and a field that accepts no insertion has none to
2562            // advertise — the caret is there to mark where a selection starts,
2563            // so it stays lit and the field asks for no continuation frames at
2564            // all (`reduce_motion` freezes it lit for the same reason).
2565            let blinks = self.editable() && !reduce_motion;
2566            if blinks {
2567                ctx.request_frame_paced_at(Duration::from_millis(BLINK_MS as u64));
2568            }
2569            // Republish the IME surface every painted frame while focused, so a
2570            // controlled change applied by a rebuild (a submit clearing the
2571            // field) refreshes the shell-facing state the event pass would
2572            // otherwise leave stale — the mobile IME mirror relies on this to
2573            // observe the clear (see `PaintCtx::publish_ime_state`).
2574            ctx.publish_ime_state(self.current_ime_state(origin, size));
2575            let caret_visible = !blinks || self.caret_visible_at(now);
2576            if caret_visible && let Some(c) = self.display().cursor_rect(self.caret_width) {
2577                let off = text_origin.to_vec2();
2578                scene.fill_rect(
2579                    Point::new(c.x0 + off.x, c.y0 + off.y),
2580                    Size::new(c.width(), c.height()),
2581                    chrome.caret,
2582                );
2583            }
2584        } else if !self.focusable() && ctx.has_focus() {
2585            // Disabled while still holding the pod's focus path: publish an
2586            // *inactive* IME surface so the shell dismisses the keyboard on the
2587            // very next frame rather than waiting for the event-pass release
2588            // (`release_focus_pending`). No caret, and no frame request — a
2589            // disabled field is at rest. A read-only field never reaches this
2590            // arm (it is focusable, so the branch above claims it) and must
2591            // not: it keeps an *active* surface with the keyboard suppressed,
2592            // which is what leaves the shell's clipboard route wired.
2593            let mut ime = self.current_ime_state(origin, size);
2594            ime.active = false;
2595            ime.caret = None;
2596            ctx.publish_ime_state(ime);
2597        }
2598
2599        if clip_content {
2600            scene.pop_clip();
2601        }
2602
2603        // The selection toolbar, outside the clip because it is not drawn here
2604        // at all: the root paints every registered pod after the whole main
2605        // tree, which is the only way the bar escapes this field's box.
2606        if focused {
2607            // Recomputed every painted frame, open or not: the anchor an
2608            // ancestor scrolled or a relayout moved follows for free, and a
2609            // toolbar opened during the *next* event pass is built (one rebuild
2610            // later) against a rect that is already current.
2611            let local_anchor = self.selection_anchor(size.height);
2612            self.toolbar_anchor = local_anchor + origin.to_vec2();
2613            // Published on every paint of a focused field, bar or no bar, and
2614            // under **both** policies: it costs one pointer-sized write and
2615            // keeps one code path where the platform edit-menu route needs the
2616            // same facts (see `frust_core::selection_toolbar`).
2617            //
2618            // Publishing only while the bar stood is what used to make the
2619            // platform route disagree with the accessibility one: the module
2620            // docs' rule that the verbs "never depend on the bar being up"
2621            // holds for both now. A host responder chain answers "may I offer
2622            // Paste?" from `actions` whenever it asks — a hardware Cmd+V
2623            // arrives with nothing on screen, and on iOS it is one of the two
2624            // paste routes the system exempts from its own permission alert —
2625            // so gating the answer on a pointer gesture the user never made
2626            // left every hardware shortcut dead.
2627            //
2628            // With no selection the anchor is the caret rect
2629            // (`selection_anchor`), which is the right place to hang a
2630            // paste-only menu; `present_menu` carries the bar's own open/closed
2631            // state as the one edge in the request, so the level below it may
2632            // change every frame without asking anyone to present anything.
2633            ctx.publish_selection_toolbar(SelectionToolbarRequest {
2634                anchor: self.toolbar_anchor,
2635                actions: self.toolbar_actions(),
2636                present_menu: self.toolbar_open,
2637            });
2638            if self.toolbar_open && selection_toolbar_policy() == SelectionToolbarPolicy::Framework
2639            {
2640                // The slot takes the anchor in the field's own local space
2641                // and lifts it into window space with the paint origin.
2642                self.toolbar.set_anchor(OverlayAnchor::Rect(local_anchor));
2643                self.toolbar.paint(ctx, size);
2644            }
2645        }
2646    }
2647
2648    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
2649        // The focus gate IS the disabled gate: refusing focus here is what
2650        // makes `Key`/`Ime` (focus-routed, never hit-tested) unreachable, and a
2651        // disabled field consumes nothing on the way — it is inert, not a
2652        // shield. A read-only field passes this gate and reaches everything
2653        // below, so each mutating path carries its own `editable` guard
2654        // instead (`handle_key`, the `Ime` arm, `handle_command`'s cut/paste).
2655        if !self.focusable() {
2656            if ctx.has_focus() || self.release_focus_pending {
2657                ctx.release_focus();
2658                self.release_focus_pending = false;
2659                self.focused = false;
2660                self.captured = false;
2661                ctx.request_redraw();
2662            }
2663            return EventResult::Ignored;
2664        }
2665        // The floated toolbar gets first refusal: its own input arrives as an
2666        // `InputEvent::Overlay` broadcast addressed to this slot's key, and the
2667        // slot answers `Some` for exactly what belongs to the surface.
2668        if let Some(result) = self.toolbar.event(ctx, event, &mut ()) {
2669            // Drained in the same pass the pod dispatched them in — the queue is
2670            // pass-scoped, not a mailbox (`EventCtx::dispatch_edit_command`).
2671            // Drained unconditionally, *then* gated: leaving commands sitting in
2672            // a pass-scoped queue would hand them to whoever drains it next.
2673            let commands = ctx.take_edit_commands();
2674            // The same focus gate the shell-delivered `InputEvent::EditCommand`
2675            // route enforces, and for the same reason: a field answers a
2676            // clipboard verb only for a session it actually holds. The pod is
2677            // only ever mounted while this field is focused, so this is the
2678            // invariant restated rather than a case seen in practice — but the
2679            // two routes ending in `handle_command` must not disagree about
2680            // when a verb is allowed to land.
2681            if !commands.is_empty() && ctx.has_focus() {
2682                self.focused = true;
2683                // A clipboard verb is not part of any tap sequence, exactly as
2684                // on the shell-delivered route.
2685                self.last_tap = None;
2686                for cmd in &commands {
2687                    self.handle_command(ctx, cmd);
2688                }
2689                // Re-claim the session the tap was aimed at. The root never
2690                // blurs on an overlay press, so this is belt-and-braces rather
2691                // than the load-bearing half — what matters is that a verb
2692                // taken from the bar leaves the field exactly as focused as it
2693                // found it, which is the whole reason the bar is reachable.
2694                if ctx.has_focus() {
2695                    ctx.request_focus();
2696                }
2697            }
2698            // A press that landed on nothing floated: the light-dismiss signal
2699            // this slot registered `OutsideTap::Notify` for. The press itself
2700            // is not consumed, so the field's own `Down` arm still runs below —
2701            // this only closes the bar early enough that an outside press on a
2702            // *sibling* widget closes it too.
2703            if self.toolbar.take_outside_down() {
2704                self.outside_press_dismissed = self.toolbar_open;
2705                self.hide_toolbar(ctx);
2706            }
2707            return result;
2708        }
2709        match event {
2710            InputEvent::Pointer(p) => match p.phase {
2711                PointerPhase::Down => {
2712                    if inside(p.position, ctx.size()) {
2713                        // A secondary press is the desktop context-menu gesture
2714                        // (no mobile shell delivers one): it claims focus —
2715                        // *starting* a session when there was none, which is
2716                        // what makes right-clicking an idle field useful —
2717                        // moves no caret, and toggles the toolbar over whatever
2718                        // is selected, or over the caret when nothing is. The
2719                        // caret deliberately stays put where a browser would
2720                        // relocate it: the menu opens on the selection the user
2721                        // already has, and a right-click that silently collapsed
2722                        // it would be the worse surprise.
2723                        if !presses(p) {
2724                            ctx.request_focus();
2725                            self.focused = true;
2726                            self.toolbar_open = !self.toolbar_open;
2727                            // A context press is not part of any tap sequence.
2728                            self.clear_gesture();
2729                            self.last_tap = None;
2730                            self.reset_blink();
2731                            self.publish(ctx);
2732                            ctx.request_redraw();
2733                            return EventResult::Handled;
2734                        }
2735                        ctx.request_focus();
2736                        ctx.capture_pointer();
2737                        self.focused = true;
2738                        self.captured = true;
2739                        // Hide rule: every primary press puts the bar away
2740                        // first. What the press turns out to be (a toggle, a
2741                        // double-tap, a hold) decides on its own whether to put
2742                        // one back up — `was_open` is the toggle's memory.
2743                        let was_open =
2744                            self.toolbar_open || std::mem::take(&mut self.outside_press_dismissed);
2745                        self.toolbar_open = false;
2746                        self.gesture = Gesture::Tap { at: p.position };
2747                        self.hold = None;
2748                        self.tap_in_selection = None;
2749                        let (x, y) = self.editor_point(p.position, ctx.size().height);
2750                        // A second press on the same spot within the double-tap
2751                        // window selects the word and opens **nothing**: the
2752                        // user is selecting, and a bar over the word they are
2753                        // about to type over is in the way.
2754                        if self.is_double_tap(p.position) {
2755                            self.last_tap = None;
2756                            self.select_word_at_point(ctx, x, y);
2757                            return EventResult::Handled;
2758                        }
2759                        self.last_tap = None;
2760                        self.hold = Some(HoldState {
2761                            started_at: None,
2762                            elapsed: false,
2763                        });
2764                        // A press that lands *inside* an existing selection
2765                        // neither moves the caret nor collapses it: it is
2766                        // either the start of the toolbar toggle (resolved on
2767                        // the release, fire-on-up-inside like every other
2768                        // baseline widget) or the start of an ordinary caret
2769                        // drag, and only the `Move` past the slop can say which.
2770                        if self.point_in_selection(p.position, ctx.size().height) {
2771                            self.tap_in_selection = Some(was_open);
2772                            ctx.request_redraw();
2773                            return EventResult::Handled;
2774                        }
2775                        self.move_to_point(ctx, x, y, false);
2776                        EventResult::Handled
2777                    } else {
2778                        // A `Down` outside our bounds that still reaches us (we are
2779                        // the root) is a blur: drop focus. Nested, the container's
2780                        // routing clears our focus path instead.
2781                        if self.focused {
2782                            ctx.release_focus();
2783                            self.focused = false;
2784                            self.hide_toolbar(ctx);
2785                            ctx.request_redraw();
2786                        }
2787                        self.clear_gesture();
2788                        self.last_tap = None;
2789                        self.outside_press_dismissed = false;
2790                        EventResult::Ignored
2791                    }
2792                }
2793                PointerPhase::Move => {
2794                    if !self.captured {
2795                        return EventResult::Ignored;
2796                    }
2797                    // The slop guard, and it outlives the long-press fire:
2798                    // **both** a still-a-tap press and an already-fired hold
2799                    // refuse to touch the selection while the finger stays
2800                    // within `TOUCH_SLOP` of where it landed. Only a press
2801                    // that actually wandered that far extends anything.
2802                    match self.gesture {
2803                        Gesture::Tap { at } => {
2804                            if (p.position - at).hypot() <= TOUCH_SLOP {
2805                                // The one thing an in-slop move can do is
2806                                // deliver a hold whose threshold a paint
2807                                // already observed (fire-on-move-arrival, so
2808                                // the word is selected the instant a held
2809                                // finger jitters rather than waiting out
2810                                // another frame).
2811                                self.fire_hold(ctx);
2812                                return EventResult::Handled;
2813                            }
2814                            // Past the slop: it became a drag. The hold is
2815                            // cancelled, the tap-in-selection candidate with it
2816                            // (a drag out of a selection is an ordinary caret
2817                            // drag), and the press stops being a tap for the
2818                            // double-tap window's purposes.
2819                            self.hold = None;
2820                            self.tap_in_selection = None;
2821                            self.gesture = Gesture::Drag;
2822                        }
2823                        Gesture::HoldFired { at } => {
2824                            if (p.position - at).hypot() <= TOUCH_SLOP {
2825                                // A held finger jittering over the word it just
2826                                // selected. `TOUCH_SLOP` is 18 logical px —
2827                                // several characters wide at a normal text
2828                                // size, and wide enough to span a word boundary
2829                                // — so re-resolving the selection from here
2830                                // would silently redraw it under a finger the
2831                                // user is holding deliberately still.
2832                                return EventResult::Handled;
2833                            }
2834                            // The finger left the slop: the user is now
2835                            // dragging the long-press selection outward, which
2836                            // is an extend like any other (see the module docs'
2837                            // "Selection gestures and the toolbar" for the
2838                            // granularity that extend carries). Becoming a
2839                            // `Drag` is what lets a finger brought back toward
2840                            // the press point shrink the selection again
2841                            // instead of freezing it at its widest.
2842                            self.gesture = Gesture::Drag;
2843                        }
2844                        Gesture::Drag | Gesture::None => {}
2845                    }
2846                    let (x, y) = self.editor_point(p.position, ctx.size().height);
2847                    self.move_to_point(ctx, x, y, true);
2848                    EventResult::Handled
2849                }
2850                PointerPhase::Up => {
2851                    if !self.captured {
2852                        return EventResult::Ignored;
2853                    }
2854                    self.captured = false;
2855                    // Last chance for a hold whose threshold elapsed with no
2856                    // pass to fire it on; it consumes the release outright, so
2857                    // a long-press never also resolves as a tap.
2858                    if !self.fire_hold(ctx) {
2859                        // A release inside a selection the press never left
2860                        // toggles the toolbar: up it goes when the press found
2861                        // it down, and the press's own hide rule is what closes
2862                        // it the second time.
2863                        if let Some(was_open) = self.tap_in_selection.take()
2864                            && self.gesture.tap_point().is_some()
2865                        {
2866                            self.toolbar_open = !was_open;
2867                        }
2868                        // Seed the double-tap window, dated by the last painted
2869                        // frame — a press that wandered past the slop, and one
2870                        // a long-press resolved, are no longer taps
2871                        // (`Gesture::tap_point`) and seed nothing.
2872                        if let Some(down) = self.gesture.tap_point() {
2873                            self.last_tap = Some((down, self.last_frame_time));
2874                        }
2875                    }
2876                    self.clear_gesture();
2877                    ctx.request_redraw();
2878                    EventResult::Handled
2879                }
2880                PointerPhase::Cancel => {
2881                    if !self.captured {
2882                        return EventResult::Ignored;
2883                    }
2884                    // A `Cancel` must never touch application state: only clear the
2885                    // drag flag and the in-flight gesture (never the selection,
2886                    // and never the toolbar — a gesture stolen mid-press says
2887                    // nothing about whether the bar should still be up), and
2888                    // request a redraw.
2889                    self.captured = false;
2890                    self.clear_gesture();
2891                    self.last_tap = None;
2892                    ctx.request_redraw();
2893                    EventResult::Handled
2894                }
2895            },
2896            InputEvent::Key(k) => {
2897                if !ctx.has_focus() {
2898                    return EventResult::Ignored;
2899                }
2900                self.focused = true;
2901                self.last_tap = None;
2902                self.handle_key(ctx, &k.key, k.modifiers)
2903            }
2904            InputEvent::Ime(e) => {
2905                if !ctx.has_focus() {
2906                    return EventResult::Ignored;
2907                }
2908                // A read-only field publishes an *active* surface — that is what
2909                // keeps the shells' clipboard routes wired — but takes no text
2910                // from it. A composition or commit that arrives anyway is
2911                // refused exactly as it was when the field held no session.
2912                if !self.editable() {
2913                    return EventResult::Ignored;
2914                }
2915                self.focused = true;
2916                self.last_tap = None;
2917                self.handle_ime(ctx, e)
2918            }
2919            // A clipboard verb is focus-routed like `Key`/`Ime` and gated the
2920            // same way: an unfocused field ignores it rather than answering for
2921            // a selection the user is not looking at.
2922            InputEvent::EditCommand(cmd) => {
2923                if !ctx.has_focus() {
2924                    return EventResult::Ignored;
2925                }
2926                self.focused = true;
2927                self.last_tap = None;
2928                self.handle_command(ctx, cmd)
2929            }
2930            // Hide rule: the anchor just moved out from under the bar. The
2931            // scroll itself is still somebody else's (this field scrolls
2932            // nothing of its own horizontally, and its multi-line offset is
2933            // caret-driven), so it stays unconsumed.
2934            InputEvent::Scroll { .. } => {
2935                self.hide_toolbar(ctx);
2936                self.last_tap = None;
2937                EventResult::Ignored
2938            }
2939            // The delivery vehicle for a long-press whose threshold an earlier
2940            // paint observed (see the module docs' "Selection gestures and the
2941            // toolbar"): the soonest pass carrying a real `EventCtx` when no
2942            // pointer event arrived first. Still never consumed — a broadcast
2943            // reports `Ignored` whatever it did.
2944            InputEvent::Housekeeping => {
2945                self.fire_hold(ctx);
2946                EventResult::Ignored
2947            }
2948            // A floated surface's own input, already offered to the slot above:
2949            // reaching this arm means the broadcast was addressed to some other
2950            // owner's surface, which is none of this field's business.
2951            InputEvent::Overlay(_) => EventResult::Ignored,
2952            // `InputEvent` grows (a scale gesture and file drops are planned);
2953            // this field handles only the variants named above and ignores the
2954            // rest, like the overlay arm.
2955            _ => EventResult::Ignored,
2956        }
2957    }
2958
2959    fn semantics(&self, ctx: &mut SemanticsCtx) {
2960        // A single-line TextInput node exposing its current text as `value`.
2961        // accesskit tracks focus at the tree level, so a focused field records
2962        // itself as the pass's focus node rather than carrying a per-node flag.
2963        //
2964        // Obscured, the role becomes `PasswordInput` (the one piece of password
2965        // semantics this option delivers) and the reported value is the *masked*
2966        // mirror — an assistive-tech client reads the node value verbatim, so
2967        // publishing the real secret there would defeat the masking.
2968        let role = if self.obscured {
2969            Role::PasswordInput
2970        } else {
2971            Role::TextInput
2972        };
2973        let value = if self.obscured {
2974            mask_text(self.editor.text())
2975        } else {
2976            self.editor.text().to_string()
2977        };
2978        // Disabled and read-only are reported as distinct accesskit node
2979        // states, never conflated (a screen reader announces them
2980        // differently — see the module docs' "Read-only mode" section): a
2981        // disabled field says `set_disabled()`, a read-only *enabled* field
2982        // says `set_read_only()`. `!enabled` wins if somehow both flags are
2983        // set — a disabled field is the stronger claim.
2984        // The clipboard verbs, published on the field's OWN node.
2985        //
2986        // They are deliberately NOT keyed to `toolbar_open`: the floating
2987        // toolbar is a *pointer* affordance, and gating the accessible route on
2988        // it would mean the verbs existed only for a user who had already
2989        // performed the long-press that raises it. The floated pod contributes
2990        // no semantics of its own (the overlay portal's deliberate choice), so
2991        // this node is the only place the verbs can live.
2992        //
2993        // The enabled set is `toolbar_actions()` — the same predicates the bar
2994        // itself is built from, read once here so the two routes cannot offer
2995        // different verbs for the same state.
2996        //
2997        // accesskit models these as *custom* actions: its `Action` enum has no
2998        // Copy/Cut/Paste/SelectAll of its own, so each verb is an id plus a
2999        // label the client reads out (see `A11Y_CUT_ID` on why the ids are
3000        // stable). Only the enabled ones are published — an action offered and
3001        // then refused is worse than one never offered.
3002        //
3003        // Gated on `focusable()` as a whole, the way mounting the bar is: a
3004        // disabled field never holds the focus a verb routes along, and
3005        // `toolbar_actions()` alone would still offer `copy` over a selection it
3006        // happened to be showing. A read-only field passes — it is copyable, and
3007        // `toolbar_actions()` has already withheld the cut and paste it must not
3008        // offer. Focus itself is deliberately *not* required — a client explores
3009        // the tree before it acts, and a field that advertised nothing until
3010        // focused would not be discovered.
3011        let verbs = self.toolbar_actions();
3012        let offered: Vec<CustomAction> = [
3013            (A11Y_CUT_ID, "Cut", verbs.cut),
3014            (A11Y_COPY_ID, "Copy", verbs.copy),
3015            (A11Y_PASTE_ID, "Paste", verbs.paste),
3016            (A11Y_SELECT_ALL_ID, "Select all", verbs.select_all),
3017        ]
3018        .into_iter()
3019        .filter(|(_, _, enabled)| *enabled && self.focusable())
3020        .map(|(id, description, _)| CustomAction {
3021            id,
3022            description: description.into(),
3023        })
3024        .collect();
3025        let id = ctx.push_node(role, |node| {
3026            node.set_value(value);
3027            if !self.enabled {
3028                node.set_disabled();
3029            } else if self.read_only {
3030                node.set_read_only();
3031            }
3032            if !offered.is_empty() {
3033                node.add_action(Action::CustomAction);
3034                node.set_custom_actions(offered);
3035            }
3036        });
3037        if self.focused {
3038            ctx.set_focused(id);
3039        }
3040    }
3041}
3042
3043#[cfg(test)]
3044mod tests {
3045    use super::*;
3046    use frust_core::{
3047        FrameTime, KeyEvent, Modifiers, PointerButton, PointerEvent, RenderRoot, ScrollDelta,
3048        SelectionToolbarBuilder, set_selection_toolbar_builder, set_selection_toolbar_policy,
3049    };
3050    use std::any::Any;
3051    use std::sync::{Arc, Mutex};
3052
3053    #[derive(Default)]
3054    struct AppState {
3055        value: String,
3056        changes: u32,
3057        submits: u32,
3058        last_submit: String,
3059        reject: bool,
3060    }
3061
3062    fn build(state: &mut AppState) -> TextInputView<AppState> {
3063        text_input(state.value.clone(), |s: &mut AppState, v: String| {
3064            s.changes += 1;
3065            if !s.reject {
3066                s.value = v;
3067            }
3068        })
3069        .placeholder("type here")
3070        .on_submit(|s: &mut AppState, v: String| {
3071            s.submits += 1;
3072            s.last_submit = v;
3073        })
3074    }
3075
3076    /// Build + lay out a render root over the build closure, ready for events.
3077    fn harness(state: &mut AppState) -> RenderRoot<AppState, TextInputView<AppState>> {
3078        let mut root = RenderRoot::new();
3079        root.rebuild(&mut build, state);
3080        root.layout(Size::new(300.0, 200.0));
3081        root
3082    }
3083
3084    fn widget(root: &RenderRoot<AppState, TextInputView<AppState>>) -> &TextInputWidget {
3085        let id = root.root_id().expect("root built");
3086        let w = root.tree().pod(id).expect("root pod").widget();
3087        (w as &dyn Any)
3088            .downcast_ref::<TextInputWidget>()
3089            .expect("root is a TextInputWidget")
3090    }
3091
3092    fn pointer(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
3093        InputEvent::Pointer(PointerEvent {
3094            phase,
3095            position: Point::new(x, y),
3096            button: PointerButton::Primary,
3097        })
3098    }
3099
3100    /// The same event on the secondary (right) button.
3101    fn secondary_pointer(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
3102        InputEvent::Pointer(PointerEvent {
3103            phase,
3104            position: Point::new(x, y),
3105            button: PointerButton::Secondary,
3106        })
3107    }
3108
3109    fn ch(text: &str) -> InputEvent {
3110        InputEvent::Key(KeyEvent {
3111            key: Key::Character(text.to_string()),
3112            modifiers: Modifiers::default(),
3113            repeat: false,
3114        })
3115    }
3116
3117    fn named(key: NamedKey, modifiers: Modifiers) -> InputEvent {
3118        InputEvent::Key(KeyEvent {
3119            key: Key::Named(key),
3120            modifiers,
3121            repeat: false,
3122        })
3123    }
3124
3125    #[test]
3126    fn tap_focuses_and_publishes_ime_state() {
3127        let mut state = AppState::default();
3128        let mut root = harness(&mut state);
3129        assert!(!root.is_focus_active());
3130        assert!(root.ime_state().is_none());
3131
3132        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
3133
3134        assert!(root.is_focus_active(), "tap inside focuses the field");
3135        assert!(widget(&root).focused);
3136        let ime = root.ime_state().expect("focus publishes an IME surface");
3137        assert!(ime.active);
3138        assert!(ime.caret.is_some(), "an IME surface carries a caret rect");
3139    }
3140
3141    #[test]
3142    fn a_secondary_press_focuses_opens_the_toolbar_and_moves_no_caret() {
3143        let mut state = AppState::default();
3144        let mut root = harness(&mut state);
3145
3146        // Unfocused: a right-click is the desktop context-menu gesture, so it
3147        // *starts* a session (a menu over a field nobody is editing is still
3148        // the field's menu) and opens the toolbar over the caret.
3149        root.event(
3150            &mut state,
3151            &secondary_pointer(PointerPhase::Down, 10.0, 10.0),
3152        );
3153        assert!(root.is_focus_active(), "a context press claims focus");
3154        assert!(widget(&root).focused);
3155        assert!(
3156            root.ime_state().is_some_and(|ime| ime.active),
3157            "and publishes the session's IME surface like any other focus claim"
3158        );
3159        assert!(widget(&root).toolbar_open, "and opens the toolbar");
3160
3161        // Again: the toggle puts it away, leaving the session standing.
3162        root.event(
3163            &mut state,
3164            &secondary_pointer(PointerPhase::Down, 10.0, 10.0),
3165        );
3166        assert!(
3167            !widget(&root).toolbar_open,
3168            "a second context press closes it"
3169        );
3170        assert!(root.is_focus_active(), "and never blurs the field");
3171
3172        // Focused and typed into: a right-click anywhere in the field leaves the
3173        // caret where it is, and leaves the live session alone — it is consumed
3174        // as a tap *inside* the field, never read as an outside-tap blur.
3175        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
3176        root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
3177        for c in ["a", "b", "c"] {
3178            root.event(&mut state, &ch(c));
3179        }
3180        let caret = widget(&root).editor.editing_state_bytes().extent;
3181        assert_eq!(caret, 3, "the caret sits after the typed text");
3182
3183        let outcome = root.event(
3184            &mut state,
3185            &secondary_pointer(PointerPhase::Down, 1.0, 10.0),
3186        );
3187
3188        assert!(outcome.handled, "the field still swallows the press");
3189        assert_eq!(
3190            widget(&root).editor.editing_state_bytes().extent,
3191            caret,
3192            "a right-click places no caret"
3193        );
3194        assert!(!widget(&root).captured, "and starts no selection drag");
3195        assert!(root.is_focus_active(), "the typing session survives it");
3196        assert!(widget(&root).focused);
3197        assert!(widget(&root).toolbar_open, "and it opens the toolbar");
3198    }
3199
3200    #[test]
3201    fn typing_inserts_and_fires_on_change() {
3202        let mut state = AppState::default();
3203        let mut root = harness(&mut state);
3204        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
3205
3206        root.event(&mut state, &ch("h"));
3207        root.event(&mut state, &ch("i"));
3208
3209        assert_eq!(state.value, "hi", "on_change fed each char into app state");
3210        assert_eq!(state.changes, 2);
3211        assert_eq!(widget(&root).editor.text(), "hi");
3212    }
3213
3214    #[test]
3215    fn keys_ignored_while_unfocused() {
3216        let mut state = AppState::default();
3217        let mut root = harness(&mut state);
3218        // No prior focus: a key is not consumed and does not edit.
3219        let outcome = root.event(&mut state, &ch("x"));
3220        assert!(!outcome.handled);
3221        assert_eq!(state.changes, 0);
3222        assert_eq!(widget(&root).editor.text(), "");
3223    }
3224
3225    #[test]
3226    fn backspace_over_emoji_removes_grapheme() {
3227        let mut state = AppState::default();
3228        let mut root = harness(&mut state);
3229        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
3230        root.event(&mut state, &ch("a"));
3231        root.event(&mut state, &ch("\u{1F600}")); // grinning face (4 bytes)
3232        assert_eq!(widget(&root).editor.text(), "a\u{1F600}");
3233
3234        root.event(
3235            &mut state,
3236            &named(NamedKey::Backspace, Modifiers::default()),
3237        );
3238
3239        // The whole emoji code point is removed as a unit (the editor's
3240        // grapheme integrity), not a single byte.
3241        assert_eq!(widget(&root).editor.text(), "a");
3242        assert_eq!(state.value, "a");
3243    }
3244
3245    #[test]
3246    fn arrows_with_shift_extend_selection() {
3247        let mut state = AppState::default();
3248        let mut root = harness(&mut state);
3249        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
3250        for c in ["a", "b", "c"] {
3251            root.event(&mut state, &ch(c));
3252        }
3253        let changes_before = state.changes;
3254
3255        let shift = Modifiers {
3256            shift: true,
3257            ..Modifiers::default()
3258        };
3259        root.event(&mut state, &named(NamedKey::ArrowLeft, shift));
3260
3261        let es = widget(&root).editor.editing_state_bytes();
3262        assert_ne!(es.base, es.extent, "shift+arrow extends the selection");
3263        assert_eq!(
3264            state.changes, changes_before,
3265            "a selection-only move does not fire on_change"
3266        );
3267    }
3268
3269    #[test]
3270    fn enter_fires_on_submit_once_and_keeps_focus() {
3271        let mut state = AppState::default();
3272        let mut root = harness(&mut state);
3273        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
3274        root.event(&mut state, &ch("h"));
3275        root.event(&mut state, &ch("i"));
3276
3277        root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
3278
3279        assert_eq!(state.submits, 1, "Enter fires on_submit exactly once");
3280        assert_eq!(state.last_submit, "hi");
3281        assert!(root.is_focus_active(), "submit keeps focus");
3282    }
3283
3284    #[test]
3285    fn newline_commit_submits_instead_of_inserting() {
3286        // The iOS Return path: UITextInput's Return arrives as insertText("\n")
3287        // → ImeEvent::Commit("\n"). Must behave exactly like NamedKey::Enter on
3288        // this single-line widget — submit, keep the text newline-free.
3289        let mut state = AppState::default();
3290        let mut root = harness(&mut state);
3291        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
3292        root.event(&mut state, &ch("h"));
3293        root.event(&mut state, &ch("i"));
3294
3295        root.event(
3296            &mut state,
3297            &InputEvent::Ime(ImeEvent::Commit("\n".to_string())),
3298        );
3299
3300        assert_eq!(state.submits, 1, "newline commit fires on_submit once");
3301        assert_eq!(state.last_submit, "hi");
3302        assert!(
3303            !widget(&root).editor.text().contains('\n'),
3304            "no literal newline lands in the single-line field"
3305        );
3306        assert!(root.is_focus_active(), "submit keeps focus");
3307
3308        // CRLF variant (some platforms/hardware keyboards): same behavior.
3309        root.event(
3310            &mut state,
3311            &InputEvent::Ime(ImeEvent::Commit("\r\n".to_string())),
3312        );
3313        assert_eq!(state.submits, 2);
3314        assert!(!widget(&root).editor.text().contains('\r'));
3315    }
3316
3317    #[test]
3318    fn escape_releases_focus() {
3319        let mut state = AppState::default();
3320        let mut root = harness(&mut state);
3321        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
3322        assert!(root.is_focus_active());
3323
3324        root.event(&mut state, &named(NamedKey::Escape, Modifiers::default()));
3325
3326        assert!(!root.is_focus_active(), "Escape blurs the field");
3327        assert!(root.ime_state().is_none());
3328        assert!(!widget(&root).focused);
3329    }
3330
3331    #[test]
3332    fn blur_via_outside_tap_unpublishes_ime_state() {
3333        let mut state = AppState::default();
3334        let mut root = harness(&mut state);
3335        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
3336        assert!(root.ime_state().is_some());
3337
3338        // Tap outside the field's height (still reaches the root widget).
3339        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 150.0));
3340
3341        assert!(!root.is_focus_active(), "outside tap blurs the field");
3342        assert!(root.ime_state().is_none());
3343        assert!(!widget(&root).focused);
3344    }
3345
3346    #[test]
3347    fn blurred_selection_stops_painting_its_highlight_but_survives_for_refocus() {
3348        // Regression test: the caret already goes dark on blur, but the
3349        // selection highlight fill was not gated the same way, so a field
3350        // that lost focus while holding a non-empty selection kept painting
3351        // it. Asserting on `selection_rects()` alone would only prove the
3352        // selection *model* still holds a range — it says nothing about
3353        // whether `paint` actually stopped filling it, which is exactly the
3354        // gap that let this ship — so this uses a scene recorder that
3355        // observes the real `fill_rect` calls instead.
3356        let mut state = AppState::default();
3357        let mut root = harness(&mut state);
3358        focused_with_selection(&mut state, &mut root, "abc");
3359        assert_eq!(
3360            widget(&root).editor.selected_text(),
3361            Some("abc"),
3362            "the drag produced a non-empty selection"
3363        );
3364
3365        // Focused half of the contract: the highlight is painted.
3366        let mut focused_rec = ChromeRecorder::default();
3367        root.paint(&mut focused_rec, FrameTime::ZERO);
3368        assert!(
3369            focused_rec.rects.contains(&SELECTION),
3370            "a focused field with a non-empty selection must paint the highlight"
3371        );
3372
3373        // Blur without touching the selection model at all.
3374        root.event(&mut state, &named(NamedKey::Escape, Modifiers::default()));
3375        assert!(!root.is_focus_active(), "Escape blurs the field");
3376        assert_eq!(
3377            widget(&root).editor.selected_text(),
3378            Some("abc"),
3379            "the selection must survive blur so a later refocus restores the highlight"
3380        );
3381
3382        // Blurred half of the contract: the fill stops even though the
3383        // selection (and the text) is unchanged.
3384        let mut blurred_rec = ChromeRecorder::default();
3385        root.paint(&mut blurred_rec, FrameTime::ZERO);
3386        assert!(
3387            !blurred_rec.rects.contains(&SELECTION),
3388            "a blurred field must stop painting its selection highlight"
3389        );
3390    }
3391
3392    #[test]
3393    fn meta_a_selects_all_without_typing() {
3394        let mut state = AppState::default();
3395        let mut root = harness(&mut state);
3396        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
3397        for c in ["a", "b", "c"] {
3398            root.event(&mut state, &ch(c));
3399        }
3400        let changes_before = state.changes;
3401
3402        let meta = Modifiers {
3403            meta: true,
3404            ..Modifiers::default()
3405        };
3406        root.event(
3407            &mut state,
3408            &InputEvent::Key(KeyEvent {
3409                key: Key::Character("a".to_string()),
3410                modifiers: meta,
3411                repeat: false,
3412            }),
3413        );
3414
3415        let es = widget(&root).editor.editing_state_bytes();
3416        assert_eq!(es.base.min(es.extent), 0);
3417        assert_eq!(es.base.max(es.extent), 3, "whole buffer selected");
3418        assert_eq!(
3419            state.changes, changes_before,
3420            "select-all does not type an 'a'"
3421        );
3422        assert_eq!(widget(&root).editor.text(), "abc");
3423    }
3424
3425    #[test]
3426    fn controlled_reconcile_rejected_value_shows_app_value() {
3427        let mut state = AppState {
3428            reject: true,
3429            ..AppState::default()
3430        };
3431        let mut root = harness(&mut state);
3432        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
3433
3434        // The app rejects the edit: on_change fires but `value` stays empty while
3435        // the editor already holds "x".
3436        root.event(&mut state, &ch("x"));
3437        assert_eq!(state.changes, 1);
3438        assert_eq!(state.value, "");
3439        assert_eq!(widget(&root).editor.text(), "x");
3440
3441        // The next rebuild reconciles the editor back to the app's (empty) value.
3442        root.rebuild(&mut build, &mut state);
3443        assert_eq!(
3444            widget(&root).editor.text(),
3445            "",
3446            "a rejected edit is pulled back to the app's value on rebuild"
3447        );
3448    }
3449
3450    #[test]
3451    fn controlled_reconcile_same_value_preserves_selection() {
3452        let mut state = AppState {
3453            value: "ab".to_string(),
3454            ..AppState::default()
3455        };
3456        let mut root = harness(&mut state);
3457        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
3458
3459        let meta = Modifiers {
3460            meta: true,
3461            ..Modifiers::default()
3462        };
3463        root.event(
3464            &mut state,
3465            &InputEvent::Key(KeyEvent {
3466                key: Key::Character("a".to_string()),
3467                modifiers: meta,
3468                repeat: false,
3469            }),
3470        );
3471        let before = widget(&root).editor.editing_state_bytes();
3472        assert_ne!(before.base, before.extent);
3473
3474        // Rebuild with the unchanged value: no reconcile, selection preserved.
3475        root.rebuild(&mut build, &mut state);
3476        let after = widget(&root).editor.editing_state_bytes();
3477        assert_eq!(
3478            (before.base, before.extent),
3479            (after.base, after.extent),
3480            "an unchanged value leaves the selection intact"
3481        );
3482    }
3483
3484    #[test]
3485    fn ime_apply_editing_state_syncs_value() {
3486        let mut state = AppState::default();
3487        let mut root = harness(&mut state);
3488        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
3489
3490        // Mobile state-sync path: push a whole editing value (UTF-16 indexed).
3491        root.event(
3492            &mut state,
3493            &InputEvent::Ime(ImeEvent::ApplyEditingState(EditingState {
3494                text: "hello".to_string(),
3495                selection_base: 5,
3496                selection_extent: 5,
3497                composing_base: -1,
3498                composing_extent: -1,
3499            })),
3500        );
3501
3502        assert_eq!(widget(&root).editor.text(), "hello");
3503        assert_eq!(state.value, "hello", "on_change reflects the synced value");
3504    }
3505
3506    #[test]
3507    fn ime_empty_compose_retracts_the_preedit_without_panicking() {
3508        // Every shell spells a cancelled/abandoned composition as a `Compose`
3509        // with empty text. The editor's set-marked-text primitive asserts the
3510        // text is non-empty, so this test is the assertion: in a debug build
3511        // (which is what `cargo test` runs) the old routing panicked here.
3512        let mut state = AppState::default();
3513        let mut root = harness(&mut state);
3514        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
3515
3516        root.event(
3517            &mut state,
3518            &InputEvent::Ime(ImeEvent::Compose {
3519                text: "ni".to_string(),
3520                cursor: Some((2, 2)),
3521            }),
3522        );
3523        assert_eq!(widget(&root).editor.text(), "ni", "the preedit is live");
3524
3525        root.event(
3526            &mut state,
3527            &InputEvent::Ime(ImeEvent::Compose {
3528                text: String::new(),
3529                cursor: None,
3530            }),
3531        );
3532        assert_eq!(
3533            widget(&root).editor.text(),
3534            "",
3535            "the marked text is retracted, not replaced with an empty preedit"
3536        );
3537        assert_eq!(
3538            widget(&root).editor.editing_state_bytes().composing,
3539            None,
3540            "and no composing region is left behind"
3541        );
3542        assert_eq!(state.value, "");
3543
3544        // A retraction with nothing marked is a no-op, not a second panic.
3545        root.event(
3546            &mut state,
3547            &InputEvent::Ime(ImeEvent::Compose {
3548                text: String::new(),
3549                cursor: None,
3550            }),
3551        );
3552        assert_eq!(widget(&root).editor.text(), "");
3553    }
3554
3555    #[test]
3556    fn ime_empty_commit_retracts_the_preedit_without_panicking() {
3557        // The same defect through the commit arm, which reaches the same
3558        // primitive: a platform that reports an empty commit for a composition
3559        // that produced nothing must retract, not assert.
3560        let mut state = AppState::default();
3561        let mut root = harness(&mut state);
3562        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
3563
3564        root.event(
3565            &mut state,
3566            &InputEvent::Ime(ImeEvent::Compose {
3567                text: "ni".to_string(),
3568                cursor: None,
3569            }),
3570        );
3571        root.event(
3572            &mut state,
3573            &InputEvent::Ime(ImeEvent::Commit(String::new())),
3574        );
3575
3576        assert_eq!(widget(&root).editor.text(), "");
3577        assert_eq!(widget(&root).editor.editing_state_bytes().composing, None);
3578    }
3579
3580    #[test]
3581    fn ime_commit_inserts_text() {
3582        let mut state = AppState::default();
3583        let mut root = harness(&mut state);
3584        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
3585
3586        root.event(
3587            &mut state,
3588            &InputEvent::Ime(ImeEvent::Commit("ni".to_string())),
3589        );
3590
3591        assert_eq!(widget(&root).editor.text(), "ni");
3592        assert_eq!(state.value, "ni");
3593    }
3594
3595    #[test]
3596    fn cancel_disarms_drag_without_touching_state() {
3597        let mut state = AppState::default();
3598        let mut root = harness(&mut state);
3599        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
3600        assert!(widget(&root).captured);
3601        let changes_before = state.changes;
3602
3603        root.event(&mut state, &pointer(PointerPhase::Cancel, 10.0, 10.0));
3604
3605        assert!(!widget(&root).captured, "Cancel disarms the drag");
3606        assert_eq!(
3607            state.changes, changes_before,
3608            "Cancel must not fire on_change"
3609        );
3610    }
3611
3612    #[test]
3613    fn blink_requests_frame_only_while_focused() {
3614        // A fresh, unfocused field does not ask for continuation frames.
3615        let mut state = AppState::default();
3616        let mut root = harness(&mut state);
3617        let mut sink = NullScene;
3618        assert!(
3619            !root.paint(&mut sink, FrameTime::ZERO).needs_frame,
3620            "an unfocused field is at rest"
3621        );
3622
3623        // Once focused, paint pumps the blink and asks for the next frame — a
3624        // paced (CosmeticLoop) request, the same classification the
3625        // design-system decorative loops (skeleton/progress/dots/toast) use,
3626        // since the blink is an indefinite toggle with no endpoint the mobile
3627        // frame gate may throttle. Focused with a *completed* tap: a finger
3628        // still down is a live long-press timer, whose continuation frames are
3629        // deliberately unpaced (see `the_long_press_timer_requests_plain_frames_
3630        // while_the_blink_paces`).
3631        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
3632        root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
3633        let outcome = root.paint(&mut sink, FrameTime::ZERO);
3634        assert!(outcome.needs_frame, "a focused field blinks its caret");
3635        assert!(
3636            outcome.needs_frame_paced_only,
3637            "the caret blink is a CosmeticLoop request — the frame gate must be able to pace it"
3638        );
3639    }
3640
3641    #[test]
3642    fn reduce_motion_keeps_the_double_tap_window_pumping_its_own_clock() {
3643        // The double-tap window is dated from the paint clock, and under
3644        // reduce_motion the caret blink — normally the only thing keeping that
3645        // clock moving on an idle focused field — stops requesting frames
3646        // entirely. Without a request of its own the window would never
3647        // elapse, and a press arriving any amount of wall time later would
3648        // still measure zero against a frozen clock and resolve as a
3649        // double-tap.
3650        let mut state = AppState {
3651            value: "hello world".to_string(),
3652            ..Default::default()
3653        };
3654        let mut root = harness(&mut state);
3655        let mut theme = Theme::neutral();
3656        theme.motion.reduce_motion = true;
3657        root.set_theme(Box::new(theme));
3658
3659        // Date the completed tap from a painted frame at t=0.
3660        root.paint(&mut NullScene, ft_ms(0.0));
3661        tap(&mut root, &mut state, 20.0, 10.0);
3662        assert!(
3663            widget(&root).last_tap.is_some(),
3664            "sanity: the tap seeded a double-tap window"
3665        );
3666
3667        // Inside the window: the field asks for the frame that will advance
3668        // the clock, even though the caret is frozen and asking for nothing.
3669        let inside = root.paint(&mut NullScene, ft_ms(50.0));
3670        assert!(
3671            inside.needs_frame,
3672            "a live double-tap window must pump its own clock under reduce_motion"
3673        );
3674
3675        // Past it: the field goes back to rest rather than spinning frames.
3676        let outside = root.paint(&mut NullScene, ft_ms(DOUBLE_TAP_MS + 100.0));
3677        assert!(
3678            !outside.needs_frame,
3679            "an elapsed window stops asking — the request is bounded by the window"
3680        );
3681
3682        // And the window really has elapsed: a press this late is an ordinary
3683        // caret placement, not a second tap selecting the word.
3684        root.event(&mut state, &pointer(PointerPhase::Down, 20.0, 10.0));
3685        assert_eq!(
3686            selection(&root),
3687            None,
3688            "past DOUBLE_TAP_MS the press is just a press"
3689        );
3690    }
3691
3692    #[test]
3693    fn blink_paces_at_its_own_500ms_interval() {
3694        // The caret names its own (slower) cadence via
3695        // `request_frame_paced_at` rather than a bare `request_frame_paced`
3696        // (the theme's cosmetic-loop cap) — asserted on the paint outcome's
3697        // `paced_interval`, mirroring `frust-core`'s
3698        // `paint_surfaces_the_requested_paced_interval_on_outcome`.
3699        let mut state = AppState::default();
3700        let mut root = harness(&mut state);
3701        let mut sink = NullScene;
3702        // A completed tap, so no long-press timer is live — see
3703        // `blink_requests_frame_only_while_focused`.
3704        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
3705        root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
3706        let outcome = root.paint(&mut sink, FrameTime::ZERO);
3707        assert!(outcome.needs_frame_paced_only);
3708        assert_eq!(
3709            outcome.paced_interval,
3710            Some(Duration::from_millis(BLINK_MS as u64)),
3711            "the caret paces at its own 500ms half-period, not the theme cap"
3712        );
3713    }
3714
3715    #[test]
3716    fn reduce_motion_freezes_the_caret_visible_and_stops_requesting_frames() {
3717        // reduce_motion is the one exception among the paced loops: the caret
3718        // freezes VISIBLE (it is a position cue), not hidden, and stops
3719        // requesting blink frames entirely while frozen.
3720        let mut state = AppState::default();
3721        let mut root = harness(&mut state);
3722        let mut theme = Theme::neutral();
3723        theme.motion.reduce_motion = true;
3724        root.set_theme(Box::new(theme));
3725        let caret_color = Theme::neutral().scheme().primary;
3726
3727        // Focus seeds `blink_epoch` at the first paint (t=0). A completed tap,
3728        // so the long-press timer — which keeps requesting plain frames
3729        // precisely *because* reduce_motion must not stop a gesture from
3730        // firing — is not live here.
3731        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
3732        root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
3733        root.paint(&mut NullScene, ft_ms(0.0));
3734
3735        // Mid-cycle from that epoch — an unfrozen caret would be hidden here
3736        // (see `caret_visibility_toggles_across_the_blink_period`) — but
3737        // reduce_motion must still paint it, and request no continuation frame.
3738        let mut mid = CaretRecorder {
3739            caret_color: Some(caret_color),
3740            caret_fills: 0,
3741        };
3742        let outcome = root.paint(&mut mid, ft_ms(BLINK_MS + 10.0));
3743        assert_eq!(
3744            mid.caret_fills, 1,
3745            "reduce_motion freezes the caret visible, even mid-blink-cycle"
3746        );
3747        assert!(
3748            !outcome.needs_frame,
3749            "a frozen caret must not request a continuation frame"
3750        );
3751
3752        // Clearing the token resumes the ordinary paced blink.
3753        let mut theme = Theme::neutral();
3754        theme.motion.reduce_motion = false;
3755        root.set_theme(Box::new(theme));
3756        let resumed = root.paint(&mut NullScene, ft_ms(2.0 * BLINK_MS + 20.0));
3757        assert!(
3758            resumed.needs_frame_paced_only,
3759            "blinking resumes once reduce_motion clears"
3760        );
3761        assert_eq!(
3762            resumed.paced_interval,
3763            Some(Duration::from_millis(BLINK_MS as u64))
3764        );
3765    }
3766
3767    /// A `FrameTime` `ms` milliseconds from the origin.
3768    fn ft_ms(ms: f64) -> FrameTime {
3769        FrameTime::from_nanos((ms * 1_000_000.0) as u64)
3770    }
3771
3772    #[test]
3773    fn caret_visibility_toggles_across_the_blink_period() {
3774        let mut state = AppState::default();
3775        let root = harness(&mut state);
3776        let w = widget(&root);
3777        // Deterministic phase math off the reset epoch (blink_epoch == ZERO).
3778        assert!(
3779            w.caret_visible_at(ft_ms(0.0)),
3780            "visible at the start of the cycle"
3781        );
3782        assert!(w.caret_visible_at(ft_ms(BLINK_MS - 1.0)));
3783        assert!(
3784            !w.caret_visible_at(ft_ms(BLINK_MS + 1.0)),
3785            "hidden mid-cycle"
3786        );
3787        assert!(
3788            w.caret_visible_at(ft_ms(2.0 * BLINK_MS + 1.0)),
3789            "visible again"
3790        );
3791    }
3792
3793    /// A scene that counts caret fills — the caret is the only bare `fill_rect`
3794    /// emitted with the caret color once a non-empty selection isn't present.
3795    #[derive(Default)]
3796    struct CaretRecorder {
3797        caret_color: Option<Color>,
3798        caret_fills: usize,
3799    }
3800
3801    impl PaintScene for CaretRecorder {
3802        fn fill_rect(&mut self, _o: Point, _s: Size, color: Color) {
3803            if Some(color) == self.caret_color {
3804                self.caret_fills += 1;
3805            }
3806        }
3807        fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, _c: Color) {}
3808        fn draw_text(&mut self, _o: Point, _t: &str) {}
3809    }
3810
3811    #[test]
3812    fn caret_blink_phase_advances_from_paint_frame_time() {
3813        // Two-frame blink test: the caret is painted in the visible half
3814        // of the cycle and absent in the hidden half, with the phase measured
3815        // purely from the injected `RenderRoot::paint` frame time — proving the
3816        // blink advances off the shell clock, not a hidden wall clock.
3817        let mut state = AppState::default();
3818        let mut root = harness(&mut state);
3819        // Focus so the caret is painted; the focus Down flags a blink reset.
3820        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
3821
3822        // Frame 1 at t=0: seeds blink_epoch=0 and paints the caret (visible half).
3823        let mut f1 = CaretRecorder {
3824            caret_color: Some(CARET),
3825            caret_fills: 0,
3826        };
3827        root.paint(&mut f1, ft_ms(0.0));
3828        assert_eq!(f1.caret_fills, 1, "caret visible at the start of the cycle");
3829
3830        // Frame 2 mid-cycle (t = BLINK_MS + a bit): the caret is hidden.
3831        let mut f2 = CaretRecorder {
3832            caret_color: Some(CARET),
3833            caret_fills: 0,
3834        };
3835        root.paint(&mut f2, ft_ms(BLINK_MS + 10.0));
3836        assert_eq!(
3837            f2.caret_fills, 0,
3838            "caret hidden mid-cycle (phase from paint time)"
3839        );
3840
3841        // Frame 3 in the next visible half proves the phase keeps advancing.
3842        let mut f3 = CaretRecorder {
3843            caret_color: Some(CARET),
3844            caret_fills: 0,
3845        };
3846        root.paint(&mut f3, ft_ms(2.0 * BLINK_MS + 10.0));
3847        assert_eq!(f3.caret_fills, 1, "caret visible again in the next cycle");
3848    }
3849
3850    // --- Themed chrome ---
3851
3852    /// Records rounded-rect (chrome) and rect (selection/caret) fill colors.
3853    #[derive(Default)]
3854    struct ChromeRecorder {
3855        rrects: Vec<Color>,
3856        rects: Vec<Color>,
3857    }
3858
3859    impl PaintScene for ChromeRecorder {
3860        fn fill_rect(&mut self, _o: Point, _s: Size, color: Color) {
3861            self.rects.push(color);
3862        }
3863        fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, color: Color) {
3864            self.rrects.push(color);
3865        }
3866        fn draw_text(&mut self, _o: Point, _t: &str) {}
3867        fn draw_glyph_run(&mut self, _run: frust_scene::GlyphRun) {}
3868    }
3869
3870    /// Paint a focused field (so the accent border + caret show) at frame time 0.
3871    fn paint_chrome(root: &mut RenderRoot<AppState, TextInputView<AppState>>) -> ChromeRecorder {
3872        let mut rec = ChromeRecorder::default();
3873        root.paint(&mut rec, FrameTime::ZERO);
3874        rec
3875    }
3876
3877    #[test]
3878    fn unthemed_chrome_uses_fallback_constants() {
3879        let mut state = AppState::default();
3880        let mut root = harness(&mut state);
3881        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
3882        let rec = paint_chrome(&mut root);
3883        // Border (focused → accent) then background.
3884        assert_eq!(rec.rrects, vec![ACCENT, BG]);
3885        // The only bare rect on an empty focused field is the caret.
3886        assert_eq!(rec.rects, vec![CARET]);
3887    }
3888
3889    #[test]
3890    fn themed_chrome_resolves_roles() {
3891        let mut state = AppState::default();
3892        let mut root = harness(&mut state);
3893        root.set_theme(Box::new(Theme::neutral()));
3894        let theme = Theme::neutral();
3895        let scheme = theme.scheme();
3896        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
3897        let rec = paint_chrome(&mut root);
3898        assert_eq!(
3899            rec.rrects,
3900            vec![scheme.primary, scheme.surface],
3901            "focused border is primary, background is surface"
3902        );
3903        assert_eq!(rec.rects, vec![scheme.primary], "caret is primary");
3904    }
3905
3906    // --- Chrome geometry seam: padding / border_width / corner_radius /
3907    // caret_width / focus_ring_width ---
3908
3909    /// Records rounded-rect (chrome) and rect (selection/caret) fill
3910    /// *geometry* — origin/size/radius/color — so the seam's effect on the
3911    /// actual painted chrome can be asserted, not just that the builder
3912    /// accepted a value.
3913    #[derive(Default)]
3914    struct ChromeGeometryRecorder {
3915        rrects: Vec<(Point, Size, f64, Color)>,
3916        rects: Vec<(Point, Size, Color)>,
3917    }
3918
3919    impl PaintScene for ChromeGeometryRecorder {
3920        fn fill_rect(&mut self, origin: Point, size: Size, color: Color) {
3921            self.rects.push((origin, size, color));
3922        }
3923        fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, color: Color) {
3924            self.rrects.push((origin, size, radius, color));
3925        }
3926        fn draw_text(&mut self, _o: Point, _t: &str) {}
3927        fn draw_glyph_run(&mut self, _run: frust_scene::GlyphRun) {}
3928    }
3929
3930    #[test]
3931    fn default_chrome_geometry_matches_the_unthemed_constants() {
3932        // Pins the resolved default geometry: a field that calls none of the
3933        // geometry setters must render byte-for-byte at the constants.
3934        let mut state = AppState::default();
3935        let mut root = harness(&mut state);
3936        let field_size = root.layout(Size::new(300.0, 200.0));
3937        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
3938
3939        let mut rec = ChromeGeometryRecorder::default();
3940        root.paint(&mut rec, FrameTime::ZERO);
3941
3942        let (outer_origin, outer_size, outer_radius, _) = rec.rrects[0];
3943        let (inner_origin, inner_size, inner_radius, _) = rec.rrects[1];
3944        assert_eq!(outer_size, field_size, "outer chrome rect covers the field");
3945        assert_eq!(outer_radius, RADIUS, "default corner radius is RADIUS");
3946        assert_eq!(
3947            inner_origin.x - outer_origin.x,
3948            BORDER_W,
3949            "default border width is BORDER_W"
3950        );
3951        assert_eq!(inner_origin.y - outer_origin.y, BORDER_W);
3952        assert_eq!(
3953            inner_size,
3954            Size::new(
3955                outer_size.width - 2.0 * BORDER_W,
3956                outer_size.height - 2.0 * BORDER_W
3957            )
3958        );
3959        assert_eq!(inner_radius, RADIUS - BORDER_W);
3960
3961        let (_, caret_size, _) = *rec.rects.last().expect("caret painted while focused");
3962        assert!(
3963            (caret_size.width - CARET_W as f64).abs() < 1e-6,
3964            "default caret width is CARET_W"
3965        );
3966    }
3967
3968    #[test]
3969    fn custom_padding_changes_the_resolved_height_and_published_caret_offset() {
3970        let mut default_state = AppState::default();
3971        let mut default_root = harness(&mut default_state);
3972        let default_size = default_root.layout(Size::new(300.0, 200.0));
3973        default_root.event(&mut default_state, &pointer(PointerPhase::Down, 10.0, 10.0));
3974        let default_caret_x = default_root
3975            .ime_state()
3976            .expect("focused")
3977            .caret
3978            .expect("caret rect")
3979            .x0;
3980
3981        let mut state = AppState::default();
3982        let mut logic = |s: &mut AppState| {
3983            text_input(s.value.clone(), |s: &mut AppState, v: String| s.value = v)
3984                .padding(30.0, 40.0)
3985        };
3986        let mut root = RenderRoot::new();
3987        root.rebuild(&mut logic, &mut state);
3988        let custom_size = root.layout(Size::new(300.0, 200.0));
3989        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
3990        let custom_caret_x = root
3991            .ime_state()
3992            .expect("focused")
3993            .caret
3994            .expect("caret rect")
3995            .x0;
3996
3997        assert_eq!(
3998            custom_size.height - default_size.height,
3999            2.0 * (40.0 - PAD_Y),
4000            "vertical padding is reflected in the resolved field height, not just accepted"
4001        );
4002        assert!(
4003            (custom_caret_x - default_caret_x - (30.0 - PAD_X)).abs() < 1e-6,
4004            "horizontal padding shifts the published caret rect"
4005        );
4006    }
4007
4008    #[test]
4009    fn custom_border_width_and_corner_radius_resize_the_painted_chrome() {
4010        let mut state = AppState::default();
4011        let mut logic = |s: &mut AppState| {
4012            text_input(s.value.clone(), |s: &mut AppState, v: String| s.value = v)
4013                .border_width(4.0)
4014                .corner_radius(2.0)
4015        };
4016        let mut root = RenderRoot::new();
4017        root.rebuild(&mut logic, &mut state);
4018        root.layout(Size::new(300.0, 200.0));
4019
4020        let mut rec = ChromeGeometryRecorder::default();
4021        root.paint(&mut rec, FrameTime::ZERO);
4022
4023        let (outer_origin, outer_size, outer_radius, _) = rec.rrects[0];
4024        let (inner_origin, inner_size, inner_radius, _) = rec.rrects[1];
4025        assert_eq!(
4026            outer_radius, 2.0,
4027            "corner_radius reaches the painted outer rect"
4028        );
4029        assert_eq!(
4030            inner_radius, 0.0,
4031            "inner radius clamps at 0 once border_width exceeds corner_radius"
4032        );
4033        assert_eq!(
4034            inner_origin.x - outer_origin.x,
4035            4.0,
4036            "border_width insets the fill"
4037        );
4038        assert_eq!(inner_origin.y - outer_origin.y, 4.0);
4039        assert_eq!(
4040            inner_size,
4041            Size::new(outer_size.width - 8.0, outer_size.height - 8.0)
4042        );
4043    }
4044
4045    #[test]
4046    fn custom_caret_width_changes_the_painted_caret_rect() {
4047        let mut state = AppState::default();
4048        let mut logic = |s: &mut AppState| {
4049            text_input(s.value.clone(), |s: &mut AppState, v: String| s.value = v).caret_width(6.0)
4050        };
4051        let mut root = RenderRoot::new();
4052        root.rebuild(&mut logic, &mut state);
4053        root.layout(Size::new(300.0, 200.0));
4054        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
4055
4056        let mut rec = ChromeGeometryRecorder::default();
4057        root.paint(&mut rec, FrameTime::ZERO);
4058        let (_, caret_size, _) = *rec.rects.last().expect("caret painted");
4059        assert!(
4060            (caret_size.width - 6.0).abs() < 1e-6,
4061            "caret_width is reflected in the painted caret rect, not just accepted by the builder"
4062        );
4063    }
4064
4065    #[test]
4066    fn custom_focus_ring_width_only_widens_the_border_while_focused() {
4067        // The focus treatment specifically: unfocused, the idle border_width
4068        // is unaffected; focused, focus_ring_width takes over.
4069        let mut state = AppState::default();
4070        let mut logic = |s: &mut AppState| {
4071            text_input(s.value.clone(), |s: &mut AppState, v: String| s.value = v)
4072                .focus_ring_width(5.0)
4073        };
4074        let mut root = RenderRoot::new();
4075        root.rebuild(&mut logic, &mut state);
4076        root.layout(Size::new(300.0, 200.0));
4077
4078        let mut idle = ChromeGeometryRecorder::default();
4079        root.paint(&mut idle, FrameTime::ZERO);
4080        let (idle_outer, _, _, _) = idle.rrects[0];
4081        let (idle_inner, _, _, _) = idle.rrects[1];
4082        assert_eq!(
4083            idle_inner.x - idle_outer.x,
4084            BORDER_W,
4085            "idle border stays the default width when unfocused"
4086        );
4087
4088        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
4089        let mut focused = ChromeGeometryRecorder::default();
4090        root.paint(&mut focused, FrameTime::ZERO);
4091        let (focused_outer, _, _, _) = focused.rrects[0];
4092        let (focused_inner, _, _, _) = focused.rrects[1];
4093        assert_eq!(
4094            focused_inner.x - focused_outer.x,
4095            5.0,
4096            "the focused appearance responds to focus_ring_width"
4097        );
4098    }
4099
4100    #[test]
4101    fn geometry_field_changes_request_the_right_change_flags() {
4102        let mut state = AppState::default();
4103        let mut root = harness(&mut state);
4104
4105        // Padding changes the resolved `Size`, so it must relayout.
4106        let mut padded = |s: &mut AppState| {
4107            text_input(s.value.clone(), |s: &mut AppState, v: String| s.value = v)
4108                .padding(20.0, 20.0)
4109        };
4110        let flags = root.rebuild(&mut padded, &mut state);
4111        assert!(flags.needs_layout(), "a padding change must relayout");
4112
4113        // Border width alone is paint-only geometry — it never resizes the
4114        // field, so it must not force a relayout on top of an unrelated
4115        // padding change that already did.
4116        let mut bordered = |s: &mut AppState| {
4117            text_input(s.value.clone(), |s: &mut AppState, v: String| s.value = v)
4118                .padding(20.0, 20.0)
4119                .border_width(4.0)
4120        };
4121        let flags = root.rebuild(&mut bordered, &mut state);
4122        assert!(flags.needs_paint());
4123        assert!(
4124            !flags.needs_layout(),
4125            "border_width alone does not resize the field"
4126        );
4127    }
4128
4129    #[test]
4130    fn paint_refreshes_ime_state_after_a_controlled_clear() {
4131        // The mobile IME mirror relies on `ime_state()` tracking the field even
4132        // when an app-driven controlled change (a submit clearing the draft) is
4133        // applied by a *rebuild* rather than an event. The event pass alone
4134        // leaves the published state stale (it only refreshes on edits); the
4135        // focused widget republishes during paint, which runs after every
4136        // rebuild. This is the regression guard for that refresh.
4137        let mut state = AppState::default();
4138        let mut root = harness(&mut state);
4139        let mut sink = NullScene;
4140
4141        // Focus and type "hi" through the event pass — ime_state now reads "hi".
4142        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
4143        root.event(&mut state, &ch("h"));
4144        root.event(&mut state, &ch("i"));
4145        assert_eq!(root.ime_state().expect("focused").editing.text, "hi");
4146
4147        // Simulate an app-driven controlled clear (what `on_submit` → clear draft
4148        // does): set the controlled value to "" and run a frame with NO event.
4149        state.value.clear();
4150        root.rebuild(&mut build, &mut state);
4151        root.layout(Size::new(300.0, 200.0));
4152        root.paint(&mut sink, FrameTime::ZERO);
4153
4154        let ime = root
4155            .ime_state()
4156            .expect("still focused, so still publishing");
4157        assert_eq!(
4158            ime.editing.text, "",
4159            "paint refreshes the shell-facing IME state to the controlled-cleared value \
4160             (without this, the mobile IME mirror re-pushes the stale text)"
4161        );
4162    }
4163
4164    // --- Themed text color ---
4165
4166    /// Records each glyph run's solid brush color (mirrors `text.rs`'s
4167    /// `GlyphRecorder`).
4168    #[derive(Default)]
4169    struct TextGlyphRecorder {
4170        colors: Vec<Color>,
4171    }
4172
4173    impl PaintScene for TextGlyphRecorder {
4174        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
4175        fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, _c: Color) {}
4176        fn draw_text(&mut self, _o: Point, _t: &str) {}
4177        fn draw_glyph_run(&mut self, run: frust_scene::GlyphRun) {
4178            if let peniko::Brush::Solid(color) = run.brush {
4179                self.colors.push(color);
4180            }
4181        }
4182    }
4183
4184    /// Paint `root` and return the first content glyph run's brush color.
4185    fn painted_text_color(root: &mut RenderRoot<AppState, TextInputView<AppState>>) -> Color {
4186        let mut rec = TextGlyphRecorder::default();
4187        root.paint(&mut rec, FrameTime::ZERO);
4188        *rec.colors.first().expect("one glyph run painted")
4189    }
4190
4191    // --- Placeholder family resolution ---
4192
4193    /// Records font bytes from each glyph run, enabling font-resolution testing.
4194    #[derive(Default)]
4195    struct PlaceholderFontRecorder {
4196        font_bytes: Vec<Vec<u8>>,
4197    }
4198
4199    impl PaintScene for PlaceholderFontRecorder {
4200        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
4201        fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, _c: Color) {}
4202        fn draw_text(&mut self, _o: Point, _t: &str) {}
4203        fn draw_glyph_run(&mut self, run: frust_scene::GlyphRun) {
4204            // Capture the font bytes from this glyph run.
4205            self.font_bytes.push(run.font.font().data.as_ref().to_vec());
4206        }
4207    }
4208
4209    /// Paint `root` and return the font bytes of the first glyph run's font.
4210    fn painted_placeholder_font_bytes(
4211        root: &mut RenderRoot<AppState, TextInputView<AppState>>,
4212    ) -> Vec<u8> {
4213        let mut rec = PlaceholderFontRecorder::default();
4214        root.paint(&mut rec, FrameTime::ZERO);
4215        rec.font_bytes
4216            .first()
4217            .expect("one glyph run painted")
4218            .clone()
4219    }
4220
4221    #[test]
4222    fn unthemed_text_input_keeps_black_default() {
4223        // Parity: with no theme threaded in, the glyph color stays exactly the
4224        // TextStyle default (black) — unchanged from before this retrofit.
4225        let mut state = AppState {
4226            value: "hi".to_string(),
4227            ..AppState::default()
4228        };
4229        let mut root = harness(&mut state);
4230        assert_eq!(painted_text_color(&mut root), Color::BLACK);
4231    }
4232
4233    #[test]
4234    fn themed_dark_text_input_resolves_on_surface() {
4235        let mut state = AppState {
4236            value: "hi".to_string(),
4237            ..AppState::default()
4238        };
4239        let mut root = harness(&mut state);
4240
4241        let mut theme = Theme::neutral();
4242        theme.brightness = frust_theme::Brightness::Dark;
4243        let expected = theme.scheme().on_surface;
4244        assert_ne!(
4245            expected,
4246            Color::BLACK,
4247            "fixture sanity: dark on_surface must differ from the black fallback"
4248        );
4249        root.set_theme(Box::new(theme));
4250        root.layout(Size::new(300.0, 200.0));
4251
4252        assert_eq!(
4253            painted_text_color(&mut root),
4254            expected,
4255            "a themed field's glyphs resolve to on_surface(dark), not black"
4256        );
4257    }
4258
4259    #[test]
4260    fn explicit_text_style_wins_over_theme() {
4261        let custom = Color::from_rgb8(10, 20, 30);
4262        fn logic(state: &mut AppState) -> TextInputView<AppState> {
4263            text_input(state.value.clone(), |s: &mut AppState, v: String| {
4264                s.value = v;
4265            })
4266            .text_style(TextStyle::new(16.0, Color::from_rgb8(10, 20, 30)))
4267        }
4268
4269        let mut state = AppState {
4270            value: "hi".to_string(),
4271            ..AppState::default()
4272        };
4273        let mut root: RenderRoot<AppState, TextInputView<AppState>> = RenderRoot::new();
4274        root.rebuild(&mut logic, &mut state);
4275        root.set_theme(Box::new(Theme::neutral()));
4276        root.layout(Size::new(300.0, 200.0));
4277
4278        assert_eq!(
4279            painted_text_color(&mut root),
4280            custom,
4281            "an explicit .text_style() color wins over the themed default"
4282        );
4283    }
4284
4285    #[test]
4286    fn rebuild_with_new_text_style_relayouts_with_new_metrics() {
4287        // rebuild() must reconcile a changed text_style: a larger font size
4288        // rebuilds the underlying TextEditor (via `apply_style`) and grows the
4289        // field's measured height on the next layout.
4290        fn logic_a(state: &mut AppState) -> TextInputView<AppState> {
4291            text_input(state.value.clone(), |s: &mut AppState, v: String| {
4292                s.value = v;
4293            })
4294            .text_style(TextStyle::new(16.0, Color::BLACK))
4295        }
4296        fn logic_b(state: &mut AppState) -> TextInputView<AppState> {
4297            text_input(state.value.clone(), |s: &mut AppState, v: String| {
4298                s.value = v;
4299            })
4300            .text_style(TextStyle::new(40.0, Color::BLACK))
4301        }
4302
4303        let mut state = AppState::default();
4304        let mut root: RenderRoot<AppState, TextInputView<AppState>> = RenderRoot::new();
4305        root.rebuild(&mut logic_a, &mut state);
4306        let size_a = root.layout(Size::new(300.0, 200.0));
4307
4308        root.rebuild(&mut logic_b, &mut state);
4309        let size_b = root.layout(Size::new(300.0, 200.0));
4310
4311        assert!(
4312            size_b.height > size_a.height,
4313            "a rebuild with a larger text_style size grows the field height \
4314             ({size_a:?} -> {size_b:?})"
4315        );
4316    }
4317
4318    #[test]
4319    fn theme_swap_with_no_view_change_repaints_new_glyph_color() {
4320        // Mirrors `text.rs`'s regression of the same name: a bare `set_theme`
4321        // with no view change must still re-resolve the baked color at the
4322        // next layout, per the `set_theme` -> `ChangeFlags::LAYOUT` contract.
4323        let mut state = AppState {
4324            value: "hi".to_string(),
4325            ..AppState::default()
4326        };
4327        let mut root = harness(&mut state);
4328
4329        let mut theme_a = Theme::neutral();
4330        theme_a.brightness = frust_theme::Brightness::Light;
4331        let color_a = theme_a.scheme().on_surface;
4332        root.set_theme(Box::new(theme_a));
4333        root.layout(Size::new(300.0, 200.0));
4334        assert_eq!(painted_text_color(&mut root), color_a);
4335
4336        let mut theme_b = Theme::neutral();
4337        theme_b.brightness = frust_theme::Brightness::Dark;
4338        let color_b = theme_b.scheme().on_surface;
4339        assert_ne!(
4340            color_a, color_b,
4341            "fixture sanity: themes must actually differ"
4342        );
4343        root.set_theme(Box::new(theme_b));
4344        root.layout(Size::new(300.0, 200.0));
4345
4346        assert_eq!(
4347            painted_text_color(&mut root),
4348            color_b,
4349            "a bare theme swap (no view change) must re-resolve the themed glyph \
4350             color at the next layout"
4351        );
4352    }
4353
4354    // --- Nested-blur regression ---
4355    //
4356    // A `TextInput` nested in a `Column` beside a `Button`. Focusing the field
4357    // then tapping the sibling is a *container-routed* blur: `route_event`
4358    // clears the field pod's recorded focus path, but the widget's `event()` is
4359    // never called on that dispatch. If the widget-internal `focused` flag
4360    // stayed set, the next `paint` would republish the (already-cleared) IME
4361    // surface and pump a caret-blink continuation frame — resurrecting the
4362    // dismissed keyboard and keeping the desktop wait-loop spinning forever.
4363    // Threading the pod's focus into paint (`PaintCtx::has_focus`) is what makes
4364    // the widget observe the blur and converge.
4365
4366    #[derive(Default)]
4367    struct NestedState {
4368        value: String,
4369    }
4370
4371    fn nested_logic(state: &mut NestedState) -> crate::FlexView<NestedState> {
4372        use frust_core::any;
4373        crate::Column(vec![
4374            any(text_input(
4375                state.value.clone(),
4376                |s: &mut NestedState, v: String| {
4377                    s.value = v;
4378                },
4379            )),
4380            any(crate::button("ok", |_s: &mut NestedState| {})),
4381        ])
4382    }
4383
4384    #[test]
4385    fn nested_blur_clears_ime_and_idles_paint() {
4386        let mut state = NestedState::default();
4387        let mut root: RenderRoot<NestedState, crate::FlexView<NestedState>> = RenderRoot::new();
4388        root.rebuild(&mut nested_logic, &mut state);
4389        let mut tcx = frust_text::TextContext::new();
4390        root.layout_with_text(Size::new(300.0, 200.0), &mut tcx as &mut dyn Any);
4391
4392        // Focus the field with a full tap (Down+Up) inside its bounds, at the
4393        // top of the column. The Up releases the field's pointer capture, so the
4394        // next Down is hit-tested afresh instead of routing back to the field.
4395        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
4396        root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
4397        assert!(root.is_focus_active(), "tap inside the field focuses it");
4398        assert!(
4399            root.ime_state().is_some(),
4400            "focusing the nested field publishes an IME surface"
4401        );
4402
4403        // Tap the sibling button, below the field (a container-routed blur): the
4404        // field's pod focus is cleared by `route_event`, but its own `event()` is
4405        // never called on this dispatch. The `Up` completes the button's own
4406        // tap cycle (`Button` has a press-scale
4407        // animation that lazily launches at the next `paint` — completing the
4408        // gesture here, with no paint in between, cancels the retarget before
4409        // it ever launches, so this stays a pure blur probe rather than also
4410        // asserting anything about the button's own animation).
4411        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 45.0));
4412        root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 45.0));
4413
4414        // Leg 1 — the blur event clears the shell-facing focus + IME surface.
4415        assert!(!root.is_focus_active(), "the sibling tap blurs the field");
4416        assert!(
4417            root.ime_state().is_none(),
4418            "container-routed blur clears the IME surface"
4419        );
4420
4421        // Legs 2 & 3 — a subsequent paint must NOT resurrect the cleared IME
4422        // surface, and must report the tree at rest (no stale caret-blink frame).
4423        let mut sink = NullScene;
4424        let outcome = root.paint(&mut sink, FrameTime::ZERO);
4425        assert!(
4426            root.ime_state().is_none(),
4427            "paint must not republish the cleared IME surface (F1 resurrection)"
4428        );
4429        assert!(
4430            !outcome.needs_frame,
4431            "a blurred field paints at rest — the desktop wait-loop idles"
4432        );
4433    }
4434
4435    /// Two-level nesting: the field sits inside an INNER Column, whose pod is a
4436    /// child of the outer Column beside the button. A blur tap on the button
4437    /// clears the focus link at the outer level only (the inner Column's pod) —
4438    /// the field's own pod flag deep in the blurred subtree legitimately stays
4439    /// stale, which is exactly the case `paint_child`'s ancestor-composed
4440    /// `has_focus` seeding must cover.
4441    fn deep_nested_logic(state: &mut NestedState) -> crate::FlexView<NestedState> {
4442        use frust_core::any;
4443        crate::Column(vec![
4444            any(crate::Column(vec![any(text_input(
4445                state.value.clone(),
4446                |s: &mut NestedState, v: String| {
4447                    s.value = v;
4448                },
4449            ))])),
4450            any(crate::button("ok", |_s: &mut NestedState| {})),
4451        ])
4452    }
4453
4454    #[test]
4455    fn deep_nested_blur_idles_paint_despite_stale_inner_focus_flag() {
4456        let mut state = NestedState::default();
4457        let mut root: RenderRoot<NestedState, crate::FlexView<NestedState>> = RenderRoot::new();
4458        root.rebuild(&mut deep_nested_logic, &mut state);
4459        let mut tcx = frust_text::TextContext::new();
4460        root.layout_with_text(Size::new(300.0, 200.0), &mut tcx as &mut dyn Any);
4461
4462        // Focus the field (full Down+Up tap — the Up releases capture so the
4463        // blur Down hit-tests afresh; see nested_blur_clears_ime_and_idles_paint).
4464        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
4465        root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
4466        assert!(root.is_focus_active());
4467        assert!(root.ime_state().is_some());
4468
4469        // Blur via the OUTER-level sibling: route_event clears the focused link
4470        // at the outer Column (the inner Column's pod); the field's own pod flag
4471        // two levels down stays stale. The `Up` completes the button's own tap
4472        // cycle (see `nested_blur_clears_ime_and_idles_paint`'s identical note)
4473        // so this stays a pure blur probe.
4474        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 45.0));
4475        root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 45.0));
4476        assert!(!root.is_focus_active());
4477        assert!(root.ime_state().is_none());
4478
4479        // Paint: the cleared outer link must force has_focus == false for the
4480        // whole subtree (ancestor-composed seeding), so the stale deep flag
4481        // cannot re-arm the caret blink or republish the IME surface.
4482        let mut sink = NullScene;
4483        let outcome = root.paint(&mut sink, FrameTime::ZERO);
4484        assert!(
4485            root.ime_state().is_none(),
4486            "deep-nested stale focus flag must not resurrect the IME surface"
4487        );
4488        assert!(
4489            !outcome.needs_frame,
4490            "deep-nested stale focus flag must not busy-loop the desktop shell"
4491        );
4492    }
4493
4494    // --- Cross-branch unmount: a stale flag must not kill a live session ------
4495    //
4496    // The shipped shape is a scrollable list above a persistent composer. The
4497    // user taps a row's field, then taps the composer: `route_event`'s
4498    // blur-on-outside-tap breaks the focus link at the NEAREST COMMON ANCESTOR
4499    // (the outer Column's pod for the list branch), so the row field's own pod
4500    // flag, one level deeper, legitimately stays set. The list then recycles /
4501    // filters / shrinks and the generic reconciler tears that row down.
4502    //
4503    // The row owns no session — the composer does. A release here is a
4504    // user-visible input regression: the keyboard drops mid-typing in a field
4505    // nothing touched. The effective-focus chain threaded through `BuildCtx`
4506    // (`has_focus`/`with_focus_link`) is what tells the two apart, and both
4507    // directions are pinned below over the same fixture.
4508
4509    #[derive(Default)]
4510    struct BranchState {
4511        row: String,
4512        composer: String,
4513        show_row: bool,
4514        show_composer: bool,
4515    }
4516
4517    /// Outer Column: [ inner Column (the "list", 0-or-1 row field), composer
4518    /// field ]. Each field's initial text names its branch, so `ime_state`
4519    /// identifies which one owns the session.
4520    fn branches_logic(state: &mut BranchState) -> crate::FlexView<BranchState> {
4521        use frust_core::{AnyView, any};
4522        let mut rows: Vec<AnyView<BranchState>> = Vec::new();
4523        if state.show_row {
4524            rows.push(any(text_input(
4525                state.row.clone(),
4526                |s: &mut BranchState, v: String| s.row = v,
4527            )));
4528        }
4529        let mut outer: Vec<AnyView<BranchState>> = vec![any(crate::Column(rows))];
4530        if state.show_composer {
4531            outer.push(any(text_input(
4532                state.composer.clone(),
4533                |s: &mut BranchState, v: String| s.composer = v,
4534            )));
4535        }
4536        crate::Column(outer)
4537    }
4538
4539    /// Build the two-branch tree with both fields present, focus the ROW field,
4540    /// then focus the COMPOSER — leaving the row's own pod flag stale below the
4541    /// cleared outer link. Returns the root, its text context and the state.
4542    fn focus_row_then_composer() -> (
4543        RenderRoot<BranchState, crate::FlexView<BranchState>>,
4544        frust_text::TextContext,
4545        BranchState,
4546    ) {
4547        let mut state = BranchState {
4548            row: "row".to_string(),
4549            composer: "composer".to_string(),
4550            show_row: true,
4551            show_composer: true,
4552        };
4553        let mut root: RenderRoot<BranchState, crate::FlexView<BranchState>> = RenderRoot::new();
4554        root.rebuild(&mut branches_logic, &mut state);
4555        let mut tcx = frust_text::TextContext::new();
4556        root.layout_with_text(Size::new(300.0, 200.0), &mut tcx as &mut dyn Any);
4557
4558        // Tap the row field (full Down+Up so the Up releases its capture and the
4559        // next Down hit-tests afresh — see nested_blur_clears_ime_and_idles_paint).
4560        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
4561        root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
4562        assert_eq!(
4563            root.ime_state().map(|s| s.editing.text),
4564            Some("row".to_string()),
4565            "the row field owns the session first"
4566        );
4567
4568        // Tap the composer: focus moves branches. The blur clears the link at the
4569        // outer Column only; the row field's flag inside the inner Column stays.
4570        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 50.0));
4571        root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 50.0));
4572        assert!(root.is_focus_active());
4573        assert_eq!(
4574            root.ime_state().map(|s| s.editing.text),
4575            Some("composer".to_string()),
4576            "the composer now owns the session"
4577        );
4578        (root, tcx, state)
4579    }
4580
4581    #[test]
4582    fn tearing_down_a_stale_focused_branch_leaves_the_live_session_alone() {
4583        let (mut root, _tcx, mut state) = focus_row_then_composer();
4584        let ime_before = root.ime_state();
4585        let gen_before = root.focus_ime_generation();
4586
4587        // The list shrinks: the row field — whose stale flag is still set — is
4588        // torn down by the generic reconciler.
4589        state.show_row = false;
4590        root.rebuild(&mut branches_logic, &mut state);
4591
4592        assert!(
4593            root.is_focus_active(),
4594            "unmounting a stale-flagged row must not blur the composer the user is typing in"
4595        );
4596        assert_eq!(
4597            root.ime_state(),
4598            ime_before,
4599            "the live IME surface is untouched by an unrelated branch's unmount"
4600        );
4601        assert_eq!(
4602            root.focus_ime_generation(),
4603            gen_before,
4604            "zero spurious edges: the shell's frame gate must see no focus/IME change at all"
4605        );
4606    }
4607
4608    #[test]
4609    fn tearing_down_the_live_focused_branch_still_releases_the_session() {
4610        // The twin of the test above over the same fixture: when the pod that
4611        // actually holds the session dies, the release must still happen — the
4612        // narrowed gate must not have turned into "never mark".
4613        let (mut root, _tcx, mut state) = focus_row_then_composer();
4614        let gen_before = root.focus_ime_generation();
4615
4616        state.show_composer = false;
4617        root.rebuild(&mut branches_logic, &mut state);
4618
4619        assert!(
4620            !root.is_focus_active(),
4621            "the focused composer's unmount releases the session"
4622        );
4623        assert_eq!(
4624            root.ime_state(),
4625            None,
4626            "the shell-facing surface dies with the widget that published it"
4627        );
4628        assert_eq!(
4629            root.focus_ime_generation(),
4630            gen_before.wrapping_add(1),
4631            "one orphaned live focus path is exactly one edge"
4632        );
4633    }
4634
4635    // --- Wrapper type swap: the severing path `rebuild_child` owns -----------
4636    //
4637    // `Padding(if editing { text_input } else { text })` is the single-child
4638    // wrapper shape: the view swaps the padded child's concrete type, so the
4639    // focused widget is torn down inside `AnyView::rebuild` and a fresh,
4640    // non-focusable one takes its pod. Nothing about that reaches the root by
4641    // itself — no event, no publish — so without the orphan mark the keyboard
4642    // stays up over an idle screen, `is_focus_active()` keeps reporting a
4643    // session, and `Key`/`Ime` events keep routing into a widget that ignores
4644    // them. Both directions are pinned below over one fixture, exactly like the
4645    // cross-branch unmount pair above.
4646
4647    #[derive(Default)]
4648    struct WrapState {
4649        row: String,
4650        composer: String,
4651        row_editing: bool,
4652    }
4653
4654    /// Outer Column: [ Padding-wrapped slot, composer field ]. The slot holds a
4655    /// `text_input` while `row_editing` and a plain `text` label otherwise, so
4656    /// flipping the flag is an `AnyView` type swap inside the wrapper — the
4657    /// `rebuild_child` path, not the multi-child reconciler's.
4658    fn wrapped_slot_logic(state: &mut WrapState) -> crate::FlexView<WrapState> {
4659        use frust_core::{AnyView, any};
4660        // The branch is taken *inside* the wrapper, so the wrapper's own view
4661        // type is stable across the flip and only its child's concrete type
4662        // changes — the `rebuild_child` swap. (Branching outside and handing
4663        // `Padding` a pre-erased `AnyView` would double-erase the child and
4664        // hide the swap from the wrapper entirely.)
4665        let slot: AnyView<WrapState> = if state.row_editing {
4666            any(crate::Padding(
4667                crate::EdgeInsets::all(0.0),
4668                text_input(state.row.clone(), |s: &mut WrapState, v: String| s.row = v),
4669            ))
4670        } else {
4671            any(crate::Padding(
4672                crate::EdgeInsets::all(0.0),
4673                crate::text(state.row.clone()),
4674            ))
4675        };
4676        crate::Column(vec![
4677            slot,
4678            any(text_input(
4679                state.composer.clone(),
4680                |s: &mut WrapState, v: String| s.composer = v,
4681            )),
4682        ])
4683    }
4684
4685    /// Build the fixture and focus the **wrapped** field, leaving it (and the
4686    /// wrapper pod above it) on the live focus chain.
4687    fn focus_the_wrapped_field() -> (
4688        RenderRoot<WrapState, crate::FlexView<WrapState>>,
4689        frust_text::TextContext,
4690        WrapState,
4691    ) {
4692        let mut state = WrapState {
4693            row: "row".to_string(),
4694            composer: "composer".to_string(),
4695            row_editing: true,
4696        };
4697        let mut root: RenderRoot<WrapState, crate::FlexView<WrapState>> = RenderRoot::new();
4698        root.rebuild(&mut wrapped_slot_logic, &mut state);
4699        let mut tcx = frust_text::TextContext::new();
4700        root.layout_with_text(Size::new(300.0, 200.0), &mut tcx as &mut dyn Any);
4701
4702        // Full Down+Up so the Up releases the capture and a later Down
4703        // hit-tests afresh (see the cross-branch fixture above).
4704        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
4705        root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
4706        assert_eq!(
4707            root.ime_state().map(|s| s.editing.text),
4708            Some("row".to_string()),
4709            "the wrapped field owns the session"
4710        );
4711        (root, tcx, state)
4712    }
4713
4714    #[test]
4715    fn swapping_a_live_focused_wrapper_child_releases_the_session() {
4716        let (mut root, _tcx, mut state) = focus_the_wrapped_field();
4717        let gen_before = root.focus_ime_generation();
4718
4719        // The wrapper's child type-swaps out from under the focused field.
4720        state.row_editing = false;
4721        root.rebuild(&mut wrapped_slot_logic, &mut state);
4722
4723        assert!(
4724            !root.is_focus_active(),
4725            "the root's focus mirror must not outlive the type-swapped field"
4726        );
4727        assert_eq!(
4728            root.ime_state(),
4729            None,
4730            "the shell-facing surface dies with the widget that published it"
4731        );
4732        assert_eq!(
4733            root.focus_ime_generation(),
4734            gen_before.wrapping_add(1),
4735            "one severed live focus path is exactly one edge"
4736        );
4737    }
4738
4739    #[test]
4740    fn swapping_a_stale_focused_wrapper_child_leaves_the_live_session_alone() {
4741        let (mut root, _tcx, mut state) = focus_the_wrapped_field();
4742
4743        // Focus moves to the composer: the blur clears the link at the outer
4744        // Column (the wrapper's own pod), leaving the wrapped field's flag one
4745        // level deeper legitimately stale.
4746        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 50.0));
4747        root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 50.0));
4748        assert_eq!(
4749            root.ime_state().map(|s| s.editing.text),
4750            Some("composer".to_string()),
4751            "the composer now owns the session"
4752        );
4753        let ime_before = root.ime_state();
4754        let gen_before = root.focus_ime_generation();
4755
4756        // The same swap as the twin above, now on a dead branch.
4757        state.row_editing = false;
4758        root.rebuild(&mut wrapped_slot_logic, &mut state);
4759
4760        assert!(
4761            root.is_focus_active(),
4762            "swapping a stale-flagged wrapper child must not blur the composer \
4763             the user is typing in"
4764        );
4765        assert_eq!(
4766            root.ime_state(),
4767            ime_before,
4768            "the live IME surface is untouched by an unrelated branch's swap"
4769        );
4770        assert_eq!(
4771            root.focus_ime_generation(),
4772            gen_before,
4773            "zero spurious edges: the shell's frame gate must see no focus/IME change"
4774        );
4775    }
4776
4777    /// A no-op paint sink for `needs_frame` assertions (glyph/rect output is not
4778    /// under test here).
4779    struct NullScene;
4780    impl PaintScene for NullScene {
4781        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
4782        fn draw_text(&mut self, _o: Point, _t: &str) {}
4783    }
4784
4785    // --- Multi-line mode ---
4786
4787    /// A 3-visible-line multi-line field (newline-on-Enter default).
4788    fn multiline_logic(state: &mut AppState) -> TextInputView<AppState> {
4789        text_input(state.value.clone(), |s: &mut AppState, v: String| {
4790            s.changes += 1;
4791            if !s.reject {
4792                s.value = v;
4793            }
4794        })
4795        .multiline(3)
4796        .on_submit(|s: &mut AppState, v: String| {
4797            s.submits += 1;
4798            s.last_submit = v;
4799        })
4800    }
4801
4802    /// A multi-line field that submits on Enter (newline only on Shift+Enter).
4803    fn multiline_submit_logic(state: &mut AppState) -> TextInputView<AppState> {
4804        text_input(state.value.clone(), |s: &mut AppState, v: String| {
4805            s.changes += 1;
4806            s.value = v;
4807        })
4808        .multiline(3)
4809        .submit_on_enter(true)
4810        .on_submit(|s: &mut AppState, v: String| {
4811            s.submits += 1;
4812            s.last_submit = v;
4813        })
4814    }
4815
4816    /// A tall window so the field's natural (capped) height is never clamped by
4817    /// the root constraints; returns the laid-out field height.
4818    fn relayout(root: &mut RenderRoot<AppState, TextInputView<AppState>>) -> f64 {
4819        root.layout(Size::new(300.0, 800.0)).height
4820    }
4821
4822    #[test]
4823    fn multiline_height_grows_per_line_up_to_cap_then_stops() {
4824        let mut state = AppState::default();
4825        let mut root = RenderRoot::new();
4826        root.rebuild(&mut multiline_logic, &mut state);
4827        let h1 = relayout(&mut root); // one (empty) line
4828
4829        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
4830        // Enter inserts a newline in this newline-on-Enter field.
4831        root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
4832        let h2 = relayout(&mut root); // two lines
4833        root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
4834        let h3 = relayout(&mut root); // three lines (== the cap)
4835
4836        assert!(h2 > h1, "a second line grows the field: {h1} -> {h2}");
4837        assert!(h3 > h2, "a third line grows the field: {h2} -> {h3}");
4838
4839        // A fourth and fifth line exceed the 3-line cap: the box height freezes.
4840        root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
4841        let h4 = relayout(&mut root);
4842        root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
4843        let h5 = relayout(&mut root);
4844        assert_eq!(h4, h3, "height stops growing at the visible-line cap");
4845        assert_eq!(h5, h4, "still capped past the cap");
4846    }
4847
4848    #[test]
4849    fn multiline_scroll_offset_engages_only_past_the_cap() {
4850        let mut state = AppState::default();
4851        let mut root = RenderRoot::new();
4852        root.rebuild(&mut multiline_logic, &mut state);
4853        root.layout(Size::new(300.0, 800.0));
4854        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
4855
4856        // Within the cap (two lines) the content fits: no scroll.
4857        root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
4858        let h = relayout(&mut root);
4859        assert_eq!(
4860            widget(&root).scroll_y(h),
4861            0.0,
4862            "content within the cap never scrolls"
4863        );
4864
4865        // Push past the cap: the caret (on the last line) must be kept in view by
4866        // a positive scroll offset.
4867        for _ in 0..4 {
4868            root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
4869        }
4870        let h = relayout(&mut root);
4871        assert!(
4872            widget(&root).scroll_y(h) > 0.0,
4873            "content past the cap scrolls to keep the caret in view"
4874        );
4875    }
4876
4877    #[test]
4878    fn enter_inserts_newline_in_multiline_by_default() {
4879        let mut state = AppState::default();
4880        let mut root = RenderRoot::new();
4881        root.rebuild(&mut multiline_logic, &mut state);
4882        root.layout(Size::new(300.0, 800.0));
4883        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
4884
4885        root.event(&mut state, &ch("a"));
4886        root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
4887        root.event(&mut state, &ch("b"));
4888
4889        assert_eq!(
4890            widget(&root).editor.text(),
4891            "a\nb",
4892            "Enter inserts a newline"
4893        );
4894        assert_eq!(state.submits, 0, "a newline-on-Enter field does not submit");
4895        assert_eq!(state.value, "a\nb", "the newline flows through on_change");
4896    }
4897
4898    #[test]
4899    fn shift_enter_submits_in_a_newline_on_enter_multiline() {
4900        let mut state = AppState::default();
4901        let mut root = RenderRoot::new();
4902        root.rebuild(&mut multiline_logic, &mut state);
4903        root.layout(Size::new(300.0, 800.0));
4904        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
4905        root.event(&mut state, &ch("h"));
4906        root.event(&mut state, &ch("i"));
4907
4908        let shift = Modifiers {
4909            shift: true,
4910            ..Modifiers::default()
4911        };
4912        root.event(&mut state, &named(NamedKey::Enter, shift));
4913
4914        assert_eq!(state.submits, 1, "Shift+Enter inverts the default → submit");
4915        assert_eq!(state.last_submit, "hi");
4916        assert!(
4917            !widget(&root).editor.text().contains('\n'),
4918            "Shift+Enter must not insert a newline here"
4919        );
4920    }
4921
4922    #[test]
4923    fn submit_on_enter_multiline_submits_and_shift_enter_inserts_newline() {
4924        let mut state = AppState::default();
4925        let mut root = RenderRoot::new();
4926        root.rebuild(&mut multiline_submit_logic, &mut state);
4927        root.layout(Size::new(300.0, 800.0));
4928        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
4929        root.event(&mut state, &ch("h"));
4930        root.event(&mut state, &ch("i"));
4931
4932        // Plain Enter submits (explicit submit_on_enter(true)).
4933        root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
4934        assert_eq!(
4935            state.submits, 1,
4936            "Enter submits when submit_on_enter is set"
4937        );
4938        assert!(!widget(&root).editor.text().contains('\n'));
4939
4940        // Shift+Enter inverts it → insert a newline instead.
4941        let shift = Modifiers {
4942            shift: true,
4943            ..Modifiers::default()
4944        };
4945        root.event(&mut state, &named(NamedKey::Enter, shift));
4946        assert_eq!(state.submits, 1, "Shift+Enter does not submit here");
4947        assert!(
4948            widget(&root).editor.text().contains('\n'),
4949            "Shift+Enter inserts a newline when submit_on_enter is set"
4950        );
4951    }
4952
4953    #[test]
4954    fn ime_newline_commit_inserts_newline_in_multiline() {
4955        // The iOS Return path (Commit("\n")) inserts a literal newline in a
4956        // newline-on-Enter multi-line field, rather than submitting.
4957        let mut state = AppState::default();
4958        let mut root = RenderRoot::new();
4959        root.rebuild(&mut multiline_logic, &mut state);
4960        root.layout(Size::new(300.0, 800.0));
4961        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
4962        root.event(&mut state, &ch("a"));
4963
4964        root.event(
4965            &mut state,
4966            &InputEvent::Ime(ImeEvent::Commit("\n".to_string())),
4967        );
4968
4969        assert!(
4970            widget(&root).editor.text().contains('\n'),
4971            "a newline commit lands a literal newline in a multi-line field"
4972        );
4973        assert_eq!(state.submits, 0, "the newline commit does not submit");
4974    }
4975
4976    #[test]
4977    fn arrow_up_down_move_caret_across_lines_in_multiline() {
4978        let mut state = AppState::default();
4979        let mut root = RenderRoot::new();
4980        root.rebuild(&mut multiline_logic, &mut state);
4981        root.layout(Size::new(300.0, 800.0));
4982        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
4983        root.event(&mut state, &ch("a"));
4984        root.event(&mut state, &ch("b"));
4985        root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
4986        root.event(&mut state, &ch("c"));
4987        root.event(&mut state, &ch("d")); // caret at end of line 2 (byte 5)
4988        assert_eq!(widget(&root).editor.editing_state_bytes().extent, 5);
4989
4990        // ArrowUp crosses onto line 1 (byte offset within [0, 2]).
4991        root.event(&mut state, &named(NamedKey::ArrowUp, Modifiers::default()));
4992        let up = widget(&root).editor.editing_state_bytes().extent;
4993        assert!(up <= 2, "ArrowUp moves the caret onto line 1, got {up}");
4994
4995        // ArrowDown returns to line 2 (byte offset >= 3, past the newline).
4996        root.event(
4997            &mut state,
4998            &named(NamedKey::ArrowDown, Modifiers::default()),
4999        );
5000        let down = widget(&root).editor.editing_state_bytes().extent;
5001        assert!(
5002            down >= 3,
5003            "ArrowDown moves the caret back to line 2, got {down}"
5004        );
5005    }
5006
5007    /// Regression: `Widget::layout`'s single-line branch must reset the
5008    /// editor's wrap width, or a field rebuilt from multiline into single-line
5009    /// keeps the stale wrapped layout — `content_height` (and, downstream, the
5010    /// text's vertical placement) stays wrong until something else happens to
5011    /// touch the wrap width again.
5012    #[test]
5013    fn multiline_to_single_line_rebuild_resets_wrap_width() {
5014        // Long enough to wrap across several lines at a narrow width.
5015        let long_text = "one two three four five six seven eight nine ten".to_string();
5016        let mut state = AppState {
5017            value: long_text.clone(),
5018            ..AppState::default()
5019        };
5020        let mut root = RenderRoot::new();
5021        root.rebuild(&mut multiline_logic, &mut state);
5022        let multi_height = root.layout(Size::new(120.0, 800.0)).height;
5023        assert!(
5024            widget(&root).editor.line_count() > 1,
5025            "seed text must actually wrap for this regression to be meaningful"
5026        );
5027
5028        // Rebuild the *same* root into a single-line field (multiline dropped)
5029        // with the identical text — the value is unchanged, so `rebuild` never
5030        // calls `set_controlled_value`; only `max_visible_lines` flips to
5031        // `None`, exercising the single-line `layout` branch on a widget whose
5032        // editor still carries the old wrap width.
5033        root.rebuild(&mut build, &mut state);
5034        let single_height = root.layout(Size::new(120.0, 800.0)).height;
5035
5036        assert_eq!(
5037            widget(&root).editor.line_count(),
5038            1,
5039            "single-line relayout must reset the wrap width so the editor \
5040             reflows back onto one line"
5041        );
5042        assert!(
5043            single_height < multi_height,
5044            "single-line relayout must collapse the stale wrapped height: \
5045             multi={multi_height}, single={single_height}"
5046        );
5047
5048        // Cross-check against a field built single-line from scratch with the
5049        // same text/width: the rebuilt-down field must match it exactly, not
5050        // merely be smaller.
5051        let mut fresh_state = AppState {
5052            value: long_text,
5053            ..AppState::default()
5054        };
5055        let mut fresh_root = RenderRoot::new();
5056        fresh_root.rebuild(&mut build, &mut fresh_state);
5057        let fresh_height = fresh_root.layout(Size::new(120.0, 800.0)).height;
5058        assert_eq!(
5059            single_height, fresh_height,
5060            "a multiline->single-line rebuild must match a field built \
5061             single-line from scratch"
5062        );
5063
5064        // Layout and caret placement agree: the origin's centering formula is
5065        // now measured against the corrected (single-line) content height.
5066        let w = widget(&root);
5067        assert_eq!(
5068            w.content_origin_y(single_height),
5069            w.text_top(single_height),
5070            "single-line mode must use the centered single-line placement"
5071        );
5072    }
5073
5074    #[test]
5075    fn placeholder_inherits_font_family_from_style() {
5076        // Verify that an empty unfocused field shows a placeholder with the input's
5077        // configured font family, weight, and letter-spacing — not the default style.
5078        use frust_text::{FontFamily, FontWeight};
5079
5080        let mut state = AppState::default();
5081        let mut root = RenderRoot::new();
5082
5083        // Build with a custom font family style.
5084        let custom_family = FontFamily::named("Monospace");
5085        let custom_style = TextStyle {
5086            family: custom_family.clone(),
5087            weight: FontWeight::BOLD,
5088            size: 18.0,
5089            letter_spacing: 1.5,
5090            ..TextStyle::default()
5091        };
5092
5093        root.rebuild(
5094            &mut |s: &mut AppState| {
5095                text_input(s.value.clone(), |_s: &mut AppState, _v: String| {})
5096                    .placeholder("Enter text")
5097                    .text_style(custom_style.clone())
5098            },
5099            &mut state,
5100        );
5101        root.layout(Size::new(300.0, 200.0));
5102
5103        let w = widget(&root);
5104        // Verify the widget's configured style has the custom family.
5105        assert_eq!(
5106            w.style.family, custom_family,
5107            "widget style should have the custom family"
5108        );
5109        assert_eq!(
5110            w.style.weight,
5111            FontWeight::BOLD,
5112            "widget style should have the custom weight"
5113        );
5114        assert_eq!(
5115            w.style.letter_spacing, 1.5,
5116            "widget style should have the custom letter-spacing"
5117        );
5118
5119        // Paint the widget (the placeholder will be rendered since the field is empty and unfocused).
5120        let mut sink = NullScene;
5121        root.paint(&mut sink, FrameTime::ZERO);
5122
5123        // The test verifies that the placeholder is laid out without crashing and
5124        // the field's style is correctly applied. A proper pixel-level assertion
5125        // would require inspecting glyph runs directly (which RecordingScene doesn't
5126        // support), but the layout success itself proves the family was accepted.
5127    }
5128
5129    #[test]
5130    fn placeholder_font_reflects_configured_style_family() {
5131        // Genuine shaped-output assertion: verify that an empty,
5132        // unfocused field's placeholder shapes with the input's configured font
5133        // family, not a fallback. Two TextInputs—one with default family (SystemUi),
5134        // one with an explicit named family—must resolve to different fonts in
5135        // their placeholder runs. If `ph_style` regresses to `TextStyle::new(size,
5136        // color)`, both would drop the family and resolve to the same default font,
5137        // causing this assertion to fail.
5138        use frust_text::{FontFamily, GenericSlot};
5139
5140        // First TextInput: default style (no explicit family).
5141        // The placeholder will shape with the default family (SystemUi).
5142        let mut state_default = AppState::default();
5143        let mut root_default = RenderRoot::new();
5144        root_default.rebuild(
5145            &mut |s: &mut AppState| {
5146                text_input(s.value.clone(), |_s: &mut AppState, _v: String| {}).placeholder("test")
5147            },
5148            &mut state_default,
5149        );
5150        root_default.layout(Size::new(300.0, 200.0));
5151        let default_font = painted_placeholder_font_bytes(&mut root_default);
5152
5153        // Second TextInput: explicit monospace family using stack_with_generic.
5154        // Monospace is a generic family available on all platforms; it will
5155        // resolve to a different system font than SystemUi on any test host.
5156        let monospace_style = TextStyle {
5157            family: FontFamily::stack_with_generic(Vec::<String>::new(), GenericSlot::Monospace),
5158            ..TextStyle::default()
5159        };
5160        let mut state_monospace = AppState::default();
5161        let mut root_monospace = RenderRoot::new();
5162        root_monospace.rebuild(
5163            &mut |s: &mut AppState| {
5164                text_input(s.value.clone(), |_s: &mut AppState, _v: String| {})
5165                    .placeholder("test")
5166                    .text_style(monospace_style.clone())
5167            },
5168            &mut state_monospace,
5169        );
5170        root_monospace.layout(Size::new(300.0, 200.0));
5171        let monospace_font = painted_placeholder_font_bytes(&mut root_monospace);
5172
5173        // Assert: the two placeholders resolved to different fonts.
5174        // If ph_style regressed (losing the family), both would use SystemUi
5175        // and resolve to the same font.
5176        assert_ne!(
5177            default_font, monospace_font,
5178            "placeholder with Monospace family should resolve to a different font \
5179             than the default SystemUi family; if this fails, ph_style likely \
5180             regressed to not preserving the configured family"
5181        );
5182    }
5183
5184    // --- App-font parity (the shell drain must reach the private context) ---
5185    //
5186    // A `TextInput` owns a private `TextContext` (module docs' Text context
5187    // ownership). The defect this section pins: if that private context were a
5188    // bare `TextContext::new()`, an app font registered through
5189    // `frust::register_app_fonts` — drained by the shell into the *shell-owned*
5190    // context, the one `LayoutCtx::text_context` threads to `Text` — would never
5191    // reach the field, which would silently fall back to a platform face.
5192    //
5193    // `frust-widgets` cannot call `frust::register_app_fonts` (its registry
5194    // lives in `frust-shell-common`, above this crate), so these tests
5195    // reproduce the drain's single observable effect instead:
5196    // `FontRegistryWatcher::drain_into` is a `TextContext::register_fonts` call
5197    // on the shell-owned context, and nothing else.
5198
5199    /// The registered test font's bytes — the same public-domain subsetted
5200    /// asset `frust-text`'s own registration tests use (and which
5201    /// `frust-shell-common` likewise includes cross-crate rather than
5202    /// duplicating a font file per crate).
5203    const TUFFY: &[u8] = include_bytes!("../../frust-text/tests/fonts/Tuffy-Subset.ttf");
5204
5205    /// The same face with its `name` table rewritten to "Helvetica". Used for
5206    /// the late-registration test so the *pre-registration* leg is meaningful
5207    /// on any host: "Helvetica" resolves to whatever the platform has (or a
5208    /// fallback) before registration, and to these exact bytes after, per
5209    /// fontique's registered-shadows-system-family rule.
5210    const TUFFY_AS_HELVETICA: &[u8] =
5211        include_bytes!("../../frust-text/tests/fonts/Tuffy-As-Helvetica.ttf");
5212
5213    /// Tuffy's digit advance at 24px, in logical px — measured from this exact
5214    /// asset through this exact parley pin (see `docs/DEVELOPMENT.md`'s
5215    /// Version-Pin Policy). Hard-coded so the assertion is on the *shaped
5216    /// metric*, not merely on "some font was registered": the device symptom
5217    /// behind this bug was a wrong per-glyph advance (1-em fallback boxes)
5218    /// while registration itself reported success.
5219    const TUFFY_DIGIT_ADVANCE_24PX: f32 = 13.3125;
5220
5221    /// Slack on [`TUFFY_DIGIT_ADVANCE_24PX`]: parley lays out with
5222    /// `quantize = true`, so a run's successive x deltas land on subpixel
5223    /// boundaries and one digit pair in ten reads ~0.12px short of the nominal
5224    /// advance. Far tighter than any real font swap (a fallback face differs by
5225    /// whole pixels at this size).
5226    const ADVANCE_TOLERANCE: f32 = 0.25;
5227
5228    /// The digits string every advance assertion shapes — uniform-width in
5229    /// Tuffy, so one expected advance covers every glyph in the run.
5230    const DIGITS: &str = "0123456789";
5231
5232    /// Records each painted glyph run's resolved font bytes and per-glyph
5233    /// advances (successive x deltas) — shaped output, not registration state.
5234    #[derive(Default)]
5235    struct ShapedRunRecorder {
5236        runs: Vec<(Vec<u8>, Vec<f32>)>,
5237    }
5238
5239    impl PaintScene for ShapedRunRecorder {
5240        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
5241        fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, _c: Color) {}
5242        fn draw_text(&mut self, _o: Point, _t: &str) {}
5243        fn draw_glyph_run(&mut self, run: frust_scene::GlyphRun) {
5244            let advances = run.glyphs.windows(2).map(|w| w[1].x - w[0].x).collect();
5245            self.runs
5246                .push((run.font.font().data.as_ref().to_vec(), advances));
5247        }
5248    }
5249
5250    /// Paint `root` and return the first glyph run's `(font bytes, advances)`.
5251    fn painted_shaped_run(
5252        root: &mut RenderRoot<AppState, TextInputView<AppState>>,
5253    ) -> (Vec<u8>, Vec<f32>) {
5254        let mut rec = ShapedRunRecorder::default();
5255        root.paint(&mut rec, FrameTime::ZERO);
5256        rec.runs.first().expect("one glyph run painted").clone()
5257    }
5258
5259    /// A 24px style in `family`.
5260    fn family_style(family: frust_text::FontFamily) -> TextStyle {
5261        TextStyle {
5262            family,
5263            ..TextStyle::new(24.0, Color::BLACK)
5264        }
5265    }
5266
5267    /// A laid-out field carrying `value` (empty = the placeholder path) in
5268    /// `family`, built *now* — i.e. with whatever fonts are registered at call
5269    /// time, which is the ordering under test.
5270    fn field_in_family(
5271        state: &mut AppState,
5272        family: frust_text::FontFamily,
5273    ) -> RenderRoot<AppState, TextInputView<AppState>> {
5274        let style = family_style(family);
5275        let mut root = RenderRoot::new();
5276        root.rebuild(
5277            &mut |s: &mut AppState| {
5278                text_input(s.value.clone(), |_s: &mut AppState, _v: String| {})
5279                    .placeholder(DIGITS)
5280                    .text_style(style.clone())
5281            },
5282            state,
5283        );
5284        root.layout(Size::new(600.0, 200.0));
5285        root
5286    }
5287
5288    /// Assert the shaped run resolved to `expected`'s exact bytes.
5289    ///
5290    /// Compared behind a `bool` rather than `assert_eq!` on purpose: an
5291    /// `assert_eq!` between two font files prints both in full, which is tens
5292    /// of megabytes of failure output per failing test.
5293    fn assert_same_font(font: &[u8], expected: &[u8], what: &str) {
5294        assert!(
5295            font == expected,
5296            "{what}: shaped against a different face than the registered app \
5297             font ({} bytes shaped vs {} expected)",
5298            font.len(),
5299            expected.len()
5300        );
5301    }
5302
5303    /// Assert the shaped run resolved to something *other* than `other`'s bytes.
5304    fn assert_other_font(font: &[u8], other: &[u8], what: &str) {
5305        assert!(
5306            font != other,
5307            "{what}: expected a different face, got the same {} bytes",
5308            font.len()
5309        );
5310    }
5311
5312    /// Assert every advance in `advances` is `expected` (within quantization
5313    /// slack — see [`ADVANCE_TOLERANCE`]).
5314    fn assert_uniform_advance(advances: &[f32], expected: f32, what: &str) {
5315        assert!(!advances.is_empty(), "{what}: no advances measured");
5316        for a in advances {
5317            assert!(
5318                (a - expected).abs() <= ADVANCE_TOLERANCE,
5319                "{what}: shaped advance {a} != the registered font's own {expected} \
5320                 (all advances: {advances:?})"
5321            );
5322        }
5323    }
5324
5325    #[test]
5326    fn app_registered_font_shapes_the_field_content() {
5327        // The shell's construction-time drain, reproduced exactly.
5328        let mut shell_ctx = frust_text::TextContext::new();
5329        shell_ctx
5330            .register_fonts(TUFFY.to_vec())
5331            .expect("valid TTF bytes must register");
5332
5333        // A field built *after* that drain — the real ordering: a design system
5334        // registers its fonts from `app!`'s setup block, before the first
5335        // rebuild builds any widget.
5336        let mut state = AppState {
5337            value: DIGITS.to_string(),
5338            ..AppState::default()
5339        };
5340        let mut root = field_in_family(&mut state, frust_text::FontFamily::named("Tuffy"));
5341        let (font, advances) = painted_shaped_run(&mut root);
5342
5343        assert_same_font(&font, TUFFY, "content");
5344        assert_uniform_advance(&advances, TUFFY_DIGIT_ADVANCE_24PX, "content");
5345
5346        // Negative control: an unregistered family name resolves to a fallback
5347        // face, whose bytes cannot be the registered asset's.
5348        let mut fallback_state = AppState {
5349            value: DIGITS.to_string(),
5350            ..AppState::default()
5351        };
5352        let mut fallback_root = field_in_family(
5353            &mut fallback_state,
5354            frust_text::FontFamily::named("Frust No Such Family"),
5355        );
5356        let (fallback_font, fallback_advances) = painted_shaped_run(&mut fallback_root);
5357        assert_other_font(&fallback_font, TUFFY, "unregistered-family control");
5358        assert_ne!(
5359            fallback_advances, advances,
5360            "fixture sanity: the fallback face must shape these digits to \
5361             different metrics, or this test could pass without the app font"
5362        );
5363    }
5364
5365    #[test]
5366    fn app_registered_font_shapes_the_placeholder() {
5367        // The placeholder shapes on its own path (`text_ctx.layout` in `paint`,
5368        // not the editor's retained layout), so it needs its own coverage.
5369        let mut shell_ctx = frust_text::TextContext::new();
5370        shell_ctx
5371            .register_fonts(TUFFY.to_vec())
5372            .expect("valid TTF bytes must register");
5373
5374        // Empty value + never focused = the placeholder is what gets painted.
5375        let mut state = AppState::default();
5376        let mut root = field_in_family(&mut state, frust_text::FontFamily::named("Tuffy"));
5377        let (font, advances) = painted_shaped_run(&mut root);
5378
5379        assert_same_font(&font, TUFFY, "placeholder");
5380        assert_uniform_advance(&advances, TUFFY_DIGIT_ADVANCE_24PX, "placeholder");
5381    }
5382
5383    #[test]
5384    fn font_registered_after_build_reshapes_the_field_at_the_next_layout() {
5385        // The shells' *per-frame* late drain: a font can register after this
5386        // widget's private context was built. `layout`'s `sync_app_fonts` picks
5387        // it up and rebuilds the editor, whose retained parley layout would
5388        // otherwise stay shaped against the old faces (clearing the private
5389        // context's shape cache alone would not cover it).
5390        let mut state = AppState {
5391            value: DIGITS.to_string(),
5392            ..AppState::default()
5393        };
5394        let mut root = field_in_family(&mut state, frust_text::FontFamily::named("Helvetica"));
5395        let (before_font, before_advances) = painted_shaped_run(&mut root);
5396        assert_other_font(
5397            &before_font,
5398            TUFFY_AS_HELVETICA,
5399            "pre-registration control (nothing has registered this face yet)",
5400        );
5401
5402        let mut shell_ctx = frust_text::TextContext::new();
5403        shell_ctx
5404            .register_fonts(TUFFY_AS_HELVETICA.to_vec())
5405            .expect("valid TTF bytes must register");
5406
5407        // The shell forces LAYOUT on a `drain_into` that applied something;
5408        // this is that relayout.
5409        root.layout(Size::new(600.0, 200.0));
5410        let (after_font, after_advances) = painted_shaped_run(&mut root);
5411
5412        // A registered family shadows any system "Helvetica" (fontique 0.11),
5413        // so this holds on every host.
5414        assert_same_font(&after_font, TUFFY_AS_HELVETICA, "late-registered");
5415        assert_uniform_advance(&after_advances, TUFFY_DIGIT_ADVANCE_24PX, "late-registered");
5416        assert_ne!(
5417            after_advances, before_advances,
5418            "the late registration must actually change the shaped metrics"
5419        );
5420    }
5421
5422    // --- enabled(false) ---
5423
5424    /// The standard fixture field, with `enabled`/`obscured`/`read_only`
5425    /// dialled in.
5426    fn options_logic(
5427        enabled: bool,
5428        obscured: bool,
5429        read_only: bool,
5430    ) -> impl FnMut(&mut AppState) -> TextInputView<AppState> {
5431        move |state: &mut AppState| {
5432            text_input(state.value.clone(), |s: &mut AppState, v: String| {
5433                s.changes += 1;
5434                s.value = v;
5435            })
5436            .placeholder("type here")
5437            .enabled(enabled)
5438            .obscured(obscured)
5439            .read_only(read_only)
5440        }
5441    }
5442
5443    /// Build + lay out a root over `logic`.
5444    fn options_root(
5445        logic: &mut impl FnMut(&mut AppState) -> TextInputView<AppState>,
5446        state: &mut AppState,
5447    ) -> RenderRoot<AppState, TextInputView<AppState>> {
5448        let mut root = RenderRoot::new();
5449        root.rebuild(logic, state);
5450        root.layout(Size::new(300.0, 200.0));
5451        root
5452    }
5453
5454    #[test]
5455    fn disabled_field_refuses_focus_and_stays_inert() {
5456        let mut state = AppState::default();
5457        let mut logic = options_logic(false, false, false);
5458        let mut root = options_root(&mut logic, &mut state);
5459
5460        let outcome = root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
5461        assert!(!outcome.handled, "a disabled field consumes nothing");
5462        assert!(
5463            !root.is_focus_active(),
5464            "a tap must not focus a disabled field"
5465        );
5466        assert!(!widget(&root).focused);
5467        assert!(!widget(&root).captured, "no drag capture either");
5468        assert!(root.ime_state().is_none(), "no IME surface is published");
5469
5470        // Keys and IME are unreachable (they route down the focus path, which
5471        // the tap never claimed) and edit nothing even when injected directly.
5472        root.event(&mut state, &ch("x"));
5473        root.event(
5474            &mut state,
5475            &InputEvent::Ime(ImeEvent::Commit("ni".to_string())),
5476        );
5477        assert_eq!(widget(&root).editor.text(), "");
5478        assert_eq!(state.changes, 0);
5479
5480        // No caret is painted, and the field is at rest.
5481        let mut rec = CaretRecorder {
5482            caret_color: Some(CARET.multiply_alpha(DISABLED_CONTENT_ALPHA)),
5483            caret_fills: 0,
5484        };
5485        let outcome = root.paint(&mut rec, FrameTime::ZERO);
5486        assert_eq!(rec.caret_fills, 0, "a disabled field paints no caret");
5487        assert!(!outcome.needs_frame, "a disabled field never blinks");
5488    }
5489
5490    #[test]
5491    fn disabling_a_focused_field_releases_focus_and_deactivates_ime() {
5492        let mut state = AppState::default();
5493        let mut enabled_logic = options_logic(true, false, false);
5494        let mut root = options_root(&mut enabled_logic, &mut state);
5495        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
5496        assert!(root.is_focus_active());
5497        assert!(root.ime_state().expect("focused").active);
5498
5499        // Flip the flag on a rebuild while the field holds focus.
5500        let mut disabled_logic = options_logic(false, false, false);
5501        let flags = root.rebuild(&mut disabled_logic, &mut state);
5502        assert!(
5503            flags.needs_layout(),
5504            "the disabled dim is baked at layout, so the flip must relayout"
5505        );
5506        root.layout(Size::new(300.0, 200.0));
5507        assert!(
5508            !widget(&root).focused,
5509            "the widget stops considering itself focused immediately"
5510        );
5511
5512        // Leg 1 — the next paint already refuses to act focused and hands the
5513        // shell an inactive IME surface (dismissing the keyboard). The root reads
5514        // that inactive publish as a session release, so it drops the surface
5515        // outright (both mobile bridges serialise `None` to the same inactive
5516        // wire form — see `RenderRoot::ime_state`) and clears its focus mirror
5517        // rather than leaving it standing over a field that stopped editing.
5518        let mut sink = NullScene;
5519        let outcome = root.paint(&mut sink, FrameTime::ZERO);
5520        assert!(!outcome.needs_frame, "a disabled field paints at rest");
5521        assert!(
5522            root.ime_state().is_none(),
5523            "the published inactive surface releases the session"
5524        );
5525        assert!(
5526            !root.is_focus_active(),
5527            "the root's focus mirror goes with it"
5528        );
5529
5530        // Leg 2 — the first event that reaches the field releases the pod-level
5531        // focus path, and edits nothing on the way.
5532        root.event(&mut state, &ch("x"));
5533        assert!(
5534            !root.is_focus_active(),
5535            "the stranded focus path is released"
5536        );
5537        assert!(root.ime_state().is_none());
5538        assert_eq!(widget(&root).editor.text(), "");
5539    }
5540
5541    /// A design-system-shaped theme whose `on_surface_variant` role is itself
5542    /// **translucent** — the shape an iOS-style design system installs (its
5543    /// `secondaryLabel` token really is `rgba(.., 0.60)`). Built inline here
5544    /// because no design language ships in this crate any more. The dim under
5545    /// test is an alpha *multiplier* on the resolved role, so it has to behave
5546    /// identically over an already-translucent one — which is exactly what a
5547    /// token *swap* would not do.
5548    fn translucent_role_theme() -> Theme {
5549        Theme::builder(Theme::neutral())
5550            .design_language(frust_theme::DesignLanguage::Cupertino)
5551            .map_colors_light(|c| frust_theme::ColorScheme {
5552                on_surface_variant: c.on_surface_variant.multiply_alpha(0.6),
5553                ..c
5554            })
5555            .build()
5556    }
5557
5558    #[test]
5559    fn disabled_dims_content_and_outline_in_every_design_language() {
5560        // The dim is an alpha multiplier on the *resolved* role, so it must
5561        // behave identically unthemed, under the neutral baseline, and under a
5562        // design system whose own `on_surface_variant` is already translucent
5563        // (see `translucent_role_theme` — the reason a token swap would not be
5564        // portable).
5565        let translucent = translucent_role_theme();
5566        assert!(
5567            translucent.scheme().on_surface_variant.components[3] < 1.0,
5568            "fixture sanity: the third arm's role must really be translucent"
5569        );
5570        let languages: Vec<(&str, Option<Theme>)> = vec![
5571            ("unthemed", None),
5572            ("neutral", Some(Theme::neutral())),
5573            ("translucent-role", Some(translucent)),
5574        ];
5575        for (name, theme) in languages {
5576            let enabled = Chrome::resolve(theme.as_ref(), true);
5577            let disabled = Chrome::resolve(theme.as_ref(), false);
5578            assert_eq!(
5579                disabled.placeholder,
5580                enabled.placeholder.multiply_alpha(DISABLED_CONTENT_ALPHA),
5581                "{name}: disabled placeholder is the enabled role at 38%"
5582            );
5583            assert_eq!(
5584                disabled.border,
5585                enabled.border.multiply_alpha(DISABLED_CONTAINER_ALPHA),
5586                "{name}: disabled outline is the enabled role at 12%"
5587            );
5588            assert!(
5589                disabled.placeholder.components[3] < enabled.placeholder.components[3],
5590                "{name}: the disabled placeholder must actually be more transparent"
5591            );
5592            assert_eq!(
5593                disabled.bg, enabled.bg,
5594                "{name}: the container fill stays opaque (see Chrome::resolve)"
5595            );
5596        }
5597    }
5598
5599    #[test]
5600    fn disabled_dims_the_layout_baked_glyph_color() {
5601        // The second resolution point: the glyph color is baked into the shaped
5602        // editor state at LAYOUT time, so dimming has to happen there too.
5603        let mut state = AppState {
5604            value: "hi".to_string(),
5605            ..AppState::default()
5606        };
5607        let mut logic = options_logic(false, false, false);
5608        let mut root = options_root(&mut logic, &mut state);
5609        root.set_theme(Box::new(Theme::neutral()));
5610        root.layout(Size::new(300.0, 200.0));
5611
5612        let expected = Theme::neutral()
5613            .scheme()
5614            .on_surface
5615            .multiply_alpha(DISABLED_CONTENT_ALPHA);
5616        assert_eq!(
5617            painted_text_color(&mut root),
5618            expected,
5619            "a disabled field's glyphs are on_surface at 38%"
5620        );
5621    }
5622
5623    #[test]
5624    fn disabled_dims_the_unthemed_fallback_glyph_color() {
5625        let mut state = AppState {
5626            value: "hi".to_string(),
5627            ..AppState::default()
5628        };
5629        let mut logic = options_logic(false, false, false);
5630        let mut root = options_root(&mut logic, &mut state);
5631        assert_eq!(
5632            painted_text_color(&mut root),
5633            Color::BLACK.multiply_alpha(DISABLED_CONTENT_ALPHA),
5634            "with no theme threaded the black fallback dims by the same rule"
5635        );
5636    }
5637
5638    // --- read_only(true) ---
5639
5640    /// A read-only field over `value`, ready for events.
5641    fn read_only_root(
5642        state: &mut AppState,
5643        logic: &mut impl FnMut(&mut AppState) -> TextInputView<AppState>,
5644    ) -> RenderRoot<AppState, TextInputView<AppState>> {
5645        options_root(logic, state)
5646    }
5647
5648    /// Drag-select the whole buffer of an already-built field, the way a user
5649    /// produces a selection — [`focused_with_selection`] without the typing a
5650    /// read-only field would refuse.
5651    fn drag_select_all(
5652        state: &mut AppState,
5653        root: &mut RenderRoot<AppState, TextInputView<AppState>>,
5654    ) {
5655        root.event(state, &pointer(PointerPhase::Down, 0.0, 10.0));
5656        root.event(state, &pointer(PointerPhase::Move, 290.0, 10.0));
5657        root.event(state, &pointer(PointerPhase::Up, 290.0, 10.0));
5658    }
5659
5660    #[test]
5661    fn read_only_takes_focus_on_a_press_and_paints_focused() {
5662        // A read-only field is focusable so its content can be selected and
5663        // copied; what it withholds is editing, not the session.
5664        let mut state = AppState {
5665            value: "abc".to_string(),
5666            ..AppState::default()
5667        };
5668        let mut logic = options_logic(true, false, true);
5669        let mut root = read_only_root(&mut state, &mut logic);
5670
5671        let outcome = root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
5672        assert!(
5673            outcome.handled,
5674            "the press is consumed, like any focus claim"
5675        );
5676        assert!(root.is_focus_active(), "a press focuses a read-only field");
5677        assert!(widget(&root).focused);
5678        assert!(widget(&root).captured, "and captures, so a drag can select");
5679
5680        let rec = paint_chrome(&mut root);
5681        assert_eq!(
5682            rec.rrects[0], ACCENT,
5683            "a focused read-only field paints the focused accent border"
5684        );
5685        assert_eq!(rec.rects, vec![CARET], "and draws its caret");
5686    }
5687
5688    #[test]
5689    fn a_read_only_caret_is_drawn_steady_and_asks_for_no_blink_frames() {
5690        // The blink advertises an insertion point; a field that takes no
5691        // insertion has none to advertise, so the caret marks where a selection
5692        // would start and the field otherwise sits at rest.
5693        let mut state = AppState {
5694            value: "abc".to_string(),
5695            ..AppState::default()
5696        };
5697        let mut logic = options_logic(true, false, true);
5698        let mut root = read_only_root(&mut state, &mut logic);
5699        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
5700        root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
5701
5702        for ms in [0.0, BLINK_MS + 10.0, 2.0 * BLINK_MS + 10.0] {
5703            let mut rec = CaretRecorder {
5704                caret_color: Some(CARET),
5705                caret_fills: 0,
5706            };
5707            let outcome = root.paint(&mut rec, ft_ms(ms));
5708            assert_eq!(rec.caret_fills, 1, "the caret stays lit at t={ms}ms");
5709            assert!(
5710                !outcome.needs_frame,
5711                "no continuation frame is asked for at t={ms}ms"
5712            );
5713        }
5714    }
5715
5716    #[test]
5717    fn a_focused_read_only_field_publishes_an_active_keyboard_suppressed_surface() {
5718        // Active so each shell's clipboard route (which hangs off the platform
5719        // surface) stays wired; suppressed so no on-screen keyboard comes up.
5720        let mut state = AppState {
5721            value: "abc".to_string(),
5722            ..AppState::default()
5723        };
5724        let mut logic = options_logic(true, false, true);
5725        let mut root = read_only_root(&mut state, &mut logic);
5726        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
5727
5728        let ime = root.ime_state().expect("a focused read-only field has one");
5729        assert!(ime.active, "the surface is live, not released");
5730        assert!(ime.suppress_soft_keyboard);
5731        assert_eq!(ime.editing.text, "abc");
5732
5733        // An editable field says nothing, exactly as it did before the hint.
5734        let mut state = AppState::default();
5735        let mut editable_logic = options_logic(true, false, false);
5736        let mut root = options_root(&mut editable_logic, &mut state);
5737        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
5738        let ime = root.ime_state().expect("a focused editable field has one");
5739        assert!(ime.active);
5740        assert!(
5741            !ime.suppress_soft_keyboard,
5742            "an editable field wants its keyboard"
5743        );
5744    }
5745
5746    #[test]
5747    fn read_only_copies_a_drag_selection_and_selects_all_from_the_chords() {
5748        let mut state = AppState {
5749            value: "abc".to_string(),
5750            ..AppState::default()
5751        };
5752        let mut logic = options_logic(true, false, true);
5753        let mut root = read_only_root(&mut state, &mut logic);
5754        drag_select_all(&mut state, &mut root);
5755        assert_eq!(
5756            selection(&root).as_deref(),
5757            Some("abc"),
5758            "a drag selects on a read-only field"
5759        );
5760
5761        root.event(&mut state, &chord("c", meta()));
5762        assert_eq!(
5763            root.take_clipboard_write().as_deref(),
5764            Some("abc"),
5765            "copy is the verb read-only exists to allow"
5766        );
5767        assert_eq!(state.changes, 0, "a copy is not an edit");
5768
5769        // Select-all reaches the same buffer from a fresh session, where a
5770        // press placed a caret and selected nothing.
5771        let mut state = AppState {
5772            value: "abc".to_string(),
5773            ..AppState::default()
5774        };
5775        let mut logic = options_logic(true, false, true);
5776        let mut root = read_only_root(&mut state, &mut logic);
5777        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
5778        root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
5779        assert_eq!(selection(&root), None, "sanity: nothing selected yet");
5780
5781        root.event(&mut state, &chord("a", meta()));
5782        assert_eq!(selection(&root).as_deref(), Some("abc"));
5783        assert_eq!(state.changes, 0);
5784    }
5785
5786    #[test]
5787    fn read_only_answers_cut_and_paste_with_nothing() {
5788        // Consumed, not ignored: the verb was understood. What it may not do is
5789        // change the buffer, write half a cut to the clipboard, or report an
5790        // edit that never happened.
5791        let mut state = AppState {
5792            value: "abc".to_string(),
5793            ..AppState::default()
5794        };
5795        let mut logic = options_logic(true, false, true);
5796        let mut root = read_only_root(&mut state, &mut logic);
5797        drag_select_all(&mut state, &mut root);
5798
5799        let outcome = root.event(&mut state, &chord("x", meta()));
5800        assert!(outcome.handled, "the cut chord is consumed");
5801        assert_eq!(
5802            root.take_clipboard_write(),
5803            None,
5804            "a refused cut writes nothing — half a cut is not a copy"
5805        );
5806
5807        let outcome = root.event(&mut state, &edit(EditCommand::Paste("zz".to_string())));
5808        assert!(outcome.handled, "the paste command is consumed");
5809
5810        assert_eq!(
5811            widget(&root).editor.text(),
5812            "abc",
5813            "the buffer is untouched"
5814        );
5815        assert_eq!(state.changes, 0, "and no on_change fires for either");
5816        assert_eq!(state.value, "abc");
5817    }
5818
5819    #[test]
5820    fn read_only_still_refuses_typing_and_ime_commits() {
5821        let mut state = AppState {
5822            value: "abc".to_string(),
5823            ..AppState::default()
5824        };
5825        let mut logic = options_logic(true, false, true);
5826        let mut root = read_only_root(&mut state, &mut logic);
5827        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
5828        assert!(root.is_focus_active(), "refused while genuinely focused");
5829
5830        for event in [
5831            ch("x"),
5832            InputEvent::Ime(ImeEvent::Commit("ni".to_string())),
5833            named(NamedKey::Backspace, Modifiers::default()),
5834            named(NamedKey::Delete, Modifiers::default()),
5835            named(NamedKey::Enter, Modifiers::default()),
5836        ] {
5837            let outcome = root.event(&mut state, &event);
5838            assert!(
5839                !outcome.handled,
5840                "an edit is refused unconsumed, as it was when the field \
5841                 refused focus outright: {event:?}"
5842            );
5843        }
5844        assert_eq!(widget(&root).editor.text(), "abc");
5845        assert_eq!(state.changes, 0);
5846        assert_eq!(state.submits, 0, "Enter submits nothing either");
5847    }
5848
5849    #[test]
5850    fn escape_ends_a_read_only_session_without_touching_the_text() {
5851        // A field that can hold a session needs a keyboard way out of it, so
5852        // Escape stays answered where the editing keys do not. It ends the
5853        // session and takes the toolbar with it, and mutates nothing on the way.
5854        let mut state = AppState {
5855            value: "abc".to_string(),
5856            ..AppState::default()
5857        };
5858        let mut logic = options_logic(true, false, true);
5859        let mut root = read_only_root(&mut state, &mut logic);
5860        root.event(
5861            &mut state,
5862            &secondary_pointer(PointerPhase::Down, 20.0, 10.0),
5863        );
5864        assert!(root.is_focus_active(), "the context press opened a session");
5865        assert!(widget(&root).toolbar_open, "…with the bar up");
5866
5867        let outcome = root.event(&mut state, &named(NamedKey::Escape, Modifiers::default()));
5868
5869        assert!(
5870            outcome.handled,
5871            "Escape is answered, not left for an ancestor"
5872        );
5873        assert!(!root.is_focus_active(), "the session is over");
5874        assert!(!widget(&root).focused);
5875        assert!(
5876            !widget(&root).toolbar_open,
5877            "the toolbar goes with the session it belonged to"
5878        );
5879        assert_eq!(widget(&root).editor.text(), "abc", "and nothing was edited");
5880        assert_eq!(state.changes, 0);
5881    }
5882
5883    #[test]
5884    fn disabled_still_refuses_focus_where_read_only_no_longer_does() {
5885        // `enabled(false)` is the stronger claim and is unchanged by the
5886        // read-only split: no focus, no caret, no surface, nothing consumed.
5887        let mut state = AppState {
5888            value: "abc".to_string(),
5889            ..AppState::default()
5890        };
5891        let mut logic = options_logic(false, false, false);
5892        let mut root = options_root(&mut logic, &mut state);
5893
5894        let outcome = root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
5895        assert!(!outcome.handled, "a disabled field consumes nothing");
5896        assert!(!root.is_focus_active(), "and takes no focus");
5897        assert!(root.ime_state().is_none(), "so it publishes no surface");
5898
5899        let mut rec = CaretRecorder {
5900            caret_color: Some(CARET),
5901            caret_fills: 0,
5902        };
5903        let outcome = root.paint(&mut rec, FrameTime::ZERO);
5904        assert_eq!(rec.caret_fills, 0, "a disabled field paints no caret");
5905        assert!(!outcome.needs_frame);
5906    }
5907
5908    #[test]
5909    fn making_a_focused_field_read_only_keeps_the_session_and_drops_the_keyboard() {
5910        // Unlike a field disabled while focused, this one keeps its session:
5911        // the text is still on screen and still worth selecting. What goes is
5912        // the on-screen keyboard, asked for on the very next published surface.
5913        let mut state = AppState {
5914            value: "abc".to_string(),
5915            ..AppState::default()
5916        };
5917        let mut live_logic = options_logic(true, false, false);
5918        let mut root = options_root(&mut live_logic, &mut state);
5919        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
5920        assert!(root.is_focus_active());
5921        let ime = root.ime_state().expect("focused");
5922        assert!(ime.active && !ime.suppress_soft_keyboard);
5923
5924        // Flip the flag on a rebuild while the field holds focus.
5925        let mut read_only_logic = options_logic(true, false, true);
5926        let flags = root.rebuild(&mut read_only_logic, &mut state);
5927        assert!(
5928            !flags.needs_layout(),
5929            "read-only never dims, so the flip is PAINT-only, unlike disabled"
5930        );
5931        root.layout(Size::new(300.0, 200.0));
5932        assert!(
5933            widget(&root).focused,
5934            "the widget keeps considering itself focused"
5935        );
5936
5937        // Leg 1 — the next paint keeps the session and re-publishes it with the
5938        // keyboard suppressed; the caret is still drawn, now steady.
5939        let mut rec = CaretRecorder {
5940            caret_color: Some(CARET),
5941            caret_fills: 0,
5942        };
5943        root.paint(&mut rec, FrameTime::ZERO);
5944        assert_eq!(rec.caret_fills, 1, "the caret survives the flip");
5945        let ime = root.ime_state().expect("the session survives the flip");
5946        assert!(ime.active, "the surface stays live for the clipboard route");
5947        assert!(ime.suppress_soft_keyboard, "but the keyboard is asked down");
5948        assert!(root.is_focus_active(), "the root's focus mirror stays");
5949
5950        // Leg 2 — and the keyboard events it can no longer act on are refused
5951        // without disturbing any of that.
5952        root.event(&mut state, &ch("x"));
5953        assert!(root.is_focus_active(), "a refused key does not blur");
5954        assert_eq!(widget(&root).editor.text(), "abc");
5955        assert_eq!(state.changes, 0);
5956    }
5957
5958    #[test]
5959    fn read_only_paints_full_alpha_chrome_in_every_design_language() {
5960        // The first of the two dimming resolution points: `Chrome::resolve`,
5961        // observed here through the actual `paint` pass (not called directly),
5962        // so the assertion also proves `paint` feeds it `enabled` alone.
5963        let languages: Vec<(&str, Option<Theme>)> = vec![
5964            ("unthemed", None),
5965            ("neutral", Some(Theme::neutral())),
5966            ("translucent-role", Some(translucent_role_theme())),
5967        ];
5968        for (name, theme) in languages {
5969            let mut state = AppState::default();
5970            let mut logic = options_logic(true, false, true);
5971            let mut root = options_root(&mut logic, &mut state);
5972            if let Some(theme) = theme.clone() {
5973                root.set_theme(Box::new(theme));
5974            }
5975            let live_border = Chrome::resolve(theme.as_ref(), true).border;
5976            let rec = paint_chrome(&mut root);
5977            assert_eq!(
5978                rec.rrects[0], live_border,
5979                "{name}: a read-only field's idle border matches the fully-\
5980                 enabled resolved border — not `DISABLED_CONTAINER_ALPHA`-dimmed"
5981            );
5982        }
5983    }
5984
5985    #[test]
5986    fn read_only_paints_full_alpha_layout_baked_glyph_color() {
5987        // The second of the two dimming resolution points:
5988        // `effective_style`'s LAYOUT-time bake.
5989        let mut state = AppState {
5990            value: "hi".to_string(),
5991            ..AppState::default()
5992        };
5993        let mut logic = options_logic(true, false, true);
5994        let mut root = options_root(&mut logic, &mut state);
5995        root.set_theme(Box::new(Theme::neutral()));
5996        root.layout(Size::new(300.0, 200.0));
5997
5998        assert_eq!(
5999            painted_text_color(&mut root),
6000            Theme::neutral().scheme().on_surface,
6001            "a read-only field's glyphs stay full-alpha on_surface, not dimmed"
6002        );
6003    }
6004
6005    #[test]
6006    fn read_only_paints_full_alpha_unthemed_fallback_glyph_color() {
6007        let mut state = AppState {
6008            value: "hi".to_string(),
6009            ..AppState::default()
6010        };
6011        let mut logic = options_logic(true, false, true);
6012        let mut root = options_root(&mut logic, &mut state);
6013        assert_eq!(
6014            painted_text_color(&mut root),
6015            Color::BLACK,
6016            "with no theme threaded, read-only stays the undimmed black fallback"
6017        );
6018    }
6019
6020    #[test]
6021    fn switching_a_read_only_field_to_live_shows_no_alpha_change() {
6022        // The motivating case: a static mock rendered read-only then switched
6023        // interactive at a live handoff must show **no** alpha change at
6024        // either resolution point — the pop `enabled(false)` would have
6025        // produced.
6026        let mut state = AppState {
6027            value: "hi".to_string(),
6028            ..AppState::default()
6029        };
6030        let mut read_only_logic = options_logic(true, false, true);
6031        let mut root = options_root(&mut read_only_logic, &mut state);
6032        let before_glyph = painted_text_color(&mut root);
6033        let before_border = paint_chrome(&mut root).rrects[0];
6034
6035        let mut live_logic = options_logic(true, false, false);
6036        root.rebuild(&mut live_logic, &mut state);
6037        root.layout(Size::new(300.0, 200.0));
6038        let after_glyph = painted_text_color(&mut root);
6039        let after_border = paint_chrome(&mut root).rrects[0];
6040
6041        assert_eq!(
6042            before_glyph, after_glyph,
6043            "glyph color must not change across the read-only -> live handoff"
6044        );
6045        assert_eq!(
6046            before_border, after_border,
6047            "border color must not change across the read-only -> live handoff"
6048        );
6049        assert_eq!(
6050            before_glyph,
6051            Color::BLACK,
6052            "sanity: read-only starts undimmed"
6053        );
6054    }
6055
6056    /// The clipboard verbs a field's own semantics node currently offers, as
6057    /// `(id, label)` pairs in publication order.
6058    fn a11y_verbs(
6059        root: &RenderRoot<AppState, TextInputView<AppState>>,
6060    ) -> (bool, Vec<(i32, String)>) {
6061        let update = root.semantics();
6062        let (_, node) = update
6063            .nodes
6064            .iter()
6065            .find(|(_, n)| matches!(n.role(), Role::TextInput | Role::PasswordInput))
6066            .expect("a text field node");
6067        (
6068            node.supports_action(Action::CustomAction),
6069            node.custom_actions()
6070                .iter()
6071                .map(|a| (a.id, a.description.to_string()))
6072                .collect(),
6073        )
6074    }
6075
6076    #[test]
6077    fn the_clipboard_verbs_ride_the_field_s_own_node_with_the_toolbar_closed() {
6078        // The accessible route must not depend on the floating toolbar, which
6079        // is a pointer affordance and contributes no semantics of its own.
6080        let mut state = AppState::default();
6081        let mut root = harness(&mut state);
6082        focused_with_selection(&mut state, &mut root, "abc");
6083        assert!(
6084            !widget(&root).toolbar_open,
6085            "sanity: a drag-select raises no bar, so this is the closed case"
6086        );
6087
6088        let (supports, offered) = a11y_verbs(&root);
6089        assert!(
6090            supports,
6091            "the field advertises that it takes custom actions at all"
6092        );
6093        assert_eq!(
6094            offered,
6095            vec![
6096                (A11Y_CUT_ID, "Cut".to_string()),
6097                (A11Y_COPY_ID, "Copy".to_string()),
6098                (A11Y_PASTE_ID, "Paste".to_string()),
6099            ],
6100            "an interactive field with all of its text selected offers cut/copy/\
6101             paste, and no select-all — exactly the bar's own enabled set"
6102        );
6103
6104        // Invoking one reaches `handle_command`: resolve the advertised id the
6105        // way a dispatcher would, deliver it as the `EditCommand` a shell
6106        // already sends for a platform edit menu, and watch the verb land.
6107        let copy_id = offered
6108            .iter()
6109            .find(|(_, label)| label == "Copy")
6110            .expect("copy was offered")
6111            .0;
6112        let cmd = match copy_id {
6113            A11Y_CUT_ID => EditCommand::Cut,
6114            A11Y_COPY_ID => EditCommand::Copy,
6115            A11Y_SELECT_ALL_ID => EditCommand::SelectAll,
6116            other => panic!("the published id {other} resolves to no verb"),
6117        };
6118        root.event(&mut state, &edit(cmd));
6119        assert_eq!(
6120            root.take_clipboard_write().as_deref(),
6121            Some("abc"),
6122            "the advertised id resolves to a verb that reaches handle_command"
6123        );
6124    }
6125
6126    #[test]
6127    fn the_published_verbs_track_the_field_s_own_refusals() {
6128        // Empty and unfocused: nothing to copy or select, but a paste would
6129        // land, so paste alone is offered.
6130        let mut state = AppState::default();
6131        let mut logic = options_logic(true, false, false);
6132        let root = options_root(&mut logic, &mut state);
6133        assert_eq!(
6134            a11y_verbs(&root).1,
6135            vec![(A11Y_PASTE_ID, "Paste".to_string())],
6136            "an empty field offers only paste"
6137        );
6138
6139        // Obscured: the mirror is no more handable than the real buffer, so a
6140        // selection buys neither copy nor cut.
6141        let mut state = AppState {
6142            value: "hunter2".to_string(),
6143            ..Default::default()
6144        };
6145        let mut logic = options_logic(true, true, false);
6146        let mut root = options_root(&mut logic, &mut state);
6147        focused_with_selection(&mut state, &mut root, "");
6148        assert!(
6149            selection(&root).is_some(),
6150            "sanity: the refusal below is only meaningful over a real selection"
6151        );
6152        assert_eq!(
6153            a11y_verbs(&root).1,
6154            vec![(A11Y_PASTE_ID, "Paste".to_string())],
6155            "an obscured field hands out neither the buffer nor its bullets"
6156        );
6157
6158        // Read-only: copy is fine over a selection, cut and paste are not —
6159        // the field is focusable and copyable, so the accessible route carries
6160        // exactly the verbs it will actually answer.
6161        let mut state = AppState {
6162            value: "abc".to_string(),
6163            ..Default::default()
6164        };
6165        let mut logic = options_logic(true, false, true);
6166        let mut root = options_root(&mut logic, &mut state);
6167        drag_select_all(&mut state, &mut root);
6168        assert_eq!(
6169            a11y_verbs(&root).1,
6170            vec![(A11Y_COPY_ID, "Copy".to_string())],
6171            "a read-only field offers copy over its selection, never cut or paste"
6172        );
6173
6174        // Disabled: same reasoning, and the stronger claim.
6175        let mut state = AppState {
6176            value: "abc".to_string(),
6177            ..Default::default()
6178        };
6179        let mut logic = options_logic(false, false, false);
6180        let root = options_root(&mut logic, &mut state);
6181        assert!(
6182            a11y_verbs(&root).1.is_empty(),
6183            "a disabled field advertises no verbs either"
6184        );
6185    }
6186
6187    #[test]
6188    fn read_only_reports_read_only_not_disabled_semantics() {
6189        let mut state = AppState::default();
6190        let mut logic = options_logic(true, false, true);
6191        let root = options_root(&mut logic, &mut state);
6192        let update = root.semantics();
6193        let (_, node) = update
6194            .nodes
6195            .iter()
6196            .find(|(_, n)| n.role() == Role::TextInput)
6197            .expect("a text field node");
6198        assert!(
6199            node.is_read_only(),
6200            "a read-only field says so to a11y, distinct from disabled"
6201        );
6202        assert!(
6203            !node.is_disabled(),
6204            "read-only is not the same claim as disabled"
6205        );
6206    }
6207
6208    #[test]
6209    fn disabled_wins_semantics_over_read_only_when_both_are_set() {
6210        let mut state = AppState::default();
6211        let mut logic = options_logic(false, false, true);
6212        let root = options_root(&mut logic, &mut state);
6213        let update = root.semantics();
6214        let (_, node) = update
6215            .nodes
6216            .iter()
6217            .find(|(_, n)| n.role() == Role::TextInput)
6218            .expect("a text field node");
6219        assert!(node.is_disabled(), "disabled is the stronger claim");
6220        assert!(
6221            !node.is_read_only(),
6222            "disabled and read-only are never both reported"
6223        );
6224    }
6225
6226    #[test]
6227    fn read_only_obscured_field_stays_password_role_and_copies_nothing() {
6228        // Orthogonality: `read_only` never touches masking or the IME
6229        // content-type hint. The two refusals compose rather than cancel — the
6230        // field focuses like any read-only one, and hands out neither the
6231        // secret nor its bullets.
6232        let mut state = AppState {
6233            value: "hunter2".to_string(),
6234            ..AppState::default()
6235        };
6236        let mut logic = options_logic(true, true, true);
6237        let mut root = options_root(&mut logic, &mut state);
6238
6239        let update = root.semantics();
6240        let (_, node) = update
6241            .nodes
6242            .iter()
6243            .find(|(_, n)| n.role() == Role::PasswordInput)
6244            .expect("read-only + obscured still contributes Role::PasswordInput");
6245        assert_eq!(node.value(), Some("\u{2022}".repeat(7).as_str()));
6246        assert!(node.is_read_only());
6247
6248        drag_select_all(&mut state, &mut root);
6249        assert!(
6250            root.is_focus_active(),
6251            "it focuses like any read-only field"
6252        );
6253        assert!(
6254            selection(&root).is_some(),
6255            "sanity: the refusal below is only meaningful over a real selection"
6256        );
6257
6258        let ime = root.ime_state().expect("focused");
6259        assert_eq!(
6260            ime.content_type,
6261            ImeContentType::Password,
6262            "the content-type hint is read-only's business to leave alone"
6263        );
6264        assert!(ime.suppress_soft_keyboard);
6265
6266        root.event(&mut state, &chord("c", meta()));
6267        assert_eq!(
6268            root.take_clipboard_write(),
6269            None,
6270            "an obscured field copies nothing, read-only or not"
6271        );
6272        assert!(
6273            a11y_verbs(&root).1.is_empty(),
6274            "and advertises no verb it would refuse"
6275        );
6276    }
6277
6278    // --- obscured(true) ---
6279
6280    #[test]
6281    fn mask_offsets_map_1_to_1_per_char_across_a_multi_byte_grapheme() {
6282        // "a😀b": 1 + 4 + 1 real bytes; masked "•••" is 3 × 3 bytes.
6283        let text = "a\u{1F600}b";
6284        assert_eq!(mask_text(text), "\u{2022}\u{2022}\u{2022}");
6285        for (real, masked) in [(0, 0), (1, 3), (5, 6), (6, 9)] {
6286            assert_eq!(real_to_masked(text, real), masked, "real {real} -> masked");
6287            assert_eq!(
6288                masked_to_real(text, masked),
6289                real,
6290                "masked {masked} -> real"
6291            );
6292        }
6293        // Interior offsets snap back to the enclosing character's start, both
6294        // ways (the emoji spans real bytes 1..5 and masked bytes 3..6).
6295        assert_eq!(real_to_masked(text, 3), 3, "mid-emoji snaps to its start");
6296        assert_eq!(masked_to_real(text, 4), 1, "mid-mask snaps to its start");
6297        // A newline is preserved so a multi-line field keeps its line count.
6298        assert_eq!(mask_text("a\nb"), "\u{2022}\n\u{2022}");
6299    }
6300
6301    #[test]
6302    fn obscured_paints_bullets_and_keeps_the_real_value() {
6303        /// Records the glyph ids of every painted run, in order.
6304        #[derive(Default)]
6305        struct GlyphIdRecorder {
6306            ids: Vec<u16>,
6307        }
6308        impl PaintScene for GlyphIdRecorder {
6309            fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
6310            fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, _c: Color) {}
6311            fn draw_text(&mut self, _o: Point, _t: &str) {}
6312            fn draw_glyph_run(&mut self, run: frust_scene::GlyphRun) {
6313                self.ids.extend(run.glyphs.iter().map(|g| g.id as u16));
6314            }
6315        }
6316
6317        fn ids(root: &mut RenderRoot<AppState, TextInputView<AppState>>) -> Vec<u16> {
6318            let mut rec = GlyphIdRecorder::default();
6319            root.paint(&mut rec, FrameTime::ZERO);
6320            rec.ids
6321        }
6322
6323        // An obscured field holding "abc" must paint exactly what a plain field
6324        // holding "•••" paints — and nothing of what "abc" paints.
6325        let mut secret_state = AppState {
6326            value: "abc".to_string(),
6327            ..AppState::default()
6328        };
6329        let mut secret_logic = options_logic(true, true, false);
6330        let mut secret = options_root(&mut secret_logic, &mut secret_state);
6331
6332        let mut bullets_state = AppState {
6333            value: "\u{2022}\u{2022}\u{2022}".to_string(),
6334            ..AppState::default()
6335        };
6336        let mut plain_logic = options_logic(true, false, false);
6337        let mut bullets = options_root(&mut plain_logic, &mut bullets_state);
6338
6339        let mut clear_state = AppState {
6340            value: "abc".to_string(),
6341            ..AppState::default()
6342        };
6343        let mut clear = options_root(&mut plain_logic, &mut clear_state);
6344
6345        assert_eq!(
6346            ids(&mut secret),
6347            ids(&mut bullets),
6348            "an obscured field paints bullets"
6349        );
6350        assert_ne!(
6351            ids(&mut secret),
6352            ids(&mut clear),
6353            "…and not the real characters"
6354        );
6355        assert_eq!(
6356            widget(&secret).editor.text(),
6357            "abc",
6358            "the model text is untouched by masking"
6359        );
6360    }
6361
6362    #[test]
6363    fn obscured_measures_from_the_masked_text_not_the_real_one() {
6364        // Masking at glyph-emission time only would leave the field measured
6365        // from the real text: an 'i'-heavy secret would size like 'i's while
6366        // painting bullets. The mirror is what gets measured, so an obscured
6367        // field's width matches the equivalent bullet string's.
6368        let mut secret_state = AppState {
6369            value: "iiiiiiiiii".to_string(),
6370            ..AppState::default()
6371        };
6372        let mut secret_logic = options_logic(true, true, false);
6373        let secret = options_root(&mut secret_logic, &mut secret_state);
6374
6375        let mut bullets_state = AppState {
6376            value: "\u{2022}".repeat(10),
6377            ..AppState::default()
6378        };
6379        let mut plain_logic = options_logic(true, false, false);
6380        let bullets = options_root(&mut plain_logic, &mut bullets_state);
6381
6382        let masked_w = widget(&secret).display().layout_size().width;
6383        let bullets_w = widget(&bullets).display().layout_size().width;
6384        let real_w = widget(&secret).editor.layout_size().width;
6385        assert_eq!(masked_w, bullets_w, "measured from the masked mirror");
6386        assert!(
6387            masked_w > real_w,
6388            "sanity: bullets are wider than 'i's ({masked_w} vs {real_w})"
6389        );
6390    }
6391
6392    #[test]
6393    fn obscured_editing_matches_unobscured_across_a_multi_byte_grapheme() {
6394        // The masking must not disturb the editing arithmetic: run the same
6395        // key sequence on an obscured and a clear field and require identical
6396        // editing state at every step, including over a 4-byte emoji.
6397        let mut secret_state = AppState::default();
6398        let mut secret_logic = options_logic(true, true, false);
6399        let mut secret = options_root(&mut secret_logic, &mut secret_state);
6400        let mut clear_state = AppState::default();
6401        let mut clear_logic = options_logic(true, false, false);
6402        let mut clear = options_root(&mut clear_logic, &mut clear_state);
6403
6404        let plain = Modifiers::default();
6405        let meta = Modifiers {
6406            meta: true,
6407            ..Modifiers::default()
6408        };
6409        let events = vec![
6410            pointer(PointerPhase::Down, 10.0, 10.0),
6411            ch("a"),
6412            ch("\u{1F600}"),
6413            ch("b"),
6414            named(NamedKey::ArrowLeft, plain),
6415            named(NamedKey::Backspace, plain),
6416            named(NamedKey::End, plain),
6417            named(NamedKey::Backspace, plain),
6418            InputEvent::Key(KeyEvent {
6419                key: Key::Character("a".to_string()),
6420                modifiers: meta,
6421                repeat: false,
6422            }),
6423        ];
6424        for (i, event) in events.iter().enumerate() {
6425            secret.event(&mut secret_state, event);
6426            clear.event(&mut clear_state, event);
6427            assert_eq!(
6428                widget(&secret).editor.editing_state_bytes(),
6429                widget(&clear).editor.editing_state_bytes(),
6430                "editing state diverged at step {i}"
6431            );
6432        }
6433        // The emoji was deleted as one grapheme in both, and the app saw the
6434        // real text throughout.
6435        assert_eq!(secret_state.value, "a");
6436        assert_eq!(secret_state.value, clear_state.value);
6437        assert_eq!(secret_state.changes, clear_state.changes);
6438    }
6439
6440    #[test]
6441    fn obscured_caret_rect_follows_the_masked_layout() {
6442        // The caret must sit where the *bullets* end, not where the real text
6443        // would have ended.
6444        let mut secret_state = AppState {
6445            value: "iiii".to_string(),
6446            ..AppState::default()
6447        };
6448        let mut secret_logic = options_logic(true, true, false);
6449        let mut secret = options_root(&mut secret_logic, &mut secret_state);
6450        secret.event(&mut secret_state, &pointer(PointerPhase::Down, 290.0, 10.0));
6451        let masked_caret = secret
6452            .ime_state()
6453            .expect("focused")
6454            .caret
6455            .expect("a caret rect");
6456
6457        let mut bullets_state = AppState {
6458            value: "\u{2022}".repeat(4),
6459            ..AppState::default()
6460        };
6461        let mut plain_logic = options_logic(true, false, false);
6462        let mut bullets = options_root(&mut plain_logic, &mut bullets_state);
6463        bullets.event(
6464            &mut bullets_state,
6465            &pointer(PointerPhase::Down, 290.0, 10.0),
6466        );
6467        let bullets_caret = bullets
6468            .ime_state()
6469            .expect("focused")
6470            .caret
6471            .expect("a caret rect");
6472
6473        assert_eq!(
6474            masked_caret, bullets_caret,
6475            "the obscured caret tracks the masked glyphs"
6476        );
6477        assert_eq!(
6478            secret.ime_state().expect("focused").editing.text,
6479            "iiii",
6480            "the IME surface still carries the real text (see the module docs)"
6481        );
6482    }
6483
6484    #[test]
6485    fn obscured_tap_places_the_caret_from_the_masked_hit_test() {
6486        // A tap between the first and second bullet must land on real byte 1,
6487        // resolved against the masked advances (bullets are much wider than
6488        // 'i's, so hit-testing the real layout would overshoot to the end).
6489        let mut state = AppState {
6490            value: "iiiiiiii".to_string(),
6491            ..AppState::default()
6492        };
6493        let mut logic = options_logic(true, true, false);
6494        let mut root = options_root(&mut logic, &mut state);
6495        // Mid-way through the first mask glyph -> caret before or after char 0.
6496        let half_bullet = widget(&root).display().layout_size().width / 16.0;
6497        root.event(
6498            &mut state,
6499            &pointer(PointerPhase::Down, PAD_X + half_bullet, 10.0),
6500        );
6501        let extent = widget(&root).editor.editing_state_bytes().extent;
6502        assert!(
6503            extent <= 1,
6504            "a tap inside the first mask glyph lands at byte 0 or 1, got {extent}"
6505        );
6506    }
6507
6508    #[test]
6509    fn obscured_reports_password_role_with_a_masked_value() {
6510        let mut state = AppState {
6511            value: "hunter2".to_string(),
6512            ..AppState::default()
6513        };
6514        let mut logic = options_logic(true, true, false);
6515        let root = options_root(&mut logic, &mut state);
6516        let update = root.semantics();
6517        let (_, node) = update
6518            .nodes
6519            .iter()
6520            .find(|(_, n)| n.role() == Role::PasswordInput)
6521            .expect("an obscured field contributes a Role::PasswordInput node");
6522        assert_eq!(
6523            node.value(),
6524            Some("\u{2022}".repeat(7).as_str()),
6525            "the a11y value is masked too — a client reads it verbatim"
6526        );
6527
6528        // Enabled + clear stays a plain TextInput node carrying the real value.
6529        let mut plain_logic = options_logic(true, false, false);
6530        let plain = options_root(&mut plain_logic, &mut state);
6531        let update = plain.semantics();
6532        let (_, node) = update
6533            .nodes
6534            .iter()
6535            .find(|(_, n)| n.role() == Role::TextInput)
6536            .expect("a clear field stays Role::TextInput");
6537        assert_eq!(node.value(), Some("hunter2"));
6538        assert!(!node.is_disabled(), "an enabled field is not disabled");
6539    }
6540
6541    #[test]
6542    fn disabled_reports_disabled_semantics() {
6543        let mut state = AppState::default();
6544        let mut logic = options_logic(false, false, false);
6545        let root = options_root(&mut logic, &mut state);
6546        let update = root.semantics();
6547        let (_, node) = update
6548            .nodes
6549            .iter()
6550            .find(|(_, n)| n.role() == Role::TextInput)
6551            .expect("a text field node");
6552        assert!(node.is_disabled(), "a disabled field says so to a11y");
6553    }
6554
6555    #[test]
6556    fn toggling_obscured_relayouts_and_keeps_the_value() {
6557        let mut state = AppState {
6558            value: "iiiiiiii".to_string(),
6559            ..AppState::default()
6560        };
6561        let mut clear_logic = options_logic(true, false, false);
6562        let mut root = options_root(&mut clear_logic, &mut state);
6563        let clear_width = widget(&root).display().layout_size().width;
6564
6565        let mut secret_logic = options_logic(true, true, false);
6566        let flags = root.rebuild(&mut secret_logic, &mut state);
6567        assert!(
6568            flags.needs_layout(),
6569            "masking changes the measured text, so the flip must relayout"
6570        );
6571        root.layout(Size::new(300.0, 200.0));
6572        assert!(
6573            widget(&root).display().layout_size().width > clear_width,
6574            "the masked mirror is measured after the flip"
6575        );
6576        assert_eq!(widget(&root).editor.text(), "iiiiiiii");
6577
6578        // …and back again.
6579        root.rebuild(&mut clear_logic, &mut state);
6580        root.layout(Size::new(300.0, 200.0));
6581        assert_eq!(widget(&root).display().layout_size().width, clear_width);
6582        assert!(widget(&root).mask_editor.is_none());
6583    }
6584
6585    // --- content_type (widget half) ---
6586    //
6587    // These lock the widget's IME content-type mapping only — the leak this
6588    // closes lives at the platform seam, and no widget-level test can observe
6589    // whether a shell actually honours the hint. See the module docs' scope
6590    // boundary and `ImeContentType`'s own docs.
6591
6592    #[test]
6593    fn obscured_publishes_password_content_type_across_focus_edit_and_refocus() {
6594        let mut state = AppState {
6595            value: "hunter2".to_string(),
6596            ..AppState::default()
6597        };
6598        let mut logic = options_logic(true, true, false);
6599        let mut root = options_root(&mut logic, &mut state);
6600
6601        // Focus.
6602        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
6603        assert_eq!(
6604            root.ime_state().expect("focused").content_type,
6605            ImeContentType::Password,
6606            "an obscured field's very first published IME surface must already \
6607             be Password"
6608        );
6609
6610        // Edit.
6611        root.event(&mut state, &ch("x"));
6612        assert_eq!(
6613            root.ime_state().expect("still focused").content_type,
6614            ImeContentType::Password,
6615            "content type must not drop on edit"
6616        );
6617
6618        // Blur, then re-focus.
6619        root.event(&mut state, &named(NamedKey::Escape, Modifiers::default()));
6620        assert!(root.ime_state().is_none(), "blur unpublishes the surface");
6621        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
6622        assert_eq!(
6623            root.ime_state().expect("re-focused").content_type,
6624            ImeContentType::Password,
6625            "content type must be correct again on re-focus, not just the first time"
6626        );
6627    }
6628
6629    #[test]
6630    fn unobscured_field_publishes_the_default_no_hint_content_type() {
6631        // No behaviour change for the common case: a plain field's published
6632        // surface keeps the `Normal` default it always had.
6633        let mut state = AppState::default();
6634        let mut root = harness(&mut state);
6635
6636        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
6637        assert_eq!(
6638            root.ime_state().expect("focused").content_type,
6639            ImeContentType::Normal
6640        );
6641
6642        root.event(&mut state, &ch("h"));
6643        assert_eq!(
6644            root.ime_state().expect("still focused").content_type,
6645            ImeContentType::Normal
6646        );
6647    }
6648
6649    #[test]
6650    fn obscured_content_type_is_correct_on_the_first_publication_after_focus() {
6651        // The ordering guarantee the security fix rests on: a field that starts
6652        // `Normal` and flips to `Password` a frame later has already leaked to
6653        // the platform IME (see the module docs). Assert there is no such
6654        // window by checking the *very first* `ImeState` a freshly built,
6655        // never-before-focused obscured field emits — a single `Down` event,
6656        // nothing before it.
6657        let mut state = AppState {
6658            value: "hunter2".to_string(),
6659            ..AppState::default()
6660        };
6661        let mut logic = options_logic(true, true, false);
6662        let mut root = options_root(&mut logic, &mut state);
6663        assert!(
6664            root.ime_state().is_none(),
6665            "an unfocused field publishes nothing yet"
6666        );
6667
6668        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
6669
6670        let ime = root
6671            .ime_state()
6672            .expect("focusing publishes the first IME surface");
6673        assert_eq!(
6674            ime.content_type,
6675            ImeContentType::Password,
6676            "the first-ever publication for an obscured field must already \
6677             carry Password"
6678        );
6679        assert_eq!(
6680            ime.editing.text, "hunter2",
6681            "sanity: this is the real first publication, not a stale one"
6682        );
6683    }
6684
6685    // --- Clipboard and selection commands ---
6686
6687    /// A decoded clipboard verb, focus-routed like a key — the same event
6688    /// `frust-testing`'s `edit_command` builds, spelled locally because
6689    /// `frust-widgets` takes no edge on that crate.
6690    fn edit(cmd: EditCommand) -> InputEvent {
6691        InputEvent::EditCommand(cmd)
6692    }
6693
6694    /// Focus the field, type `text`, then drag-select the whole buffer — the
6695    /// selection a copy/cut acts on, produced the way a user produces it.
6696    fn focused_with_selection(
6697        state: &mut AppState,
6698        root: &mut RenderRoot<AppState, TextInputView<AppState>>,
6699        text: &str,
6700    ) {
6701        root.event(state, &pointer(PointerPhase::Down, 10.0, 10.0));
6702        for c in text.chars() {
6703            root.event(state, &ch(&c.to_string()));
6704        }
6705        // Press at the left edge (before the first glyph), drag past the last.
6706        root.event(state, &pointer(PointerPhase::Down, 0.0, 10.0));
6707        root.event(state, &pointer(PointerPhase::Move, 290.0, 10.0));
6708        root.event(state, &pointer(PointerPhase::Up, 290.0, 10.0));
6709    }
6710
6711    /// The ctrl/meta chord modifier the clipboard shortcuts key off.
6712    fn meta() -> Modifiers {
6713        Modifiers {
6714            meta: true,
6715            ..Modifiers::default()
6716        }
6717    }
6718
6719    fn ctrl() -> Modifiers {
6720        Modifiers {
6721            ctrl: true,
6722            ..Modifiers::default()
6723        }
6724    }
6725
6726    fn shift() -> Modifiers {
6727        Modifiers {
6728            shift: true,
6729            ..Modifiers::default()
6730        }
6731    }
6732
6733    fn chord(text: &str, modifiers: Modifiers) -> InputEvent {
6734        InputEvent::Key(KeyEvent {
6735            key: Key::Character(text.to_string()),
6736            modifiers,
6737            repeat: false,
6738        })
6739    }
6740
6741    #[test]
6742    fn copy_writes_the_selection_and_edits_nothing() {
6743        let mut state = AppState::default();
6744        let mut root = harness(&mut state);
6745        focused_with_selection(&mut state, &mut root, "abc");
6746        assert_eq!(
6747            widget(&root).editor.selected_text(),
6748            Some("abc"),
6749            "the drag selected the whole buffer"
6750        );
6751        let changes = state.changes;
6752
6753        root.event(&mut state, &edit(EditCommand::Copy));
6754
6755        assert_eq!(root.take_clipboard_write().as_deref(), Some("abc"));
6756        assert_eq!(widget(&root).editor.text(), "abc", "a copy mutates nothing");
6757        assert_eq!(state.changes, changes, "a copy is not an edit");
6758        assert_eq!(state.value, "abc");
6759    }
6760
6761    #[test]
6762    fn cut_writes_the_selection_removes_it_and_reports_one_change() {
6763        let mut state = AppState::default();
6764        let mut root = harness(&mut state);
6765        focused_with_selection(&mut state, &mut root, "abc");
6766        let changes = state.changes;
6767
6768        root.event(&mut state, &edit(EditCommand::Cut));
6769
6770        assert_eq!(root.take_clipboard_write().as_deref(), Some("abc"));
6771        assert_eq!(widget(&root).editor.text(), "", "the selection is gone");
6772        assert_eq!(state.changes, changes + 1, "one on_change, not two");
6773        assert_eq!(state.value, "");
6774    }
6775
6776    #[test]
6777    fn copy_and_cut_do_nothing_without_a_selection() {
6778        let mut state = AppState::default();
6779        let mut root = harness(&mut state);
6780        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
6781        for c in ["a", "b"] {
6782            root.event(&mut state, &ch(c));
6783        }
6784        let changes = state.changes;
6785
6786        root.event(&mut state, &edit(EditCommand::Copy));
6787        assert!(root.take_clipboard_write().is_none());
6788        // A collapsed cut must not fall back to deleting the grapheme its
6789        // `Backdelete` would otherwise take.
6790        root.event(&mut state, &edit(EditCommand::Cut));
6791        assert!(root.take_clipboard_write().is_none());
6792
6793        assert_eq!(widget(&root).editor.text(), "ab");
6794        assert_eq!(state.changes, changes, "neither verb fired on_change");
6795    }
6796
6797    #[test]
6798    fn paste_into_a_single_line_field_strips_newlines_and_replaces_the_selection() {
6799        let mut state = AppState::default();
6800        let mut root = harness(&mut state);
6801        focused_with_selection(&mut state, &mut root, "xy");
6802        let changes = state.changes;
6803
6804        root.event(&mut state, &edit(EditCommand::Paste("a\nb".to_string())));
6805
6806        assert_eq!(
6807            widget(&root).editor.text(),
6808            "ab",
6809            "the newline is denied and the selection replaced"
6810        );
6811        assert_eq!(state.changes, changes + 1, "one edit, one on_change");
6812        assert_eq!(state.value, "ab");
6813    }
6814
6815    #[test]
6816    fn paste_into_a_multiline_field_keeps_the_newline() {
6817        let mut state = AppState::default();
6818        let mut root = RenderRoot::new();
6819        root.rebuild(&mut multiline_logic, &mut state);
6820        root.layout(Size::new(300.0, 200.0));
6821        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
6822
6823        root.event(&mut state, &edit(EditCommand::Paste("a\nb".to_string())));
6824
6825        assert_eq!(widget(&root).editor.text(), "a\nb");
6826        assert_eq!(state.changes, 1);
6827    }
6828
6829    #[test]
6830    fn a_paste_sanitised_to_nothing_changes_nothing() {
6831        let mut state = AppState::default();
6832        let mut root = harness(&mut state);
6833        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
6834        root.event(&mut state, &ch("a"));
6835        let changes = state.changes;
6836
6837        let outcome = root.event(&mut state, &edit(EditCommand::Paste("\n".to_string())));
6838
6839        assert!(outcome.handled, "the verb was understood, and answered");
6840        assert_eq!(widget(&root).editor.text(), "a", "nothing was inserted");
6841        assert_eq!(state.changes, changes, "an empty paste is not an edit");
6842    }
6843
6844    #[test]
6845    fn select_all_via_the_command_selects_the_whole_buffer_without_typing() {
6846        let mut state = AppState::default();
6847        let mut root = harness(&mut state);
6848        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
6849        for c in ["a", "b", "c"] {
6850            root.event(&mut state, &ch(c));
6851        }
6852        let changes = state.changes;
6853
6854        root.event(&mut state, &edit(EditCommand::SelectAll));
6855
6856        assert_eq!(widget(&root).editor.selected_text(), Some("abc"));
6857        assert_eq!(state.changes, changes);
6858        assert_eq!(widget(&root).editor.text(), "abc");
6859    }
6860
6861    #[test]
6862    fn an_obscured_field_refuses_copy_and_cut_but_still_pastes() {
6863        let mut state = AppState::default();
6864        let mut logic = options_logic(true, true, false);
6865        let mut root = options_root(&mut logic, &mut state);
6866        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
6867        for c in ["a", "b", "c"] {
6868            root.event(&mut state, &ch(c));
6869        }
6870        root.event(&mut state, &edit(EditCommand::SelectAll));
6871        assert_eq!(widget(&root).editor.selected_text(), Some("abc"));
6872        let changes = state.changes;
6873
6874        // Neither the real buffer nor the bullet mirror may reach the clipboard.
6875        root.event(&mut state, &edit(EditCommand::Copy));
6876        assert!(root.take_clipboard_write().is_none());
6877        root.event(&mut state, &edit(EditCommand::Cut));
6878        assert!(root.take_clipboard_write().is_none());
6879        assert_eq!(
6880            widget(&root).editor.text(),
6881            "abc",
6882            "a refused cut deletes nothing"
6883        );
6884        assert_eq!(state.changes, changes);
6885
6886        // Writing *into* a password field is ordinary: the paste still lands,
6887        // replacing the (still intact) selection.
6888        root.event(&mut state, &edit(EditCommand::Paste("zz".to_string())));
6889        assert_eq!(widget(&root).editor.text(), "zz");
6890        assert_eq!(state.changes, changes + 1);
6891    }
6892
6893    #[test]
6894    fn meta_c_copies_and_meta_x_cuts() {
6895        let mut state = AppState::default();
6896        let mut root = harness(&mut state);
6897        focused_with_selection(&mut state, &mut root, "abc");
6898
6899        root.event(&mut state, &chord("c", meta()));
6900        assert_eq!(root.take_clipboard_write().as_deref(), Some("abc"));
6901        assert_eq!(widget(&root).editor.text(), "abc");
6902
6903        root.event(&mut state, &chord("X", meta()));
6904        assert_eq!(
6905            root.take_clipboard_write().as_deref(),
6906            Some("abc"),
6907            "the chord is ASCII case-insensitive"
6908        );
6909        assert_eq!(widget(&root).editor.text(), "");
6910    }
6911
6912    #[test]
6913    fn ctrl_v_and_shift_insert_request_a_paste_and_type_nothing() {
6914        let mut state = AppState::default();
6915        let mut root = harness(&mut state);
6916        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
6917        assert!(!root.take_paste_request());
6918
6919        root.event(&mut state, &chord("v", ctrl()));
6920        assert!(root.take_paste_request(), "Ctrl+V asks the shell to read");
6921        assert_eq!(widget(&root).editor.text(), "", "and types no 'v'");
6922
6923        root.event(&mut state, &named(NamedKey::Insert, shift()));
6924        assert!(
6925            root.take_paste_request(),
6926            "Shift+Insert is the legacy paste"
6927        );
6928
6929        root.event(&mut state, &named(NamedKey::Paste, Modifiers::default()));
6930        assert!(root.take_paste_request(), "so is the hardware Paste key");
6931
6932        assert_eq!(state.changes, 0, "asking for a paste is not an edit");
6933    }
6934
6935    #[test]
6936    fn ctrl_insert_copies_and_shift_delete_cuts() {
6937        let mut state = AppState::default();
6938        let mut root = harness(&mut state);
6939        focused_with_selection(&mut state, &mut root, "abc");
6940
6941        root.event(&mut state, &named(NamedKey::Insert, ctrl()));
6942        assert_eq!(root.take_clipboard_write().as_deref(), Some("abc"));
6943        assert_eq!(widget(&root).editor.text(), "abc");
6944
6945        root.event(&mut state, &named(NamedKey::Delete, shift()));
6946        assert_eq!(root.take_clipboard_write().as_deref(), Some("abc"));
6947        assert_eq!(widget(&root).editor.text(), "", "Shift+Delete is a cut");
6948    }
6949
6950    #[test]
6951    fn ctrl_shift_delete_stays_a_forward_delete() {
6952        let mut state = AppState::default();
6953        let mut root = harness(&mut state);
6954        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
6955        for c in ["a", "b"] {
6956            root.event(&mut state, &ch(c));
6957        }
6958        // Caret home, then Ctrl+Shift+Delete: an OS-level gesture, never a cut.
6959        root.event(&mut state, &named(NamedKey::Home, Modifiers::default()));
6960        root.event(
6961            &mut state,
6962            &named(
6963                NamedKey::Delete,
6964                Modifiers {
6965                    shift: true,
6966                    ctrl: true,
6967                    ..Modifiers::default()
6968                },
6969            ),
6970        );
6971
6972        assert!(root.take_clipboard_write().is_none(), "nothing was copied");
6973        assert_eq!(widget(&root).editor.text(), "b", "the grapheme ahead went");
6974    }
6975
6976    #[test]
6977    fn the_hardware_copy_and_cut_keys_resolve_to_the_same_verbs() {
6978        let mut state = AppState::default();
6979        let mut root = harness(&mut state);
6980        focused_with_selection(&mut state, &mut root, "abc");
6981
6982        root.event(&mut state, &named(NamedKey::Copy, Modifiers::default()));
6983        assert_eq!(root.take_clipboard_write().as_deref(), Some("abc"));
6984        assert_eq!(widget(&root).editor.text(), "abc");
6985
6986        root.event(&mut state, &named(NamedKey::Cut, Modifiers::default()));
6987        assert_eq!(root.take_clipboard_write().as_deref(), Some("abc"));
6988        assert_eq!(widget(&root).editor.text(), "");
6989    }
6990
6991    #[test]
6992    fn a_bare_insert_edits_nothing_and_is_left_unconsumed() {
6993        let mut state = AppState::default();
6994        let mut root = harness(&mut state);
6995        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
6996        root.event(&mut state, &ch("a"));
6997
6998        let outcome = root.event(&mut state, &named(NamedKey::Insert, Modifiers::default()));
6999
7000        assert!(!outcome.handled, "no overtype mode to toggle: not ours");
7001        assert!(!root.take_paste_request());
7002        assert_eq!(widget(&root).editor.text(), "a");
7003    }
7004
7005    #[test]
7006    fn an_unrecognized_chord_is_still_consumed_and_never_typed() {
7007        let mut state = AppState::default();
7008        let mut root = harness(&mut state);
7009        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
7010        root.event(&mut state, &ch("a"));
7011        let changes = state.changes;
7012
7013        let outcome = root.event(&mut state, &chord("z", meta()));
7014
7015        assert!(
7016            outcome.handled,
7017            "an undecoded chord is swallowed, not typed"
7018        );
7019        assert_eq!(widget(&root).editor.text(), "a", "no 'z' was inserted");
7020        assert_eq!(state.changes, changes);
7021        assert!(root.take_clipboard_write().is_none());
7022        assert!(!root.take_paste_request());
7023    }
7024
7025    #[test]
7026    fn an_unfocused_field_ignores_every_edit_command() {
7027        let mut state = AppState::default();
7028        let mut root = harness(&mut state);
7029
7030        for cmd in [
7031            EditCommand::SelectAll,
7032            EditCommand::Paste("hi".to_string()),
7033            EditCommand::Copy,
7034            EditCommand::Cut,
7035        ] {
7036            let outcome = root.event(&mut state, &edit(cmd));
7037            assert!(!outcome.handled, "a focus-routed verb reaches nobody");
7038        }
7039
7040        assert!(root.take_clipboard_write().is_none());
7041        assert_eq!(widget(&root).editor.text(), "");
7042        assert_eq!(state.changes, 0);
7043        assert!(!widget(&root).focused);
7044    }
7045
7046    // -----------------------------------------------------------------------
7047    // Selection gestures and the floated toolbar
7048    // -----------------------------------------------------------------------
7049
7050    /// Serialises every test that writes the process-global selection-toolbar
7051    /// slots. Rust runs a crate's tests in parallel threads sharing one process,
7052    /// so two of them installing a builder would see each other's writes —
7053    /// `frust_core::selection_toolbar`'s own tests keep the identical lock for
7054    /// the identical reason.
7055    static TOOLBAR_LOCK: Mutex<()> = Mutex::new(());
7056
7057    /// What the toolbar double recorded. `Arc<Mutex<_>>` rather than the
7058    /// `Rc<RefCell<_>>` a pod fixture would normally use, because a
7059    /// [`SelectionToolbarBuilder`] is a process-global `Send + Sync` closure.
7060    type ToolbarLog = Arc<Mutex<Vec<String>>>;
7061
7062    /// The double's fixed size, so a placement assertion has a rect to expect.
7063    const PROBE_SIZE: Size = Size::new(120.0, 40.0);
7064    /// The colour the double fills itself with — how a scene log tells the
7065    /// floated pod's paint apart from the field's own.
7066    const PROBE_COLOR: Color = Color::from_rgb8(0x11, 0x22, 0x33);
7067    /// The window every toolbar test lays out in (the `harness` window).
7068    const TOOLBAR_WINDOW: Size = Size::new(300.0, 200.0);
7069
7070    /// A recording paint sink that keeps colours and shapes apart, so a test can
7071    /// ask both "was the pod painted?" and "where?".
7072    #[derive(Default)]
7073    struct SceneLog {
7074        fills: Vec<(Point, Size, Color)>,
7075        rounded: Vec<(Point, Size)>,
7076    }
7077
7078    impl PaintScene for SceneLog {
7079        fn fill_rect(&mut self, o: Point, s: Size, c: Color) {
7080            self.fills.push((o, s, c));
7081        }
7082        fn fill_rounded_rect(&mut self, o: Point, s: Size, _r: f64, _c: Color) {
7083            self.rounded.push((o, s));
7084        }
7085        fn draw_text(&mut self, _o: Point, _t: &str) {}
7086    }
7087
7088    impl SceneLog {
7089        /// The pod's own fill, if it was painted at all.
7090        fn probe(&self) -> Option<(Point, Size)> {
7091            self.fills
7092                .iter()
7093                .find(|(_, _, c)| *c == PROBE_COLOR)
7094                .map(|(o, s, _)| (*o, *s))
7095        }
7096
7097        /// Whether the pod's fill was the **last** thing painted — i.e. it
7098        /// floated above the whole main tree instead of being drawn in place.
7099        fn probe_painted_last(&self) -> bool {
7100            self.fills.last().is_some_and(|(_, _, c)| *c == PROBE_COLOR)
7101        }
7102
7103        /// The field's own chrome rect (the first rounded rect it fills), which
7104        /// is where the field was laid out.
7105        fn field_rect(&self) -> Rect {
7106            let (o, s) = self
7107                .rounded
7108                .first()
7109                .copied()
7110                .expect("the field always paints its chrome");
7111            Rect::from_origin_size(o, s)
7112        }
7113    }
7114
7115    /// The floated toolbar double: a fixed-size rect that records the presses it
7116    /// receives and dispatches [`EditCommand::Copy`] on the release, standing in
7117    /// for whatever a design system installs.
7118    ///
7119    /// Deliberately **not** `crate::selection_toolbar`'s real bar: what is under
7120    /// test here is the field's hosting of a pod — placement, routing, the
7121    /// command drain — not anyone's button layout.
7122    struct ProbeToolbar {
7123        log: ToolbarLog,
7124    }
7125
7126    /// The double's retained widget.
7127    struct ProbeToolbarWidget {
7128        log: ToolbarLog,
7129    }
7130
7131    impl View<()> for ProbeToolbar {
7132        type Element = ProbeToolbarWidget;
7133        fn build(&self, _ctx: &mut BuildCtx<'_>) -> ProbeToolbarWidget {
7134            ProbeToolbarWidget {
7135                log: Arc::clone(&self.log),
7136            }
7137        }
7138        fn rebuild(
7139            &self,
7140            _prev: &Self,
7141            element: &mut ProbeToolbarWidget,
7142            _ctx: &mut BuildCtx<'_>,
7143        ) -> ChangeFlags {
7144            element.log = Arc::clone(&self.log);
7145            ChangeFlags::NONE
7146        }
7147    }
7148
7149    impl Widget for ProbeToolbarWidget {
7150        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
7151            bc.constrain(PROBE_SIZE)
7152        }
7153        fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
7154            scene.fill_rect(ctx.origin(), ctx.size(), PROBE_COLOR);
7155        }
7156        fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
7157            if let InputEvent::Pointer(p) = event {
7158                self.log
7159                    .lock()
7160                    .unwrap_or_else(|e| e.into_inner())
7161                    .push(format!("{:?}@{},{}", p.phase, p.position.x, p.position.y));
7162                // Fire on the release, like the real bar's items do.
7163                if p.phase == PointerPhase::Up {
7164                    ctx.dispatch_edit_command(EditCommand::Copy);
7165                }
7166                return EventResult::Handled;
7167            }
7168            EventResult::Ignored
7169        }
7170    }
7171
7172    /// Install the double in the process-global builder slot (and assert the
7173    /// framework route), handing back its log. Call under [`TOOLBAR_LOCK`].
7174    fn install_probe_toolbar() -> ToolbarLog {
7175        let log: ToolbarLog = Arc::new(Mutex::new(Vec::new()));
7176        let captured = Arc::clone(&log);
7177        let builder: SelectionToolbarBuilder =
7178            Arc::new(move |_request: &SelectionToolbarRequest, _window: Size| {
7179                frust_core::any(ProbeToolbar {
7180                    log: Arc::clone(&captured),
7181                })
7182            });
7183        set_selection_toolbar_builder(builder);
7184        set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
7185        log
7186    }
7187
7188    /// How many presses the double has seen.
7189    fn probe_presses(log: &ToolbarLog) -> usize {
7190        log.lock().unwrap_or_else(|e| e.into_inner()).len()
7191    }
7192
7193    /// One whole frame: rebuild — the only pass carrying a `BuildCtx`, so the
7194    /// only one that can mount or drop the toolbar pod — then layout, then paint
7195    /// at `ms`. The loop every shipping shell runs.
7196    fn toolbar_frame(
7197        root: &mut RenderRoot<AppState, TextInputView<AppState>>,
7198        logic: &mut impl FnMut(&mut AppState) -> TextInputView<AppState>,
7199        state: &mut AppState,
7200        ms: f64,
7201    ) -> SceneLog {
7202        root.rebuild(logic, state);
7203        root.layout(TOOLBAR_WINDOW);
7204        let mut scene = SceneLog::default();
7205        root.paint(&mut scene, ft_ms(ms));
7206        scene
7207    }
7208
7209    /// A complete in-slop tap at `(x, y)`.
7210    fn tap(
7211        root: &mut RenderRoot<AppState, TextInputView<AppState>>,
7212        state: &mut AppState,
7213        x: f64,
7214        y: f64,
7215    ) {
7216        root.event(state, &pointer(PointerPhase::Down, x, y));
7217        root.event(state, &pointer(PointerPhase::Up, x, y));
7218    }
7219
7220    /// The selected text, or `None` — spelled out so an assertion reads as the
7221    /// selection rather than as an editor call.
7222    fn selection(root: &RenderRoot<AppState, TextInputView<AppState>>) -> Option<String> {
7223        widget(root).editor.selected_text().map(str::to_owned)
7224    }
7225
7226    /// Open the bar with a context press and paint it, asserting a pod really
7227    /// got registered — the starting position each hide-rule leg needs.
7228    fn open_toolbar(
7229        root: &mut RenderRoot<AppState, TextInputView<AppState>>,
7230        logic: &mut impl FnMut(&mut AppState) -> TextInputView<AppState>,
7231        state: &mut AppState,
7232        ms: f64,
7233    ) {
7234        root.event(state, &secondary_pointer(PointerPhase::Down, 20.0, 10.0));
7235        assert!(widget(root).toolbar_open, "the leg starts with the bar up");
7236        let scene = toolbar_frame(root, logic, state, ms);
7237        assert!(scene.probe().is_some(), "…and with a pod really registered");
7238    }
7239
7240    #[test]
7241    fn a_stationary_long_press_selects_the_word_and_floats_the_toolbar() {
7242        let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
7243        let _log = install_probe_toolbar();
7244        let mut state = AppState {
7245            value: "hello world".to_string(),
7246            ..Default::default()
7247        };
7248        let mut logic = options_logic(true, false, false);
7249        let mut root = options_root(&mut logic, &mut state);
7250        toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
7251
7252        // The press arms the hold; its first painted frame seeds the epoch.
7253        root.event(&mut state, &pointer(PointerPhase::Down, 20.0, 10.0));
7254        toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
7255        assert!(
7256            !widget(&root).toolbar_open,
7257            "nothing fires before the threshold"
7258        );
7259
7260        // The frame past the threshold marks it elapsed and latches the flush
7261        // that becomes the `Housekeeping` broadcast the fire rides out on.
7262        toolbar_frame(&mut root, &mut logic, &mut state, LONG_PRESS_MS + 50.0);
7263        assert!(
7264            frust_core::has_pending_result_flush(),
7265            "the crossing paint latches the deferred-callback flush"
7266        );
7267        root.event(&mut state, &InputEvent::Housekeeping);
7268
7269        assert_eq!(
7270            selection(&root).as_deref(),
7271            Some("hello"),
7272            "the word under the press point is selected"
7273        );
7274        assert!(widget(&root).toolbar_open, "and the toolbar is asked for");
7275
7276        // The pod is mounted by the next rebuild and registered by its paint,
7277        // above the whole field rather than inside it.
7278        let scene = toolbar_frame(&mut root, &mut logic, &mut state, LONG_PRESS_MS + 70.0);
7279        assert!(
7280            scene.probe_painted_last(),
7281            "the floated pod paints after the main tree: {:?}",
7282            scene.fills
7283        );
7284
7285        // The published request is the selection's own bounding box, absolute.
7286        let field = scene.field_rect();
7287        let w = widget(&root);
7288        let expected = w
7289            .display()
7290            .selection_rects()
7291            .into_iter()
7292            .reduce(|a, b| a.union(b))
7293            .expect("a non-collapsed selection has rects")
7294            + Vec2::new(w.pad_x, w.content_origin_y(field.height()))
7295            + field.origin().to_vec2();
7296        let published = root
7297            .selection_toolbar()
7298            .expect("an open toolbar publishes its request under either policy");
7299        assert_eq!(published.anchor, expected);
7300        assert_eq!(
7301            published.actions,
7302            SelectionToolbarActions {
7303                copy: true,
7304                cut: true,
7305                paste: true,
7306                select_all: true,
7307            },
7308            "a word selected out of a longer line enables all four verbs"
7309        );
7310    }
7311
7312    /// Drive a field to "the long-press fired, the finger is still down",
7313    /// pressing at `press_x`, and hand back the root/state to move from there.
7314    fn held_word(
7315        logic: &mut impl FnMut(&mut AppState) -> TextInputView<AppState>,
7316        state: &mut AppState,
7317        press_x: f64,
7318    ) -> RenderRoot<AppState, TextInputView<AppState>> {
7319        let mut root = options_root(logic, state);
7320        toolbar_frame(&mut root, logic, state, 0.0);
7321        root.event(state, &pointer(PointerPhase::Down, press_x, 10.0));
7322        toolbar_frame(&mut root, logic, state, 0.0);
7323        toolbar_frame(&mut root, logic, state, LONG_PRESS_MS + 50.0);
7324        root.event(state, &InputEvent::Housekeeping);
7325        assert_eq!(
7326            selection(&root).as_deref(),
7327            Some("hello"),
7328            "the leg starts from a fired hold over the first word"
7329        );
7330        root
7331    }
7332
7333    #[test]
7334    fn an_in_slop_move_after_the_hold_fired_leaves_the_selected_word_alone() {
7335        let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
7336        let _log = install_probe_toolbar();
7337        let mut state = AppState {
7338            value: "hello world".to_string(),
7339            ..Default::default()
7340        };
7341        let mut logic = options_logic(true, false, false);
7342        // Press inside "hello", four characters in.
7343        let mut root = held_word(&mut logic, &mut state, 30.0);
7344        assert!(widget(&root).toolbar_open, "and with the bar raised");
7345        assert_eq!(
7346            widget(&root).gesture,
7347            Gesture::HoldFired {
7348                at: Point::new(30.0, 10.0)
7349            },
7350            "a fired hold keeps its press point rather than forgetting it"
7351        );
7352
7353        // The finger is still down and jitters 14px — comfortably inside the
7354        // 18px TOUCH_SLOP that makes a press "stationary", yet several
7355        // characters wide at this text size, so it reaches across the word
7356        // boundary into "world". The selection must not notice.
7357        let jitter = Point::new(44.0, 10.0);
7358        assert!(
7359            (jitter - Point::new(30.0, 10.0)).hypot() <= TOUCH_SLOP,
7360            "the leg is only meaningful while the jitter stays in slop"
7361        );
7362        root.event(&mut state, &pointer(PointerPhase::Move, jitter.x, jitter.y));
7363
7364        assert_eq!(
7365            selection(&root).as_deref(),
7366            Some("hello"),
7367            "a held finger jittering in slop must not re-resolve the selection"
7368        );
7369        assert_eq!(
7370            widget(&root).gesture,
7371            Gesture::HoldFired {
7372                at: Point::new(30.0, 10.0)
7373            },
7374            "and the press stays resolved-but-stationary, not promoted to a drag"
7375        );
7376        assert!(
7377            widget(&root).toolbar_open,
7378            "so the bar still stands over the selection its verbs were built from"
7379        );
7380
7381        // The release must not resolve as a tap either: a long-press seeds no
7382        // double-tap window and toggles no toolbar.
7383        root.event(&mut state, &pointer(PointerPhase::Up, jitter.x, jitter.y));
7384        assert!(
7385            widget(&root).last_tap.is_none(),
7386            "a long-press is not a tap"
7387        );
7388        assert!(
7389            widget(&root).toolbar_open,
7390            "and the release does not close it"
7391        );
7392    }
7393
7394    #[test]
7395    fn a_drag_out_of_a_fired_hold_extends_by_word_and_tracks_back() {
7396        let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
7397        let _log = install_probe_toolbar();
7398        let mut state = AppState {
7399            value: "hello world".to_string(),
7400            ..Default::default()
7401        };
7402        let mut logic = options_logic(true, false, false);
7403        let mut root = held_word(&mut logic, &mut state, 30.0);
7404
7405        // Past the slop, into the second word. The post-hold drag is
7406        // word-granular: it swallows "world" whole rather than cutting the
7407        // selection off at the cluster under the pointer. This pins the
7408        // retained-granularity assumption the module docs call out — a text
7409        // engine that dropped it would truncate every long-press drag here
7410        // instead of failing loudly.
7411        let out = 30.0 + TOUCH_SLOP + 20.0;
7412        root.event(&mut state, &pointer(PointerPhase::Move, out, 10.0));
7413        assert_eq!(
7414            widget(&root).gesture,
7415            Gesture::Drag,
7416            "leaving the slop promotes the resolved hold to a drag"
7417        );
7418        assert_eq!(
7419            selection(&root).as_deref(),
7420            Some("hello world"),
7421            "the drag extends by whole words, not to the cluster under the pointer"
7422        );
7423
7424        // Dragging back toward the press point shrinks it again — the promotion
7425        // to `Drag` is what keeps the selection tracking the finger instead of
7426        // freezing at its widest.
7427        root.event(&mut state, &pointer(PointerPhase::Move, 30.0, 10.0));
7428        assert_eq!(
7429            selection(&root).as_deref(),
7430            Some("hello"),
7431            "a finger brought back shrinks the selection rather than sticking"
7432        );
7433    }
7434
7435    #[test]
7436    fn a_drag_past_the_slop_cancels_the_hold_and_selects_instead() {
7437        let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
7438        let _log = install_probe_toolbar();
7439        let mut state = AppState {
7440            value: "hello world".to_string(),
7441            ..Default::default()
7442        };
7443        let mut logic = options_logic(true, false, false);
7444        let mut root = options_root(&mut logic, &mut state);
7445        toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
7446
7447        root.event(&mut state, &pointer(PointerPhase::Down, 20.0, 10.0));
7448        toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
7449        // Past the slop well before the threshold: the gesture became a drag.
7450        root.event(
7451            &mut state,
7452            &pointer(PointerPhase::Move, 20.0 + TOUCH_SLOP + 80.0, 10.0),
7453        );
7454        toolbar_frame(&mut root, &mut logic, &mut state, 200.0);
7455        toolbar_frame(&mut root, &mut logic, &mut state, LONG_PRESS_MS + 100.0);
7456        assert!(
7457            !frust_core::has_pending_result_flush(),
7458            "a cancelled hold latches nothing, however long the finger stays down"
7459        );
7460
7461        root.event(&mut state, &InputEvent::Housekeeping);
7462        assert!(
7463            !widget(&root).toolbar_open,
7464            "a drag raises no toolbar of its own"
7465        );
7466        let selected = selection(&root).expect("the drag extended a selection");
7467        assert!(
7468            selected.ends_with("world"),
7469            "the selection followed the pointer to the end of the line, rather than \
7470             snapping to the word under the press: {selected:?}"
7471        );
7472    }
7473
7474    #[test]
7475    fn a_double_tap_selects_the_word_and_a_late_second_tap_only_places_the_caret() {
7476        let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
7477        let _log = install_probe_toolbar();
7478        let mut state = AppState {
7479            value: "hello world".to_string(),
7480            ..Default::default()
7481        };
7482        let mut logic = options_logic(true, false, false);
7483        let mut root = options_root(&mut logic, &mut state);
7484        toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
7485
7486        // A tap, then a second one on the same spot a second later: past the
7487        // window, so it is an ordinary caret placement. This leg runs first,
7488        // while nothing is selected — a second press landing *inside* a
7489        // selection is the toggle gesture, which owns that case.
7490        tap(&mut root, &mut state, 20.0, 10.0);
7491        toolbar_frame(&mut root, &mut logic, &mut state, 900.0);
7492        root.event(&mut state, &pointer(PointerPhase::Down, 21.0, 10.0));
7493        assert_eq!(
7494            selection(&root),
7495            None,
7496            "past DOUBLE_TAP_MS the press is just a press"
7497        );
7498        root.event(&mut state, &pointer(PointerPhase::Up, 21.0, 10.0));
7499
7500        // The same pair inside the window: the word under it is selected.
7501        toolbar_frame(&mut root, &mut logic, &mut state, 1000.0);
7502        root.event(&mut state, &pointer(PointerPhase::Down, 21.0, 10.0));
7503        assert_eq!(
7504            selection(&root).as_deref(),
7505            Some("hello"),
7506            "the second press of a double-tap selects the word"
7507        );
7508        assert!(
7509            !widget(&root).toolbar_open,
7510            "and deliberately raises nothing over the word it just picked"
7511        );
7512    }
7513
7514    #[test]
7515    fn a_tap_inside_the_selection_toggles_the_toolbar_and_keeps_the_selection() {
7516        let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
7517        let _log = install_probe_toolbar();
7518        let mut state = AppState {
7519            value: "hello world".to_string(),
7520            ..Default::default()
7521        };
7522        let mut logic = options_logic(true, false, false);
7523        let mut root = options_root(&mut logic, &mut state);
7524        toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
7525
7526        // Get a selection the honest way, then leave the double-tap window.
7527        tap(&mut root, &mut state, 20.0, 10.0);
7528        toolbar_frame(&mut root, &mut logic, &mut state, 100.0);
7529        tap(&mut root, &mut state, 21.0, 10.0);
7530        assert_eq!(selection(&root).as_deref(), Some("hello"));
7531        toolbar_frame(&mut root, &mut logic, &mut state, 600.0);
7532
7533        // A press inside that selection leaves it exactly alone…
7534        root.event(&mut state, &pointer(PointerPhase::Down, 20.0, 10.0));
7535        assert_eq!(
7536            selection(&root).as_deref(),
7537            Some("hello"),
7538            "the press neither collapses the selection nor moves the caret"
7539        );
7540        assert!(
7541            !widget(&root).toolbar_open,
7542            "and nothing opens on the press itself"
7543        );
7544        // …and the release raises the bar, fire-on-up-inside.
7545        root.event(&mut state, &pointer(PointerPhase::Up, 20.0, 10.0));
7546        assert!(widget(&root).toolbar_open, "the release opens the toolbar");
7547        assert_eq!(selection(&root).as_deref(), Some("hello"));
7548
7549        // The same tap again toggles it away, still without disturbing the
7550        // selection it is pointing at.
7551        toolbar_frame(&mut root, &mut logic, &mut state, 1200.0);
7552        tap(&mut root, &mut state, 20.0, 10.0);
7553        assert!(
7554            !widget(&root).toolbar_open,
7555            "a second tap inside the selection closes it"
7556        );
7557        assert_eq!(selection(&root).as_deref(), Some("hello"));
7558    }
7559
7560    #[test]
7561    fn a_press_inside_the_floated_toolbar_reaches_the_pod_and_its_copy_reaches_the_field() {
7562        let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
7563        let log = install_probe_toolbar();
7564        let mut state = AppState {
7565            value: "hello world".to_string(),
7566            ..Default::default()
7567        };
7568        let mut logic = options_logic(true, false, false);
7569        let mut root = options_root(&mut logic, &mut state);
7570        toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
7571
7572        // Select a word, then open the bar with a context press — which opens no
7573        // capture, so the root's overlay pre-pass is reachable afterwards.
7574        tap(&mut root, &mut state, 20.0, 10.0);
7575        toolbar_frame(&mut root, &mut logic, &mut state, 100.0);
7576        tap(&mut root, &mut state, 21.0, 10.0);
7577        assert_eq!(selection(&root).as_deref(), Some("hello"));
7578        root.event(
7579            &mut state,
7580            &secondary_pointer(PointerPhase::Down, 20.0, 10.0),
7581        );
7582        let scene = toolbar_frame(&mut root, &mut logic, &mut state, 200.0);
7583        assert!(
7584            scene.probe_painted_last(),
7585            "the pod is registered and floated"
7586        );
7587
7588        // Placed against the selection: centred on it, clear of it by the gap,
7589        // and never covering it.
7590        let placed = widget(&root).toolbar.window_rect();
7591        let anchor = root
7592            .selection_toolbar()
7593            .expect("the request is published while the bar is up")
7594            .anchor;
7595        assert_eq!(placed.size(), PROBE_SIZE, "placement never resizes the pod");
7596        assert_eq!(
7597            placed.y0,
7598            anchor.y1 + TOOLBAR_GAP,
7599            "flipped below the selection and the gap clear of it — a field at the \
7600             top of the window has no room above it"
7601        );
7602        assert_eq!(
7603            placed.x0,
7604            crate::DEFAULT_PADDING,
7605            "centred on the selection where it fits and shifted back inside the \
7606             window's padding where it does not — a 120px bar centred on a word \
7607             near the leading edge hangs outside"
7608        );
7609
7610        // A press inside it reaches the pod, and the field keeps its session.
7611        let hit = placed.center();
7612        root.event(&mut state, &pointer(PointerPhase::Down, hit.x, hit.y));
7613        assert_eq!(probe_presses(&log), 1, "the press was routed into the pod");
7614        assert!(
7615            root.is_focus_active(),
7616            "a press on the bar never blurs the field it acts on"
7617        );
7618        assert!(
7619            widget(&root).toolbar_open,
7620            "and a press alone takes no verb"
7621        );
7622
7623        // The release dispatches Copy, which the field drains and applies.
7624        root.event(&mut state, &pointer(PointerPhase::Up, hit.x, hit.y));
7625        assert_eq!(probe_presses(&log), 2);
7626        assert_eq!(
7627            root.take_clipboard_write().as_deref(),
7628            Some("hello"),
7629            "the pod's dispatched verb was applied by the field, not by the pod"
7630        );
7631        assert!(
7632            !widget(&root).toolbar_open,
7633            "a verb taken closes the bar that offered it"
7634        );
7635        assert!(root.is_focus_active(), "with the session still standing");
7636        assert!(
7637            toolbar_frame(&mut root, &mut logic, &mut state, 300.0)
7638                .probe()
7639                .is_none(),
7640            "and the pod is gone from the very next paint"
7641        );
7642    }
7643
7644    #[test]
7645    fn typing_escape_scrolling_and_an_outside_press_each_put_the_toolbar_away() {
7646        let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
7647        let _log = install_probe_toolbar();
7648        let mut state = AppState {
7649            value: "hello world".to_string(),
7650            ..Default::default()
7651        };
7652        let mut logic = options_logic(true, false, false);
7653        let mut root = options_root(&mut logic, &mut state);
7654        toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
7655
7656        // Each leg opens the bar, paints it (so a pod is really registered),
7657        // applies one hide rule, and demands it be gone — both from the widget's
7658        // own state and from the next painted frame.
7659
7660        // A text change: the selection the bar was pointing at is stale.
7661        open_toolbar(&mut root, &mut logic, &mut state, 100.0);
7662        root.event(&mut state, &ch("x"));
7663        assert!(!widget(&root).toolbar_open, "typing puts it away");
7664        assert!(
7665            toolbar_frame(&mut root, &mut logic, &mut state, 120.0)
7666                .probe()
7667                .is_none(),
7668            "and nothing is registered on the next paint"
7669        );
7670
7671        // Escape: the session goes, and the bar with it.
7672        open_toolbar(&mut root, &mut logic, &mut state, 200.0);
7673        root.event(&mut state, &named(NamedKey::Escape, Modifiers::default()));
7674        assert!(!widget(&root).toolbar_open, "Escape puts it away");
7675        assert!(!root.is_focus_active());
7676        assert!(
7677            toolbar_frame(&mut root, &mut logic, &mut state, 220.0)
7678                .probe()
7679                .is_none()
7680        );
7681
7682        // A scroll: the anchor moved out from under it.
7683        open_toolbar(&mut root, &mut logic, &mut state, 300.0);
7684        root.event(
7685            &mut state,
7686            &InputEvent::Scroll {
7687                position: Point::new(20.0, 10.0),
7688                delta: ScrollDelta::Pixels(0.0, -40.0),
7689            },
7690        );
7691        assert!(!widget(&root).toolbar_open, "a scroll puts it away");
7692        assert!(
7693            toolbar_frame(&mut root, &mut logic, &mut state, 320.0)
7694                .probe()
7695                .is_none()
7696        );
7697
7698        // A primary press outside the field: the blur takes it too.
7699        open_toolbar(&mut root, &mut logic, &mut state, 400.0);
7700        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 150.0));
7701        assert!(!widget(&root).toolbar_open, "an outside press puts it away");
7702        assert!(
7703            !root.is_focus_active(),
7704            "and blurs the field as it always did"
7705        );
7706        assert!(
7707            toolbar_frame(&mut root, &mut logic, &mut state, 420.0)
7708                .probe()
7709                .is_none()
7710        );
7711    }
7712
7713    #[test]
7714    fn an_obscured_field_offers_neither_copy_nor_cut_but_still_offers_paste() {
7715        let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
7716        let _log = install_probe_toolbar();
7717        let mut state = AppState {
7718            value: "secret".to_string(),
7719            ..Default::default()
7720        };
7721        let mut logic = options_logic(true, true, false);
7722        let mut root = options_root(&mut logic, &mut state);
7723        toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
7724
7725        // Focus, select everything, and ask for the bar.
7726        tap(&mut root, &mut state, 20.0, 10.0);
7727        root.event(&mut state, &edit(EditCommand::SelectAll));
7728        assert_eq!(selection(&root).as_deref(), Some("secret"));
7729        root.event(
7730            &mut state,
7731            &secondary_pointer(PointerPhase::Down, 20.0, 10.0),
7732        );
7733        toolbar_frame(&mut root, &mut logic, &mut state, 100.0);
7734
7735        let published = root
7736            .selection_toolbar()
7737            .expect("an obscured field publishes its request like any other");
7738        assert_eq!(
7739            published.actions,
7740            SelectionToolbarActions {
7741                copy: false,
7742                cut: false,
7743                paste: true,
7744                select_all: false,
7745            },
7746            "the secret is not this widget's to hand out, but writing into it is \
7747             ordinary — and everything is already selected"
7748        );
7749    }
7750
7751    #[test]
7752    fn a_focused_field_publishes_its_verbs_with_no_bar_up() {
7753        // The fact a platform responder chain answers "may I offer Paste?"
7754        // from. It asks whenever it likes — a hardware Cmd+V arrives with
7755        // nothing on screen and never raises a bar first — so a publish gated
7756        // on the bar left every hardware clipboard shortcut unanswerable.
7757        let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
7758        let _log = install_probe_toolbar();
7759        let mut state = AppState {
7760            value: "hello world".to_string(),
7761            ..Default::default()
7762        };
7763        let mut logic = options_logic(true, false, false);
7764        let mut root = options_root(&mut logic, &mut state);
7765        toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
7766
7767        // An ordinary tap to focus: no long press, no context press, no bar.
7768        tap(&mut root, &mut state, 20.0, 10.0);
7769        let scene = toolbar_frame(&mut root, &mut logic, &mut state, 100.0);
7770        assert!(widget(&root).focused, "the tap focused the field");
7771        assert!(!widget(&root).toolbar_open, "and raised no bar");
7772        assert!(scene.probe().is_none(), "so nothing floated either");
7773        assert_eq!(selection(&root), None, "a plain tap selects nothing");
7774
7775        let published = root
7776            .selection_toolbar()
7777            .expect("a focused field publishes whether or not a bar is up");
7778        assert_eq!(
7779            published.actions,
7780            widget(&root).toolbar_actions(),
7781            "the published verbs are the field's own, computed from its state \
7782             rather than from the bar — the rule the accesskit route already kept"
7783        );
7784        assert!(
7785            published.actions.paste,
7786            "a bare caret in an interactive field is exactly what paste is for"
7787        );
7788        assert!(
7789            !published.present_menu,
7790            "…while nothing asked for a menu, so nothing asks a shell to present one"
7791        );
7792        assert_eq!(
7793            published.anchor,
7794            widget(&root).toolbar_anchor,
7795            "anchored on the caret rect with the selection collapsed"
7796        );
7797
7798        // And the bar going up is the same request with the one flag raised.
7799        root.event(
7800            &mut state,
7801            &secondary_pointer(PointerPhase::Down, 20.0, 10.0),
7802        );
7803        toolbar_frame(&mut root, &mut logic, &mut state, 200.0);
7804        assert!(
7805            root.selection_toolbar()
7806                .expect("still focused, still publishing")
7807                .present_menu
7808        );
7809    }
7810
7811    #[test]
7812    fn the_native_policy_publishes_the_request_and_floats_nothing() {
7813        let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
7814        let _log = install_probe_toolbar();
7815        set_selection_toolbar_policy(SelectionToolbarPolicy::Native);
7816        let mut state = AppState {
7817            value: "hello world".to_string(),
7818            ..Default::default()
7819        };
7820        let mut logic = options_logic(true, false, false);
7821        let mut root = options_root(&mut logic, &mut state);
7822        toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
7823
7824        tap(&mut root, &mut state, 20.0, 10.0);
7825        toolbar_frame(&mut root, &mut logic, &mut state, 100.0);
7826        tap(&mut root, &mut state, 21.0, 10.0);
7827        assert_eq!(selection(&root).as_deref(), Some("hello"));
7828        root.event(
7829            &mut state,
7830            &secondary_pointer(PointerPhase::Down, 20.0, 10.0),
7831        );
7832        assert!(widget(&root).toolbar_open);
7833
7834        let scene = toolbar_frame(&mut root, &mut logic, &mut state, 200.0);
7835        assert!(
7836            scene.probe().is_none(),
7837            "the platform draws it: the field floats nothing at all"
7838        );
7839        assert!(
7840            !widget(&root).toolbar.is_open(),
7841            "and mounts no pod to float"
7842        );
7843        assert!(
7844            root.selection_toolbar().is_some(),
7845            "…while the request a shell hands the platform is published all the same"
7846        );
7847
7848        // Leave the slot as the rest of the process expects to find it.
7849        set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
7850    }
7851
7852    #[test]
7853    fn the_long_press_timer_requests_plain_frames_while_the_blink_paces() {
7854        // A finger still down is a live long-press timer, and its continuation
7855        // frames are deliberately NOT the blink's paced ones: a frame gate that
7856        // throttled them would delay the gesture past its own threshold, and
7857        // `reduce_motion` — which stops the blink's requests entirely — must not
7858        // stop a gesture from firing at all.
7859        let mut state = AppState::default();
7860        let mut root = harness(&mut state);
7861        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
7862        let held = root.paint(&mut NullScene, ft_ms(0.0));
7863        assert!(held.needs_frame, "a live hold keeps the frames coming");
7864        assert!(
7865            !held.needs_frame_paced_only,
7866            "a gesture clock is not a cosmetic loop the frame gate may pace"
7867        );
7868
7869        // Released: the hold is gone and only the caret's paced request is left.
7870        root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
7871        let idle = root.paint(&mut NullScene, ft_ms(20.0));
7872        assert!(
7873            idle.needs_frame_paced_only,
7874            "with no hold in flight the blink is the only thing asking"
7875        );
7876
7877        // Frozen blink, live hold: the plain request survives reduce_motion.
7878        let mut theme = Theme::neutral();
7879        theme.motion.reduce_motion = true;
7880        root.set_theme(Box::new(theme));
7881        // Past the double-tap window, so the next press is an ordinary one that
7882        // arms a hold rather than a word-selecting second tap.
7883        root.paint(&mut NullScene, ft_ms(400.0));
7884        root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
7885        let frozen = root.paint(&mut NullScene, ft_ms(420.0));
7886        assert!(
7887            frozen.needs_frame,
7888            "a frozen blink must not freeze the long-press timer"
7889        );
7890        assert!(!frozen.needs_frame_paced_only);
7891    }
7892
7893    #[test]
7894    fn a_cancel_clears_the_hold_without_touching_the_selection_or_the_toolbar() {
7895        let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
7896        let _log = install_probe_toolbar();
7897        let mut state = AppState {
7898            value: "hello world".to_string(),
7899            ..Default::default()
7900        };
7901        let mut logic = options_logic(true, false, false);
7902        let mut root = options_root(&mut logic, &mut state);
7903        toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
7904
7905        // A selection, and a bar up over it.
7906        tap(&mut root, &mut state, 20.0, 10.0);
7907        toolbar_frame(&mut root, &mut logic, &mut state, 100.0);
7908        tap(&mut root, &mut state, 21.0, 10.0);
7909        assert_eq!(selection(&root).as_deref(), Some("hello"));
7910        open_toolbar(&mut root, &mut logic, &mut state, 600.0);
7911
7912        // A gesture stolen with no press of ours in flight says nothing about
7913        // the bar: a Cancel is not one of the hide rules.
7914        root.event(&mut state, &pointer(PointerPhase::Cancel, 20.0, 10.0));
7915        assert!(
7916            widget(&root).toolbar_open,
7917            "a Cancel never puts the toolbar away"
7918        );
7919
7920        // And a press the platform steals mid-hold disarms the gesture without
7921        // touching the selection it was made over.
7922        root.event(&mut state, &pointer(PointerPhase::Down, 20.0, 10.0));
7923        root.event(&mut state, &pointer(PointerPhase::Cancel, 20.0, 10.0));
7924        assert_eq!(
7925            selection(&root).as_deref(),
7926            Some("hello"),
7927            "a Cancel never touches application state"
7928        );
7929        assert!(!widget(&root).captured, "it does disarm the drag");
7930        assert!(widget(&root).hold.is_none(), "and the hold timer with it");
7931        assert_eq!(widget(&root).gesture, Gesture::None);
7932        assert!(widget(&root).tap_in_selection.is_none());
7933    }
7934}