Skip to content

Commit da056e6

Browse files
author
wangnaihe
committed
feat: implement Phase 1 interactive apps (reactive state, events, animations)
- Add reactive signal system (state.rs) with create/get/set/dirty tracking - Add EventAction enum (Increment/Decrement/Set/Toggle) on Component - Compiler: parse signal declarations, onClick attrs, {signal} interpolation - Runtime: rebuild UI on signal change, execute event actions on click - CSS transition animation (opacity, background) with 60fps frame loop - Scroll support (overflow: scroll/hidden with mousewheel + clip) - Image component with placeholder rendering - TextInput component with keyboard input and blinking cursor - Focus management (Tab/Shift+Tab cycling, Enter/Space activation) - position: fixed (viewport-relative) and sticky (relative fallback) - display: inline/inline-block via Taffy flex approximation - Migrate all examples from .ts to .tsx syntax - Reactive counter example with signal(0) + increment/decrement/reset - 139 tests passing, clippy clean Made-with: Cursor
1 parent 59fcc9c commit da056e6

File tree

21 files changed

+2129
-545
lines changed

21 files changed

+2129
-545
lines changed

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ w3cos-dom = { path = "crates/w3cos-dom" }
2424
w3cos-a11y = { path = "crates/w3cos-a11y" }
2525
w3cos-ai-bridge = { path = "crates/w3cos-ai-bridge" }
2626
png = "0.17"
27+
image = "0.25"
2728

2829
# Layout: Taffy 0.9 — Flexbox + Grid + Block + position
2930
taffy = { version = "0.9", features = ["std", "flexbox", "grid", "block_layout", "content_size"] }

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ cd w3cos
3535
cargo build --release
3636

3737
# Compile a TypeScript app to a native binary
38-
./target/release/w3cos build examples/showcase/app.ts -o showcase --release
38+
./target/release/w3cos build examples/showcase/app.tsx -o showcase --release
3939
./showcase # Opens a native window — no browser involved
4040
```
4141

ROADMAP.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,6 @@
5050
- [ ] PWA manifest support
5151
- [ ] npm package compatibility (pure-logic packages)
5252
- [ ] Cross-compilation: Linux x86/ARM, macOS
53-
- [ ] Android container (Waydroid integration)
54-
- [ ] Wine integration for Windows apps
55-
5653
## Phase 4 — Operating System
5754
- [ ] Bootable ISO (Buildroot) available on GitHub Releases
5855
- [ ] W3C OS as system shell (replaces desktop environment)

crates/w3cos-compiler/src/codegen.rs

Lines changed: 173 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,64 @@
1-
use crate::parser::{AppTree, Node, NodeKind, StyleDecl};
1+
use crate::parser::{AppTree, Node, NodeKind, SignalDecl, StyleDecl};
22
#[allow(unused_imports)]
33
use anyhow::Context;
44
use anyhow::Result;
55

66
/// Generate a complete Rust main.rs that builds and runs a W3C OS application.
77
pub fn generate(tree: &AppTree) -> Result<String> {
8-
let component_code = gen_node(&tree.root, 0);
8+
let is_reactive = !tree.signals.is_empty();
9+
let signal_names: Vec<&str> = tree.signals.iter().map(|s| s.name.as_str()).collect();
10+
let component_code = gen_node(&tree.root, 0, &signal_names);
911

10-
Ok(format!(
11-
r#"//! Auto-generated by w3cos compiler — do not edit.
12+
if is_reactive {
13+
let signal_inits = gen_signal_inits(&tree.signals);
14+
Ok(format!(
15+
r#"//! Auto-generated by w3cos compiler — do not edit.
16+
use w3cos_std::{{Component, EventAction, Style}};
17+
use w3cos_std::style::*;
18+
use w3cos_std::color::Color;
19+
20+
fn main() {{
21+
w3cos_runtime::run_app(build_ui).expect("W3C OS app crashed");
22+
}}
23+
24+
fn build_ui() -> Component {{
25+
{signal_inits}
26+
{component_code}
27+
}}
28+
"#
29+
))
30+
} else {
31+
Ok(format!(
32+
r#"//! Auto-generated by w3cos compiler — do not edit.
1233
use w3cos_std::{{Component, Style}};
1334
use w3cos_std::style::*;
1435
use w3cos_std::color::Color;
1536
1637
fn main() {{
17-
let root = build_ui();
18-
w3cos_runtime::run_app(root).expect("W3C OS app crashed");
38+
w3cos_runtime::run_app(build_ui).expect("W3C OS app crashed");
1939
}}
2040
2141
fn build_ui() -> Component {{
2242
{component_code}
2343
}}
2444
"#
25-
))
45+
))
46+
}
47+
}
48+
49+
fn gen_signal_inits(signals: &[SignalDecl]) -> String {
50+
signals
51+
.iter()
52+
.enumerate()
53+
.map(|(i, sig)| {
54+
format!(
55+
" let _ = w3cos_runtime::state::create_signal({initial});\n let {name} = w3cos_runtime::state::get_signal({i});",
56+
initial = sig.initial,
57+
name = sig.name,
58+
)
59+
})
60+
.collect::<Vec<_>>()
61+
.join("\n")
2662
}
2763

2864
/// Generate a Cargo.toml for the compiled application.
@@ -82,18 +118,32 @@ fn find_workspace_root() -> Result<std::path::PathBuf> {
82118
)
83119
}
84120

85-
fn gen_node(node: &Node, depth: usize) -> String {
121+
fn gen_node(node: &Node, depth: usize, signal_names: &[&str]) -> String {
86122
let indent = " ".repeat(depth + 1);
87123
let style_code = gen_style(&node.style, depth + 1);
88124

89125
match &node.kind {
90126
NodeKind::Text(content) => {
91127
let text = node.text.as_deref().unwrap_or(content.as_str());
92-
format!("{indent}Component::text({text:?}, {style_code})")
128+
let text_expr = gen_text_expr(text, signal_names);
129+
format!("{indent}Component::text({text_expr}, {style_code})")
93130
}
94131
NodeKind::Button(label) => {
95132
let lbl = node.label.as_deref().unwrap_or(label.as_str());
96-
format!("{indent}Component::button({lbl:?}, {style_code})")
133+
if let Some(ref action_str) = node.on_click {
134+
let action = gen_event_action(action_str, signal_names);
135+
format!("{indent}Component::button_with_click({lbl:?}, {style_code}, {action})")
136+
} else {
137+
format!("{indent}Component::button({lbl:?}, {style_code})")
138+
}
139+
}
140+
NodeKind::Image(src) => {
141+
let src_val = node.src.as_deref().unwrap_or(src.as_str());
142+
format!("{indent}Component::image({src_val:?}, {style_code})")
143+
}
144+
NodeKind::TextInput => {
145+
let placeholder = node.placeholder.as_deref().unwrap_or("Enter text");
146+
format!("{indent}Component::text_input(\"\", {placeholder:?}, {style_code})")
97147
}
98148
NodeKind::Column | NodeKind::Row | NodeKind::Box => {
99149
let constructor = match &node.kind {
@@ -108,7 +158,7 @@ fn gen_node(node: &Node, depth: usize) -> String {
108158
let items: Vec<String> = node
109159
.children
110160
.iter()
111-
.map(|c| gen_node(c, depth + 2))
161+
.map(|c| gen_node(c, depth + 2, signal_names))
112162
.collect();
113163
format!("vec![\n{},\n{indent}]", items.join(",\n"))
114164
};
@@ -117,6 +167,53 @@ fn gen_node(node: &Node, depth: usize) -> String {
117167
}
118168
}
119169

170+
fn gen_text_expr(text: &str, signal_names: &[&str]) -> String {
171+
if !text.contains('{') {
172+
return format!("{text:?}");
173+
}
174+
// Check for {signal_name} interpolation
175+
for (i, name) in signal_names.iter().enumerate() {
176+
let placeholder = format!("{{{name}}}");
177+
if text == placeholder {
178+
return format!("w3cos_runtime::state::get_signal({i}).to_string()");
179+
}
180+
if text.contains(&placeholder) {
181+
return format!(
182+
"{text:?}.replace(\"{placeholder}\", &w3cos_runtime::state::get_signal({i}).to_string())"
183+
);
184+
}
185+
}
186+
format!("{text:?}")
187+
}
188+
189+
fn gen_event_action(action_str: &str, signal_names: &[&str]) -> String {
190+
let parts: Vec<&str> = action_str.split(':').collect();
191+
if parts.len() < 2 {
192+
return "EventAction::None".to_string();
193+
}
194+
195+
let action_type = parts[0].trim();
196+
let signal_name = parts[1].trim();
197+
let signal_id = signal_names
198+
.iter()
199+
.position(|&n| n == signal_name)
200+
.unwrap_or(0);
201+
202+
match action_type {
203+
"increment" => format!("EventAction::Increment({signal_id})"),
204+
"decrement" => format!("EventAction::Decrement({signal_id})"),
205+
"toggle" => format!("EventAction::Toggle({signal_id})"),
206+
"set" => {
207+
let value = parts
208+
.get(2)
209+
.and_then(|v| v.trim().parse::<i64>().ok())
210+
.unwrap_or(0);
211+
format!("EventAction::Set({signal_id}, {value})")
212+
}
213+
_ => "EventAction::None".to_string(),
214+
}
215+
}
216+
120217
fn gen_style(s: &StyleDecl, depth: usize) -> String {
121218
let indent = " ".repeat(depth);
122219
let mut fields = Vec::new();
@@ -272,7 +369,11 @@ mod tests {
272369
children: vec![],
273370
text: Some("hello".to_string()),
274371
label: None,
372+
on_click: None,
373+
src: None,
374+
placeholder: None,
275375
},
376+
signals: vec![],
276377
};
277378
let rust = generate(&tree).unwrap();
278379
assert!(rust.contains("Component::text(\"hello\""));
@@ -289,7 +390,11 @@ mod tests {
289390
children: vec![],
290391
text: None,
291392
label: Some("click me".to_string()),
393+
on_click: None,
394+
src: None,
395+
placeholder: None,
292396
},
397+
signals: vec![],
293398
};
294399
let rust = generate(&tree).unwrap();
295400
assert!(rust.contains("Component::button(\"click me\""));
@@ -308,18 +413,28 @@ mod tests {
308413
children: vec![],
309414
text: Some("a".to_string()),
310415
label: None,
416+
on_click: None,
417+
src: None,
418+
placeholder: None,
311419
},
312420
Node {
313421
kind: NodeKind::Text("b".to_string()),
314422
style: StyleDecl::default(),
315423
children: vec![],
316424
text: Some("b".to_string()),
317425
label: None,
426+
on_click: None,
427+
src: None,
428+
placeholder: None,
318429
},
319430
],
320431
text: None,
321432
label: None,
433+
on_click: None,
434+
src: None,
435+
placeholder: None,
322436
},
437+
signals: vec![],
323438
};
324439
let rust = generate(&tree).unwrap();
325440
assert!(rust.contains("Component::column"));
@@ -328,6 +443,26 @@ mod tests {
328443
assert!(rust.contains("vec!["));
329444
}
330445

446+
#[test]
447+
fn codegen_text_input_produces_component_text_input() {
448+
let tree = AppTree {
449+
root: Node {
450+
kind: NodeKind::TextInput,
451+
style: StyleDecl::default(),
452+
children: vec![],
453+
text: None,
454+
label: None,
455+
on_click: None,
456+
src: None,
457+
placeholder: Some("Enter name".to_string()),
458+
},
459+
signals: vec![],
460+
};
461+
let rust = generate(&tree).unwrap();
462+
assert!(rust.contains("Component::text_input"));
463+
assert!(rust.contains("\"Enter name\""));
464+
}
465+
331466
#[test]
332467
fn codegen_row_with_style() {
333468
let mut style = StyleDecl::default();
@@ -339,7 +474,11 @@ mod tests {
339474
children: vec![],
340475
text: None,
341476
label: None,
477+
on_click: None,
478+
src: None,
479+
placeholder: None,
342480
},
481+
signals: vec![],
343482
};
344483
let rust = generate(&tree).unwrap();
345484
assert!(rust.contains("Component::row"));
@@ -358,10 +497,33 @@ mod tests {
358497
children: vec![],
359498
text: Some("styled".to_string()),
360499
label: None,
500+
on_click: None,
501+
src: None,
502+
placeholder: None,
361503
},
504+
signals: vec![],
362505
};
363506
let rust = generate(&tree).unwrap();
364507
assert!(rust.contains("Color::from_hex(\"#fff\")"));
365508
assert!(rust.contains("Color::from_hex(\"#e94560\")"));
366509
}
510+
511+
#[test]
512+
fn codegen_image_produces_component_image() {
513+
let tree = AppTree {
514+
root: Node {
515+
kind: NodeKind::Image("path.png".to_string()),
516+
style: StyleDecl::default(),
517+
children: vec![],
518+
text: None,
519+
label: None,
520+
on_click: None,
521+
src: Some("path.png".to_string()),
522+
placeholder: None,
523+
},
524+
signals: vec![],
525+
};
526+
let rust = generate(&tree).unwrap();
527+
assert!(rust.contains("Component::image(\"path.png\""));
528+
}
367529
}

0 commit comments

Comments
 (0)