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