Skip to main content

pdfrum_form/
session.rs

1//! The session record: everything an interaction remembers between events.
2//!
3//! A session is a record of facts, and applying an event is a function over
4//! it. There is nothing here that owns a widget, points at one, or observes
5//! one — a focus target is an identifier, a dirty entry is an identifier, and
6//! a function that mutates the session cannot invalidate a reference someone
7//! else is holding.
8//!
9//! Focus is owned per **document**, not per page: one field has the keyboard
10//! at a time, whichever page it is on.
11
12use std::collections::{BTreeMap, BTreeSet};
13
14use pdfrum_common::PageIndex;
15use pdfrum_doc::Subtype;
16
17use crate::edit::Place;
18use crate::event::Modifiers;
19use crate::field::FieldState;
20
21/// Which field a session is talking about: an index into the form's field
22/// list.
23///
24/// `From<u32>` exists so `impl Into<FieldId>` arguments accept a literal.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
26pub struct FieldId(pub u32);
27
28impl From<u32> for FieldId {
29    fn from(n: u32) -> Self {
30        Self(n)
31    }
32}
33
34impl From<FieldId> for u32 {
35    fn from(id: FieldId) -> Self {
36        id.0
37    }
38}
39
40/// Which annotation: a page and its index in that page's **raw `/Annots`
41/// array**.
42///
43/// # The index space is the raw array, not a filtered position
44///
45/// This distinction is invisible until a page carries a pop-up annotation,
46/// and then it decides whether an appearance lands on the right widget.
47///
48/// A page's annotation list as the rendering path builds it **drops
49/// pop-ups**, so a widget's position in that list is not its position in the
50/// file's `/Annots` array: on a page whose first annotation is a pop-up, every
51/// later widget's filtered position is one lower than its raw index.
52///
53/// The appearance overlay a caller draws through is keyed by the **raw**
54/// index. Hand back a filtered position and every appearance after the first
55/// pop-up lands on the wrong annotation, silently, with no type to catch it.
56// It is also the natural choice rather than a concession: hit testing walks
57// `/Annots` itself, so the raw index is what the walk already has and the
58// filtered one would have to be computed.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
60pub struct AnnotId {
61    /// Which page.
62    pub page: PageIndex,
63    /// Which entry of that page's `/Annots` array — the raw index, counting
64    /// pop-ups.
65    pub index: u32,
66}
67
68impl AnnotId {
69    /// An annotation identifier, from a page index and a **raw** `/Annots`
70    /// index.
71    #[must_use]
72    pub fn new(page: impl Into<PageIndex>, index: u32) -> AnnotId {
73        AnnotId {
74            page: page.into(),
75            index,
76        }
77    }
78}
79
80/// Where keyboard input goes.
81///
82/// A non-widget annotation can hold focus too, once a caller adds its subtype
83/// to the focus ring — which is how tabbing to a link and pressing Return
84/// fires the link's action.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum FocusTarget {
87    /// A form field's widget.
88    Widget(FieldId, AnnotId),
89    /// A focusable annotation that is not a form widget.
90    Annot(AnnotId),
91}
92
93impl FocusTarget {
94    /// The annotation holding focus, whichever kind of target this is.
95    #[must_use]
96    pub fn annot(self) -> AnnotId {
97        match self {
98            FocusTarget::Widget(_, annot) | FocusTarget::Annot(annot) => annot,
99        }
100    }
101
102    /// The field holding focus, if the target is a widget.
103    #[must_use]
104    pub fn field(self) -> Option<FieldId> {
105        match self {
106            FocusTarget::Widget(field, _) => Some(field),
107            FocusTarget::Annot(_) => None,
108        }
109    }
110}
111
112/// A mouse drag in progress.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub struct DragAnchor {
115    /// Which field the drag started in.
116    pub field: FieldId,
117    /// Where in that field's text it started.
118    pub start: Place,
119}
120
121/// The switches a caller sets once and the engine reads.
122#[derive(Debug, Clone, PartialEq)]
123pub struct SessionConfig {
124    /// Which modifier means "this is a shortcut, not text".
125    ///
126    /// Control everywhere but Apple keyboards, where it is Meta. A field
127    /// rather than a compile-time platform test, so that both behaviours are
128    /// reachable — and testable — on one machine.
129    pub accelerator: Modifiers,
130    /// Whether the accelerator with `Y` redoes.
131    ///
132    /// True off Apple, false on it, where the same gesture is spelled with
133    /// shift and `Z`. That asymmetry is real and is reproduced.
134    pub redo_on_ctrl_y: bool,
135    /// Which annotation subtypes join the focus ring. Widgets alone by
136    /// default.
137    pub focusable: Vec<Subtype>,
138    /// How many undo items a field keeps.
139    ///
140    /// Clamped up to four: a replace-selection group is four items, and a
141    /// capacity that could not hold one would have to evict half a group.
142    pub max_undo_items: u32,
143    /// How deep a calculation may trigger another calculation.
144    ///
145    /// **One by default, which is the oracle's answer and not a loose reading
146    /// of it**: upstream guards the sweep with a flag rather than a counter,
147    /// so the outer sweep is authoritative and every nested call it provokes
148    /// returns immediately — no nesting at all. The knob makes that
149    /// configurable rather than looser, mirroring
150    /// [`Limits::max_calculate_depth`](pdfrum_common::Limits::max_calculate_depth).
151    pub max_calculate_depth: u32,
152}
153
154impl SessionConfig {
155    /// The defaults for an Apple keyboard: Meta accelerates, and the
156    /// accelerator with `Y` does nothing.
157    #[must_use]
158    pub fn apple() -> SessionConfig {
159        SessionConfig {
160            accelerator: Modifiers::META,
161            redo_on_ctrl_y: false,
162            ..SessionConfig::default()
163        }
164    }
165}
166
167impl Default for SessionConfig {
168    fn default() -> SessionConfig {
169        SessionConfig {
170            accelerator: Modifiers::CONTROL,
171            redo_on_ctrl_y: true,
172            focusable: vec![Subtype::Widget],
173            max_undo_items: crate::edit::UndoStack::DEFAULT_MAX,
174            max_calculate_depth: pdfrum_common::Limits::default().max_calculate_depth,
175        }
176    }
177}
178
179/// One interaction session over one document.
180#[derive(Debug, Clone, Default)]
181pub struct FormSession {
182    /// What has the keyboard, if anything.
183    pub focus: Option<FocusTarget>,
184    /// Per-field interaction state, created when a field is first touched.
185    ///
186    /// A field keeps its state across focus changes, which is why clicking
187    /// away from a half-typed field and back finds the typing still there.
188    pub fields: BTreeMap<FieldId, FieldState>,
189    /// What the pointer is over, for enter and exit.
190    pub hover: Option<AnnotId>,
191    /// A drag in progress, if one is.
192    pub drag: Option<DragAnchor>,
193    /// Fields whose interaction state has outrun the document's value.
194    pub dirty: BTreeSet<FieldId>,
195    /// What a `/AA /F` format script asked each field to *show*, which is not
196    /// what it stores.
197    ///
198    /// [`CommitOutcome::display`](crate::CommitOutcome::display) is a
199    /// formatting script's whole output, and this is where it survives until
200    /// the next appearance is generated — a field with
201    /// `AFNumber_Format(2, 0, 0, 0, "", true)` otherwise regenerates `(1234)`
202    /// where it should draw `(1,234.00)`.
203    ///
204    /// Keyed by **field** rather than by widget, because regeneration gives
205    /// every control of the field the same string. Cleared when a field takes
206    /// focus: the editor is seeded from the stored value and never from the
207    /// formatted text, so a user who clicks into a currency field sees `1234`
208    /// to edit, not `1,234.00`.
209    pub formatted: BTreeMap<FieldId, String>,
210    /// The switches.
211    pub config: SessionConfig,
212    /// `Field.borderStyle` writes a script made, keyed by field.
213    ///
214    /// Spent into appearance generation rather than written into `/BS`: the
215    /// dictionary stays as the file left it, and a regenerated stream sees
216    /// the new style through [`pdfrum_doc::ap::widget::LiveInput`].
217    pub border_styles: BTreeMap<FieldId, pdfrum_doc::ap::BorderStyle>,
218}
219
220impl FormSession {
221    /// An empty session with the default switches.
222    #[must_use]
223    pub fn new() -> FormSession {
224        FormSession::default()
225    }
226
227    /// An empty session with the given switches.
228    #[must_use]
229    pub fn with_config(config: SessionConfig) -> FormSession {
230        FormSession {
231            config,
232            ..FormSession::default()
233        }
234    }
235
236    /// The field that currently has the keyboard, if a field does.
237    #[must_use]
238    pub fn focused_field(&self) -> Option<FieldId> {
239        self.focus.and_then(FocusTarget::field)
240    }
241
242    /// The interaction state of the focused field, if there is one.
243    #[must_use]
244    pub fn focused_state(&self) -> Option<&FieldState> {
245        self.fields.get(&self.focused_field()?)
246    }
247
248    /// The interaction state of the focused field, mutably.
249    pub fn focused_state_mut(&mut self) -> Option<&mut FieldState> {
250        let field = self.focused_field()?;
251        self.fields.get_mut(&field)
252    }
253
254    /// Whether these modifiers make a gesture a shortcut in this session.
255    ///
256    /// One predicate, shared with the key router rather than restated here:
257    /// two spellings of the same question drift, and the oracle has only one.
258    #[must_use]
259    pub fn is_accelerator(&self, modifiers: Modifiers) -> bool {
260        crate::field::text::is_shortcut(modifiers, self.config.accelerator)
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn a_literal_converts_the_way_an_impl_into_argument_needs() {
270        fn takes(id: impl Into<FieldId>) -> FieldId {
271            id.into()
272        }
273        assert_eq!(takes(0), FieldId(0));
274        assert_eq!(takes(FieldId(3)), FieldId(3));
275        assert_eq!(u32::from(FieldId(3)), 3);
276    }
277
278    #[test]
279    fn a_fresh_session_has_no_focus_and_no_state() {
280        let session = FormSession::new();
281        assert!(session.focus.is_none());
282        assert!(session.focused_field().is_none());
283        assert!(session.fields.is_empty());
284        assert!(session.dirty.is_empty());
285    }
286
287    #[test]
288    fn a_focus_target_names_its_annotation_either_way() {
289        let annot = AnnotId::new(0, 3);
290        let widget = FocusTarget::Widget(FieldId(1), annot);
291        let plain = FocusTarget::Annot(annot);
292
293        assert_eq!(widget.annot(), annot);
294        assert_eq!(plain.annot(), annot);
295        assert_eq!(widget.field(), Some(FieldId(1)));
296        assert_eq!(plain.field(), None);
297    }
298
299    /// The defaults reproduce each platform's own behaviour, and both are
300    /// reachable from one machine — which is what makes the shortcut
301    /// assertions runnable at all.
302    #[test]
303    fn the_two_platform_configurations_differ_in_exactly_two_switches() {
304        let general = SessionConfig::default();
305        let apple = SessionConfig::apple();
306
307        assert_eq!(general.accelerator, Modifiers::CONTROL);
308        assert!(general.redo_on_ctrl_y);
309        assert_eq!(apple.accelerator, Modifiers::META);
310        assert!(!apple.redo_on_ctrl_y);
311        assert_eq!(general.focusable, apple.focusable);
312        assert_eq!(general.max_undo_items, apple.max_undo_items);
313    }
314
315    /// The wrong platform's modifier is rejected, which is asserted as hard
316    /// as the right one being accepted.
317    #[test]
318    fn the_accelerator_test_rejects_the_other_platforms_modifier() {
319        let session = FormSession::new();
320        assert!(session.is_accelerator(Modifiers::CONTROL));
321        assert!(session.is_accelerator(Modifiers::CONTROL | Modifiers::SHIFT));
322        assert!(!session.is_accelerator(Modifiers::META));
323        assert!(!session.is_accelerator(Modifiers::NONE));
324        // Alt disqualifies; an extra modifier does not, which is the
325        // oracle's subset answer.
326        assert!(!session.is_accelerator(Modifiers::CONTROL | Modifiers::ALT));
327        assert!(session.is_accelerator(Modifiers::CONTROL | Modifiers::META));
328
329        let apple = FormSession::with_config(SessionConfig::apple());
330        assert!(apple.is_accelerator(Modifiers::META));
331        assert!(!apple.is_accelerator(Modifiers::CONTROL));
332    }
333
334    #[test]
335    fn only_widgets_are_focusable_by_default() {
336        assert_eq!(SessionConfig::default().focusable, vec![Subtype::Widget]);
337    }
338}