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 crate::column()
4373 .child(text_input(
4374 state.value.clone(),
4375 |s: &mut NestedState, v: String| {
4376 s.value = v;
4377 },
4378 ))
4379 .child(crate::button("ok", |_s: &mut NestedState| {}))
4380 }
4381
4382 #[test]
4383 fn nested_blur_clears_ime_and_idles_paint() {
4384 let mut state = NestedState::default();
4385 let mut root: RenderRoot<NestedState, crate::FlexView<NestedState>> = RenderRoot::new();
4386 root.rebuild(&mut nested_logic, &mut state);
4387 let mut tcx = frust_text::TextContext::new();
4388 root.layout_with_text(Size::new(300.0, 200.0), &mut tcx as &mut dyn Any);
4389
4390 // Focus the field with a full tap (Down+Up) inside its bounds, at the
4391 // top of the column. The Up releases the field's pointer capture, so the
4392 // next Down is hit-tested afresh instead of routing back to the field.
4393 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
4394 root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
4395 assert!(root.is_focus_active(), "tap inside the field focuses it");
4396 assert!(
4397 root.ime_state().is_some(),
4398 "focusing the nested field publishes an IME surface"
4399 );
4400
4401 // Tap the sibling button, below the field (a container-routed blur): the
4402 // field's pod focus is cleared by `route_event`, but its own `event()` is
4403 // never called on this dispatch. The `Up` completes the button's own
4404 // tap cycle (`Button` has a press-scale
4405 // animation that lazily launches at the next `paint` — completing the
4406 // gesture here, with no paint in between, cancels the retarget before
4407 // it ever launches, so this stays a pure blur probe rather than also
4408 // asserting anything about the button's own animation).
4409 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 45.0));
4410 root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 45.0));
4411
4412 // Leg 1 — the blur event clears the shell-facing focus + IME surface.
4413 assert!(!root.is_focus_active(), "the sibling tap blurs the field");
4414 assert!(
4415 root.ime_state().is_none(),
4416 "container-routed blur clears the IME surface"
4417 );
4418
4419 // Legs 2 & 3 — a subsequent paint must NOT resurrect the cleared IME
4420 // surface, and must report the tree at rest (no stale caret-blink frame).
4421 let mut sink = NullScene;
4422 let outcome = root.paint(&mut sink, FrameTime::ZERO);
4423 assert!(
4424 root.ime_state().is_none(),
4425 "paint must not republish the cleared IME surface (F1 resurrection)"
4426 );
4427 assert!(
4428 !outcome.needs_frame,
4429 "a blurred field paints at rest — the desktop wait-loop idles"
4430 );
4431 }
4432
4433 /// Two-level nesting: the field sits inside an INNER Column, whose pod is a
4434 /// child of the outer Column beside the button. A blur tap on the button
4435 /// clears the focus link at the outer level only (the inner Column's pod) —
4436 /// the field's own pod flag deep in the blurred subtree legitimately stays
4437 /// stale, which is exactly the case `paint_child`'s ancestor-composed
4438 /// `has_focus` seeding must cover.
4439 fn deep_nested_logic(state: &mut NestedState) -> crate::FlexView<NestedState> {
4440 crate::column()
4441 .child(crate::column().child(text_input(
4442 state.value.clone(),
4443 |s: &mut NestedState, v: String| {
4444 s.value = v;
4445 },
4446 )))
4447 .child(crate::button("ok", |_s: &mut NestedState| {}))
4448 }
4449
4450 #[test]
4451 fn deep_nested_blur_idles_paint_despite_stale_inner_focus_flag() {
4452 let mut state = NestedState::default();
4453 let mut root: RenderRoot<NestedState, crate::FlexView<NestedState>> = RenderRoot::new();
4454 root.rebuild(&mut deep_nested_logic, &mut state);
4455 let mut tcx = frust_text::TextContext::new();
4456 root.layout_with_text(Size::new(300.0, 200.0), &mut tcx as &mut dyn Any);
4457
4458 // Focus the field (full Down+Up tap — the Up releases capture so the
4459 // blur Down hit-tests afresh; see nested_blur_clears_ime_and_idles_paint).
4460 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
4461 root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
4462 assert!(root.is_focus_active());
4463 assert!(root.ime_state().is_some());
4464
4465 // Blur via the OUTER-level sibling: route_event clears the focused link
4466 // at the outer Column (the inner Column's pod); the field's own pod flag
4467 // two levels down stays stale. The `Up` completes the button's own tap
4468 // cycle (see `nested_blur_clears_ime_and_idles_paint`'s identical note)
4469 // so this stays a pure blur probe.
4470 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 45.0));
4471 root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 45.0));
4472 assert!(!root.is_focus_active());
4473 assert!(root.ime_state().is_none());
4474
4475 // Paint: the cleared outer link must force has_focus == false for the
4476 // whole subtree (ancestor-composed seeding), so the stale deep flag
4477 // cannot re-arm the caret blink or republish the IME surface.
4478 let mut sink = NullScene;
4479 let outcome = root.paint(&mut sink, FrameTime::ZERO);
4480 assert!(
4481 root.ime_state().is_none(),
4482 "deep-nested stale focus flag must not resurrect the IME surface"
4483 );
4484 assert!(
4485 !outcome.needs_frame,
4486 "deep-nested stale focus flag must not busy-loop the desktop shell"
4487 );
4488 }
4489
4490 // --- Cross-branch unmount: a stale flag must not kill a live session ------
4491 //
4492 // The shipped shape is a scrollable list above a persistent composer. The
4493 // user taps a row's field, then taps the composer: `route_event`'s
4494 // blur-on-outside-tap breaks the focus link at the NEAREST COMMON ANCESTOR
4495 // (the outer Column's pod for the list branch), so the row field's own pod
4496 // flag, one level deeper, legitimately stays set. The list then recycles /
4497 // filters / shrinks and the generic reconciler tears that row down.
4498 //
4499 // The row owns no session — the composer does. A release here is a
4500 // user-visible input regression: the keyboard drops mid-typing in a field
4501 // nothing touched. The effective-focus chain threaded through `BuildCtx`
4502 // (`has_focus`/`with_focus_link`) is what tells the two apart, and both
4503 // directions are pinned below over the same fixture.
4504
4505 #[derive(Default)]
4506 struct BranchState {
4507 row: String,
4508 composer: String,
4509 show_row: bool,
4510 show_composer: bool,
4511 }
4512
4513 /// Outer Column: [ inner Column (the "list", 0-or-1 row field), composer
4514 /// field ]. Each field's initial text names its branch, so `ime_state`
4515 /// identifies which one owns the session.
4516 fn branches_logic(state: &mut BranchState) -> crate::FlexView<BranchState> {
4517 use frust_core::{AnyView, any};
4518 let mut rows: Vec<AnyView<BranchState>> = Vec::new();
4519 if state.show_row {
4520 rows.push(any(text_input(
4521 state.row.clone(),
4522 |s: &mut BranchState, v: String| s.row = v,
4523 )));
4524 }
4525 let mut outer: Vec<AnyView<BranchState>> = vec![any(crate::Column(rows))];
4526 if state.show_composer {
4527 outer.push(any(text_input(
4528 state.composer.clone(),
4529 |s: &mut BranchState, v: String| s.composer = v,
4530 )));
4531 }
4532 crate::Column(outer)
4533 }
4534
4535 /// Build the two-branch tree with both fields present, focus the ROW field,
4536 /// then focus the COMPOSER — leaving the row's own pod flag stale below the
4537 /// cleared outer link. Returns the root, its text context and the state.
4538 fn focus_row_then_composer() -> (
4539 RenderRoot<BranchState, crate::FlexView<BranchState>>,
4540 frust_text::TextContext,
4541 BranchState,
4542 ) {
4543 let mut state = BranchState {
4544 row: "row".to_string(),
4545 composer: "composer".to_string(),
4546 show_row: true,
4547 show_composer: true,
4548 };
4549 let mut root: RenderRoot<BranchState, crate::FlexView<BranchState>> = RenderRoot::new();
4550 root.rebuild(&mut branches_logic, &mut state);
4551 let mut tcx = frust_text::TextContext::new();
4552 root.layout_with_text(Size::new(300.0, 200.0), &mut tcx as &mut dyn Any);
4553
4554 // Tap the row field (full Down+Up so the Up releases its capture and the
4555 // next Down hit-tests afresh — see nested_blur_clears_ime_and_idles_paint).
4556 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
4557 root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
4558 assert_eq!(
4559 root.ime_state().map(|s| s.editing.text),
4560 Some("row".to_string()),
4561 "the row field owns the session first"
4562 );
4563
4564 // Tap the composer: focus moves branches. The blur clears the link at the
4565 // outer Column only; the row field's flag inside the inner Column stays.
4566 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 50.0));
4567 root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 50.0));
4568 assert!(root.is_focus_active());
4569 assert_eq!(
4570 root.ime_state().map(|s| s.editing.text),
4571 Some("composer".to_string()),
4572 "the composer now owns the session"
4573 );
4574 (root, tcx, state)
4575 }
4576
4577 #[test]
4578 fn tearing_down_a_stale_focused_branch_leaves_the_live_session_alone() {
4579 let (mut root, _tcx, mut state) = focus_row_then_composer();
4580 let ime_before = root.ime_state();
4581 let gen_before = root.focus_ime_generation();
4582
4583 // The list shrinks: the row field — whose stale flag is still set — is
4584 // torn down by the generic reconciler.
4585 state.show_row = false;
4586 root.rebuild(&mut branches_logic, &mut state);
4587
4588 assert!(
4589 root.is_focus_active(),
4590 "unmounting a stale-flagged row must not blur the composer the user is typing in"
4591 );
4592 assert_eq!(
4593 root.ime_state(),
4594 ime_before,
4595 "the live IME surface is untouched by an unrelated branch's unmount"
4596 );
4597 assert_eq!(
4598 root.focus_ime_generation(),
4599 gen_before,
4600 "zero spurious edges: the shell's frame gate must see no focus/IME change at all"
4601 );
4602 }
4603
4604 #[test]
4605 fn tearing_down_the_live_focused_branch_still_releases_the_session() {
4606 // The twin of the test above over the same fixture: when the pod that
4607 // actually holds the session dies, the release must still happen — the
4608 // narrowed gate must not have turned into "never mark".
4609 let (mut root, _tcx, mut state) = focus_row_then_composer();
4610 let gen_before = root.focus_ime_generation();
4611
4612 state.show_composer = false;
4613 root.rebuild(&mut branches_logic, &mut state);
4614
4615 assert!(
4616 !root.is_focus_active(),
4617 "the focused composer's unmount releases the session"
4618 );
4619 assert_eq!(
4620 root.ime_state(),
4621 None,
4622 "the shell-facing surface dies with the widget that published it"
4623 );
4624 assert_eq!(
4625 root.focus_ime_generation(),
4626 gen_before.wrapping_add(1),
4627 "one orphaned live focus path is exactly one edge"
4628 );
4629 }
4630
4631 // --- Wrapper type swap: the severing path `rebuild_child` owns -----------
4632 //
4633 // `Padding(if editing { text_input } else { text })` is the single-child
4634 // wrapper shape: the view swaps the padded child's concrete type, so the
4635 // focused widget is torn down inside `AnyView::rebuild` and a fresh,
4636 // non-focusable one takes its pod. Nothing about that reaches the root by
4637 // itself — no event, no publish — so without the orphan mark the keyboard
4638 // stays up over an idle screen, `is_focus_active()` keeps reporting a
4639 // session, and `Key`/`Ime` events keep routing into a widget that ignores
4640 // them. Both directions are pinned below over one fixture, exactly like the
4641 // cross-branch unmount pair above.
4642
4643 #[derive(Default)]
4644 struct WrapState {
4645 row: String,
4646 composer: String,
4647 row_editing: bool,
4648 }
4649
4650 /// Outer Column: [ Padding-wrapped slot, composer field ]. The slot holds a
4651 /// `text_input` while `row_editing` and a plain `text` label otherwise, so
4652 /// flipping the flag is an `AnyView` type swap inside the wrapper — the
4653 /// `rebuild_child` path, not the multi-child reconciler's.
4654 fn wrapped_slot_logic(state: &mut WrapState) -> crate::FlexView<WrapState> {
4655 use frust_core::{AnyView, any};
4656 // The branch is taken *inside* the wrapper, so the wrapper's own view
4657 // type is stable across the flip and only its child's concrete type
4658 // changes — the `rebuild_child` swap. (Branching outside and handing
4659 // `Padding` a pre-erased `AnyView` would double-erase the child and
4660 // hide the swap from the wrapper entirely.)
4661 let slot: AnyView<WrapState> = if state.row_editing {
4662 any(crate::Padding(
4663 crate::EdgeInsets::all(0.0),
4664 text_input(state.row.clone(), |s: &mut WrapState, v: String| s.row = v),
4665 ))
4666 } else {
4667 any(crate::Padding(
4668 crate::EdgeInsets::all(0.0),
4669 crate::text(state.row.clone()),
4670 ))
4671 };
4672 crate::column().child(slot).child(text_input(
4673 state.composer.clone(),
4674 |s: &mut WrapState, v: String| s.composer = v,
4675 ))
4676 }
4677
4678 /// Build the fixture and focus the **wrapped** field, leaving it (and the
4679 /// wrapper pod above it) on the live focus chain.
4680 fn focus_the_wrapped_field() -> (
4681 RenderRoot<WrapState, crate::FlexView<WrapState>>,
4682 frust_text::TextContext,
4683 WrapState,
4684 ) {
4685 let mut state = WrapState {
4686 row: "row".to_string(),
4687 composer: "composer".to_string(),
4688 row_editing: true,
4689 };
4690 let mut root: RenderRoot<WrapState, crate::FlexView<WrapState>> = RenderRoot::new();
4691 root.rebuild(&mut wrapped_slot_logic, &mut state);
4692 let mut tcx = frust_text::TextContext::new();
4693 root.layout_with_text(Size::new(300.0, 200.0), &mut tcx as &mut dyn Any);
4694
4695 // Full Down+Up so the Up releases the capture and a later Down
4696 // hit-tests afresh (see the cross-branch fixture above).
4697 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
4698 root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
4699 assert_eq!(
4700 root.ime_state().map(|s| s.editing.text),
4701 Some("row".to_string()),
4702 "the wrapped field owns the session"
4703 );
4704 (root, tcx, state)
4705 }
4706
4707 #[test]
4708 fn swapping_a_live_focused_wrapper_child_releases_the_session() {
4709 let (mut root, _tcx, mut state) = focus_the_wrapped_field();
4710 let gen_before = root.focus_ime_generation();
4711
4712 // The wrapper's child type-swaps out from under the focused field.
4713 state.row_editing = false;
4714 root.rebuild(&mut wrapped_slot_logic, &mut state);
4715
4716 assert!(
4717 !root.is_focus_active(),
4718 "the root's focus mirror must not outlive the type-swapped field"
4719 );
4720 assert_eq!(
4721 root.ime_state(),
4722 None,
4723 "the shell-facing surface dies with the widget that published it"
4724 );
4725 assert_eq!(
4726 root.focus_ime_generation(),
4727 gen_before.wrapping_add(1),
4728 "one severed live focus path is exactly one edge"
4729 );
4730 }
4731
4732 #[test]
4733 fn swapping_a_stale_focused_wrapper_child_leaves_the_live_session_alone() {
4734 let (mut root, _tcx, mut state) = focus_the_wrapped_field();
4735
4736 // Focus moves to the composer: the blur clears the link at the outer
4737 // Column (the wrapper's own pod), leaving the wrapped field's flag one
4738 // level deeper legitimately stale.
4739 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 50.0));
4740 root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 50.0));
4741 assert_eq!(
4742 root.ime_state().map(|s| s.editing.text),
4743 Some("composer".to_string()),
4744 "the composer now owns the session"
4745 );
4746 let ime_before = root.ime_state();
4747 let gen_before = root.focus_ime_generation();
4748
4749 // The same swap as the twin above, now on a dead branch.
4750 state.row_editing = false;
4751 root.rebuild(&mut wrapped_slot_logic, &mut state);
4752
4753 assert!(
4754 root.is_focus_active(),
4755 "swapping a stale-flagged wrapper child must not blur the composer \
4756 the user is typing in"
4757 );
4758 assert_eq!(
4759 root.ime_state(),
4760 ime_before,
4761 "the live IME surface is untouched by an unrelated branch's swap"
4762 );
4763 assert_eq!(
4764 root.focus_ime_generation(),
4765 gen_before,
4766 "zero spurious edges: the shell's frame gate must see no focus/IME change"
4767 );
4768 }
4769
4770 /// A no-op paint sink for `needs_frame` assertions (glyph/rect output is not
4771 /// under test here).
4772 struct NullScene;
4773 impl PaintScene for NullScene {
4774 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
4775 fn draw_text(&mut self, _o: Point, _t: &str) {}
4776 }
4777
4778 // --- Multi-line mode ---
4779
4780 /// A 3-visible-line multi-line field (newline-on-Enter default).
4781 fn multiline_logic(state: &mut AppState) -> TextInputView<AppState> {
4782 text_input(state.value.clone(), |s: &mut AppState, v: String| {
4783 s.changes += 1;
4784 if !s.reject {
4785 s.value = v;
4786 }
4787 })
4788 .multiline(3)
4789 .on_submit(|s: &mut AppState, v: String| {
4790 s.submits += 1;
4791 s.last_submit = v;
4792 })
4793 }
4794
4795 /// A multi-line field that submits on Enter (newline only on Shift+Enter).
4796 fn multiline_submit_logic(state: &mut AppState) -> TextInputView<AppState> {
4797 text_input(state.value.clone(), |s: &mut AppState, v: String| {
4798 s.changes += 1;
4799 s.value = v;
4800 })
4801 .multiline(3)
4802 .submit_on_enter(true)
4803 .on_submit(|s: &mut AppState, v: String| {
4804 s.submits += 1;
4805 s.last_submit = v;
4806 })
4807 }
4808
4809 /// A tall window so the field's natural (capped) height is never clamped by
4810 /// the root constraints; returns the laid-out field height.
4811 fn relayout(root: &mut RenderRoot<AppState, TextInputView<AppState>>) -> f64 {
4812 root.layout(Size::new(300.0, 800.0)).height
4813 }
4814
4815 #[test]
4816 fn multiline_height_grows_per_line_up_to_cap_then_stops() {
4817 let mut state = AppState::default();
4818 let mut root = RenderRoot::new();
4819 root.rebuild(&mut multiline_logic, &mut state);
4820 let h1 = relayout(&mut root); // one (empty) line
4821
4822 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
4823 // Enter inserts a newline in this newline-on-Enter field.
4824 root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
4825 let h2 = relayout(&mut root); // two lines
4826 root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
4827 let h3 = relayout(&mut root); // three lines (== the cap)
4828
4829 assert!(h2 > h1, "a second line grows the field: {h1} -> {h2}");
4830 assert!(h3 > h2, "a third line grows the field: {h2} -> {h3}");
4831
4832 // A fourth and fifth line exceed the 3-line cap: the box height freezes.
4833 root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
4834 let h4 = relayout(&mut root);
4835 root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
4836 let h5 = relayout(&mut root);
4837 assert_eq!(h4, h3, "height stops growing at the visible-line cap");
4838 assert_eq!(h5, h4, "still capped past the cap");
4839 }
4840
4841 #[test]
4842 fn multiline_scroll_offset_engages_only_past_the_cap() {
4843 let mut state = AppState::default();
4844 let mut root = RenderRoot::new();
4845 root.rebuild(&mut multiline_logic, &mut state);
4846 root.layout(Size::new(300.0, 800.0));
4847 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
4848
4849 // Within the cap (two lines) the content fits: no scroll.
4850 root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
4851 let h = relayout(&mut root);
4852 assert_eq!(
4853 widget(&root).scroll_y(h),
4854 0.0,
4855 "content within the cap never scrolls"
4856 );
4857
4858 // Push past the cap: the caret (on the last line) must be kept in view by
4859 // a positive scroll offset.
4860 for _ in 0..4 {
4861 root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
4862 }
4863 let h = relayout(&mut root);
4864 assert!(
4865 widget(&root).scroll_y(h) > 0.0,
4866 "content past the cap scrolls to keep the caret in view"
4867 );
4868 }
4869
4870 #[test]
4871 fn enter_inserts_newline_in_multiline_by_default() {
4872 let mut state = AppState::default();
4873 let mut root = RenderRoot::new();
4874 root.rebuild(&mut multiline_logic, &mut state);
4875 root.layout(Size::new(300.0, 800.0));
4876 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
4877
4878 root.event(&mut state, &ch("a"));
4879 root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
4880 root.event(&mut state, &ch("b"));
4881
4882 assert_eq!(
4883 widget(&root).editor.text(),
4884 "a\nb",
4885 "Enter inserts a newline"
4886 );
4887 assert_eq!(state.submits, 0, "a newline-on-Enter field does not submit");
4888 assert_eq!(state.value, "a\nb", "the newline flows through on_change");
4889 }
4890
4891 #[test]
4892 fn shift_enter_submits_in_a_newline_on_enter_multiline() {
4893 let mut state = AppState::default();
4894 let mut root = RenderRoot::new();
4895 root.rebuild(&mut multiline_logic, &mut state);
4896 root.layout(Size::new(300.0, 800.0));
4897 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
4898 root.event(&mut state, &ch("h"));
4899 root.event(&mut state, &ch("i"));
4900
4901 let shift = Modifiers {
4902 shift: true,
4903 ..Modifiers::default()
4904 };
4905 root.event(&mut state, &named(NamedKey::Enter, shift));
4906
4907 assert_eq!(state.submits, 1, "Shift+Enter inverts the default → submit");
4908 assert_eq!(state.last_submit, "hi");
4909 assert!(
4910 !widget(&root).editor.text().contains('\n'),
4911 "Shift+Enter must not insert a newline here"
4912 );
4913 }
4914
4915 #[test]
4916 fn submit_on_enter_multiline_submits_and_shift_enter_inserts_newline() {
4917 let mut state = AppState::default();
4918 let mut root = RenderRoot::new();
4919 root.rebuild(&mut multiline_submit_logic, &mut state);
4920 root.layout(Size::new(300.0, 800.0));
4921 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
4922 root.event(&mut state, &ch("h"));
4923 root.event(&mut state, &ch("i"));
4924
4925 // Plain Enter submits (explicit submit_on_enter(true)).
4926 root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
4927 assert_eq!(
4928 state.submits, 1,
4929 "Enter submits when submit_on_enter is set"
4930 );
4931 assert!(!widget(&root).editor.text().contains('\n'));
4932
4933 // Shift+Enter inverts it → insert a newline instead.
4934 let shift = Modifiers {
4935 shift: true,
4936 ..Modifiers::default()
4937 };
4938 root.event(&mut state, &named(NamedKey::Enter, shift));
4939 assert_eq!(state.submits, 1, "Shift+Enter does not submit here");
4940 assert!(
4941 widget(&root).editor.text().contains('\n'),
4942 "Shift+Enter inserts a newline when submit_on_enter is set"
4943 );
4944 }
4945
4946 #[test]
4947 fn ime_newline_commit_inserts_newline_in_multiline() {
4948 // The iOS Return path (Commit("\n")) inserts a literal newline in a
4949 // newline-on-Enter multi-line field, rather than submitting.
4950 let mut state = AppState::default();
4951 let mut root = RenderRoot::new();
4952 root.rebuild(&mut multiline_logic, &mut state);
4953 root.layout(Size::new(300.0, 800.0));
4954 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
4955 root.event(&mut state, &ch("a"));
4956
4957 root.event(
4958 &mut state,
4959 &InputEvent::Ime(ImeEvent::Commit("\n".to_string())),
4960 );
4961
4962 assert!(
4963 widget(&root).editor.text().contains('\n'),
4964 "a newline commit lands a literal newline in a multi-line field"
4965 );
4966 assert_eq!(state.submits, 0, "the newline commit does not submit");
4967 }
4968
4969 #[test]
4970 fn arrow_up_down_move_caret_across_lines_in_multiline() {
4971 let mut state = AppState::default();
4972 let mut root = RenderRoot::new();
4973 root.rebuild(&mut multiline_logic, &mut state);
4974 root.layout(Size::new(300.0, 800.0));
4975 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
4976 root.event(&mut state, &ch("a"));
4977 root.event(&mut state, &ch("b"));
4978 root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
4979 root.event(&mut state, &ch("c"));
4980 root.event(&mut state, &ch("d")); // caret at end of line 2 (byte 5)
4981 assert_eq!(widget(&root).editor.editing_state_bytes().extent, 5);
4982
4983 // ArrowUp crosses onto line 1 (byte offset within [0, 2]).
4984 root.event(&mut state, &named(NamedKey::ArrowUp, Modifiers::default()));
4985 let up = widget(&root).editor.editing_state_bytes().extent;
4986 assert!(up <= 2, "ArrowUp moves the caret onto line 1, got {up}");
4987
4988 // ArrowDown returns to line 2 (byte offset >= 3, past the newline).
4989 root.event(
4990 &mut state,
4991 &named(NamedKey::ArrowDown, Modifiers::default()),
4992 );
4993 let down = widget(&root).editor.editing_state_bytes().extent;
4994 assert!(
4995 down >= 3,
4996 "ArrowDown moves the caret back to line 2, got {down}"
4997 );
4998 }
4999
5000 /// Regression: `Widget::layout`'s single-line branch must reset the
5001 /// editor's wrap width, or a field rebuilt from multiline into single-line
5002 /// keeps the stale wrapped layout — `content_height` (and, downstream, the
5003 /// text's vertical placement) stays wrong until something else happens to
5004 /// touch the wrap width again.
5005 #[test]
5006 fn multiline_to_single_line_rebuild_resets_wrap_width() {
5007 // Long enough to wrap across several lines at a narrow width.
5008 let long_text = "one two three four five six seven eight nine ten".to_string();
5009 let mut state = AppState {
5010 value: long_text.clone(),
5011 ..AppState::default()
5012 };
5013 let mut root = RenderRoot::new();
5014 root.rebuild(&mut multiline_logic, &mut state);
5015 let multi_height = root.layout(Size::new(120.0, 800.0)).height;
5016 assert!(
5017 widget(&root).editor.line_count() > 1,
5018 "seed text must actually wrap for this regression to be meaningful"
5019 );
5020
5021 // Rebuild the *same* root into a single-line field (multiline dropped)
5022 // with the identical text — the value is unchanged, so `rebuild` never
5023 // calls `set_controlled_value`; only `max_visible_lines` flips to
5024 // `None`, exercising the single-line `layout` branch on a widget whose
5025 // editor still carries the old wrap width.
5026 root.rebuild(&mut build, &mut state);
5027 let single_height = root.layout(Size::new(120.0, 800.0)).height;
5028
5029 assert_eq!(
5030 widget(&root).editor.line_count(),
5031 1,
5032 "single-line relayout must reset the wrap width so the editor \
5033 reflows back onto one line"
5034 );
5035 assert!(
5036 single_height < multi_height,
5037 "single-line relayout must collapse the stale wrapped height: \
5038 multi={multi_height}, single={single_height}"
5039 );
5040
5041 // Cross-check against a field built single-line from scratch with the
5042 // same text/width: the rebuilt-down field must match it exactly, not
5043 // merely be smaller.
5044 let mut fresh_state = AppState {
5045 value: long_text,
5046 ..AppState::default()
5047 };
5048 let mut fresh_root = RenderRoot::new();
5049 fresh_root.rebuild(&mut build, &mut fresh_state);
5050 let fresh_height = fresh_root.layout(Size::new(120.0, 800.0)).height;
5051 assert_eq!(
5052 single_height, fresh_height,
5053 "a multiline->single-line rebuild must match a field built \
5054 single-line from scratch"
5055 );
5056
5057 // Layout and caret placement agree: the origin's centering formula is
5058 // now measured against the corrected (single-line) content height.
5059 let w = widget(&root);
5060 assert_eq!(
5061 w.content_origin_y(single_height),
5062 w.text_top(single_height),
5063 "single-line mode must use the centered single-line placement"
5064 );
5065 }
5066
5067 #[test]
5068 fn placeholder_inherits_font_family_from_style() {
5069 // Verify that an empty unfocused field shows a placeholder with the input's
5070 // configured font family, weight, and letter-spacing — not the default style.
5071 use frust_text::{FontFamily, FontWeight};
5072
5073 let mut state = AppState::default();
5074 let mut root = RenderRoot::new();
5075
5076 // Build with a custom font family style.
5077 let custom_family = FontFamily::named("Monospace");
5078 let custom_style = TextStyle {
5079 family: custom_family.clone(),
5080 weight: FontWeight::BOLD,
5081 size: 18.0,
5082 letter_spacing: 1.5,
5083 ..TextStyle::default()
5084 };
5085
5086 root.rebuild(
5087 &mut |s: &mut AppState| {
5088 text_input(s.value.clone(), |_s: &mut AppState, _v: String| {})
5089 .placeholder("Enter text")
5090 .text_style(custom_style.clone())
5091 },
5092 &mut state,
5093 );
5094 root.layout(Size::new(300.0, 200.0));
5095
5096 let w = widget(&root);
5097 // Verify the widget's configured style has the custom family.
5098 assert_eq!(
5099 w.style.family, custom_family,
5100 "widget style should have the custom family"
5101 );
5102 assert_eq!(
5103 w.style.weight,
5104 FontWeight::BOLD,
5105 "widget style should have the custom weight"
5106 );
5107 assert_eq!(
5108 w.style.letter_spacing, 1.5,
5109 "widget style should have the custom letter-spacing"
5110 );
5111
5112 // Paint the widget (the placeholder will be rendered since the field is empty and unfocused).
5113 let mut sink = NullScene;
5114 root.paint(&mut sink, FrameTime::ZERO);
5115
5116 // The test verifies that the placeholder is laid out without crashing and
5117 // the field's style is correctly applied. A proper pixel-level assertion
5118 // would require inspecting glyph runs directly (which RecordingScene doesn't
5119 // support), but the layout success itself proves the family was accepted.
5120 }
5121
5122 #[test]
5123 fn placeholder_font_reflects_configured_style_family() {
5124 // Genuine shaped-output assertion: verify that an empty,
5125 // unfocused field's placeholder shapes with the input's configured font
5126 // family, not a fallback. Two TextInputs—one with default family (SystemUi),
5127 // one with an explicit named family—must resolve to different fonts in
5128 // their placeholder runs. If `ph_style` regresses to `TextStyle::new(size,
5129 // color)`, both would drop the family and resolve to the same default font,
5130 // causing this assertion to fail.
5131 use frust_text::{FontFamily, GenericSlot};
5132
5133 // First TextInput: default style (no explicit family).
5134 // The placeholder will shape with the default family (SystemUi).
5135 let mut state_default = AppState::default();
5136 let mut root_default = RenderRoot::new();
5137 root_default.rebuild(
5138 &mut |s: &mut AppState| {
5139 text_input(s.value.clone(), |_s: &mut AppState, _v: String| {}).placeholder("test")
5140 },
5141 &mut state_default,
5142 );
5143 root_default.layout(Size::new(300.0, 200.0));
5144 let default_font = painted_placeholder_font_bytes(&mut root_default);
5145
5146 // Second TextInput: explicit monospace family using stack_with_generic.
5147 // Monospace is a generic family available on all platforms; it will
5148 // resolve to a different system font than SystemUi on any test host.
5149 let monospace_style = TextStyle {
5150 family: FontFamily::stack_with_generic(Vec::<String>::new(), GenericSlot::Monospace),
5151 ..TextStyle::default()
5152 };
5153 let mut state_monospace = AppState::default();
5154 let mut root_monospace = RenderRoot::new();
5155 root_monospace.rebuild(
5156 &mut |s: &mut AppState| {
5157 text_input(s.value.clone(), |_s: &mut AppState, _v: String| {})
5158 .placeholder("test")
5159 .text_style(monospace_style.clone())
5160 },
5161 &mut state_monospace,
5162 );
5163 root_monospace.layout(Size::new(300.0, 200.0));
5164 let monospace_font = painted_placeholder_font_bytes(&mut root_monospace);
5165
5166 // Assert: the two placeholders resolved to different fonts.
5167 // If ph_style regressed (losing the family), both would use SystemUi
5168 // and resolve to the same font.
5169 assert_ne!(
5170 default_font, monospace_font,
5171 "placeholder with Monospace family should resolve to a different font \
5172 than the default SystemUi family; if this fails, ph_style likely \
5173 regressed to not preserving the configured family"
5174 );
5175 }
5176
5177 // --- App-font parity (the shell drain must reach the private context) ---
5178 //
5179 // A `TextInput` owns a private `TextContext` (module docs' Text context
5180 // ownership). The defect this section pins: if that private context were a
5181 // bare `TextContext::new()`, an app font registered through
5182 // `frust::register_app_fonts` — drained by the shell into the *shell-owned*
5183 // context, the one `LayoutCtx::text_context` threads to `Text` — would never
5184 // reach the field, which would silently fall back to a platform face.
5185 //
5186 // `frust-widgets` cannot call `frust::register_app_fonts` (its registry
5187 // lives in `frust-shell-common`, above this crate), so these tests
5188 // reproduce the drain's single observable effect instead:
5189 // `FontRegistryWatcher::drain_into` is a `TextContext::register_fonts` call
5190 // on the shell-owned context, and nothing else.
5191
5192 /// The registered test font's bytes — the same public-domain subsetted
5193 /// asset `frust-text`'s own registration tests use (and which
5194 /// `frust-shell-common` likewise includes cross-crate rather than
5195 /// duplicating a font file per crate).
5196 const TUFFY: &[u8] = include_bytes!("../../frust-text/tests/fonts/Tuffy-Subset.ttf");
5197
5198 /// The same face with its `name` table rewritten to "Helvetica". Used for
5199 /// the late-registration test so the *pre-registration* leg is meaningful
5200 /// on any host: "Helvetica" resolves to whatever the platform has (or a
5201 /// fallback) before registration, and to these exact bytes after, per
5202 /// fontique's registered-shadows-system-family rule.
5203 const TUFFY_AS_HELVETICA: &[u8] =
5204 include_bytes!("../../frust-text/tests/fonts/Tuffy-As-Helvetica.ttf");
5205
5206 /// Tuffy's digit advance at 24px, in logical px — measured from this exact
5207 /// asset through this exact parley pin (see `docs/DEVELOPMENT.md`'s
5208 /// Version-Pin Policy). Hard-coded so the assertion is on the *shaped
5209 /// metric*, not merely on "some font was registered": the device symptom
5210 /// behind this bug was a wrong per-glyph advance (1-em fallback boxes)
5211 /// while registration itself reported success.
5212 const TUFFY_DIGIT_ADVANCE_24PX: f32 = 13.3125;
5213
5214 /// Slack on [`TUFFY_DIGIT_ADVANCE_24PX`]: parley lays out with
5215 /// `quantize = true`, so a run's successive x deltas land on subpixel
5216 /// boundaries and one digit pair in ten reads ~0.12px short of the nominal
5217 /// advance. Far tighter than any real font swap (a fallback face differs by
5218 /// whole pixels at this size).
5219 const ADVANCE_TOLERANCE: f32 = 0.25;
5220
5221 /// The digits string every advance assertion shapes — uniform-width in
5222 /// Tuffy, so one expected advance covers every glyph in the run.
5223 const DIGITS: &str = "0123456789";
5224
5225 /// Records each painted glyph run's resolved font bytes and per-glyph
5226 /// advances (successive x deltas) — shaped output, not registration state.
5227 #[derive(Default)]
5228 struct ShapedRunRecorder {
5229 runs: Vec<(Vec<u8>, Vec<f32>)>,
5230 }
5231
5232 impl PaintScene for ShapedRunRecorder {
5233 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
5234 fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, _c: Color) {}
5235 fn draw_text(&mut self, _o: Point, _t: &str) {}
5236 fn draw_glyph_run(&mut self, run: frust_scene::GlyphRun) {
5237 let advances = run.glyphs.windows(2).map(|w| w[1].x - w[0].x).collect();
5238 self.runs
5239 .push((run.font.font().data.as_ref().to_vec(), advances));
5240 }
5241 }
5242
5243 /// Paint `root` and return the first glyph run's `(font bytes, advances)`.
5244 fn painted_shaped_run(
5245 root: &mut RenderRoot<AppState, TextInputView<AppState>>,
5246 ) -> (Vec<u8>, Vec<f32>) {
5247 let mut rec = ShapedRunRecorder::default();
5248 root.paint(&mut rec, FrameTime::ZERO);
5249 rec.runs.first().expect("one glyph run painted").clone()
5250 }
5251
5252 /// A 24px style in `family`.
5253 fn family_style(family: frust_text::FontFamily) -> TextStyle {
5254 TextStyle {
5255 family,
5256 ..TextStyle::new(24.0, Color::BLACK)
5257 }
5258 }
5259
5260 /// A laid-out field carrying `value` (empty = the placeholder path) in
5261 /// `family`, built *now* — i.e. with whatever fonts are registered at call
5262 /// time, which is the ordering under test.
5263 fn field_in_family(
5264 state: &mut AppState,
5265 family: frust_text::FontFamily,
5266 ) -> RenderRoot<AppState, TextInputView<AppState>> {
5267 let style = family_style(family);
5268 let mut root = RenderRoot::new();
5269 root.rebuild(
5270 &mut |s: &mut AppState| {
5271 text_input(s.value.clone(), |_s: &mut AppState, _v: String| {})
5272 .placeholder(DIGITS)
5273 .text_style(style.clone())
5274 },
5275 state,
5276 );
5277 root.layout(Size::new(600.0, 200.0));
5278 root
5279 }
5280
5281 /// Assert the shaped run resolved to `expected`'s exact bytes.
5282 ///
5283 /// Compared behind a `bool` rather than `assert_eq!` on purpose: an
5284 /// `assert_eq!` between two font files prints both in full, which is tens
5285 /// of megabytes of failure output per failing test.
5286 fn assert_same_font(font: &[u8], expected: &[u8], what: &str) {
5287 assert!(
5288 font == expected,
5289 "{what}: shaped against a different face than the registered app \
5290 font ({} bytes shaped vs {} expected)",
5291 font.len(),
5292 expected.len()
5293 );
5294 }
5295
5296 /// Assert the shaped run resolved to something *other* than `other`'s bytes.
5297 fn assert_other_font(font: &[u8], other: &[u8], what: &str) {
5298 assert!(
5299 font != other,
5300 "{what}: expected a different face, got the same {} bytes",
5301 font.len()
5302 );
5303 }
5304
5305 /// Assert every advance in `advances` is `expected` (within quantization
5306 /// slack — see [`ADVANCE_TOLERANCE`]).
5307 fn assert_uniform_advance(advances: &[f32], expected: f32, what: &str) {
5308 assert!(!advances.is_empty(), "{what}: no advances measured");
5309 for a in advances {
5310 assert!(
5311 (a - expected).abs() <= ADVANCE_TOLERANCE,
5312 "{what}: shaped advance {a} != the registered font's own {expected} \
5313 (all advances: {advances:?})"
5314 );
5315 }
5316 }
5317
5318 #[test]
5319 fn app_registered_font_shapes_the_field_content() {
5320 // The shell's construction-time drain, reproduced exactly.
5321 let mut shell_ctx = frust_text::TextContext::new();
5322 shell_ctx
5323 .register_fonts(TUFFY.to_vec())
5324 .expect("valid TTF bytes must register");
5325
5326 // A field built *after* that drain — the real ordering: a design system
5327 // registers its fonts from `app!`'s setup block, before the first
5328 // rebuild builds any widget.
5329 let mut state = AppState {
5330 value: DIGITS.to_string(),
5331 ..AppState::default()
5332 };
5333 let mut root = field_in_family(&mut state, frust_text::FontFamily::named("Tuffy"));
5334 let (font, advances) = painted_shaped_run(&mut root);
5335
5336 assert_same_font(&font, TUFFY, "content");
5337 assert_uniform_advance(&advances, TUFFY_DIGIT_ADVANCE_24PX, "content");
5338
5339 // Negative control: an unregistered family name resolves to a fallback
5340 // face, whose bytes cannot be the registered asset's.
5341 let mut fallback_state = AppState {
5342 value: DIGITS.to_string(),
5343 ..AppState::default()
5344 };
5345 let mut fallback_root = field_in_family(
5346 &mut fallback_state,
5347 frust_text::FontFamily::named("Frust No Such Family"),
5348 );
5349 let (fallback_font, fallback_advances) = painted_shaped_run(&mut fallback_root);
5350 assert_other_font(&fallback_font, TUFFY, "unregistered-family control");
5351 assert_ne!(
5352 fallback_advances, advances,
5353 "fixture sanity: the fallback face must shape these digits to \
5354 different metrics, or this test could pass without the app font"
5355 );
5356 }
5357
5358 #[test]
5359 fn app_registered_font_shapes_the_placeholder() {
5360 // The placeholder shapes on its own path (`text_ctx.layout` in `paint`,
5361 // not the editor's retained layout), so it needs its own coverage.
5362 let mut shell_ctx = frust_text::TextContext::new();
5363 shell_ctx
5364 .register_fonts(TUFFY.to_vec())
5365 .expect("valid TTF bytes must register");
5366
5367 // Empty value + never focused = the placeholder is what gets painted.
5368 let mut state = AppState::default();
5369 let mut root = field_in_family(&mut state, frust_text::FontFamily::named("Tuffy"));
5370 let (font, advances) = painted_shaped_run(&mut root);
5371
5372 assert_same_font(&font, TUFFY, "placeholder");
5373 assert_uniform_advance(&advances, TUFFY_DIGIT_ADVANCE_24PX, "placeholder");
5374 }
5375
5376 #[test]
5377 fn font_registered_after_build_reshapes_the_field_at_the_next_layout() {
5378 // The shells' *per-frame* late drain: a font can register after this
5379 // widget's private context was built. `layout`'s `sync_app_fonts` picks
5380 // it up and rebuilds the editor, whose retained parley layout would
5381 // otherwise stay shaped against the old faces (clearing the private
5382 // context's shape cache alone would not cover it).
5383 let mut state = AppState {
5384 value: DIGITS.to_string(),
5385 ..AppState::default()
5386 };
5387 let mut root = field_in_family(&mut state, frust_text::FontFamily::named("Helvetica"));
5388 let (before_font, before_advances) = painted_shaped_run(&mut root);
5389 assert_other_font(
5390 &before_font,
5391 TUFFY_AS_HELVETICA,
5392 "pre-registration control (nothing has registered this face yet)",
5393 );
5394
5395 let mut shell_ctx = frust_text::TextContext::new();
5396 shell_ctx
5397 .register_fonts(TUFFY_AS_HELVETICA.to_vec())
5398 .expect("valid TTF bytes must register");
5399
5400 // The shell forces LAYOUT on a `drain_into` that applied something;
5401 // this is that relayout.
5402 root.layout(Size::new(600.0, 200.0));
5403 let (after_font, after_advances) = painted_shaped_run(&mut root);
5404
5405 // A registered family shadows any system "Helvetica" (fontique 0.11),
5406 // so this holds on every host.
5407 assert_same_font(&after_font, TUFFY_AS_HELVETICA, "late-registered");
5408 assert_uniform_advance(&after_advances, TUFFY_DIGIT_ADVANCE_24PX, "late-registered");
5409 assert_ne!(
5410 after_advances, before_advances,
5411 "the late registration must actually change the shaped metrics"
5412 );
5413 }
5414
5415 // --- enabled(false) ---
5416
5417 /// The standard fixture field, with `enabled`/`obscured`/`read_only`
5418 /// dialled in.
5419 fn options_logic(
5420 enabled: bool,
5421 obscured: bool,
5422 read_only: bool,
5423 ) -> impl FnMut(&mut AppState) -> TextInputView<AppState> {
5424 move |state: &mut AppState| {
5425 text_input(state.value.clone(), |s: &mut AppState, v: String| {
5426 s.changes += 1;
5427 s.value = v;
5428 })
5429 .placeholder("type here")
5430 .enabled(enabled)
5431 .obscured(obscured)
5432 .read_only(read_only)
5433 }
5434 }
5435
5436 /// Build + lay out a root over `logic`.
5437 fn options_root(
5438 logic: &mut impl FnMut(&mut AppState) -> TextInputView<AppState>,
5439 state: &mut AppState,
5440 ) -> RenderRoot<AppState, TextInputView<AppState>> {
5441 let mut root = RenderRoot::new();
5442 root.rebuild(logic, state);
5443 root.layout(Size::new(300.0, 200.0));
5444 root
5445 }
5446
5447 #[test]
5448 fn disabled_field_refuses_focus_and_stays_inert() {
5449 let mut state = AppState::default();
5450 let mut logic = options_logic(false, false, false);
5451 let mut root = options_root(&mut logic, &mut state);
5452
5453 let outcome = root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
5454 assert!(!outcome.handled, "a disabled field consumes nothing");
5455 assert!(
5456 !root.is_focus_active(),
5457 "a tap must not focus a disabled field"
5458 );
5459 assert!(!widget(&root).focused);
5460 assert!(!widget(&root).captured, "no drag capture either");
5461 assert!(root.ime_state().is_none(), "no IME surface is published");
5462
5463 // Keys and IME are unreachable (they route down the focus path, which
5464 // the tap never claimed) and edit nothing even when injected directly.
5465 root.event(&mut state, &ch("x"));
5466 root.event(
5467 &mut state,
5468 &InputEvent::Ime(ImeEvent::Commit("ni".to_string())),
5469 );
5470 assert_eq!(widget(&root).editor.text(), "");
5471 assert_eq!(state.changes, 0);
5472
5473 // No caret is painted, and the field is at rest.
5474 let mut rec = CaretRecorder {
5475 caret_color: Some(CARET.multiply_alpha(DISABLED_CONTENT_ALPHA)),
5476 caret_fills: 0,
5477 };
5478 let outcome = root.paint(&mut rec, FrameTime::ZERO);
5479 assert_eq!(rec.caret_fills, 0, "a disabled field paints no caret");
5480 assert!(!outcome.needs_frame, "a disabled field never blinks");
5481 }
5482
5483 #[test]
5484 fn disabling_a_focused_field_releases_focus_and_deactivates_ime() {
5485 let mut state = AppState::default();
5486 let mut enabled_logic = options_logic(true, false, false);
5487 let mut root = options_root(&mut enabled_logic, &mut state);
5488 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
5489 assert!(root.is_focus_active());
5490 assert!(root.ime_state().expect("focused").active);
5491
5492 // Flip the flag on a rebuild while the field holds focus.
5493 let mut disabled_logic = options_logic(false, false, false);
5494 let flags = root.rebuild(&mut disabled_logic, &mut state);
5495 assert!(
5496 flags.needs_layout(),
5497 "the disabled dim is baked at layout, so the flip must relayout"
5498 );
5499 root.layout(Size::new(300.0, 200.0));
5500 assert!(
5501 !widget(&root).focused,
5502 "the widget stops considering itself focused immediately"
5503 );
5504
5505 // Leg 1 — the next paint already refuses to act focused and hands the
5506 // shell an inactive IME surface (dismissing the keyboard). The root reads
5507 // that inactive publish as a session release, so it drops the surface
5508 // outright (both mobile bridges serialise `None` to the same inactive
5509 // wire form — see `RenderRoot::ime_state`) and clears its focus mirror
5510 // rather than leaving it standing over a field that stopped editing.
5511 let mut sink = NullScene;
5512 let outcome = root.paint(&mut sink, FrameTime::ZERO);
5513 assert!(!outcome.needs_frame, "a disabled field paints at rest");
5514 assert!(
5515 root.ime_state().is_none(),
5516 "the published inactive surface releases the session"
5517 );
5518 assert!(
5519 !root.is_focus_active(),
5520 "the root's focus mirror goes with it"
5521 );
5522
5523 // Leg 2 — the first event that reaches the field releases the pod-level
5524 // focus path, and edits nothing on the way.
5525 root.event(&mut state, &ch("x"));
5526 assert!(
5527 !root.is_focus_active(),
5528 "the stranded focus path is released"
5529 );
5530 assert!(root.ime_state().is_none());
5531 assert_eq!(widget(&root).editor.text(), "");
5532 }
5533
5534 /// A design-system-shaped theme whose `on_surface_variant` role is itself
5535 /// **translucent** — the shape an iOS-style design system installs (its
5536 /// `secondaryLabel` token really is `rgba(.., 0.60)`). Built inline here
5537 /// because no design language ships in this crate any more. The dim under
5538 /// test is an alpha *multiplier* on the resolved role, so it has to behave
5539 /// identically over an already-translucent one — which is exactly what a
5540 /// token *swap* would not do.
5541 fn translucent_role_theme() -> Theme {
5542 Theme::builder(Theme::neutral())
5543 .design_language(frust_theme::DesignLanguage::Cupertino)
5544 .map_colors_light(|c| frust_theme::ColorScheme {
5545 on_surface_variant: c.on_surface_variant.multiply_alpha(0.6),
5546 ..c
5547 })
5548 .build()
5549 }
5550
5551 #[test]
5552 fn disabled_dims_content_and_outline_in_every_design_language() {
5553 // The dim is an alpha multiplier on the *resolved* role, so it must
5554 // behave identically unthemed, under the neutral baseline, and under a
5555 // design system whose own `on_surface_variant` is already translucent
5556 // (see `translucent_role_theme` — the reason a token swap would not be
5557 // portable).
5558 let translucent = translucent_role_theme();
5559 assert!(
5560 translucent.scheme().on_surface_variant.components[3] < 1.0,
5561 "fixture sanity: the third arm's role must really be translucent"
5562 );
5563 let languages: Vec<(&str, Option<Theme>)> = vec![
5564 ("unthemed", None),
5565 ("neutral", Some(Theme::neutral())),
5566 ("translucent-role", Some(translucent)),
5567 ];
5568 for (name, theme) in languages {
5569 let enabled = Chrome::resolve(theme.as_ref(), true);
5570 let disabled = Chrome::resolve(theme.as_ref(), false);
5571 assert_eq!(
5572 disabled.placeholder,
5573 enabled.placeholder.multiply_alpha(DISABLED_CONTENT_ALPHA),
5574 "{name}: disabled placeholder is the enabled role at 38%"
5575 );
5576 assert_eq!(
5577 disabled.border,
5578 enabled.border.multiply_alpha(DISABLED_CONTAINER_ALPHA),
5579 "{name}: disabled outline is the enabled role at 12%"
5580 );
5581 assert!(
5582 disabled.placeholder.components[3] < enabled.placeholder.components[3],
5583 "{name}: the disabled placeholder must actually be more transparent"
5584 );
5585 assert_eq!(
5586 disabled.bg, enabled.bg,
5587 "{name}: the container fill stays opaque (see Chrome::resolve)"
5588 );
5589 }
5590 }
5591
5592 #[test]
5593 fn disabled_dims_the_layout_baked_glyph_color() {
5594 // The second resolution point: the glyph color is baked into the shaped
5595 // editor state at LAYOUT time, so dimming has to happen there too.
5596 let mut state = AppState {
5597 value: "hi".to_string(),
5598 ..AppState::default()
5599 };
5600 let mut logic = options_logic(false, false, false);
5601 let mut root = options_root(&mut logic, &mut state);
5602 root.set_theme(Box::new(Theme::neutral()));
5603 root.layout(Size::new(300.0, 200.0));
5604
5605 let expected = Theme::neutral()
5606 .scheme()
5607 .on_surface
5608 .multiply_alpha(DISABLED_CONTENT_ALPHA);
5609 assert_eq!(
5610 painted_text_color(&mut root),
5611 expected,
5612 "a disabled field's glyphs are on_surface at 38%"
5613 );
5614 }
5615
5616 #[test]
5617 fn disabled_dims_the_unthemed_fallback_glyph_color() {
5618 let mut state = AppState {
5619 value: "hi".to_string(),
5620 ..AppState::default()
5621 };
5622 let mut logic = options_logic(false, false, false);
5623 let mut root = options_root(&mut logic, &mut state);
5624 assert_eq!(
5625 painted_text_color(&mut root),
5626 Color::BLACK.multiply_alpha(DISABLED_CONTENT_ALPHA),
5627 "with no theme threaded the black fallback dims by the same rule"
5628 );
5629 }
5630
5631 // --- read_only(true) ---
5632
5633 /// A read-only field over `value`, ready for events.
5634 fn read_only_root(
5635 state: &mut AppState,
5636 logic: &mut impl FnMut(&mut AppState) -> TextInputView<AppState>,
5637 ) -> RenderRoot<AppState, TextInputView<AppState>> {
5638 options_root(logic, state)
5639 }
5640
5641 /// Drag-select the whole buffer of an already-built field, the way a user
5642 /// produces a selection — [`focused_with_selection`] without the typing a
5643 /// read-only field would refuse.
5644 fn drag_select_all(
5645 state: &mut AppState,
5646 root: &mut RenderRoot<AppState, TextInputView<AppState>>,
5647 ) {
5648 root.event(state, &pointer(PointerPhase::Down, 0.0, 10.0));
5649 root.event(state, &pointer(PointerPhase::Move, 290.0, 10.0));
5650 root.event(state, &pointer(PointerPhase::Up, 290.0, 10.0));
5651 }
5652
5653 #[test]
5654 fn read_only_takes_focus_on_a_press_and_paints_focused() {
5655 // A read-only field is focusable so its content can be selected and
5656 // copied; what it withholds is editing, not the session.
5657 let mut state = AppState {
5658 value: "abc".to_string(),
5659 ..AppState::default()
5660 };
5661 let mut logic = options_logic(true, false, true);
5662 let mut root = read_only_root(&mut state, &mut logic);
5663
5664 let outcome = root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
5665 assert!(
5666 outcome.handled,
5667 "the press is consumed, like any focus claim"
5668 );
5669 assert!(root.is_focus_active(), "a press focuses a read-only field");
5670 assert!(widget(&root).focused);
5671 assert!(widget(&root).captured, "and captures, so a drag can select");
5672
5673 let rec = paint_chrome(&mut root);
5674 assert_eq!(
5675 rec.rrects[0], ACCENT,
5676 "a focused read-only field paints the focused accent border"
5677 );
5678 assert_eq!(rec.rects, vec![CARET], "and draws its caret");
5679 }
5680
5681 #[test]
5682 fn a_read_only_caret_is_drawn_steady_and_asks_for_no_blink_frames() {
5683 // The blink advertises an insertion point; a field that takes no
5684 // insertion has none to advertise, so the caret marks where a selection
5685 // would start and the field otherwise sits at rest.
5686 let mut state = AppState {
5687 value: "abc".to_string(),
5688 ..AppState::default()
5689 };
5690 let mut logic = options_logic(true, false, true);
5691 let mut root = read_only_root(&mut state, &mut logic);
5692 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
5693 root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
5694
5695 for ms in [0.0, BLINK_MS + 10.0, 2.0 * BLINK_MS + 10.0] {
5696 let mut rec = CaretRecorder {
5697 caret_color: Some(CARET),
5698 caret_fills: 0,
5699 };
5700 let outcome = root.paint(&mut rec, ft_ms(ms));
5701 assert_eq!(rec.caret_fills, 1, "the caret stays lit at t={ms}ms");
5702 assert!(
5703 !outcome.needs_frame,
5704 "no continuation frame is asked for at t={ms}ms"
5705 );
5706 }
5707 }
5708
5709 #[test]
5710 fn a_focused_read_only_field_publishes_an_active_keyboard_suppressed_surface() {
5711 // Active so each shell's clipboard route (which hangs off the platform
5712 // surface) stays wired; suppressed so no on-screen keyboard comes up.
5713 let mut state = AppState {
5714 value: "abc".to_string(),
5715 ..AppState::default()
5716 };
5717 let mut logic = options_logic(true, false, true);
5718 let mut root = read_only_root(&mut state, &mut logic);
5719 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
5720
5721 let ime = root.ime_state().expect("a focused read-only field has one");
5722 assert!(ime.active, "the surface is live, not released");
5723 assert!(ime.suppress_soft_keyboard);
5724 assert_eq!(ime.editing.text, "abc");
5725
5726 // An editable field says nothing, exactly as it did before the hint.
5727 let mut state = AppState::default();
5728 let mut editable_logic = options_logic(true, false, false);
5729 let mut root = options_root(&mut editable_logic, &mut state);
5730 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
5731 let ime = root.ime_state().expect("a focused editable field has one");
5732 assert!(ime.active);
5733 assert!(
5734 !ime.suppress_soft_keyboard,
5735 "an editable field wants its keyboard"
5736 );
5737 }
5738
5739 #[test]
5740 fn read_only_copies_a_drag_selection_and_selects_all_from_the_chords() {
5741 let mut state = AppState {
5742 value: "abc".to_string(),
5743 ..AppState::default()
5744 };
5745 let mut logic = options_logic(true, false, true);
5746 let mut root = read_only_root(&mut state, &mut logic);
5747 drag_select_all(&mut state, &mut root);
5748 assert_eq!(
5749 selection(&root).as_deref(),
5750 Some("abc"),
5751 "a drag selects on a read-only field"
5752 );
5753
5754 root.event(&mut state, &chord("c", meta()));
5755 assert_eq!(
5756 root.take_clipboard_write().as_deref(),
5757 Some("abc"),
5758 "copy is the verb read-only exists to allow"
5759 );
5760 assert_eq!(state.changes, 0, "a copy is not an edit");
5761
5762 // Select-all reaches the same buffer from a fresh session, where a
5763 // press placed a caret and selected nothing.
5764 let mut state = AppState {
5765 value: "abc".to_string(),
5766 ..AppState::default()
5767 };
5768 let mut logic = options_logic(true, false, true);
5769 let mut root = read_only_root(&mut state, &mut logic);
5770 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
5771 root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
5772 assert_eq!(selection(&root), None, "sanity: nothing selected yet");
5773
5774 root.event(&mut state, &chord("a", meta()));
5775 assert_eq!(selection(&root).as_deref(), Some("abc"));
5776 assert_eq!(state.changes, 0);
5777 }
5778
5779 #[test]
5780 fn read_only_answers_cut_and_paste_with_nothing() {
5781 // Consumed, not ignored: the verb was understood. What it may not do is
5782 // change the buffer, write half a cut to the clipboard, or report an
5783 // edit that never happened.
5784 let mut state = AppState {
5785 value: "abc".to_string(),
5786 ..AppState::default()
5787 };
5788 let mut logic = options_logic(true, false, true);
5789 let mut root = read_only_root(&mut state, &mut logic);
5790 drag_select_all(&mut state, &mut root);
5791
5792 let outcome = root.event(&mut state, &chord("x", meta()));
5793 assert!(outcome.handled, "the cut chord is consumed");
5794 assert_eq!(
5795 root.take_clipboard_write(),
5796 None,
5797 "a refused cut writes nothing — half a cut is not a copy"
5798 );
5799
5800 let outcome = root.event(&mut state, &edit(EditCommand::Paste("zz".to_string())));
5801 assert!(outcome.handled, "the paste command is consumed");
5802
5803 assert_eq!(
5804 widget(&root).editor.text(),
5805 "abc",
5806 "the buffer is untouched"
5807 );
5808 assert_eq!(state.changes, 0, "and no on_change fires for either");
5809 assert_eq!(state.value, "abc");
5810 }
5811
5812 #[test]
5813 fn read_only_still_refuses_typing_and_ime_commits() {
5814 let mut state = AppState {
5815 value: "abc".to_string(),
5816 ..AppState::default()
5817 };
5818 let mut logic = options_logic(true, false, true);
5819 let mut root = read_only_root(&mut state, &mut logic);
5820 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
5821 assert!(root.is_focus_active(), "refused while genuinely focused");
5822
5823 for event in [
5824 ch("x"),
5825 InputEvent::Ime(ImeEvent::Commit("ni".to_string())),
5826 named(NamedKey::Backspace, Modifiers::default()),
5827 named(NamedKey::Delete, Modifiers::default()),
5828 named(NamedKey::Enter, Modifiers::default()),
5829 ] {
5830 let outcome = root.event(&mut state, &event);
5831 assert!(
5832 !outcome.handled,
5833 "an edit is refused unconsumed, as it was when the field \
5834 refused focus outright: {event:?}"
5835 );
5836 }
5837 assert_eq!(widget(&root).editor.text(), "abc");
5838 assert_eq!(state.changes, 0);
5839 assert_eq!(state.submits, 0, "Enter submits nothing either");
5840 }
5841
5842 #[test]
5843 fn escape_ends_a_read_only_session_without_touching_the_text() {
5844 // A field that can hold a session needs a keyboard way out of it, so
5845 // Escape stays answered where the editing keys do not. It ends the
5846 // session and takes the toolbar with it, and mutates nothing on the way.
5847 let mut state = AppState {
5848 value: "abc".to_string(),
5849 ..AppState::default()
5850 };
5851 let mut logic = options_logic(true, false, true);
5852 let mut root = read_only_root(&mut state, &mut logic);
5853 root.event(
5854 &mut state,
5855 &secondary_pointer(PointerPhase::Down, 20.0, 10.0),
5856 );
5857 assert!(root.is_focus_active(), "the context press opened a session");
5858 assert!(widget(&root).toolbar_open, "…with the bar up");
5859
5860 let outcome = root.event(&mut state, &named(NamedKey::Escape, Modifiers::default()));
5861
5862 assert!(
5863 outcome.handled,
5864 "Escape is answered, not left for an ancestor"
5865 );
5866 assert!(!root.is_focus_active(), "the session is over");
5867 assert!(!widget(&root).focused);
5868 assert!(
5869 !widget(&root).toolbar_open,
5870 "the toolbar goes with the session it belonged to"
5871 );
5872 assert_eq!(widget(&root).editor.text(), "abc", "and nothing was edited");
5873 assert_eq!(state.changes, 0);
5874 }
5875
5876 #[test]
5877 fn disabled_still_refuses_focus_where_read_only_no_longer_does() {
5878 // `enabled(false)` is the stronger claim and is unchanged by the
5879 // read-only split: no focus, no caret, no surface, nothing consumed.
5880 let mut state = AppState {
5881 value: "abc".to_string(),
5882 ..AppState::default()
5883 };
5884 let mut logic = options_logic(false, false, false);
5885 let mut root = options_root(&mut logic, &mut state);
5886
5887 let outcome = root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
5888 assert!(!outcome.handled, "a disabled field consumes nothing");
5889 assert!(!root.is_focus_active(), "and takes no focus");
5890 assert!(root.ime_state().is_none(), "so it publishes no surface");
5891
5892 let mut rec = CaretRecorder {
5893 caret_color: Some(CARET),
5894 caret_fills: 0,
5895 };
5896 let outcome = root.paint(&mut rec, FrameTime::ZERO);
5897 assert_eq!(rec.caret_fills, 0, "a disabled field paints no caret");
5898 assert!(!outcome.needs_frame);
5899 }
5900
5901 #[test]
5902 fn making_a_focused_field_read_only_keeps_the_session_and_drops_the_keyboard() {
5903 // Unlike a field disabled while focused, this one keeps its session:
5904 // the text is still on screen and still worth selecting. What goes is
5905 // the on-screen keyboard, asked for on the very next published surface.
5906 let mut state = AppState {
5907 value: "abc".to_string(),
5908 ..AppState::default()
5909 };
5910 let mut live_logic = options_logic(true, false, false);
5911 let mut root = options_root(&mut live_logic, &mut state);
5912 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
5913 assert!(root.is_focus_active());
5914 let ime = root.ime_state().expect("focused");
5915 assert!(ime.active && !ime.suppress_soft_keyboard);
5916
5917 // Flip the flag on a rebuild while the field holds focus.
5918 let mut read_only_logic = options_logic(true, false, true);
5919 let flags = root.rebuild(&mut read_only_logic, &mut state);
5920 assert!(
5921 !flags.needs_layout(),
5922 "read-only never dims, so the flip is PAINT-only, unlike disabled"
5923 );
5924 root.layout(Size::new(300.0, 200.0));
5925 assert!(
5926 widget(&root).focused,
5927 "the widget keeps considering itself focused"
5928 );
5929
5930 // Leg 1 — the next paint keeps the session and re-publishes it with the
5931 // keyboard suppressed; the caret is still drawn, now steady.
5932 let mut rec = CaretRecorder {
5933 caret_color: Some(CARET),
5934 caret_fills: 0,
5935 };
5936 root.paint(&mut rec, FrameTime::ZERO);
5937 assert_eq!(rec.caret_fills, 1, "the caret survives the flip");
5938 let ime = root.ime_state().expect("the session survives the flip");
5939 assert!(ime.active, "the surface stays live for the clipboard route");
5940 assert!(ime.suppress_soft_keyboard, "but the keyboard is asked down");
5941 assert!(root.is_focus_active(), "the root's focus mirror stays");
5942
5943 // Leg 2 — and the keyboard events it can no longer act on are refused
5944 // without disturbing any of that.
5945 root.event(&mut state, &ch("x"));
5946 assert!(root.is_focus_active(), "a refused key does not blur");
5947 assert_eq!(widget(&root).editor.text(), "abc");
5948 assert_eq!(state.changes, 0);
5949 }
5950
5951 #[test]
5952 fn read_only_paints_full_alpha_chrome_in_every_design_language() {
5953 // The first of the two dimming resolution points: `Chrome::resolve`,
5954 // observed here through the actual `paint` pass (not called directly),
5955 // so the assertion also proves `paint` feeds it `enabled` alone.
5956 let languages: Vec<(&str, Option<Theme>)> = vec![
5957 ("unthemed", None),
5958 ("neutral", Some(Theme::neutral())),
5959 ("translucent-role", Some(translucent_role_theme())),
5960 ];
5961 for (name, theme) in languages {
5962 let mut state = AppState::default();
5963 let mut logic = options_logic(true, false, true);
5964 let mut root = options_root(&mut logic, &mut state);
5965 if let Some(theme) = theme.clone() {
5966 root.set_theme(Box::new(theme));
5967 }
5968 let live_border = Chrome::resolve(theme.as_ref(), true).border;
5969 let rec = paint_chrome(&mut root);
5970 assert_eq!(
5971 rec.rrects[0], live_border,
5972 "{name}: a read-only field's idle border matches the fully-\
5973 enabled resolved border — not `DISABLED_CONTAINER_ALPHA`-dimmed"
5974 );
5975 }
5976 }
5977
5978 #[test]
5979 fn read_only_paints_full_alpha_layout_baked_glyph_color() {
5980 // The second of the two dimming resolution points:
5981 // `effective_style`'s LAYOUT-time bake.
5982 let mut state = AppState {
5983 value: "hi".to_string(),
5984 ..AppState::default()
5985 };
5986 let mut logic = options_logic(true, false, true);
5987 let mut root = options_root(&mut logic, &mut state);
5988 root.set_theme(Box::new(Theme::neutral()));
5989 root.layout(Size::new(300.0, 200.0));
5990
5991 assert_eq!(
5992 painted_text_color(&mut root),
5993 Theme::neutral().scheme().on_surface,
5994 "a read-only field's glyphs stay full-alpha on_surface, not dimmed"
5995 );
5996 }
5997
5998 #[test]
5999 fn read_only_paints_full_alpha_unthemed_fallback_glyph_color() {
6000 let mut state = AppState {
6001 value: "hi".to_string(),
6002 ..AppState::default()
6003 };
6004 let mut logic = options_logic(true, false, true);
6005 let mut root = options_root(&mut logic, &mut state);
6006 assert_eq!(
6007 painted_text_color(&mut root),
6008 Color::BLACK,
6009 "with no theme threaded, read-only stays the undimmed black fallback"
6010 );
6011 }
6012
6013 #[test]
6014 fn switching_a_read_only_field_to_live_shows_no_alpha_change() {
6015 // The motivating case: a static mock rendered read-only then switched
6016 // interactive at a live handoff must show **no** alpha change at
6017 // either resolution point — the pop `enabled(false)` would have
6018 // produced.
6019 let mut state = AppState {
6020 value: "hi".to_string(),
6021 ..AppState::default()
6022 };
6023 let mut read_only_logic = options_logic(true, false, true);
6024 let mut root = options_root(&mut read_only_logic, &mut state);
6025 let before_glyph = painted_text_color(&mut root);
6026 let before_border = paint_chrome(&mut root).rrects[0];
6027
6028 let mut live_logic = options_logic(true, false, false);
6029 root.rebuild(&mut live_logic, &mut state);
6030 root.layout(Size::new(300.0, 200.0));
6031 let after_glyph = painted_text_color(&mut root);
6032 let after_border = paint_chrome(&mut root).rrects[0];
6033
6034 assert_eq!(
6035 before_glyph, after_glyph,
6036 "glyph color must not change across the read-only -> live handoff"
6037 );
6038 assert_eq!(
6039 before_border, after_border,
6040 "border color must not change across the read-only -> live handoff"
6041 );
6042 assert_eq!(
6043 before_glyph,
6044 Color::BLACK,
6045 "sanity: read-only starts undimmed"
6046 );
6047 }
6048
6049 /// The clipboard verbs a field's own semantics node currently offers, as
6050 /// `(id, label)` pairs in publication order.
6051 fn a11y_verbs(
6052 root: &RenderRoot<AppState, TextInputView<AppState>>,
6053 ) -> (bool, Vec<(i32, String)>) {
6054 let update = root.semantics();
6055 let (_, node) = update
6056 .nodes
6057 .iter()
6058 .find(|(_, n)| matches!(n.role(), Role::TextInput | Role::PasswordInput))
6059 .expect("a text field node");
6060 (
6061 node.supports_action(Action::CustomAction),
6062 node.custom_actions()
6063 .iter()
6064 .map(|a| (a.id, a.description.to_string()))
6065 .collect(),
6066 )
6067 }
6068
6069 #[test]
6070 fn the_clipboard_verbs_ride_the_field_s_own_node_with_the_toolbar_closed() {
6071 // The accessible route must not depend on the floating toolbar, which
6072 // is a pointer affordance and contributes no semantics of its own.
6073 let mut state = AppState::default();
6074 let mut root = harness(&mut state);
6075 focused_with_selection(&mut state, &mut root, "abc");
6076 assert!(
6077 !widget(&root).toolbar_open,
6078 "sanity: a drag-select raises no bar, so this is the closed case"
6079 );
6080
6081 let (supports, offered) = a11y_verbs(&root);
6082 assert!(
6083 supports,
6084 "the field advertises that it takes custom actions at all"
6085 );
6086 assert_eq!(
6087 offered,
6088 vec![
6089 (A11Y_CUT_ID, "Cut".to_string()),
6090 (A11Y_COPY_ID, "Copy".to_string()),
6091 (A11Y_PASTE_ID, "Paste".to_string()),
6092 ],
6093 "an interactive field with all of its text selected offers cut/copy/\
6094 paste, and no select-all — exactly the bar's own enabled set"
6095 );
6096
6097 // Invoking one reaches `handle_command`: resolve the advertised id the
6098 // way a dispatcher would, deliver it as the `EditCommand` a shell
6099 // already sends for a platform edit menu, and watch the verb land.
6100 let copy_id = offered
6101 .iter()
6102 .find(|(_, label)| label == "Copy")
6103 .expect("copy was offered")
6104 .0;
6105 let cmd = match copy_id {
6106 A11Y_CUT_ID => EditCommand::Cut,
6107 A11Y_COPY_ID => EditCommand::Copy,
6108 A11Y_SELECT_ALL_ID => EditCommand::SelectAll,
6109 other => panic!("the published id {other} resolves to no verb"),
6110 };
6111 root.event(&mut state, &edit(cmd));
6112 assert_eq!(
6113 root.take_clipboard_write().as_deref(),
6114 Some("abc"),
6115 "the advertised id resolves to a verb that reaches handle_command"
6116 );
6117 }
6118
6119 #[test]
6120 fn the_published_verbs_track_the_field_s_own_refusals() {
6121 // Empty and unfocused: nothing to copy or select, but a paste would
6122 // land, so paste alone is offered.
6123 let mut state = AppState::default();
6124 let mut logic = options_logic(true, false, false);
6125 let root = options_root(&mut logic, &mut state);
6126 assert_eq!(
6127 a11y_verbs(&root).1,
6128 vec![(A11Y_PASTE_ID, "Paste".to_string())],
6129 "an empty field offers only paste"
6130 );
6131
6132 // Obscured: the mirror is no more handable than the real buffer, so a
6133 // selection buys neither copy nor cut.
6134 let mut state = AppState {
6135 value: "hunter2".to_string(),
6136 ..Default::default()
6137 };
6138 let mut logic = options_logic(true, true, false);
6139 let mut root = options_root(&mut logic, &mut state);
6140 focused_with_selection(&mut state, &mut root, "");
6141 assert!(
6142 selection(&root).is_some(),
6143 "sanity: the refusal below is only meaningful over a real selection"
6144 );
6145 assert_eq!(
6146 a11y_verbs(&root).1,
6147 vec![(A11Y_PASTE_ID, "Paste".to_string())],
6148 "an obscured field hands out neither the buffer nor its bullets"
6149 );
6150
6151 // Read-only: copy is fine over a selection, cut and paste are not —
6152 // the field is focusable and copyable, so the accessible route carries
6153 // exactly the verbs it will actually answer.
6154 let mut state = AppState {
6155 value: "abc".to_string(),
6156 ..Default::default()
6157 };
6158 let mut logic = options_logic(true, false, true);
6159 let mut root = options_root(&mut logic, &mut state);
6160 drag_select_all(&mut state, &mut root);
6161 assert_eq!(
6162 a11y_verbs(&root).1,
6163 vec![(A11Y_COPY_ID, "Copy".to_string())],
6164 "a read-only field offers copy over its selection, never cut or paste"
6165 );
6166
6167 // Disabled: same reasoning, and the stronger claim.
6168 let mut state = AppState {
6169 value: "abc".to_string(),
6170 ..Default::default()
6171 };
6172 let mut logic = options_logic(false, false, false);
6173 let root = options_root(&mut logic, &mut state);
6174 assert!(
6175 a11y_verbs(&root).1.is_empty(),
6176 "a disabled field advertises no verbs either"
6177 );
6178 }
6179
6180 #[test]
6181 fn read_only_reports_read_only_not_disabled_semantics() {
6182 let mut state = AppState::default();
6183 let mut logic = options_logic(true, false, true);
6184 let root = options_root(&mut logic, &mut state);
6185 let update = root.semantics();
6186 let (_, node) = update
6187 .nodes
6188 .iter()
6189 .find(|(_, n)| n.role() == Role::TextInput)
6190 .expect("a text field node");
6191 assert!(
6192 node.is_read_only(),
6193 "a read-only field says so to a11y, distinct from disabled"
6194 );
6195 assert!(
6196 !node.is_disabled(),
6197 "read-only is not the same claim as disabled"
6198 );
6199 }
6200
6201 #[test]
6202 fn disabled_wins_semantics_over_read_only_when_both_are_set() {
6203 let mut state = AppState::default();
6204 let mut logic = options_logic(false, false, true);
6205 let root = options_root(&mut logic, &mut state);
6206 let update = root.semantics();
6207 let (_, node) = update
6208 .nodes
6209 .iter()
6210 .find(|(_, n)| n.role() == Role::TextInput)
6211 .expect("a text field node");
6212 assert!(node.is_disabled(), "disabled is the stronger claim");
6213 assert!(
6214 !node.is_read_only(),
6215 "disabled and read-only are never both reported"
6216 );
6217 }
6218
6219 #[test]
6220 fn read_only_obscured_field_stays_password_role_and_copies_nothing() {
6221 // Orthogonality: `read_only` never touches masking or the IME
6222 // content-type hint. The two refusals compose rather than cancel — the
6223 // field focuses like any read-only one, and hands out neither the
6224 // secret nor its bullets.
6225 let mut state = AppState {
6226 value: "hunter2".to_string(),
6227 ..AppState::default()
6228 };
6229 let mut logic = options_logic(true, true, true);
6230 let mut root = options_root(&mut logic, &mut state);
6231
6232 let update = root.semantics();
6233 let (_, node) = update
6234 .nodes
6235 .iter()
6236 .find(|(_, n)| n.role() == Role::PasswordInput)
6237 .expect("read-only + obscured still contributes Role::PasswordInput");
6238 assert_eq!(node.value(), Some("\u{2022}".repeat(7).as_str()));
6239 assert!(node.is_read_only());
6240
6241 drag_select_all(&mut state, &mut root);
6242 assert!(
6243 root.is_focus_active(),
6244 "it focuses like any read-only field"
6245 );
6246 assert!(
6247 selection(&root).is_some(),
6248 "sanity: the refusal below is only meaningful over a real selection"
6249 );
6250
6251 let ime = root.ime_state().expect("focused");
6252 assert_eq!(
6253 ime.content_type,
6254 ImeContentType::Password,
6255 "the content-type hint is read-only's business to leave alone"
6256 );
6257 assert!(ime.suppress_soft_keyboard);
6258
6259 root.event(&mut state, &chord("c", meta()));
6260 assert_eq!(
6261 root.take_clipboard_write(),
6262 None,
6263 "an obscured field copies nothing, read-only or not"
6264 );
6265 assert!(
6266 a11y_verbs(&root).1.is_empty(),
6267 "and advertises no verb it would refuse"
6268 );
6269 }
6270
6271 // --- obscured(true) ---
6272
6273 #[test]
6274 fn mask_offsets_map_1_to_1_per_char_across_a_multi_byte_grapheme() {
6275 // "a😀b": 1 + 4 + 1 real bytes; masked "•••" is 3 × 3 bytes.
6276 let text = "a\u{1F600}b";
6277 assert_eq!(mask_text(text), "\u{2022}\u{2022}\u{2022}");
6278 for (real, masked) in [(0, 0), (1, 3), (5, 6), (6, 9)] {
6279 assert_eq!(real_to_masked(text, real), masked, "real {real} -> masked");
6280 assert_eq!(
6281 masked_to_real(text, masked),
6282 real,
6283 "masked {masked} -> real"
6284 );
6285 }
6286 // Interior offsets snap back to the enclosing character's start, both
6287 // ways (the emoji spans real bytes 1..5 and masked bytes 3..6).
6288 assert_eq!(real_to_masked(text, 3), 3, "mid-emoji snaps to its start");
6289 assert_eq!(masked_to_real(text, 4), 1, "mid-mask snaps to its start");
6290 // A newline is preserved so a multi-line field keeps its line count.
6291 assert_eq!(mask_text("a\nb"), "\u{2022}\n\u{2022}");
6292 }
6293
6294 #[test]
6295 fn obscured_paints_bullets_and_keeps_the_real_value() {
6296 /// Records the glyph ids of every painted run, in order.
6297 #[derive(Default)]
6298 struct GlyphIdRecorder {
6299 ids: Vec<u16>,
6300 }
6301 impl PaintScene for GlyphIdRecorder {
6302 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
6303 fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, _c: Color) {}
6304 fn draw_text(&mut self, _o: Point, _t: &str) {}
6305 fn draw_glyph_run(&mut self, run: frust_scene::GlyphRun) {
6306 self.ids.extend(run.glyphs.iter().map(|g| g.id as u16));
6307 }
6308 }
6309
6310 fn ids(root: &mut RenderRoot<AppState, TextInputView<AppState>>) -> Vec<u16> {
6311 let mut rec = GlyphIdRecorder::default();
6312 root.paint(&mut rec, FrameTime::ZERO);
6313 rec.ids
6314 }
6315
6316 // An obscured field holding "abc" must paint exactly what a plain field
6317 // holding "•••" paints — and nothing of what "abc" paints.
6318 let mut secret_state = AppState {
6319 value: "abc".to_string(),
6320 ..AppState::default()
6321 };
6322 let mut secret_logic = options_logic(true, true, false);
6323 let mut secret = options_root(&mut secret_logic, &mut secret_state);
6324
6325 let mut bullets_state = AppState {
6326 value: "\u{2022}\u{2022}\u{2022}".to_string(),
6327 ..AppState::default()
6328 };
6329 let mut plain_logic = options_logic(true, false, false);
6330 let mut bullets = options_root(&mut plain_logic, &mut bullets_state);
6331
6332 let mut clear_state = AppState {
6333 value: "abc".to_string(),
6334 ..AppState::default()
6335 };
6336 let mut clear = options_root(&mut plain_logic, &mut clear_state);
6337
6338 assert_eq!(
6339 ids(&mut secret),
6340 ids(&mut bullets),
6341 "an obscured field paints bullets"
6342 );
6343 assert_ne!(
6344 ids(&mut secret),
6345 ids(&mut clear),
6346 "…and not the real characters"
6347 );
6348 assert_eq!(
6349 widget(&secret).editor.text(),
6350 "abc",
6351 "the model text is untouched by masking"
6352 );
6353 }
6354
6355 #[test]
6356 fn obscured_measures_from_the_masked_text_not_the_real_one() {
6357 // Masking at glyph-emission time only would leave the field measured
6358 // from the real text: an 'i'-heavy secret would size like 'i's while
6359 // painting bullets. The mirror is what gets measured, so an obscured
6360 // field's width matches the equivalent bullet string's.
6361 let mut secret_state = AppState {
6362 value: "iiiiiiiiii".to_string(),
6363 ..AppState::default()
6364 };
6365 let mut secret_logic = options_logic(true, true, false);
6366 let secret = options_root(&mut secret_logic, &mut secret_state);
6367
6368 let mut bullets_state = AppState {
6369 value: "\u{2022}".repeat(10),
6370 ..AppState::default()
6371 };
6372 let mut plain_logic = options_logic(true, false, false);
6373 let bullets = options_root(&mut plain_logic, &mut bullets_state);
6374
6375 let masked_w = widget(&secret).display().layout_size().width;
6376 let bullets_w = widget(&bullets).display().layout_size().width;
6377 let real_w = widget(&secret).editor.layout_size().width;
6378 assert_eq!(masked_w, bullets_w, "measured from the masked mirror");
6379 assert!(
6380 masked_w > real_w,
6381 "sanity: bullets are wider than 'i's ({masked_w} vs {real_w})"
6382 );
6383 }
6384
6385 #[test]
6386 fn obscured_editing_matches_unobscured_across_a_multi_byte_grapheme() {
6387 // The masking must not disturb the editing arithmetic: run the same
6388 // key sequence on an obscured and a clear field and require identical
6389 // editing state at every step, including over a 4-byte emoji.
6390 let mut secret_state = AppState::default();
6391 let mut secret_logic = options_logic(true, true, false);
6392 let mut secret = options_root(&mut secret_logic, &mut secret_state);
6393 let mut clear_state = AppState::default();
6394 let mut clear_logic = options_logic(true, false, false);
6395 let mut clear = options_root(&mut clear_logic, &mut clear_state);
6396
6397 let plain = Modifiers::default();
6398 let meta = Modifiers {
6399 meta: true,
6400 ..Modifiers::default()
6401 };
6402 let events = vec![
6403 pointer(PointerPhase::Down, 10.0, 10.0),
6404 ch("a"),
6405 ch("\u{1F600}"),
6406 ch("b"),
6407 named(NamedKey::ArrowLeft, plain),
6408 named(NamedKey::Backspace, plain),
6409 named(NamedKey::End, plain),
6410 named(NamedKey::Backspace, plain),
6411 InputEvent::Key(KeyEvent {
6412 key: Key::Character("a".to_string()),
6413 modifiers: meta,
6414 repeat: false,
6415 }),
6416 ];
6417 for (i, event) in events.iter().enumerate() {
6418 secret.event(&mut secret_state, event);
6419 clear.event(&mut clear_state, event);
6420 assert_eq!(
6421 widget(&secret).editor.editing_state_bytes(),
6422 widget(&clear).editor.editing_state_bytes(),
6423 "editing state diverged at step {i}"
6424 );
6425 }
6426 // The emoji was deleted as one grapheme in both, and the app saw the
6427 // real text throughout.
6428 assert_eq!(secret_state.value, "a");
6429 assert_eq!(secret_state.value, clear_state.value);
6430 assert_eq!(secret_state.changes, clear_state.changes);
6431 }
6432
6433 #[test]
6434 fn obscured_caret_rect_follows_the_masked_layout() {
6435 // The caret must sit where the *bullets* end, not where the real text
6436 // would have ended.
6437 let mut secret_state = AppState {
6438 value: "iiii".to_string(),
6439 ..AppState::default()
6440 };
6441 let mut secret_logic = options_logic(true, true, false);
6442 let mut secret = options_root(&mut secret_logic, &mut secret_state);
6443 secret.event(&mut secret_state, &pointer(PointerPhase::Down, 290.0, 10.0));
6444 let masked_caret = secret
6445 .ime_state()
6446 .expect("focused")
6447 .caret
6448 .expect("a caret rect");
6449
6450 let mut bullets_state = AppState {
6451 value: "\u{2022}".repeat(4),
6452 ..AppState::default()
6453 };
6454 let mut plain_logic = options_logic(true, false, false);
6455 let mut bullets = options_root(&mut plain_logic, &mut bullets_state);
6456 bullets.event(
6457 &mut bullets_state,
6458 &pointer(PointerPhase::Down, 290.0, 10.0),
6459 );
6460 let bullets_caret = bullets
6461 .ime_state()
6462 .expect("focused")
6463 .caret
6464 .expect("a caret rect");
6465
6466 assert_eq!(
6467 masked_caret, bullets_caret,
6468 "the obscured caret tracks the masked glyphs"
6469 );
6470 assert_eq!(
6471 secret.ime_state().expect("focused").editing.text,
6472 "iiii",
6473 "the IME surface still carries the real text (see the module docs)"
6474 );
6475 }
6476
6477 #[test]
6478 fn obscured_tap_places_the_caret_from_the_masked_hit_test() {
6479 // A tap between the first and second bullet must land on real byte 1,
6480 // resolved against the masked advances (bullets are much wider than
6481 // 'i's, so hit-testing the real layout would overshoot to the end).
6482 let mut state = AppState {
6483 value: "iiiiiiii".to_string(),
6484 ..AppState::default()
6485 };
6486 let mut logic = options_logic(true, true, false);
6487 let mut root = options_root(&mut logic, &mut state);
6488 // Mid-way through the first mask glyph -> caret before or after char 0.
6489 let half_bullet = widget(&root).display().layout_size().width / 16.0;
6490 root.event(
6491 &mut state,
6492 &pointer(PointerPhase::Down, PAD_X + half_bullet, 10.0),
6493 );
6494 let extent = widget(&root).editor.editing_state_bytes().extent;
6495 assert!(
6496 extent <= 1,
6497 "a tap inside the first mask glyph lands at byte 0 or 1, got {extent}"
6498 );
6499 }
6500
6501 #[test]
6502 fn obscured_reports_password_role_with_a_masked_value() {
6503 let mut state = AppState {
6504 value: "hunter2".to_string(),
6505 ..AppState::default()
6506 };
6507 let mut logic = options_logic(true, true, false);
6508 let root = options_root(&mut logic, &mut state);
6509 let update = root.semantics();
6510 let (_, node) = update
6511 .nodes
6512 .iter()
6513 .find(|(_, n)| n.role() == Role::PasswordInput)
6514 .expect("an obscured field contributes a Role::PasswordInput node");
6515 assert_eq!(
6516 node.value(),
6517 Some("\u{2022}".repeat(7).as_str()),
6518 "the a11y value is masked too — a client reads it verbatim"
6519 );
6520
6521 // Enabled + clear stays a plain TextInput node carrying the real value.
6522 let mut plain_logic = options_logic(true, false, false);
6523 let plain = options_root(&mut plain_logic, &mut state);
6524 let update = plain.semantics();
6525 let (_, node) = update
6526 .nodes
6527 .iter()
6528 .find(|(_, n)| n.role() == Role::TextInput)
6529 .expect("a clear field stays Role::TextInput");
6530 assert_eq!(node.value(), Some("hunter2"));
6531 assert!(!node.is_disabled(), "an enabled field is not disabled");
6532 }
6533
6534 #[test]
6535 fn disabled_reports_disabled_semantics() {
6536 let mut state = AppState::default();
6537 let mut logic = options_logic(false, false, false);
6538 let root = options_root(&mut logic, &mut state);
6539 let update = root.semantics();
6540 let (_, node) = update
6541 .nodes
6542 .iter()
6543 .find(|(_, n)| n.role() == Role::TextInput)
6544 .expect("a text field node");
6545 assert!(node.is_disabled(), "a disabled field says so to a11y");
6546 }
6547
6548 #[test]
6549 fn toggling_obscured_relayouts_and_keeps_the_value() {
6550 let mut state = AppState {
6551 value: "iiiiiiii".to_string(),
6552 ..AppState::default()
6553 };
6554 let mut clear_logic = options_logic(true, false, false);
6555 let mut root = options_root(&mut clear_logic, &mut state);
6556 let clear_width = widget(&root).display().layout_size().width;
6557
6558 let mut secret_logic = options_logic(true, true, false);
6559 let flags = root.rebuild(&mut secret_logic, &mut state);
6560 assert!(
6561 flags.needs_layout(),
6562 "masking changes the measured text, so the flip must relayout"
6563 );
6564 root.layout(Size::new(300.0, 200.0));
6565 assert!(
6566 widget(&root).display().layout_size().width > clear_width,
6567 "the masked mirror is measured after the flip"
6568 );
6569 assert_eq!(widget(&root).editor.text(), "iiiiiiii");
6570
6571 // …and back again.
6572 root.rebuild(&mut clear_logic, &mut state);
6573 root.layout(Size::new(300.0, 200.0));
6574 assert_eq!(widget(&root).display().layout_size().width, clear_width);
6575 assert!(widget(&root).mask_editor.is_none());
6576 }
6577
6578 // --- content_type (widget half) ---
6579 //
6580 // These lock the widget's IME content-type mapping only — the leak this
6581 // closes lives at the platform seam, and no widget-level test can observe
6582 // whether a shell actually honours the hint. See the module docs' scope
6583 // boundary and `ImeContentType`'s own docs.
6584
6585 #[test]
6586 fn obscured_publishes_password_content_type_across_focus_edit_and_refocus() {
6587 let mut state = AppState {
6588 value: "hunter2".to_string(),
6589 ..AppState::default()
6590 };
6591 let mut logic = options_logic(true, true, false);
6592 let mut root = options_root(&mut logic, &mut state);
6593
6594 // Focus.
6595 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
6596 assert_eq!(
6597 root.ime_state().expect("focused").content_type,
6598 ImeContentType::Password,
6599 "an obscured field's very first published IME surface must already \
6600 be Password"
6601 );
6602
6603 // Edit.
6604 root.event(&mut state, &ch("x"));
6605 assert_eq!(
6606 root.ime_state().expect("still focused").content_type,
6607 ImeContentType::Password,
6608 "content type must not drop on edit"
6609 );
6610
6611 // Blur, then re-focus.
6612 root.event(&mut state, &named(NamedKey::Escape, Modifiers::default()));
6613 assert!(root.ime_state().is_none(), "blur unpublishes the surface");
6614 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
6615 assert_eq!(
6616 root.ime_state().expect("re-focused").content_type,
6617 ImeContentType::Password,
6618 "content type must be correct again on re-focus, not just the first time"
6619 );
6620 }
6621
6622 #[test]
6623 fn unobscured_field_publishes_the_default_no_hint_content_type() {
6624 // No behaviour change for the common case: a plain field's published
6625 // surface keeps the `Normal` default it always had.
6626 let mut state = AppState::default();
6627 let mut root = harness(&mut state);
6628
6629 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
6630 assert_eq!(
6631 root.ime_state().expect("focused").content_type,
6632 ImeContentType::Normal
6633 );
6634
6635 root.event(&mut state, &ch("h"));
6636 assert_eq!(
6637 root.ime_state().expect("still focused").content_type,
6638 ImeContentType::Normal
6639 );
6640 }
6641
6642 #[test]
6643 fn obscured_content_type_is_correct_on_the_first_publication_after_focus() {
6644 // The ordering guarantee the security fix rests on: a field that starts
6645 // `Normal` and flips to `Password` a frame later has already leaked to
6646 // the platform IME (see the module docs). Assert there is no such
6647 // window by checking the *very first* `ImeState` a freshly built,
6648 // never-before-focused obscured field emits — a single `Down` event,
6649 // nothing before it.
6650 let mut state = AppState {
6651 value: "hunter2".to_string(),
6652 ..AppState::default()
6653 };
6654 let mut logic = options_logic(true, true, false);
6655 let mut root = options_root(&mut logic, &mut state);
6656 assert!(
6657 root.ime_state().is_none(),
6658 "an unfocused field publishes nothing yet"
6659 );
6660
6661 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
6662
6663 let ime = root
6664 .ime_state()
6665 .expect("focusing publishes the first IME surface");
6666 assert_eq!(
6667 ime.content_type,
6668 ImeContentType::Password,
6669 "the first-ever publication for an obscured field must already \
6670 carry Password"
6671 );
6672 assert_eq!(
6673 ime.editing.text, "hunter2",
6674 "sanity: this is the real first publication, not a stale one"
6675 );
6676 }
6677
6678 // --- Clipboard and selection commands ---
6679
6680 /// A decoded clipboard verb, focus-routed like a key — the same event
6681 /// `frust-testing`'s `edit_command` builds, spelled locally because
6682 /// `frust-widgets` takes no edge on that crate.
6683 fn edit(cmd: EditCommand) -> InputEvent {
6684 InputEvent::EditCommand(cmd)
6685 }
6686
6687 /// Focus the field, type `text`, then drag-select the whole buffer — the
6688 /// selection a copy/cut acts on, produced the way a user produces it.
6689 fn focused_with_selection(
6690 state: &mut AppState,
6691 root: &mut RenderRoot<AppState, TextInputView<AppState>>,
6692 text: &str,
6693 ) {
6694 root.event(state, &pointer(PointerPhase::Down, 10.0, 10.0));
6695 for c in text.chars() {
6696 root.event(state, &ch(&c.to_string()));
6697 }
6698 // Press at the left edge (before the first glyph), drag past the last.
6699 root.event(state, &pointer(PointerPhase::Down, 0.0, 10.0));
6700 root.event(state, &pointer(PointerPhase::Move, 290.0, 10.0));
6701 root.event(state, &pointer(PointerPhase::Up, 290.0, 10.0));
6702 }
6703
6704 /// The ctrl/meta chord modifier the clipboard shortcuts key off.
6705 fn meta() -> Modifiers {
6706 Modifiers {
6707 meta: true,
6708 ..Modifiers::default()
6709 }
6710 }
6711
6712 fn ctrl() -> Modifiers {
6713 Modifiers {
6714 ctrl: true,
6715 ..Modifiers::default()
6716 }
6717 }
6718
6719 fn shift() -> Modifiers {
6720 Modifiers {
6721 shift: true,
6722 ..Modifiers::default()
6723 }
6724 }
6725
6726 fn chord(text: &str, modifiers: Modifiers) -> InputEvent {
6727 InputEvent::Key(KeyEvent {
6728 key: Key::Character(text.to_string()),
6729 modifiers,
6730 repeat: false,
6731 })
6732 }
6733
6734 #[test]
6735 fn copy_writes_the_selection_and_edits_nothing() {
6736 let mut state = AppState::default();
6737 let mut root = harness(&mut state);
6738 focused_with_selection(&mut state, &mut root, "abc");
6739 assert_eq!(
6740 widget(&root).editor.selected_text(),
6741 Some("abc"),
6742 "the drag selected the whole buffer"
6743 );
6744 let changes = state.changes;
6745
6746 root.event(&mut state, &edit(EditCommand::Copy));
6747
6748 assert_eq!(root.take_clipboard_write().as_deref(), Some("abc"));
6749 assert_eq!(widget(&root).editor.text(), "abc", "a copy mutates nothing");
6750 assert_eq!(state.changes, changes, "a copy is not an edit");
6751 assert_eq!(state.value, "abc");
6752 }
6753
6754 #[test]
6755 fn cut_writes_the_selection_removes_it_and_reports_one_change() {
6756 let mut state = AppState::default();
6757 let mut root = harness(&mut state);
6758 focused_with_selection(&mut state, &mut root, "abc");
6759 let changes = state.changes;
6760
6761 root.event(&mut state, &edit(EditCommand::Cut));
6762
6763 assert_eq!(root.take_clipboard_write().as_deref(), Some("abc"));
6764 assert_eq!(widget(&root).editor.text(), "", "the selection is gone");
6765 assert_eq!(state.changes, changes + 1, "one on_change, not two");
6766 assert_eq!(state.value, "");
6767 }
6768
6769 #[test]
6770 fn copy_and_cut_do_nothing_without_a_selection() {
6771 let mut state = AppState::default();
6772 let mut root = harness(&mut state);
6773 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
6774 for c in ["a", "b"] {
6775 root.event(&mut state, &ch(c));
6776 }
6777 let changes = state.changes;
6778
6779 root.event(&mut state, &edit(EditCommand::Copy));
6780 assert!(root.take_clipboard_write().is_none());
6781 // A collapsed cut must not fall back to deleting the grapheme its
6782 // `Backdelete` would otherwise take.
6783 root.event(&mut state, &edit(EditCommand::Cut));
6784 assert!(root.take_clipboard_write().is_none());
6785
6786 assert_eq!(widget(&root).editor.text(), "ab");
6787 assert_eq!(state.changes, changes, "neither verb fired on_change");
6788 }
6789
6790 #[test]
6791 fn paste_into_a_single_line_field_strips_newlines_and_replaces_the_selection() {
6792 let mut state = AppState::default();
6793 let mut root = harness(&mut state);
6794 focused_with_selection(&mut state, &mut root, "xy");
6795 let changes = state.changes;
6796
6797 root.event(&mut state, &edit(EditCommand::Paste("a\nb".to_string())));
6798
6799 assert_eq!(
6800 widget(&root).editor.text(),
6801 "ab",
6802 "the newline is denied and the selection replaced"
6803 );
6804 assert_eq!(state.changes, changes + 1, "one edit, one on_change");
6805 assert_eq!(state.value, "ab");
6806 }
6807
6808 #[test]
6809 fn paste_into_a_multiline_field_keeps_the_newline() {
6810 let mut state = AppState::default();
6811 let mut root = RenderRoot::new();
6812 root.rebuild(&mut multiline_logic, &mut state);
6813 root.layout(Size::new(300.0, 200.0));
6814 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
6815
6816 root.event(&mut state, &edit(EditCommand::Paste("a\nb".to_string())));
6817
6818 assert_eq!(widget(&root).editor.text(), "a\nb");
6819 assert_eq!(state.changes, 1);
6820 }
6821
6822 #[test]
6823 fn a_paste_sanitised_to_nothing_changes_nothing() {
6824 let mut state = AppState::default();
6825 let mut root = harness(&mut state);
6826 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
6827 root.event(&mut state, &ch("a"));
6828 let changes = state.changes;
6829
6830 let outcome = root.event(&mut state, &edit(EditCommand::Paste("\n".to_string())));
6831
6832 assert!(outcome.handled, "the verb was understood, and answered");
6833 assert_eq!(widget(&root).editor.text(), "a", "nothing was inserted");
6834 assert_eq!(state.changes, changes, "an empty paste is not an edit");
6835 }
6836
6837 #[test]
6838 fn select_all_via_the_command_selects_the_whole_buffer_without_typing() {
6839 let mut state = AppState::default();
6840 let mut root = harness(&mut state);
6841 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
6842 for c in ["a", "b", "c"] {
6843 root.event(&mut state, &ch(c));
6844 }
6845 let changes = state.changes;
6846
6847 root.event(&mut state, &edit(EditCommand::SelectAll));
6848
6849 assert_eq!(widget(&root).editor.selected_text(), Some("abc"));
6850 assert_eq!(state.changes, changes);
6851 assert_eq!(widget(&root).editor.text(), "abc");
6852 }
6853
6854 #[test]
6855 fn an_obscured_field_refuses_copy_and_cut_but_still_pastes() {
6856 let mut state = AppState::default();
6857 let mut logic = options_logic(true, true, false);
6858 let mut root = options_root(&mut logic, &mut state);
6859 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
6860 for c in ["a", "b", "c"] {
6861 root.event(&mut state, &ch(c));
6862 }
6863 root.event(&mut state, &edit(EditCommand::SelectAll));
6864 assert_eq!(widget(&root).editor.selected_text(), Some("abc"));
6865 let changes = state.changes;
6866
6867 // Neither the real buffer nor the bullet mirror may reach the clipboard.
6868 root.event(&mut state, &edit(EditCommand::Copy));
6869 assert!(root.take_clipboard_write().is_none());
6870 root.event(&mut state, &edit(EditCommand::Cut));
6871 assert!(root.take_clipboard_write().is_none());
6872 assert_eq!(
6873 widget(&root).editor.text(),
6874 "abc",
6875 "a refused cut deletes nothing"
6876 );
6877 assert_eq!(state.changes, changes);
6878
6879 // Writing *into* a password field is ordinary: the paste still lands,
6880 // replacing the (still intact) selection.
6881 root.event(&mut state, &edit(EditCommand::Paste("zz".to_string())));
6882 assert_eq!(widget(&root).editor.text(), "zz");
6883 assert_eq!(state.changes, changes + 1);
6884 }
6885
6886 #[test]
6887 fn meta_c_copies_and_meta_x_cuts() {
6888 let mut state = AppState::default();
6889 let mut root = harness(&mut state);
6890 focused_with_selection(&mut state, &mut root, "abc");
6891
6892 root.event(&mut state, &chord("c", meta()));
6893 assert_eq!(root.take_clipboard_write().as_deref(), Some("abc"));
6894 assert_eq!(widget(&root).editor.text(), "abc");
6895
6896 root.event(&mut state, &chord("X", meta()));
6897 assert_eq!(
6898 root.take_clipboard_write().as_deref(),
6899 Some("abc"),
6900 "the chord is ASCII case-insensitive"
6901 );
6902 assert_eq!(widget(&root).editor.text(), "");
6903 }
6904
6905 #[test]
6906 fn ctrl_v_and_shift_insert_request_a_paste_and_type_nothing() {
6907 let mut state = AppState::default();
6908 let mut root = harness(&mut state);
6909 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
6910 assert!(!root.take_paste_request());
6911
6912 root.event(&mut state, &chord("v", ctrl()));
6913 assert!(root.take_paste_request(), "Ctrl+V asks the shell to read");
6914 assert_eq!(widget(&root).editor.text(), "", "and types no 'v'");
6915
6916 root.event(&mut state, &named(NamedKey::Insert, shift()));
6917 assert!(
6918 root.take_paste_request(),
6919 "Shift+Insert is the legacy paste"
6920 );
6921
6922 root.event(&mut state, &named(NamedKey::Paste, Modifiers::default()));
6923 assert!(root.take_paste_request(), "so is the hardware Paste key");
6924
6925 assert_eq!(state.changes, 0, "asking for a paste is not an edit");
6926 }
6927
6928 #[test]
6929 fn ctrl_insert_copies_and_shift_delete_cuts() {
6930 let mut state = AppState::default();
6931 let mut root = harness(&mut state);
6932 focused_with_selection(&mut state, &mut root, "abc");
6933
6934 root.event(&mut state, &named(NamedKey::Insert, ctrl()));
6935 assert_eq!(root.take_clipboard_write().as_deref(), Some("abc"));
6936 assert_eq!(widget(&root).editor.text(), "abc");
6937
6938 root.event(&mut state, &named(NamedKey::Delete, shift()));
6939 assert_eq!(root.take_clipboard_write().as_deref(), Some("abc"));
6940 assert_eq!(widget(&root).editor.text(), "", "Shift+Delete is a cut");
6941 }
6942
6943 #[test]
6944 fn ctrl_shift_delete_stays_a_forward_delete() {
6945 let mut state = AppState::default();
6946 let mut root = harness(&mut state);
6947 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
6948 for c in ["a", "b"] {
6949 root.event(&mut state, &ch(c));
6950 }
6951 // Caret home, then Ctrl+Shift+Delete: an OS-level gesture, never a cut.
6952 root.event(&mut state, &named(NamedKey::Home, Modifiers::default()));
6953 root.event(
6954 &mut state,
6955 &named(
6956 NamedKey::Delete,
6957 Modifiers {
6958 shift: true,
6959 ctrl: true,
6960 ..Modifiers::default()
6961 },
6962 ),
6963 );
6964
6965 assert!(root.take_clipboard_write().is_none(), "nothing was copied");
6966 assert_eq!(widget(&root).editor.text(), "b", "the grapheme ahead went");
6967 }
6968
6969 #[test]
6970 fn the_hardware_copy_and_cut_keys_resolve_to_the_same_verbs() {
6971 let mut state = AppState::default();
6972 let mut root = harness(&mut state);
6973 focused_with_selection(&mut state, &mut root, "abc");
6974
6975 root.event(&mut state, &named(NamedKey::Copy, Modifiers::default()));
6976 assert_eq!(root.take_clipboard_write().as_deref(), Some("abc"));
6977 assert_eq!(widget(&root).editor.text(), "abc");
6978
6979 root.event(&mut state, &named(NamedKey::Cut, Modifiers::default()));
6980 assert_eq!(root.take_clipboard_write().as_deref(), Some("abc"));
6981 assert_eq!(widget(&root).editor.text(), "");
6982 }
6983
6984 #[test]
6985 fn a_bare_insert_edits_nothing_and_is_left_unconsumed() {
6986 let mut state = AppState::default();
6987 let mut root = harness(&mut state);
6988 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
6989 root.event(&mut state, &ch("a"));
6990
6991 let outcome = root.event(&mut state, &named(NamedKey::Insert, Modifiers::default()));
6992
6993 assert!(!outcome.handled, "no overtype mode to toggle: not ours");
6994 assert!(!root.take_paste_request());
6995 assert_eq!(widget(&root).editor.text(), "a");
6996 }
6997
6998 #[test]
6999 fn an_unrecognized_chord_is_still_consumed_and_never_typed() {
7000 let mut state = AppState::default();
7001 let mut root = harness(&mut state);
7002 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
7003 root.event(&mut state, &ch("a"));
7004 let changes = state.changes;
7005
7006 let outcome = root.event(&mut state, &chord("z", meta()));
7007
7008 assert!(
7009 outcome.handled,
7010 "an undecoded chord is swallowed, not typed"
7011 );
7012 assert_eq!(widget(&root).editor.text(), "a", "no 'z' was inserted");
7013 assert_eq!(state.changes, changes);
7014 assert!(root.take_clipboard_write().is_none());
7015 assert!(!root.take_paste_request());
7016 }
7017
7018 #[test]
7019 fn an_unfocused_field_ignores_every_edit_command() {
7020 let mut state = AppState::default();
7021 let mut root = harness(&mut state);
7022
7023 for cmd in [
7024 EditCommand::SelectAll,
7025 EditCommand::Paste("hi".to_string()),
7026 EditCommand::Copy,
7027 EditCommand::Cut,
7028 ] {
7029 let outcome = root.event(&mut state, &edit(cmd));
7030 assert!(!outcome.handled, "a focus-routed verb reaches nobody");
7031 }
7032
7033 assert!(root.take_clipboard_write().is_none());
7034 assert_eq!(widget(&root).editor.text(), "");
7035 assert_eq!(state.changes, 0);
7036 assert!(!widget(&root).focused);
7037 }
7038
7039 // -----------------------------------------------------------------------
7040 // Selection gestures and the floated toolbar
7041 // -----------------------------------------------------------------------
7042
7043 /// Serialises every test that writes the process-global selection-toolbar
7044 /// slots. Rust runs a crate's tests in parallel threads sharing one process,
7045 /// so two of them installing a builder would see each other's writes —
7046 /// `frust_core::selection_toolbar`'s own tests keep the identical lock for
7047 /// the identical reason.
7048 static TOOLBAR_LOCK: Mutex<()> = Mutex::new(());
7049
7050 /// What the toolbar double recorded. `Arc<Mutex<_>>` rather than the
7051 /// `Rc<RefCell<_>>` a pod fixture would normally use, because a
7052 /// [`SelectionToolbarBuilder`] is a process-global `Send + Sync` closure.
7053 type ToolbarLog = Arc<Mutex<Vec<String>>>;
7054
7055 /// The double's fixed size, so a placement assertion has a rect to expect.
7056 const PROBE_SIZE: Size = Size::new(120.0, 40.0);
7057 /// The colour the double fills itself with — how a scene log tells the
7058 /// floated pod's paint apart from the field's own.
7059 const PROBE_COLOR: Color = Color::from_rgb8(0x11, 0x22, 0x33);
7060 /// The window every toolbar test lays out in (the `harness` window).
7061 const TOOLBAR_WINDOW: Size = Size::new(300.0, 200.0);
7062
7063 /// A recording paint sink that keeps colours and shapes apart, so a test can
7064 /// ask both "was the pod painted?" and "where?".
7065 #[derive(Default)]
7066 struct SceneLog {
7067 fills: Vec<(Point, Size, Color)>,
7068 rounded: Vec<(Point, Size)>,
7069 }
7070
7071 impl PaintScene for SceneLog {
7072 fn fill_rect(&mut self, o: Point, s: Size, c: Color) {
7073 self.fills.push((o, s, c));
7074 }
7075 fn fill_rounded_rect(&mut self, o: Point, s: Size, _r: f64, _c: Color) {
7076 self.rounded.push((o, s));
7077 }
7078 fn draw_text(&mut self, _o: Point, _t: &str) {}
7079 }
7080
7081 impl SceneLog {
7082 /// The pod's own fill, if it was painted at all.
7083 fn probe(&self) -> Option<(Point, Size)> {
7084 self.fills
7085 .iter()
7086 .find(|(_, _, c)| *c == PROBE_COLOR)
7087 .map(|(o, s, _)| (*o, *s))
7088 }
7089
7090 /// Whether the pod's fill was the **last** thing painted — i.e. it
7091 /// floated above the whole main tree instead of being drawn in place.
7092 fn probe_painted_last(&self) -> bool {
7093 self.fills.last().is_some_and(|(_, _, c)| *c == PROBE_COLOR)
7094 }
7095
7096 /// The field's own chrome rect (the first rounded rect it fills), which
7097 /// is where the field was laid out.
7098 fn field_rect(&self) -> Rect {
7099 let (o, s) = self
7100 .rounded
7101 .first()
7102 .copied()
7103 .expect("the field always paints its chrome");
7104 Rect::from_origin_size(o, s)
7105 }
7106 }
7107
7108 /// The floated toolbar double: a fixed-size rect that records the presses it
7109 /// receives and dispatches [`EditCommand::Copy`] on the release, standing in
7110 /// for whatever a design system installs.
7111 ///
7112 /// Deliberately **not** `crate::selection_toolbar`'s real bar: what is under
7113 /// test here is the field's hosting of a pod — placement, routing, the
7114 /// command drain — not anyone's button layout.
7115 struct ProbeToolbar {
7116 log: ToolbarLog,
7117 }
7118
7119 /// The double's retained widget.
7120 struct ProbeToolbarWidget {
7121 log: ToolbarLog,
7122 }
7123
7124 impl View<()> for ProbeToolbar {
7125 type Element = ProbeToolbarWidget;
7126 fn build(&self, _ctx: &mut BuildCtx<'_>) -> ProbeToolbarWidget {
7127 ProbeToolbarWidget {
7128 log: Arc::clone(&self.log),
7129 }
7130 }
7131 fn rebuild(
7132 &self,
7133 _prev: &Self,
7134 element: &mut ProbeToolbarWidget,
7135 _ctx: &mut BuildCtx<'_>,
7136 ) -> ChangeFlags {
7137 element.log = Arc::clone(&self.log);
7138 ChangeFlags::NONE
7139 }
7140 }
7141
7142 impl Widget for ProbeToolbarWidget {
7143 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
7144 bc.constrain(PROBE_SIZE)
7145 }
7146 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
7147 scene.fill_rect(ctx.origin(), ctx.size(), PROBE_COLOR);
7148 }
7149 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
7150 if let InputEvent::Pointer(p) = event {
7151 self.log
7152 .lock()
7153 .unwrap_or_else(|e| e.into_inner())
7154 .push(format!("{:?}@{},{}", p.phase, p.position.x, p.position.y));
7155 // Fire on the release, like the real bar's items do.
7156 if p.phase == PointerPhase::Up {
7157 ctx.dispatch_edit_command(EditCommand::Copy);
7158 }
7159 return EventResult::Handled;
7160 }
7161 EventResult::Ignored
7162 }
7163 }
7164
7165 /// Install the double in the process-global builder slot (and assert the
7166 /// framework route), handing back its log. Call under [`TOOLBAR_LOCK`].
7167 fn install_probe_toolbar() -> ToolbarLog {
7168 let log: ToolbarLog = Arc::new(Mutex::new(Vec::new()));
7169 let captured = Arc::clone(&log);
7170 let builder: SelectionToolbarBuilder =
7171 Arc::new(move |_request: &SelectionToolbarRequest, _window: Size| {
7172 frust_core::any(ProbeToolbar {
7173 log: Arc::clone(&captured),
7174 })
7175 });
7176 set_selection_toolbar_builder(builder);
7177 set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
7178 log
7179 }
7180
7181 /// How many presses the double has seen.
7182 fn probe_presses(log: &ToolbarLog) -> usize {
7183 log.lock().unwrap_or_else(|e| e.into_inner()).len()
7184 }
7185
7186 /// One whole frame: rebuild — the only pass carrying a `BuildCtx`, so the
7187 /// only one that can mount or drop the toolbar pod — then layout, then paint
7188 /// at `ms`. The loop every shipping shell runs.
7189 fn toolbar_frame(
7190 root: &mut RenderRoot<AppState, TextInputView<AppState>>,
7191 logic: &mut impl FnMut(&mut AppState) -> TextInputView<AppState>,
7192 state: &mut AppState,
7193 ms: f64,
7194 ) -> SceneLog {
7195 root.rebuild(logic, state);
7196 root.layout(TOOLBAR_WINDOW);
7197 let mut scene = SceneLog::default();
7198 root.paint(&mut scene, ft_ms(ms));
7199 scene
7200 }
7201
7202 /// A complete in-slop tap at `(x, y)`.
7203 fn tap(
7204 root: &mut RenderRoot<AppState, TextInputView<AppState>>,
7205 state: &mut AppState,
7206 x: f64,
7207 y: f64,
7208 ) {
7209 root.event(state, &pointer(PointerPhase::Down, x, y));
7210 root.event(state, &pointer(PointerPhase::Up, x, y));
7211 }
7212
7213 /// The selected text, or `None` — spelled out so an assertion reads as the
7214 /// selection rather than as an editor call.
7215 fn selection(root: &RenderRoot<AppState, TextInputView<AppState>>) -> Option<String> {
7216 widget(root).editor.selected_text().map(str::to_owned)
7217 }
7218
7219 /// Open the bar with a context press and paint it, asserting a pod really
7220 /// got registered — the starting position each hide-rule leg needs.
7221 fn open_toolbar(
7222 root: &mut RenderRoot<AppState, TextInputView<AppState>>,
7223 logic: &mut impl FnMut(&mut AppState) -> TextInputView<AppState>,
7224 state: &mut AppState,
7225 ms: f64,
7226 ) {
7227 root.event(state, &secondary_pointer(PointerPhase::Down, 20.0, 10.0));
7228 assert!(widget(root).toolbar_open, "the leg starts with the bar up");
7229 let scene = toolbar_frame(root, logic, state, ms);
7230 assert!(scene.probe().is_some(), "…and with a pod really registered");
7231 }
7232
7233 #[test]
7234 fn a_stationary_long_press_selects_the_word_and_floats_the_toolbar() {
7235 let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
7236 let _log = install_probe_toolbar();
7237 let mut state = AppState {
7238 value: "hello world".to_string(),
7239 ..Default::default()
7240 };
7241 let mut logic = options_logic(true, false, false);
7242 let mut root = options_root(&mut logic, &mut state);
7243 toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
7244
7245 // The press arms the hold; its first painted frame seeds the epoch.
7246 root.event(&mut state, &pointer(PointerPhase::Down, 20.0, 10.0));
7247 toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
7248 assert!(
7249 !widget(&root).toolbar_open,
7250 "nothing fires before the threshold"
7251 );
7252
7253 // The frame past the threshold marks it elapsed and latches the flush
7254 // that becomes the `Housekeeping` broadcast the fire rides out on.
7255 toolbar_frame(&mut root, &mut logic, &mut state, LONG_PRESS_MS + 50.0);
7256 assert!(
7257 frust_core::has_pending_result_flush(),
7258 "the crossing paint latches the deferred-callback flush"
7259 );
7260 root.event(&mut state, &InputEvent::Housekeeping);
7261
7262 assert_eq!(
7263 selection(&root).as_deref(),
7264 Some("hello"),
7265 "the word under the press point is selected"
7266 );
7267 assert!(widget(&root).toolbar_open, "and the toolbar is asked for");
7268
7269 // The pod is mounted by the next rebuild and registered by its paint,
7270 // above the whole field rather than inside it.
7271 let scene = toolbar_frame(&mut root, &mut logic, &mut state, LONG_PRESS_MS + 70.0);
7272 assert!(
7273 scene.probe_painted_last(),
7274 "the floated pod paints after the main tree: {:?}",
7275 scene.fills
7276 );
7277
7278 // The published request is the selection's own bounding box, absolute.
7279 let field = scene.field_rect();
7280 let w = widget(&root);
7281 let expected = w
7282 .display()
7283 .selection_rects()
7284 .into_iter()
7285 .reduce(|a, b| a.union(b))
7286 .expect("a non-collapsed selection has rects")
7287 + Vec2::new(w.pad_x, w.content_origin_y(field.height()))
7288 + field.origin().to_vec2();
7289 let published = root
7290 .selection_toolbar()
7291 .expect("an open toolbar publishes its request under either policy");
7292 assert_eq!(published.anchor, expected);
7293 assert_eq!(
7294 published.actions,
7295 SelectionToolbarActions {
7296 copy: true,
7297 cut: true,
7298 paste: true,
7299 select_all: true,
7300 },
7301 "a word selected out of a longer line enables all four verbs"
7302 );
7303 }
7304
7305 /// Drive a field to "the long-press fired, the finger is still down",
7306 /// pressing at `press_x`, and hand back the root/state to move from there.
7307 fn held_word(
7308 logic: &mut impl FnMut(&mut AppState) -> TextInputView<AppState>,
7309 state: &mut AppState,
7310 press_x: f64,
7311 ) -> RenderRoot<AppState, TextInputView<AppState>> {
7312 let mut root = options_root(logic, state);
7313 toolbar_frame(&mut root, logic, state, 0.0);
7314 root.event(state, &pointer(PointerPhase::Down, press_x, 10.0));
7315 toolbar_frame(&mut root, logic, state, 0.0);
7316 toolbar_frame(&mut root, logic, state, LONG_PRESS_MS + 50.0);
7317 root.event(state, &InputEvent::Housekeeping);
7318 assert_eq!(
7319 selection(&root).as_deref(),
7320 Some("hello"),
7321 "the leg starts from a fired hold over the first word"
7322 );
7323 root
7324 }
7325
7326 #[test]
7327 fn an_in_slop_move_after_the_hold_fired_leaves_the_selected_word_alone() {
7328 let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
7329 let _log = install_probe_toolbar();
7330 let mut state = AppState {
7331 value: "hello world".to_string(),
7332 ..Default::default()
7333 };
7334 let mut logic = options_logic(true, false, false);
7335 // Press inside "hello", four characters in.
7336 let mut root = held_word(&mut logic, &mut state, 30.0);
7337 assert!(widget(&root).toolbar_open, "and with the bar raised");
7338 assert_eq!(
7339 widget(&root).gesture,
7340 Gesture::HoldFired {
7341 at: Point::new(30.0, 10.0)
7342 },
7343 "a fired hold keeps its press point rather than forgetting it"
7344 );
7345
7346 // The finger is still down and jitters 14px — comfortably inside the
7347 // 18px TOUCH_SLOP that makes a press "stationary", yet several
7348 // characters wide at this text size, so it reaches across the word
7349 // boundary into "world". The selection must not notice.
7350 let jitter = Point::new(44.0, 10.0);
7351 assert!(
7352 (jitter - Point::new(30.0, 10.0)).hypot() <= TOUCH_SLOP,
7353 "the leg is only meaningful while the jitter stays in slop"
7354 );
7355 root.event(&mut state, &pointer(PointerPhase::Move, jitter.x, jitter.y));
7356
7357 assert_eq!(
7358 selection(&root).as_deref(),
7359 Some("hello"),
7360 "a held finger jittering in slop must not re-resolve the selection"
7361 );
7362 assert_eq!(
7363 widget(&root).gesture,
7364 Gesture::HoldFired {
7365 at: Point::new(30.0, 10.0)
7366 },
7367 "and the press stays resolved-but-stationary, not promoted to a drag"
7368 );
7369 assert!(
7370 widget(&root).toolbar_open,
7371 "so the bar still stands over the selection its verbs were built from"
7372 );
7373
7374 // The release must not resolve as a tap either: a long-press seeds no
7375 // double-tap window and toggles no toolbar.
7376 root.event(&mut state, &pointer(PointerPhase::Up, jitter.x, jitter.y));
7377 assert!(
7378 widget(&root).last_tap.is_none(),
7379 "a long-press is not a tap"
7380 );
7381 assert!(
7382 widget(&root).toolbar_open,
7383 "and the release does not close it"
7384 );
7385 }
7386
7387 #[test]
7388 fn a_drag_out_of_a_fired_hold_extends_by_word_and_tracks_back() {
7389 let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
7390 let _log = install_probe_toolbar();
7391 let mut state = AppState {
7392 value: "hello world".to_string(),
7393 ..Default::default()
7394 };
7395 let mut logic = options_logic(true, false, false);
7396 let mut root = held_word(&mut logic, &mut state, 30.0);
7397
7398 // Past the slop, into the second word. The post-hold drag is
7399 // word-granular: it swallows "world" whole rather than cutting the
7400 // selection off at the cluster under the pointer. This pins the
7401 // retained-granularity assumption the module docs call out — a text
7402 // engine that dropped it would truncate every long-press drag here
7403 // instead of failing loudly.
7404 let out = 30.0 + TOUCH_SLOP + 20.0;
7405 root.event(&mut state, &pointer(PointerPhase::Move, out, 10.0));
7406 assert_eq!(
7407 widget(&root).gesture,
7408 Gesture::Drag,
7409 "leaving the slop promotes the resolved hold to a drag"
7410 );
7411 assert_eq!(
7412 selection(&root).as_deref(),
7413 Some("hello world"),
7414 "the drag extends by whole words, not to the cluster under the pointer"
7415 );
7416
7417 // Dragging back toward the press point shrinks it again — the promotion
7418 // to `Drag` is what keeps the selection tracking the finger instead of
7419 // freezing at its widest.
7420 root.event(&mut state, &pointer(PointerPhase::Move, 30.0, 10.0));
7421 assert_eq!(
7422 selection(&root).as_deref(),
7423 Some("hello"),
7424 "a finger brought back shrinks the selection rather than sticking"
7425 );
7426 }
7427
7428 #[test]
7429 fn a_drag_past_the_slop_cancels_the_hold_and_selects_instead() {
7430 let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
7431 let _log = install_probe_toolbar();
7432 let mut state = AppState {
7433 value: "hello world".to_string(),
7434 ..Default::default()
7435 };
7436 let mut logic = options_logic(true, false, false);
7437 let mut root = options_root(&mut logic, &mut state);
7438 toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
7439
7440 root.event(&mut state, &pointer(PointerPhase::Down, 20.0, 10.0));
7441 toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
7442 // Past the slop well before the threshold: the gesture became a drag.
7443 root.event(
7444 &mut state,
7445 &pointer(PointerPhase::Move, 20.0 + TOUCH_SLOP + 80.0, 10.0),
7446 );
7447 toolbar_frame(&mut root, &mut logic, &mut state, 200.0);
7448 toolbar_frame(&mut root, &mut logic, &mut state, LONG_PRESS_MS + 100.0);
7449 assert!(
7450 !frust_core::has_pending_result_flush(),
7451 "a cancelled hold latches nothing, however long the finger stays down"
7452 );
7453
7454 root.event(&mut state, &InputEvent::Housekeeping);
7455 assert!(
7456 !widget(&root).toolbar_open,
7457 "a drag raises no toolbar of its own"
7458 );
7459 let selected = selection(&root).expect("the drag extended a selection");
7460 assert!(
7461 selected.ends_with("world"),
7462 "the selection followed the pointer to the end of the line, rather than \
7463 snapping to the word under the press: {selected:?}"
7464 );
7465 }
7466
7467 #[test]
7468 fn a_double_tap_selects_the_word_and_a_late_second_tap_only_places_the_caret() {
7469 let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
7470 let _log = install_probe_toolbar();
7471 let mut state = AppState {
7472 value: "hello world".to_string(),
7473 ..Default::default()
7474 };
7475 let mut logic = options_logic(true, false, false);
7476 let mut root = options_root(&mut logic, &mut state);
7477 toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
7478
7479 // A tap, then a second one on the same spot a second later: past the
7480 // window, so it is an ordinary caret placement. This leg runs first,
7481 // while nothing is selected — a second press landing *inside* a
7482 // selection is the toggle gesture, which owns that case.
7483 tap(&mut root, &mut state, 20.0, 10.0);
7484 toolbar_frame(&mut root, &mut logic, &mut state, 900.0);
7485 root.event(&mut state, &pointer(PointerPhase::Down, 21.0, 10.0));
7486 assert_eq!(
7487 selection(&root),
7488 None,
7489 "past DOUBLE_TAP_MS the press is just a press"
7490 );
7491 root.event(&mut state, &pointer(PointerPhase::Up, 21.0, 10.0));
7492
7493 // The same pair inside the window: the word under it is selected.
7494 toolbar_frame(&mut root, &mut logic, &mut state, 1000.0);
7495 root.event(&mut state, &pointer(PointerPhase::Down, 21.0, 10.0));
7496 assert_eq!(
7497 selection(&root).as_deref(),
7498 Some("hello"),
7499 "the second press of a double-tap selects the word"
7500 );
7501 assert!(
7502 !widget(&root).toolbar_open,
7503 "and deliberately raises nothing over the word it just picked"
7504 );
7505 }
7506
7507 #[test]
7508 fn a_tap_inside_the_selection_toggles_the_toolbar_and_keeps_the_selection() {
7509 let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
7510 let _log = install_probe_toolbar();
7511 let mut state = AppState {
7512 value: "hello world".to_string(),
7513 ..Default::default()
7514 };
7515 let mut logic = options_logic(true, false, false);
7516 let mut root = options_root(&mut logic, &mut state);
7517 toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
7518
7519 // Get a selection the honest way, then leave the double-tap window.
7520 tap(&mut root, &mut state, 20.0, 10.0);
7521 toolbar_frame(&mut root, &mut logic, &mut state, 100.0);
7522 tap(&mut root, &mut state, 21.0, 10.0);
7523 assert_eq!(selection(&root).as_deref(), Some("hello"));
7524 toolbar_frame(&mut root, &mut logic, &mut state, 600.0);
7525
7526 // A press inside that selection leaves it exactly alone…
7527 root.event(&mut state, &pointer(PointerPhase::Down, 20.0, 10.0));
7528 assert_eq!(
7529 selection(&root).as_deref(),
7530 Some("hello"),
7531 "the press neither collapses the selection nor moves the caret"
7532 );
7533 assert!(
7534 !widget(&root).toolbar_open,
7535 "and nothing opens on the press itself"
7536 );
7537 // …and the release raises the bar, fire-on-up-inside.
7538 root.event(&mut state, &pointer(PointerPhase::Up, 20.0, 10.0));
7539 assert!(widget(&root).toolbar_open, "the release opens the toolbar");
7540 assert_eq!(selection(&root).as_deref(), Some("hello"));
7541
7542 // The same tap again toggles it away, still without disturbing the
7543 // selection it is pointing at.
7544 toolbar_frame(&mut root, &mut logic, &mut state, 1200.0);
7545 tap(&mut root, &mut state, 20.0, 10.0);
7546 assert!(
7547 !widget(&root).toolbar_open,
7548 "a second tap inside the selection closes it"
7549 );
7550 assert_eq!(selection(&root).as_deref(), Some("hello"));
7551 }
7552
7553 #[test]
7554 fn a_press_inside_the_floated_toolbar_reaches_the_pod_and_its_copy_reaches_the_field() {
7555 let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
7556 let log = install_probe_toolbar();
7557 let mut state = AppState {
7558 value: "hello world".to_string(),
7559 ..Default::default()
7560 };
7561 let mut logic = options_logic(true, false, false);
7562 let mut root = options_root(&mut logic, &mut state);
7563 toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
7564
7565 // Select a word, then open the bar with a context press — which opens no
7566 // capture, so the root's overlay pre-pass is reachable afterwards.
7567 tap(&mut root, &mut state, 20.0, 10.0);
7568 toolbar_frame(&mut root, &mut logic, &mut state, 100.0);
7569 tap(&mut root, &mut state, 21.0, 10.0);
7570 assert_eq!(selection(&root).as_deref(), Some("hello"));
7571 root.event(
7572 &mut state,
7573 &secondary_pointer(PointerPhase::Down, 20.0, 10.0),
7574 );
7575 let scene = toolbar_frame(&mut root, &mut logic, &mut state, 200.0);
7576 assert!(
7577 scene.probe_painted_last(),
7578 "the pod is registered and floated"
7579 );
7580
7581 // Placed against the selection: centred on it, clear of it by the gap,
7582 // and never covering it.
7583 let placed = widget(&root).toolbar.window_rect();
7584 let anchor = root
7585 .selection_toolbar()
7586 .expect("the request is published while the bar is up")
7587 .anchor;
7588 assert_eq!(placed.size(), PROBE_SIZE, "placement never resizes the pod");
7589 assert_eq!(
7590 placed.y0,
7591 anchor.y1 + TOOLBAR_GAP,
7592 "flipped below the selection and the gap clear of it — a field at the \
7593 top of the window has no room above it"
7594 );
7595 assert_eq!(
7596 placed.x0,
7597 crate::DEFAULT_PADDING,
7598 "centred on the selection where it fits and shifted back inside the \
7599 window's padding where it does not — a 120px bar centred on a word \
7600 near the leading edge hangs outside"
7601 );
7602
7603 // A press inside it reaches the pod, and the field keeps its session.
7604 let hit = placed.center();
7605 root.event(&mut state, &pointer(PointerPhase::Down, hit.x, hit.y));
7606 assert_eq!(probe_presses(&log), 1, "the press was routed into the pod");
7607 assert!(
7608 root.is_focus_active(),
7609 "a press on the bar never blurs the field it acts on"
7610 );
7611 assert!(
7612 widget(&root).toolbar_open,
7613 "and a press alone takes no verb"
7614 );
7615
7616 // The release dispatches Copy, which the field drains and applies.
7617 root.event(&mut state, &pointer(PointerPhase::Up, hit.x, hit.y));
7618 assert_eq!(probe_presses(&log), 2);
7619 assert_eq!(
7620 root.take_clipboard_write().as_deref(),
7621 Some("hello"),
7622 "the pod's dispatched verb was applied by the field, not by the pod"
7623 );
7624 assert!(
7625 !widget(&root).toolbar_open,
7626 "a verb taken closes the bar that offered it"
7627 );
7628 assert!(root.is_focus_active(), "with the session still standing");
7629 assert!(
7630 toolbar_frame(&mut root, &mut logic, &mut state, 300.0)
7631 .probe()
7632 .is_none(),
7633 "and the pod is gone from the very next paint"
7634 );
7635 }
7636
7637 #[test]
7638 fn typing_escape_scrolling_and_an_outside_press_each_put_the_toolbar_away() {
7639 let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
7640 let _log = install_probe_toolbar();
7641 let mut state = AppState {
7642 value: "hello world".to_string(),
7643 ..Default::default()
7644 };
7645 let mut logic = options_logic(true, false, false);
7646 let mut root = options_root(&mut logic, &mut state);
7647 toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
7648
7649 // Each leg opens the bar, paints it (so a pod is really registered),
7650 // applies one hide rule, and demands it be gone — both from the widget's
7651 // own state and from the next painted frame.
7652
7653 // A text change: the selection the bar was pointing at is stale.
7654 open_toolbar(&mut root, &mut logic, &mut state, 100.0);
7655 root.event(&mut state, &ch("x"));
7656 assert!(!widget(&root).toolbar_open, "typing puts it away");
7657 assert!(
7658 toolbar_frame(&mut root, &mut logic, &mut state, 120.0)
7659 .probe()
7660 .is_none(),
7661 "and nothing is registered on the next paint"
7662 );
7663
7664 // Escape: the session goes, and the bar with it.
7665 open_toolbar(&mut root, &mut logic, &mut state, 200.0);
7666 root.event(&mut state, &named(NamedKey::Escape, Modifiers::default()));
7667 assert!(!widget(&root).toolbar_open, "Escape puts it away");
7668 assert!(!root.is_focus_active());
7669 assert!(
7670 toolbar_frame(&mut root, &mut logic, &mut state, 220.0)
7671 .probe()
7672 .is_none()
7673 );
7674
7675 // A scroll: the anchor moved out from under it.
7676 open_toolbar(&mut root, &mut logic, &mut state, 300.0);
7677 root.event(
7678 &mut state,
7679 &InputEvent::Scroll {
7680 position: Point::new(20.0, 10.0),
7681 delta: ScrollDelta::Pixels(0.0, -40.0),
7682 },
7683 );
7684 assert!(!widget(&root).toolbar_open, "a scroll puts it away");
7685 assert!(
7686 toolbar_frame(&mut root, &mut logic, &mut state, 320.0)
7687 .probe()
7688 .is_none()
7689 );
7690
7691 // A primary press outside the field: the blur takes it too.
7692 open_toolbar(&mut root, &mut logic, &mut state, 400.0);
7693 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 150.0));
7694 assert!(!widget(&root).toolbar_open, "an outside press puts it away");
7695 assert!(
7696 !root.is_focus_active(),
7697 "and blurs the field as it always did"
7698 );
7699 assert!(
7700 toolbar_frame(&mut root, &mut logic, &mut state, 420.0)
7701 .probe()
7702 .is_none()
7703 );
7704 }
7705
7706 #[test]
7707 fn an_obscured_field_offers_neither_copy_nor_cut_but_still_offers_paste() {
7708 let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
7709 let _log = install_probe_toolbar();
7710 let mut state = AppState {
7711 value: "secret".to_string(),
7712 ..Default::default()
7713 };
7714 let mut logic = options_logic(true, true, false);
7715 let mut root = options_root(&mut logic, &mut state);
7716 toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
7717
7718 // Focus, select everything, and ask for the bar.
7719 tap(&mut root, &mut state, 20.0, 10.0);
7720 root.event(&mut state, &edit(EditCommand::SelectAll));
7721 assert_eq!(selection(&root).as_deref(), Some("secret"));
7722 root.event(
7723 &mut state,
7724 &secondary_pointer(PointerPhase::Down, 20.0, 10.0),
7725 );
7726 toolbar_frame(&mut root, &mut logic, &mut state, 100.0);
7727
7728 let published = root
7729 .selection_toolbar()
7730 .expect("an obscured field publishes its request like any other");
7731 assert_eq!(
7732 published.actions,
7733 SelectionToolbarActions {
7734 copy: false,
7735 cut: false,
7736 paste: true,
7737 select_all: false,
7738 },
7739 "the secret is not this widget's to hand out, but writing into it is \
7740 ordinary — and everything is already selected"
7741 );
7742 }
7743
7744 #[test]
7745 fn a_focused_field_publishes_its_verbs_with_no_bar_up() {
7746 // The fact a platform responder chain answers "may I offer Paste?"
7747 // from. It asks whenever it likes — a hardware Cmd+V arrives with
7748 // nothing on screen and never raises a bar first — so a publish gated
7749 // on the bar left every hardware clipboard shortcut unanswerable.
7750 let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
7751 let _log = install_probe_toolbar();
7752 let mut state = AppState {
7753 value: "hello world".to_string(),
7754 ..Default::default()
7755 };
7756 let mut logic = options_logic(true, false, false);
7757 let mut root = options_root(&mut logic, &mut state);
7758 toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
7759
7760 // An ordinary tap to focus: no long press, no context press, no bar.
7761 tap(&mut root, &mut state, 20.0, 10.0);
7762 let scene = toolbar_frame(&mut root, &mut logic, &mut state, 100.0);
7763 assert!(widget(&root).focused, "the tap focused the field");
7764 assert!(!widget(&root).toolbar_open, "and raised no bar");
7765 assert!(scene.probe().is_none(), "so nothing floated either");
7766 assert_eq!(selection(&root), None, "a plain tap selects nothing");
7767
7768 let published = root
7769 .selection_toolbar()
7770 .expect("a focused field publishes whether or not a bar is up");
7771 assert_eq!(
7772 published.actions,
7773 widget(&root).toolbar_actions(),
7774 "the published verbs are the field's own, computed from its state \
7775 rather than from the bar — the rule the accesskit route already kept"
7776 );
7777 assert!(
7778 published.actions.paste,
7779 "a bare caret in an interactive field is exactly what paste is for"
7780 );
7781 assert!(
7782 !published.present_menu,
7783 "…while nothing asked for a menu, so nothing asks a shell to present one"
7784 );
7785 assert_eq!(
7786 published.anchor,
7787 widget(&root).toolbar_anchor,
7788 "anchored on the caret rect with the selection collapsed"
7789 );
7790
7791 // And the bar going up is the same request with the one flag raised.
7792 root.event(
7793 &mut state,
7794 &secondary_pointer(PointerPhase::Down, 20.0, 10.0),
7795 );
7796 toolbar_frame(&mut root, &mut logic, &mut state, 200.0);
7797 assert!(
7798 root.selection_toolbar()
7799 .expect("still focused, still publishing")
7800 .present_menu
7801 );
7802 }
7803
7804 #[test]
7805 fn the_native_policy_publishes_the_request_and_floats_nothing() {
7806 let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
7807 let _log = install_probe_toolbar();
7808 set_selection_toolbar_policy(SelectionToolbarPolicy::Native);
7809 let mut state = AppState {
7810 value: "hello world".to_string(),
7811 ..Default::default()
7812 };
7813 let mut logic = options_logic(true, false, false);
7814 let mut root = options_root(&mut logic, &mut state);
7815 toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
7816
7817 tap(&mut root, &mut state, 20.0, 10.0);
7818 toolbar_frame(&mut root, &mut logic, &mut state, 100.0);
7819 tap(&mut root, &mut state, 21.0, 10.0);
7820 assert_eq!(selection(&root).as_deref(), Some("hello"));
7821 root.event(
7822 &mut state,
7823 &secondary_pointer(PointerPhase::Down, 20.0, 10.0),
7824 );
7825 assert!(widget(&root).toolbar_open);
7826
7827 let scene = toolbar_frame(&mut root, &mut logic, &mut state, 200.0);
7828 assert!(
7829 scene.probe().is_none(),
7830 "the platform draws it: the field floats nothing at all"
7831 );
7832 assert!(
7833 !widget(&root).toolbar.is_open(),
7834 "and mounts no pod to float"
7835 );
7836 assert!(
7837 root.selection_toolbar().is_some(),
7838 "…while the request a shell hands the platform is published all the same"
7839 );
7840
7841 // Leave the slot as the rest of the process expects to find it.
7842 set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
7843 }
7844
7845 #[test]
7846 fn the_long_press_timer_requests_plain_frames_while_the_blink_paces() {
7847 // A finger still down is a live long-press timer, and its continuation
7848 // frames are deliberately NOT the blink's paced ones: a frame gate that
7849 // throttled them would delay the gesture past its own threshold, and
7850 // `reduce_motion` — which stops the blink's requests entirely — must not
7851 // stop a gesture from firing at all.
7852 let mut state = AppState::default();
7853 let mut root = harness(&mut state);
7854 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
7855 let held = root.paint(&mut NullScene, ft_ms(0.0));
7856 assert!(held.needs_frame, "a live hold keeps the frames coming");
7857 assert!(
7858 !held.needs_frame_paced_only,
7859 "a gesture clock is not a cosmetic loop the frame gate may pace"
7860 );
7861
7862 // Released: the hold is gone and only the caret's paced request is left.
7863 root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
7864 let idle = root.paint(&mut NullScene, ft_ms(20.0));
7865 assert!(
7866 idle.needs_frame_paced_only,
7867 "with no hold in flight the blink is the only thing asking"
7868 );
7869
7870 // Frozen blink, live hold: the plain request survives reduce_motion.
7871 let mut theme = Theme::neutral();
7872 theme.motion.reduce_motion = true;
7873 root.set_theme(Box::new(theme));
7874 // Past the double-tap window, so the next press is an ordinary one that
7875 // arms a hold rather than a word-selecting second tap.
7876 root.paint(&mut NullScene, ft_ms(400.0));
7877 root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
7878 let frozen = root.paint(&mut NullScene, ft_ms(420.0));
7879 assert!(
7880 frozen.needs_frame,
7881 "a frozen blink must not freeze the long-press timer"
7882 );
7883 assert!(!frozen.needs_frame_paced_only);
7884 }
7885
7886 #[test]
7887 fn a_cancel_clears_the_hold_without_touching_the_selection_or_the_toolbar() {
7888 let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
7889 let _log = install_probe_toolbar();
7890 let mut state = AppState {
7891 value: "hello world".to_string(),
7892 ..Default::default()
7893 };
7894 let mut logic = options_logic(true, false, false);
7895 let mut root = options_root(&mut logic, &mut state);
7896 toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
7897
7898 // A selection, and a bar up over it.
7899 tap(&mut root, &mut state, 20.0, 10.0);
7900 toolbar_frame(&mut root, &mut logic, &mut state, 100.0);
7901 tap(&mut root, &mut state, 21.0, 10.0);
7902 assert_eq!(selection(&root).as_deref(), Some("hello"));
7903 open_toolbar(&mut root, &mut logic, &mut state, 600.0);
7904
7905 // A gesture stolen with no press of ours in flight says nothing about
7906 // the bar: a Cancel is not one of the hide rules.
7907 root.event(&mut state, &pointer(PointerPhase::Cancel, 20.0, 10.0));
7908 assert!(
7909 widget(&root).toolbar_open,
7910 "a Cancel never puts the toolbar away"
7911 );
7912
7913 // And a press the platform steals mid-hold disarms the gesture without
7914 // touching the selection it was made over.
7915 root.event(&mut state, &pointer(PointerPhase::Down, 20.0, 10.0));
7916 root.event(&mut state, &pointer(PointerPhase::Cancel, 20.0, 10.0));
7917 assert_eq!(
7918 selection(&root).as_deref(),
7919 Some("hello"),
7920 "a Cancel never touches application state"
7921 );
7922 assert!(!widget(&root).captured, "it does disarm the drag");
7923 assert!(widget(&root).hold.is_none(), "and the hold timer with it");
7924 assert_eq!(widget(&root).gesture, Gesture::None);
7925 assert!(widget(&root).tap_in_selection.is_none());
7926 }
7927}