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}