Skip to main content

pdfrum_form/
event.rs

1//! The input vocabulary: what a caller hands the engine.
2//!
3//! These are *semantic* events, in page space, with typed keys, typed
4//! modifiers and `kurbo` points.
5//! They are deliberately not the `.evt` grammar's own types: that file format
6//! parses integers with `atoi` and emits one verb for a key-down/key-up pair,
7//! which is a faithful description of a text file and a poor description of
8//! what a form does. The two layers meet in one conversion function, which
9//! lives with the parser.
10//!
11//! Two variants a reader may go looking for are absent by derivation rather
12//! than by omission. There is no key-up: the oracle's entry point for it is
13//! documented as permanently unimplemented and returns false, so an API that
14//! modelled it would invite callers to send an event that cannot do anything.
15//! And there is no idle tick: a blank line in an event script does not pump
16//! the host's message loop, and nothing in a script-free build observes one.
17
18/// A position in page space — PDF user space, y-**up**, origin at the page's
19/// crop box — in this crate's own `f32`.
20///
21/// **Private, deliberately.** The public vocabulary is [`kurbo::Point`], and
22/// this is what [`crate::route::apply`] narrows it to on the way in — the same
23/// place the oracle narrows its own `double` pair.
24/// Every geometric comparison in this crate is `f32` against widget edges
25/// that `page::to_rect` already rounded to `f32`: an `f64` point meeting one
26/// of those changes inclusive-edge behaviour and can move a caret across a
27/// glyph boundary, which is why the narrowing is at the entry function and
28/// not one layer further in.
29#[derive(Debug, Clone, Copy, PartialEq)]
30pub(crate) struct Point {
31    /// Distance right of the crop box's left edge.
32    pub(crate) x: f32,
33    /// Distance **up** from the crop box's bottom edge.
34    pub(crate) y: f32,
35}
36
37impl Point {
38    /// A point at the given page-space coordinates.
39    pub(crate) fn new(x: f32, y: f32) -> Point {
40        Point { x, y }
41    }
42
43    /// The narrowing: a caller's `f64` page-space point onto this crate's.
44    ///
45    /// The whole of the `f64`/`f32` boundary, in one function, called from
46    /// one place. A page coordinate past `f32`'s exact range has already lost
47    /// its meaning, so rounding it loses nothing that was still there.
48    #[expect(
49        clippy::cast_possible_truncation,
50        reason = "page coordinates beyond f32 have already lost meaning, and every \
51                  geometric query in this crate is f32 — see the type's own docs"
52    )]
53    pub(crate) fn narrow(at: kurbo::Point) -> Point {
54        Point::new(at.x as f32, at.y as f32)
55    }
56}
57
58/// Which mouse button an event came from.
59///
60/// The right button is representable because event scripts contain it, and
61/// the correct response to those lines is to consume nothing: outside XFA
62/// builds — which are declined — the right-button entry points do nothing.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum Button {
65    /// The primary button.
66    Left,
67    /// The secondary button. Never has an effect.
68    Right,
69}
70
71/// A key on a keyboard, as an event reports it.
72///
73/// The named variants are the keys the form layer *decides on* — navigation,
74/// editing, and the three accelerator letters — plus the two modifier keys a
75/// host reports as keys in their own right. Everything else is [`Key::Other`],
76/// the arm that says "the form layer does not decide on this".
77///
78/// [`Key::from_virtual`] and [`Key::virtual_code`] are the boundary with a
79/// host's own event queue, which speaks in bare integers.
80///
81/// ```
82/// use pdfrum_form::Key;
83///
84/// assert_eq!(Key::from_virtual(0x09), Key::Tab);
85/// assert_eq!(Key::Tab.virtual_code(), 0x09);
86/// // A code the form layer does not branch on round-trips too.
87/// assert_eq!(Key::from_virtual(0x70), Key::Other(0x70));
88/// assert_eq!(Key::Other(0x70).virtual_code(), 0x70);
89/// ```
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
91#[non_exhaustive]
92pub enum Key {
93    /// No key. Also what a selection-clearing delete is rewritten to, which
94    /// is why the text field branches on it rather than ignoring it.
95    Unknown,
96    /// Backspace.
97    Backspace,
98    /// Tab — focus traversal.
99    Tab,
100    /// Line feed. Distinct from [`Key::Return`], which is the carriage
101    /// return a host sends for the Enter key.
102    Newline,
103    /// Carriage return — activates a widget, or commits a single-line field.
104    Return,
105    /// Escape — discards an in-progress edit.
106    Escape,
107    /// Space — activates a widget.
108    Space,
109    /// Page up. Not handled by the edit control.
110    PageUp,
111    /// Page down. Not handled by the edit control.
112    PageDown,
113    /// End of line, or of the document with the accelerator held.
114    End,
115    /// Start of line, or of the document with the accelerator held.
116    Home,
117    /// Caret left.
118    Left,
119    /// Caret up.
120    Up,
121    /// Caret right.
122    Right,
123    /// Caret down.
124    Down,
125    /// Insert. Not handled.
126    Insert,
127    /// Forward delete.
128    Delete,
129    /// The letter A — select-all with the accelerator.
130    A,
131    /// The letter Y — redo with the accelerator, off Apple.
132    Y,
133    /// The letter Z — undo, or redo with shift.
134    Z,
135    /// The shift key reported as a key in its own right. Never consumed.
136    Shift,
137    /// The control key reported as a key in its own right. Never consumed.
138    Control,
139    /// Any other key, by the code a host reported it under.
140    ///
141    /// Never carries a code a named variant already names: [`Key::from_virtual`]
142    /// is the only way one is built from an integer, and it maps the named
143    /// codes first.
144    Other(u16),
145}
146
147impl Key {
148    /// The key a host's virtual-key code names.
149    ///
150    /// Total, and the inverse of [`Key::virtual_code`]: a code no variant
151    /// names becomes [`Key::Other`] carrying it unchanged.
152    #[must_use]
153    pub const fn from_virtual(code: u16) -> Key {
154        match code {
155            0x00 => Key::Unknown,
156            0x08 => Key::Backspace,
157            0x09 => Key::Tab,
158            0x0A => Key::Newline,
159            0x0D => Key::Return,
160            0x10 => Key::Shift,
161            0x11 => Key::Control,
162            0x1B => Key::Escape,
163            0x20 => Key::Space,
164            0x21 => Key::PageUp,
165            0x22 => Key::PageDown,
166            0x23 => Key::End,
167            0x24 => Key::Home,
168            0x25 => Key::Left,
169            0x26 => Key::Up,
170            0x27 => Key::Right,
171            0x28 => Key::Down,
172            0x2D => Key::Insert,
173            0x2E => Key::Delete,
174            0x41 => Key::A,
175            0x59 => Key::Y,
176            0x5A => Key::Z,
177            other => Key::Other(other),
178        }
179    }
180
181    /// The virtual-key code this key is reported under.
182    ///
183    /// The inverse of [`Key::from_virtual`] over every value that function
184    /// can produce.
185    #[must_use]
186    pub const fn virtual_code(self) -> u16 {
187        match self {
188            Key::Unknown => 0x00,
189            Key::Backspace => 0x08,
190            Key::Tab => 0x09,
191            Key::Newline => 0x0A,
192            Key::Return => 0x0D,
193            Key::Shift => 0x10,
194            Key::Control => 0x11,
195            Key::Escape => 0x1B,
196            Key::Space => 0x20,
197            Key::PageUp => 0x21,
198            Key::PageDown => 0x22,
199            Key::End => 0x23,
200            Key::Home => 0x24,
201            Key::Left => 0x25,
202            Key::Up => 0x26,
203            Key::Right => 0x27,
204            Key::Down => 0x28,
205            Key::Insert => 0x2D,
206            Key::Delete => 0x2E,
207            Key::A => 0x41,
208            Key::Y => 0x59,
209            Key::Z => 0x5A,
210            Key::Other(code) => code,
211        }
212    }
213}
214
215/// The modifier bits carried by an event (`FWL_EVENTFLAG`).
216///
217/// A hand-written bitflag newtype: nine constants and a handful of
218/// operations, sharing the algebra of `pdfrum_font::FontFlags` and
219/// `pdfrum_doc::AnnotFlags`, unknown-bit retention included.
220///
221/// ```
222/// use pdfrum_form::Modifiers;
223///
224/// let m = Modifiers::SHIFT | Modifiers::CONTROL;
225/// assert!(m.contains(Modifiers::SHIFT));
226/// assert!(!m.without(Modifiers::SHIFT).contains(Modifiers::SHIFT));
227/// assert_eq!(Modifiers::from_bits(1 << 30).bits(), 1 << 30);
228/// ```
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
230pub struct Modifiers(u32);
231
232impl Modifiers {
233    /// No modifiers held.
234    pub const NONE: Self = Self(0);
235    /// Shift.
236    pub const SHIFT: Self = Self(1 << 0);
237    /// Control.
238    pub const CONTROL: Self = Self(1 << 1);
239    /// Alt.
240    pub const ALT: Self = Self(1 << 2);
241    /// Meta — Command on Apple keyboards.
242    pub const META: Self = Self(1 << 3);
243    /// The key came from the numeric keypad.
244    pub const KEYPAD: Self = Self(1 << 4);
245    /// The key is repeating because it is held down.
246    pub const AUTO_REPEAT: Self = Self(1 << 5);
247    /// The left mouse button is down.
248    pub const LEFT_BUTTON: Self = Self(1 << 6);
249    /// The middle mouse button is down.
250    pub const MIDDLE_BUTTON: Self = Self(1 << 7);
251    /// The right mouse button is down.
252    pub const RIGHT_BUTTON: Self = Self(1 << 8);
253
254    /// The raw modifier word, including any bit this type does not name.
255    #[must_use]
256    pub const fn bits(self) -> u32 {
257        self.0
258    }
259
260    /// The word as a host reported it. **Unknown bits are retained.**
261    #[must_use]
262    pub const fn from_bits(bits: u32) -> Self {
263        Self(bits)
264    }
265
266    /// Whether every bit of `other` is set here.
267    ///
268    /// [`Modifiers::NONE`] is contained in everything, which is what makes
269    /// `contains` the wrong question to ask about "no modifiers held" — use
270    /// `== Modifiers::NONE` for that.
271    #[must_use]
272    pub const fn contains(self, other: Self) -> bool {
273        self.0 & other.0 == other.0
274    }
275
276    /// Both sets of bits.
277    #[must_use]
278    pub const fn union(self, other: Self) -> Self {
279        Self(self.0 | other.0)
280    }
281
282    /// A copy with `other`'s bits set. An alias for [`Modifiers::union`].
283    #[must_use]
284    pub const fn with(self, other: Self) -> Self {
285        self.union(other)
286    }
287
288    /// The bits of `self` that are not in `other`.
289    #[must_use]
290    pub const fn without(self, other: Self) -> Self {
291        Self(self.0 & !other.0)
292    }
293
294    /// Whether no bit at all is set.
295    #[must_use]
296    pub const fn is_empty(self) -> bool {
297        self.0 == 0
298    }
299}
300
301impl std::ops::BitOr for Modifiers {
302    type Output = Self;
303
304    fn bitor(self, rhs: Self) -> Self {
305        self.union(rhs)
306    }
307}
308
309/// One input event.
310///
311/// Points are [`kurbo::Point`] — page space, PDF user space, y-**up**, origin
312/// at the crop box, and `f64`. That is the same vocabulary `Page::crop_box`
313/// speaks and the same one the oracle's own entry points take
314/// (`FORM_OnMouseMove(.., double page_x, double page_y)`); the crate narrows
315/// to its private `f32` point in [`crate::route::apply`] and nowhere else.
316#[derive(Debug, Clone, Copy, PartialEq)]
317pub enum Event {
318    /// The pointer moved. Drives hover enter/exit and extends a live drag.
319    MouseMove {
320        /// Where, in page space.
321        at: kurbo::Point,
322        /// Which modifiers were held.
323        modifiers: Modifiers,
324    },
325    /// A mouse button went down.
326    MouseDown {
327        /// Which button.
328        button: Button,
329        /// Where, in page space.
330        at: kurbo::Point,
331        /// Which modifiers were held.
332        modifiers: Modifiers,
333    },
334    /// A mouse button came up.
335    MouseUp {
336        /// Which button.
337        button: Button,
338        /// Where, in page space.
339        at: kurbo::Point,
340        /// Which modifiers were held.
341        modifiers: Modifiers,
342    },
343    /// A double click. Carries no button because the grammar rejects any
344    /// button but the left one.
345    DoubleClick {
346        /// Where, in page space.
347        at: kurbo::Point,
348        /// Which modifiers were held.
349        modifiers: Modifiers,
350    },
351    /// The wheel turned. Deltas are notches, negative `y` meaning down.
352    MouseWheel {
353        /// Where the pointer was, in page space.
354        at: kurbo::Point,
355        /// Horizontal and vertical notches.
356        delta: (i32, i32),
357        /// Which modifiers were held.
358        modifiers: Modifiers,
359    },
360    /// Focus was requested at a point, without a click.
361    Focus {
362        /// Where, in page space.
363        at: kurbo::Point,
364        /// Which modifiers were held.
365        modifiers: Modifiers,
366    },
367    /// A key went down. Navigation and shortcuts arrive here, never as text.
368    KeyDown {
369        /// Which key.
370        key: Key,
371        /// Which modifiers were held.
372        modifiers: Modifiers,
373    },
374    /// A character was typed. Text arrives here, never as a key-down.
375    ///
376    /// This split is the single most load-bearing fact in the event model:
377    /// typing sends only this, and the accelerator shortcuts are decided only
378    /// on the key-down path. A character that arrives here with the
379    /// accelerator held is deliberately *not* a shortcut.
380    Char {
381        /// The character typed.
382        ch: char,
383        /// Which modifiers were held.
384        modifiers: Modifiers,
385    },
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    /// The `f64`-to-`f32` hazard, pinned at the boundary that answers it.
393    ///
394    /// [`crate::route::apply`] narrows an [`Event`]'s `f64` point to this
395    /// `f32` one before any comparison. The interior then compares against
396    /// widget edges `page::to_rect` already rounded the same way, so an
397    /// on-the-edge click stays on the edge. If the narrowing ever moved
398    /// deeper — an `f64` reaching `hit::contains` or `Plate::to_widget` —
399    /// the value it met would be a *different* number from the one this
400    /// pins, and `hit.rs`'s `containment_includes_every_edge` would start
401    /// disagreeing with a caller who clicked exactly on a boundary.
402    #[expect(
403        clippy::float_cmp,
404        reason = "bit-exactness is the assertion: a tolerance would pass under \
405                  precisely the half-migration this test exists to forbid"
406    )]
407    #[test]
408    fn a_fractional_coordinate_is_narrowed_before_any_comparison() {
409        // A value with a fractional part that `f32` cannot hold exactly.
410        let at = kurbo::Point::new(10.1, 713.7);
411        let narrowed = Point::narrow(at);
412
413        // What the interior sees is the `f32` nearest the caller's `f64` —
414        // and it is *not* the caller's value, which is the whole point.
415        assert_eq!(narrowed.x, 10.1_f32);
416        assert_eq!(narrowed.y, 713.7_f32);
417        assert!(f64::from(narrowed.x) != at.x, "10.1 is not exact in f32");
418
419        // And it is exactly what `page::to_rect` produces for the same
420        // number, so an edge written `10.1` in the file and a click at
421        // `10.1` from the host meet as equals.
422        let edge = crate::page::to_rect(kurbo::Rect::new(10.1, 713.7, 20.0, 800.0));
423        assert_eq!(narrowed.x, edge.left);
424        assert_eq!(narrowed.y, edge.bottom);
425        assert!(
426            crate::hit::contains(edge, narrowed.x, narrowed.y),
427            "a click exactly on a fractional edge is inside it"
428        );
429    }
430
431    /// An integer coordinate — which is all an `.evt` script can write, since
432    /// its parser is a hand-rolled `atoi` — survives the widening and the
433    /// narrowing unchanged. This is why no golden moved.
434    #[expect(
435        clippy::float_cmp,
436        reason = "exactness is the assertion — this is why no golden moved"
437    )]
438    #[test]
439    fn an_integer_coordinate_round_trips_exactly() {
440        for value in [0.0_f64, 1.0, 312.0, -450.0, 9999.0] {
441            let narrowed = Point::narrow(kurbo::Point::new(value, value));
442            assert_eq!(f64::from(narrowed.x), value);
443            assert_eq!(f64::from(narrowed.y), value);
444        }
445    }
446
447    #[test]
448    fn modifiers_contains_is_subset_not_equality() {
449        let both = Modifiers::SHIFT | Modifiers::CONTROL;
450        assert!(both.contains(Modifiers::SHIFT));
451        assert!(both.contains(Modifiers::CONTROL));
452        assert!(both.contains(Modifiers::NONE));
453        assert!(!both.contains(Modifiers::ALT));
454        assert!(!Modifiers::SHIFT.contains(both));
455    }
456
457    #[test]
458    fn modifiers_none_is_empty_and_everything_contains_it() {
459        assert!(Modifiers::NONE.is_empty());
460        assert!(!Modifiers::SHIFT.is_empty());
461        assert!(Modifiers::NONE.contains(Modifiers::NONE));
462    }
463
464    #[test]
465    fn modifiers_without_removes_only_named_bits() {
466        let all = Modifiers::SHIFT | Modifiers::CONTROL | Modifiers::ALT;
467        assert_eq!(
468            all.without(Modifiers::CONTROL),
469            Modifiers::SHIFT | Modifiers::ALT
470        );
471    }
472
473    /// The bit values are a wire format, not an internal choice: an event
474    /// script's modifier field and the ported link-action assertions both
475    /// name them numerically.
476    #[test]
477    fn modifier_bits_are_the_documented_wire_values() {
478        assert_eq!(Modifiers::SHIFT.bits(), 1);
479        assert_eq!(Modifiers::CONTROL.bits(), 2);
480        assert_eq!((Modifiers::SHIFT | Modifiers::CONTROL).bits(), 3);
481        assert_eq!(Modifiers::ALT.bits(), 4);
482        assert_eq!(Modifiers::META.bits(), 8);
483    }
484
485    #[test]
486    fn unknown_modifier_bits_round_trip() {
487        let f = Modifiers::from_bits((1 << 30) | Modifiers::SHIFT.bits());
488        assert_eq!(f.bits(), (1 << 30) | 1);
489        assert!(f.contains(Modifiers::SHIFT));
490        assert!(!f.contains(Modifiers::CONTROL));
491    }
492
493    #[test]
494    fn modifier_set_algebra() {
495        let m = Modifiers::SHIFT | Modifiers::CONTROL | Modifiers::ALT;
496        assert!(m.contains(Modifiers::SHIFT | Modifiers::ALT));
497        assert!(m.contains(Modifiers::NONE));
498        assert!(!m.contains(Modifiers::SHIFT | Modifiers::META));
499        assert_eq!(
500            m.without(Modifiers::CONTROL),
501            Modifiers::SHIFT | Modifiers::ALT
502        );
503        assert_eq!(Modifiers::NONE.with(Modifiers::META), Modifiers::META);
504        assert!(Modifiers::NONE.is_empty());
505        assert!(!m.is_empty());
506    }
507
508    /// The codes are a wire format — a host's virtual-key word and the
509    /// `.evt` grammar's integers both name them numerically — so the table
510    /// is pinned rather than merely round-tripped.
511    #[test]
512    fn key_codes_are_the_virtual_key_codes() {
513        assert_eq!(Key::Tab.virtual_code(), 0x09);
514        assert_eq!(Key::Return.virtual_code(), 0x0D);
515        assert_eq!(Key::Delete.virtual_code(), 0x2E);
516        assert_eq!(Key::A.virtual_code(), 0x41);
517        assert_eq!(Key::Z.virtual_code(), 0x5A);
518    }
519
520    /// Every named variant survives the trip out to a code and back, and so
521    /// does an `Other` the table does not name. The list is written out
522    /// rather than iterated because a variant added without a table row is
523    /// exactly the mistake this catches.
524    #[test]
525    fn every_key_round_trips_through_its_virtual_code() {
526        let named = [
527            Key::Unknown,
528            Key::Backspace,
529            Key::Tab,
530            Key::Newline,
531            Key::Return,
532            Key::Escape,
533            Key::Space,
534            Key::PageUp,
535            Key::PageDown,
536            Key::End,
537            Key::Home,
538            Key::Left,
539            Key::Up,
540            Key::Right,
541            Key::Down,
542            Key::Insert,
543            Key::Delete,
544            Key::A,
545            Key::Y,
546            Key::Z,
547            Key::Shift,
548            Key::Control,
549        ];
550        for key in named {
551            assert_eq!(Key::from_virtual(key.virtual_code()), key, "{key:?}");
552        }
553        // Distinct codes stay distinct: a table that mapped two variants to
554        // one code would pass the loop above and fail here.
555        let mut codes: Vec<u16> = named.iter().map(|k| k.virtual_code()).collect();
556        codes.sort_unstable();
557        let count = codes.len();
558        codes.dedup();
559        assert_eq!(codes.len(), count, "two variants share a virtual code");
560    }
561
562    /// The `.evt` corpus sends F-keys, digits and the clipboard letters
563    /// precisely to check that nothing consumes them. They must arrive.
564    #[test]
565    fn undecided_codes_arrive_as_other_unchanged() {
566        for code in [0x70_u16, 0x30, 0x43, 0x56, 0x58, 0xFFFF] {
567            assert_eq!(Key::from_virtual(code), Key::Other(code));
568            assert_eq!(Key::Other(code).virtual_code(), code);
569        }
570    }
571}