Skip to main content

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}