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}