159 lines
4.7 KiB
Rust
159 lines
4.7 KiB
Rust
//! Platform-agnostic node tree.
|
|
//!
|
|
//! `PlatformNode` is the universal representation of a UI element.
|
|
//! Each backend converts this to its native equivalent.
|
|
|
|
/// An attribute on a platform node.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Attribute {
|
|
pub name: String,
|
|
pub value: String,
|
|
}
|
|
|
|
impl Attribute {
|
|
pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
|
|
Self { name: name.into(), value: value.into() }
|
|
}
|
|
}
|
|
|
|
/// A boxed event handler function.
|
|
/// Using `Box<dyn Fn(String)>` so platform nodes can store handlers without
|
|
/// knowing the native event type.
|
|
pub type EventHandler = Box<dyn Fn(String) + Send + Sync>;
|
|
|
|
/// The kind of a platform node.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum PlatformNodeKind {
|
|
/// An element node: `<div>`, `<button>`, etc.
|
|
Element { tag: String },
|
|
/// A plain text node.
|
|
Text { content: String },
|
|
/// A component boundary marker (used for patch reconciliation).
|
|
Component { name: String },
|
|
/// A fragment — groups children without a wrapper element.
|
|
Fragment,
|
|
}
|
|
|
|
/// A platform-agnostic UI node.
|
|
///
|
|
/// This is the universal intermediate representation. Each backend renders
|
|
/// `PlatformNode` trees to its native format.
|
|
#[derive(Debug)]
|
|
pub struct PlatformNode {
|
|
pub kind: PlatformNodeKind,
|
|
pub attributes: Vec<Attribute>,
|
|
pub children: Vec<PlatformNode>,
|
|
/// Opaque platform handle — the backend stores its native pointer/reference
|
|
/// here after mounting. `None` before mount.
|
|
pub native_handle: Option<usize>,
|
|
}
|
|
|
|
impl PlatformNode {
|
|
/// Create an element node.
|
|
pub fn element(tag: impl Into<String>) -> Self {
|
|
Self {
|
|
kind: PlatformNodeKind::Element { tag: tag.into() },
|
|
attributes: Vec::new(),
|
|
children: Vec::new(),
|
|
native_handle: None,
|
|
}
|
|
}
|
|
|
|
/// Create a text node.
|
|
pub fn text(content: impl Into<String>) -> Self {
|
|
Self {
|
|
kind: PlatformNodeKind::Text { content: content.into() },
|
|
attributes: Vec::new(),
|
|
children: Vec::new(),
|
|
native_handle: None,
|
|
}
|
|
}
|
|
|
|
/// Create a fragment node.
|
|
pub fn fragment() -> Self {
|
|
Self {
|
|
kind: PlatformNodeKind::Fragment,
|
|
attributes: Vec::new(),
|
|
children: Vec::new(),
|
|
native_handle: None,
|
|
}
|
|
}
|
|
|
|
/// Add an attribute.
|
|
pub fn with_attr(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
|
|
self.attributes.push(Attribute::new(name, value));
|
|
self
|
|
}
|
|
|
|
/// Add a child node.
|
|
pub fn with_child(mut self, child: PlatformNode) -> Self {
|
|
self.children.push(child);
|
|
self
|
|
}
|
|
|
|
/// Get the tag name if this is an element node.
|
|
pub fn tag(&self) -> Option<&str> {
|
|
match &self.kind {
|
|
PlatformNodeKind::Element { tag } => Some(tag),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Get the text content if this is a text node.
|
|
pub fn text_content(&self) -> Option<&str> {
|
|
match &self.kind {
|
|
PlatformNodeKind::Text { content } => Some(content),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Render the node tree to an HTML string.
|
|
/// This is used by the server backend and for testing all backends.
|
|
pub fn to_html(&self) -> String {
|
|
match &self.kind {
|
|
PlatformNodeKind::Text { content } => html_escape(content),
|
|
PlatformNodeKind::Fragment => {
|
|
self.children.iter().map(|c| c.to_html()).collect()
|
|
}
|
|
PlatformNodeKind::Component { name } => {
|
|
format!("<!-- component:{} -->", name)
|
|
}
|
|
PlatformNodeKind::Element { tag } => {
|
|
let mut out = format!("<{}", tag);
|
|
for attr in &self.attributes {
|
|
out.push_str(&format!(" {}=\"{}\"", attr.name, html_escape_attr(&attr.value)));
|
|
}
|
|
// Void elements — no closing tag
|
|
if is_void_element(tag) {
|
|
out.push_str(" />");
|
|
return out;
|
|
}
|
|
out.push('>');
|
|
for child in &self.children {
|
|
out.push_str(&child.to_html());
|
|
}
|
|
out.push_str(&format!("</{}>", tag));
|
|
out
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn html_escape(s: &str) -> String {
|
|
s.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
}
|
|
|
|
fn html_escape_attr(s: &str) -> String {
|
|
html_escape(s).replace('"', """)
|
|
}
|
|
|
|
fn is_void_element(tag: &str) -> bool {
|
|
matches!(
|
|
tag,
|
|
"area" | "base" | "br" | "col" | "embed" | "hr" | "img" | "input"
|
|
| "link" | "meta" | "param" | "source" | "track" | "wbr"
|
|
)
|
|
}
|