From bcf22b84629509ce4ca8ff821bbdd7e818c83473 Mon Sep 17 00:00:00 2001 From: rorychatt Date: Mon, 10 Aug 2026 11:16:49 +0200 Subject: [PATCH 1/3] [00133] Add the rusty-xaml crate: runtime XAML parser and widget builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parses XAML markup into `rusty` widget trees at runtime, where `rusty-ivyml` does the same job at compile time. The vocabulary is a table for the same reason `codegen::shape_for` is: the constructors are not uniform, `List` attaches children with `.item`, and some attributes are consumed by the constructor. Nothing is dropped silently — an unknown element, an unknown attribute, an unparseable value or a child on a leaf is a `XamlError` carrying the element, the attribute and the line it came from. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 20 + Cargo.toml | 12 +- rusty-xaml/Cargo.toml | 20 + rusty-xaml/src/build.rs | 1136 +++++++++++++++++++++++++++++++++++++ rusty-xaml/src/context.rs | 143 +++++ rusty-xaml/src/error.rs | 262 +++++++++ rusty-xaml/src/lib.rs | 110 ++++ rusty-xaml/src/value.rs | 431 ++++++++++++++ 8 files changed, 2133 insertions(+), 1 deletion(-) create mode 100644 rusty-xaml/Cargo.toml create mode 100644 rusty-xaml/src/build.rs create mode 100644 rusty-xaml/src/context.rs create mode 100644 rusty-xaml/src/error.rs create mode 100644 rusty-xaml/src/lib.rs create mode 100644 rusty-xaml/src/value.rs diff --git a/Cargo.lock b/Cargo.lock index 5413dea..a0fba5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2078,6 +2078,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -2187,6 +2196,17 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "rusty-xaml" +version = "0.1.0" +dependencies = [ + "roxmltree", + "rusty", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "ryu" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 7ee0431..9401a38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,14 @@ [workspace] -members = ["rusty", "rusty-filter", "rusty-macros", "rusty-ivyml", "rusty-server", "rusty-docs", "rusty-desktop"] +members = [ + "rusty", + "rusty-filter", + "rusty-macros", + "rusty-ivyml", + "rusty-xaml", + "rusty-server", + "rusty-docs", + "rusty-desktop", +] resolver = "2" [workspace.package] @@ -21,3 +30,4 @@ tower-http = { version = "0.6", features = ["fs"] } clap = { version = "4", features = ["derive", "env"] } tokio-tungstenite = "0.29" bytes = "1" +roxmltree = "0.21" diff --git a/rusty-xaml/Cargo.toml b/rusty-xaml/Cargo.toml new file mode 100644 index 0000000..4e8fed5 --- /dev/null +++ b/rusty-xaml/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "rusty-xaml" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Parses XAML UI documents into Rusty widget trees at runtime" + +[dependencies] +rusty = { path = "../rusty" } +roxmltree.workspace = true +# `value.rs` deserializes XAML enum words straight into `rusty`'s enums, which +# needs the `DeserializeOwned` bound that `serde_json` does not re-export. +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +# For `examples/xaml_counter.rs`, which serves the parsed tree like the +# examples in `rusty/examples`. +tokio.workspace = true diff --git a/rusty-xaml/src/build.rs b/rusty-xaml/src/build.rs new file mode 100644 index 0000000..53d56bf --- /dev/null +++ b/rusty-xaml/src/build.rs @@ -0,0 +1,1136 @@ +//! The XAML vocabulary, and how it maps onto `rusty::widgets`. +//! +//! This is the runtime counterpart of `rusty-ivyml`'s `codegen::shape_for`, and +//! it is a table for the same reason: the constructors are not uniform +//! (`Layout::vertical()` vs `TextBlock::new(content)` vs `Card::new()`), `List` +//! attaches children with `.item` rather than `.child`, and several attributes +//! are consumed by the constructor instead of by a builder call. None of that +//! can be derived from a tag name. +//! +//! Two rules hold everywhere: +//! +//! - Nothing is dropped silently. An unknown element, an unknown attribute, an +//! unparseable value, a child on a leaf — each is an error. A XAML document +//! that looks styled and renders unstyled is the harder bug to find. +//! - Every XAML spelling has its Rusty spelling as an accepted alias, so a tree +//! can be written either way (`` or ``, `Spacing` or +//! `Gap`). + +use roxmltree::{Document, Node}; + +use rusty::views::Element; +use rusty::widgets::badge::BadgeVariant; +use rusty::widgets::button::ButtonVariant; +use rusty::widgets::text::TextVariant; +use rusty::widgets::{ + Badge, Button, Card, Container, Image, Layout, List, ListItem, Progress, Separator, Spacer, + TextBlock, TextInput, +}; + +use crate::context::{Handler, XamlContext}; +use crate::error::{Position, XamlError}; +use crate::value::{self, Site}; + +/// The namespace `x:Name`, `x:Key` and the other XAML directives live in. +/// Attributes in it are ignored — see the crate docs for why `x:Name` in +/// particular cannot become a widget id. +const XAML_DIRECTIVE_NS: &str = "http://schemas.microsoft.com/winfx/2006/xaml"; + +/// One attribute, with its value still unresolved. +#[derive(Debug)] +struct Attr { + name: String, + raw: String, + pos: Position, +} + +/// An element's attributes, minus the ones a constructor has taken. +/// +/// `take` removes, so whatever is left after the constructor runs is exactly the +/// set that must map onto builder calls — the runtime form of `codegen.rs`'s +/// `consumed_by_ctor` list. +#[derive(Debug)] +struct Attrs { + /// The element's own position, used for errors about a missing attribute. + pos: Position, + list: Vec, +} + +impl Attrs { + fn take(&mut self, names: &[&str]) -> Option { + let index = self + .list + .iter() + .position(|a| names.contains(&a.name.as_str()))?; + Some(self.list.remove(index)) + } + + fn require(&mut self, element: &str, names: &[&str]) -> Result { + self.take(names).ok_or_else(|| XamlError::MissingAttribute { + element: element.to_string(), + attribute: names[0].to_string(), + pos: self.pos, + }) + } + + /// The first attribute that was not consumed, for widgets that take none. + fn any(&self) -> Option<&Attr> { + self.list.first() + } +} + +/// One parse in progress: the document (for positions) and the runtime data. +struct Build<'a, 'input> { + doc: &'a Document<'input>, + ctx: &'a XamlContext, +} + +/// Parse the document's root element into a widget tree. +pub(crate) fn build_root(doc: &Document<'_>, ctx: &XamlContext) -> Result { + // Not `Document::root_element`, which panics when there is no element. + let root = doc + .root() + .children() + .find(|node| node.is_element()) + .ok_or(XamlError::NoRoot)?; + + Build { doc, ctx }.element(root) +} + +impl Build<'_, '_> { + fn element(&self, node: Node<'_, '_>) -> Result { + let name = node.tag_name().name(); + let pos = self.position(node.range().start); + let mut attrs = self.attrs(node, name, pos)?; + let text = text_content(node); + let children: Vec> = node.children().filter(|n| n.is_element()).collect(); + + match name { + "StackPanel" | "Layout" => { + let layout = match attrs.take(&["Orientation", "Direction"]) { + None => Layout::vertical(), + Some(attr) => match self.text_of(&attr, name)?.as_str() { + "Vertical" => Layout::vertical(), + other => { + if other == "Horizontal" { + Layout::horizontal() + } else { + return Err(site(name, &attr).unsupported( + other, + "expected `Vertical` or `Horizontal`; \ + a grid is ``", + )); + } + } + }, + }; + self.layout(layout, name, attrs, text, &children) + } + "Grid" => { + let attr = attrs.require(name, &["Columns"])?; + let columns = value::as_usize(&self.resolve(&attr, name)?, site(name, &attr))?; + self.layout(Layout::grid(columns), name, attrs, text, &children) + } + "WrapPanel" => self.layout( + Layout::horizontal().wrap(true), + name, + attrs, + text, + &children, + ), + "Border" | "Container" => { + reject_text(name, &text, pos)?; + let mut widget = Container::new(); + for attr in &attrs.list { + widget = self.container_attr(widget, attr, name)?; + } + for child in &children { + widget = widget.child(self.element(*child)?); + } + Ok(widget.into()) + } + "GroupBox" | "Card" => { + reject_text(name, &text, pos)?; + let mut widget = Card::new(); + for attr in &attrs.list { + widget = self.card_attr(widget, attr, name)?; + } + for child in &children { + widget = widget.child(self.element(*child)?); + } + Ok(widget.into()) + } + "ItemsControl" | "ListView" | "List" => { + reject_text(name, &text, pos)?; + // `List` holds only `items`; it has no builder to apply. + if let Some(attr) = attrs.any() { + return Err(unknown_attribute(name, &attr.name, attr.pos)); + } + let mut widget = List::new(); + for child in &children { + // `.item`, not `.child`: see `List` in `rusty::widgets::list`. + widget = widget.item(self.element(*child)?); + } + Ok(widget.into()) + } + "ListViewItem" | "ListItem" => { + reject_children(name, &children, pos)?; + let title = self.content(name, &mut attrs, &["Content", "Title"], &text)?; + let mut widget = ListItem::new(&title); + for attr in &attrs.list { + widget = self.list_item_attr(widget, attr, name)?; + } + Ok(widget.into()) + } + "TextBlock" => { + reject_children(name, &children, pos)?; + let content = self.content(name, &mut attrs, &["Text", "Content"], &text)?; + let mut widget = TextBlock::new(&content); + for attr in &attrs.list { + widget = self.text_block_attr(widget, attr, name)?; + } + Ok(widget.into()) + } + "Button" => { + reject_children(name, &children, pos)?; + let title = self.content(name, &mut attrs, &["Content", "Title"], &text)?; + let mut widget = Button::new(&title); + for attr in &attrs.list { + widget = self.button_attr(widget, attr, name)?; + } + Ok(widget.into()) + } + "Badge" => { + reject_children(name, &children, pos)?; + let label = self.content(name, &mut attrs, &["Content", "Label"], &text)?; + let mut widget = Badge::new(&label); + for attr in &attrs.list { + widget = self.badge_attr(widget, attr, name)?; + } + Ok(widget.into()) + } + "TextBox" | "TextInput" => { + reject_children(name, &children, pos)?; + reject_text(name, &text, pos)?; + let mut widget = TextInput::new(); + for attr in &attrs.list { + widget = self.text_input_attr(widget, attr, name)?; + } + Ok(widget.into()) + } + "Image" => { + reject_children(name, &children, pos)?; + reject_text(name, &text, pos)?; + let source = attrs.require(name, &["Source"])?; + let mut widget = Image::new(&self.text_of(&source, name)?); + for attr in &attrs.list { + widget = self.image_attr(widget, attr, name)?; + } + Ok(widget.into()) + } + "ProgressBar" | "Progress" => { + reject_children(name, &children, pos)?; + reject_text(name, &text, pos)?; + let attr = attrs.require(name, &["Value"])?; + let value = value::as_f64(&self.resolve(&attr, name)?, site(name, &attr))?; + let mut widget = Progress::new(value); + for attr in &attrs.list { + widget = self.progress_attr(widget, attr, name)?; + } + Ok(widget.into()) + } + "Separator" => { + reject_children(name, &children, pos)?; + reject_text(name, &text, pos)?; + let mut widget = match attrs.take(&["Orientation"]) { + None => Separator::horizontal(), + Some(attr) => match self.text_of(&attr, name)?.as_str() { + "Horizontal" => Separator::horizontal(), + other => { + if other == "Vertical" { + Separator::vertical() + } else { + return Err(site(name, &attr) + .unsupported(other, "expected `Horizontal` or `Vertical`")); + } + } + }, + }; + for attr in &attrs.list { + widget = match attr.name.as_str() { + "Text" => widget.text(&self.text_of(attr, name)?), + _ => return Err(unknown_attribute(name, &attr.name, attr.pos)), + }; + } + Ok(widget.into()) + } + "Spacer" => { + reject_children(name, &children, pos)?; + reject_text(name, &text, pos)?; + if let Some(attr) = attrs.any() { + return Err(unknown_attribute(name, &attr.name, attr.pos)); + } + Ok(Spacer::new().into()) + } + other => Err(XamlError::UnknownElement { + element: other.to_string(), + pos, + }), + } + } + + /// `StackPanel`, `Grid` and `WrapPanel` differ only in their constructor. + fn layout( + &self, + mut widget: Layout, + element: &str, + attrs: Attrs, + text: Option, + children: &[Node<'_, '_>], + ) -> Result { + reject_text(element, &text, attrs.pos)?; + for attr in &attrs.list { + widget = self.layout_attr(widget, attr, element)?; + } + for child in children { + widget = widget.child(self.element(*child)?); + } + Ok(widget.into()) + } + + fn layout_attr(&self, w: Layout, attr: &Attr, element: &str) -> Result { + let site = site(element, attr); + let value = self.resolve(attr, element)?; + Ok(match attr.name.as_str() { + "Spacing" | "Gap" => w.gap(value::as_f64(&value, site)?), + "Padding" => w.padding(value::as_f64(&value, site)?), + "HorizontalAlignment" | "Align" => w.align(value::as_align(&value, site)?), + "VerticalAlignment" | "Justify" => w.justify(value::as_justify(&value, site)?), + "Width" => w.width(value::as_size(&value, site)?), + "Height" => w.height(value::as_size(&value, site)?), + "Wrap" => w.wrap(value::as_bool(&value, site)?), + _ => return Err(unknown_attribute(element, &attr.name, attr.pos)), + }) + } + + fn container_attr( + &self, + w: Container, + attr: &Attr, + element: &str, + ) -> Result { + let site = site(element, attr); + let value = self.resolve(attr, element)?; + Ok(match attr.name.as_str() { + "Padding" => w.padding(value::as_f64(&value, site)?), + "Width" => w.width(value::as_size(&value, site)?), + "Height" => w.height(value::as_size(&value, site)?), + // `Container` is the one widget with a background, so `Background` is + // honoured here and rejected everywhere else. + "Background" => w.background(value::as_color(&value, site)?), + "Border" => w.border(value::as_bool(&value, site)?), + "Rounded" => w.rounded(value::as_bool(&value, site)?), + _ => return Err(unknown_attribute(element, &attr.name, attr.pos)), + }) + } + + fn card_attr(&self, w: Card, attr: &Attr, element: &str) -> Result { + let site = site(element, attr); + let value = self.resolve(attr, element)?; + Ok(match attr.name.as_str() { + // `Card::new()` takes no argument, so a `GroupBox`'s `Header` is a + // builder call rather than a constructor argument. + "Header" | "Title" => w.title(&value::as_text(&value)), + "Subtitle" => w.subtitle(&value::as_text(&value)), + "Padding" => w.padding(value::as_f64(&value, site)?), + _ => return Err(unknown_attribute(element, &attr.name, attr.pos)), + }) + } + + fn list_item_attr( + &self, + w: ListItem, + attr: &Attr, + element: &str, + ) -> Result { + if attr.name == "Click" { + let handler = self.handler(attr, element)?; + return Ok(w.on_click(move || handler())); + } + let value = self.resolve(attr, element)?; + Ok(match attr.name.as_str() { + "Subtitle" => w.subtitle(&value::as_text(&value)), + "Icon" => w.icon(value::as_text(&value).as_str()), + _ => return Err(unknown_attribute(element, &attr.name, attr.pos)), + }) + } + + fn text_block_attr( + &self, + w: TextBlock, + attr: &Attr, + element: &str, + ) -> Result { + let site = site(element, attr); + let value = self.resolve(attr, element)?; + Ok(match attr.name.as_str() { + "Variant" => w.variant(value::as_enum::(&value, site)?), + "Foreground" | "Color" => w.color(value::as_color(&value, site)?), + // `TextBlock::bold` takes no argument, so `FontWeight` accepts the one + // value it can express rather than a whole `FontWeight` enum. + "FontWeight" => match value::as_text(&value).as_str() { + "Bold" => w.bold(), + other => return Err(site.unsupported(other, "expected `Bold`")), + }, + "Bold" => { + if value::as_bool(&value, site)? { + w.bold() + } else { + w + } + } + "Italic" => { + if value::as_bool(&value, site)? { + w.italic() + } else { + w + } + } + _ => return Err(unknown_attribute(element, &attr.name, attr.pos)), + }) + } + + fn button_attr(&self, w: Button, attr: &Attr, element: &str) -> Result { + if attr.name == "Click" { + let handler = self.handler(attr, element)?; + return Ok(w.on_click(move || handler())); + } + let site = site(element, attr); + let value = self.resolve(attr, element)?; + Ok(match attr.name.as_str() { + "Variant" => w.variant(value::as_enum::(&value, site)?), + // XAML says what is enabled; Rusty says what is disabled. + "IsEnabled" => w.disabled(!value::as_bool(&value, site)?), + "Disabled" => w.disabled(value::as_bool(&value, site)?), + "Loading" => w.loading(value::as_bool(&value, site)?), + "Foreground" | "Color" => w.color(value::as_color(&value, site)?), + "Icon" => w.icon(value::as_text(&value).as_str()), + _ => return Err(unknown_attribute(element, &attr.name, attr.pos)), + }) + } + + fn badge_attr(&self, w: Badge, attr: &Attr, element: &str) -> Result { + let site = site(element, attr); + let value = self.resolve(attr, element)?; + Ok(match attr.name.as_str() { + "Variant" => w.variant(value::as_enum::(&value, site)?), + "Foreground" | "Color" => w.color(value::as_color(&value, site)?), + _ => return Err(unknown_attribute(element, &attr.name, attr.pos)), + }) + } + + fn text_input_attr( + &self, + w: TextInput, + attr: &Attr, + element: &str, + ) -> Result { + if attr.name == "Changed" { + let handler = self.handler(attr, element)?; + // `TextInput::on_change` is `Fn(String)` while a context handler is + // `Fn()`, so the new text is dropped. A handler that needs the text + // belongs in Rust, wired with `TextInput::on_change` directly. + return Ok(w.on_change(move |_| handler())); + } + let site = site(element, attr); + let value = self.resolve(attr, element)?; + Ok(match attr.name.as_str() { + "Text" | "Value" => w.value(&value::as_text(&value)), + "Placeholder" => w.placeholder(&value::as_text(&value)), + "Label" => w.label(&value::as_text(&value)), + "IsEnabled" => w.disabled(!value::as_bool(&value, site)?), + "Disabled" => w.disabled(value::as_bool(&value, site)?), + "IsReadOnly" | "ReadOnly" => w.read_only(value::as_bool(&value, site)?), + _ => return Err(unknown_attribute(element, &attr.name, attr.pos)), + }) + } + + fn image_attr(&self, w: Image, attr: &Attr, element: &str) -> Result { + let site = site(element, attr); + let value = self.resolve(attr, element)?; + Ok(match attr.name.as_str() { + "Alt" => w.alt(&value::as_text(&value)), + "Width" => w.width(value::as_size(&value, site)?), + "Height" => w.height(value::as_size(&value, site)?), + _ => return Err(unknown_attribute(element, &attr.name, attr.pos)), + }) + } + + fn progress_attr( + &self, + w: Progress, + attr: &Attr, + element: &str, + ) -> Result { + let site = site(element, attr); + let value = self.resolve(attr, element)?; + Ok(match attr.name.as_str() { + "Maximum" | "Max" => w.max(value::as_f64(&value, site)?), + "Label" => w.label(&value::as_text(&value)), + "Foreground" | "Color" => w.color(value::as_color(&value, site)?), + _ => return Err(unknown_attribute(element, &attr.name, attr.pos)), + }) + } + + /// The constructor argument, from an attribute or from text content. + /// + /// `Hello` and `` are the + /// same tree; both at once is an error rather than a silent winner. + fn content( + &self, + element: &str, + attrs: &mut Attrs, + names: &[&str], + text: &Option, + ) -> Result { + match (attrs.take(names), text) { + (Some(attr), Some(_)) => Err(XamlError::DuplicateContent { + element: element.to_string(), + attribute: attr.name, + pos: attr.pos, + }), + (Some(attr), None) => self.text_of(&attr, element), + (None, Some(text)) => { + // Text content resolves exactly as the attribute would, so + // `{Binding Title}` binds. + let site = Site { + element, + attribute: names[0], + pos: attrs.pos, + }; + Ok(value::as_text(&value::resolve(text, self.ctx, site)?)) + } + (None, None) => Err(XamlError::MissingAttribute { + element: element.to_string(), + attribute: names[0].to_string(), + pos: attrs.pos, + }), + } + } + + fn resolve(&self, attr: &Attr, element: &str) -> Result { + value::resolve(&attr.raw, self.ctx, site(element, attr)) + } + + fn text_of(&self, attr: &Attr, element: &str) -> Result { + Ok(value::as_text(&self.resolve(attr, element)?)) + } + + /// An event attribute's value is a handler *name*, never a binding. + fn handler(&self, attr: &Attr, element: &str) -> Result { + let name = attr.raw.trim(); + self.ctx + .handler_of(name) + .ok_or_else(|| XamlError::UnknownHandler { + element: element.to_string(), + attribute: attr.name.clone(), + handler: name.to_string(), + pos: attr.pos, + }) + } + + /// Collect the attributes that describe the widget, dropping the XAML + /// directives that describe the document. + fn attrs(&self, node: Node<'_, '_>, element: &str, pos: Position) -> Result { + let mut list = Vec::new(); + + for attr in node.attributes() { + // `xmlns` and `xmlns:*` are namespace declarations, which roxmltree + // does not report as attributes at all. + if let Some(uri) = attr.namespace() { + let prefix = node.lookup_prefix(uri); + if uri == XAML_DIRECTIVE_NS || prefix == Some("x") { + continue; + } + // Some other prefix, e.g. a design-time `d:DesignHeight`. It is + // reported rather than ignored: a prefix this crate does not know + // is a prefix whose effect it cannot honour. + let qualified = match prefix { + Some(prefix) => format!("{}:{}", prefix, attr.name()), + None => attr.name().to_string(), + }; + return Err(unknown_attribute( + element, + &qualified, + self.position(attr.range().start), + )); + } + + list.push(Attr { + name: attr.name().to_string(), + raw: attr.value().to_string(), + pos: self.position(attr.range().start), + }); + } + + Ok(Attrs { pos, list }) + } + + /// A byte offset as a line and column. + /// + /// `text_pos_at` rescans the input, so it is called once per element and once + /// per attribute, never once per error: an error outlives the `Document` that + /// could resolve its offset, so the position has to be resolved eagerly. + fn position(&self, offset: usize) -> Position { + let pos = self.doc.text_pos_at(offset); + Position::new(pos.row, pos.col) + } +} + +/// The element's text content, or `None` when there is only whitespace. +/// +/// Comments and processing instructions are separate node kinds in roxmltree, so +/// they are skipped by construction rather than by filtering. +fn text_content(node: Node<'_, '_>) -> Option { + let mut text = String::new(); + for child in node.children().filter(|n| n.is_text()) { + text.push_str(child.text().unwrap_or_default()); + } + + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn reject_text(element: &str, text: &Option, pos: Position) -> Result<(), XamlError> { + match text { + None => Ok(()), + Some(text) => Err(XamlError::UnsupportedValue { + element: element.to_string(), + attribute: "text content".to_string(), + value: text.clone(), + reason: "text content is only accepted by `TextBlock`, `Button`, `Badge` \ + and `ListViewItem`" + .to_string(), + pos, + }), + } +} + +fn reject_children( + element: &str, + children: &[Node<'_, '_>], + pos: Position, +) -> Result<(), XamlError> { + if children.is_empty() { + Ok(()) + } else { + Err(XamlError::NoChildrenAllowed { + element: element.to_string(), + pos, + }) + } +} + +fn unknown_attribute(element: &str, attribute: &str, pos: Position) -> XamlError { + XamlError::UnknownAttribute { + element: element.to_string(), + attribute: attribute.to_string(), + pos, + } +} + +fn site<'a>(element: &'a str, attr: &'a Attr) -> Site<'a> { + Site { + element, + attribute: &attr.name, + pos: attr.pos, + } +} + +#[cfg(test)] +mod tests { + use crate::{parse, parse_with, XamlContext, XamlError}; + use rusty::views::Element; + use serde_json::{json, Value}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + /// The wire form of a tree, which is what a widget actually is. + fn wire(xaml: &str) -> Value { + serde_json::to_value(parse(xaml).expect("parses")).expect("serializes") + } + + fn err(xaml: &str) -> XamlError { + parse(xaml).expect_err("should not parse") + } + + #[test] + fn stack_panel_is_a_vertical_layout_by_default() { + let json = wire(r#""#); + assert_eq!(json["type"], "layout"); + assert_eq!(json["direction"], "vertical"); + } + + #[test] + fn stack_panel_orientation_selects_the_constructor() { + assert_eq!( + wire(r#""#)["direction"], + "horizontal" + ); + assert_eq!( + wire(r#""#)["direction"], + "vertical" + ); + } + + #[test] + fn an_unknown_orientation_is_rejected_and_names_the_value() { + let err = err(r#""#); + assert!(matches!(err, XamlError::UnsupportedValue { .. }), "{err:?}"); + assert!(err.to_string().contains("Diagonal"), "{}", err); + } + + #[test] + fn layout_is_an_accepted_alias_for_stack_panel() { + assert_eq!(wire(r#""#), wire(r#""#)); + } + + #[test] + fn grid_takes_its_columns_from_the_constructor() { + let json = wire(r#""#); + assert_eq!(json["type"], "layout"); + assert_eq!(json["direction"], "grid"); + assert_eq!(json["columns"], 3); + } + + #[test] + fn a_grid_without_columns_is_an_error() { + let err = err(r#""#); + assert!( + matches!(&err, XamlError::MissingAttribute { attribute, .. } if attribute == "Columns"), + "{err:?}" + ); + } + + #[test] + fn wrap_panel_is_a_wrapping_horizontal_layout() { + let json = wire(r#""#); + assert_eq!(json["direction"], "horizontal"); + assert_eq!(json["wrap"], true); + } + + #[test] + fn border_is_a_container() { + let json = wire( + r#""#, + ); + + assert_eq!(json["type"], "container"); + assert_eq!(json["padding"], 8.0); + assert_eq!(json["width"], "240px"); + assert_eq!(json["height"], "50%"); + assert_eq!(json["background"], "primary"); + assert_eq!(json["border"], true); + assert_eq!(json["rounded"], true); + } + + #[test] + fn group_box_is_a_card_and_header_is_its_title() { + let json = wire(r#""#); + + assert_eq!(json["type"], "card"); + assert_eq!(json["title"], "Totals"); + assert_eq!(json["subtitle"], "This month"); + assert_eq!(json["padding"], 12.0); + } + + #[test] + fn items_control_attaches_children_as_items() { + let json = wire( + r#" + + + "#, + ); + + assert_eq!(json["type"], "list"); + assert_eq!(json["items"][0]["type"], "list_item"); + assert_eq!(json["items"][0]["title"], "First"); + assert_eq!(json["items"][0]["subtitle"], "1"); + assert_eq!(json["items"][0]["icon"], "mail"); + assert_eq!(json["items"][1]["title"], "Second"); + // Children landed in `items`, not `children`. + assert!(json["children"].is_null()); + } + + #[test] + fn list_view_and_list_are_accepted_aliases() { + assert_eq!(wire(r#""#), wire(r#""#)); + assert_eq!(wire(r#""#), wire(r#""#)); + } + + #[test] + fn list_takes_no_attributes() { + let err = err(r#""#); + assert!( + matches!(&err, XamlError::UnknownAttribute { attribute, .. } if attribute == "Padding"), + "{err:?}" + ); + } + + #[test] + fn text_block_carries_variant_colour_and_weight() { + // `r##` so the `#` of the hex colour does not close the raw string. + let json = wire( + r##""##, + ); + + assert_eq!(json["type"], "text_block"); + assert_eq!(json["content"], "Hi"); + assert_eq!(json["variant"], "heading1"); + assert_eq!(json["color"], "#ff0000"); + assert_eq!(json["bold"], true); + } + + #[test] + fn font_weight_accepts_only_bold() { + let err = err(r#""#); + assert!(matches!(err, XamlError::UnsupportedValue { .. }), "{err:?}"); + assert!(err.to_string().contains("SemiBold"), "{}", err); + } + + #[test] + fn text_content_and_the_text_attribute_are_the_same_tree() { + assert_eq!( + wire(r#"Hello"#), + wire(r#""#) + ); + } + + #[test] + fn setting_the_content_twice_is_an_error() { + let err = err(r#"Hello"#); + assert!(matches!(err, XamlError::DuplicateContent { .. }), "{err:?}"); + assert!(err.to_string().contains("TextBlock"), "{}", err); + } + + #[test] + fn a_text_block_without_content_is_an_error() { + let err = err(r#""#); + assert!( + matches!(&err, XamlError::MissingAttribute { attribute, .. } if attribute == "Text"), + "{err:?}" + ); + } + + #[test] + fn button_maps_content_variant_and_enabled() { + let json = + wire(r#"