Skip to main content

oino_ui/
node.rs

1//! The [`Node`] enum (§4.1): one variant per node kind, serde tag `"t"`,
2//! common props and the tolerant [`Unknown`] catch-all.
3
4use serde::de::Error as DeError;
5use serde::ser::Error as SerError;
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7use serde_json::Value as Json;
8
9use crate::extras::button::Button;
10use crate::extras::code::Code;
11use crate::extras::diff::Diff;
12use crate::extras::empty::Empty;
13use crate::extras::image::Image;
14use crate::extras::input::Input;
15use crate::extras::key_hints::KeyHints;
16use crate::extras::log::Log;
17use crate::extras::markdown::Markdown;
18use crate::extras::select::Select;
19use crate::extras::spinner::Spinner;
20use crate::extras::toggle::Toggle;
21use crate::extras::tree::Tree;
22use crate::layout::Layout;
23use crate::style::Style;
24use crate::widgets::barchart::BarChart;
25use crate::widgets::block::{Block, BlockProps};
26use crate::widgets::chart::Chart;
27use crate::widgets::gauge::{Gauge, LineGauge};
28use crate::widgets::list::List;
29use crate::widgets::paragraph::Paragraph;
30use crate::widgets::scrollbar::Scrollbar;
31use crate::widgets::sparkline::Sparkline;
32use crate::widgets::table::Table;
33use crate::widgets::tabs::Tabs;
34
35/// Node / item identity among siblings (§4.1): 1–128 chars, unique per collection.
36pub type Key = String;
37
38/// Action id (§4.6): `^[A-Za-z0-9_.:/-]{1,128}$`, chosen by the extension.
39pub type ActionId = String;
40
41/// The common props of every node (Appendix A `Common`): identity, base style,
42/// surrounding block and the fallback rendered for unknown / unsupported kinds.
43#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
44pub struct Common {
45    /// Identity among siblings (§4.1).
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub key: Option<Key>,
48    /// Base style of the node's area (§4.1).
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub style: Option<Style>,
51    /// ratatui `.block(..)`: chrome around the widget's inner area.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub block: Option<BlockProps>,
54    /// Rendered when the kind is unknown or unsupported by the frontend (§4.1).
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub fallback: Option<Box<Node>>,
57}
58
59impl Common {
60    /// Empty common props.
61    #[must_use]
62    pub fn new() -> Self {
63        Self {
64            key: None,
65            style: None,
66            block: None,
67            fallback: None,
68        }
69    }
70
71    /// Sets the node key.
72    #[must_use]
73    pub fn key(mut self, key: impl Into<Key>) -> Self {
74        self.key = Some(key.into());
75        self
76    }
77
78    /// Sets the base style.
79    #[must_use]
80    pub fn style(mut self, style: Style) -> Self {
81        self.style = Some(style);
82        self
83    }
84
85    /// Sets the surrounding block.
86    #[must_use]
87    pub fn block(mut self, block: BlockProps) -> Self {
88        self.block = Some(block);
89        self
90    }
91
92    /// Sets the fallback node.
93    #[must_use]
94    pub fn fallback(mut self, node: impl Into<Node>) -> Self {
95        self.fallback = Some(Box::new(node.into()));
96        self
97    }
98}
99
100/// An unknown node kind (§4.8): parsers keep the tag (and the common props) so the
101/// renderer can draw `fallback` or nothing. Lowercase tags that are OUS v1 kinds
102/// are rejected by [`validate`](crate::limits::validate) (§4.8 migration gate).
103#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
104pub struct Unknown {
105    /// The raw `t` tag.
106    pub t: String,
107    /// Identity among siblings.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub key: Option<Key>,
110    /// Base style.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub style: Option<Style>,
113    /// Surrounding block, when present.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub block: Option<BlockProps>,
116    /// Rendered instead of nothing.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub fallback: Option<Box<Node>>,
119}
120
121/// One widget-tree node (§4): `{"t": "<Kind>", ...}`.
122///
123/// Parsing is tolerant (§4.8 / D-U14): unknown props are ignored, unknown kinds
124/// become [`Node::Unknown`]. Serialization emits exactly the Appendix A fields.
125#[derive(Clone, Debug, PartialEq)]
126pub enum Node {
127    /// Container; children solved on the main axis (§4.3).
128    Layout(Layout),
129    /// Chrome around a child (§4.5.1).
130    Block(Block),
131    /// Wrapped text (§4.5.1).
132    Paragraph(Paragraph),
133    /// Selectable list (§4.5.1).
134    List(List),
135    /// Column table (§4.5.1).
136    Table(Table),
137    /// Tab bar (+ optional local panes) (§4.5.1).
138    Tabs(Tabs),
139    /// Progress bar (§4.5.1).
140    Gauge(Gauge),
141    /// One-row progress line (§4.5.1).
142    LineGauge(LineGauge),
143    /// Value series as bars (§4.5.1).
144    Sparkline(Sparkline),
145    /// Grouped bars (§4.5.1).
146    BarChart(BarChart),
147    /// Scatter / line / bar plot (§4.5.1).
148    Chart(Chart),
149    /// Scroll position indicator (§4.5.1).
150    Scrollbar(Scrollbar),
151    /// Markdown text (§4.5.2).
152    Markdown(Markdown),
153    /// Syntax-highlighted code (§4.5.2).
154    Code(Code),
155    /// Unified / split diff (§4.5.2).
156    Diff(Diff),
157    /// Image (§4.5.2).
158    Image(Image),
159    /// Tailing log (§4.5.2).
160    Log(Log),
161    /// Expandable tree (§4.5.2).
162    Tree(Tree),
163    /// Key hints line (§4.5.2).
164    KeyHints(KeyHints),
165    /// Text field (§4.5.2).
166    Input(Input),
167    /// Boolean switch (§4.5.2).
168    Toggle(Toggle),
169    /// Cycling select (§4.5.2).
170    Select(Select),
171    /// Action button (§4.5.2).
172    Button(Button),
173    /// Activity indicator (§4.5.2).
174    Spinner(Spinner),
175    /// Empty state (§4.5.2).
176    Empty(Empty),
177    /// Unknown kind (§4.8).
178    Unknown(Unknown),
179}
180
181macro_rules! for_each_kind {
182    ($macro:ident) => {
183        $macro! {
184            (Layout, "Layout", Layout),
185            (Block, "Block", Block),
186            (Paragraph, "Paragraph", Paragraph),
187            (List, "List", List),
188            (Table, "Table", Table),
189            (Tabs, "Tabs", Tabs),
190            (Gauge, "Gauge", Gauge),
191            (LineGauge, "LineGauge", LineGauge),
192            (Sparkline, "Sparkline", Sparkline),
193            (BarChart, "BarChart", BarChart),
194            (Chart, "Chart", Chart),
195            (Scrollbar, "Scrollbar", Scrollbar),
196            (Markdown, "Markdown", Markdown),
197            (Code, "Code", Code),
198            (Diff, "Diff", Diff),
199            (Image, "Image", Image),
200            (Log, "Log", Log),
201            (Tree, "Tree", Tree),
202            (KeyHints, "KeyHints", KeyHints),
203            (Input, "Input", Input),
204            (Toggle, "Toggle", Toggle),
205            (Select, "Select", Select),
206            (Button, "Button", Button),
207            (Spinner, "Spinner", Spinner),
208            (Empty, "Empty", Empty)
209        }
210    };
211}
212
213macro_rules! impl_node_kind_table {
214    ($(($variant:ident, $tag:literal, $ty:ty)),* $(,)?) => {
215        /// Every known node tag (Appendix A order).
216        pub const NODE_KINDS: [&str; 25] = [$($tag),*];
217
218        impl Node {
219            /// The node's tag, or `None` for [`Node::Unknown`] (use `Unknown::t`).
220            #[must_use]
221            pub fn tag(&self) -> Option<&'static str> {
222                match self {
223                    $(Self::$variant(_) => Some($tag),)*
224                    Self::Unknown(_) => None,
225                }
226            }
227
228            /// The node's tag, including unknown ones.
229            #[must_use]
230            pub fn kind(&self) -> &str {
231                match self {
232                    $(Self::$variant(_) => $tag,)*
233                    Self::Unknown(u) => &u.t,
234                }
235            }
236
237            /// Shared borrow of the common props (the unknown kind keeps a subset).
238            #[must_use]
239            pub fn common(&self) -> CommonRef<'_> {
240                match self {
241                    $(Self::$variant(inner) => CommonRef {
242                        key: inner.common.key.as_deref(),
243                        style: inner.common.style.as_ref(),
244                        block: inner.common.block.as_ref(),
245                        fallback: inner.common.fallback.as_deref(),
246                    },)*
247                    Self::Unknown(u) => CommonRef {
248                        key: u.key.as_deref(),
249                        style: u.style.as_ref(),
250                        block: u.block.as_ref(),
251                        fallback: u.fallback.as_deref(),
252                    },
253                }
254            }
255        }
256
257        /// Borrowed view of a node's common props.
258        #[derive(Clone, Copy, Debug)]
259        pub struct CommonRef<'a> {
260            /// Identity among siblings.
261            pub key: Option<&'a str>,
262            /// Base style.
263            pub style: Option<&'a Style>,
264            /// Surrounding block.
265            pub block: Option<&'a BlockProps>,
266            /// Fallback node.
267            pub fallback: Option<&'a Node>,
268        }
269
270        impl Serialize for Node {
271            fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
272                fn tagged<S: Serializer>(
273                    serializer: S,
274                    tag: &str,
275                    value: &impl Serialize,
276                ) -> Result<S::Ok, S::Error> {
277                    use serde::ser::SerializeMap;
278                    let inner = serde_json::to_value(value)
279                        .map_err(|e| S::Error::custom(e.to_string()))?;
280                    let Json::Object(map) = inner else {
281                        return Err(S::Error::custom("node props must serialize to an object"));
282                    };
283                    let mut out = serializer.serialize_map(Some(map.len() + 1))?;
284                    out.serialize_entry("t", tag)?;
285                    for (k, v) in map {
286                        out.serialize_entry(&k, &v)?;
287                    }
288                    out.end()
289                }
290                match self {
291                    $(Self::$variant(inner) => tagged(serializer, $tag, inner),)*
292                    Self::Unknown(u) => u.serialize(serializer),
293                }
294            }
295        }
296
297        impl<'de> Deserialize<'de> for Node {
298            fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
299                let value = Json::deserialize(deserializer)?;
300                let tag = match value.get("t") {
301                    Some(Json::String(tag)) => tag.clone(),
302                    _ if !value.is_object() => {
303                        return Err(D::Error::custom("a node must be a JSON object"));
304                    }
305                    _ => {
306                        return Err(D::Error::custom("a node needs a string \"t\" tag"));
307                    }
308                };
309                match tag.as_str() {
310                    $($tag => serde_json::from_value::<$ty>(value)
311                        .map(Self::$variant)
312                        .map_err(|e| D::Error::custom(format!("{tag}: {}", e))),)*
313                    _ => serde_json::from_value::<Unknown>(value)
314                        .map(Self::Unknown)
315                        .map_err(|e| D::Error::custom(format!("{tag}: {}", e))),
316                }
317            }
318        }
319    };
320}
321
322for_each_kind!(impl_node_kind_table);
323
324/// Converts a builder value into a [`Node`] (§11.2: `w.into()`).
325pub trait IntoNode: Into<Node> {
326    /// Converts into a node.
327    fn into_node(self) -> Node {
328        self.into()
329    }
330}
331
332impl IntoNode for Node {}
333
334/// Extension-side common-props builder for any node (§4.1): `node.with(|c| ...)`.
335pub trait WithCommon: Sized {
336    /// Mutable access to the node's common props.
337    fn common_mut(&mut self) -> &mut Common;
338
339    /// Sets the node key.
340    #[must_use]
341    fn key(mut self, key: impl Into<Key>) -> Self {
342        self.common_mut().key = Some(key.into());
343        self
344    }
345
346    /// Sets the base style.
347    #[must_use]
348    fn style(mut self, style: Style) -> Self {
349        self.common_mut().style = Some(style);
350        self
351    }
352
353    /// Sets the surrounding block.
354    #[must_use]
355    fn block(mut self, block: impl Into<BlockProps>) -> Self {
356        self.common_mut().block = Some(block.into());
357        self
358    }
359
360    /// Sets the fallback node.
361    #[must_use]
362    fn fallback(mut self, node: impl Into<Node>) -> Self {
363        self.common_mut().fallback = Some(Box::new(node.into()));
364        self
365    }
366}
367
368macro_rules! impl_with_common {
369    ($(($variant:ident, $tag:literal, $ty:ty)),* $(,)?) => {
370        $(impl WithCommon for $ty {
371            fn common_mut(&mut self) -> &mut Common {
372                &mut self.common
373            }
374        })*
375    };
376}
377
378for_each_kind!(impl_with_common);
379
380#[cfg(test)]
381mod tests {
382    #![allow(
383        clippy::unwrap_used,
384        clippy::expect_used,
385        clippy::panic,
386        reason = "tests assert by unwrapping"
387    )]
388    use super::*;
389
390    #[test]
391    fn unknown_kind_is_kept_with_fallback() {
392        let raw = r#"{"t":"Holgram","style":{"fg":"accent"},"fallback":{"t":"Spinner"}}"#;
393        let node: Node = serde_json::from_str(raw).expect("unknown node parses");
394        assert_eq!(node.kind(), "Holgram");
395        let fallback = node.common().fallback.expect("fallback kept");
396        assert_eq!(fallback.tag(), Some("Spinner"));
397        let round: Node = serde_json::from_str(&serde_json::to_string(&node).expect("serializes"))
398            .expect("re-parses");
399        assert_eq!(node, round);
400    }
401
402    #[test]
403    fn missing_tag_is_an_error() {
404        assert!(
405            serde_json::from_str::<Node>(r#"{"text":"hi"}"#).is_err(),
406            "missing t must not parse"
407        );
408    }
409
410    #[test]
411    fn all_tags_round_trip() {
412        let samples: Vec<Node> = vec![
413            Layout::vertical([crate::layout::Constraint::Fill(1)]).into(),
414            Block::bordered().into(),
415            Paragraph::new("x").into(),
416            List::new(["a"]).into(),
417            Table::new(
418                [crate::widgets::table::Row::new(["a"])],
419                [crate::layout::Constraint::Length(4)],
420            )
421            .into(),
422            Tabs::new([crate::widgets::tabs::TabTitle::new("a", "A")]).into(),
423            Gauge::new().into(),
424            LineGauge::new(0.5).into(),
425            Sparkline::new([Some(1), None]).into(),
426            BarChart::new([crate::widgets::barchart::BarGroup::new([
427                crate::widgets::barchart::Bar::new(1),
428            ])])
429            .into(),
430            Chart::new([crate::widgets::chart::Dataset::new([[0.0, 1.0]])]).into(),
431            Scrollbar::new(crate::widgets::scrollbar::ScrollbarState::new(10)).into(),
432            Markdown::new("# x").into(),
433            Code::new("let x = 1;").into(),
434            Diff::patch("@@ -1 +1 @@\n-a\n+b").into(),
435            Image::new(crate::extras::image::ImageSource::path("a.png"), "an image").into(),
436            Log::new(["line"]).into(),
437            Tree::new([crate::extras::tree::TreeNode::new("a", "A")]).into(),
438            KeyHints::new([("enter", "open")]).into(),
439            Input::new("name").into(),
440            Toggle::new("on", "Enabled", true).into(),
441            Select::new("mode", [("a", "A")]).into(),
442            Button::new("Go", "go").into(),
443            Spinner::new("working").into(),
444            Empty::new().title("Nothing").into(),
445        ];
446        assert_eq!(samples.len(), NODE_KINDS.len());
447        for node in samples {
448            let json = serde_json::to_value(&node).unwrap_or(Json::Null);
449            let back: Node = serde_json::from_value(json).unwrap_or(Node::Unknown(Unknown {
450                t: "boom".into(),
451                ..Unknown::default()
452            }));
453            assert_eq!(node, back, "round trip for {}", node.kind());
454        }
455    }
456}