feat: bootstrap namespace-driven Tessera renderer
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
/target
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
Generated
+2037
File diff suppressed because it is too large
Load Diff
+20
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "tessera"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Cross-platform desktop rendering client"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
font8x8 = "0.3"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
softbuffer = "0.4.8"
|
||||
tiny_http = "0.12"
|
||||
winit = "0.30.13"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
@@ -0,0 +1,74 @@
|
||||
# Tessera
|
||||
|
||||
Tessera is a clean desktop rendering host. It starts with an empty page. Coding agents submit source code to a namespace through a local CRUD API; Tessera parses that code, builds its architecture tree, compiles it to drawing commands, and renders the result.
|
||||
|
||||
## Start
|
||||
|
||||
```bash
|
||||
cargo run --release
|
||||
```
|
||||
|
||||
Tessera listens only on `127.0.0.1:4737`.
|
||||
|
||||
Start every agent integration by reading the live Markdown guide:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:4737/agent
|
||||
```
|
||||
|
||||
## Namespace code API
|
||||
|
||||
The initial active namespace is `app.main`. Tessera has no file concept.
|
||||
|
||||
```text
|
||||
GET /v1/namespaces/app.main/code
|
||||
PUT /v1/namespaces/app.main/code
|
||||
DELETE /v1/namespaces/app.main/code
|
||||
```
|
||||
|
||||
Submit code as plain text:
|
||||
|
||||
```bash
|
||||
curl \
|
||||
--request PUT \
|
||||
--header 'Content-Type: text/plain; charset=utf-8' \
|
||||
--data-binary @- \
|
||||
http://127.0.0.1:4737/v1/namespaces/app.main/code <<'TESSERA'
|
||||
namespace app.main
|
||||
|
||||
use tessera.ui::{Column, Text}
|
||||
|
||||
export scene Main {
|
||||
render {
|
||||
Column(gap: 12, padding: 24) {
|
||||
Text("Hello", role: .title)
|
||||
Text("Rendered by Tessera")
|
||||
}
|
||||
}
|
||||
}
|
||||
TESSERA
|
||||
```
|
||||
|
||||
Updates are atomic. Invalid code returns diagnostics with HTTP 422 and preserves the last successfully rendered revision.
|
||||
|
||||
## Developer tools
|
||||
|
||||
The developer sidebar is closed by default.
|
||||
|
||||
- macOS: `Command-Option-I` or `F12`
|
||||
- Windows/Linux: `Control-Shift-I` or `F12`
|
||||
|
||||
The upper section displays the compiler-produced architecture tree. Selecting a node shows the corresponding source range below it.
|
||||
|
||||
## Current syntax experiment
|
||||
|
||||
The first parser intentionally supports only enough syntax to test the architecture:
|
||||
|
||||
- `namespace`
|
||||
- namespace-only `use`
|
||||
- one `export scene`
|
||||
- one `render` root
|
||||
- `Column`, `Row`, `Spacer`, and `Text`
|
||||
- `gap`, `padding`, and `.title`
|
||||
|
||||
There are no files, HTML, CSS, JavaScript, DOM, or resource-network concepts in the runtime.
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
use std::{
|
||||
io::Read,
|
||||
sync::{Arc, RwLock},
|
||||
thread,
|
||||
};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
|
||||
use winit::event_loop::EventLoopProxy;
|
||||
|
||||
use crate::{
|
||||
app::AppEvent,
|
||||
compiler,
|
||||
document::{NamespaceState, RenderDocument},
|
||||
};
|
||||
|
||||
pub const API_ADDRESS: &str = "127.0.0.1:4737";
|
||||
const MAX_CODE_SIZE: u64 = 1_048_576;
|
||||
|
||||
pub fn start(
|
||||
namespace: Arc<RwLock<NamespaceState>>,
|
||||
proxy: EventLoopProxy<AppEvent>,
|
||||
) -> Result<()> {
|
||||
let server = Server::http(API_ADDRESS)
|
||||
.map_err(|error| anyhow::anyhow!(error))
|
||||
.with_context(|| format!("bind Tessera API to {API_ADDRESS}"))?;
|
||||
thread::Builder::new()
|
||||
.name("tessera-agent-api".to_owned())
|
||||
.spawn(move || {
|
||||
for request in server.incoming_requests() {
|
||||
handle(request, &namespace, &proxy);
|
||||
}
|
||||
})
|
||||
.context("start Tessera agent API thread")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle(
|
||||
mut request: Request,
|
||||
namespace: &Arc<RwLock<NamespaceState>>,
|
||||
proxy: &EventLoopProxy<AppEvent>,
|
||||
) {
|
||||
let method = request.method().clone();
|
||||
let path = request
|
||||
.url()
|
||||
.split('?')
|
||||
.next()
|
||||
.unwrap_or(request.url())
|
||||
.to_owned();
|
||||
|
||||
let response = if method == Method::Get && path == "/agent" {
|
||||
match namespace.read() {
|
||||
Ok(namespace) => markdown(StatusCode(200), agent_guide(&namespace)),
|
||||
Err(_) => error_response(StatusCode(500), "namespace lock is poisoned"),
|
||||
}
|
||||
} else if let Some(addressed_namespace) = namespace_from_path(&path).map(str::to_owned) {
|
||||
code_request(&mut request, method, &addressed_namespace, namespace, proxy)
|
||||
} else if method == Method::Options {
|
||||
text(StatusCode(204), "", "text/plain")
|
||||
} else {
|
||||
error_response(StatusCode(404), "route not found; start with GET /agent")
|
||||
};
|
||||
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
|
||||
fn code_request(
|
||||
request: &mut Request,
|
||||
method: Method,
|
||||
addressed_namespace: &str,
|
||||
namespace: &Arc<RwLock<NamespaceState>>,
|
||||
proxy: &EventLoopProxy<AppEvent>,
|
||||
) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
let Ok(current) = namespace.read() else {
|
||||
return error_response(StatusCode(500), "namespace lock is poisoned");
|
||||
};
|
||||
if addressed_namespace != current.name {
|
||||
return error_response(
|
||||
StatusCode(404),
|
||||
format!(
|
||||
"namespace `{addressed_namespace}` is not active; current namespace is `{}`",
|
||||
current.name
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if method == Method::Get {
|
||||
let mut response = text(
|
||||
StatusCode(200),
|
||||
¤t.document.source,
|
||||
"text/plain; charset=utf-8",
|
||||
);
|
||||
add_header(
|
||||
&mut response,
|
||||
"X-Tessera-Revision",
|
||||
¤t.revision.to_string(),
|
||||
);
|
||||
return response;
|
||||
}
|
||||
drop(current);
|
||||
|
||||
match method {
|
||||
Method::Put => match read_code(request) {
|
||||
Ok(code) => match compiler::compile(&code, addressed_namespace) {
|
||||
Ok(document) => {
|
||||
if let Err(error) = document.validate() {
|
||||
return compile_error(error.to_string());
|
||||
}
|
||||
match namespace.write() {
|
||||
Ok(mut current) => {
|
||||
current.revision += 1;
|
||||
current.document = document;
|
||||
let revision = current.revision;
|
||||
drop(current);
|
||||
let _ = proxy.send_event(AppEvent::DocumentUpdated);
|
||||
json(
|
||||
StatusCode(200),
|
||||
serde_json::json!({
|
||||
"ok": true,
|
||||
"namespace": addressed_namespace,
|
||||
"revision": revision,
|
||||
"diagnostics": []
|
||||
}),
|
||||
)
|
||||
}
|
||||
Err(_) => error_response(StatusCode(500), "namespace lock is poisoned"),
|
||||
}
|
||||
}
|
||||
Err(error) => compile_error(error.to_string()),
|
||||
},
|
||||
Err(error) => error_response(StatusCode(400), error.to_string()),
|
||||
},
|
||||
Method::Delete => match namespace.write() {
|
||||
Ok(mut current) => {
|
||||
current.revision += 1;
|
||||
current.document = RenderDocument::default();
|
||||
let revision = current.revision;
|
||||
drop(current);
|
||||
let _ = proxy.send_event(AppEvent::DocumentUpdated);
|
||||
json(
|
||||
StatusCode(200),
|
||||
serde_json::json!({
|
||||
"ok": true,
|
||||
"namespace": addressed_namespace,
|
||||
"revision": revision
|
||||
}),
|
||||
)
|
||||
}
|
||||
Err(_) => error_response(StatusCode(500), "namespace lock is poisoned"),
|
||||
},
|
||||
Method::Options => text(StatusCode(204), "", "text/plain"),
|
||||
_ => error_response(StatusCode(405), "code supports GET, PUT, and DELETE"),
|
||||
}
|
||||
}
|
||||
|
||||
fn namespace_from_path(path: &str) -> Option<&str> {
|
||||
path.strip_prefix("/v1/namespaces/")?
|
||||
.strip_suffix("/code")
|
||||
.filter(|namespace| !namespace.is_empty() && !namespace.contains('/'))
|
||||
}
|
||||
|
||||
fn read_code(request: &mut Request) -> Result<String> {
|
||||
if request
|
||||
.body_length()
|
||||
.is_some_and(|length| length as u64 > MAX_CODE_SIZE)
|
||||
{
|
||||
anyhow::bail!("code exceeds the 1 MiB limit");
|
||||
}
|
||||
let mut code = String::new();
|
||||
request
|
||||
.as_reader()
|
||||
.take(MAX_CODE_SIZE + 1)
|
||||
.read_to_string(&mut code)
|
||||
.context("read code")?;
|
||||
if code.len() as u64 > MAX_CODE_SIZE {
|
||||
anyhow::bail!("code exceeds the 1 MiB limit");
|
||||
}
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
fn agent_guide(namespace: &NamespaceState) -> String {
|
||||
format!(
|
||||
r#"# Tessera Agent API
|
||||
|
||||
Current namespace: `{name}`
|
||||
Current revision: `{revision}`
|
||||
|
||||
Tessera stores code by namespace. It has no files. Submit code only; Tessera parses,
|
||||
checks, compiles, and renders it.
|
||||
|
||||
## Code CRUD
|
||||
|
||||
- Read: `GET http://{address}/v1/namespaces/{name}/code`
|
||||
- Create or update: `PUT http://{address}/v1/namespaces/{name}/code`
|
||||
- Delete: `DELETE http://{address}/v1/namespaces/{name}/code`
|
||||
|
||||
Send PUT bodies as `text/plain; charset=utf-8`.
|
||||
|
||||
## Minimal syntax
|
||||
|
||||
```tessera
|
||||
namespace {name}
|
||||
|
||||
use tessera.ui::{{Column, Row, Spacer, Text}}
|
||||
|
||||
export scene Main {{
|
||||
render {{
|
||||
Column(gap: 12, padding: 24) {{
|
||||
Text("Hello", role: .title)
|
||||
Text("Rendered by Tessera")
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
```
|
||||
|
||||
Supported by this syntax experiment: one exported scene, one render root,
|
||||
`Column`, `Row`, `Spacer`, `Text`, `gap`, `padding`, and `.title`.
|
||||
Compilation errors reject the update and preserve the last rendered revision.
|
||||
"#,
|
||||
name = namespace.name,
|
||||
revision = namespace.revision,
|
||||
address = API_ADDRESS
|
||||
)
|
||||
}
|
||||
|
||||
fn compile_error(message: String) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
json(
|
||||
StatusCode(422),
|
||||
serde_json::json!({
|
||||
"ok": false,
|
||||
"diagnostics": [{
|
||||
"severity": "error",
|
||||
"message": message
|
||||
}]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn error_response(
|
||||
status: StatusCode,
|
||||
message: impl Into<String>,
|
||||
) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
json(status, serde_json::json!({ "error": message.into() }))
|
||||
}
|
||||
|
||||
fn json(status: StatusCode, value: serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
text(
|
||||
status,
|
||||
&value.to_string(),
|
||||
"application/json; charset=utf-8",
|
||||
)
|
||||
}
|
||||
|
||||
fn markdown(status: StatusCode, value: String) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
text(status, &value, "text/markdown; charset=utf-8")
|
||||
}
|
||||
|
||||
fn text(status: StatusCode, value: &str, content_type: &str) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
let mut response = Response::from_string(value).with_status_code(status);
|
||||
add_header(&mut response, "Content-Type", content_type);
|
||||
add_header(
|
||||
&mut response,
|
||||
"Access-Control-Allow-Origin",
|
||||
"http://127.0.0.1",
|
||||
);
|
||||
add_header(
|
||||
&mut response,
|
||||
"Access-Control-Allow-Methods",
|
||||
"GET, PUT, DELETE, OPTIONS",
|
||||
);
|
||||
add_header(
|
||||
&mut response,
|
||||
"Access-Control-Allow-Headers",
|
||||
"Content-Type",
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
fn add_header(response: &mut Response<std::io::Cursor<Vec<u8>>>, name: &str, value: &str) {
|
||||
if let Ok(header) = Header::from_bytes(name, value) {
|
||||
response.add_header(header);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extracts_namespace_only_from_code_routes() {
|
||||
assert_eq!(
|
||||
namespace_from_path("/v1/namespaces/app.main/code"),
|
||||
Some("app.main")
|
||||
);
|
||||
assert_eq!(namespace_from_path("/v1/namespaces/app.main"), None);
|
||||
assert_eq!(namespace_from_path("/v1/namespaces/a/b/code"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guide_contains_only_code_crud_routes() {
|
||||
let guide = agent_guide(&NamespaceState::default());
|
||||
assert!(guide.contains("Code CRUD"));
|
||||
assert!(guide.contains("PUT http://127.0.0.1:4737"));
|
||||
assert!(!guide.contains("display_list"));
|
||||
}
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
use std::{
|
||||
num::NonZeroU32,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
use softbuffer::{Context, Surface};
|
||||
use winit::{
|
||||
application::ApplicationHandler,
|
||||
dpi::{LogicalSize, PhysicalPosition},
|
||||
event::{ElementState, MouseButton, WindowEvent},
|
||||
event_loop::ActiveEventLoop,
|
||||
keyboard::{Key, ModifiersState, NamedKey},
|
||||
window::{Window, WindowAttributes, WindowId},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
developer_tools::DeveloperToolsState,
|
||||
document::NamespaceState,
|
||||
geometry::Point,
|
||||
scene::{self, SceneLayout},
|
||||
software_renderer::SoftwareRenderer,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum AppEvent {
|
||||
DocumentUpdated,
|
||||
}
|
||||
|
||||
pub struct TesseraApp {
|
||||
window: Option<Arc<Window>>,
|
||||
context: Option<Context<Arc<Window>>>,
|
||||
surface: Option<Surface<Arc<Window>, Arc<Window>>>,
|
||||
namespace: Arc<RwLock<NamespaceState>>,
|
||||
developer_tools: DeveloperToolsState,
|
||||
layout: SceneLayout,
|
||||
pointer: Point,
|
||||
modifiers: ModifiersState,
|
||||
}
|
||||
|
||||
impl TesseraApp {
|
||||
pub fn new(namespace: Arc<RwLock<NamespaceState>>) -> Self {
|
||||
Self {
|
||||
window: None,
|
||||
context: None,
|
||||
surface: None,
|
||||
namespace,
|
||||
developer_tools: DeveloperToolsState::default(),
|
||||
layout: SceneLayout::default(),
|
||||
pointer: Point::default(),
|
||||
modifiers: ModifiersState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn request_redraw(&self) {
|
||||
if let Some(window) = &self.window {
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_developer_tools(&mut self) {
|
||||
if let Ok(namespace) = self.namespace.read() {
|
||||
self.developer_tools.toggle(&namespace.document);
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn document_updated(&mut self) {
|
||||
if let Ok(namespace) = self.namespace.read() {
|
||||
self.developer_tools.document_updated(&namespace.document);
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn handle_keyboard(&mut self, event: &winit::event::KeyEvent) {
|
||||
if event.state != ElementState::Pressed || event.repeat {
|
||||
return;
|
||||
}
|
||||
let is_f12 = matches!(event.logical_key, Key::Named(NamedKey::F12));
|
||||
let is_i =
|
||||
matches!(&event.logical_key, Key::Character(value) if value.eq_ignore_ascii_case("i"));
|
||||
let mac_shortcut = self.modifiers.super_key() && self.modifiers.alt_key() && is_i;
|
||||
let other_shortcut = self.modifiers.control_key() && self.modifiers.shift_key() && is_i;
|
||||
if is_f12 || mac_shortcut || other_shortcut {
|
||||
self.toggle_developer_tools();
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_click(&mut self) {
|
||||
let Some(node_id) = self.layout.developer_tools.node_at(self.pointer) else {
|
||||
return;
|
||||
};
|
||||
self.developer_tools.selected_node_id = Some(node_id.to_owned());
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn redraw(&mut self) {
|
||||
let (Some(window), Some(surface)) = (self.window.as_ref(), self.surface.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
let size = window.inner_size();
|
||||
let (Some(width), Some(height)) =
|
||||
(NonZeroU32::new(size.width), NonZeroU32::new(size.height))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(namespace) = self.namespace.read() else {
|
||||
return;
|
||||
};
|
||||
let scene = scene::build_scene(
|
||||
size.width,
|
||||
size.height,
|
||||
window.scale_factor() as f32,
|
||||
&namespace.document,
|
||||
&self.developer_tools,
|
||||
);
|
||||
drop(namespace);
|
||||
self.layout = scene.layout;
|
||||
|
||||
surface
|
||||
.resize(width, height)
|
||||
.expect("resize software surface");
|
||||
let mut buffer = surface.buffer_mut().expect("acquire software buffer");
|
||||
SoftwareRenderer::render(&scene.display_list, &mut buffer, size.width, size.height);
|
||||
buffer.present().expect("present software-rendered frame");
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplicationHandler<AppEvent> for TesseraApp {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.window.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let attributes = WindowAttributes::default()
|
||||
.with_title("Tessera")
|
||||
.with_inner_size(LogicalSize::new(1_280.0, 800.0))
|
||||
.with_min_inner_size(LogicalSize::new(720.0, 480.0));
|
||||
let window = Arc::new(
|
||||
event_loop
|
||||
.create_window(attributes)
|
||||
.expect("create Tessera window"),
|
||||
);
|
||||
let context = Context::new(window.clone()).expect("create software-rendering context");
|
||||
let surface =
|
||||
Surface::new(&context, window.clone()).expect("create software-rendering surface");
|
||||
|
||||
self.window = Some(window.clone());
|
||||
self.context = Some(context);
|
||||
self.surface = Some(surface);
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: AppEvent) {
|
||||
match event {
|
||||
AppEvent::DocumentUpdated => self.document_updated(),
|
||||
}
|
||||
}
|
||||
|
||||
fn window_event(
|
||||
&mut self,
|
||||
event_loop: &ActiveEventLoop,
|
||||
window_id: WindowId,
|
||||
event: WindowEvent,
|
||||
) {
|
||||
let Some(window) = self.window.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if window.id() != window_id {
|
||||
return;
|
||||
}
|
||||
|
||||
match event {
|
||||
WindowEvent::CloseRequested => event_loop.exit(),
|
||||
WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } => {
|
||||
window.request_redraw()
|
||||
}
|
||||
WindowEvent::CursorMoved {
|
||||
position: PhysicalPosition { x, y },
|
||||
..
|
||||
} => self.pointer = Point::new(x as f32, y as f32),
|
||||
WindowEvent::MouseInput {
|
||||
state: ElementState::Released,
|
||||
button: MouseButton::Left,
|
||||
..
|
||||
} => self.handle_click(),
|
||||
WindowEvent::ModifiersChanged(modifiers) => self.modifiers = modifiers.state(),
|
||||
WindowEvent::KeyboardInput { event, .. } => self.handle_keyboard(&event),
|
||||
WindowEvent::RedrawRequested => self.redraw(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
+625
@@ -0,0 +1,625 @@
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
use crate::document::{CodeNode, Frame, RenderCommand, RenderDocument};
|
||||
|
||||
pub fn compile(source: &str, expected_namespace: &str) -> Result<RenderDocument> {
|
||||
let tokens = Lexer::new(source).tokenize()?;
|
||||
let syntax = Parser::new(tokens).parse()?;
|
||||
if syntax.namespace != expected_namespace {
|
||||
bail!(
|
||||
"namespace mismatch: API addresses `{expected_namespace}`, code declares `{}`",
|
||||
syntax.namespace
|
||||
);
|
||||
}
|
||||
|
||||
let mut commands = Vec::new();
|
||||
layout_node(&syntax.root, 0.0, 0.0, &mut commands);
|
||||
Ok(RenderDocument {
|
||||
source: source.to_owned(),
|
||||
root: Some(architecture(source, &syntax)),
|
||||
frame: Frame {
|
||||
background: "#F7F7F5".to_owned(),
|
||||
commands,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct SyntaxDocument {
|
||||
namespace: String,
|
||||
scene_name: String,
|
||||
scene_span: Span,
|
||||
render_span: Span,
|
||||
root: SyntaxNode,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct SyntaxNode {
|
||||
name: String,
|
||||
arguments: Vec<Argument>,
|
||||
children: Vec<SyntaxNode>,
|
||||
span: Span,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Argument {
|
||||
Positional(Value),
|
||||
Named(String, Value),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Value {
|
||||
Text(String),
|
||||
Number(f32),
|
||||
Symbol(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Span {
|
||||
start: usize,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum TokenKind {
|
||||
Identifier(String),
|
||||
String(String),
|
||||
Number(f32),
|
||||
LeftBrace,
|
||||
RightBrace,
|
||||
LeftParen,
|
||||
RightParen,
|
||||
Colon,
|
||||
Comma,
|
||||
Dot,
|
||||
End,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Token {
|
||||
kind: TokenKind,
|
||||
span: Span,
|
||||
}
|
||||
|
||||
struct Lexer<'a> {
|
||||
source: &'a str,
|
||||
offset: usize,
|
||||
}
|
||||
|
||||
impl<'a> Lexer<'a> {
|
||||
fn new(source: &'a str) -> Self {
|
||||
Self { source, offset: 0 }
|
||||
}
|
||||
|
||||
fn tokenize(mut self) -> Result<Vec<Token>> {
|
||||
let mut tokens = Vec::new();
|
||||
while self.offset < self.source.len() {
|
||||
self.skip_whitespace_and_comments();
|
||||
if self.offset >= self.source.len() {
|
||||
break;
|
||||
}
|
||||
let start = self.offset;
|
||||
let character = self.current_char().expect("offset is in bounds");
|
||||
let kind = match character {
|
||||
'{' => self.single(TokenKind::LeftBrace),
|
||||
'}' => self.single(TokenKind::RightBrace),
|
||||
'(' => self.single(TokenKind::LeftParen),
|
||||
')' => self.single(TokenKind::RightParen),
|
||||
':' => self.single(TokenKind::Colon),
|
||||
',' => self.single(TokenKind::Comma),
|
||||
'.' => self.single(TokenKind::Dot),
|
||||
'"' => self.string()?,
|
||||
value if value.is_ascii_digit() || value == '-' => self.number()?,
|
||||
value if is_identifier_start(value) => self.identifier(),
|
||||
_ => bail!("unexpected character `{character}` at byte {start}"),
|
||||
};
|
||||
tokens.push(Token {
|
||||
kind,
|
||||
span: Span {
|
||||
start,
|
||||
end: self.offset,
|
||||
},
|
||||
});
|
||||
}
|
||||
tokens.push(Token {
|
||||
kind: TokenKind::End,
|
||||
span: Span {
|
||||
start: self.source.len(),
|
||||
end: self.source.len(),
|
||||
},
|
||||
});
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
fn current_char(&self) -> Option<char> {
|
||||
self.source[self.offset..].chars().next()
|
||||
}
|
||||
|
||||
fn advance(&mut self) -> Option<char> {
|
||||
let character = self.current_char()?;
|
||||
self.offset += character.len_utf8();
|
||||
Some(character)
|
||||
}
|
||||
|
||||
fn single(&mut self, kind: TokenKind) -> TokenKind {
|
||||
self.advance();
|
||||
kind
|
||||
}
|
||||
|
||||
fn skip_whitespace_and_comments(&mut self) {
|
||||
loop {
|
||||
while self.current_char().is_some_and(char::is_whitespace) {
|
||||
self.advance();
|
||||
}
|
||||
if self.source[self.offset..].starts_with("//") {
|
||||
while self.current_char().is_some_and(|value| value != '\n') {
|
||||
self.advance();
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn identifier(&mut self) -> TokenKind {
|
||||
let start = self.offset;
|
||||
while self.current_char().is_some_and(is_identifier_continue) {
|
||||
self.advance();
|
||||
}
|
||||
TokenKind::Identifier(self.source[start..self.offset].to_owned())
|
||||
}
|
||||
|
||||
fn number(&mut self) -> Result<TokenKind> {
|
||||
let start = self.offset;
|
||||
if self.current_char() == Some('-') {
|
||||
self.advance();
|
||||
}
|
||||
while self
|
||||
.current_char()
|
||||
.is_some_and(|value| value.is_ascii_digit())
|
||||
{
|
||||
self.advance();
|
||||
}
|
||||
if self.current_char() == Some('.') {
|
||||
self.advance();
|
||||
while self
|
||||
.current_char()
|
||||
.is_some_and(|value| value.is_ascii_digit())
|
||||
{
|
||||
self.advance();
|
||||
}
|
||||
}
|
||||
Ok(TokenKind::Number(self.source[start..self.offset].parse()?))
|
||||
}
|
||||
|
||||
fn string(&mut self) -> Result<TokenKind> {
|
||||
self.advance();
|
||||
let mut value = String::new();
|
||||
while let Some(character) = self.advance() {
|
||||
match character {
|
||||
'"' => return Ok(TokenKind::String(value)),
|
||||
'\\' => match self.advance() {
|
||||
Some('n') => value.push('\n'),
|
||||
Some('t') => value.push('\t'),
|
||||
Some('"') => value.push('"'),
|
||||
Some('\\') => value.push('\\'),
|
||||
Some(other) => bail!("unsupported string escape `\\{other}`"),
|
||||
None => bail!("unterminated string escape"),
|
||||
},
|
||||
other => value.push(other),
|
||||
}
|
||||
}
|
||||
bail!("unterminated string literal")
|
||||
}
|
||||
}
|
||||
|
||||
fn is_identifier_start(value: char) -> bool {
|
||||
value == '_' || value.is_alphabetic()
|
||||
}
|
||||
|
||||
fn is_identifier_continue(value: char) -> bool {
|
||||
value == '_' || value.is_alphanumeric()
|
||||
}
|
||||
|
||||
struct Parser {
|
||||
tokens: Vec<Token>,
|
||||
current: usize,
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
fn new(tokens: Vec<Token>) -> Self {
|
||||
Self { tokens, current: 0 }
|
||||
}
|
||||
|
||||
fn parse(&mut self) -> Result<SyntaxDocument> {
|
||||
self.expect_word("namespace")?;
|
||||
let namespace = self.parse_path()?;
|
||||
|
||||
while self.check_word("use") {
|
||||
self.parse_use()?;
|
||||
}
|
||||
|
||||
self.expect_word("export")?;
|
||||
let scene_start = self.peek().span.start;
|
||||
self.expect_word("scene")?;
|
||||
let scene_name = self.identifier()?;
|
||||
self.expect(TokenKind::LeftBrace)?;
|
||||
let render_start = self.peek().span.start;
|
||||
self.expect_word("render")?;
|
||||
self.expect(TokenKind::LeftBrace)?;
|
||||
let root = self.parse_node()?;
|
||||
let render_end = self.expect(TokenKind::RightBrace)?.span.end;
|
||||
let scene_end = self.expect(TokenKind::RightBrace)?.span.end;
|
||||
self.expect(TokenKind::End)?;
|
||||
|
||||
Ok(SyntaxDocument {
|
||||
namespace,
|
||||
scene_name,
|
||||
scene_span: Span {
|
||||
start: scene_start,
|
||||
end: scene_end,
|
||||
},
|
||||
render_span: Span {
|
||||
start: render_start,
|
||||
end: render_end,
|
||||
},
|
||||
root,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_path(&mut self) -> Result<String> {
|
||||
let mut path = self.identifier()?;
|
||||
while self.matches(&TokenKind::Dot) {
|
||||
path.push('.');
|
||||
path.push_str(&self.identifier()?);
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn parse_use(&mut self) -> Result<()> {
|
||||
self.expect_word("use")?;
|
||||
self.identifier()?;
|
||||
while self.matches(&TokenKind::Dot) {
|
||||
self.identifier()?;
|
||||
}
|
||||
if self.matches(&TokenKind::Colon) {
|
||||
self.expect(TokenKind::Colon)?;
|
||||
self.expect(TokenKind::LeftBrace)?;
|
||||
if !self.check(&TokenKind::RightBrace) {
|
||||
loop {
|
||||
self.identifier()?;
|
||||
if !self.matches(&TokenKind::Comma) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.expect(TokenKind::RightBrace)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_node(&mut self) -> Result<SyntaxNode> {
|
||||
let start = self.peek().span.start;
|
||||
let name = self.identifier()?;
|
||||
let mut arguments = Vec::new();
|
||||
if self.matches(&TokenKind::LeftParen) {
|
||||
if !self.check(&TokenKind::RightParen) {
|
||||
loop {
|
||||
if let TokenKind::Identifier(name) = &self.peek().kind
|
||||
&& self.peek_next().kind == TokenKind::Colon
|
||||
{
|
||||
let name = name.clone();
|
||||
self.advance();
|
||||
self.advance();
|
||||
arguments.push(Argument::Named(name, self.value()?));
|
||||
} else {
|
||||
arguments.push(Argument::Positional(self.value()?));
|
||||
}
|
||||
if !self.matches(&TokenKind::Comma) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.expect(TokenKind::RightParen)?;
|
||||
}
|
||||
|
||||
let mut children = Vec::new();
|
||||
if self.matches(&TokenKind::LeftBrace) {
|
||||
while !self.check(&TokenKind::RightBrace) {
|
||||
children.push(self.parse_node()?);
|
||||
}
|
||||
self.expect(TokenKind::RightBrace)?;
|
||||
}
|
||||
let end = self.previous().span.end;
|
||||
Ok(SyntaxNode {
|
||||
name,
|
||||
arguments,
|
||||
children,
|
||||
span: Span { start, end },
|
||||
})
|
||||
}
|
||||
|
||||
fn value(&mut self) -> Result<Value> {
|
||||
let token = self.advance().clone();
|
||||
match token.kind {
|
||||
TokenKind::String(value) => Ok(Value::Text(value)),
|
||||
TokenKind::Number(value) => Ok(Value::Number(value)),
|
||||
TokenKind::Identifier(value) => Ok(Value::Symbol(value)),
|
||||
TokenKind::Dot => Ok(Value::Symbol(format!(".{}", self.identifier()?))),
|
||||
_ => bail!("expected a value at byte {}", token.span.start),
|
||||
}
|
||||
}
|
||||
|
||||
fn identifier(&mut self) -> Result<String> {
|
||||
let token = self.advance().clone();
|
||||
match token.kind {
|
||||
TokenKind::Identifier(value) => Ok(value),
|
||||
_ => bail!("expected an identifier at byte {}", token.span.start),
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_word(&mut self, expected: &str) -> Result<()> {
|
||||
let actual = self.identifier()?;
|
||||
if actual != expected {
|
||||
bail!("expected `{expected}`, found `{actual}`");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn expect(&mut self, expected: TokenKind) -> Result<Token> {
|
||||
if self.check(&expected) {
|
||||
return Ok(self.advance().clone());
|
||||
}
|
||||
bail!(
|
||||
"expected {}, found {} at byte {}",
|
||||
token_name(&expected),
|
||||
token_name(&self.peek().kind),
|
||||
self.peek().span.start
|
||||
)
|
||||
}
|
||||
|
||||
fn matches(&mut self, expected: &TokenKind) -> bool {
|
||||
if self.check(expected) {
|
||||
self.advance();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn check(&self, expected: &TokenKind) -> bool {
|
||||
std::mem::discriminant(&self.peek().kind) == std::mem::discriminant(expected)
|
||||
}
|
||||
|
||||
fn check_word(&self, expected: &str) -> bool {
|
||||
matches!(&self.peek().kind, TokenKind::Identifier(value) if value == expected)
|
||||
}
|
||||
|
||||
fn peek(&self) -> &Token {
|
||||
&self.tokens[self.current]
|
||||
}
|
||||
|
||||
fn peek_next(&self) -> &Token {
|
||||
&self.tokens[(self.current + 1).min(self.tokens.len() - 1)]
|
||||
}
|
||||
|
||||
fn previous(&self) -> &Token {
|
||||
&self.tokens[self.current.saturating_sub(1)]
|
||||
}
|
||||
|
||||
fn advance(&mut self) -> &Token {
|
||||
let index = self.current;
|
||||
if self.current < self.tokens.len() - 1 {
|
||||
self.current += 1;
|
||||
}
|
||||
&self.tokens[index]
|
||||
}
|
||||
}
|
||||
|
||||
fn token_name(token: &TokenKind) -> &'static str {
|
||||
match token {
|
||||
TokenKind::Identifier(_) => "identifier",
|
||||
TokenKind::String(_) => "string",
|
||||
TokenKind::Number(_) => "number",
|
||||
TokenKind::LeftBrace => "`{`",
|
||||
TokenKind::RightBrace => "`}`",
|
||||
TokenKind::LeftParen => "`(`",
|
||||
TokenKind::RightParen => "`)`",
|
||||
TokenKind::Colon => "`:`",
|
||||
TokenKind::Comma => "`,`",
|
||||
TokenKind::Dot => "`.`",
|
||||
TokenKind::End => "end of code",
|
||||
}
|
||||
}
|
||||
|
||||
fn architecture(source: &str, syntax: &SyntaxDocument) -> CodeNode {
|
||||
CodeNode {
|
||||
id: format!("{}::{}", syntax.namespace, syntax.scene_name),
|
||||
name: syntax.scene_name.clone(),
|
||||
kind: "scene".to_owned(),
|
||||
start_line: line_number(source, syntax.scene_span.start),
|
||||
end_line: line_number(source, syntax.scene_span.end),
|
||||
children: vec![CodeNode {
|
||||
id: format!("{}::{}::render", syntax.namespace, syntax.scene_name),
|
||||
name: "render".to_owned(),
|
||||
kind: "render".to_owned(),
|
||||
start_line: line_number(source, syntax.render_span.start),
|
||||
end_line: line_number(source, syntax.render_span.end),
|
||||
children: vec![node_architecture(
|
||||
source,
|
||||
&syntax.namespace,
|
||||
&syntax.scene_name,
|
||||
&syntax.root,
|
||||
"0",
|
||||
)],
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn node_architecture(
|
||||
source: &str,
|
||||
namespace: &str,
|
||||
scene: &str,
|
||||
node: &SyntaxNode,
|
||||
path: &str,
|
||||
) -> CodeNode {
|
||||
CodeNode {
|
||||
id: format!("{namespace}::{scene}::render::{path}"),
|
||||
name: node.name.clone(),
|
||||
kind: node.name.to_lowercase(),
|
||||
start_line: line_number(source, node.span.start),
|
||||
end_line: line_number(source, node.span.end),
|
||||
children: node
|
||||
.children
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, child)| {
|
||||
node_architecture(source, namespace, scene, child, &format!("{path}.{index}"))
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn line_number(source: &str, offset: usize) -> usize {
|
||||
source[..offset.min(source.len())]
|
||||
.bytes()
|
||||
.filter(|byte| *byte == b'\n')
|
||||
.count()
|
||||
+ 1
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Size {
|
||||
width: f32,
|
||||
height: f32,
|
||||
}
|
||||
|
||||
fn layout_node(node: &SyntaxNode, x: f32, y: f32, commands: &mut Vec<RenderCommand>) -> Size {
|
||||
match node.name.as_str() {
|
||||
"Text" => layout_text(node, x, y, commands),
|
||||
"Row" => layout_row(node, x, y, commands),
|
||||
"Spacer" => Size {
|
||||
width: number_argument(node, "size").unwrap_or(0.0),
|
||||
height: number_argument(node, "size").unwrap_or(0.0),
|
||||
},
|
||||
_ => layout_column(node, x, y, commands),
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_text(node: &SyntaxNode, x: f32, y: f32, commands: &mut Vec<RenderCommand>) -> Size {
|
||||
let value = node.arguments.iter().find_map(|argument| match argument {
|
||||
Argument::Positional(Value::Text(value)) => Some(value.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let value = value.unwrap_or_default();
|
||||
let size = match named_argument(node, "role") {
|
||||
Some(Value::Symbol(value)) if value == ".title" => 2,
|
||||
_ => 1,
|
||||
};
|
||||
commands.push(RenderCommand::Text {
|
||||
x,
|
||||
y,
|
||||
value: value.clone(),
|
||||
size,
|
||||
color: "#242422".to_owned(),
|
||||
});
|
||||
Size {
|
||||
width: value.chars().count() as f32 * 8.0 * size as f32,
|
||||
height: 8.0 * size as f32,
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_column(node: &SyntaxNode, x: f32, y: f32, commands: &mut Vec<RenderCommand>) -> Size {
|
||||
let padding = number_argument(node, "padding").unwrap_or(0.0);
|
||||
let gap = number_argument(node, "gap").unwrap_or(0.0);
|
||||
let mut cursor_y = y + padding;
|
||||
let mut maximum_width: f32 = 0.0;
|
||||
for (index, child) in node.children.iter().enumerate() {
|
||||
if index > 0 {
|
||||
cursor_y += gap;
|
||||
}
|
||||
let size = layout_node(child, x + padding, cursor_y, commands);
|
||||
cursor_y += size.height;
|
||||
maximum_width = maximum_width.max(size.width);
|
||||
}
|
||||
Size {
|
||||
width: maximum_width + padding * 2.0,
|
||||
height: cursor_y - y + padding,
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_row(node: &SyntaxNode, x: f32, y: f32, commands: &mut Vec<RenderCommand>) -> Size {
|
||||
let padding = number_argument(node, "padding").unwrap_or(0.0);
|
||||
let gap = number_argument(node, "gap").unwrap_or(0.0);
|
||||
let mut cursor_x = x + padding;
|
||||
let mut maximum_height: f32 = 0.0;
|
||||
for (index, child) in node.children.iter().enumerate() {
|
||||
if index > 0 {
|
||||
cursor_x += gap;
|
||||
}
|
||||
let size = layout_node(child, cursor_x, y + padding, commands);
|
||||
cursor_x += size.width;
|
||||
maximum_height = maximum_height.max(size.height);
|
||||
}
|
||||
Size {
|
||||
width: cursor_x - x + padding,
|
||||
height: maximum_height + padding * 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn number_argument(node: &SyntaxNode, name: &str) -> Option<f32> {
|
||||
match named_argument(node, name) {
|
||||
Some(Value::Number(value)) => Some(*value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn named_argument<'a>(node: &'a SyntaxNode, name: &str) -> Option<&'a Value> {
|
||||
node.arguments.iter().find_map(|argument| match argument {
|
||||
Argument::Named(argument_name, value) if argument_name == name => Some(value),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const EXAMPLE: &str = r#"namespace app.main
|
||||
|
||||
use tessera.ui::{Column, Text}
|
||||
|
||||
export scene Main {
|
||||
render {
|
||||
Column(gap: 12, padding: 24) {
|
||||
Text("Hello", role: .title)
|
||||
Text("Rendered by Tessera")
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn compiles_baseline_render_syntax() {
|
||||
let document = compile(EXAMPLE, "app.main").unwrap();
|
||||
assert_eq!(document.frame.commands.len(), 2);
|
||||
assert_eq!(document.root.as_ref().unwrap().name, "Main");
|
||||
assert_eq!(
|
||||
document.root.as_ref().unwrap().children[0].children[0]
|
||||
.children
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_namespace_mismatch() {
|
||||
let error = compile(EXAMPLE, "app.other").unwrap_err();
|
||||
assert!(error.to_string().contains("namespace mismatch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_incomplete_syntax() {
|
||||
let error = compile("namespace app.main", "app.main").unwrap_err();
|
||||
assert!(error.to_string().contains("expected"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
use crate::{
|
||||
display_list::{Color, DisplayList, DrawCommand},
|
||||
document::{CodeNode, RenderDocument},
|
||||
geometry::{Point, Rect},
|
||||
};
|
||||
|
||||
const PANEL: Color = Color::rgb(247, 247, 245);
|
||||
const PANEL_ALT: Color = Color::rgb(238, 238, 235);
|
||||
const BORDER: Color = Color::rgb(214, 214, 209);
|
||||
const TEXT: Color = Color::rgb(36, 36, 34);
|
||||
const MUTED: Color = Color::rgb(112, 112, 107);
|
||||
const SELECTED: Color = Color::rgb(224, 229, 247);
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DeveloperToolsState {
|
||||
pub open: bool,
|
||||
pub selected_node_id: Option<String>,
|
||||
}
|
||||
|
||||
impl DeveloperToolsState {
|
||||
pub fn toggle(&mut self, document: &RenderDocument) {
|
||||
self.open = !self.open;
|
||||
self.ensure_selection(document);
|
||||
}
|
||||
|
||||
pub fn document_updated(&mut self, document: &RenderDocument) {
|
||||
self.ensure_selection(document);
|
||||
}
|
||||
|
||||
fn ensure_selection(&mut self, document: &RenderDocument) {
|
||||
let selection_exists = self
|
||||
.selected_node_id
|
||||
.as_deref()
|
||||
.is_some_and(|id| document.node(id).is_some());
|
||||
if !selection_exists {
|
||||
self.selected_node_id = document.root.as_ref().map(|node| node.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DeveloperToolsLayout {
|
||||
rows: Vec<TreeRowHit>,
|
||||
}
|
||||
|
||||
impl DeveloperToolsLayout {
|
||||
pub fn node_at(&self, point: Point) -> Option<&str> {
|
||||
self.rows
|
||||
.iter()
|
||||
.find(|row| row.rect.contains(point))
|
||||
.map(|row| row.node_id.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct TreeRowHit {
|
||||
rect: Rect,
|
||||
node_id: String,
|
||||
}
|
||||
|
||||
pub fn build(
|
||||
width: u32,
|
||||
height: u32,
|
||||
scale: f32,
|
||||
document: &RenderDocument,
|
||||
state: &DeveloperToolsState,
|
||||
) -> (DisplayList, DeveloperToolsLayout) {
|
||||
if !state.open {
|
||||
return (DisplayList::default(), DeveloperToolsLayout::default());
|
||||
}
|
||||
|
||||
let mut list = DisplayList::default();
|
||||
let width = width as f32;
|
||||
let height = height as f32;
|
||||
let panel_width = (430.0 * scale).min(width * 0.52);
|
||||
let panel_x = width - panel_width;
|
||||
let header_height = 44.0 * scale;
|
||||
let tree_height = ((height - header_height) * 0.42).max(150.0 * scale);
|
||||
let code_y = header_height + tree_height;
|
||||
|
||||
fill(
|
||||
&mut list,
|
||||
Rect::new(panel_x, 0.0, panel_width, height),
|
||||
0.0,
|
||||
PANEL,
|
||||
);
|
||||
fill(
|
||||
&mut list,
|
||||
Rect::new(panel_x, 0.0, 1.0 * scale, height),
|
||||
0.0,
|
||||
BORDER,
|
||||
);
|
||||
fill(
|
||||
&mut list,
|
||||
Rect::new(panel_x, header_height - scale, panel_width, scale),
|
||||
0.0,
|
||||
BORDER,
|
||||
);
|
||||
fill(
|
||||
&mut list,
|
||||
Rect::new(panel_x, code_y, panel_width, height - code_y),
|
||||
0.0,
|
||||
PANEL_ALT,
|
||||
);
|
||||
fill(
|
||||
&mut list,
|
||||
Rect::new(panel_x, code_y, panel_width, scale),
|
||||
0.0,
|
||||
BORDER,
|
||||
);
|
||||
|
||||
text(
|
||||
&mut list,
|
||||
panel_x + 14.0 * scale,
|
||||
15.0 * scale,
|
||||
"DEVELOPER",
|
||||
scale,
|
||||
TEXT,
|
||||
);
|
||||
text(
|
||||
&mut list,
|
||||
width - 116.0 * scale,
|
||||
15.0 * scale,
|
||||
"F12 TOGGLE",
|
||||
scale,
|
||||
MUTED,
|
||||
);
|
||||
|
||||
text(
|
||||
&mut list,
|
||||
panel_x + 14.0 * scale,
|
||||
header_height + 13.0 * scale,
|
||||
"ARCHITECTURE",
|
||||
scale,
|
||||
MUTED,
|
||||
);
|
||||
|
||||
let mut layout = DeveloperToolsLayout::default();
|
||||
if let Some(root) = &document.root {
|
||||
let mut flattened = Vec::new();
|
||||
flatten(root, 0, &mut flattened);
|
||||
let row_height = 24.0 * scale;
|
||||
let first_y = header_height + 34.0 * scale;
|
||||
let maximum_rows = ((tree_height - 40.0 * scale) / row_height).max(0.0) as usize;
|
||||
|
||||
for (index, (depth, node)) in flattened.into_iter().take(maximum_rows).enumerate() {
|
||||
let y = first_y + index as f32 * row_height;
|
||||
let row = Rect::new(
|
||||
panel_x + 7.0 * scale,
|
||||
y,
|
||||
panel_width - 14.0 * scale,
|
||||
row_height,
|
||||
);
|
||||
if state.selected_node_id.as_deref() == Some(node.id.as_str()) {
|
||||
fill(&mut list, row, 5.0 * scale, SELECTED);
|
||||
}
|
||||
text(
|
||||
&mut list,
|
||||
row.x + (9.0 + depth as f32 * 14.0) * scale,
|
||||
row.y + 8.0 * scale,
|
||||
&fit(&format!("{} {}", node.kind, node.name), 44),
|
||||
scale,
|
||||
TEXT,
|
||||
);
|
||||
layout.rows.push(TreeRowHit {
|
||||
rect: row,
|
||||
node_id: node.id.clone(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
text(
|
||||
&mut list,
|
||||
panel_x + 14.0 * scale,
|
||||
header_height + 48.0 * scale,
|
||||
"NO RENDER TREE",
|
||||
scale,
|
||||
MUTED,
|
||||
);
|
||||
}
|
||||
|
||||
text(
|
||||
&mut list,
|
||||
panel_x + 14.0 * scale,
|
||||
code_y + 16.0 * scale,
|
||||
"SOURCE",
|
||||
scale,
|
||||
MUTED,
|
||||
);
|
||||
draw_source(
|
||||
&mut list,
|
||||
Rect::new(panel_x, code_y, panel_width, height - code_y),
|
||||
scale,
|
||||
document,
|
||||
state.selected_node_id.as_deref(),
|
||||
);
|
||||
|
||||
(list, layout)
|
||||
}
|
||||
|
||||
fn draw_source(
|
||||
list: &mut DisplayList,
|
||||
area: Rect,
|
||||
scale: f32,
|
||||
document: &RenderDocument,
|
||||
selected_id: Option<&str>,
|
||||
) {
|
||||
if document.source.is_empty() {
|
||||
text(
|
||||
list,
|
||||
area.x + 14.0 * scale,
|
||||
area.y + 50.0 * scale,
|
||||
"NO SOURCE LOADED",
|
||||
scale,
|
||||
MUTED,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let all_lines: Vec<_> = document.source.lines().collect();
|
||||
let selected = selected_id.and_then(|id| document.node(id));
|
||||
let start = selected
|
||||
.map(|node| node.start_line.saturating_sub(1))
|
||||
.unwrap_or(0)
|
||||
.min(all_lines.len());
|
||||
let declared_end = selected
|
||||
.map(|node| node.end_line)
|
||||
.unwrap_or(all_lines.len());
|
||||
let end = if declared_end == 0 {
|
||||
all_lines.len()
|
||||
} else {
|
||||
declared_end.min(all_lines.len())
|
||||
};
|
||||
let line_height = 18.0 * scale;
|
||||
let first_y = area.y + 42.0 * scale;
|
||||
let maximum_lines =
|
||||
((area.y + area.height - first_y - 10.0 * scale) / line_height).max(0.0) as usize;
|
||||
let max_chars = ((area.width / (8.0 * scale)) - 9.0).max(8.0) as usize;
|
||||
|
||||
for (offset, source_line) in all_lines[start..end].iter().take(maximum_lines).enumerate() {
|
||||
let line_number = start + offset + 1;
|
||||
let y = first_y + offset as f32 * line_height;
|
||||
text(
|
||||
list,
|
||||
area.x + 12.0 * scale,
|
||||
y,
|
||||
&format!("{:>4}", line_number),
|
||||
scale,
|
||||
MUTED,
|
||||
);
|
||||
text(
|
||||
list,
|
||||
area.x + 55.0 * scale,
|
||||
y,
|
||||
&fit(&source_line.replace('\t', " "), max_chars),
|
||||
scale,
|
||||
TEXT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn flatten<'a>(node: &'a CodeNode, depth: usize, output: &mut Vec<(usize, &'a CodeNode)>) {
|
||||
output.push((depth, node));
|
||||
for child in &node.children {
|
||||
flatten(child, depth + 1, output);
|
||||
}
|
||||
}
|
||||
|
||||
fn fit(value: &str, maximum: usize) -> String {
|
||||
let characters: Vec<_> = value.chars().collect();
|
||||
if characters.len() <= maximum {
|
||||
return value.to_owned();
|
||||
}
|
||||
if maximum < 3 {
|
||||
return characters.into_iter().take(maximum).collect();
|
||||
}
|
||||
characters
|
||||
.into_iter()
|
||||
.take(maximum - 3)
|
||||
.chain(['.', '.', '.'])
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn fill(list: &mut DisplayList, rect: Rect, radius: f32, color: Color) {
|
||||
list.push(DrawCommand::FillRoundedRect {
|
||||
rect,
|
||||
radius,
|
||||
color,
|
||||
});
|
||||
}
|
||||
|
||||
fn text(list: &mut DisplayList, x: f32, y: f32, value: &str, scale: f32, color: Color) {
|
||||
list.push(DrawCommand::Text {
|
||||
origin: Point::new(x, y),
|
||||
value: value.to_owned(),
|
||||
scale: scale.round().max(1.0) as u32,
|
||||
color,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use crate::geometry::{Point, Rect};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Color {
|
||||
pub red: u8,
|
||||
pub green: u8,
|
||||
pub blue: u8,
|
||||
pub alpha: u8,
|
||||
}
|
||||
|
||||
impl Color {
|
||||
pub const fn rgb(red: u8, green: u8, blue: u8) -> Self {
|
||||
Self {
|
||||
red,
|
||||
green,
|
||||
blue,
|
||||
alpha: 255,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn rgba(red: u8, green: u8, blue: u8, alpha: u8) -> Self {
|
||||
Self {
|
||||
red,
|
||||
green,
|
||||
blue,
|
||||
alpha,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum DrawCommand {
|
||||
Clear(Color),
|
||||
FillRoundedRect {
|
||||
rect: Rect,
|
||||
radius: f32,
|
||||
color: Color,
|
||||
},
|
||||
StrokeRoundedRect {
|
||||
rect: Rect,
|
||||
radius: f32,
|
||||
width: f32,
|
||||
color: Color,
|
||||
},
|
||||
Text {
|
||||
origin: Point,
|
||||
value: String,
|
||||
scale: u32,
|
||||
color: Color,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DisplayList {
|
||||
commands: Vec<DrawCommand>,
|
||||
}
|
||||
|
||||
impl DisplayList {
|
||||
pub fn push(&mut self, command: DrawCommand) {
|
||||
self.commands.push(command);
|
||||
}
|
||||
|
||||
pub fn commands(&self) -> &[DrawCommand] {
|
||||
&self.commands
|
||||
}
|
||||
|
||||
pub fn extend(&mut self, other: DisplayList) {
|
||||
self.commands.extend(other.commands);
|
||||
}
|
||||
}
|
||||
+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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Point {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
impl Point {
|
||||
pub const fn new(x: f32, y: f32) -> Self {
|
||||
Self { x, y }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Rect {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
|
||||
Self {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn contains(self, point: Point) -> bool {
|
||||
point.x >= self.x
|
||||
&& point.x <= self.x + self.width
|
||||
&& point.y >= self.y
|
||||
&& point.y <= self.y + self.height
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rectangle_contains_edges_but_not_outside_points() {
|
||||
let rect = Rect::new(10.0, 20.0, 30.0, 40.0);
|
||||
assert!(rect.contains(Point::new(10.0, 20.0)));
|
||||
assert!(rect.contains(Point::new(40.0, 60.0)));
|
||||
assert!(!rect.contains(Point::new(9.9, 20.0)));
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
mod api;
|
||||
mod app;
|
||||
mod compiler;
|
||||
mod developer_tools;
|
||||
mod display_list;
|
||||
mod document;
|
||||
mod geometry;
|
||||
mod scene;
|
||||
mod software_renderer;
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use anyhow::Result;
|
||||
use app::{AppEvent, TesseraApp};
|
||||
use document::NamespaceState;
|
||||
use winit::event_loop::{ControlFlow, EventLoop};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let event_loop = EventLoop::<AppEvent>::with_user_event().build()?;
|
||||
event_loop.set_control_flow(ControlFlow::Wait);
|
||||
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
|
||||
api::start(namespace.clone(), event_loop.create_proxy())?;
|
||||
event_loop.run_app(&mut TesseraApp::new(namespace))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use crate::{
|
||||
developer_tools::{self, DeveloperToolsLayout, DeveloperToolsState},
|
||||
display_list::DisplayList,
|
||||
document::RenderDocument,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SceneLayout {
|
||||
pub developer_tools: DeveloperToolsLayout,
|
||||
}
|
||||
|
||||
pub fn build_scene(
|
||||
width: u32,
|
||||
height: u32,
|
||||
scale: f32,
|
||||
document: &RenderDocument,
|
||||
developer_tools_state: &DeveloperToolsState,
|
||||
) -> DisplayScene {
|
||||
let mut display_list = document.display_list(scale).unwrap_or_else(|_| {
|
||||
RenderDocument::default()
|
||||
.display_list(scale)
|
||||
.expect("default document is valid")
|
||||
});
|
||||
let (developer_list, developer_layout) =
|
||||
developer_tools::build(width, height, scale, document, developer_tools_state);
|
||||
display_list.extend(developer_list);
|
||||
|
||||
DisplayScene {
|
||||
display_list,
|
||||
layout: SceneLayout {
|
||||
developer_tools: developer_layout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DisplayScene {
|
||||
pub display_list: DisplayList,
|
||||
pub layout: SceneLayout,
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
use font8x8::{BASIC_FONTS, UnicodeFonts};
|
||||
|
||||
use crate::{
|
||||
display_list::{Color, DisplayList, DrawCommand},
|
||||
geometry::{Point, Rect},
|
||||
};
|
||||
|
||||
pub struct SoftwareRenderer;
|
||||
|
||||
impl SoftwareRenderer {
|
||||
pub fn render(list: &DisplayList, target: &mut [u32], width: u32, height: u32) {
|
||||
let mut canvas = Canvas {
|
||||
pixels: target,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
|
||||
for command in list.commands() {
|
||||
match command {
|
||||
DrawCommand::Clear(color) => canvas.clear(*color),
|
||||
DrawCommand::FillRoundedRect {
|
||||
rect,
|
||||
radius,
|
||||
color,
|
||||
} => canvas.fill_rounded_rect(*rect, *radius, *color),
|
||||
DrawCommand::StrokeRoundedRect {
|
||||
rect,
|
||||
radius,
|
||||
width,
|
||||
color,
|
||||
} => canvas.stroke_rounded_rect(*rect, *radius, *width, *color),
|
||||
DrawCommand::Text {
|
||||
origin,
|
||||
value,
|
||||
scale,
|
||||
color,
|
||||
} => canvas.draw_text(*origin, value, *scale, *color),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Canvas<'a> {
|
||||
pixels: &'a mut [u32],
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
impl Canvas<'_> {
|
||||
fn clear(&mut self, color: Color) {
|
||||
self.pixels.fill(pack(color));
|
||||
}
|
||||
|
||||
fn fill_rounded_rect(&mut self, rect: Rect, radius: f32, color: Color) {
|
||||
let bounds = self.clamped_bounds(rect);
|
||||
let radius = radius.max(0.0).min(rect.width / 2.0).min(rect.height / 2.0);
|
||||
for y in bounds.1..bounds.3 {
|
||||
for x in bounds.0..bounds.2 {
|
||||
if rounded_rect_contains(rect, radius, x as f32 + 0.5, y as f32 + 0.5) {
|
||||
self.blend_pixel(x, y, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stroke_rounded_rect(&mut self, rect: Rect, radius: f32, width: f32, color: Color) {
|
||||
let bounds = self.clamped_bounds(rect);
|
||||
let width = width.max(1.0).min(rect.width / 2.0).min(rect.height / 2.0);
|
||||
let outer_radius = radius.max(0.0).min(rect.width / 2.0).min(rect.height / 2.0);
|
||||
let inner = Rect::new(
|
||||
rect.x + width,
|
||||
rect.y + width,
|
||||
(rect.width - width * 2.0).max(0.0),
|
||||
(rect.height - width * 2.0).max(0.0),
|
||||
);
|
||||
let inner_radius = (outer_radius - width).max(0.0);
|
||||
|
||||
for y in bounds.1..bounds.3 {
|
||||
for x in bounds.0..bounds.2 {
|
||||
let px = x as f32 + 0.5;
|
||||
let py = y as f32 + 0.5;
|
||||
if rounded_rect_contains(rect, outer_radius, px, py)
|
||||
&& !rounded_rect_contains(inner, inner_radius, px, py)
|
||||
{
|
||||
self.blend_pixel(x, y, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_text(&mut self, origin: Point, value: &str, scale: u32, color: Color) {
|
||||
let scale = scale.max(1) as i32;
|
||||
let mut cursor_x = origin.x.round() as i32;
|
||||
let baseline_y = origin.y.round() as i32;
|
||||
|
||||
for character in value.chars() {
|
||||
if let Some(glyph) = BASIC_FONTS.get(character) {
|
||||
for (row, byte) in glyph.iter().enumerate() {
|
||||
for column in 0..8 {
|
||||
if byte & (1 << column) != 0 {
|
||||
self.fill_pixels(
|
||||
cursor_x + column * scale,
|
||||
baseline_y + row as i32 * scale,
|
||||
scale,
|
||||
scale,
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor_x += 8 * scale;
|
||||
}
|
||||
}
|
||||
|
||||
fn clamped_bounds(&self, rect: Rect) -> (i32, i32, i32, i32) {
|
||||
(
|
||||
rect.x.max(0.0).floor() as i32,
|
||||
rect.y.max(0.0).floor() as i32,
|
||||
(rect.x + rect.width).min(self.width as f32).ceil() as i32,
|
||||
(rect.y + rect.height).min(self.height as f32).ceil() as i32,
|
||||
)
|
||||
}
|
||||
|
||||
fn fill_pixels(&mut self, x: i32, y: i32, width: i32, height: i32, color: Color) {
|
||||
for py in y.max(0)..(y + height).min(self.height as i32) {
|
||||
for px in x.max(0)..(x + width).min(self.width as i32) {
|
||||
self.blend_pixel(px, py, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn blend_pixel(&mut self, x: i32, y: i32, source: Color) {
|
||||
if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 || source.alpha == 0
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let index = y as usize * self.width as usize + x as usize;
|
||||
if source.alpha == 255 {
|
||||
self.pixels[index] = pack(source);
|
||||
return;
|
||||
}
|
||||
|
||||
let destination = self.pixels[index];
|
||||
let dst_red = (destination >> 16) & 0xff;
|
||||
let dst_green = (destination >> 8) & 0xff;
|
||||
let dst_blue = destination & 0xff;
|
||||
let alpha = source.alpha as u32;
|
||||
let inverse = 255 - alpha;
|
||||
let red = (source.red as u32 * alpha + dst_red * inverse) / 255;
|
||||
let green = (source.green as u32 * alpha + dst_green * inverse) / 255;
|
||||
let blue = (source.blue as u32 * alpha + dst_blue * inverse) / 255;
|
||||
self.pixels[index] = (red << 16) | (green << 8) | blue;
|
||||
}
|
||||
}
|
||||
|
||||
fn rounded_rect_contains(rect: Rect, radius: f32, x: f32, y: f32) -> bool {
|
||||
if rect.width <= 0.0 || rect.height <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
if radius <= 0.0 {
|
||||
return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
|
||||
}
|
||||
|
||||
let closest_x = x.clamp(rect.x + radius, rect.x + rect.width - radius);
|
||||
let closest_y = y.clamp(rect.y + radius, rect.y + rect.height - radius);
|
||||
let dx = x - closest_x;
|
||||
let dy = y - closest_y;
|
||||
dx * dx + dy * dy <= radius * radius
|
||||
}
|
||||
|
||||
fn pack(color: Color) -> u32 {
|
||||
(color.red as u32) << 16 | (color.green as u32) << 8 | color.blue as u32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn renderer_changes_pixels_inside_a_rectangle() {
|
||||
let mut list = DisplayList::default();
|
||||
list.push(DrawCommand::Clear(Color::rgb(0, 0, 0)));
|
||||
list.push(DrawCommand::FillRoundedRect {
|
||||
rect: Rect::new(1.0, 1.0, 2.0, 2.0),
|
||||
radius: 0.0,
|
||||
color: Color::rgb(255, 0, 0),
|
||||
});
|
||||
let mut pixels = vec![0; 16];
|
||||
SoftwareRenderer::render(&list, &mut pixels, 4, 4);
|
||||
assert_eq!(pixels[5], 0x00ff0000);
|
||||
assert_eq!(pixels[0], 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user