Skip to main content

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}