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}