use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use rux_layout::Node as LayoutNode;
use rux_parser::Sfc;
use rux_script::{Builder, Engine};
use rux_style::BindingRegistry;
pub use rux_reactive::json_string;
pub use rux_style::{InteractionState, Viewport, Warning};
pub struct Document {
sfc: Sfc,
components: HashMap<String, Sfc>,
engine: Engine,
base: PathBuf,
focus: Option<Focus>,
registry: BindingRegistry,
state: InteractionState,
viewport: Viewport,
diagnostics: Diagnostics,
pub root: LayoutNode,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Diagnostics {
pub error: Option<String>,
pub stale: bool,
pub warnings: Vec<Warning>,
}
impl Diagnostics {
pub fn is_empty(&self) -> bool {
self.error.is_none() && self.warnings.is_empty()
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Focus {
pub model: String,
pub caret: usize,
pub anchor: usize,
pub preedit: Option<(usize, usize)>,
}
impl Focus {
pub fn at(model: impl Into<String>, caret: usize) -> Self {
let model = model.into();
Self { model, caret, anchor: caret, preedit: None }
}
pub fn range(&self) -> (usize, usize) {
(self.caret.min(self.anchor), self.caret.max(self.anchor))
}
pub fn is_collapsed(&self) -> bool {
self.caret == self.anchor
}
}
fn apply_focus(node: &mut LayoutNode, focus: Option<&Focus>) {
if node.model.is_some() {
if let Some(text) = node.children.first_mut().and_then(|c| c.text.as_mut()) {
let mine = focus.filter(|f| node.model.as_deref() == Some(f.model.as_str()));
text.caret = mine.map(|f| f.caret.min(text.text.len()));
text.selection = mine.filter(|f| !f.is_collapsed()).map(|f| {
let (start, end) = f.range();
(start.min(text.text.len()), end.min(text.text.len()))
});
text.preedit = mine.and_then(|f| f.preedit).map(|(start, end)| {
(start.min(text.text.len()), end.min(text.text.len()))
});
}
}
for child in &mut node.children {
apply_focus(child, focus);
}
}
fn divergence(a: Option<&[usize]>, b: Option<&[usize]>) -> Vec<usize> {
match (a, b) {
(Some(a), Some(b)) => a.iter().zip(b).take_while(|(x, y)| x == y).map(|(x, _)| *x).collect(),
_ => Vec::new(),
}
}
fn collect_warnings() -> Vec<Warning> {
let mut warnings = rux_style::take_warnings();
warnings.extend(rux_script::take_warnings());
warnings
}
pub fn take_warnings() -> Vec<Warning> {
collect_warnings()
}
pub fn set_stderr_echo(on: bool) {
rux_script::set_stderr_echo(on);
rux_style::set_stderr_echo(on);
}
pub fn is_entry_point(path: impl AsRef<Path>) -> Option<bool> {
let src = std::fs::read_to_string(path.as_ref()).ok()?;
let sfc = rux_parser::parse_sfc(&src).ok()?;
Some(sfc.template.tag == "screen")
}
fn resolve_images(node: &mut LayoutNode, base: &Path) {
if let Some(img) = &mut node.image {
if !img.src.is_empty() {
let path = base.join(&img.src);
if let Ok((w, h)) = image::image_dimensions(&path) {
img.intrinsic = (w as f32, h as f32);
} else {
eprintln!("rux: cannot read image {}", path.display());
}
img.src = path.to_string_lossy().into_owned();
}
}
if let Some(rux_layout::Background::Image(src)) = &mut node.style.background {
if !src.is_empty() {
*src = base.join(&*src).to_string_lossy().into_owned();
}
}
for child in &mut node.children {
resolve_images(child, base);
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct LoadError {
pub message: String,
pub file: Option<PathBuf>,
pub line: Option<usize>,
pub column: Option<usize>,
parse: bool,
}
impl LoadError {
fn plain(message: String) -> Self {
Self { message, file: None, line: None, column: None, parse: false }
}
fn parse(err: rux_parser::ParseError, file: Option<&Path>) -> Self {
Self {
message: err.message,
file: file.map(Path::to_path_buf),
line: err.line,
column: err.column,
parse: true,
}
}
}
impl std::fmt::Display for LoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if !self.parse {
return write!(f, "{}", self.message);
}
match (self.line, self.column) {
(Some(l), Some(c)) => {
write!(f, "parse error at line {l}, column {c}: {}", self.message)
}
_ => write!(f, "parse error: {}", self.message),
}
}
}
impl std::error::Error for LoadError {}
impl Document {
pub fn load(path: impl AsRef<Path>) -> Result<Self, String> {
Self::load_checked(path).map_err(|e| e.to_string())
}
pub fn load_checked(path: impl AsRef<Path>) -> Result<Self, LoadError> {
let path = path.as_ref();
let src = std::fs::read_to_string(path)
.map_err(|e| LoadError::plain(format!("reading {}: {e}", path.display())))?;
let sfc = rux_parser::parse_sfc(&src).map_err(|e| LoadError::parse(e, Some(path)))?;
let base = path.parent().unwrap_or_else(|| Path::new("."));
let (main_script, imports) = extract_imports(&sfc.script);
let mut components = HashMap::new();
let mut combined_script = main_script;
for import in imports {
let comp_path = base.join(&import.file);
let comp_src = std::fs::read_to_string(&comp_path).map_err(|e| {
LoadError::plain(format!("reading component {}: {e}", comp_path.display()))
})?;
let comp_sfc =
rux_parser::parse_sfc(&comp_src).map_err(|e| LoadError::parse(e, Some(&comp_path)))?;
let (comp_script, _nested) = extract_imports(&comp_sfc.script);
combined_script.push('\n');
combined_script.push_str(&comp_script);
components.insert(import.tag, comp_sfc);
}
let mut engine = build_engine(&combined_script).map_err(LoadError::plain)?;
let (mut root, registry) = rux_style::build_styled_tree_tracked(&sfc, &components, &mut engine)
.map_err(LoadError::plain)?;
resolve_images(&mut root, base);
Ok(Self {
sfc,
components,
engine,
base: base.to_path_buf(),
focus: None,
registry,
state: InteractionState::default(),
viewport: Viewport::default(),
diagnostics: Diagnostics {
warnings: collect_warnings(),
..Diagnostics::default()
},
root,
})
}
pub fn from_source(src: &str) -> Result<Self, String> {
Self::from_source_checked(src).map_err(|e| e.to_string())
}
pub fn from_source_checked(src: &str) -> Result<Self, LoadError> {
let sfc = rux_parser::parse_sfc(src).map_err(|e| LoadError::parse(e, None))?;
let (main_script, _imports) = extract_imports(&sfc.script);
let mut engine = build_engine(&main_script).map_err(LoadError::plain)?;
let (mut root, registry) =
rux_style::build_styled_tree_tracked(&sfc, &HashMap::new(), &mut engine)
.map_err(LoadError::plain)?;
let base = PathBuf::from(".");
resolve_images(&mut root, &base);
Ok(Self {
sfc,
components: HashMap::new(),
engine,
base,
focus: None,
registry,
state: InteractionState::default(),
viewport: Viewport::default(),
diagnostics: Diagnostics {
warnings: collect_warnings(),
..Diagnostics::default()
},
root,
})
}
pub fn engine_mut(&mut self) -> &mut Engine {
&mut self.engine
}
pub fn diagnostics(&self) -> &Diagnostics {
&self.diagnostics
}
pub fn set_load_error(&mut self, error: impl Into<String>) {
self.diagnostics.error = Some(error.into());
self.diagnostics.stale = true;
}
pub fn clear_stale(&mut self) {
self.diagnostics.stale = false;
}
pub fn replace_with(&mut self, mut fresh: Document) {
fresh.viewport = self.viewport;
fresh.state = self.state.clone();
fresh.rebuild();
*self = fresh;
}
pub fn set_focus(&mut self, focus: Option<Focus>) {
self.focus = focus;
apply_focus(&mut self.root, self.focus.as_ref());
}
pub fn interaction(&self) -> &InteractionState {
&self.state
}
pub fn set_interaction(&mut self, next: InteractionState) -> bool {
if next == self.state {
return false;
}
let mut roots: Vec<Vec<usize>> = Vec::new();
if next.focused_model == self.state.focused_model {
roots.push(divergence(self.state.hovered.as_deref(), next.hovered.as_deref()));
roots.push(divergence(self.state.active.as_deref(), next.active.as_deref()));
} else {
roots.push(Vec::new());
}
self.state = next;
self.restyle(&roots);
true
}
pub fn set_viewport(&mut self, viewport: Viewport) -> bool {
if viewport == self.viewport {
return false;
}
let before = self.media_state(self.viewport);
let after = self.media_state(viewport);
self.viewport = viewport;
if before == after {
return false;
}
self.rebuild();
true
}
fn media_state(&self, viewport: Viewport) -> Vec<bool> {
let mut out = rux_style::media_matches(&self.sfc.style, viewport);
let mut tags: Vec<&String> = self.components.keys().collect();
tags.sort();
for tag in tags {
out.extend(rux_style::media_matches(&self.components[tag].style, viewport));
}
out
}
fn restyle(&mut self, roots: &[Vec<usize>]) {
let Ok((mut fresh_root, fresh_reg)) = rux_style::build_styled_tree_stateful(
&self.sfc,
&self.components,
&mut self.engine,
&self.state,
self.viewport,
) else {
return;
};
resolve_images(&mut fresh_root, &self.base);
for path in roots {
let Some(fresh) = node_at(&fresh_root, path) else { continue };
let fresh_node = fresh.clone();
if let Some(live) = node_at_mut(&mut self.root, path) {
*live = fresh_node;
apply_focus(live, self.focus.as_ref());
}
}
self.registry = fresh_reg;
}
pub fn rebuild(&mut self) {
if let Ok((mut root, registry)) = rux_style::build_styled_tree_stateful(
&self.sfc,
&self.components,
&mut self.engine,
&self.state,
self.viewport,
) {
resolve_images(&mut root, &self.base);
apply_focus(&mut root, self.focus.as_ref());
self.registry = registry;
self.root = root;
self.diagnostics.warnings = collect_warnings();
}
}
#[must_use]
pub fn patch(&mut self, changed: &HashSet<String>) -> bool {
if changed.is_empty() {
return true; }
if !self.registry.structural.is_disjoint(changed) {
return false;
}
self.reconcile(changed);
self.patch_values(changed);
true
}
fn patch_values(&mut self, changed: &HashSet<String>) {
for binding in &self.registry.text {
if binding.deps.is_disjoint(changed) {
continue;
}
let text = rux_style::eval_text_binding(binding, &mut self.engine);
if let Some(node) = node_at_mut(&mut self.root, &binding.path) {
if let Some(content) = node.text.as_mut() {
content.text = text;
}
}
}
for binding in &self.registry.value {
if binding.deps.is_disjoint(changed) {
continue;
}
let (text, color) = rux_style::eval_value_binding(binding, &mut self.engine);
if let Some(node) = node_at_mut(&mut self.root, &binding.path) {
if let Some(content) = node.children.first_mut().and_then(|c| c.text.as_mut()) {
content.text = text;
content.color = color;
}
}
}
for binding in &self.registry.show {
if binding.deps.is_disjoint(changed) {
continue;
}
let visible = self.engine.eval_bool(&binding.cond, &binding.locals);
if let Some(node) = node_at_mut(&mut self.root, &binding.path) {
node.hidden = !visible;
}
}
for binding in &self.registry.src {
if binding.deps.is_disjoint(changed) {
continue;
}
let raw = rux_style::eval_src_binding(binding, &mut self.engine);
if let Some(node) = node_at_mut(&mut self.root, &binding.path) {
if let Some(img) = node.image.as_mut() {
img.src = raw;
}
resolve_images(node, &self.base);
}
}
for binding in &self.registry.options {
if binding.deps.is_disjoint(changed) {
continue;
}
let opts = rux_style::eval_options_binding(binding, &mut self.engine);
if let Some(node) = node_at_mut(&mut self.root, &binding.path) {
node.options = Some(opts);
}
}
}
fn reconcile(&mut self, changed: &HashSet<String>) {
let mut affected: Vec<Vec<usize>> = self
.registry
.structural_parents
.iter()
.filter(|p| !p.deps.is_disjoint(changed))
.map(|p| p.tree_path.clone())
.collect();
let toggles: Vec<Vec<usize>> = self
.registry
.toggles
.iter()
.filter(|t| !t.deps.is_disjoint(changed))
.map(|t| t.path.clone())
.collect();
let mut node_splices: Vec<Vec<usize>> = self
.registry
.components
.iter()
.filter(|c| !c.deps.is_disjoint(changed))
.map(|c| c.path.clone())
.collect();
node_splices.extend(
self.registry
.styled
.iter()
.filter(|s| !s.deps.is_disjoint(changed))
.map(|s| s.path.clone()),
);
if affected.is_empty() && toggles.is_empty() && node_splices.is_empty() {
return;
}
affected.sort_by_key(Vec::len);
let mut roots: Vec<Vec<usize>> = Vec::new();
for p in affected {
if !roots.iter().any(|r| p.starts_with(r.as_slice())) {
roots.push(p);
}
}
let Ok((mut fresh_root, fresh_reg)) = rux_style::build_styled_tree_stateful(
&self.sfc,
&self.components,
&mut self.engine,
&self.state,
self.viewport,
) else {
return;
};
resolve_images(&mut fresh_root, &self.base);
for p in &roots {
let Some(fresh) = node_at(&fresh_root, p) else { continue };
let fresh_children = fresh.children.clone();
if let Some(live) = node_at_mut(&mut self.root, p) {
live.children = fresh_children;
apply_focus(live, self.focus.as_ref());
}
}
for p in &toggles {
if roots.iter().any(|r| p.starts_with(r.as_slice())) {
continue; }
if let Some(fresh) = node_at(&fresh_root, p) {
let fresh_node = fresh.clone();
if let Some(live) = node_at_mut(&mut self.root, p) {
*live = fresh_node;
}
}
}
for p in &node_splices {
if roots.iter().any(|r| p.starts_with(r.as_slice())) {
continue;
}
if let Some(fresh) = node_at(&fresh_root, p) {
let fresh_node = fresh.clone();
if let Some(live) = node_at_mut(&mut self.root, p) {
*live = fresh_node;
apply_focus(live, self.focus.as_ref());
}
}
}
self.registry = fresh_reg;
}
pub fn apply_edit(&mut self, model: &str, value: &str) {
self.engine.set_string(model, value);
let changed: HashSet<String> = std::iter::once(model.to_string()).collect();
self.apply_change(&changed);
}
pub fn apply_handler(&mut self, src: &str) -> bool {
let changed = self.engine.run_handler_tracked(src);
if changed.is_empty() {
return false;
}
self.apply_change(&changed);
true
}
fn apply_change(&mut self, changed: &HashSet<String>) {
let patched = self.patch(changed);
if !patched {
self.rebuild();
}
if std::env::var_os("RUX_TRACE").is_some() {
let mut names: Vec<&str> = changed.iter().map(String::as_str).collect();
names.sort_unstable();
eprintln!(
"rux: change {names:?} → {}",
if patched { "patched in place (no rebuild)" } else { "rebuilt (structural)" }
);
}
}
}
fn node_at<'a>(root: &'a LayoutNode, path: &[usize]) -> Option<&'a LayoutNode> {
let mut node = root;
for &i in path {
node = node.children.get(i)?;
}
Some(node)
}
fn node_at_mut<'a>(root: &'a mut LayoutNode, path: &[usize]) -> Option<&'a mut LayoutNode> {
let mut node = root;
for &i in path {
node = node.children.get_mut(i)?;
}
Some(node)
}
struct Import {
tag: String,
file: String,
}
fn extract_imports(script: &str) -> (String, Vec<Import>) {
let mut cleaned = String::new();
let mut imports = Vec::new();
for line in script.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("use ") {
if let Some(path) = rest.strip_suffix(';').map(str::trim).filter(|p| {
!p.is_empty() && !p.contains(char::is_whitespace) && !p.contains(';')
}) {
let segments: Vec<&str> = path.split("::").collect();
let file = format!("{}.rux", segments.join("/"));
let tag = segments
.last()
.map(|s| s.replace('_', "-"))
.unwrap_or_default();
imports.push(Import { tag, file });
continue; }
}
cleaned.push_str(line);
cleaned.push('\n');
}
(cleaned, imports)
}
fn build_engine(script: &str) -> Result<Engine, String> {
let mut builder = Builder::new();
builder.host_number("full", || 100.0);
builder.build(script)
}
#[cfg(test)]
mod tests {
use super::*;
fn find_text(node: &LayoutNode, needle: &str) -> bool {
if let Some(t) = &node.text {
if t.text.contains(needle) {
return true;
}
}
node.children.iter().any(|c| find_text(c, needle))
}
#[test]
fn loads_document_and_expands_imported_component() {
use std::fs;
let dir = std::env::temp_dir().join(format!("rux_test_{}", std::process::id()));
let comp_dir = dir.join("components");
fs::create_dir_all(&comp_dir).unwrap();
fs::write(
comp_dir.join("stat.rux"),
r#"<template><view><text>{{ label }}: {{ value }}</text></view></template>"#,
)
.unwrap();
fs::write(
dir.join("app.rux"),
"<template><screen><stat :label=\"title\" :value=\"n\" /></screen></template>\n\
<script>\n\
use components::stat;\n\
let title = signal(\"Battery\");\n\
let n = signal(82);\n\
</script>",
)
.unwrap();
let doc = Document::load(dir.join("app.rux")).expect("load app");
assert!(find_text(&doc.root, "Battery"), "component label prop rendered");
assert!(find_text(&doc.root, "82"), "component value prop rendered");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn resolves_image_src_and_intrinsic_size() {
use std::fs;
let dir = std::env::temp_dir().join(format!("rux_img_{}", std::process::id()));
fs::create_dir_all(dir.join("assets")).unwrap();
let png = dir.join("assets/dot.png");
image::RgbaImage::from_pixel(2, 1, image::Rgba([255, 0, 0, 255]))
.save(&png)
.unwrap();
fs::write(
dir.join("app.rux"),
r#"<template><screen><image src="assets/dot.png" /></screen></template>"#,
)
.unwrap();
let doc = Document::load(dir.join("app.rux")).expect("load app");
let img = doc.root.children[0].image.as_ref().expect("image node");
assert_eq!(img.intrinsic, (2.0, 1.0));
assert_eq!(Path::new(&img.src), png, "src resolved against the .rux dir");
let _ = fs::remove_dir_all(&dir);
}
fn caret_of(node: &LayoutNode, model: &str) -> Option<usize> {
if node.model.as_deref() == Some(model) {
return node.children.first()?.text.as_ref()?.caret;
}
node.children.iter().find_map(|c| caret_of(c, model))
}
#[test]
fn focus_moves_the_caret_out_of_the_old_input() {
let mut doc = Document::from_source(
"<template><screen> <input r-model=\"name\" /><input r-model=\"city\" /> </screen></template>
<script>let name = signal(\"abc\"); let city = signal(\"xyz\");</script>",
)
.expect("load");
doc.set_focus(Some(Focus::at("name", 2)));
assert_eq!(caret_of(&doc.root, "name"), Some(2));
assert_eq!(caret_of(&doc.root, "city"), None);
doc.set_focus(Some(Focus::at("city", 1)));
assert_eq!(caret_of(&doc.root, "name"), None, "old input kept its caret");
assert_eq!(caret_of(&doc.root, "city"), Some(1));
doc.set_focus(None);
assert_eq!(caret_of(&doc.root, "name"), None);
assert_eq!(caret_of(&doc.root, "city"), None);
}
fn selection_of(node: &LayoutNode, model: &str) -> Option<(usize, usize)> {
if node.model.as_deref() == Some(model) {
return node.children.first()?.text.as_ref()?.selection;
}
node.children.iter().find_map(|c| selection_of(c, model))
}
fn preedit_of(node: &LayoutNode, model: &str) -> Option<(usize, usize)> {
if node.model.as_deref() == Some(model) {
return node.children.first()?.text.as_ref()?.preedit;
}
node.children.iter().find_map(|c| preedit_of(c, model))
}
fn two_inputs() -> Document {
Document::from_source(
"<template><screen> <input r-model=\"name\" /><input r-model=\"city\" /> </screen></template>
<script>let name = signal(\"abc\"); let city = signal(\"xyz\");</script>",
)
.expect("load")
}
#[test]
fn selection_paints_only_in_the_focused_input() {
let mut doc = two_inputs();
doc.set_focus(Some(Focus { model: "name".into(), caret: 3, anchor: 1, preedit: None }));
assert_eq!(selection_of(&doc.root, "name"), Some((1, 3)));
assert_eq!(selection_of(&doc.root, "city"), None);
doc.set_focus(Some(Focus { model: "name".into(), caret: 1, anchor: 3, preedit: None }));
assert_eq!(selection_of(&doc.root, "name"), Some((1, 3)));
}
#[test]
fn focus_moves_the_selection_out_of_the_old_input() {
let mut doc = two_inputs();
doc.set_focus(Some(Focus { model: "name".into(), caret: 3, anchor: 0, preedit: None }));
assert_eq!(selection_of(&doc.root, "name"), Some((0, 3)));
doc.set_focus(Some(Focus { model: "city".into(), caret: 2, anchor: 0, preedit: None }));
assert_eq!(selection_of(&doc.root, "name"), None, "old input kept its selection");
assert_eq!(selection_of(&doc.root, "city"), Some((0, 2)));
doc.set_focus(None);
assert_eq!(selection_of(&doc.root, "name"), None);
assert_eq!(selection_of(&doc.root, "city"), None);
}
#[test]
fn a_collapsed_selection_is_none() {
let mut doc = two_inputs();
doc.set_focus(Some(Focus::at("name", 2)));
assert_eq!(caret_of(&doc.root, "name"), Some(2));
assert_eq!(selection_of(&doc.root, "name"), None);
}
#[test]
fn selection_survives_a_rebuild() {
let mut doc = two_inputs();
doc.set_focus(Some(Focus { model: "name".into(), caret: 3, anchor: 1, preedit: None }));
doc.rebuild();
assert_eq!(selection_of(&doc.root, "name"), Some((1, 3)));
assert_eq!(caret_of(&doc.root, "name"), Some(3));
assert_eq!(selection_of(&doc.root, "city"), None);
}
#[test]
fn a_composition_marks_only_the_focused_input() {
let mut doc = two_inputs();
doc.set_focus(Some(Focus {
model: "name".into(),
caret: 3,
anchor: 3,
preedit: Some((1, 3)),
}));
assert_eq!(preedit_of(&doc.root, "name"), Some((1, 3)));
assert_eq!(preedit_of(&doc.root, "city"), None);
doc.set_focus(Some(Focus::at("city", 1)));
assert_eq!(preedit_of(&doc.root, "name"), None, "old input kept its composition");
assert_eq!(preedit_of(&doc.root, "city"), None);
}
#[test]
fn a_composition_survives_a_rebuild() {
let mut doc = two_inputs();
doc.set_focus(Some(Focus {
model: "name".into(),
caret: 2,
anchor: 2,
preedit: Some((0, 2)),
}));
doc.rebuild();
assert_eq!(preedit_of(&doc.root, "name"), Some((0, 2)));
}
fn patch_doc() -> Document {
Document::from_source(
"<template><screen><text class=\"c\">{{ n }}</text><input r-model=\"name\" /></screen></template>
<script>let n = signal(0); let name = signal(\"hi\");</script>",
)
.expect("load")
}
#[test]
fn patch_updates_text_and_preserves_caret() {
let mut doc = patch_doc();
doc.set_focus(Some(Focus::at("name", 1)));
let changed = doc.engine_mut().run_handler_tracked("n = n + 1");
assert!(doc.patch(&changed), "a display-only change patches in place");
assert_eq!(doc.root.children[0].text.as_ref().unwrap().text, "1");
assert_eq!(caret_of(&doc.root, "name"), Some(1));
}
#[test]
fn patch_updates_input_value_in_place() {
let mut doc = patch_doc();
let changed = doc.engine_mut().run_handler_tracked("name = \"yo\"");
assert!(doc.patch(&changed), "an input value change patches in place");
assert_eq!(doc.root.children[1].children[0].text.as_ref().unwrap().text, "yo");
assert_eq!(doc.root.children[0].text.as_ref().unwrap().text, "0");
}
fn input_text(doc: &Document) -> &str {
&doc.root.children[0].children[0].text.as_ref().unwrap().text
}
#[test]
fn typing_patches_the_input_value_in_place() {
let mut doc = Document::from_source(
"<template><screen><input r-model=\"name\" placeholder=\"type…\" /></screen></template>
<script>let name = signal(\"ab\");</script>",
)
.expect("load");
doc.set_focus(Some(Focus::at("name", 2)));
assert_eq!(input_text(&doc), "ab");
doc.engine_mut().set_string("name", "abc");
let changed: HashSet<String> = std::iter::once("name".to_string()).collect();
assert!(doc.patch(&changed), "value-only input edit patches in place");
assert_eq!(input_text(&doc), "abc");
doc.engine_mut().set_string("name", "");
assert!(doc.patch(&changed));
assert_eq!(input_text(&doc), "type…");
}
#[test]
fn options_patch_in_place() {
let mut doc = Document::from_source(
"<template><screen><input type=\"select\" r-model=\"fruit\" :options=\"fruits\" /></screen></template>
<script>let fruit = signal(\"a\"); let fruits = signal([\"a\", \"b\"]);</script>",
)
.expect("load");
assert_eq!(doc.root.children[0].options.as_ref().unwrap().len(), 2);
let changed = doc.engine_mut().run_handler_tracked("fruits = [\"a\", \"b\", \"c\"]");
assert!(doc.patch(&changed), "an :options change patches in place");
assert_eq!(doc.root.children[0].options.as_ref().unwrap().len(), 3, "list grew in place");
}
#[test]
fn component_prop_reconciles_in_place() {
use std::fs;
let dir = std::env::temp_dir().join(format!("rux_prop_{}", std::process::id()));
let comp_dir = dir.join("components");
fs::create_dir_all(&comp_dir).unwrap();
fs::write(
comp_dir.join("stat.rux"),
r#"<template><view><text>{{ value }}</text></view></template>"#,
)
.unwrap();
fs::write(
dir.join("app.rux"),
"<template><screen><stat :value=\"n\" /></screen></template>\n\
<script>\nuse components::stat;\nlet n = signal(1);\n</script>",
)
.unwrap();
let mut doc = Document::load(dir.join("app.rux")).expect("load app");
assert!(find_text(&doc.root, "1"), "prop starts at 1");
let changed = doc.engine_mut().run_handler_tracked("n = 2");
assert!(doc.patch(&changed), "a component prop change reconciles in place");
assert!(find_text(&doc.root, "2"), "component re-expanded with the new prop");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn toggle_reconciles_and_preserves_an_outside_caret() {
let mut doc = Document::from_source(
"<template><screen>\
<input r-model=\"name\" />\
<input type=\"checkbox\" class=\"box\" r-model=\"on\" />\
</screen></template>
<style>.box { background: #000000; } .box.checked { background: #00ff00; }</style>
<script>let name = signal(\"ab\"); let on = signal(false);</script>",
)
.expect("load");
doc.set_focus(Some(Focus::at("name", 1)));
let green = |n: &LayoutNode| matches!(&n.style.background, Some(rux_layout::Background::Color(c)) if c.g == 1.0);
assert!(!green(&doc.root.children[1]), "unchecked → not green");
let changed = doc.engine_mut().run_handler_tracked("on = true");
assert!(doc.patch(&changed), "a toggle reconciles in place");
assert!(green(&doc.root.children[1]), "checked → .box.checked (green) applied");
assert!(doc.root.children[1].children.len() == 1, "checkmark added");
assert_eq!(caret_of(&doc.root, "name"), Some(1));
}
#[test]
fn warnings_are_collected_for_the_overlay() {
let doc = Document::from_source(
"<template><screen><view class=\"card\" /></screen></template>
<style>.card { filter: blur(2px); background: var(--nope); }</style>",
)
.expect("load");
let warnings = &doc.diagnostics().warnings;
assert!(
warnings.iter().any(|w| w.message.contains("filter")),
"unhonored property reported: {warnings:?}"
);
assert!(
warnings.iter().any(|w| w.message.contains("--nope")),
"undefined var reported: {warnings:?}"
);
assert!(doc.diagnostics().error.is_none(), "the document still built");
}
#[test]
fn a_clean_document_has_no_diagnostics() {
let doc = Document::from_source(
"<template><screen><view class=\"card\" /></screen></template>
<style>.card { background: #313244; }</style>",
)
.expect("load");
assert!(doc.diagnostics().is_empty(), "{:?}", doc.diagnostics());
}
#[test]
fn a_failed_reload_keeps_the_last_good_tree() {
let mut doc = Document::from_source(
"<template><screen><text>hello</text></screen></template>",
)
.expect("load");
let before = doc.root.children.len();
doc.set_load_error("parse error at line 6, column 13: mismatched closing tag");
assert_eq!(doc.root.children.len(), before, "the tree is untouched");
assert!(doc.diagnostics().error.is_some());
assert!(doc.diagnostics().stale, "what's on screen predates the error");
}
#[test]
fn a_successful_reload_clears_the_error() {
let mut doc = Document::from_source("<template><screen><text>old</text></screen></template>")
.expect("load");
doc.set_load_error("something was wrong");
let fresh = Document::from_source("<template><screen><text>new</text></screen></template>")
.expect("load");
doc.replace_with(fresh);
assert!(doc.diagnostics().error.is_none(), "error cleared");
assert!(!doc.diagnostics().stale);
assert_eq!(doc.root.children[0].text.as_ref().unwrap().text, "new");
}
#[test]
fn a_reload_keeps_the_window_viewport() {
let mut doc = media_doc();
doc.set_viewport(Viewport { width: 480.0, height: 800.0 });
assert!(is_red(&doc.root.children[0]));
let fresh = Document::from_source(
"<template><screen><view class=\"card\" /></screen></template>
<style>
.card { background: #00ff00; }
@media (max-width: 600px) { .card { background: #ff0000; } }
</style>",
)
.expect("load");
doc.replace_with(fresh);
assert!(
is_red(&doc.root.children[0]),
"still narrow after the reload, so the @media rule still applies"
);
}
fn media_doc() -> Document {
Document::from_source(
"<template><screen><view class=\"card\" /></screen></template>
<style>
.card { background: #00ff00; }
@media (max-width: 600px) { .card { background: #ff0000; } }
</style>",
)
.expect("load")
}
fn is_red(n: &LayoutNode) -> bool {
matches!(&n.style.background, Some(rux_layout::Background::Color(c)) if c.r == 1.0 && c.g == 0.0)
}
#[test]
fn resize_across_a_breakpoint_restyles() {
let mut doc = media_doc();
assert!(!is_red(&doc.root.children[0]), "the default viewport is wide");
assert!(doc.set_viewport(Viewport { width: 480.0, height: 800.0 }), "breakpoint crossed");
assert!(is_red(&doc.root.children[0]), "narrow → the @media rule applies");
assert!(doc.set_viewport(Viewport { width: 1000.0, height: 800.0 }), "crossed back");
assert!(!is_red(&doc.root.children[0]), "wide again → the base rule");
}
#[test]
fn resize_within_a_breakpoint_is_not_a_change() {
let mut doc = media_doc();
doc.set_viewport(Viewport { width: 400.0, height: 800.0 });
assert!(
!doc.set_viewport(Viewport { width: 500.0, height: 800.0 }),
"still under 600px, nothing to redo"
);
assert!(is_red(&doc.root.children[0]), "and the styling is still correct");
}
#[test]
fn resize_does_nothing_without_media_queries() {
let mut doc = Document::from_source(
"<template><screen><view class=\"card\" /></screen></template>
<style>.card { background: #00ff00; }</style>",
)
.expect("load");
assert!(!doc.set_viewport(Viewport { width: 320.0, height: 480.0 }));
assert!(!doc.set_viewport(Viewport { width: 1600.0, height: 900.0 }));
}
fn hover_doc() -> Document {
Document::from_source(
"<template><screen>\
<view class=\"card\"><text>one</text></view>\
<view class=\"card\"><input r-model=\"name\" /></view>\
</screen></template>
<style>.card { background: #000000; } .card:hover { background: #00ff00; }</style>
<script>let name = signal(\"ab\");</script>",
)
.expect("load")
}
fn hovering(path: &[usize]) -> InteractionState {
InteractionState { hovered: Some(path.to_vec()), ..InteractionState::default() }
}
fn is_green(n: &LayoutNode) -> bool {
matches!(&n.style.background, Some(rux_layout::Background::Color(c)) if c.g == 1.0)
}
#[test]
fn hover_restyles_only_the_hovered_element() {
let mut doc = hover_doc();
assert!(!is_green(&doc.root.children[0]), "nothing hovered → no green");
assert!(doc.set_interaction(hovering(&[0])), "entering a card restyles");
assert!(is_green(&doc.root.children[0]), "hovered card is green");
assert!(!is_green(&doc.root.children[1]), "its sibling is NOT");
assert!(doc.set_interaction(InteractionState::default()), "leaving restyles");
assert!(!is_green(&doc.root.children[0]), "hover ends → back to black");
}
#[test]
fn same_hover_target_is_not_a_change() {
let mut doc = hover_doc();
assert!(doc.set_interaction(hovering(&[0])));
assert!(
!doc.set_interaction(hovering(&[0])),
"re-reporting the same target does no work"
);
}
#[test]
fn hover_change_preserves_a_caret_elsewhere() {
let mut doc = hover_doc();
doc.set_focus(Some(Focus::at("name", 1)));
assert_eq!(caret_of(&doc.root, "name"), Some(1));
assert!(doc.set_interaction(hovering(&[0])));
assert_eq!(caret_of(&doc.root, "name"), Some(1), "caret survives a hover change");
assert!(is_green(&doc.root.children[0]));
}
#[test]
fn clearing_pointer_state_unstyles_the_hovered_element() {
let mut doc = hover_doc();
doc.set_interaction(InteractionState {
hovered: Some(vec![0]),
active: Some(vec![0]),
..InteractionState::default()
});
assert!(is_green(&doc.root.children[0]));
assert!(doc.set_interaction(InteractionState::default()), "clearing restyles");
assert!(!is_green(&doc.root.children[0]), "nothing is hovered any more");
}
#[test]
fn hover_applies_to_the_ancestor_chain() {
let mut doc = Document::from_source(
"<template><screen>\
<view class=\"card\"><view class=\"inner\"><text>x</text></view></view>\
</screen></template>
<style>\
.card { background: #000000; } .card:hover { background: #00ff00; }\
.inner:hover { background: #0000ff; }\
</style>",
)
.expect("load");
assert!(doc.set_interaction(hovering(&[0, 0])));
assert!(is_green(&doc.root.children[0]), "the ancestor card is hovered too");
let inner = &doc.root.children[0].children[0];
assert!(
matches!(&inner.style.background, Some(rux_layout::Background::Color(c)) if c.b == 1.0),
"the inner box is hovered"
);
}
#[test]
fn no_pointer_rules_means_no_state_regions() {
let doc = Document::from_source(
"<template><screen><view class=\"card\"><text>x</text></view></screen></template>
<style>.card { background: #000000; }</style>",
)
.expect("load");
fn any_marked(n: &LayoutNode) -> bool {
n.state_path.is_some() || n.children.iter().any(any_marked)
}
assert!(!any_marked(&doc.root), "no :hover/:active rule → nothing to track");
}
#[test]
fn hoverable_elements_are_marked_for_the_shell() {
let doc = hover_doc();
assert_eq!(doc.root.children[0].state_path.as_deref(), Some(&[0][..]));
assert_eq!(doc.root.children[1].state_path.as_deref(), Some(&[1][..]));
assert!(doc.root.state_path.is_none(), "the screen has no :hover rule");
}
#[test]
fn r_show_toggles_hidden_in_place() {
let mut doc = Document::from_source(
"<template><screen><text r-show=\"on\">hi</text></screen></template>
<script>let on = signal(true);</script>",
)
.expect("load");
assert!(!doc.root.children[0].hidden, "on=true → visible");
let changed = doc.engine_mut().run_handler_tracked("on = false");
assert!(doc.patch(&changed), "r-show change patches in place");
assert!(doc.root.children[0].hidden, "on=false → hidden");
let changed = doc.engine_mut().run_handler_tracked("on = true");
assert!(doc.patch(&changed));
assert!(!doc.root.children[0].hidden, "on=true → visible again");
}
#[test]
fn r_if_reconciles_and_preserves_an_outside_caret() {
let mut doc = Document::from_source(
"<template><screen>\
<view class=\"top\"><input r-model=\"name\" /></view>\
<view class=\"list\"><text r-if=\"show\">secret</text></view>\
</screen></template>
<script>let name = signal(\"ab\"); let show = signal(false);</script>",
)
.expect("load");
doc.set_focus(Some(Focus::at("name", 1)));
assert_eq!(caret_of(&doc.root, "name"), Some(1));
assert!(!find_text(&doc.root, "secret"), "hidden while show=false");
let changed = doc.engine_mut().run_handler_tracked("show = true");
assert!(doc.patch(&changed), "an r-if change reconciles in place");
assert!(find_text(&doc.root, "secret"), "branch now shown");
assert_eq!(caret_of(&doc.root, "name"), Some(1), "outside caret survived");
let changed = doc.engine_mut().run_handler_tracked("show = false");
assert!(doc.patch(&changed));
assert!(!find_text(&doc.root, "secret"));
assert_eq!(caret_of(&doc.root, "name"), Some(1));
}
#[test]
fn r_for_reconciles_row_count() {
let mut doc = Document::from_source(
"<template><screen><view class=\"list\"><text r-for=\"n in nums\">{{ n }}</text></view></screen></template>
<script>let nums = signal([1, 2]);</script>",
)
.expect("load");
assert_eq!(doc.root.children[0].children.len(), 2, "two rows initially");
let changed = doc.engine_mut().run_handler_tracked("nums = [1, 2, 3, 4]");
assert!(doc.patch(&changed), "an r-for change reconciles in place");
assert_eq!(doc.root.children[0].children.len(), 4, "grew to four rows");
assert!(find_text(&doc.root, "4"), "new row content present");
}
#[test]
fn label_for_inherits_the_targets_tap() {
let doc = Document::from_source(
"<template><screen>\
<input type=\"checkbox\" id=\"chk\" r-model=\"on\" />\
<text for=\"chk\">Remember me</text>\
</screen></template>
<script>let on = signal(false);</script>",
)
.expect("load");
assert_eq!(
doc.root.children[1].on_tap.as_deref(),
Some("on = !on"),
"label with for= inherits the checkbox's @tap"
);
let doc2 = Document::from_source(
"<template><screen>\
<input type=\"checkbox\" id=\"chk\" r-model=\"on\" />\
<text for=\"chk\" @tap=\"on = true\">Set</text>\
</screen></template>
<script>let on = signal(false);</script>",
)
.expect("load");
assert_eq!(doc2.root.children[1].on_tap.as_deref(), Some("on = true"));
}
#[test]
fn label_for_focuses_a_text_input() {
let doc = Document::from_source(
"<template><screen>\
<input id=\"nm\" r-model=\"name\" />\
<text for=\"nm\">Name</text>\
</screen></template>
<script>let name = signal(\"\");</script>",
)
.expect("load");
let label = &doc.root.children[1];
assert_eq!(label.on_tap, None, "a text-input label has no tap handler");
assert_eq!(
label.focus_model.as_deref(),
Some("name"),
"label focuses the text input's model"
);
}
fn bg_rgb(n: &LayoutNode) -> Option<(f32, f32, f32)> {
match &n.style.background {
Some(rux_layout::Background::Color(c)) => Some((c.r, c.g, c.b)),
_ => None,
}
}
#[test]
fn dynamic_class_reconciles() {
let mut doc = Document::from_source(
"<template><screen><view class=\"chip\" :class=\"tone\" /></screen></template>
<style>.hot { background: #ff0000; } .cool { background: #0000ff; }</style>
<script>let tone = signal(\"hot\");</script>",
)
.expect("load");
assert_eq!(bg_rgb(&doc.root.children[0]), Some((1.0, 0.0, 0.0)), ":class=hot → .hot");
let changed = doc.engine_mut().run_handler_tracked("tone = \"cool\"");
assert!(doc.patch(&changed), ":class change reconciles in place");
assert_eq!(bg_rgb(&doc.root.children[0]), Some((0.0, 0.0, 1.0)), "reconciled to .cool");
}
#[test]
fn dynamic_inline_style_interpolates_and_reconciles() {
let mut doc = Document::from_source(
"<template><screen><view :style=\"`background: ${col}`\" /></screen></template>
<script>let col = signal(\"#00ff00\");</script>",
)
.expect("load");
assert_eq!(bg_rgb(&doc.root.children[0]), Some((0.0, 1.0, 0.0)), ":style set green");
let changed = doc.engine_mut().run_handler_tracked("col = \"#ff0000\"");
assert!(doc.patch(&changed));
assert_eq!(bg_rgb(&doc.root.children[0]), Some((1.0, 0.0, 0.0)), "reconciled to red");
}
#[test]
fn r_for_chip_styles() {
let doc = Document::from_source(
"<template><screen><view class=\"chips\">\
<view class=\"chip\" r-for=\"c in colors\" :style=\"`background: ${c}`\"><text>{{ c }}</text></view>\
</view></screen></template>
<script>let colors = signal([\"#ff0000\", \"#00ff00\"]);</script>",
)
.expect("load");
let chips = &doc.root.children[0];
assert_eq!(bg_rgb(&chips.children[0]), Some((1.0, 0.0, 0.0)), "first chip red");
assert_eq!(bg_rgb(&chips.children[1]), Some((0.0, 1.0, 0.0)), "second chip green");
}
#[test]
fn conditional_class_object_form() {
let mut doc = Document::from_source(
"<template><screen><view class=\"chip\" :class=\"#{ hot: warm, cool: !warm }\" /></screen></template>
<style>.hot { background: #ff0000; } .cool { background: #0000ff; }</style>
<script>let warm = signal(true);</script>",
)
.expect("load");
assert_eq!(bg_rgb(&doc.root.children[0]), Some((1.0, 0.0, 0.0)), "warm → .hot");
let changed = doc.engine_mut().run_handler_tracked("warm = false");
assert!(doc.patch(&changed), "conditional class change reconciles");
assert_eq!(bg_rgb(&doc.root.children[0]), Some((0.0, 0.0, 1.0)), "!warm → .cool");
}
#[test]
fn css_showcase_example_builds() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../examples/css-showcase.rux");
let doc = Document::load(path).expect("css-showcase.rux builds");
assert!(find_text(&doc.root, "teal"), "a :style-coloured chip rendered");
}
#[test]
fn style_object_form() {
let doc = Document::from_source(
"<template><screen><view :style=\"#{ background: col }\" /></screen></template>
<script>let col = signal(\"#00ff00\");</script>",
)
.expect("load");
assert_eq!(bg_rgb(&doc.root.children[0]), Some((0.0, 1.0, 0.0)), ":style object → green");
}
#[test]
fn checked_toggles_get_a_checked_class() {
let doc = Document::from_source(
"<template><screen> <input type=\"checkbox\" class=\"box\" r-model=\"on\" /> <input type=\"radio\" class=\"box\" r-model=\"plan\" value=\"pro\" /> <input type=\"radio\" class=\"box\" r-model=\"plan\" value=\"free\" /> </screen></template>
<style>.box { background: #000000; } .box.checked { background: #00ff00; }</style>
<script>let on = signal(true); let plan = signal(\"pro\");</script>",
)
.expect("load");
let green = |n: &LayoutNode| {
matches!(&n.style.background, Some(rux_layout::Background::Color(c)) if c.g == 1.0)
};
let boxes = &doc.root.children;
assert!(green(&boxes[0]), "checked checkbox should match .checked");
assert!(green(&boxes[1]), "radio whose value == signal is checked");
assert!(!green(&boxes[2]), "the other radio is not checked");
assert_eq!(boxes[0].children.len(), 1);
assert_eq!(boxes[1].children.len(), 1);
assert_eq!(boxes[2].children.len(), 0);
}
}