Skip to main content

pdfrum_form/
route.rs

1//! Applying one event to a session: the function the whole crate exists to
2//! provide.
3//!
4//! Every decision here is already a pure function elsewhere — `hit`,
5//! `field::text`, `edit::ops`. Routing sequences those answers, and owns one
6//! thing: **when a field's interaction state comes into being, and when it is
7//! written back to an appearance.**
8//!
9//! **State is built lazily, once.** The first event to touch a field reads its
10//! value, options and flags out of the file; every later event finds it there.
11//!
12//! **The appearance is produced on the way out, not stored.** A [`Response`]
13//! carries what a changed field should draw; nothing is cached, because the
14//! appearance is a pure function of the state and the file.
15
16use pdfrum_doc::ap::{self, TextFont};
17use pdfrum_doc::vt;
18use pdfrum_object::{Dict, Resolve};
19
20use crate::cascade::{Cascade, FieldRef, Keystroke, KeystrokeOutcome, PointerTrigger};
21use crate::commit;
22use crate::edit::ops::{self, TextEdit};
23use crate::event::{Button, Event, Key, Modifiers, Point};
24use crate::field::text::{Disposition, Motion, TextAction};
25use crate::field::{self, ChoiceState, FieldState, ToggleKind, ToggleState};
26use crate::hit::{self, Permissions};
27use crate::page::{PageForm, WidgetInfo};
28
29/// `/A` — the action an annotation performs when it is activated.
30const ACTION: &pdfrum_object::Name = &pdfrum_object::Name::from_static(b"A");
31use crate::session::{AnnotId, DragAnchor, FieldId, FocusTarget, FormSession};
32use crate::update::{AppearanceUpdate, Response, UpdateKind};
33use crate::{focus, tab};
34
35/// Everything routing needs that is not the session or the event.
36///
37/// A borrowed view rather than an owned context object: it is assembled at
38/// the call site from things the caller already has, and it owns nothing.
39/// # Examples
40///
41/// Everything here is read from the file before any event is applied; the
42/// context borrows it and owns nothing:
43///
44/// ```
45/// use pdfrum_doc::ap;
46/// use pdfrum_form::route::Context;
47/// use pdfrum_form::{FormSession, NoScripts, Permissions, read_page};
48/// # use pdfrum_object::{Dict, Name, NoResolve, Object, PdfString};
49/// # fn dict<const N: usize>(pairs: [(&'static [u8], Object); N]) -> Dict {
50/// #     Dict::from_pairs(pairs.into_iter().map(|(k, v)| (Name::from(k), v)))
51/// # }
52/// # fn nm(b: &'static [u8]) -> Object { Object::Name(Name::from(b)) }
53/// # fn rect(l: f32, b: f32, r: f32, t: f32) -> Object {
54/// #     Object::Array([l, b, r, t].into_iter().map(Object::Real).collect())
55/// # }
56/// # let helv = dict([(b"Type", nm(b"Font")), (b"Subtype", nm(b"Type1")),
57/// #     (b"BaseFont", nm(b"Helvetica"))]);
58/// # let catalog = dict([(b"AcroForm", Object::Dict(dict([
59/// #     (b"DA", Object::Str(PdfString::literal(b"/Helv 0 Tf 0 g"))),
60/// #     (b"DR", Object::Dict(dict([(b"Font",
61/// #         Object::Dict(dict([(b"Helv", Object::Dict(helv))])))]))),
62/// # ])))]);
63/// # let widget = dict([(b"Type", nm(b"Annot")), (b"Subtype", nm(b"Widget")),
64/// #     (b"FT", nm(b"Tx")), (b"T", Object::Str(PdfString::literal(b"Name"))),
65/// #     (b"V", Object::Str(PdfString::literal(b"old"))),
66/// #     (b"Rect", rect(20.0, 100.0, 180.0, 130.0)),
67/// #     (b"DA", Object::Str(PdfString::literal(b"/Helv 12 Tf 0 g")))]);
68/// # let page_dict = dict([(b"MediaBox", rect(0.0, 0.0, 200.0, 200.0)),
69/// #     (b"Annots", Object::Array([Object::Dict(widget)].into_iter().collect()))]);
70/// # let resolve = NoResolve;
71/// // The page's widgets, read once.
72/// let page = read_page(0, &page_dict, &catalog, &resolve);
73/// let mut build = pdfrum_page::BuildContext::new();
74/// // The fonts the form's `/DR` declares.
75/// let fonts = ap::FormFonts::load(&catalog, &resolve, &mut build);
76///
77/// let ctx = Context {
78///     page: &page,
79///     catalog: &catalog,
80///     resolve: &resolve,
81///     fonts: &fonts,
82///     permissions: Permissions::ALL,
83/// };
84/// assert_eq!(ctx.page.widgets.len(), 1);
85///
86/// // The session and the cascade are the caller's; the context is rebuilt
87/// // per page, the session outlives every event.
88/// let mut session = FormSession::new();
89/// let mut cascade = NoScripts;
90/// # let _ = (&mut session, &mut cascade);
91/// ```
92pub struct Context<'a, R: Resolve> {
93    /// The page the event happened on, already read.
94    pub page: &'a PageForm,
95    /// The document catalog, for the form's default resources.
96    pub catalog: &'a Dict,
97    /// The object resolver.
98    pub resolve: &'a R,
99    /// The fonts the form's `/DR` declares.
100    pub fonts: &'a ap::FormFonts,
101    /// What the document permits.
102    pub permissions: Permissions,
103}
104
105impl<R: Resolve> Context<'_, R> {
106    /// The widget at a raw `/Annots` index, if there is one.
107    fn widget(&self, id: AnnotId) -> Option<&WidgetInfo> {
108        self.page.widgets.iter().find(|w| w.id == id)
109    }
110
111    /// The widget a field's state was built from — its first control.
112    fn widget_of_field(&self, field: FieldId) -> Option<&WidgetInfo> {
113        self.page.widgets.iter().find(|w| w.field == field)
114    }
115
116    /// The page-local field a document-wide field position names, when one of
117    /// its widgets is on this page.
118    ///
119    /// The two spaces are different and only this converts between them: see
120    /// `page`'s module documentation, and [`PageForm::field_of_index`].
121    fn field_of_index(&self, index: u32) -> Option<FieldId> {
122        self.page.field_of_index(index)
123    }
124}
125
126/// Applies one event to a session.
127///
128/// The single entry point, and a total function: every event has an answer,
129/// including "nothing here", which is [`Response::ignored`].
130///
131/// # Where the `f64` stops
132///
133/// An [`Event`]'s point is a [`kurbo::Point`], and **this function is the one
134/// place it is narrowed** to `f32`, before any comparison. Every geometric
135/// query below here compares `f32` against widget edges already rounded the
136/// same way; letting an `f64` reach one of them would move an inclusive edge,
137/// or a caret across a glyph boundary.
138/// # Examples
139///
140/// A click is three events, and the field's interaction state comes into
141/// being on the first one that touches it:
142///
143/// ```
144/// # use kurbo::Point;
145/// # use pdfrum_form::field::FieldState;
146/// # use pdfrum_form::{Button, Event, FieldId, Modifiers};
147/// # use pdfrum_doc::ap;
148/// # use pdfrum_form::route::{self, Context};
149/// # use pdfrum_form::{FormSession, NoScripts, Permissions};
150/// # use pdfrum_object::{Dict, Name, NoResolve, Object, PdfString};
151/// # fn dict<const N: usize>(pairs: [(&'static [u8], Object); N]) -> Dict {
152/// #     Dict::from_pairs(pairs.into_iter().map(|(k, v)| (Name::from(k), v)))
153/// # }
154/// # fn nm(b: &'static [u8]) -> Object { Object::Name(Name::from(b)) }
155/// # fn rect(l: f32, b: f32, r: f32, t: f32) -> Object {
156/// #     Object::Array([l, b, r, t].into_iter().map(Object::Real).collect())
157/// # }
158/// # let helv = dict([(b"Type", nm(b"Font")), (b"Subtype", nm(b"Type1")),
159/// #     (b"BaseFont", nm(b"Helvetica"))]);
160/// # let catalog = dict([(b"AcroForm", Object::Dict(dict([
161/// #     (b"DA", Object::Str(PdfString::literal(b"/Helv 0 Tf 0 g"))),
162/// #     (b"DR", Object::Dict(dict([(b"Font",
163/// #         Object::Dict(dict([(b"Helv", Object::Dict(helv))])))]))),
164/// # ])))]);
165/// # let widget = dict([(b"Type", nm(b"Annot")), (b"Subtype", nm(b"Widget")),
166/// #     (b"FT", nm(b"Tx")), (b"T", Object::Str(PdfString::literal(b"Name"))),
167/// #     (b"V", Object::Str(PdfString::literal(b"old"))),
168/// #     (b"Rect", rect(20.0, 100.0, 180.0, 130.0)),
169/// #     (b"DA", Object::Str(PdfString::literal(b"/Helv 12 Tf 0 g")))]);
170/// # let page_dict = dict([(b"MediaBox", rect(0.0, 0.0, 200.0, 200.0)),
171/// #     (b"Annots", Object::Array([Object::Dict(widget)].into_iter().collect()))]);
172/// # let resolve = NoResolve;
173/// # let page = pdfrum_form::read_page(0, &page_dict, &catalog, &resolve);
174/// # let mut build = pdfrum_page::BuildContext::new();
175/// # let fonts = ap::FormFonts::load(&catalog, &resolve, &mut build);
176/// # let ctx = Context { page: &page, catalog: &catalog, resolve: &resolve,
177/// #     fonts: &fonts, permissions: Permissions::ALL };
178/// # let mut session = FormSession::new();
179/// # let mut cascade = NoScripts;
180/// let at = Point { x: 100.0, y: 115.0 };
181/// for event in [
182///     Event::MouseMove { at, modifiers: Modifiers::NONE },
183///     Event::MouseDown { button: Button::Left, at, modifiers: Modifiers::NONE },
184///     Event::MouseUp { button: Button::Left, at, modifiers: Modifiers::NONE },
185/// ] {
186///     route::apply(&mut session, &ctx, &mut cascade, event);
187/// }
188///
189/// // Typing goes to whatever the click focused.
190/// let response = route::apply(&mut session, &ctx, &mut cascade,
191///     Event::Char { ch: 'X', modifiers: Modifiers::NONE });
192/// assert!(response.consumed);
193/// // Every changed field arrives as an appearance the caller re-renders.
194/// for update in &response.updates {
195///     let _ = update.annot;
196/// }
197///
198/// let Some(FieldState::Text(state)) = session.fields.get(&FieldId(0)) else {
199///     unreachable!("the click built the field's state")
200/// };
201/// assert_eq!(state.edit.text, "oldX");
202///
203/// // A click that lands on no widget is still this session's business while
204/// // a field holds the keyboard: it drops focus, which commits the edit.
205/// let miss = route::apply(&mut session, &ctx, &mut cascade,
206///     Event::MouseDown { button: Button::Left, at: Point { x: 5.0, y: 5.0 },
207///         modifiers: Modifiers::NONE });
208/// assert!(miss.consumed);
209/// assert!(route::focus_of(&session, &ctx).is_none());
210///
211/// // With nothing focused, the same click is answered rather than dropped —
212/// // every event has an answer, and this one is "nothing here".
213/// let again = route::apply(&mut session, &ctx, &mut cascade,
214///     Event::MouseDown { button: Button::Left, at: Point { x: 5.0, y: 5.0 },
215///         modifiers: Modifiers::NONE });
216/// assert!(!again.consumed);
217/// ```
218pub fn apply<R: Resolve>(
219    session: &mut FormSession,
220    ctx: &Context<'_, R>,
221    cascade: &mut dyn Cascade,
222    event: Event,
223) -> Response {
224    let mut response = route(session, ctx, cascade, event);
225    // A script the event ran may have called `Field.setFocus`, which records
226    // a request rather than moving the keyboard itself. Spending it here is
227    // what `SetFocusAnnot` does at the end of the native call, and it is
228    // spent *after* the event's own routing so the field the event was about
229    // has already committed.
230    response.absorb(honour_focus_requests(session, ctx, cascade));
231    response
232}
233
234/// Spends every `Field.setFocus` a script left, until none is left.
235///
236/// A loop rather than one drain because the `/AA /Bl` and `/AA /Fo` this
237/// runs are themselves scripts that may call `setFocus` again. The bound is
238/// [`MAX_SCRIPTED_FOCUS_MOVES`], because two fields whose focus scripts each
239/// name the other would otherwise never stop.
240fn honour_focus_requests<R: Resolve>(
241    session: &mut FormSession,
242    ctx: &Context<'_, R>,
243    cascade: &mut dyn Cascade,
244) -> Response {
245    let mut response = Response::ignored();
246    for _ in 0..MAX_SCRIPTED_FOCUS_MOVES {
247        let Some(index) = cascade.take_focus_request() else {
248            return response;
249        };
250        response.absorb(focus_field(session, ctx, cascade, index));
251    }
252    // The budget is spent. Whatever is still queued is dropped rather than
253    // followed, and the keyboard stays where the last honoured move left it.
254    cascade.take_focus_request();
255    response
256}
257
258/// How many times one event may move the keyboard through `Field.setFocus`.
259///
260/// Two fields whose `/AA /Fo` scripts each call `setFocus` on the other are a
261/// live-lock, and upstream has no counter for it — `SetFocusAnnot` recurses
262/// through `OnSetFocus` until the stack runs out. A bound is the refusing
263/// answer, and eight is past anything a document does on purpose.
264const MAX_SCRIPTED_FOCUS_MOVES: u32 = 8;
265
266/// The event's own routing, with no focus request spent.
267fn route<R: Resolve>(
268    session: &mut FormSession,
269    ctx: &Context<'_, R>,
270    cascade: &mut dyn Cascade,
271    event: Event,
272) -> Response {
273    match event {
274        Event::MouseMove { at, modifiers } => {
275            mouse_move(session, ctx, cascade, Point::narrow(at), modifiers)
276        }
277        Event::MouseDown {
278            button: Button::Left,
279            at,
280            modifiers,
281        } => mouse_down(session, ctx, cascade, Point::narrow(at), modifiers),
282        Event::MouseUp {
283            button: Button::Left,
284            at,
285            modifiers,
286        } => mouse_up(session, ctx, cascade, Point::narrow(at), modifiers),
287        // The right button reaches a widget but changes nothing and — the
288        // asymmetry `focus::miss_drops_focus` records — does not drop focus
289        // when it misses.
290        Event::MouseDown {
291            button: Button::Right,
292            ..
293        }
294        | Event::MouseUp {
295            button: Button::Right,
296            ..
297        } => Response::ignored(),
298        Event::DoubleClick { at, modifiers } => {
299            double_click(session, ctx, cascade, Point::narrow(at), modifiers)
300        }
301        Event::MouseWheel {
302            at,
303            delta,
304            modifiers,
305        } => wheel(session, ctx, Point::narrow(at), delta, modifiers),
306        Event::Focus { at, modifiers } => {
307            focus_at(session, ctx, cascade, Point::narrow(at), modifiers)
308        }
309        Event::KeyDown { key, modifiers } => key_down(session, ctx, cascade, key, modifiers),
310        Event::Char { ch, modifiers } => char_typed(session, ctx, cascade, ch, modifiers),
311    }
312}
313
314/// A pointer move. Drives hover, and extends a drag when one is live.
315fn mouse_move<R: Resolve>(
316    session: &mut FormSession,
317    ctx: &Context<'_, R>,
318    cascade: &mut dyn Cascade,
319    at: Point,
320    modifiers: Modifiers,
321) -> Response {
322    let over = hit::annot_at_point(
323        &ctx.page.candidates,
324        session.focus.map(FocusTarget::annot),
325        at.x,
326        at.y,
327    );
328    let moved = session.hover != over;
329    let left = session.hover;
330    session.hover = over;
331
332    // **The hover *edge* is what fires `/AA /X` and `/AA /E`**, in that
333    // order: `CPDFSDK_PageView::OnMouseMove` sends `OnMouseExit` to the
334    // annotation the pointer left and `OnMouseEnter` to the one it arrived
335    // at, and a move within one widget sends neither. Two calls rather than
336    // one, because a move from one widget straight onto another is both.
337    if moved {
338        if let Some(annot) = left {
339            fire_pointer(
340                session,
341                ctx,
342                cascade,
343                annot,
344                PointerTrigger::Exit,
345                modifiers,
346            );
347        }
348        if let Some(annot) = over {
349            fire_pointer(
350                session,
351                ctx,
352                cascade,
353                annot,
354                PointerTrigger::Enter,
355                modifiers,
356            );
357        }
358    }
359
360    // An open dropdown carries `Styles::kListboxHoverSel`
361    // (`cpwl_combo_box.cpp:210-211`), whose whole effect in
362    // `CPWL_ListBox::OnMouseMove` (`cpwl_list_box.cpp:167-181`) is to select
363    // the row under the pointer. It is recorded as *hover* rather than folded
364    // into the selection because dismissing the list must leave the stored
365    // value alone — which is exactly what `bug_736695_4` renders.
366    if let Some(response) = hover_in_popup(session, ctx, at) {
367        return response;
368    }
369
370    // A drag in progress extends the selection, which is the one thing a
371    // bare move can change about a field's appearance.
372    if let Some(anchor) = session.drag {
373        return match drag_to(session, ctx, anchor, at) {
374            Some(update) => Response::with(vec![update]),
375            None => Response::consumed(),
376        };
377    }
378    if moved && over.is_some() {
379        return Response::consumed();
380    }
381    Response::ignored()
382}
383
384/// The primary button going down: focus, and place the caret.
385fn mouse_down<R: Resolve>(
386    session: &mut FormSession,
387    ctx: &Context<'_, R>,
388    cascade: &mut dyn Cascade,
389    at: Point,
390    modifiers: Modifiers,
391) -> Response {
392    // An open dropdown is a **window in front of the page**, so it is tested
393    // before the annotations under it. Upstream this is not a special case at
394    // all — `CPWL_Wnd::OnLButtonDown` walks its children first, and the list
395    // is a child — but here the widget hit test is containment over
396    // `/Annots`, which the list is not in. Without this the same click read
397    // as a miss and killed focus (see `popup_hit`).
398    if let Some((field, annot, index)) = popup_hit(session, ctx, at) {
399        return press_in_popup(session, ctx, field, annot, index);
400    }
401    let hit = hit::widget_at_point(
402        &ctx.page.candidates,
403        session.focus.map(FocusTarget::annot),
404        ctx.permissions,
405        at.x,
406        at.y,
407    );
408    let Some(id) = hit else {
409        // A left click on nothing drops focus, and committing the field that
410        // held it is what turns its live editor state back into a generated
411        // appearance.
412        return if focus::miss_drops_focus(Button::Left) {
413            kill_focus(session, ctx, cascade)
414        } else {
415            Response::ignored()
416        };
417    };
418    let Some(widget) = ctx.widget(id) else {
419        return Response::ignored();
420    };
421    let field = widget.field;
422
423    // `/AA /D` runs **before** focus moves: `CFFL_InteractiveFormFiller::
424    // OnLButtonDown` fires the action and only then hands the click to the
425    // form field, which is where focus is taken. So a document with all six
426    // scripts alerts `down` and then `focus`, in that order.
427    fire_pointer(session, ctx, cascade, id, PointerTrigger::Down, modifiers);
428
429    let mut response = take_focus(session, ctx, cascade, FocusTarget::Widget(field, id));
430    ensure_state(session, ctx, field);
431
432    // Where the click lands inside the widget is the field kind's business.
433    match session.fields.get(&field) {
434        Some(FieldState::Text(_)) => {
435            let point = to_plate(widget, at);
436            with_edit(session, ctx, field, |edit, config, metrics| {
437                ops::click_at(edit, config, metrics, point);
438            });
439            session.drag = caret_anchor(session, field);
440        }
441        Some(FieldState::Choice(_)) => {
442            // `CPWL_CBButton::OnLButtonDown` (`cpwl_cbbutton.cpp:65-75`)
443            // notifies its parent, and `CPWL_ComboBox::NotifyLButtonDown`
444            // (`cpwl_combo_box.cpp:497-503`) is `SetPopup(!is_popup_)` — a
445            // **toggle**, so a second click on the button shuts the list it
446            // opened. A click anywhere else in the box does not.
447            if toggle_popup_at(session, ctx, field, id, at) {
448                response.absorb(redraw(session, ctx, field, id));
449                return response;
450            }
451            if let Some(update) = choice_click(session, ctx, field, id, at, modifiers) {
452                response.push(update);
453                return response;
454            }
455        }
456        // A toggle acts on the *up* edge, not this one, which is what makes
457        // dragging off a check box before releasing leave it alone; a push
458        // button and an unclassifiable widget take a click and do nothing.
459        Some(FieldState::Toggle(_) | FieldState::Button(_)) | None => {}
460    }
461
462    response.absorb(redraw(session, ctx, field, id));
463    response
464}
465
466/// The primary button coming up: activate a toggle, end a drag.
467fn mouse_up<R: Resolve>(
468    session: &mut FormSession,
469    ctx: &Context<'_, R>,
470    cascade: &mut dyn Cascade,
471    at: Point,
472    modifiers: Modifiers,
473) -> Response {
474    // The release **finishes** the drag before ending it. Dropping the anchor
475    // first loses the last leg of the selection, which is the whole of it
476    // when the pointer never moved between the intermediate positions and the
477    // release — and a drag whose only move is the release point is exactly
478    // what the upstream `SelectTextWithMouse` sends.
479    let finished = session
480        .drag
481        .and_then(|anchor| drag_to(session, ctx, anchor, at));
482    session.drag = None;
483
484    // **The two `/AA` entries fire whether or not a drag ended here**, and
485    // before the drag's own answer is returned: upstream's `OnLButtonUp` runs
486    // `SetFocusAnnot` and `OnButtonUp` on every release that lands on a
487    // widget, and the selection the drag left is not something either of them
488    // consults. Firing them only on the no-drag path would make a click that
489    // moved one pixel run no script.
490    if let Some(id) = hit::widget_at_point(
491        &ctx.page.candidates,
492        session.focus.map(FocusTarget::annot),
493        ctx.permissions,
494        at.x,
495        at.y,
496    ) {
497        // `SetFocusAnnot` first, `OnButtonUp` second
498        // (`cffl_interactiveformfiller.cpp:213-250`) — so a document with
499        // both scripts alerts `focus` and then `up`.
500        fire_pointer(session, ctx, cascade, id, PointerTrigger::Focus, modifiers);
501        fire_pointer(session, ctx, cascade, id, PointerTrigger::Up, modifiers);
502    }
503
504    if let Some(update) = finished {
505        return Response::with(vec![update]);
506    }
507    // The release inside an open dropdown is what *commits* the row — see
508    // `release_in_popup` for why the press only hovers it.
509    if let Some((field, annot, index)) = popup_hit(session, ctx, at) {
510        return release_in_popup(session, ctx, field, annot, index);
511    }
512    let hit = hit::widget_at_point(
513        &ctx.page.candidates,
514        session.focus.map(FocusTarget::annot),
515        ctx.permissions,
516        at.x,
517        at.y,
518    );
519    let Some(id) = hit else {
520        return Response::ignored();
521    };
522    let Some(widget) = ctx.widget(id) else {
523        return Response::ignored();
524    };
525    let field = widget.field;
526    let read_only = widget.flags.is_read_only();
527    let kind = toggle_kind(widget);
528
529    if let (Some(kind), Some(FieldState::Toggle(state))) = (kind, session.fields.get_mut(&field)) {
530        let moved = field::activate(state, kind, read_only);
531        if moved {
532            // A radio button clears its siblings, which are the other
533            // controls of the same field on this page.
534            clear_siblings(session, ctx, field, id);
535            session.dirty.insert(field);
536            let mut response = Response::consumed();
537            for other in ctx.page.widgets.iter().filter(|w| w.field == field) {
538                response.absorb(redraw(session, ctx, field, other.id));
539            }
540            return response;
541        }
542        // Read-only: consumed, and nothing moved.
543        return Response::consumed();
544    }
545    Response::consumed()
546}
547
548/// A double click selects the whole line under the pointer.
549fn double_click<R: Resolve>(
550    session: &mut FormSession,
551    ctx: &Context<'_, R>,
552    cascade: &mut dyn Cascade,
553    at: Point,
554    modifiers: Modifiers,
555) -> Response {
556    // A double click is an `OnLButtonDblClk`, which the form filler routes
557    // through `SetFocusAnnot` exactly as a release does — so `/AA /Fo` runs
558    // again even on the field that already holds focus, which is what
559    // `mouse_events`'s second `focus` alert records.
560    if let Some(annot) = session.focus.map(FocusTarget::annot) {
561        fire_pointer(
562            session,
563            ctx,
564            cascade,
565            annot,
566            PointerTrigger::Focus,
567            modifiers,
568        );
569    }
570    let Some(field) = session.focused_field() else {
571        return Response::ignored();
572    };
573    if !matches!(session.fields.get(&field), Some(FieldState::Text(_))) {
574        return Response::ignored();
575    }
576    // A double click selects the **whole field**, not the line under the
577    // pointer. `CPWL_Edit::OnLButtonDblClk` (`cpwl_edit.cpp:636-644`) calls
578    // `edit_impl_->SelectAll()`; the embeddertest's comment says "the entire
579    // line" and its field is single-line, so the two agree there and only
580    // there. A multiline field is where the wrong reading shows.
581    //
582    // The point is still needed: upstream selects only when the click is
583    // inside the client area (or the field overflows), so a double click on
584    // the border selects nothing.
585    let Some(widget) = ctx.widget_of_field(field) else {
586        return Response::consumed();
587    };
588    let point = to_plate(widget, at);
589    let client =
590        pdfrum_doc::geom::normalize(ap::field_body::client_rect(&widget.dict, ctx.resolve));
591    // `CFX_FloatRect::Contains` (`fx_coordinates.cpp:229-234`) is inclusive on
592    // all four edges, where `kurbo::Rect::contains` is half-open — a click
593    // exactly on the client's top or right edge selects upstream and would
594    // not here.
595    let inside = point.x >= client.x0
596        && point.x <= client.x1
597        && point.y >= client.y0
598        && point.y <= client.y1;
599    if !inside {
600        return Response::consumed();
601    }
602    with_edit(session, ctx, field, |edit, _config, _metrics| {
603        edit.select_all();
604    });
605    let Some(id) = session.focus.map(FocusTarget::annot) else {
606        return Response::consumed();
607    };
608    let mut response = Response::consumed();
609    response.absorb(redraw(session, ctx, field, id));
610    response
611}
612
613/// The wheel scrolls whatever is under the pointer, focused or not.
614///
615/// A **list box** moves its selection rather than its view, with the wheel's
616/// own Shift and Control passed through: the wheel and the arrow keys are one
617/// operation, and those flags change what a multi-select list does with the
618/// row it lands on.
619///
620/// A **combo box** does nothing. Treating it as a list would let a wheel notch
621/// silently change a committed value.
622fn wheel<R: Resolve>(
623    session: &mut FormSession,
624    ctx: &Context<'_, R>,
625    at: Point,
626    delta: (i32, i32),
627    modifiers: Modifiers,
628) -> Response {
629    let hit = hit::widget_at_point(
630        &ctx.page.candidates,
631        session.focus.map(FocusTarget::annot),
632        ctx.permissions,
633        at.x,
634        at.y,
635    );
636    let Some(id) = hit else {
637        return Response::ignored();
638    };
639    let Some(widget) = ctx.widget(id) else {
640        return Response::ignored();
641    };
642    let field = widget.field;
643    ensure_state(session, ctx, field);
644    // Read against the state that already exists: a row's height is measured
645    // from an option's label, so the count cannot be taken before the field
646    // has options.
647    let rows = match session.fields.get(&field) {
648        Some(FieldState::Choice(choice)) => visible_rows(ctx, widget, choice),
649        _ => 0,
650    };
651
652    let moved = match session.fields.get_mut(&field) {
653        // A combo box is not a list under the wheel; see this function's docs.
654        Some(FieldState::Choice(state)) if state.config.combo => false,
655        Some(FieldState::Choice(state)) => scroll_choice(state, delta.1, rows, modifiers),
656        Some(FieldState::Text(_)) => scroll_text(session, ctx, field, delta.1),
657        Some(FieldState::Toggle(_) | FieldState::Button(_)) | None => false,
658    };
659    if !moved {
660        return Response::consumed();
661    }
662    let mut response = Response::consumed();
663    response.absorb(redraw(session, ctx, field, id));
664    response
665}
666
667/// Focus requested at a point, without a click.
668fn focus_at<R: Resolve>(
669    session: &mut FormSession,
670    ctx: &Context<'_, R>,
671    cascade: &mut dyn Cascade,
672    at: Point,
673    modifiers: Modifiers,
674) -> Response {
675    let hit = hit::widget_at_point(
676        &ctx.page.candidates,
677        session.focus.map(FocusTarget::annot),
678        ctx.permissions,
679        at.x,
680        at.y,
681    );
682    let Some(id) = hit else {
683        return Response::ignored();
684    };
685    let Some(widget) = ctx.widget(id) else {
686        return Response::ignored();
687    };
688    let field = widget.field;
689    // The explicit focus verb is `SetFocusAnnot` directly, so `/AA /Fo` runs
690    // here for the same reason it runs on a release.
691    fire_pointer(session, ctx, cascade, id, PointerTrigger::Focus, modifiers);
692    let mut response = take_focus(session, ctx, cascade, FocusTarget::Widget(field, id));
693    ensure_state(session, ctx, field);
694    response.absorb(redraw(session, ctx, field, id));
695    response
696}
697
698/// A key going down, to whatever holds focus.
699fn key_down<R: Resolve>(
700    session: &mut FormSession,
701    ctx: &Context<'_, R>,
702    cascade: &mut dyn Cascade,
703    key: Key,
704    modifiers: Modifiers,
705) -> Response {
706    // Tab moves focus, and it does so whether or not anything holds it.
707    if key == Key::Tab {
708        return tab_to_next(session, ctx, cascade, modifiers);
709    }
710    let Some(target) = session.focus else {
711        return Response::ignored();
712    };
713    let Some(field) = target.field() else {
714        // A focused annotation that is not a widget — a link, once a caller
715        // has put links in the focus ring — fires its action on Return.
716        return annot_key(session, ctx, target.annot(), key, modifiers);
717    };
718    let annot = target.annot();
719
720    match session.fields.get(&field) {
721        Some(FieldState::Text(_)) => text_key(session, ctx, cascade, field, annot, key, modifiers),
722        Some(FieldState::Choice(_)) => choice_key(session, ctx, field, annot, key, modifiers),
723        Some(FieldState::Toggle(_)) => {
724            // Return and Space activate; a read-only control consumes them
725            // and does nothing, which is a different answer from ignoring.
726            if matches!(key, Key::Return | Key::Space) {
727                Response::consumed()
728            } else {
729                Response::ignored()
730            }
731        }
732        // `[oracle-bug]` a focused push button fires its `/A` on Return, the
733        // same activation a focused link gets. `CFFL_PushButton` has no
734        // `OnChar` override (where `CFFL_TextField` does, at
735        // `cffl_textfield.cpp:116-140`), so
736        // `fpdf_formfill_embeddertest.cpp:3658-3667` asserts `DoURIAction`
737        // `.Times(0)` and `ASSERT_FALSE(FORM_OnChar(…, kReturn, 0))` — both
738        // marked `TODO(crbug.com/1028991)` saying they should be one and
739        // true — while the adjacent `LinkActionInvokeTest` (`:3670-3690`)
740        // asserts `.Times(4)` and `ASSERT_TRUE` for a link. §12.6.3 table 196
741        // performs an annotation's `/A` when it is *activated*, and a keyboard
742        // activation of a tab-focused button is one. pdf.js gets it free by
743        // rendering push buttons as `<a>` (`annotation_layer.js:2129-2137`).
744        Some(FieldState::Button(_)) => annot_key(session, ctx, annot, key, modifiers),
745        None => Response::ignored(),
746    }
747}
748
749/// A typed character, to whatever holds focus.
750fn char_typed<R: Resolve>(
751    session: &mut FormSession,
752    ctx: &Context<'_, R>,
753    cascade: &mut dyn Cascade,
754    ch: char,
755    modifiers: Modifiers,
756) -> Response {
757    let Some(target) = session.focus else {
758        return Response::ignored();
759    };
760    let Some(field) = target.field() else {
761        return Response::ignored();
762    };
763    let annot = target.annot();
764    let accelerator = session.config.accelerator;
765
766    match session.fields.get(&field) {
767        Some(FieldState::Text(state)) => {
768            let (read_only, multi_line) = (state.config.read_only, state.config.multi_line);
769            let action = field::text::route_char(ch, modifiers, accelerator, read_only, multi_line);
770            perform_text(session, ctx, cascade, field, annot, action)
771        }
772        Some(FieldState::Choice(_)) => choice_char(session, ctx, cascade, field, annot, ch),
773        Some(FieldState::Toggle(_)) => {
774            // A check box takes Return and Space as activation, and consumes
775            // them read-only or not.
776            if matches!(ch, '\r' | ' ') {
777                let widget = ctx.widget(annot);
778                let read_only = widget.is_some_and(|w| w.flags.is_read_only());
779                let kind = widget.and_then(toggle_kind);
780                if let (Some(kind), Some(FieldState::Toggle(state))) =
781                    (kind, session.fields.get_mut(&field))
782                    && field::activate(state, kind, read_only)
783                {
784                    clear_siblings(session, ctx, field, annot);
785                    session.dirty.insert(field);
786                    let mut response = Response::consumed();
787                    response.absorb(redraw(session, ctx, field, annot));
788                    return response;
789                }
790                return Response::consumed();
791            }
792            Response::ignored()
793        }
794        // `[oracle-bug]` The char path too: the upstream assertion is on
795        // `FORM_OnChar(…, kReturn, 0)` (`fpdf_formfill_embeddertest.cpp:3667`),
796        // so a typed Return activates a focused push button exactly as the
797        // key path does. See `key_down`.
798        Some(FieldState::Button(_)) if ch == '\r' => {
799            annot_key(session, ctx, annot, Key::Return, modifiers)
800        }
801        Some(FieldState::Button(_)) | None => Response::ignored(),
802    }
803}
804
805/// A key for a focused annotation that is not a form widget.
806///
807/// **Return fires its action, and nothing else does.** Shift, Space and the
808/// accelerator are each explicitly *not* an activation — the ported
809/// assertions check those rejections as specifically as they check the
810/// acceptance, because "any key activates a link" is the plausible wrong
811/// implementation.
812///
813/// The action comes back as a **request** the caller may inspect, ignore or
814/// perform, with the modifiers that were held riding along: a link's action
815/// is expected to see them, which is how a control-click opens in a new
816/// window. Nothing is followed here — this crate navigates nothing.
817fn annot_key<R: Resolve>(
818    session: &FormSession,
819    ctx: &Context<'_, R>,
820    annot: AnnotId,
821    key: Key,
822    modifiers: Modifiers,
823) -> Response {
824    let _ = session;
825    if key != Key::Return {
826        return Response::ignored();
827    }
828    let Some(action) = action_of(ctx, annot) else {
829        return Response::ignored();
830    };
831    Response::with(vec![AppearanceUpdate::new(
832        annot,
833        UpdateKind::ActionRequested {
834            action: Box::new(action),
835            modifiers,
836        },
837    )])
838}
839
840/// The action an annotation carries, from its `/A`.
841fn action_of<R: Resolve>(ctx: &Context<'_, R>, annot: AnnotId) -> Option<pdfrum_doc::nav::Action> {
842    let dict = ctx.page.dicts.get(&annot.index)?;
843    let action = dict.dict(ACTION, ctx.resolve)?;
844    Some(pdfrum_doc::nav::Action::new(action))
845}
846
847/// A key for a focused text field.
848fn text_key<R: Resolve>(
849    session: &mut FormSession,
850    ctx: &Context<'_, R>,
851    cascade: &mut dyn Cascade,
852    field: FieldId,
853    annot: AnnotId,
854    key: Key,
855    modifiers: Modifiers,
856) -> Response {
857    let has_selection = match session.fields.get(&field) {
858        Some(FieldState::Text(state)) => state.edit.has_selection(),
859        _ => false,
860    };
861    let action = field::text::route_key(
862        key,
863        modifiers,
864        session.config.accelerator,
865        session.config.redo_on_ctrl_y,
866        has_selection,
867    );
868    perform_text(session, ctx, cascade, field, annot, action)
869}
870
871/// Performs a routed text action.
872fn perform_text<R: Resolve>(
873    session: &mut FormSession,
874    ctx: &Context<'_, R>,
875    cascade: &mut dyn Cascade,
876    field: FieldId,
877    annot: AnnotId,
878    action: Disposition,
879) -> Response {
880    let action = match action {
881        Disposition::Do(action) => action,
882        Disposition::Consume => return Response::consumed(),
883        Disposition::Ignore => return Response::ignored(),
884    };
885
886    // Commit and escape leave the field rather than editing it.
887    match action {
888        TextAction::Commit | TextAction::Escape => {
889            let mut response = Response::consumed();
890            response.absorb(kill_focus(session, ctx, cascade));
891            return response;
892        }
893        _ => {}
894    }
895
896    // The per-character keystroke hook, before the edit is applied.
897    //
898    // `CFFL_InteractiveFormFiller::OnChar` gathers the field action and runs
899    // `/AA /K` with `willCommit` false (`cffl_interactiveformfiller.cpp:1020`)
900    // ahead of the insertion, then applies `SetSelection` and
901    // `ReplaceSelection` from what the script left behind
902    // (`cffl_textfield.cpp:216-222`). A refusal is "do nothing": the character
903    // is dropped and the field is unchanged, which is `:1052`'s
904    // `RecreatePWLWindowFromSavedState` restated.
905    let action = match keystroke_hook(session, ctx, cascade, field, action) {
906        Keyed::Perform(action) => action,
907        Keyed::Refused => return Response::consumed(),
908        Keyed::Rewrote => {
909            session.dirty.insert(field);
910            let mut response = Response::consumed();
911            response.absorb(redraw(session, ctx, field, annot));
912            return response;
913        }
914    };
915
916    let max_len = match session.fields.get(&field) {
917        Some(FieldState::Text(state)) => state.config.max_len.map(std::num::NonZeroU32::get),
918        _ => None,
919    };
920    let mut changed = false;
921    with_edit(session, ctx, field, |edit, config, metrics| {
922        changed = perform_on_edit(edit, config, metrics, action, max_len);
923    });
924    if changed {
925        session.dirty.insert(field);
926    }
927
928    let mut response = Response::consumed();
929    response.absorb(redraw(session, ctx, field, annot));
930    response
931}
932
933/// What the per-character keystroke hook left for routing to do.
934enum Keyed {
935    /// Go ahead with this action, unchanged.
936    Perform(TextAction),
937    /// The hook rewrote the text; it is already applied, so edit nothing more.
938    Rewrote,
939    /// The hook refused. The character is dropped and the field is unchanged.
940    Refused,
941}
942
943/// Offers a text action to the per-character keystroke hook.
944///
945/// # Which actions reach a script, and which do not
946///
947/// Only the ones that **change the text**: an insertion, a return, and the
948/// two deletions. Caret movement, selection, undo, redo and scrolling build no
949/// action at all — a script that saw arrow keys would be seeing keystrokes the
950/// specification says a keystroke event is not about.
951///
952/// A hook that rewrites `change` is answered by *replacing the selection* with
953/// what it returned, rather than by re-running the original action.
954fn keystroke_hook<R: Resolve>(
955    session: &mut FormSession,
956    ctx: &Context<'_, R>,
957    cascade: &mut dyn Cascade,
958    field: FieldId,
959    action: TextAction,
960) -> Keyed {
961    let change = match action {
962        TextAction::Insert(ch) => ch.to_string(),
963        TextAction::InsertReturn => "\n".to_string(),
964        // A deletion is a keystroke whose change is empty; the selection it
965        // replaces is what the edit control already holds.
966        TextAction::Backspace | TextAction::Delete => String::new(),
967        _ => return Keyed::Perform(action),
968    };
969    let Some(reference) = field_ref(ctx, field) else {
970        return Keyed::Perform(action);
971    };
972    let Some(FieldState::Text(state)) = session.fields.get(&field) else {
973        return Keyed::Perform(action);
974    };
975    let offered = Keystroke::of(&state.edit, change);
976
977    match cascade.keystroke(&reference, offered.clone()) {
978        KeystrokeOutcome::Reject => Keyed::Refused,
979        KeystrokeOutcome::Accept(back) if back == offered => Keyed::Perform(action),
980        KeystrokeOutcome::Accept(back) => {
981            // The script moved the caret, rewrote the text, or both. Apply
982            // what it left rather than what was offered.
983            set_field_text(session, field, &back.applied());
984            Keyed::Rewrote
985        }
986    }
987}
988
989/// One text action against a live edit control.
990fn perform_on_edit(
991    edit: &mut TextEdit,
992    config: &vt::Config,
993    metrics: &vt::Metrics<'_>,
994    action: TextAction,
995    max_len: Option<u32>,
996) -> bool {
997    match action {
998        TextAction::Insert(ch) => ops::insert_char(edit, config, metrics, ch, max_len),
999        TextAction::InsertReturn => ops::insert_char(edit, config, metrics, '\n', max_len),
1000        TextAction::Backspace => ops::backspace(edit, config, metrics),
1001        TextAction::Delete => ops::delete(edit, config, metrics),
1002        TextAction::ClearSelection => {
1003            edit.select_none();
1004            true
1005        }
1006        TextAction::SelectAll => {
1007            edit.select_all();
1008            true
1009        }
1010        TextAction::Undo => ops::undo(edit, config, metrics),
1011        TextAction::Redo => ops::redo(edit, config, metrics),
1012        TextAction::Move { motion, extend } => move_caret(edit, config, metrics, motion, extend),
1013        // Handled by the caller, which leaves the field rather than editing.
1014        TextAction::Commit | TextAction::Escape => false,
1015    }
1016}
1017
1018/// Moves the caret, extending the selection when asked.
1019fn move_caret(
1020    edit: &mut TextEdit,
1021    config: &vt::Config,
1022    metrics: &vt::Metrics<'_>,
1023    motion: Motion,
1024    extend: bool,
1025) -> bool {
1026    let len = edit.len_chars();
1027    let at = edit.caret_index();
1028    let to = match motion {
1029        Motion::Left => at.saturating_sub(1),
1030        Motion::Right => (at + 1).min(len),
1031        Motion::DocStart | Motion::LineStart => 0,
1032        Motion::DocEnd | Motion::LineEnd => len,
1033        // A single-line field has nowhere to go vertically, and a multiline
1034        // one moves by the layout's own line breaks.
1035        Motion::Up => line_step(edit, config, metrics, at, -1),
1036        Motion::Down => line_step(edit, config, metrics, at, 1),
1037    };
1038    if extend {
1039        edit.move_caret_keeping_selection(to);
1040    } else {
1041        edit.set_caret_index(to);
1042    }
1043    // Upstream runs `ScrollToCaret` after every one of these, which is what
1044    // lets an arrow key walk off the visible end of a long value and bring
1045    // the view with it.
1046    ops::scroll_to_caret(edit, config, metrics);
1047    true
1048}
1049
1050/// The index one line up or down from `at`, staying in the sticky column.
1051fn line_step(
1052    edit: &TextEdit,
1053    config: &vt::Config,
1054    metrics: &vt::Metrics<'_>,
1055    at: usize,
1056    direction: i32,
1057) -> usize {
1058    let place = vt::hit::place_of_word_index(&edit.layout, at);
1059    let line = i64::from(place.line) + i64::from(direction);
1060    if line < 0 {
1061        return 0;
1062    }
1063    let target = vt::hit::place_at_point(
1064        &edit.layout,
1065        config.plate,
1066        config,
1067        metrics,
1068        edit.offset,
1069        kurbo::Point::new(
1070            f64::from(edit.sticky_x),
1071            f64::from(line_y(edit, place.section, line)),
1072        ),
1073    );
1074    let _ = metrics;
1075    vt::hit::word_index_of_place(&edit.layout, target)
1076}
1077
1078/// The page-space y of a line, for a vertical move.
1079fn line_y(edit: &TextEdit, section: u32, line: i64) -> f32 {
1080    edit.layout
1081        .sections
1082        .get(usize::try_from(section).unwrap_or(0))
1083        .and_then(|section| section.lines.get(usize::try_from(line).unwrap_or(0)))
1084        .map_or(0.0, |line| line.y)
1085}
1086
1087/// A key for a focused choice field.
1088///
1089/// The arrow keys carry their modifiers for the same reason the wheel does:
1090/// the two gestures are one operation and must not diverge here.
1091fn choice_key<R: Resolve>(
1092    session: &mut FormSession,
1093    ctx: &Context<'_, R>,
1094    field: FieldId,
1095    annot: AnnotId,
1096    key: Key,
1097    modifiers: Modifiers,
1098) -> Response {
1099    let rows = match (ctx.widget(annot), session.fields.get(&field)) {
1100        (Some(widget), Some(FieldState::Choice(choice))) => visible_rows(ctx, widget, choice),
1101        _ => 0,
1102    };
1103    let moved = match session.fields.get_mut(&field) {
1104        Some(FieldState::Choice(state)) => {
1105            let (shift, ctrl) = (
1106                modifiers.contains(Modifiers::SHIFT),
1107                modifiers.contains(Modifiers::CONTROL),
1108            );
1109            let moved = match key {
1110                Key::Up => field::choice::move_caret_by(state, -1, shift, ctrl),
1111                Key::Down => field::choice::move_caret_by(state, 1, shift, ctrl),
1112                Key::Return | Key::Space => return Response::consumed(),
1113                _ => return Response::ignored(),
1114            };
1115            // The view follows the caret only when it would otherwise leave
1116            // the box — the same rule the wheel obeys, because upstream they
1117            // are the same operation.
1118            let caret = state.caret_index.unwrap_or(0);
1119            moved | field::choice::scroll_into_view(state, caret, rows)
1120        }
1121        _ => return Response::ignored(),
1122    };
1123    if !moved {
1124        return Response::consumed();
1125    }
1126    session.dirty.insert(field);
1127    let mut response = Response::consumed();
1128    response.absorb(redraw(session, ctx, field, annot));
1129    response
1130}
1131
1132/// A character for a focused choice field: type-ahead, or text in an
1133/// editable combo.
1134fn choice_char<R: Resolve>(
1135    session: &mut FormSession,
1136    ctx: &Context<'_, R>,
1137    cascade: &mut dyn Cascade,
1138    field: FieldId,
1139    annot: AnnotId,
1140    ch: char,
1141) -> Response {
1142    let (combo, editable) = match session.fields.get(&field) {
1143        Some(FieldState::Choice(state)) => (state.config.combo, state.config.editable),
1144        _ => (false, false),
1145    };
1146    // `CPWL_ComboBox::OnChar` (`cpwl_combo_box.cpp:441-497`) reads two
1147    // characters before anything else, and they are **not** symmetric:
1148    //
1149    // - `Return` **toggles** the list, editable or not, and then re-reads the
1150    //   current row into the edit half — so a second Return shuts what the
1151    //   first opened;
1152    // - `Space` opens it, only on a **gated** combo, and only when it is
1153    //   shut. An editable combo's Space falls through and types a space.
1154    //
1155    // Both return `true` whatever happened, which is why the responses below
1156    // are consumed even where the list refused to move.
1157    if combo && matches!(ch, '\r' | '\n') {
1158        return toggle_popup_by_key(session, ctx, field, annot);
1159    }
1160    if combo && !editable && ch == ' ' {
1161        let shut = matches!(
1162            session.fields.get(&field),
1163            Some(FieldState::Choice(state)) if !state.popup_open
1164        );
1165        if shut {
1166            return toggle_popup_by_key(session, ctx, field, annot);
1167        }
1168        return Response::consumed();
1169    }
1170    if editable {
1171        // The keystroke hook sees an editable combo's typing exactly as it
1172        // sees a text field's: `CFFL_ComboBox` builds the same
1173        // `CFFL_FieldAction` from its edit half
1174        // (`fpdfsdk/formfiller/cffl_combobox.cpp:180-196`).
1175        if let Some(reference) = field_ref(ctx, field)
1176            && let Some(FieldState::Choice(state)) = session.fields.get(&field)
1177            && let Some(edit) = state.edit.as_ref()
1178        {
1179            let offered = Keystroke::of(edit, ch.to_string());
1180            match cascade.keystroke(&reference, offered.clone()) {
1181                KeystrokeOutcome::Reject => return Response::consumed(),
1182                KeystrokeOutcome::Accept(back) if back != offered => {
1183                    set_field_text(session, field, &back.applied());
1184                    if let Some(FieldState::Choice(state)) = session.fields.get_mut(&field) {
1185                        state.selected.clear();
1186                        state.caret_index = None;
1187                        state.edit = None;
1188                    }
1189                    session.dirty.insert(field);
1190                    let mut response = Response::consumed();
1191                    response.absorb(redraw(session, ctx, field, annot));
1192                    return response;
1193                }
1194                KeystrokeOutcome::Accept(_) => {}
1195            }
1196        }
1197        // An editable combo's typed text goes to its own edit control, and
1198        // typing clears the index selection.
1199        let mut changed = false;
1200        with_combo_edit(session, ctx, field, |edit, config, metrics| {
1201            changed = ops::insert_char(edit, config, metrics, ch, None);
1202        });
1203        if changed {
1204            if let Some(FieldState::Choice(state)) = session.fields.get_mut(&field) {
1205                state.selected.clear();
1206                state.caret_index = None;
1207            }
1208            session.dirty.insert(field);
1209        }
1210        let mut response = Response::consumed();
1211        response.absorb(redraw(session, ctx, field, annot));
1212        return response;
1213    }
1214
1215    let moved = match session.fields.get_mut(&field) {
1216        Some(FieldState::Choice(state)) => field::choice::type_ahead(state, ch),
1217        _ => false,
1218    };
1219    if !moved {
1220        return Response::consumed();
1221    }
1222    session.dirty.insert(field);
1223    let mut response = Response::consumed();
1224    response.absorb(redraw(session, ctx, field, annot));
1225    response
1226}
1227
1228/// A click inside a choice field's rows.
1229fn choice_click<R: Resolve>(
1230    session: &mut FormSession,
1231    ctx: &Context<'_, R>,
1232    field: FieldId,
1233    id: AnnotId,
1234    at: Point,
1235    modifiers: Modifiers,
1236) -> Option<AppearanceUpdate> {
1237    let widget = ctx.widget(id)?;
1238    let row = row_at(ctx, widget, session.fields.get(&field), at)?;
1239    // A combo box's own box has no rows — `row_at` says so — so the drop
1240    // button, handled by the caller, is the only thing a click in one does.
1241    let multi = match session.fields.get(&field) {
1242        Some(FieldState::Choice(state)) => state.config.multi_select,
1243        _ => false,
1244    };
1245    let Some(FieldState::Choice(state)) = session.fields.get_mut(&field) else {
1246        return None;
1247    };
1248    let moved = if multi && modifiers.contains(Modifiers::SHIFT) {
1249        field::choice::select_range_to(state, row)
1250    } else if multi && modifiers.contains(Modifiers::CONTROL) {
1251        field::choice::toggle_index(state, row)
1252    } else {
1253        field::choice::select_only(state, row)
1254    };
1255    if moved {
1256        session.dirty.insert(field);
1257    }
1258    appearance_of(session, ctx, field, id)
1259}
1260
1261/// Which row of a list box a page-space point falls on.
1262fn row_at<R: Resolve>(
1263    ctx: &Context<'_, R>,
1264    widget: &WidgetInfo,
1265    state: Option<&FieldState>,
1266    at: Point,
1267) -> Option<usize> {
1268    let FieldState::Choice(choice) = state? else {
1269        return None;
1270    };
1271    // A combo box's list is not drawn, so a click in the box selects nothing
1272    // by row; only a list box has rows under the pointer.
1273    if choice.config.combo {
1274        return None;
1275    }
1276    let client = ap::field_body::client_rect(&widget.dict, ctx.resolve);
1277    let height = row_height(ctx, widget, choice);
1278    if height <= 0.0 {
1279        return None;
1280    }
1281    // The point arrives in **page** space and the client rectangle is in the
1282    // appearance stream's, which is the widget's own box at the origin. They
1283    // are the same space only for a widget whose `/Rect` happens to start at
1284    // (0, 0); anywhere else the subtraction below is a difference of two
1285    // unrelated numbers, and it was — a list box at y 371 produced a large
1286    // negative quotient, a saturating `usize`, and no row at all, so the
1287    // click focused the widget and then selected nothing.
1288    //
1289    // `CFFL_FormField::OnLButtonDown` (`cffl_formfield.cpp:103`) passes
1290    // `FFLtoPWL(point)` into the list for exactly this reason.
1291    let point = to_plate(widget, at);
1292    #[expect(
1293        clippy::cast_possible_truncation,
1294        clippy::cast_sign_loss,
1295        reason = "the quotient is bounded by the option count immediately below"
1296    )]
1297    let offset = ((client.y1 - point.y) / f64::from(height)) as usize;
1298    let row = choice.top_visible.checked_add(offset)?;
1299    (row < choice.options.len()).then_some(row)
1300}
1301
1302/// How many rows of a list box fit in its client area.
1303///
1304/// The count the no-overscroll clamp is stated against: a list scrolls only
1305/// far enough to put its last row at the bottom of the box.
1306fn visible_rows<R: Resolve>(
1307    ctx: &Context<'_, R>,
1308    widget: &WidgetInfo,
1309    choice: &ChoiceState,
1310) -> usize {
1311    let client = ap::field_body::client_rect(&widget.dict, ctx.resolve);
1312    let height = row_height(ctx, widget, choice);
1313    if height <= 0.0 {
1314        return 0;
1315    }
1316    #[expect(
1317        clippy::cast_possible_truncation,
1318        clippy::cast_sign_loss,
1319        reason = "a row count is bounded by the widget's height in points"
1320    )]
1321    let rows = (pdfrum_doc::geom::height(client) / height) as usize;
1322    rows
1323}
1324
1325/// The width of a combo box's drop button, in PDF units.
1326///
1327/// The same constant `ap::shapes::drop_button` draws with. It takes the
1328/// rightmost slice of the client rectangle, clamped to the client's left edge
1329/// for a widget narrower than the button — which is why the `max` below is not
1330/// decoration.
1331const DROP_BUTTON_WIDTH: f32 = 13.0;
1332
1333/// Whether a page-space point is inside a combo box's drop button.
1334///
1335/// The button is a child window in *plate* space, so the point is mapped
1336/// through the widget's own rotation before it is compared: a `/MK /R 90`
1337/// combo has its button on the top edge as drawn, not the right one.
1338fn on_drop_button<R: Resolve>(ctx: &Context<'_, R>, widget: &WidgetInfo, at: Point) -> bool {
1339    let client = ap::field_body::client_rect(&widget.dict, ctx.resolve);
1340    let (left, right) = (
1341        pdfrum_doc::geom::left(client),
1342        pdfrum_doc::geom::right(client),
1343    );
1344    let edge = (right - DROP_BUTTON_WIDTH).max(left);
1345    let point = to_plate(widget, at);
1346    #[expect(
1347        clippy::cast_possible_truncation,
1348        reason = "a plate coordinate is a widget-sized number"
1349    )]
1350    let (x, y) = (point.x as f32, point.y as f32);
1351    x >= edge
1352        && x <= right
1353        && y >= pdfrum_doc::geom::bottom(client)
1354        && y <= pdfrum_doc::geom::top(client)
1355}
1356
1357/// Where a combo box's dropdown would be, whether or not it is open.
1358///
1359/// [`None`] for anything that is not a combo, and for a combo whose list has
1360/// no room to open — `SetPopup`'s two "refuse, but report success" exits.
1361fn popup_geometry<R: Resolve>(
1362    ctx: &Context<'_, R>,
1363    widget: &WidgetInfo,
1364    choice: &ChoiceState,
1365) -> Option<crate::popup::PopupGeometry> {
1366    if !choice.config.combo {
1367        return None;
1368    }
1369    crate::popup::place(
1370        widget.rect,
1371        ctx.page.page_height,
1372        choice.options.len(),
1373        row_height(ctx, widget, choice),
1374    )
1375}
1376
1377/// Opens or closes a combo box's dropdown, reporting whether the state moved.
1378///
1379/// There are **three failure paths**, all of which report success and change
1380/// nothing:
1381///
1382/// - no list at all (a field that is not a combo);
1383/// - a list whose content rectangle has no height (no options);
1384/// - a `QueryWherePopup` that comes back with nothing (no room on the page).
1385///
1386/// So a click on the drop button of a combo with nowhere to open is still
1387/// *consumed*; it simply leaves the list shut. That is why this returns
1388/// "did anything move" rather than "did it succeed": the caller wants to know
1389/// whether to redraw, and the two questions have different answers here.
1390fn set_popup<R: Resolve>(
1391    ctx: &Context<'_, R>,
1392    widget: &WidgetInfo,
1393    choice: &mut ChoiceState,
1394    open: bool,
1395) -> bool {
1396    if !choice.config.combo || open == choice.popup_open {
1397        return false;
1398    }
1399    if !open {
1400        choice.popup_open = false;
1401        choice.hovered = None;
1402        return true;
1403    }
1404    if popup_geometry(ctx, widget, choice).is_none() {
1405        return false;
1406    }
1407    choice.popup_open = true;
1408    // `RepositionChildWnd` (`cpwl_combo_box.cpp:275`) runs
1409    // `ScrollToListItem(select_item_)` as it opens, so an already-selected
1410    // row is scrolled into view rather than the list opening at the top.
1411    // Recomputed *after* the flag is set because the geometry is the same
1412    // either way — the popup's size does not depend on whether it is showing.
1413    if let Some(selected) = choice.selected.iter().next().copied() {
1414        let rows =
1415            popup_geometry(ctx, widget, choice).map_or(0, |geometry| geometry.visible_rows());
1416        field::choice::scroll_into_view(choice, selected, rows);
1417    }
1418    true
1419}
1420
1421/// Shuts every open dropdown on the page.
1422///
1423/// A list is closed before focus is dropped, so nothing survives a focus
1424/// change — and because a session focuses one field at a time, closing *every*
1425/// one is the same operation stated without a special case for which field it
1426/// was.
1427fn close_all_popups(session: &mut FormSession) -> bool {
1428    let mut closed = false;
1429    for state in session.fields.values_mut() {
1430        if let FieldState::Choice(choice) = state
1431            && choice.popup_open
1432        {
1433            choice.popup_open = false;
1434            choice.hovered = None;
1435            closed = true;
1436        }
1437    }
1438    closed
1439}
1440
1441/// The field whose dropdown is open on this page, if one is.
1442///
1443/// At most one, because opening one takes focus and taking focus closes the
1444/// last. The walk is over the page's widgets rather than over the session's
1445/// fields so that a field with a control on two pages answers for the page
1446/// being asked about.
1447fn open_popup_of(session: &FormSession, page: &PageForm) -> Option<(FieldId, AnnotId)> {
1448    page.widgets
1449        .iter()
1450        .find_map(|widget| match session.fields.get(&widget.field) {
1451            Some(FieldState::Choice(choice)) if choice.popup_open => {
1452                Some((widget.field, widget.id))
1453            }
1454            _ => None,
1455        })
1456}
1457
1458/// The open dropdown a page-space point falls inside, with the row it names.
1459///
1460/// **An open dropdown must be hit-tested before the annotations are.** A
1461/// mouse-down below an open combo is inside the list, and the list is not in
1462/// `/Annots` — so plain rect containment over the array reads that click as a
1463/// miss and drops focus instead of selecting a row.
1464fn popup_hit<R: Resolve>(
1465    session: &FormSession,
1466    ctx: &Context<'_, R>,
1467    at: Point,
1468) -> Option<(FieldId, AnnotId, usize)> {
1469    let (field, annot) = open_popup_of(session, ctx.page)?;
1470    let widget = ctx.widget(annot)?;
1471    let FieldState::Choice(choice) = session.fields.get(&field)? else {
1472        return None;
1473    };
1474    let geometry = popup_geometry(ctx, widget, choice)?;
1475    let offset = geometry.row_at(at.x, at.y)?;
1476    let index = crate::popup::option_at(choice, offset)?;
1477    Some((field, annot, index))
1478}
1479
1480/// A pointer move over an open dropdown, which hover-selects a row.
1481///
1482/// [`None`] when the pointer is not over an open list, which is what lets
1483/// `mouse_move` fall through to hover and drag as before. A move that stays
1484/// on the same row answers `Some(consumed)` with no update: the pointer is
1485/// still inside a window, so the event is not the page's, but nothing was
1486/// repainted.
1487fn hover_in_popup<R: Resolve>(
1488    session: &mut FormSession,
1489    ctx: &Context<'_, R>,
1490    at: Point,
1491) -> Option<Response> {
1492    let (field, annot, index) = popup_hit(session, ctx, at)?;
1493    let moved = match session.fields.get_mut(&field) {
1494        Some(FieldState::Choice(choice)) => {
1495            let moved = choice.hovered != Some(index);
1496            choice.hovered = Some(index);
1497            moved
1498        }
1499        _ => false,
1500    };
1501    if !moved {
1502        return Some(Response::consumed());
1503    }
1504    let mut response = Response::consumed();
1505    response.absorb(redraw(session, ctx, field, annot));
1506    Some(response)
1507}
1508
1509/// A left-down on the drop button, reporting whether the list moved.
1510///
1511/// Returns `false` for a click that is not on the button, which is what lets
1512/// the caller fall through to the ordinary click handling; a click that *is*
1513/// on it but cannot open the list — no options, no room — also answers
1514/// `false`, because nothing moved and there is nothing to redraw. Either way
1515/// the click stays consumed: the widget took focus before this ran.
1516fn toggle_popup_at<R: Resolve>(
1517    session: &mut FormSession,
1518    ctx: &Context<'_, R>,
1519    field: FieldId,
1520    id: AnnotId,
1521    at: Point,
1522) -> bool {
1523    let Some(widget) = ctx.widget(id) else {
1524        return false;
1525    };
1526    let combo = matches!(
1527        session.fields.get(&field),
1528        Some(FieldState::Choice(choice)) if choice.config.combo
1529    );
1530    if !combo || !on_drop_button(ctx, widget, at) {
1531        return false;
1532    }
1533    let Some(FieldState::Choice(choice)) = session.fields.get_mut(&field) else {
1534        return false;
1535    };
1536    let open = !choice.popup_open;
1537    // Split so the immutable `ctx.widget` borrow and the mutable state borrow
1538    // do not overlap: `set_popup` needs both, and the widget is `ctx`'s.
1539    let mut taken = std::mem::take(choice);
1540    let moved = set_popup(ctx, widget, &mut taken, open);
1541    if let Some(FieldState::Choice(choice)) = session.fields.get_mut(&field) {
1542        *choice = taken;
1543    }
1544    moved
1545}
1546
1547/// `Return` (or a gated combo's `Space`) toggling the dropdown.
1548///
1549/// The keyboard spelling of `toggle_popup_at`, without a point to test. The
1550/// response is always consumed, even when the list refused to open, so a
1551/// combo with nowhere to open still swallows the key rather than letting it
1552/// type a character.
1553fn toggle_popup_by_key<R: Resolve>(
1554    session: &mut FormSession,
1555    ctx: &Context<'_, R>,
1556    field: FieldId,
1557    annot: AnnotId,
1558) -> Response {
1559    let Some(widget) = ctx.widget(annot) else {
1560        return Response::consumed();
1561    };
1562    let Some(FieldState::Choice(choice)) = session.fields.get_mut(&field) else {
1563        return Response::consumed();
1564    };
1565    let open = !choice.popup_open;
1566    let mut taken = std::mem::take(choice);
1567    let moved = set_popup(ctx, widget, &mut taken, open);
1568    if let Some(FieldState::Choice(choice)) = session.fields.get_mut(&field) {
1569        *choice = taken;
1570    }
1571    if !moved {
1572        return Response::consumed();
1573    }
1574    let mut response = Response::consumed();
1575    response.absorb(redraw(session, ctx, field, annot));
1576    response
1577}
1578
1579/// A left-down inside an open dropdown's rows.
1580///
1581/// **Down hovers, up selects.** The press moves the list's own selection; the
1582/// *commit* — copying the row's text into the edit half and shutting the list
1583/// — happens on release. Splitting them matters because a press that drags off
1584/// the list before releasing must leave the field alone.
1585fn press_in_popup<R: Resolve>(
1586    session: &mut FormSession,
1587    ctx: &Context<'_, R>,
1588    field: FieldId,
1589    annot: AnnotId,
1590    index: usize,
1591) -> Response {
1592    if let Some(FieldState::Choice(choice)) = session.fields.get_mut(&field) {
1593        choice.hovered = Some(index);
1594    }
1595    let mut response = Response::consumed();
1596    response.absorb(redraw(session, ctx, field, annot));
1597    response
1598}
1599
1600/// A left-up inside an open dropdown's rows: the row is chosen.
1601///
1602/// Four steps in order: carry the row's label into the text half, select all
1603/// of it, focus the edit, shut the list. The first routes through a selection
1604/// replacement, so **every combo selection is undoable**. The last is why the
1605/// list shuts on release rather than on press.
1606fn release_in_popup<R: Resolve>(
1607    session: &mut FormSession,
1608    ctx: &Context<'_, R>,
1609    field: FieldId,
1610    annot: AnnotId,
1611    index: usize,
1612) -> Response {
1613    let label = match session.fields.get_mut(&field) {
1614        Some(FieldState::Choice(choice)) => {
1615            field::choice::select_only(choice, index);
1616            choice.popup_open = false;
1617            choice.hovered = None;
1618            choice
1619                .options
1620                .get(index)
1621                .map(|option| option.label.clone())
1622                .unwrap_or_default()
1623        }
1624        _ => return Response::consumed(),
1625    };
1626    // An editable combo shows the chosen row in its text half, which is what
1627    // `SetSelectText`'s `edit_->ReplaceSelection(list_->GetText())` puts
1628    // there. A gated one has no text half and reads its label from the
1629    // selection instead.
1630    set_combo_text(session, ctx, field, label);
1631    session.dirty.insert(field);
1632    let mut response = Response::consumed();
1633    response.absorb(redraw(session, ctx, field, annot));
1634    response
1635}
1636
1637/// `SetSelectText()` then `SelectAllText()`: the chosen row's label into the
1638/// combo's text half, **left selected**.
1639///
1640/// Both halves matter and the second is the one that shows: after choosing a
1641/// row the text half holds that row's label with **every character selected**,
1642/// so it draws as white glyphs on a navy band rather than as black text on
1643/// white.
1644///
1645/// The control is rebuilt from the label rather than edited in place: the
1646/// whole text is being replaced, so there is nothing of the old one to keep,
1647/// and `with_combo_edit` is the one place that knows the plate and the face
1648/// to build it with.
1649fn set_combo_text<R: Resolve>(
1650    session: &mut FormSession,
1651    ctx: &Context<'_, R>,
1652    field: FieldId,
1653    label: String,
1654) {
1655    let editable = matches!(
1656        session.fields.get(&field),
1657        Some(FieldState::Choice(choice)) if choice.config.editable
1658    );
1659    if !editable {
1660        return;
1661    }
1662    if let Some(FieldState::Choice(choice)) = session.fields.get_mut(&field) {
1663        choice.edit_text = label;
1664        // Dropped rather than rewritten, so `with_combo_edit` below lays the
1665        // new label out from scratch: one place the text can come from
1666        // instead of two that must agree.
1667        choice.edit = None;
1668    }
1669    with_combo_edit(session, ctx, field, |edit, _config, _metrics| {
1670        edit.select_all();
1671    });
1672}
1673
1674/// What a host draws for one page's open dropdown, if one is open.
1675///
1676/// State and geometry: the library says where the list is and what is in it,
1677/// and the host paints it on its own schedule. Nothing here is a callback and
1678/// nothing is a trait — a viewer that never asks is never told, and one that
1679/// asks twice gets the same answer.
1680///
1681/// [`None`] when nothing on the page has its dropdown open, which is the
1682/// common case: only a click on a drop button, a `Return` or a `Space` on a
1683/// gated combo opens one.
1684#[must_use]
1685/// # Examples
1686///
1687/// ```
1688/// # use pdfrum_doc::ap;
1689/// # use pdfrum_form::route::{self, Context};
1690/// # use pdfrum_form::{FormSession, NoScripts, Permissions};
1691/// # use pdfrum_object::{Dict, Name, NoResolve, Object, PdfString};
1692/// # fn dict<const N: usize>(pairs: [(&'static [u8], Object); N]) -> Dict {
1693/// #     Dict::from_pairs(pairs.into_iter().map(|(k, v)| (Name::from(k), v)))
1694/// # }
1695/// # fn nm(b: &'static [u8]) -> Object { Object::Name(Name::from(b)) }
1696/// # fn rect(l: f32, b: f32, r: f32, t: f32) -> Object {
1697/// #     Object::Array([l, b, r, t].into_iter().map(Object::Real).collect())
1698/// # }
1699/// # let helv = dict([(b"Type", nm(b"Font")), (b"Subtype", nm(b"Type1")),
1700/// #     (b"BaseFont", nm(b"Helvetica"))]);
1701/// # let catalog = dict([(b"AcroForm", Object::Dict(dict([
1702/// #     (b"DA", Object::Str(PdfString::literal(b"/Helv 0 Tf 0 g"))),
1703/// #     (b"DR", Object::Dict(dict([(b"Font",
1704/// #         Object::Dict(dict([(b"Helv", Object::Dict(helv))])))]))),
1705/// # ])))]);
1706/// # let widget = dict([(b"Type", nm(b"Annot")), (b"Subtype", nm(b"Widget")),
1707/// #     (b"FT", nm(b"Tx")), (b"T", Object::Str(PdfString::literal(b"Name"))),
1708/// #     (b"V", Object::Str(PdfString::literal(b"old"))),
1709/// #     (b"Rect", rect(20.0, 100.0, 180.0, 130.0)),
1710/// #     (b"DA", Object::Str(PdfString::literal(b"/Helv 12 Tf 0 g")))]);
1711/// # let page_dict = dict([(b"MediaBox", rect(0.0, 0.0, 200.0, 200.0)),
1712/// #     (b"Annots", Object::Array([Object::Dict(widget)].into_iter().collect()))]);
1713/// # let resolve = NoResolve;
1714/// # let page = pdfrum_form::read_page(0, &page_dict, &catalog, &resolve);
1715/// # let mut build = pdfrum_page::BuildContext::new();
1716/// # let fonts = ap::FormFonts::load(&catalog, &resolve, &mut build);
1717/// # let ctx = Context { page: &page, catalog: &catalog, resolve: &resolve,
1718/// #     fonts: &fonts, permissions: Permissions::ALL };
1719/// # let mut session = FormSession::new();
1720/// # let mut cascade = NoScripts;
1721/// // Nothing on this page has a dropdown, so a host is told to draw none.
1722/// assert!(route::popup_view(&session, &ctx).is_none());
1723/// ```
1724pub fn popup_view<R: Resolve>(
1725    session: &FormSession,
1726    ctx: &Context<'_, R>,
1727) -> Option<crate::popup::PopupView> {
1728    let (field, annot) = open_popup_of(session, ctx.page)?;
1729    let widget = ctx.widget(annot)?;
1730    let FieldState::Choice(choice) = session.fields.get(&field)? else {
1731        return None;
1732    };
1733    let geometry = popup_geometry(ctx, widget, choice)?;
1734    Some(crate::popup::PopupView {
1735        annot,
1736        anchor: crate::popup::widen(widget.rect),
1737        geometry,
1738        options: choice
1739            .options
1740            .iter()
1741            .map(|option| option.label.clone())
1742            .collect(),
1743        selected: choice.selected.iter().next().copied(),
1744        hovered: choice.hovered,
1745        top_visible: choice.top_visible,
1746        edit_text: choice.config.editable.then(|| choice.edit_text.clone()),
1747    })
1748}
1749
1750/// How far one scrollable control has scrolled, in rows.
1751///
1752/// Keyed by annotation rather than carried on [`popup_view`]: a **list box**
1753/// scrolls without any dropdown being open, and its scroll bar is the host's
1754/// to draw for exactly the same reason the dropdown is. The host draws chrome;
1755/// this crate returns values, not callbacks.
1756///
1757/// [`None`] for an annotation that is not a choice widget, or one the session
1758/// has never built state for.
1759#[must_use]
1760/// # Examples
1761///
1762/// ```
1763/// # use pdfrum_form::AnnotId;
1764/// # use pdfrum_doc::ap;
1765/// # use pdfrum_form::route::{self, Context};
1766/// # use pdfrum_form::{FormSession, NoScripts, Permissions};
1767/// # use pdfrum_object::{Dict, Name, NoResolve, Object, PdfString};
1768/// # fn dict<const N: usize>(pairs: [(&'static [u8], Object); N]) -> Dict {
1769/// #     Dict::from_pairs(pairs.into_iter().map(|(k, v)| (Name::from(k), v)))
1770/// # }
1771/// # fn nm(b: &'static [u8]) -> Object { Object::Name(Name::from(b)) }
1772/// # fn rect(l: f32, b: f32, r: f32, t: f32) -> Object {
1773/// #     Object::Array([l, b, r, t].into_iter().map(Object::Real).collect())
1774/// # }
1775/// # let helv = dict([(b"Type", nm(b"Font")), (b"Subtype", nm(b"Type1")),
1776/// #     (b"BaseFont", nm(b"Helvetica"))]);
1777/// # let catalog = dict([(b"AcroForm", Object::Dict(dict([
1778/// #     (b"DA", Object::Str(PdfString::literal(b"/Helv 0 Tf 0 g"))),
1779/// #     (b"DR", Object::Dict(dict([(b"Font",
1780/// #         Object::Dict(dict([(b"Helv", Object::Dict(helv))])))]))),
1781/// # ])))]);
1782/// # let widget = dict([(b"Type", nm(b"Annot")), (b"Subtype", nm(b"Widget")),
1783/// #     (b"FT", nm(b"Tx")), (b"T", Object::Str(PdfString::literal(b"Name"))),
1784/// #     (b"V", Object::Str(PdfString::literal(b"old"))),
1785/// #     (b"Rect", rect(20.0, 100.0, 180.0, 130.0)),
1786/// #     (b"DA", Object::Str(PdfString::literal(b"/Helv 12 Tf 0 g")))]);
1787/// # let page_dict = dict([(b"MediaBox", rect(0.0, 0.0, 200.0, 200.0)),
1788/// #     (b"Annots", Object::Array([Object::Dict(widget)].into_iter().collect()))]);
1789/// # let resolve = NoResolve;
1790/// # let page = pdfrum_form::read_page(0, &page_dict, &catalog, &resolve);
1791/// # let mut build = pdfrum_page::BuildContext::new();
1792/// # let fonts = ap::FormFonts::load(&catalog, &resolve, &mut build);
1793/// # let ctx = Context { page: &page, catalog: &catalog, resolve: &resolve,
1794/// #     fonts: &fonts, permissions: Permissions::ALL };
1795/// # let mut session = FormSession::new();
1796/// # let mut cascade = NoScripts;
1797/// // The page's one widget is a text field, which has no rows to scroll.
1798/// assert!(route::scroll_view(&session, &ctx, AnnotId::new(0u32, 0)).is_none());
1799/// ```
1800pub fn scroll_view<R: Resolve>(
1801    session: &FormSession,
1802    ctx: &Context<'_, R>,
1803    annot: AnnotId,
1804) -> Option<crate::popup::ScrollView> {
1805    let widget = ctx.widget(annot)?;
1806    let FieldState::Choice(choice) = session.fields.get(&widget.field)? else {
1807        return None;
1808    };
1809    // An open dropdown scrolls in its own window, which is taller than the
1810    // widget; a closed combo and a list box scroll inside the widget's box.
1811    let visible = match popup_geometry(ctx, widget, choice).filter(|_| choice.popup_open) {
1812        Some(geometry) => geometry.visible_rows(),
1813        None => visible_rows(ctx, widget, choice),
1814    };
1815    Some(crate::popup::ScrollView {
1816        top_visible: choice.top_visible,
1817        visible_rows: visible,
1818        total: choice.options.len(),
1819    })
1820}
1821
1822/// The host reporting that the user picked a row of an open dropdown.
1823///
1824/// A host that drew the list from [`popup_view`] tells the session what was
1825/// chosen, and the session does what a click on that row would have done —
1826/// select it, shut the list, and hand back the widget's new appearance.
1827/// Exactly `NotifyLButtonUp`'s sequence, reachable without synthesizing a
1828/// click at coordinates the host would have to compute backwards from the
1829/// geometry it was given.
1830///
1831/// An index past the end of the options is ignored, and the response is
1832/// [`Response::ignored`] — a host cannot corrupt a field by miscounting.
1833/// # Examples
1834///
1835/// ```
1836/// # use pdfrum_form::AnnotId;
1837/// # use pdfrum_doc::ap;
1838/// # use pdfrum_form::route::{self, Context};
1839/// # use pdfrum_form::{FormSession, NoScripts, Permissions};
1840/// # use pdfrum_object::{Dict, Name, NoResolve, Object, PdfString};
1841/// # fn dict<const N: usize>(pairs: [(&'static [u8], Object); N]) -> Dict {
1842/// #     Dict::from_pairs(pairs.into_iter().map(|(k, v)| (Name::from(k), v)))
1843/// # }
1844/// # fn nm(b: &'static [u8]) -> Object { Object::Name(Name::from(b)) }
1845/// # fn rect(l: f32, b: f32, r: f32, t: f32) -> Object {
1846/// #     Object::Array([l, b, r, t].into_iter().map(Object::Real).collect())
1847/// # }
1848/// # let helv = dict([(b"Type", nm(b"Font")), (b"Subtype", nm(b"Type1")),
1849/// #     (b"BaseFont", nm(b"Helvetica"))]);
1850/// # let catalog = dict([(b"AcroForm", Object::Dict(dict([
1851/// #     (b"DA", Object::Str(PdfString::literal(b"/Helv 0 Tf 0 g"))),
1852/// #     (b"DR", Object::Dict(dict([(b"Font",
1853/// #         Object::Dict(dict([(b"Helv", Object::Dict(helv))])))]))),
1854/// # ])))]);
1855/// # let widget = dict([(b"Type", nm(b"Annot")), (b"Subtype", nm(b"Widget")),
1856/// #     (b"FT", nm(b"Tx")), (b"T", Object::Str(PdfString::literal(b"Name"))),
1857/// #     (b"V", Object::Str(PdfString::literal(b"old"))),
1858/// #     (b"Rect", rect(20.0, 100.0, 180.0, 130.0)),
1859/// #     (b"DA", Object::Str(PdfString::literal(b"/Helv 12 Tf 0 g")))]);
1860/// # let page_dict = dict([(b"MediaBox", rect(0.0, 0.0, 200.0, 200.0)),
1861/// #     (b"Annots", Object::Array([Object::Dict(widget)].into_iter().collect()))]);
1862/// # let resolve = NoResolve;
1863/// # let page = pdfrum_form::read_page(0, &page_dict, &catalog, &resolve);
1864/// # let mut build = pdfrum_page::BuildContext::new();
1865/// # let fonts = ap::FormFonts::load(&catalog, &resolve, &mut build);
1866/// # let ctx = Context { page: &page, catalog: &catalog, resolve: &resolve,
1867/// #     fonts: &fonts, permissions: Permissions::ALL };
1868/// # let mut session = FormSession::new();
1869/// # let mut cascade = NoScripts;
1870/// // A host cannot corrupt a field by naming a row that is not there — or,
1871/// // as here, an annotation that is not a choice widget at all.
1872/// let response = route::choose(&mut session, &ctx, &mut cascade, AnnotId::new(0u32, 0), 7);
1873/// assert!(!response.consumed);
1874/// ```
1875pub fn choose<R: Resolve>(
1876    session: &mut FormSession,
1877    ctx: &Context<'_, R>,
1878    cascade: &mut dyn Cascade,
1879    annot: AnnotId,
1880    index: usize,
1881) -> Response {
1882    // The cascade is taken but not spent here, and that is upstream's shape
1883    // rather than an omission: `CFFL_ComboBox::SaveData`
1884    // (`fpdfsdk/formfiller/cffl_combobox.cpp:90`) runs only from
1885    // `CommitData`, which only `KillFocusForAnnot` calls — so choosing a row
1886    // changes the selection and the scripts run when the field is left.
1887    // The parameter is on the signature because this is one of the three
1888    // entry points that *can* reach a commit , and a
1889    // caller must not have to discover later that it needs one.
1890    let _ = &cascade;
1891    let Some(widget) = ctx.widget(annot) else {
1892        return Response::ignored();
1893    };
1894    let field = widget.field;
1895    let in_range = matches!(
1896        session.fields.get(&field),
1897        Some(FieldState::Choice(choice)) if index < choice.options.len()
1898    );
1899    if !in_range {
1900        return Response::ignored();
1901    }
1902    release_in_popup(session, ctx, field, annot, index)
1903}
1904
1905/// The host reporting that an open dropdown was dismissed without a choice.
1906///
1907/// `SetPopup(false)`, and nothing else: the stored selection is untouched,
1908/// which is what `bug_736695_4` asserts by hovering a row, clicking away, and
1909/// rendering a field that never changed.
1910///
1911/// [`Response::ignored`] when that annotation had no dropdown open, so a host
1912/// may call it unconditionally.
1913/// # Examples
1914///
1915/// ```
1916/// # use pdfrum_form::AnnotId;
1917/// # use pdfrum_doc::ap;
1918/// # use pdfrum_form::route::{self, Context};
1919/// # use pdfrum_form::{FormSession, NoScripts, Permissions};
1920/// # use pdfrum_object::{Dict, Name, NoResolve, Object, PdfString};
1921/// # fn dict<const N: usize>(pairs: [(&'static [u8], Object); N]) -> Dict {
1922/// #     Dict::from_pairs(pairs.into_iter().map(|(k, v)| (Name::from(k), v)))
1923/// # }
1924/// # fn nm(b: &'static [u8]) -> Object { Object::Name(Name::from(b)) }
1925/// # fn rect(l: f32, b: f32, r: f32, t: f32) -> Object {
1926/// #     Object::Array([l, b, r, t].into_iter().map(Object::Real).collect())
1927/// # }
1928/// # let helv = dict([(b"Type", nm(b"Font")), (b"Subtype", nm(b"Type1")),
1929/// #     (b"BaseFont", nm(b"Helvetica"))]);
1930/// # let catalog = dict([(b"AcroForm", Object::Dict(dict([
1931/// #     (b"DA", Object::Str(PdfString::literal(b"/Helv 0 Tf 0 g"))),
1932/// #     (b"DR", Object::Dict(dict([(b"Font",
1933/// #         Object::Dict(dict([(b"Helv", Object::Dict(helv))])))]))),
1934/// # ])))]);
1935/// # let widget = dict([(b"Type", nm(b"Annot")), (b"Subtype", nm(b"Widget")),
1936/// #     (b"FT", nm(b"Tx")), (b"T", Object::Str(PdfString::literal(b"Name"))),
1937/// #     (b"V", Object::Str(PdfString::literal(b"old"))),
1938/// #     (b"Rect", rect(20.0, 100.0, 180.0, 130.0)),
1939/// #     (b"DA", Object::Str(PdfString::literal(b"/Helv 12 Tf 0 g")))]);
1940/// # let page_dict = dict([(b"MediaBox", rect(0.0, 0.0, 200.0, 200.0)),
1941/// #     (b"Annots", Object::Array([Object::Dict(widget)].into_iter().collect()))]);
1942/// # let resolve = NoResolve;
1943/// # let page = pdfrum_form::read_page(0, &page_dict, &catalog, &resolve);
1944/// # let mut build = pdfrum_page::BuildContext::new();
1945/// # let fonts = ap::FormFonts::load(&catalog, &resolve, &mut build);
1946/// # let ctx = Context { page: &page, catalog: &catalog, resolve: &resolve,
1947/// #     fonts: &fonts, permissions: Permissions::ALL };
1948/// # let mut session = FormSession::new();
1949/// # let mut cascade = NoScripts;
1950/// // Safe to call unconditionally: with no dropdown open it changes nothing
1951/// // and says so.
1952/// assert!(!route::close_popup(&mut session, &ctx, AnnotId::new(0u32, 0)).consumed);
1953/// ```
1954pub fn close_popup<R: Resolve>(
1955    session: &mut FormSession,
1956    ctx: &Context<'_, R>,
1957    annot: AnnotId,
1958) -> Response {
1959    let Some(widget) = ctx.widget(annot) else {
1960        return Response::ignored();
1961    };
1962    let field = widget.field;
1963    let closed = match session.fields.get_mut(&field) {
1964        Some(FieldState::Choice(choice)) if choice.popup_open => {
1965            choice.popup_open = false;
1966            choice.hovered = None;
1967            true
1968        }
1969        _ => false,
1970    };
1971    if !closed {
1972        return Response::ignored();
1973    }
1974    let mut response = Response::consumed();
1975    response.absorb(redraw(session, ctx, field, annot));
1976    response
1977}
1978
1979/// The height of one list-box row: the **laid-out** line, not the font size.
1980///
1981/// A row is one laid-out line: `(ascent - descent) * size / 1000`. At 12
1982/// points in Arimo that is **13.392** units, not 12, because the pair sums to
1983/// 1116. Returning the font size instead makes every row an eighth short,
1984/// which moves the scroll clamp, the wheel's visible-row count and the hit
1985/// test together.
1986///
1987/// The call below is deliberately the **same one** `ap::field_body::list_box`
1988/// makes per row, into a zero-height plate so the layout reports the row's
1989/// extent rather than the box's — so the height that is hit-tested and the
1990/// height that is drawn cannot drift apart. The first option's label is
1991/// measured because every row shares one font and one size.
1992fn row_height<R: Resolve>(ctx: &Context<'_, R>, widget: &WidgetInfo, choice: &ChoiceState) -> f32 {
1993    let client = ap::field_body::client_rect(&widget.dict, ctx.resolve);
1994    let plate = pdfrum_doc::geom::rect(
1995        pdfrum_doc::geom::left(client),
1996        0.0,
1997        pdfrum_doc::geom::right(client),
1998        0.0,
1999    );
2000    let size = font_size(ctx, widget);
2001    let config = vt::Config {
2002        plate,
2003        font_size: if size > 0.0 {
2004            size
2005        } else {
2006            LIST_ROW_DEFAULT_SIZE
2007        },
2008        ..vt::Config::default()
2009    };
2010    let label = choice
2011        .options
2012        .first()
2013        .map_or("", |option| option.label.as_str());
2014    let measured = with_font(ctx, widget, |font, _substitute| {
2015        let layout = vt::layout(label, &config, &font.metrics);
2016        pdfrum_doc::geom::height(layout.content_rect_pdf(plate))
2017    });
2018    // A widget whose `/DA` names a font the form does not declare has no face
2019    // to measure with. Falling back to the font size keeps the clamp finite
2020    // rather than dividing by zero, for the path that cannot do better.
2021    match measured {
2022        Some(height) if height > 0.0 => height,
2023        _ => config.font_size,
2024    }
2025}
2026
2027/// The size a list box's rows are set at when its `/DA` leaves it automatic.
2028///
2029/// `ap::field_body`'s own `LIST_ROW_DEFAULT_SIZE`, which is private to that
2030/// crate; the two must agree, and a test asserts a measured row against a
2031/// drawn one so they cannot quietly stop agreeing.
2032const LIST_ROW_DEFAULT_SIZE: f32 = 12.0;
2033
2034/// The `/DA` font size, zero meaning automatic.
2035fn font_size<R: Resolve>(ctx: &Context<'_, R>, widget: &WidgetInfo) -> f32 {
2036    let form = ctx
2037        .catalog
2038        .dict(pdfrum_object::names::ACRO_FORM, ctx.resolve)
2039        .unwrap_or_default();
2040    ap::freetext::default_appearance(&widget.dict, &form, ctx.resolve)
2041        .map_or(0.0, |appearance| appearance.size)
2042}
2043
2044/// A wheel notch over a list box.
2045///
2046/// **It moves the selection, not the view** — the same operation the arrow
2047/// keys perform — and the view follows only when the newly selected row would
2048/// otherwise be off screen. Reading the wheel as a scrollbar drag, the obvious
2049/// guess, leaves the selection behind on a row that has scrolled out of
2050/// sight.
2051fn scroll_choice(
2052    state: &mut ChoiceState,
2053    delta_y: i32,
2054    visible_rows: usize,
2055    modifiers: Modifiers,
2056) -> bool {
2057    if delta_y == 0 || state.options.is_empty() {
2058        return false;
2059    }
2060    // A negative delta is downward, which is the *next* row. The wheel's own
2061    // modifiers are handed on, because `OnMouseWheel` hands them to `OnVK`
2062    // and they decide what a multi-select list does with the row.
2063    let moved = field::choice::move_caret_by(
2064        state,
2065        if delta_y < 0 { 1 } else { -1 },
2066        modifiers.contains(Modifiers::SHIFT),
2067        modifiers.contains(Modifiers::CONTROL),
2068    );
2069    let caret = state.caret_index.unwrap_or(0);
2070    let scrolled = field::choice::scroll_into_view(state, caret, visible_rows);
2071    moved || scrolled
2072}
2073
2074/// Scrolls a text field by a wheel notch.
2075///
2076/// A `DoNotScroll` field does not move: [`TextEdit::auto_scroll`] gates every
2077/// writer of the scroll position, the wheel included, and such a field is
2078/// drawn no scrollbar to drag either.
2079fn scroll_text<R: Resolve>(
2080    session: &mut FormSession,
2081    ctx: &Context<'_, R>,
2082    field: FieldId,
2083    delta_y: i32,
2084) -> bool {
2085    if delta_y == 0 {
2086        return false;
2087    }
2088    let mut moved = false;
2089    with_edit(session, ctx, field, |edit, config, _metrics| {
2090        moved = ops::scroll_by(edit, config, delta_y);
2091    });
2092    moved
2093}
2094
2095/// Moves focus to the next or previous ring entry.
2096fn tab_to_next<R: Resolve>(
2097    session: &mut FormSession,
2098    ctx: &Context<'_, R>,
2099    cascade: &mut dyn Cascade,
2100    modifiers: Modifiers,
2101) -> Response {
2102    // Every modifier but shift refuses the gesture outright.
2103    if modifiers.contains(Modifiers::CONTROL)
2104        || modifiers.contains(Modifiers::ALT)
2105        || modifiers.contains(Modifiers::META)
2106    {
2107        return Response::ignored();
2108    }
2109    let backward = modifiers.contains(Modifiers::SHIFT);
2110    let ring = focus_ring(session, ctx);
2111    if ring.order.is_empty() {
2112        return Response::ignored();
2113    }
2114    // With nothing focused, forward and backward Tab land on *different*
2115    // annotations, because the cursor starts between the ends rather than
2116    // before them.
2117    let next = match (session.focus.map(FocusTarget::annot), backward) {
2118        (Some(current), false) => ring.next(current),
2119        (Some(current), true) => ring.prev(current),
2120        (None, false) => ring.first(),
2121        (None, true) => ring.last(),
2122    };
2123    let Some(next) = next else {
2124        return Response::ignored();
2125    };
2126    let target = match ctx.widget(next) {
2127        Some(widget) => FocusTarget::Widget(widget.field, next),
2128        None => FocusTarget::Annot(next),
2129    };
2130    let mut response = take_focus(session, ctx, cascade, target);
2131    if let Some(field) = target.field() {
2132        ensure_state(session, ctx, field);
2133        response.absorb(redraw(session, ctx, field, next));
2134    }
2135    response
2136}
2137
2138/// The page's focus ring, filtered to the session's focusable subtypes.
2139///
2140/// The order is the **page's**, read from its `/Tabs` per page, not a
2141/// constant: under `/R` the first Tab can land on a different annotation than
2142/// structure order would answer.
2143fn focus_ring<R: Resolve>(session: &FormSession, ctx: &Context<'_, R>) -> tab::FocusRing {
2144    let focusables: Vec<tab::Focusable> = ctx
2145        .page
2146        .focusables
2147        .iter()
2148        .filter(|(subtype, _)| session.config.focusable.contains(subtype))
2149        .map(|(_, focusable)| *focusable)
2150        .collect();
2151    tab::FocusRing::build(&focusables, ctx.page.tab_order)
2152}
2153
2154/// Runs one of the six pointer and focus `/AA` entries for the field an
2155/// annotation belongs to.
2156///
2157/// **Named by annotation rather than by field**, because that is what the
2158/// hover and hit tests answer with: a field with two widgets fires the
2159/// trigger for the one the pointer is actually over. A widget the page's
2160/// field list does not reach fires nothing, which is what `field_ref`
2161/// answering `None` means.
2162fn fire_pointer<R: Resolve>(
2163    session: &FormSession,
2164    ctx: &Context<'_, R>,
2165    cascade: &mut dyn Cascade,
2166    annot: AnnotId,
2167    trigger: PointerTrigger,
2168    modifiers: Modifiers,
2169) {
2170    let _ = session;
2171    let Some(widget) = ctx.page.widgets.iter().find(|w| w.id == annot) else {
2172        return;
2173    };
2174    let Some(field) = field_ref(ctx, widget.field) else {
2175        return;
2176    };
2177    cascade.pointer(&field, trigger, modifiers);
2178}
2179
2180/// How a field a caller is leaving named itself to its scripts.
2181///
2182/// `None` for a target that is not a widget, or one this page does not carry
2183/// — a script cannot be run for a field the routing context cannot see.
2184fn field_ref<R: Resolve>(ctx: &Context<'_, R>, field: FieldId) -> Option<FieldRef> {
2185    let widget = ctx.widget_of_field(field)?;
2186    Some(FieldRef {
2187        name: widget.name.clone(),
2188        // The **document-wide** position, not the page-local `FieldId`: a
2189        // script names fields in the space `/CO`, `Doc.numFields` and
2190        // `Doc.getNthFieldName` count in, and handing it a page-local id
2191        // would make a two-page form recalculate the wrong field. See
2192        // `page`'s module documentation for the two spaces.
2193        index: widget.field_index,
2194    })
2195}
2196
2197/// The text a field currently holds in the session, for the commit gate.
2198///
2199/// Only the two families that carry text have one: a toggle's value is its
2200/// `/AS` state and a push button has none, and neither reaches the keystroke
2201/// half of the commit cascade.
2202fn edited_text(session: &FormSession, field: FieldId) -> Option<String> {
2203    match session.fields.get(&field)? {
2204        FieldState::Text(text) => Some(text.edit.text.clone()),
2205        FieldState::Choice(choice) if choice.config.editable => Some(choice.edit_text.clone()),
2206        FieldState::Choice(_) | FieldState::Toggle(_) | FieldState::Button(_) => None,
2207    }
2208}
2209
2210/// Runs the commit cascade for a field that is losing focus.
2211///
2212/// Losing focus is the *only* point at which the script gates run over a
2213/// whole field value. The answer says whether focus may proceed: see
2214/// [`commit::CommitOutcome::keeps_focus`] and `commit`'s module documentation
2215/// for why a refusal keeps the field here where the oracle drops it.
2216///
2217/// `None` when nothing ran — a field with no text, or one whose value has not
2218/// moved — which is the ordinary case and the one that must cost nothing.
2219fn commit_field<R: Resolve>(
2220    session: &mut FormSession,
2221    ctx: &Context<'_, R>,
2222    cascade: &mut dyn Cascade,
2223    field: FieldId,
2224) -> Option<commit::CommitOutcome> {
2225    let reference = field_ref(ctx, field)?;
2226    let edited = edited_text(session, field)?;
2227    let stored = ctx.widget_of_field(field)?.value(ctx.resolve);
2228    let outcome = commit::run(
2229        &reference,
2230        &stored,
2231        &edited,
2232        cascade,
2233        session.config.max_calculate_depth,
2234    );
2235
2236    if outcome.reverted {
2237        // The gate refused: the field goes back to what the document holds,
2238        // and whatever a previous format script asked to be shown goes with
2239        // it — the value it described is no longer the value.
2240        set_field_text(session, field, &stored);
2241        session.formatted.remove(&field);
2242        return Some(outcome);
2243    }
2244    for (index, value) in &outcome.writes {
2245        // A calculation names fields by their document-wide field id, which
2246        // is what `FieldRef::index` carries.
2247        let written = ctx.field_of_index(*index).unwrap_or(FieldId(*index));
2248        set_field_text(session, written, value);
2249        session.dirty.insert(written);
2250        // A calculated field is formatted too: `AfterValueChange` is
2251        // `OnCalculate` then `ResetFieldAppearance(pField, OnFormat(pField))`
2252        // (`fpdfsdk/cpdfsdk_interactiveform.cpp:586-588`), and the second
2253        // half runs for every field the first half wrote.
2254        let display = field_ref(ctx, written)
2255            .and_then(|reference| cascade.format(&reference, value))
2256            .filter(|display| display != value);
2257        record_display(session, written, display);
2258    }
2259    // `ResetFieldAppearance(pField, OnFormat(pField))` — the formatting
2260    // script's answer is what the regenerated appearance draws, and `None`
2261    // puts the raw value back rather than leaving a stale display string.
2262    // Only when the commit actually ran: see `CommitOutcome::formats`.
2263    if outcome.formats() {
2264        record_display(session, field, outcome.display.clone());
2265    }
2266    Some(outcome)
2267}
2268
2269/// Remembers — or forgets — what a field is to *show* in place of what it
2270/// stores.
2271///
2272/// `None` is the answer for a field with no format script and for one whose
2273/// script produced its input unchanged, and it must **erase** any earlier
2274/// string rather than leaving one: `None` means "draw the raw value", so a
2275/// stale entry here would keep drawing an answer the document no longer
2276/// gives.
2277fn record_display(session: &mut FormSession, field: FieldId, display: Option<String>) {
2278    match display {
2279        Some(display) => {
2280            session.formatted.insert(field, display);
2281        }
2282        None => {
2283            session.formatted.remove(&field);
2284        }
2285    }
2286}
2287
2288/// Puts a field's text back to `value`, whichever text-bearing family it is.
2289fn set_field_text(session: &mut FormSession, field: FieldId, value: &str) {
2290    match session.fields.get_mut(&field) {
2291        Some(FieldState::Text(text)) => {
2292            text.edit.text = value.to_string();
2293            text.edit.undo = crate::edit::UndoStack::default();
2294        }
2295        Some(FieldState::Choice(choice)) if choice.config.editable => {
2296            choice.edit_text = value.to_string();
2297        }
2298        _ => {}
2299    }
2300}
2301
2302/// Gives focus to a target, committing whatever held it.
2303fn take_focus<R: Resolve>(
2304    session: &mut FormSession,
2305    ctx: &Context<'_, R>,
2306    cascade: &mut dyn Cascade,
2307    target: FocusTarget,
2308) -> Response {
2309    // The outgoing field's scripts run *before* focus moves, because a
2310    // refusal keeps it — `commit`'s module doc, and A63.
2311    if let Some(previous) = session.focus.and_then(FocusTarget::field)
2312        && session.focus != Some(target)
2313        && let Some(outcome) = commit_field(session, ctx, cascade, previous)
2314        && outcome.keeps_focus()
2315    {
2316        let annot = session.focus.map(FocusTarget::annot);
2317        let mut response = Response::consumed();
2318        if let Some(annot) = annot {
2319            response.absorb(redraw(session, ctx, previous, annot));
2320        }
2321        return response;
2322    }
2323
2324    let change = focus::set(session, target);
2325    if !change.moved() {
2326        return Response::consumed();
2327    }
2328    let mut response = Response::consumed();
2329    // The outgoing field commits on the way out, which is what turns its
2330    // live editor state back into a generated appearance.
2331    if let Some(previous) = change.from {
2332        if change.clear_undo
2333            && let Some(field) = previous.field()
2334        {
2335            clear_undo(session, field);
2336        }
2337        if let Some(field) = previous.field() {
2338            response.absorb(redraw(session, ctx, field, previous.annot()));
2339        }
2340    }
2341    response.push(AppearanceUpdate::new(
2342        target.annot(),
2343        UpdateKind::FocusChanged {
2344            from: change.from.map(FocusTarget::annot),
2345            to: Some(target.annot()),
2346        },
2347    ));
2348    response
2349}
2350
2351/// Gives the keyboard to a field a script named, running the two `/AA`
2352/// entries a click would.
2353///
2354/// `index` is a position in the document-wide field list — what
2355/// [`Cascade::take_focus_request`] answers and what
2356/// [`FieldRef::index`](crate::FieldRef::index) carries.
2357///
2358/// # The order, which is the whole of what this function is for
2359///
2360/// `CJS_Field::setFocus` reaches `CPDFSDK_FormFillEnvironment::SetFocusAnnot`,
2361/// which does two things in one order and never the other:
2362///
2363/// 1. the widget that **held** the keyboard loses it, which runs its
2364///    `/AA /Bl`;
2365/// 2. the widget that **takes** it runs its `/AA /Fo`.
2366///
2367/// So a document with a script on each alerts the outgoing field's line
2368/// first. A `setFocus` naming the field that already holds the keyboard is
2369/// `SetFocusAnnot`'s `focus_annot_ == pAnnot` early return: neither script
2370/// runs and nothing moves.
2371///
2372/// Answers [`Response::ignored`] for an index this page does not carry —
2373/// a script may name a field on a page nobody has read, and a routing context
2374/// that cannot see the widget cannot run its scripts.
2375/// # Examples
2376///
2377/// ```
2378/// # use pdfrum_doc::ap;
2379/// # use pdfrum_form::route::{self, Context};
2380/// # use pdfrum_form::{FormSession, NoScripts, Permissions};
2381/// # use pdfrum_object::{Dict, Name, NoResolve, Object, PdfString};
2382/// # fn dict<const N: usize>(pairs: [(&'static [u8], Object); N]) -> Dict {
2383/// #     Dict::from_pairs(pairs.into_iter().map(|(k, v)| (Name::from(k), v)))
2384/// # }
2385/// # fn nm(b: &'static [u8]) -> Object { Object::Name(Name::from(b)) }
2386/// # fn rect(l: f32, b: f32, r: f32, t: f32) -> Object {
2387/// #     Object::Array([l, b, r, t].into_iter().map(Object::Real).collect())
2388/// # }
2389/// # let helv = dict([(b"Type", nm(b"Font")), (b"Subtype", nm(b"Type1")),
2390/// #     (b"BaseFont", nm(b"Helvetica"))]);
2391/// # let catalog = dict([(b"AcroForm", Object::Dict(dict([
2392/// #     (b"DA", Object::Str(PdfString::literal(b"/Helv 0 Tf 0 g"))),
2393/// #     (b"DR", Object::Dict(dict([(b"Font",
2394/// #         Object::Dict(dict([(b"Helv", Object::Dict(helv))])))]))),
2395/// # ])))]);
2396/// # let widget = dict([(b"Type", nm(b"Annot")), (b"Subtype", nm(b"Widget")),
2397/// #     (b"FT", nm(b"Tx")), (b"T", Object::Str(PdfString::literal(b"Name"))),
2398/// #     (b"V", Object::Str(PdfString::literal(b"old"))),
2399/// #     (b"Rect", rect(20.0, 100.0, 180.0, 130.0)),
2400/// #     (b"DA", Object::Str(PdfString::literal(b"/Helv 12 Tf 0 g")))]);
2401/// # let page_dict = dict([(b"MediaBox", rect(0.0, 0.0, 200.0, 200.0)),
2402/// #     (b"Annots", Object::Array([Object::Dict(widget)].into_iter().collect()))]);
2403/// # let resolve = NoResolve;
2404/// # let page = pdfrum_form::read_page(0, &page_dict, &catalog, &resolve);
2405/// # let mut build = pdfrum_page::BuildContext::new();
2406/// # let fonts = ap::FormFonts::load(&catalog, &resolve, &mut build);
2407/// # let ctx = Context { page: &page, catalog: &catalog, resolve: &resolve,
2408/// #     fonts: &fonts, permissions: Permissions::ALL };
2409/// # let mut session = FormSession::new();
2410/// # let mut cascade = NoScripts;
2411/// // `index` is the document-wide field position a script names, not the
2412/// // page-local `FieldId`. This page's `/AcroForm` lists no `/Fields`, so no
2413/// // widget carries that position and the move is ignored rather than
2414/// // guessed at.
2415/// assert!(!route::focus_field(&mut session, &ctx, &mut cascade, 0).consumed);
2416/// assert!(route::focus_of(&session, &ctx).is_none());
2417/// ```
2418pub fn focus_field<R: Resolve>(
2419    session: &mut FormSession,
2420    ctx: &Context<'_, R>,
2421    cascade: &mut dyn Cascade,
2422    index: u32,
2423) -> Response {
2424    let Some(field) = ctx.field_of_index(index) else {
2425        return Response::ignored();
2426    };
2427    let Some(id) = ctx.widget_of_field(field).map(|widget| widget.id) else {
2428        return Response::ignored();
2429    };
2430    let target = FocusTarget::Widget(field, id);
2431    if session.focus == Some(target) {
2432        // `if (focus_annot_ == pAnnot) return true;` — the keyboard is
2433        // already here, and neither script fires.
2434        return Response::consumed();
2435    }
2436    // (1) The outgoing widget's `/AA /Bl`. `KillFocusAnnot` reaches
2437    // `CFFL_InteractiveFormFiller::OnKillFocus`, which is the one path that
2438    // runs the entry — a click that leaves a field does not, which is the
2439    // upstream bug `mouse_events.evt` names beside its own two "should
2440    // trigger an On Blur event" comments and which we reproduce.
2441    if let Some(previous) = session.focus.map(FocusTarget::annot) {
2442        fire_pointer(
2443            session,
2444            ctx,
2445            cascade,
2446            previous,
2447            PointerTrigger::Blur,
2448            Modifiers::NONE,
2449        );
2450    }
2451    // (2) The incoming widget's `/AA /Fo`, then the move itself — which
2452    // commits whatever the outgoing field held, exactly as a click does.
2453    fire_pointer(
2454        session,
2455        ctx,
2456        cascade,
2457        id,
2458        PointerTrigger::Focus,
2459        Modifiers::NONE,
2460    );
2461    let mut response = take_focus(session, ctx, cascade, target);
2462    ensure_state(session, ctx, field);
2463    response.absorb(redraw(session, ctx, field, id));
2464    response
2465}
2466
2467/// Drops focus, redrawing what held it as a committed appearance.
2468///
2469/// Public because it is not only a left click's miss path: the embedder's own
2470/// `FORM_ForceToKillFocus` is the same operation, and a second implementation
2471/// of it would be a second chance to forget the redraw. Dropping focus is
2472/// what turns a field's live editor state back into a generated stream, so a
2473/// version that only reported `FocusChanged` would leave the caret and the
2474/// live text on the page.
2475/// # Examples
2476///
2477/// ```
2478/// # use kurbo::Point;
2479/// # use pdfrum_form::{Button, Event, Modifiers};
2480/// # use pdfrum_doc::ap;
2481/// # use pdfrum_form::route::{self, Context};
2482/// # use pdfrum_form::{FormSession, NoScripts, Permissions};
2483/// # use pdfrum_object::{Dict, Name, NoResolve, Object, PdfString};
2484/// # fn dict<const N: usize>(pairs: [(&'static [u8], Object); N]) -> Dict {
2485/// #     Dict::from_pairs(pairs.into_iter().map(|(k, v)| (Name::from(k), v)))
2486/// # }
2487/// # fn nm(b: &'static [u8]) -> Object { Object::Name(Name::from(b)) }
2488/// # fn rect(l: f32, b: f32, r: f32, t: f32) -> Object {
2489/// #     Object::Array([l, b, r, t].into_iter().map(Object::Real).collect())
2490/// # }
2491/// # let helv = dict([(b"Type", nm(b"Font")), (b"Subtype", nm(b"Type1")),
2492/// #     (b"BaseFont", nm(b"Helvetica"))]);
2493/// # let catalog = dict([(b"AcroForm", Object::Dict(dict([
2494/// #     (b"DA", Object::Str(PdfString::literal(b"/Helv 0 Tf 0 g"))),
2495/// #     (b"DR", Object::Dict(dict([(b"Font",
2496/// #         Object::Dict(dict([(b"Helv", Object::Dict(helv))])))]))),
2497/// # ])))]);
2498/// # let widget = dict([(b"Type", nm(b"Annot")), (b"Subtype", nm(b"Widget")),
2499/// #     (b"FT", nm(b"Tx")), (b"T", Object::Str(PdfString::literal(b"Name"))),
2500/// #     (b"V", Object::Str(PdfString::literal(b"old"))),
2501/// #     (b"Rect", rect(20.0, 100.0, 180.0, 130.0)),
2502/// #     (b"DA", Object::Str(PdfString::literal(b"/Helv 12 Tf 0 g")))]);
2503/// # let page_dict = dict([(b"MediaBox", rect(0.0, 0.0, 200.0, 200.0)),
2504/// #     (b"Annots", Object::Array([Object::Dict(widget)].into_iter().collect()))]);
2505/// # let resolve = NoResolve;
2506/// # let page = pdfrum_form::read_page(0, &page_dict, &catalog, &resolve);
2507/// # let mut build = pdfrum_page::BuildContext::new();
2508/// # let fonts = ap::FormFonts::load(&catalog, &resolve, &mut build);
2509/// # let ctx = Context { page: &page, catalog: &catalog, resolve: &resolve,
2510/// #     fonts: &fonts, permissions: Permissions::ALL };
2511/// # let mut session = FormSession::new();
2512/// # let mut cascade = NoScripts;
2513/// let at = Point { x: 100.0, y: 115.0 };
2514/// for event in [
2515///     Event::MouseDown { button: Button::Left, at, modifiers: Modifiers::NONE },
2516///     Event::MouseUp { button: Button::Left, at, modifiers: Modifiers::NONE },
2517/// ] {
2518///     route::apply(&mut session, &ctx, &mut cascade, event);
2519/// }
2520/// assert!(route::focus_of(&session, &ctx).is_some());
2521///
2522/// // Dropping focus turns the live editor back into a generated appearance,
2523/// // which is why it hands back an update rather than only a flag.
2524/// let response = route::kill_focus(&mut session, &ctx, &mut cascade);
2525/// assert!(response.consumed);
2526/// assert!(!response.updates.is_empty());
2527/// assert!(route::focus_of(&session, &ctx).is_none());
2528/// ```
2529pub fn kill_focus<R: Resolve>(
2530    session: &mut FormSession,
2531    ctx: &Context<'_, R>,
2532    cascade: &mut dyn Cascade,
2533) -> Response {
2534    let mut response = drop_focus(session, ctx, cascade);
2535    // The commit this ran is a script, and a script may call
2536    // `Field.setFocus` — which then puts the keyboard somewhere rather than
2537    // nowhere. Spent here for the same reason `apply` spends it.
2538    response.absorb(honour_focus_requests(session, ctx, cascade));
2539    response
2540}
2541
2542/// [`kill_focus`] without spending a focus request.
2543fn drop_focus<R: Resolve>(
2544    session: &mut FormSession,
2545    ctx: &Context<'_, R>,
2546    cascade: &mut dyn Cascade,
2547) -> Response {
2548    // `CPWL_ComboBox::KillFocus` (`cpwl_combo_box.cpp:52-58`) shuts the list
2549    // *before* the base class drops focus, and returns early if it could not
2550    // — so a dropdown never outlives the focus that opened it. Run
2551    // unconditionally, ahead of `focus::kill`, because it must happen even
2552    // when the outgoing field is not the one that had a list open.
2553    let closed = close_all_popups(session);
2554    // The commit runs before focus goes, because a refusal keeps the field
2555    // (`commit`'s module doc, A63). `FORM_ForceToKillFocus` is the same
2556    // operation and the same gate: `KillFocusForAnnot` consults `CommitData`
2557    // first (`fpdfsdk/formfiller/cffl_formfield.cpp:306`).
2558    if let Some(previous) = session.focus.and_then(FocusTarget::field)
2559        && let Some(outcome) = commit_field(session, ctx, cascade, previous)
2560        && outcome.keeps_focus()
2561    {
2562        let annot = session.focus.map(FocusTarget::annot);
2563        let mut response = Response::consumed();
2564        if let Some(annot) = annot {
2565            response.absorb(redraw(session, ctx, previous, annot));
2566        }
2567        return response;
2568    }
2569    let change = focus::kill(session);
2570    let Some(was) = change.from else {
2571        // Nothing held focus, but a list may still have been open — a host
2572        // that opened one through `choose`'s sibling entry points, or a
2573        // session whose focus was force-killed. Report the redraw rather than
2574        // leaving a shut list drawn.
2575        return if closed {
2576            Response::consumed()
2577        } else {
2578            Response::ignored()
2579        };
2580    };
2581    let mut response = Response::consumed();
2582    if let Some(field) = was.field() {
2583        if change.clear_undo {
2584            clear_undo(session, field);
2585        }
2586        response.absorb(redraw(session, ctx, field, was.annot()));
2587    }
2588    response.push(AppearanceUpdate::new(
2589        was.annot(),
2590        UpdateKind::FocusChanged {
2591            from: Some(was.annot()),
2592            to: None,
2593        },
2594    ));
2595    response
2596}
2597
2598/// Empties a field's undo history, which leaving it for another field does.
2599fn clear_undo(session: &mut FormSession, field: FieldId) {
2600    if let Some(FieldState::Text(state)) = session.fields.get_mut(&field) {
2601        state.edit.undo = crate::edit::UndoStack::default();
2602    }
2603}
2604
2605/// A radio button's siblings on this page lose their state when it is set.
2606///
2607/// Every control of the field is walked: the one at the clicked index takes
2608/// its own on state and **every other one is set to `Off`**. Which control is
2609/// which matters, because two kids of a radio group carry different on-state
2610/// names — that is how `/V` names the chosen one.
2611///
2612/// A field's controls share one [`ToggleState`] here, so the per-control `/AS`
2613/// that walk writes cannot be stored control by control. What *is* storable is
2614/// **which** control is the checked one, and that is what this records: a
2615/// caller reading [`ToggleState::checked_control`] can tell the chosen kid
2616/// from its siblings, where before the two were indistinguishable.
2617///
2618/// # How the difference is drawn
2619///
2620/// A toggle's appearance is its `/AS` state. The generator reads
2621/// [`ap::widget::LiveInput::appearance_state`] first, filled in from
2622/// [`ToggleState::state_for_control`]: the chosen kid its own on-state name,
2623/// every sibling `Off`, and a group nothing has clicked `None`, which is the
2624/// file's own `/AS` unchanged.
2625fn clear_siblings<R: Resolve>(
2626    session: &mut FormSession,
2627    ctx: &Context<'_, R>,
2628    field: FieldId,
2629    chosen: AnnotId,
2630) {
2631    let Some(widget) = ctx.widget(chosen) else {
2632        return;
2633    };
2634    if toggle_kind(widget) != Some(ToggleKind::Radio) {
2635        return;
2636    }
2637    if let Some(FieldState::Toggle(state)) = session.fields.get_mut(&field) {
2638        state.checked_control = Some(chosen);
2639    }
2640}
2641
2642/// Which toggle a widget is, if it is one.
2643fn toggle_kind(widget: &WidgetInfo) -> Option<ToggleKind> {
2644    match widget.kind {
2645        Some(pdfrum_doc::form::FieldKind::Check) => Some(ToggleKind::Check),
2646        Some(pdfrum_doc::form::FieldKind::Radio) => Some(ToggleKind::Radio),
2647        _ => None,
2648    }
2649}
2650
2651/// The drag anchor a fresh click drops.
2652fn caret_anchor(session: &FormSession, field: FieldId) -> Option<DragAnchor> {
2653    match session.fields.get(&field) {
2654        Some(FieldState::Text(state)) => Some(DragAnchor {
2655            field,
2656            start: state.edit.caret,
2657        }),
2658        _ => None,
2659    }
2660}
2661
2662/// Extends a drag to a point.
2663fn drag_to<R: Resolve>(
2664    session: &mut FormSession,
2665    ctx: &Context<'_, R>,
2666    anchor: DragAnchor,
2667    at: Point,
2668) -> Option<AppearanceUpdate> {
2669    let field = anchor.field;
2670    let point = ctx
2671        .widget_of_field(field)
2672        .map(|widget| to_plate(widget, at))?;
2673    with_edit(session, ctx, field, |edit, config, metrics| {
2674        ops::drag_to(edit, config, metrics, point);
2675    });
2676    let id = session.focus.map(FocusTarget::annot)?;
2677    appearance_of(session, ctx, field, id)
2678}
2679
2680/// Builds a field's interaction state, if it does not have one yet.
2681fn ensure_state<R: Resolve>(session: &mut FormSession, ctx: &Context<'_, R>, field: FieldId) {
2682    if session.fields.contains_key(&field) {
2683        return;
2684    }
2685    let Some(widget) = ctx.widget_of_field(field) else {
2686        return;
2687    };
2688    let Some(state) = build_state(ctx, widget) else {
2689        return;
2690    };
2691    session.fields.insert(field, state);
2692}
2693
2694/// Reads one field's interaction state out of the file.
2695fn build_state<R: Resolve>(ctx: &Context<'_, R>, widget: &WidgetInfo) -> Option<FieldState> {
2696    let family = field::family_of(widget.kind?)?;
2697    Some(match family {
2698        field::Family::Text => {
2699            let config = widget.text_config(ctx.resolve);
2700            let value = widget.value(ctx.resolve);
2701            FieldState::Text(field::TextState {
2702                edit: build_edit(ctx, widget, &value, &config),
2703                config,
2704            })
2705        }
2706        field::Family::Choice => {
2707            let config = widget.choice_config();
2708            let options = widget.options(ctx.resolve);
2709            let selected = widget.selected(ctx.resolve);
2710            let mut state = ChoiceState::new(options, config);
2711            for index in selected {
2712                state.selected.insert(index);
2713            }
2714            state.caret_index = state.selected.iter().next().copied();
2715            state.top_visible = widget.top_index(ctx.resolve);
2716            FieldState::Choice(state)
2717        }
2718        field::Family::Toggle => {
2719            let on_state = on_state_of(ctx, widget);
2720            let checked = !widget.value(ctx.resolve).is_empty()
2721                && widget.value(ctx.resolve) != field::toggle::OFF_STATE;
2722            let mut state = ToggleState::new(field::toggle::OFF_STATE, on_state);
2723            state.set_checked(checked);
2724            FieldState::Toggle(state)
2725        }
2726        field::Family::Button => FieldState::Button(field::ButtonState::default()),
2727    })
2728}
2729
2730/// The appearance-state name a toggle shows when checked.
2731fn on_state_of<R: Resolve>(ctx: &Context<'_, R>, widget: &WidgetInfo) -> String {
2732    // The `/AP /N` dictionary's keys are the states; the one that is not
2733    // `Off` is the on state.
2734    widget
2735        .dict
2736        .dict(pdfrum_object::names::AP, ctx.resolve)
2737        .and_then(|ap| ap.dict(pdfrum_object::names::N, ctx.resolve))
2738        .and_then(|normal| {
2739            normal
2740                .keys()
2741                .find(|key| key.as_bytes() != field::toggle::OFF_STATE.as_bytes())
2742                .map(|key| String::from_utf8_lossy(key.as_bytes()).into_owned())
2743        })
2744        .unwrap_or_default()
2745}
2746
2747/// Builds a text field's edit control over a value.
2748fn build_edit<R: Resolve>(
2749    ctx: &Context<'_, R>,
2750    widget: &WidgetInfo,
2751    value: &str,
2752    config: &crate::field::TextConfig,
2753) -> TextEdit {
2754    let plate = ap::field_body::client_rect(&widget.dict, ctx.resolve);
2755    let vt_config = text_config(ctx, widget, plate, config);
2756    let mut edit = with_font(ctx, widget, |font, _substitute| {
2757        TextEdit::new(value, &vt_config, &font.metrics, !config.multi_line)
2758    })
2759    .unwrap_or_else(|| {
2760        // No face at all: lay the value out against zero-width metrics, so
2761        // the text is still stored and every query still answers.
2762        let width = |_code: u32| 0;
2763        let metrics = vt::Metrics {
2764            width: &width,
2765            ascent: 0,
2766            descent: 0,
2767        };
2768        TextEdit::new(value, &vt_config, &metrics, !config.multi_line)
2769    });
2770    // `CFFL_TextField::GetCreateParam` (`cffl_textfield.cpp:54-63`) raises
2771    // `kEditAutoScroll` for a text field without `DoNotScroll`, multi-line or
2772    // not, and `CPWL_Edit::OnCreated` (`cpwl_edit.cpp:131`) hands it to the
2773    // control. This is the one place that knows the flag.
2774    edit.auto_scroll = config.auto_scroll;
2775    edit
2776}
2777
2778/// The layout configuration a text field's body is set with.
2779///
2780/// The **same** configuration `ap::field_body` builds, because a caret
2781/// computed against a different one lands somewhere the glyphs are not.
2782fn text_config<R: Resolve>(
2783    ctx: &Context<'_, R>,
2784    widget: &WidgetInfo,
2785    plate: kurbo::Rect,
2786    config: &crate::field::TextConfig,
2787) -> vt::Config {
2788    let mut vt_config = vt::Config {
2789        plate,
2790        alignment: ap::field_body::alignment(&widget.dict, ctx.resolve),
2791        font_size: font_size(ctx, widget),
2792        multi_line: config.multi_line,
2793        auto_return: config.multi_line,
2794        sub_word: config.password.then_some('*'),
2795        ..vt::Config::default()
2796    };
2797    if let Some(max) = config.max_len {
2798        let cells = usize::try_from(max.get()).unwrap_or(0);
2799        if config.comb {
2800            vt_config.char_array = cells;
2801        } else {
2802            vt_config.limit_char = cells;
2803        }
2804    }
2805    vt_config
2806}
2807
2808/// Runs `body` with the face a widget's `/DA` names, and the **second face**
2809/// for the characters that face's charset cannot write.
2810///
2811/// Answers `None` only when the form declares no font at all, which is a
2812/// document with no `/DR` and no fallback — there is no metric to lay text
2813/// out with, so the caller declines rather than inventing one.
2814///
2815/// # Why the width closure has to know about the second face
2816///
2817/// This is the same construction `ap::generate_appearances_with_text` makes
2818/// for the *stored* path, and it is made here rather than borrowed because
2819/// the closure has to outlive the [`TextFont`] that borrows it.
2820///
2821/// The point worth restating is that the substitute enters through the
2822/// **width closure** and not only through the encoder. A run set in two faces
2823/// advances by two faces' metrics; measuring it all with the first gives a
2824/// line the wrong length wherever the second one writes — which is exactly how
2825/// a Hebrew selection band comes to end ten units short of the glyphs it was
2826/// supposed to cover. The face is chosen per character, and knows nothing
2827/// about whether the character was typed or stored, so the typed path takes
2828/// the same answer as the stored one.
2829fn with_font<R: Resolve, T>(
2830    ctx: &Context<'_, R>,
2831    widget: &WidgetInfo,
2832    body: impl FnOnce(&TextFont<'_>, Option<ap::Substitute<'_>>) -> T,
2833) -> Option<T> {
2834    let form = ctx
2835        .catalog
2836        .dict(pdfrum_object::names::ACRO_FORM, ctx.resolve)
2837        .unwrap_or_default();
2838    let name = ap::freetext::default_appearance(&widget.dict, &form, ctx.resolve)
2839        .map(|appearance| appearance.font_name)
2840        .unwrap_or_default();
2841    // `face` falls back to the last declared face on its own, so a name the
2842    // form does not declare still lays out rather than declining.
2843    let font = ctx.fonts.face(&name)?;
2844    let da_charset = ap::font_map::font_charset(font);
2845    let substitute = ap::font_map::SUBSTITUTABLE_CHARSETS
2846        .iter()
2847        .find(|charset| **charset != da_charset)
2848        .and_then(|charset| ctx.fonts.substitute(*charset));
2849    let width = move |code: u32| match substitute {
2850        Some(sub) if !ap::font_map::da_font_writes(font, da_charset, code) => {
2851            ap::font_map::substitute_width(sub.font, code)
2852        }
2853        _ => TextFont::char_width(font, code),
2854    };
2855    let text_font = TextFont {
2856        metrics: TextFont::metrics_of(font, &width),
2857        font,
2858    };
2859    Some(body(&text_font, substitute))
2860}
2861
2862/// Replaces a field's selection with `text`, or deletes it when `text` is
2863/// empty.
2864///
2865/// The embedder's paste, and half of its cut. Answers whether the field
2866/// changed — which an empty replacement of an empty selection does not, and
2867/// a read-only field never does.
2868/// # Examples
2869///
2870/// ```
2871/// # use kurbo::Point;
2872/// # use pdfrum_form::field::FieldState;
2873/// # use pdfrum_form::{Button, Event, FieldId, Modifiers};
2874/// # use pdfrum_doc::ap;
2875/// # use pdfrum_form::route::{self, Context};
2876/// # use pdfrum_form::{FormSession, NoScripts, Permissions};
2877/// # use pdfrum_object::{Dict, Name, NoResolve, Object, PdfString};
2878/// # fn dict<const N: usize>(pairs: [(&'static [u8], Object); N]) -> Dict {
2879/// #     Dict::from_pairs(pairs.into_iter().map(|(k, v)| (Name::from(k), v)))
2880/// # }
2881/// # fn nm(b: &'static [u8]) -> Object { Object::Name(Name::from(b)) }
2882/// # fn rect(l: f32, b: f32, r: f32, t: f32) -> Object {
2883/// #     Object::Array([l, b, r, t].into_iter().map(Object::Real).collect())
2884/// # }
2885/// # let helv = dict([(b"Type", nm(b"Font")), (b"Subtype", nm(b"Type1")),
2886/// #     (b"BaseFont", nm(b"Helvetica"))]);
2887/// # let catalog = dict([(b"AcroForm", Object::Dict(dict([
2888/// #     (b"DA", Object::Str(PdfString::literal(b"/Helv 0 Tf 0 g"))),
2889/// #     (b"DR", Object::Dict(dict([(b"Font",
2890/// #         Object::Dict(dict([(b"Helv", Object::Dict(helv))])))]))),
2891/// # ])))]);
2892/// # let widget = dict([(b"Type", nm(b"Annot")), (b"Subtype", nm(b"Widget")),
2893/// #     (b"FT", nm(b"Tx")), (b"T", Object::Str(PdfString::literal(b"Name"))),
2894/// #     (b"V", Object::Str(PdfString::literal(b"old"))),
2895/// #     (b"Rect", rect(20.0, 100.0, 180.0, 130.0)),
2896/// #     (b"DA", Object::Str(PdfString::literal(b"/Helv 12 Tf 0 g")))]);
2897/// # let page_dict = dict([(b"MediaBox", rect(0.0, 0.0, 200.0, 200.0)),
2898/// #     (b"Annots", Object::Array([Object::Dict(widget)].into_iter().collect()))]);
2899/// # let resolve = NoResolve;
2900/// # let page = pdfrum_form::read_page(0, &page_dict, &catalog, &resolve);
2901/// # let mut build = pdfrum_page::BuildContext::new();
2902/// # let fonts = ap::FormFonts::load(&catalog, &resolve, &mut build);
2903/// # let ctx = Context { page: &page, catalog: &catalog, resolve: &resolve,
2904/// #     fonts: &fonts, permissions: Permissions::ALL };
2905/// # let mut session = FormSession::new();
2906/// # let mut cascade = NoScripts;
2907/// # let at = Point { x: 100.0, y: 115.0 };
2908/// # for event in [
2909/// #     Event::MouseDown { button: Button::Left, at, modifiers: Modifiers::NONE },
2910/// #     Event::MouseUp { button: Button::Left, at, modifiers: Modifiers::NONE },
2911/// # ] { route::apply(&mut session, &ctx, &mut cascade, event); }
2912/// // The embedder's paste, at the caret the click left.
2913/// assert!(route::replace_selection(&mut session, &ctx, FieldId(0), "Hello"));
2914///
2915/// let Some(FieldState::Text(state)) = session.fields.get(&FieldId(0)) else {
2916///     unreachable!("the click built the field's state")
2917/// };
2918/// assert_eq!(state.edit.text, "oldHello");
2919///
2920/// // A field the session has never built state for has no selection to
2921/// // replace, and refuses rather than creating one.
2922/// assert!(!route::replace_selection(&mut session, &ctx, FieldId(9), "x"));
2923/// ```
2924pub fn replace_selection<R: Resolve>(
2925    session: &mut FormSession,
2926    ctx: &Context<'_, R>,
2927    field: FieldId,
2928    text: &str,
2929) -> bool {
2930    let max_len = match session.fields.get(&field) {
2931        Some(FieldState::Text(state)) if !state.config.read_only => {
2932            state.config.max_len.map(std::num::NonZeroU32::get)
2933        }
2934        // A read-only field refuses, and a non-text field has no selection to
2935        // replace.
2936        _ => return false,
2937    };
2938    let mut changed = false;
2939    with_edit(session, ctx, field, |edit, config, metrics| {
2940        if text.is_empty() && !edit.has_selection() {
2941            return;
2942        }
2943        changed = ops::replace_selection(edit, config, metrics, text, max_len);
2944    });
2945    if changed {
2946        session.dirty.insert(field);
2947    }
2948    changed
2949}
2950
2951/// A page-space point in the widget's **appearance-stream** space, y-up.
2952///
2953/// The two spaces differ by two things rather than one.
2954///
2955/// The widget's own corner, first: `ap::widget::rotated_rect` places a
2956/// widget's box at the origin, so a plate is always `(0, 0)`-based while an
2957/// event's point is wherever the widget sits on the page. Forgetting it is
2958/// silent rather than loud — it puts every click far to the right of the
2959/// text, where the hit test clamps it to one end and every caret lands in the
2960/// same place.
2961///
2962/// And the widget's **rotation**: at `/MK /R 90` the appearance stream is set
2963/// into a box whose axes are exchanged, so a click that is not un-rotated
2964/// arrives on the wrong axis entirely. [`geom::Plate::to_widget`] carries the
2965/// table.
2966///
2967/// The result stays y-**up**, because that is what every consumer wants:
2968/// `ap::field_body::client_rect` is y-up, and `vt::hit`'s queries take a y-up
2969/// point and do their own flip. Handing them a y-down one flips it twice.
2970fn to_plate(widget: &WidgetInfo, at: Point) -> kurbo::Point {
2971    let point = plate_of(widget).to_widget(at);
2972    kurbo::Point::new(f64::from(point.x), f64::from(point.y))
2973}
2974
2975/// The widget's page↔plate mapping, rotation included.
2976fn plate_of(widget: &WidgetInfo) -> crate::geom::Plate {
2977    crate::geom::Plate::new(widget.rect, widget.rotation)
2978}
2979
2980/// Runs `body` against a text field's live edit control.
2981fn with_edit<R: Resolve>(
2982    session: &mut FormSession,
2983    ctx: &Context<'_, R>,
2984    field: FieldId,
2985    body: impl FnOnce(&mut TextEdit, &vt::Config, &vt::Metrics<'_>),
2986) {
2987    let Some(widget) = ctx.widget_of_field(field).cloned() else {
2988        return;
2989    };
2990    let Some(FieldState::Text(state)) = session.fields.get_mut(&field) else {
2991        return;
2992    };
2993    let plate = ap::field_body::client_rect(&widget.dict, ctx.resolve);
2994    let config = text_config(ctx, &widget, plate, &state.config);
2995    with_font(ctx, &widget, |font, _substitute| {
2996        body(&mut state.edit, &config, &font.metrics);
2997    });
2998}
2999
3000/// Runs `body` against an editable combo box's edit control.
3001fn with_combo_edit<R: Resolve>(
3002    session: &mut FormSession,
3003    ctx: &Context<'_, R>,
3004    field: FieldId,
3005    body: impl FnOnce(&mut TextEdit, &vt::Config, &vt::Metrics<'_>),
3006) {
3007    let Some(widget) = ctx.widget_of_field(field).cloned() else {
3008        return;
3009    };
3010    let Some(FieldState::Choice(state)) = session.fields.get_mut(&field) else {
3011        return;
3012    };
3013    let client = ap::field_body::client_rect(&widget.dict, ctx.resolve);
3014    // A combo box sets its text into the box left of the drop button.
3015    let plate = kurbo::Rect::new(client.x0, client.y0, client.x1 - 13.0, client.y1);
3016    let config = vt::Config {
3017        plate,
3018        font_size: font_size(ctx, &widget),
3019        ..vt::Config::default()
3020    };
3021    let editable = state.config.editable;
3022    if !editable {
3023        return;
3024    }
3025    let text = state.edit_text.clone();
3026    with_font(ctx, &widget, |font, _substitute| {
3027        let mut edit = state
3028            .edit
3029            .take()
3030            .unwrap_or_else(|| Box::new(TextEdit::new(text, &config, &font.metrics, true)));
3031        body(&mut edit, &config, &font.metrics);
3032        state.edit_text.clone_from(&edit.text);
3033        state.edit = Some(edit);
3034    });
3035}
3036
3037/// The appearance a field should now draw, and the update carrying it.
3038fn redraw<R: Resolve>(
3039    session: &FormSession,
3040    ctx: &Context<'_, R>,
3041    field: FieldId,
3042    id: AnnotId,
3043) -> Response {
3044    match appearance_of(session, ctx, field, id) {
3045        Some(update) => Response::with(vec![update]),
3046        None => Response::consumed(),
3047    }
3048}
3049
3050/// Builds one widget's appearance from the session's state.
3051///
3052/// The focused field takes the live-edit path, with its caret and selection;
3053/// every other field takes the committed one. That branch is the whole seam
3054/// between appearance generation and interaction.
3055fn appearance_of<R: Resolve>(
3056    session: &FormSession,
3057    ctx: &Context<'_, R>,
3058    field: FieldId,
3059    id: AnnotId,
3060) -> Option<AppearanceUpdate> {
3061    let widget = ctx.widget(id)?;
3062    let state = session.fields.get(&field)?;
3063    let focused = session.focus.map(FocusTarget::annot) == Some(id);
3064
3065    // A format script's answer is what an **unfocused** field draws, and the
3066    // raw value is what a focused one edits — `CFFL_FormField::OnSetFocus`
3067    // seeds its editor from `GetValue()`, never from the formatted text.
3068    let display = (!focused)
3069        .then(|| session.formatted.get(&field))
3070        .flatten()
3071        .map(String::as_str);
3072    let generated = generate(ctx, widget, state, focused, display)?;
3073    let kind = if focused {
3074        UpdateKind::LiveEdit(Box::new(generated))
3075    } else {
3076        UpdateKind::Regenerated(Box::new(generated))
3077    };
3078    Some(AppearanceUpdate::new(id, kind))
3079}
3080
3081/// Generates a widget's appearance stream for its current interaction state.
3082///
3083/// The whole seam between appearance generation and interaction, and it is
3084/// one branch: a **focused** field is drawn with its caret or its selection
3085/// bands over the text the *session* holds, and every other field is drawn
3086/// from the text the *file* holds. Both go through the same generator, so a
3087/// committed field and a never-touched one produce the same bytes.
3088fn generate<R: Resolve>(
3089    ctx: &Context<'_, R>,
3090    widget: &WidgetInfo,
3091    state: &FieldState,
3092    focused: bool,
3093    display: Option<&str>,
3094) -> Option<pdfrum_doc::GeneratedAp> {
3095    let selected = selected_rows(state);
3096    let live = live_state(state, &selected, display);
3097    let highlight = focused.then(|| highlight_of(ctx, widget, state)).flatten();
3098    // A radio group's kids each carry a different on-state name, and a click
3099    // on one sets that kid's `/AS` to its own name and every sibling's to
3100    // `Off`. A session holds one record per *field*, so this is the per-kid
3101    // half of `CheckControl` the record can express — see
3102    // `ToggleState::state_for_control`, and `clear_siblings` for what puts
3103    // the chosen control there. `None` means "read the widget's own `/AS`",
3104    // which is every widget nothing has clicked.
3105    let as_override = match state {
3106        FieldState::Toggle(toggle) => toggle.state_for_control(widget.id),
3107        FieldState::Text(_) | FieldState::Choice(_) | FieldState::Button(_) => None,
3108    };
3109    // `LiveInput` is **not** `#[non_exhaustive]`, so this literal has to name
3110    // every field and a new one upstream is a compile error here rather than a
3111    // silent default. That is a real cost paid once already — `6e87424`'s
3112    // `appearance_state` broke this construction site and left `pdfrum-form`
3113    // failing to build for a period — and the fix is not to spell the literal
3114    // differently but to mark the struct: whoever adds a sixth field should
3115    // put `#[non_exhaustive]` on it first, and give it a `Default` so callers
3116    // outside `pdfrum-doc` can still build one.
3117    with_font(ctx, widget, |font, substitute| {
3118        ap::widget::generate_with_live_faces(
3119            &widget.dict,
3120            ctx.catalog,
3121            font,
3122            ctx.resolve,
3123            ap::widget::LiveInput {
3124                caret_and_selection: highlight.as_ref(),
3125                live: live.as_ref(),
3126                substitute,
3127                appearance_state: as_override.map(str::as_bytes),
3128            },
3129        )
3130    })
3131    .flatten()
3132}
3133
3134/// Which annotation holds focus, and what its focus rectangle is.
3135///
3136/// The two halves are independent and both are needed. The **index** decides
3137/// the tint: a widget the form filler is editing is never given the
3138/// form-field highlight, focused or not, and in a single-focus session the
3139/// focused one is the only widget a live control reaches. The **box** decides
3140/// what is stroked in the tint's place, which most field types answer with
3141/// nothing at all.
3142///
3143/// The whole table, by control:
3144///
3145/// | control | focus box |
3146/// |---|---|
3147/// | text field | [`ap::FocusBox::None`] |
3148/// | **any** combo box | [`ap::FocusBox::None`] |
3149/// | multi-select list | its caret row |
3150/// | single-select list, check box, radio | [`ap::FocusBox::Inflated`] |
3151/// | push button | [`ap::FocusBox::Rect`] of the window **deflated by the border** |
3152///
3153/// So "focused" is mostly a *negative* instruction: it suppresses the tint,
3154/// and only three of the five controls stroke anything in its place.
3155// Two rows are easy to get wrong in the same direction, by reaching for the
3156// generic answer where the control overrides it. A combo box gives an empty
3157// rectangle whatever its custom-text flag says, so the editable and gated
3158// cases are one row, not two. And a push button deflates where the generic
3159// answer inflates — the opposite sign on the same number.
3160#[must_use]
3161/// # Examples
3162///
3163/// ```
3164/// # use pdfrum_doc::ap;
3165/// # use pdfrum_form::route::{self, Context};
3166/// # use pdfrum_form::{FormSession, NoScripts, Permissions};
3167/// # use pdfrum_object::{Dict, Name, NoResolve, Object, PdfString};
3168/// # fn dict<const N: usize>(pairs: [(&'static [u8], Object); N]) -> Dict {
3169/// #     Dict::from_pairs(pairs.into_iter().map(|(k, v)| (Name::from(k), v)))
3170/// # }
3171/// # fn nm(b: &'static [u8]) -> Object { Object::Name(Name::from(b)) }
3172/// # fn rect(l: f32, b: f32, r: f32, t: f32) -> Object {
3173/// #     Object::Array([l, b, r, t].into_iter().map(Object::Real).collect())
3174/// # }
3175/// # let helv = dict([(b"Type", nm(b"Font")), (b"Subtype", nm(b"Type1")),
3176/// #     (b"BaseFont", nm(b"Helvetica"))]);
3177/// # let catalog = dict([(b"AcroForm", Object::Dict(dict([
3178/// #     (b"DA", Object::Str(PdfString::literal(b"/Helv 0 Tf 0 g"))),
3179/// #     (b"DR", Object::Dict(dict([(b"Font",
3180/// #         Object::Dict(dict([(b"Helv", Object::Dict(helv))])))]))),
3181/// # ])))]);
3182/// # let widget = dict([(b"Type", nm(b"Annot")), (b"Subtype", nm(b"Widget")),
3183/// #     (b"FT", nm(b"Tx")), (b"T", Object::Str(PdfString::literal(b"Name"))),
3184/// #     (b"V", Object::Str(PdfString::literal(b"old"))),
3185/// #     (b"Rect", rect(20.0, 100.0, 180.0, 130.0)),
3186/// #     (b"DA", Object::Str(PdfString::literal(b"/Helv 12 Tf 0 g")))]);
3187/// # let page_dict = dict([(b"MediaBox", rect(0.0, 0.0, 200.0, 200.0)),
3188/// #     (b"Annots", Object::Array([Object::Dict(widget)].into_iter().collect()))]);
3189/// # let resolve = NoResolve;
3190/// # let page = pdfrum_form::read_page(0, &page_dict, &catalog, &resolve);
3191/// # let mut build = pdfrum_page::BuildContext::new();
3192/// # let fonts = ap::FormFonts::load(&catalog, &resolve, &mut build);
3193/// # let ctx = Context { page: &page, catalog: &catalog, resolve: &resolve,
3194/// #     fonts: &fonts, permissions: Permissions::ALL };
3195/// # let mut session = FormSession::new();
3196/// # let mut cascade = NoScripts;
3197/// // A fresh session holds no focus, so the page renders with none.
3198/// assert!(route::focus_of(&session, &ctx).is_none());
3199/// ```
3200pub fn focus_of<R: Resolve>(session: &FormSession, ctx: &Context<'_, R>) -> Option<ap::Focus> {
3201    let target = session.focus?;
3202    let annot = target.annot();
3203    if annot.page != ctx.page.page {
3204        // Focus belongs to the document, not the page, so a page that does
3205        // not hold it contributes no focus to its own render.
3206        return None;
3207    }
3208    let index = usize::try_from(annot.index).unwrap_or(0);
3209    let Some(field) = target.field() else {
3210        return Some(ap::Focus::at(index));
3211    };
3212    let box_ = match session.fields.get(&field) {
3213        // A text field strokes nothing. A field with no state yet has no
3214        // control to ask, so it strokes nothing either.
3215        Some(FieldState::Text(_)) | None => ap::FocusBox::None,
3216        // A combo box strokes nothing whether it is editable or gated:
3217        // `CPWL_ComboBox::GetFocusRect` returns an empty rectangle
3218        // unconditionally.
3219        Some(FieldState::Choice(choice)) if choice.config.combo => ap::FocusBox::None,
3220        // A single-select list box takes the window rectangle inflated by one
3221        // — the generic `CPWL_Wnd` answer, which it does not override.
3222        Some(FieldState::Choice(choice)) if !choice.config.multi_select => ap::FocusBox::Inflated,
3223        // A multi-select list box strokes its **caret row** rather than its
3224        // own edges, which is why its dashes trace a band inside the widget.
3225        Some(FieldState::Choice(choice)) => caret_row_box(ctx, annot, choice),
3226        // A check box and a radio button take the generic inflation.
3227        Some(FieldState::Toggle(_)) => ap::FocusBox::Inflated,
3228        // A push button deflates by its own border instead.
3229        Some(FieldState::Button(_)) => push_button_box(ctx, annot),
3230    };
3231    Some(ap::Focus { annot: index, box_ })
3232}
3233
3234/// The rectangle a push button strokes: its window, deflated by the border.
3235///
3236/// The deflation is by the border on **each** side — the same `widget_border`
3237/// width `ap::field_body::client_rect` already reads. Unlike every other row
3238/// in the table this is a real rectangle rather than a rule, so it is produced
3239/// in page space, which is what [`ap::FocusBox::Rect`] carries.
3240fn push_button_box<R: Resolve>(ctx: &Context<'_, R>, annot: AnnotId) -> ap::FocusBox {
3241    let Some(widget) = ctx.widget(annot) else {
3242        return ap::FocusBox::None;
3243    };
3244    let width = f64::from(ap::widget::widget_border(&widget.dict, ctx.resolve).width);
3245    let rect = pdfrum_doc::geom::normalize(widget_rect(widget));
3246    // `CFX_FloatRect::GetDeflated` on a box narrower than twice its border
3247    // turns it inside out rather than emptying it, and `GetFocusBox` then
3248    // drops it for not being inside the page. Normalizing keeps the same
3249    // answer without a second rule.
3250    ap::FocusBox::Rect(pdfrum_doc::geom::normalize(kurbo::Rect::new(
3251        rect.x0 + width,
3252        rect.y0 + width,
3253        rect.x1 - width,
3254        rect.y1 - width,
3255    )))
3256}
3257
3258/// The rectangle a multi-select list box strokes: its caret row, clipped to
3259/// the client area.
3260fn caret_row_box<R: Resolve>(
3261    ctx: &Context<'_, R>,
3262    annot: AnnotId,
3263    choice: &ChoiceState,
3264) -> ap::FocusBox {
3265    let Some(widget) = ctx.widget(annot) else {
3266        return ap::FocusBox::None;
3267    };
3268    let Some(caret) = choice.caret_index else {
3269        return ap::FocusBox::None;
3270    };
3271    let client = ap::field_body::client_rect(&widget.dict, ctx.resolve);
3272    let height = f64::from(row_height(ctx, widget, choice));
3273    if height <= 0.0 {
3274        return ap::FocusBox::None;
3275    }
3276    // Rows are drawn from the top down, starting at the first visible one.
3277    let Some(offset) = caret.checked_sub(choice.top_visible) else {
3278        return ap::FocusBox::None;
3279    };
3280    #[expect(
3281        clippy::cast_precision_loss,
3282        reason = "a row offset is bounded by the option count, which a file \
3283                  cannot make large enough to lose a mantissa bit"
3284    )]
3285    let top = client.y1 - height * offset as f64;
3286    let bottom = top - height;
3287    // Clipped to the client area, so a caret scrolled out of view strokes
3288    // nothing rather than a band outside the widget.
3289    if bottom >= client.y1 || top <= client.y0 {
3290        return ap::FocusBox::None;
3291    }
3292    // `client_rect` is in the appearance stream's own space — `rotated_rect`
3293    // puts the box at the origin — and a focus box is in **page** space, so
3294    // the widget's own corner is added back. Skipping this strokes a band at
3295    // the foot of the page, which is where the widget would be if its
3296    // rectangle started at zero.
3297    let origin = pdfrum_doc::geom::normalize(widget_rect(widget));
3298    ap::FocusBox::Rect(kurbo::Rect::new(
3299        client.x0 + origin.x0,
3300        bottom.max(client.y0) + origin.y0,
3301        client.x1 + origin.x0,
3302        top.min(client.y1) + origin.y0,
3303    ))
3304}
3305
3306/// A widget's `/Rect` as `kurbo` sees it.
3307fn widget_rect(widget: &WidgetInfo) -> kurbo::Rect {
3308    kurbo::Rect::new(
3309        f64::from(widget.rect.left),
3310        f64::from(widget.rect.bottom),
3311        f64::from(widget.rect.right),
3312        f64::from(widget.rect.top),
3313    )
3314}
3315
3316/// The rows a choice field has selected, as the generator wants them.
3317///
3318/// Materialized separately because [`ap::field_body::LiveState`] borrows the
3319/// slice, so it cannot own one built inside its own constructor.
3320fn selected_rows(state: &FieldState) -> Vec<usize> {
3321    match state {
3322        FieldState::Choice(choice) => choice.selected.iter().copied().collect(),
3323        FieldState::Text(_) | FieldState::Toggle(_) | FieldState::Button(_) => Vec::new(),
3324    }
3325}
3326
3327/// What the session is showing, in place of what the file stores.
3328fn live_state<'a>(
3329    state: &'a FieldState,
3330    selected: &'a [usize],
3331    display: Option<&'a str>,
3332) -> Option<ap::field_body::LiveState<'a>> {
3333    match state {
3334        FieldState::Text(text) => Some(ap::field_body::LiveState {
3335            // `pEdit->SetText(sValue.value_or(pField->GetValue()))` — one
3336            // line, and the whole of what a format script changes
3337            // (`fpdfsdk/cpdfsdk_appstream.cpp:1752`). The formatted string is
3338            // drawn and never stored, which is why the caret still edits the
3339            // raw value the moment this field takes focus.
3340            text: display.unwrap_or(&text.edit.text),
3341            scroll: text.edit.scroll,
3342            ..ap::field_body::LiveState::default()
3343        }),
3344        FieldState::Choice(choice) => Some(ap::field_body::LiveState {
3345            // An editable combo shows what has been typed into it; every
3346            // other choice field shows the row it has selected, which the
3347            // generator resolves from `selected` rather than from text.
3348            //
3349            // A format script's answer overrides the typed text and nothing
3350            // else: `SetAsComboBox(sValue)` is the combo half of the same
3351            // `ResetAppearance` optional, and a list box is passed
3352            // `std::nullopt` unconditionally
3353            // (`cpdfsdk_interactiveform.cpp:611`).
3354            text: match (display, choice.config.editable) {
3355                (Some(display), true) => display,
3356                (_, true) => &choice.edit_text,
3357                (_, false) => "",
3358            },
3359            selected,
3360            top_visible: choice.top_visible,
3361            scroll: (0.0, 0.0),
3362        }),
3363        // A toggle's appearance is its `/AS` state, not a body, and a push
3364        // button's caption never changes.
3365        FieldState::Toggle(_) | FieldState::Button(_) => None,
3366    }
3367}
3368
3369/// The caret or selection bands a focused field draws.
3370fn highlight_of<R: Resolve>(
3371    ctx: &Context<'_, R>,
3372    widget: &WidgetInfo,
3373    state: &FieldState,
3374) -> Option<ap::field_body::Highlight> {
3375    match state {
3376        FieldState::Text(text) => {
3377            let plate = ap::field_body::client_rect(&widget.dict, ctx.resolve);
3378            let config = text_config(ctx, widget, plate, &text.config);
3379            with_font(ctx, widget, |font, _substitute| {
3380                ops::highlight(
3381                    &text.edit,
3382                    &config,
3383                    &font.metrics,
3384                    ap::field_body::CARET_WIDTH,
3385                )
3386            })
3387        }
3388        // **An editable combo box has a caret and a selection band too**, and
3389        // for the same reason a text field does: its text half *is* a
3390        // `CPWL_Edit` (`cpwl_combo_box.cpp:190-203`), read-only only when the
3391        // box is gated (`:115-118`). Choosing a row leaves that edit holding
3392        // the row's label with everything selected, which is what draws the
3393        // navy band under `bug_736695_3`'s `Spain`; clicking into an empty
3394        // one leaves a caret, which is the 24-pixel bar at columns 166-167 of
3395        // `bug_736695_2`'s golden.
3396        //
3397        // A **gated** combo answers nothing: its edit is read-only and shows
3398        // neither, which is `CPWL_Edit::GetFocusRect` returning empty for
3399        // every combo and `SetCaret` forcing the caret invisible on one that
3400        // is not focused in its own right.
3401        FieldState::Choice(choice) if choice.config.editable => {
3402            let edit = choice.edit.as_deref()?;
3403            let client = ap::field_body::client_rect(&widget.dict, ctx.resolve);
3404            // The text sits left of the drop button, which is the plate
3405            // `with_combo_edit` laid it out in — the band has to be measured
3406            // in the same box or it lands a button's width off.
3407            let plate = kurbo::Rect::new(
3408                client.x0,
3409                client.y0,
3410                client.x1 - f64::from(DROP_BUTTON_WIDTH),
3411                client.y1,
3412            );
3413            let config = vt::Config {
3414                plate,
3415                font_size: font_size(ctx, widget),
3416                ..vt::Config::default()
3417            };
3418            with_font(ctx, widget, |font, _substitute| {
3419                ops::highlight(edit, &config, &font.metrics, ap::field_body::CARET_WIDTH)
3420            })
3421        }
3422        FieldState::Choice(_) | FieldState::Toggle(_) | FieldState::Button(_) => None,
3423    }
3424}
3425
3426#[cfg(test)]
3427mod tests {
3428    use super::*;
3429    use pdfrum_object::{Name, NoResolve, Object, PdfString};
3430
3431    /// A page carrying one `/Tx` widget whose `/DA` names `/Arial`, under a
3432    /// catalog whose `/AcroForm /DR /Font` declares that name as a bare
3433    /// non-embedded TrueType — `form_textfield_focused_ltr`'s shape, and the
3434    /// one that makes the substitution question arise at all.
3435    fn page_and_catalog() -> (Dict, Dict) {
3436        let font = Dict::from_pairs([
3437            (
3438                pdfrum_object::names::TYPE.clone(),
3439                Object::Name(Name::from_static(b"Font").clone()),
3440            ),
3441            (
3442                pdfrum_object::names::SUBTYPE.clone(),
3443                Object::Name(Name::from_static(b"TrueType").clone()),
3444            ),
3445            (
3446                Name::from_static(b"BaseFont").clone(),
3447                Object::Name(Name::from_static(b"Arial").clone()),
3448            ),
3449        ]);
3450        let catalog = Dict::from_pairs([(
3451            Name::from_static(b"AcroForm").clone(),
3452            Object::Dict(Dict::from_pairs([(
3453                Name::from_static(b"DR").clone(),
3454                Object::Dict(Dict::from_pairs([(
3455                    Name::from_static(b"Font").clone(),
3456                    Object::Dict(Dict::from_pairs([(
3457                        Name::from_static(b"Arial").clone(),
3458                        Object::Dict(font),
3459                    )])),
3460                )])),
3461            )])),
3462        )]);
3463        let widget = Dict::from_pairs([
3464            (
3465                pdfrum_object::names::TYPE.clone(),
3466                Object::Name(Name::from_static(b"Annot").clone()),
3467            ),
3468            (
3469                pdfrum_object::names::SUBTYPE.clone(),
3470                Object::Name(Name::from_static(b"Widget").clone()),
3471            ),
3472            (
3473                Name::from_static(b"FT").clone(),
3474                Object::Name(Name::from_static(b"Tx").clone()),
3475            ),
3476            (
3477                Name::from_static(b"T").clone(),
3478                Object::Str(PdfString::literal(*b"Text Box")),
3479            ),
3480            (
3481                Name::from_static(b"Rect").clone(),
3482                Object::Array(
3483                    [
3484                        Object::Int(50),
3485                        Object::Int(40),
3486                        Object::Int(150),
3487                        Object::Int(70),
3488                    ]
3489                    .into_iter()
3490                    .collect(),
3491                ),
3492            ),
3493            (
3494                Name::from_static(b"DA").clone(),
3495                Object::Str(PdfString::literal(*b"/Arial 12 Tf 0 0 0 rg")),
3496            ),
3497        ]);
3498        let page = Dict::from_pairs([(
3499            Name::from_static(b"Annots").clone(),
3500            Object::Array([Object::Dict(widget)].into_iter().collect()),
3501        )]);
3502        (page, catalog)
3503    }
3504
3505    /// The width closure `with_font` hands the layout **measures a character
3506    /// the `/DA` font cannot write in the second face**, not in the `/DA`
3507    /// font's fallback.
3508    ///
3509    /// # The defect this pins, which the appearance-stream test cannot
3510    ///
3511    /// Reading the emitted stream for `/_B1` catches a regression in the
3512    /// *encoder* and misses one in the **widths**: a version that puts the
3513    /// substitute on `LiveInput` and leaves the width closure on the `/DA`
3514    /// font still writes `/_B1` and still emits the right bytes, while every
3515    /// advance, caret column and selection band comes out of the wrong table.
3516    /// That was a real regression: a Hebrew selection band ended at device
3517    /// column 101 with its glyphs running to 111, ten columns of dark where
3518    /// the oracle's are white, because Latin advances were measuring a Hebrew
3519    /// run.
3520    ///
3521    /// Layout and encoding share one per-character face index, so a character
3522    /// written in the second face is measured in it too.
3523    ///
3524    /// The numbers are the two faces' own and are asserted as a **relation**
3525    /// rather than as constants: which face stands in for `/Arial` depends on
3526    /// the substitution options, and the claim is that the two differ and
3527    /// that the closure takes the substitute's.
3528    #[test]
3529    fn the_width_closure_measures_an_unwritable_character_in_the_second_face() {
3530        // Bet, which an Ansi `/DA` font cannot write.
3531        const BET: u32 = 0x05D1;
3532
3533        let (page, catalog) = page_and_catalog();
3534        let resolve = NoResolve;
3535        let form = crate::page::read(0, &page, &catalog, &resolve);
3536        let widget = form.widgets.first().expect("one /Tx widget");
3537
3538        let mut build = pdfrum_page::BuildContext::new();
3539        let fonts = ap::FormFonts::load(&catalog, &resolve, &mut build);
3540        let ctx = Context {
3541            page: &form,
3542            catalog: &catalog,
3543            resolve: &resolve,
3544            fonts: &fonts,
3545            permissions: hit::Permissions::ALL,
3546        };
3547
3548        let da = fonts.face(b"Arial").expect("the /DR declares one face");
3549        let substitute = fonts
3550            .substitute(pdfrum_font::Charset::Hebrew)
3551            .expect("the Hebrew second face loads with no font directory at all");
3552        assert!(
3553            !ap::font_map::da_font_writes(da, ap::font_map::font_charset(da), BET),
3554            "the fixture's own font must be unable to write the character, \
3555             or the substitution never arises"
3556        );
3557
3558        let (da_width, substitute_width) = (
3559            TextFont::char_width(da, BET),
3560            ap::font_map::substitute_width(substitute.font, BET),
3561        );
3562        assert_ne!(
3563            da_width, substitute_width,
3564            "the two faces must disagree, or this test cannot tell them apart"
3565        );
3566
3567        let measured = with_font(&ctx, widget, |font, _substitute| {
3568            (
3569                (font.metrics.width)(BET),
3570                (font.metrics.width)(u32::from(b'a')),
3571            )
3572        })
3573        .expect("the widget's /DA resolves to a face");
3574
3575        assert_eq!(
3576            measured.0, substitute_width,
3577            "a character the /DA font cannot write is measured in the second face"
3578        );
3579        assert_eq!(
3580            measured.1,
3581            TextFont::char_width(da, u32::from(b'a')),
3582            "and one it can is still measured in the /DA font"
3583        );
3584    }
3585}