Skip to main content

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}