Skip to content

Commit c899148

Browse files
author
wangnaihe
committed
feat: implement Dynamic DOM as rendering source of truth (#30)
This is the foundational architecture change that makes w3cos-dom's Document the primary data model, enabling runtime DOM manipulation via standard W3C APIs (createElement, appendChild, etc.). Runtime (w3cos-runtime/src/dom.rs): - Thread-local Document with W3C API wrappers: create_element, create_text_node, append_child, remove_child, insert_before, set_attribute, set_style_property, set_text_content, class_list_*, add_event_listener, query_selector, get_element_by_id, etc. - DOM dirty tracking (is_document_dirty / clear_document_dirty) - to_component_tree() bridge for rendering pipeline - 8 unit tests Event loop (window.rs): - New App::new_dom(setup) constructor + run_dom() entry point - rebuild_if_dirty() now checks both signal dirty AND DOM dirty - DOM mode: setup runs once to build initial DOM, subsequent changes trigger to_component_tree() -> layout -> render automatically - run_app_dom() public API in lib.rs - Backward compatible: existing Component-mode apps unchanged Compiler (codegen.rs): - New generate_dom() backend: emits w3cos_runtime::dom::* calls instead of Component::column/row/text constructors - Maps Column->div, Row->div+flex-direction:row, Button->button, Text->text node, Image->img, TextInput->input - Style properties emitted as set_style_property() CSS calls DOM improvements (w3cos-dom): - NodeId::from_u32() for runtime API bridge - Improved to_component_tree(): respects flex-direction for Row/Column, handles img, input, heading defaults (h1-h6 sizes), more HTML tags - Event bubbling: dispatch_with_bubbling() walks parent chain - Document::dispatch_event_bubbling() convenience method Closes #30 Made-with: Cursor
1 parent cc6063d commit c899148

7 files changed

Lines changed: 669 additions & 10 deletions

File tree

crates/w3cos-compiler/src/codegen.rs

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,209 @@ fn gen_dimension(val: &str) -> String {
366366
"Dimension::Auto".to_string()
367367
}
368368

369+
// ---------------------------------------------------------------------------
370+
// DOM codegen backend — generates document.createElement / appendChild calls
371+
// ---------------------------------------------------------------------------
372+
373+
/// Generate Rust code that builds a DOM tree using w3cos_runtime::dom APIs.
374+
pub fn generate_dom(tree: &AppTree) -> Result<String> {
375+
let is_reactive = !tree.signals.is_empty();
376+
let signal_names: Vec<&str> = tree.signals.iter().map(|s| s.name.as_str()).collect();
377+
let mut body = String::new();
378+
379+
if is_reactive {
380+
for (i, sig) in tree.signals.iter().enumerate() {
381+
body.push_str(&format!(
382+
" let _ = w3cos_runtime::state::create_signal({});\n let {} = w3cos_runtime::state::get_signal({});\n",
383+
sig.initial, sig.name, i
384+
));
385+
}
386+
body.push('\n');
387+
}
388+
389+
let mut counter = 0;
390+
let root_var = gen_dom_node(&tree.root, &mut body, &mut counter, 1, &signal_names);
391+
body.push_str(&format!("\n let body = w3cos_runtime::dom::body_id();\n"));
392+
body.push_str(&format!(
393+
" w3cos_runtime::dom::append_child(body, {root_var});\n"
394+
));
395+
396+
let imports = if is_reactive {
397+
"use w3cos_std::EventAction;\n"
398+
} else {
399+
""
400+
};
401+
402+
Ok(format!(
403+
r#"//! Auto-generated by w3cos compiler — do not edit (DOM mode).
404+
{imports}
405+
fn main() {{
406+
w3cos_runtime::run_app_dom(setup).expect("W3C OS app crashed");
407+
}}
408+
409+
fn setup() {{
410+
{body}}}
411+
"#
412+
))
413+
}
414+
415+
fn gen_dom_node(
416+
node: &Node,
417+
out: &mut String,
418+
counter: &mut usize,
419+
depth: usize,
420+
signal_names: &[&str],
421+
) -> String {
422+
let indent = " ".repeat(depth);
423+
let var = format!("n{}", *counter);
424+
*counter += 1;
425+
426+
match &node.kind {
427+
NodeKind::Text(content) => {
428+
let text = node.text.as_deref().unwrap_or(content.as_str());
429+
let text_expr = gen_text_expr(text, signal_names);
430+
out.push_str(&format!(
431+
"{indent}let {var} = w3cos_runtime::dom::create_text_node(&{text_expr});\n"
432+
));
433+
}
434+
NodeKind::Button(label) => {
435+
let lbl = node.label.as_deref().unwrap_or(label.as_str());
436+
out.push_str(&format!(
437+
"{indent}let {var} = w3cos_runtime::dom::create_element(\"button\");\n"
438+
));
439+
out.push_str(&format!(
440+
"{indent}w3cos_runtime::dom::set_text_content({var}, {lbl:?});\n"
441+
));
442+
gen_dom_style_calls(&node.style, &var, out, &indent);
443+
if let Some(ref action_str) = node.on_click {
444+
let action = gen_event_action(action_str, signal_names);
445+
out.push_str(&format!(
446+
"{indent}w3cos_runtime::dom::add_event_listener({var}, \"click\", {action});\n"
447+
));
448+
}
449+
}
450+
NodeKind::Image(src) => {
451+
let src_val = node.src.as_deref().unwrap_or(src.as_str());
452+
out.push_str(&format!(
453+
"{indent}let {var} = w3cos_runtime::dom::create_element(\"img\");\n"
454+
));
455+
out.push_str(&format!(
456+
"{indent}w3cos_runtime::dom::set_attribute({var}, \"src\", {src_val:?});\n"
457+
));
458+
gen_dom_style_calls(&node.style, &var, out, &indent);
459+
}
460+
NodeKind::TextInput => {
461+
let placeholder = node.placeholder.as_deref().unwrap_or("Enter text");
462+
out.push_str(&format!(
463+
"{indent}let {var} = w3cos_runtime::dom::create_element(\"input\");\n"
464+
));
465+
out.push_str(&format!(
466+
"{indent}w3cos_runtime::dom::set_attribute({var}, \"type\", \"text\");\n"
467+
));
468+
out.push_str(&format!(
469+
"{indent}w3cos_runtime::dom::set_attribute({var}, \"placeholder\", {placeholder:?});\n"
470+
));
471+
gen_dom_style_calls(&node.style, &var, out, &indent);
472+
}
473+
NodeKind::Column | NodeKind::Row | NodeKind::Box => {
474+
let tag = "div";
475+
out.push_str(&format!(
476+
"{indent}let {var} = w3cos_runtime::dom::create_element({tag:?});\n"
477+
));
478+
479+
// Set flex-direction for Row
480+
if matches!(node.kind, NodeKind::Row) {
481+
out.push_str(&format!(
482+
"{indent}w3cos_runtime::dom::set_style_property({var}, \"flex-direction\", \"row\");\n"
483+
));
484+
}
485+
486+
gen_dom_style_calls(&node.style, &var, out, &indent);
487+
488+
if let Some(ref action_str) = node.on_click {
489+
let action = gen_event_action(action_str, signal_names);
490+
out.push_str(&format!(
491+
"{indent}w3cos_runtime::dom::add_event_listener({var}, \"click\", {action});\n"
492+
));
493+
}
494+
495+
for child in &node.children {
496+
let child_var = gen_dom_node(child, out, counter, depth, signal_names);
497+
out.push_str(&format!(
498+
"{indent}w3cos_runtime::dom::append_child({var}, {child_var});\n"
499+
));
500+
}
501+
}
502+
}
503+
504+
var
505+
}
506+
507+
fn gen_dom_style_calls(style: &StyleDecl, var: &str, out: &mut String, indent: &str) {
508+
let sp = |prop: &str, val: &str| {
509+
format!("{indent}w3cos_runtime::dom::set_style_property({var}, {prop:?}, {val:?});\n")
510+
};
511+
512+
if let Some(gap) = style.gap {
513+
out.push_str(&sp("gap", &format!("{gap}px")));
514+
}
515+
if let Some(p) = style.padding {
516+
out.push_str(&sp("padding", &format!("{p}px")));
517+
}
518+
if let Some(fs) = style.font_size {
519+
out.push_str(&sp("font-size", &format!("{fs}px")));
520+
}
521+
if let Some(fw) = style.font_weight {
522+
out.push_str(&sp("font-weight", &fw.to_string()));
523+
}
524+
if let Some(ref c) = style.color {
525+
out.push_str(&sp("color", c));
526+
}
527+
if let Some(ref bg) = style.background {
528+
out.push_str(&sp("background-color", bg));
529+
}
530+
if let Some(br) = style.border_radius {
531+
out.push_str(&sp("border-radius", &format!("{br}px")));
532+
}
533+
if let Some(bw) = style.border_width {
534+
out.push_str(&sp("border-width", &format!("{bw}px")));
535+
}
536+
if let Some(ref bc) = style.border_color {
537+
out.push_str(&sp("border-color", bc));
538+
}
539+
if let Some(ref ai) = style.align_items {
540+
out.push_str(&sp("align-items", ai));
541+
}
542+
if let Some(ref jc) = style.justify_content {
543+
out.push_str(&sp("justify-content", jc));
544+
}
545+
if let Some(ref w) = style.width {
546+
out.push_str(&sp("width", w));
547+
}
548+
if let Some(ref h) = style.height {
549+
out.push_str(&sp("height", h));
550+
}
551+
if let Some(fg) = style.flex_grow {
552+
out.push_str(&sp("flex-grow", &fg.to_string()));
553+
}
554+
if let Some(ref d) = style.display {
555+
out.push_str(&sp("display", d));
556+
}
557+
if let Some(ref p) = style.position {
558+
out.push_str(&sp("position", p));
559+
}
560+
if let Some(ref ov) = style.overflow {
561+
out.push_str(&sp("overflow", ov));
562+
}
563+
if let Some(ref t) = style.top { out.push_str(&sp("top", t)); }
564+
if let Some(ref r) = style.right { out.push_str(&sp("right", r)); }
565+
if let Some(ref b) = style.bottom { out.push_str(&sp("bottom", b)); }
566+
if let Some(ref l) = style.left { out.push_str(&sp("left", l)); }
567+
if let Some(zi) = style.z_index {
568+
out.push_str(&sp("z-index", &zi.to_string()));
569+
}
570+
}
571+
369572
#[cfg(test)]
370573
mod tests {
371574
use crate::parser::{AppTree, Node, NodeKind, StyleDecl};

crates/w3cos-dom/src/document.rs

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,12 +216,60 @@ impl Document {
216216
};
217217
}
218218

219+
let is_row = matches!(
220+
style.flex_direction,
221+
w3cos_std::style::FlexDirection::Row
222+
| w3cos_std::style::FlexDirection::RowReverse
223+
);
224+
219225
match node.tag.as_str() {
220226
"body" | "div" | "section" | "main" | "article" | "nav" | "header"
221-
| "footer" => w3cos_std::Component::column(style, children),
222-
"span" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => {
223-
if let Some(text) = &node.text_content {
227+
| "footer" | "aside" | "form" | "fieldset" | "ul" | "ol" | "dl" => {
228+
if is_row {
229+
w3cos_std::Component::row(style, children)
230+
} else {
231+
w3cos_std::Component::column(style, children)
232+
}
233+
}
234+
"span" | "label" | "em" | "strong" | "code" | "small" | "li" | "dd"
235+
| "dt" => {
236+
if let Some(text) = &node.text_content
237+
&& children.is_empty()
238+
{
224239
w3cos_std::Component::text(text, style)
240+
} else if is_row {
241+
w3cos_std::Component::row(style, children)
242+
} else {
243+
w3cos_std::Component::column(style, children)
244+
}
245+
}
246+
"p" => {
247+
if let Some(text) = &node.text_content
248+
&& children.is_empty()
249+
{
250+
w3cos_std::Component::text(text, style)
251+
} else {
252+
w3cos_std::Component::column(style, children)
253+
}
254+
}
255+
"h1" | "h2" | "h3" | "h4" | "h5" | "h6" => {
256+
if let Some(text) = &node.text_content {
257+
let mut heading_style = style;
258+
let default_size = match node.tag.as_str() {
259+
"h1" => 32.0,
260+
"h2" => 24.0,
261+
"h3" => 20.0,
262+
"h4" => 18.0,
263+
"h5" => 16.0,
264+
_ => 14.0,
265+
};
266+
if heading_style.font_size == 16.0 {
267+
heading_style.font_size = default_size;
268+
}
269+
if heading_style.font_weight == 400 {
270+
heading_style.font_weight = 700;
271+
}
272+
w3cos_std::Component::text(text, heading_style)
225273
} else {
226274
w3cos_std::Component::column(style, children)
227275
}
@@ -230,7 +278,32 @@ impl Document {
230278
let label = node.text_content.as_deref().unwrap_or("Button");
231279
w3cos_std::Component::button(label, style)
232280
}
233-
_ => w3cos_std::Component::column(style, children),
281+
"img" => {
282+
let src = node
283+
.attributes
284+
.iter()
285+
.find(|(k, _)| k == "src")
286+
.map(|(_, v)| v.as_str())
287+
.unwrap_or("");
288+
w3cos_std::Component::image(src, style)
289+
}
290+
"input" => {
291+
let placeholder = node
292+
.attributes
293+
.iter()
294+
.find(|(k, _)| k == "placeholder")
295+
.map(|(_, v)| v.as_str())
296+
.unwrap_or("");
297+
let value = node.text_content.as_deref().unwrap_or("");
298+
w3cos_std::Component::text_input(value, placeholder, style)
299+
}
300+
_ => {
301+
if is_row {
302+
w3cos_std::Component::row(style, children)
303+
} else {
304+
w3cos_std::Component::column(style, children)
305+
}
306+
}
234307
}
235308
}
236309
}
@@ -239,6 +312,16 @@ impl Document {
239312
pub fn node_count(&self) -> usize {
240313
self.nodes.len()
241314
}
315+
316+
/// Dispatch an event with bubbling through the DOM tree.
317+
pub fn dispatch_event_bubbling(&mut self, event: &mut crate::events::Event) {
318+
let parents: std::collections::HashMap<NodeId, Option<NodeId>> = self
319+
.nodes
320+
.iter()
321+
.map(|n| (n.id, n.parent))
322+
.collect();
323+
self.events.dispatch_with_bubbling(&parents, event);
324+
}
242325
}
243326

244327
impl Default for Document {

crates/w3cos-dom/src/events.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,27 @@ impl EventRegistry {
133133
}
134134
}
135135
}
136+
137+
/// Dispatch an event with W3C-standard bubbling up the DOM tree.
138+
/// Fires listeners on the target first, then walks up via parent pointers.
139+
pub fn dispatch_with_bubbling(
140+
&mut self,
141+
parents: &std::collections::HashMap<NodeId, Option<NodeId>>,
142+
event: &mut Event,
143+
) {
144+
let mut current = Some(event.target);
145+
while let Some(node_id) = current {
146+
for (listener_node, listener) in self.listeners.iter_mut() {
147+
if *listener_node == node_id && listener.event_type == event.event_type {
148+
(listener.handler)(event);
149+
if event.stop_propagation {
150+
return;
151+
}
152+
}
153+
}
154+
current = parents.get(&node_id).copied().flatten();
155+
}
156+
}
136157
}
137158

138159
impl Default for EventRegistry {

crates/w3cos-dom/src/node.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ impl NodeId {
88
pub fn as_u32(self) -> u32 {
99
self.0
1010
}
11+
12+
pub fn from_u32(v: u32) -> Self {
13+
Self(v)
14+
}
1115
}
1216

1317
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

0 commit comments

Comments
 (0)