Skip to main content

pdfrum_form/field/
toggle.rs

1//! Check boxes and radio buttons: the two controls whose value is a state
2//! name rather than text.
3//!
4//! They share a machine and differ in one line. A check box **toggles**; a
5//! radio button **sets**, unconditionally, and there is no way to un-select
6//! one by clicking it.
7//!
8//! Two rules that look like bugs and are not: **a read-only control consumes
9//! the keystroke and does nothing** (consumption and effect are independent),
10//! and **neither control handles arrow keys at all**.
11
12/// The off state's appearance name, which every check box and radio button
13/// shares.
14///
15/// A file may name the *on* state anything; this is the only reserved
16/// spelling.
17///
18/// ```
19/// use pdfrum_form::field::{ToggleState, toggle::OFF_STATE};
20///
21/// let mut state = ToggleState::new(OFF_STATE, "Yes");
22/// state.set_checked(true);
23/// assert_eq!(state.state, "Yes");
24/// ```
25pub const OFF_STATE: &str = "Off";
26
27/// A check box or radio button's interaction state.
28///
29/// One fact: which appearance state the control is showing.
30///
31/// ```
32/// use pdfrum_form::field::{ToggleKind, ToggleState, activate, toggle::OFF_STATE};
33///
34/// let mut state = ToggleState::new(OFF_STATE, "On");
35/// assert!(!state.is_checked());
36/// activate(&mut state, ToggleKind::Check, false);
37/// assert_eq!(state.state, "On");
38/// ```
39#[derive(Debug, Clone, PartialEq, Eq, Default)]
40pub struct ToggleState {
41    /// The appearance state name currently selected — the "on" name when the
42    /// control is checked, [`OFF_STATE`] when it is not.
43    pub state: String,
44    /// The name this control shows when checked, from its appearance
45    /// dictionary. Empty when the control offers no on state at all.
46    pub on_state: String,
47    /// Which **control** of the field is the checked one, by its raw
48    /// `/Annots` index, once one has been chosen.
49    ///
50    /// A field's controls share one state here, which is right for a check
51    /// box (a field with one kid) and not enough for a radio group, where the
52    /// clicked control shows its own on state and every other control of the
53    /// field shows `Off`. This records the half of that a shared state *can*
54    /// express — **which** control is on — so a sibling's appearance can be
55    /// answered `Off` without a second state record.
56    ///
57    /// `None` before any control has been activated, which is the state a
58    /// group loaded from a file with no `/V` is in.
59    pub checked_control: Option<crate::session::AnnotId>,
60}
61
62impl ToggleState {
63    /// A control showing `state`, whose checked appearance is `on_state`.
64    ///
65    /// An empty `on_state` means the control offers no checked appearance at
66    /// all, and it can then never be checked.
67    ///
68    /// ```
69    /// use pdfrum_form::field::{ToggleState, toggle::OFF_STATE};
70    ///
71    /// let state = ToggleState::new(OFF_STATE, "On");
72    /// assert!(!state.is_checked());
73    /// assert_eq!(state.checked_control, None, "nothing has been clicked yet");
74    /// ```
75    #[must_use]
76    pub fn new(state: impl Into<String>, on_state: impl Into<String>) -> ToggleState {
77        ToggleState {
78            state: state.into(),
79            on_state: on_state.into(),
80            checked_control: None,
81        }
82    }
83
84    /// Whether the control is currently checked.
85    ///
86    /// An empty state reads as clear rather than as ambiguous: a widget with
87    /// no appearance state at all is not checked.
88    ///
89    /// ```
90    /// use pdfrum_form::field::ToggleState;
91    ///
92    /// assert!(ToggleState::new("On", "On").is_checked());
93    /// assert!(!ToggleState::new("Off", "On").is_checked());
94    /// assert!(!ToggleState::new("", "On").is_checked());
95    /// ```
96    #[must_use]
97    pub fn is_checked(&self) -> bool {
98        self.state != OFF_STATE && !self.state.is_empty()
99    }
100
101    /// The appearance state one **control** of this field should draw.
102    ///
103    /// The per-control answer a shared [`ToggleState`] can give: the clicked
104    /// control shows its own on state and every other control of the same
105    /// field shows `Off`.
106    ///
107    /// Three cases, and the middle one is why this is not simply
108    /// [`Self::state`]:
109    ///
110    /// - **Nothing has been clicked** ([`Self::checked_control`] is `None`):
111    ///   `None`, meaning "read the widget's own `/AS`" — a group loaded from a
112    ///   file must render exactly as the file wrote it, kid by kid.
113    /// - **This control is the chosen one**: its own on state, which for a
114    ///   radio group is a name only this kid carries.
115    /// - **A sibling was chosen**: [`OFF_STATE`], whatever the file's `/AS`
116    ///   still says.
117    ///
118    /// ```
119    /// use pdfrum_form::AnnotId;
120    /// use pdfrum_form::field::{ToggleState, toggle::OFF_STATE};
121    ///
122    /// let clicked = AnnotId::new(0u32, 1);
123    /// let sibling = AnnotId::new(0u32, 2);
124    ///
125    /// let mut state = ToggleState::new(OFF_STATE, "Choice1");
126    /// // Nothing clicked yet: read each widget's own `/AS`.
127    /// assert_eq!(state.state_for_control(clicked), None);
128    ///
129    /// state.set_checked(true);
130    /// state.checked_control = Some(clicked);
131    /// assert_eq!(state.state_for_control(clicked), Some("Choice1"));
132    /// assert_eq!(state.state_for_control(sibling), Some(OFF_STATE));
133    /// ```
134    // A check box is a field with one control, so `control` is always the
135    // chosen one once anything has been clicked and the answer is its own
136    // state either way.
137    #[must_use]
138    pub fn state_for_control(&self, control: crate::session::AnnotId) -> Option<&str> {
139        let chosen = self.checked_control?;
140        if chosen == control {
141            Some(&self.state)
142        } else {
143            Some(OFF_STATE)
144        }
145    }
146
147    /// Sets the control checked or clear.
148    ///
149    /// Checking a control with no on state leaves it clear: there is no
150    /// appearance to show.
151    ///
152    /// ```
153    /// use pdfrum_form::field::{ToggleState, toggle::OFF_STATE};
154    ///
155    /// let mut none = ToggleState::new(OFF_STATE, "");
156    /// none.set_checked(true);
157    /// assert!(!none.is_checked());
158    /// ```
159    pub fn set_checked(&mut self, checked: bool) {
160        if checked && !self.on_state.is_empty() {
161            self.state.clone_from(&self.on_state);
162        } else {
163            self.state = OFF_STATE.to_string();
164        }
165    }
166
167    /// Flips the control. What a click on a check box does.
168    ///
169    /// ```
170    /// use pdfrum_form::field::{ToggleState, toggle::OFF_STATE};
171    ///
172    /// let mut state = ToggleState::new(OFF_STATE, "On");
173    /// state.toggle();
174    /// assert!(state.is_checked());
175    /// state.toggle();
176    /// assert!(!state.is_checked());
177    /// ```
178    pub fn toggle(&mut self) {
179        let checked = self.is_checked();
180        self.set_checked(!checked);
181    }
182}
183
184/// Which of the two controls a widget is, for the one line where they differ.
185///
186/// ```
187/// use pdfrum_form::field::{ToggleKind, ToggleState, activate, toggle::OFF_STATE};
188///
189/// // A check box flips; a radio button sets and never clears.
190/// let mut check = ToggleState::new(OFF_STATE, "On");
191/// activate(&mut check, ToggleKind::Check, false);
192/// activate(&mut check, ToggleKind::Check, false);
193/// assert!(!check.is_checked());
194///
195/// let mut radio = ToggleState::new(OFF_STATE, "On");
196/// activate(&mut radio, ToggleKind::Radio, false);
197/// activate(&mut radio, ToggleKind::Radio, false);
198/// assert!(radio.is_checked());
199/// ```
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub enum ToggleKind {
202    /// A check box: activation flips it.
203    Check,
204    /// A radio button: activation sets it, and never clears it.
205    Radio,
206}
207
208/// Activates a control — what a click or a Return or Space does.
209///
210/// Returns whether the state moved. A read-only control never moves, but its
211/// caller still reports the event as consumed.
212///
213/// ```
214/// use pdfrum_form::field::{ToggleKind, ToggleState, activate, toggle::OFF_STATE};
215///
216/// let mut state = ToggleState::new(OFF_STATE, "On");
217/// assert!(activate(&mut state, ToggleKind::Check, false));
218/// assert!(state.is_checked());
219///
220/// // A radio button re-activated is a no-op, not a clear.
221/// let mut radio = ToggleState::new(OFF_STATE, "On");
222/// activate(&mut radio, ToggleKind::Radio, false);
223/// assert!(!activate(&mut radio, ToggleKind::Radio, false));
224/// assert!(radio.is_checked());
225///
226/// // A read-only control does not move.
227/// let mut fixed = ToggleState::new(OFF_STATE, "On");
228/// assert!(!activate(&mut fixed, ToggleKind::Check, true));
229/// assert!(!fixed.is_checked());
230/// ```
231pub fn activate(state: &mut ToggleState, kind: ToggleKind, read_only: bool) -> bool {
232    if read_only {
233        return false;
234    }
235    let before = state.state.clone();
236    match kind {
237        ToggleKind::Check => state.toggle(),
238        // A radio button sets. Clicking a selected one again leaves it
239        // selected; only a sibling can clear it.
240        ToggleKind::Radio => state.set_checked(true),
241    }
242    state.state != before
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    fn check() -> ToggleState {
250        ToggleState::new(OFF_STATE, "On")
251    }
252
253    #[test]
254    fn a_check_box_flips_each_time_it_is_activated() {
255        let mut state = check();
256        assert!(!state.is_checked());
257
258        assert!(activate(&mut state, ToggleKind::Check, false));
259        assert!(state.is_checked());
260        assert_eq!(state.state, "On");
261
262        assert!(activate(&mut state, ToggleKind::Check, false));
263        assert!(!state.is_checked());
264        assert_eq!(state.state, OFF_STATE);
265    }
266
267    /// The asymmetry with a check box, and the whole reason the two share a
268    /// machine rather than a function.
269    #[test]
270    fn a_radio_button_cannot_be_cleared_by_activating_it_again() {
271        let mut state = check();
272        assert!(activate(&mut state, ToggleKind::Radio, false));
273        assert!(state.is_checked());
274
275        // Activating it again is a no-op, not a clear.
276        assert!(!activate(&mut state, ToggleKind::Radio, false));
277        assert!(state.is_checked());
278    }
279
280    /// The event is consumed by the caller; the state does not move. Both
281    /// halves are asserted upstream.
282    #[test]
283    fn a_read_only_control_does_not_move() {
284        let mut state = check();
285        assert!(!activate(&mut state, ToggleKind::Check, true));
286        assert!(!state.is_checked());
287
288        let mut radio = check();
289        assert!(!activate(&mut radio, ToggleKind::Radio, true));
290        assert!(!radio.is_checked());
291    }
292
293    #[test]
294    fn a_control_with_no_on_state_stays_clear() {
295        let mut state = ToggleState::new(OFF_STATE, "");
296        assert!(!activate(&mut state, ToggleKind::Check, false));
297        assert!(!state.is_checked());
298    }
299
300    /// A file may name the on state anything; "Off" is the only reserved
301    /// spelling.
302    #[test]
303    fn the_on_state_name_comes_from_the_file() {
304        let mut state = ToggleState::new(OFF_STATE, "Yes");
305        state.set_checked(true);
306        assert_eq!(state.state, "Yes");
307        assert!(state.is_checked());
308
309        state.set_checked(false);
310        assert_eq!(state.state, OFF_STATE);
311    }
312
313    /// An empty state is not a checked one — a widget with no appearance
314    /// state at all reads as clear rather than as ambiguous.
315    #[test]
316    fn an_empty_state_reads_as_clear() {
317        assert!(!ToggleState::new("", "On").is_checked());
318    }
319}