use std::collections::BTreeMap;
use pathland_core::{
Opcode, border_edges, category, component_type, parameter, property_id, size,
tokens::TokenValue, tree, value_type,
};
mod capi;
mod css;
mod debug;
pub mod icons;
pub mod role_spec;
pub mod token_spec;
fn text_style_scale(code: u8) -> Option<(f32, f32)> {
Some(match code {
0 => (34.0, 700.0), 1 => (28.0, 700.0), 2 => (22.0, 700.0), 3 => (20.0, 600.0), 4 => (17.0, 600.0), 5 => (15.0, 400.0), 6 => (15.0, 400.0), 7 => (14.0, 400.0), 8 => (13.0, 400.0), 9 => (12.0, 400.0), 10 => (11.0, 400.0), _ => return None,
})
}
fn font_design_family(code: u8) -> Option<&'static str> {
match code {
1 => Some("serif"),
2 => Some("sans-serif"),
3 => Some("ui-monospace, SFMono-Regular, Menlo, monospace"),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Node {
pub component: u16,
pub text: Option<String>,
pub properties: BTreeMap<u16, u32>,
pub strings: BTreeMap<u16, String>,
pub token_refs: BTreeMap<u16, String>,
pub date: Option<(i32, u32)>,
pub children: Vec<u32>,
pub fit_thresholds: Vec<f32>,
}
fn fit_candidates_valid(node: &Node) -> bool {
node.children.len() > 1
&& node.fit_thresholds.len() == node.children.len()
&& node.fit_thresholds.windows(2).all(|w| w[0] <= w[1])
}
fn fit_band_styles(nodes: &BTreeMap<u32, Node>) -> String {
let mut rules = String::new();
for (id, node) in nodes {
if node.component != component_type::SIZE_THAT_FITS || !fit_candidates_valid(node) {
continue;
}
let n = node.children.len();
let mut seen = f32::NEG_INFINITY;
for (i, &t) in node.fit_thresholds.iter().enumerate() {
if i == 0 {
seen = t;
continue; }
if t <= seen {
continue; }
seen = t;
let mut band = format!("@container pf-{id} (min-width: {t}px) {{");
for j in 0..n {
if j == i {
continue; }
band.push_str(&format!(
" [data-pathland-id=\"{id}\"] > [data-pathland-fit-cand=\"{j}\"] {{ display: none !important; }}"
));
}
band.push_str(&format!(
" [data-pathland-id=\"{id}\"] > [data-pathland-fit-cand=\"{i}\"] {{ display: contents !important; }} }} \n"
));
rules.push_str(&band);
}
}
rules
}
impl Node {
fn new(component: u16) -> Self {
Self {
component,
text: None,
properties: BTreeMap::new(),
strings: BTreeMap::new(),
token_refs: BTreeMap::new(),
date: None,
children: Vec::new(),
fit_thresholds: Vec::new(),
}
}
fn checked(&self) -> bool {
self.properties
.get(&property_id::SELECTED)
.copied()
.unwrap_or(0)
!= 0
}
fn role_code(&self) -> u8 {
self.properties
.get(&property_id::ROLE)
.map(|b| f32::from_bits(*b) as u8)
.unwrap_or(0)
}
fn text_style_code(&self) -> Option<u8> {
self.properties
.get(&property_id::TEXT_STYLE)
.map(|b| f32::from_bits(*b) as u8)
}
fn f32_property(&self, prop: u16, default: f32) -> f32 {
self.properties
.get(&prop)
.copied()
.map(f32::from_bits)
.unwrap_or(default)
}
fn content_margins(&self) -> Option<f32> {
self.properties
.get(&property_id::CONTENT_MARGINS)
.map(|bits| f32::from_bits(*bits))
}
fn u32_property(&self, prop: u16, default: u32) -> u32 {
self.properties.get(&prop).copied().unwrap_or(default)
}
fn token_ref(&self, prop: u16) -> Option<String> {
self.token_refs.get(&prop).map(|path| resolve_token_ref(path))
}
fn style_css(&self) -> String {
let mut css = String::new();
let f32p = |prop: u16| self.properties.get(&prop).map(|b| f32::from_bits(*b));
let px = |v: f32| format!("{v}px");
if let Some(v) = self.token_ref(property_id::COLOR) {
css.push_str(&format!("color:{v};"));
} else if let Some(bits) = self.properties.get(&property_id::COLOR) {
css.push_str(&format!("color:{};", rgba(*bits)));
}
if let Some(v) = self.token_ref(property_id::BACKGROUND_COLOR) {
css.push_str(&format!("background-color:{v};"));
} else if let Some(bits) = self.properties.get(&property_id::BACKGROUND_COLOR) {
css.push_str(&format!("background-color:{};", rgba(*bits)));
}
if let Some(bits) = self.properties.get(&property_id::FONT_DESIGN) {
if let Some(family) = font_design_family(f32::from_bits(*bits) as u8) {
css.push_str(&format!("font-family:{family};"));
}
}
if let Some(family) = self.strings.get(&property_id::FONT_FAMILY) {
css.push_str(&format!("font-family:'{}';", family.replace('\'', "\\'")));
}
if let Some(v) = self.token_ref(property_id::SPACING) {
css.push_str(&format!("gap:{v};"));
} else if let Some(v) = f32p(property_id::SPACING) {
if v > 0.0 {
css.push_str(&format!("gap:{};", px(v)));
}
}
if let Some(v) = self.token_ref(property_id::CONTENT_MARGINS) {
css.push_str(&format!("padding:{v};"));
} else if let Some(m) = self.content_margins() {
if m > 0.0 {
css.push_str(&format!("padding:{};", px(m)));
}
}
if let Some(v) = self.token_ref(property_id::PADDING) {
css.push_str(&format!("padding:{v};"));
} else if let Some(v) = f32p(property_id::PADDING) {
if v > 0.0 {
css.push_str(&format!("padding:{};", px(v)));
}
}
for (prop, side) in [
(property_id::PADDING_TOP, "padding-top"),
(property_id::PADDING_RIGHT, "padding-right"),
(property_id::PADDING_BOTTOM, "padding-bottom"),
(property_id::PADDING_LEFT, "padding-left"),
] {
if let Some(v) = self.token_ref(prop) {
css.push_str(&format!("{side}:{v};"));
} else if let Some(v) = f32p(prop) {
if v > 0.0 {
css.push_str(&format!("{side}:{};", px(v)));
}
}
}
if let Some(v) = self.token_ref(property_id::WIDTH) {
css.push_str(&format!("width:{v};"));
} else if let Some(v) = f32p(property_id::WIDTH) {
if v != pathland_core::size::HUG_CONTENT {
css.push_str(&format!("width:{};", size_css(v)));
}
}
if let Some(v) = self.token_ref(property_id::HEIGHT) {
css.push_str(&format!("height:{v};"));
} else if let Some(v) = f32p(property_id::HEIGHT) {
if v != pathland_core::size::HUG_CONTENT {
css.push_str(&format!("height:{};", size_css(v)));
}
}
if let (Some(x), Some(y)) = (f32p(property_id::OFFSET_X), f32p(property_id::OFFSET_Y)) {
if x != 0.0 || y != 0.0 {
css.push_str(&format!("transform:translate({}px, {}px);", x, y));
}
} else {
if let Some(v) = f32p(property_id::POSITION_X) {
if v != 0.0 {
css.push_str(&format!("left:{};", px(v)));
}
}
if let Some(v) = f32p(property_id::POSITION_Y) {
if v != 0.0 {
css.push_str(&format!("top:{};", px(v)));
}
}
}
if let Some(style) = self.text_style_code() {
if let Some((size, weight)) = text_style_scale(style) {
css.push_str(&format!("font-size:{size}px;font-weight:{weight};"));
}
}
if let Some(v) = self.token_ref(property_id::FONT_SIZE) {
css.push_str(&format!("font-size:{v};"));
} else if let Some(v) = f32p(property_id::FONT_SIZE) {
if v > 0.0 {
css.push_str(&format!("font-size:{};", px(v)));
}
}
if let Some(v) = self.token_ref(property_id::FONT_WEIGHT) {
css.push_str(&format!("font-weight:{v};"));
} else if let Some(v) = f32p(property_id::FONT_WEIGHT) {
if v > 0.0 {
css.push_str(&format!("font-weight:{v};"));
}
}
if let Some(v) = self.token_ref(property_id::BORDER_RADIUS) {
css.push_str(&format!("border-radius:{v};"));
} else if let Some(v) = f32p(property_id::BORDER_RADIUS) {
if v != 0.0 {
css.push_str(&format!("border-radius:{};", px(v)));
}
}
let mut filters: Vec<String> = Vec::new();
if let Some(v) = f32p(property_id::BLUR_RADIUS) {
filters.push(format!("blur({}px)", v));
}
if let Some(v) = f32p(property_id::SATURATION) {
filters.push(format!("saturate({v})"));
}
if let Some(v) = f32p(property_id::CONTRAST) {
filters.push(format!("contrast({v})"));
}
if let Some(v) = f32p(property_id::BRIGHTNESS) {
filters.push(format!("brightness({v})"));
}
if let Some(v) = f32p(property_id::GRAYSCALE) {
filters.push(format!("grayscale({v})"));
}
if let Some(v) = f32p(property_id::HUE_ROTATION) {
filters.push(format!("hue-rotate({v}deg)"));
}
if !filters.is_empty() {
css.push_str(&format!("filter:{};", filters.join(" ")));
}
if let Some(v) = f32p(property_id::ROTATION_DEGREES) {
if v != 0.0 {
css.push_str(&format!("transform:rotate({v}deg);"));
}
}
if let Some(v) = f32p(property_id::SCALE) {
if v != 1.0 {
css.push_str(&format!("transform:scale({v});"));
}
}
if let (Some(r), Some(color_bits)) = (
f32p(property_id::SHADOW_RADIUS),
self.properties.get(&property_id::SHADOW_COLOR).copied(),
) {
if r != 0.0 {
let x = f32p(property_id::SHADOW_X).unwrap_or(0.0);
let y = f32p(property_id::SHADOW_Y).unwrap_or(0.0);
let color = self
.token_ref(property_id::SHADOW_COLOR)
.unwrap_or_else(|| rgba(color_bits));
css.push_str(&format!("box-shadow:{x}px {y}px {r}px {color};"));
}
}
if let Some(v) = self.token_ref(property_id::OPACITY) {
css.push_str(&format!("opacity:{v};"));
} else if let Some(v) = f32p(property_id::OPACITY) {
if v < 1.0 {
css.push_str(&format!("opacity:{v};"));
}
}
if let Some(v) = f32p(property_id::Z_INDEX) {
css.push_str(&format!("z-index:{};", v as i32));
}
if let Some(v) = f32p(property_id::ALIGNMENT) {
if !matches!(
self.component,
component_type::ZSTACK
| component_type::GRID
| component_type::LAZY_VGRID
| component_type::LAZY_HGRID
) {
css.push_str(&format!(
"align-items:{};",
match v as u8 {
0 => "flex-start",
1 => "center",
2 => "flex-end",
_ => "flex-start",
}
));
}
}
if let Some(v) = f32p(property_id::TEXT_ALIGNMENT) {
css.push_str(&format!(
"text-align:{};",
match (v as u8).min(2) {
0 => "left",
1 => "center",
_ => "right",
}
));
}
if let Some(v) = f32p(property_id::TEXT_CASE) {
css.push_str(&format!(
"text-transform:{};",
match v as u8 {
1 => "uppercase",
2 => "lowercase",
_ => "none",
}
));
}
if let Some(bits) = self.properties.get(&property_id::VISIBLE) {
if *bits == 0 {
css.push_str("display:none;");
}
}
if let Some(v) = f32p(property_id::FONT_STYLE) {
if v != 0.0 {
css.push_str("font-style:italic;");
}
}
if let Some(v) = f32p(property_id::FONT_DESIGN) {
css.push_str(&format!(
"font-family:{};",
match v as u8 {
1 => "Georgia, 'Times New Roman', serif",
2 => "ui-rounded, system-ui, sans-serif",
3 => "ui-monospace, monospace",
_ => "inherit",
}
));
}
let mut decoration = Vec::new();
if let Some(v) = f32p(property_id::UNDERLINE) {
if v != 0.0 {
decoration.push("underline");
}
}
if let Some(v) = f32p(property_id::STRIKETHROUGH) {
if v != 0.0 {
decoration.push("line-through");
}
}
if !decoration.is_empty() {
css.push_str(&format!("text-decoration:{};", decoration.join(" ")));
}
if let Some(v) = f32p(property_id::CLIPS_TO_BOUNDS) {
if v != 0.0 {
css.push_str("overflow:hidden;");
}
}
if let Some(v) = f32p(property_id::ALLOWS_HIT_TESTING) {
if v == 0.0 {
css.push_str("pointer-events:none;");
}
}
if let Some(v) = f32p(property_id::COLOR_INVERT) {
if v != 0.0 {
css.push_str("filter:invert(1);");
}
}
if let Some(v) = f32p(property_id::CONTENT_MODE) {
css.push_str(&format!(
"object-fit:{};",
if (v as u8) == 1 { "cover" } else { "contain" }
));
}
if let Some(v) = f32p(property_id::ASPECT_RATIO) {
css.push_str(&format!("aspect-ratio:{v};"));
}
css
}
fn border_style(&self) -> String {
let width = match self.token_ref(property_id::BORDER_WIDTH) {
Some(v) => v,
None => {
let Some(width_bits) = self.properties.get(&property_id::BORDER_WIDTH) else {
return String::new();
};
format!("{}px", f32::from_bits(*width_bits))
}
};
let color = match self.token_ref(property_id::BORDER_COLOR) {
Some(v) => v,
None => rgba(
self.properties
.get(&property_id::BORDER_COLOR)
.copied()
.unwrap_or(0xFF00_0000),
),
};
let edges = self
.properties
.get(&property_id::BORDER_EDGES)
.copied()
.unwrap_or(border_edges::ALL);
let mut css = String::new();
for (flag, side) in [
(border_edges::TOP, "top"),
(border_edges::LEADING, "left"),
(border_edges::BOTTOM, "bottom"),
(border_edges::TRAILING, "right"),
] {
if edges & flag != 0 {
css.push_str(&format!("border-{side}:{width} solid {color};"));
}
}
css
}
}
fn unpack_color(argb: u32) -> (u32, u32, u32, f32) {
let r = (argb >> 16) & 0xFF;
let g = (argb >> 8) & 0xFF;
let b = argb & 0xFF;
let a = ((argb >> 24) & 0xFF) as f32 / 255.0;
(r, g, b, a)
}
fn rgba(argb: u32) -> String {
let (r, g, b, a) = unpack_color(argb);
format!("rgba({r},{g},{b},{a})")
}
fn size_css(v: f32) -> String {
if v == pathland_core::size::FILL {
"100%".to_string()
} else if v == pathland_core::size::HUG_CONTENT {
"fit-content".to_string()
} else {
format!("{v}px")
}
}
fn stack_main_horizontal(component: u16) -> Option<bool> {
match component {
component_type::HSTACK | component_type::LAZY_HSTACK => Some(true),
component_type::VSTACK | component_type::LAZY_VSTACK => Some(false),
_ => None,
}
}
fn fills_axis(
nodes: &BTreeMap<u32, Node>,
id: u32,
axis_horizontal: bool,
main_axis: bool,
) -> bool {
let Some(node) = nodes.get(&id) else {
return false;
};
let prop = if axis_horizontal {
property_id::WIDTH
} else {
property_id::HEIGHT
};
let hint = node.properties.get(&prop).map(|b| f32::from_bits(*b));
match hint {
Some(v) if v == size::FILL => return true,
Some(v) if v.is_finite() && v > 0.0 => return false, _ => {}
}
let greedy = match node.component {
component_type::COLOR | component_type::SCROLLVIEW => true,
component_type::SPACER => main_axis,
component_type::DIVIDER => !main_axis,
_ => false,
};
if greedy {
return true;
}
if !matches!(
node.component,
component_type::VSTACK
| component_type::HSTACK
| component_type::ZSTACK
| component_type::LAZY_VSTACK
| component_type::LAZY_HSTACK
) {
return false;
}
let child_main = stack_main_horizontal(node.component)
.map(|main_h| main_h == axis_horizontal)
.unwrap_or(false); node.children
.iter()
.any(|&child| fills_axis(nodes, child, axis_horizontal, child_main))
}
fn fill_propagation(
nodes: &BTreeMap<u32, Node>,
id: u32,
node: &Node,
main_horizontal: bool,
) -> String {
let mut extra = String::new();
for (axis_h, prop, css) in [
(true, property_id::WIDTH, "width:100%;"),
(false, property_id::HEIGHT, "height:100%;"),
] {
let hint = node.properties.get(&prop).map(|b| f32::from_bits(*b));
let hug = hint.is_none() || hint == Some(size::HUG_CONTENT);
if hug && fills_axis(nodes, id, axis_h, main_horizontal == axis_h) {
extra.push_str(css);
}
}
extra
}
fn grid_count(node: &Node, prop: u16) -> Option<u32> {
let v = node.f32_property(prop, -1.0);
if v <= 0.0 {
None
} else {
Some(v.round() as u32)
}
}
fn grid_columns(node: &Node) -> Option<u32> {
grid_count(node, property_id::GRID_COLUMNS)
}
fn grid_rows(node: &Node) -> Option<u32> {
grid_count(node, property_id::GRID_ROWS)
}
fn align_h(code: u8) -> &'static str {
match code {
1 | 3 | 4 => "center",
2 | 6 | 7 => "end",
_ => "start",
}
}
fn align_v(code: u8) -> &'static str {
match code {
1 | 5 | 6 => "center",
2 | 4 | 8 => "end",
_ => "start",
}
}
fn grid_tracks(node: &Node) -> Option<Vec<&str>> {
node.strings
.get(&property_id::GRID_TRACKS)
.map(|s| s.split(',').map(str::trim).collect())
}
fn grid_tracks_css(node: &Node) -> Option<String> {
let tracks = grid_tracks(node)?;
let mut out = String::new();
for t in tracks {
if !out.is_empty() {
out.push(' ');
}
if let Some(pts) = t.strip_prefix("fixed:") {
out.push_str(&format!("{pts}px"));
} else if let Some(pts) = t.strip_prefix("adaptive:") {
out.push_str(&format!("minmax({pts}px,1fr)"));
} else {
out.push_str("1fr");
}
}
Some(out)
}
fn days_to_date(days: i32) -> (i32, u32, u32) {
let z = i64::from(days) + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y as i32, m as u32, d as u32)
}
fn millis_to_hms(millis: u32) -> (u32, u32, u32) {
let total = millis / 1000;
(total / 3600, (total % 3600) / 60, total % 60)
}
fn event_attrs(node: &Node) -> String {
let mut attrs = String::new();
if let Some(m) = node.properties.get(&property_id::EVENT_LISTENERS) {
if *m != 0 {
attrs.push_str(&format!(" data-event-listeners=\"{m}\""));
}
}
if let Some(a) = node.properties.get(&property_id::ACTION_ID) {
attrs.push_str(&format!(" data-action-id=\"{a}\""));
}
if let Some(b) = node.properties.get(&property_id::BINDING_ID) {
attrs.push_str(&format!(" data-binding-id=\"{b}\""));
}
attrs
}
fn aria_attrs(node: &Node) -> String {
let mut attrs = String::new();
if let Some(bits) = node.properties.get(&property_id::ROLE) {
let code = f32::from_bits(*bits) as u8;
let kind = role_spec::shell_kind(node.component);
if let Some(role) = role_spec::aria_role(kind, code) {
attrs.push_str(&format!(" role=\"{role}\""));
}
}
if let Some(bits) = node.properties.get(&property_id::STATE) {
match f32::from_bits(*bits) as u8 {
1 => attrs.push_str(" aria-disabled=\"true\""),
3 => attrs.push_str(" aria-pressed=\"true\""),
4 => attrs.push_str(" aria-selected=\"true\""),
5 => attrs.push_str(" aria-expanded=\"true\""),
6 => attrs.push_str(" aria-busy=\"true\""),
_ => {}
}
}
if let Some(bits) = node.properties.get(&property_id::ENABLED) {
if *bits == 0 {
attrs.push_str(" disabled");
}
}
attrs
}
fn decode(opcodes: &[Opcode], strings: &[u8]) -> (BTreeMap<u32, Node>, Tokens) {
let mut nodes = BTreeMap::new();
let mut tokens = Tokens::default();
for op in opcodes {
match op.category() {
category::TREE => apply_tree(&mut nodes, op.command(), *op),
category::PARAMETER => {
if op.command() == parameter::SET_DESIGN_TOKEN {
if let Some(path) = strings_str(strings, op.a()) {
let vt = (op.b() & 0xFF) as u8;
let value = if vt == value_type::STRING {
strings_str(strings, op.c())
.map(TokenValue::Str)
.unwrap_or(TokenValue::U32(0))
} else {
TokenValue::from_wire(vt, op.c()).unwrap_or(TokenValue::U32(op.c()))
};
tokens.set(&path, vt, value);
}
} else {
apply_style(&mut nodes, op.command(), *op, strings);
}
}
_ => {}
}
}
(nodes, tokens)
}
fn strings_str(strings: &[u8], offset: u32) -> Option<String> {
let offset = offset as usize;
if offset + 4 > strings.len() {
return None;
}
let len = u32::from_le_bytes(strings[offset..offset + 4].try_into().ok()?) as usize;
let start = offset + 4;
if start + len > strings.len() {
return None;
}
std::str::from_utf8(&strings[start..start + len]).ok().map(str::to_owned)
}
fn list_csv(strings: &[u8], offset: u32) -> Option<String> {
let offset = offset as usize;
if offset + 4 > strings.len() {
return None;
}
let count = u32::from_le_bytes(strings[offset..offset + 4].try_into().ok()?) as usize;
let start = offset + 4;
if start + count * 4 > strings.len() {
return None;
}
let mut parts = Vec::with_capacity(count);
for i in 0..count {
let bits = u32::from_le_bytes(
strings[start + i * 4..start + i * 4 + 4].try_into().ok()?,
);
let value = f32::from_bits(bits);
parts.push(if value.fract() == 0.0 {
format!("{:.0}", value)
} else {
format!("{:.1}", value)
});
}
Some(parts.join(","))
}
fn list_f32(strings: &[u8], offset: u32) -> Option<Vec<f32>> {
let offset = offset as usize;
if offset + 4 > strings.len() {
return None;
}
let count = u32::from_le_bytes(strings[offset..offset + 4].try_into().ok()?) as usize;
let start = offset + 4;
if start + count * 4 > strings.len() {
return None;
}
let mut values = Vec::with_capacity(count);
for i in 0..count {
let bits = u32::from_le_bytes(
strings[start + i * 4..start + i * 4 + 4].try_into().ok()?,
);
values.push(f32::from_bits(bits));
}
Some(values)
}
#[derive(Debug, Default, Clone)]
struct Tokens {
base: BTreeMap<String, (u8, TokenValue)>,
dark: BTreeMap<String, (u8, TokenValue)>,
}
impl Tokens {
fn set(&mut self, path: &str, value_type: u8, value: TokenValue) {
if let Some(bare) = path.strip_prefix("dark.") {
self.dark.insert(bare.to_string(), (value_type, value));
} else {
self.base.insert(path.to_string(), (value_type, value));
}
}
fn css(&self) -> String {
let mut css = String::new();
for (path, (vt, value)) in &self.base {
css.push_str(&format!(
":root{{{}:{};}}",
token_to_css_var(path),
token_css_value(path, *vt, value)
));
}
if !self.dark.is_empty() {
let rules: String = self
.dark
.iter()
.map(|(path, (vt, value))| {
format!(":root{{{}:{};}}", token_to_css_var(path), token_css_value(path, *vt, value))
})
.collect();
css.push_str(&format!("@media (prefers-color-scheme: dark){{{rules}}}"));
}
css
}
}
fn token_to_css_var(path: &str) -> String {
let bare = path.strip_prefix(token_spec::DARK_PREFIX).unwrap_or(path);
let name: String = bare
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
.collect();
format!("{}{name}", token_spec::VAR_PREFIX)
}
fn is_length_token(path: &str) -> bool {
for prefix in token_spec::LENGTH_PREFIXES {
if path.starts_with(prefix) {
return true;
}
}
for (prefix, suffix) in token_spec::LENGTH_PREFIX_SUFFIX {
if path.starts_with(prefix) && path.ends_with(suffix) {
return true;
}
}
if token_spec::LENGTH_EXACT.contains(&path) {
return true;
}
path.starts_with("elevation.")
&& token_spec::LENGTH_ELEVATION_SUFFIXES
.iter()
.any(|s| path.ends_with(s))
}
fn token_css_value(path: &str, _value_type: u8, value: &TokenValue) -> String {
match value {
TokenValue::Str(s) => format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")),
TokenValue::Color(argb) => rgba(*argb),
TokenValue::F32(v) => {
if is_length_token(path) {
format!("{v}px")
} else {
format!("{v}")
}
}
TokenValue::I32(v) => format!("{v}"),
TokenValue::U32(v) => format!("{v}"),
TokenValue::U8(v) => format!("{v}"),
}
}
fn resolve_token_ref(path: &str) -> String {
let bare = path.strip_prefix(token_spec::DARK_PREFIX).unwrap_or(path);
if let Some(rest) = bare.strip_prefix(token_spec::SPACE_FAMILY) {
if rest.parse::<f64>().is_ok() {
return format!("calc(var(--pl-space-base) * {rest})");
}
}
format!("var({})", token_to_css_var(bare))
}
fn apply_tree(nodes: &mut BTreeMap<u32, Node>, command: u8, op: Opcode) {
match command {
tree::CREATE_NODE => {
let id = op.a();
let component = op.b() as u16;
nodes.insert(id, Node::new(component));
}
tree::DELETE_NODE => {
nodes.remove(&op.a());
}
tree::INSERT_CHILD => {
let (parent, child) = (op.a(), op.b());
if let Some(node) = nodes.get_mut(&parent) {
let index = op.c();
if index == pathland_core::APPEND {
node.children.push(child);
} else {
let index = (index as usize).min(node.children.len());
node.children.insert(index, child);
}
}
}
tree::REMOVE_CHILD => {
let (parent, child) = (op.a(), op.b());
if let Some(node) = nodes.get_mut(&parent) {
node.children.retain(|&c| c != child);
}
}
tree::MOVE_CHILD => {
let (parent, child, index) = (op.a(), op.b(), op.c());
if let Some(node) = nodes.get_mut(&parent) {
node.children.retain(|&c| c != child);
let index = (index as usize).min(node.children.len());
node.children.insert(index, child);
}
}
_ => {}
}
}
fn apply_style(nodes: &mut BTreeMap<u32, Node>, command: u8, op: Opcode, strings: &[u8]) {
match command {
parameter::SET_TEXT => {
if let Some(text) = strings_str(strings, op.b()) {
if let Some(node) = nodes.get_mut(&op.a()) {
node.text = Some(text);
}
}
}
parameter::SET_PROPERTY => {
let property = (op.b() & 0xFFFF) as u16;
if let Some(node) = nodes.get_mut(&op.a()) {
let vt = (op.b() >> 16) as u8;
if vt == value_type::STRING {
if let Some(text) = strings_str(strings, op.c()) {
node.strings.insert(property, text);
}
} else if vt == value_type::DESIGN_TOKEN {
if let Some(path) = strings_str(strings, op.c()) {
node.token_refs.insert(property, path);
}
} else if vt == value_type::LIST {
if let Some(csv) = list_csv(strings, op.c()) {
node.strings.insert(property, csv);
}
if let Some(values) = list_f32(strings, op.c()) {
node.fit_thresholds = values;
}
} else {
node.properties.insert(property, op.c());
}
}
}
parameter::SET_DATE => {
if let Some(node) = nodes.get_mut(&op.a()) {
node.date = Some((op.b() as i32, op.c()));
}
}
_ => {}
}
}
#[derive(Debug, Clone, Copy)]
pub struct HtmlRenderer {
debug_comments: bool,
}
impl HtmlRenderer {
#[must_use]
pub fn new() -> Self {
Self {
debug_comments: false,
}
}
#[must_use]
pub fn with_debug_comments(mut self, on: bool) -> Self {
self.debug_comments = on;
self
}
#[must_use]
pub fn debug_comments(&self) -> bool {
self.debug_comments
}
#[must_use]
pub fn render_document(&self, opcodes: &[Opcode], strings: &[u8], root: u32) -> String {
let (nodes, tokens) = decode(opcodes, strings);
let body = self.render_node(&nodes, root, None);
let token_rules = tokens.css();
let token_style = if token_rules.is_empty() {
String::new()
} else {
format!("<style data-pathland-tokens>{token_rules}</style>\n")
};
let fit_rules = fit_band_styles(&nodes);
let fit_style = if fit_rules.is_empty() {
String::new()
} else {
format!("<style data-pathland-fit>{fit_rules}</style>\n")
};
format!(
"<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n\
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
<title>Pathland</title>\n\
{}{}{}\n</head>\n<body>{body}</body>\n</html>\n",
css::STYLE,
token_style,
fit_style
)
}
#[must_use]
pub fn render_fragment(&self, opcodes: &[Opcode], strings: &[u8], root: u32) -> String {
let (nodes, _) = decode(opcodes, strings);
self.render_node(&nodes, root, None)
}
fn render_node(
&self,
nodes: &BTreeMap<u32, Node>,
id: u32,
parent_component: Option<u16>,
) -> String {
let Some(node) = nodes.get(&id) else {
return String::new();
};
let mut children: String = node
.children
.iter()
.map(|&child| self.render_node(nodes, child, Some(node.component)))
.collect();
let mut data_id = format!(" data-pathland-id=\"{id}\"");
data_id.push_str(&slot_attrs(node));
let mut media_attr = String::new();
if !node.children.is_empty() {
if let Some(src) = node.strings.get(&property_id::AUDIO_SOURCE) {
media_attr.push_str(" data-pathland-media");
children = format!(
"<audio src=\"{}\" data-pathland-media></audio>{children}",
escape(src)
);
} else if let Some(src) = node.strings.get(&property_id::VIDEO_SOURCE) {
media_attr.push_str(" data-pathland-media");
children = format!(
"<video src=\"{}\" data-pathland-media></video>{children}",
escape(src)
);
}
}
data_id.push_str(&media_attr);
let mut css = format!("{}{}", node.style_css(), node.border_style());
let mut derived = String::new();
if let Some(parent) = parent_component {
if let Some(main_horizontal) = stack_main_horizontal(parent) {
if fills_axis(nodes, id, !main_horizontal, false) {
derived.push_str("align-self:stretch;");
}
}
}
let line_limit = node.u32_property(property_id::LINE_LIMIT, 0);
if line_limit > 0 {
css.push_str(&format!(
"display:-webkit-box;overflow:hidden;-webkit-line-clamp:{line_limit};-webkit-box-orient:vertical;"
));
}
let style = style_attr(&format!("{css}{derived}"));
let event = event_attrs(node);
let aria = aria_attrs(node);
let kind = role_spec::shell_kind(node.component);
let semantic = role_spec::semantic_tag(kind, node.role_code());
let element = match node.component {
component_type::VSTACK => {
wrap_stack(id, "column", semantic, node, &media_attr, &children, &format!("{css}{}{derived}", fill_propagation(nodes, id, node, false)), &event, &aria)
}
component_type::HSTACK => {
wrap_stack(id, "row", semantic, node, &media_attr, &children, &format!("{css}{}{derived}", fill_propagation(nodes, id, node, true)), &event, &aria)
}
component_type::LAZY_VSTACK => {
wrap_stack(id, "column", semantic, node, &media_attr, &children, &format!("{css}{}{derived}", fill_propagation(nodes, id, node, false)), &event, &aria)
}
component_type::LAZY_HSTACK => {
wrap_stack(id, "row", semantic, node, &media_attr, &children, &format!("{css}{}{derived}", fill_propagation(nodes, id, node, true)), &event, &aria)
}
component_type::SIZE_THAT_FITS => {
let fit_attr = node
.strings
.get(&property_id::FIT_QUERY)
.map(|csv| format!(" data-pathland-fit=\"{}\"", escape(csv)))
.unwrap_or_default();
if fit_candidates_valid(node) {
let fit_cands = format!(" data-pathland-fit-cands=\"{}\"", node.children.len());
let mut boxed = String::new();
for (i, &child) in node.children.iter().enumerate() {
let inner = self.render_node(nodes, child, Some(node.component));
let default = if i == 0 { "display:contents" } else { "display:none" };
boxed.push_str(&format!(
"<div data-pathland-fit-cand=\"{i}\" data-pathland-fit-cand-id=\"{child}\" style=\"{default}\">{inner}</div>"
));
}
let slot_base = format!(
"display:flex;flex-direction:inherit;align-items:inherit;justify-content:inherit;flex:1 1 auto;align-self:stretch;min-width:0;min-height:0;container-type:inline-size;container-name:pf-{id};{css}{derived}"
);
format!(
"<div{data_id} class=\"pathland-ftf\"{fit_attr}{fit_cands}{event}{aria} style=\"{slot_base}\">{boxed}</div>"
)
} else if node.children.len() > 1 {
let only = self.render_node(nodes, node.children[0], Some(node.component));
format!(
"<div{data_id} class=\"pathland-ftf\"{fit_attr}{event}{aria} style=\"display:flex;flex-direction:inherit;align-items:inherit;justify-content:inherit;flex:1 1 auto;align-self:stretch;min-width:0;min-height:0;{css}{derived}\">{only}</div>"
)
} else {
format!(
"<div{data_id} class=\"pathland-ftf\"{fit_attr}{event}{aria} style=\"display:flex;flex-direction:inherit;align-items:inherit;justify-content:inherit;flex:1 1 auto;align-self:stretch;min-width:0;min-height:0;{css}{derived}\">{children}</div>"
)
}
}
component_type::TEXT => {
let tag = role_spec::text_tag(kind, node.role_code(), node.text_style_code())
.unwrap_or("span");
let text = escape(node.text.as_deref().unwrap_or_default());
format!("<{tag}{data_id}{event}{aria}{style}>{text}</{tag}>")
}
component_type::ICON => {
let icon_name = node
.strings
.get(&property_id::ICON_NAME)
.map(|n| format!(" data-pathland-icon=\"{}\"", escape(n)))
.unwrap_or_default();
let inner = node
.strings
.get(&property_id::ICON_NAME)
.and_then(|n| icons::web_inner(n))
.unwrap_or_else(icons::web_fallback_svg);
let icon_aria = match node.strings.get(&property_id::LABEL) {
Some(label) if !label.is_empty() => {
format!(" role=\"img\" aria-label=\"{}\"", escape(label))
}
_ => " aria-hidden=\"true\" focusable=\"false\"".to_string(),
};
let open = icons::WEB_SVG_OPEN.trim_end_matches('>');
format!("{open}{data_id}{icon_name}{icon_aria}{event}{style}>{inner}</svg>")
}
component_type::BUTTON => {
let body = if node.children.is_empty() {
escape(node.text.as_deref().unwrap_or_default())
} else {
children
};
let button_class = class_attr("pathland-button");
format!("<button{data_id}{event}{aria}{button_class}{style}>{body}</button>")
}
component_type::TOGGLE => {
let toggle_style = node.f32_property(property_id::TOGGLE_STYLE, 0.0).round() as u8;
let checked = if node.checked() { " checked" } else { "" };
let body = if node.children.is_empty() {
format!(
"<span class=\"pathland-text\">{}</span>",
escape(node.text.as_deref().unwrap_or_default())
)
} else {
format!("<div class=\"pathland-custom-body\">{children}</div>")
};
let toggle_class = class_attr("pathland-toggle");
match toggle_style {
1 => format!(
"<label{data_id}{event}{aria}{toggle_class}{style}><input type=\"checkbox\"{checked}>{body}</label>"
),
2 => {
let pressed = if node.checked() { "true" } else { "false" };
format!(
"<button{data_id}{event}{aria} type=\"button\" aria-pressed=\"{pressed}\"{style}>{body}</button>"
)
}
_ => format!(
"<label{data_id}{event}{aria}{toggle_class}{style}><input type=\"checkbox\" role=\"switch\"{checked}>{body}</label>"
),
}
}
component_type::SPACER => {
let tag = semantic.unwrap_or("div");
let combined = format!("flex:1;{css}");
let spacer_style = style_attr(&combined);
format!("<{tag}{data_id}{event}{aria}{spacer_style}></{tag}>")
}
component_type::SLIDER => {
let min = node.f32_property(property_id::MIN_VALUE, 0.0);
let max = node.f32_property(property_id::MAX_VALUE, 1.0);
let value = node.f32_property(property_id::VALUE, min);
let body = if node.children.is_empty() {
format!(
"<span class=\"pathland-text\">{}</span>",
escape(node.text.as_deref().unwrap_or_default())
)
} else {
format!("<div class=\"pathland-custom-body\">{children}</div>")
};
let slider_class = class_attr("pathland-slider");
format!(
"<label{data_id}{event}{aria}{slider_class}{style}><input type=\"range\" min=\"{min}\" max=\"{max}\" step=\"any\" value=\"{value}\">{body}</label>"
)
}
component_type::TEXT_FIELD => {
let value = escape(node.text.as_deref().unwrap_or_default());
let label = escape(
node.strings
.get(&property_id::LABEL)
.map(String::as_str)
.unwrap_or_default(),
);
let prompt = escape(
node.strings
.get(&property_id::PROMPT)
.map(String::as_str)
.unwrap_or_default(),
);
let input_type = if node.f32_property(property_id::IS_SECURE, 0.0) != 0.0 {
"password"
} else {
"text"
};
let textfield_class = class_attr("pathland-textfield");
format!(
"<label{data_id}{event}{aria}{textfield_class}{style}><span class=\"pathland-label\">{label}</span><input class=\"pathland-input\" type=\"{input_type}\" value=\"{value}\" placeholder=\"{prompt}\"></label>"
)
}
component_type::TEXT_EDITOR => {
let value = escape(node.text.as_deref().unwrap_or_default());
format!("<textarea{data_id}{event}{aria} class=\"pathland-input\" rows=\"4\"{style}>{value}</textarea>")
}
component_type::IMAGE => {
let src = escape(
node.strings
.get(&property_id::IMAGE_SOURCE)
.map(String::as_str)
.unwrap_or_default(),
);
let alt = escape(
node.strings
.get(&property_id::LABEL)
.map(String::as_str)
.unwrap_or_default(),
);
format!("<img{data_id}{event}{aria} src=\"{src}\" alt=\"{alt}\"{style}>")
}
component_type::AUDIO => {
let src = escape(
node.strings
.get(&property_id::AUDIO_SOURCE)
.map(String::as_str)
.unwrap_or_default(),
);
if node.children.is_empty() {
format!("<audio{data_id}{event}{aria} src=\"{src}\" controls{style}></audio>")
} else {
format!(
"<div{data_id}{event}{aria} class=\"pathland-media\"{style}>{children}</div>"
)
}
}
component_type::VIDEO => {
let src = escape(
node.strings
.get(&property_id::VIDEO_SOURCE)
.map(String::as_str)
.unwrap_or_default(),
);
if node.children.is_empty() {
format!("<video{data_id}{event}{aria} src=\"{src}\" controls{style}></video>")
} else {
format!(
"<div{data_id}{event}{aria} class=\"pathland-media\"{style}>{children}</div>"
)
}
}
component_type::COLOR => {
let tag = semantic.unwrap_or("div");
let color = node.u32_property(property_id::COLOR, 0xFF00_0000);
format!(
"<{tag}{data_id}{event}{aria} style=\"flex:1 1 auto;align-self:stretch;background-color:{};{css}\">{children}</{tag}>",
rgba(color)
)
}
component_type::SHAPE => {
let kind = node.f32_property(property_id::SHAPE_KIND, 0.0).round() as u8;
let fill = node.u32_property(property_id::COLOR, 0xFF00_0000);
let w = node.f32_property(property_id::WIDTH, 100.0);
let h = node.f32_property(property_id::HEIGHT, 100.0);
let base = format!(
"width:{};height:{};background-color:{};{css}",
size_css(w),
size_css(h),
rgba(fill)
);
let tag = semantic.unwrap_or("div");
match kind {
0 | 4 => format!("<{tag}{data_id}{event}{aria} style=\"{base}border-radius:50%;\"></{tag}>"),
2 => {
let r = node.f32_property(property_id::BORDER_RADIUS, 8.0);
format!("<{tag}{data_id}{event}{aria} style=\"{base}border-radius:{r}px;\"></{tag}>")
}
3 => format!("<{tag}{data_id}{event}{aria} style=\"{base}border-radius:9999px;\"></{tag}>"),
5 => format!(
"<svg{data_id}{event}{aria} width=\"100\" height=\"100\" viewBox=\"0 0 100 100\"><rect width=\"100\" height=\"100\" fill=\"{}\"/></svg>",
rgba(fill)
),
_ => format!("<{tag}{data_id}{event}{aria} style=\"{base}\"></{tag}>"),
}
}
component_type::DIVIDER => {
let width = node.f32_property(property_id::BORDER_WIDTH, 1.0);
let color = node
.properties
.get(&property_id::COLOR)
.copied()
.map(rgba)
.unwrap_or_else(|| "rgba(0,0,0,0.2)".to_string());
format!(
"<div{data_id}{event}{aria} style=\"height:0;width:100%;border-top:{width}px solid {color};{derived}\"></div>"
)
}
component_type::PROGRESS_VIEW => {
let indeterminate = node.f32_property(property_id::IS_INDETERMINATE, 0.0) != 0.0
|| node.f32_property(property_id::PROGRESS, -1.0) < 0.0;
if indeterminate {
let spinner_class = class_attr("pathland-spinner");
format!("<div{data_id}{spinner_class}{style}></div>")
} else {
let value = node.f32_property(property_id::PROGRESS, 0.0);
let max = node.f32_property(property_id::MAX_VALUE, 1.0);
format!(
"<progress{data_id}{event}{aria} value=\"{value}\" max=\"{max}\"{style}></progress>"
)
}
}
component_type::GAUGE => {
let min = node.f32_property(property_id::MIN_VALUE, 0.0);
let max = node.f32_property(property_id::MAX_VALUE, 1.0);
let value = node.f32_property(property_id::VALUE, min);
let pct = if max > min {
((value - min) / (max - min)).clamp(0.0, 1.0) * 100.0
} else {
0.0
};
let gauge_class = class_attr("pathland-gauge");
format!(
"<div{data_id}{event}{aria}{gauge_class} data-min=\"{min}\" data-max=\"{max}\"{style}><div style=\"width:{pct}%\"></div></div>"
)
}
component_type::GRID | component_type::LAZY_VGRID | component_type::LAZY_HGRID => {
let tag = semantic.unwrap_or("div");
let explicit_rows = node
.children
.iter()
.any(|&c| nodes.get(&c).is_some_and(|n| n.component == component_type::GRID_ROW));
let track_count = grid_tracks(node).map(|t| t.len() as u32);
let base_columns = grid_columns(node);
let effective_columns = if let Some(n) = track_count {
Some(n)
} else if explicit_rows {
let mut width = base_columns.map(|c| c as usize).unwrap_or(0);
let mut run = 0usize;
for &child in &node.children {
if nodes.get(&child).is_some_and(|n| n.component == component_type::GRID_ROW) {
width = width.max(nodes.get(&child).map(|n| n.children.len()).unwrap_or(0));
run = 0;
} else {
run += 1;
width = width.max(run);
}
}
base_columns.or(Some((width.max(1)) as u32))
} else {
base_columns
};
let grid_css = grid_style(node, effective_columns);
let combined = format!("{grid_css}{css}");
let grid_style = style_attr(&combined);
let align = node.f32_property(property_id::ALIGNMENT, 0.0) as u8;
let pos_h = |stretch: bool| if stretch { "stretch" } else { align_h(align) };
let pos_v = |stretch: bool| if stretch { "stretch" } else { align_v(align) };
let cell_shell = |cell_html: String, row: usize, col: usize, cell: u32| {
let placement = if explicit_rows {
format!("grid-row:{};grid-column:{};", row + 1, col + 1)
} else {
String::new()
};
let z = nodes
.get(&cell)
.map(|cn| cn.f32_property(property_id::Z_INDEX, 0.0))
.filter(|z| *z != 0.0)
.map(|z| format!("z-index:{};", z as i32))
.unwrap_or_default();
format!(
"<div style=\"{placement}{z}justify-self:{};align-self:{};\">{cell_html}</div>",
pos_h(fills_axis(nodes, cell, true, false)),
pos_v(fills_axis(nodes, cell, false, false)),
)
};
let inner: String = if explicit_rows {
let columns = effective_columns.unwrap_or(1) as usize;
let mut out = String::new();
let mut row = 0usize;
let mut col = 0usize;
for &child in &node.children {
let Some(cn) = nodes.get(&child) else { continue };
if cn.component == component_type::GRID_ROW {
if col > 0 {
row += 1;
}
col = 0;
let mut cells = String::new();
for &cell in &cn.children {
let cell_html = self.render_node(nodes, cell, Some(node.component));
if !cell_html.is_empty() {
cells.push_str(&cell_shell(cell_html, row, col, cell));
}
col += 1;
}
out.push_str(&format!(
"<div data-pathland-id=\"{child}\" style=\"display:contents\">{cells}</div>"
));
row += 1;
col = 0;
} else {
if col >= columns {
row += 1;
col = 0;
}
let cell_html = self.render_node(nodes, child, Some(node.component));
if !cell_html.is_empty() {
out.push_str(&cell_shell(cell_html, row, col, child));
col += 1;
}
}
}
out
} else {
node.children
.iter()
.filter_map(|&child| {
let cell_html = self.render_node(nodes, child, Some(node.component));
if cell_html.is_empty() {
None
} else {
Some(cell_shell(cell_html, 0, 0, child))
}
})
.collect()
};
format!("<{tag}{data_id}{event}{aria}{grid_style}>{inner}</{tag}>")
}
component_type::SCROLLVIEW => {
let tag = semantic.unwrap_or("div");
let combined = format!("flex:1 1 auto;align-self:stretch;overflow:auto;{css}");
let scroll_style = style_attr(&combined);
let content = node
.children
.first()
.map(|&c| self.render_node(nodes, c, Some(node.component)))
.unwrap_or_default();
format!("<{tag}{data_id}{event}{aria}{scroll_style}>{content}</{tag}>")
}
component_type::ZSTACK => {
let tag = semantic.unwrap_or("div");
let align = node.f32_property(property_id::ALIGNMENT, 0.0) as u8;
let pos_h = |stretch: bool| if stretch { "stretch" } else { align_h(align) };
let pos_v = |stretch: bool| if stretch { "stretch" } else { align_v(align) };
let inner: String = node
.children
.iter()
.filter_map(|&child| {
let child_html = self.render_node(nodes, child, Some(node.component));
if child_html.is_empty() {
None
} else {
let z = nodes
.get(&child)
.map(|cn| cn.f32_property(property_id::Z_INDEX, 0.0) as i32)
.unwrap_or(0);
Some(format!(
"<div style=\"grid-area:1/1;z-index:{z};justify-self:{};align-self:{};\">{child_html}</div>",
pos_h(fills_axis(nodes, child, true, false)),
pos_v(fills_axis(nodes, child, false, false)),
))
}
})
.collect();
let mut zcss = format!(
"display:grid;isolation:isolate;grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr);justify-items:{};align-items:{};",
align_h(align),
align_v(align)
);
for (axis_h, prop, hug_css, fill_css) in [
(true, property_id::WIDTH, "width:max-content;", "width:100%;"),
(false, property_id::HEIGHT, "height:max-content;", "height:100%;"),
] {
let hint = node.properties.get(&prop).map(|b| f32::from_bits(*b));
let hug = hint.is_none() || hint == Some(size::HUG_CONTENT);
if hug {
zcss.push_str(if fills_axis(nodes, id, axis_h, false) {
fill_css
} else {
hug_css
});
}
}
let combined = format!("{zcss}{css}{derived}");
let zstyle = style_attr(&combined);
format!("<{tag}{data_id}{event}{aria}{zstyle}>{inner}</{tag}>")
}
component_type::STEPPER => {
let min = node.f32_property(property_id::MIN_VALUE, 0.0);
let max = node.f32_property(property_id::MAX_VALUE, 100.0);
let value = node.f32_property(property_id::VALUE, min);
let step = node.f32_property(property_id::STEP_VALUE, 1.0);
let display = format!("{}", (value * 100.0).round() / 100.0);
let stepper_class = class_attr("pathland-stepper");
format!(
"<div{data_id}{event}{aria}{stepper_class}{style}><button type=\"button\" data-step=\"-1\">−</button><span>{display}</span><button type=\"button\" data-step=\"1\">+</button><span class=\"pathland-stepper-range\" hidden data-min=\"{min}\" data-max=\"{max}\" data-step=\"{step}\"></span></div>"
)
}
component_type::DATE_PICKER => {
let mode = node.f32_property(property_id::DATE_PICKER_MODE, 0.0).round() as u8;
let (t, value) = match node.date {
Some((days, millis)) => match mode {
1 => {
let (hh, mm, _) = millis_to_hms(millis);
("time", format!("{hh:02}:{mm:02}"))
}
2 => {
let (y, m, d) = days_to_date(days);
let (hh, mm, _) = millis_to_hms(millis);
(
"datetime-local",
format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}"),
)
}
_ => {
let (y, m, d) = days_to_date(days);
("date", format!("{y:04}-{m:02}-{d:02}"))
}
},
None => ("date", String::new()),
};
format!("<input{data_id}{event}{aria} type=\"{t}\" value=\"{value}\"{style}>")
}
component_type::PICKER => {
let selected = node.u32_property(property_id::SELECTION, 0);
let options: String = node
.children
.iter()
.enumerate()
.map(|(i, &child)| {
let label = nodes
.get(&child)
.and_then(|n| n.text.clone())
.unwrap_or_default();
let sel = if i as u32 == selected { " selected" } else { "" };
format!(
"<option data-pathland-id=\"{child}\" value=\"{i}\"{sel}>{}</option>",
escape(&label)
)
})
.collect();
format!("<select{data_id}{event}{aria}{style}>{options}</select>")
}
component_type::MENU => {
let label = escape(node.text.as_deref().unwrap_or_default());
let menu_class = class_attr("pathland-menu");
format!(
"<div{data_id}{event}{aria}{menu_class} role=\"menu\"{style}><div class=\"pathland-menu-trigger\">{label}</div><div class=\"pathland-menu-items\">{children}</div></div>"
)
}
component_type::COLOR_PICKER => {
let color = node.u32_property(property_id::COLOR_VALUE, 0xFF00_0000);
let hex = format!(
"#{:02x}{:02x}{:02x}",
(color >> 16) & 0xFF,
(color >> 8) & 0xFF,
color & 0xFF
);
format!("<input{data_id}{event}{aria} type=\"color\" value=\"{hex}\"{style}>")
}
component_type::COMMENT => String::new(),
_ => String::new(),
};
if self.debug_comments {
format!("{}{}", debug::node_comment(id, node), element)
} else {
element
}
}
}
fn wrap_stack(
id: u32,
direction: &str,
semantic: Option<&'static str>,
node: &Node,
media_attr: &str,
children: &str,
css: &str,
event: &str,
aria: &str,
) -> String {
let tag = semantic.unwrap_or("div");
let alignment = node
.properties
.get(&property_id::ALIGNMENT)
.map(|bits| f32::from_bits(*bits) as u8)
.unwrap_or(3);
let align = match alignment {
0 => "flex-start",
1 => "center",
2 => "flex-end",
_ => "flex-start",
};
let combined = format!(
"display:flex;flex-direction:{direction};align-items:{align};{css}"
);
let stack_style = style_attr(&combined);
format!(
"<{tag} data-pathland-id=\"{id}\"{}{media_attr}{event}{aria}{stack_style}>{children}</{tag}>",
slot_attrs(node),
)
}
fn grid_style(node: &Node, columns: Option<u32>) -> String {
let align = node.f32_property(property_id::ALIGNMENT, 0.0) as u8;
let mut css = format!(
"display:grid;justify-items:{};align-items:{};",
align_h(align),
align_v(align)
);
let tracks_css = grid_tracks_css(node);
match node.component {
component_type::GRID => {
if let Some(t) = &tracks_css {
css.push_str(&format!("grid-template-columns:{t};"));
} else if let Some(n) = columns {
css.push_str(&format!("grid-template-columns:repeat({n},minmax(0,1fr));"));
}
if let Some(n) = grid_rows(node) {
css.push_str(&format!("grid-template-rows:repeat({n},minmax(0,1fr));"));
}
}
component_type::LAZY_HGRID => {
if let Some(t) = &tracks_css {
css.push_str(&format!("grid-template-rows:{t};"));
} else if let Some(n) = grid_rows(node) {
css.push_str(&format!("grid-template-rows:repeat({n},minmax(0,1fr));"));
}
css.push_str("grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);");
}
_ => {
if let Some(t) = &tracks_css {
css.push_str(&format!("grid-template-columns:{t};"));
} else if let Some(n) = columns {
css.push_str(&format!("grid-template-columns:repeat({n},minmax(0,1fr));"));
}
}
}
css
}
fn style_attr(css: &str) -> String {
if css.is_empty() {
String::new()
} else {
format!(" style=\"{css}\"")
}
}
fn class_attr(classes: &str) -> String {
let classes = classes.trim();
if classes.is_empty() {
String::new()
} else {
format!(" class=\"{classes}\"")
}
}
fn escape(input: &str) -> String {
input
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn slot_attrs(node: &Node) -> String {
let mut attrs = String::new();
if let Some(path) = node.strings.get(&property_id::ROUTE) {
attrs.push_str(&format!(" data-pathland-route=\"{}\"", escape(path)));
}
let chrome = node.f32_property(property_id::NAV_CHROME, 0.0).round() as u8;
if chrome == 1 {
attrs.push_str(" data-pathland-nav-chrome=\"custom\"");
}
let depth = node.u32_property(property_id::NAV_DEPTH, 1).max(1);
if depth > 1 {
attrs.push_str(&format!(" data-pathland-depth=\"{depth}\""));
}
let transition = node.f32_property(property_id::TRANSITION, 0.0).round() as u8;
if transition != 0 {
let name = match transition {
2 => "fade",
3 => "slide",
4 => "scale",
_ => "platform",
};
attrs.push_str(&format!(" data-pathland-transition=\"{name}\""));
}
attrs
}
#[cfg(test)]
mod tests {
use super::*;
use pathland_core::Opcode;
#[test]
fn renders_vstack_of_text() {
let mut opcodes = Vec::new();
let mut strings = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 0, 1, pathland_core::APPEND));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, pathland_core::APPEND));
strings.extend_from_slice(&(5u32).to_le_bytes());
strings.extend_from_slice(b"Hello");
opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 2, 0, 0));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &strings, 1);
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("flex-direction:column"), "stack inline flex");
assert!(html.contains("<span data-pathland-id=\"2\">Hello</span>"));
assert!(html.contains("data-pathland-id=\"1\""));
}
#[test]
fn escapes_text() {
assert_eq!(escape("<a & b>"), "<a & b>");
}
#[test]
fn renders_border() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
let mut strings = Vec::new();
opcodes.push(Opcode::new(
category::TREE,
tree::CREATE_NODE,
0,
1,
component_type::TEXT as u32,
0,
));
strings.extend_from_slice(&(5u32).to_le_bytes());
strings.extend_from_slice(b"Hello");
opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::F32 as u32) << 16) | property_id::BORDER_WIDTH as u32,
2.0f32.to_bits(),
));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::COLOR as u32) << 16) | property_id::BORDER_COLOR as u32,
0xFFFF_0000,
));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::U32 as u32) << 16) | property_id::BORDER_EDGES as u32,
border_edges::ALL,
));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &strings, 1);
assert!(html.contains("border-top:2px solid rgba(255,0,0,1)"));
assert!(html.contains("border-left:2px solid rgba(255,0,0,1)"));
assert!(html.contains("border-bottom:2px solid rgba(255,0,0,1)"));
assert!(html.contains("border-right:2px solid rgba(255,0,0,1)"));
}
#[test]
fn renders_toggle_switch_checked() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
let mut strings = Vec::new();
opcodes.push(Opcode::new(
category::TREE,
tree::CREATE_NODE,
0,
1,
component_type::TOGGLE as u32,
0,
));
strings.extend_from_slice(&(7u32).to_le_bytes());
strings.extend_from_slice(b"Enabled");
opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::F32 as u32) << 16) | property_id::TOGGLE_STYLE as u32,
0.0f32.to_bits(),
));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::U8 as u32) << 16) | property_id::SELECTED as u32,
1,
));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &strings, 1);
assert!(html.contains("<input type=\"checkbox\" role=\"switch\" checked>"));
assert!(html.contains("<span class=\"pathland-text\">Enabled</span>"));
}
#[test]
fn renders_toggle_checkbox_unchecked() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
let mut strings = Vec::new();
opcodes.push(Opcode::new(
category::TREE,
tree::CREATE_NODE,
0,
1,
component_type::TOGGLE as u32,
0,
));
strings.extend_from_slice(&(5u32).to_le_bytes());
strings.extend_from_slice(b"Email");
opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::F32 as u32) << 16) | property_id::TOGGLE_STYLE as u32,
1.0f32.to_bits(),
));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::U8 as u32) << 16) | property_id::SELECTED as u32,
0,
));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &strings, 1);
assert!(html.contains("<input type=\"checkbox\">"));
assert!(!html.contains("<input type=\"checkbox\" role=\"switch\""));
assert!(html.contains("<span class=\"pathland-text\">Email</span>"));
}
#[test]
fn renders_toggle_button_pressed() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
let mut strings = Vec::new();
opcodes.push(Opcode::new(
category::TREE,
tree::CREATE_NODE,
0,
1,
component_type::TOGGLE as u32,
0,
));
strings.extend_from_slice(&(4u32).to_le_bytes());
strings.extend_from_slice(b"Mute");
opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::F32 as u32) << 16) | property_id::TOGGLE_STYLE as u32,
2.0f32.to_bits(),
));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::U8 as u32) << 16) | property_id::SELECTED as u32,
1,
));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &strings, 1);
assert!(html.contains("<button") && html.contains("type=\"button\""));
assert!(html.contains("aria-pressed=\"true\""));
assert!(html.contains("<span class=\"pathland-text\">Mute</span>"));
assert!(html.contains("</button>"));
}
#[test]
fn renders_slider() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
let mut strings = Vec::new();
opcodes.push(Opcode::new(
category::TREE,
tree::CREATE_NODE,
0,
1,
component_type::SLIDER as u32,
0,
));
strings.extend_from_slice(&(6u32).to_le_bytes());
strings.extend_from_slice(b"Volume");
opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::F32 as u32) << 16) | property_id::MIN_VALUE as u32,
0.0f32.to_bits(),
));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::F32 as u32) << 16) | property_id::MAX_VALUE as u32,
1.0f32.to_bits(),
));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::F32 as u32) << 16) | property_id::VALUE as u32,
0.5f32.to_bits(),
));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &strings, 1);
assert!(html.contains(
"<input type=\"range\" min=\"0\" max=\"1\" step=\"any\" value=\"0.5\">"
));
assert!(html.contains("<span class=\"pathland-text\">Volume</span>"));
}
#[test]
fn renders_text_field() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
let mut strings = Vec::new();
opcodes.push(Opcode::new(
category::TREE,
tree::CREATE_NODE,
0,
1,
component_type::TEXT_FIELD as u32,
0,
));
strings.extend_from_slice(&(3u32).to_le_bytes());
strings.extend_from_slice(b"Bob");
opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
strings.extend_from_slice(&(5u32).to_le_bytes());
strings.extend_from_slice(b"Name:");
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::STRING as u32) << 16) | property_id::LABEL as u32,
7, ));
strings.extend_from_slice(&(5u32).to_le_bytes());
strings.extend_from_slice(b"Enter");
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::STRING as u32) << 16) | property_id::PROMPT as u32,
16, ));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &strings, 1);
assert!(html.contains("class=\"pathland-textfield\""));
assert!(html.contains("<span class=\"pathland-label\">Name:</span>"));
assert!(html.contains(
"<input class=\"pathland-input\" type=\"text\" value=\"Bob\" placeholder=\"Enter\">"
));
}
#[test]
fn applies_network_decoded_frame() {
use pathland_core_transport::encode_frame;
let mut opcodes = Vec::new();
let mut strings = Vec::new();
opcodes.push(Opcode::new(
category::TREE,
tree::CREATE_NODE,
0,
1,
component_type::TEXT as u32,
0,
));
strings.extend_from_slice(&(5u32).to_le_bytes());
strings.extend_from_slice(b"Hello");
opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
let bytes = encode_frame(&opcodes, &strings);
assert!(bytes.len() > 16);
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &strings, 1);
assert!(html.contains("<span data-pathland-id=\"1\">Hello</span>"));
}
#[test]
fn days_to_date_maps_epoch_and_pre_epoch() {
assert_eq!(days_to_date(0), (1970, 1, 1));
assert_eq!(days_to_date(19_723), (2024, 1, 1));
assert_eq!(days_to_date(-1), (1969, 12, 31));
assert_eq!(days_to_date(-365), (1969, 1, 1));
}
#[test]
fn renders_image_with_source() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
let mut strings = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::IMAGE as u32, 0));
strings.extend_from_slice(&(15u32).to_le_bytes());
strings.extend_from_slice(b"assets/logo.png");
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::STRING as u32) << 16) | property_id::IMAGE_SOURCE as u32,
0,
));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &strings, 1);
assert!(html.contains("<img data-pathland-id=\"1\" src=\"assets/logo.png\" alt=\"\">"));
}
#[test]
fn renders_semantic_icon_with_filled_remix_markup() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
let mut strings = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::ICON as u32, 0));
strings.extend_from_slice(&(4u32).to_le_bytes());
strings.extend_from_slice(b"play");
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::STRING as u32) << 16) | property_id::ICON_NAME as u32,
0,
));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &strings, 1);
assert!(html.contains("<svg class=\"pathland-icon\""), "icon svg shell: {}", html);
assert!(html.contains("fill=\"currentColor\""), "filled shell: {}", html);
assert!(!html.contains("stroke="), "no stroke on a filled icon: {}", html);
assert!(html.contains("data-pathland-id=\"1\""), "icon keeps its node id: {}", html);
assert!(html.contains("aria-hidden=\"true\""), "decorative by default: {}", html);
assert!(html.contains("d=\"M19.376 12.4161L8.77735 19.4818"), "play remix path: {}", html);
assert!(html.ends_with("</svg>") || html.contains("</svg>"), "svg closed: {}", html);
}
#[test]
fn labeled_icon_is_presentable_and_unknown_name_falls_back() {
use pathland_core::value_type;
let icon = |name: &str, label: Option<&str>| -> String {
let mut opcodes = Vec::new();
let mut strings = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::ICON as u32, 0));
let name_offset = 0usize;
strings.extend_from_slice(&(name.len() as u32).to_le_bytes());
strings.extend_from_slice(name.as_bytes());
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::STRING as u32) << 16) | property_id::ICON_NAME as u32,
name_offset as u32,
));
let label_offset = 4usize + name.len();
if let Some(label) = label {
strings.extend_from_slice(&(label.len() as u32).to_le_bytes());
strings.extend_from_slice(label.as_bytes());
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::STRING as u32) << 16) | property_id::LABEL as u32,
label_offset as u32,
));
}
HtmlRenderer::new().render_document(&opcodes, &strings, 1)
};
let labeled = icon("music", Some("Playlist"));
assert!(labeled.contains("role=\"img\""), "labeled icon is an image: {}", labeled);
assert!(labeled.contains("aria-label=\"Playlist\""), "aria label: {}", labeled);
assert!(!labeled.contains("aria-hidden"), "no aria-hidden when labeled: {}", labeled);
let unknown = icon("definitely-not-an-icon", None);
assert!(unknown.contains("aria-hidden=\"true\""), "unlabeled stays decorative: {}", unknown);
assert!(unknown.contains("cx=\"12\" cy=\"12\" r=\"8\""), "fallback glyph: {}", unknown);
}
#[test]
fn renders_media_and_image_alt() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
let mut strings = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::IMAGE as u32, 0));
strings.extend_from_slice(&(32u32).to_le_bytes());
strings.extend_from_slice(b"/_pathland/assets/icons/home.svg");
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::STRING as u32) << 16) | property_id::IMAGE_SOURCE as u32,
0,
));
strings.extend_from_slice(&(4u32).to_le_bytes());
strings.extend_from_slice(b"Home");
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::STRING as u32) << 16) | property_id::LABEL as u32,
36,
));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::F32 as u32) << 16) | property_id::CONTENT_MODE as u32,
1f32.to_bits(),
));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::VIDEO as u32, 0));
strings.extend_from_slice(&(30u32).to_le_bytes());
strings.extend_from_slice(b"https://example.com/sample.mp4");
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
2,
((value_type::STRING as u32) << 16) | property_id::VIDEO_SOURCE as u32,
44,
));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::AUDIO as u32, 0));
strings.extend_from_slice(&(30u32).to_le_bytes());
strings.extend_from_slice(b"https://example.com/sample.mp3");
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
3,
((value_type::STRING as u32) << 16) | property_id::AUDIO_SOURCE as u32,
78,
));
let renderer = HtmlRenderer::new();
let img = renderer.render_fragment(&opcodes, &strings, 1);
assert!(img.contains("alt=\"Home\""), "image alt from LABEL");
assert!(img.contains("object-fit:cover;"), "ContentMode Fill -> cover");
let video = renderer.render_fragment(&opcodes, &strings, 2);
assert!(video.contains("<video data-pathland-id=\"2\" src=\"https://example.com/sample.mp4\" controls"), "video native controls");
let audio = renderer.render_fragment(&opcodes, &strings, 3);
assert!(audio.contains("<audio data-pathland-id=\"3\" src=\"https://example.com/sample.mp3\" controls"), "audio native controls");
}
#[test]
fn custom_controls_media_preserves_container_with_hidden_audio() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::BUTTON as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
let mut strings = Vec::new();
strings.extend_from_slice(&(29u32).to_le_bytes());
strings.extend_from_slice(b"https://example.com/track.mp3");
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::STRING as u32) << 16) | property_id::AUDIO_SOURCE as u32,
0,
));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::U32 as u32) << 16) | property_id::PLAYBACK_STATE as u32,
1,
));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::F32 as u32) << 16) | property_id::MEDIA_POSITION as u32,
12.5f32.to_bits(),
));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::F32 as u32) << 16) | property_id::MEDIA_VOLUME as u32,
0.5f32.to_bits(),
));
let renderer = HtmlRenderer::new();
let html = renderer.render_fragment(&opcodes, &strings, 1);
assert!(
html.contains("data-pathland-id=\"1\" data-pathland-media"),
"the container is marked app-driven: {html}"
);
assert!(
html.contains("display:flex;flex-direction:column"),
"the VStack body keeps its flex layout: {html}"
);
assert!(
html.contains("<audio src=\"https://example.com/track.mp3\" data-pathland-media></audio>"),
"the container holds a hidden, control-less audio element"
);
assert!(
!html.contains("controls"),
"the injected media element has no native controls"
);
assert!(html.contains("<button"), "the custom control children render");
let debug = renderer.with_debug_comments(true).render_fragment(&opcodes, &strings, 1);
assert!(debug.contains("<!-- #1 VStack:"), "debug comment names the VStack: {debug}");
}
#[test]
fn wrapper_media_node_keeps_audio_component_with_custom_children() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::AUDIO as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::HSTACK as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::BUTTON as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 2, 3, 0));
let mut strings = Vec::new();
strings.extend_from_slice(&(29u32).to_le_bytes());
strings.extend_from_slice(b"https://example.com/track.mp3");
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::STRING as u32) << 16) | property_id::AUDIO_SOURCE as u32,
0,
));
let renderer = HtmlRenderer::new();
let html = renderer.render_fragment(&opcodes, &strings, 1);
assert!(
html.contains("data-pathland-id=\"1\" data-pathland-media"),
"the AUDIO shell carries the app-driven media marker: {html}"
);
assert!(
html.contains("class=\"pathland-media\""),
"the AUDIO node renders the media wrapper, not a bare <audio>: {html}"
);
assert!(
html.contains("<audio src=\"https://example.com/track.mp3\" data-pathland-media></audio>"),
"a hidden, control-less audio is injected first: {html}"
);
assert!(
html.contains("data-pathland-id=\"2\""),
"the style body (control children) renders inside the wrapper: {html}"
);
}
#[test]
fn renders_grid_with_columns() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::GRID as u32, 0));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::F32 as u32) << 16) | property_id::GRID_COLUMNS as u32,
2.0f32.to_bits(),
));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &[], 1);
assert!(html.contains("grid-template-columns:repeat(2,minmax(0,1fr))"), "grid inline");
assert!(!html.contains("width:2px"), "GRID_COLUMNS is a count, never a pixel box: {html}");
assert!(html.contains("justify-items:start;align-items:start"), "default cell alignment");
assert!(html.contains("justify-self:start;align-self:start;"), "cell wrapper positions per ALIGNMENT");
}
#[test]
fn grid_rows_and_lazy_hgrid_tracks() {
use pathland_core::value_type;
use pathland_core::size;
let build = |component: u16, columns: f32, rows: f32| {
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component as u32, 0));
if columns != pathland_core::size::HUG_CONTENT {
opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 1,
((value_type::F32 as u32) << 16) | property_id::GRID_COLUMNS as u32, columns.to_bits()));
}
if rows != pathland_core::size::HUG_CONTENT {
opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 1,
((value_type::F32 as u32) << 16) | property_id::GRID_ROWS as u32, rows.to_bits()));
}
let renderer = HtmlRenderer::new();
renderer.render_document(&opcodes, &[], 1)
};
let g = build(component_type::GRID, 2.0, 3.0);
assert!(g.contains("grid-template-columns:repeat(2,minmax(0,1fr))"), "grid columns: {g}");
assert!(g.contains("grid-template-rows:repeat(3,minmax(0,1fr))"), "grid rows: {g}");
assert!(!g.contains("width:2px") && !g.contains("height:3px"), "counts never pixels: {g}");
let h = build(component_type::LAZY_HGRID, 2.0, 3.0);
assert!(h.contains("grid-template-rows:repeat(3,minmax(0,1fr))"), "hgrid rows: {h}");
assert!(h.contains("grid-auto-flow:column;grid-auto-columns:minmax(0,1fr)"), "hgrid auto columns: {h}");
assert!(!h.contains("grid-template-columns:repeat(2,minmax(0,1fr))"), "hgrid ignores GRID_COLUMNS: {h}");
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::GRID as u32, 0));
opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 1,
((value_type::F32 as u32) << 16) | property_id::GRID_COLUMNS as u32, size::FILL.to_bits()));
opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 1,
((value_type::F32 as u32) << 16) | property_id::WIDTH as u32, size::FILL.to_bits()));
let renderer = HtmlRenderer::new();
let f = renderer.render_document(&opcodes, &[], 1);
assert!(!f.contains("grid-template-columns:repeat"), "FILL count = auto-fit: {f}");
assert!(f.contains("width:100%"), "FILL WIDTH frame expands: {f}");
}
#[test]
fn grid_rows_flatten_explicit_rows() {
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::GRID as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::GRID_ROW as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::TEXT as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 4, component_type::TEXT as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 5, component_type::GRID_ROW as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 6, component_type::TEXT as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 7, component_type::TEXT as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, u32::MAX));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 2, 3, u32::MAX));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 2, 4, u32::MAX));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 5, u32::MAX));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 5, 6, u32::MAX));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 7, u32::MAX));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &[], 1);
assert!(html.contains("grid-template-columns:repeat(2,minmax(0,1fr))"), "widest row defines columns: {html}");
assert!(html.contains("grid-row:1;grid-column:1;"), "cell at (0,0): {html}");
assert!(html.contains("grid-row:1;grid-column:2;"), "cell at (0,1): {html}");
assert!(html.contains("grid-row:2;grid-column:1;"), "short row's cell at (1,0): {html}");
assert!(html.contains("grid-row:3;grid-column:1;"), "bare cell auto-flows to row 2: {html}");
assert!(!html.contains("GRID_ROW"), "GRID_ROW is structural: {html}");
}
#[test]
fn grid_tracks_render_per_track_template() {
use pathland_core::value_type;
let mut strings = Vec::new();
strings.extend_from_slice(&(25u32).to_le_bytes());
strings.extend_from_slice(b"flex,fixed:80,adaptive:50");
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::GRID as u32, 0));
opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 1,
((value_type::STRING as u32) << 16) | property_id::GRID_TRACKS as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, u32::MAX));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &strings, 1);
assert!(html.contains("grid-template-columns:1fr 80px minmax(50px,1fr);"), "track spec: {html}");
assert!(!html.contains("grid-template-columns:repeat"), "tracks override the count: {html}");
}
#[test]
fn zstack_2d_alignment_splits_h_and_v() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::ZSTACK as u32, 0));
opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 1,
((value_type::F32 as u32) << 16) | property_id::ALIGNMENT as u32, 7.0f32.to_bits()));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, u32::MAX));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &[], 1);
assert!(html.contains("justify-self:end;align-self:start;"), "topTrailing splits h/v: {html}");
}
#[test]
fn grid_cells_stretch_when_fill_or_greedy() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::GRID as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 2,
((value_type::F32 as u32) << 16) | property_id::WIDTH as u32, pathland_core::size::FILL.to_bits()));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::COLOR as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 4, component_type::TEXT as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, u32::MAX));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 3, u32::MAX));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 4, u32::MAX));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &[], 1);
assert!(html.contains("justify-self:stretch;align-self:stretch;"), "greedy COLOR stretches: {html}");
assert!(html.contains("justify-self:stretch;align-self:start;"), "FILL-width cell: {html}");
assert!(html.contains("justify-self:start;align-self:start;"), "Hug cell positioned: {html}");
}
#[test]
fn scrollview_renders_only_the_first_child() {
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::SCROLLVIEW as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::TEXT as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, u32::MAX));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 3, u32::MAX));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &[], 1);
assert!(html.contains("data-pathland-id=\"2\""), "first child is the content: {html}");
assert!(!html.contains("data-pathland-id=\"3\""), "extra children not rendered: {html}");
}
#[test]
fn renders_scrollview_and_zstack_containers() {
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::SCROLLVIEW as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::ZSTACK as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 4, component_type::TEXT as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 3, 4, 0));
let renderer = HtmlRenderer::new();
let scroll = renderer.render_document(&opcodes, &[], 1);
assert!(scroll.contains("overflow:auto"), "scrollview inline");
assert!(scroll.contains("<span data-pathland-id=\"2\"></span>"));
let zstack = renderer.render_document(&opcodes, &[], 3);
assert!(zstack.contains("display:grid"), "zstack grid inline");
assert!(zstack.contains("isolation:isolate"), "zstack scopes shell z-indexes: {zstack}");
assert!(zstack.contains("width:max-content"), "zstack hugs to its largest child");
assert!(zstack.contains("grid-area:1/1;z-index:0;"), "shell carries an explicit default z-index: {zstack}");
assert!(zstack.contains("justify-self:start"), "zstack child positioned per ALIGNMENT");
assert!(zstack.contains("grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr);"),
"zstack tracks shrink below content min-height (minmax(0,1fr)): {zstack}");
}
#[test]
fn zstack_child_z_index_lands_on_the_shell() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::ZSTACK as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::TEXT as u32, 0));
opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 2,
((value_type::F32 as u32) << 16) | property_id::Z_INDEX as u32, 5.0f32.to_bits()));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, u32::MAX));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 3, u32::MAX));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &[], 1);
assert!(html.contains("grid-area:1/1;z-index:5;"), "z-index on the child shell: {html}");
assert!(html.contains("z-index:5"), "the child keeps its own z-index: {html}");
assert!(html.contains("grid-area:1/1;z-index:0;"), "sibling shell defaults to 0: {html}");
}
#[test]
fn renders_progress_view_and_spinner() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::PROGRESS_VIEW as u32, 0));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::F32 as u32) << 16) | property_id::PROGRESS as u32,
0.5f32.to_bits(),
));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::PROGRESS_VIEW as u32, 0));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
2,
((value_type::U8 as u32) << 16) | property_id::IS_INDETERMINATE as u32,
1,
));
let renderer = HtmlRenderer::new();
assert!(renderer.render_document(&opcodes, &[], 1).contains("<progress"));
assert!(renderer.render_document(&opcodes, &[], 2).contains("pathland-spinner"));
}
#[test]
fn renders_date_picker_from_set_date() {
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::DATE_PICKER as u32, 0));
opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_DATE, 0, 1, 19_723, 0));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &[], 1);
assert!(html.contains("<input data-pathland-id=\"1\" type=\"date\" value=\"2024-01-01\">"));
}
#[test]
fn renders_picker_from_child_options() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::PICKER as u32, 0));
let mut strings = Vec::new();
strings.extend_from_slice(&(3u32).to_le_bytes());
strings.extend_from_slice(b"Red");
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 2, 0, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::U32 as u32) << 16) | property_id::SELECTION as u32,
0,
));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &strings, 1);
assert!(html.contains("<select"));
assert!(html.contains("<option data-pathland-id=\"2\" value=\"0\" selected>Red</option>"));
}
#[test]
fn renders_color_picker_and_color_node() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::COLOR_PICKER as u32, 0));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::COLOR as u32) << 16) | property_id::COLOR_VALUE as u32,
0xFF00_00FF,
));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::COLOR as u32, 0));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
2,
((value_type::COLOR as u32) << 16) | property_id::COLOR as u32,
0xFF00_00FF,
));
let renderer = HtmlRenderer::new();
assert!(renderer.render_document(&opcodes, &[], 1).contains("<input data-pathland-id=\"1\" type=\"color\" value=\"#0000ff\">"));
assert!(renderer.render_document(&opcodes, &[], 2).contains("background-color:rgba(0,0,255,1)"));
}
#[test]
fn button_composite_renders_custom_body() {
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::BUTTON as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
let mut strings = Vec::new();
strings.extend_from_slice(&(1u32).to_le_bytes());
strings.extend_from_slice(b"A");
opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 2, 0, 0));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &strings, 1);
assert!(html.contains("<button data-pathland-id=\"1\" class=\"pathland-button\">"), "button uses the design-system class");
assert!(html.contains("<span data-pathland-id=\"2\">A</span>"));
}
#[test]
fn line_limit_clamps_text_to_n_lines() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::TEXT as u32, 0));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::U32 as u32) << 16) | property_id::LINE_LIMIT as u32,
1,
));
let mut strings = Vec::new();
strings.extend_from_slice(&(2u32).to_le_bytes());
strings.extend_from_slice(b"Hi");
opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
assert!(
html.contains("style=\"display:-webkit-box;overflow:hidden;-webkit-line-clamp:1;-webkit-box-orient:vertical;\""),
"a positive LINE_LIMIT clamps the text to N lines: {html}"
);
let mut no_limit = Vec::new();
no_limit.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::TEXT as u32, 0));
no_limit.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
assert!(
!HtmlRenderer::new()
.render_document(&no_limit, &strings, 1)
.contains("line-clamp"),
"no LINE_LIMIT means unlimited (no clamp)"
);
}
#[test]
fn content_fitting_contract_c7_c10() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, pathland_core::APPEND));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
2,
((value_type::F32 as u32) << 16) | property_id::WIDTH as u32,
120.0f32.to_bits(),
));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
2,
((value_type::U32 as u32) << 16) | property_id::LINE_LIMIT as u32,
2,
));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
2,
((value_type::F32 as u32) << 16) | property_id::TRUNCATION_MODE as u32,
0.0f32.to_bits(),
));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
2,
((value_type::U8 as u32) << 16) | property_id::CLIPS_TO_BOUNDS as u32,
1,
));
let html = HtmlRenderer::new().render_document(&opcodes, &[], 1);
assert!(html.contains("width:120px"), "C7 fixed-width wraps: {html}");
assert!(html.contains("line-clamp:2"), "C8 LINE_LIMIT clamps: {html}");
assert!(html.contains("overflow:hidden"), "C10 clips to bounds: {html}");
assert!(!html.contains("white-space:nowrap"), "C9 no forced single line");
assert!(!html.contains("text-overflow"), "C9 no standalone ellipsis");
let mut trunc_alone = Vec::new();
trunc_alone.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::TEXT as u32, 0));
trunc_alone.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::F32 as u32) << 16) | property_id::TRUNCATION_MODE as u32,
0.0f32.to_bits(),
));
let html = HtmlRenderer::new().render_document(&trunc_alone, &[], 1);
assert!(!html.contains("line-clamp"), "TRUNCATION alone does not clamp");
assert!(!html.contains("white-space:nowrap"), "TRUNCATION alone does not force single-line");
assert!(!html.contains("text-overflow"), "TRUNCATION alone emits no ellipsis");
}
#[test]
fn event_attrs_and_aria_are_emitted() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::BUTTON as u32, 0));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::U32 as u32) << 16) | property_id::EVENT_LISTENERS as u32,
pathland_core::listener::POINTER_DOWN | pathland_core::listener::POINTER_UP,
));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::U32 as u32) << 16) | property_id::ACTION_ID as u32,
42,
));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::U8 as u32) << 16) | property_id::ENABLED as u32,
0,
));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &[], 1);
assert!(html.contains("data-event-listeners=\"5\""));
assert!(html.contains("data-action-id=\"42\""));
assert!(html.contains(" disabled"));
}
#[test]
fn alignment_maps_to_flex_cross_axis() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::F32 as u32) << 16) | property_id::ALIGNMENT as u32,
0.0f32.to_bits(), ));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &[], 1);
assert!(html.contains("flex-direction:column"), "stack inline flex"); assert!(html.contains("align-items:flex-start"), "alignment inline");
}
#[test]
fn layout_default_cross_axis_is_hug_not_stretch() {
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &[], 1);
assert!(html.contains("align-items:flex-start"), "default is hug: {}", html);
assert!(!html.contains("align-items:stretch"), "no implicit stretch");
}
#[test]
fn layout_fill_alignment_is_hug_positioning() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::F32 as u32) << 16) | property_id::ALIGNMENT as u32,
3.0f32.to_bits(),
));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &[], 1);
assert!(html.contains("align-items:flex-start"), "Fill=3 → hug: {}", html);
}
#[test]
fn fit_slot_forwards_parent_alignment_into_its_box() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::F32 as u32) << 16) | property_id::ALIGNMENT as u32,
1.0f32.to_bits(),
));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::SIZE_THAT_FITS as u32, 0));
let mut strings = Vec::new();
strings.extend_from_slice(&2u32.to_le_bytes());
strings.extend_from_slice(&0.0f32.to_le_bytes());
strings.extend_from_slice(&640.0f32.to_le_bytes());
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
2,
((value_type::LIST as u32) << 16) | property_id::FIT_QUERY as u32,
0,
));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::HSTACK as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 2, 3, 0));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &strings, 1);
assert!(html.contains("align-items:center"), "parent VStack centers: {}", html);
assert!(html.contains("data-pathland-fit=\"0,640\""), "fit mirror rides the slot: {}", html);
let slot = "class=\"pathland-ftf\"";
let pos_before = html.find(slot).expect("slot div");
let snippet = &html[pos_before..html[pos_before..].find('>').unwrap() + pos_before + 1];
assert!(
snippet.contains("display:flex;")
&& snippet.contains("flex-direction:inherit;")
&& snippet.contains("align-items:inherit;")
&& snippet.contains("justify-content:inherit;")
&& snippet.contains("flex:1 1 auto;")
&& snippet.contains("align-self:stretch;")
&& snippet.contains("min-width:0;")
&& snippet.contains("min-height:0;"),
"slot forwards the parent flex settings while staying size-taking: {snippet}"
);
assert!(!snippet.contains("align-items:stretch;"), "forwarded, not stretch-only: {snippet}");
assert!(html.contains("display:flex;flex-direction:row;"), "the candidate HSTACK stays a row");
}
#[test]
fn snapshot_fit_slot_renders_all_candidates_with_container_query_rules() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::SIZE_THAT_FITS as u32, 0));
let mut strings = Vec::new();
strings.extend_from_slice(&2u32.to_le_bytes());
strings.extend_from_slice(&0.0f32.to_le_bytes());
strings.extend_from_slice(&640.0f32.to_le_bytes());
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::LIST as u32) << 16) | property_id::FIT_QUERY as u32,
0,
));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::HSTACK as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::VSTACK as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 3, 1));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &strings, 1);
assert!(html.contains("container-type:inline-size;"), "slot is a container: {}", html);
assert!(html.contains("container-name:pf-1;"), "slot has a unique container name: {}", html);
assert!(html.contains("data-pathland-fit-cands=\"2\""), "candidate count mirrors: {}", html);
assert!(html.contains("data-pathland-fit-cand=\"0\""), "candidate 0 wrapper: {}", html);
assert!(html.contains("data-pathland-fit-cand=\"1\""), "candidate 1 wrapper: {}", html);
assert!(html.contains("data-pathland-fit-cand-id=\"2\""), "wrapper carries candidate id 2: {}", html);
assert!(html.contains("data-pathland-fit-cand-id=\"3\""), "wrapper carries candidate id 3: {}", html);
assert!(html.contains("data-pathland-fit-cand=\"0\" data-pathland-fit-cand-id=\"2\" style=\"display:contents\""));
assert!(html.contains("data-pathland-fit-cand=\"1\" data-pathland-fit-cand-id=\"3\" style=\"display:none\""));
assert!(html.contains("<div data-pathland-id=\"2\" class=") || html.contains("<div data-pathland-id=\"2\""),
"candidate content keeps its id: {}", html);
let fit_style = html.find("<style data-pathland-fit>").expect("band rules style element");
let style_close = html[fit_style..].find("</style>").unwrap() + fit_style;
let rules = &html[fit_style..style_close];
assert!(rules.contains("@container pf-1 (min-width: 640px)"), "band rule for candidate 1: {rules}");
assert!(rules.contains("[data-pathland-id=\"1\"] > [data-pathland-fit-cand=\"0\"] { display: none !important; }"), "{rules}");
assert!(rules.contains("[data-pathland-id=\"1\"] > [data-pathland-fit-cand=\"1\"] { display: contents !important; }"), "{rules}");
assert!(!rules.contains("[data-pathland-fit-cand] { display: none"), "the hide selector never targets all candidates: {rules}");
let mut single = Vec::new();
single.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::SIZE_THAT_FITS as u32, 0));
single.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 3, 0));
single.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::VSTACK as u32, 0));
let html_single = renderer.render_document(&single, &strings, 1);
assert!(!html_single.contains("data-pathland-fit-cands"), "single-child form unchanged: {}", html_single);
assert!(!html_single.contains("data-pathland-fit-cand"), "single-child form unchanged: {}", html_single);
assert!(!html_single.contains("<style data-pathland-fit>"), "no band rules for a single child");
}
#[test]
fn snapshot_fit_slot_three_candidates_one_visible_ascending_bands() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::SIZE_THAT_FITS as u32, 0));
let mut strings = Vec::new();
strings.extend_from_slice(&3u32.to_le_bytes());
strings.extend_from_slice(&0.0f32.to_le_bytes());
strings.extend_from_slice(&600.0f32.to_le_bytes());
strings.extend_from_slice(&900.0f32.to_le_bytes());
opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 1, ((value_type::LIST as u32) << 16) | property_id::FIT_QUERY as u32, 0));
for (child, idx) in [(2u32, 0u32), (3, 1), (4, 2)] {
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, child, component_type::VSTACK as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, child, idx));
}
let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
assert!(html.contains("data-pathland-fit-cands=\"3\""), "{html}");
assert!(html.contains("data-pathland-fit-cand=\"0\" data-pathland-fit-cand-id=\"2\" style=\"display:contents\""), "{html}");
assert!(html.contains("data-pathland-fit-cand=\"1\" data-pathland-fit-cand-id=\"3\" style=\"display:none\""), "{html}");
assert!(html.contains("data-pathland-fit-cand=\"2\" data-pathland-fit-cand-id=\"4\" style=\"display:none\""), "{html}");
let fit_style = html.find("<style data-pathland-fit>").expect("band style element");
let close = html[fit_style..].find("</style>").unwrap() + fit_style;
let rules = &html[fit_style..close];
assert!(rules.contains("@container pf-1 (min-width: 600px)"), "{rules}");
assert!(rules.contains("@container pf-1 (min-width: 900px)"), "{rules}");
assert!(!rules.contains("min-width: 0px"), "no band for T_0: {rules}");
assert!(
rules.find("min-width: 600px").unwrap() < rules.find("min-width: 900px").unwrap(),
"bands ascending: {rules}"
);
assert!(rules.contains("[data-pathland-id=\"1\"] > [data-pathland-fit-cand=\"1\"] { display: contents !important; }"), "{rules}");
assert!(rules.contains("[data-pathland-id=\"1\"] > [data-pathland-fit-cand=\"0\"] { display: none !important; }"), "{rules}");
assert!(rules.contains("[data-pathland-id=\"1\"] > [data-pathland-fit-cand=\"2\"] { display: none !important; }"), "{rules}");
assert!(rules.contains("[data-pathland-id=\"1\"] > [data-pathland-fit-cand=\"2\"] { display: contents !important; }"), "{rules}");
assert!(!rules.contains("[data-pathland-fit-cand] { display: none"), "never a hide-all selector: {rules}");
}
#[test]
fn snapshot_fit_slot_invalid_table_degrades_to_fallback_child() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::SIZE_THAT_FITS as u32, 0));
let mut strings = Vec::new();
strings.extend_from_slice(&1u32.to_le_bytes());
strings.extend_from_slice(&0.0f32.to_le_bytes());
opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 1, ((value_type::LIST as u32) << 16) | property_id::FIT_QUERY as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::VSTACK as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::VSTACK as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 3, 1));
let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
assert!(!html.contains("data-pathland-fit-cands"), "no gating: {html}");
assert!(!html.contains("data-pathland-fit-cand"), "no wrappers: {html}");
assert!(!html.contains("<style data-pathland-fit>"), "no bands: {html}");
assert!(html.contains("data-pathland-id=\"2\""), "fallback child renders: {html}");
assert!(!html.contains("data-pathland-id=\"3\""), "invalid candidates are not rendered ungated: {html}");
}
#[test]
fn fixed_child_keeps_points_box_in_hug_container() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::IMAGE as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, pathland_core::APPEND));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
2,
((value_type::F32 as u32) << 16) | property_id::WIDTH as u32,
220.0f32.to_bits(),
));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
2,
((value_type::F32 as u32) << 16) | property_id::HEIGHT as u32,
220.0f32.to_bits(),
));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &[], 1);
assert!(html.contains("align-items:flex-start"), "hug container");
assert!(html.contains("width:220px;height:220px"), "exact box: {}", html);
}
#[test]
fn divider_spans_cross_axis_and_scrollview_is_greedy() {
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::DIVIDER as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, pathland_core::APPEND));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::SCROLLVIEW as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 3, pathland_core::APPEND));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &[], 1);
assert!(html.contains("width:100%"), "divider greedy: {}", html);
assert!(
html.contains("flex:1 1 auto;align-self:stretch;overflow:auto"),
"scrollview greedy: {}",
html
);
}
#[test]
fn fill_propagates_through_a_hug_stack() {
use pathland_core::{size, value_type};
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::BUTTON as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, pathland_core::APPEND));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
2,
((value_type::F32 as u32) << 16) | property_id::WIDTH as u32,
size::FILL.to_bits(),
));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &[], 1);
assert!(html.contains("width:100%"), "fill propagation: {}", html);
assert!(html.contains("align-self:stretch"), "cross-axis fill: {}", html);
let mut hug = Vec::new();
hug.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
hug.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
hug.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, pathland_core::APPEND));
let html_hug = renderer.render_document(&hug, &[], 1);
assert!(!html_hug.contains("width:100%"), "no propagation: {}", html_hug);
}
#[test]
fn text_field_secure_uses_password_input() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::TEXT_FIELD as u32, 0));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::U8 as u32) << 16) | property_id::IS_SECURE as u32,
1,
));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &[], 1);
assert!(html.contains("<input class=\"pathland-input\" type=\"password\""));
}
#[test]
fn style_css_applies_color_font_and_visibility() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::TEXT as u32, 0));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::COLOR as u32) << 16) | property_id::COLOR as u32,
0xFF11_2233,
));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::F32 as u32) << 16) | property_id::FONT_SIZE as u32,
18.0f32.to_bits(),
));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::U8 as u32) << 16) | property_id::VISIBLE as u32,
0,
));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &[], 1);
assert!(html.contains("color:rgba(17,34,51,1)"));
assert!(html.contains("font-size:18px"), "font-size inline (dp): {}", html);
assert!(html.contains("hidden"), "visible=0 -> hidden class");
}
#[test]
fn text_style_applies_default_typography_and_explicit_overrides_win() {
use pathland_core::text_style;
use pathland_core::value_type;
let render = |props: &[(u16, f32)]| {
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(
category::TREE,
tree::CREATE_NODE,
0,
1,
component_type::TEXT as u32,
0,
));
for (prop, value) in props {
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::F32 as u32) << 16) | *prop as u32,
value.to_bits(),
));
}
HtmlRenderer::new().render_document(&opcodes, &[], 1)
};
let html = render(&[(property_id::TEXT_STYLE, text_style::TITLE2 as f32)]);
assert!(html.contains("<h3"), "TITLE2 is a heading: {}", html);
assert!(html.contains("font-size:22px"), "style size: {}", html);
assert!(html.contains("font-weight:700"), "style weight: {}", html);
let html = render(&[
(property_id::TEXT_STYLE, text_style::TITLE2 as f32),
(property_id::FONT_SIZE, 11.0),
]);
assert!(html.contains("font-size:11px"), "explicit size wins: {}", html);
}
#[test]
fn style_css_expands_fill_hints_to_percent() {
use pathland_core::size;
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::TEXT as u32, 0));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::F32 as u32) << 16) | property_id::WIDTH as u32,
size::FILL.to_bits(),
));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::F32 as u32) << 16) | property_id::HEIGHT as u32,
size::FILL.to_bits(),
));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &[], 1);
assert!(html.contains("width:100%;height:100%;"), "FILL expands: {}", html);
}
#[test]
fn debug_comments_are_opt_in() {
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::F32 as u32) << 16) | property_id::SPACING as u32,
4.0f32.to_bits(),
));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::F32 as u32) << 16) | property_id::ALIGNMENT as u32,
3.0f32.to_bits(),
));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&opcodes, &[], 1);
assert!(!html.contains("<!--"), "comments off by default: {html}");
let renderer = HtmlRenderer::new().with_debug_comments(true);
assert!(renderer.debug_comments());
let html = renderer.render_document(&opcodes, &[], 1);
assert!(
html.contains("<!-- #1 VStack: spacing=4, alignment=Fill -->"),
"{html}"
);
assert!(html.contains("<!-- #2 Text -->"), "{html}");
}
#[test]
fn builtin_css_block_contains_reset_tokens_and_components() {
let css = css::STYLE;
assert!(css.contains("box-sizing: border-box"), "preflight reset present");
assert!(css.contains("--pl-color-primary"), "primary token");
assert!(css.contains("--pl-color-background"), "background token");
assert!(css.contains("--pl-color-surface"), "surface token");
assert!(css.contains("--pl-color-text-primary"), "text token");
assert!(css.contains("--pl-color-text-secondary"), "muted/secondary text token");
assert!(css.contains("--pl-color-border"), "border token");
assert!(css.contains("--pl-font-sans"), "font token");
assert!(css.contains(".pathland-button"), "button component");
assert!(css.contains(".pathland-toggle"), "toggle component");
assert!(css.contains(".pathland-spinner"), "spinner component");
assert!(css.contains(".pathland-gauge"), "gauge component");
assert!(css.contains("prefers-color-scheme: dark"), "dark mode");
assert!(css.contains("font-feature-settings: 'liga' 1, 'calt' 1"), "Chrome ligature fix");
assert!(css.contains("--pl-font-body-family: system-ui, -apple-system"), "system font stack");
assert!(!css.contains("Inter"), "no bundled/external font family");
assert!(css.contains("--pl-color-background"), "page background token");
assert!(css.contains("color-scheme: light dark"), "native form controls follow the theme");
assert!(css.contains(".pathland-input"), "input component");
assert!(css.contains("--pl-input-border"), "input border token");
assert!(css.contains("outline: 1px solid var(--pl-input-border)"), "inset outline instead of border");
assert!(css.contains("outline-offset: -1px"), "inset ring");
assert!(css.contains("--pl-input-focus"), "input focus token");
}
#[test]
fn builtin_css_covers_tier1_default_tokens() {
let css = css::STYLE;
assert!(css.contains("--pl-space-base"), "space.base token");
assert_eq!(count(css, "--pl-space-base"), 2, "space.base has a light + dark default");
for var in [
"--pl-font-body-size",
"--pl-font-body-weight",
"--pl-font-body-family",
] {
assert!(css.contains(var), "missing {var}");
}
for var in [
"--pl-control-background",
"--pl-control-foreground",
"--pl-control-border",
"--pl-control-border-width",
"--pl-control-radius",
"--pl-control-height",
"--pl-control-accent",
"--pl-button-background",
"--pl-button-background-hover",
"--pl-button-foreground",
"--pl-button-border",
"--pl-button-radius",
"--pl-button-padding",
"--pl-button-font-size",
"--pl-button-font-weight",
"--pl-input-background",
"--pl-input-foreground",
"--pl-input-placeholder",
"--pl-input-border",
"--pl-input-radius",
"--pl-input-font-size",
] {
assert!(css.contains(var), "missing canonical token {var}");
}
for var in [
"--pl-radius-xs",
"--pl-radius-sm",
"--pl-radius-md",
"--pl-radius-lg",
"--pl-radius-xl",
"--pl-radius-full",
"--pl-border-width-thin",
] {
assert!(css.contains(var), "missing {var}");
}
for var in [
"--pl-elevation-low-color",
"--pl-elevation-low-x",
"--pl-elevation-low-y",
"--pl-elevation-low-blur",
"--pl-elevation-high-color",
] {
assert!(css.contains(var), "missing {var}");
}
assert!(!css.contains("--pl-color-focus-ring"), "focus ring now maps to control.accent");
assert!(!css.contains("--pl-button-bg"), "internal button vars mapped onto button.*");
assert!(!css.contains("--pl-shadow-sm"), "shadow-sm mapped onto elevation.low.*");
}
#[test]
fn builtin_css_canonical_rules_reference_the_catalog() {
let css = css::STYLE;
assert!(css.contains("border-radius: var(--pl-button-radius)"), "button radius from button.*");
assert!(css.contains("background-color: var(--pl-button-background)"), "button bg from button.*");
assert!(css.contains("color: var(--pl-button-foreground)"), "button fg from button.*");
assert!(css.contains("background-color: var(--pl-input-background)"), "input bg from input.*");
assert!(css.contains("color: var(--pl-input-foreground)"), "input fg from input.*");
assert!(css.contains("outline: 2px solid var(--pl-control-accent)"), "focus ring uses control.accent");
assert!(css.contains("var(--pl-elevation-low-x)"), "button shadow uses elevation.low.*");
assert!(css.contains("var(--pl-elevation-high-x)"), "menu shadow uses elevation.high.*");
}
fn count(haystack: &str, needle: &str) -> usize {
haystack.matches(needle).count()
}
#[test]
fn document_head_loads_no_external_fonts() {
let renderer = HtmlRenderer::new();
let html = renderer.render_document(&[], &[], 0);
assert!(!html.contains("rsms.me"), "no font CDN link: {html}");
assert!(!html.contains("preconnect"), "no preconnect: {html}");
assert!(
!html.contains("<link rel=\"stylesheet\""),
"no external stylesheets (fonts are system-native): {html}"
);
let style = html.find("<style>").unwrap();
let body = html.find("<body>").unwrap();
assert!(style < body, "style block before body");
}
#[test]
fn renders_stack_with_gap_and_structural_child_inline() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
let mut strings = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::F32 as u32) << 16) | property_id::SPACING as u32,
12.0f32.to_bits(),
));
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
2,
((value_type::F32 as u32) << 16) | property_id::PADDING as u32,
8.0f32.to_bits(),
));
strings.extend_from_slice(&(2u32).to_le_bytes());
strings.extend_from_slice(b"Hi");
opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 2, 0, 0));
let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
assert!(html.contains("flex-direction:column"), "stack inline flex");
assert!(html.contains("gap:12px"), "spacing inline (dp): {}", html);
assert!(html.contains("padding:8px"), "padding inline (dp): {}", html);
}
#[test]
fn renders_decorative_typography_inline_and_enum_class() {
use pathland_core::value_type;
let mut opcodes = Vec::new();
let mut strings = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::TEXT as u32, 0));
for (prop, vt, value) in [
(property_id::FONT_SIZE, value_type::F32, 18.0f32.to_bits()),
(property_id::FONT_WEIGHT, value_type::F32, 700.0f32.to_bits()),
(property_id::TEXT_ALIGNMENT, value_type::F32, 1.0f32.to_bits()),
(property_id::BORDER_RADIUS, value_type::F32, 8.0f32.to_bits()),
(property_id::BLUR_RADIUS, value_type::F32, 3.0f32.to_bits()),
(property_id::ROTATION_DEGREES, value_type::F32, 90.0f32.to_bits()),
] {
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((vt as u32) << 16) | prop as u32,
value,
));
}
strings.extend_from_slice(&(2u32).to_le_bytes());
strings.extend_from_slice(b"Hi");
opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
assert!(html.contains("font-size:18px"), "font-size inline: {}", html);
assert!(html.contains("font-weight:700"), "font-weight inline");
assert!(html.contains("border-radius:8px"), "border-radius inline");
assert!(html.contains("blur(3px)"), "blur inline filter");
assert!(html.contains("rotate(90deg)"), "rotation inline transform");
assert!(html.contains("text-align:center"), "text-align inline");
}
fn string_section(entries: &[&str]) -> Vec<u8> {
let mut v = Vec::new();
for s in entries {
v.extend_from_slice(&(s.len() as u32).to_le_bytes());
v.extend_from_slice(s.as_bytes());
}
v
}
#[test]
fn set_design_token_emits_base_and_dark_override_css() {
let strings = string_section(&["color.primary", "dark.color.primary"]);
let dark_offset = (4 + 13) as u32; let opcodes = vec![
Opcode::new(category::PARAMETER, parameter::SET_DESIGN_TOKEN, 0, 0, value_type::COLOR as u32, 0xFF_2563EB),
Opcode::new(
category::PARAMETER,
parameter::SET_DESIGN_TOKEN,
0,
dark_offset,
value_type::COLOR as u32,
0xFF_60A5FA,
),
];
let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
assert!(html.contains(":root{--pl-color-primary:rgba(37,99,235,1);}"), "base override: {html}");
assert!(
html.contains("@media (prefers-color-scheme: dark){:root{--pl-color-primary:rgba(96,165,250,1);}}"),
"dark override scoped in media query: {html}"
);
}
#[test]
fn design_token_css_renders_inside_a_style_element() {
let strings = string_section(&["color.primary", "dark.color.primary"]);
let dark_offset = (4 + 13) as u32; let opcodes = vec![
Opcode::new(category::PARAMETER, parameter::SET_DESIGN_TOKEN, 0, 0, value_type::COLOR as u32, 0xFF_2563EB),
Opcode::new(
category::PARAMETER,
parameter::SET_DESIGN_TOKEN,
0,
dark_offset,
value_type::COLOR as u32,
0xFF_60A5FA,
),
];
let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
assert!(
html.contains("<style data-pathland-tokens>:root{--pl-color-primary:rgba(37,99,235,1);}"),
"base rule inside the token style element: {html}"
);
assert!(
html.contains("rgba(96,165,250,1);}}</style>"),
"token style element is closed after the dark media query: {html}"
);
assert!(
!html.contains("</style>:root") && !html.contains("</style>@media"),
"no bare token rules outside a style element: {html}"
);
let plain = HtmlRenderer::new().render_document(&[], &[], 0);
assert!(
!plain.contains("data-pathland-tokens"),
"no token style element without overrides: {plain}"
);
}
#[test]
fn set_design_token_registers_a_length_token_with_px() {
let strings = string_section(&["space.base"]);
let opcodes = vec![Opcode::new(
category::PARAMETER,
parameter::SET_DESIGN_TOKEN,
0,
0,
value_type::F32 as u32,
4.0f32.to_bits(),
)];
let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
assert!(html.contains(":root{--pl-space-base:4px;}"), "length token px: {html}");
}
#[test]
fn string_valued_design_token_emits_quoted_css() {
let strings = string_section(&["font.body.family", "Inter"]);
let opcodes = vec![Opcode::new(
category::PARAMETER,
parameter::SET_DESIGN_TOKEN,
0,
0,
value_type::STRING as u32,
20,
)];
let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
assert!(html.contains(":root{--pl-font-body-family:'Inter';}"), "string token: {html}");
}
#[test]
fn design_token_property_ref_renders_var() {
let strings = string_section(&["color.primary"]);
let opcodes = vec![
Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::TEXT as u32, 0),
Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::DESIGN_TOKEN as u32) << 16) | property_id::COLOR as u32,
0,
),
];
let html = HtmlRenderer::new().render_fragment(&opcodes, &strings, 1);
assert!(html.contains("color:var(--pl-color-primary);"), "token ref: {html}");
}
#[test]
fn generative_space_ref_renders_calc() {
let strings = string_section(&["space.2"]);
let opcodes = vec![
Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0),
Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
((value_type::DESIGN_TOKEN as u32) << 16) | property_id::SPACING as u32,
0,
),
];
let html = HtmlRenderer::new().render_fragment(&opcodes, &strings, 1);
assert!(
html.contains("gap:calc(var(--pl-space-base) * 2);"),
"generative space ref: {html}"
);
}
}