Skip to main content

pdfrum_form/
tab.rs

1//! Focus traversal order: which annotation Tab moves to next.
2//!
3//! This is **not** the order annotations are drawn or hit-tested in. Those
4//! sort by a layout band and put the focused annotation first or last; this
5//! one reads the page's own `/Tabs` entry and produces a ring that focus walks
6//! and never wraps.
7//!
8//! # Three orders, and only two of them sort anything
9//!
10//! - **Structure** — plain annotation order, with no sorting at all. This is
11//!   the default, and it is what *anything other than* `R` or `C` selects.
12//!   The `S` spelling the specification defines is not special-cased: it
13//!   falls through to the same branch that an absent entry does.
14//! - **Row** — reading order across the page: repeatedly take the topmost
15//!   remaining annotation, then take everything whose vertical centre falls
16//!   strictly inside its band, left to right.
17//! - **Column** — the same idea rotated: leftmost first, banding on
18//!   horizontal centres.
19//!
20//! # Why this terminates and the original does not
21//!
22//! The C++'s row and column loops both contain `if (index < 0) continue;`
23//! inside a `while (!list.empty())` that erases nothing on that path — so a
24//! page whose every remaining annotation has a non-positive top spins
25//! forever. It is not reachable from any file in the corpus, and no test
26//! asserts it, which is why it has survived.
27//!
28//! This banding is a fold that consumes its input: when no candidate is
29//! found, the remainder is appended in index order and the caller is told.
30//! **A library that can hang on input is a bug regardless of what the oracle
31//! does**, and this is the one place in the crate where the behaviour is
32//! deliberately better rather than identical.
33
34// The banding arithmetic mirrors the oracle's bit for bit: sums halved rather
35// than `midpoint`, and comparisons left strict. Both lints below would suggest
36// changes that move annotations between bands.
37#![allow(clippy::manual_midpoint, clippy::float_cmp)]
38
39use crate::session::AnnotId;
40
41/// A rectangle in page space, as a focusable annotation's `/Rect`, in this
42/// crate's own `f32`.
43///
44/// The raw rectangle, deliberately: the ring is built from `/Rect` and not
45/// from the inflated box a focused widget draws into.
46///
47/// **Private.** The public vocabulary is [`kurbo::Rect`]; `page::to_rect`
48/// narrows into this one on the way in and `PopupGeometry` widens back out
49/// on the way out. It stays `f32` because [`Rect::center_y`]'s banding
50/// midpoint is compared *strictly* against other `f32` edges: widening it
51/// would move annotations between bands, and it is fed only by widget
52/// `/Rect`s, never by an event point.
53#[derive(Debug, Clone, Copy, PartialEq)]
54pub(crate) struct Rect {
55    /// Left edge.
56    pub(crate) left: f32,
57    /// Bottom edge.
58    pub(crate) bottom: f32,
59    /// Right edge.
60    pub(crate) right: f32,
61    /// Top edge.
62    pub(crate) top: f32,
63}
64
65impl Rect {
66    /// A rectangle from its four edges.
67    pub(crate) fn new(left: f32, bottom: f32, right: f32, top: f32) -> Rect {
68        Rect {
69            left,
70            bottom,
71            right,
72            top,
73        }
74    }
75
76    /// The vertical midpoint, which row banding tests.
77    ///
78    /// Written as the oracle writes it — sum then halve — rather than as
79    /// `f32::midpoint`, which rounds differently at the extremes. The banding
80    /// comparisons are strict, so a midpoint that differs in the last bit
81    /// moves an annotation between bands.
82    pub(crate) fn center_y(self) -> f32 {
83        (self.top + self.bottom) / 2.0
84    }
85
86    /// The horizontal midpoint, which column banding tests.
87    ///
88    /// Sum then halve, for the reason [`Rect::center_y`] gives.
89    pub(crate) fn center_x(self) -> f32 {
90        (self.left + self.right) / 2.0
91    }
92}
93
94/// Which traversal order a page asks for.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
96pub enum TabOrder {
97    /// Annotation order, unsorted. The default, and what any unrecognized
98    /// `/Tabs` value means.
99    #[default]
100    Structure,
101    /// Reading order across rows.
102    Row,
103    /// Reading order down columns.
104    Column,
105}
106
107impl TabOrder {
108    /// Reads the order from a page's `/Tabs` value.
109    ///
110    /// Only `R` and `C` are recognized. Everything else — including the `S`
111    /// the specification defines for structure order, and an absent entry —
112    /// is structure order, because that is the branch the oracle falls
113    /// through to.
114    #[must_use]
115    pub fn from_tabs(tabs: Option<&[u8]>) -> TabOrder {
116        match tabs {
117            Some(b"R") => TabOrder::Row,
118            Some(b"C") => TabOrder::Column,
119            _ => TabOrder::Structure,
120        }
121    }
122}
123
124/// One annotation eligible for focus.
125///
126/// The `id` carries a **raw** `/Annots` index, pop-ups counted — see
127/// [`AnnotId`]. Unlike hit testing, pop-ups never appear here at all: they
128/// are not a focusable subtype. That makes it tempting to number the ring
129/// from its own positions, and it would be wrong for the same reason —
130/// whatever the ring hands back is used to key an appearance.
131#[derive(Debug, Clone, Copy, PartialEq)]
132pub(crate) struct Focusable {
133    /// Which annotation, by its raw `/Annots` index.
134    pub(crate) id: AnnotId,
135    /// Its rectangle, from the raw `/Rect` rather than a focused widget's
136    /// inflated box.
137    pub(crate) rect: Rect,
138}
139
140/// The focus ring for one page, in traversal order.
141///
142/// `degenerate` reports that the banding could not make progress and the
143/// remainder was appended in index order — the recovery that replaces the
144/// oracle's hang.
145#[derive(Debug, Clone, PartialEq)]
146pub struct FocusRing {
147    /// The annotations, in traversal order.
148    pub order: Vec<AnnotId>,
149    /// Whether the banding degenerated.
150    pub degenerate: bool,
151}
152
153impl FocusRing {
154    /// Builds the ring for a page.
155    ///
156    /// `annots` arrives in annotation order and must already be filtered to
157    /// the focusable subtypes — signature widgets excluded, which the caller
158    /// does because only it knows which widgets are signatures.
159    pub(crate) fn build(annots: &[Focusable], order: TabOrder) -> FocusRing {
160        match order {
161            TabOrder::Structure => FocusRing {
162                order: annots.iter().map(|a| a.id).collect(),
163                degenerate: false,
164            },
165            TabOrder::Row => band(annots, Axis::Row),
166            TabOrder::Column => band(annots, Axis::Column),
167        }
168    }
169
170    /// The annotation after `current`, or `None` at the end.
171    ///
172    /// Focus does not wrap: the last annotation has no next, which is what
173    /// makes a fifth Tab across four fields report the event unconsumed.
174    #[must_use]
175    pub fn next(&self, current: AnnotId) -> Option<AnnotId> {
176        let at = self.order.iter().position(|a| *a == current)?;
177        self.order.get(at + 1).copied()
178    }
179
180    /// The annotation before `current`, or `None` at the start.
181    #[must_use]
182    pub fn prev(&self, current: AnnotId) -> Option<AnnotId> {
183        let at = self.order.iter().position(|a| *a == current)?;
184        self.order.get(at.checked_sub(1)?).copied()
185    }
186
187    /// The first annotation, which a forward Tab from nothing lands on.
188    #[must_use]
189    pub fn first(&self) -> Option<AnnotId> {
190        self.order.first().copied()
191    }
192
193    /// The last annotation, which a backward Tab from nothing lands on.
194    ///
195    /// That the two differ is the whole reason both exist: with nothing
196    /// focused, forward and backward Tab land on *different* annotations,
197    /// because the cursor starts between the ends rather than before them.
198    #[must_use]
199    pub fn last(&self) -> Option<AnnotId> {
200        self.order.last().copied()
201    }
202
203    /// How many annotations are in the ring.
204    #[must_use]
205    pub fn len(&self) -> usize {
206        self.order.len()
207    }
208
209    /// Whether nothing is focusable.
210    #[must_use]
211    pub fn is_empty(&self) -> bool {
212        self.order.is_empty()
213    }
214}
215
216/// Which axis a banding pass runs along.
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218enum Axis {
219    Row,
220    Column,
221}
222
223/// The banding fold, shared by the row and column orders.
224///
225/// Each pass picks a seed — the topmost remaining for rows, the leftmost for
226/// columns — emits it, then emits every remaining annotation whose centre on
227/// the cross axis lies **strictly** inside the seed's extent, in index order.
228/// The comparisons are strict at both ends, so an annotation exactly level
229/// with a band's edge starts a new band rather than joining that one.
230//
231// [oracle-bug] cpdfsdk_annotiterator.cpp:137-147 cannot terminate when no
232// candidate remains. The row pass seeds `float fTop = 0.0f;` (:137) and tests
233// `rcAnnot.top > fTop` (:140), so a page whose remaining annotations all have
234// a non-positive top leaves `nLeftTopIndex` at -1; the `continue` at :146 then
235// re-enters `while (!sa.empty())` (:135) without erasing anything, so the loop
236// state is bit-identical on every pass and the iterator spins forever. A
237// non-positive top is ordinary: §12.5.5 places `/Tabs R` order in the page's
238// own coordinate space, which a `/MediaBox` with a negative or zero origin
239// puts entirely at or below zero. pdf.js implements no `/Tabs` order at all —
240// every widget gets `tabIndex = 0` (annotation_layer.js:412) and the DOM
241// decides — so it cannot hang here either. We terminate instead: with no
242// candidate, the remainder is appended in index order and the ring records
243// `degenerate`. A library that hangs on input its own spec admits is a bug
244// whatever the oracle does.
245fn band(annots: &[Focusable], axis: Axis) -> FocusRing {
246    // Sort by the axis's primary key, keeping annotation order within ties.
247    let mut remaining: Vec<Focusable> = annots.to_vec();
248    match axis {
249        Axis::Row => remaining.sort_by(|a, b| {
250            a.rect
251                .left
252                .partial_cmp(&b.rect.left)
253                .unwrap_or(std::cmp::Ordering::Equal)
254        }),
255        Axis::Column => remaining.sort_by(|a, b| {
256            b.rect
257                .top
258                .partial_cmp(&a.rect.top)
259                .unwrap_or(std::cmp::Ordering::Equal)
260        }),
261    }
262
263    let mut order = Vec::with_capacity(remaining.len());
264    let mut degenerate = false;
265
266    while !remaining.is_empty() {
267        let Some(seed) = seed_index(&remaining, axis) else {
268            // No candidate: this is the `[oracle-bug]` above, at the line
269            // where it bites — `cpdfsdk_annotiterator.cpp:145-147` spins
270            // forever here. Append what is left in index order and say so.
271            degenerate = true;
272            order.extend(remaining.iter().map(|a| a.id));
273            break;
274        };
275
276        let Some(head) = remaining.get(seed).copied() else {
277            degenerate = true;
278            order.extend(remaining.iter().map(|a| a.id));
279            break;
280        };
281        remaining.remove(seed);
282        order.push(head.id);
283
284        // Everything whose cross-axis centre is strictly inside the seed's
285        // extent joins this band, in index order.
286        let mut kept = Vec::with_capacity(remaining.len());
287        for annot in remaining.drain(..) {
288            let joins = match axis {
289                Axis::Row => {
290                    annot.rect.center_y() > head.rect.bottom
291                        && annot.rect.center_y() < head.rect.top
292                }
293                Axis::Column => {
294                    annot.rect.center_x() > head.rect.left
295                        && annot.rect.center_x() < head.rect.right
296                }
297            };
298            if joins {
299                order.push(annot.id);
300            } else {
301                kept.push(annot);
302            }
303        }
304        remaining = kept;
305    }
306
307    FocusRing { order, degenerate }
308}
309
310/// Picks the next band's seed, or `None` when nothing qualifies.
311///
312/// The scan runs **downward** with a strict comparison, so a tie never
313/// displaces the running best — and the running best when a tie is met was
314/// set by a *higher* index. So the winner among ties is the **highest** index,
315/// which after the left-ascending sort is the **rightmost** of the tied
316/// annotations for a row pass.
317///
318/// "Lowest index, i.e. leftmost" is the natural guess and it is wrong;
319/// `row_order_seeds_each_band_with_the_rightmost_of_the_topmost` is the test
320/// that fails if this is changed to it.
321fn seed_index(remaining: &[Focusable], axis: Axis) -> Option<usize> {
322    match axis {
323        Axis::Row => {
324            let mut best: Option<usize> = None;
325            let mut top = 0.0f32;
326            for (i, annot) in remaining.iter().enumerate().rev() {
327                if annot.rect.top > top {
328                    best = Some(i);
329                    top = annot.rect.top;
330                }
331            }
332            best
333        }
334        Axis::Column => {
335            // Two upstream quirks are reproduced here rather than tidied,
336            // because both are reachable and both change the order.
337            //
338            // The guard is `left < 0`, which reads as "nothing chosen yet"
339            // only while coordinates are positive. A page whose annotations
340            // sit at negative x re-enters it on later iterations, and each
341            // time it does it seeds **index zero** rather than the index it
342            // is looking at. Neither is what the row pass does, and the
343            // difference is why the two passes are not one function with a
344            // flipped axis.
345            let mut best: Option<usize> = None;
346            let mut left = -1.0f32;
347            for (i, annot) in remaining.iter().enumerate().rev() {
348                if left < 0.0 {
349                    best = Some(0);
350                    left = annot.rect.left;
351                } else if annot.rect.left < left {
352                    best = Some(i);
353                    left = annot.rect.left;
354                }
355            }
356            best
357        }
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    fn annot(index: u32, left: f32, bottom: f32, right: f32, top: f32) -> Focusable {
366        Focusable {
367            id: AnnotId::new(0, index),
368            rect: Rect::new(left, bottom, right, top),
369        }
370    }
371
372    fn ids(ring: &FocusRing) -> Vec<u32> {
373        ring.order.iter().map(|a| a.index).collect()
374    }
375
376    /// Only `R` and `C` are recognized; `S` is not special-cased and lands in
377    /// the same branch as an absent entry.
378    #[test]
379    fn the_tabs_entry_recognizes_exactly_two_spellings() {
380        assert_eq!(TabOrder::from_tabs(Some(b"R")), TabOrder::Row);
381        assert_eq!(TabOrder::from_tabs(Some(b"C")), TabOrder::Column);
382        assert_eq!(TabOrder::from_tabs(Some(b"S")), TabOrder::Structure);
383        assert_eq!(TabOrder::from_tabs(Some(b"r")), TabOrder::Structure);
384        assert_eq!(TabOrder::from_tabs(Some(b"")), TabOrder::Structure);
385        assert_eq!(TabOrder::from_tabs(None), TabOrder::Structure);
386    }
387
388    #[test]
389    fn structure_order_sorts_nothing() {
390        let annots = [
391            annot(0, 500.0, 500.0, 600.0, 600.0),
392            annot(1, 0.0, 0.0, 100.0, 100.0),
393            annot(2, 200.0, 700.0, 300.0, 800.0),
394        ];
395        let ring = FocusRing::build(&annots, TabOrder::Structure);
396        assert_eq!(ids(&ring), vec![0, 1, 2]);
397        assert!(!ring.degenerate);
398    }
399
400    /// Focus does not wrap in either direction.
401    #[test]
402    fn the_ring_has_two_ends_and_no_wrap() {
403        let annots = [
404            annot(0, 0.0, 0.0, 10.0, 10.0),
405            annot(1, 0.0, 20.0, 10.0, 30.0),
406            annot(2, 0.0, 40.0, 10.0, 50.0),
407        ];
408        let ring = FocusRing::build(&annots, TabOrder::Structure);
409
410        assert_eq!(ring.next(AnnotId::new(0, 0)), Some(AnnotId::new(0, 1)));
411        assert_eq!(ring.next(AnnotId::new(0, 2)), None);
412        assert_eq!(ring.prev(AnnotId::new(0, 2)), Some(AnnotId::new(0, 1)));
413        assert_eq!(ring.prev(AnnotId::new(0, 0)), None);
414    }
415
416    /// With nothing focused, forward and backward Tab land on different
417    /// annotations — the cursor starts between the ends, not before them.
418    #[test]
419    fn the_two_ends_are_different_annotations() {
420        let annots = [
421            annot(0, 0.0, 0.0, 10.0, 10.0),
422            annot(1, 0.0, 20.0, 10.0, 30.0),
423        ];
424        let ring = FocusRing::build(&annots, TabOrder::Structure);
425        assert_eq!(ring.first(), Some(AnnotId::new(0, 0)));
426        assert_eq!(ring.last(), Some(AnnotId::new(0, 1)));
427        assert_ne!(ring.first(), ring.last());
428    }
429
430    /// Row order reads across the page, one band at a time — but the band's
431    /// *seed* is the rightmost of the annotations tied for topmost, not the
432    /// leftmost, and the reading order that follows is relative to it.
433    ///
434    /// Two rows of two, indices deliberately not in reading order, gives
435    /// `3, 1, 0, 2` rather than the `1, 3, 2, 0` a reader expects. The seed
436    /// scan runs from the highest index down with a strict comparison, so an
437    /// annotation only displaces the running best by being *strictly* higher;
438    /// the tied one visited first — which after the left-ascending sort is
439    /// the rightmost — keeps the seat. This is the oracle's order and it is
440    /// what the goldens contain.
441    #[test]
442    fn row_order_seeds_each_band_with_the_rightmost_of_the_topmost() {
443        let annots = [
444            annot(0, 300.0, 100.0, 400.0, 150.0), // bottom row, right
445            annot(1, 100.0, 500.0, 200.0, 550.0), // top row, left
446            annot(2, 100.0, 100.0, 200.0, 150.0), // bottom row, left
447            annot(3, 300.0, 500.0, 400.0, 550.0), // top row, right
448        ];
449        let ring = FocusRing::build(&annots, TabOrder::Row);
450        assert_eq!(ids(&ring), vec![3, 1, 0, 2]);
451        assert!(!ring.degenerate);
452    }
453
454    /// With no tie the seed is simply the topmost, and the band that follows
455    /// it reads left to right.
456    #[test]
457    fn an_untied_row_band_reads_left_to_right() {
458        let annots = [
459            annot(0, 300.0, 500.0, 400.0, 540.0), // top row, right, lower top
460            annot(1, 100.0, 500.0, 200.0, 550.0), // top row, left, highest
461            annot(2, 100.0, 100.0, 200.0, 150.0), // bottom row
462        ];
463        let ring = FocusRing::build(&annots, TabOrder::Row);
464        assert_eq!(ids(&ring), vec![1, 0, 2]);
465    }
466
467    /// Column order bands on horizontal centres, and its seed rule is the
468    /// quirky one: the guard reads "nothing chosen yet" as `left < 0`, and
469    /// when it fires it seeds index **zero** rather than the index being
470    /// looked at. Both are reproduced, so this order is what the oracle
471    /// produces rather than what a rotated row pass would.
472    #[test]
473    fn column_order_reads_down_bands() {
474        let annots = [
475            annot(0, 300.0, 100.0, 400.0, 150.0), // right column, bottom
476            annot(1, 100.0, 500.0, 200.0, 550.0), // left column, top
477            annot(2, 100.0, 100.0, 200.0, 150.0), // left column, bottom
478            annot(3, 300.0, 500.0, 400.0, 550.0), // right column, top
479        ];
480        let ring = FocusRing::build(&annots, TabOrder::Column);
481        assert_eq!(ids(&ring), vec![1, 2, 3, 0]);
482        assert!(!ring.degenerate);
483    }
484
485    /// The banding comparison is strict at both ends, so an annotation whose
486    /// centre sits exactly on a band edge starts its own band.
487    #[test]
488    fn a_centre_exactly_on_a_band_edge_does_not_join_it() {
489        // The second annotation's centre y is exactly the first's bottom.
490        let annots = [
491            annot(0, 0.0, 100.0, 50.0, 200.0),
492            annot(1, 100.0, 50.0, 150.0, 150.0),
493        ];
494        let ring = FocusRing::build(&annots, TabOrder::Row);
495        assert_eq!(annots[1].rect.center_y(), annots[0].rect.bottom);
496        // Both are still emitted, but as two bands rather than one.
497        assert_eq!(ids(&ring), vec![0, 1]);
498    }
499
500    /// The case the oracle spins on: every annotation's top is non-positive,
501    /// so its downward scan never finds a candidate and its loop erases
502    /// nothing. Here the remainder is appended and the caller is told.
503    #[test]
504    fn banding_terminates_where_the_oracle_would_hang() {
505        let annots = [
506            annot(0, 10.0, -200.0, 20.0, -100.0),
507            annot(1, 30.0, -400.0, 40.0, -300.0),
508        ];
509        let ring = FocusRing::build(&annots, TabOrder::Row);
510
511        assert!(ring.degenerate, "the recovery should be reported");
512        assert_eq!(ring.len(), 2, "no annotation may be dropped");
513        assert_eq!(ids(&ring), vec![0, 1]);
514    }
515
516    /// A top of exactly zero is the boundary case, and it degenerates too:
517    /// the comparison is strict.
518    #[test]
519    fn a_zero_top_is_on_the_hanging_side_of_the_comparison() {
520        let annots = [annot(0, 0.0, -10.0, 10.0, 0.0)];
521        let ring = FocusRing::build(&annots, TabOrder::Row);
522        assert!(ring.degenerate);
523        assert_eq!(ids(&ring), vec![0]);
524    }
525
526    /// Whatever the geometry, every annotation comes out exactly once and the
527    /// pass returns. This is the property the oracle cannot state.
528    #[test]
529    fn banding_always_terminates_and_preserves_every_annotation() {
530        for seed in 0..200u32 {
531            let mut bits = seed.wrapping_mul(2_654_435_761);
532            let mut annots = Vec::new();
533            for i in 0..6u32 {
534                let mut next = || {
535                    bits = bits.wrapping_mul(1_103_515_245).wrapping_add(12_345);
536                    // Bounded to 0..1000, so the conversion is exact.
537                    f32::from(u16::try_from((bits >> 16) % 1000).unwrap_or(0)) - 500.0
538                };
539                let (x, y) = (next(), next());
540                annots.push(annot(i, x, y, x + 20.0, y + 20.0));
541            }
542
543            for order in [TabOrder::Row, TabOrder::Column, TabOrder::Structure] {
544                let ring = FocusRing::build(&annots, order);
545                assert_eq!(ring.len(), 6, "annotation lost at seed {seed}");
546
547                let mut seen: Vec<u32> = ring.order.iter().map(|a| a.index).collect();
548                seen.sort_unstable();
549                assert_eq!(seen, vec![0, 1, 2, 3, 4, 5], "duplicate at seed {seed}");
550            }
551        }
552    }
553
554    /// The ring is built only from focusable annotations, but it reports the
555    /// raw `/Annots` indices those annotations have — so a page whose
556    /// pop-ups and non-widgets leave gaps in the numbering keeps the gaps.
557    #[test]
558    fn the_ring_keeps_the_gaps_that_unfocusable_annotations_leave() {
559        // /Annots = [ popup, widget, highlight, widget ]; only 1 and 3 are
560        // focusable, so those are the indices the ring must carry.
561        let annots = [
562            annot(1, 100.0, 400.0, 200.0, 450.0),
563            annot(3, 100.0, 200.0, 200.0, 250.0),
564        ];
565        let ring = FocusRing::build(&annots, TabOrder::Structure);
566
567        assert_eq!(ids(&ring), vec![1, 3], "the ring is not renumbered");
568        assert_eq!(ring.first(), Some(AnnotId::new(0, 1)));
569        assert_eq!(ring.next(AnnotId::new(0, 1)), Some(AnnotId::new(0, 3)));
570        assert_eq!(ring.next(AnnotId::new(0, 3)), None);
571    }
572
573    #[test]
574    fn an_empty_page_has_an_empty_ring() {
575        let ring = FocusRing::build(&[], TabOrder::Row);
576        assert!(ring.is_empty());
577        assert_eq!(ring.first(), None);
578        assert_eq!(ring.last(), None);
579        assert!(!ring.degenerate);
580    }
581
582    /// An annotation the ring does not contain has neither a next nor a
583    /// previous, rather than defaulting to an end.
584    #[test]
585    fn an_unknown_annotation_has_no_neighbours() {
586        let annots = [annot(0, 0.0, 0.0, 10.0, 10.0)];
587        let ring = FocusRing::build(&annots, TabOrder::Structure);
588        assert_eq!(ring.next(AnnotId::new(0, 99)), None);
589        assert_eq!(ring.prev(AnnotId::new(0, 99)), None);
590    }
591
592    mod annotiter {
593        //! Ported focus-traversal assertions.
594        //!
595        //! In-crate rather than in `tests/`, because the fixture is built from
596        //! [`Focusable`] and [`Rect`] — the crate's own `f32` geometry, not its
597        //! public vocabulary. Nothing else about them moved.
598        //!
599        //! The fixture is the oracle's own `annotiter.pdf`, transcribed rather than
600        //! parsed: one field with four widget kids, on three pages that differ only
601        //! in the traversal order they ask for. Its geometry is the point — the four
602        //! widgets sit at the corners of a square, in an annotation order that is
603        //! none of the three traversal orders, so each order produces a different
604        //! answer and a wrong implementation cannot accidentally agree.
605        //!
606        //! ```text
607        //!   (201,400) LeftTop  #2        #1 RightTop  (401,401)
608        //!
609        //!   (200,200) LeftBottom #0      #3 RightBottom (400,201)
610        //! ```
611
612        use super::*;
613
614        /// The four widgets of `annotiter.pdf`, in the order its `/Annots` array
615        /// lists them.
616        fn annotiter_widgets() -> Vec<Focusable> {
617            [
618                (0, 200.0, 200.0, 220.0, 220.0), // Sub_LeftBottom
619                (1, 401.0, 401.0, 421.0, 421.0), // Sub_RightTop
620                (2, 201.0, 400.0, 221.0, 420.0), // Sub_LeftTop
621                (3, 400.0, 201.0, 420.0, 221.0), // Sub_RightBottom
622            ]
623            .into_iter()
624            .map(|(index, left, bottom, right, top)| Focusable {
625                id: AnnotId::new(0, index),
626                rect: Rect::new(left, bottom, right, top),
627            })
628            .collect()
629        }
630
631        fn row_ring() -> FocusRing {
632            FocusRing::build(&annotiter_widgets(), TabOrder::Row)
633        }
634
635        fn indices(ring: &FocusRing) -> Vec<u32> {
636            ring.order.iter().map(|a| a.index).collect()
637        }
638
639        /// `FormFillFirstTab`: a forward tab with nothing focused lands on annot 1.
640        #[test]
641        fn first_tab_lands_on_annot_one() {
642            assert_eq!(row_ring().first(), Some(AnnotId::new(0, 1)));
643        }
644
645        /// `FormFillFirstShiftTab`: a backward tab with nothing focused lands on
646        /// annot 0 — a *different* annotation, because the cursor starts between the
647        /// ends rather than before them.
648        #[test]
649        fn first_shift_tab_lands_on_annot_zero() {
650            assert_eq!(row_ring().last(), Some(AnnotId::new(0, 0)));
651        }
652
653        /// `FormFillContinuousTab`: four forward tabs visit 1, 2, 3, 0 and the fifth
654        /// is not handled, because focus does not wrap.
655        #[test]
656        fn continuous_tab_visits_one_two_three_zero_then_stops() {
657            let ring = row_ring();
658            let mut at = ring.first().expect("the ring is not empty");
659            assert_eq!(at, AnnotId::new(0, 1));
660
661            let mut visited = vec![at.index];
662            for _ in 0..3 {
663                at = ring.next(at).expect("a next annotation");
664                visited.push(at.index);
665            }
666            assert_eq!(visited, vec![1, 2, 3, 0]);
667
668            assert_eq!(ring.next(at), None, "the fifth tab is not handled");
669        }
670
671        /// `FormFillContinuousShiftTab`: four backward tabs visit 0, 3, 2, 1 and the
672        /// fifth is not handled.
673        #[test]
674        fn continuous_shift_tab_visits_zero_three_two_one_then_stops() {
675            let ring = row_ring();
676            let mut at = ring.last().expect("the ring is not empty");
677            assert_eq!(at, AnnotId::new(0, 0));
678
679            let mut visited = vec![at.index];
680            for _ in 0..3 {
681                at = ring.prev(at).expect("a previous annotation");
682                visited.push(at.index);
683            }
684            assert_eq!(visited, vec![0, 3, 2, 1]);
685
686            assert_eq!(ring.prev(at), None, "the fifth shift-tab is not handled");
687        }
688
689        /// The forward and backward walks are exact reverses of one another over this
690        /// fixture, which is what makes the two "first tab" answers differ.
691        #[test]
692        fn the_backward_walk_reverses_the_forward_one() {
693            let ring = row_ring();
694            let forward = indices(&ring);
695            let mut backward = forward.clone();
696            backward.reverse();
697
698            let mut walked = vec![ring.last().expect("a last").index];
699            let mut at = ring.last().expect("a last");
700            while let Some(prev) = ring.prev(at) {
701                walked.push(prev.index);
702                at = prev;
703            }
704            assert_eq!(walked, backward);
705        }
706
707        /// The three pages of the fixture differ only in the order they ask for, and
708        /// each produces a different answer over the same four widgets — which is
709        /// what makes the fixture able to tell the orders apart at all.
710        #[test]
711        fn the_three_orders_disagree_over_this_fixture() {
712            let widgets = annotiter_widgets();
713            let row = indices(&FocusRing::build(&widgets, TabOrder::Row));
714            let column = indices(&FocusRing::build(&widgets, TabOrder::Column));
715            let structure = indices(&FocusRing::build(&widgets, TabOrder::Structure));
716
717            assert_eq!(structure, vec![0, 1, 2, 3], "structure order sorts nothing");
718            assert_eq!(row, vec![1, 2, 3, 0]);
719            assert_ne!(row, column);
720            assert_ne!(row, structure);
721            assert_ne!(column, structure);
722        }
723
724        /// Every order is a permutation of the annotations: none is dropped and none
725        /// is visited twice, whichever way the page asks for.
726        #[test]
727        fn every_order_is_a_permutation() {
728            let widgets = annotiter_widgets();
729            for order in [TabOrder::Row, TabOrder::Column, TabOrder::Structure] {
730                let ring = FocusRing::build(&widgets, order);
731                let mut seen = indices(&ring);
732                seen.sort_unstable();
733                assert_eq!(seen, vec![0, 1, 2, 3], "{order:?} is not a permutation");
734                assert!(!ring.degenerate, "{order:?} should not degenerate");
735            }
736        }
737    }
738
739    mod never_panics {
740        //! The property that outranks every behavioural one, for the geometry
741        //! this crate keeps private: **no input panics.**
742        //!
743        //! In-crate rather than in `tests/never_panics.rs`, because these three
744        //! generate [`Plate`]s, [`Candidate`]s and [`Focusable`]s out of
745        //! `f32` [`Rect`]s — the crate's own geometry, not its public
746        //! vocabulary. The rest of that file's properties are over public
747        //! types and stayed where they were.
748
749        use crate::event::Point;
750        use crate::geom::{Plate, Rotation};
751        use crate::hit::{Candidate, LayoutBand, Permissions, WidgetHit, widget_at_point};
752        use crate::session::AnnotId;
753        use crate::tab::{FocusRing, Focusable, Rect, TabOrder};
754
755        /// A small deterministic generator: a counter run through a mixing
756        /// step. Enough spread to reach the awkward cases, and reproducible
757        /// when one fails.
758        struct Gen(u32);
759
760        impl Gen {
761            fn next(&mut self) -> u32 {
762                self.0 = self.0.wrapping_mul(1_103_515_245).wrapping_add(12_345);
763                self.0
764            }
765
766            fn coord(&mut self) -> f32 {
767                // Bounded to -1000..1000, so the conversion is exact.
768                let raw = i16::try_from(self.next() % 2000).unwrap_or(0) - 1000;
769                f32::from(raw) / 2.0
770            }
771
772            fn below(&mut self, n: u32) -> u32 {
773                if n == 0 { 0 } else { self.next() % n }
774            }
775        }
776
777        /// Rectangles that would break a naive implementation: inverted, degenerate,
778        /// negative, and a one-by-one box a real corpus file contains.
779        fn awkward_rects() -> Vec<Rect> {
780            vec![
781                Rect::new(0.0, 0.0, 0.0, 0.0),
782                Rect::new(10.0, 10.0, 10.0, 10.0),
783                Rect::new(1.0, 1.0, 2.0, 2.0),
784                // Written inside out, as bug_889099's field is.
785                Rect::new(100.0, 100.0, 200.0, -130.0),
786                Rect::new(200.0, 200.0, 100.0, 100.0),
787                Rect::new(-500.0, -500.0, -400.0, -400.0),
788                Rect::new(0.0, 0.0, 1e6, 1e6),
789            ]
790        }
791
792        /// The plate transform survives every awkward rectangle and every rotation,
793        /// and never produces a value that is not a number.
794        #[test]
795        fn the_plate_transform_never_panics_or_produces_nonsense() {
796            let mut rng = Gen(1);
797            for rect in awkward_rects() {
798                for rotation in [
799                    Rotation::None,
800                    Rotation::Quarter,
801                    Rotation::Half,
802                    Rotation::ThreeQuarter,
803                ] {
804                    let plate = Plate::new(rect, rotation);
805                    for _ in 0..20 {
806                        let at = Point::new(rng.coord(), rng.coord());
807                        let there = plate.to_plate(at);
808                        let back = plate.to_page(there);
809                        assert!(there.x.is_finite() && there.y.is_finite());
810                        assert!(back.x.is_finite() && back.y.is_finite());
811                    }
812                    assert!(plate.width().is_finite());
813                    assert!(plate.height().is_finite());
814                    assert!(
815                        plate.width() >= 0.0,
816                        "a normalized box has no negative width"
817                    );
818                    assert!(plate.height() >= 0.0);
819                }
820            }
821        }
822
823        /// Hit testing over generated geometry returns, and never names an
824        /// annotation that is not in the list.
825        #[test]
826        fn hit_testing_never_panics_and_never_invents_an_annotation() {
827            let mut rng = Gen(7);
828            for _ in 0..200 {
829                let count = rng.below(6);
830                let candidates: Vec<Candidate> = (0..count)
831                    .map(|i| {
832                        let (x, y) = (rng.coord(), rng.coord());
833                        Candidate {
834                            id: AnnotId::new(rng.below(3), i),
835                            rect: Rect::new(x, y, x + rng.coord(), y + rng.coord()),
836                            band: match rng.below(3) {
837                                0 => LayoutBand::Popup,
838                                1 => LayoutBand::Widget,
839                                _ => LayoutBand::Other,
840                            },
841                            widget: (rng.below(2) == 0).then(|| WidgetHit {
842                                signature: rng.below(2) == 0,
843                                hidden: rng.below(2) == 0,
844                                read_only: rng.below(2) == 0,
845                                push_button: rng.below(2) == 0,
846                            }),
847                        }
848                    })
849                    .collect();
850
851                let focused = candidates.first().map(|c| c.id);
852                for permissions in [Permissions::ALL, Permissions::NONE] {
853                    let hit = widget_at_point(
854                        &candidates,
855                        focused,
856                        permissions,
857                        rng.coord(),
858                        rng.coord(),
859                    );
860                    if let Some(hit) = hit {
861                        assert!(
862                            candidates.iter().any(|c| c.id == hit),
863                            "hit test named an annotation that is not in the list"
864                        );
865                    }
866                }
867            }
868        }
869
870        /// The tab-order banding terminates over any geometry, in every order, and
871        /// emits each annotation exactly once. The upstream loop hangs here.
872        #[test]
873        fn the_focus_ring_always_terminates_and_is_always_a_permutation() {
874            let mut rng = Gen(13);
875            for _ in 0..300 {
876                let count = rng.below(8);
877                let annots: Vec<Focusable> = (0..count)
878                    .map(|i| {
879                        let (x, y) = (rng.coord(), rng.coord());
880                        Focusable {
881                            id: AnnotId::new(0, i),
882                            // Deliberately including zero-area and inverted boxes.
883                            rect: Rect::new(x, y, x + rng.coord(), y + rng.coord()),
884                        }
885                    })
886                    .collect();
887
888                for order in [TabOrder::Row, TabOrder::Column, TabOrder::Structure] {
889                    let built = FocusRing::build(&annots, order);
890                    assert_eq!(built.len(), annots.len(), "{order:?} lost an annotation");
891
892                    let mut seen: Vec<u32> = built.order.iter().map(|a| a.index).collect();
893                    seen.sort_unstable();
894                    let expected: Vec<u32> = (0..count).collect();
895                    assert_eq!(seen, expected, "{order:?} is not a permutation");
896
897                    // Walking the ring from either end terminates.
898                    if let Some(mut at) = built.first() {
899                        let mut steps = 0;
900                        while let Some(next) = built.next(at) {
901                            at = next;
902                            steps += 1;
903                            assert!(
904                                steps <= count as usize,
905                                "the forward walk did not terminate"
906                            );
907                        }
908                    }
909                }
910            }
911        }
912    }
913}