feat: port el-ui vessels — rename crates→vessels, add El source + manifests
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "el-platform"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "el-ui platform rendering backends — same component code, every platform"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
name = "el_platform"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,23 @@
|
||||
// el-platform — Platform abstraction surface for el-ui.
|
||||
//
|
||||
// Wraps the El runtime's filesystem, network, environment, and clock
|
||||
// primitives behind a stable API so application vessels depend on this
|
||||
// vessel rather than runtime builtin names directly.
|
||||
//
|
||||
// The Rust crate also implements per-target render backends
|
||||
// (web/server/ios/android/macos/linux/windows). At the El layer the
|
||||
// target is fixed at compile time — the runtime IS the backend — so no
|
||||
// `PlatformBackend` polymorphism is exposed here. DOM patching and native
|
||||
// widget mounting will arrive once el-ui-compiler emits browser/native code.
|
||||
|
||||
vessel "el-platform" {
|
||||
version "1.0.0"
|
||||
description "Platform abstraction: env, filesystem, network, clock, UUID"
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Android backend — NDK + JNI bridge.
|
||||
//!
|
||||
//! Architecture: correct and complete. JNI calls are stubs marked TODO.
|
||||
//!
|
||||
//! Each `PlatformNode` maps to an Android View:
|
||||
//! element("div") → LinearLayout / FrameLayout
|
||||
//! element("span") → TextView (inline)
|
||||
//! element("button") → Button
|
||||
//! element("input") → EditText
|
||||
//! text("...") → TextView
|
||||
//!
|
||||
//! Event binding:
|
||||
//! "click" → setOnClickListener
|
||||
//! "input" → addTextChangedListener
|
||||
//! "change" → setOnCheckedChangeListener
|
||||
|
||||
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
|
||||
|
||||
pub struct AndroidBackend;
|
||||
|
||||
impl AndroidBackend {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
fn android_view_class(tag: &str) -> &'static str {
|
||||
match tag {
|
||||
"button" => "android.widget.Button",
|
||||
"input" => "android.widget.EditText",
|
||||
"textarea" => "android.widget.EditText",
|
||||
"img" => "android.widget.ImageView",
|
||||
"ul" | "ol" => "android.widget.ListView",
|
||||
"li" => "android.view.View",
|
||||
"nav" => "androidx.appcompat.widget.Toolbar",
|
||||
"div" | "section" | "main" | "article" => "android.widget.FrameLayout",
|
||||
"span" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => {
|
||||
"android.widget.TextView"
|
||||
}
|
||||
_ => "android.view.View",
|
||||
}
|
||||
}
|
||||
|
||||
fn android_event_listener(event: &str) -> &'static str {
|
||||
match event {
|
||||
"click" => "setOnClickListener",
|
||||
"input" | "change" => "addTextChangedListener",
|
||||
"focus" => "setOnFocusChangeListener",
|
||||
_ => "setOnTouchListener",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AndroidBackend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformBackend for AndroidBackend {
|
||||
fn name(&self) -> &'static str {
|
||||
"android"
|
||||
}
|
||||
|
||||
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
|
||||
let mut node = PlatformNode::element(tag);
|
||||
// TODO: JNI call to create the Android view:
|
||||
// env.call_static_method(activity_class, "createElement", "(Ljava/lang/String;)J", &[...])
|
||||
node.attributes.push(crate::Attribute::new(
|
||||
"data-android-class",
|
||||
Self::android_view_class(tag),
|
||||
));
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
|
||||
let mut node = PlatformNode::text(content);
|
||||
// TODO: JNI: create TextView, set text = content
|
||||
node.attributes
|
||||
.push(crate::Attribute::new("data-android-class", "android.widget.TextView"));
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
fn set_attribute(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
name: &str,
|
||||
value: &str,
|
||||
) -> PlatformResult<()> {
|
||||
// TODO: map attribute to Java property setter via JNI
|
||||
// "class" → setBackground / setTextAppearance
|
||||
// "disabled" → setEnabled(false)
|
||||
// "placeholder" → setHint(value)
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
node.attributes.push(crate::Attribute::new(name, value));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
// TODO: JNI: ((ViewGroup) parent).addView(child)
|
||||
parent.children.push(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
|
||||
if child_index >= parent.children.len() {
|
||||
return Err(PlatformError::Render(format!(
|
||||
"android: child index {} out of bounds",
|
||||
child_index
|
||||
)));
|
||||
}
|
||||
// TODO: JNI: ((ViewGroup) parent).removeViewAt(child_index)
|
||||
parent.children.remove(child_index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
index: usize,
|
||||
new_child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
if index >= parent.children.len() {
|
||||
return Err(PlatformError::Render(
|
||||
"android: replace_child out of bounds".into(),
|
||||
));
|
||||
}
|
||||
// TODO: JNI: remove old view, add new view at index
|
||||
parent.children[index] = new_child;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bind_event(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
event: &str,
|
||||
_handler: EventHandler,
|
||||
) -> PlatformResult<()> {
|
||||
let listener = Self::android_event_listener(event);
|
||||
// TODO: JNI: view.setOnClickListener(new View.OnClickListener() { ... })
|
||||
node.attributes.push(crate::Attribute::new(
|
||||
format!("data-android-event-{}", event),
|
||||
listener,
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
|
||||
Ok(node.to_html())
|
||||
}
|
||||
|
||||
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
|
||||
// TODO: get Activity by container_id, set root as content view
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
|
||||
// TODO: diff and apply JNI mutations
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! iOS backend — UIKit via C FFI / Objective-C bridge.
|
||||
//!
|
||||
//! Architecture: correct and complete. Native UIKit calls are stubs marked
|
||||
//! TODO — a future agent fills in the actual `extern "C"` calls.
|
||||
//!
|
||||
//! Each `PlatformNode` maps to a UIKit view:
|
||||
//! element("div") → UIView
|
||||
//! element("span") → UILabel (inline)
|
||||
//! element("button") → UIButton
|
||||
//! element("input") → UITextField
|
||||
//! text("...") → UILabel
|
||||
//!
|
||||
//! Event binding maps DOM event names to UIControl target-action pairs:
|
||||
//! "click" → UIControlEventTouchUpInside
|
||||
//! "input" → UIControlEventEditingChanged
|
||||
|
||||
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
|
||||
|
||||
pub struct IosBackend;
|
||||
|
||||
impl IosBackend {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Map an HTML tag name to the UIKit class name it maps to.
|
||||
fn uikit_class(tag: &str) -> &'static str {
|
||||
match tag {
|
||||
"button" => "UIButton",
|
||||
"input" => "UITextField",
|
||||
"textarea" => "UITextView",
|
||||
"img" => "UIImageView",
|
||||
"ul" | "ol" => "UITableView",
|
||||
"li" => "UITableViewCell",
|
||||
"nav" => "UINavigationBar",
|
||||
_ => "UIView",
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a DOM event name to a UIControlEvent constant name.
|
||||
fn uicontrol_event(event: &str) -> &'static str {
|
||||
match event {
|
||||
"click" => "UIControlEventTouchUpInside",
|
||||
"input" | "change" => "UIControlEventEditingChanged",
|
||||
"focus" => "UIControlEventEditingDidBegin",
|
||||
"blur" => "UIControlEventEditingDidEnd",
|
||||
_ => "UIControlEventAllEvents",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for IosBackend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformBackend for IosBackend {
|
||||
fn name(&self) -> &'static str {
|
||||
"ios"
|
||||
}
|
||||
|
||||
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
|
||||
let mut node = PlatformNode::element(tag);
|
||||
// TODO: call UIKit C FFI to allocate the view:
|
||||
// extern "C" { fn el_ios_create_view(class_name: *const c_char) -> usize; }
|
||||
// node.native_handle = Some(unsafe { el_ios_create_view(class_cstr) });
|
||||
let uikit_class = Self::uikit_class(tag);
|
||||
node.attributes
|
||||
.push(crate::Attribute::new("data-uikit-class", uikit_class));
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
|
||||
let mut node = PlatformNode::text(content);
|
||||
// TODO: UILabel with text = content
|
||||
node.attributes
|
||||
.push(crate::Attribute::new("data-uikit-class", "UILabel"));
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
fn set_attribute(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
name: &str,
|
||||
value: &str,
|
||||
) -> PlatformResult<()> {
|
||||
// TODO: map attribute names to UIKit property setters:
|
||||
// "class" → apply style from stylesheet
|
||||
// "disabled" → view.isUserInteractionEnabled = false
|
||||
// "placeholder" → textField.placeholder = value
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
node.attributes.push(crate::Attribute::new(name, value));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
// TODO: [parentView addSubview:childView] via FFI
|
||||
parent.children.push(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
|
||||
if child_index >= parent.children.len() {
|
||||
return Err(PlatformError::Render(format!(
|
||||
"ios: child index {} out of bounds",
|
||||
child_index
|
||||
)));
|
||||
}
|
||||
// TODO: [childView removeFromSuperview] via FFI
|
||||
parent.children.remove(child_index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
index: usize,
|
||||
new_child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
if index >= parent.children.len() {
|
||||
return Err(PlatformError::Render("ios: replace_child out of bounds".into()));
|
||||
}
|
||||
// TODO: remove old view, insert new view via FFI
|
||||
parent.children[index] = new_child;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bind_event(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
event: &str,
|
||||
_handler: EventHandler,
|
||||
) -> PlatformResult<()> {
|
||||
let uievent = Self::uicontrol_event(event);
|
||||
// TODO: [view addTarget:target action:@selector(handler:) forControlEvents:uievent]
|
||||
node.attributes
|
||||
.push(crate::Attribute::new(format!("data-ios-event-{}", event), uievent));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
|
||||
// iOS doesn't render to HTML strings at runtime, but we support it for
|
||||
// testing and SSR fallback.
|
||||
Ok(node.to_html())
|
||||
}
|
||||
|
||||
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
|
||||
// TODO: get UIViewController by container_id and set root as its view
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
|
||||
// TODO: diff old and new trees, apply UIKit mutations via FFI
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Linux backend — GTK/Wayland.
|
||||
//!
|
||||
//! Architecture: correct and complete. GTK calls are stubs marked TODO.
|
||||
//!
|
||||
//! Each `PlatformNode` maps to a GtkWidget:
|
||||
//! element("div") → GtkBox (vertical)
|
||||
//! element("button") → GtkButton
|
||||
//! element("input") → GtkEntry
|
||||
//! text("...") → GtkLabel
|
||||
|
||||
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
|
||||
|
||||
pub struct LinuxBackend;
|
||||
|
||||
impl LinuxBackend {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
fn gtk_widget(tag: &str) -> &'static str {
|
||||
match tag {
|
||||
"button" => "GtkButton",
|
||||
"input" => "GtkEntry",
|
||||
"textarea" => "GtkTextView",
|
||||
"img" => "GtkImage",
|
||||
"ul" | "ol" => "GtkListBox",
|
||||
"li" => "GtkListBoxRow",
|
||||
"nav" => "GtkHeaderBar",
|
||||
"div" | "section" | "main" | "article" | "span" => "GtkBox",
|
||||
_ => "GtkWidget",
|
||||
}
|
||||
}
|
||||
|
||||
fn gtk_signal(event: &str) -> &'static str {
|
||||
match event {
|
||||
"click" => "clicked",
|
||||
"input" | "change" => "changed",
|
||||
"focus" => "focus-in-event",
|
||||
"blur" => "focus-out-event",
|
||||
"keydown" | "keypress" => "key-press-event",
|
||||
"keyup" => "key-release-event",
|
||||
_ => "event",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LinuxBackend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformBackend for LinuxBackend {
|
||||
fn name(&self) -> &'static str {
|
||||
"linux"
|
||||
}
|
||||
|
||||
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
|
||||
let mut node = PlatformNode::element(tag);
|
||||
// TODO: gtk_button_new() / gtk_box_new() / etc. via gtk-rs or raw FFI
|
||||
node.attributes
|
||||
.push(crate::Attribute::new("data-gtk-widget", Self::gtk_widget(tag)));
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
|
||||
let mut node = PlatformNode::text(content);
|
||||
// TODO: gtk_label_new(content)
|
||||
node.attributes
|
||||
.push(crate::Attribute::new("data-gtk-widget", "GtkLabel"));
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
fn set_attribute(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
name: &str,
|
||||
value: &str,
|
||||
) -> PlatformResult<()> {
|
||||
// TODO: map to GTK property setters
|
||||
// "class" → gtk_widget_add_css_class
|
||||
// "disabled" → gtk_widget_set_sensitive(false)
|
||||
// "placeholder" → gtk_entry_set_placeholder_text
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
node.attributes.push(crate::Attribute::new(name, value));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
// TODO: gtk_box_append(parent, child)
|
||||
parent.children.push(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
|
||||
if child_index >= parent.children.len() {
|
||||
return Err(PlatformError::Render(format!(
|
||||
"linux: child index {} out of bounds",
|
||||
child_index
|
||||
)));
|
||||
}
|
||||
// TODO: gtk_widget_unparent(child)
|
||||
parent.children.remove(child_index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
index: usize,
|
||||
new_child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
if index >= parent.children.len() {
|
||||
return Err(PlatformError::Render("linux: replace_child out of bounds".into()));
|
||||
}
|
||||
parent.children[index] = new_child;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bind_event(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
event: &str,
|
||||
_handler: EventHandler,
|
||||
) -> PlatformResult<()> {
|
||||
let signal = Self::gtk_signal(event);
|
||||
// TODO: g_signal_connect(widget, signal, callback, data)
|
||||
node.attributes
|
||||
.push(crate::Attribute::new(format!("data-gtk-signal-{}", event), signal));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
|
||||
Ok(node.to_html())
|
||||
}
|
||||
|
||||
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
|
||||
// TODO: get GtkWindow and call gtk_window_set_child(root)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
|
||||
// TODO: diff and apply GTK mutations
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//! macOS backend — AppKit bindings.
|
||||
//!
|
||||
//! Architecture: correct and complete. AppKit calls are stubs marked TODO.
|
||||
//!
|
||||
//! Each `PlatformNode` maps to an NSView:
|
||||
//! element("div") → NSView
|
||||
//! element("button") → NSButton
|
||||
//! element("input") → NSTextField
|
||||
//! text("...") → NSTextField (label mode)
|
||||
|
||||
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
|
||||
|
||||
pub struct MacosBackend;
|
||||
|
||||
impl MacosBackend {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
fn appkit_class(tag: &str) -> &'static str {
|
||||
match tag {
|
||||
"button" => "NSButton",
|
||||
"input" => "NSTextField",
|
||||
"textarea" => "NSTextView",
|
||||
"img" => "NSImageView",
|
||||
"ul" | "ol" => "NSTableView",
|
||||
"nav" => "NSToolbar",
|
||||
_ => "NSView",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MacosBackend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformBackend for MacosBackend {
|
||||
fn name(&self) -> &'static str {
|
||||
"macos"
|
||||
}
|
||||
|
||||
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
|
||||
let mut node = PlatformNode::element(tag);
|
||||
// TODO: extern "C" { fn el_macos_create_view(class_name: *const c_char) -> usize; }
|
||||
node.attributes
|
||||
.push(crate::Attribute::new("data-appkit-class", Self::appkit_class(tag)));
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
|
||||
let mut node = PlatformNode::text(content);
|
||||
// TODO: NSTextField in label mode with stringValue = content
|
||||
node.attributes
|
||||
.push(crate::Attribute::new("data-appkit-class", "NSTextField"));
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
fn set_attribute(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
name: &str,
|
||||
value: &str,
|
||||
) -> PlatformResult<()> {
|
||||
// TODO: map to AppKit property setters
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
node.attributes.push(crate::Attribute::new(name, value));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
// TODO: [parentView addSubview:childView]
|
||||
parent.children.push(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
|
||||
if child_index >= parent.children.len() {
|
||||
return Err(PlatformError::Render(format!(
|
||||
"macos: child index {} out of bounds",
|
||||
child_index
|
||||
)));
|
||||
}
|
||||
// TODO: [childView removeFromSuperview]
|
||||
parent.children.remove(child_index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
index: usize,
|
||||
new_child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
if index >= parent.children.len() {
|
||||
return Err(PlatformError::Render("macos: replace_child out of bounds".into()));
|
||||
}
|
||||
parent.children[index] = new_child;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bind_event(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
event: &str,
|
||||
_handler: EventHandler,
|
||||
) -> PlatformResult<()> {
|
||||
// TODO: NSButton.target + NSButton.action pattern via FFI
|
||||
node.attributes
|
||||
.push(crate::Attribute::new(format!("data-appkit-event-{}", event), "bound"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
|
||||
Ok(node.to_html())
|
||||
}
|
||||
|
||||
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
|
||||
// TODO: find NSWindow or NSViewController and set root as contentView
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
|
||||
// TODO: diff and apply AppKit mutations
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Platform backend implementations.
|
||||
|
||||
pub mod android;
|
||||
pub mod ios;
|
||||
pub mod linux;
|
||||
pub mod macos;
|
||||
pub mod server;
|
||||
pub mod web;
|
||||
pub mod windows;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Server backend — SSR: render to HTML string, served by axum.
|
||||
//!
|
||||
//! This is the primary SSR backend. An axum handler calls `render_to_string()`
|
||||
//! on the component tree and returns the result as an HTTP response.
|
||||
//!
|
||||
//! The same component code runs server-side without any changes. Only the
|
||||
//! backend (chosen by `el.toml`) differs.
|
||||
|
||||
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
|
||||
|
||||
/// Server-side rendering backend.
|
||||
///
|
||||
/// Renders component trees to full HTML strings. No DOM, no browser APIs.
|
||||
/// An axum handler uses this backend to generate the initial page HTML.
|
||||
pub struct ServerBackend {
|
||||
/// Whether to emit hydration markers (`data-el-hydrate`) for client takeover.
|
||||
pub hydration_markers: bool,
|
||||
}
|
||||
|
||||
impl ServerBackend {
|
||||
pub fn new() -> Self {
|
||||
Self { hydration_markers: true }
|
||||
}
|
||||
|
||||
/// Disable hydration markers (pure static HTML, no client-side takeover).
|
||||
pub fn static_only() -> Self {
|
||||
Self { hydration_markers: false }
|
||||
}
|
||||
|
||||
/// Wrap rendered HTML in a full HTML document skeleton.
|
||||
pub fn render_page(
|
||||
&self,
|
||||
node: &PlatformNode,
|
||||
title: &str,
|
||||
runtime_script: &str,
|
||||
) -> PlatformResult<String> {
|
||||
let body = self.render_to_string(node)?;
|
||||
Ok(format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" data-el-ssr="true">
|
||||
{body}
|
||||
</div>
|
||||
<script type="module" src="{runtime_script}"></script>
|
||||
</body>
|
||||
</html>"#
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ServerBackend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformBackend for ServerBackend {
|
||||
fn name(&self) -> &'static str {
|
||||
"server"
|
||||
}
|
||||
|
||||
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
|
||||
Ok(PlatformNode::element(tag))
|
||||
}
|
||||
|
||||
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
|
||||
Ok(PlatformNode::text(content))
|
||||
}
|
||||
|
||||
fn set_attribute(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
name: &str,
|
||||
value: &str,
|
||||
) -> PlatformResult<()> {
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
node.attributes.push(crate::Attribute::new(name, value));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
parent.children.push(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
|
||||
if child_index >= parent.children.len() {
|
||||
return Err(PlatformError::Render(format!(
|
||||
"server: child index {} out of bounds",
|
||||
child_index
|
||||
)));
|
||||
}
|
||||
parent.children.remove(child_index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
index: usize,
|
||||
new_child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
if index >= parent.children.len() {
|
||||
return Err(PlatformError::Render(format!(
|
||||
"server: replace_child index {} out of bounds",
|
||||
index
|
||||
)));
|
||||
}
|
||||
parent.children[index] = new_child;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bind_event(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
event: &str,
|
||||
_handler: EventHandler,
|
||||
) -> PlatformResult<()> {
|
||||
// On the server, event handlers are emitted as data attributes.
|
||||
// The client-side hydration pass picks them up and binds real listeners.
|
||||
if self.hydration_markers {
|
||||
node.attributes
|
||||
.push(crate::Attribute::new(format!("data-el-{}", event), "hydrate"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
|
||||
Ok(node.to_html())
|
||||
}
|
||||
|
||||
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
|
||||
// Server has no mount concept — rendering is one-shot.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
|
||||
// Server rendering is stateless — no patch needed.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn supports_ssr(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Tests for el-platform backends.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{
|
||||
backend_for, backends::{server::ServerBackend, web::WebBackend},
|
||||
config::{PlatformConfig, PlatformTarget},
|
||||
node::PlatformNode,
|
||||
PlatformBackend,
|
||||
};
|
||||
|
||||
// ── Test 1: PlatformTarget::from_str parses all targets ──────────────────
|
||||
#[test]
|
||||
fn test_platform_target_from_str() {
|
||||
assert_eq!(PlatformTarget::from_str("web"), Some(PlatformTarget::Web));
|
||||
assert_eq!(PlatformTarget::from_str("server"), Some(PlatformTarget::Server));
|
||||
assert_eq!(PlatformTarget::from_str("ios"), Some(PlatformTarget::Ios));
|
||||
assert_eq!(PlatformTarget::from_str("android"), Some(PlatformTarget::Android));
|
||||
assert_eq!(PlatformTarget::from_str("macos"), Some(PlatformTarget::Macos));
|
||||
assert_eq!(PlatformTarget::from_str("linux"), Some(PlatformTarget::Linux));
|
||||
assert_eq!(PlatformTarget::from_str("windows"), Some(PlatformTarget::Windows));
|
||||
assert_eq!(PlatformTarget::from_str("unknown"), None);
|
||||
}
|
||||
|
||||
// ── Test 2: PlatformTarget::as_str round-trips ───────────────────────────
|
||||
#[test]
|
||||
fn test_platform_target_as_str() {
|
||||
assert_eq!(PlatformTarget::Web.as_str(), "web");
|
||||
assert_eq!(PlatformTarget::Server.as_str(), "server");
|
||||
assert_eq!(PlatformTarget::Ios.as_str(), "ios");
|
||||
}
|
||||
|
||||
// ── Test 3: is_native() identifies native targets ────────────────────────
|
||||
#[test]
|
||||
fn test_is_native() {
|
||||
assert!(!PlatformTarget::Web.is_native());
|
||||
assert!(!PlatformTarget::Server.is_native());
|
||||
assert!(PlatformTarget::Ios.is_native());
|
||||
assert!(PlatformTarget::Android.is_native());
|
||||
assert!(PlatformTarget::Macos.is_native());
|
||||
assert!(PlatformTarget::Linux.is_native());
|
||||
assert!(PlatformTarget::Windows.is_native());
|
||||
}
|
||||
|
||||
// ── Test 4: PlatformConfig defaults to web ───────────────────────────────
|
||||
#[test]
|
||||
fn test_platform_config_default() {
|
||||
let cfg = PlatformConfig::default();
|
||||
assert_eq!(cfg.target, PlatformTarget::Web);
|
||||
assert!(!cfg.ssr);
|
||||
}
|
||||
|
||||
// ── Test 5: PlatformConfig with_ssr ──────────────────────────────────────
|
||||
#[test]
|
||||
fn test_platform_config_with_ssr() {
|
||||
let cfg = PlatformConfig::new(PlatformTarget::Server).with_ssr(true);
|
||||
assert_eq!(cfg.target, PlatformTarget::Server);
|
||||
assert!(cfg.ssr);
|
||||
}
|
||||
|
||||
// ── Test 6: WebBackend renders element to HTML ───────────────────────────
|
||||
#[test]
|
||||
fn test_web_backend_renders_element() {
|
||||
let backend = WebBackend::new();
|
||||
let mut div = backend.create_element("div").unwrap();
|
||||
backend.set_attribute(&mut div, "class", "container").unwrap();
|
||||
backend.append_child(&mut div, backend.create_text("Hello").unwrap()).unwrap();
|
||||
|
||||
let html = backend.render_to_string(&div).unwrap();
|
||||
assert!(html.contains("<div"), "should contain div tag");
|
||||
assert!(html.contains("container"), "should contain class name");
|
||||
assert!(html.contains("Hello"), "should contain text content");
|
||||
assert!(html.contains("</div>"), "should close div tag");
|
||||
}
|
||||
|
||||
// ── Test 7: ServerBackend supports SSR ───────────────────────────────────
|
||||
#[test]
|
||||
fn test_server_backend_supports_ssr() {
|
||||
let backend = ServerBackend::new();
|
||||
assert!(backend.supports_ssr());
|
||||
}
|
||||
|
||||
// ── Test 8: ServerBackend renders full HTML page ─────────────────────────
|
||||
#[test]
|
||||
fn test_server_backend_render_page() {
|
||||
let backend = ServerBackend::new();
|
||||
let root = PlatformNode::element("div")
|
||||
.with_attr("id", "content")
|
||||
.with_child(PlatformNode::text("Hello World"));
|
||||
|
||||
let page = backend.render_page(&root, "My App", "/app.js").unwrap();
|
||||
assert!(page.contains("<!DOCTYPE html>"), "should be full HTML doc");
|
||||
assert!(page.contains("My App"), "should include title");
|
||||
assert!(page.contains("Hello World"), "should include content");
|
||||
assert!(page.contains("/app.js"), "should include script src");
|
||||
}
|
||||
|
||||
// ── Test 9: PlatformNode::to_html renders nested tree ────────────────────
|
||||
#[test]
|
||||
fn test_platform_node_to_html_nested() {
|
||||
let node = PlatformNode::element("ul")
|
||||
.with_child(PlatformNode::element("li").with_child(PlatformNode::text("item 1")))
|
||||
.with_child(PlatformNode::element("li").with_child(PlatformNode::text("item 2")));
|
||||
|
||||
let html = node.to_html();
|
||||
assert!(html.contains("<ul>"));
|
||||
assert!(html.contains("<li>item 1</li>"));
|
||||
assert!(html.contains("<li>item 2</li>"));
|
||||
assert!(html.contains("</ul>"));
|
||||
}
|
||||
|
||||
// ── Test 10: PlatformNode::to_html escapes text content ──────────────────
|
||||
#[test]
|
||||
fn test_html_escaping() {
|
||||
let node = PlatformNode::element("span")
|
||||
.with_child(PlatformNode::text("<script>alert('xss')</script>"));
|
||||
let html = node.to_html();
|
||||
assert!(!html.contains("<script>"), "should escape <script>");
|
||||
assert!(html.contains("<script>"), "should contain escaped form");
|
||||
}
|
||||
|
||||
// ── Test 11: Void elements render correctly ───────────────────────────────
|
||||
#[test]
|
||||
fn test_void_elements() {
|
||||
let node = PlatformNode::element("input").with_attr("type", "text");
|
||||
let html = node.to_html();
|
||||
assert!(html.contains("<input"), "should have input");
|
||||
assert!(!html.contains("</input>"), "void element should not have closing tag");
|
||||
assert!(html.contains("/>"), "should self-close");
|
||||
}
|
||||
|
||||
// ── Test 12: Fragment node renders children inline ────────────────────────
|
||||
#[test]
|
||||
fn test_fragment_node() {
|
||||
let frag = PlatformNode::fragment()
|
||||
.with_child(PlatformNode::element("span").with_child(PlatformNode::text("A")))
|
||||
.with_child(PlatformNode::element("span").with_child(PlatformNode::text("B")));
|
||||
let html = frag.to_html();
|
||||
assert!(html.contains("<span>A</span>"));
|
||||
assert!(html.contains("<span>B</span>"));
|
||||
// No wrapping element
|
||||
assert!(!html.starts_with('<') || html.starts_with("<span>"));
|
||||
}
|
||||
|
||||
// ── Test 13: WebBackend remove_child works ───────────────────────────────
|
||||
#[test]
|
||||
fn test_web_backend_remove_child() {
|
||||
let backend = WebBackend::new();
|
||||
let mut parent = PlatformNode::element("div");
|
||||
backend
|
||||
.append_child(&mut parent, PlatformNode::text("first"))
|
||||
.unwrap();
|
||||
backend
|
||||
.append_child(&mut parent, PlatformNode::text("second"))
|
||||
.unwrap();
|
||||
assert_eq!(parent.children.len(), 2);
|
||||
|
||||
backend.remove_child(&mut parent, 0).unwrap();
|
||||
assert_eq!(parent.children.len(), 1);
|
||||
assert_eq!(parent.children[0].text_content(), Some("second"));
|
||||
}
|
||||
|
||||
// ── Test 14: WebBackend replace_child works ──────────────────────────────
|
||||
#[test]
|
||||
fn test_web_backend_replace_child() {
|
||||
let backend = WebBackend::new();
|
||||
let mut parent = PlatformNode::element("div");
|
||||
backend
|
||||
.append_child(&mut parent, PlatformNode::text("old"))
|
||||
.unwrap();
|
||||
backend
|
||||
.replace_child(&mut parent, 0, PlatformNode::text("new"))
|
||||
.unwrap();
|
||||
assert_eq!(parent.children[0].text_content(), Some("new"));
|
||||
}
|
||||
|
||||
// ── Test 15: backend_for() returns correct backend names ─────────────────
|
||||
#[test]
|
||||
fn test_backend_for_names() {
|
||||
assert_eq!(backend_for(&PlatformTarget::Web).name(), "web");
|
||||
assert_eq!(backend_for(&PlatformTarget::Server).name(), "server");
|
||||
assert_eq!(backend_for(&PlatformTarget::Ios).name(), "ios");
|
||||
assert_eq!(backend_for(&PlatformTarget::Android).name(), "android");
|
||||
assert_eq!(backend_for(&PlatformTarget::Macos).name(), "macos");
|
||||
assert_eq!(backend_for(&PlatformTarget::Linux).name(), "linux");
|
||||
assert_eq!(backend_for(&PlatformTarget::Windows).name(), "windows");
|
||||
}
|
||||
|
||||
// ── Test 16: WebBackend bind_event emits data attribute ──────────────────
|
||||
#[test]
|
||||
fn test_web_backend_bind_event() {
|
||||
let backend = WebBackend::new();
|
||||
let mut btn = backend.create_element("button").unwrap();
|
||||
backend
|
||||
.bind_event(&mut btn, "click", Box::new(|_| {}))
|
||||
.unwrap();
|
||||
let has_attr = btn.attributes.iter().any(|a| a.name == "data-el-click");
|
||||
assert!(has_attr, "should emit data-el-click attribute");
|
||||
}
|
||||
|
||||
// ── Test 17: ServerBackend bind_event emits hydration marker ─────────────
|
||||
#[test]
|
||||
fn test_server_backend_bind_event() {
|
||||
let backend = ServerBackend::new();
|
||||
let mut btn = PlatformNode::element("button");
|
||||
backend
|
||||
.bind_event(&mut btn, "click", Box::new(|_| {}))
|
||||
.unwrap();
|
||||
let has_attr = btn.attributes.iter().any(|a| a.name.contains("data-el-click"));
|
||||
assert!(has_attr, "should emit hydration marker for click event");
|
||||
}
|
||||
|
||||
// ── Test 18: WebBackend backend does not support raw SSR flag ────────────
|
||||
#[test]
|
||||
fn test_web_backend_supports_ssr() {
|
||||
// Web backend supports ssr (renders to HTML for hydration)
|
||||
let backend = WebBackend::new();
|
||||
assert!(backend.supports_ssr());
|
||||
}
|
||||
|
||||
// ── Test 19: remove_child out of bounds returns error ────────────────────
|
||||
#[test]
|
||||
fn test_remove_child_out_of_bounds() {
|
||||
let backend = WebBackend::new();
|
||||
let mut parent = PlatformNode::element("div");
|
||||
let result = backend.remove_child(&mut parent, 5);
|
||||
assert!(result.is_err(), "out-of-bounds remove should return error");
|
||||
}
|
||||
|
||||
// ── Test 20: All native backends render to HTML for testing ──────────────
|
||||
#[test]
|
||||
fn test_native_backends_render_to_html() {
|
||||
// Native backends support render_to_string for testing and SSR fallback.
|
||||
let targets = [
|
||||
PlatformTarget::Ios,
|
||||
PlatformTarget::Android,
|
||||
PlatformTarget::Macos,
|
||||
PlatformTarget::Linux,
|
||||
PlatformTarget::Windows,
|
||||
];
|
||||
for target in &targets {
|
||||
let backend = backend_for(target);
|
||||
let node = PlatformNode::element("div")
|
||||
.with_child(PlatformNode::text("test"));
|
||||
let html = backend.render_to_string(&node).unwrap();
|
||||
assert!(
|
||||
html.contains("test"),
|
||||
"{} backend should render text content",
|
||||
target.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Web backend — DOM rendering in browsers.
|
||||
//!
|
||||
//! This backend formalizes what `runtime/src/renderer.js` does, as a Rust
|
||||
//! description of the DOM patching strategy. In a WASM build, this would call
|
||||
//! into the browser's DOM API directly via `web-sys`.
|
||||
|
||||
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
|
||||
|
||||
/// Web DOM backend.
|
||||
///
|
||||
/// Renders component trees to the browser DOM. In this Rust implementation,
|
||||
/// `render_to_string` produces an HTML string (matching what the JS renderer
|
||||
/// does for its initial hydration pass). A full WASM build would instead
|
||||
/// call `document.createElement()` etc. via `web-sys`.
|
||||
pub struct WebBackend;
|
||||
|
||||
impl WebBackend {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WebBackend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformBackend for WebBackend {
|
||||
fn name(&self) -> &'static str {
|
||||
"web"
|
||||
}
|
||||
|
||||
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
|
||||
Ok(PlatformNode::element(tag))
|
||||
}
|
||||
|
||||
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
|
||||
Ok(PlatformNode::text(content))
|
||||
}
|
||||
|
||||
fn set_attribute(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
name: &str,
|
||||
value: &str,
|
||||
) -> PlatformResult<()> {
|
||||
// Remove existing attribute with same name then push new one.
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
node.attributes.push(crate::Attribute::new(name, value));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
parent.children.push(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
|
||||
if child_index >= parent.children.len() {
|
||||
return Err(PlatformError::Render(format!(
|
||||
"web: child index {} out of bounds (len {})",
|
||||
child_index,
|
||||
parent.children.len()
|
||||
)));
|
||||
}
|
||||
parent.children.remove(child_index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
index: usize,
|
||||
new_child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
if index >= parent.children.len() {
|
||||
return Err(PlatformError::Render(format!(
|
||||
"web: replace_child index {} out of bounds",
|
||||
index
|
||||
)));
|
||||
}
|
||||
parent.children[index] = new_child;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bind_event(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
event: &str,
|
||||
_handler: EventHandler,
|
||||
) -> PlatformResult<()> {
|
||||
// In a real WASM build: node.add_event_listener_with_callback(event, &closure)
|
||||
// Here we record the binding in a data attribute so the JS renderer can pick it up.
|
||||
node.attributes
|
||||
.push(crate::Attribute::new(format!("data-el-{}", event), "[bound]"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
|
||||
Ok(node.to_html())
|
||||
}
|
||||
|
||||
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
|
||||
// In a real WASM build:
|
||||
// let container = document.query_selector(container_id)?;
|
||||
// container.set_inner_html(&root.to_html());
|
||||
// For the Rust-side representation, mount is a no-op.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
|
||||
// Full DOM patching mirrors renderer.js patch():
|
||||
// 1. Walk old and new trees in parallel.
|
||||
// 2. For each position: if node kinds differ, replace; else update attributes.
|
||||
// 3. Recurse into children.
|
||||
// In a WASM build this calls web-sys DOM mutation APIs.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn supports_ssr(&self) -> bool {
|
||||
// Web backend can produce HTML strings for hydration.
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Windows backend — Win32/WinUI.
|
||||
//!
|
||||
//! Architecture: correct and complete. Win32/WinUI calls are stubs marked TODO.
|
||||
//!
|
||||
//! Each `PlatformNode` maps to a HWND or WinUI control:
|
||||
//! element("div") → Panel (WinUI StackPanel)
|
||||
//! element("button") → Button (WinUI)
|
||||
//! element("input") → TextBox (WinUI)
|
||||
//! text("...") → TextBlock (WinUI)
|
||||
|
||||
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
|
||||
|
||||
pub struct WindowsBackend;
|
||||
|
||||
impl WindowsBackend {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
fn winui_control(tag: &str) -> &'static str {
|
||||
match tag {
|
||||
"button" => "Microsoft.UI.Xaml.Controls.Button",
|
||||
"input" => "Microsoft.UI.Xaml.Controls.TextBox",
|
||||
"textarea" => "Microsoft.UI.Xaml.Controls.TextBox",
|
||||
"img" => "Microsoft.UI.Xaml.Controls.Image",
|
||||
"ul" | "ol" => "Microsoft.UI.Xaml.Controls.ListView",
|
||||
"li" => "Microsoft.UI.Xaml.Controls.ListViewItem",
|
||||
"nav" => "Microsoft.UI.Xaml.Controls.NavigationView",
|
||||
_ => "Microsoft.UI.Xaml.Controls.StackPanel",
|
||||
}
|
||||
}
|
||||
|
||||
fn winui_event(event: &str) -> &'static str {
|
||||
match event {
|
||||
"click" => "Click",
|
||||
"input" | "change" => "TextChanged",
|
||||
"focus" => "GotFocus",
|
||||
"blur" => "LostFocus",
|
||||
"keydown" | "keypress" => "KeyDown",
|
||||
"keyup" => "KeyUp",
|
||||
_ => "PointerPressed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WindowsBackend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformBackend for WindowsBackend {
|
||||
fn name(&self) -> &'static str {
|
||||
"windows"
|
||||
}
|
||||
|
||||
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
|
||||
let mut node = PlatformNode::element(tag);
|
||||
// TODO: WinRT/COM activation via windows-rs crate:
|
||||
// let panel: StackPanel = StackPanel::new()?;
|
||||
node.attributes
|
||||
.push(crate::Attribute::new("data-winui-control", Self::winui_control(tag)));
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
|
||||
let mut node = PlatformNode::text(content);
|
||||
// TODO: TextBlock::new()?.set_text(content)
|
||||
node.attributes
|
||||
.push(crate::Attribute::new("data-winui-control", "Microsoft.UI.Xaml.Controls.TextBlock"));
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
fn set_attribute(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
name: &str,
|
||||
value: &str,
|
||||
) -> PlatformResult<()> {
|
||||
// TODO: map to WinUI property setters
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
node.attributes.push(crate::Attribute::new(name, value));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
// TODO: panel.Children().Append(child_element)
|
||||
parent.children.push(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
|
||||
if child_index >= parent.children.len() {
|
||||
return Err(PlatformError::Render(format!(
|
||||
"windows: child index {} out of bounds",
|
||||
child_index
|
||||
)));
|
||||
}
|
||||
// TODO: panel.Children().RemoveAt(child_index)
|
||||
parent.children.remove(child_index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
index: usize,
|
||||
new_child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
if index >= parent.children.len() {
|
||||
return Err(PlatformError::Render(
|
||||
"windows: replace_child out of bounds".into(),
|
||||
));
|
||||
}
|
||||
parent.children[index] = new_child;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bind_event(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
event: &str,
|
||||
_handler: EventHandler,
|
||||
) -> PlatformResult<()> {
|
||||
let winui_event = Self::winui_event(event);
|
||||
// TODO: button.Click(TypedEventHandler::new(|sender, args| { handler(...) }))
|
||||
node.attributes.push(crate::Attribute::new(
|
||||
format!("data-winui-event-{}", event),
|
||||
winui_event,
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
|
||||
Ok(node.to_html())
|
||||
}
|
||||
|
||||
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
|
||||
// TODO: get Window by container_id, set root as Content
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
|
||||
// TODO: diff and apply WinUI mutations
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Platform configuration — parsed from `el.toml`.
|
||||
|
||||
/// Which platform to target for rendering.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PlatformTarget {
|
||||
/// Web: DOM rendering in browsers.
|
||||
Web,
|
||||
/// Server: SSR — render to HTML string, served by axum.
|
||||
Server,
|
||||
/// iOS: UIKit via C FFI / ObjC bridge.
|
||||
Ios,
|
||||
/// Android: NDK + JNI bridge.
|
||||
Android,
|
||||
/// macOS: AppKit bindings.
|
||||
Macos,
|
||||
/// Linux: GTK/Wayland.
|
||||
Linux,
|
||||
/// Windows: Win32/WinUI.
|
||||
Windows,
|
||||
}
|
||||
|
||||
impl PlatformTarget {
|
||||
/// Parse from the string value used in `el.toml`.
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"web" => Some(Self::Web),
|
||||
"server" => Some(Self::Server),
|
||||
"ios" => Some(Self::Ios),
|
||||
"android" => Some(Self::Android),
|
||||
"macos" => Some(Self::Macos),
|
||||
"linux" => Some(Self::Linux),
|
||||
"windows" => Some(Self::Windows),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The canonical string name for this target.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Web => "web",
|
||||
Self::Server => "server",
|
||||
Self::Ios => "ios",
|
||||
Self::Android => "android",
|
||||
Self::Macos => "macos",
|
||||
Self::Linux => "linux",
|
||||
Self::Windows => "windows",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this target is a native (non-web, non-server) platform.
|
||||
pub fn is_native(&self) -> bool {
|
||||
matches!(self, Self::Ios | Self::Android | Self::Macos | Self::Linux | Self::Windows)
|
||||
}
|
||||
}
|
||||
|
||||
/// Full platform configuration, reflecting the `[platform]` section of `el.toml`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlatformConfig {
|
||||
pub target: PlatformTarget,
|
||||
/// Enable server-side rendering fallback.
|
||||
pub ssr: bool,
|
||||
}
|
||||
|
||||
impl Default for PlatformConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
target: PlatformTarget::Web,
|
||||
ssr: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformConfig {
|
||||
pub fn new(target: PlatformTarget) -> Self {
|
||||
Self { target, ssr: false }
|
||||
}
|
||||
|
||||
pub fn with_ssr(mut self, ssr: bool) -> Self {
|
||||
self.ssr = ssr;
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! el-platform — Universal rendering backends for el-ui.
|
||||
//!
|
||||
//! The same component code produces native output for every target platform.
|
||||
//! No bridge. No virtual DOM. Direct platform calls.
|
||||
//!
|
||||
//! The target is chosen in `el.toml`:
|
||||
//!
|
||||
//! ```toml
|
||||
//! [platform]
|
||||
//! target = "web" # web | server | ios | android | macos | linux | windows
|
||||
//! ssr = true
|
||||
//! ```
|
||||
//!
|
||||
//! All platforms implement the `PlatformBackend` trait. A future agent fills in
|
||||
//! the native API calls for iOS/Android/macOS/Linux/Windows — the architecture
|
||||
//! is correct and complete now.
|
||||
|
||||
pub mod backends;
|
||||
pub mod config;
|
||||
pub mod node;
|
||||
|
||||
pub use backends::{
|
||||
android::AndroidBackend,
|
||||
ios::IosBackend,
|
||||
linux::LinuxBackend,
|
||||
macos::MacosBackend,
|
||||
server::ServerBackend,
|
||||
web::WebBackend,
|
||||
windows::WindowsBackend,
|
||||
};
|
||||
pub use config::{PlatformConfig, PlatformTarget};
|
||||
pub use node::{Attribute, EventHandler, PlatformNode, PlatformNodeKind};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PlatformError {
|
||||
#[error("render error: {0}")]
|
||||
Render(String),
|
||||
#[error("mount error: {0}")]
|
||||
Mount(String),
|
||||
#[error("unsupported operation on target {target}: {op}")]
|
||||
Unsupported { target: String, op: String },
|
||||
#[error("event binding error: {0}")]
|
||||
EventBinding(String),
|
||||
}
|
||||
|
||||
pub type PlatformResult<T> = Result<T, PlatformError>;
|
||||
|
||||
/// The core trait every platform backend must implement.
|
||||
///
|
||||
/// All rendering paths go through this interface. Component code is identical
|
||||
/// across targets — only the backend chosen by `el.toml` differs.
|
||||
pub trait PlatformBackend: Send + Sync {
|
||||
/// The platform name (e.g. "web", "server", "ios").
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Create a new element node on this platform.
|
||||
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode>;
|
||||
|
||||
/// Create a text node on this platform.
|
||||
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode>;
|
||||
|
||||
/// Set an attribute on a node.
|
||||
fn set_attribute(&self, node: &mut PlatformNode, name: &str, value: &str)
|
||||
-> PlatformResult<()>;
|
||||
|
||||
/// Remove an attribute from a node.
|
||||
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()>;
|
||||
|
||||
/// Append a child node to a parent.
|
||||
fn append_child(&self, parent: &mut PlatformNode, child: PlatformNode)
|
||||
-> PlatformResult<()>;
|
||||
|
||||
/// Remove a child node from a parent.
|
||||
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()>;
|
||||
|
||||
/// Replace a child node at the given index.
|
||||
fn replace_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
index: usize,
|
||||
new_child: PlatformNode,
|
||||
) -> PlatformResult<()>;
|
||||
|
||||
/// Bind an event handler to a node.
|
||||
fn bind_event(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
event: &str,
|
||||
handler: EventHandler,
|
||||
) -> PlatformResult<()>;
|
||||
|
||||
/// Render a node tree to its platform representation.
|
||||
/// For `server`, this returns an HTML string.
|
||||
/// For `web`, this patches the live DOM.
|
||||
/// For native targets, this calls the appropriate native APIs.
|
||||
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String>;
|
||||
|
||||
/// Mount a node tree into the platform's root container.
|
||||
/// `container_id` is a platform-specific identifier (CSS selector for web,
|
||||
/// view controller ID for iOS, activity ID for Android, etc.).
|
||||
fn mount(&self, root: PlatformNode, container_id: &str) -> PlatformResult<()>;
|
||||
|
||||
/// Patch an existing mounted tree with a new tree.
|
||||
/// The backend performs the minimal update needed.
|
||||
fn patch(&self, old: &PlatformNode, new: &PlatformNode) -> PlatformResult<()>;
|
||||
|
||||
/// Whether this backend supports SSR (rendering to HTML string on the server).
|
||||
fn supports_ssr(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Select the backend for a given platform target.
|
||||
pub fn backend_for(target: &PlatformTarget) -> Box<dyn PlatformBackend> {
|
||||
match target {
|
||||
PlatformTarget::Web => Box::new(WebBackend::new()),
|
||||
PlatformTarget::Server => Box::new(ServerBackend::new()),
|
||||
PlatformTarget::Ios => Box::new(IosBackend::new()),
|
||||
PlatformTarget::Android => Box::new(AndroidBackend::new()),
|
||||
PlatformTarget::Macos => Box::new(MacosBackend::new()),
|
||||
PlatformTarget::Linux => Box::new(LinuxBackend::new()),
|
||||
PlatformTarget::Windows => Box::new(WindowsBackend::new()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// el-platform — Platform abstraction surface for el-ui apps.
|
||||
//
|
||||
// The Rust crate defines `PlatformBackend` trait + per-target render backends
|
||||
// (web/server/ios/android/macos/linux/windows). The El surface is narrower:
|
||||
// El apps reach the host through the runtime's filesystem, network, and OS
|
||||
// primitives. This vessel wraps those into a stable, named API so El callers
|
||||
// don't depend directly on builtin names.
|
||||
//
|
||||
// RUNTIME PARITY GAPS:
|
||||
// - There is no `PlatformBackend` polymorphism at the El layer. The runtime
|
||||
// IS the backend; target selection happens during compilation/packaging,
|
||||
// not at runtime.
|
||||
// - DOM mutation, native widget mounting, event binding — none of those
|
||||
// have El surfaces yet. Use el-ui-compiler when those land.
|
||||
// - `fs_list` returns a JSON array string; consumers must parse with the
|
||||
// forthcoming json_array_get builtin. Until that lands, callers should
|
||||
// treat the value opaquely.
|
||||
|
||||
// ── Targets ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn target_web() -> String { "web" }
|
||||
fn target_server() -> String { "server" }
|
||||
fn target_ios() -> String { "ios" }
|
||||
fn target_android() -> String { "android" }
|
||||
fn target_macos() -> String { "macos" }
|
||||
fn target_linux() -> String { "linux" }
|
||||
fn target_windows() -> String { "windows" }
|
||||
|
||||
// The compiled binary's target is fixed at build time. EL_TARGET is set by
|
||||
// the build harness; absent EL_TARGET, default to "server" (the El runtime
|
||||
// runs as a host process).
|
||||
fn platform_target() -> String {
|
||||
let t: String = env("EL_TARGET")
|
||||
if str_eq(t, "") { return "server" }
|
||||
t
|
||||
}
|
||||
|
||||
fn platform_is_native(t: String) -> Bool {
|
||||
if str_eq(t, "ios") { return true }
|
||||
if str_eq(t, "android") { return true }
|
||||
if str_eq(t, "macos") { return true }
|
||||
if str_eq(t, "linux") { return true }
|
||||
if str_eq(t, "windows") { return true }
|
||||
false
|
||||
}
|
||||
|
||||
fn platform_supports_ssr(t: String) -> Bool {
|
||||
str_eq(t, "server")
|
||||
}
|
||||
|
||||
// ── Errors ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn err_io() -> String { "platform.io" }
|
||||
fn err_network() -> String { "platform.network" }
|
||||
fn err_unsupported() -> String { "platform.unsupported" }
|
||||
fn err_env_missing() -> String { "platform.env_missing" }
|
||||
|
||||
// ── Environment ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn platform_env(key: String) -> String {
|
||||
env(key)
|
||||
}
|
||||
|
||||
fn platform_env_or(key: String, fallback: String) -> String {
|
||||
let v: String = env(key)
|
||||
if str_eq(v, "") { return fallback }
|
||||
v
|
||||
}
|
||||
|
||||
// Strict variant: returns "" if missing, callers branch.
|
||||
// (Runtime has no panic() — fail-soft return preserves type.)
|
||||
fn platform_env_required(key: String) -> String {
|
||||
let v: String = env(key)
|
||||
if str_eq(v, "") {
|
||||
println("[el-platform] ERROR " + err_env_missing() + ":" + key)
|
||||
return ""
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
// ── Filesystem ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn platform_fs_read(path: String) -> String {
|
||||
fs_read(path)
|
||||
}
|
||||
|
||||
fn platform_fs_write(path: String, content: String) -> Bool {
|
||||
fs_write(path, content)
|
||||
true
|
||||
}
|
||||
|
||||
// Returns a JSON array of entry names (opaque until json_array_get lands).
|
||||
fn platform_fs_list(path: String) -> String {
|
||||
let raw: String = fs_list(path)
|
||||
if str_eq(raw, "") { return "[]" }
|
||||
raw
|
||||
}
|
||||
|
||||
fn platform_fs_exists(path: String) -> Bool {
|
||||
let raw: String = fs_read(path)
|
||||
!str_eq(raw, "")
|
||||
}
|
||||
|
||||
// ── HTTP ────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn platform_http_get(url: String) -> String {
|
||||
http_get(url)
|
||||
}
|
||||
|
||||
fn platform_http_post(url: String, body: String) -> String {
|
||||
http_post(url, body)
|
||||
}
|
||||
|
||||
fn platform_http_post_json(url: String, json_body: String) -> String {
|
||||
http_post_json(url, json_body)
|
||||
}
|
||||
|
||||
fn platform_http_get_authed(url: String, bearer: String) -> String {
|
||||
let headers: String = "Authorization: Bearer " + bearer
|
||||
http_get_with_headers(url, headers)
|
||||
}
|
||||
|
||||
fn platform_http_post_authed(url: String, body: String, bearer: String) -> String {
|
||||
let headers: String = "Authorization: Bearer " + bearer
|
||||
http_post_with_headers(url, body, headers)
|
||||
}
|
||||
|
||||
// ── Time / OS clock ─────────────────────────────────────────────────────────
|
||||
|
||||
fn platform_now() -> Int {
|
||||
time_now()
|
||||
}
|
||||
|
||||
fn platform_sleep_ms(ms: Int) -> Bool {
|
||||
sleep_ms(ms)
|
||||
true
|
||||
}
|
||||
|
||||
// ── Identity (for trace/log correlation) ───────────────────────────────────
|
||||
|
||||
fn platform_uuid() -> String {
|
||||
uuid_v4()
|
||||
}
|
||||
|
||||
// ── Entry — smoke test ──────────────────────────────────────────────────────
|
||||
//
|
||||
// elc-bug-workaround: top-level `let` does not scope into the surrounding
|
||||
// statement substitution in the entry block, so we call platform_target()
|
||||
// inline at each use site.
|
||||
|
||||
println("[el-platform] target=" + platform_target() + " ssr=" + bool_to_str(platform_supports_ssr(platform_target())))
|
||||
println("[el-platform] uuid=" + platform_uuid())
|
||||
@@ -0,0 +1,158 @@
|
||||
//! 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"
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user