use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use rux_layout::{Node as LayoutNode, Offset};
use rux_parser::{Sfc, StyleInclude};
use rux_reactive::Value;
use rux_script::{Builder, Engine};
use rux_style::{BindingRegistry, Instances};
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,
instances: Instances,
computeds: Vec<Computed>,
effects: Vec<Effect>,
history: History,
pending_scroll: Option<Vec<Offset>>,
pub root: LayoutNode,
}
#[derive(Clone, Debug)]
struct History {
entries: Vec<Entry>,
at: usize,
}
#[derive(Clone, Debug)]
struct Entry {
location: String,
scroll: Vec<Offset>,
}
impl Entry {
fn new(location: impl Into<String>) -> Self {
Self { location: location.into(), scroll: Vec::new() }
}
}
impl Default for History {
fn default() -> Self {
Self { entries: vec![Entry::new(ROOT_PATH)], at: 0 }
}
}
impl History {
fn starting_at(path: &str) -> Self {
let path = if path.is_empty() { ROOT_PATH } else { path };
Self { entries: vec![Entry::new(path)], at: 0 }
}
fn current(&self) -> &str {
&self.entries[self.at].location
}
fn go_to(&mut self, index: usize) -> bool {
if index >= self.entries.len() || index == self.at {
return false;
}
self.at = index;
true
}
fn push(&mut self, path: &str) -> bool {
if self.current() == path {
return false;
}
self.entries.truncate(self.at + 1);
self.entries.push(Entry::new(path));
self.at = self.entries.len() - 1;
true
}
fn replace(&mut self, path: &str) -> bool {
if self.current() == path && self.at + 1 == self.entries.len() {
return false;
}
self.entries.truncate(self.at + 1);
self.entries[self.at] = Entry::new(path);
true
}
fn back(&mut self) -> bool {
if self.at == 0 {
return false;
}
self.at -= 1;
true
}
fn forward(&mut self) -> bool {
if self.at + 1 >= self.entries.len() {
return false;
}
self.at += 1;
true
}
}
pub const ROOT_PATH: &str = "/";
#[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 row: Option<String>,
pub caret: usize,
pub anchor: usize,
pub preedit: Option<(usize, usize)>,
}
impl Focus {
pub fn at(model: impl Into<String>, caret: usize) -> Self {
Self::at_row(model, None, caret)
}
pub fn at_row(model: impl Into<String>, row: Option<String>, caret: usize) -> Self {
Self { model: model.into(), row, caret, anchor: caret, preedit: None }
}
pub fn is(&self, model: &str, row: Option<&str>) -> bool {
self.model == model && self.row.as_deref() == row
}
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>) {
apply_focus_in(node, focus, None);
}
fn apply_focus_in(node: &mut LayoutNode, focus: Option<&Focus>, row: Option<&str>) {
let row = node.key.as_deref().or(row);
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().is_some_and(|m| f.is(m, row))
});
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_in(child, focus, row);
}
}
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 mut sfc = rux_parser::parse_sfc(&src).map_err(|e| LoadError::parse(e, Some(path)))?;
let base = path.parent().unwrap_or_else(|| Path::new("."));
resolve_style_includes(&mut sfc, base)?;
let (main_script, imports) = extract_imports(&sfc.script);
let (main_script, computeds, effects) = extract_reactives(&main_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 mut comp_sfc =
rux_parser::parse_sfc(&comp_src).map_err(|e| LoadError::parse(e, Some(&comp_path)))?;
let comp_base = comp_path.parent().unwrap_or_else(|| Path::new(".")).to_path_buf();
resolve_style_includes(&mut comp_sfc, &comp_base)?;
let (comp_script, _nested) = extract_imports(&comp_sfc.script);
let (comp_script, _c, _e) = extract_reactives(&comp_script);
combined_script.push('\n');
combined_script.push_str(&component_functions(&comp_script));
components.insert(import.tag, comp_sfc);
}
rux_script::set_routes(rux_style::named_routes(&sfc.template));
let mut engine = build_engine(&combined_script).map_err(LoadError::plain)?;
let mut instances = Instances::new();
let (mut root, registry) = rux_style::build_styled_tree_tracked(&sfc, &components, &mut engine, &mut instances)
.map_err(LoadError::plain)?;
resolve_images(&mut root, base);
let mut doc = 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()
},
instances,
computeds,
effects,
history: History::default(),
pending_scroll: None,
root,
};
doc.init_reactive();
Ok(doc)
}
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))?;
for path in &sfc.style_src {
warn_unresolvable_include(path);
}
let (main_script, _imports) = extract_imports(&sfc.script);
let (main_script, computeds, effects) = extract_reactives(&main_script);
rux_script::set_routes(rux_style::named_routes(&sfc.template));
let mut engine = build_engine(&main_script).map_err(LoadError::plain)?;
let mut instances = Instances::new();
let (mut root, registry) =
rux_style::build_styled_tree_tracked(&sfc, &HashMap::new(), &mut engine, &mut instances)
.map_err(LoadError::plain)?;
let base = PathBuf::from(".");
resolve_images(&mut root, &base);
let mut doc = Self {
sfc,
components: HashMap::new(),
engine,
base,
focus: None,
registry,
state: InteractionState::default(),
viewport: Viewport::default(),
diagnostics: Diagnostics {
warnings: collect_warnings(),
..Diagnostics::default()
},
instances,
computeds,
effects,
history: History::default(),
pending_scroll: None,
root,
};
doc.init_reactive();
Ok(doc)
}
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
&& next.focused_row == self.state.focused_row
{
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,
&mut self.instances,
&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();
let row = row_at(&fresh_root, path);
if let Some(live) = node_at_mut(&mut self.root, path) {
*live = fresh_node;
apply_focus_in(live, self.focus.as_ref(), row.as_deref());
}
}
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,
&mut self.instances,
&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,
&mut self.instances,
&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();
let row = row_at(&fresh_root, p);
if let Some(live) = node_at_mut(&mut self.root, p) {
live.children = fresh_children;
apply_focus_in(live, self.focus.as_ref(), row.as_deref());
}
}
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();
let row = row_at(&fresh_root, p);
if let Some(live) = node_at_mut(&mut self.root, p) {
*live = fresh_node;
apply_focus_in(live, self.focus.as_ref(), row.as_deref());
}
}
}
self.registry = fresh_reg;
}
pub fn apply_edit(&mut self, model: &str, value: &str) {
self.apply_edit_in(model, None, value);
}
pub fn apply_edit_in(&mut self, model: &str, row: Option<&str>, value: &str) {
let locals = self.locals_for(model, row);
let changed = self.engine.assign_string(model, value, &locals);
if changed.is_empty() {
return;
}
self.apply_change(&changed);
}
pub fn value_in(&mut self, model: &str, row: Option<&str>) -> String {
let locals = self.locals_for(model, row);
self.engine.get_string_in(model, &locals)
}
fn locals_for(&self, model: &str, row: Option<&str>) -> Vec<(String, rux_reactive::Value)> {
self.registry
.value
.iter()
.find(|b| b.model == model && b.row.as_deref() == row)
.map(|b| b.locals.clone())
.unwrap_or_default()
}
pub fn apply_handler(&mut self, src: &str) -> bool {
self.apply_handler_in(src, None)
}
pub fn apply_handler_in(&mut self, src: &str, instance: Option<&str>) -> bool {
let _ = rux_script::take_emissions();
let _ = rux_script::take_navigations();
let ran = self.dispatch_handler(src, instance, 0);
self.apply_navigations() || ran
}
fn apply_navigations(&mut self) -> bool {
let mut moved = false;
for nav in rux_script::take_navigations() {
moved |= match nav {
rux_script::Nav::To(path) => self.navigate(&path),
rux_script::Nav::Replace(path) => self.replace(&path),
rux_script::Nav::Back => self.back(),
rux_script::Nav::Forward => self.forward(),
};
}
moved
}
pub fn route(&self) -> &str {
rux_script::split_query(self.history.current()).0
}
pub fn location(&self) -> &str {
self.history.current()
}
pub fn navigate(&mut self, path: &str) -> bool {
if !self.history.push(path) {
return false;
}
self.set_scroll_intent(None);
self.show_current_route()
}
pub fn start_at(&mut self, path: &str) -> bool {
self.history = History::starting_at(path);
self.set_scroll_intent(None);
self.show_current_route()
}
pub fn history_position(&self) -> (usize, usize) {
(self.history.at, self.history.entries.len())
}
pub fn go_to(&mut self, index: usize) -> bool {
if !self.history.go_to(index) {
return false;
}
self.restore_scroll_here();
self.show_current_route()
}
pub fn replace(&mut self, path: &str) -> bool {
if !self.history.replace(path) {
return false;
}
self.set_scroll_intent(None);
self.show_current_route()
}
pub fn back(&mut self) -> bool {
if !self.history.back() {
return false;
}
self.restore_scroll_here();
self.show_current_route()
}
pub fn forward(&mut self) -> bool {
if !self.history.forward() {
return false;
}
self.restore_scroll_here();
self.show_current_route()
}
fn restore_scroll_here(&mut self) {
let recorded = self.history.entries[self.history.at].scroll.clone();
self.set_scroll_intent(Some(recorded));
}
pub fn record_scroll(&mut self, offsets: &[Offset]) {
let entry = &mut self.history.entries[self.history.at];
if entry.scroll != offsets {
entry.scroll = offsets.to_vec();
}
}
pub fn take_scroll(&mut self) -> Option<Vec<Offset>> {
self.pending_scroll.take()
}
fn set_scroll_intent(&mut self, restored: Option<Vec<Offset>>) {
let remembering = rux_style::restore_scroll(&self.sfc.template);
self.pending_scroll = match restored {
Some(offsets) if remembering => Some(offsets),
_ => Some(Vec::new()),
};
}
fn show_current_route(&mut self) -> bool {
let location = self.history.current().to_string();
let path = rux_script::split_query(&location).0.to_string();
self.instances.retain(|_, i| i.route.as_deref().is_none_or(|r| r == path));
let moved = self.publish_route(&location);
if !moved {
return false;
}
self.apply_change(&HashSet::from_iter(
rux_script::ROUTER_SIGNALS.iter().map(|s| s.to_string()),
));
true
}
fn publish_route(&mut self, location: &str) -> bool {
let (path, query) = rux_script::split_query(location);
let params = rux_style::route_params(&self.sfc.template, path);
let (at, len) = (self.history.at, self.history.entries.len());
let mut moved = self.engine.set_route(path);
moved |= self.engine.set_provided(rux_script::PARAMS_SIGNAL, Value::Map(params));
moved |= self
.engine
.set_provided(rux_script::QUERY_SIGNAL, Value::Map(rux_script::parse_query(query)));
moved |= self.engine.set_provided(rux_script::CAN_BACK_SIGNAL, Value::Bool(at > 0));
moved |=
self.engine.set_provided(rux_script::CAN_FORWARD_SIGNAL, Value::Bool(at + 1 < len));
moved
}
fn dispatch_handler(&mut self, src: &str, instance: Option<&str>, depth: usize) -> bool {
const MAX_EVENT_DEPTH: usize = 8;
if depth > MAX_EVENT_DEPTH {
rux_script::warn_script(format!(
"an event chain is still going after {MAX_EVENT_DEPTH} rounds and has been \
stopped; a component is probably emitting an event that comes back to it"
));
return false;
}
let Some(key) = instance.filter(|k| self.instances.contains_key(*k)) else {
let changed = self.engine.run_handler_tracked(src);
for (event, _) in rux_script::take_emissions() {
rux_script::warn_script(format!(
"`emit(\"{event}\")` outside a component has no caller to receive it"
));
}
if changed.is_empty() {
return false;
}
self.apply_change(&changed);
return true;
};
let entry = &self.instances[key];
let mut locals = entry.state.clone();
locals.extend(entry.props.iter().cloned());
let (after, changed) = self.engine.run_scoped_handler(src, &locals);
let state_names: Vec<String> =
self.instances[key].state.iter().map(|(n, _)| n.clone()).collect();
let mut moved = false;
for (name, value) in after {
if !state_names.contains(&name) {
continue;
}
let slot = self
.instances
.get_mut(key)
.and_then(|i| i.state.iter_mut().find(|(n, _)| *n == name));
if let Some(slot) = slot {
if slot.1 != value {
slot.1 = value;
moved = true;
}
}
}
let mut fired = false;
for (event, payload) in rux_script::take_emissions() {
let listener = self.instances[key]
.listeners
.iter()
.find(|(name, _)| *name == event)
.map(|(_, body)| body.clone());
let Some(body) = listener else { continue };
let caller = self.instances[key].caller.clone();
fired |= self.dispatch_handler(&with_event(&body, payload.as_ref()), caller.as_deref(), depth + 1);
}
if !changed.is_empty() {
self.apply_change(&changed);
return true;
}
if moved {
self.rebuild();
return true;
}
fired
}
fn init_reactive(&mut self) {
for i in 0..self.computeds.len() {
let (name, expr) = (self.computeds[i].name.clone(), self.computeds[i].expr.clone());
let (_, deps) = self.engine.recompute(&name, &expr);
self.computeds[i].deps = deps;
}
let mut writes: HashSet<String> = HashSet::new();
for i in 0..self.effects.len() {
let body = self.effects[i].body.clone();
let (mut reads, wrote) = self.engine.run_effect_tracked(&body);
reads.retain(|n| !wrote.contains(n));
self.effects[i].deps = reads;
writes.extend(wrote);
}
self.diagnostics.warnings.extend(collect_warnings());
if !writes.is_empty() {
self.apply_change_depth(&writes, 1);
}
}
fn refresh_computed(&mut self, changed: &mut HashSet<String>) {
for i in 0..self.computeds.len() {
let stale = !self.computeds[i].deps.is_disjoint(changed);
if !stale {
continue;
}
let (name, expr) = (self.computeds[i].name.clone(), self.computeds[i].expr.clone());
let (moved, deps) = self.engine.recompute(&name, &expr);
self.computeds[i].deps = deps;
if moved {
changed.insert(name);
}
}
}
fn run_effects(&mut self, changed: &HashSet<String>) -> HashSet<String> {
let mut writes = HashSet::new();
for i in 0..self.effects.len() {
if self.effects[i].deps.is_disjoint(changed) {
continue;
}
let body = self.effects[i].body.clone();
let (mut reads, wrote) = self.engine.run_effect_tracked(&body);
reads.retain(|n| !wrote.contains(n));
self.effects[i].deps = reads;
writes.extend(wrote);
}
writes
}
fn apply_change(&mut self, changed: &HashSet<String>) {
self.apply_change_depth(changed, 0);
}
fn apply_change_depth(&mut self, changed: &HashSet<String>, depth: u32) {
const MAX_EFFECT_ROUNDS: u32 = 8;
let mut changed = changed.clone();
self.refresh_computed(&mut changed);
let patched = self.patch(&changed);
if !patched {
self.rebuild();
}
let writes = self.run_effects(&changed);
if !writes.is_empty() {
if depth + 1 >= MAX_EFFECT_ROUNDS {
let mut names: Vec<&str> = writes.iter().map(String::as_str).collect();
names.sort_unstable();
rux_style::warn_stylesheet(format!(
"an `effect` keeps re-triggering itself (still writing {names:?} after \
{MAX_EFFECT_ROUNDS} rounds); it was stopped. An effect must not write a \
signal it also reads."
));
self.diagnostics.warnings.extend(collect_warnings());
return;
}
self.apply_change_depth(&writes, depth + 1);
return;
}
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 row_at(root: &LayoutNode, path: &[usize]) -> Option<String> {
let mut node = root;
let mut row = node.key.clone();
for &i in path {
node = node.children.get(i)?;
if node.key.is_some() {
row = node.key.clone();
}
}
row
}
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)
}
fn resolve_style_includes(sfc: &mut Sfc, base: &Path) -> Result<(), LoadError> {
if sfc.style_src.is_empty() {
return Ok(());
}
let mut includes = Vec::with_capacity(sfc.style_src.len());
for relative in &sfc.style_src {
let path = base.join(relative);
let css = std::fs::read_to_string(&path).map_err(|e| {
LoadError::plain(format!("reading stylesheet {}: {e}", path.display()))
})?;
includes.push(StyleInclude { path: relative.clone(), css });
}
sfc.style_includes = includes;
Ok(())
}
fn warn_unresolvable_include(path: &str) {
rux_style::warn_stylesheet(format!(
"`<style src=\"{path}\">` was ignored: this document was loaded from source, \
not from a file, so there is nothing for the path to be relative to"
));
}
#[derive(Clone, Debug)]
struct Computed {
name: String,
expr: String,
deps: HashSet<String>,
}
#[derive(Clone, Debug)]
struct Effect {
body: String,
deps: HashSet<String>,
}
fn extract_reactives(script: &str) -> (String, Vec<Computed>, Vec<Effect>) {
let mut cleaned = String::new();
let mut computeds = Vec::new();
let mut effects = Vec::new();
let lines: Vec<&str> = script.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("computed ") {
if let Some((name, expr)) = rest.split_once('=') {
let name = name.trim();
let expr = expr.trim().trim_end_matches(';').trim();
if is_identifier(name) && !expr.is_empty() {
computeds.push(Computed {
name: name.to_string(),
expr: expr.to_string(),
deps: HashSet::new(),
});
cleaned.push_str(&format!("let {name} = {expr};\n"));
i += 1;
continue;
}
}
}
if trimmed == "effect {" || trimmed.starts_with("effect {") {
let mut depth = 0i32;
let mut body = String::new();
let mut j = i;
let mut closed = false;
while j < lines.len() {
let l = lines[j];
for c in l.chars() {
match c {
'{' => depth += 1,
'}' => depth -= 1,
_ => {}
}
}
let start = if j == i { l.find('{').map(|p| p + 1).unwrap_or(0) } else { 0 };
body.push_str(&l[start..]);
body.push('\n');
cleaned.push('\n'); j += 1;
if depth <= 0 {
closed = true;
break;
}
}
if closed {
let body = body.trim_end();
let body = body.strip_suffix('}').unwrap_or(body).to_string();
effects.push(Effect { body, deps: HashSet::new() });
i = j;
continue;
}
rux_style::warn_stylesheet(
"an `effect {` block is never closed; it was ignored".to_string(),
);
i = j;
continue;
}
cleaned.push_str(line);
cleaned.push('\n');
i += 1;
}
(cleaned, computeds, effects)
}
fn is_identifier(s: &str) -> bool {
!s.is_empty()
&& !s.starts_with(|c: char| c.is_ascii_digit())
&& s.chars().all(|c| c.is_alphanumeric() || c == '_')
}
fn with_event(body: &str, payload: Option<&rux_reactive::Value>) -> String {
match payload {
Some(value) => format!("let event = {}; {body}", value.to_rhai_literal()),
None => body.to_string(),
}
}
fn component_functions(script: &str) -> String {
let mut out = String::new();
let lines: Vec<&str> = script.lines().collect();
let mut i = 0;
while i < lines.len() {
if !lines[i].trim().starts_with("fn ") {
i += 1;
continue;
}
let mut depth = 0i32;
let mut seen = false;
while i < lines.len() {
for c in lines[i].chars() {
match c {
'{' => {
depth += 1;
seen = true;
}
'}' => depth -= 1,
_ => {}
}
}
out.push_str(lines[i]);
out.push('\n');
i += 1;
if seen && depth <= 0 {
break;
}
}
}
out
}
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);
let mut engine = builder.build(script)?;
for name in rux_script::ROUTER_SIGNALS {
if engine.declares(name) {
rux_script::warn_script(format!(
"`{name}` is the router's and is provided for you; a `let {name}` of your own is \
overwritten on every navigation"
));
}
}
engine.set_route(ROOT_PATH);
engine.set_provided(rux_script::PARAMS_SIGNAL, Value::Map(Vec::new()));
engine.set_provided(rux_script::QUERY_SIGNAL, Value::Map(Vec::new()));
engine.set_provided(rux_script::CAN_BACK_SIGNAL, Value::Bool(false));
engine.set_provided(rux_script::CAN_FORWARD_SIGNAL, Value::Bool(false));
Ok(engine)
}
#[cfg(test)]
mod tests {
use super::*;
fn text_of(node: &LayoutNode) -> Vec<String> {
let mut out: Vec<String> = node.text.iter().map(|t| t.text.clone()).collect();
for child in &node.children {
out.extend(text_of(child));
}
out
}
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 included_stylesheets_cascade_under_the_document() {
use std::fs;
let dir = std::env::temp_dir().join(format!("rux_css_{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("theme.css"),
".card { background: #ff0000; } .plain { background: #0000ff; }",
)
.unwrap();
fs::write(
dir.join("app.rux"),
"<template><screen><view class=\"card\" /><view class=\"plain\" /></screen></template>\n\
<style src=\"theme.css\">\n .card { background: #00ff00; }\n</style>",
)
.unwrap();
let doc = Document::load(dir.join("app.rux")).expect("load app");
let bg = |i: usize| doc.root.children[i].style.background.clone();
assert!(
matches!(bg(0), Some(rux_layout::Background::Color(c)) if c.g == 1.0),
"same specificity, so the document's own rule wins on source order"
);
assert!(
matches!(bg(1), Some(rux_layout::Background::Color(c)) if c.b == 1.0),
"and what the document says nothing about still comes from the include"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_missing_stylesheet_fails_the_load() {
use std::fs;
let dir = std::env::temp_dir().join(format!("rux_css_missing_{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("app.rux"),
"<template><screen /></template>\n<style src=\"nope.css\">.a{color:red}</style>",
)
.unwrap();
let Err(err) = Document::load(dir.join("app.rux")) else {
panic!("a document naming a stylesheet that is not there must not load");
};
assert!(err.contains("nope.css"), "names the file that is missing: {err}");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn an_include_from_source_warns_instead_of_failing() {
let _ = take_warnings(); let doc = Document::from_source(
"<template><screen /></template>\n<style src=\"theme.css\">.a{color:red}</style>",
)
.expect("renders anyway");
assert!(
doc.diagnostics.warnings.iter().any(|w| w.message.contains("theme.css")),
"the warning names the sheet that was ignored: {:?}",
doc.diagnostics.warnings
);
}
#[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(), row: None, 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(), row: None, 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(), row: None, caret: 3, anchor: 0, preedit: None }));
assert_eq!(selection_of(&doc.root, "name"), Some((0, 3)));
doc.set_focus(Some(Focus { model: "city".into(), row: None, 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_key_identifies_a_row_across_a_reorder() {
let mut doc = Document::from_source(
"<template><screen>\
<text r-for=\"row in rows\" r-key=\"row.id\">{{ row.text }}</text>\
</screen></template>
<script>\
let rows = signal([\
#{ id: \"a\", text: \"alpha\" },\
#{ id: \"b\", text: \"bravo\" }\
]);\
</script>",
)
.expect("load");
let keys = |d: &Document| -> Vec<Option<String>> {
d.root.children.iter().map(|c| c.key.clone()).collect()
};
assert_eq!(keys(&doc), vec![Some("a".into()), Some("b".into())]);
assert!(
doc.apply_handler(
"rows = [#{ id: \"b\", text: \"bravo\" }, #{ id: \"a\", text: \"alpha\" }];"
),
"the reorder changed a signal"
);
assert_eq!(
keys(&doc),
vec![Some("b".into()), Some("a".into())],
"the keys moved with their rows"
);
assert!(find_text(&doc.root, "bravo"));
}
fn keyed_inputs() -> Document {
Document::from_source(
"<template><screen>\
<view r-for=\"row in rows\" r-key=\"row.id\">\
<input r-model=\"draft\" />\
</view>\
</screen></template>
<script>\
let rows = signal([#{ id: \"a\" }, #{ id: \"b\" }]);\
let draft = signal(\"hello\");\
</script>",
)
.expect("load")
}
#[test]
fn only_the_focused_row_gets_a_caret() {
let mut doc = keyed_inputs();
doc.set_focus(Some(Focus::at_row("draft", Some("b".into()), 2)));
let carets: Vec<Option<usize>> = doc
.root
.children
.iter()
.map(|row| caret_of(row, "draft"))
.collect();
assert_eq!(
carets,
vec![None, Some(2)],
"the caret is in row b only, not in every row bound to `draft`"
);
}
#[test]
fn the_caret_follows_its_row_across_a_reorder() {
let mut doc = keyed_inputs();
doc.set_focus(Some(Focus::at_row("draft", Some("b".into()), 2)));
assert!(
doc.apply_handler("rows = [#{ id: \"b\" }, #{ id: \"a\" }];"),
"the reorder changed a signal"
);
assert_eq!(doc.root.children[0].key.as_deref(), Some("b"), "row b is first now");
let carets: Vec<Option<usize>> = doc
.root
.children
.iter()
.map(|row| caret_of(row, "draft"))
.collect();
assert_eq!(
carets,
vec![Some(2), None],
"the caret moved with row b instead of staying in the first slot"
);
}
#[test]
fn a_rows_field_reads_and_writes_in_its_own_scope() {
let mut doc = Document::from_source(
"<template><screen>\
<input r-for=\"row in rows\" r-key=\"row.id\" r-model=\"rows[row.at.to_int()].note\" />\
</screen></template>
<script>\
let rows = signal([\
#{ id: \"a\", at: 0, note: \"alpha\" },\
#{ id: \"b\", at: 1, note: \"bravo\" }\
]);\
</script>",
)
.expect("load");
let model = "rows[row.at.to_int()].note";
assert_eq!(doc.value_in(model, Some("a")), "alpha");
assert_eq!(doc.value_in(model, Some("b")), "bravo", "each row reads its own value");
doc.apply_edit_in(model, Some("b"), "bravo!");
assert_eq!(doc.value_in(model, Some("b")), "bravo!", "the edit landed");
assert_eq!(doc.value_in(model, Some("a")), "alpha", "and only in that row");
}
#[test]
fn a_path_model_is_assigned_not_shadowed() {
let mut doc = Document::from_source(
"<template><screen><input r-model=\"user.name\" /></screen></template>
<script>let user = signal(#{ name: \"ada\" });</script>",
)
.expect("load");
doc.apply_edit("user.name", "grace");
assert_eq!(doc.value_in("user.name", None), "grace");
}
#[test]
fn an_awkward_value_survives_the_round_trip() {
let mut doc = two_inputs();
let awkward = "she said \"hi\" \\ then left";
doc.apply_edit("name", awkward);
assert_eq!(doc.value_in("name", None), awkward);
}
fn with_component(component: &str, app: &str) -> Document {
use std::fs;
let dir = std::env::temp_dir().join(format!(
"rux_slot_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(dir.join("components")).unwrap();
fs::write(dir.join("components/card.rux"), component).unwrap();
fs::write(dir.join("app.rux"), app).unwrap();
let doc = Document::load(dir.join("app.rux")).expect("load");
let _ = fs::remove_dir_all(&dir);
doc
}
#[test]
fn two_instances_keep_their_own_state() {
let doc = with_component(
"<template><view class=\"card\">\
<text>{{ count }}</text>\
<view @tap=\"count = count + 1\"><text>add</text></view>\
</view></template>\n\
<script>\nlet count = signal(0);\n</script>",
"<template><screen><card /><card /></screen></template>\n\
<script>\nuse components::card;\n</script>",
);
assert_eq!(doc.instances.len(), 2, "one entry per instance: {:?}", doc.instances);
assert!(
doc.instances.values().all(|i| i.state.iter().any(|(n, _)| n == "count")),
"each holds its own `count`: {:?}",
doc.instances
);
}
#[test]
fn a_handler_moves_only_its_own_instance() {
let mut doc = with_component(
"<template><view>\
<text>{{ label }}:{{ count }}</text>\
<view @tap=\"count = count + 1\"><text>add</text></view>\
</view></template>\n\
<script>\nlet count = signal(0);\n</script>",
"<template><screen>\
<card :label=\""a"\" /><card :label=\""b"\" />\
</screen></template>\n\
<script>\nuse components::card;\n</script>",
);
assert!(find_text(&doc.root, "a:0"), "{:?}", text_of(&doc.root));
assert!(find_text(&doc.root, "b:0"), "{:?}", text_of(&doc.root));
let second = doc.root.children[1].clone();
let button = second.children.iter().find(|c| c.on_tap.is_some()).expect("a tappable box");
let (src, instance) = (button.on_tap.clone().unwrap(), button.instance.clone());
assert!(instance.is_some(), "the node knows which instance it is in");
assert!(doc.apply_handler_in(&src, instance.as_deref()), "the tap changed state");
assert!(find_text(&doc.root, "b:1"), "the tapped card counted: {:?}", text_of(&doc.root));
assert!(
find_text(&doc.root, "a:0"),
"and the other one did not: {:?}",
text_of(&doc.root)
);
}
fn tap(doc: &mut Document, node: &LayoutNode) -> bool {
fn find(node: &LayoutNode) -> Option<&LayoutNode> {
if node.on_tap.is_some() {
return Some(node);
}
node.children.iter().find_map(find)
}
let button = find(node).expect("a tappable box").clone();
doc.apply_handler_in(&button.on_tap.clone().unwrap(), button.instance.as_deref())
}
#[test]
fn an_emitted_event_runs_the_callers_handler() {
let mut doc = with_component(
"<template><view>\
<view @tap=\"emit("bumped")\"><text>add</text></view>\
</view></template>",
"<template><screen>\
<text>total {{ total }}</text>\
<card @bumped=\"total = total + 1\" />\
</screen></template>\n\
<script>\nuse components::card;\nlet total = signal(0);\n</script>",
);
let card = doc.root.children[1].clone();
assert!(tap(&mut doc, &card), "the tap reached the caller");
assert!(find_text(&doc.root, "total 1"), "{:?}", text_of(&doc.root));
let card = doc.root.children[1].clone();
assert!(tap(&mut doc, &card), "and again");
assert!(find_text(&doc.root, "total 2"), "{:?}", text_of(&doc.root));
}
#[test]
fn an_event_carries_its_payload() {
let mut doc = with_component(
"<template><view>\
<view @tap=\"emit("picked", label)\"><text>pick</text></view>\
</view></template>",
"<template><screen>\
<text>chose {{ chosen }}</text>\
<card :label=\""blue"\" @picked=\"chosen = event\" />\
</screen></template>\n\
<script>\nuse components::card;\nlet chosen = signal(\"nothing\");\n</script>",
);
let card = doc.root.children[1].clone();
assert!(tap(&mut doc, &card), "the tap reached the caller");
assert!(find_text(&doc.root, "chose blue"), "{:?}", text_of(&doc.root));
}
#[test]
fn a_listener_runs_in_the_callers_scope() {
let mut doc = with_component(
"<template><view>\
<text>inner {{ total }}</text>\
<view @tap=\"emit("bumped")\"><text>add</text></view>\
</view></template>\n\
<script>\nlet total = signal(100);\n</script>",
"<template><screen>\
<text>outer {{ total }}</text>\
<card @bumped=\"total = total + 1\" />\
</screen></template>\n\
<script>\nuse components::card;\nlet total = signal(0);\n</script>",
);
let card = doc.root.children[1].clone();
assert!(tap(&mut doc, &card));
assert!(find_text(&doc.root, "outer 1"), "the caller's own: {:?}", text_of(&doc.root));
assert!(
find_text(&doc.root, "inner 100"),
"the component's like-named state is untouched: {:?}",
text_of(&doc.root)
);
}
#[test]
fn an_event_with_no_listener_is_ignored() {
let _ = take_warnings();
let mut doc = with_component(
"<template><view>\
<view @tap=\"count = count + 1; emit("bumped")\"><text>add</text></view>\
<text>{{ count }}</text>\
</view></template>\n\
<script>\nlet count = signal(0);\n</script>",
"<template><screen><card /></screen></template>\n\
<script>\nuse components::card;\n</script>",
);
let card = doc.root.children[0].clone();
assert!(tap(&mut doc, &card), "the handler's own work still happened");
assert!(find_text(&doc.root, "1"), "{:?}", text_of(&doc.root));
assert!(take_warnings().is_empty(), "an unheard event is not a mistake");
}
#[test]
fn emit_outside_a_component_warns() {
let _ = take_warnings();
let mut doc = Document::from_source(
"<template><screen><view @tap=\"emit("bumped")\"><text>go</text></view></screen></template>",
)
.expect("loads");
let button = doc.root.children[0].clone();
doc.apply_handler_in(&button.on_tap.clone().unwrap(), None);
let warnings = take_warnings();
assert!(
warnings.iter().any(|w| w.message.contains("no caller")),
"the warning says why nothing happened: {warnings:?}"
);
}
fn with_router(app: &str) -> Document {
use std::fs;
let dir = std::env::temp_dir().join(format!(
"rux_router_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(dir.join("components")).unwrap();
fs::write(dir.join("components/home.rux"), "<template><text>the home page</text></template>")
.unwrap();
fs::write(
dir.join("components/settings.rux"),
"<template><text>settings live here</text></template>",
)
.unwrap();
fs::write(
dir.join("components/user.rux"),
"<template><view>\
<text>user {{ id }} seen {{ seen }}</text>\
<view @tap=\"seen = seen + 1\"><text>look</text></view>\
</view></template>\n\
<script>\nlet seen = signal(0);\n</script>",
)
.unwrap();
fs::write(
dir.join("components/missing.rux"),
"<template><text>no such page</text></template>",
)
.unwrap();
fs::write(dir.join("app.rux"), app).unwrap();
let doc = Document::load(dir.join("app.rux")).expect("load");
let _ = fs::remove_dir_all(&dir);
doc
}
fn router_app() -> Document {
with_router(
"<template><screen>\
<text to=\"/\">home</text>\
<text to=\"/settings\">settings</text>\
<router>\
<route path=\"/\" view=\"home\" />\
<route path=\"/settings\" view=\"settings\" />\
<route path=\"/user/:id\" view=\"user\" />\
<route fallback view=\"missing\" />\
</router>\
</screen></template>\n\
<script>\nuse components::home;\nuse components::settings;\n\
use components::user;\nuse components::missing;\n</script>",
)
}
#[test]
fn the_router_renders_the_matching_route() {
let mut doc = router_app();
assert!(find_text(&doc.root, "the home page"), "{:?}", text_of(&doc.root));
assert!(!find_text(&doc.root, "settings live here"), "and not the others");
assert!(doc.navigate("/settings"), "navigating changed something");
assert!(find_text(&doc.root, "settings live here"), "{:?}", text_of(&doc.root));
assert!(!find_text(&doc.root, "the home page"), "the old view is gone");
}
#[test]
fn a_path_parameter_reaches_the_view() {
let mut doc = router_app();
doc.navigate("/user/7");
assert!(find_text(&doc.root, "user 7 seen 0"), "{:?}", text_of(&doc.root));
doc.navigate("/user/12");
assert!(find_text(&doc.root, "user 12 seen 0"), "{:?}", text_of(&doc.root));
}
#[test]
fn an_unmatched_path_falls_back() {
let mut doc = router_app();
doc.navigate("/nowhere");
assert!(find_text(&doc.root, "no such page"), "{:?}", text_of(&doc.root));
}
#[test]
fn a_document_can_start_somewhere_other_than_root() {
let mut doc = router_app();
assert!(doc.start_at("/user/7"), "the document moved off the home page");
assert_eq!(doc.route(), "/user/7");
assert!(find_text(&doc.root, "user 7 seen 0"), "{:?}", text_of(&doc.root));
}
#[test]
fn starting_at_a_path_leaves_nothing_behind_it() {
let mut doc = router_app();
doc.start_at("/settings");
assert_eq!(doc.history_position(), (0, 1));
assert!(!doc.back(), "there is nowhere back to");
assert_eq!(doc.route(), "/settings");
}
#[test]
fn starting_at_nothing_starts_at_the_root() {
let mut doc = router_app();
doc.start_at("");
assert_eq!(doc.route(), ROOT_PATH);
assert!(find_text(&doc.root, "the home page"), "{:?}", text_of(&doc.root));
}
#[test]
fn the_history_can_be_walked_by_index() {
let mut doc = router_app();
doc.navigate("/settings");
doc.navigate("/user/3");
assert_eq!(doc.history_position(), (2, 3));
assert!(doc.go_to(0), "jumped two entries at once, as a long-press Back does");
assert_eq!(doc.route(), ROOT_PATH);
assert_eq!(doc.history_position(), (0, 3), "jumping is not a visit: nothing was dropped");
assert!(doc.go_to(2), "and forward again to where it had been");
assert_eq!(doc.route(), "/user/3");
}
#[test]
fn an_out_of_range_history_index_is_refused() {
let mut doc = router_app();
doc.navigate("/settings");
assert!(!doc.go_to(9), "there is no ninth entry");
assert!(!doc.go_to(1), "already there");
assert_eq!(doc.route(), "/settings");
}
#[test]
fn replace_leaves_no_entry_to_go_back_to() {
let mut doc = router_app();
doc.navigate("/settings");
assert!(doc.replace("/user/1"), "the document moved");
assert_eq!(doc.route(), "/user/1");
assert_eq!(doc.history_position(), (1, 2), "it took the entry, it did not add one");
assert!(doc.back(), "back goes past the page that was replaced");
assert_eq!(doc.route(), ROOT_PATH, "and lands on the one before it");
}
#[test]
fn a_handler_can_replace_the_current_page() {
let mut doc = with_router(
"<template><screen>\
<view @tap=\"replace("/settings")\"><text>go</text></view>\
<router>\
<route path=\"/\" view=\"home\" />\
<route path=\"/settings\" view=\"settings\" />\
<route fallback view=\"missing\" />\
</router>\
</screen></template>\n\
<script>\nuse components::home;\nuse components::settings;\n\
use components::missing;\n</script>",
);
let button = doc.root.children[0].clone();
assert!(doc.apply_handler(&button.on_tap.clone().expect("a tap")));
assert_eq!(doc.route(), "/settings");
assert_eq!(doc.history_position(), (0, 1), "nothing was added to go back to");
}
#[test]
fn params_are_readable_outside_the_matched_view() {
let mut doc = with_router(
"<template><screen>\
<text>looking at {{ params.id }}</text>\
<router>\
<route path=\"/\" view=\"home\" />\
<route path=\"/user/:id\" view=\"user\" />\
<route fallback view=\"missing\" />\
</router>\
</screen></template>\n\
<script>\nuse components::home;\nuse components::user;\n\
use components::missing;\n</script>",
);
doc.navigate("/user/7");
assert!(find_text(&doc.root, "looking at 7"), "{:?}", text_of(&doc.root));
doc.navigate("/");
assert!(find_text(&doc.root, "looking at"), "{:?}", text_of(&doc.root));
assert!(!find_text(&doc.root, "looking at 7"), "{:?}", text_of(&doc.root));
}
#[test]
fn the_history_says_whether_it_can_be_walked() {
let mut doc = router_app();
assert_eq!(doc.value_in("can_go_back", None), "false", "nothing behind the first page");
assert_eq!(doc.value_in("can_go_forward", None), "false");
doc.navigate("/settings");
assert_eq!(doc.value_in("can_go_back", None), "true");
assert_eq!(doc.value_in("can_go_forward", None), "false", "nothing ahead of the last page");
doc.back();
assert_eq!(doc.value_in("can_go_back", None), "false");
assert_eq!(doc.value_in("can_go_forward", None), "true", "the page just left is ahead");
doc.navigate("/user/1");
assert_eq!(doc.value_in("can_go_forward", None), "false");
}
#[test]
fn a_query_is_readable_and_does_not_change_the_page() {
let mut doc = with_router(
"<template><screen>\
<text>looking for {{ query.q }}</text>\
<router>\
<route path=\"/\" view=\"home\" />\
<route path=\"/settings\" view=\"settings\" />\
<route fallback view=\"missing\" />\
</router>\
</screen></template>\n\
<script>\nuse components::home;\nuse components::settings;\n\
use components::missing;\n</script>",
);
doc.navigate("/settings?q=dark+mode&page=2");
assert_eq!(doc.route(), "/settings", "the path alone");
assert_eq!(doc.location(), "/settings?q=dark+mode&page=2", "the whole address");
assert!(find_text(&doc.root, "settings live here"), "it matched: {:?}", text_of(&doc.root));
assert!(find_text(&doc.root, "looking for dark mode"), "{:?}", text_of(&doc.root));
doc.navigate("/");
assert!(!find_text(&doc.root, "looking for dark mode"), "{:?}", text_of(&doc.root));
doc.back();
assert_eq!(doc.location(), "/settings?q=dark+mode&page=2");
assert!(find_text(&doc.root, "looking for dark mode"), "{:?}", text_of(&doc.root));
}
#[test]
fn a_named_route_builds_its_own_path() {
let mut doc = with_router(
"<template><screen>\
<view @tap=\"navigate(path_for("who", #{ id: "7" }))\">\
<text>go</text>\
</view>\
<router>\
<route path=\"/\" view=\"home\" />\
<route name=\"who\" path=\"/user/:id\" view=\"user\" />\
<route fallback view=\"missing\" />\
</router>\
</screen></template>\n\
<script>\nuse components::home;\nuse components::user;\n\
use components::missing;\n</script>",
);
let button = doc.root.children[0].clone();
assert!(doc.apply_handler(&button.on_tap.clone().expect("a tap")));
assert_eq!(doc.route(), "/user/7");
assert!(find_text(&doc.root, "user 7 seen 0"), "{:?}", text_of(&doc.root));
}
#[test]
fn path_for_puts_what_is_left_over_in_the_query() {
let mut doc = with_router(
"<template><screen>\
<view @tap=\"navigate(path_for("who", \
#{ id: "7", tab: "posts" }))\">\
<text>go</text>\
</view>\
<text>tab {{ query.tab }}</text>\
<router>\
<route path=\"/\" view=\"home\" />\
<route name=\"who\" path=\"/user/:id\" view=\"user\" />\
<route fallback view=\"missing\" />\
</router>\
</screen></template>\n\
<script>\nuse components::home;\nuse components::user;\n\
use components::missing;\n</script>",
);
let button = doc.root.children[0].clone();
doc.apply_handler(&button.on_tap.clone().expect("a tap"));
assert_eq!(doc.location(), "/user/7?tab=posts");
assert_eq!(doc.route(), "/user/7", "the leftover did not become a path segment");
assert!(find_text(&doc.root, "tab posts"), "{:?}", text_of(&doc.root));
}
#[test]
fn path_for_escapes_what_it_is_given() {
let mut doc = with_router(
"<template><screen>\
<view @tap=\"navigate(path_for("who", \
#{ id: "a/b", q: "x&y z" }))\">\
<text>go</text>\
</view>\
<text>q is {{ query.q }}</text>\
<router>\
<route path=\"/\" view=\"home\" />\
<route name=\"who\" path=\"/user/:id\" view=\"user\" />\
<route fallback view=\"missing\" />\
</router>\
</screen></template>\n\
<script>\nuse components::home;\nuse components::user;\n\
use components::missing;\n</script>",
);
let button = doc.root.children[0].clone();
doc.apply_handler(&button.on_tap.clone().expect("a tap"));
assert_eq!(doc.location(), "/user/a%2Fb?q=x%26y%20z");
assert!(find_text(&doc.root, "user a/b"), "the id came back whole: {:?}", text_of(&doc.root));
assert!(find_text(&doc.root, "q is x&y z"), "and so did the query: {:?}", text_of(&doc.root));
}
fn at_y(y: f32) -> Vec<Offset> {
vec![Offset { x: 0.0, y }]
}
#[test]
fn back_returns_to_where_the_page_was_left() {
let mut doc = router_app();
doc.record_scroll(&at_y(120.0));
doc.navigate("/settings");
assert_eq!(doc.take_scroll(), Some(Vec::new()), "a page being opened starts at the top");
doc.record_scroll(&at_y(40.0));
doc.back();
assert_eq!(doc.take_scroll(), Some(at_y(120.0)), "and one returned to does not");
doc.forward();
assert_eq!(doc.take_scroll(), Some(at_y(40.0)), "forward is a return too");
}
#[test]
fn scrolling_alone_asks_for_nothing() {
let mut doc = router_app();
doc.navigate("/settings");
assert!(doc.take_scroll().is_some(), "the navigation spoke");
doc.record_scroll(&at_y(80.0));
assert_eq!(doc.take_scroll(), None, "and then stopped speaking");
}
#[test]
fn restore_scroll_false_always_starts_at_the_top() {
let mut doc = with_router(
"<template><screen>\
<router restore-scroll=\"false\">\
<route path=\"/\" view=\"home\" />\
<route path=\"/settings\" view=\"settings\" />\
<route fallback view=\"missing\" />\
</router>\
</screen></template>\n\
<script>\nuse components::home;\nuse components::settings;\n\
use components::missing;\n</script>",
);
doc.record_scroll(&at_y(150.0));
doc.navigate("/settings");
assert_eq!(doc.take_scroll(), Some(Vec::new()));
doc.back();
assert_eq!(doc.take_scroll(), Some(Vec::new()), "a return is the top too, when off");
}
#[test]
fn a_replace_lands_at_the_top() {
let mut doc = router_app();
doc.record_scroll(&at_y(90.0));
doc.replace("/settings");
assert_eq!(doc.take_scroll(), Some(Vec::new()));
}
#[test]
fn a_trailing_slash_is_the_same_path() {
let mut doc = router_app();
doc.navigate("/settings/");
assert!(find_text(&doc.root, "settings live here"), "{:?}", text_of(&doc.root));
}
#[test]
fn a_link_navigates_when_tapped() {
let mut doc = router_app();
let link = doc.root.children[1].clone();
assert_eq!(link.access.role, rux_layout::AccessRole::Link, "announced as a link");
assert!(doc.apply_handler(&link.on_tap.clone().expect("a link taps")));
assert_eq!(doc.route(), "/settings");
assert!(find_text(&doc.root, "settings live here"), "{:?}", text_of(&doc.root));
}
#[test]
fn history_walks_both_ways() {
let mut doc = router_app();
doc.navigate("/settings");
doc.navigate("/user/3");
assert!(!doc.forward(), "nothing ahead of the newest entry");
assert!(doc.back(), "back to settings");
assert_eq!(doc.route(), "/settings");
assert!(doc.back(), "back to home");
assert_eq!(doc.route(), "/");
assert!(!doc.back(), "and no further");
assert!(doc.forward(), "forward again");
assert_eq!(doc.route(), "/settings");
assert!(find_text(&doc.root, "settings live here"), "{:?}", text_of(&doc.root));
}
#[test]
fn a_new_path_after_going_back_drops_the_forward_entries() {
let mut doc = router_app();
doc.navigate("/settings");
doc.back();
doc.navigate("/user/1");
assert!(!doc.forward(), "settings is no longer ahead: {:?}", doc.history);
assert!(doc.back(), "but home is still behind");
assert_eq!(doc.route(), "/");
}
#[test]
fn navigating_to_the_current_path_is_not_a_visit() {
let mut doc = router_app();
doc.navigate("/settings");
assert!(!doc.navigate("/settings"), "no change, so nothing to repaint");
assert!(doc.back());
assert_eq!(doc.route(), "/", "one Back is enough to leave");
}
#[test]
fn a_route_view_is_fresh_on_a_second_visit() {
let mut doc = router_app();
doc.navigate("/user/7");
let view = doc.root.children[2].clone();
let button = view.children.iter().find(|c| c.on_tap.is_some()).expect("the look button");
let (src, instance) = (button.on_tap.clone().unwrap(), button.instance.clone());
doc.apply_handler_in(&src, instance.as_deref());
assert!(find_text(&doc.root, "user 7 seen 1"), "state moves while here: {:?}", text_of(&doc.root));
doc.navigate("/settings");
doc.navigate("/user/7");
assert!(
find_text(&doc.root, "user 7 seen 0"),
"and starts over on return: {:?}",
text_of(&doc.root)
);
}
#[test]
fn the_current_link_matches_the_current_pseudo() {
let mut doc = with_router(
"<template><screen>\
<text to=\"/\" class=\"nav\">home</text>\
<text to=\"/settings\" class=\"nav\">settings</text>\
<router><route path=\"/\" view=\"home\" />\
<route path=\"/settings\" view=\"settings\" /></router>\
</screen></template>\n\
<style>.nav { color: #888888; } .nav:current { color: #ff0000; }</style>\n\
<script>\nuse components::home;\nuse components::settings;\n</script>",
);
let lit = |n: &LayoutNode| -> Option<(f32, f32, f32)> {
n.text.as_ref().map(|t| (t.color.r, t.color.g, t.color.b))
};
assert_ne!(lit(&doc.root.children[0]), lit(&doc.root.children[1]), "one of them is current");
let home_on_home = lit(&doc.root.children[0]);
doc.navigate("/settings");
assert_eq!(
lit(&doc.root.children[1]),
home_on_home,
"the current colour moved to the settings link"
);
assert_ne!(lit(&doc.root.children[0]), home_on_home, "and off the home link");
}
#[test]
fn a_route_naming_an_unimported_view_warns() {
let _ = take_warnings();
let doc = with_router(
"<template><screen><router>\
<route path=\"/\" view=\"nowhere\" />\
</router></screen></template>\n\
<script>\nuse components::home;\n</script>",
);
let warnings = &doc.diagnostics.warnings;
assert!(
warnings.iter().any(|w| w.message.contains("nowhere")),
"the warning names the view: {warnings:?}"
);
}
#[test]
fn component_state_is_not_a_document_signal() {
let mut doc = with_component(
"<template><view><text>{{ count }}</text></view></template>\n\
<script>\nlet count = signal(7);\n</script>",
"<template><screen><card /><text>{{ count }}</text></screen></template>\n\
<script>\nuse components::card;\n</script>",
);
assert!(find_text(&doc.root, "7"), "the component sees its own: {:?}", text_of(&doc.root));
assert_eq!(doc.value_in("count", None), "", "{:?}", text_of(&doc.root));
}
#[test]
fn a_component_reads_and_writes_an_unshadowed_document_signal() {
let mut doc = with_component(
"<template><view>\
<text>saw {{ theme }}</text>\
<view @tap=\"theme = "dark"\"><text>go</text></view>\
</view></template>\n\
<script>\nlet count = signal(0);\n</script>",
"<template><screen><card /></screen></template>\n\
<script>\nuse components::card;\nlet theme = signal(\"light\");\n</script>",
);
assert!(
find_text(&doc.root, "saw light"),
"the document's signal is visible inside: {:?}",
text_of(&doc.root)
);
let card = doc.root.children[0].clone();
assert!(tap(&mut doc, &card), "the handler wrote a document signal");
assert_eq!(doc.value_in("theme", None), "dark");
assert!(find_text(&doc.root, "saw dark"), "{:?}", text_of(&doc.root));
}
#[test]
fn hiding_a_component_drops_its_state() {
let mut doc = with_component(
"<template><view @tap=\"count = count + 1\"><text>n {{ count }}</text></view>\
</template>\n\
<script>\nlet count = signal(0);\n</script>",
"<template><screen><card r-if=\"shown\" /></screen></template>\n\
<script>\nuse components::card;\nlet shown = signal(true);\n</script>",
);
let card = doc.root.children[0].clone();
tap(&mut doc, &card);
assert!(find_text(&doc.root, "n 1"), "it counted: {:?}", text_of(&doc.root));
doc.apply_handler("shown = false");
assert!(doc.instances.is_empty(), "gone from the map: {:?}", doc.instances.keys());
doc.apply_handler("shown = true");
assert!(
find_text(&doc.root, "n 0"),
"and comes back new, not where it was left: {:?}",
text_of(&doc.root)
);
}
#[test]
fn a_row_that_goes_away_takes_its_instance_with_it() {
let mut doc = with_component(
"<template><view><text>{{ label }}</text></view></template>\n\
<script>\nlet seen = signal(0);\n</script>",
"<template><screen>\
<card r-for=\"row in rows\" r-key=\"row.id\" :label=\"row.id\" />\
</screen></template>\n\
<script>\nuse components::card;\n\
let rows = signal([#{ id: \"a\" }, #{ id: \"b\" }, #{ id: \"c\" }]);\n</script>",
);
assert_eq!(doc.instances.len(), 3, "one per row: {:?}", doc.instances.keys());
doc.apply_handler("rows = [#{ id: \"a\" }]");
assert_eq!(doc.instances.len(), 1, "two rows left, so two instances did: {:?}", doc.instances.keys());
assert!(find_text(&doc.root, "a"), "{:?}", text_of(&doc.root));
}
#[test]
fn a_slot_renders_the_callers_children() {
let doc = with_component(
"<template><view class=\"card\"><text>title</text><slot /></view></template>",
"<template><screen>\
<card><text>from the caller</text></card>\
</screen></template>\n\
<script>\nuse components::card;\n</script>",
);
assert!(find_text(&doc.root, "title"), "the component's own markup: {:?}", text_of(&doc.root));
assert!(
find_text(&doc.root, "from the caller"),
"and the children it was handed: {:?}",
text_of(&doc.root)
);
}
#[test]
fn slot_content_reads_the_callers_scope() {
let doc = with_component(
"<template><view><slot /></view></template>",
"<template><screen>\
<card><text>{{ greeting }}</text></card>\
</screen></template>\n\
<script>\nuse components::card;\nlet greeting = signal(\"hello there\");\n</script>",
);
assert!(
find_text(&doc.root, "hello there"),
"the caller's signal resolved inside the slot: {:?}",
text_of(&doc.root)
);
}
#[test]
fn an_empty_slot_falls_back_to_its_own_children() {
let doc = with_component(
"<template><view><slot><text>nothing here yet</text></slot></view></template>",
"<template><screen><card /></screen></template>\n\
<script>\nuse components::card;\n</script>",
);
assert!(
find_text(&doc.root, "nothing here yet"),
"the fallback showed: {:?}",
text_of(&doc.root)
);
}
#[test]
fn a_slot_adds_no_node_of_its_own() {
let doc = with_component(
"<template><view><slot /></view></template>",
"<template><screen>\
<card><text>a</text><text>b</text></card>\
</screen></template>\n\
<script>\nuse components::card;\n</script>",
);
let card = &doc.root.children[0];
assert_eq!(card.children.len(), 2, "two children, no wrapper: {:?}", text_of(card));
assert!(card.children.iter().all(|c| c.text.is_some()));
}
#[test]
fn numbers_render_the_same_in_text_and_in_script() {
let doc = Document::from_source(
"<template><screen>\
<text>{{ total }}</text>\
<text>{{ \"total is \" + total }}</text>\
<text>{{ half }}</text>\
<text>{{ \"half is \" + half }}</text>\
</screen></template>
<script>\n\
let total = signal(32);\n\
let half = signal(2.5);\n\
</script>",
)
.expect("load");
let shown = text_of(&doc.root);
assert!(shown.contains(&"32".to_string()), "{shown:?}");
assert!(shown.contains(&"total is 32".to_string()), "{shown:?}");
assert!(shown.contains(&"2.5".to_string()), "{shown:?}");
assert!(shown.contains(&"half is 2.5".to_string()), "{shown:?}");
}
#[test]
fn a_computed_tracks_what_it_reads() {
let mut doc = Document::from_source(
"<template><screen><text>{{ total }} for {{ count }}</text></screen></template>
<script>\
let count = signal(2);\n\
let price = signal(10);\n\
computed total = count * price;\n\
</script>",
)
.expect("load");
assert!(find_text(&doc.root, "20 for 2"), "computed on load: {:?}", doc.root);
assert!(doc.apply_handler("count = 3;"), "the handler changed a signal");
assert!(find_text(&doc.root, "30 for 3"), "and the computed followed");
}
#[test]
fn a_computed_may_read_an_earlier_computed() {
let mut doc = Document::from_source(
"<template><screen><text>{{ shout }}</text></screen></template>
<script>\n\
let name = signal(\"ada\");\n\
computed greeting = \"hi \" + name;\n\
computed shout = greeting + \"!\";\n\
</script>",
)
.expect("load");
assert!(find_text(&doc.root, "hi ada!"));
assert!(doc.apply_handler("name = \"grace\";"));
assert!(find_text(&doc.root, "hi grace!"), "the whole chain refreshed");
}
#[test]
fn an_effect_runs_on_load_and_on_change() {
let mut doc = Document::from_source(
"<template><screen><text>{{ mirror }}</text></screen></template>
<script>\n\
let count = signal(1);\n\
let mirror = signal(0);\n\
effect {\n\
mirror = count * 100;\n\
}\n\
</script>",
)
.expect("load");
assert!(find_text(&doc.root, "100"), "ran once on load: {:?}", text_of(&doc.root));
assert!(doc.apply_handler("count = 2;"));
assert!(
find_text(&doc.root, "200"),
"ran again when count changed: {:?}",
text_of(&doc.root)
);
}
#[test]
fn an_effect_ignores_signals_it_never_read() {
let mut doc = Document::from_source(
"<template><screen><text>{{ mirror }}</text></screen></template>
<script>\n\
let watched = signal(1);\n\
let other = signal(1);\n\
let mirror = signal(0);\n\
effect {\n\
mirror = watched * 100;\n\
}\n\
</script>",
)
.expect("load");
assert_eq!(doc.effects.len(), 1);
assert!(doc.effects[0].deps.contains("watched"), "it read `watched`");
assert!(!doc.effects[0].deps.contains("other"), "it never read `other`");
assert!(
!doc.effects[0].deps.contains("mirror"),
"writing a signal is not reading it, or every effect would feed itself"
);
assert!(doc.apply_handler("other = 2;"));
assert!(find_text(&doc.root, "100"), "an unread signal leaves it alone");
assert!(doc.apply_handler("watched = 3;"));
assert!(find_text(&doc.root, "300"), "the one it read wakes it");
}
#[test]
fn an_effect_is_not_woken_by_its_own_writes() {
let _ = take_warnings();
let mut doc = Document::from_source(
"<template><screen><text>{{ n }}</text></screen></template>
<script>\n\
let n = signal(0);\n\
effect {\n\
n = n + 1;\n\
}\n\
</script>",
)
.expect("load");
assert!(find_text(&doc.root, "1"), "ran once: {:?}", text_of(&doc.root));
assert!(doc.apply_handler("n = 100;"));
assert!(
find_text(&doc.root, "100"),
"an outside write is not chased by the effect that owns n: {:?}",
text_of(&doc.root)
);
assert!(doc.diagnostics.warnings.is_empty(), "{:?}", doc.diagnostics.warnings);
}
#[test]
fn effects_that_feed_each_other_are_stopped_and_reported() {
let _ = take_warnings();
let doc = Document::from_source(
"<template><screen><text>{{ a }}</text></screen></template>
<script>\n\
let a = signal(0);\n\
let b = signal(0);\n\
effect {\n\
b = a + 1;\n\
}\n\
effect {\n\
a = b + 1;\n\
}\n\
</script>",
)
.expect("load");
assert!(
doc.diagnostics.warnings.iter().any(|w| w.message.contains("re-triggering")),
"the cycle is named rather than hung on: {:?}",
doc.diagnostics.warnings
);
}
#[test]
fn each_rows_select_is_its_own() {
let doc = Document::from_source(
"<template><screen>\
<input r-for=\"row in rows\" r-key=\"row.id\" type=\"select\" \
r-model=\"pick\" :options=\"row.options\" />\
</screen></template>
<script>\
let pick = signal(\"a\");\
let rows = signal([\
#{ id: \"one\", options: [\"a\", \"b\"] },\
#{ id: \"two\", options: [\"c\", \"d\"] }\
]);\
</script>",
)
.expect("load");
let mut measure = |_: &rux_layout::TextContent, _: Option<f32>| (10.0, 10.0);
let out = rux_layout::layout(&doc.root, 800.0, 600.0, &mut measure);
let rows: Vec<Option<String>> = out.selects.iter().map(|s| s.row.clone()).collect();
assert_eq!(
rows,
vec![Some("one".to_string()), Some("two".to_string())],
"two selects, each stamped with the row it is in"
);
assert_eq!(out.selects[0].model, out.selects[1].model);
assert_eq!(out.selects[0].options, vec!["a", "b"]);
assert_eq!(out.selects[1].options, vec!["c", "d"]);
}
#[test]
fn keys_that_cannot_work_are_warned_about() {
let _ = take_warnings();
let doc = Document::from_source(
"<template><screen>\
<text r-for=\"row in rows\" r-key=\"row.id\">{{ row.id }}</text>\
</screen></template>
<script>let rows = signal([#{ id: \"a\" }, #{ id: \"a\" }]);</script>",
)
.expect("load");
assert!(
doc.diagnostics.warnings.iter().any(|w| w.message.contains("duplicate key")),
"duplicate keys are reported: {:?}",
doc.diagnostics.warnings
);
let _ = take_warnings();
let doc = Document::from_source(
"<template><screen><text r-key=\"x\">hi</text></screen></template>",
)
.expect("load");
assert!(
doc.diagnostics.warnings.iter().any(|w| w.message.contains("without `r-for`")),
"a key with no list is reported: {:?}",
doc.diagnostics.warnings
);
}
#[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(), row: None, 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(),
row: None,
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(),
row: None,
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);
}
}