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(options: Vec<ChoiceOption>, config: ChoiceConfig) -> ChoiceState {
356 ChoiceState {
357 options,
358 config,
359 ..ChoiceState::default()
360 }
361 }
362
363 /// The text this field reports as its focused text.
364 ///
365 /// The row last acted upon for a choice field, which for a single-select
366 /// field is the same thing as "the selected row" and for a multi-select
367 /// one deliberately is not.
368 ///
369 /// ```
370 /// use pdfrum_form::field::{ChoiceConfig, ChoiceOption, ChoiceState};
371 ///
372 /// let rows = ["Apple", "Banana", "Cherry", "Date"]
373 /// .map(|l| ChoiceOption { label: l.to_string(), value: l.to_string() })
374 /// .to_vec();
375 /// let mut state = ChoiceState::new(rows, ChoiceConfig::default());
376 /// state.selected.insert(0);
377 /// state.selected.insert(2);
378 ///
379 /// // With nothing acted upon, the first selected row answers.
380 /// assert_eq!(state.focused_text(), "Apple");
381 ///
382 /// // Acting on a row moves it — even to a row that is not selected.
383 /// state.caret_index = Some(3);
384 /// assert_eq!(state.focused_text(), "Date");
385 /// ```
386 #[must_use]
387 pub fn focused_text(&self) -> String {
388 // An **editable** combo box reports what is in its text half, which
389 // is not an option's label: typing into one inserts characters rather
390 // than jumping between options, and clears the index selection as it
391 // goes. A gated box has no text half to consult and answers with the
392 // row last acted upon.
393 if self.config.editable {
394 return self.edit_text.clone();
395 }
396 let index = self
397 .caret_index
398 .or_else(|| self.selected.iter().next().copied());
399 index
400 .and_then(|i| self.options.get(i))
401 .map(|o| o.label.clone())
402 .unwrap_or_default()
403 }
404}
405
406/// Which behaviour family a field kind belongs to.
407///
408/// Returns `None` for the two kinds that never get interaction state at all:
409/// a signature widget, which is never given an appearance and never takes an
410/// edit, and anything the classifier could not name.
411///
412/// ```
413/// use pdfrum_doc::form::FieldKind;
414/// use pdfrum_form::field::{Family, family_of};
415///
416/// // A combo box and a list box are one family, not two.
417/// assert_eq!(family_of(FieldKind::Combo), Some(Family::Choice));
418/// assert_eq!(family_of(FieldKind::List), Some(Family::Choice));
419/// assert_eq!(family_of(FieldKind::Signature), None);
420/// ```
421#[must_use]
422pub fn family_of(kind: FieldKind) -> Option<Family> {
423 match kind {
424 FieldKind::Text => Some(Family::Text),
425 FieldKind::Combo | FieldKind::List => Some(Family::Choice),
426 FieldKind::Check | FieldKind::Radio => Some(Family::Toggle),
427 FieldKind::Button => Some(Family::Button),
428 FieldKind::Signature => None,
429 }
430}
431
432/// The four behaviour families.
433///
434/// ```
435/// use pdfrum_doc::form::FieldKind;
436/// use pdfrum_form::field::{Family, family_of};
437///
438/// assert_eq!(family_of(FieldKind::Check), Some(Family::Toggle));
439/// assert_eq!(family_of(FieldKind::Radio), Some(Family::Toggle));
440/// ```
441#[derive(Debug, Clone, Copy, PartialEq, Eq)]
442pub enum Family {
443 /// A text field.
444 Text,
445 /// A combo box or list box.
446 Choice,
447 /// A check box or radio button.
448 Toggle,
449 /// A push button.
450 Button,
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 #[test]
458 fn a_signature_widget_gets_no_interaction_state() {
459 assert_eq!(family_of(FieldKind::Signature), None);
460 assert_eq!(family_of(FieldKind::Text), Some(Family::Text));
461 assert_eq!(family_of(FieldKind::Combo), Some(Family::Choice));
462 assert_eq!(family_of(FieldKind::List), Some(Family::Choice));
463 assert_eq!(family_of(FieldKind::Check), Some(Family::Toggle));
464 assert_eq!(family_of(FieldKind::Radio), Some(Family::Toggle));
465 assert_eq!(family_of(FieldKind::Button), Some(Family::Button));
466 }
467
468 /// `/Ff` as a bare word, so a bit-shift table reads as one.
469 fn ff(bits: i64) -> FieldFlags {
470 FieldFlags::from_bits(bits)
471 }
472
473 /// A non-positive limit means unlimited, which the type says rather than
474 /// the reader having to remember.
475 #[test]
476 fn a_zero_length_limit_is_no_limit() {
477 assert_eq!(TextConfig::read(ff(0), Some(0)).max_len, None);
478 assert_eq!(TextConfig::read(ff(0), None).max_len, None);
479 assert_eq!(
480 TextConfig::read(ff(0), Some(10)).max_len,
481 NonZeroU32::new(10)
482 );
483 }
484
485 /// Undo is on for every text field, from no flag at all.
486 #[test]
487 fn a_text_field_always_records_undo() {
488 assert!(TextConfig::read(ff(0), None).undo_enabled);
489 assert!(TextConfig::read(ff(1), None).undo_enabled);
490 }
491
492 #[test]
493 fn a_field_scrolls_unless_it_is_told_not_to() {
494 assert!(TextConfig::read(ff(0), None).auto_scroll);
495 assert!(TextConfig::read(ff(1 << 12), None).auto_scroll);
496 assert!(!TextConfig::read(ff(1 << 23), None).auto_scroll);
497 }
498
499 #[test]
500 fn text_flags_read_the_documented_bits() {
501 let config = TextConfig::read(ff((1 << 12) | (1 << 13) | (1 << 24) | 1), None);
502 assert!(config.multi_line);
503 assert!(config.password);
504 assert!(config.comb);
505 assert!(config.read_only);
506 }
507
508 /// The editable bit is a combo box's alone: a list box with the same bit
509 /// set is not editable, because the bit does not mean that there.
510 #[test]
511 fn only_a_combo_box_can_be_editable() {
512 let combo = ChoiceConfig::read(ff((1 << 17) | (1 << 18)));
513 assert!(combo.combo);
514 assert!(combo.editable);
515
516 let list = ChoiceConfig::read(ff(1 << 18));
517 assert!(!list.combo);
518 assert!(!list.editable);
519 }
520
521 /// And multi-select is a list box's alone, for the same reason.
522 #[test]
523 fn only_a_list_box_can_be_multi_select() {
524 let list = ChoiceConfig::read(ff(1 << 21));
525 assert!(list.multi_select);
526
527 let combo = ChoiceConfig::read(ff((1 << 17) | (1 << 21)));
528 assert!(!combo.multi_select);
529 }
530
531 fn options(labels: &[&str]) -> Vec<ChoiceOption> {
532 labels
533 .iter()
534 .map(|l| ChoiceOption {
535 label: (*l).to_string(),
536 value: (*l).to_string(),
537 })
538 .collect()
539 }
540
541 /// The focused text follows the row last acted upon, not the selection.
542 #[test]
543 fn focused_text_is_the_row_last_acted_upon() {
544 let mut state = ChoiceState::new(
545 options(&["Apple", "Banana", "Cherry", "Date"]),
546 ChoiceConfig::default(),
547 );
548 state.selected.insert(0);
549 state.selected.insert(2);
550
551 // With nothing acted upon, the first selected row answers.
552 assert_eq!(state.focused_text(), "Apple");
553
554 // Acting on a row moves it, even to a row that is not selected.
555 state.caret_index = Some(3);
556 assert_eq!(state.focused_text(), "Date");
557 }
558
559 #[test]
560 fn an_empty_choice_field_reports_no_text() {
561 let state = ChoiceState::new(Vec::new(), ChoiceConfig::default());
562 assert_eq!(state.focused_text(), "");
563 }
564}