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}