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}