拆分 UI State 到 HTML 渲染器

拆分 html_renderer 为资源、节点、容器、文本和图片模块

接入 ui.design.to_html Runtime 工具

保留临时 ui-design-to-html 测试 bin

生产渲染器输出带元数据注释的 HTML 片段
This commit is contained in:
2026-08-31 19:04:36 +08:00
parent c5200c7d45
commit 7915d58038
19 changed files with 1168 additions and 0 deletions
+35
View File
@@ -1715,6 +1715,7 @@ dependencies = [
"image",
"jsonschema",
"libc",
"maud",
"nalgebra",
"oxc_allocator",
"oxc_ast",
@@ -2732,6 +2733,28 @@ dependencies = [
"rawpointer",
]
[[package]]
name = "maud"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e"
dependencies = [
"itoa",
"maud_macros",
]
[[package]]
name = "maud_macros"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca"
dependencies = [
"proc-macro2",
"proc-macro2-diagnostics",
"quote",
"syn 2.0.118",
]
[[package]]
name = "memchr"
version = "2.8.2"
@@ -3965,6 +3988,18 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "proc-macro2-diagnostics"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"version_check",
]
[[package]]
name = "psl-types"
version = "2.0.11"
@@ -56,6 +56,7 @@ unicode-normalization = "0.1"
uuid = { version = "1", features = ["v4"] }
zip = { version = "2", default-features = false, features = ["deflate"] }
tauri-plugin-clipboard-manager = "2.3.2"
maud = "0.27.0"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
@@ -421,6 +421,7 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
"ui.workflow.run" => {
observe_agent_runtime_ui_workflow(root, agent_id, run_id, task, &action.input).await
}
"ui.design.to_html" => observe_agent_runtime_ui_design_to_html(root, &action.input),
"blackboard.write" => {
observe_agent_runtime_blackboard_write(root, agent_id, run_id, &action.input)
}
@@ -1097,6 +1097,7 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_read_only_delivery_pla
| "command.output_read"
| "command.poll"
| "image.inspect" => false,
"ui.design.to_html" => false,
"preview.validate" => agent_id != "preview-playtest" && agent_id != "code-prototype",
"command.run_limited" => {
agent_id != "preview-readiness" && agent_id != "code-prototype"
@@ -12,6 +12,7 @@ pub(crate) fn agent_runtime_tool_is_parallel_safe_read(tool: &str) -> bool {
| "file.list"
| "file.read"
| "task.list"
| "ui.design.to_html"
)
}
@@ -100,6 +101,7 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id(
"image.inspect" => Some("image.inspect"),
"canvas.asset_generate" => Some("canvas.asset_generate"),
"ui.workflow.run" => Some("asset.register"),
"ui.design.to_html" => Some("ui.design.to_html"),
"blackboard.write" => Some("memory.write"),
"agent.message" => Some("conversation.write"),
"agent.delegate" => Some("agent.delegate"),
@@ -100,6 +100,7 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> {
"image.inspect",
"canvas.asset_generate",
"ui.workflow.run",
"ui.design.to_html",
"blackboard.write",
"agent.message",
"agent.delegate",
@@ -339,6 +340,7 @@ pub(crate) fn agent_runtime_autonomous_design_foundation_command_is_allowed(
| "image.inspect"
| "canvas.asset_generate"
| "canvas.asset_import"
| "ui.design.to_html"
| "agent.audit"
| "agent.run_status"
)
@@ -17,6 +17,7 @@ mod project_ops;
mod run_status;
mod task_ops;
mod ui_workflow;
mod ui_html;
pub(in crate::agent) use action_history::*;
pub(in crate::agent) use command_ops::*;
@@ -35,6 +36,7 @@ pub(in crate::agent) use project_ops::*;
pub(in crate::agent) use run_status::*;
pub(in crate::agent) use task_ops::*;
pub(in crate::agent) use ui_workflow::*;
pub(in crate::agent) use ui_html::*;
#[cfg(test)]
pub(crate) use delegation::observe_agent_runtime_agent_delegate_at_locked;
@@ -49,6 +49,7 @@ fn autonomous_design_foundation_command_is_allowed(command_id: &str) -> bool {
| "canvas.asset_import"
| "asset.register"
| "ui.workflow.run"
| "ui.design.to_html"
| "agent.audit"
| "agent.action_history"
| "agent.run_status"
@@ -0,0 +1,57 @@
use super::*;
use crate::ui_editor::html_renderer::render_ui_design_state_html;
use crate::ui_editor::persistence::{load_ui_design_state_at, LoadUiDesignStateInput};
use serde_json::Value;
pub(in crate::agent) fn observe_agent_runtime_ui_design_to_html(
root: &Path,
input: &Value,
) -> AgentRuntimeToolObservation {
let asset_id = input
.get("assetId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let Some(asset_id) = asset_id else {
return AgentRuntimeToolObservation {
tool: "ui.design.to_html".to_string(),
status: "rejected".to_string(),
summary: "ui.design.to_html 缺少 assetId".to_string(),
detail: None,
};
};
let project_id = match game_creator_agent_runtime_context_project_id(root) {
Ok(project_id) => project_id,
Err(error) => return ui_html_error(root, error),
};
let snapshot = match load_ui_design_state_at(LoadUiDesignStateInput {
project_path: root.to_string_lossy().into_owned(),
expected_project_id: project_id,
asset_id: asset_id.to_string(),
}) {
Ok(snapshot) => snapshot,
Err(error) => return ui_html_error(root, error),
};
match render_ui_design_state_html(&snapshot.state) {
Ok(html) => AgentRuntimeToolObservation {
tool: "ui.design.to_html".to_string(),
status: "ok".to_string(),
summary: format!(
"已生成 UI HTMLrevision {}{} 字节)",
snapshot.revision,
html.len()
),
detail: Some(html),
},
Err(error) => ui_html_error(root, error),
}
}
fn ui_html_error(root: &Path, error: String) -> AgentRuntimeToolObservation {
AgentRuntimeToolObservation {
tool: "ui.design.to_html".to_string(),
status: "error".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 500),
detail: None,
}
}
@@ -1391,6 +1391,9 @@ fn runtime_tool_description(tool: &str) -> &'static str {
"ui.workflow.run" => {
"先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。"
}
"ui.design.to_html" => {
"只读读取指定 UI 设计, 生成HTML片段;"
}
"blackboard.write" => "向项目级共享黑板追加稳定结论。",
"agent.message" => "向一个目标 Agent 写入定向上下文消息。",
"agent.delegate" => {
@@ -1683,6 +1686,14 @@ fn runtime_tool_input_schema(tool: &str) -> Value {
}
}
}),
"ui.design.to_html" => json!({
"type": "object",
"required": ["assetId"],
"additionalProperties": false,
"properties": {
"assetId": { "type": "string", "minLength": 1, "maxLength": 160 }
}
}),
"blackboard.write" => two_string_input_schema("title", "content"),
"agent.message" => two_string_input_schema("agentId", "content"),
"agent.delegate" => json!({
@@ -0,0 +1,399 @@
use maud::{html, PreEscaped};
use serde_json::Value;
use std::env;
use std::fmt::Write as _;
use std::fs;
fn main() {
let args = env::args().skip(1).collect::<Vec<_>>();
if args.len() != 2 {
eprintln!("用法:ui-design-to-html <ui-state.json> <output.html>");
std::process::exit(2);
}
let input = fs::read_to_string(&args[0])
.unwrap_or_else(|error| fail(&format!("读取输入失败:{error}")));
let document: Value = serde_json::from_str(&input)
.unwrap_or_else(|error| fail(&format!("解析 UI JSON 失败:{error}")));
let state = document.get("state").unwrap_or(&document);
let html = render_state(state).unwrap_or_else(|error| fail(&error));
fs::write(&args[1], html).unwrap_or_else(|error| fail(&format!("写入 HTML 失败:{error}")));
}
fn render_state(state: &Value) -> Result<String, String> {
let trees = state
.get("ui_trees")
.and_then(Value::as_array)
.ok_or("State 缺少 ui_trees")?;
let mut body = String::new();
for (index, tree) in trees.iter().enumerate() {
if index > 0 {
body.push_str("\n<!-- -------------------- -->\n");
}
body.push_str(&render_tree(state, tree)?);
}
Ok(html! {
(PreEscaped("<!doctype html>"))
html {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
style { (PreEscaped("html,body{margin:0;width:100%;min-height:100%;}body{overflow:auto;}[data-ui-tree]{position:relative;width:100%;min-height:0;overflow:hidden;}:root{--ui-scale:1;}")) }
}
body { (PreEscaped(body)) }
}
}.into_string())
}
fn render_tree(state: &Value, tree: &Value) -> Result<String, String> {
let source = tree
.get("src_ui_design")
.and_then(Value::as_str)
.ok_or("UITree 缺少 src_ui_design")?;
let image = state
.get("ui_design_images")
.and_then(|images| images.get(source))
.ok_or_else(|| format!("缺少设计图资源:{source}"))?;
let width = logical_dimension(image, 0)?;
let height = logical_dimension(image, 1)?;
let root = tree.get("root").ok_or("UITree 缺少 root")?;
let mut content = String::new();
let tree_metadata =
serde_json::json!({"srcUiDesign": source, "width": width, "height": height});
write!(
&mut content,
"<!-- genarrative-ui-tree: {} -->",
comment_safe(&tree_metadata.to_string())
)
.unwrap();
write!(&mut content, "<div data-ui-tree=\"{}\" style=\"position:relative;width:100%;height:auto;min-height:0;aspect-ratio:{} / {};--ui-design-width:{}px;--ui-design-height:{}px;\">", html_escape(source), width, height, width, height).unwrap();
content.push_str(&render_node(state, root, false)?);
content.push_str("</div>");
Ok(content)
}
fn render_node(state: &Value, node: &Value, in_container: bool) -> Result<String, String> {
let id = node
.get("id")
.and_then(Value::as_str)
.ok_or("Node 缺少 id")?;
let metadata = node.get("metadata").ok_or("Node 缺少 metadata")?;
let name = metadata
.get("name")
.and_then(Value::as_str)
.unwrap_or_default();
let description = metadata
.get("description")
.and_then(Value::as_str)
.unwrap_or_default();
let layout = node.get("layout").ok_or("Node 缺少 layout")?;
let node_metadata = serde_json::json!({"nodeId": id, "name": name, "description": description});
let mut output = format!(
"<!-- genarrative-ui-node: {} -->",
comment_safe(&node_metadata.to_string())
);
if node.get("children_display_mode").and_then(Value::as_str) == Some("Exclusive") {
output.push_str("<!-- genarrative-ui-node-group: childrenDisplayMode=Exclusive; all children retained -->");
}
let style = node_style(layout, in_container)?;
let style = format!("{style}border:0;outline:0;background:transparent;overflow:visible;");
write!(
&mut output,
"<div data-node-id=\"{}\" style=\"{}\">",
html_escape(id),
style
)
.unwrap();
if let Some(components) = node.get("components").and_then(Value::as_array) {
for component in components {
output.push_str(&render_component(state, component)?);
}
}
let child_container = layout.get("container").is_some_and(|value| value != "None");
if let Some(children) = node.get("children").and_then(Value::as_array) {
for child in children {
output.push_str(&render_node(state, child, child_container)?);
}
}
output.push_str("</div>");
Ok(output)
}
fn render_component(state: &Value, component: &Value) -> Result<String, String> {
if let Some(text) = component.get("Text") {
let content = text
.get("content")
.and_then(Value::as_str)
.unwrap_or_default();
let size = text
.get("font_sizing")
.and_then(|sizing| {
sizing
.get("Fixed")
.or_else(|| sizing.get("BestFit").and_then(|range| range.get("min")))
})
.and_then(Value::as_u64)
.unwrap_or(14);
let alignment = text
.get("alignment")
.and_then(Value::as_str)
.unwrap_or("UpperLeft");
let horizontal = if alignment.ends_with("Center") {
"center"
} else if alignment.ends_with("Right") {
"right"
} else {
"left"
};
let vertical = if alignment.starts_with("Middle") {
"center"
} else if alignment.starts_with("Lower") {
"flex-end"
} else {
"flex-start"
};
let color = text
.get("color")
.and_then(Value::as_array)
.map(|v| {
[
v.first().and_then(Value::as_u64).unwrap_or(255),
v.get(1).and_then(Value::as_u64).unwrap_or(255),
v.get(2).and_then(Value::as_u64).unwrap_or(255),
v.get(3).and_then(Value::as_u64).unwrap_or(255),
]
})
.unwrap_or([255, 255, 255, 255]);
let white_space =
if text.get("horizontal_overflow").and_then(Value::as_str) == Some("Overflow") {
"nowrap"
} else {
"normal"
};
let overflow = if text.get("vertical_overflow").and_then(Value::as_str) == Some("Overflow")
{
"visible"
} else {
"hidden"
};
let overflow_wrap = if white_space == "normal" {
"anywhere"
} else {
"normal"
};
let line_spacing = text
.get("line_spacing")
.and_then(Value::as_f64)
.unwrap_or(1.0);
let (font_weight, font_style) = match text.get("font_style").and_then(Value::as_str) {
Some("Bold") => (700, "normal"),
Some("Italic") => (400, "italic"),
Some("BoldItalic") => (700, "italic"),
_ => (400, "normal"),
};
let style = format!("position:absolute;inset:0;display:flex;width:100%;height:100%;box-sizing:border-box;color:rgba({},{},{},{});font-size:calc({size}px * var(--ui-scale, 1));font-weight:{font_weight};font-style:{font_style};align-items:{vertical};justify-content:{};text-align:{horizontal};line-height:{line_spacing};white-space:{white_space};overflow:{overflow};overflow-wrap:{overflow_wrap};", color[0], color[1], color[2], color[3] as f32 / 255.0, if horizontal == "left" { "flex-start" } else if horizontal == "center" { "center" } else { "flex-end" });
return Ok(
html! { div data-component-kind="Text" style=(style) { (content) } }.into_string(),
);
}
if let Some(image) = component.get("Image") {
let target = image
.get("target_graphic")
.and_then(Value::as_str)
.ok_or("Image 缺少 target_graphic")?;
let sprite = state
.get("sprite_assets")
.and_then(|sprites| sprites.get(target))
.ok_or_else(|| format!("缺少 Sprite 资源:{target}"))?;
let path = sprite
.get("path")
.and_then(Value::as_str)
.ok_or("Sprite 缺少 path")?;
if path.trim().is_empty()
|| path.starts_with('/')
|| path.split('/').any(|part| part == "..")
|| path.chars().any(|character| {
character.is_control() || matches!(character, '\'' | '"' | '`' | '(' | ')')
})
{
return Err(format!("资源路径无效:{path}"));
}
let src = format!("/{path}");
let image_type = image.get("image_type").unwrap_or(&Value::Null);
if let Some(tiled) = image_type.get("Tiled") {
let multiplier = tiled
.get("pixels_per_unit_multiplier")
.and_then(Value::as_f64)
.unwrap_or(1.0);
let w = logical_sprite_dimension(sprite, 0, multiplier)?;
let h = logical_sprite_dimension(sprite, 1, multiplier)?;
let style = format!("position:absolute;inset:0;overflow:hidden;background-image:url(\"{src}\");background-repeat:repeat;background-position:top left;background-size:{w}px {h}px;");
return Ok(html! { div data-component-kind="Image" style=(style) {} }.into_string());
}
let mut image_style = "width:100%;height:100%;display:block;object-fit:fill;".to_string();
if let Some(simple) = image_type.get("Simple") {
if simple
.get("preserve_aspect")
.and_then(Value::as_bool)
.unwrap_or(false)
{
image_style = image_style.replace("fill", "contain");
}
}
if let Some(filled) = image_type.get("Filled") {
let amount = filled
.get("amount")
.and_then(Value::as_f64)
.unwrap_or(1.0)
.clamp(0.0, 1.0);
if amount < 1.0 {
image_style.push_str(&format!(
"clip-path:inset(0 {}% 0 0);",
(1.0 - amount) * 100.0
));
}
}
return Ok(html! { div data-component-kind="Image" style="position:absolute;inset:0;overflow:hidden;" { img src=(src) alt="" aria-hidden="true" draggable="false" style=(image_style); } }.into_string());
}
Err("未知 UI 组件类型".to_string())
}
fn node_style(layout: &Value, in_container: bool) -> Result<String, String> {
let transform = layout
.get("transform")
.ok_or("ControlLayout 缺少 transform")?;
let amin = pair(transform, "anchor_min")?;
let amax = pair(transform, "anchor_max")?;
let omin = pair(transform, "offset_min")?;
let omax = pair(transform, "offset_max")?;
if amin[0] > amax[0] || amin[1] > amax[1] {
return Err("Transform anchors must be ordered".to_string());
}
let mut style = if in_container {
"position:relative;".to_string()
} else {
format!(
"position:absolute;left:{};top:{};right:{};bottom:{};",
css_length(amin[0] * 100.0, omin[0])?,
css_length(amin[1] * 100.0, omin[1])?,
css_length((1.0 - amax[0]) * 100.0, -omax[0])?,
css_length((1.0 - amax[1]) * 100.0, -omax[1])?
)
};
if let Some(container) = layout.get("container") {
style.push_str(&container_css(container));
}
Ok(style)
}
fn container_css(container: &Value) -> String {
if container == "None" {
return String::new();
}
if let Some(props) = container.get("HBox") {
return format!(
"display:flex;flex-direction:row;gap:calc({}px * var(--ui-scale, 1));",
number(props.get("separation"))
);
}
if let Some(props) = container.get("VBox") {
return format!(
"display:flex;flex-direction:column;gap:calc({}px * var(--ui-scale, 1));",
number(props.get("separation"))
);
}
if let Some(props) = container.get("Grid") {
return format!("display:grid;grid-template-columns:repeat({},minmax(0,1fr));column-gap:calc({}px * var(--ui-scale, 1));row-gap:calc({}px * var(--ui-scale, 1));", props.get("columns").and_then(Value::as_u64).unwrap_or(1), number(props.get("h_separation")), number(props.get("v_separation")));
}
if let Some(props) = container.get("Margin") {
return format!(
"display:grid;padding:calc({}px * var(--ui-scale, 1)) calc({}px * var(--ui-scale, 1)) calc({}px * var(--ui-scale, 1)) calc({}px * var(--ui-scale, 1));",
number(props.get("margin_top")),
number(props.get("margin_right")),
number(props.get("margin_bottom")),
number(props.get("margin_left"))
);
}
"display:grid;place-items:center;".to_string()
}
fn css_length(percent: f64, offset: f64) -> Result<String, String> {
if !percent.is_finite() || !offset.is_finite() {
return Err("Transform 包含非有限数值".to_string());
}
if percent == 0.0 {
return Ok(format!("calc({offset}px * var(--ui-scale, 1))"));
}
if offset == 0.0 {
return Ok(format!("{percent}%"));
}
Ok(format!(
"calc({percent}% {} {}px * var(--ui-scale, 1))",
if offset < 0.0 { '-' } else { '+' },
offset.abs()
))
}
fn pair(value: &Value, field: &str) -> Result<[f64; 2], String> {
let values = value
.get(field)
.and_then(Value::as_array)
.ok_or_else(|| format!("Transform 缺少 {field}"))?;
if values.len() != 2 {
return Err(format!("Transform.{field} 不是二元数组"));
}
Ok([
values[0].as_f64().ok_or("Transform 数值无效")?,
values[1].as_f64().ok_or("Transform 数值无效")?,
])
}
fn logical_dimension(image: &Value, index: usize) -> Result<f64, String> {
let pixels = image
.get("pixel_size")
.and_then(Value::as_array)
.and_then(|v| v.get(index))
.and_then(Value::as_f64)
.ok_or("设计图 pixel_size 无效")?;
let ppu = image
.get("pixels_per_unit")
.and_then(Value::as_f64)
.ok_or("设计图 pixels_per_unit 无效")?;
if pixels <= 0.0 || ppu <= 0.0 {
return Err("设计图尺寸无效".to_string());
}
Ok(pixels / ppu)
}
fn logical_sprite_dimension(sprite: &Value, index: usize, multiplier: f64) -> Result<f64, String> {
let pixels = sprite
.get("pixel_size")
.and_then(Value::as_array)
.and_then(|v| v.get(index))
.and_then(Value::as_f64)
.ok_or("Sprite pixel_size 无效")?;
let ppu = sprite
.get("pixels_per_unit")
.and_then(Value::as_f64)
.ok_or("Sprite pixels_per_unit 无效")?;
if pixels <= 0.0 || ppu <= 0.0 || multiplier <= 0.0 {
return Err("Sprite 尺寸无效".to_string());
}
Ok(pixels / (ppu * multiplier))
}
fn number(value: Option<&Value>) -> String {
value
.and_then(Value::as_f64)
.map(|v| v.to_string())
.unwrap_or_else(|| "0".to_string())
}
fn comment_safe(value: &str) -> String {
value.replace("--", "- -")
}
fn html_escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('"', "&quot;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn fail(message: &str) -> ! {
eprintln!("{message}");
std::process::exit(1)
}
@@ -0,0 +1,61 @@
use maud::{Markup, PreEscaped};
pub(super) fn font_face_rule(
font: &crate::ui_editor::resource::font::FontAsset,
family: &str,
) -> Result<String, String> {
let path = asset_url(&font.path)?;
Ok(format!("@font-face{{font-family:'{family}';src:url('{path}') format('{}');font-style:{};font-weight:{};}}", font.metadata.format.extension(), if font.metadata.italic { "italic" } else { "normal" }, font.metadata.weight))
}
pub(super) fn html_comment(label: &str, value: serde_json::Value) -> Markup {
let text = serde_json::to_string(&value)
.unwrap_or_else(|_| "{}".to_string())
.replace("--", "- -");
PreEscaped(format!("<!-- {label}: {text} -->"))
}
pub(super) fn asset_url(path: &str) -> Result<String, String> {
let path = path.trim();
if path.is_empty()
|| path.starts_with('/')
|| path.split('/').any(|part| part == "..")
|| path.chars().any(|character| {
character.is_control() || matches!(character, '\'' | '"' | '`' | '(' | ')')
})
{
return Err(format!("资源路径无效:{path}"));
}
Ok(format!("/{path}"))
}
pub(super) fn finite(value: f32, field: &str) -> Result<f32, String> {
if value.is_finite() {
Ok(value)
} else {
Err(format!("{field} 必须是有限数值"))
}
}
pub(super) fn finite_positive(value: f32, field: &str) -> Result<f32, String> {
finite(value, field).and_then(|v| {
if v > 0.0 {
Ok(v)
} else {
Err(format!("{field} 必须大于 0"))
}
})
}
pub(super) fn trim_float(value: f32) -> String {
let text = format!("{value:.4}");
text.trim_end_matches('0').trim_end_matches('.').to_string()
}
pub(super) fn hex_id(value: &str) -> String {
value
.as_bytes()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
@@ -0,0 +1,57 @@
use super::assets::{asset_url, hex_id};
use super::image::image_styles;
use super::text::text_style;
use crate::ui_editor::component::text::FontSource;
use crate::ui_editor::component::Component;
use crate::ui_editor::state::State;
use maud::{html, Markup};
pub(super) fn render_component(state: &State, component: &Component) -> Result<Markup, String> {
match component {
Component::Text(text) => {
let mut style = text_style(
text.font_style,
text.alignment,
text.font_sizing,
text.color,
text.line_spacing.get(),
text.horizontal_overflow,
text.vertical_overflow,
);
if let FontSource::Bound(font_id) = &text.font {
state
.font_assets
.get(font_id)
.ok_or_else(|| format!("Text 缺少字体资源:{}", font_id.as_str()))?;
let family = format!("ui-editor-font-{}", hex_id(font_id.as_str()));
style.push_str(&format!("font-family:'{family}';"));
return Ok(html! {
div data-component-kind="Text" style=(style) {
(text.content)
}
});
}
Ok(html! { div data-component-kind="Text" style=(style) { (text.content) } })
}
Component::Image(image) => {
let sprite_id = image
.target_graphic
.as_ref()
.ok_or_else(|| "Image 缺少 target_graphic".to_string())?;
let sprite = state
.sprite_assets
.get(sprite_id)
.ok_or_else(|| format!("Image 缺少 Sprite 资源:{}", sprite_id.as_str()))?;
let src = asset_url(&sprite.path)?;
let (style, image_style) = image_styles(&image.image_type, sprite, &src)?;
Ok(match image_style {
Some(image_style) => html! {
div data-component-kind="Image" style=(style) {
img src=(src) alt="" aria-hidden="true" draggable="false" style=(image_style);
}
},
None => html! { div data-component-kind="Image" style=(style) {} },
})
}
}
}
@@ -0,0 +1,82 @@
use super::assets::trim_float;
use crate::ui_editor::layout::control_layout::{Container, ControlLayout};
const UI_SCALE: &str = "var(--ui-scale, 1)";
pub(super) fn container_style(container: &Container) -> String {
match container {
Container::None => String::new(),
Container::HBox { alignment, separation } => format!("display:flex;flex-direction:row;justify-content:{};gap:{};min-width:0;min-height:0;", alignment_css(*alignment), scaled_px(*separation).unwrap_or_else(|_| "0px".to_string())),
Container::VBox { alignment, separation } => format!("display:flex;flex-direction:column;justify-content:{};gap:{};min-width:0;min-height:0;", alignment_css(*alignment), scaled_px(*separation).unwrap_or_else(|_| "0px".to_string())),
Container::Grid { columns, h_separation, v_separation } => format!("display:grid;grid-template-columns:repeat({},minmax(0,1fr));column-gap:{};row-gap:{};min-width:0;min-height:0;", columns, scaled_px(*h_separation).unwrap_or_default(), scaled_px(*v_separation).unwrap_or_default()),
Container::Margin { margin_left, margin_top, margin_right, margin_bottom } => format!("display:grid;padding-left:{};padding-top:{};padding-right:{};padding-bottom:{};min-width:0;min-height:0;", scaled_px(*margin_left).unwrap_or_default(), scaled_px(*margin_top).unwrap_or_default(), scaled_px(*margin_right).unwrap_or_default(), scaled_px(*margin_bottom).unwrap_or_default()),
Container::Center { .. } => "display:grid;place-items:center;min-width:0;min-height:0;".to_string(),
}
}
pub(super) fn child_container_style(layout: &ControlLayout, parent: &Container) -> String {
let min_w = scaled_px(layout.custom_minimum_size.x).unwrap_or_default();
let min_h = scaled_px(layout.custom_minimum_size.y).unwrap_or_default();
match parent {
Container::HBox { .. } => format!(
"min-width:{min_w};min-height:{min_h};{}align-self:{};",
flex_grow(
layout.size_flags_horizontal,
layout.size_flags_stretch_ratio
),
cross_axis(layout.size_flags_vertical)
),
Container::VBox { .. } => format!(
"min-width:{min_w};min-height:{min_h};{}align-self:{};",
flex_grow(layout.size_flags_vertical, layout.size_flags_stretch_ratio),
cross_axis(layout.size_flags_horizontal)
),
Container::Margin { .. } => {
"grid-area:1 / 1;align-self:stretch;justify-self:stretch;".to_string()
}
Container::Center { use_top_left } => {
if *use_top_left {
"justify-self:center;align-self:center;transform:translate(50%,50%);".to_string()
} else {
"justify-self:center;align-self:center;".to_string()
}
}
Container::Grid { .. } | Container::None => {
format!("min-width:{min_w};min-height:{min_h};")
}
}
}
fn scaled_px(value: f32) -> Result<String, String> {
if !value.is_finite() {
return Err("layout px 必须是有限数值".to_string());
}
Ok(format!("calc({}px * {})", trim_float(value), UI_SCALE))
}
fn alignment_css(
value: crate::ui_editor::layout::control_layout::ContainerAlignment,
) -> &'static str {
match value {
crate::ui_editor::layout::control_layout::ContainerAlignment::Begin => "flex-start",
crate::ui_editor::layout::control_layout::ContainerAlignment::Center => "center",
crate::ui_editor::layout::control_layout::ContainerAlignment::End => "flex-end",
}
}
fn cross_axis(value: u8) -> &'static str {
if value & 1 != 0 {
"stretch"
} else if value == 4 {
"center"
} else if value == 8 {
"flex-end"
} else {
"flex-start"
}
}
fn flex_grow(flags: u8, ratio: f32) -> String {
if flags & 2 != 0 && ratio.is_finite() && ratio >= 0.0 {
format!("flex-grow:{};", trim_float(ratio))
} else {
String::new()
}
}
@@ -0,0 +1,156 @@
use super::assets::{finite_positive, trim_float};
use crate::ui_editor::component::image::{
FillMethod, HorizontalFillOrigin, ImageType, Radial180Origin, Radial360Origin, Radial90Origin,
VerticalFillOrigin,
};
const UI_SCALE: &str = "var(--ui-scale, 1)";
pub(super) fn image_styles(
image_type: &ImageType,
sprite: &crate::ui_editor::resource::sprite::SpriteAsset,
src: &str,
) -> Result<(String, Option<String>), String> {
match image_type {
ImageType::Simple { preserve_aspect } => Ok((
"position:absolute;inset:0;overflow:hidden;".to_string(),
Some(format!(
"width:100%;height:100%;display:block;object-fit:{};",
if *preserve_aspect { "contain" } else { "fill" }
)),
)),
ImageType::Filled {
preserve_aspect,
method,
amount,
} => {
let mut image = format!(
"width:100%;height:100%;display:block;object-fit:{};",
if *preserve_aspect { "contain" } else { "fill" }
);
if let Some(clip) = fill_clip_path(method, amount.clamp(0.0, 1.0)) {
if clip.starts_with("conic-gradient") {
image.push_str(&format!("mask-image:{clip};-webkit-mask-image:{clip};"));
} else {
image.push_str(&format!("clip-path:{clip};"));
}
}
Ok((
"position:absolute;inset:0;overflow:hidden;".to_string(),
Some(image),
))
}
ImageType::Tiled {
pixels_per_unit_multiplier,
..
} => {
let w = finite_positive(
sprite.pixel_size.x
/ (sprite.pixels_per_unit().get() * pixels_per_unit_multiplier.get()),
"tile width",
)?;
let h = finite_positive(
sprite.pixel_size.y
/ (sprite.pixels_per_unit().get() * pixels_per_unit_multiplier.get()),
"tile height",
)?;
Ok((format!("position:absolute;inset:0;background-image:url(\"{src}\");background-repeat:repeat;background-position:top left;background-size:calc({}px * {}) calc({}px * {});overflow:hidden;", trim_float(w), UI_SCALE, trim_float(h), UI_SCALE), None))
}
ImageType::Sliced {
fill_center,
pixels_per_unit_multiplier,
} => {
let border = sprite.border();
let scale = finite_positive(
sprite.pixels_per_unit().get() * pixels_per_unit_multiplier.get(),
"slice scale",
)?;
let widths = [
border.top() as f32 / scale,
border.right() as f32 / scale,
border.bottom() as f32 / scale,
border.left() as f32 / scale,
];
Ok((format!("position:absolute;inset:0;overflow:hidden;border-style:solid;border-width:calc({}px * {}) calc({}px * {}) calc({}px * {}) calc({}px * {});border-image-source:url(\"{src}\");border-image-slice:{} {} {} {}{};border-image-width:calc({}px * {}) calc({}px * {}) calc({}px * {}) calc({}px * {});border-image-repeat:stretch;", trim_float(widths[0]), UI_SCALE, trim_float(widths[1]), UI_SCALE, trim_float(widths[2]), UI_SCALE, trim_float(widths[3]), UI_SCALE, border.top(), border.right(), border.bottom(), border.left(), if *fill_center { " fill" } else { "" }, trim_float(widths[0]), UI_SCALE, trim_float(widths[1]), UI_SCALE, trim_float(widths[2]), UI_SCALE, trim_float(widths[3]), UI_SCALE), None))
}
}
}
fn fill_clip_path(method: &FillMethod, amount: f32) -> Option<String> {
if amount >= 1.0 {
return None;
}
if amount <= 0.0 {
return Some("inset(0 0 0 100%)".to_string());
}
match method {
FillMethod::Horizontal(HorizontalFillOrigin::Left) => Some(format!(
"inset(0 {}% 0 0)",
trim_float((1.0 - amount) * 100.0)
)),
FillMethod::Horizontal(HorizontalFillOrigin::Right) => Some(format!(
"inset(0 0 0 {}%)",
trim_float((1.0 - amount) * 100.0)
)),
FillMethod::Vertical(VerticalFillOrigin::Top) => Some(format!(
"inset(0 0 {}% 0)",
trim_float((1.0 - amount) * 100.0)
)),
FillMethod::Vertical(VerticalFillOrigin::Bottom) => Some(format!(
"inset({}% 0 0 0)",
trim_float((1.0 - amount) * 100.0)
)),
FillMethod::Radial90 { origin, clockwise } => Some(conic_mask(
radial_origin_angle_90(*origin),
*clockwise,
amount,
90.0,
)),
FillMethod::Radial180 { origin, clockwise } => Some(conic_mask(
radial_origin_angle_180(*origin),
*clockwise,
amount,
180.0,
)),
FillMethod::Radial360 { origin, clockwise } => Some(conic_mask(
radial_origin_angle_360(*origin),
*clockwise,
amount,
360.0,
)),
}
}
fn conic_mask(origin: f32, clockwise: bool, amount: f32, max_sweep: f32) -> String {
let sweep = amount * max_sweep;
let start = if clockwise { origin } else { origin - sweep };
format!(
"conic-gradient(from {}deg, #000 0deg {}deg, transparent {}deg 360deg)",
trim_float(start),
trim_float(sweep),
trim_float(sweep)
)
}
fn radial_origin_angle_90(origin: Radial90Origin) -> f32 {
match origin {
Radial90Origin::TopLeft => 0.0,
Radial90Origin::TopRight => 90.0,
Radial90Origin::BottomRight => 180.0,
Radial90Origin::BottomLeft => 270.0,
}
}
fn radial_origin_angle_180(origin: Radial180Origin) -> f32 {
match origin {
Radial180Origin::Top => 0.0,
Radial180Origin::Right => 90.0,
Radial180Origin::Bottom => 180.0,
Radial180Origin::Left => 270.0,
}
}
fn radial_origin_angle_360(origin: Radial360Origin) -> f32 {
match origin {
Radial360Origin::Top => 0.0,
Radial360Origin::Right => 90.0,
Radial360Origin::Bottom => 180.0,
Radial360Origin::Left => 270.0,
}
}
@@ -0,0 +1,142 @@
mod assets;
mod component;
mod container;
mod image;
mod node;
mod text;
use self::assets::{finite_positive, font_face_rule, hex_id, html_comment};
use self::component::render_component;
use self::container::{child_container_style, container_style};
use self::node::{is_container, transform_style};
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
use crate::ui_editor::layout::control_layout::Container;
use crate::ui_editor::layout::node::Node;
use crate::ui_editor::state::{State, UITree};
use maud::{html, Markup, PreEscaped};
use serde_json::json;
pub(crate) fn render_ui_design_state_html(state: &State) -> Result<String, String> {
let mut trees = Vec::with_capacity(state.ui_trees.len());
for (index, tree) in state.ui_trees.iter().enumerate() {
if index > 0 {
trees.push("\n<!-- -------------------- -->\n".to_string());
}
trees.push(render_tree(state, tree)?.into_string());
}
let font_faces = state
.font_assets
.values()
.map(|font| {
let family = format!("ui-editor-font-{}", hex_id(font.asset_id.as_str()));
font_face_rule(font, &family)
})
.collect::<Result<Vec<_>, _>>()?
.join("");
let fragment_comment = html_comment(
"genarrative-ui-fragment",
json!({
"format": "html-fragment",
"treeCount": state.ui_trees.len(),
}),
);
let fonts = (!font_faces.is_empty()).then(|| {
html! {
style data-ui-fonts { (PreEscaped(font_faces)) }
}
.into_string()
});
let mut fragment = String::new();
fragment.push_str(&fragment_comment.into_string());
if let Some(fonts) = fonts {
fragment.push_str(&fonts);
}
fragment.push_str(&trees.concat());
Ok(fragment)
}
fn render_tree(state: &State, tree: &UITree) -> Result<Markup, String> {
let image = state
.ui_design_images
.get(&tree.src_ui_design)
.ok_or_else(|| format!("UITree 缺少 src_ui_design{}", tree.src_ui_design.as_str()))?;
let width = finite_positive(image.pixel_size.x, "UI design width")?
/ finite_positive(image.pixels_per_unit.get(), "UI design pixelsPerUnit")?;
let height = finite_positive(image.pixel_size.y, "UI design height")?
/ finite_positive(image.pixels_per_unit.get(), "UI design pixelsPerUnit")?;
let tree_comment = html_comment(
"genarrative-ui-tree",
json!({
"srcUiDesign": tree.src_ui_design.as_str(),
"width": width,
"height": height,
}),
);
let style = format!(
"position:relative;width:100%;height:auto;min-height:0;aspect-ratio:{width} / {height};--ui-design-width:{width}px;--ui-design-height:{height}px;"
);
Ok(html! {
(tree_comment)
div data-ui-tree=(tree.src_ui_design.as_str()) style=(style) {
(render_node(state, &tree.root, None)?)
}
})
}
fn render_node(
state: &State,
node: &Node,
parent_container: Option<&Container>,
) -> Result<Markup, String> {
let mut style = transform_style(&node.layout, parent_container.is_some())?;
style.push_str("border:0;outline:0;background:transparent;overflow:visible;");
style.push_str(&container_style(&node.layout.container));
if let Some(parent) = parent_container {
style.push_str(&child_container_style(&node.layout, parent));
}
let comment = html_comment(
"genarrative-ui-node",
json!({
"nodeId": node.id.as_str(),
"name": node.metadata.name,
"description": node.metadata.description,
}),
);
let exclusive_comment = matches!(node.children_display_mode, ChildrenDisplayMode::Exclusive)
.then(|| {
html_comment(
"genarrative-ui-node-group",
json!({"childrenDisplayMode": "Exclusive", "childrenRendered": "all"}),
)
});
let components = node
.components
.iter()
.map(|component| render_component(state, component))
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.map(|fragment| fragment.into_string())
.collect::<Vec<_>>();
let children = node
.children
.iter()
.map(|child| {
render_node(
state,
child,
is_container(&node.layout.container).then_some(&node.layout.container),
)
})
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.map(|fragment| fragment.into_string())
.collect::<Vec<_>>();
Ok(html! {
(comment)
@if let Some(group_comment) = exclusive_comment { (group_comment) }
div data-node-id=(node.id.as_str()) style=(style) {
(PreEscaped(components.concat()))
(PreEscaped(children.concat()))
}
})
}
@@ -0,0 +1,80 @@
use super::assets::{finite, trim_float};
use crate::ui_editor::layout::control_layout::ControlLayout;
const UI_SCALE: &str = "var(--ui-scale, 1)";
pub(super) fn transform_style(
layout: &ControlLayout,
in_container: bool,
) -> Result<String, String> {
let transform = &layout.transform;
for value in transform
.anchor_min
.iter()
.chain(transform.anchor_max.iter())
.chain(transform.offset_min.iter())
.chain(transform.offset_max.iter())
{
if !value.is_finite() {
return Err("Transform 包含非有限数值".to_string());
}
}
if transform.anchor_min.x > transform.anchor_max.x
|| transform.anchor_min.y > transform.anchor_max.y
{
return Err("Transform anchors must be ordered".to_string());
}
if in_container {
return Ok(format!(
"position:relative;min-width:{};min-height:{};",
scaled_px(layout.custom_minimum_size.x)?,
scaled_px(layout.custom_minimum_size.y)?
));
}
Ok(format!(
"position:absolute;left:{};top:{};right:{};bottom:{};",
css_length(transform.anchor_min.x * 100.0, transform.offset_min.x)?,
css_length(transform.anchor_min.y * 100.0, transform.offset_min.y)?,
css_length(
(1.0 - transform.anchor_max.x) * 100.0,
-transform.offset_max.x
)?,
css_length(
(1.0 - transform.anchor_max.y) * 100.0,
-transform.offset_max.y
)?
))
}
fn css_length(percent: f32, offset: f32) -> Result<String, String> {
if !percent.is_finite() || !offset.is_finite() {
return Err("Transform 无法转换为 CSS".to_string());
}
if percent == 0.0 {
return Ok(format!("calc({}px * {})", trim_float(offset), UI_SCALE));
}
if offset == 0.0 {
return Ok(format!("{}%", trim_float(percent)));
}
Ok(format!(
"calc({}% {} {}px * {})",
trim_float(percent),
if offset < 0.0 { '-' } else { '+' },
trim_float(offset.abs()),
UI_SCALE
))
}
fn scaled_px(value: f32) -> Result<String, String> {
finite(value, "layout px")?;
Ok(format!("calc({}px * {})", trim_float(value), UI_SCALE))
}
pub(super) fn is_container(
container: &crate::ui_editor::layout::control_layout::Container,
) -> bool {
!matches!(
container,
crate::ui_editor::layout::control_layout::Container::None
)
}
@@ -0,0 +1,77 @@
use crate::ui_editor::component::text::{
FontSizing, FontStyle, HorizontalTextOverflow, TextAlignment, VerticalTextOverflow,
};
const UI_SCALE: &str = "var(--ui-scale, 1)";
pub(super) fn text_style(
font_style: FontStyle,
alignment: TextAlignment,
sizing: FontSizing,
color: [u8; 4],
line_spacing: f32,
horizontal_overflow: HorizontalTextOverflow,
vertical_overflow: VerticalTextOverflow,
) -> String {
let [r, g, b, a] = color;
let (justify, align, text_align) = match alignment {
TextAlignment::UpperLeft | TextAlignment::MiddleLeft | TextAlignment::LowerLeft => (
"flex-start",
if matches!(alignment, TextAlignment::MiddleLeft) {
"center"
} else if matches!(alignment, TextAlignment::LowerLeft) {
"flex-end"
} else {
"flex-start"
},
"left",
),
TextAlignment::UpperCenter | TextAlignment::MiddleCenter | TextAlignment::LowerCenter => (
"center",
if matches!(alignment, TextAlignment::MiddleCenter) {
"center"
} else if matches!(alignment, TextAlignment::LowerCenter) {
"flex-end"
} else {
"flex-start"
},
"center",
),
_ => (
"flex-end",
if matches!(alignment, TextAlignment::MiddleRight) {
"center"
} else if matches!(alignment, TextAlignment::LowerRight) {
"flex-end"
} else {
"flex-start"
},
"right",
),
};
let (weight, italic) = match font_style {
FontStyle::Normal => (400, "normal"),
FontStyle::Bold => (700, "normal"),
FontStyle::Italic => (400, "italic"),
FontStyle::BoldItalic => (700, "italic"),
};
let size = match sizing {
FontSizing::Fixed(value) => value.get(),
FontSizing::BestFit(range) => range.min().get(),
};
let (white_space, overflow, wrap) = match (horizontal_overflow, vertical_overflow) {
(HorizontalTextOverflow::Wrap, VerticalTextOverflow::Truncate) => {
("normal", "hidden", "anywhere")
}
(HorizontalTextOverflow::Wrap, VerticalTextOverflow::Overflow) => {
("normal", "visible", "anywhere")
}
(HorizontalTextOverflow::Overflow, VerticalTextOverflow::Truncate) => {
("nowrap", "hidden", "normal")
}
(HorizontalTextOverflow::Overflow, VerticalTextOverflow::Overflow) => {
("nowrap", "visible", "normal")
}
};
format!("position:absolute;inset:0;display:flex;width:100%;height:100%;box-sizing:border-box;padding:0;color:rgba({r},{g},{b},{});font-family:system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;font-size:calc({size}px * {UI_SCALE});font-weight:{weight};font-style:{italic};align-items:{align};justify-content:{justify};text-align:{text_align};line-height:{};white-space:{white_space};overflow:{overflow};overflow-wrap:{wrap};", f32::from(a) / 255.0, line_spacing)
}
@@ -1,5 +1,6 @@
pub mod commands;
pub mod component;
pub(crate) mod html_renderer;
pub mod layout;
pub mod persistence;
pub mod resource;