1use std::collections::BTreeMap;
18
19use pathland_core::{
20 Opcode, border_edges, category, component_type, parameter, property_id, size,
21 tokens::TokenValue, tree, value_type,
22};
23
24mod capi;
25mod css;
26mod debug;
27pub mod icons;
28pub mod role_spec;
29pub mod token_spec;
30
31fn text_style_scale(code: u8) -> Option<(f32, f32)> {
35 Some(match code {
36 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,
48 })
49}
50
51fn font_design_family(code: u8) -> Option<&'static str> {
54 match code {
55 1 => Some("serif"),
56 2 => Some("sans-serif"),
57 3 => Some("ui-monospace, SFMono-Regular, Menlo, monospace"),
58 _ => None,
59 }
60}
61
62#[derive(Debug, Clone, PartialEq)]
64pub struct Node {
65 pub component: u16,
67 pub text: Option<String>,
69 pub properties: BTreeMap<u16, u32>,
71 pub strings: BTreeMap<u16, String>,
73 pub token_refs: BTreeMap<u16, String>,
75 pub date: Option<(i32, u32)>,
77 pub children: Vec<u32>,
79 pub fit_thresholds: Vec<f32>,
83}
84
85fn fit_candidates_valid(node: &Node) -> bool {
92 node.children.len() > 1
93 && node.fit_thresholds.len() == node.children.len()
94 && node.fit_thresholds.windows(2).all(|w| w[0] <= w[1])
95}
96
97fn fit_band_styles(nodes: &BTreeMap<u32, Node>) -> String {
116 let mut rules = String::new();
117 for (id, node) in nodes {
118 if node.component != component_type::SIZE_THAT_FITS || !fit_candidates_valid(node) {
119 continue;
120 }
121 let n = node.children.len();
122 let mut seen = f32::NEG_INFINITY;
123 for (i, &t) in node.fit_thresholds.iter().enumerate() {
124 if i == 0 {
125 seen = t;
126 continue; }
128 if t <= seen {
129 continue; }
131 seen = t;
132 let mut band = format!("@container pf-{id} (min-width: {t}px) {{");
133 for j in 0..n {
134 if j == i {
135 continue; }
137 band.push_str(&format!(
138 " [data-pathland-id=\"{id}\"] > [data-pathland-fit-cand=\"{j}\"] {{ display: none !important; }}"
139 ));
140 }
141 band.push_str(&format!(
142 " [data-pathland-id=\"{id}\"] > [data-pathland-fit-cand=\"{i}\"] {{ display: contents !important; }} }} \n"
143 ));
144 rules.push_str(&band);
145 }
146 }
147 rules
148}
149
150impl Node {
151 fn new(component: u16) -> Self {
152 Self {
153 component,
154 text: None,
155 properties: BTreeMap::new(),
156 strings: BTreeMap::new(),
157 token_refs: BTreeMap::new(),
158 date: None,
159 children: Vec::new(),
160 fit_thresholds: Vec::new(),
161 }
162 }
163
164 fn checked(&self) -> bool {
166 self.properties
167 .get(&property_id::SELECTED)
168 .copied()
169 .unwrap_or(0)
170 != 0
171 }
172
173 fn role_code(&self) -> u8 {
176 self.properties
177 .get(&property_id::ROLE)
178 .map(|b| f32::from_bits(*b) as u8)
179 .unwrap_or(0)
180 }
181
182 fn text_style_code(&self) -> Option<u8> {
185 self.properties
186 .get(&property_id::TEXT_STYLE)
187 .map(|b| f32::from_bits(*b) as u8)
188 }
189
190 fn f32_property(&self, prop: u16, default: f32) -> f32 {
192 self.properties
193 .get(&prop)
194 .copied()
195 .map(f32::from_bits)
196 .unwrap_or(default)
197 }
198
199 fn content_margins(&self) -> Option<f32> {
201 self.properties
202 .get(&property_id::CONTENT_MARGINS)
203 .map(|bits| f32::from_bits(*bits))
204 }
205
206 fn u32_property(&self, prop: u16, default: u32) -> u32 {
208 self.properties.get(&prop).copied().unwrap_or(default)
209 }
210
211 fn token_ref(&self, prop: u16) -> Option<String> {
216 self.token_refs.get(&prop).map(|path| resolve_token_ref(path))
217 }
218
219 fn style_css(&self) -> String {
225 let mut css = String::new();
226 let f32p = |prop: u16| self.properties.get(&prop).map(|b| f32::from_bits(*b));
227 let px = |v: f32| format!("{v}px");
228
229 if let Some(v) = self.token_ref(property_id::COLOR) {
231 css.push_str(&format!("color:{v};"));
232 } else if let Some(bits) = self.properties.get(&property_id::COLOR) {
233 css.push_str(&format!("color:{};", rgba(*bits)));
234 }
235 if let Some(v) = self.token_ref(property_id::BACKGROUND_COLOR) {
236 css.push_str(&format!("background-color:{v};"));
237 } else if let Some(bits) = self.properties.get(&property_id::BACKGROUND_COLOR) {
238 css.push_str(&format!("background-color:{};", rgba(*bits)));
239 }
240 if let Some(bits) = self.properties.get(&property_id::FONT_DESIGN) {
241 if let Some(family) = font_design_family(f32::from_bits(*bits) as u8) {
242 css.push_str(&format!("font-family:{family};"));
243 }
244 }
245 if let Some(family) = self.strings.get(&property_id::FONT_FAMILY) {
246 css.push_str(&format!("font-family:'{}';", family.replace('\'', "\\'")));
247 }
248
249 if let Some(v) = self.token_ref(property_id::SPACING) {
251 css.push_str(&format!("gap:{v};"));
252 } else if let Some(v) = f32p(property_id::SPACING) {
253 if v > 0.0 {
254 css.push_str(&format!("gap:{};", px(v)));
255 }
256 }
257 if let Some(v) = self.token_ref(property_id::CONTENT_MARGINS) {
258 css.push_str(&format!("padding:{v};"));
259 } else if let Some(m) = self.content_margins() {
260 if m > 0.0 {
261 css.push_str(&format!("padding:{};", px(m)));
262 }
263 }
264 if let Some(v) = self.token_ref(property_id::PADDING) {
265 css.push_str(&format!("padding:{v};"));
266 } else if let Some(v) = f32p(property_id::PADDING) {
267 if v > 0.0 {
268 css.push_str(&format!("padding:{};", px(v)));
269 }
270 }
271 for (prop, side) in [
272 (property_id::PADDING_TOP, "padding-top"),
273 (property_id::PADDING_RIGHT, "padding-right"),
274 (property_id::PADDING_BOTTOM, "padding-bottom"),
275 (property_id::PADDING_LEFT, "padding-left"),
276 ] {
277 if let Some(v) = self.token_ref(prop) {
278 css.push_str(&format!("{side}:{v};"));
279 } else if let Some(v) = f32p(prop) {
280 if v > 0.0 {
281 css.push_str(&format!("{side}:{};", px(v)));
282 }
283 }
284 }
285 if let Some(v) = self.token_ref(property_id::WIDTH) {
290 css.push_str(&format!("width:{v};"));
291 } else if let Some(v) = f32p(property_id::WIDTH) {
292 if v != pathland_core::size::HUG_CONTENT {
293 css.push_str(&format!("width:{};", size_css(v)));
294 }
295 }
296 if let Some(v) = self.token_ref(property_id::HEIGHT) {
297 css.push_str(&format!("height:{v};"));
298 } else if let Some(v) = f32p(property_id::HEIGHT) {
299 if v != pathland_core::size::HUG_CONTENT {
300 css.push_str(&format!("height:{};", size_css(v)));
301 }
302 }
303 if let (Some(x), Some(y)) = (f32p(property_id::OFFSET_X), f32p(property_id::OFFSET_Y)) {
304 if x != 0.0 || y != 0.0 {
305 css.push_str(&format!("transform:translate({}px, {}px);", x, y));
306 }
307 } else {
308 if let Some(v) = f32p(property_id::POSITION_X) {
309 if v != 0.0 {
310 css.push_str(&format!("left:{};", px(v)));
311 }
312 }
313 if let Some(v) = f32p(property_id::POSITION_Y) {
314 if v != 0.0 {
315 css.push_str(&format!("top:{};", px(v)));
316 }
317 }
318 }
319
320 if let Some(style) = self.text_style_code() {
324 if let Some((size, weight)) = text_style_scale(style) {
325 css.push_str(&format!("font-size:{size}px;font-weight:{weight};"));
326 }
327 }
328 if let Some(v) = self.token_ref(property_id::FONT_SIZE) {
329 css.push_str(&format!("font-size:{v};"));
330 } else if let Some(v) = f32p(property_id::FONT_SIZE) {
331 if v > 0.0 {
332 css.push_str(&format!("font-size:{};", px(v)));
333 }
334 }
335 if let Some(v) = self.token_ref(property_id::FONT_WEIGHT) {
336 css.push_str(&format!("font-weight:{v};"));
337 } else if let Some(v) = f32p(property_id::FONT_WEIGHT) {
338 if v > 0.0 {
339 css.push_str(&format!("font-weight:{v};"));
340 }
341 }
342 if let Some(v) = self.token_ref(property_id::BORDER_RADIUS) {
343 css.push_str(&format!("border-radius:{v};"));
344 } else if let Some(v) = f32p(property_id::BORDER_RADIUS) {
345 if v != 0.0 {
346 css.push_str(&format!("border-radius:{};", px(v)));
347 }
348 }
349
350 let mut filters: Vec<String> = Vec::new();
352 if let Some(v) = f32p(property_id::BLUR_RADIUS) {
353 filters.push(format!("blur({}px)", v));
354 }
355 if let Some(v) = f32p(property_id::SATURATION) {
356 filters.push(format!("saturate({v})"));
357 }
358 if let Some(v) = f32p(property_id::CONTRAST) {
359 filters.push(format!("contrast({v})"));
360 }
361 if let Some(v) = f32p(property_id::BRIGHTNESS) {
362 filters.push(format!("brightness({v})"));
363 }
364 if let Some(v) = f32p(property_id::GRAYSCALE) {
365 filters.push(format!("grayscale({v})"));
366 }
367 if let Some(v) = f32p(property_id::HUE_ROTATION) {
368 filters.push(format!("hue-rotate({v}deg)"));
369 }
370 if !filters.is_empty() {
371 css.push_str(&format!("filter:{};", filters.join(" ")));
372 }
373 if let Some(v) = f32p(property_id::ROTATION_DEGREES) {
374 if v != 0.0 {
375 css.push_str(&format!("transform:rotate({v}deg);"));
376 }
377 }
378 if let Some(v) = f32p(property_id::SCALE) {
379 if v != 1.0 {
380 css.push_str(&format!("transform:scale({v});"));
381 }
382 }
383 if let (Some(r), Some(color_bits)) = (
385 f32p(property_id::SHADOW_RADIUS),
386 self.properties.get(&property_id::SHADOW_COLOR).copied(),
387 ) {
388 if r != 0.0 {
389 let x = f32p(property_id::SHADOW_X).unwrap_or(0.0);
390 let y = f32p(property_id::SHADOW_Y).unwrap_or(0.0);
391 let color = self
392 .token_ref(property_id::SHADOW_COLOR)
393 .unwrap_or_else(|| rgba(color_bits));
394 css.push_str(&format!("box-shadow:{x}px {y}px {r}px {color};"));
395 }
396 }
397 if let Some(v) = self.token_ref(property_id::OPACITY) {
398 css.push_str(&format!("opacity:{v};"));
399 } else if let Some(v) = f32p(property_id::OPACITY) {
400 if v < 1.0 {
401 css.push_str(&format!("opacity:{v};"));
402 }
403 }
404 if let Some(v) = f32p(property_id::Z_INDEX) {
405 css.push_str(&format!("z-index:{};", v as i32));
406 }
407
408 if let Some(v) = f32p(property_id::ALIGNMENT) {
415 if !matches!(
416 self.component,
417 component_type::ZSTACK
418 | component_type::GRID
419 | component_type::LAZY_VGRID
420 | component_type::LAZY_HGRID
421 ) {
422 css.push_str(&format!(
423 "align-items:{};",
424 match v as u8 {
425 0 => "flex-start",
426 1 => "center",
427 2 => "flex-end",
428 _ => "flex-start",
429 }
430 ));
431 }
432 }
433 if let Some(v) = f32p(property_id::TEXT_ALIGNMENT) {
435 css.push_str(&format!(
436 "text-align:{};",
437 match (v as u8).min(2) {
438 0 => "left",
439 1 => "center",
440 _ => "right",
441 }
442 ));
443 }
444 if let Some(v) = f32p(property_id::TEXT_CASE) {
446 css.push_str(&format!(
447 "text-transform:{};",
448 match v as u8 {
449 1 => "uppercase",
450 2 => "lowercase",
451 _ => "none",
452 }
453 ));
454 }
455 if let Some(bits) = self.properties.get(&property_id::VISIBLE) {
457 if *bits == 0 {
458 css.push_str("display:none;");
459 }
460 }
461 if let Some(v) = f32p(property_id::FONT_STYLE) {
463 if v != 0.0 {
464 css.push_str("font-style:italic;");
465 }
466 }
467 if let Some(v) = f32p(property_id::FONT_DESIGN) {
469 css.push_str(&format!(
470 "font-family:{};",
471 match v as u8 {
472 1 => "Georgia, 'Times New Roman', serif",
473 2 => "ui-rounded, system-ui, sans-serif",
474 3 => "ui-monospace, monospace",
475 _ => "inherit",
476 }
477 ));
478 }
479 let mut decoration = Vec::new();
481 if let Some(v) = f32p(property_id::UNDERLINE) {
482 if v != 0.0 {
483 decoration.push("underline");
484 }
485 }
486 if let Some(v) = f32p(property_id::STRIKETHROUGH) {
487 if v != 0.0 {
488 decoration.push("line-through");
489 }
490 }
491 if !decoration.is_empty() {
492 css.push_str(&format!("text-decoration:{};", decoration.join(" ")));
493 }
494 if let Some(v) = f32p(property_id::CLIPS_TO_BOUNDS) {
496 if v != 0.0 {
497 css.push_str("overflow:hidden;");
498 }
499 }
500 if let Some(v) = f32p(property_id::ALLOWS_HIT_TESTING) {
502 if v == 0.0 {
503 css.push_str("pointer-events:none;");
504 }
505 }
506 if let Some(v) = f32p(property_id::COLOR_INVERT) {
508 if v != 0.0 {
509 css.push_str("filter:invert(1);");
510 }
511 }
512 if let Some(v) = f32p(property_id::CONTENT_MODE) {
518 css.push_str(&format!(
519 "object-fit:{};",
520 if (v as u8) == 1 { "cover" } else { "contain" }
521 ));
522 }
523 if let Some(v) = f32p(property_id::ASPECT_RATIO) {
524 css.push_str(&format!("aspect-ratio:{v};"));
525 }
526
527 css
528 }
529
530 fn border_style(&self) -> String {
533 let width = match self.token_ref(property_id::BORDER_WIDTH) {
534 Some(v) => v,
535 None => {
536 let Some(width_bits) = self.properties.get(&property_id::BORDER_WIDTH) else {
537 return String::new();
538 };
539 format!("{}px", f32::from_bits(*width_bits))
540 }
541 };
542 let color = match self.token_ref(property_id::BORDER_COLOR) {
543 Some(v) => v,
544 None => rgba(
545 self.properties
546 .get(&property_id::BORDER_COLOR)
547 .copied()
548 .unwrap_or(0xFF00_0000),
549 ),
550 };
551 let edges = self
552 .properties
553 .get(&property_id::BORDER_EDGES)
554 .copied()
555 .unwrap_or(border_edges::ALL);
556
557 let mut css = String::new();
558 for (flag, side) in [
559 (border_edges::TOP, "top"),
560 (border_edges::LEADING, "left"),
561 (border_edges::BOTTOM, "bottom"),
562 (border_edges::TRAILING, "right"),
563 ] {
564 if edges & flag != 0 {
565 css.push_str(&format!("border-{side}:{width} solid {color};"));
566 }
567 }
568 css
569 }
570}
571
572fn unpack_color(argb: u32) -> (u32, u32, u32, f32) {
574 let r = (argb >> 16) & 0xFF;
575 let g = (argb >> 8) & 0xFF;
576 let b = argb & 0xFF;
577 let a = ((argb >> 24) & 0xFF) as f32 / 255.0;
578 (r, g, b, a)
579}
580
581fn rgba(argb: u32) -> String {
583 let (r, g, b, a) = unpack_color(argb);
584 format!("rgba({r},{g},{b},{a})")
585}
586
587fn size_css(v: f32) -> String {
591 if v == pathland_core::size::FILL {
592 "100%".to_string()
593 } else if v == pathland_core::size::HUG_CONTENT {
594 "fit-content".to_string()
595 } else {
596 format!("{v}px")
597 }
598}
599
600fn stack_main_horizontal(component: u16) -> Option<bool> {
603 match component {
604 component_type::HSTACK | component_type::LAZY_HSTACK => Some(true),
605 component_type::VSTACK | component_type::LAZY_VSTACK => Some(false),
606 _ => None,
607 }
608}
609
610fn fills_axis(
619 nodes: &BTreeMap<u32, Node>,
620 id: u32,
621 axis_horizontal: bool,
622 main_axis: bool,
623) -> bool {
624 let Some(node) = nodes.get(&id) else {
625 return false;
626 };
627 let prop = if axis_horizontal {
628 property_id::WIDTH
629 } else {
630 property_id::HEIGHT
631 };
632 let hint = node.properties.get(&prop).map(|b| f32::from_bits(*b));
633 match hint {
634 Some(v) if v == size::FILL => return true,
635 Some(v) if v.is_finite() && v > 0.0 => return false, _ => {}
637 }
638 let greedy = match node.component {
639 component_type::COLOR | component_type::SCROLLVIEW => true,
640 component_type::SPACER => main_axis,
641 component_type::DIVIDER => !main_axis,
642 _ => false,
643 };
644 if greedy {
645 return true;
646 }
647 if !matches!(
648 node.component,
649 component_type::VSTACK
650 | component_type::HSTACK
651 | component_type::ZSTACK
652 | component_type::LAZY_VSTACK
653 | component_type::LAZY_HSTACK
654 ) {
655 return false;
656 }
657 let child_main = stack_main_horizontal(node.component)
659 .map(|main_h| main_h == axis_horizontal)
660 .unwrap_or(false); node.children
662 .iter()
663 .any(|&child| fills_axis(nodes, child, axis_horizontal, child_main))
664}
665
666fn fill_propagation(
671 nodes: &BTreeMap<u32, Node>,
672 id: u32,
673 node: &Node,
674 main_horizontal: bool,
675) -> String {
676 let mut extra = String::new();
677 for (axis_h, prop, css) in [
678 (true, property_id::WIDTH, "width:100%;"),
679 (false, property_id::HEIGHT, "height:100%;"),
680 ] {
681 let hint = node.properties.get(&prop).map(|b| f32::from_bits(*b));
682 let hug = hint.is_none() || hint == Some(size::HUG_CONTENT);
683 if hug && fills_axis(nodes, id, axis_h, main_horizontal == axis_h) {
684 extra.push_str(css);
685 }
686 }
687 extra
688}
689fn grid_count(node: &Node, prop: u16) -> Option<u32> {
692 let v = node.f32_property(prop, -1.0);
693 if v <= 0.0 {
694 None
695 } else {
696 Some(v.round() as u32)
697 }
698}
699
700fn grid_columns(node: &Node) -> Option<u32> {
703 grid_count(node, property_id::GRID_COLUMNS)
704}
705
706fn grid_rows(node: &Node) -> Option<u32> {
709 grid_count(node, property_id::GRID_ROWS)
710}
711
712fn align_h(code: u8) -> &'static str {
715 match code {
716 1 | 3 | 4 => "center",
717 2 | 6 | 7 => "end",
718 _ => "start",
719 }
720}
721
722fn align_v(code: u8) -> &'static str {
724 match code {
725 1 | 5 | 6 => "center",
726 2 | 4 | 8 => "end",
727 _ => "start",
728 }
729}
730
731fn grid_tracks(node: &Node) -> Option<Vec<&str>> {
734 node.strings
735 .get(&property_id::GRID_TRACKS)
736 .map(|s| s.split(',').map(str::trim).collect())
737}
738
739fn grid_tracks_css(node: &Node) -> Option<String> {
742 let tracks = grid_tracks(node)?;
743 let mut out = String::new();
744 for t in tracks {
745 if !out.is_empty() {
746 out.push(' ');
747 }
748 if let Some(pts) = t.strip_prefix("fixed:") {
749 out.push_str(&format!("{pts}px"));
750 } else if let Some(pts) = t.strip_prefix("adaptive:") {
751 out.push_str(&format!("minmax({pts}px,1fr)"));
752 } else {
753 out.push_str("1fr");
754 }
755 }
756 Some(out)
757}
758
759fn days_to_date(days: i32) -> (i32, u32, u32) {
762 let z = i64::from(days) + 719_468;
763 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
764 let doe = z - era * 146_097;
765 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
766 let y = yoe + era * 400;
767 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
768 let mp = (5 * doy + 2) / 153;
769 let d = doy - (153 * mp + 2) / 5 + 1;
770 let m = if mp < 10 { mp + 3 } else { mp - 9 };
771 let y = if m <= 2 { y + 1 } else { y };
772 (y as i32, m as u32, d as u32)
773}
774
775fn millis_to_hms(millis: u32) -> (u32, u32, u32) {
777 let total = millis / 1000;
778 (total / 3600, (total % 3600) / 60, total % 60)
779}
780
781fn event_attrs(node: &Node) -> String {
784 let mut attrs = String::new();
785 if let Some(m) = node.properties.get(&property_id::EVENT_LISTENERS) {
786 if *m != 0 {
787 attrs.push_str(&format!(" data-event-listeners=\"{m}\""));
788 }
789 }
790 if let Some(a) = node.properties.get(&property_id::ACTION_ID) {
791 attrs.push_str(&format!(" data-action-id=\"{a}\""));
792 }
793 if let Some(b) = node.properties.get(&property_id::BINDING_ID) {
794 attrs.push_str(&format!(" data-binding-id=\"{b}\""));
795 }
796 attrs
797}
798
799fn aria_attrs(node: &Node) -> String {
805 let mut attrs = String::new();
806 if let Some(bits) = node.properties.get(&property_id::ROLE) {
807 let code = f32::from_bits(*bits) as u8;
808 let kind = role_spec::shell_kind(node.component);
809 if let Some(role) = role_spec::aria_role(kind, code) {
810 attrs.push_str(&format!(" role=\"{role}\""));
811 }
812 }
813 if let Some(bits) = node.properties.get(&property_id::STATE) {
814 match f32::from_bits(*bits) as u8 {
815 1 => attrs.push_str(" aria-disabled=\"true\""),
816 3 => attrs.push_str(" aria-pressed=\"true\""),
817 4 => attrs.push_str(" aria-selected=\"true\""),
818 5 => attrs.push_str(" aria-expanded=\"true\""),
819 6 => attrs.push_str(" aria-busy=\"true\""),
820 _ => {}
821 }
822 }
823 if let Some(bits) = node.properties.get(&property_id::ENABLED) {
824 if *bits == 0 {
825 attrs.push_str(" disabled");
826 }
827 }
828 attrs
829}
830
831fn decode(opcodes: &[Opcode], strings: &[u8]) -> (BTreeMap<u32, Node>, Tokens) {
835 let mut nodes = BTreeMap::new();
836 let mut tokens = Tokens::default();
837 for op in opcodes {
838 match op.category() {
839 category::TREE => apply_tree(&mut nodes, op.command(), *op),
840 category::PARAMETER => {
841 if op.command() == parameter::SET_DESIGN_TOKEN {
842 if let Some(path) = strings_str(strings, op.a()) {
845 let vt = (op.b() & 0xFF) as u8;
846 let value = if vt == value_type::STRING {
847 strings_str(strings, op.c())
848 .map(TokenValue::Str)
849 .unwrap_or(TokenValue::U32(0))
850 } else {
851 TokenValue::from_wire(vt, op.c()).unwrap_or(TokenValue::U32(op.c()))
852 };
853 tokens.set(&path, vt, value);
854 }
855 } else {
856 apply_style(&mut nodes, op.command(), *op, strings);
857 }
858 }
859 _ => {}
860 }
861 }
862 (nodes, tokens)
863}
864
865fn strings_str(strings: &[u8], offset: u32) -> Option<String> {
867 let offset = offset as usize;
868 if offset + 4 > strings.len() {
869 return None;
870 }
871 let len = u32::from_le_bytes(strings[offset..offset + 4].try_into().ok()?) as usize;
872 let start = offset + 4;
873 if start + len > strings.len() {
874 return None;
875 }
876 std::str::from_utf8(&strings[start..start + len]).ok().map(str::to_owned)
877}
878
879fn list_csv(strings: &[u8], offset: u32) -> Option<String> {
883 let offset = offset as usize;
884 if offset + 4 > strings.len() {
885 return None;
886 }
887 let count = u32::from_le_bytes(strings[offset..offset + 4].try_into().ok()?) as usize;
888 let start = offset + 4;
889 if start + count * 4 > strings.len() {
890 return None;
891 }
892 let mut parts = Vec::with_capacity(count);
893 for i in 0..count {
894 let bits = u32::from_le_bytes(
895 strings[start + i * 4..start + i * 4 + 4].try_into().ok()?,
896 );
897 let value = f32::from_bits(bits);
898 parts.push(if value.fract() == 0.0 {
900 format!("{:.0}", value)
901 } else {
902 format!("{:.1}", value)
903 });
904 }
905 Some(parts.join(","))
906}
907
908fn list_f32(strings: &[u8], offset: u32) -> Option<Vec<f32>> {
911 let offset = offset as usize;
912 if offset + 4 > strings.len() {
913 return None;
914 }
915 let count = u32::from_le_bytes(strings[offset..offset + 4].try_into().ok()?) as usize;
916 let start = offset + 4;
917 if start + count * 4 > strings.len() {
918 return None;
919 }
920 let mut values = Vec::with_capacity(count);
921 for i in 0..count {
922 let bits = u32::from_le_bytes(
923 strings[start + i * 4..start + i * 4 + 4].try_into().ok()?,
924 );
925 values.push(f32::from_bits(bits));
926 }
927 Some(values)
928}
929
930#[derive(Debug, Default, Clone)]
937struct Tokens {
938 base: BTreeMap<String, (u8, TokenValue)>,
939 dark: BTreeMap<String, (u8, TokenValue)>,
940}
941
942impl Tokens {
943 fn set(&mut self, path: &str, value_type: u8, value: TokenValue) {
944 if let Some(bare) = path.strip_prefix("dark.") {
945 self.dark.insert(bare.to_string(), (value_type, value));
946 } else {
947 self.base.insert(path.to_string(), (value_type, value));
948 }
949 }
950
951 fn css(&self) -> String {
955 let mut css = String::new();
956 for (path, (vt, value)) in &self.base {
957 css.push_str(&format!(
958 ":root{{{}:{};}}",
959 token_to_css_var(path),
960 token_css_value(path, *vt, value)
961 ));
962 }
963 if !self.dark.is_empty() {
964 let rules: String = self
965 .dark
966 .iter()
967 .map(|(path, (vt, value))| {
968 format!(":root{{{}:{};}}", token_to_css_var(path), token_css_value(path, *vt, value))
969 })
970 .collect();
971 css.push_str(&format!("@media (prefers-color-scheme: dark){{{rules}}}"));
972 }
973 css
974 }
975}
976
977fn token_to_css_var(path: &str) -> String {
982 let bare = path.strip_prefix(token_spec::DARK_PREFIX).unwrap_or(path);
983 let name: String = bare
984 .chars()
985 .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
986 .collect();
987 format!("{}{name}", token_spec::VAR_PREFIX)
988}
989
990fn is_length_token(path: &str) -> bool {
992 for prefix in token_spec::LENGTH_PREFIXES {
993 if path.starts_with(prefix) {
994 return true;
995 }
996 }
997 for (prefix, suffix) in token_spec::LENGTH_PREFIX_SUFFIX {
998 if path.starts_with(prefix) && path.ends_with(suffix) {
999 return true;
1000 }
1001 }
1002 if token_spec::LENGTH_EXACT.contains(&path) {
1003 return true;
1004 }
1005 path.starts_with("elevation.")
1006 && token_spec::LENGTH_ELEVATION_SUFFIXES
1007 .iter()
1008 .any(|s| path.ends_with(s))
1009}
1010
1011fn token_css_value(path: &str, _value_type: u8, value: &TokenValue) -> String {
1014 match value {
1015 TokenValue::Str(s) => format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")),
1016 TokenValue::Color(argb) => rgba(*argb),
1017 TokenValue::F32(v) => {
1018 if is_length_token(path) {
1019 format!("{v}px")
1020 } else {
1021 format!("{v}")
1022 }
1023 }
1024 TokenValue::I32(v) => format!("{v}"),
1025 TokenValue::U32(v) => format!("{v}"),
1026 TokenValue::U8(v) => format!("{v}"),
1027 }
1028}
1029
1030fn resolve_token_ref(path: &str) -> String {
1034 let bare = path.strip_prefix(token_spec::DARK_PREFIX).unwrap_or(path);
1035 if let Some(rest) = bare.strip_prefix(token_spec::SPACE_FAMILY) {
1036 if rest.parse::<f64>().is_ok() {
1037 return format!("calc(var(--pl-space-base) * {rest})");
1038 }
1039 }
1040 format!("var({})", token_to_css_var(bare))
1041}
1042
1043fn apply_tree(nodes: &mut BTreeMap<u32, Node>, command: u8, op: Opcode) {
1044 match command {
1045 tree::CREATE_NODE => {
1046 let id = op.a();
1047 let component = op.b() as u16;
1048 nodes.insert(id, Node::new(component));
1049 }
1050 tree::DELETE_NODE => {
1051 nodes.remove(&op.a());
1052 }
1053 tree::INSERT_CHILD => {
1054 let (parent, child) = (op.a(), op.b());
1055 if let Some(node) = nodes.get_mut(&parent) {
1056 let index = op.c();
1057 if index == pathland_core::APPEND {
1058 node.children.push(child);
1059 } else {
1060 let index = (index as usize).min(node.children.len());
1061 node.children.insert(index, child);
1062 }
1063 }
1064 }
1065 tree::REMOVE_CHILD => {
1066 let (parent, child) = (op.a(), op.b());
1067 if let Some(node) = nodes.get_mut(&parent) {
1068 node.children.retain(|&c| c != child);
1069 }
1070 }
1071 tree::MOVE_CHILD => {
1072 let (parent, child, index) = (op.a(), op.b(), op.c());
1073 if let Some(node) = nodes.get_mut(&parent) {
1074 node.children.retain(|&c| c != child);
1075 let index = (index as usize).min(node.children.len());
1076 node.children.insert(index, child);
1077 }
1078 }
1079 _ => {}
1080 }
1081}
1082
1083fn apply_style(nodes: &mut BTreeMap<u32, Node>, command: u8, op: Opcode, strings: &[u8]) {
1084 match command {
1085 parameter::SET_TEXT => {
1086 if let Some(text) = strings_str(strings, op.b()) {
1087 if let Some(node) = nodes.get_mut(&op.a()) {
1088 node.text = Some(text);
1089 }
1090 }
1091 }
1092 parameter::SET_PROPERTY => {
1093 let property = (op.b() & 0xFFFF) as u16;
1094 if let Some(node) = nodes.get_mut(&op.a()) {
1095 let vt = (op.b() >> 16) as u8;
1096 if vt == value_type::STRING {
1097 if let Some(text) = strings_str(strings, op.c()) {
1098 node.strings.insert(property, text);
1099 }
1100 } else if vt == value_type::DESIGN_TOKEN {
1101 if let Some(path) = strings_str(strings, op.c()) {
1104 node.token_refs.insert(property, path);
1105 }
1106 } else if vt == value_type::LIST {
1107 if let Some(csv) = list_csv(strings, op.c()) {
1112 node.strings.insert(property, csv);
1113 }
1114 if let Some(values) = list_f32(strings, op.c()) {
1115 node.fit_thresholds = values;
1116 }
1117 } else {
1118 node.properties.insert(property, op.c());
1119 }
1120 }
1121 }
1122 parameter::SET_DATE => {
1123 if let Some(node) = nodes.get_mut(&op.a()) {
1124 node.date = Some((op.b() as i32, op.c()));
1126 }
1127 }
1128 _ => {}
1129 }
1130}
1131
1132#[derive(Debug, Clone, Copy)]
1137pub struct HtmlRenderer {
1138 debug_comments: bool,
1143}
1144
1145impl HtmlRenderer {
1146 #[must_use]
1148 pub fn new() -> Self {
1149 Self {
1150 debug_comments: false,
1151 }
1152 }
1153
1154 #[must_use]
1158 pub fn with_debug_comments(mut self, on: bool) -> Self {
1159 self.debug_comments = on;
1160 self
1161 }
1162
1163 #[must_use]
1165 pub fn debug_comments(&self) -> bool {
1166 self.debug_comments
1167 }
1168
1169 #[must_use]
1171 pub fn render_document(&self, opcodes: &[Opcode], strings: &[u8], root: u32) -> String {
1172 let (nodes, tokens) = decode(opcodes, strings);
1173 let body = self.render_node(&nodes, root, None);
1174 let token_rules = tokens.css();
1179 let token_style = if token_rules.is_empty() {
1180 String::new()
1181 } else {
1182 format!("<style data-pathland-tokens>{token_rules}</style>\n")
1183 };
1184 let fit_rules = fit_band_styles(&nodes);
1189 let fit_style = if fit_rules.is_empty() {
1190 String::new()
1191 } else {
1192 format!("<style data-pathland-fit>{fit_rules}</style>\n")
1193 };
1194 format!(
1195 "<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n\
1196 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
1197 <title>Pathland</title>\n\
1198 {}{}{}\n</head>\n<body>{body}</body>\n</html>\n",
1199 css::STYLE,
1200 token_style,
1201 fit_style
1202 )
1203 }
1204
1205 #[must_use]
1210 pub fn render_fragment(&self, opcodes: &[Opcode], strings: &[u8], root: u32) -> String {
1211 let (nodes, _) = decode(opcodes, strings);
1212 self.render_node(&nodes, root, None)
1213 }
1214
1215 fn render_node(
1216 &self,
1217 nodes: &BTreeMap<u32, Node>,
1218 id: u32,
1219 parent_component: Option<u16>,
1220 ) -> String {
1221 let Some(node) = nodes.get(&id) else {
1222 return String::new();
1223 };
1224 let mut children: String = node
1225 .children
1226 .iter()
1227 .map(|&child| self.render_node(nodes, child, Some(node.component)))
1228 .collect();
1229 let mut data_id = format!(" data-pathland-id=\"{id}\"");
1230 data_id.push_str(&slot_attrs(node));
1231 let mut media_attr = String::new();
1239 if !node.children.is_empty() {
1240 if let Some(src) = node.strings.get(&property_id::AUDIO_SOURCE) {
1241 media_attr.push_str(" data-pathland-media");
1242 children = format!(
1243 "<audio src=\"{}\" data-pathland-media></audio>{children}",
1244 escape(src)
1245 );
1246 } else if let Some(src) = node.strings.get(&property_id::VIDEO_SOURCE) {
1247 media_attr.push_str(" data-pathland-media");
1248 children = format!(
1249 "<video src=\"{}\" data-pathland-media></video>{children}",
1250 escape(src)
1251 );
1252 }
1253 }
1254 data_id.push_str(&media_attr);
1255 let mut css = format!("{}{}", node.style_css(), node.border_style());
1256 let mut derived = String::new();
1260 if let Some(parent) = parent_component {
1264 if let Some(main_horizontal) = stack_main_horizontal(parent) {
1265 if fills_axis(nodes, id, !main_horizontal, false) {
1266 derived.push_str("align-self:stretch;");
1267 }
1268 }
1269 }
1270 let line_limit = node.u32_property(property_id::LINE_LIMIT, 0);
1277 if line_limit > 0 {
1278 css.push_str(&format!(
1279 "display:-webkit-box;overflow:hidden;-webkit-line-clamp:{line_limit};-webkit-box-orient:vertical;"
1280 ));
1281 }
1282 let style = style_attr(&format!("{css}{derived}"));
1283 let event = event_attrs(node);
1284 let aria = aria_attrs(node);
1285 let kind = role_spec::shell_kind(node.component);
1288 let semantic = role_spec::semantic_tag(kind, node.role_code());
1289
1290 let element = match node.component {
1291 component_type::VSTACK => {
1292 wrap_stack(id, "column", semantic, node, &media_attr, &children, &format!("{css}{}{derived}", fill_propagation(nodes, id, node, false)), &event, &aria)
1293 }
1294 component_type::HSTACK => {
1295 wrap_stack(id, "row", semantic, node, &media_attr, &children, &format!("{css}{}{derived}", fill_propagation(nodes, id, node, true)), &event, &aria)
1296 }
1297 component_type::LAZY_VSTACK => {
1298 wrap_stack(id, "column", semantic, node, &media_attr, &children, &format!("{css}{}{derived}", fill_propagation(nodes, id, node, false)), &event, &aria)
1299 }
1300 component_type::LAZY_HSTACK => {
1301 wrap_stack(id, "row", semantic, node, &media_attr, &children, &format!("{css}{}{derived}", fill_propagation(nodes, id, node, true)), &event, &aria)
1302 }
1303 component_type::SIZE_THAT_FITS => {
1304 let fit_attr = node
1331 .strings
1332 .get(&property_id::FIT_QUERY)
1333 .map(|csv| format!(" data-pathland-fit=\"{}\"", escape(csv)))
1334 .unwrap_or_default();
1335 if fit_candidates_valid(node) {
1336 let fit_cands = format!(" data-pathland-fit-cands=\"{}\"", node.children.len());
1337 let mut boxed = String::new();
1338 for (i, &child) in node.children.iter().enumerate() {
1339 let inner = self.render_node(nodes, child, Some(node.component));
1346 let default = if i == 0 { "display:contents" } else { "display:none" };
1347 boxed.push_str(&format!(
1348 "<div data-pathland-fit-cand=\"{i}\" data-pathland-fit-cand-id=\"{child}\" style=\"{default}\">{inner}</div>"
1349 ));
1350 }
1351 let slot_base = format!(
1352 "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}"
1353 );
1354 format!(
1355 "<div{data_id} class=\"pathland-ftf\"{fit_attr}{fit_cands}{event}{aria} style=\"{slot_base}\">{boxed}</div>"
1356 )
1357 } else if node.children.len() > 1 {
1358 let only = self.render_node(nodes, node.children[0], Some(node.component));
1363 format!(
1364 "<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>"
1365 )
1366 } else {
1367 format!(
1368 "<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>"
1369 )
1370 }
1371 }
1372 component_type::TEXT => {
1373 let tag = role_spec::text_tag(kind, node.role_code(), node.text_style_code())
1377 .unwrap_or("span");
1378 let text = escape(node.text.as_deref().unwrap_or_default());
1379 format!("<{tag}{data_id}{event}{aria}{style}>{text}</{tag}>")
1380 }
1381 component_type::ICON => {
1382 let icon_name = node
1387 .strings
1388 .get(&property_id::ICON_NAME)
1389 .map(|n| format!(" data-pathland-icon=\"{}\"", escape(n)))
1390 .unwrap_or_default();
1391 let inner = node
1392 .strings
1393 .get(&property_id::ICON_NAME)
1394 .and_then(|n| icons::web_inner(n))
1395 .unwrap_or_else(icons::web_fallback_svg);
1396 let icon_aria = match node.strings.get(&property_id::LABEL) {
1397 Some(label) if !label.is_empty() => {
1398 format!(" role=\"img\" aria-label=\"{}\"", escape(label))
1399 }
1400 _ => " aria-hidden=\"true\" focusable=\"false\"".to_string(),
1401 };
1402 let open = icons::WEB_SVG_OPEN.trim_end_matches('>');
1407 format!("{open}{data_id}{icon_name}{icon_aria}{event}{style}>{inner}</svg>")
1408 }
1409 component_type::BUTTON => {
1410 let body = if node.children.is_empty() {
1412 escape(node.text.as_deref().unwrap_or_default())
1413 } else {
1414 children
1415 };
1416 let button_class = class_attr("pathland-button");
1417 format!("<button{data_id}{event}{aria}{button_class}{style}>{body}</button>")
1418 }
1419 component_type::TOGGLE => {
1420 let toggle_style = node.f32_property(property_id::TOGGLE_STYLE, 0.0).round() as u8;
1421 let checked = if node.checked() { " checked" } else { "" };
1422 let body = if node.children.is_empty() {
1423 format!(
1424 "<span class=\"pathland-text\">{}</span>",
1425 escape(node.text.as_deref().unwrap_or_default())
1426 )
1427 } else {
1428 format!("<div class=\"pathland-custom-body\">{children}</div>")
1429 };
1430 let toggle_class = class_attr("pathland-toggle");
1431 match toggle_style {
1432 1 => format!(
1433 "<label{data_id}{event}{aria}{toggle_class}{style}><input type=\"checkbox\"{checked}>{body}</label>"
1434 ),
1435 2 => {
1436 let pressed = if node.checked() { "true" } else { "false" };
1437 format!(
1438 "<button{data_id}{event}{aria} type=\"button\" aria-pressed=\"{pressed}\"{style}>{body}</button>"
1439 )
1440 }
1441 _ => format!(
1442 "<label{data_id}{event}{aria}{toggle_class}{style}><input type=\"checkbox\" role=\"switch\"{checked}>{body}</label>"
1443 ),
1444 }
1445 }
1446 component_type::SPACER => {
1447 let tag = semantic.unwrap_or("div");
1448 let combined = format!("flex:1;{css}");
1449 let spacer_style = style_attr(&combined);
1450 format!("<{tag}{data_id}{event}{aria}{spacer_style}></{tag}>")
1451 }
1452 component_type::SLIDER => {
1453 let min = node.f32_property(property_id::MIN_VALUE, 0.0);
1454 let max = node.f32_property(property_id::MAX_VALUE, 1.0);
1455 let value = node.f32_property(property_id::VALUE, min);
1456 let body = if node.children.is_empty() {
1457 format!(
1458 "<span class=\"pathland-text\">{}</span>",
1459 escape(node.text.as_deref().unwrap_or_default())
1460 )
1461 } else {
1462 format!("<div class=\"pathland-custom-body\">{children}</div>")
1463 };
1464 let slider_class = class_attr("pathland-slider");
1465 format!(
1466 "<label{data_id}{event}{aria}{slider_class}{style}><input type=\"range\" min=\"{min}\" max=\"{max}\" step=\"any\" value=\"{value}\">{body}</label>"
1467 )
1468 }
1469 component_type::TEXT_FIELD => {
1470 let value = escape(node.text.as_deref().unwrap_or_default());
1471 let label = escape(
1472 node.strings
1473 .get(&property_id::LABEL)
1474 .map(String::as_str)
1475 .unwrap_or_default(),
1476 );
1477 let prompt = escape(
1478 node.strings
1479 .get(&property_id::PROMPT)
1480 .map(String::as_str)
1481 .unwrap_or_default(),
1482 );
1483 let input_type = if node.f32_property(property_id::IS_SECURE, 0.0) != 0.0 {
1484 "password"
1485 } else {
1486 "text"
1487 };
1488 let textfield_class = class_attr("pathland-textfield");
1489 format!(
1490 "<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>"
1491 )
1492 }
1493 component_type::TEXT_EDITOR => {
1494 let value = escape(node.text.as_deref().unwrap_or_default());
1495 format!("<textarea{data_id}{event}{aria} class=\"pathland-input\" rows=\"4\"{style}>{value}</textarea>")
1496 }
1497 component_type::IMAGE => {
1498 let src = escape(
1499 node.strings
1500 .get(&property_id::IMAGE_SOURCE)
1501 .map(String::as_str)
1502 .unwrap_or_default(),
1503 );
1504 let alt = escape(
1507 node.strings
1508 .get(&property_id::LABEL)
1509 .map(String::as_str)
1510 .unwrap_or_default(),
1511 );
1512 format!("<img{data_id}{event}{aria} src=\"{src}\" alt=\"{alt}\"{style}>")
1513 }
1514 component_type::AUDIO => {
1515 let src = escape(
1516 node.strings
1517 .get(&property_id::AUDIO_SOURCE)
1518 .map(String::as_str)
1519 .unwrap_or_default(),
1520 );
1521 if node.children.is_empty() {
1522 format!("<audio{data_id}{event}{aria} src=\"{src}\" controls{style}></audio>")
1525 } else {
1526 format!(
1529 "<div{data_id}{event}{aria} class=\"pathland-media\"{style}>{children}</div>"
1530 )
1531 }
1532 }
1533 component_type::VIDEO => {
1534 let src = escape(
1535 node.strings
1536 .get(&property_id::VIDEO_SOURCE)
1537 .map(String::as_str)
1538 .unwrap_or_default(),
1539 );
1540 if node.children.is_empty() {
1541 format!("<video{data_id}{event}{aria} src=\"{src}\" controls{style}></video>")
1543 } else {
1544 format!(
1546 "<div{data_id}{event}{aria} class=\"pathland-media\"{style}>{children}</div>"
1547 )
1548 }
1549 }
1550 component_type::COLOR => {
1551 let tag = semantic.unwrap_or("div");
1552 let color = node.u32_property(property_id::COLOR, 0xFF00_0000);
1553 format!(
1556 "<{tag}{data_id}{event}{aria} style=\"flex:1 1 auto;align-self:stretch;background-color:{};{css}\">{children}</{tag}>",
1557 rgba(color)
1558 )
1559 }
1560 component_type::SHAPE => {
1561 let kind = node.f32_property(property_id::SHAPE_KIND, 0.0).round() as u8;
1562 let fill = node.u32_property(property_id::COLOR, 0xFF00_0000);
1563 let w = node.f32_property(property_id::WIDTH, 100.0);
1564 let h = node.f32_property(property_id::HEIGHT, 100.0);
1565 let base = format!(
1566 "width:{};height:{};background-color:{};{css}",
1567 size_css(w),
1568 size_css(h),
1569 rgba(fill)
1570 );
1571 let tag = semantic.unwrap_or("div");
1574 match kind {
1575 0 | 4 => format!("<{tag}{data_id}{event}{aria} style=\"{base}border-radius:50%;\"></{tag}>"),
1576 2 => {
1577 let r = node.f32_property(property_id::BORDER_RADIUS, 8.0);
1578 format!("<{tag}{data_id}{event}{aria} style=\"{base}border-radius:{r}px;\"></{tag}>")
1579 }
1580 3 => format!("<{tag}{data_id}{event}{aria} style=\"{base}border-radius:9999px;\"></{tag}>"),
1581 5 => format!(
1582 "<svg{data_id}{event}{aria} width=\"100\" height=\"100\" viewBox=\"0 0 100 100\"><rect width=\"100\" height=\"100\" fill=\"{}\"/></svg>",
1583 rgba(fill)
1584 ),
1585 _ => format!("<{tag}{data_id}{event}{aria} style=\"{base}\"></{tag}>"),
1586 }
1587 }
1588 component_type::DIVIDER => {
1589 let width = node.f32_property(property_id::BORDER_WIDTH, 1.0);
1590 let color = node
1591 .properties
1592 .get(&property_id::COLOR)
1593 .copied()
1594 .map(rgba)
1595 .unwrap_or_else(|| "rgba(0,0,0,0.2)".to_string());
1596 format!(
1602 "<div{data_id}{event}{aria} style=\"height:0;width:100%;border-top:{width}px solid {color};{derived}\"></div>"
1603 )
1604 }
1605 component_type::PROGRESS_VIEW => {
1606 let indeterminate = node.f32_property(property_id::IS_INDETERMINATE, 0.0) != 0.0
1607 || node.f32_property(property_id::PROGRESS, -1.0) < 0.0;
1608if indeterminate {
1609 let spinner_class = class_attr("pathland-spinner");
1610 format!("<div{data_id}{spinner_class}{style}></div>")
1611 } else {
1612 let value = node.f32_property(property_id::PROGRESS, 0.0);
1613 let max = node.f32_property(property_id::MAX_VALUE, 1.0);
1614 format!(
1615 "<progress{data_id}{event}{aria} value=\"{value}\" max=\"{max}\"{style}></progress>"
1616 )
1617 }
1618 }
1619 component_type::GAUGE => {
1620 let min = node.f32_property(property_id::MIN_VALUE, 0.0);
1621 let max = node.f32_property(property_id::MAX_VALUE, 1.0);
1622 let value = node.f32_property(property_id::VALUE, min);
1623 let pct = if max > min {
1624 ((value - min) / (max - min)).clamp(0.0, 1.0) * 100.0
1625 } else {
1626 0.0
1627 };
1628 let gauge_class = class_attr("pathland-gauge");
1629 format!(
1633 "<div{data_id}{event}{aria}{gauge_class} data-min=\"{min}\" data-max=\"{max}\"{style}><div style=\"width:{pct}%\"></div></div>"
1634 )
1635 }
1636 component_type::GRID | component_type::LAZY_VGRID | component_type::LAZY_HGRID => {
1637 let tag = semantic.unwrap_or("div");
1638 let explicit_rows = node
1641 .children
1642 .iter()
1643 .any(|&c| nodes.get(&c).is_some_and(|n| n.component == component_type::GRID_ROW));
1644 let track_count = grid_tracks(node).map(|t| t.len() as u32);
1649 let base_columns = grid_columns(node);
1650 let effective_columns = if let Some(n) = track_count {
1651 Some(n)
1652 } else if explicit_rows {
1653 let mut width = base_columns.map(|c| c as usize).unwrap_or(0);
1654 let mut run = 0usize;
1655 for &child in &node.children {
1656 if nodes.get(&child).is_some_and(|n| n.component == component_type::GRID_ROW) {
1657 width = width.max(nodes.get(&child).map(|n| n.children.len()).unwrap_or(0));
1658 run = 0;
1659 } else {
1660 run += 1;
1661 width = width.max(run);
1662 }
1663 }
1664 base_columns.or(Some((width.max(1)) as u32))
1665 } else {
1666 base_columns
1667 };
1668 let grid_css = grid_style(node, effective_columns);
1669 let combined = format!("{grid_css}{css}");
1670 let grid_style = style_attr(&combined);
1671 let align = node.f32_property(property_id::ALIGNMENT, 0.0) as u8;
1676 let pos_h = |stretch: bool| if stretch { "stretch" } else { align_h(align) };
1677 let pos_v = |stretch: bool| if stretch { "stretch" } else { align_v(align) };
1678 let cell_shell = |cell_html: String, row: usize, col: usize, cell: u32| {
1679 let placement = if explicit_rows {
1680 format!("grid-row:{};grid-column:{};", row + 1, col + 1)
1681 } else {
1682 String::new()
1683 };
1684 let z = nodes
1687 .get(&cell)
1688 .map(|cn| cn.f32_property(property_id::Z_INDEX, 0.0))
1689 .filter(|z| *z != 0.0)
1690 .map(|z| format!("z-index:{};", z as i32))
1691 .unwrap_or_default();
1692 format!(
1693 "<div style=\"{placement}{z}justify-self:{};align-self:{};\">{cell_html}</div>",
1694 pos_h(fills_axis(nodes, cell, true, false)),
1695 pos_v(fills_axis(nodes, cell, false, false)),
1696 )
1697 };
1698 let inner: String = if explicit_rows {
1699 let columns = effective_columns.unwrap_or(1) as usize;
1703 let mut out = String::new();
1704 let mut row = 0usize;
1705 let mut col = 0usize;
1706 for &child in &node.children {
1707 let Some(cn) = nodes.get(&child) else { continue };
1708 if cn.component == component_type::GRID_ROW {
1709 if col > 0 {
1713 row += 1;
1714 }
1715 col = 0;
1716 let mut cells = String::new();
1717 for &cell in &cn.children {
1718 let cell_html = self.render_node(nodes, cell, Some(node.component));
1719 if !cell_html.is_empty() {
1720 cells.push_str(&cell_shell(cell_html, row, col, cell));
1721 }
1722 col += 1;
1723 }
1724 out.push_str(&format!(
1725 "<div data-pathland-id=\"{child}\" style=\"display:contents\">{cells}</div>"
1726 ));
1727 row += 1;
1728 col = 0;
1729 } else {
1730 if col >= columns {
1731 row += 1;
1732 col = 0;
1733 }
1734 let cell_html = self.render_node(nodes, child, Some(node.component));
1735 if !cell_html.is_empty() {
1736 out.push_str(&cell_shell(cell_html, row, col, child));
1737 col += 1;
1738 }
1739 }
1740 }
1741 out
1742 } else {
1743 node.children
1744 .iter()
1745 .filter_map(|&child| {
1746 let cell_html = self.render_node(nodes, child, Some(node.component));
1747 if cell_html.is_empty() {
1748 None
1749 } else {
1750 Some(cell_shell(cell_html, 0, 0, child))
1751 }
1752 })
1753 .collect()
1754 };
1755 format!("<{tag}{data_id}{event}{aria}{grid_style}>{inner}</{tag}>")
1756 }
1757 component_type::SCROLLVIEW => {
1758 let tag = semantic.unwrap_or("div");
1759 let combined = format!("flex:1 1 auto;align-self:stretch;overflow:auto;{css}");
1765 let scroll_style = style_attr(&combined);
1766 let content = node
1767 .children
1768 .first()
1769 .map(|&c| self.render_node(nodes, c, Some(node.component)))
1770 .unwrap_or_default();
1771 format!("<{tag}{data_id}{event}{aria}{scroll_style}>{content}</{tag}>")
1772 }
1773 component_type::ZSTACK => {
1774 let tag = semantic.unwrap_or("div");
1775 let align = node.f32_property(property_id::ALIGNMENT, 0.0) as u8;
1782 let pos_h = |stretch: bool| if stretch { "stretch" } else { align_h(align) };
1783 let pos_v = |stretch: bool| if stretch { "stretch" } else { align_v(align) };
1784 let inner: String = node
1785 .children
1786 .iter()
1787 .filter_map(|&child| {
1788 let child_html = self.render_node(nodes, child, Some(node.component));
1789 if child_html.is_empty() {
1790 None
1791 } else {
1792 let z = nodes
1800 .get(&child)
1801 .map(|cn| cn.f32_property(property_id::Z_INDEX, 0.0) as i32)
1802 .unwrap_or(0);
1803 Some(format!(
1804 "<div style=\"grid-area:1/1;z-index:{z};justify-self:{};align-self:{};\">{child_html}</div>",
1805 pos_h(fills_axis(nodes, child, true, false)),
1806 pos_v(fills_axis(nodes, child, false, false)),
1807 ))
1808 }
1809 })
1810 .collect();
1811 let mut zcss = format!(
1814 "display:grid;isolation:isolate;grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr);justify-items:{};align-items:{};",
1815 align_h(align),
1816 align_v(align)
1817 );
1818 for (axis_h, prop, hug_css, fill_css) in [
1819 (true, property_id::WIDTH, "width:max-content;", "width:100%;"),
1820 (false, property_id::HEIGHT, "height:max-content;", "height:100%;"),
1821 ] {
1822 let hint = node.properties.get(&prop).map(|b| f32::from_bits(*b));
1823 let hug = hint.is_none() || hint == Some(size::HUG_CONTENT);
1824 if hug {
1825 zcss.push_str(if fills_axis(nodes, id, axis_h, false) {
1826 fill_css
1827 } else {
1828 hug_css
1829 });
1830 }
1831 }
1832 let combined = format!("{zcss}{css}{derived}");
1833 let zstyle = style_attr(&combined);
1834 format!("<{tag}{data_id}{event}{aria}{zstyle}>{inner}</{tag}>")
1835 }
1836 component_type::STEPPER => {
1837 let min = node.f32_property(property_id::MIN_VALUE, 0.0);
1838 let max = node.f32_property(property_id::MAX_VALUE, 100.0);
1839 let value = node.f32_property(property_id::VALUE, min);
1840 let step = node.f32_property(property_id::STEP_VALUE, 1.0);
1841 let display = format!("{}", (value * 100.0).round() / 100.0);
1847 let stepper_class = class_attr("pathland-stepper");
1848 format!(
1849 "<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>"
1850 )
1851 }
1852 component_type::DATE_PICKER => {
1853 let mode = node.f32_property(property_id::DATE_PICKER_MODE, 0.0).round() as u8;
1854 let (t, value) = match node.date {
1855 Some((days, millis)) => match mode {
1856 1 => {
1857 let (hh, mm, _) = millis_to_hms(millis);
1858 ("time", format!("{hh:02}:{mm:02}"))
1859 }
1860 2 => {
1861 let (y, m, d) = days_to_date(days);
1862 let (hh, mm, _) = millis_to_hms(millis);
1863 (
1864 "datetime-local",
1865 format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}"),
1866 )
1867 }
1868 _ => {
1869 let (y, m, d) = days_to_date(days);
1870 ("date", format!("{y:04}-{m:02}-{d:02}"))
1871 }
1872 },
1873 None => ("date", String::new()),
1874 };
1875 format!("<input{data_id}{event}{aria} type=\"{t}\" value=\"{value}\"{style}>")
1876 }
1877 component_type::PICKER => {
1878 let selected = node.u32_property(property_id::SELECTION, 0);
1879 let options: String = node
1880 .children
1881 .iter()
1882 .enumerate()
1883 .map(|(i, &child)| {
1884 let label = nodes
1885 .get(&child)
1886 .and_then(|n| n.text.clone())
1887 .unwrap_or_default();
1888 let sel = if i as u32 == selected { " selected" } else { "" };
1889 format!(
1893 "<option data-pathland-id=\"{child}\" value=\"{i}\"{sel}>{}</option>",
1894 escape(&label)
1895 )
1896 })
1897 .collect();
1898 format!("<select{data_id}{event}{aria}{style}>{options}</select>")
1899 }
1900 component_type::MENU => {
1901 let label = escape(node.text.as_deref().unwrap_or_default());
1907 let menu_class = class_attr("pathland-menu");
1908 format!(
1909 "<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>"
1910 )
1911 }
1912 component_type::COLOR_PICKER => {
1913 let color = node.u32_property(property_id::COLOR_VALUE, 0xFF00_0000);
1914 let hex = format!(
1915 "#{:02x}{:02x}{:02x}",
1916 (color >> 16) & 0xFF,
1917 (color >> 8) & 0xFF,
1918 color & 0xFF
1919 );
1920 format!("<input{data_id}{event}{aria} type=\"color\" value=\"{hex}\"{style}>")
1921 }
1922 component_type::COMMENT => String::new(),
1923 _ => String::new(),
1924 };
1925 if self.debug_comments {
1926 format!("{}{}", debug::node_comment(id, node), element)
1927 } else {
1928 element
1929 }
1930 }
1931}
1932
1933 fn wrap_stack(
1934 id: u32,
1935 direction: &str,
1936 semantic: Option<&'static str>,
1937 node: &Node,
1938 media_attr: &str,
1939 children: &str,
1940 css: &str,
1941 event: &str,
1942 aria: &str,
1943 ) -> String {
1944 let tag = semantic.unwrap_or("div");
1945 let alignment = node
1946 .properties
1947 .get(&property_id::ALIGNMENT)
1948 .map(|bits| f32::from_bits(*bits) as u8)
1949 .unwrap_or(3);
1950 let align = match alignment {
1954 0 => "flex-start",
1955 1 => "center",
1956 2 => "flex-end",
1957 _ => "flex-start",
1958 };
1959 let combined = format!(
1960 "display:flex;flex-direction:{direction};align-items:{align};{css}"
1961 );
1962 let stack_style = style_attr(&combined);
1963 format!(
1964 "<{tag} data-pathland-id=\"{id}\"{}{media_attr}{event}{aria}{stack_style}>{children}</{tag}>",
1965 slot_attrs(node),
1966 )
1967 }
1968
1969fn grid_style(node: &Node, columns: Option<u32>) -> String {
1976 let align = node.f32_property(property_id::ALIGNMENT, 0.0) as u8;
1977 let mut css = format!(
1978 "display:grid;justify-items:{};align-items:{};",
1979 align_h(align),
1980 align_v(align)
1981 );
1982 let tracks_css = grid_tracks_css(node);
1983 match node.component {
1984 component_type::GRID => {
1985 if let Some(t) = &tracks_css {
1986 css.push_str(&format!("grid-template-columns:{t};"));
1987 } else if let Some(n) = columns {
1988 css.push_str(&format!("grid-template-columns:repeat({n},minmax(0,1fr));"));
1989 }
1990 if let Some(n) = grid_rows(node) {
1991 css.push_str(&format!("grid-template-rows:repeat({n},minmax(0,1fr));"));
1992 }
1993 }
1994 component_type::LAZY_HGRID => {
1995 if let Some(t) = &tracks_css {
1996 css.push_str(&format!("grid-template-rows:{t};"));
1997 } else if let Some(n) = grid_rows(node) {
1998 css.push_str(&format!("grid-template-rows:repeat({n},minmax(0,1fr));"));
1999 }
2000 css.push_str("grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);");
2001 }
2002 _ => {
2003 if let Some(t) = &tracks_css {
2004 css.push_str(&format!("grid-template-columns:{t};"));
2005 } else if let Some(n) = columns {
2006 css.push_str(&format!("grid-template-columns:repeat({n},minmax(0,1fr));"));
2007 }
2008 }
2009 }
2010 css
2011}
2012
2013fn style_attr(css: &str) -> String {
2015 if css.is_empty() {
2016 String::new()
2017 } else {
2018 format!(" style=\"{css}\"")
2019 }
2020}
2021
2022fn class_attr(classes: &str) -> String {
2024 let classes = classes.trim();
2025 if classes.is_empty() {
2026 String::new()
2027 } else {
2028 format!(" class=\"{classes}\"")
2029 }
2030}
2031
2032fn escape(input: &str) -> String {
2034 input
2035 .replace('&', "&")
2036 .replace('<', "<")
2037 .replace('>', ">")
2038 .replace('"', """)
2039 .replace('\'', "'")
2040}
2041
2042fn slot_attrs(node: &Node) -> String {
2049 let mut attrs = String::new();
2050 if let Some(path) = node.strings.get(&property_id::ROUTE) {
2051 attrs.push_str(&format!(" data-pathland-route=\"{}\"", escape(path)));
2052 }
2053 let chrome = node.f32_property(property_id::NAV_CHROME, 0.0).round() as u8;
2054 if chrome == 1 {
2055 attrs.push_str(" data-pathland-nav-chrome=\"custom\"");
2056 }
2057 let depth = node.u32_property(property_id::NAV_DEPTH, 1).max(1);
2061 if depth > 1 {
2062 attrs.push_str(&format!(" data-pathland-depth=\"{depth}\""));
2063 }
2064 let transition = node.f32_property(property_id::TRANSITION, 0.0).round() as u8;
2065 if transition != 0 {
2066 let name = match transition {
2067 2 => "fade",
2068 3 => "slide",
2069 4 => "scale",
2070 _ => "platform",
2071 };
2072 attrs.push_str(&format!(" data-pathland-transition=\"{name}\""));
2073 }
2074 attrs
2075}
2076
2077#[cfg(test)]
2078mod tests {
2079 use super::*;
2080 use pathland_core::Opcode;
2081
2082 #[test]
2083 fn renders_vstack_of_text() {
2084 let mut opcodes = Vec::new();
2086 let mut strings = Vec::new();
2087 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
2088 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 0, 1, pathland_core::APPEND));
2089 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
2090 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, pathland_core::APPEND));
2091 strings.extend_from_slice(&(5u32).to_le_bytes());
2092 strings.extend_from_slice(b"Hello");
2093 opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 2, 0, 0));
2094
2095 let renderer = HtmlRenderer::new();
2096 let html = renderer.render_document(&opcodes, &strings, 1);
2097 assert!(html.contains("<!DOCTYPE html>"));
2098 assert!(html.contains("flex-direction:column"), "stack inline flex");
2099 assert!(html.contains("<span data-pathland-id=\"2\">Hello</span>"));
2100 assert!(html.contains("data-pathland-id=\"1\""));
2101 }
2102
2103 #[test]
2104 fn escapes_text() {
2105 assert_eq!(escape("<a & b>"), "<a & b>");
2106 }
2107
2108 #[test]
2109 fn renders_border() {
2110 use pathland_core::value_type;
2111
2112 let mut opcodes = Vec::new();
2113 let mut strings = Vec::new();
2114 opcodes.push(Opcode::new(
2115 category::TREE,
2116 tree::CREATE_NODE,
2117 0,
2118 1,
2119 component_type::TEXT as u32,
2120 0,
2121 ));
2122 strings.extend_from_slice(&(5u32).to_le_bytes());
2123 strings.extend_from_slice(b"Hello");
2124 opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
2125 opcodes.push(Opcode::new(
2127 category::PARAMETER,
2128 parameter::SET_PROPERTY,
2129 0,
2130 1,
2131 ((value_type::F32 as u32) << 16) | property_id::BORDER_WIDTH as u32,
2132 2.0f32.to_bits(),
2133 ));
2134 opcodes.push(Opcode::new(
2135 category::PARAMETER,
2136 parameter::SET_PROPERTY,
2137 0,
2138 1,
2139 ((value_type::COLOR as u32) << 16) | property_id::BORDER_COLOR as u32,
2140 0xFFFF_0000,
2141 ));
2142 opcodes.push(Opcode::new(
2143 category::PARAMETER,
2144 parameter::SET_PROPERTY,
2145 0,
2146 1,
2147 ((value_type::U32 as u32) << 16) | property_id::BORDER_EDGES as u32,
2148 border_edges::ALL,
2149 ));
2150
2151 let renderer = HtmlRenderer::new();
2152 let html = renderer.render_document(&opcodes, &strings, 1);
2153
2154 assert!(html.contains("border-top:2px solid rgba(255,0,0,1)"));
2155 assert!(html.contains("border-left:2px solid rgba(255,0,0,1)"));
2156 assert!(html.contains("border-bottom:2px solid rgba(255,0,0,1)"));
2157 assert!(html.contains("border-right:2px solid rgba(255,0,0,1)"));
2158 }
2159
2160 #[test]
2161 fn renders_toggle_switch_checked() {
2162 use pathland_core::value_type;
2163
2164 let mut opcodes = Vec::new();
2165 let mut strings = Vec::new();
2166 opcodes.push(Opcode::new(
2167 category::TREE,
2168 tree::CREATE_NODE,
2169 0,
2170 1,
2171 component_type::TOGGLE as u32,
2172 0,
2173 ));
2174 strings.extend_from_slice(&(7u32).to_le_bytes());
2175 strings.extend_from_slice(b"Enabled");
2176 opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
2177 opcodes.push(Opcode::new(
2178 category::PARAMETER,
2179 parameter::SET_PROPERTY,
2180 0,
2181 1,
2182 ((value_type::F32 as u32) << 16) | property_id::TOGGLE_STYLE as u32,
2183 0.0f32.to_bits(),
2184 ));
2185 opcodes.push(Opcode::new(
2186 category::PARAMETER,
2187 parameter::SET_PROPERTY,
2188 0,
2189 1,
2190 ((value_type::U8 as u32) << 16) | property_id::SELECTED as u32,
2191 1,
2192 ));
2193
2194 let renderer = HtmlRenderer::new();
2195 let html = renderer.render_document(&opcodes, &strings, 1);
2196
2197 assert!(html.contains("<input type=\"checkbox\" role=\"switch\" checked>"));
2198 assert!(html.contains("<span class=\"pathland-text\">Enabled</span>"));
2199 }
2200
2201 #[test]
2202 fn renders_toggle_checkbox_unchecked() {
2203 use pathland_core::value_type;
2204
2205 let mut opcodes = Vec::new();
2206 let mut strings = Vec::new();
2207 opcodes.push(Opcode::new(
2208 category::TREE,
2209 tree::CREATE_NODE,
2210 0,
2211 1,
2212 component_type::TOGGLE as u32,
2213 0,
2214 ));
2215 strings.extend_from_slice(&(5u32).to_le_bytes());
2216 strings.extend_from_slice(b"Email");
2217 opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
2218 opcodes.push(Opcode::new(
2219 category::PARAMETER,
2220 parameter::SET_PROPERTY,
2221 0,
2222 1,
2223 ((value_type::F32 as u32) << 16) | property_id::TOGGLE_STYLE as u32,
2224 1.0f32.to_bits(),
2225 ));
2226 opcodes.push(Opcode::new(
2227 category::PARAMETER,
2228 parameter::SET_PROPERTY,
2229 0,
2230 1,
2231 ((value_type::U8 as u32) << 16) | property_id::SELECTED as u32,
2232 0,
2233 ));
2234
2235 let renderer = HtmlRenderer::new();
2236 let html = renderer.render_document(&opcodes, &strings, 1);
2237
2238 assert!(html.contains("<input type=\"checkbox\">"));
2239 assert!(!html.contains("<input type=\"checkbox\" role=\"switch\""));
2240 assert!(html.contains("<span class=\"pathland-text\">Email</span>"));
2241 }
2242
2243 #[test]
2244 fn renders_toggle_button_pressed() {
2245 use pathland_core::value_type;
2246
2247 let mut opcodes = Vec::new();
2248 let mut strings = Vec::new();
2249 opcodes.push(Opcode::new(
2250 category::TREE,
2251 tree::CREATE_NODE,
2252 0,
2253 1,
2254 component_type::TOGGLE as u32,
2255 0,
2256 ));
2257 strings.extend_from_slice(&(4u32).to_le_bytes());
2258 strings.extend_from_slice(b"Mute");
2259 opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
2260 opcodes.push(Opcode::new(
2261 category::PARAMETER,
2262 parameter::SET_PROPERTY,
2263 0,
2264 1,
2265 ((value_type::F32 as u32) << 16) | property_id::TOGGLE_STYLE as u32,
2266 2.0f32.to_bits(),
2267 ));
2268 opcodes.push(Opcode::new(
2269 category::PARAMETER,
2270 parameter::SET_PROPERTY,
2271 0,
2272 1,
2273 ((value_type::U8 as u32) << 16) | property_id::SELECTED as u32,
2274 1,
2275 ));
2276
2277 let renderer = HtmlRenderer::new();
2278 let html = renderer.render_document(&opcodes, &strings, 1);
2279
2280 assert!(html.contains("<button") && html.contains("type=\"button\""));
2281 assert!(html.contains("aria-pressed=\"true\""));
2282 assert!(html.contains("<span class=\"pathland-text\">Mute</span>"));
2283 assert!(html.contains("</button>"));
2284 }
2285
2286 #[test]
2287 fn renders_slider() {
2288 use pathland_core::value_type;
2289
2290 let mut opcodes = Vec::new();
2291 let mut strings = Vec::new();
2292 opcodes.push(Opcode::new(
2293 category::TREE,
2294 tree::CREATE_NODE,
2295 0,
2296 1,
2297 component_type::SLIDER as u32,
2298 0,
2299 ));
2300 strings.extend_from_slice(&(6u32).to_le_bytes());
2301 strings.extend_from_slice(b"Volume");
2302 opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
2303 opcodes.push(Opcode::new(
2304 category::PARAMETER,
2305 parameter::SET_PROPERTY,
2306 0,
2307 1,
2308 ((value_type::F32 as u32) << 16) | property_id::MIN_VALUE as u32,
2309 0.0f32.to_bits(),
2310 ));
2311 opcodes.push(Opcode::new(
2312 category::PARAMETER,
2313 parameter::SET_PROPERTY,
2314 0,
2315 1,
2316 ((value_type::F32 as u32) << 16) | property_id::MAX_VALUE as u32,
2317 1.0f32.to_bits(),
2318 ));
2319 opcodes.push(Opcode::new(
2320 category::PARAMETER,
2321 parameter::SET_PROPERTY,
2322 0,
2323 1,
2324 ((value_type::F32 as u32) << 16) | property_id::VALUE as u32,
2325 0.5f32.to_bits(),
2326 ));
2327
2328 let renderer = HtmlRenderer::new();
2329 let html = renderer.render_document(&opcodes, &strings, 1);
2330
2331 assert!(html.contains(
2332 "<input type=\"range\" min=\"0\" max=\"1\" step=\"any\" value=\"0.5\">"
2333 ));
2334 assert!(html.contains("<span class=\"pathland-text\">Volume</span>"));
2335 }
2336
2337 #[test]
2338 fn renders_text_field() {
2339 use pathland_core::value_type;
2340
2341 let mut opcodes = Vec::new();
2342 let mut strings = Vec::new();
2343 opcodes.push(Opcode::new(
2344 category::TREE,
2345 tree::CREATE_NODE,
2346 0,
2347 1,
2348 component_type::TEXT_FIELD as u32,
2349 0,
2350 ));
2351 strings.extend_from_slice(&(3u32).to_le_bytes());
2353 strings.extend_from_slice(b"Bob");
2354 opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
2355 strings.extend_from_slice(&(5u32).to_le_bytes());
2357 strings.extend_from_slice(b"Name:");
2358 opcodes.push(Opcode::new(
2359 category::PARAMETER,
2360 parameter::SET_PROPERTY,
2361 0,
2362 1,
2363 ((value_type::STRING as u32) << 16) | property_id::LABEL as u32,
2364 7, ));
2366 strings.extend_from_slice(&(5u32).to_le_bytes());
2368 strings.extend_from_slice(b"Enter");
2369 opcodes.push(Opcode::new(
2370 category::PARAMETER,
2371 parameter::SET_PROPERTY,
2372 0,
2373 1,
2374 ((value_type::STRING as u32) << 16) | property_id::PROMPT as u32,
2375 16, ));
2377
2378 let renderer = HtmlRenderer::new();
2379 let html = renderer.render_document(&opcodes, &strings, 1);
2380
2381 assert!(html.contains("class=\"pathland-textfield\""));
2382 assert!(html.contains("<span class=\"pathland-label\">Name:</span>"));
2383 assert!(html.contains(
2384 "<input class=\"pathland-input\" type=\"text\" value=\"Bob\" placeholder=\"Enter\">"
2385 ));
2386 }
2387
2388 #[test]
2389 fn applies_network_decoded_frame() {
2390 use pathland_core_transport::encode_frame;
2394
2395 let mut opcodes = Vec::new();
2396 let mut strings = Vec::new();
2397 opcodes.push(Opcode::new(
2398 category::TREE,
2399 tree::CREATE_NODE,
2400 0,
2401 1,
2402 component_type::TEXT as u32,
2403 0,
2404 ));
2405 strings.extend_from_slice(&(5u32).to_le_bytes());
2406 strings.extend_from_slice(b"Hello");
2407 opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
2408
2409 let bytes = encode_frame(&opcodes, &strings);
2410 assert!(bytes.len() > 16);
2411
2412 let renderer = HtmlRenderer::new();
2413 let html = renderer.render_document(&opcodes, &strings, 1);
2414 assert!(html.contains("<span data-pathland-id=\"1\">Hello</span>"));
2415 }
2416
2417 #[test]
2418 fn days_to_date_maps_epoch_and_pre_epoch() {
2419 assert_eq!(days_to_date(0), (1970, 1, 1));
2420 assert_eq!(days_to_date(19_723), (2024, 1, 1));
2421 assert_eq!(days_to_date(-1), (1969, 12, 31));
2423 assert_eq!(days_to_date(-365), (1969, 1, 1));
2424 }
2425
2426 #[test]
2427 fn renders_image_with_source() {
2428 use pathland_core::value_type;
2429
2430 let mut opcodes = Vec::new();
2431 let mut strings = Vec::new();
2432 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::IMAGE as u32, 0));
2433 strings.extend_from_slice(&(15u32).to_le_bytes());
2434 strings.extend_from_slice(b"assets/logo.png");
2435 opcodes.push(Opcode::new(
2436 category::PARAMETER,
2437 parameter::SET_PROPERTY,
2438 0,
2439 1,
2440 ((value_type::STRING as u32) << 16) | property_id::IMAGE_SOURCE as u32,
2441 0,
2442 ));
2443
2444 let renderer = HtmlRenderer::new();
2445 let html = renderer.render_document(&opcodes, &strings, 1);
2446 assert!(html.contains("<img data-pathland-id=\"1\" src=\"assets/logo.png\" alt=\"\">"));
2447 }
2448
2449 #[test]
2450 fn renders_semantic_icon_with_filled_remix_markup() {
2451 use pathland_core::value_type;
2452
2453 let mut opcodes = Vec::new();
2454 let mut strings = Vec::new();
2455 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::ICON as u32, 0));
2456 strings.extend_from_slice(&(4u32).to_le_bytes());
2457 strings.extend_from_slice(b"play");
2458 opcodes.push(Opcode::new(
2459 category::PARAMETER,
2460 parameter::SET_PROPERTY,
2461 0,
2462 1,
2463 ((value_type::STRING as u32) << 16) | property_id::ICON_NAME as u32,
2464 0,
2465 ));
2466
2467 let renderer = HtmlRenderer::new();
2468 let html = renderer.render_document(&opcodes, &strings, 1);
2469 assert!(html.contains("<svg class=\"pathland-icon\""), "icon svg shell: {}", html);
2470 assert!(html.contains("fill=\"currentColor\""), "filled shell: {}", html);
2471 assert!(!html.contains("stroke="), "no stroke on a filled icon: {}", html);
2472 assert!(html.contains("data-pathland-id=\"1\""), "icon keeps its node id: {}", html);
2473 assert!(html.contains("aria-hidden=\"true\""), "decorative by default: {}", html);
2474 assert!(html.contains("d=\"M19.376 12.4161L8.77735 19.4818"), "play remix path: {}", html);
2475 assert!(html.ends_with("</svg>") || html.contains("</svg>"), "svg closed: {}", html);
2476 }
2477
2478 #[test]
2479 fn labeled_icon_is_presentable_and_unknown_name_falls_back() {
2480 use pathland_core::value_type;
2481
2482 let icon = |name: &str, label: Option<&str>| -> String {
2484 let mut opcodes = Vec::new();
2485 let mut strings = Vec::new();
2486 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::ICON as u32, 0));
2487 let name_offset = 0usize;
2488 strings.extend_from_slice(&(name.len() as u32).to_le_bytes());
2489 strings.extend_from_slice(name.as_bytes());
2490 opcodes.push(Opcode::new(
2491 category::PARAMETER,
2492 parameter::SET_PROPERTY,
2493 0,
2494 1,
2495 ((value_type::STRING as u32) << 16) | property_id::ICON_NAME as u32,
2496 name_offset as u32,
2497 ));
2498 let label_offset = 4usize + name.len();
2499 if let Some(label) = label {
2500 strings.extend_from_slice(&(label.len() as u32).to_le_bytes());
2501 strings.extend_from_slice(label.as_bytes());
2502 opcodes.push(Opcode::new(
2503 category::PARAMETER,
2504 parameter::SET_PROPERTY,
2505 0,
2506 1,
2507 ((value_type::STRING as u32) << 16) | property_id::LABEL as u32,
2508 label_offset as u32,
2509 ));
2510 }
2511 HtmlRenderer::new().render_document(&opcodes, &strings, 1)
2512 };
2513
2514 let labeled = icon("music", Some("Playlist"));
2515 assert!(labeled.contains("role=\"img\""), "labeled icon is an image: {}", labeled);
2516 assert!(labeled.contains("aria-label=\"Playlist\""), "aria label: {}", labeled);
2517 assert!(!labeled.contains("aria-hidden"), "no aria-hidden when labeled: {}", labeled);
2518
2519 let unknown = icon("definitely-not-an-icon", None);
2520 assert!(unknown.contains("aria-hidden=\"true\""), "unlabeled stays decorative: {}", unknown);
2521 assert!(unknown.contains("cx=\"12\" cy=\"12\" r=\"8\""), "fallback glyph: {}", unknown);
2522 }
2523
2524 #[test]
2525 fn renders_media_and_image_alt() {
2526 use pathland_core::value_type;
2527
2528 let mut opcodes = Vec::new();
2529 let mut strings = Vec::new();
2530 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::IMAGE as u32, 0));
2532 strings.extend_from_slice(&(32u32).to_le_bytes());
2533 strings.extend_from_slice(b"/_pathland/assets/icons/home.svg");
2534 opcodes.push(Opcode::new(
2535 category::PARAMETER,
2536 parameter::SET_PROPERTY,
2537 0,
2538 1,
2539 ((value_type::STRING as u32) << 16) | property_id::IMAGE_SOURCE as u32,
2540 0,
2541 ));
2542 strings.extend_from_slice(&(4u32).to_le_bytes());
2543 strings.extend_from_slice(b"Home");
2544 opcodes.push(Opcode::new(
2545 category::PARAMETER,
2546 parameter::SET_PROPERTY,
2547 0,
2548 1,
2549 ((value_type::STRING as u32) << 16) | property_id::LABEL as u32,
2550 36,
2551 ));
2552 opcodes.push(Opcode::new(
2553 category::PARAMETER,
2554 parameter::SET_PROPERTY,
2555 0,
2556 1,
2557 ((value_type::F32 as u32) << 16) | property_id::CONTENT_MODE as u32,
2558 1f32.to_bits(),
2559 ));
2560 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::VIDEO as u32, 0));
2562 strings.extend_from_slice(&(30u32).to_le_bytes());
2563 strings.extend_from_slice(b"https://example.com/sample.mp4");
2564 opcodes.push(Opcode::new(
2565 category::PARAMETER,
2566 parameter::SET_PROPERTY,
2567 0,
2568 2,
2569 ((value_type::STRING as u32) << 16) | property_id::VIDEO_SOURCE as u32,
2570 44,
2571 ));
2572 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::AUDIO as u32, 0));
2573 strings.extend_from_slice(&(30u32).to_le_bytes());
2574 strings.extend_from_slice(b"https://example.com/sample.mp3");
2575 opcodes.push(Opcode::new(
2576 category::PARAMETER,
2577 parameter::SET_PROPERTY,
2578 0,
2579 3,
2580 ((value_type::STRING as u32) << 16) | property_id::AUDIO_SOURCE as u32,
2581 78,
2582 ));
2583
2584 let renderer = HtmlRenderer::new();
2585 let img = renderer.render_fragment(&opcodes, &strings, 1);
2586 assert!(img.contains("alt=\"Home\""), "image alt from LABEL");
2587 assert!(img.contains("object-fit:cover;"), "ContentMode Fill -> cover");
2588 let video = renderer.render_fragment(&opcodes, &strings, 2);
2589 assert!(video.contains("<video data-pathland-id=\"2\" src=\"https://example.com/sample.mp4\" controls"), "video native controls");
2590 let audio = renderer.render_fragment(&opcodes, &strings, 3);
2591 assert!(audio.contains("<audio data-pathland-id=\"3\" src=\"https://example.com/sample.mp3\" controls"), "audio native controls");
2592 }
2593
2594 #[test]
2598 fn custom_controls_media_preserves_container_with_hidden_audio() {
2599 use pathland_core::value_type;
2600
2601 let mut opcodes = Vec::new();
2602 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
2604 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::BUTTON as u32, 0));
2605 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
2606 let mut strings = Vec::new();
2607 strings.extend_from_slice(&(29u32).to_le_bytes());
2608 strings.extend_from_slice(b"https://example.com/track.mp3");
2609 opcodes.push(Opcode::new(
2610 category::PARAMETER,
2611 parameter::SET_PROPERTY,
2612 0,
2613 1,
2614 ((value_type::STRING as u32) << 16) | property_id::AUDIO_SOURCE as u32,
2615 0,
2616 ));
2617 opcodes.push(Opcode::new(
2619 category::PARAMETER,
2620 parameter::SET_PROPERTY,
2621 0,
2622 1,
2623 ((value_type::U32 as u32) << 16) | property_id::PLAYBACK_STATE as u32,
2624 1,
2625 ));
2626 opcodes.push(Opcode::new(
2627 category::PARAMETER,
2628 parameter::SET_PROPERTY,
2629 0,
2630 1,
2631 ((value_type::F32 as u32) << 16) | property_id::MEDIA_POSITION as u32,
2632 12.5f32.to_bits(),
2633 ));
2634 opcodes.push(Opcode::new(
2635 category::PARAMETER,
2636 parameter::SET_PROPERTY,
2637 0,
2638 1,
2639 ((value_type::F32 as u32) << 16) | property_id::MEDIA_VOLUME as u32,
2640 0.5f32.to_bits(),
2641 ));
2642
2643 let renderer = HtmlRenderer::new();
2644 let html = renderer.render_fragment(&opcodes, &strings, 1);
2645 assert!(
2646 html.contains("data-pathland-id=\"1\" data-pathland-media"),
2647 "the container is marked app-driven: {html}"
2648 );
2649 assert!(
2650 html.contains("display:flex;flex-direction:column"),
2651 "the VStack body keeps its flex layout: {html}"
2652 );
2653 assert!(
2654 html.contains("<audio src=\"https://example.com/track.mp3\" data-pathland-media></audio>"),
2655 "the container holds a hidden, control-less audio element"
2656 );
2657 assert!(
2658 !html.contains("controls"),
2659 "the injected media element has no native controls"
2660 );
2661 assert!(html.contains("<button"), "the custom control children render");
2662 let debug = renderer.with_debug_comments(true).render_fragment(&opcodes, &strings, 1);
2663 assert!(debug.contains("<!-- #1 VStack:"), "debug comment names the VStack: {debug}");
2664 }
2665
2666 #[test]
2670 fn wrapper_media_node_keeps_audio_component_with_custom_children() {
2671 use pathland_core::value_type;
2672
2673 let mut opcodes = Vec::new();
2674 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::AUDIO as u32, 0));
2675 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::HSTACK as u32, 0));
2676 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::BUTTON as u32, 0));
2677 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
2678 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 2, 3, 0));
2679 let mut strings = Vec::new();
2680 strings.extend_from_slice(&(29u32).to_le_bytes());
2681 strings.extend_from_slice(b"https://example.com/track.mp3");
2682 opcodes.push(Opcode::new(
2683 category::PARAMETER,
2684 parameter::SET_PROPERTY,
2685 0,
2686 1,
2687 ((value_type::STRING as u32) << 16) | property_id::AUDIO_SOURCE as u32,
2688 0,
2689 ));
2690
2691 let renderer = HtmlRenderer::new();
2692 let html = renderer.render_fragment(&opcodes, &strings, 1);
2693 assert!(
2694 html.contains("data-pathland-id=\"1\" data-pathland-media"),
2695 "the AUDIO shell carries the app-driven media marker: {html}"
2696 );
2697 assert!(
2698 html.contains("class=\"pathland-media\""),
2699 "the AUDIO node renders the media wrapper, not a bare <audio>: {html}"
2700 );
2701 assert!(
2702 html.contains("<audio src=\"https://example.com/track.mp3\" data-pathland-media></audio>"),
2703 "a hidden, control-less audio is injected first: {html}"
2704 );
2705 assert!(
2706 html.contains("data-pathland-id=\"2\""),
2707 "the style body (control children) renders inside the wrapper: {html}"
2708 );
2709 }
2710
2711 #[test]
2712 fn renders_grid_with_columns() {
2713 use pathland_core::value_type;
2714
2715 let mut opcodes = Vec::new();
2716 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::GRID as u32, 0));
2717 opcodes.push(Opcode::new(
2718 category::PARAMETER,
2719 parameter::SET_PROPERTY,
2720 0,
2721 1,
2722 ((value_type::F32 as u32) << 16) | property_id::GRID_COLUMNS as u32,
2723 2.0f32.to_bits(),
2724 ));
2725 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
2726 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
2727
2728 let renderer = HtmlRenderer::new();
2729 let html = renderer.render_document(&opcodes, &[], 1);
2730 assert!(html.contains("grid-template-columns:repeat(2,minmax(0,1fr))"), "grid inline");
2731 assert!(!html.contains("width:2px"), "GRID_COLUMNS is a count, never a pixel box: {html}");
2732 assert!(html.contains("justify-items:start;align-items:start"), "default cell alignment");
2733 assert!(html.contains("justify-self:start;align-self:start;"), "cell wrapper positions per ALIGNMENT");
2734 }
2735
2736 #[test]
2737 fn grid_rows_and_lazy_hgrid_tracks() {
2738 use pathland_core::value_type;
2739 use pathland_core::size;
2740
2741 let build = |component: u16, columns: f32, rows: f32| {
2742 let mut opcodes = Vec::new();
2743 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component as u32, 0));
2744 if columns != pathland_core::size::HUG_CONTENT {
2745 opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 1,
2746 ((value_type::F32 as u32) << 16) | property_id::GRID_COLUMNS as u32, columns.to_bits()));
2747 }
2748 if rows != pathland_core::size::HUG_CONTENT {
2749 opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 1,
2750 ((value_type::F32 as u32) << 16) | property_id::GRID_ROWS as u32, rows.to_bits()));
2751 }
2752 let renderer = HtmlRenderer::new();
2753 renderer.render_document(&opcodes, &[], 1)
2754 };
2755
2756 let g = build(component_type::GRID, 2.0, 3.0);
2758 assert!(g.contains("grid-template-columns:repeat(2,minmax(0,1fr))"), "grid columns: {g}");
2759 assert!(g.contains("grid-template-rows:repeat(3,minmax(0,1fr))"), "grid rows: {g}");
2760 assert!(!g.contains("width:2px") && !g.contains("height:3px"), "counts never pixels: {g}");
2761
2762 let h = build(component_type::LAZY_HGRID, 2.0, 3.0);
2764 assert!(h.contains("grid-template-rows:repeat(3,minmax(0,1fr))"), "hgrid rows: {h}");
2765 assert!(h.contains("grid-auto-flow:column;grid-auto-columns:minmax(0,1fr)"), "hgrid auto columns: {h}");
2766 assert!(!h.contains("grid-template-columns:repeat(2,minmax(0,1fr))"), "hgrid ignores GRID_COLUMNS: {h}");
2767
2768 let mut opcodes = Vec::new();
2770 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::GRID as u32, 0));
2771 opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 1,
2772 ((value_type::F32 as u32) << 16) | property_id::GRID_COLUMNS as u32, size::FILL.to_bits()));
2773 opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 1,
2774 ((value_type::F32 as u32) << 16) | property_id::WIDTH as u32, size::FILL.to_bits()));
2775 let renderer = HtmlRenderer::new();
2776 let f = renderer.render_document(&opcodes, &[], 1);
2777 assert!(!f.contains("grid-template-columns:repeat"), "FILL count = auto-fit: {f}");
2778 assert!(f.contains("width:100%"), "FILL WIDTH frame expands: {f}");
2779 }
2780
2781 #[test]
2782 fn grid_rows_flatten_explicit_rows() {
2783 let mut opcodes = Vec::new();
2784 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::GRID as u32, 0));
2785 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::GRID_ROW as u32, 0));
2787 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::TEXT as u32, 0));
2788 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 4, component_type::TEXT as u32, 0));
2789 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 5, component_type::GRID_ROW as u32, 0));
2791 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 6, component_type::TEXT as u32, 0));
2792 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 7, component_type::TEXT as u32, 0));
2794 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, u32::MAX));
2795 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 2, 3, u32::MAX));
2796 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 2, 4, u32::MAX));
2797 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 5, u32::MAX));
2798 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 5, 6, u32::MAX));
2799 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 7, u32::MAX));
2800
2801 let renderer = HtmlRenderer::new();
2802 let html = renderer.render_document(&opcodes, &[], 1);
2803 assert!(html.contains("grid-template-columns:repeat(2,minmax(0,1fr))"), "widest row defines columns: {html}");
2805 assert!(html.contains("grid-row:1;grid-column:1;"), "cell at (0,0): {html}");
2807 assert!(html.contains("grid-row:1;grid-column:2;"), "cell at (0,1): {html}");
2808 assert!(html.contains("grid-row:2;grid-column:1;"), "short row's cell at (1,0): {html}");
2809 assert!(html.contains("grid-row:3;grid-column:1;"), "bare cell auto-flows to row 2: {html}");
2810 assert!(!html.contains("GRID_ROW"), "GRID_ROW is structural: {html}");
2812 }
2813
2814 #[test]
2815 fn grid_tracks_render_per_track_template() {
2816 use pathland_core::value_type;
2817
2818 let mut strings = Vec::new();
2819 strings.extend_from_slice(&(25u32).to_le_bytes());
2820 strings.extend_from_slice(b"flex,fixed:80,adaptive:50");
2821 let mut opcodes = Vec::new();
2822 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::GRID as u32, 0));
2823 opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 1,
2824 ((value_type::STRING as u32) << 16) | property_id::GRID_TRACKS as u32, 0));
2825 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
2826 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, u32::MAX));
2827
2828 let renderer = HtmlRenderer::new();
2829 let html = renderer.render_document(&opcodes, &strings, 1);
2830 assert!(html.contains("grid-template-columns:1fr 80px minmax(50px,1fr);"), "track spec: {html}");
2831 assert!(!html.contains("grid-template-columns:repeat"), "tracks override the count: {html}");
2832 }
2833
2834 #[test]
2835 fn zstack_2d_alignment_splits_h_and_v() {
2836 use pathland_core::value_type;
2837 let mut opcodes = Vec::new();
2838 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::ZSTACK as u32, 0));
2839 opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 1,
2841 ((value_type::F32 as u32) << 16) | property_id::ALIGNMENT as u32, 7.0f32.to_bits()));
2842 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
2843 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, u32::MAX));
2844
2845 let renderer = HtmlRenderer::new();
2846 let html = renderer.render_document(&opcodes, &[], 1);
2847 assert!(html.contains("justify-self:end;align-self:start;"), "topTrailing splits h/v: {html}");
2848 }
2849
2850 #[test]
2851 fn grid_cells_stretch_when_fill_or_greedy() {
2852 use pathland_core::value_type;
2853
2854 let mut opcodes = Vec::new();
2855 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::GRID as u32, 0));
2856 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
2857 opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 2,
2858 ((value_type::F32 as u32) << 16) | property_id::WIDTH as u32, pathland_core::size::FILL.to_bits()));
2859 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::COLOR as u32, 0));
2860 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 4, component_type::TEXT as u32, 0));
2861 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, u32::MAX));
2862 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 3, u32::MAX));
2863 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 4, u32::MAX));
2864
2865 let renderer = HtmlRenderer::new();
2866 let html = renderer.render_document(&opcodes, &[], 1);
2867 assert!(html.contains("justify-self:stretch;align-self:stretch;"), "greedy COLOR stretches: {html}");
2871 assert!(html.contains("justify-self:stretch;align-self:start;"), "FILL-width cell: {html}");
2872 assert!(html.contains("justify-self:start;align-self:start;"), "Hug cell positioned: {html}");
2873 }
2874
2875 #[test]
2876 fn scrollview_renders_only_the_first_child() {
2877 let mut opcodes = Vec::new();
2878 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::SCROLLVIEW as u32, 0));
2879 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
2880 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::TEXT as u32, 0));
2881 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, u32::MAX));
2882 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 3, u32::MAX));
2883
2884 let renderer = HtmlRenderer::new();
2885 let html = renderer.render_document(&opcodes, &[], 1);
2886 assert!(html.contains("data-pathland-id=\"2\""), "first child is the content: {html}");
2887 assert!(!html.contains("data-pathland-id=\"3\""), "extra children not rendered: {html}");
2888 }
2889
2890 #[test]
2891 fn renders_scrollview_and_zstack_containers() {
2892 let mut opcodes = Vec::new();
2893 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::SCROLLVIEW as u32, 0));
2894 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
2895 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
2896 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::ZSTACK as u32, 0));
2897 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 4, component_type::TEXT as u32, 0));
2898 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 3, 4, 0));
2899
2900 let renderer = HtmlRenderer::new();
2901 let scroll = renderer.render_document(&opcodes, &[], 1);
2902 assert!(scroll.contains("overflow:auto"), "scrollview inline");
2903 assert!(scroll.contains("<span data-pathland-id=\"2\"></span>"));
2904 let zstack = renderer.render_document(&opcodes, &[], 3);
2905 assert!(zstack.contains("display:grid"), "zstack grid inline");
2906 assert!(zstack.contains("isolation:isolate"), "zstack scopes shell z-indexes: {zstack}");
2907 assert!(zstack.contains("width:max-content"), "zstack hugs to its largest child");
2908 assert!(zstack.contains("grid-area:1/1;z-index:0;"), "shell carries an explicit default z-index: {zstack}");
2909 assert!(zstack.contains("justify-self:start"), "zstack child positioned per ALIGNMENT");
2910 assert!(zstack.contains("grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr);"),
2911 "zstack tracks shrink below content min-height (minmax(0,1fr)): {zstack}");
2912 }
2913
2914 #[test]
2915 fn zstack_child_z_index_lands_on_the_shell() {
2916 use pathland_core::value_type;
2917 let mut opcodes = Vec::new();
2918 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::ZSTACK as u32, 0));
2919 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
2920 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::TEXT as u32, 0));
2921 opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 2,
2924 ((value_type::F32 as u32) << 16) | property_id::Z_INDEX as u32, 5.0f32.to_bits()));
2925 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, u32::MAX));
2926 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 3, u32::MAX));
2927
2928 let renderer = HtmlRenderer::new();
2929 let html = renderer.render_document(&opcodes, &[], 1);
2930 assert!(html.contains("grid-area:1/1;z-index:5;"), "z-index on the child shell: {html}");
2931 assert!(html.contains("z-index:5"), "the child keeps its own z-index: {html}");
2932 assert!(html.contains("grid-area:1/1;z-index:0;"), "sibling shell defaults to 0: {html}");
2934 }
2935
2936 #[test]
2937 fn renders_progress_view_and_spinner() {
2938 use pathland_core::value_type;
2939
2940 let mut opcodes = Vec::new();
2941 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::PROGRESS_VIEW as u32, 0));
2942 opcodes.push(Opcode::new(
2943 category::PARAMETER,
2944 parameter::SET_PROPERTY,
2945 0,
2946 1,
2947 ((value_type::F32 as u32) << 16) | property_id::PROGRESS as u32,
2948 0.5f32.to_bits(),
2949 ));
2950 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::PROGRESS_VIEW as u32, 0));
2951 opcodes.push(Opcode::new(
2952 category::PARAMETER,
2953 parameter::SET_PROPERTY,
2954 0,
2955 2,
2956 ((value_type::U8 as u32) << 16) | property_id::IS_INDETERMINATE as u32,
2957 1,
2958 ));
2959
2960 let renderer = HtmlRenderer::new();
2961 assert!(renderer.render_document(&opcodes, &[], 1).contains("<progress"));
2962 assert!(renderer.render_document(&opcodes, &[], 2).contains("pathland-spinner"));
2963 }
2964
2965 #[test]
2966 fn renders_date_picker_from_set_date() {
2967 let mut opcodes = Vec::new();
2968 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::DATE_PICKER as u32, 0));
2969 opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_DATE, 0, 1, 19_723, 0));
2971
2972 let renderer = HtmlRenderer::new();
2973 let html = renderer.render_document(&opcodes, &[], 1);
2974 assert!(html.contains("<input data-pathland-id=\"1\" type=\"date\" value=\"2024-01-01\">"));
2975 }
2976
2977 #[test]
2978 fn renders_picker_from_child_options() {
2979 use pathland_core::value_type;
2980
2981 let mut opcodes = Vec::new();
2982 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::PICKER as u32, 0));
2983 let mut strings = Vec::new();
2984 strings.extend_from_slice(&(3u32).to_le_bytes());
2985 strings.extend_from_slice(b"Red");
2986 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
2987 opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 2, 0, 0));
2988 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
2989 opcodes.push(Opcode::new(
2990 category::PARAMETER,
2991 parameter::SET_PROPERTY,
2992 0,
2993 1,
2994 ((value_type::U32 as u32) << 16) | property_id::SELECTION as u32,
2995 0,
2996 ));
2997
2998 let renderer = HtmlRenderer::new();
2999 let html = renderer.render_document(&opcodes, &strings, 1);
3000 assert!(html.contains("<select"));
3001 assert!(html.contains("<option data-pathland-id=\"2\" value=\"0\" selected>Red</option>"));
3002 }
3003
3004 #[test]
3005 fn renders_color_picker_and_color_node() {
3006 use pathland_core::value_type;
3007
3008 let mut opcodes = Vec::new();
3009 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::COLOR_PICKER as u32, 0));
3010 opcodes.push(Opcode::new(
3011 category::PARAMETER,
3012 parameter::SET_PROPERTY,
3013 0,
3014 1,
3015 ((value_type::COLOR as u32) << 16) | property_id::COLOR_VALUE as u32,
3016 0xFF00_00FF,
3017 ));
3018 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::COLOR as u32, 0));
3019 opcodes.push(Opcode::new(
3020 category::PARAMETER,
3021 parameter::SET_PROPERTY,
3022 0,
3023 2,
3024 ((value_type::COLOR as u32) << 16) | property_id::COLOR as u32,
3025 0xFF00_00FF,
3026 ));
3027
3028 let renderer = HtmlRenderer::new();
3029 assert!(renderer.render_document(&opcodes, &[], 1).contains("<input data-pathland-id=\"1\" type=\"color\" value=\"#0000ff\">"));
3030 assert!(renderer.render_document(&opcodes, &[], 2).contains("background-color:rgba(0,0,255,1)"));
3031 }
3032
3033 #[test]
3034 fn button_composite_renders_custom_body() {
3035 let mut opcodes = Vec::new();
3036 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::BUTTON as u32, 0));
3037 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
3038 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
3039 let mut strings = Vec::new();
3040 strings.extend_from_slice(&(1u32).to_le_bytes());
3041 strings.extend_from_slice(b"A");
3042 opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 2, 0, 0));
3043
3044 let renderer = HtmlRenderer::new();
3045 let html = renderer.render_document(&opcodes, &strings, 1);
3046 assert!(html.contains("<button data-pathland-id=\"1\" class=\"pathland-button\">"), "button uses the design-system class");
3047 assert!(html.contains("<span data-pathland-id=\"2\">A</span>"));
3048 }
3049
3050 #[test]
3051 fn line_limit_clamps_text_to_n_lines() {
3052 use pathland_core::value_type;
3053
3054 let mut opcodes = Vec::new();
3055 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::TEXT as u32, 0));
3056 opcodes.push(Opcode::new(
3057 category::PARAMETER,
3058 parameter::SET_PROPERTY,
3059 0,
3060 1,
3061 ((value_type::U32 as u32) << 16) | property_id::LINE_LIMIT as u32,
3062 1,
3063 ));
3064 let mut strings = Vec::new();
3065 strings.extend_from_slice(&(2u32).to_le_bytes());
3066 strings.extend_from_slice(b"Hi");
3067 opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
3068
3069 let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
3070 assert!(
3071 html.contains("style=\"display:-webkit-box;overflow:hidden;-webkit-line-clamp:1;-webkit-box-orient:vertical;\""),
3072 "a positive LINE_LIMIT clamps the text to N lines: {html}"
3073 );
3074 let mut no_limit = Vec::new();
3076 no_limit.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::TEXT as u32, 0));
3077 no_limit.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
3078 assert!(
3079 !HtmlRenderer::new()
3080 .render_document(&no_limit, &strings, 1)
3081 .contains("line-clamp"),
3082 "no LINE_LIMIT means unlimited (no clamp)"
3083 );
3084 }
3085
3086 #[test]
3087 fn content_fitting_contract_c7_c10() {
3088 use pathland_core::value_type;
3089 let mut opcodes = Vec::new();
3093 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
3094 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
3095 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, pathland_core::APPEND));
3096 opcodes.push(Opcode::new(
3098 category::PARAMETER,
3099 parameter::SET_PROPERTY,
3100 0,
3101 2,
3102 ((value_type::F32 as u32) << 16) | property_id::WIDTH as u32,
3103 120.0f32.to_bits(),
3104 ));
3105 opcodes.push(Opcode::new(
3107 category::PARAMETER,
3108 parameter::SET_PROPERTY,
3109 0,
3110 2,
3111 ((value_type::U32 as u32) << 16) | property_id::LINE_LIMIT as u32,
3112 2,
3113 ));
3114 opcodes.push(Opcode::new(
3117 category::PARAMETER,
3118 parameter::SET_PROPERTY,
3119 0,
3120 2,
3121 ((value_type::F32 as u32) << 16) | property_id::TRUNCATION_MODE as u32,
3122 0.0f32.to_bits(),
3123 ));
3124 opcodes.push(Opcode::new(
3126 category::PARAMETER,
3127 parameter::SET_PROPERTY,
3128 0,
3129 2,
3130 ((value_type::U8 as u32) << 16) | property_id::CLIPS_TO_BOUNDS as u32,
3131 1,
3132 ));
3133 let html = HtmlRenderer::new().render_document(&opcodes, &[], 1);
3134 assert!(html.contains("width:120px"), "C7 fixed-width wraps: {html}");
3135 assert!(html.contains("line-clamp:2"), "C8 LINE_LIMIT clamps: {html}");
3136 assert!(html.contains("overflow:hidden"), "C10 clips to bounds: {html}");
3137 assert!(!html.contains("white-space:nowrap"), "C9 no forced single line");
3138 assert!(!html.contains("text-overflow"), "C9 no standalone ellipsis");
3139
3140 let mut trunc_alone = Vec::new();
3142 trunc_alone.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::TEXT as u32, 0));
3143 trunc_alone.push(Opcode::new(
3144 category::PARAMETER,
3145 parameter::SET_PROPERTY,
3146 0,
3147 1,
3148 ((value_type::F32 as u32) << 16) | property_id::TRUNCATION_MODE as u32,
3149 0.0f32.to_bits(),
3150 ));
3151 let html = HtmlRenderer::new().render_document(&trunc_alone, &[], 1);
3152 assert!(!html.contains("line-clamp"), "TRUNCATION alone does not clamp");
3153 assert!(!html.contains("white-space:nowrap"), "TRUNCATION alone does not force single-line");
3154 assert!(!html.contains("text-overflow"), "TRUNCATION alone emits no ellipsis");
3155 }
3156
3157 #[test]
3158 fn event_attrs_and_aria_are_emitted() {
3159 use pathland_core::value_type;
3160
3161 let mut opcodes = Vec::new();
3162 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::BUTTON as u32, 0));
3163 opcodes.push(Opcode::new(
3164 category::PARAMETER,
3165 parameter::SET_PROPERTY,
3166 0,
3167 1,
3168 ((value_type::U32 as u32) << 16) | property_id::EVENT_LISTENERS as u32,
3169 pathland_core::listener::POINTER_DOWN | pathland_core::listener::POINTER_UP,
3170 ));
3171 opcodes.push(Opcode::new(
3172 category::PARAMETER,
3173 parameter::SET_PROPERTY,
3174 0,
3175 1,
3176 ((value_type::U32 as u32) << 16) | property_id::ACTION_ID as u32,
3177 42,
3178 ));
3179 opcodes.push(Opcode::new(
3180 category::PARAMETER,
3181 parameter::SET_PROPERTY,
3182 0,
3183 1,
3184 ((value_type::U8 as u32) << 16) | property_id::ENABLED as u32,
3185 0,
3186 ));
3187
3188 let renderer = HtmlRenderer::new();
3189 let html = renderer.render_document(&opcodes, &[], 1);
3190 assert!(html.contains("data-event-listeners=\"5\""));
3191 assert!(html.contains("data-action-id=\"42\""));
3192 assert!(html.contains(" disabled"));
3193 }
3194
3195 #[test]
3196 fn alignment_maps_to_flex_cross_axis() {
3197 use pathland_core::value_type;
3198
3199 let mut opcodes = Vec::new();
3200 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
3201 opcodes.push(Opcode::new(
3202 category::PARAMETER,
3203 parameter::SET_PROPERTY,
3204 0,
3205 1,
3206 ((value_type::F32 as u32) << 16) | property_id::ALIGNMENT as u32,
3207 0.0f32.to_bits(), ));
3209
3210 let renderer = HtmlRenderer::new();
3211 let html = renderer.render_document(&opcodes, &[], 1);
3212 assert!(html.contains("flex-direction:column"), "stack inline flex"); assert!(html.contains("align-items:flex-start"), "alignment inline");
3213 }
3214
3215 #[test]
3216 fn layout_default_cross_axis_is_hug_not_stretch() {
3217 let mut opcodes = Vec::new();
3220 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
3221 let renderer = HtmlRenderer::new();
3222 let html = renderer.render_document(&opcodes, &[], 1);
3223 assert!(html.contains("align-items:flex-start"), "default is hug: {}", html);
3224 assert!(!html.contains("align-items:stretch"), "no implicit stretch");
3225 }
3226
3227 #[test]
3228 fn layout_fill_alignment_is_hug_positioning() {
3229 use pathland_core::value_type;
3230
3231 let mut opcodes = Vec::new();
3233 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
3234 opcodes.push(Opcode::new(
3235 category::PARAMETER,
3236 parameter::SET_PROPERTY,
3237 0,
3238 1,
3239 ((value_type::F32 as u32) << 16) | property_id::ALIGNMENT as u32,
3240 3.0f32.to_bits(),
3241 ));
3242 let renderer = HtmlRenderer::new();
3243 let html = renderer.render_document(&opcodes, &[], 1);
3244 assert!(html.contains("align-items:flex-start"), "Fill=3 → hug: {}", html);
3245 }
3246
3247 #[test]
3248 fn fit_slot_forwards_parent_alignment_into_its_box() {
3249 use pathland_core::value_type;
3250
3251 let mut opcodes = Vec::new();
3259 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
3260 opcodes.push(Opcode::new(
3262 category::PARAMETER,
3263 parameter::SET_PROPERTY,
3264 0,
3265 1,
3266 ((value_type::F32 as u32) << 16) | property_id::ALIGNMENT as u32,
3267 1.0f32.to_bits(),
3268 ));
3269 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::SIZE_THAT_FITS as u32, 0));
3270 let mut strings = Vec::new();
3272 strings.extend_from_slice(&2u32.to_le_bytes());
3273 strings.extend_from_slice(&0.0f32.to_le_bytes());
3274 strings.extend_from_slice(&640.0f32.to_le_bytes());
3275 opcodes.push(Opcode::new(
3276 category::PARAMETER,
3277 parameter::SET_PROPERTY,
3278 0,
3279 2,
3280 ((value_type::LIST as u32) << 16) | property_id::FIT_QUERY as u32,
3281 0,
3282 ));
3283 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::HSTACK as u32, 0));
3284 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
3285 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 2, 3, 0));
3286
3287 let renderer = HtmlRenderer::new();
3288 let html = renderer.render_document(&opcodes, &strings, 1);
3289 assert!(html.contains("align-items:center"), "parent VStack centers: {}", html);
3290 assert!(html.contains("data-pathland-fit=\"0,640\""), "fit mirror rides the slot: {}", html);
3291 let slot = "class=\"pathland-ftf\"";
3292 let pos_before = html.find(slot).expect("slot div");
3293 let snippet = &html[pos_before..html[pos_before..].find('>').unwrap() + pos_before + 1];
3294 assert!(
3295 snippet.contains("display:flex;")
3296 && snippet.contains("flex-direction:inherit;")
3297 && snippet.contains("align-items:inherit;")
3298 && snippet.contains("justify-content:inherit;")
3299 && snippet.contains("flex:1 1 auto;")
3300 && snippet.contains("align-self:stretch;")
3301 && snippet.contains("min-width:0;")
3302 && snippet.contains("min-height:0;"),
3303 "slot forwards the parent flex settings while staying size-taking: {snippet}"
3304 );
3305 assert!(!snippet.contains("align-items:stretch;"), "forwarded, not stretch-only: {snippet}");
3306 assert!(html.contains("display:flex;flex-direction:row;"), "the candidate HSTACK stays a row");
3307 }
3308
3309 #[test]
3310 fn snapshot_fit_slot_renders_all_candidates_with_container_query_rules() {
3311 use pathland_core::value_type;
3312
3313 let mut opcodes = Vec::new();
3321 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::SIZE_THAT_FITS as u32, 0));
3322 let mut strings = Vec::new();
3324 strings.extend_from_slice(&2u32.to_le_bytes());
3325 strings.extend_from_slice(&0.0f32.to_le_bytes());
3326 strings.extend_from_slice(&640.0f32.to_le_bytes());
3327 opcodes.push(Opcode::new(
3328 category::PARAMETER,
3329 parameter::SET_PROPERTY,
3330 0,
3331 1,
3332 ((value_type::LIST as u32) << 16) | property_id::FIT_QUERY as u32,
3333 0,
3334 ));
3335 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::HSTACK as u32, 0));
3337 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::VSTACK as u32, 0));
3338 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
3339 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 3, 1));
3340
3341 let renderer = HtmlRenderer::new();
3342 let html = renderer.render_document(&opcodes, &strings, 1);
3343 assert!(html.contains("container-type:inline-size;"), "slot is a container: {}", html);
3345 assert!(html.contains("container-name:pf-1;"), "slot has a unique container name: {}", html);
3346 assert!(html.contains("data-pathland-fit-cands=\"2\""), "candidate count mirrors: {}", html);
3348 assert!(html.contains("data-pathland-fit-cand=\"0\""), "candidate 0 wrapper: {}", html);
3349 assert!(html.contains("data-pathland-fit-cand=\"1\""), "candidate 1 wrapper: {}", html);
3350 assert!(html.contains("data-pathland-fit-cand-id=\"2\""), "wrapper carries candidate id 2: {}", html);
3351 assert!(html.contains("data-pathland-fit-cand-id=\"3\""), "wrapper carries candidate id 3: {}", html);
3352 assert!(html.contains("data-pathland-fit-cand=\"0\" data-pathland-fit-cand-id=\"2\" style=\"display:contents\""));
3354 assert!(html.contains("data-pathland-fit-cand=\"1\" data-pathland-fit-cand-id=\"3\" style=\"display:none\""));
3355 assert!(html.contains("<div data-pathland-id=\"2\" class=") || html.contains("<div data-pathland-id=\"2\""),
3357 "candidate content keeps its id: {}", html);
3358 let fit_style = html.find("<style data-pathland-fit>").expect("band rules style element");
3360 let style_close = html[fit_style..].find("</style>").unwrap() + fit_style;
3361 let rules = &html[fit_style..style_close];
3362 assert!(rules.contains("@container pf-1 (min-width: 640px)"), "band rule for candidate 1: {rules}");
3363 assert!(rules.contains("[data-pathland-id=\"1\"] > [data-pathland-fit-cand=\"0\"] { display: none !important; }"), "{rules}");
3365 assert!(rules.contains("[data-pathland-id=\"1\"] > [data-pathland-fit-cand=\"1\"] { display: contents !important; }"), "{rules}");
3366 assert!(!rules.contains("[data-pathland-fit-cand] { display: none"), "the hide selector never targets all candidates: {rules}");
3367 let mut single = Vec::new();
3369 single.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::SIZE_THAT_FITS as u32, 0));
3370 single.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 3, 0));
3371 single.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::VSTACK as u32, 0));
3372 let html_single = renderer.render_document(&single, &strings, 1);
3373 assert!(!html_single.contains("data-pathland-fit-cands"), "single-child form unchanged: {}", html_single);
3374 assert!(!html_single.contains("data-pathland-fit-cand"), "single-child form unchanged: {}", html_single);
3375 assert!(!html_single.contains("<style data-pathland-fit>"), "no band rules for a single child");
3376 }
3377
3378 #[test]
3379 fn snapshot_fit_slot_three_candidates_one_visible_ascending_bands() {
3380 use pathland_core::value_type;
3381
3382 let mut opcodes = Vec::new();
3386 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::SIZE_THAT_FITS as u32, 0));
3387 let mut strings = Vec::new();
3388 strings.extend_from_slice(&3u32.to_le_bytes());
3389 strings.extend_from_slice(&0.0f32.to_le_bytes());
3390 strings.extend_from_slice(&600.0f32.to_le_bytes());
3391 strings.extend_from_slice(&900.0f32.to_le_bytes());
3392 opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 1, ((value_type::LIST as u32) << 16) | property_id::FIT_QUERY as u32, 0));
3393 for (child, idx) in [(2u32, 0u32), (3, 1), (4, 2)] {
3394 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, child, component_type::VSTACK as u32, 0));
3395 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, child, idx));
3396 }
3397
3398 let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
3399 assert!(html.contains("data-pathland-fit-cands=\"3\""), "{html}");
3401 assert!(html.contains("data-pathland-fit-cand=\"0\" data-pathland-fit-cand-id=\"2\" style=\"display:contents\""), "{html}");
3402 assert!(html.contains("data-pathland-fit-cand=\"1\" data-pathland-fit-cand-id=\"3\" style=\"display:none\""), "{html}");
3403 assert!(html.contains("data-pathland-fit-cand=\"2\" data-pathland-fit-cand-id=\"4\" style=\"display:none\""), "{html}");
3404
3405 let fit_style = html.find("<style data-pathland-fit>").expect("band style element");
3406 let close = html[fit_style..].find("</style>").unwrap() + fit_style;
3407 let rules = &html[fit_style..close];
3408 assert!(rules.contains("@container pf-1 (min-width: 600px)"), "{rules}");
3409 assert!(rules.contains("@container pf-1 (min-width: 900px)"), "{rules}");
3410 assert!(!rules.contains("min-width: 0px"), "no band for T_0: {rules}");
3411 assert!(
3412 rules.find("min-width: 600px").unwrap() < rules.find("min-width: 900px").unwrap(),
3413 "bands ascending: {rules}"
3414 );
3415 assert!(rules.contains("[data-pathland-id=\"1\"] > [data-pathland-fit-cand=\"1\"] { display: contents !important; }"), "{rules}");
3417 assert!(rules.contains("[data-pathland-id=\"1\"] > [data-pathland-fit-cand=\"0\"] { display: none !important; }"), "{rules}");
3418 assert!(rules.contains("[data-pathland-id=\"1\"] > [data-pathland-fit-cand=\"2\"] { display: none !important; }"), "{rules}");
3419 assert!(rules.contains("[data-pathland-id=\"1\"] > [data-pathland-fit-cand=\"2\"] { display: contents !important; }"), "{rules}");
3421 assert!(!rules.contains("[data-pathland-fit-cand] { display: none"), "never a hide-all selector: {rules}");
3422 }
3423
3424 #[test]
3425 fn snapshot_fit_slot_invalid_table_degrades_to_fallback_child() {
3426 use pathland_core::value_type;
3427
3428 let mut opcodes = Vec::new();
3433 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::SIZE_THAT_FITS as u32, 0));
3434 let mut strings = Vec::new();
3435 strings.extend_from_slice(&1u32.to_le_bytes());
3436 strings.extend_from_slice(&0.0f32.to_le_bytes());
3437 opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 1, ((value_type::LIST as u32) << 16) | property_id::FIT_QUERY as u32, 0));
3438 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::VSTACK as u32, 0));
3439 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::VSTACK as u32, 0));
3440 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
3441 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 3, 1));
3442
3443 let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
3444 assert!(!html.contains("data-pathland-fit-cands"), "no gating: {html}");
3445 assert!(!html.contains("data-pathland-fit-cand"), "no wrappers: {html}");
3446 assert!(!html.contains("<style data-pathland-fit>"), "no bands: {html}");
3447 assert!(html.contains("data-pathland-id=\"2\""), "fallback child renders: {html}");
3448 assert!(!html.contains("data-pathland-id=\"3\""), "invalid candidates are not rendered ungated: {html}");
3449 }
3450
3451 #[test]
3452 fn fixed_child_keeps_points_box_in_hug_container() {
3453 use pathland_core::value_type;
3454
3455 let mut opcodes = Vec::new();
3458 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
3459 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::IMAGE as u32, 0));
3460 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, pathland_core::APPEND));
3461 opcodes.push(Opcode::new(
3462 category::PARAMETER,
3463 parameter::SET_PROPERTY,
3464 0,
3465 2,
3466 ((value_type::F32 as u32) << 16) | property_id::WIDTH as u32,
3467 220.0f32.to_bits(),
3468 ));
3469 opcodes.push(Opcode::new(
3470 category::PARAMETER,
3471 parameter::SET_PROPERTY,
3472 0,
3473 2,
3474 ((value_type::F32 as u32) << 16) | property_id::HEIGHT as u32,
3475 220.0f32.to_bits(),
3476 ));
3477 let renderer = HtmlRenderer::new();
3478 let html = renderer.render_document(&opcodes, &[], 1);
3479 assert!(html.contains("align-items:flex-start"), "hug container");
3480 assert!(html.contains("width:220px;height:220px"), "exact box: {}", html);
3481 }
3482
3483 #[test]
3484 fn divider_spans_cross_axis_and_scrollview_is_greedy() {
3485 let mut opcodes = Vec::new();
3488 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
3489 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::DIVIDER as u32, 0));
3490 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, pathland_core::APPEND));
3491 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::SCROLLVIEW as u32, 0));
3492 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 3, pathland_core::APPEND));
3493 let renderer = HtmlRenderer::new();
3494 let html = renderer.render_document(&opcodes, &[], 1);
3495 assert!(html.contains("width:100%"), "divider greedy: {}", html);
3496 assert!(
3497 html.contains("flex:1 1 auto;align-self:stretch;overflow:auto"),
3498 "scrollview greedy: {}",
3499 html
3500 );
3501 }
3502
3503 #[test]
3504 fn fill_propagates_through_a_hug_stack() {
3505 use pathland_core::{size, value_type};
3506 let mut opcodes = Vec::new();
3510 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
3511 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::BUTTON as u32, 0));
3512 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, pathland_core::APPEND));
3513 opcodes.push(Opcode::new(
3514 category::PARAMETER,
3515 parameter::SET_PROPERTY,
3516 0,
3517 2,
3518 ((value_type::F32 as u32) << 16) | property_id::WIDTH as u32,
3519 size::FILL.to_bits(),
3520 ));
3521 let renderer = HtmlRenderer::new();
3522 let html = renderer.render_document(&opcodes, &[], 1);
3523 assert!(html.contains("width:100%"), "fill propagation: {}", html);
3524 assert!(html.contains("align-self:stretch"), "cross-axis fill: {}", html);
3525 let mut hug = Vec::new();
3527 hug.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
3528 hug.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
3529 hug.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, pathland_core::APPEND));
3530 let html_hug = renderer.render_document(&hug, &[], 1);
3531 assert!(!html_hug.contains("width:100%"), "no propagation: {}", html_hug);
3532 }
3533
3534 #[test]
3535 fn text_field_secure_uses_password_input() {
3536 use pathland_core::value_type;
3537
3538 let mut opcodes = Vec::new();
3539 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::TEXT_FIELD as u32, 0));
3540 opcodes.push(Opcode::new(
3541 category::PARAMETER,
3542 parameter::SET_PROPERTY,
3543 0,
3544 1,
3545 ((value_type::U8 as u32) << 16) | property_id::IS_SECURE as u32,
3546 1,
3547 ));
3548
3549 let renderer = HtmlRenderer::new();
3550 let html = renderer.render_document(&opcodes, &[], 1);
3551 assert!(html.contains("<input class=\"pathland-input\" type=\"password\""));
3552 }
3553
3554 #[test]
3555 fn style_css_applies_color_font_and_visibility() {
3556 use pathland_core::value_type;
3557
3558 let mut opcodes = Vec::new();
3559 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::TEXT as u32, 0));
3560 opcodes.push(Opcode::new(
3561 category::PARAMETER,
3562 parameter::SET_PROPERTY,
3563 0,
3564 1,
3565 ((value_type::COLOR as u32) << 16) | property_id::COLOR as u32,
3566 0xFF11_2233,
3567 ));
3568 opcodes.push(Opcode::new(
3569 category::PARAMETER,
3570 parameter::SET_PROPERTY,
3571 0,
3572 1,
3573 ((value_type::F32 as u32) << 16) | property_id::FONT_SIZE as u32,
3574 18.0f32.to_bits(),
3575 ));
3576 opcodes.push(Opcode::new(
3577 category::PARAMETER,
3578 parameter::SET_PROPERTY,
3579 0,
3580 1,
3581 ((value_type::U8 as u32) << 16) | property_id::VISIBLE as u32,
3582 0,
3583 ));
3584
3585 let renderer = HtmlRenderer::new();
3586 let html = renderer.render_document(&opcodes, &[], 1);
3587 assert!(html.contains("color:rgba(17,34,51,1)"));
3588 assert!(html.contains("font-size:18px"), "font-size inline (dp): {}", html);
3589 assert!(html.contains("hidden"), "visible=0 -> hidden class");
3590 }
3591
3592 #[test]
3593 fn text_style_applies_default_typography_and_explicit_overrides_win() {
3594 use pathland_core::text_style;
3595 use pathland_core::value_type;
3596
3597 let render = |props: &[(u16, f32)]| {
3598 let mut opcodes = Vec::new();
3599 opcodes.push(Opcode::new(
3600 category::TREE,
3601 tree::CREATE_NODE,
3602 0,
3603 1,
3604 component_type::TEXT as u32,
3605 0,
3606 ));
3607 for (prop, value) in props {
3608 opcodes.push(Opcode::new(
3609 category::PARAMETER,
3610 parameter::SET_PROPERTY,
3611 0,
3612 1,
3613 ((value_type::F32 as u32) << 16) | *prop as u32,
3614 value.to_bits(),
3615 ));
3616 }
3617 HtmlRenderer::new().render_document(&opcodes, &[], 1)
3618 };
3619
3620 let html = render(&[(property_id::TEXT_STYLE, text_style::TITLE2 as f32)]);
3622 assert!(html.contains("<h3"), "TITLE2 is a heading: {}", html);
3623 assert!(html.contains("font-size:22px"), "style size: {}", html);
3624 assert!(html.contains("font-weight:700"), "style weight: {}", html);
3625
3626 let html = render(&[
3628 (property_id::TEXT_STYLE, text_style::TITLE2 as f32),
3629 (property_id::FONT_SIZE, 11.0),
3630 ]);
3631 assert!(html.contains("font-size:11px"), "explicit size wins: {}", html);
3632 }
3633
3634 #[test]
3635 fn style_css_expands_fill_hints_to_percent() {
3636 use pathland_core::size;
3637
3638 let mut opcodes = Vec::new();
3639 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::TEXT as u32, 0));
3640 opcodes.push(Opcode::new(
3641 category::PARAMETER,
3642 parameter::SET_PROPERTY,
3643 0,
3644 1,
3645 ((value_type::F32 as u32) << 16) | property_id::WIDTH as u32,
3646 size::FILL.to_bits(),
3647 ));
3648 opcodes.push(Opcode::new(
3649 category::PARAMETER,
3650 parameter::SET_PROPERTY,
3651 0,
3652 1,
3653 ((value_type::F32 as u32) << 16) | property_id::HEIGHT as u32,
3654 size::FILL.to_bits(),
3655 ));
3656
3657 let renderer = HtmlRenderer::new();
3661 let html = renderer.render_document(&opcodes, &[], 1);
3662 assert!(html.contains("width:100%;height:100%;"), "FILL expands: {}", html);
3663 }
3664
3665 #[test]
3666 fn debug_comments_are_opt_in() {
3667 let mut opcodes = Vec::new();
3668 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
3669 opcodes.push(Opcode::new(
3670 category::PARAMETER,
3671 parameter::SET_PROPERTY,
3672 0,
3673 1,
3674 ((value_type::F32 as u32) << 16) | property_id::SPACING as u32,
3675 4.0f32.to_bits(),
3676 ));
3677 opcodes.push(Opcode::new(
3678 category::PARAMETER,
3679 parameter::SET_PROPERTY,
3680 0,
3681 1,
3682 ((value_type::F32 as u32) << 16) | property_id::ALIGNMENT as u32,
3683 3.0f32.to_bits(),
3684 ));
3685 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
3686 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
3687
3688 let renderer = HtmlRenderer::new();
3690 let html = renderer.render_document(&opcodes, &[], 1);
3691 assert!(!html.contains("<!--"), "comments off by default: {html}");
3692
3693 let renderer = HtmlRenderer::new().with_debug_comments(true);
3696 assert!(renderer.debug_comments());
3697 let html = renderer.render_document(&opcodes, &[], 1);
3698 assert!(
3699 html.contains("<!-- #1 VStack: spacing=4, alignment=Fill -->"),
3700 "{html}"
3701 );
3702 assert!(html.contains("<!-- #2 Text -->"), "{html}");
3703 }
3704
3705 #[test]
3706 fn builtin_css_block_contains_reset_tokens_and_components() {
3707 let css = css::STYLE;
3708 assert!(css.contains("box-sizing: border-box"), "preflight reset present");
3710 assert!(css.contains("--pl-color-primary"), "primary token");
3712 assert!(css.contains("--pl-color-background"), "background token");
3713 assert!(css.contains("--pl-color-surface"), "surface token");
3714 assert!(css.contains("--pl-color-text-primary"), "text token");
3715 assert!(css.contains("--pl-color-text-secondary"), "muted/secondary text token");
3716 assert!(css.contains("--pl-color-border"), "border token");
3717 assert!(css.contains("--pl-font-sans"), "font token");
3718 assert!(css.contains(".pathland-button"), "button component");
3720 assert!(css.contains(".pathland-toggle"), "toggle component");
3721 assert!(css.contains(".pathland-spinner"), "spinner component");
3722 assert!(css.contains(".pathland-gauge"), "gauge component");
3723 assert!(css.contains("prefers-color-scheme: dark"), "dark mode");
3725 assert!(css.contains("font-feature-settings: 'liga' 1, 'calt' 1"), "Chrome ligature fix");
3728 assert!(css.contains("--pl-font-body-family: system-ui, -apple-system"), "system font stack");
3729 assert!(!css.contains("Inter"), "no bundled/external font family");
3730 assert!(css.contains("--pl-color-background"), "page background token");
3732 assert!(css.contains("color-scheme: light dark"), "native form controls follow the theme");
3733 assert!(css.contains(".pathland-input"), "input component");
3734 assert!(css.contains("--pl-input-border"), "input border token");
3735 assert!(css.contains("outline: 1px solid var(--pl-input-border)"), "inset outline instead of border");
3736 assert!(css.contains("outline-offset: -1px"), "inset ring");
3737 assert!(css.contains("--pl-input-focus"), "input focus token");
3738 }
3739
3740 #[test]
3741 fn builtin_css_covers_tier1_default_tokens() {
3742 let css = css::STYLE;
3743 assert!(css.contains("--pl-space-base"), "space.base token");
3746 assert_eq!(count(css, "--pl-space-base"), 2, "space.base has a light + dark default");
3747 for var in [
3749 "--pl-font-body-size",
3750 "--pl-font-body-weight",
3751 "--pl-font-body-family",
3752 ] {
3753 assert!(css.contains(var), "missing {var}");
3754 }
3755 for var in [
3757 "--pl-control-background",
3758 "--pl-control-foreground",
3759 "--pl-control-border",
3760 "--pl-control-border-width",
3761 "--pl-control-radius",
3762 "--pl-control-height",
3763 "--pl-control-accent",
3764 "--pl-button-background",
3765 "--pl-button-background-hover",
3766 "--pl-button-foreground",
3767 "--pl-button-border",
3768 "--pl-button-radius",
3769 "--pl-button-padding",
3770 "--pl-button-font-size",
3771 "--pl-button-font-weight",
3772 "--pl-input-background",
3773 "--pl-input-foreground",
3774 "--pl-input-placeholder",
3775 "--pl-input-border",
3776 "--pl-input-radius",
3777 "--pl-input-font-size",
3778 ] {
3779 assert!(css.contains(var), "missing canonical token {var}");
3780 }
3781 for var in [
3783 "--pl-radius-xs",
3784 "--pl-radius-sm",
3785 "--pl-radius-md",
3786 "--pl-radius-lg",
3787 "--pl-radius-xl",
3788 "--pl-radius-full",
3789 "--pl-border-width-thin",
3790 ] {
3791 assert!(css.contains(var), "missing {var}");
3792 }
3793 for var in [
3795 "--pl-elevation-low-color",
3796 "--pl-elevation-low-x",
3797 "--pl-elevation-low-y",
3798 "--pl-elevation-low-blur",
3799 "--pl-elevation-high-color",
3800 ] {
3801 assert!(css.contains(var), "missing {var}");
3802 }
3803 assert!(!css.contains("--pl-color-focus-ring"), "focus ring now maps to control.accent");
3804 assert!(!css.contains("--pl-button-bg"), "internal button vars mapped onto button.*");
3805 assert!(!css.contains("--pl-shadow-sm"), "shadow-sm mapped onto elevation.low.*");
3806 }
3807
3808 #[test]
3809 fn builtin_css_canonical_rules_reference_the_catalog() {
3810 let css = css::STYLE;
3811 assert!(css.contains("border-radius: var(--pl-button-radius)"), "button radius from button.*");
3812 assert!(css.contains("background-color: var(--pl-button-background)"), "button bg from button.*");
3813 assert!(css.contains("color: var(--pl-button-foreground)"), "button fg from button.*");
3814 assert!(css.contains("background-color: var(--pl-input-background)"), "input bg from input.*");
3815 assert!(css.contains("color: var(--pl-input-foreground)"), "input fg from input.*");
3816 assert!(css.contains("outline: 2px solid var(--pl-control-accent)"), "focus ring uses control.accent");
3817 assert!(css.contains("var(--pl-elevation-low-x)"), "button shadow uses elevation.low.*");
3818 assert!(css.contains("var(--pl-elevation-high-x)"), "menu shadow uses elevation.high.*");
3819 }
3820
3821 fn count(haystack: &str, needle: &str) -> usize {
3822 haystack.matches(needle).count()
3823 }
3824
3825 #[test]
3826 fn document_head_loads_no_external_fonts() {
3827 let renderer = HtmlRenderer::new();
3828 let html = renderer.render_document(&[], &[], 0);
3829 assert!(!html.contains("rsms.me"), "no font CDN link: {html}");
3830 assert!(!html.contains("preconnect"), "no preconnect: {html}");
3831 assert!(
3832 !html.contains("<link rel=\"stylesheet\""),
3833 "no external stylesheets (fonts are system-native): {html}"
3834 );
3835 let style = html.find("<style>").unwrap();
3837 let body = html.find("<body>").unwrap();
3838 assert!(style < body, "style block before body");
3839 }
3840
3841 #[test]
3842 fn renders_stack_with_gap_and_structural_child_inline() {
3843 use pathland_core::value_type;
3844
3845 let mut opcodes = Vec::new();
3846 let mut strings = Vec::new();
3847 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
3848 opcodes.push(Opcode::new(
3849 category::PARAMETER,
3850 parameter::SET_PROPERTY,
3851 0,
3852 1,
3853 ((value_type::F32 as u32) << 16) | property_id::SPACING as u32,
3854 12.0f32.to_bits(),
3855 ));
3856 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
3857 opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
3858 opcodes.push(Opcode::new(
3859 category::PARAMETER,
3860 parameter::SET_PROPERTY,
3861 0,
3862 2,
3863 ((value_type::F32 as u32) << 16) | property_id::PADDING as u32,
3864 8.0f32.to_bits(),
3865 ));
3866 strings.extend_from_slice(&(2u32).to_le_bytes());
3867 strings.extend_from_slice(b"Hi");
3868 opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 2, 0, 0));
3869
3870 let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
3871 assert!(html.contains("flex-direction:column"), "stack inline flex");
3872 assert!(html.contains("gap:12px"), "spacing inline (dp): {}", html);
3873 assert!(html.contains("padding:8px"), "padding inline (dp): {}", html);
3874 }
3875
3876 #[test]
3877 fn renders_decorative_typography_inline_and_enum_class() {
3878 use pathland_core::value_type;
3879
3880 let mut opcodes = Vec::new();
3881 let mut strings = Vec::new();
3882 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::TEXT as u32, 0));
3883 for (prop, vt, value) in [
3884 (property_id::FONT_SIZE, value_type::F32, 18.0f32.to_bits()),
3885 (property_id::FONT_WEIGHT, value_type::F32, 700.0f32.to_bits()),
3886 (property_id::TEXT_ALIGNMENT, value_type::F32, 1.0f32.to_bits()),
3887 (property_id::BORDER_RADIUS, value_type::F32, 8.0f32.to_bits()),
3888 (property_id::BLUR_RADIUS, value_type::F32, 3.0f32.to_bits()),
3889 (property_id::ROTATION_DEGREES, value_type::F32, 90.0f32.to_bits()),
3890 ] {
3891 opcodes.push(Opcode::new(
3892 category::PARAMETER,
3893 parameter::SET_PROPERTY,
3894 0,
3895 1,
3896 ((vt as u32) << 16) | prop as u32,
3897 value,
3898 ));
3899 }
3900 strings.extend_from_slice(&(2u32).to_le_bytes());
3901 strings.extend_from_slice(b"Hi");
3902 opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
3903
3904 let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
3905 assert!(html.contains("font-size:18px"), "font-size inline: {}", html);
3907 assert!(html.contains("font-weight:700"), "font-weight inline");
3908 assert!(html.contains("border-radius:8px"), "border-radius inline");
3909 assert!(html.contains("blur(3px)"), "blur inline filter");
3910 assert!(html.contains("rotate(90deg)"), "rotation inline transform");
3911 assert!(html.contains("text-align:center"), "text-align inline");
3912 }
3913
3914 fn string_section(entries: &[&str]) -> Vec<u8> {
3915 let mut v = Vec::new();
3916 for s in entries {
3917 v.extend_from_slice(&(s.len() as u32).to_le_bytes());
3918 v.extend_from_slice(s.as_bytes());
3919 }
3920 v
3921 }
3922
3923 #[test]
3924 fn set_design_token_emits_base_and_dark_override_css() {
3925 let strings = string_section(&["color.primary", "dark.color.primary"]);
3926 let dark_offset = (4 + 13) as u32; let opcodes = vec![
3928 Opcode::new(category::PARAMETER, parameter::SET_DESIGN_TOKEN, 0, 0, value_type::COLOR as u32, 0xFF_2563EB),
3929 Opcode::new(
3930 category::PARAMETER,
3931 parameter::SET_DESIGN_TOKEN,
3932 0,
3933 dark_offset,
3934 value_type::COLOR as u32,
3935 0xFF_60A5FA,
3936 ),
3937 ];
3938 let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
3939 assert!(html.contains(":root{--pl-color-primary:rgba(37,99,235,1);}"), "base override: {html}");
3940 assert!(
3941 html.contains("@media (prefers-color-scheme: dark){:root{--pl-color-primary:rgba(96,165,250,1);}}"),
3942 "dark override scoped in media query: {html}"
3943 );
3944 }
3945
3946 #[test]
3947 fn design_token_css_renders_inside_a_style_element() {
3948 let strings = string_section(&["color.primary", "dark.color.primary"]);
3951 let dark_offset = (4 + 13) as u32; let opcodes = vec![
3953 Opcode::new(category::PARAMETER, parameter::SET_DESIGN_TOKEN, 0, 0, value_type::COLOR as u32, 0xFF_2563EB),
3954 Opcode::new(
3955 category::PARAMETER,
3956 parameter::SET_DESIGN_TOKEN,
3957 0,
3958 dark_offset,
3959 value_type::COLOR as u32,
3960 0xFF_60A5FA,
3961 ),
3962 ];
3963 let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
3964
3965 assert!(
3967 html.contains("<style data-pathland-tokens>:root{--pl-color-primary:rgba(37,99,235,1);}"),
3968 "base rule inside the token style element: {html}"
3969 );
3970 assert!(
3971 html.contains("rgba(96,165,250,1);}}</style>"),
3972 "token style element is closed after the dark media query: {html}"
3973 );
3974 assert!(
3975 !html.contains("</style>:root") && !html.contains("</style>@media"),
3976 "no bare token rules outside a style element: {html}"
3977 );
3978
3979 let plain = HtmlRenderer::new().render_document(&[], &[], 0);
3981 assert!(
3982 !plain.contains("data-pathland-tokens"),
3983 "no token style element without overrides: {plain}"
3984 );
3985 }
3986
3987 #[test]
3988 fn set_design_token_registers_a_length_token_with_px() {
3989 let strings = string_section(&["space.base"]);
3990 let opcodes = vec![Opcode::new(
3991 category::PARAMETER,
3992 parameter::SET_DESIGN_TOKEN,
3993 0,
3994 0,
3995 value_type::F32 as u32,
3996 4.0f32.to_bits(),
3997 )];
3998 let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
3999 assert!(html.contains(":root{--pl-space-base:4px;}"), "length token px: {html}");
4000 }
4001
4002 #[test]
4003 fn string_valued_design_token_emits_quoted_css() {
4004 let strings = string_section(&["font.body.family", "Inter"]);
4007 let opcodes = vec![Opcode::new(
4008 category::PARAMETER,
4009 parameter::SET_DESIGN_TOKEN,
4010 0,
4011 0,
4012 value_type::STRING as u32,
4013 20,
4014 )];
4015 let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
4016 assert!(html.contains(":root{--pl-font-body-family:'Inter';}"), "string token: {html}");
4017 }
4018
4019 #[test]
4020 fn design_token_property_ref_renders_var() {
4021 let strings = string_section(&["color.primary"]);
4022 let opcodes = vec![
4023 Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::TEXT as u32, 0),
4024 Opcode::new(
4025 category::PARAMETER,
4026 parameter::SET_PROPERTY,
4027 0,
4028 1,
4029 ((value_type::DESIGN_TOKEN as u32) << 16) | property_id::COLOR as u32,
4030 0,
4031 ),
4032 ];
4033 let html = HtmlRenderer::new().render_fragment(&opcodes, &strings, 1);
4034 assert!(html.contains("color:var(--pl-color-primary);"), "token ref: {html}");
4035 }
4036
4037 #[test]
4038 fn generative_space_ref_renders_calc() {
4039 let strings = string_section(&["space.2"]);
4040 let opcodes = vec![
4041 Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0),
4042 Opcode::new(
4043 category::PARAMETER,
4044 parameter::SET_PROPERTY,
4045 0,
4046 1,
4047 ((value_type::DESIGN_TOKEN as u32) << 16) | property_id::SPACING as u32,
4048 0,
4049 ),
4050 ];
4051 let html = HtmlRenderer::new().render_fragment(&opcodes, &strings, 1);
4052 assert!(
4053 html.contains("gap:calc(var(--pl-space-base) * 2);"),
4054 "generative space ref: {html}"
4055 );
4056 }
4057}