pdfrum_form/popup.rs
1//! The open combo-box dropdown, as **state and geometry** rather than as a
2//! window.
3//!
4//! A dropdown is drawn *outside* the widget's `/Rect`, so it cannot live in
5//! the widget's appearance stream: a form's `/AP` is mapped onto its `/Rect`,
6//! and folding a taller list into it would scale the widget's body rather
7//! than overflow below it. This module publishes what a host needs to paint
8//! the list as a second object — where it would be, what is in it, which row
9//! is selected or hovered, and how tall a row is. The reverse direction is
10//! [`crate::route`]'s `choose` and `close_popup`.
11
12use crate::field::ChoiceState;
13use crate::session::AnnotId;
14use crate::tab::Rect;
15
16/// The list's border, in PDF units, on every side.
17///
18/// A fixed solid 1 unit whatever the widget's own `/MK /BW` says: the dropdown
19/// is chrome the viewer draws, not something the file describes.
20pub const LIST_BORDER: f32 = 1.0;
21
22/// The tallest a dropdown is allowed to grow, in PDF units.
23pub const MAX_LIST_HEIGHT: f32 = 140.0;
24
25/// How many rows the minimum popup shows, and the count above which that
26/// minimum applies at all.
27///
28/// A list of **more than three** options may not be clamped below three rows
29/// plus its border; a list of three or fewer has no floor and may be squeezed
30/// to nothing.
31pub const MIN_POPUP_ROWS: usize = 3;
32
33/// Which side of the widget the list opens on.
34///
35/// Picked by room: below when the space under the widget can hold the whole
36/// list, above when it cannot but the space over it can, and otherwise
37/// whichever side is larger.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Placement {
40 /// The list hangs below the widget, growing downward from its bottom
41 /// edge. `bBottom == true`.
42 Below,
43 /// The list rises above the widget, growing upward from its top edge.
44 /// `bBottom == false`.
45 Above,
46}
47
48/// Where an open dropdown sits and how tall its rows are.
49///
50/// Pure geometry, in **page space** (PDF user space, y-up) — the same space
51/// the widget's `/Rect` is written in and the same space an [`crate::Event`]
52/// arrives in, so a host can hit-test a click against [`Self::rect`] without
53/// a transform.
54#[derive(Debug, Clone, Copy, PartialEq)]
55pub struct PopupGeometry {
56 /// The whole list window, border included, in page space.
57 ///
58 /// A [`kurbo::Rect`] on the way *out*: this is geometry a host paints and
59 /// hit-tests against, in the same vocabulary `Page::crop_box` speaks. The
60 /// values inside are the crate's `f32` widened losslessly — the placement
61 /// arithmetic below is `f32` throughout, and stays that way.
62 pub rect: kurbo::Rect,
63 /// Which side of the widget it opened on.
64 pub placement: Placement,
65 /// The height of one row, which is the **laid-out** line height and not
66 /// the font size — see [`crate::route`]'s `row_height`.
67 pub row_height: f32,
68}
69
70/// This crate's `f32` rectangle widened for a caller. Lossless.
71pub(crate) fn widen(rect: Rect) -> kurbo::Rect {
72 kurbo::Rect::new(
73 f64::from(rect.left),
74 f64::from(rect.bottom),
75 f64::from(rect.right),
76 f64::from(rect.top),
77 )
78}
79
80impl PopupGeometry {
81 /// The window as this crate's `f32` rectangle.
82 ///
83 /// The inverse of [`widen`] over every value [`PopupGeometry::rect`] can
84 /// hold, because every one of them was widened from an `f32` here.
85 #[expect(
86 clippy::cast_possible_truncation,
87 reason = "every value in `rect` was widened from an f32 by `widen`"
88 )]
89 fn narrow(&self) -> Rect {
90 Rect::new(
91 self.rect.x0 as f32,
92 self.rect.y0 as f32,
93 self.rect.x1 as f32,
94 self.rect.y1 as f32,
95 )
96 }
97
98 /// The area the rows are drawn in: the window deflated by its border.
99 ///
100 /// This is what row rectangles are measured against, and a visible scroll
101 /// bar's width is **not** subtracted from it — the rows keep their full
102 /// width behind one.
103 #[must_use]
104 pub fn plate(&self) -> kurbo::Rect {
105 widen(self.plate_f32())
106 }
107
108 /// [`PopupGeometry::plate`] in the crate's own `f32`, which is what the
109 /// row arithmetic below measures against.
110 pub(crate) fn plate_f32(&self) -> Rect {
111 let border = LIST_BORDER;
112 let rect = self.narrow();
113 // `GetDeflated` on a rectangle narrower than twice its border would
114 // turn it inside out. Upstream's `CPWL_Wnd::GetClientRect` guards the
115 // same case with `rcWindow.Contains(rcClient)`, answering an empty
116 // rectangle when the deflation escaped the window.
117 if rect.right - rect.left <= border * 2.0 || rect.top - rect.bottom <= border * 2.0 {
118 return Rect::new(rect.left, rect.bottom, rect.left, rect.bottom);
119 }
120 Rect::new(
121 rect.left + border,
122 rect.bottom + border,
123 rect.right - border,
124 rect.top - border,
125 )
126 }
127
128 /// The rectangle of one visible row, counting `offset` rows down from the
129 /// first one showing.
130 ///
131 /// Rows stack downward from the plate's top by exactly one row height
132 /// each. A row past the bottom of the plate still has a rectangle — the
133 /// caller clips.
134 #[must_use]
135 pub fn row_rect(&self, offset: usize) -> kurbo::Rect {
136 let plate = self.plate_f32();
137 // The product is computed in `f64` and narrowed once, so an absurd
138 // offset saturates to an off-plate rectangle rather than wrapping.
139 #[expect(
140 clippy::cast_precision_loss,
141 reason = "a row offset past 2^53 has no rectangle worth naming"
142 )]
143 let down = f64::from(self.row_height) * offset as f64;
144 #[expect(
145 clippy::cast_possible_truncation,
146 reason = "narrowed once, after the multiply, so the clamp is the f32 range"
147 )]
148 let top = plate.top - down as f32;
149 widen(Rect::new(
150 plate.left,
151 top - self.row_height,
152 plate.right,
153 top,
154 ))
155 }
156
157 /// Which visible row a page-space point falls on, or [`None`] when the
158 /// point is outside the plate.
159 ///
160 /// The offset is from the first visible row, so a caller adds
161 /// [`ChoiceState::top_visible`] to get an option index.
162 pub(crate) fn row_at(&self, x: f32, y: f32) -> Option<usize> {
163 let plate = self.plate_f32();
164 if x < plate.left || x > plate.right || y < plate.bottom || y > plate.top {
165 return None;
166 }
167 if self.row_height <= 0.0 {
168 return None;
169 }
170 let offset = ((plate.top - y) / self.row_height).floor();
171 if !offset.is_finite() || offset < 0.0 {
172 return None;
173 }
174 // Bounded by the plate test above: `plate.top - y` is at most the
175 // plate's height, so the quotient is at most the visible row count.
176 #[expect(
177 clippy::cast_possible_truncation,
178 clippy::cast_sign_loss,
179 reason = "non-negative and bounded by the plate height over the row height"
180 )]
181 let offset = offset as usize;
182 Some(offset)
183 }
184
185 /// How many rows fit in the plate, whole and partial alike.
186 ///
187 /// A partial row at the bottom is still drawn — any item overlapping the
188 /// plate is kept — so a viewer counting rows to paint wants the rounded-up
189 /// count, not the floor.
190 #[must_use]
191 pub fn visible_rows(&self) -> usize {
192 if self.row_height <= 0.0 {
193 return 0;
194 }
195 let plate = self.plate_f32();
196 let rows = ((plate.top - plate.bottom) / self.row_height).ceil();
197 if !rows.is_finite() || rows <= 0.0 {
198 return 0;
199 }
200 #[expect(
201 clippy::cast_possible_truncation,
202 clippy::cast_sign_loss,
203 reason = "a row count is bounded by the popup's height in points"
204 )]
205 let rows = rows as usize;
206 rows
207 }
208}
209
210/// Everything a host needs to draw one open dropdown.
211///
212/// Returned by [`crate::route::popup_view`].
213///
214/// **Owned, not borrowed from the session.** A per-page entry point assembles
215/// a borrowed [`crate::route::Context`], drives the body and drops it, so a
216/// view that borrowed the session could not outlive the call. The cost is a
217/// `Vec<String>` per query, on a query a host makes once per render of a page
218/// that has a dropdown open.
219#[derive(Debug, Clone, PartialEq)]
220pub struct PopupView {
221 /// Which widget the list belongs to, by **raw** `/Annots` index — the
222 /// same key space appearance updates and the annotation overlay use.
223 pub annot: AnnotId,
224 /// The widget's own `/Rect`, page space: the anchor the list hangs from.
225 pub anchor: kurbo::Rect,
226 /// Where the list is and how tall its rows are.
227 pub geometry: PopupGeometry,
228 /// The rows' labels, in option order.
229 ///
230 /// Labels and not values: a list shows [`crate::field::ChoiceOption::label`],
231 /// while `value` is what the field *stores* when they differ, and drawing
232 /// the latter would put the wrong string on the page for every `/Opt`
233 /// entry written as a two-element array.
234 pub options: Vec<String>,
235 /// Which option is selected, if any.
236 ///
237 /// A combo box selects at most one — `field::choice::select_only` is the
238 /// only path a click into the list takes — so this is an index and not a
239 /// set.
240 pub selected: Option<usize>,
241 /// Which option the pointer is over, if any.
242 ///
243 /// Hovering a row *selects* it rather than merely tinting it. This
244 /// reports the pointer's row so a host can paint the band before the click
245 /// lands.
246 pub hovered: Option<usize>,
247 /// The first option currently showing, for a list taller than the popup.
248 pub top_visible: usize,
249 /// An **editable** combo's text half, which is a typed string rather than
250 /// an option's label. [`None`] for a gated combo, which has no text half.
251 pub edit_text: Option<String>,
252}
253
254impl PopupView {
255 /// The label of one visible row, counting `offset` rows down from the
256 /// first one showing.
257 ///
258 /// The pairing for [`PopupGeometry::row_rect`]: the two take the same
259 /// offset, so a painter walks `0..visible_rows()` asking each for its
260 /// rectangle and its text without doing the `top_visible` arithmetic
261 /// itself.
262 #[must_use]
263 pub fn label_at(&self, offset: usize) -> Option<&str> {
264 let index = self.top_visible.checked_add(offset)?;
265 self.options.get(index).map(String::as_str)
266 }
267
268 /// Whether the row at `offset` visible rows down is the selected one.
269 ///
270 /// The band drawn navy with white text. **Hover counts**: the row under
271 /// the pointer is selected as far as the drawing is concerned, even though
272 /// the field's stored value has not moved.
273 #[must_use]
274 pub fn is_banded(&self, offset: usize) -> bool {
275 let Some(index) = self.top_visible.checked_add(offset) else {
276 return false;
277 };
278 self.hovered == Some(index) || (self.hovered.is_none() && self.selected == Some(index))
279 }
280}
281
282/// How far a scrollable control has scrolled, in rows.
283///
284/// A second value getter beside [`PopupView`], for the same reason: a scroll
285/// bar is host chrome the library merely *knows about*. It answers for a
286/// scrolling **list box** as well as for an open dropdown, which is why it is
287/// keyed by annotation rather than carried on the popup.
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289pub struct ScrollView {
290 /// The first row showing.
291 pub top_visible: usize,
292 /// How many rows the box can show at once.
293 pub visible_rows: usize,
294 /// How many rows there are altogether.
295 pub total: usize,
296}
297
298impl ScrollView {
299 /// Whether a scroll bar would be drawn at all.
300 ///
301 /// The bar is hidden whenever the plate is at least as tall as the
302 /// content, which is exactly "every row fits".
303 #[must_use]
304 pub fn is_scrollable(&self) -> bool {
305 self.total > self.visible_rows
306 }
307}
308
309/// Where a list of `rows` rows would open from a widget whose `/Rect` is
310/// `anchor` on a page `page` units tall, and how tall it would be.
311///
312/// The clamp and the side choice, as one function over numbers:
313///
314/// 1. the list's content is `rows * row_height`, and the window it wants is
315/// that plus a border on each side;
316/// 2. the **floor** is three rows plus the border, but only when there are
317/// more than three options — a shorter list has no floor;
318/// 3. `kMaxListBoxHeight` (140) is clamped into `[floor, wanted]`, so a list
319/// shorter than 140 units asks for its own height and a longer one asks
320/// for 140 — unless the floor pushes back above it;
321/// 4. the side is chosen by room: below if the space under the widget can
322/// hold that height, else above if the space over it can, else whichever
323/// side is larger — and the height becomes that side's room.
324///
325/// Returns [`None`] when the list would have no height at all — a zero-height
326/// content rectangle, or a chosen side with non-positive room. Both are
327/// **refusals to open**: the combo stays closed and the click is still
328/// consumed.
329pub(crate) fn place(
330 anchor: Rect,
331 page_height: f32,
332 rows: usize,
333 row_height: f32,
334) -> Option<PopupGeometry> {
335 if row_height <= 0.0 || rows == 0 {
336 return None;
337 }
338 let border = LIST_BORDER * 2.0;
339 // `list_->GetContentRect().Height()`, the stacked items' total.
340 #[expect(
341 clippy::cast_precision_loss,
342 reason = "an option count past 2^24 has already exceeded any page"
343 )]
344 let content = row_height * rows as f32;
345 if content <= 0.0 {
346 return None;
347 }
348 let floor = if rows > MIN_POPUP_ROWS {
349 row_height * 3.0 + border
350 } else {
351 0.0
352 };
353 let ceiling = content + border;
354 // `std::clamp(kMaxListBoxHeight, fPopupMin, fPopupMax)`. Upstream's
355 // argument order means the **minimum wins** when the two cross, which is
356 // how a four-row list of very tall rows can still be asked to open
357 // taller than 140.
358 let wanted = MAX_LIST_HEIGHT.max(floor).min(ceiling.max(floor));
359
360 // `rcPageView(0, height, width, 0)` normalized, against the widget's
361 // rect: the room above the widget's top and below its bottom. The C++
362 // measures against the page's *display* size from the origin and not
363 // against a crop box that starts elsewhere, so this does too.
364 let above = page_height - anchor.top;
365 let below = anchor.bottom;
366
367 let (placement, height) = if below > wanted {
368 (Placement::Below, wanted)
369 } else if above > wanted {
370 (Placement::Above, wanted)
371 } else if above > below {
372 (Placement::Above, above)
373 } else {
374 (Placement::Below, below)
375 };
376 if height <= 0.0 {
377 return None;
378 }
379
380 // `RepositionChildWnd` (`cpwl_combo_box.cpp:238-283`): the window grows
381 // by `fPopupRet` on the chosen side, and the list child takes the grown
382 // part — so the list's near edge is the widget's own edge, exactly, and
383 // the two never overlap.
384 let rect = match placement {
385 Placement::Below => Rect::new(
386 anchor.left,
387 anchor.bottom - height,
388 anchor.right,
389 anchor.bottom,
390 ),
391 Placement::Above => Rect::new(anchor.left, anchor.top, anchor.right, anchor.top + height),
392 };
393 Some(PopupGeometry {
394 rect: widen(rect),
395 placement,
396 row_height,
397 })
398}
399
400/// The option a click at `offset` visible rows down selects, if the list has
401/// one there.
402#[must_use]
403pub fn option_at(state: &ChoiceState, offset: usize) -> Option<usize> {
404 let index = state.top_visible.checked_add(offset)?;
405 (index < state.options.len()).then_some(index)
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 /// `bug_736695_2`: a two-option combo at `/Rect [165.7 315.9 315.7 330.1]`
413 /// on a 342-unit page. There is far more room below (315.9) than above
414 /// (11.9), and two rows of 13.392 plus a 2-unit border is 28.784 — well
415 /// under both `kMaxListBoxHeight` and the room, so the list opens
416 /// downward at its own height.
417 #[test]
418 fn two_options_open_downward_at_their_own_height() {
419 let anchor = Rect::new(165.7, 315.9, 315.7, 330.1);
420 let popup = place(anchor, 342.0, 2, 13.392).expect("a two-row list fits");
421 assert_eq!(popup.placement, Placement::Below);
422 assert!((popup.rect.y1 - 315.9).abs() < 1e-4, "{:?}", popup.rect);
423 assert!(
424 ((popup.rect.y1 - popup.rect.y0) - 28.784).abs() < 1e-3,
425 "{:?}",
426 popup.rect
427 );
428 assert!((popup.rect.x0 - 165.7).abs() < 1e-4);
429 assert!((popup.rect.x1 - 315.7).abs() < 1e-4);
430 }
431
432 /// `bug_1372651`: three options at `/Rect [70 135 150 155]` on a
433 /// 200-unit page. 135 below, 45 above; three rows plus border is 42.176,
434 /// which fits below.
435 #[test]
436 fn three_options_open_downward() {
437 let anchor = Rect::new(70.0, 135.0, 150.0, 155.0);
438 let popup = place(anchor, 200.0, 3, 13.392).expect("a three-row list fits");
439 assert_eq!(popup.placement, Placement::Below);
440 assert!(
441 ((popup.rect.y1 - popup.rect.y0) - 42.176).abs() < 1e-3,
442 "{:?}",
443 popup.rect
444 );
445 assert!((popup.rect.y1 - 135.0).abs() < 1e-4);
446 }
447
448 /// A widget near the **bottom** of the page has no room under it, so the
449 /// list rises instead — the `bBottom == false` branch.
450 #[test]
451 fn no_room_below_opens_upward() {
452 let anchor = Rect::new(10.0, 4.0, 100.0, 24.0);
453 let popup = place(anchor, 200.0, 2, 13.392).expect("there is room above");
454 assert_eq!(popup.placement, Placement::Above);
455 assert!((popup.rect.y0 - 24.0).abs() < 1e-4, "{:?}", popup.rect);
456 assert!(((popup.rect.y1 - popup.rect.y0) - 28.784).abs() < 1e-3);
457 }
458
459 /// Squeezed on both sides, the list takes the larger side's room rather
460 /// than the height it asked for — upstream's last `if`.
461 #[test]
462 fn squeezed_takes_the_larger_side_whole() {
463 // 6 below, 10 above, on a 36-unit page with a 20-unit widget.
464 let anchor = Rect::new(0.0, 6.0, 50.0, 26.0);
465 let popup = place(anchor, 36.0, 2, 13.392).expect("ten units is still a popup");
466 assert_eq!(popup.placement, Placement::Above);
467 assert!(
468 ((popup.rect.y1 - popup.rect.y0) - 10.0).abs() < 1e-4,
469 "{:?}",
470 popup.rect
471 );
472 }
473
474 /// A long list is capped at `kMaxListBoxHeight` even where the page has
475 /// room for all of it.
476 #[test]
477 fn a_long_list_is_capped_at_one_hundred_and_forty() {
478 let anchor = Rect::new(0.0, 400.0, 100.0, 420.0);
479 let popup = place(anchor, 800.0, 40, 13.392).expect("a forty-row list opens");
480 assert!(
481 ((popup.rect.y1 - popup.rect.y0) - 140.0).abs() < 1e-4,
482 "{:?}",
483 popup.rect
484 );
485 }
486
487 /// The three-row floor applies only above three options — and it beats
488 /// the 140 cap when the rows are tall enough, because upstream clamps the
489 /// **constant** into `[min, max]` rather than the other way round.
490 #[test]
491 fn the_three_row_floor_outranks_the_cap() {
492 let anchor = Rect::new(0.0, 400.0, 100.0, 420.0);
493 // Four rows of 60 units: the floor is 182, above the 140 cap.
494 let popup = place(anchor, 800.0, 4, 60.0).expect("a four-row list opens");
495 assert!(
496 ((popup.rect.y1 - popup.rect.y0) - 182.0).abs() < 1e-4,
497 "{:?}",
498 popup.rect
499 );
500 }
501
502 /// Three options of the same tall rows have **no** floor, so the cap
503 /// stands: the boundary is `> 3`, not `>= 3`.
504 #[test]
505 fn three_tall_options_are_still_capped() {
506 let anchor = Rect::new(0.0, 400.0, 100.0, 420.0);
507 let popup = place(anchor, 800.0, 3, 60.0).expect("a three-row list opens");
508 assert!(
509 ((popup.rect.y1 - popup.rect.y0) - 140.0).abs() < 1e-4,
510 "{:?}",
511 popup.rect
512 );
513 }
514
515 /// A widget filling the page has nowhere to open, and `place` refuses
516 /// rather than returning a zero-height rectangle — `SetPopup`'s
517 /// non-positive `fPopupRet` exit.
518 #[test]
519 fn nowhere_to_open_refuses() {
520 let anchor = Rect::new(0.0, 0.0, 100.0, 200.0);
521 assert_eq!(place(anchor, 200.0, 2, 13.392), None);
522 }
523
524 /// An empty list refuses too — the zero-height content rectangle.
525 #[test]
526 fn an_empty_list_refuses() {
527 let anchor = Rect::new(0.0, 100.0, 100.0, 120.0);
528 assert_eq!(place(anchor, 400.0, 0, 13.392), None);
529 assert_eq!(place(anchor, 400.0, 2, 0.0), None);
530 }
531
532 /// The plate is the window less its border, and rows stack down from the
533 /// plate's top.
534 #[test]
535 fn rows_stack_downward_from_the_plate() {
536 let anchor = Rect::new(165.7, 315.9, 315.7, 330.1);
537 let popup = place(anchor, 342.0, 2, 13.392).expect("a two-row list fits");
538 let plate = popup.plate_f32();
539 assert!((plate.top - 314.9).abs() < 1e-4, "{plate:?}");
540 assert!((plate.left - 166.7).abs() < 1e-4);
541 let first = popup.row_rect(0);
542 assert!((first.y1 - 314.9).abs() < 1e-4, "{first:?}");
543 assert!((first.y0 - (314.9 - 13.392)).abs() < 1e-3);
544 let second = popup.row_rect(1);
545 assert!((second.y1 - first.y0).abs() < 1e-6);
546 }
547
548 /// A click inside the plate answers its row; one outside answers none.
549 #[test]
550 fn a_point_finds_its_row() {
551 let anchor = Rect::new(165.7, 315.9, 315.7, 330.1);
552 let popup = place(anchor, 342.0, 2, 13.392).expect("a two-row list fits");
553 // `bug_736695_3` clicks (312, 310), which is the first row.
554 assert_eq!(popup.row_at(312.0, 310.0), Some(0));
555 assert_eq!(popup.row_at(312.0, 295.0), Some(1));
556 // Inside the widget, above the list.
557 assert_eq!(popup.row_at(312.0, 324.0), None);
558 // Below the list.
559 assert_eq!(popup.row_at(312.0, 280.0), None);
560 // Left of it.
561 assert_eq!(popup.row_at(100.0, 310.0), None);
562 }
563
564 /// A scroll view is scrollable exactly when a row does not fit.
565 #[test]
566 fn scroll_view_reports_whether_it_scrolls() {
567 assert!(
568 ScrollView {
569 top_visible: 0,
570 visible_rows: 3,
571 total: 9,
572 }
573 .is_scrollable()
574 );
575 assert!(
576 !ScrollView {
577 top_visible: 0,
578 visible_rows: 9,
579 total: 9,
580 }
581 .is_scrollable()
582 );
583 }
584}