use std::collections::{BTreeMap, HashMap};
use std::rc::Rc;
use std::sync::Arc;
use vello_cpu::peniko::{Blob, ImageAlphaType, ImageData, ImageFormat};
use crate::Problem;
use crate::fonts::{self, Family, FontFile, FontFiles, FontId};
use crate::parse::{self, Child, Element, Pos, TextPart, Value};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rgba {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: u8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Theme {
#[default]
Light,
Dark,
}
impl Theme {
pub fn named(name: &str) -> Option<Theme> {
match name {
"light" => Some(Theme::Light),
"dark" => Some(Theme::Dark),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Color {
pub light: Rgba,
pub dark: Rgba,
}
impl Color {
pub const fn fixed(c: Rgba) -> Color {
Color { light: c, dark: c }
}
pub fn get(self, theme: Theme) -> Rgba {
match theme {
Theme::Light => self.light,
Theme::Dark => self.dark,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Size {
Px(f32),
Hug,
Fill,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Layout {
None,
Row,
Column,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Align {
Start,
Center,
End,
SpaceBetween,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Kind {
Frame,
Text,
Rect,
Ellipse,
Vector,
Image,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Fill {
Solid(Color),
Linear {
angle: f32,
stops: Vec<(Color, f32)>,
},
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TextAlign {
Left,
Center,
Right,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Vector {
pub d: String,
pub view_box: [f32; 4],
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ImageId(pub [u8; 32]);
impl ImageId {
pub fn of(bytes: &[u8]) -> ImageId {
use sha2::Digest;
ImageId(sha2::Sha256::digest(bytes).into())
}
}
impl std::fmt::Debug for ImageId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for b in &self.0[..6] {
write!(f, "{b:02x}")?;
}
Ok(())
}
}
pub const MAX_IMAGE_SIDE: u32 = 4096;
#[derive(Debug)]
pub struct ImageFile {
pub id: ImageId,
pub bytes: Vec<u8>,
pub width: u32,
pub height: u32,
pub pixels: Arc<Vec<u8>>,
pub pixels_width: u32,
pub pixels_height: u32,
}
impl ImageFile {
pub fn new(bytes: Vec<u8>) -> Result<ImageFile, String> {
ImageFile::with_id(ImageId::of(&bytes), bytes)
}
pub fn with_id(id: ImageId, bytes: Vec<u8>) -> Result<ImageFile, String> {
let decoded = decode(&bytes)?;
let (width, height) = (decoded.width, decoded.height);
let (pixels, pixels_width, pixels_height) =
fit(decoded.data.data(), width, height, MAX_IMAGE_SIDE);
Ok(ImageFile {
id,
bytes,
width,
height,
pixels: Arc::new(pixels),
pixels_width,
pixels_height,
})
}
pub fn image_data(&self) -> ImageData {
ImageData {
data: Blob::new(self.pixels.clone()),
format: ImageFormat::Rgba8,
alpha_type: ImageAlphaType::Alpha,
width: self.pixels_width,
height: self.pixels_height,
}
}
}
pub fn fit(rgba: &[u8], width: u32, height: u32, max: u32) -> (Vec<u8>, u32, u32) {
if width <= max && height <= max {
return (rgba.to_vec(), width, height);
}
let scale = max as f64 / width.max(height) as f64;
let w = ((width as f64 * scale).round() as u32).clamp(1, max);
let h = ((height as f64 * scale).round() as u32).clamp(1, max);
let weights = |from: u32, to: u32| -> Vec<Vec<(usize, f32)>> {
let ratio = from as f64 / to as f64;
(0..to)
.map(|o| {
let (a, b) = (o as f64 * ratio, (o + 1) as f64 * ratio);
(a.floor() as usize..(b.ceil() as usize).min(from as usize))
.map(|i| {
let cover = (b.min(i as f64 + 1.0) - a.max(i as f64)) / ratio;
(i, cover as f32)
})
.filter(|&(_, c)| c > 0.0)
.collect()
})
.collect()
};
let (across, down) = (weights(width, w), weights(height, h));
let mut rows = vec![0f32; 4 * w as usize * height as usize];
for y in 0..height as usize {
let line = &rgba[4 * y * width as usize..][..4 * width as usize];
for (x, ws) in across.iter().enumerate() {
let mut sum = [0f32; 4];
for &(i, c) in ws {
let p = &line[4 * i..][..4];
let a = p[3] as f32 * c;
sum[0] += p[0] as f32 * a;
sum[1] += p[1] as f32 * a;
sum[2] += p[2] as f32 * a;
sum[3] += a;
}
rows[4 * (y * w as usize + x)..][..4].copy_from_slice(&sum);
}
}
let mut out = vec![0u8; 4 * w as usize * h as usize];
for (y, ws) in down.iter().enumerate() {
for x in 0..w as usize {
let mut sum = [0f32; 4];
for &(i, c) in ws {
let p = &rows[4 * (i * w as usize + x)..][..4];
for k in 0..4 {
sum[k] += p[k] * c;
}
}
let px = &mut out[4 * (y * w as usize + x)..][..4];
if sum[3] > 0.0 {
for k in 0..3 {
px[k] = (sum[k] / sum[3]).round().clamp(0.0, 255.0) as u8;
}
px[3] = sum[3].round().clamp(0.0, 255.0) as u8;
}
}
}
(out, w, h)
}
#[derive(Debug, Clone)]
pub struct Origin {
pub file: String,
pub pos: Pos,
}
impl Default for Origin {
fn default() -> Origin {
Origin {
file: String::new(),
pos: Pos { line: 1, col: 1 },
}
}
}
impl PartialEq for Origin {
fn eq(&self, _: &Origin) -> bool {
true
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Image {
pub src: String,
pub id: ImageId,
pub at: Origin,
}
pub fn decode(data: &[u8]) -> Result<ImageData, String> {
let (pixels, width, height) = if data.starts_with(b"\x89PNG\r\n\x1a\n") {
let broken = |e: &dyn std::fmt::Display| format!("is a PNG that doesn't decode: {e}");
let mut decoder = png::Decoder::new(std::io::Cursor::new(data));
decoder.set_transformations(png::Transformations::normalize_to_color8());
let mut reader = decoder.read_info().map_err(|e| broken(&e))?;
let size = reader
.output_buffer_size()
.ok_or_else(|| broken(&"too big to hold"))?;
let mut buf = vec![0; size];
let info = reader.next_frame(&mut buf).map_err(|e| broken(&e))?;
buf.truncate(info.buffer_size());
let rgba = match info.color_type {
png::ColorType::Rgba => buf,
png::ColorType::Rgb => buf
.chunks(3)
.flat_map(|p| [p[0], p[1], p[2], 255])
.collect(),
png::ColorType::GrayscaleAlpha => buf
.chunks(2)
.flat_map(|p| [p[0], p[0], p[0], p[1]])
.collect(),
png::ColorType::Grayscale => buf.iter().flat_map(|&g| [g, g, g, 255]).collect(),
png::ColorType::Indexed => return Err(broken(&"an unexpanded palette")),
};
(rgba, info.width, info.height)
} else if data.starts_with(&[0xff, 0xd8, 0xff]) {
use zune_jpeg::JpegDecoder;
use zune_jpeg::zune_core::colorspace::ColorSpace;
use zune_jpeg::zune_core::options::DecoderOptions;
let broken = |e: &dyn std::fmt::Display| format!("is a JPEG that doesn't decode: {e}");
let options = DecoderOptions::default()
.set_strict_mode(true)
.jpeg_set_out_colorspace(ColorSpace::RGBA);
let mut decoder = JpegDecoder::new_with_options(std::io::Cursor::new(data), options);
let pixels = decoder.decode().map_err(|e| broken(&e))?;
let info = decoder.info().ok_or_else(|| broken(&"no header"))?;
(pixels, info.width as u32, info.height as u32)
} else {
return Err("is not a PNG or JPEG file".into());
};
if width == 0 || height == 0 || pixels.len() != width as usize * height as usize * 4 {
return Err(format!(
"is an image of {width}×{height} pixels that doesn't decode whole"
));
}
Ok(ImageData {
data: Blob::new(Arc::new(pixels)),
format: ImageFormat::Rgba8,
alpha_type: ImageAlphaType::Alpha,
width,
height,
})
}
#[derive(Debug, Clone, PartialEq)]
pub struct Shadow {
pub x: f32,
pub y: f32,
pub blur: f32,
pub spread: f32,
pub color: Color,
pub at: Origin,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TextStyle {
pub font: Option<FontId>,
pub content: String,
pub size: f32,
pub weight: u16,
pub color: Color,
pub line_height: f32,
pub align: TextAlign,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Node {
pub kind: Kind,
pub name: String,
pub x: f32,
pub y: f32,
pub width: Size,
pub height: Size,
pub opacity: f32,
pub on_click: Option<String>,
pub fill: Option<Fill>,
pub stroke: Option<Color>,
pub stroke_width: f32,
pub radius: [f32; 4],
pub shadow: Option<Shadow>,
pub clip: bool,
pub layout: Layout,
pub gap: f32,
pub padding: [f32; 4],
pub align: Align,
pub justify: Align,
pub text: Option<TextStyle>,
pub vector: Option<Vector>,
pub image: Option<Image>,
pub children: Vec<Node>,
}
impl Node {
fn new(kind: Kind, name: &str) -> Node {
Node {
kind,
name: name.to_string(),
x: 0.0,
y: 0.0,
width: Size::Hug,
height: Size::Hug,
opacity: 1.0,
on_click: None,
fill: None,
stroke: None,
stroke_width: 1.0,
radius: [0.0; 4],
shadow: None,
clip: false,
layout: Layout::None,
gap: 0.0,
padding: [0.0; 4],
align: Align::Start,
justify: Align::Start,
text: None,
vector: None,
image: None,
children: Vec::new(),
}
}
}
pub trait Files {
fn bytes(&self, path: &str) -> Option<Vec<u8>>;
fn list(&self, path: &str) -> Option<Vec<String>>;
fn read(&self, path: &str) -> Option<String> {
String::from_utf8(self.bytes(path)?).ok()
}
fn image(&self, path: &str) -> Option<Result<Arc<ImageFile>, String>> {
self.bytes(path)
.map(|bytes| ImageFile::new(bytes).map(Arc::new))
}
fn font(&self, path: &str) -> Option<Result<Arc<FontFile>, String>> {
self.bytes(path)
.map(|bytes| FontFile::new(bytes).map(Arc::new))
}
}
pub fn listed<'a>(paths: impl Iterator<Item = &'a String>, folder: &str) -> Option<Vec<String>> {
let prefix = format!("{folder}/");
let mut names: Vec<String> = paths
.filter_map(|p| p.strip_prefix(&prefix))
.filter(|rest| !rest.contains('/'))
.map(str::to_string)
.collect();
names.sort();
(!names.is_empty()).then_some(names)
}
impl Files for HashMap<String, String> {
fn bytes(&self, path: &str) -> Option<Vec<u8>> {
self.get(path).map(|s| s.clone().into_bytes())
}
fn list(&self, path: &str) -> Option<Vec<String>> {
listed(self.keys(), path)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Scene {
pub root: Node,
pub images: ImageFiles,
pub fonts: FontFiles,
pub files: Vec<String>,
pub themed: bool,
}
pub type ImageFiles = std::collections::BTreeMap<ImageId, Arc<ImageFile>>;
impl PartialEq for ImageFile {
fn eq(&self, other: &ImageFile) -> bool {
self.id == other.id
}
}
pub fn resolve(files: &dyn Files, path: &str) -> Result<Scene, Vec<Problem>> {
let mut r = Resolver {
files,
problems: Vec::new(),
read: Vec::new(),
stack: Vec::new(),
missing: Vec::new(),
depth: 0,
images: ImageFiles::new(),
by_path: HashMap::new(),
fonts: FontFiles::new(),
families: HashMap::new(),
themes: HashMap::new(),
themed: false,
};
let root = r.design(path, &[], None, "", None);
if r.problems.is_empty() {
Ok(Scene {
root: root.expect("no problems means a root"),
images: r.images,
fonts: r.fonts,
files: r.read,
themed: r.themed,
})
} else {
Err(r.problems)
}
}
struct Resolver<'a> {
files: &'a dyn Files,
problems: Vec<Problem>,
read: Vec<String>,
stack: Vec<String>,
missing: Vec<String>,
depth: usize,
images: ImageFiles,
by_path: HashMap<String, Result<ImageId, String>>,
fonts: FontFiles,
families: HashMap<String, (Found, bool)>,
themes: HashMap<String, Rc<Themes>>,
themed: bool,
}
#[derive(Debug, Clone, Default)]
struct Themes {
found: bool,
light: BTreeMap<String, Rgba>,
dark: BTreeMap<String, Rgba>,
fonts: BTreeMap<String, ThemesFont>,
}
#[derive(Debug, Clone)]
struct ThemesFont {
written: String,
at: Option<String>,
origin: Origin,
}
struct ThemesFile {
light: BTreeMap<String, Rgba>,
dark: BTreeMap<String, Rgba>,
fonts: BTreeMap<String, ThemesFont>,
}
pub const THEMES_FILE: &str = "themes.yml";
const MAX_DEPTH: usize = parse::MAX_DEPTH;
#[derive(Debug, Clone)]
struct Given {
value: Value,
by: String,
}
type Props = HashMap<String, Given>;
type Found = Option<Result<Rc<Family>, String>>;
impl Resolver<'_> {
fn problem(&mut self, file: &str, pos: Pos, message: String) {
self.problems.push(Problem {
file: file.to_string(),
pos,
message,
});
}
fn bind_element(&mut self, path: &str, el: &mut Element) {
for attr in &mut el.attrs {
self.bind(path, &mut attr.value);
}
for child in &mut el.children {
if let Child::Element(e) = child {
self.bind_element(path, e);
}
}
}
fn bind(&mut self, path: &str, value: &mut Value) {
match value {
Value::Theme(name, pos) => {
let (light, dark) = self
.theme_color(path, name, *pos)
.map_or(("#0000".into(), "#0000".into()), |(l, d)| (hex(l), hex(d)));
*value = Value::Themed {
light,
dark,
written: format!("theme.{name}"),
};
}
Value::Str(s, pos) if s.starts_with("linear-gradient(") && s.contains("theme.") => {
let (mut light, mut dark) = (String::new(), String::new());
let mut rest = s.as_str();
let mut col = pos.col + 1;
while let Some(at) = rest.find("theme.") {
let before = &rest[..at];
light.push_str(before);
dark.push_str(before);
col += before.chars().count() as u32;
let after = &rest[at + "theme.".len()..];
let len = after
.find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '-'))
.unwrap_or(after.len());
let name = &after[..len];
let at_ref = Pos {
line: pos.line,
col,
};
match self.theme_color(path, name, at_ref) {
Some((l, d)) => {
light.push_str(&hex(l));
dark.push_str(&hex(d));
}
None => {
light.push_str("#0000");
dark.push_str("#0000");
}
}
col += ("theme.".len() + name.chars().count()) as u32;
rest = &after[len..];
}
light.push_str(rest);
dark.push_str(rest);
*value = Value::Themed {
light,
dark,
written: format!("\"{s}\""),
};
}
Value::Array(items) => {
for item in items {
self.bind(path, item);
}
}
Value::Object(fields) => {
for (_, v) in fields {
self.bind(path, v);
}
}
_ => {}
}
}
fn theme_color(&mut self, path: &str, name: &str, pos: Pos) -> Option<(Rgba, Rgba)> {
let folder = path.rsplit_once('/').map_or("", |(folder, _)| folder);
let themes = self.themes(folder);
let problem = if !themes.found {
format!(
"theme.{name}: no {THEMES_FILE} with kind: themes in this design's folder or above it"
)
} else {
match (themes.light.get(name), themes.dark.get(name)) {
(Some(l), Some(d)) => return Some((*l, *d)),
(None, None) => {
let mut names: Vec<&String> =
themes.light.keys().chain(themes.dark.keys()).collect();
names.sort();
names.dedup();
let names: Vec<&str> = names.iter().map(|n| n.as_str()).collect();
if names.is_empty() {
format!("theme.{name}: the themes have no colors")
} else {
format!(
"theme.{name}: the themes have no {name}; they have {}",
names.join(", ")
)
}
}
(Some(_), None) => format!(
"theme.{name}: only the light theme has {name}; a name needs a color in both"
),
(None, Some(_)) => format!(
"theme.{name}: only the dark theme has {name}; a name needs a color in both"
),
}
};
self.problem(path, pos, problem);
None
}
fn themes(&mut self, folder: &str) -> Rc<Themes> {
if let Some(themes) = self.themes.get(folder) {
return themes.clone();
}
let above = match folder.rsplit_once('/') {
Some((parent, _)) => self.themes(parent),
None if folder.is_empty() => Rc::new(Themes::default()),
None => self.themes(""),
};
let file = if folder.is_empty() {
THEMES_FILE.to_string()
} else {
format!("{folder}/{THEMES_FILE}")
};
let themes = match self.themes_file(&file) {
None => above,
Some(own) => {
let mut merged = (*above).clone();
merged.found = true;
merged.light.extend(own.light);
merged.dark.extend(own.dark);
merged.fonts.extend(own.fonts);
Rc::new(merged)
}
};
self.themes.insert(folder.to_string(), themes.clone());
themes
}
fn themes_file(&mut self, path: &str) -> Option<ThemesFile> {
let text = self.files.read(path)?;
if !self.read.iter().any(|p| p == path) {
self.read.push(path.to_string());
}
let mut file = ThemesFile {
light: BTreeMap::new(),
dark: BTreeMap::new(),
fonts: BTreeMap::new(),
};
let yaml: serde_yaml::Value = match serde_yaml::from_str(&text) {
Ok(yaml) => yaml,
Err(e) => {
if !text.lines().any(|l| l.trim() == "kind: themes") {
return None;
}
let pos = e.location().map_or(Pos { line: 1, col: 1 }, |l| Pos {
line: l.line() as u32,
col: l.column() as u32,
});
self.problem(path, pos, format!("this themes file isn't valid YAML: {e}"));
return Some(file);
}
};
let serde_yaml::Value::Mapping(top) = yaml else {
return None;
};
if top.get("kind").and_then(|k| k.as_str()) != Some("themes") {
return None;
}
let start = Pos { line: 1, col: 1 };
for (key, value) in &top {
let key = key.as_str().unwrap_or_default();
let theme = match key {
"kind" => continue,
"light" => &mut file.light,
"dark" => &mut file.dark,
"fonts" => {
self.themes_fonts(path, &text, value, &mut file.fonts);
continue;
}
other => {
let pos = key_pos(&text, None, other).unwrap_or(start);
self.problem(
path,
pos,
format!("{other}: a themes file has kind, fonts, light and dark only"),
);
continue;
}
};
let at = key_pos(&text, None, key).unwrap_or(start);
let serde_yaml::Value::Mapping(colors) = value else {
self.problem(
path,
at,
format!("{key}: expected a map of names to colors, like surface: \"#ffffff\""),
);
continue;
};
for (name, color) in colors {
let Some(name) = name.as_str() else {
self.problem(path, at, format!("{key}: a color's name is text"));
continue;
};
let pos = key_pos(&text, Some(key), name).unwrap_or(at);
if name.is_empty()
|| name.starts_with(|c: char| c.is_ascii_digit() || c == '-')
|| !name
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
{
self.problem(
path,
pos,
format!(
"{key}.{name}: a name holds letters, digits, _ and -, and starts with a letter"
),
);
continue;
}
let value = match color {
serde_yaml::Value::String(s) => rgba(s),
serde_yaml::Value::Null => Err(
"no color: write it in quotes, \"#rrggbb\", as YAML reads # as a comment"
.to_string(),
),
_ => Err("expected a color like \"#rrggbb\"".to_string()),
};
match value {
Ok(c) => {
theme.insert(name.to_string(), c);
}
Err(e) => self.problem(path, pos, format!("{key}.{name}: {e}")),
}
}
}
for needed in ["light", "dark"] {
if !top.contains_key(needed) {
self.problem(
path,
start,
format!("a themes file has a light and a dark theme; this one has no {needed}"),
);
}
}
Some(file)
}
fn themes_fonts(
&mut self,
path: &str,
text: &str,
value: &serde_yaml::Value,
into: &mut BTreeMap<String, ThemesFont>,
) {
let start = Pos { line: 1, col: 1 };
let at = key_pos(text, None, "fonts").unwrap_or(start);
let serde_yaml::Value::Mapping(entries) = value else {
self.problem(
path,
at,
"fonts: expected a map of names to font files or folders, like figtree: ./fonts/Figtree".into(),
);
return;
};
for (name, written) in entries {
let Some(name) = name.as_str() else {
self.problem(path, at, "fonts: a font's name is text".into());
continue;
};
let pos = key_pos(text, Some("fonts"), name).unwrap_or(at);
if name.is_empty()
|| !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
self.problem(
path,
pos,
format!(
"fonts.{name}: a font's name holds lowercase letters, digits and - only"
),
);
continue;
}
let Some(written) = written.as_str() else {
self.problem(
path,
pos,
format!("fonts.{name}: expected a path to a font file or a folder of one family's, like ./fonts/Figtree"),
);
continue;
};
into.insert(
name.to_string(),
ThemesFont {
written: written.to_string(),
at: join(path, written),
origin: Origin {
file: path.to_string(),
pos,
},
},
);
}
}
fn design(
&mut self,
path: &str,
args: &[(parse::Attr, String)],
from: Option<(&str, Pos)>,
used_as: &str,
parent: Option<Layout>,
) -> Option<Node> {
if self.stack.iter().any(|p| p == path) {
let (file, pos) = from.unwrap_or((path, Pos { line: 1, col: 1 }));
self.problem(file, pos, format!("{path} uses itself"));
return None;
}
let Some(source) = self.files.read(path) else {
if from.is_some() && self.missing.iter().any(|p| p == path) {
return None;
}
let (file, pos) = from.unwrap_or((path, Pos { line: 1, col: 1 }));
self.problem(file, pos, format!("{path} — no such file"));
return None;
};
if !self.read.iter().any(|p| p == path) {
self.read.push(path.to_string());
}
let mut file = match parse::parse(&source) {
Ok(file) => file,
Err(mut problem) => {
problem.file = path.to_string();
self.problems.push(problem);
return None;
}
};
self.bind_element(path, &mut file.root);
let mut imports = HashMap::new();
for import in &file.imports {
if import.name == parse::THEME {
self.problem(
path,
import.pos,
format!(
"import {0} — {0} is the name of the design's themes",
import.name
),
);
continue;
}
if ELEMENTS.contains(&import.name.as_str()) {
self.problem(
path,
import.pos,
format!("import {0} — {0} is a built-in element", import.name),
);
continue;
}
if imports.contains_key(&import.name) {
self.problem(
path,
import.pos,
format!("import {0} — {0} is already imported", import.name),
);
continue;
}
if !import.path.ends_with(".design") {
self.problem(
path,
import.pos,
format!("import {} — a design is a .design file", import.path),
);
continue;
}
match join(path, &import.path) {
Some(target) => {
if self.files.read(&target).is_none() {
self.problem(
path,
import.pos,
format!("import {} — no such file", import.path),
);
self.missing.push(target.clone());
}
imports.insert(import.name.clone(), target);
}
None => self.problem(
path,
import.pos,
format!("import {} — outside the served folder", import.path),
),
}
}
if file.root.name != "Frame" {
self.problem(
path,
file.root.pos,
format!("the root element must be a Frame, not {}", file.root.name),
);
return None;
}
let mut props = Props::new();
if let Some(attr) = file.root.attrs.iter().find(|a| a.name == "props") {
match &attr.value {
Value::Object(fields) => {
for (k, v) in fields {
if k == parse::THEME {
self.problem(
path,
attr.pos,
format!(
"props: {k} is the name of the design's themes, not a prop"
),
);
continue;
}
props.insert(
k.clone(),
Given {
value: v.clone(),
by: path.to_string(),
},
);
}
}
_ => self.problem(
path,
attr.pos,
"props: expected an object of defaults".into(),
),
}
}
let (caller, caller_pos) = from.unwrap_or((path, file.root.pos));
let caller = caller.to_string();
let mut overrides = Vec::new();
for (arg, by) in args {
if matches!(
arg.name.as_str(),
"x" | "y" | "width" | "height" | "name" | "opacity" | "onClick"
) {
overrides.push((arg.clone(), by.clone()));
} else if props.contains_key(&arg.name) {
props.insert(
arg.name.clone(),
Given {
value: arg.value.clone(),
by: by.clone(),
},
);
} else {
self.problem(
&caller,
arg.pos,
format!("{used_as} has no prop \"{}\"", arg.name),
);
}
}
let _ = caller_pos;
self.stack.push(path.to_string());
let mut root = self.element(path, &file.root, &props, &imports, true, parent);
self.stack.pop();
if let Some(root) = root.as_mut() {
for (attr, by) in &overrides {
self.common(&caller, by, root, attr, attr.value.clone());
self.fill_needs_layout(&caller, attr, &attr.value, parent);
self.place_needs_no_layout(&caller, attr, parent);
}
}
root
}
fn element(
&mut self,
path: &str,
el: &Element,
props: &Props,
imports: &HashMap<String, String>,
root: bool,
parent: Option<Layout>,
) -> Option<Node> {
if self.depth >= MAX_DEPTH {
self.problem(path, el.pos, format!("nested more than {MAX_DEPTH} deep"));
return None;
}
self.depth += 1;
let node = self.element_inner(path, el, props, imports, root, parent);
self.depth -= 1;
node
}
fn element_inner(
&mut self,
path: &str,
el: &Element,
props: &Props,
imports: &HashMap<String, String>,
root: bool,
parent: Option<Layout>,
) -> Option<Node> {
let mut seen: Vec<&str> = Vec::new();
let mut attrs = Vec::new();
for attr in &el.attrs {
if seen.contains(&attr.name.as_str()) {
self.problem(path, attr.pos, format!("{} is given twice", attr.name));
} else {
seen.push(&attr.name);
attrs.push(attr);
}
}
if let Some(target) = imports.get(&el.name) {
if !el.children.is_empty() {
self.problem(path, el.pos, format!("{} takes no children", el.name));
}
let args: Vec<(parse::Attr, String)> = attrs
.iter()
.map(|a| {
let (value, by) = substitute(&a.value, props, path);
let arg = parse::Attr {
name: a.name.clone(),
value,
pos: a.pos,
};
(arg, by)
})
.collect();
let target = target.clone();
return self.design(&target, &args, Some((path, el.pos)), &el.name, parent);
}
let kind = match el.name.as_str() {
"Frame" => Kind::Frame,
"Text" => Kind::Text,
"Rect" => Kind::Rect,
"Ellipse" => Kind::Ellipse,
"Vector" => Kind::Vector,
"Image" => Kind::Image,
other => {
self.problem(path, el.pos, format!("unknown element {other}"));
return None;
}
};
let mut node = Node::new(kind, &el.name);
let mut font = None;
for &attr in &attrs {
if attr.name == "props" {
if !root {
self.problem(
path,
attr.pos,
"props: only a design's root declares props".into(),
);
}
continue;
}
if root && matches!(attr.name.as_str(), "x" | "y") {
self.problem(
path,
attr.pos,
format!(
"{}: a design's root has no place of its own; place an instance with x and y where it is used",
attr.name
),
);
continue;
}
let (value, by) = match &attr.value {
Value::Prop(p) => match props.get(p) {
Some(given) => (given.value.clone(), given.by.clone()),
None => {
self.problem(path, attr.pos, format!("{}: no prop \"{p}\"", attr.name));
continue;
}
},
v => (v.clone(), path.to_string()),
};
self.themed |= has_theme(&value);
self.fill_needs_layout(path, attr, &value, parent);
self.place_needs_no_layout(path, attr, parent);
if kind == Kind::Text && attr.name == "font" {
font = Some((attr, value, by));
continue;
}
self.attribute(path, &by, &mut node, attr, value);
}
match kind {
Kind::Vector if !el.attrs.iter().any(|a| a.name == "d") => self.problem(
path,
el.pos,
format!("{} needs a path, d=\"M…\"", node.name),
),
Kind::Image if node.image.is_none() && !el.attrs.iter().any(|a| a.name == "src") => {
self.problem(
path,
el.pos,
format!("{} needs a file, src=\"…\"", node.name),
)
}
_ => {}
}
if kind == Kind::Text {
let mut content = String::new();
for child in &el.children {
match child {
Child::Text(parts, pos) => {
for part in parts {
match part {
TextPart::Literal(s) => content.push_str(s),
TextPart::Prop(p) => match props.get(p).map(|g| &g.value) {
Some(Value::Str(s, _)) => content.push_str(s),
Some(Value::Num(n)) => content.push_str(&n.to_string()),
_ => self.problem(path, *pos, format!("no text prop \"{p}\"")),
},
TextPart::Theme(name, at) => self.problem(
path,
*at,
format!(
"theme.{name} is a color, not text: give it to color={{theme.{name}}}"
),
),
}
}
}
Child::Element(e) => self.problem(path, e.pos, "Text holds text only".into()),
}
}
let text = node.text.get_or_insert_with(default_text);
text.content = content;
let weight = text.weight;
let weight_at = attrs.iter().find(|a| a.name == "weight").map(|a| a.pos);
text.font = self.text_font(path, el.pos, font, weight, weight_at);
} else {
for child in &el.children {
match child {
Child::Element(e) => {
if kind != Kind::Frame {
self.problem(path, e.pos, format!("{} takes no children", el.name));
} else if let Some(n) =
self.element(path, e, props, imports, false, Some(node.layout))
{
node.children.push(n);
}
}
Child::Text(_, pos) => self.problem(
path,
*pos,
format!("text belongs in a Text, not a {}", el.name),
),
}
}
}
Some(node)
}
fn fill_needs_layout(
&mut self,
path: &str,
attr: &parse::Attr,
value: &Value,
parent: Option<Layout>,
) {
if parent == Some(Layout::None)
&& matches!(attr.name.as_str(), "width" | "height")
&& matches!(value, Value::Str(s, _) if s == "fill")
{
self.problem(
path,
attr.pos,
format!(
"{}: \"fill\" needs a parent with layout=\"row\" or \"column\"",
attr.name
),
);
}
}
fn place_needs_no_layout(&mut self, path: &str, attr: &parse::Attr, parent: Option<Layout>) {
if matches!(parent, Some(Layout::Row | Layout::Column))
&& matches!(attr.name.as_str(), "x" | "y")
{
self.problem(
path,
attr.pos,
format!(
"{}: places a child only in a frame with layout=\"none\"",
attr.name
),
);
}
}
#[allow(clippy::unit_arg)]
fn attribute(
&mut self,
path: &str,
by: &str,
node: &mut Node,
attr: &parse::Attr,
value: Value,
) {
let kind = node.kind;
let (at_file, at_pos) = written_at(path, by, attr, &value);
let ok = match (attr.name.as_str(), kind) {
("x" | "y" | "width" | "height" | "name" | "opacity" | "onClick", _) => {
self.common(path, by, node, attr, value);
return;
}
("fill", k) if k != Kind::Text => fill(&value).map(|f| node.fill = Some(f)),
("stroke", k) if k != Kind::Text => color(&value).map(|c| node.stroke = Some(c)),
("strokeWidth", k) if k != Kind::Text => px(&value).map(|n| node.stroke_width = n),
("radius", k) if k != Kind::Text => match &value {
Value::Num(_) => px(&value).map(|n| node.radius = [n; 4]),
Value::Array(a) if a.len() == 4 => {
pxs(a).map(|v| node.radius = [v[0], v[1], v[2], v[3]])
}
_ => Err("expected pixels or an array of 4".into()),
},
("shadow", k) if k != Kind::Text => shadow(&value).map(|mut s| {
s.at = Origin {
file: path.to_string(),
pos: attr.pos,
};
node.shadow = Some(s)
}),
("clip", Kind::Frame) => match value {
Value::Bool(b) => Ok(node.clip = b),
_ => Err("expected true or false".into()),
},
("layout", Kind::Frame) => match str_of(&value).as_deref() {
Some("row") => Ok(node.layout = Layout::Row),
Some("column") => Ok(node.layout = Layout::Column),
Some("none") => Ok(node.layout = Layout::None),
_ => Err("expected \"row\", \"column\" or \"none\"".into()),
},
("gap", Kind::Frame) => px(&value).map(|n| node.gap = n),
("padding", Kind::Frame) => match &value {
Value::Num(_) => px(&value).map(|n| node.padding = [n; 4]),
Value::Array(a) if a.len() == 2 => {
pxs(a).map(|v| node.padding = [v[0], v[1], v[0], v[1]])
}
Value::Array(a) if a.len() == 4 => {
pxs(a).map(|v| node.padding = [v[0], v[1], v[2], v[3]])
}
other => Err(format!(
"expected pixels or an array of 2 or 4, found {}",
show(other)
)),
},
("align", Kind::Frame) => align(&value, false).map(|a| node.align = a),
("justify", Kind::Frame) => align(&value, true).map(|a| node.justify = a),
("size", Kind::Text) => {
px(&value).map(|n| node.text.get_or_insert_with(default_text).size = n)
}
("weight", Kind::Text) => match num(&value) {
Ok(w) if fonts::WEIGHTS.iter().any(|&k| k as f32 == w) => {
Ok(node.text.get_or_insert_with(default_text).weight = w as u16)
}
_ => Err("expected 100 to 900 in steps of 100".into()),
},
("color", Kind::Text) => {
color(&value).map(|c| node.text.get_or_insert_with(default_text).color = c)
}
("lineHeight", Kind::Text) => {
px(&value).map(|n| node.text.get_or_insert_with(default_text).line_height = n)
}
("textAlign", Kind::Text) => match str_of(&value).as_deref() {
Some("left") => {
Ok(node.text.get_or_insert_with(default_text).align = TextAlign::Left)
}
Some("center") => {
Ok(node.text.get_or_insert_with(default_text).align = TextAlign::Center)
}
Some("right") => {
Ok(node.text.get_or_insert_with(default_text).align = TextAlign::Right)
}
_ => Err("expected \"left\", \"center\" or \"right\"".into()),
},
("d", Kind::Vector) => match str_of(&value) {
Some(d) => match path_numbers(&d).map_err(|e| e.to_string()).and_then(|()| {
vello_cpu::kurbo::BezPath::from_svg(&d)
.map_err(|e| format!("not SVG path data: {e}"))
}) {
Ok(_) => {
let view_box = node.vector.as_ref().map(|v| v.view_box);
let view_box = view_box.unwrap_or_else(|| bounds(&d));
Ok(node.vector = Some(Vector { d, view_box }))
}
Err(e) => Err(e),
},
None => Err("expected SVG path data".into()),
},
("viewBox", Kind::Vector) => match str_of(&value).as_deref().map(view_box) {
Some(Ok(b)) => {
let v = node.vector.get_or_insert_with(|| Vector {
d: String::new(),
view_box: b,
});
Ok(v.view_box = b)
}
Some(Err(e)) => Err(e),
None => Err("expected \"minX minY width height\"".into()),
},
("src", Kind::Image) => match str_of(&value) {
Some(src) => match join(by, &src) {
None => {
let e = format!("src: {src} — outside the served folder");
self.problem(&at_file, at_pos, e);
return;
}
Some(at) => match self.image_file(&at) {
None => {
self.problem(&at_file, at_pos, format!("src: {src} — no such file"));
return;
}
Some(Err(e)) => {
self.problem(&at_file, at_pos, format!("src: {src} {e}"));
return;
}
Some(Ok(id)) => {
node.image = Some(Image {
src: at,
id,
at: Origin {
file: path.to_string(),
pos: attr.pos,
},
});
Ok(())
}
},
},
None => Err("expected a path".into()),
},
(name, _) => {
self.problem(
path,
attr.pos,
format!("{} takes no attribute {name}", node.name),
);
return;
}
};
if let Err(e) = ok {
self.problem(path, attr.pos, format!("{}: {e}", attr.name));
}
}
fn image_file(&mut self, at: &str) -> Option<Result<ImageId, String>> {
if let Some(known) = self.by_path.get(at) {
return Some(known.clone());
}
let file = self.files.image(at)?;
if !self.read.iter().any(|p| p == at) {
self.read.push(at.to_string());
}
let id = file.map(|file| {
let id = file.id;
self.images.entry(id).or_insert(file);
id
});
self.by_path.insert(at.to_string(), id.clone());
Some(id)
}
fn text_font(
&mut self,
path: &str,
pos: Pos,
font: Option<(&parse::Attr, Value, String)>,
weight: u16,
weight_at: Option<Pos>,
) -> Option<FontId> {
let Some((attr, value, by)) = font else {
if !fonts::INTER_WEIGHTS.contains(&weight) {
self.no_weight(
path,
pos,
weight_at,
weight,
fonts::INTER,
&fonts::INTER_WEIGHTS,
);
}
return None;
};
let (file, at_pos) = written_at(path, &by, attr, &value);
let Some(name) = str_of(&value) else {
self.problem(
&file,
at_pos,
"font: expected the name of a font the themes name, like \"figtree\"".into(),
);
return None;
};
let folder = by.rsplit_once('/').map_or("", |(folder, _)| folder);
let named = self.themes(folder).fonts.clone();
let Some(entry) = named.get(&name) else {
let problem = if named.is_empty() {
format!(
"font: no font named {name}; no {THEMES_FILE} in this design's folder or above it names fonts"
)
} else {
let names: Vec<&str> = named.keys().map(String::as_str).collect();
format!(
"font: no font named {name}; the themes name {}",
names.join(", ")
)
};
self.problem(&file, at_pos, problem);
return None;
};
let origin = entry.origin.clone();
let Some(at) = entry.at.clone() else {
let key = format!("{}\0{}", origin.file, name);
if let std::collections::hash_map::Entry::Vacant(first) = self.families.entry(key) {
first.insert((None, true));
self.problem(
&origin.file,
origin.pos,
format!(
"fonts.{name}: {} — outside the served folder",
entry.written
),
);
}
return None;
};
let (family, reported) = self.family(&at);
let family = match family {
Some(Ok(family)) => family,
missing_or_bad => {
if !reported {
let problem = match missing_or_bad {
Some(Err(e)) => format!("fonts.{name}: {} {e}", entry.written),
_ => format!("fonts.{name}: {} — no such file or folder", entry.written),
};
self.problem(&origin.file, origin.pos, problem);
if let Some(known) = self.families.get_mut(&at) {
known.1 = true;
}
}
return None;
}
};
match family.face(weight) {
Some(face) => {
self.fonts.entry(face.id).or_insert_with(|| face.clone());
Some(face.id)
}
None => {
let has = family.weights();
self.no_weight(path, pos, weight_at, weight, &family.name, &has);
None
}
}
}
fn no_weight(
&mut self,
path: &str,
pos: Pos,
weight_at: Option<Pos>,
weight: u16,
family: &str,
has: &[u16],
) {
let message = if has.is_empty() {
format!(
"weight: {family} has no {weight}; it has no weight from 100 to 900 in steps of 100"
)
} else {
format!(
"weight: {family} has no {weight}; it has {}",
fonts::list(has)
)
};
self.problem(path, weight_at.unwrap_or(pos), message);
}
fn family(&mut self, at: &str) -> (Found, bool) {
if let Some(known) = self.families.get(at) {
return known.clone();
}
let family = match self.font_file(at) {
Some(Ok(file)) => Some(Ok(Rc::new(Family {
name: file.family.clone(),
faces: vec![file],
}))),
Some(Err(e)) => Some(Err(e)),
None => self.files.list(at).map(|names| {
let mut faces = Vec::new();
for name in names.into_iter().filter(|n| fonts::is_font_name(n)) {
match self.font_file(&format!("{at}/{name}")) {
Some(Ok(file)) => faces.push((name, file)),
Some(Err(e)) => return Err(format!("holds {name}, which {e}")),
None => {}
}
}
Family::of_folder(faces).map(Rc::new)
}),
};
self.families
.insert(at.to_string(), (family.clone(), false));
(family, false)
}
fn font_file(&mut self, at: &str) -> Option<Result<Arc<FontFile>, String>> {
let file = self.files.font(at)?;
if !self.read.iter().any(|p| p == at) {
self.read.push(at.to_string());
}
Some(file)
}
fn interaction(&mut self, path: &str, action: &str) -> Result<String, String> {
let Some((verb, target)) = action.split_once(' ') else {
return Ok(action.to_string());
};
let target = target.trim();
if !target.ends_with(".design") {
return Err(format!("{action} — a design is a .design file"));
}
match join(path, target) {
None => Err(format!("{action} — outside the served folder")),
Some(at) if self.files.bytes(&at).is_none() => Err(format!("{action} — no such file")),
Some(at) => Ok(format!("{verb} {at}")),
}
}
fn common(&mut self, path: &str, by: &str, node: &mut Node, attr: &parse::Attr, value: Value) {
let (at_file, at_pos) = written_at(path, by, attr, &value);
let ok = match attr.name.as_str() {
"x" => num(&value).map(|n| node.x = n),
"y" => num(&value).map(|n| node.y = n),
"width" => size(&value).map(|s| node.width = s),
"height" => size(&value).map(|s| node.height = s),
"name" => str_of(&value)
.map(|s| node.name = s)
.ok_or_else(|| "expected a string".into()),
"opacity" => match value {
Value::Num(n) if (0.0..=1.0).contains(&n) => {
node.opacity = n as f32;
Ok(())
}
other => Err(format!(
"expected a number from 0 to 1, found {}",
show(&other)
)),
},
"onClick" => match str_of(&value).filter(|s| interaction_ok(s)) {
Some(action) => match self.interaction(by, &action) {
Ok(a) => {
node.on_click = Some(a);
Ok(())
}
Err(e) => {
self.problem(&at_file, at_pos, format!("onClick: {e}"));
return;
}
},
None => Err(
"expected \"navigate <path>\", \"back\", \"overlay <path>\" or \"close\""
.into(),
),
},
_ => unreachable!(),
};
if let Err(e) = ok {
self.problem(path, attr.pos, format!("{}: {e}", attr.name));
}
}
}
fn default_text() -> TextStyle {
TextStyle {
font: None,
content: String::new(),
size: 14.0,
weight: 400,
color: Color::fixed(Rgba {
r: 0x0f,
g: 0x17,
b: 0x2a,
a: 255,
}),
line_height: 1.4,
align: TextAlign::Left,
}
}
fn view_box(s: &str) -> Result<[f32; 4], String> {
let v: Vec<f32> = s
.split([' ', ','])
.filter(|p| !p.is_empty())
.map(|p| parse::number(p).map(|n| n as f32))
.collect::<Result<_, _>>()?;
match v[..] {
[x, y, w, h] if w > 0.0 && h > 0.0 => Ok([x, y, w, h]),
_ => Err("expected \"minX minY width height\"".into()),
}
}
fn path_numbers(d: &str) -> Result<(), String> {
let b = d.as_bytes();
let mut i = 0;
let mut command = b' ';
let mut param = 0;
while i < b.len() {
let c = b[i];
if c.is_ascii_alphabetic() && c != b'e' && c != b'E' {
command = c;
param = 0;
i += 1;
continue;
}
if c.is_ascii_whitespace() || c == b',' {
i += 1;
continue;
}
if matches!(command, b'A' | b'a') && matches!(param % 7, 3 | 4) && matches!(c, b'0' | b'1')
{
param += 1;
i += 1;
continue;
}
let start = i;
if matches!(b[i], b'+' | b'-') {
i += 1;
}
let mut dot = false;
while i < b.len() && (b[i].is_ascii_digit() || (b[i] == b'.' && !dot)) {
dot |= b[i] == b'.';
i += 1;
}
if i < b.len() && matches!(b[i], b'e' | b'E') {
let mut j = i + 1;
if j < b.len() && matches!(b[j], b'+' | b'-') {
j += 1;
}
if j < b.len() && b[j].is_ascii_digit() {
i = j;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
}
}
if i == start {
return Ok(());
}
let s = &d[start..i];
if let Ok(n) = s.parse::<f64>() {
parse::in_range(s, n)?;
}
param += 1;
}
Ok(())
}
fn bounds(d: &str) -> [f32; 4] {
use vello_cpu::kurbo::Shape;
let b = vello_cpu::kurbo::BezPath::from_svg(d)
.map(|p| p.bounding_box())
.unwrap_or_default();
[
b.x0 as f32,
b.y0 as f32,
(b.width() as f32).max(1.0),
(b.height() as f32).max(1.0),
]
}
fn interaction_ok(s: &str) -> bool {
s == "back"
|| s == "close"
|| s.strip_prefix("navigate ")
.or(s.strip_prefix("overlay "))
.is_some_and(|p| !p.trim().is_empty())
}
pub fn join(from: &str, relative: &str) -> Option<String> {
let mut parts: Vec<&str> = from.split('/').filter(|p| !p.is_empty()).collect();
parts.pop();
for part in relative.split('/') {
match part {
"." | "" => {}
".." => {
parts.pop()?;
}
p => parts.push(p),
}
}
Some(parts.join("/"))
}
fn substitute(value: &Value, props: &Props, path: &str) -> (Value, String) {
match value {
Value::Prop(p) => match props.get(p) {
Some(given) => (given.value.clone(), given.by.clone()),
None => (value.clone(), path.to_string()),
},
v => (v.clone(), path.to_string()),
}
}
fn written_at(path: &str, by: &str, attr: &parse::Attr, value: &Value) -> (String, Pos) {
match value {
Value::Str(_, pos) if by != path => (by.to_string(), *pos),
_ => (path.to_string(), attr.pos),
}
}
fn show(v: &Value) -> String {
match v {
Value::Str(s, _) => format!("\"{s}\""),
Value::Num(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Array(_) => "an array".into(),
Value::Object(_) => "an object".into(),
Value::Prop(p) => p.clone(),
Value::Theme(name, _) => format!("theme.{name}"),
Value::Themed { written, .. } => written.clone(),
}
}
fn str_of(v: &Value) -> Option<String> {
match v {
Value::Str(s, _) => Some(s.clone()),
_ => None,
}
}
fn key_pos(text: &str, under: Option<&str>, key: &str) -> Option<Pos> {
let names = |line: &str, key: &str| {
let line = line.trim_start();
[
format!("{key}:"),
format!("\"{key}\":"),
format!("'{key}':"),
]
.iter()
.any(|k| line.starts_with(k.as_str()))
};
let top = |line: &str| !line.starts_with([' ', '\t']) && !line.trim().is_empty();
let mut inside = under.is_none();
for (n, line) in text.lines().enumerate() {
if line.trim_start().starts_with('#') {
continue;
}
let pos = Pos {
line: n as u32 + 1,
col: (line.len() - line.trim_start().len()) as u32 + 1,
};
match under {
None if top(line) && names(line, key) => return Some(pos),
None => {}
Some(block) if top(line) => inside = names(line, block),
Some(_) if inside && names(line, key) => return Some(pos),
Some(_) => {}
}
}
None
}
const ELEMENTS: [&str; 6] = ["Frame", "Text", "Rect", "Ellipse", "Vector", "Image"];
fn num(v: &Value) -> Result<f32, String> {
match v {
Value::Num(n) => Ok(*n as f32),
other => Err(format!("expected a number, found {}", show(other))),
}
}
fn px(v: &Value) -> Result<f32, String> {
match v {
Value::Num(n) if *n >= 0.0 => Ok(*n as f32),
Value::Num(n) => Err(format!("expected a number of 0 or more, found {n}")),
other => Err(format!("expected a number, found {}", show(other))),
}
}
fn pxs(a: &[Value]) -> Result<Vec<f32>, String> {
a.iter().map(px).collect()
}
fn size(v: &Value) -> Result<Size, String> {
match v {
Value::Num(n) if *n >= 0.0 => Ok(Size::Px(*n as f32)),
Value::Num(n) => Err(format!("expected pixels of 0 or more, found {n}")),
Value::Str(s, _) if s == "hug" => Ok(Size::Hug),
Value::Str(s, _) if s == "fill" => Ok(Size::Fill),
other => Err(format!(
"expected pixels, \"hug\" or \"fill\", found {}",
show(other)
)),
}
}
fn align(v: &Value, justify: bool) -> Result<Align, String> {
match str_of(v).as_deref() {
Some("start") => Ok(Align::Start),
Some("center") => Ok(Align::Center),
Some("end") => Ok(Align::End),
Some("space-between") if justify => Ok(Align::SpaceBetween),
_ if justify => Err("expected \"start\", \"center\", \"end\" or \"space-between\"".into()),
_ => Err("expected \"start\", \"center\" or \"end\"".into()),
}
}
fn color(v: &Value) -> Result<Color, String> {
match v {
Value::Str(s, _) => rgba(s).map(Color::fixed),
Value::Themed { light, dark, .. } => Ok(Color {
light: rgba(light)?,
dark: rgba(dark)?,
}),
_ => Err(format!("expected a color, found {}", show(v))),
}
}
pub fn rgba(s: &str) -> Result<Rgba, String> {
if s.starts_with("theme.") {
return Err(format!(
"{s} is a theme color: write it in braces, {{{s}}}, not in quotes"
));
}
let hex = s.strip_prefix('#').ok_or("expected a color like #rrggbb")?;
if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(format!("{s} is not a color"));
}
let d = |i: usize, n: usize| {
u8::from_str_radix(&hex[i..i + n], 16).map_err(|_| format!("{s} is not a color"))
};
let dd = |i: usize| d(i, 1).map(|v| v * 17);
match hex.len() {
3 => Ok(Rgba {
r: dd(0)?,
g: dd(1)?,
b: dd(2)?,
a: 255,
}),
4 => Ok(Rgba {
r: dd(0)?,
g: dd(1)?,
b: dd(2)?,
a: dd(3)?,
}),
6 => Ok(Rgba {
r: d(0, 2)?,
g: d(2, 2)?,
b: d(4, 2)?,
a: 255,
}),
8 => Ok(Rgba {
r: d(0, 2)?,
g: d(2, 2)?,
b: d(4, 2)?,
a: d(6, 2)?,
}),
_ => Err(format!("{s} is not a color")),
}
}
fn hex(c: Rgba) -> String {
format!("#{:02x}{:02x}{:02x}{:02x}", c.r, c.g, c.b, c.a)
}
fn fill(v: &Value) -> Result<Fill, String> {
match v {
Value::Str(s, _) => fill_of(s),
Value::Themed { light, dark, .. } => match (fill_of(light)?, fill_of(dark)?) {
(Fill::Solid(l), Fill::Solid(d)) => Ok(Fill::Solid(Color {
light: l.light,
dark: d.light,
})),
(
Fill::Linear { angle, stops: l },
Fill::Linear {
stops: d,
angle: dark_angle,
},
) if angle == dark_angle && l.len() == d.len() => Ok(Fill::Linear {
angle,
stops: l
.iter()
.zip(&d)
.map(|((l, at), (d, _))| {
(
Color {
light: l.light,
dark: d.light,
},
*at,
)
})
.collect(),
}),
_ => Err(format!("{} is not the same fill in both themes", show(v))),
},
_ => Err(format!("expected a color or a gradient, found {}", show(v))),
}
}
fn fill_of(s: &str) -> Result<Fill, String> {
let Some(inner) = s
.strip_prefix("linear-gradient(")
.and_then(|r| r.strip_suffix(')'))
else {
return rgba(s).map(|c| Fill::Solid(Color::fixed(c)));
};
let wrong =
|| format!("expected \"linear-gradient(<angle>deg, <color> <stop>%, …)\", found \"{s}\"");
let mut parts = inner.split(',').map(str::trim);
let angle = parts
.next()
.and_then(|a| a.strip_suffix("deg"))
.ok_or_else(wrong)?;
let angle = parse::number(angle.trim())? as f32;
let mut stops = Vec::new();
for part in parts {
let (c, at) = part.split_once(' ').ok_or_else(wrong)?;
let at = at.trim().strip_suffix('%').ok_or_else(wrong)?;
let at = parse::number(at)? as f32;
stops.push((Color::fixed(rgba(c)?), at / 100.0));
}
if stops.len() < 2 {
return Err(wrong());
}
Ok(Fill::Linear { angle, stops })
}
fn has_theme(v: &Value) -> bool {
match v {
Value::Themed { .. } | Value::Theme(..) => true,
Value::Array(items) => items.iter().any(has_theme),
Value::Object(fields) => fields.iter().any(|(_, v)| has_theme(v)),
_ => false,
}
}
fn shadow(v: &Value) -> Result<Shadow, String> {
let Value::Object(fields) = v else {
return Err("expected { x, y, blur, spread, color }".into());
};
let mut s = Shadow {
x: 0.0,
y: 0.0,
blur: 0.0,
spread: 0.0,
color: Color::fixed(Rgba {
r: 0,
g: 0,
b: 0,
a: 64,
}),
at: Origin::default(),
};
for (k, v) in fields {
match k.as_str() {
"x" => s.x = num(v)?,
"y" => s.y = num(v)?,
"blur" => s.blur = num(v)?,
"spread" => s.spread = num(v)?,
"color" => s.color = color(v)?,
other => return Err(format!("a shadow has no {other}")),
}
}
Ok(s)
}
#[cfg(test)]
mod tests {
use super::*;
fn files(list: &[(&str, &str)]) -> HashMap<String, String> {
list.iter()
.map(|(p, s)| (p.to_string(), s.to_string()))
.collect()
}
#[test]
fn an_instance_takes_its_props() {
let fs = files(&[
(
"a/button.design",
r##"<Frame props={{ label: "Button", fill: "#2563eb" }} fill={fill}><Text>{label}</Text></Frame>"##,
),
(
"a/screen.design",
r#"import Button from "./button.design"
<Frame width={390}><Button label="Pay" x={10} opacity={0.5} /></Frame>"#,
),
]);
let scene = resolve(&fs, "a/screen.design").unwrap();
let button = &scene.root.children[0];
assert_eq!(button.x, 10.0);
assert_eq!(button.opacity, 0.5);
assert_eq!(
button.fill,
Some(Fill::Solid(Color::fixed(Rgba {
r: 0x25,
g: 0x63,
b: 0xeb,
a: 255
})))
);
assert_eq!(button.children[0].text.as_ref().unwrap().content, "Pay");
assert_eq!(scene.files, vec!["a/screen.design", "a/button.design"]);
}
#[test]
fn opacity_is_between_zero_and_one() {
let fs = files(&[
("dot.design", "<Frame />"),
(
"screen.design",
"import Dot from \"./dot.design\"\n<Frame opacity={1}>\n <Rect opacity={50} />\n <Dot opacity={-0.1} />\n</Frame>",
),
]);
let problems: Vec<String> = resolve(&fs, "screen.design")
.unwrap_err()
.iter()
.map(|p| p.to_string())
.collect();
assert_eq!(
problems,
vec![
"screen.design:3:9: opacity: expected a number from 0 to 1, found 50",
"screen.design:4:8: opacity: expected a number from 0 to 1, found -0.1",
]
);
}
fn problems_in(fs: &HashMap<String, String>, path: &str) -> Vec<String> {
resolve(fs, path)
.unwrap_err()
.iter()
.map(|p| p.to_string())
.collect()
}
#[test]
fn a_color_with_any_character_but_hex_digits_is_a_problem() {
for (value, col) in [
("#fff\u{200b}", 13),
("#ffé", 13),
("#ff€f00", 13),
("#ggg", 13),
] {
let fs = files(&[("a.design", &format!(r#"<Frame fill="{value}" />"#))]);
assert_eq!(
problems_in(&fs, "a.design"),
vec![format!("a.design:1:8: fill: {value} is not a color")],
"{value:?} at {col}"
);
}
}
#[test]
fn props_belong_to_the_root_only() {
let fs = files(&[(
"a.design",
r#"<Frame props={{ a: "x" }}>
<Frame props={{ b: "y" }} />
</Frame>"#,
)]);
assert_eq!(
problems_in(&fs, "a.design"),
vec!["a.design:2:10: props: only a design's root declares props"]
);
}
#[test]
fn a_root_has_no_place_of_its_own() {
let fs = files(&[
(
"dot.design",
r#"<Frame x={40} y={30} width={10} height={10} />"#,
),
(
"a.design",
r#"import Dot from "./dot.design"
<Frame width={100} height={100}>
<Dot x={5} y={6} />
</Frame>"#,
),
]);
let expected = [
"dot.design:1:8: x: a design's root has no place of its own; place an instance with x and y where it is used",
"dot.design:1:15: y: a design's root has no place of its own; place an instance with x and y where it is used",
];
assert_eq!(problems_in(&fs, "dot.design"), expected);
assert_eq!(problems_in(&fs, "a.design"), expected);
let fs = files(&[
("dot.design", r#"<Frame width={10} height={10} />"#),
(
"a.design",
r#"import Dot from "./dot.design"
<Frame width={100} height={100}>
<Dot x={5} y={6} />
</Frame>"#,
),
]);
let dot = &resolve(&fs, "a.design").unwrap().root.children[0];
assert_eq!((dot.x, dot.y), (5.0, 6.0));
}
#[test]
fn fill_needs_a_parent_with_auto_layout() {
let fs = files(&[
("dot.design", r#"<Frame width="fill" />"#),
(
"a.design",
r#"import Dot from "./dot.design"
<Frame width={100} height={100}>
<Rect width="fill" height={10} />
<Dot />
<Dot height="fill" />
<Frame layout="row"><Rect width="fill" /></Frame>
</Frame>"#,
),
]);
assert_eq!(
problems_in(&fs, "a.design"),
vec![
"a.design:3:9: width: \"fill\" needs a parent with layout=\"row\" or \"column\"",
"dot.design:1:8: width: \"fill\" needs a parent with layout=\"row\" or \"column\"",
"dot.design:1:8: width: \"fill\" needs a parent with layout=\"row\" or \"column\"",
"a.design:5:8: height: \"fill\" needs a parent with layout=\"row\" or \"column\"",
]
);
}
#[test]
fn a_tree_nests_at_most_so_deep_through_instances() {
let mut list = Vec::new();
let depth = 40;
for i in 0..depth {
let body = if i + 1 < depth {
let inner = "<Frame>".repeat(10) + "<N />" + &"</Frame>".repeat(10);
format!("import N from \"./{}.design\"\n{inner}", i + 1)
} else {
"<Frame />".to_string()
};
list.push((format!("{i}.design"), body));
}
let fs: HashMap<String, String> = list.into_iter().collect();
let found = problems_in(&fs, "0.design");
assert_eq!(found.len(), 1, "{found:?}");
assert!(
found[0].ends_with(&format!("nested more than {MAX_DEPTH} deep")),
"{found:?}"
);
}
#[test]
fn every_problem_is_reported() {
let fs = files(&[
(
"button.design",
r#"<Frame props={{ label: "B" }} padding="12px"><Text>{label}</Text></Frame>"#,
),
(
"screen.design",
"import Button from \"./button.design\"\nimport Row from \"./row.design\"\n<Frame>\n <Button lable=\"x\" />\n <Row />\n <Blob />\n</Frame>",
),
]);
let problems: Vec<String> = resolve(&fs, "screen.design")
.unwrap_err()
.iter()
.map(|p| p.to_string())
.collect();
assert_eq!(
problems,
vec![
"screen.design:2:1: import ./row.design — no such file",
"screen.design:4:11: Button has no prop \"lable\"",
"button.design:1:31: padding: expected pixels or an array of 2 or 4, found \"12px\"",
"screen.design:6:3: unknown element Blob",
]
);
}
#[test]
fn an_import_of_a_missing_file_is_a_problem_even_unused() {
let fs = files(&[("cart.design", "import Row from \"./row.design\"\n<Frame />")]);
let problems: Vec<String> = resolve(&fs, "cart.design")
.unwrap_err()
.iter()
.map(|p| p.to_string())
.collect();
assert_eq!(
problems,
vec!["cart.design:1:1: import ./row.design — no such file"]
);
}
#[test]
fn a_design_that_uses_itself() {
let fs = files(&[(
"a.design",
"import A from \"./a.design\"\n<Frame><A /></Frame>",
)]);
let problems = resolve(&fs, "a.design").unwrap_err();
assert_eq!(
problems[0].to_string(),
"a.design:2:8: a.design uses itself"
);
}
struct Mem(HashMap<String, Vec<u8>>);
impl Files for Mem {
fn bytes(&self, path: &str) -> Option<Vec<u8>> {
self.0.get(path).cloned()
}
fn list(&self, path: &str) -> Option<Vec<String>> {
listed(self.0.keys(), path)
}
}
fn png() -> Vec<u8> {
let mut out = Vec::new();
let mut e = png::Encoder::new(&mut out, 1, 1);
e.set_color(png::ColorType::Rgba);
let mut w = e.write_header().unwrap();
w.write_image_data(&[0, 0, 0, 255]).unwrap();
drop(w);
out
}
fn problems(source: &str) -> Vec<String> {
let fs = Mem([
("d/a.design".to_string(), source.as_bytes().to_vec()),
("d/photo.png".to_string(), png()),
("d/notes.txt".to_string(), b"hi".to_vec()),
(
"d/b.design".to_string(),
br#"<Frame props={{ label: "B" }}><Text>{label}</Text></Frame>"#.to_vec(),
),
]
.into());
match resolve(&fs, "d/a.design") {
Ok(_) => Vec::new(),
Err(p) => p.iter().map(|p| p.to_string()).collect(),
}
}
#[test]
fn each_problem_names_its_place() {
let cases = [
(
"<Rect />",
"d/a.design:1:1: the root element must be a Frame, not Rect",
),
(
"<Frame props=\"x\" />",
"d/a.design:1:8: props: expected an object of defaults",
),
(
"<Frame fill=\"red\" />",
"d/a.design:1:8: fill: expected a color like #rrggbb",
),
(
"<Frame fill=\"#12\" />",
"d/a.design:1:8: fill: #12 is not a color",
),
(
"<Frame fill=\"linear-gradient(90deg, #fff 0%)\" />",
"d/a.design:1:8: fill: expected \"linear-gradient(<angle>deg, <color> <stop>%, …)\", found \"linear-gradient(90deg, #fff 0%)\"",
),
(
"<Frame width=\"wide\" />",
"d/a.design:1:8: width: expected pixels, \"hug\" or \"fill\", found \"wide\"",
),
(
"<Frame gap=\"4\" />",
"d/a.design:1:8: gap: expected a number, found \"4\"",
),
(
"<Frame radius={[1, 2]} />",
"d/a.design:1:8: radius: expected pixels or an array of 4",
),
(
"<Frame layout=\"grid\" />",
"d/a.design:1:8: layout: expected \"row\", \"column\" or \"none\"",
),
(
"<Frame align=\"space-between\" />",
"d/a.design:1:8: align: expected \"start\", \"center\" or \"end\"",
),
(
"<Frame clip=\"yes\" />",
"d/a.design:1:8: clip: expected true or false",
),
(
"<Frame shadow={{ blurr: 4 }} />",
"d/a.design:1:8: shadow: a shadow has no blurr",
),
(
"<Frame onClick=\"go ./b.design\" />",
"d/a.design:1:8: onClick: expected \"navigate <path>\", \"back\", \"overlay <path>\" or \"close\"",
),
(
"<Frame size={4} />",
"d/a.design:1:8: Frame takes no attribute size",
),
(
"<Frame><Text fill=\"#fff\">a</Text></Frame>",
"d/a.design:1:14: Text takes no attribute fill",
),
(
"<Frame><Text weight={450}>a</Text></Frame>",
"d/a.design:1:14: weight: expected 100 to 900 in steps of 100",
),
(
"<Frame><Text textAlign=\"justify\">a</Text></Frame>",
"d/a.design:1:14: textAlign: expected \"left\", \"center\" or \"right\"",
),
(
"<Frame><Text><Rect /></Text></Frame>",
"d/a.design:1:14: Text holds text only",
),
(
"<Frame>hello</Frame>",
"d/a.design:1:8: text belongs in a Text, not a Frame",
),
(
"<Frame><Rect><Rect /></Rect></Frame>",
"d/a.design:1:14: Rect takes no children",
),
(
"<Frame><Text>{nope}</Text></Frame>",
"d/a.design:1:14: no text prop \"nope\"",
),
(
"<Frame gap={nope} />",
"d/a.design:1:8: gap: no prop \"nope\"",
),
(
"<Frame><Vector viewBox=\"0 0 24 24\" /></Frame>",
"d/a.design:1:8: Vector needs a path, d=\"M…\"",
),
(
"<Frame><Vector d=\"M0 0 Q\" /></Frame>",
"d/a.design:1:16: d: not SVG path data: Unexpected EOF",
),
(
"<Frame><Vector d=\"M0 0L1 1\" viewBox=\"0 0 24\" /></Frame>",
"d/a.design:1:29: viewBox: expected \"minX minY width height\"",
),
(
"<Frame><Image /></Frame>",
"d/a.design:1:8: Image needs a file, src=\"…\"",
),
(
"<Frame><Image src=\"./gone.png\" /></Frame>",
"d/a.design:1:15: src: ./gone.png — no such file",
),
(
"<Frame><Image src=\"../../x.png\" /></Frame>",
"d/a.design:1:15: src: ../../x.png — outside the served folder",
),
(
"<Frame><Image src=\"./notes.txt\" /></Frame>",
"d/a.design:1:15: src: ./notes.txt is not a PNG or JPEG file",
),
(
"<Frame><Rect clip /></Frame>",
"d/a.design:1:14: Rect takes no attribute clip",
),
(
"<Frame fill=\"linear-gradient(2000000deg, #fff 0%, #000 100%)\" />",
"d/a.design:1:8: fill: `2000000` is too large: numbers go up to 1000000",
),
(
"<Frame fill=\"linear-gradient(90deg, #fff -2000000%, #000 100%)\" />",
"d/a.design:1:8: fill: `-2000000` is too small: numbers go down to -1000000",
),
(
"<Frame fill=\"linear-gradient(NaNdeg, #fff 0%, #000 100%)\" />",
"d/a.design:1:8: fill: `NaN` is not a number",
),
(
"<Frame fill=\"linear-gradient(90deg, #fff 0%, #000 inf%)\" />",
"d/a.design:1:8: fill: `inf` is not a number",
),
(
"<Frame><Vector d=\"M0 0L1 1\" viewBox=\"0 0 2000000 24\" /></Frame>",
"d/a.design:1:29: viewBox: `2000000` is too large: numbers go up to 1000000",
),
(
"<Frame><Vector d=\"M0 0L1 1\" viewBox=\"0 0 inf 24\" /></Frame>",
"d/a.design:1:29: viewBox: `inf` is not a number",
),
(
"<Frame><Vector d=\"M0 0L2000000 0\" /></Frame>",
"d/a.design:1:16: d: `2000000` is too large: numbers go up to 1000000",
),
(
"<Frame><Vector d=\"M0 0L1e999 0\" /></Frame>",
"d/a.design:1:16: d: `1e999` is too large: numbers go up to 1000000",
),
(
"<Frame><Vector d=\"M0 0L-3e6 0\" /></Frame>",
"d/a.design:1:16: d: `-3e6` is too small: numbers go down to -1000000",
),
(
"import X from \"../../x.design\"\n<Frame />",
"d/a.design:1:1: import ../../x.design — outside the served folder",
),
(
"import B from \"./b.design\"\n<Frame><B fill=\"#fff\" /></Frame>",
"d/a.design:2:11: B has no prop \"fill\"",
),
(
"import B from \"./b.design\"\n<Frame><B>x</B></Frame>",
"d/a.design:2:8: B takes no children",
),
(
"<Frame><Blob /></Frame>",
"d/a.design:1:8: unknown element Blob",
),
];
for (source, expected) in cases {
assert_eq!(problems(source), vec![expected.to_string()], "{source}");
}
}
#[test]
fn an_image_that_does_not_decode_is_a_problem() {
let mut broken = b"\x89PNG\r\n\x1a\n".to_vec();
broken.extend(b"not a PNG past its signature");
let shirt = std::fs::read(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../docs/designs/checkout/shirt.jpg"
))
.unwrap();
let truncated = shirt[..shirt.len() / 2].to_vec();
for (name, data, expected) in [
(
"broken.png",
broken,
"d/a.design:2:8: src: ./broken.png is a PNG that doesn't decode: ",
),
(
"shirt.jpg",
truncated,
"d/a.design:2:8: src: ./shirt.jpg is a JPEG that doesn't decode: ",
),
] {
let source =
format!("<Frame>\n<Image src=\"./{name}\" width={{50}} height={{50}} />\n</Frame>");
let fs = Mem([
("d/a.design".to_string(), source.into_bytes()),
(format!("d/{name}"), data),
]
.into());
let found: Vec<String> = resolve(&fs, "d/a.design")
.unwrap_err()
.iter()
.map(|p| p.to_string())
.collect();
assert_eq!(found.len(), 1, "{found:?}");
assert!(found[0].starts_with(expected), "{found:?}");
}
let fs = Mem([
(
"a.design".to_string(),
br#"<Frame><Image src="./s.jpg" /></Frame>"#.to_vec(),
),
("s.jpg".to_string(), shirt),
]
.into());
let scene = resolve(&fs, "a.design").unwrap();
let image = scene.root.children[0].image.as_ref().unwrap();
let file = &scene.images[&image.id];
assert_eq!((file.width, file.height), (160, 160));
assert_eq!((file.pixels_width, file.pixels_height), (160, 160));
}
#[test]
fn a_large_image_file_is_resampled_once_to_fit() {
let mut data = Vec::new();
let mut e = png::Encoder::new(&mut data, 9000, 10);
e.set_color(png::ColorType::Rgba);
let mut w = e.write_header().unwrap();
let row: Vec<u8> = (0..9000)
.flat_map(|x| {
if x <= 4500 {
[255, 0, 0, 255]
} else {
[0, 0, 255, 255]
}
})
.collect();
w.write_image_data(&row.repeat(10)).unwrap();
drop(w);
let file = ImageFile::new(data).unwrap();
assert_eq!((file.width, file.height), (9000, 10));
assert_eq!((file.pixels_width, file.pixels_height), (4096, 5));
let px = |x: usize| &file.pixels[4 * x..][..4];
assert_eq!(px(0), [255, 0, 0, 255]);
assert_eq!(px(4095), [0, 0, 255, 255]);
assert_eq!(px(2048), [116, 0, 139, 255]);
let (same, w, h) = fit(&[1, 2, 3, 4], 1, 1, MAX_IMAGE_SIDE);
assert_eq!((same, w, h), (vec![1, 2, 3, 4], 1, 1));
let (mixed, _, _) = fit(&[255, 255, 255, 0, 0, 0, 255, 255], 2, 1, 1);
assert_eq!(mixed, [0, 0, 255, 128]);
}
#[test]
fn an_interaction_is_relative_to_the_file_that_wrote_it() {
let fs = files(&[
(
"app/a.design",
"import B from \"../lib/b.design\"\n<Frame><B /><B onClick=\"overlay ./c.design\" /></Frame>",
),
("app/c.design", "<Frame />"),
(
"lib/b.design",
"<Frame onClick=\"navigate ./next.design\" />",
),
("lib/next.design", "<Frame />"),
]);
let root = resolve(&fs, "app/a.design").unwrap().root;
assert_eq!(
root.children[0].on_click.as_deref(),
Some("navigate lib/next.design")
);
assert_eq!(
root.children[1].on_click.as_deref(),
Some("overlay app/c.design")
);
}
#[test]
fn an_interaction_to_nowhere_is_a_problem() {
for (click, expected) in [
(
"navigate ./nope.design",
"d/a.design:1:8: onClick: navigate ./nope.design — no such file",
),
(
"overlay ../../x.design",
"d/a.design:1:8: onClick: overlay ../../x.design — outside the served folder",
),
] {
assert_eq!(
problems(&format!("<Frame onClick=\"{click}\" />")),
vec![expected.to_string()],
"{click}"
);
}
}
#[test]
fn a_valid_design_has_no_problems() {
assert_eq!(
problems(
r##"import B from "./b.design"
<Frame fill="linear-gradient(90deg, #ffffff 0%, #000000ff 100%)">
<B label="Go" x={4} y={5} width={80} height="hug" name="Go" onClick="back" />
<Vector d="M0 0L24 24" viewBox="0 0 24 24" stroke="#000" />
<Frame radius={8} clip><Image src="./photo.png" radius={[1, 2, 3, 4]} /></Frame>
</Frame>"##
),
Vec::<String>::new()
);
}
#[test]
fn attributes_take_their_defaults() {
let fs = files(&[("a.design", "<Frame><Text>a</Text><Rect /></Frame>")]);
let root = resolve(&fs, "a.design").unwrap().root;
assert_eq!((root.width, root.height), (Size::Hug, Size::Hug));
assert_eq!(
(root.layout, root.gap, root.padding),
(Layout::None, 0.0, [0.0; 4])
);
assert_eq!((root.align, root.justify), (Align::Start, Align::Start));
assert_eq!(
(root.opacity, root.fill.clone(), root.clip),
(1.0, None, false)
);
let text = root.children[0].text.clone().unwrap();
assert_eq!(
text,
TextStyle {
content: "a".into(),
..default_text()
}
);
assert_eq!(
(text.size, text.weight, text.line_height, text.align),
(14.0, 400, 1.4, TextAlign::Left)
);
assert_eq!(root.children[1].stroke_width, 1.0);
assert_eq!(root.children[1].radius, [0.0; 4]);
}
#[test]
fn values_parse_into_their_types() {
let fs = Mem(
[
(
"a.design".to_string(),
br##"<Frame padding={[1, 2]} radius={[1, 2, 3, 4]} fill="linear-gradient(135deg, #f00 0%, #0000ff80 100%)">
<Frame padding={[1, 2, 3, 4]} fill="#abcd" />
<Vector d="M2 4L10 20" />
<Image src="./p.png" />
</Frame>"##
.to_vec(),
),
("p.png".to_string(), png()),
]
.into(),
);
let scene = resolve(&fs, "a.design").unwrap();
let root = &scene.root;
assert_eq!(root.padding, [1.0, 2.0, 1.0, 2.0]);
assert_eq!(root.radius, [1.0, 2.0, 3.0, 4.0]);
let c = |r, g, b, a| Color::fixed(Rgba { r, g, b, a });
assert_eq!(
root.fill,
Some(Fill::Linear {
angle: 135.0,
stops: vec![(c(255, 0, 0, 255), 0.0), (c(0, 0, 255, 0x80), 1.0)]
})
);
assert_eq!(root.children[0].padding, [1.0, 2.0, 3.0, 4.0]);
assert_eq!(
root.children[0].fill,
Some(Fill::Solid(c(0xaa, 0xbb, 0xcc, 0xdd)))
);
assert_eq!(
root.children[1].vector.as_ref().unwrap().view_box,
[2.0, 4.0, 8.0, 16.0]
);
assert_eq!(root.children[2].image.as_ref().unwrap().src, "p.png");
assert_eq!(scene.files, vec!["a.design", "p.png"]);
}
#[test]
fn paths_stay_in_the_served_folder() {
assert_eq!(
join("a/b.design", "./c.design").as_deref(),
Some("a/c.design")
);
assert_eq!(
join("a/b.design", "../c.design").as_deref(),
Some("c.design")
);
assert_eq!(join("a/b.design", "../../c.design"), None);
assert_eq!(
join("/a/b.design", "x/../y.png").as_deref(),
Some("a/y.png")
);
}
#[test]
fn an_interaction_leads_only_to_a_design() {
for (click, expected) in [
(
"navigate ./notes.txt",
"d/a.design:1:8: onClick: navigate ./notes.txt — a design is a .design file",
),
(
"overlay ./photo.png",
"d/a.design:1:8: onClick: overlay ./photo.png — a design is a .design file",
),
] {
assert_eq!(
problems(&format!("<Frame onClick=\"{click}\" />")),
vec![expected.to_string()],
"{click}"
);
}
assert!(problems(r#"<Frame onClick="navigate ./b.design" />"#).is_empty());
}
#[test]
fn sizes_and_spacing_are_never_negative() {
for (attr, element) in [
("gap={-4}", "Frame"),
("padding={-1}", "Frame"),
("padding={[4, -2]}", "Frame"),
("radius={-3}", "Rect"),
("radius={[1, 2, -3, 4]}", "Rect"),
("strokeWidth={-1}", "Rect"),
("size={-12}", "Text"),
("lineHeight={-1}", "Text"),
] {
let found = problems(&format!("<Frame><{element} {attr} /></Frame>"));
assert_eq!(found.len(), 1, "{attr}: {found:?}");
assert!(found[0].contains("of 0 or more"), "{attr}: {found:?}");
}
}
#[test]
fn an_import_name_is_unique_and_not_an_element() {
let found = problems(
r#"import B from "./b.design"
import B from "./b.design"
<Frame />"#,
);
assert_eq!(found, ["d/a.design:2:1: import B — B is already imported"]);
let found = problems(
r#"import Frame from "./b.design"
<Frame />"#,
);
assert_eq!(
found,
["d/a.design:1:1: import Frame — Frame is a built-in element"]
);
}
#[test]
fn justify_names_all_it_takes() {
assert_eq!(
problems(r#"<Frame justify="middle" />"#),
[
"d/a.design:1:8: justify: expected \"start\", \"center\", \"end\" or \"space-between\""
]
);
assert_eq!(
problems(r#"<Frame align="middle" />"#),
["d/a.design:1:8: align: expected \"start\", \"center\" or \"end\""]
);
}
#[test]
fn values_that_would_be_ignored_are_problems() {
let found =
problems(r#"<Frame layout="row"><Rect width={10} height={10} x={5} /></Frame>"#);
assert!(
found
.iter()
.any(|p| p.contains("x: places a child only in a frame with layout=\"none\"")),
"{found:?}"
);
let found = problems(
r#"import B from "./b.design"
<Frame layout="row"><B y={3} /></Frame>"#,
);
assert!(
found.iter().any(|p| p.contains("y: places a child only")),
"{found:?}"
);
let found = problems(r#"<Frame width={-1} height={-2} />"#);
assert_eq!(found.len(), 2, "{found:?}");
assert!(
found[0].contains("width: expected pixels of 0 or more"),
"{found:?}"
);
let found = problems(r##"<Frame fill="#ff0000" fill="#00ff00" />"##);
assert!(
found.iter().any(|p| p.contains("fill is given twice")),
"{found:?}"
);
let found = problems(
r#"import B from "./b.design"
<Frame><B label="a" label="b" /></Frame>"#,
);
assert!(
found.iter().any(|p| p.contains("label is given twice")),
"{found:?}"
);
let found = problems(
r#"import P from "./photo.png"
<Frame />"#,
);
assert_eq!(
found,
["d/a.design:1:1: import ./photo.png — a design is a .design file"]
);
assert!(problems(r#"<Frame><Rect width={1} height={1} x={5} y={5} /></Frame>"#).is_empty());
}
const THEMES: &str = r##"kind: themes
light:
surface: "#ffffff"
text: "#000"
dark:
surface: "#0f172a"
text: "#f1f5f9"
"##;
fn rgb(r: u8, g: u8, b: u8) -> Rgba {
Rgba { r, g, b, a: 255 }
}
#[test]
fn a_theme_color_has_a_value_in_each_theme() {
let fs = files(&[
("themes.yml", THEMES),
(
"a.design",
r##"<Frame fill={theme.surface} stroke="#ff0000"
shadow={{ y: 2, color: theme.text }}>
<Text color={theme.text}>Hi</Text>
<Rect width={1} height={1} fill="linear-gradient(90deg, theme.surface 0%, #ff0000 100%)" />
</Frame>"##,
),
]);
let scene = resolve(&fs, "a.design").unwrap();
assert!(scene.themed);
let surface = Color {
light: rgb(255, 255, 255),
dark: rgb(0x0f, 0x17, 0x2a),
};
let text = Color {
light: rgb(0, 0, 0),
dark: rgb(0xf1, 0xf5, 0xf9),
};
let root = &scene.root;
assert_eq!(root.fill, Some(Fill::Solid(surface)));
assert_eq!(root.stroke, Some(Color::fixed(rgb(255, 0, 0))));
assert_eq!(root.shadow.as_ref().unwrap().color, text);
assert_eq!(root.children[0].text.as_ref().unwrap().color, text);
assert_eq!(
root.children[1].fill,
Some(Fill::Linear {
angle: 90.0,
stops: vec![(surface, 0.0), (Color::fixed(rgb(255, 0, 0)), 1.0)]
})
);
assert_eq!(scene.files, vec!["a.design", "themes.yml"]);
let plain = resolve(
&files(&[("themes.yml", THEMES), ("b.design", "<Frame />")]),
"b.design",
)
.unwrap();
assert!(!plain.themed);
assert_eq!(plain.files, vec!["b.design"]);
}
struct Asked(HashMap<String, String>, std::cell::RefCell<Vec<String>>);
impl Files for Asked {
fn bytes(&self, path: &str) -> Option<Vec<u8>> {
self.1.borrow_mut().push(path.to_string());
self.0.bytes(path)
}
fn list(&self, path: &str) -> Option<Vec<String>> {
self.0.list(path)
}
}
#[test]
fn the_nearest_themes_override_those_above_name_by_name() {
let fs = Asked(
files(&[
("themes.yml", THEMES),
(
"app/themes.yml",
"kind: themes\nlight:\n text: \"#111111\"\ndark: {}\n",
),
("app/screens/themes.yml", "kind: tokens\nlight: 1\n"),
(
"app/screens/a.design",
"<Frame fill={theme.surface}><Text color={theme.text}>Hi</Text></Frame>",
),
]),
Default::default(),
);
let scene = resolve(&fs, "app/screens/a.design").unwrap();
assert_eq!(
scene.root.fill,
Some(Fill::Solid(Color {
light: rgb(255, 255, 255),
dark: rgb(0x0f, 0x17, 0x2a)
}))
);
assert_eq!(
scene.root.children[0].text.as_ref().unwrap().color,
Color {
light: rgb(0x11, 0x11, 0x11),
dark: rgb(0xf1, 0xf5, 0xf9)
}
);
let mut asked = fs.1.borrow().clone();
asked.retain(|p| p.ends_with("themes.yml"));
assert_eq!(
asked,
vec!["app/screens/themes.yml", "app/themes.yml", "themes.yml"]
.into_iter()
.rev()
.collect::<Vec<_>>()
);
}
#[test]
fn a_theme_color_belongs_to_the_file_that_wrote_it() {
let dark_red =
"kind: themes\nlight:\n accent: \"#ff0000\"\ndark:\n accent: \"#880000\"\n";
let blue = "kind: themes\nlight:\n accent: \"#0000ff\"\ndark:\n accent: \"#000088\"\n";
let fs = files(&[
("kit/themes.yml", dark_red),
(
"kit/button.design",
"<Frame props={{ fill: theme.accent }} fill={fill} />",
),
("app/themes.yml", blue),
(
"app/screen.design",
"import Button from \"../kit/button.design\"\n<Frame layout=\"row\">\n <Button />\n <Button fill={theme.accent} />\n</Frame>",
),
]);
let scene = resolve(&fs, "app/screen.design").unwrap();
let fill = |n: usize| scene.root.children[n].fill.clone();
assert_eq!(
fill(0),
Some(Fill::Solid(Color {
light: rgb(255, 0, 0),
dark: rgb(0x88, 0, 0)
}))
);
assert_eq!(
fill(1),
Some(Fill::Solid(Color {
light: rgb(0, 0, 255),
dark: rgb(0, 0, 0x88)
}))
);
assert!(scene.themed);
}
#[test]
fn a_theme_color_that_isnt_there_is_a_problem_at_the_reference() {
let one_sided = "kind: themes\nlight:\n surface: \"#fff\"\n edge: \"#000\"\ndark:\n surface: \"#000\"\n";
let design = r##"<Frame fill={theme.srface}>
<Rect width={1} height={1} fill={theme.edge} />
<Rect width={1} height={1} fill="linear-gradient(90deg, #000 0%, theme.nope 100%)" />
<Text>{theme.surface}</Text>
</Frame>"##;
let fs = files(&[("themes.yml", one_sided), ("a.design", design)]);
assert_eq!(
problems_in(&fs, "a.design"),
vec![
"a.design:1:14: theme.srface: the themes have no srface; they have edge, surface",
"a.design:2:36: theme.edge: only the light theme has edge; a name needs a color in both",
"a.design:3:68: theme.nope: the themes have no nope; they have edge, surface",
"a.design:4:10: theme.surface is a color, not text: give it to color={theme.surface}",
]
);
let fs = files(&[("a/a.design", "<Frame fill={theme.surface} />")]);
assert_eq!(
problems_in(&fs, "a/a.design"),
vec![
"a/a.design:1:14: theme.surface: no themes.yml with kind: themes in this design's folder or above it"
]
);
}
#[test]
fn a_malformed_themes_file_is_a_problem_at_its_line() {
let broken = r##"kind: themes
# the light one
light:
surface: "#ffff"
text: "#gg0000"
edge: #000
9lives: "#000"
dark: "#000"
colour: red
"##;
let fs = files(&[
("themes.yml", broken),
("a.design", "<Frame fill={theme.surface} />"),
]);
assert_eq!(
problems_in(&fs, "a.design"),
vec![
"themes.yml:5:3: light.text: #gg0000 is not a color",
"themes.yml:6:3: light.edge: no color: write it in quotes, \"#rrggbb\", as YAML reads # as a comment",
"themes.yml:7:3: light.9lives: a name holds letters, digits, _ and -, and starts with a letter",
"themes.yml:8:1: dark: expected a map of names to colors, like surface: \"#ffffff\"",
"themes.yml:9:1: colour: a themes file has kind, fonts, light and dark only",
"a.design:1:14: theme.surface: only the light theme has surface; a name needs a color in both",
]
);
let fs = files(&[
("themes.yml", "kind: themes\nlight: {}\n"),
("a.design", "<Frame fill={theme.surface} />"),
]);
assert_eq!(
problems_in(&fs, "a.design")[0],
"themes.yml:1:1: a themes file has a light and a dark theme; this one has no dark"
);
let fs = files(&[
("themes.yml", "kind: themes\nlight: [\n"),
("a.design", "<Frame fill={theme.surface} />"),
]);
assert!(
problems_in(&fs, "a.design")[0]
.starts_with("themes.yml:3:1: this themes file isn't valid YAML: "),
"{:?}",
problems_in(&fs, "a.design")
);
let fs = files(&[("themes.yml", "light: [\n"), ("a.design", "<Frame />")]);
assert!(resolve(&fs, "a.design").is_ok());
}
#[test]
fn theme_is_no_props_or_imports_name() {
let fs = files(&[
("themes.yml", THEMES),
("b.design", "<Frame />"),
(
"a.design",
"import theme from \"./b.design\"\n<Frame props={{ theme: \"#fff\" }} fill={theme} />",
),
]);
assert_eq!(
problems_in(&fs, "a.design"),
vec![
"a.design:1:1: import theme — theme is the name of the design's themes",
"a.design:2:8: props: theme is the name of the design's themes, not a prop",
"a.design:2:34: fill: no prop \"theme\"",
]
);
let fs = files(&[
("themes.yml", THEMES),
("a.design", "<Frame fill=\"theme.surface\" />"),
]);
assert_eq!(
problems_in(&fs, "a.design"),
vec![
"a.design:1:8: fill: theme.surface is a theme color: write it in braces, {theme.surface}, not in quotes"
]
);
}
fn figtree() -> Vec<u8> {
std::fs::read(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../docs/designs/checkout/fonts/Figtree/Figtree-Variable.ttf"
))
.unwrap()
}
fn inter(face: &str) -> Vec<u8> {
std::fs::read(format!(
"{}/fonts/Inter-{face}.ttf",
env!("CARGO_MANIFEST_DIR")
))
.unwrap()
}
fn mem(list: &[(&str, Vec<u8>)]) -> Mem {
Mem(list
.iter()
.map(|(p, b)| (p.to_string(), b.clone()))
.collect())
}
fn fonts_of(node: &Node, out: &mut Vec<Option<FontId>>) {
if let Some(t) = &node.text {
out.push(t.font);
}
for c in &node.children {
fonts_of(c, out);
}
}
fn named(fonts: &str) -> Vec<u8> {
format!("kind: themes\nfonts:\n{fonts}light: {{}}\ndark: {{}}\n").into_bytes()
}
#[test]
fn a_text_is_set_in_the_font_it_names_or_inter() {
let fig = FontId::of(&figtree());
let bold = FontId::of(&inter("Bold"));
let fs = mem(&[
(
"themes.yml",
named(" figtree: ./fonts/Figtree\n heading: ./fonts/Figtree\n"),
),
("fonts/Figtree/Figtree-Variable.ttf", figtree()),
("fonts/Figtree/OFL.txt", b"licence".to_vec()),
("fonts/Inter/Inter-Bold.ttf", inter("Bold")),
("fonts/Inter/Inter-Regular.ttf", inter("Regular")),
("app/themes.yml", named(" heading: ../fonts/Inter\n")),
(
"app/a.design",
br#"<Frame><Text font="figtree">a</Text><Text font="heading" weight={700}>b</Text><Text>c</Text></Frame>"#
.to_vec(),
),
]);
let scene = resolve(&fs, "app/a.design").unwrap();
let mut found = Vec::new();
fonts_of(&scene.root, &mut found);
assert_eq!(found, vec![Some(fig), Some(bold), None]);
assert_eq!(scene.fonts.len(), 2);
assert!(!scene.themed, "a font is no theme color");
let plain = files(&[("a.design", "<Frame><Text>a</Text></Frame>")]);
assert!(resolve(&plain, "a.design").unwrap().fonts.is_empty());
}
#[test]
fn weight_picks_the_face_and_a_scene_carries_only_those_used() {
let fs = mem(&[
("themes.yml", named(" inter: ./f\n fig: ./v.ttf\n")),
("f/Inter-Regular.ttf", inter("Regular")),
("f/Inter-Medium.ttf", inter("Medium")),
("f/Inter-SemiBold.ttf", inter("SemiBold")),
("f/Inter-Bold.ttf", inter("Bold")),
("v.ttf", figtree()),
(
"a.design",
br#"<Frame><Text font="inter" weight={600}>a</Text><Text font="inter" weight={600}>b</Text><Text font="inter">c</Text><Text font="fig" weight={800}>d</Text><Text font="fig" weight={300}>e</Text></Frame>"#
.to_vec(),
),
]);
let scene = resolve(&fs, "a.design").unwrap();
let mut found = Vec::new();
fonts_of(&scene.root, &mut found);
let (semi, regular, fig) = (
FontId::of(&inter("SemiBold")),
FontId::of(&inter("Regular")),
FontId::of(&figtree()),
);
assert_eq!(
found,
vec![Some(semi), Some(semi), Some(regular), Some(fig), Some(fig)]
);
assert_eq!(scene.fonts.len(), 3, "medium and bold aren't used");
}
fn font_problems(list: &[(&str, Vec<u8>)], path: &str) -> Vec<String> {
match resolve(&mem(list), path) {
Ok(_) => Vec::new(),
Err(p) => p.iter().map(|p| p.to_string()).collect(),
}
}
#[test]
fn a_weight_the_font_lacks_is_a_problem_listing_those_it_has() {
let design = |text: &str| {
vec![
("themes.yml", named(" fig: ./v.ttf\n bold: ./b\n")),
("v.ttf", figtree()),
("b/Inter-Bold.ttf", inter("Bold")),
("a.design", format!("<Frame>{text}</Frame>").into_bytes()),
]
};
for (text, expected) in [
(
r#"<Text weight={300}>a</Text>"#,
"a.design:1:14: weight: Inter has no 300; it has 400, 500, 600 and 700",
),
(
r#"<Text font="fig" weight={200}>a</Text>"#,
"a.design:1:25: weight: Figtree has no 200; it has 300, 400, 500, 600, 700, 800 and 900",
),
(
r#"<Text font="bold">a</Text>"#,
"a.design:1:8: weight: Inter has no 400; it has 700",
),
] {
assert_eq!(
font_problems(&design(text), "a.design"),
vec![expected],
"{text}"
);
}
}
#[test]
fn a_font_that_cant_be_used_is_a_problem_at_its_entry() {
assert_eq!(
font_problems(
&[
("themes.yml", named(" fig: ./v.ttf\n body: ./v.ttf\n")),
("v.ttf", figtree()),
(
"a.design",
br#"<Frame><Text font="figg">a</Text></Frame>"#.to_vec()
),
],
"a.design"
),
vec!["a.design:1:14: font: no font named figg; the themes name body, fig"]
);
assert_eq!(
font_problems(
&[(
"a.design",
br#"<Frame><Text font="fig">a</Text></Frame>"#.to_vec()
)],
"a.design"
),
vec![
"a.design:1:14: font: no font named fig; no themes.yml in this design's folder or above it names fonts"
]
);
assert_eq!(
font_problems(
&[(
"a.design",
br#"<Frame><Text font={3}>a</Text></Frame>"#.to_vec()
)],
"a.design"
),
vec![
"a.design:1:14: font: expected the name of a font the themes name, like \"figtree\""
]
);
let mut big = vec![0, 1, 0, 0];
big.resize(fonts::MAX_FONT_BYTES + 1, 0);
let mut copy = inter("Regular");
copy.push(0);
let list = vec![
("d/v.ttf", figtree()),
("d/notes.txt", b"hi".to_vec()),
("d/web.woff2", b"wOF2rest".to_vec()),
("d/web.woff", b"wOFFrest".to_vec()),
("d/all.ttc", b"ttcfrest".to_vec()),
("d/big.ttf", big),
("d/broken.ttf", b"\0\x01\0\0broken".to_vec()),
("d/empty/OFL.txt", b"licence".to_vec()),
("d/mixed/Figtree.ttf", figtree()),
("d/mixed/Inter.ttf", inter("Regular")),
("d/twice/a.ttf", inter("Regular")),
("d/twice/b.ttf", copy),
("d/bad/a.ttf", b"nope".to_vec()),
];
for (path, expected) in [
("./nope", "— no such file or folder"),
("../../up", "— outside the served folder"),
(
"./notes.txt",
"is not a font: devpage reads .ttf and .otf files",
),
(
"./web.woff2",
"is a WOFF2 file, which devpage doesn't read — decompress it to a .ttf or .otf",
),
(
"./web.woff",
"is a WOFF file, which devpage doesn't read — decompress it to a .ttf or .otf",
),
(
"./all.ttc",
"is a collection of fonts; name one font's own .ttf or .otf file",
),
(
"./big.ttf",
"is 4.0 MB; a font file is at most 4 MB — subset it to the characters the design shows",
),
(
"./broken.ttf",
"is a .ttf or .otf file that doesn't read as a font",
),
("./empty", "holds no .ttf or .otf file"),
(
"./mixed",
"holds more than one family: Figtree (Figtree.ttf), Inter (Inter.ttf)",
),
("./twice", "holds two files of weight 400: a.ttf and b.ttf"),
(
"./bad",
"holds a.ttf, which is not a font: devpage reads .ttf and .otf files",
),
] {
let mut files = list.clone();
files.push(("d/themes.yml", named(&format!(" x: {path}\n"))));
files.push((
"d/a.design",
br#"<Frame><Text font="x">a</Text><Text font="x">b</Text></Frame>"#.to_vec(),
));
assert_eq!(
font_problems(&files, "d/a.design"),
vec![format!("d/themes.yml:3:3: fonts.x: {path} {expected}")]
);
}
}
#[test]
fn a_themes_files_fonts_are_names_to_paths() {
let problems = font_problems(
&[
(
"themes.yml",
b"kind: themes\nfonts:\n Figtree: ./v.ttf\n my_font: ./v.ttf\n ok: [1]\nfont: ./v.ttf\nlight: {}\ndark: {}\n"
.to_vec(),
),
("a.design", b"<Frame><Text>a</Text></Frame>".to_vec()),
],
"a.design",
);
assert_eq!(
problems,
Vec::<String>::new(),
"read only for a text that names a font"
);
let problems = font_problems(
&[
(
"themes.yml",
b"kind: themes\nfonts:\n Figtree: ./v.ttf\n my_font: ./v.ttf\n ok: [1]\nfont: ./v.ttf\nlight: {}\ndark: {}\n"
.to_vec(),
),
("a.design", br#"<Frame><Text font="ok">a</Text></Frame>"#.to_vec()),
],
"a.design",
);
assert_eq!(
problems,
vec![
"themes.yml:3:3: fonts.Figtree: a font's name holds lowercase letters, digits and - only",
"themes.yml:4:3: fonts.my_font: a font's name holds lowercase letters, digits and - only",
"themes.yml:5:3: fonts.ok: expected a path to a font file or a folder of one family's, like ./fonts/Figtree",
"themes.yml:6:1: font: a themes file has kind, fonts, light and dark only",
"a.design:1:14: font: no font named ok; no themes.yml in this design's folder or above it names fonts",
]
);
}
#[test]
fn a_value_given_as_a_prop_is_read_where_it_was_written() {
let list = |caller: &str| {
vec![
(
"app/a.design",
format!("import Card from \"../lib/card.design\"\n<Frame>{caller}</Frame>")
.into_bytes(),
),
("app/themes.yml", named(" mine: ./v.ttf\n")),
("app/me.png", png()),
("app/v.ttf", figtree()),
("app/next.design", b"<Frame />".to_vec()),
("lib/themes.yml", named(" own: ./own.ttf\n")),
(
"lib/card.design",
br#"<Frame props={{ src: "./own.png", font: "own", go: "back" }} onClick={go}><Image src={src} /><Text font={font}>a</Text></Frame>"#
.to_vec(),
),
("lib/own.png", png()),
("lib/own.ttf", inter("Regular")),
]
};
let scene = resolve(
&mem(&list(
r#"<Card src="./me.png" font="mine" go="navigate ./next.design" />"#,
)),
"app/a.design",
)
.unwrap();
let card = &scene.root.children[0];
assert_eq!(card.children[0].image.as_ref().unwrap().src, "app/me.png");
assert_eq!(
card.children[1].text.as_ref().unwrap().font,
Some(FontId::of(&figtree()))
);
assert_eq!(card.on_click.as_deref(), Some("navigate app/next.design"));
let scene = resolve(&mem(&list("<Card />")), "app/a.design").unwrap();
let card = &scene.root.children[0];
assert_eq!(card.children[0].image.as_ref().unwrap().src, "lib/own.png");
assert_eq!(
card.children[1].text.as_ref().unwrap().font,
Some(FontId::of(&inter("Regular")))
);
assert_eq!(
font_problems(
&list(r#"<Card src="./no.png" font="own" go="overlay ./no.design" />"#),
"app/a.design"
),
vec![
"app/a.design:2:43: onClick: overlay ./no.design — no such file",
"app/a.design:2:18: src: ./no.png — no such file",
"app/a.design:2:34: font: no font named own; the themes name mine",
]
);
}
}