820 lines
26 KiB
Rust
820 lines
26 KiB
Rust
use std::collections::HashSet;
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::{Context as _, Result, bail};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::{
|
|
display_list::{Color, DisplayList, DrawCommand},
|
|
geometry::{Point, Rect},
|
|
ui_bridge::UiNode,
|
|
};
|
|
|
|
pub const DEFAULT_NAMESPACE: &str = "app.main";
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct NamespaceState {
|
|
pub name: String,
|
|
pub revision: u64,
|
|
pub submission: u64,
|
|
pub document: RenderDocument,
|
|
pub viewport: Option<crate::layout::Size>,
|
|
pub window_viewports: std::collections::BTreeMap<u64, crate::layout::Size>,
|
|
pub runtime_session: u64,
|
|
pub window_commands: Vec<crate::window::Envelope>,
|
|
}
|
|
|
|
impl Default for NamespaceState {
|
|
fn default() -> Self {
|
|
Self {
|
|
name: DEFAULT_NAMESPACE.to_owned(),
|
|
revision: 0,
|
|
submission: 0,
|
|
document: RenderDocument::default(),
|
|
viewport: None,
|
|
window_viewports: std::collections::BTreeMap::new(),
|
|
runtime_session: 0,
|
|
window_commands: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl NamespaceState {
|
|
pub fn publish_window_commands(&mut self, session: u64, commands: Vec<crate::window::Command>) {
|
|
if self.runtime_session != session {
|
|
self.window_commands.clear();
|
|
// The primary shell survives VM replacement. Secondary native
|
|
// resources belong to their originating runtime generation.
|
|
self.window_viewports
|
|
.retain(|id, _| *id == crate::window::MAIN_WINDOW);
|
|
}
|
|
self.runtime_session = session;
|
|
if session == 0 {
|
|
self.window_commands.clear();
|
|
return;
|
|
}
|
|
for command in commands {
|
|
self.window_commands
|
|
.retain(|old| !old.command.same_kind(&command));
|
|
self.window_commands
|
|
.push(crate::window::Envelope { session, command });
|
|
}
|
|
self.window_commands
|
|
.sort_by_key(|command| command.command.sequence);
|
|
}
|
|
|
|
pub fn next_submission(&self) -> Result<u64> {
|
|
self.submission
|
|
.checked_add(1)
|
|
.context("submission counter exhausted")
|
|
}
|
|
|
|
pub fn commit(&mut self, submission: u64, document: RenderDocument) -> Result<Option<u64>> {
|
|
if submission != self.submission {
|
|
return Ok(None);
|
|
}
|
|
let revision = self
|
|
.revision
|
|
.checked_add(1)
|
|
.context("revision counter exhausted")?;
|
|
self.document = document;
|
|
self.revision = revision;
|
|
Ok(Some(revision))
|
|
}
|
|
|
|
pub fn clear(&mut self) -> Result<u64> {
|
|
let submission = self.next_submission()?;
|
|
let revision = self
|
|
.revision
|
|
.checked_add(1)
|
|
.context("revision counter exhausted")?;
|
|
self.submission = submission;
|
|
self.document = RenderDocument::default();
|
|
self.runtime_session = 0;
|
|
self.window_commands.clear();
|
|
self.revision = revision;
|
|
Ok(revision)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
|
pub struct RenderDocument {
|
|
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
|
pub windows: std::collections::BTreeMap<u64, RenderDocument>,
|
|
#[serde(skip)]
|
|
pub ui_root: Option<Arc<UiNode>>,
|
|
#[serde(default)]
|
|
pub source: String,
|
|
#[serde(default)]
|
|
pub root: Option<CodeNode>,
|
|
#[serde(default)]
|
|
pub frame: Frame,
|
|
}
|
|
|
|
impl RenderDocument {
|
|
pub fn validate(&self) -> Result<()> {
|
|
let surface = &self.frame.surface;
|
|
if !surface.zoom.is_finite()
|
|
|| !(0.1..=4.0).contains(&surface.zoom)
|
|
|| ![surface.pan.x, surface.pan.y]
|
|
.iter()
|
|
.all(|v| v.is_finite() && v.abs() <= 1_000_000.0)
|
|
|| ![surface.extent.width, surface.extent.height]
|
|
.iter()
|
|
.all(|v| v.is_finite() && (0.0..=1_000_000.0).contains(v))
|
|
{
|
|
bail!("invalid page camera or extent");
|
|
}
|
|
if self.windows.len() > 15 {
|
|
bail!("too many window projections");
|
|
}
|
|
for (id, document) in &self.windows {
|
|
if *id == crate::window::MAIN_WINDOW
|
|
|| *id != document.frame.window
|
|
|| !document.windows.is_empty()
|
|
{
|
|
bail!("invalid secondary window projection");
|
|
}
|
|
document.validate()?;
|
|
}
|
|
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");
|
|
}
|
|
|
|
if self.frame.window == 0 {
|
|
bail!("invalid window identity");
|
|
}
|
|
parse_color(&self.frame.background)?;
|
|
if !self.frame.command_clips.is_empty()
|
|
&& self.frame.command_clips.len() != self.frame.commands.len()
|
|
{
|
|
bail!("invalid command clip table");
|
|
}
|
|
for clip in self.frame.command_clips.iter().flatten() {
|
|
validate_rect(clip[0], clip[1], clip[2], clip[3], 0.0)?;
|
|
}
|
|
if !self.frame.command_views.is_empty()
|
|
&& self.frame.command_views.len() != self.frame.commands.len()
|
|
{
|
|
bail!("invalid command view table");
|
|
}
|
|
for view in self.frame.command_views.iter().flatten() {
|
|
if !view.valid() {
|
|
bail!("invalid command view transform");
|
|
}
|
|
}
|
|
for command in &self.frame.commands {
|
|
command.validate()?;
|
|
}
|
|
if self.frame.mounts.len() > 10_000 {
|
|
bail!("frame exceeds the 10000 mount limit");
|
|
}
|
|
let mut tab_ids = HashSet::new();
|
|
if self.frame.tabs.len() > crate::ui_bridge::MAX_PAGES
|
|
|| self.frame.tabs.iter().filter(|tab| tab.active).count() > 1
|
|
|| self
|
|
.frame
|
|
.tabs
|
|
.iter()
|
|
.any(|tab| tab.id == 0 || !tab_ids.insert(tab.id) || tab.title.len() > 1024)
|
|
{
|
|
bail!("invalid tab projection");
|
|
}
|
|
let mut mount_ids = HashSet::new();
|
|
for mount in &self.frame.mounts {
|
|
if mount.view.is_some_and(|view| !view.valid()) {
|
|
bail!("invalid Mount view transform");
|
|
}
|
|
if mount.id == 0 || mount.block_id.is_empty() || !mount_ids.insert(mount.id) {
|
|
bail!("invalid or duplicate mount identity");
|
|
}
|
|
validate_rect(mount.x, mount.y, mount.width, mount.height, 0.0)?;
|
|
if let Some(clip) = mount.clip {
|
|
validate_rect(clip[0], clip[1], clip[2], clip[3], 0.0)?;
|
|
}
|
|
if let Some(clip) = mount.viewport_clip {
|
|
validate_rect(clip[0], clip[1], clip[2], clip[3], 0.0)?;
|
|
}
|
|
if let Some(scroll) = mount.scroll
|
|
&& (!scroll.iter().all(|n| {
|
|
n.is_finite() && (0.0..=crate::ui_bridge::MAX_DIMENSION as f32).contains(n)
|
|
}) || scroll[0] > scroll[2]
|
|
|| scroll[1] > scroll[3])
|
|
{
|
|
bail!("invalid scroll projection");
|
|
}
|
|
}
|
|
for mount in &self.frame.mounts {
|
|
for linked in [mount.parent, mount.first_child, mount.next_sibling]
|
|
.into_iter()
|
|
.flatten()
|
|
{
|
|
if !mount_ids.contains(&linked) {
|
|
bail!("mount links outside this frame");
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(root) = &self.ui_root
|
|
&& self
|
|
.frame
|
|
.mounts
|
|
.first()
|
|
.is_none_or(|mount| mount.block_id != root.block_id)
|
|
{
|
|
bail!("frame root does not match its visual projection");
|
|
}
|
|
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 frame = &self.frame;
|
|
let mut list = DisplayList::default();
|
|
list.push(DrawCommand::Clear(parse_color(&frame.background)?));
|
|
for (index, command) in frame.commands.iter().enumerate() {
|
|
let clip = frame.command_clips.get(index).copied().flatten();
|
|
if let Some(clip) = clip {
|
|
list.push(DrawCommand::PushClip(scaled_rect(
|
|
clip[0], clip[1], clip[2], clip[3], scale,
|
|
)));
|
|
}
|
|
let view = frame
|
|
.command_views
|
|
.get(index)
|
|
.copied()
|
|
.flatten()
|
|
.unwrap_or_default();
|
|
list.push(
|
|
command
|
|
.to_draw_command(scale * view.zoom)?
|
|
.translated(Point::new(view.x * scale, view.y * scale)),
|
|
);
|
|
if clip.is_some() {
|
|
list.push(DrawCommand::PopClip);
|
|
}
|
|
}
|
|
if let Some(focus) = frame
|
|
.focus
|
|
.and_then(|id| frame.mounts.iter().find(|m| m.id == id))
|
|
.filter(|mount| mount.panel.is_none())
|
|
{
|
|
if let Some(c) = focus.clip {
|
|
list.push(DrawCommand::PushClip(scaled_rect(
|
|
c[0], c[1], c[2], c[3], scale,
|
|
)));
|
|
}
|
|
list.push(
|
|
RenderCommand::StrokeRoundedRect {
|
|
x: focus
|
|
.view
|
|
.unwrap_or_default()
|
|
.rect(Rect::new(focus.x, focus.y, focus.width, focus.height))
|
|
.x,
|
|
y: focus
|
|
.view
|
|
.unwrap_or_default()
|
|
.rect(Rect::new(focus.x, focus.y, focus.width, focus.height))
|
|
.y,
|
|
width: focus.width * focus.view.unwrap_or_default().zoom,
|
|
height: focus.height * focus.view.unwrap_or_default().zoom,
|
|
radius: 7.0,
|
|
stroke_width: 2.0,
|
|
color: "#4F6FDB".into(),
|
|
}
|
|
.to_draw_command(scale)?,
|
|
);
|
|
if focus.clip.is_some() {
|
|
list.push(DrawCommand::PopClip);
|
|
}
|
|
}
|
|
if let Some(error) = &frame.fault {
|
|
list.push(
|
|
RenderCommand::RoundedRect {
|
|
x: 0.0,
|
|
y: 0.0,
|
|
width: 10000.0,
|
|
height: 36.0,
|
|
radius: 0.0,
|
|
color: "#FFF1EF".into(),
|
|
}
|
|
.to_draw_command(scale)?,
|
|
);
|
|
list.push(
|
|
RenderCommand::Text {
|
|
x: 12.0,
|
|
y: 8.0,
|
|
value: format!("运行已停止:{}", error.lines().next().unwrap_or_default()),
|
|
size: 1,
|
|
color: "#A32D25".into(),
|
|
wrap_bounds: None,
|
|
}
|
|
.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(skip)]
|
|
pub hit_index: Arc<crate::viewport::spatial::Index>,
|
|
#[serde(default)]
|
|
pub surface: crate::page::Surface,
|
|
#[serde(default = "default_window")]
|
|
pub window: u64,
|
|
#[serde(default = "default_background")]
|
|
pub background: String,
|
|
#[serde(default)]
|
|
pub commands: Vec<RenderCommand>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub command_clips: Vec<Option<[f32; 4]>>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub command_views: Vec<Option<crate::viewport::Transform>>,
|
|
#[serde(skip)]
|
|
pub command_mounts: Vec<u64>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub mounts: Vec<MountGeometry>,
|
|
#[serde(default)]
|
|
pub layout_version: u64,
|
|
#[serde(default)]
|
|
pub session: u64,
|
|
#[serde(default)]
|
|
pub focus: Option<u64>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub tabs: Vec<crate::tabs::Tab>,
|
|
#[serde(default)]
|
|
pub fault: Option<String>,
|
|
}
|
|
|
|
impl Default for Frame {
|
|
fn default() -> Self {
|
|
Self {
|
|
hit_index: Arc::default(),
|
|
surface: crate::page::Surface::default(),
|
|
window: crate::window::MAIN_WINDOW,
|
|
background: default_background(),
|
|
commands: Vec::new(),
|
|
command_clips: Vec::new(),
|
|
command_views: Vec::new(),
|
|
command_mounts: Vec::new(),
|
|
mounts: Vec::new(),
|
|
layout_version: 0,
|
|
session: 0,
|
|
focus: None,
|
|
tabs: Vec::new(),
|
|
fault: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A position in a published frame. Identifiers are opaque data, never VM handles.
|
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
|
pub struct MountGeometry {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub panel: Option<Arc<crate::scene3d::panel::Mapping>>,
|
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
|
pub canvas_viewport: bool,
|
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
|
pub scene3d_viewport: bool,
|
|
#[serde(default)]
|
|
pub camera_controls: bool,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub viewport_clip: Option<[f32; 4]>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub view: Option<crate::viewport::Transform>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub clip: Option<[f32; 4]>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub scroll: Option<[f32; 4]>,
|
|
pub id: u64,
|
|
pub block_id: String,
|
|
pub parent: Option<u64>,
|
|
pub first_child: Option<u64>,
|
|
pub next_sibling: Option<u64>,
|
|
pub x: f32,
|
|
pub y: f32,
|
|
pub width: f32,
|
|
pub height: f32,
|
|
pub interactive: bool,
|
|
}
|
|
|
|
impl MountGeometry {
|
|
pub fn view_point(&self, point: Point, clipped: bool) -> Option<Point> {
|
|
self.panel
|
|
.as_ref()
|
|
.map_or(Some(point), |panel| panel.texture_point(point, clipped))
|
|
}
|
|
pub fn logical_point(&self, point: Point) -> Option<Point> {
|
|
let point = self.view_point(point, true)?;
|
|
Some(self.view.unwrap_or_default().inverse(point))
|
|
}
|
|
pub fn logical_delta(&self, point: Option<[f32; 2]>, delta: [f32; 2]) -> Option<[f32; 2]> {
|
|
let zoom = self.view.unwrap_or_default().zoom;
|
|
if self.panel.is_none() {
|
|
return Some([delta[0] / zoom, delta[1] / zoom]);
|
|
}
|
|
let point = point?;
|
|
let current = self.view_point(Point::new(point[0], point[1]), false)?;
|
|
let previous =
|
|
self.view_point(Point::new(point[0] + delta[0], point[1] + delta[1]), false)?;
|
|
Some([
|
|
((previous.x - current.x) / zoom).clamp(-1_000_000., 1_000_000.),
|
|
((previous.y - current.y) / zoom).clamp(-1_000_000., 1_000_000.),
|
|
])
|
|
}
|
|
pub fn contains(&self, point: Point) -> bool {
|
|
let Some(point) = self.view_point(point, true) else {
|
|
return false;
|
|
};
|
|
let local = self.view.map_or(point, |view| view.inverse(point));
|
|
Rect::new(self.x, self.y, self.width, self.height).contains(local)
|
|
&& self.clip.is_none_or(|c| {
|
|
c[2] > 0.0 && c[3] > 0.0 && Rect::new(c[0], c[1], c[2], c[3]).contains(point)
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Frame {
|
|
pub fn camera_at(&self, point: Point) -> Option<&MountGeometry> {
|
|
self.hit_index
|
|
.candidates(point, self.mounts.len())
|
|
.into_iter()
|
|
.map(|index| &self.mounts[index])
|
|
.find(|mount| {
|
|
(mount.canvas_viewport || mount.scene3d_viewport && mount.camera_controls)
|
|
&& mount.contains(point)
|
|
&& mount.viewport_clip.is_some_and(|clip| {
|
|
mount.view_point(point, true).is_some_and(|point| {
|
|
Rect::new(clip[0], clip[1], clip[2], clip[3]).contains(point)
|
|
})
|
|
})
|
|
})
|
|
}
|
|
pub fn hit_test(&self, point: Point) -> Option<&MountGeometry> {
|
|
if self.fault.is_some() {
|
|
return None;
|
|
}
|
|
self.hit_index
|
|
.candidates(point, self.mounts.len())
|
|
.into_iter()
|
|
.map(|index| &self.mounts[index])
|
|
.find(|mount| mount.interactive && mount.contains(point))
|
|
}
|
|
}
|
|
|
|
fn default_window() -> u64 {
|
|
crate::window::MAIN_WINDOW
|
|
}
|
|
|
|
fn default_background() -> String {
|
|
"#F7F7F5".to_owned()
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum RenderCommand {
|
|
Scene3D {
|
|
x: f32,
|
|
y: f32,
|
|
width: f32,
|
|
height: f32,
|
|
view: Arc<crate::scene3d::View>,
|
|
},
|
|
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,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
wrap_bounds: Option<[f32; 2]>,
|
|
},
|
|
}
|
|
|
|
impl RenderCommand {
|
|
pub(crate) fn validate(&self) -> Result<()> {
|
|
match self {
|
|
Self::Scene3D {
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
view,
|
|
} => {
|
|
validate_rect(*x, *y, *width, *height, 0.0)?;
|
|
if !view.valid() {
|
|
bail!("invalid 3D view");
|
|
}
|
|
}
|
|
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,
|
|
wrap_bounds,
|
|
} => {
|
|
if !x.is_finite() || !y.is_finite() {
|
|
bail!("text coordinates must be finite");
|
|
}
|
|
if let Some([width, height]) = wrap_bounds {
|
|
validate_rect(*x, *y, *width, *height, 0.0)?;
|
|
if [*x, *y, *width, *height, *x + *width, *y + *height]
|
|
.iter()
|
|
.any(|value| value.abs() > crate::ui_bridge::MAX_DIMENSION as f32)
|
|
{
|
|
bail!("text wrap bounds exceed limits");
|
|
}
|
|
}
|
|
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::Scene3D {
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
view,
|
|
} => DrawCommand::Scene3D {
|
|
rect: scaled_rect(*x, *y, *width, *height, scale),
|
|
view: view.clone(),
|
|
},
|
|
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,
|
|
wrap_bounds,
|
|
} => match wrap_bounds {
|
|
Some([width, height]) => DrawCommand::WrappedText {
|
|
rect: scaled_rect(*x, *y, *width, *height, scale),
|
|
value: value.clone(),
|
|
style: crate::text::TextStyle::ui(*size).scaled(scale),
|
|
color: parse_color(color)?,
|
|
},
|
|
None => DrawCommand::Text {
|
|
origin: Point::new(x * scale, y * scale),
|
|
value: value.clone(),
|
|
style: crate::text::TextStyle::ui(*size).scaled(scale),
|
|
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.is_ascii() || (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 {
|
|
#[test]
|
|
fn text_wrap_bounds_validate_and_legacy_text_remains_unwrapped() {
|
|
let mut command: RenderCommand = serde_json::from_str(
|
|
r##"{"type":"text","x":0,"y":0,"value":"text","size":1,"color":"#000000"}"##,
|
|
)
|
|
.unwrap();
|
|
assert!(matches!(
|
|
command.to_draw_command(1.0).unwrap(),
|
|
DrawCommand::Text { .. }
|
|
));
|
|
if let RenderCommand::Text { wrap_bounds, .. } = &mut command {
|
|
*wrap_bounds = Some([40.0, 20.0]);
|
|
}
|
|
command.validate().unwrap();
|
|
assert!(
|
|
matches!(command.to_draw_command(1.25).unwrap(),DrawCommand::WrappedText{rect,..}if rect.width==50.0 && rect.height==25.0)
|
|
);
|
|
if let RenderCommand::Text { wrap_bounds, .. } = &mut command {
|
|
*wrap_bounds = Some([-1.0, 20.0]);
|
|
}
|
|
assert!(command.validate().is_err());
|
|
}
|
|
|
|
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)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod window_projection_tests {
|
|
use super::*;
|
|
#[test]
|
|
fn replacement_and_reset_discard_old_window_projections() {
|
|
let mut state = NamespaceState::default();
|
|
let command = crate::window::Command {
|
|
window: 1,
|
|
sequence: 1,
|
|
action: crate::window::Action::Focus,
|
|
};
|
|
state.publish_window_commands(1, vec![command.clone()]);
|
|
let resize = crate::window::Command {
|
|
window: 1,
|
|
sequence: 2,
|
|
action: crate::window::Action::Resize {
|
|
width: 900.0,
|
|
height: 600.0,
|
|
},
|
|
};
|
|
state.publish_window_commands(1, vec![resize.clone()]);
|
|
assert_eq!(state.window_commands.len(), 2);
|
|
state.publish_window_commands(2, vec![resize]);
|
|
assert_eq!(state.window_commands.len(), 1);
|
|
assert_eq!(state.window_commands[0].session, 2);
|
|
state.clear().unwrap();
|
|
assert_eq!(state.runtime_session, 0);
|
|
assert!(state.window_commands.is_empty());
|
|
}
|
|
}
|