Skip to main content

azul_core/
form.rs

1//! Form constraint validation - the shared vocabulary for `Invalid`.
2//!
3//! The RULES live in `azul_layout::form`, because they need the DOM and the
4//! text pipeline. What lives here is the ANSWER: which constraints a control
5//! failed, in a shape that crosses the C ABI and that a callback can read.
6//!
7//! # Why this is a manager and not an `EventData` variant
8//!
9//! The obvious design - and the one this item was originally filed against -
10//! is a new `EventData::Validity(..)`. It does not work: `CallbackInfo` never
11//! sees the `SyntheticEvent`. It carries the hit node and read-only access to
12//! the `LayoutWindow`, and every other event payload an app can actually read
13//! is parked in a manager and fetched by an accessor (`peek_raw_motion` is the
14//! same shape). An `EventData` variant would have been an ABI addition that
15//! no application could observe.
16
17use alloc::{collections::BTreeMap, vec::Vec};
18
19use crate::dom::DomNodeId;
20
21/// Why a control failed validation.
22///
23/// Mirrors the flags HTML's `ValidityState` exposes, minus the ones no
24/// attribute azul understands can produce. New reasons are APPENDED - the
25/// discriminant is a bit position in [`ValidityState`], so renumbering would
26/// silently change what a stored state means.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
28#[repr(C)]
29pub enum ValidityReason {
30    /// `required` and the value is empty.
31    ValueMissing = 0,
32    /// Shorter than `minlength`.
33    TooShort = 1,
34    /// Longer than `maxlength`.
35    TooLong = 2,
36    /// Below `min`.
37    RangeUnderflow = 3,
38    /// Above `max`.
39    RangeOverflow = 4,
40    /// Does not match `pattern` (11b-i-b). The whole value must match, and an
41    /// empty value is exempt - both exactly as HTML's `patternMismatch`.
42    PatternMismatch = 5,
43}
44
45impl ValidityReason {
46    /// This reason's bit in a [`ValidityState`].
47    #[must_use]
48    pub const fn bit(self) -> u32 {
49        1u32 << (self as u32)
50    }
51}
52
53/// Every constraint one control failed, at once.
54///
55/// A SET and not a single reason, because HTML's `ValidityState` is a set:
56/// one field can be both too short and out of range, and reporting only the
57/// first would make the second appear only after the first was fixed.
58///
59/// A bitset rather than a struct of bools so that appending a reason - the
60/// `PatternMismatch` 11b-i-b added - stays a pure enum append with no
61/// change to this type's layout. The same trade `GamepadState::buttons` makes.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
63#[repr(C)]
64pub struct ValidityState {
65    /// Bit `n` set means the [`ValidityReason`] with discriminant `n` failed.
66    /// Read it through [`Self::has`] rather than by hand.
67    pub flags: u32,
68}
69
70impl ValidityState {
71    /// A control that passed every constraint.
72    #[must_use]
73    pub const fn valid() -> Self {
74        Self { flags: 0 }
75    }
76
77    /// Did this control fail `reason`?
78    #[must_use]
79    pub const fn has(self, reason: ValidityReason) -> bool {
80        self.flags & reason.bit() != 0
81    }
82
83    /// Did it pass everything?
84    #[must_use]
85    pub const fn is_valid(self) -> bool {
86        self.flags == 0
87    }
88
89    /// Record a failure.
90    pub const fn insert(&mut self, reason: ValidityReason) {
91        self.flags |= reason.bit();
92    }
93}
94
95/// The outcome of the last constraint validation, so a callback can ask why
96/// its control was rejected.
97///
98/// Replaced WHOLESALE on each validation rather than merged, which is what
99/// makes a control that has since been fixed stop reporting: it simply is not
100/// in the new map.
101#[derive(Debug, Clone, PartialEq, Default)]
102pub struct FormValidationManager {
103    failures: BTreeMap<DomNodeId, ValidityState>,
104}
105
106impl FormValidationManager {
107    #[must_use]
108    pub fn new() -> Self {
109        Self::default()
110    }
111
112    /// Publish the result of one validation pass.
113    pub fn set_failures(&mut self, failures: impl IntoIterator<Item = (DomNodeId, ValidityState)>) {
114        self.failures = failures.into_iter().collect();
115    }
116
117    /// Why this control failed, or [`ValidityState::valid`] if it did not.
118    ///
119    /// A control nothing has validated yet reads as VALID rather than as
120    /// unknown. That is the honest answer for a form nobody has submitted -
121    /// HTML says the same, since an untouched field is valid until a
122    /// constraint check says otherwise.
123    #[must_use]
124    pub fn state_of(&self, node: DomNodeId) -> ValidityState {
125        self.failures
126            .get(&node)
127            .copied()
128            .unwrap_or_else(ValidityState::valid)
129    }
130
131    /// Every control that failed the last validation, in document order.
132    #[must_use]
133    pub fn failing_nodes(&self) -> Vec<DomNodeId> {
134        self.failures.keys().copied().collect()
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::{dom::DomId, styled_dom::NodeHierarchyItemId};
142
143    fn node(i: usize) -> DomNodeId {
144        DomNodeId {
145            dom: DomId { inner: 0 },
146            node: NodeHierarchyItemId::from_crate_internal(Some(crate::dom::NodeId::new(i))),
147        }
148    }
149
150    /// The discriminants ARE bit positions. Renumbering the enum would leave
151    /// every stored state meaning something else, with nothing failing to
152    /// compile - the same hazard the sensor and IME wire codes have.
153    #[test]
154    fn the_reason_discriminants_are_bit_positions_and_are_all_distinct() {
155        let all = [
156            ValidityReason::ValueMissing,
157            ValidityReason::TooShort,
158            ValidityReason::TooLong,
159            ValidityReason::RangeUnderflow,
160            ValidityReason::RangeOverflow,
161        ];
162        let mut seen = 0u32;
163        for r in all {
164            assert_ne!(r.bit(), 0, "{r:?} has no bit");
165            assert_eq!(seen & r.bit(), 0, "{r:?} shares a bit with an earlier reason");
166            seen |= r.bit();
167        }
168        // Pinned so that APPENDING a reason is the only edit that passes:
169        // reordering changes this number.
170        assert_eq!(seen, 0b11111);
171        assert_eq!(ValidityReason::ValueMissing.bit(), 1);
172    }
173
174    #[test]
175    fn a_state_holds_several_failures_at_once() {
176        let mut s = ValidityState::valid();
177        assert!(s.is_valid());
178
179        s.insert(ValidityReason::TooShort);
180        s.insert(ValidityReason::RangeOverflow);
181        assert!(!s.is_valid());
182        assert!(s.has(ValidityReason::TooShort));
183        assert!(s.has(ValidityReason::RangeOverflow));
184        assert!(
185            !s.has(ValidityReason::ValueMissing),
186            "a reason nobody inserted must not read as failed"
187        );
188
189        // Idempotent: validating twice must not double-count anything.
190        let before = s.flags;
191        s.insert(ValidityReason::TooShort);
192        assert_eq!(s.flags, before);
193    }
194
195    #[test]
196    fn an_unvalidated_control_reads_as_valid_and_a_new_pass_clears_the_old_one() {
197        let mut m = FormValidationManager::new();
198        assert!(m.state_of(node(1)).is_valid());
199        assert!(m.failing_nodes().is_empty());
200
201        let mut bad = ValidityState::valid();
202        bad.insert(ValidityReason::ValueMissing);
203        m.set_failures([(node(1), bad)]);
204        assert!(m.state_of(node(1)).has(ValidityReason::ValueMissing));
205        assert_eq!(m.failing_nodes(), alloc::vec![node(1)]);
206
207        // A second pass in which node 1 now passes and node 2 does not: the
208        // old entry must be GONE, or a field the user just fixed would keep
209        // reporting the error it no longer has.
210        m.set_failures([(node(2), bad)]);
211        assert!(m.state_of(node(1)).is_valid());
212        assert!(m.state_of(node(2)).has(ValidityReason::ValueMissing));
213    }
214}