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