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