pdfrum_form/field/mod.rs
1//! Per-field interaction state, and the configuration read once from the
2//! file.
3//!
4//! There are four state families, not seven field types: a combo box and a
5//! list box share a machine, and a check box and a radio button share one.
6//! Grouping by behaviour rather than by `/FT` is what keeps each machine
7//! small enough to state as a transition table.
8//!
9//! **Configuration is a record, not a bit mask.** Each switch is a named
10//! field on its own kind's type, read once when the field is first touched.
11// The oracle packs flags, quadding and length limit into one 32-bit style
12// word whose low bits are overloaded across widget families — the same bit is
13// multiline for an edit, multi-select for a list box and allow-custom-text for
14// a combo box — and masks sub-styles off when it builds a child. Separate
15// types leave the overloading nowhere to happen.
16
17mod button;
18// The three below stay `pub`: their *contents* are the surface — the pure
19// per-kind operations a caller drives without a session, which is how every
20// ported assertion in `tests/` is written.
21pub mod choice;
22pub mod text;
23pub mod toggle;
24
25use std::num::NonZeroU32;
26
27use pdfrum_doc::form::{FieldFlags, FieldKind};
28
29pub use button::ButtonState;
30pub use toggle::{ToggleKind, ToggleState, activate};
31
32/// How a field's text is set, read once from its flags and entries.
33///
34/// The layout half of these switches is already the variable-text engine's
35/// business; what this record adds is the editor's own — whether the field
36/// accepts edits at all, whether it records undo, and whether it scrolls.
37///
38/// The booleans are the point rather than a smell: each one is a distinct
39/// `/Ff` bit with its own meaning, and the alternative the lint suggests — a
40/// packed flag word — is exactly the design this record exists to replace,
41/// where the same bit means different things to different field kinds.
42///
43/// ```
44/// use pdfrum_form::TextConfig;
45///
46/// // The default record is the plain single-line field with no length limit;
47/// // a real field's switches come from [`TextConfig::read`].
48/// let config = TextConfig::default();
49/// assert!(!config.multi_line);
50/// assert_eq!(config.max_len, None);
51/// ```
52#[allow(clippy::struct_excessive_bools)]
53#[derive(Debug, Clone, PartialEq, Eq, Default)]
54pub struct TextConfig {
55 /// Whether the field accepts more than one line.
56 pub multi_line: bool,
57 /// Whether the field's contents are obscured as they are typed.
58 pub password: bool,
59 /// Whether the field is laid out as a row of equal cells.
60 pub comb: bool,
61 /// The cap on how many characters the field holds, if it has one.
62 ///
63 /// An `Option` rather than a sentinel, which is how "a non-positive limit
64 /// means unlimited" stops being a rule anyone has to remember.
65 pub max_len: Option<NonZeroU32>,
66 /// Whether the field refuses edits.
67 pub read_only: bool,
68 /// Whether the field records undo. Always true for a text field.
69 pub undo_enabled: bool,
70 /// Whether the field scrolls its content to follow the caret.
71 pub auto_scroll: bool,
72}
73
74impl TextConfig {
75 /// Reads a text field's configuration from its flags and length limit.
76 ///
77 /// The scrolling rule is the one worth reading twice: a field scrolls
78 /// unless it is explicitly told not to, and that is true whether or not
79 /// it is multiline — the two cases differ in what else they turn on, not
80 /// in whether they scroll.
81 ///
82 /// ```
83 /// use pdfrum_doc::form::FieldFlags;
84 /// use pdfrum_form::TextConfig;
85 ///
86 /// // Multiline (`/Ff` bit 13) still scrolls, and a zero `/MaxLen` is no
87 /// // limit rather than a limit of nothing.
88 /// let config = TextConfig::read(FieldFlags::from_bits(1 << 12), Some(0));
89 /// assert!(config.multi_line);
90 /// assert!(config.auto_scroll);
91 /// assert_eq!(config.max_len, None);
92 /// assert!(config.undo_enabled, "undo is on for every text field");
93 ///
94 /// // Only the do-not-scroll bit (24) turns scrolling off.
95 /// assert!(!TextConfig::read(FieldFlags::from_bits(1 << 23), None).auto_scroll);
96 /// ```
97 #[must_use]
98 pub fn read(flags: FieldFlags, max_len: Option<u32>) -> TextConfig {
99 TextConfig {
100 multi_line: flags.is_multiline(),
101 password: flags.is_password(),
102 comb: flags.is_comb(),
103 max_len: max_len.and_then(NonZeroU32::new),
104 read_only: flags.is_read_only(),
105 // Undo is always available on a text field: the oracle turns it
106 // on unconditionally rather than from any flag.
107 undo_enabled: true,
108 auto_scroll: flags.scrolls(),
109 }
110 }
111}
112
113/// How a choice field behaves, read once from its flags.
114///
115/// Four independent `/Ff` bits; see [`TextConfig`] for why they stay separate
116/// named fields rather than becoming a mask.
117///
118/// ```
119/// use pdfrum_form::ChoiceConfig;
120///
121/// // The default record is a plain single-select list box.
122/// let config = ChoiceConfig::default();
123/// assert!(!config.combo);
124/// assert!(!config.multi_select);
125/// ```
126#[allow(clippy::struct_excessive_bools)]
127#[derive(Debug, Clone, PartialEq, Eq, Default)]
128pub struct ChoiceConfig {
129 /// Whether the field is a drop-down rather than a list.
130 pub combo: bool,
131 /// Whether a combo box also accepts typed text.
132 pub editable: bool,
133 /// Whether a list box accepts more than one selected row.
134 pub multi_select: bool,
135 /// Whether the field refuses edits.
136 pub read_only: bool,
137}
138
139impl ChoiceConfig {
140 /// Reads a choice field's configuration from its flags.
141 ///
142 /// The editable bit is a combo box's alone and the multi-select bit is a
143 /// list box's alone: the same bit set on the other kind reads as nothing,
144 /// because it does not mean that there.
145 ///
146 /// ```
147 /// use pdfrum_doc::form::FieldFlags;
148 /// use pdfrum_form::ChoiceConfig;
149 ///
150 /// // Combo (bit 18) plus editable (bit 19).
151 /// let combo = ChoiceConfig::read(FieldFlags::from_bits((1 << 17) | (1 << 18)));
152 /// assert!(combo.combo && combo.editable);
153 ///
154 /// // The same editable bit on a list box is not read as anything.
155 /// let list = ChoiceConfig::read(FieldFlags::from_bits(1 << 18));
156 /// assert!(!list.combo && !list.editable);
157 ///
158 /// // And multi-select (bit 22) is only a list box's.
159 /// assert!(ChoiceConfig::read(FieldFlags::from_bits(1 << 21)).multi_select);
160 /// ```
161 #[must_use]
162 pub fn read(flags: FieldFlags) -> ChoiceConfig {
163 ChoiceConfig {
164 combo: flags.is_combo(),
165 // Only a combo box can be editable; the bit is meaningless on a
166 // list box and is not read as anything there.
167 editable: flags.is_combo() && flags.is_editable_combo(),
168 multi_select: !flags.is_combo() && flags.is_multi_select(),
169 read_only: flags.is_read_only(),
170 }
171 }
172}
173
174/// A field's interaction state, one variant per behaviour family.
175///
176/// ```
177/// use pdfrum_form::{ChoiceConfig, ChoiceState, FieldState};
178///
179/// let state = FieldState::Choice(ChoiceState::new(Vec::new(), ChoiceConfig::default()));
180/// assert!(matches!(state, FieldState::Choice(_)));
181/// ```
182#[derive(Debug, Clone, PartialEq)]
183pub enum FieldState {
184 /// A text field, or the text half of an editable combo box.
185 Text(TextState),
186 /// A combo box or a list box.
187 Choice(ChoiceState),
188 /// A check box or a radio button.
189 Toggle(ToggleState),
190 /// A push button.
191 Button(ButtonState),
192}
193
194/// A text field's interaction state.
195///
196/// The edit control **is** the state: the text, its layout, the caret, the
197/// selection, the scroll offset and the undo stack are one record with one
198/// invariant, rather than a string here and a stack there that a mutation has
199/// to remember to keep in step.
200///
201/// ```
202/// use pdfrum_doc::vt::{Config, Metrics};
203/// use pdfrum_form::{TextConfig, TextState};
204///
205/// let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
206/// let config = Config {
207/// plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
208/// font_size: 1.0,
209/// ..Default::default()
210/// };
211/// let state = TextState {
212/// edit: pdfrum_form::edit::TextEdit::new("hello", &config, &metrics, true),
213/// config: TextConfig::default(),
214/// };
215/// assert_eq!(state.text(), "hello");
216/// ```
217#[derive(Debug, Clone, PartialEq)]
218pub struct TextState {
219 /// The live edit control: text, layout, caret, selection, undo.
220 pub edit: crate::edit::TextEdit,
221 /// How the field is configured.
222 pub config: TextConfig,
223}
224
225impl TextState {
226 /// The text as the user has it, which may differ from the field's stored
227 /// value until the edit commits.
228 ///
229 /// ```
230 /// use pdfrum_doc::vt::{Config, Metrics};
231 /// use pdfrum_form::{TextConfig, TextState};
232 ///
233 /// let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
234 /// let config = Config {
235 /// plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
236 /// font_size: 1.0,
237 /// ..Default::default()
238 /// };
239 /// let mut state = TextState {
240 /// edit: pdfrum_form::edit::TextEdit::new("old", &config, &metrics, true),
241 /// config: TextConfig::default(),
242 /// };
243 ///
244 /// // Typing moves the live text; the field's stored `/V` is untouched
245 /// // until the edit commits.
246 /// state.edit.set_caret_index(3);
247 /// pdfrum_form::edit::ops::insert_char(&mut state.edit, &config, &metrics, 'X', None);
248 /// assert_eq!(state.text(), "oldX");
249 /// ```
250 #[must_use]
251 pub fn text(&self) -> &str {
252 &self.edit.text
253 }
254}
255
256/// One row of a choice field.
257///
258/// ```
259/// use pdfrum_form::field::ChoiceOption;
260///
261/// // A row whose stored value differs from what it displays.
262/// let row = ChoiceOption { label: "Deutschland".to_string(), value: "DE".to_string() };
263/// assert_eq!(row.value, "DE");
264/// ```
265#[derive(Debug, Clone, PartialEq, Eq, Default)]
266pub struct ChoiceOption {
267 /// What the row displays.
268 pub label: String,
269 /// What the row stores, when that differs from what it displays.
270 pub value: String,
271}
272
273/// A combo box or list box's interaction state.
274///
275/// ```
276/// use pdfrum_form::field::{ChoiceConfig, ChoiceOption, ChoiceState, choice};
277///
278/// let rows = ["Apple", "Banana"]
279/// .map(|l| ChoiceOption { label: l.to_string(), value: l.to_string() })
280/// .to_vec();
281/// let mut state = ChoiceState::new(rows, ChoiceConfig::default());
282/// choice::select_only(&mut state, 1);
283/// assert_eq!(state.focused_text(), "Banana");
284/// assert!(!state.popup_open, "nothing a file says opens a dropdown");
285/// ```
286#[derive(Debug, Clone, Default, PartialEq)]
287pub struct ChoiceState {
288 /// The rows.
289 pub options: Vec<ChoiceOption>,
290 /// Which rows are selected.
291 pub selected: std::collections::BTreeSet<usize>,
292 /// The row last acted upon.
293 ///
294 /// Not a description of the selection: a multi-select list box reports
295 /// *this* row's text as its focused text, and a call that selects nothing
296 /// — deselecting an already-deselected row — still moves it. That is the
297 /// asserted behaviour, however odd it reads.
298 pub caret_index: Option<usize>,
299 /// The pivot a shift-click ranges from. Successive shift-clicks all
300 /// pivot on the same row, so this is not updated by one.
301 pub anchor: Option<usize>,
302 /// The first row currently visible.
303 pub top_visible: usize,
304 /// Whether a combo box's dropdown is open.
305 ///
306 /// Meaningless on a list box, which has no second window to open, and
307 /// never set on one.
308 ///
309 /// Open is a *session* fact and not a document one: nothing a file can
310 /// say opens a dropdown, and the only thing that does is a click on the
311 /// drop button, a `Return`, or a `Space` on a gated combo. What it
312 /// changes is where a click lands — a point below the widget is inside
313 /// the list rather than a miss — and what a host is told to draw
314 /// ([`crate::popup::PopupView`]).
315 pub popup_open: bool,
316 /// The row the pointer is over while the dropdown is open.
317 ///
318 /// Hovering a row *selects* it. Kept beside the selection rather than
319 /// folded into it, so that closing the list without clicking leaves the
320 /// stored selection alone.
321 pub hovered: Option<usize>,
322 /// How the field is configured.
323 pub config: ChoiceConfig,
324 /// What an **editable** combo box has in its text box.
325 ///
326 /// Kept as a string beside the control rather than only inside it, so a
327 /// combo that has never been typed into still answers its text without a
328 /// layout — and so the control itself can be dropped and rebuilt when the
329 /// font or the plate changes.
330 pub edit_text: String,
331 /// The editable combo's live edit control, once one has been typed into.
332 ///
333 /// `None` until the first character: a non-editable combo never has one,
334 /// and an editable one that has only been clicked does not need one. It
335 /// is boxed because it is much larger than the rest of this record and
336 /// absent in the common case.
337 pub edit: Option<Box<crate::edit::TextEdit>>,
338}
339
340impl ChoiceState {
341 /// A choice field with the given rows and configuration.
342 ///
343 /// Nothing is selected and nothing has been acted upon yet, so the field
344 /// reports no focused text at all.
345 ///
346 /// ```
347 /// use pdfrum_form::{ChoiceConfig, ChoiceState};
348 ///
349 /// let state = ChoiceState::new(Vec::new(), ChoiceConfig::default());
350 /// assert!(state.selected.is_empty());
351 /// assert_eq!(state.caret_index, None);
352 /// assert_eq!(state.focused_text(), "");
353 /// ```
354 #[must_use]
355 pub fn new(
356 options: impl IntoIterator<Item = ChoiceOption>,
357 config: ChoiceConfig,
358 ) -> ChoiceState {
359 ChoiceState {
360 options: options.into_iter().collect(),
361 config,
362 ..ChoiceState::default()
363 }
364 }
365
366 /// The text this field reports as its focused text.
367 ///
368 /// The row last acted upon for a choice field, which for a single-select
369 /// field is the same thing as "the selected row" and for a multi-select
370 /// one deliberately is not.
371 ///
372 /// ```
373 /// use pdfrum_form::field::{ChoiceConfig, ChoiceOption, ChoiceState};
374 ///
375 /// let rows = ["Apple", "Banana", "Cherry", "Date"]
376 /// .map(|l| ChoiceOption { label: l.to_string(), value: l.to_string() })
377 /// .to_vec();
378 /// let mut state = ChoiceState::new(rows, ChoiceConfig::default());
379 /// state.selected.insert(0);
380 /// state.selected.insert(2);
381 ///
382 /// // With nothing acted upon, the first selected row answers.
383 /// assert_eq!(state.focused_text(), "Apple");
384 ///
385 /// // Acting on a row moves it — even to a row that is not selected.
386 /// state.caret_index = Some(3);
387 /// assert_eq!(state.focused_text(), "Date");
388 /// ```
389 #[must_use]
390 pub fn focused_text(&self) -> String {
391 // An **editable** combo box reports what is in its text half, which
392 // is not an option's label: typing into one inserts characters rather
393 // than jumping between options, and clears the index selection as it
394 // goes. A gated box has no text half to consult and answers with the
395 // row last acted upon.
396 if self.config.editable {
397 return self.edit_text.clone();
398 }
399 let index = self
400 .caret_index
401 .or_else(|| self.selected.iter().next().copied());
402 index
403 .and_then(|i| self.options.get(i))
404 .map(|o| o.label.clone())
405 .unwrap_or_default()
406 }
407}
408
409/// Which behaviour family a field kind belongs to.
410///
411/// Returns `None` for the two kinds that never get interaction state at all:
412/// a signature widget, which is never given an appearance and never takes an
413/// edit, and anything the classifier could not name.
414///
415/// ```
416/// use pdfrum_doc::form::FieldKind;
417/// use pdfrum_form::field::{Family, family_of};
418///
419/// // A combo box and a list box are one family, not two.
420/// assert_eq!(family_of(FieldKind::Combo), Some(Family::Choice));
421/// assert_eq!(family_of(FieldKind::List), Some(Family::Choice));
422/// assert_eq!(family_of(FieldKind::Signature), None);
423/// ```
424#[must_use]
425pub fn family_of(kind: FieldKind) -> Option<Family> {
426 match kind {
427 FieldKind::Text => Some(Family::Text),
428 FieldKind::Combo | FieldKind::List => Some(Family::Choice),
429 FieldKind::Check | FieldKind::Radio => Some(Family::Toggle),
430 FieldKind::Button => Some(Family::Button),
431 FieldKind::Signature => None,
432 }
433}
434
435/// The four behaviour families.
436///
437/// ```
438/// use pdfrum_doc::form::FieldKind;
439/// use pdfrum_form::field::{Family, family_of};
440///
441/// assert_eq!(family_of(FieldKind::Check), Some(Family::Toggle));
442/// assert_eq!(family_of(FieldKind::Radio), Some(Family::Toggle));
443/// ```
444#[derive(Debug, Clone, Copy, PartialEq, Eq)]
445pub enum Family {
446 /// A text field.
447 Text,
448 /// A combo box or list box.
449 Choice,
450 /// A check box or radio button.
451 Toggle,
452 /// A push button.
453 Button,
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459
460 #[test]
461 fn a_signature_widget_gets_no_interaction_state() {
462 assert_eq!(family_of(FieldKind::Signature), None);
463 assert_eq!(family_of(FieldKind::Text), Some(Family::Text));
464 assert_eq!(family_of(FieldKind::Combo), Some(Family::Choice));
465 assert_eq!(family_of(FieldKind::List), Some(Family::Choice));
466 assert_eq!(family_of(FieldKind::Check), Some(Family::Toggle));
467 assert_eq!(family_of(FieldKind::Radio), Some(Family::Toggle));
468 assert_eq!(family_of(FieldKind::Button), Some(Family::Button));
469 }
470
471 /// `/Ff` as a bare word, so a bit-shift table reads as one.
472 fn ff(bits: i64) -> FieldFlags {
473 FieldFlags::from_bits(bits)
474 }
475
476 /// A non-positive limit means unlimited, which the type says rather than
477 /// the reader having to remember.
478 #[test]
479 fn a_zero_length_limit_is_no_limit() {
480 assert_eq!(TextConfig::read(ff(0), Some(0)).max_len, None);
481 assert_eq!(TextConfig::read(ff(0), None).max_len, None);
482 assert_eq!(
483 TextConfig::read(ff(0), Some(10)).max_len,
484 NonZeroU32::new(10)
485 );
486 }
487
488 /// Undo is on for every text field, from no flag at all.
489 #[test]
490 fn a_text_field_always_records_undo() {
491 assert!(TextConfig::read(ff(0), None).undo_enabled);
492 assert!(TextConfig::read(ff(1), None).undo_enabled);
493 }
494
495 #[test]
496 fn a_field_scrolls_unless_it_is_told_not_to() {
497 assert!(TextConfig::read(ff(0), None).auto_scroll);
498 assert!(TextConfig::read(ff(1 << 12), None).auto_scroll);
499 assert!(!TextConfig::read(ff(1 << 23), None).auto_scroll);
500 }
501
502 #[test]
503 fn text_flags_read_the_documented_bits() {
504 let config = TextConfig::read(ff((1 << 12) | (1 << 13) | (1 << 24) | 1), None);
505 assert!(config.multi_line);
506 assert!(config.password);
507 assert!(config.comb);
508 assert!(config.read_only);
509 }
510
511 /// The editable bit is a combo box's alone: a list box with the same bit
512 /// set is not editable, because the bit does not mean that there.
513 #[test]
514 fn only_a_combo_box_can_be_editable() {
515 let combo = ChoiceConfig::read(ff((1 << 17) | (1 << 18)));
516 assert!(combo.combo);
517 assert!(combo.editable);
518
519 let list = ChoiceConfig::read(ff(1 << 18));
520 assert!(!list.combo);
521 assert!(!list.editable);
522 }
523
524 /// And multi-select is a list box's alone, for the same reason.
525 #[test]
526 fn only_a_list_box_can_be_multi_select() {
527 let list = ChoiceConfig::read(ff(1 << 21));
528 assert!(list.multi_select);
529
530 let combo = ChoiceConfig::read(ff((1 << 17) | (1 << 21)));
531 assert!(!combo.multi_select);
532 }
533
534 fn options(labels: &[&str]) -> Vec<ChoiceOption> {
535 labels
536 .iter()
537 .map(|l| ChoiceOption {
538 label: (*l).to_string(),
539 value: (*l).to_string(),
540 })
541 .collect()
542 }
543
544 /// The focused text follows the row last acted upon, not the selection.
545 #[test]
546 fn focused_text_is_the_row_last_acted_upon() {
547 let mut state = ChoiceState::new(
548 options(&["Apple", "Banana", "Cherry", "Date"]),
549 ChoiceConfig::default(),
550 );
551 state.selected.insert(0);
552 state.selected.insert(2);
553
554 // With nothing acted upon, the first selected row answers.
555 assert_eq!(state.focused_text(), "Apple");
556
557 // Acting on a row moves it, even to a row that is not selected.
558 state.caret_index = Some(3);
559 assert_eq!(state.focused_text(), "Date");
560 }
561
562 #[test]
563 fn an_empty_choice_field_reports_no_text() {
564 let state = ChoiceState::new(Vec::new(), ChoiceConfig::default());
565 assert_eq!(state.focused_text(), "");
566 }
567}