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