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