拆分 UI State 到 HTML 渲染器
拆分 html_renderer 为资源、节点、容器、文本和图片模块 接入 ui.design.to_html Runtime 工具 保留临时 ui-design-to-html 测试 bin 生产渲染器输出带元数据注释的 HTML 片段
This commit is contained in:
+35
@@ -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 HTML(revision {},{} 字节)",
|
||||
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('&', "&")
|
||||
.replace('"', """)
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user