feat: bootstrap namespace-driven Tessera renderer
This commit is contained in:
+326
@@ -0,0 +1,326 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
display_list::{Color, DisplayList, DrawCommand},
|
||||
geometry::{Point, Rect},
|
||||
};
|
||||
|
||||
pub const DEFAULT_NAMESPACE: &str = "app.main";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NamespaceState {
|
||||
pub name: String,
|
||||
pub revision: u64,
|
||||
pub document: RenderDocument,
|
||||
}
|
||||
|
||||
impl Default for NamespaceState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: DEFAULT_NAMESPACE.to_owned(),
|
||||
revision: 0,
|
||||
document: RenderDocument::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct RenderDocument {
|
||||
#[serde(default)]
|
||||
pub source: String,
|
||||
#[serde(default)]
|
||||
pub root: Option<CodeNode>,
|
||||
#[serde(default)]
|
||||
pub frame: Frame,
|
||||
}
|
||||
|
||||
impl RenderDocument {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.source.len() > 1_048_576 {
|
||||
bail!("source exceeds the 1 MiB limit");
|
||||
}
|
||||
if self.frame.commands.len() > 10_000 {
|
||||
bail!("frame exceeds the 10,000 command limit");
|
||||
}
|
||||
|
||||
parse_color(&self.frame.background)?;
|
||||
for command in &self.frame.commands {
|
||||
command.validate()?;
|
||||
}
|
||||
|
||||
let mut ids = HashSet::new();
|
||||
if let Some(root) = &self.root {
|
||||
root.validate(0, &mut ids)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn display_list(&self, scale: f32) -> Result<DisplayList> {
|
||||
let mut list = DisplayList::default();
|
||||
list.push(DrawCommand::Clear(parse_color(&self.frame.background)?));
|
||||
for command in &self.frame.commands {
|
||||
list.push(command.to_draw_command(scale)?);
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
pub fn node(&self, id: &str) -> Option<&CodeNode> {
|
||||
self.root.as_ref()?.find(id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct CodeNode {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
#[serde(default)]
|
||||
pub start_line: usize,
|
||||
#[serde(default)]
|
||||
pub end_line: usize,
|
||||
#[serde(default)]
|
||||
pub children: Vec<CodeNode>,
|
||||
}
|
||||
|
||||
impl CodeNode {
|
||||
fn validate<'a>(&'a self, depth: usize, ids: &mut HashSet<&'a str>) -> Result<()> {
|
||||
if depth > 64 {
|
||||
bail!("architecture tree exceeds 64 levels");
|
||||
}
|
||||
if self.id.is_empty() {
|
||||
bail!("architecture node id cannot be empty");
|
||||
}
|
||||
if !ids.insert(&self.id) {
|
||||
bail!("duplicate architecture node id: {}", self.id);
|
||||
}
|
||||
if self.start_line > 0 && self.end_line > 0 && self.start_line > self.end_line {
|
||||
bail!("invalid source range for node {}", self.id);
|
||||
}
|
||||
for child in &self.children {
|
||||
child.validate(depth + 1, ids)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find(&self, id: &str) -> Option<&CodeNode> {
|
||||
if self.id == id {
|
||||
return Some(self);
|
||||
}
|
||||
self.children.iter().find_map(|child| child.find(id))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Frame {
|
||||
#[serde(default = "default_background")]
|
||||
pub background: String,
|
||||
#[serde(default)]
|
||||
pub commands: Vec<RenderCommand>,
|
||||
}
|
||||
|
||||
impl Default for Frame {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
background: default_background(),
|
||||
commands: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_background() -> String {
|
||||
"#F7F7F5".to_owned()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum RenderCommand {
|
||||
RoundedRect {
|
||||
x: f32,
|
||||
y: f32,
|
||||
width: f32,
|
||||
height: f32,
|
||||
#[serde(default)]
|
||||
radius: f32,
|
||||
color: String,
|
||||
},
|
||||
StrokeRoundedRect {
|
||||
x: f32,
|
||||
y: f32,
|
||||
width: f32,
|
||||
height: f32,
|
||||
#[serde(default)]
|
||||
radius: f32,
|
||||
#[serde(default = "default_stroke_width")]
|
||||
stroke_width: f32,
|
||||
color: String,
|
||||
},
|
||||
Text {
|
||||
x: f32,
|
||||
y: f32,
|
||||
value: String,
|
||||
#[serde(default = "default_text_size")]
|
||||
size: u32,
|
||||
color: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl RenderCommand {
|
||||
fn validate(&self) -> Result<()> {
|
||||
match self {
|
||||
Self::RoundedRect {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
radius,
|
||||
color,
|
||||
} => {
|
||||
validate_rect(*x, *y, *width, *height, *radius)?;
|
||||
parse_color(color)?;
|
||||
}
|
||||
Self::StrokeRoundedRect {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
radius,
|
||||
stroke_width,
|
||||
color,
|
||||
} => {
|
||||
validate_rect(*x, *y, *width, *height, *radius)?;
|
||||
if !stroke_width.is_finite() || *stroke_width <= 0.0 {
|
||||
bail!("stroke_width must be a positive finite number");
|
||||
}
|
||||
parse_color(color)?;
|
||||
}
|
||||
Self::Text {
|
||||
x,
|
||||
y,
|
||||
value,
|
||||
size,
|
||||
color,
|
||||
} => {
|
||||
if !x.is_finite() || !y.is_finite() {
|
||||
bail!("text coordinates must be finite");
|
||||
}
|
||||
if value.len() > 16_384 {
|
||||
bail!("text command exceeds 16 KiB");
|
||||
}
|
||||
if !(1..=8).contains(size) {
|
||||
bail!("text size must be between 1 and 8");
|
||||
}
|
||||
parse_color(color)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn to_draw_command(&self, scale: f32) -> Result<DrawCommand> {
|
||||
Ok(match self {
|
||||
Self::RoundedRect {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
radius,
|
||||
color,
|
||||
} => DrawCommand::FillRoundedRect {
|
||||
rect: scaled_rect(*x, *y, *width, *height, scale),
|
||||
radius: radius * scale,
|
||||
color: parse_color(color)?,
|
||||
},
|
||||
Self::StrokeRoundedRect {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
radius,
|
||||
stroke_width,
|
||||
color,
|
||||
} => DrawCommand::StrokeRoundedRect {
|
||||
rect: scaled_rect(*x, *y, *width, *height, scale),
|
||||
radius: radius * scale,
|
||||
width: stroke_width * scale,
|
||||
color: parse_color(color)?,
|
||||
},
|
||||
Self::Text {
|
||||
x,
|
||||
y,
|
||||
value,
|
||||
size,
|
||||
color,
|
||||
} => DrawCommand::Text {
|
||||
origin: Point::new(x * scale, y * scale),
|
||||
value: value.clone(),
|
||||
scale: ((*size as f32 * scale).round() as u32).max(1),
|
||||
color: parse_color(color)?,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn default_stroke_width() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
fn default_text_size() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
fn validate_rect(x: f32, y: f32, width: f32, height: f32, radius: f32) -> Result<()> {
|
||||
if !x.is_finite() || !y.is_finite() || !width.is_finite() || !height.is_finite() {
|
||||
bail!("rectangle values must be finite");
|
||||
}
|
||||
if width < 0.0 || height < 0.0 || !radius.is_finite() || radius < 0.0 {
|
||||
bail!("rectangle dimensions and radius cannot be negative");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scaled_rect(x: f32, y: f32, width: f32, height: f32, scale: f32) -> Rect {
|
||||
Rect::new(x * scale, y * scale, width * scale, height * scale)
|
||||
}
|
||||
|
||||
pub fn parse_color(value: &str) -> Result<Color> {
|
||||
let hex = value
|
||||
.strip_prefix('#')
|
||||
.ok_or_else(|| anyhow::anyhow!("color must begin with #"))?;
|
||||
if hex.len() != 6 && hex.len() != 8 {
|
||||
bail!("color must use #RRGGBB or #RRGGBBAA");
|
||||
}
|
||||
let parse = |range| u8::from_str_radix(&hex[range], 16);
|
||||
Ok(Color::rgba(
|
||||
parse(0..2)?,
|
||||
parse(2..4)?,
|
||||
parse(4..6)?,
|
||||
if hex.len() == 8 { parse(6..8)? } else { 255 },
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_document_is_valid_and_blank() {
|
||||
let document = RenderDocument::default();
|
||||
document.validate().unwrap();
|
||||
assert!(document.root.is_none());
|
||||
assert!(document.frame.commands.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_rgb_and_rgba_colors() {
|
||||
assert_eq!(
|
||||
parse_color("#102030").unwrap(),
|
||||
Color::rgba(16, 32, 48, 255)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_color("#10203080").unwrap(),
|
||||
Color::rgba(16, 32, 48, 128)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user