ratatui_style/node.rs
1//! Framework-agnostic element view: the `StyledNode` trait and supporting
2//! types. The cascade engine knows nothing about a2ui, ratatui widgets, or any
3//! particular framework — it only knows a [`StyledNode`].
4
5/// A set of pseudo-class flags for one element.
6///
7/// Maps directly to CSS pseudo-classes: `:focus`, `:hover`, `:disabled`,
8/// `:checked`, `:active`.
9#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
10pub struct State {
11 pub focus: bool,
12 pub hover: bool,
13 pub disabled: bool,
14 pub checked: bool,
15 pub active: bool,
16}
17
18impl State {
19 pub const fn empty() -> Self {
20 Self {
21 focus: false,
22 hover: false,
23 disabled: false,
24 checked: false,
25 active: false,
26 }
27 }
28 pub const fn focus() -> Self {
29 Self {
30 focus: true,
31 ..Self::empty()
32 }
33 }
34 pub const fn disabled() -> Self {
35 Self {
36 disabled: true,
37 ..Self::empty()
38 }
39 }
40}
41
42/// Where an element sits among its siblings. Used by structural pseudo-class
43/// matching (`:nth-child`, `:first-of-type`, etc.); returned by
44/// [`StyledNode::position`] for forward-compat.
45#[derive(Debug, Clone, Default, PartialEq, Eq)]
46pub struct Position {
47 /// 0-based index among siblings.
48 pub index: usize,
49 /// Total number of siblings (including self).
50 pub sibling_count: usize,
51 /// The parent element's type name, if any.
52 pub parent_type: Option<String>,
53 /// 0-based index of this element among its same-type siblings (those sharing
54 /// `type_name`). 0 means "unknown / not supplied" — of-type pseudo-classes
55 /// that need the total count (`:last/only/nth-last-of-type`) will NOT match
56 /// when this is 0. (0-based, like `index`.) Supply it via
57 /// [`with_of_type`](Self::with_of_type).
58 pub of_type_index: usize,
59 /// Total number of same-type siblings (including self). 0 means "unknown".
60 /// The forward-looking of-type pseudos (`:last/only/nth-last-of-type`)
61 /// require this to be `> 0` to match. `:nth-of-type` / `:first-of-type` will
62 /// *prefer* this when `> 0` but fall back to previous-sibling counting
63 /// otherwise. Supply it via [`with_of_type`](Self::with_of_type).
64 pub of_type_count: usize,
65}
66
67impl Position {
68 pub fn new(index: usize, sibling_count: usize) -> Self {
69 Self {
70 index,
71 sibling_count,
72 parent_type: None,
73 of_type_index: 0,
74 of_type_count: 0,
75 }
76 }
77
78 /// Consuming builder that sets the same-type-sibling position info.
79 ///
80 /// `of_type_index` is 0-based among same-type siblings; `of_type_count` is
81 /// the total same-type sibling count (including self). Both `0` (the
82 /// default) mean "unknown" — in that state `:last/only/nth-last-of-type`
83 /// never match.
84 pub fn with_of_type(mut self, of_type_index: usize, of_type_count: usize) -> Self {
85 self.of_type_index = of_type_index;
86 self.of_type_count = of_type_count;
87 self
88 }
89}
90
91/// A borrowable view over an element's class list.
92///
93/// Two representations back the same query surface:
94/// - [`Classes::from_slice`] borrows a `&'a [&'a str]` directly — used by
95/// [`NodeRef`], it is **zero-allocation** (a compile-time guarantee when the
96/// source is `&'static str`).
97/// - [`Classes::from_vec`] owns a `Vec<&'a str>` — used by [`OwnedNode`], it
98/// costs one `Vec` allocation per call (acceptable; the hot path uses
99/// `NodeRef`).
100///
101/// Use [`as_slice`](Self::as_slice) for iteration (`&[&str]`) rather than
102/// `iter()` so both representations unify behind a single concrete return
103/// type.
104pub struct Classes<'a> {
105 repr: Repr<'a>,
106}
107
108enum Repr<'a> {
109 Slice(&'a [&'a str]),
110 Owned(Vec<&'a str>),
111}
112
113impl<'a> Classes<'a> {
114 /// Zero-allocation view over a borrowed slice. The `NodeRef` path uses
115 /// this — when `slice` is `&'static [&'static str]` no heap allocation
116 /// occurs at any point.
117 pub fn from_slice(slice: &'a [&'a str]) -> Self {
118 Self {
119 repr: Repr::Slice(slice),
120 }
121 }
122
123 /// Owning view built from an existing `Vec<&str>`. Used by [`OwnedNode`]
124 /// which stores `String`s and must materialize `&str` borrows per call.
125 pub fn from_vec(v: Vec<&'a str>) -> Self {
126 Self {
127 repr: Repr::Owned(v),
128 }
129 }
130
131 /// Unified read-only access to the underlying class names, regardless of
132 /// representation. Prefer this over an `iter()` — both reprs return the
133 /// same concrete `&[&str]`.
134 pub fn as_slice(&self) -> &[&'a str] {
135 match &self.repr {
136 Repr::Slice(s) => s,
137 Repr::Owned(v) => v,
138 }
139 }
140
141 /// Whether `name` is present (case-sensitive).
142 pub fn contains(&self, name: &str) -> bool {
143 self.as_slice().contains(&name)
144 }
145
146 pub fn is_empty(&self) -> bool {
147 self.as_slice().is_empty()
148 }
149
150 pub fn len(&self) -> usize {
151 self.as_slice().len()
152 }
153}
154
155/// The minimal contract the cascade needs to match selectors against an
156/// element.
157///
158/// Implement this on your framework's node type (e.g. a2ui's `ComponentModel`,
159/// or a plain app-state struct in a vanilla ratatui app).
160///
161/// For the draw-loop hot path prefer [`NodeRef`] (zero-allocation). [`OwnedNode`]
162/// remains available for convenience where owned `String`/`Vec<String>` storage
163/// is preferable.
164pub trait StyledNode {
165 /// Element type name — matches a CSS type selector (e.g. `"Button"`).
166 fn type_name(&self) -> &str;
167
168 /// Element id — matches a CSS `#id` selector.
169 fn id(&self) -> Option<&str>;
170
171 /// Class names — match CSS `.class` selectors.
172 ///
173 /// Returns a [`Classes<'_>`] borrow view rather than an allocating
174 /// `Vec<&str>`. [`NodeRef`] makes this zero-allocation; [`OwnedNode`]
175 /// pays one `Vec` allocation (it is not the hot path). The cascade hoists
176 /// this call out of the per-rule loop so the cost is paid at most once per
177 /// node regardless.
178 fn classes(&self) -> Classes<'_>;
179
180 /// Pseudo-class state — matches `:focus` / `:disabled` / etc.
181 fn state(&self) -> State;
182
183 /// Sibling position — for future `:nth-child` support.
184 ///
185 /// This is **optional**: `:nth-child` matching is P3 and not yet wired into
186 /// the cascade, so `compute` does not consult it. The default returns an
187 /// empty [`Position`]. Override it only when you need `:nth-child` data at
188 /// some future point — until then, leaving the default avoids forcing every
189 /// node type to materialize sibling info.
190 fn position(&self) -> Position {
191 Position::default()
192 }
193}
194
195/// A convenience node that owns its data (`String` / `Vec<String>`).
196///
197/// Handy for tests, one-off queries, and places where you want to build a node
198/// from runtime-owned strings. It is **not** allocation-free: each
199/// [`OwnedNode::new`] allocates a `String`, and [`StyledNode::classes`]
200/// allocates one `Vec` per call. For the per-frame draw loop, use [`NodeRef`].
201#[derive(Debug, Clone)]
202pub struct OwnedNode {
203 pub type_name: String,
204 pub id: Option<String>,
205 pub classes: Vec<String>,
206 pub state: State,
207 pub position: Position,
208}
209
210impl OwnedNode {
211 pub fn new(type_name: impl Into<String>) -> Self {
212 Self {
213 type_name: type_name.into(),
214 id: None,
215 classes: Vec::new(),
216 state: State::empty(),
217 position: Position::default(),
218 }
219 }
220 pub fn with_id(mut self, id: impl Into<String>) -> Self {
221 self.id = Some(id.into());
222 self
223 }
224 pub fn with_classes(mut self, classes: impl IntoIterator<Item = impl Into<String>>) -> Self {
225 self.classes = classes.into_iter().map(Into::into).collect();
226 self
227 }
228 pub fn with_state(mut self, state: State) -> Self {
229 self.state = state;
230 self
231 }
232 /// Set the sibling position. Mirrors [`NodeRef::position`].
233 pub fn with_position(mut self, position: Position) -> Self {
234 self.position = position;
235 self
236 }
237}
238
239impl StyledNode for OwnedNode {
240 fn type_name(&self) -> &str {
241 &self.type_name
242 }
243 fn id(&self) -> Option<&str> {
244 self.id.as_deref()
245 }
246 fn classes(&self) -> Classes<'_> {
247 // One Vec allocation per call — acceptable for OwnedNode (not the hot
248 // path). The draw loop uses NodeRef to avoid even this.
249 Classes::from_vec(self.classes.iter().map(String::as_str).collect())
250 }
251 fn state(&self) -> State {
252 self.state
253 }
254 fn position(&self) -> Position {
255 self.position.clone()
256 }
257}
258
259/// A borrowed node: `type_name`, `id`, and `classes` are all `&'a str` /
260/// `&'a [&'a str]` borrows, so construction is **zero-allocation** (a
261/// compile-time guarantee when the source data is `&'static`).
262///
263/// Use this in the draw loop:
264///
265/// ```rust,ignore
266/// let node = NodeRef::new("Button").classes(&["primary"]).state(State::focus());
267/// let computed = sheet.compute(&node, None);
268/// ```
269///
270/// Builder methods mirror [`OwnedNode`]'s (`with_*`) for easy migration, plus
271/// short `classes`/`state` setters.
272pub struct NodeRef<'a> {
273 type_name: &'a str,
274 id: Option<&'a str>,
275 classes: &'a [&'a str],
276 state: State,
277 position: Position,
278}
279
280impl<'a> NodeRef<'a> {
281 /// Borrow `type_name`. Zero-allocation.
282 pub fn new(type_name: &'a str) -> Self {
283 Self {
284 type_name,
285 id: None,
286 classes: &[],
287 state: State::empty(),
288 position: Position::default(),
289 }
290 }
291
292 /// Set the id (borrowed). Zero-allocation.
293 pub fn id(mut self, id: &'a str) -> Self {
294 self.id = Some(id);
295 self
296 }
297 /// Alias for [`id`](Self::id), matching [`OwnedNode::with_id`].
298 pub fn with_id(self, id: &'a str) -> Self {
299 self.id(id)
300 }
301
302 /// Set the class list (borrowed slice). Zero-allocation.
303 pub fn classes(mut self, classes: &'a [&'a str]) -> Self {
304 self.classes = classes;
305 self
306 }
307 /// Alias for [`classes`](Self::classes), matching [`OwnedNode::with_classes`].
308 pub fn with_classes(self, classes: &'a [&'a str]) -> Self {
309 self.classes(classes)
310 }
311
312 /// Set the pseudo-class state. Zero-allocation.
313 pub fn state(mut self, state: State) -> Self {
314 self.state = state;
315 self
316 }
317 /// Alias for [`state`](Self::state), matching [`OwnedNode::with_state`].
318 pub fn with_state(self, state: State) -> Self {
319 self.state(state)
320 }
321
322 /// Set the sibling position. Rarely needed in the draw loop.
323 pub fn position(mut self, position: Position) -> Self {
324 self.position = position;
325 self
326 }
327}
328
329impl<'a> StyledNode for NodeRef<'a> {
330 fn type_name(&self) -> &str {
331 self.type_name
332 }
333 fn id(&self) -> Option<&str> {
334 self.id
335 }
336 fn classes(&self) -> Classes<'_> {
337 // Zero-allocation: borrow the slice directly.
338 Classes::from_slice(self.classes)
339 }
340 fn state(&self) -> State {
341 self.state
342 }
343 fn position(&self) -> Position {
344 self.position.clone()
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351
352 #[test]
353 fn classes_slice_contains() {
354 let c = Classes::from_slice(&["a", "b"]);
355 assert!(c.contains("b"));
356 assert!(!c.contains("c"));
357 assert_eq!(c.len(), 2);
358 assert!(!c.is_empty());
359 assert_eq!(c.as_slice(), &["a", "b"]);
360 }
361
362 #[test]
363 fn classes_owned_contains() {
364 let c = Classes::from_vec(vec!["x", "y"]);
365 assert!(c.contains("x"));
366 assert!(!c.contains("z"));
367 assert_eq!(c.len(), 2);
368 }
369
370 #[test]
371 fn classes_empty() {
372 let c = Classes::from_slice(&[]);
373 assert!(c.is_empty());
374 assert_eq!(c.len(), 0);
375 }
376
377 #[test]
378 fn position_default_and_new_leave_of_type_zero() {
379 // Default and the two-arg constructor both leave the of-type fields at
380 // 0 (unknown) so the forward-looking of-type pseudos never match unless
381 // the host explicitly supplies them.
382 let d = Position::default();
383 assert_eq!(d.of_type_index, 0);
384 assert_eq!(d.of_type_count, 0);
385
386 let n = Position::new(2, 5);
387 assert_eq!(n.index, 2);
388 assert_eq!(n.sibling_count, 5);
389 assert_eq!(n.of_type_index, 0);
390 assert_eq!(n.of_type_count, 0);
391 }
392
393 #[test]
394 fn position_with_of_type_sets_fields() {
395 let p = Position::new(1, 4).with_of_type(2, 3);
396 assert_eq!(p.index, 1);
397 assert_eq!(p.sibling_count, 4);
398 assert_eq!(p.of_type_index, 2);
399 assert_eq!(p.of_type_count, 3);
400
401 // Explicitly setting to 0/0 means "unknown" again.
402 let cleared = p.with_of_type(0, 0);
403 assert_eq!(cleared.of_type_index, 0);
404 assert_eq!(cleared.of_type_count, 0);
405 }
406}