From 7915d5803853ba2fdf78796ada389873ae28b665 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 31 Aug 2026 19:04:36 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=20UI=20State=20=E5=88=B0=20H?= =?UTF-8?q?TML=20=E6=B8=B2=E6=9F=93=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拆分 html_renderer 为资源、节点、容器、文本和图片模块 接入 ui.design.to_html Runtime 工具 保留临时 ui-design-to-html 测试 bin 生产渲染器输出带元数据注释的 HTML 片段 --- .../src-tauri/Cargo.lock | 35 ++ .../src-tauri/Cargo.toml | 1 + .../agent/runtime_actions/action_execution.rs | 1 + .../runtime_actions/autonomous_policy.rs | 1 + .../agent/runtime_actions/parallel_ledger.rs | 2 + .../runtime_actions/tool_policy_snapshot.rs | 2 + .../src-tauri/src/agent/runtime_tools.rs | 2 + .../src/agent/runtime_tools/policy.rs | 1 + .../src/agent/runtime_tools/ui_html.rs | 57 +++ .../src-tauri/src/agent_native_tools.rs | 11 + .../src-tauri/src/bin/ui-design-to-html.rs | 399 ++++++++++++++++++ .../src/ui_editor/html_renderer/assets.rs | 61 +++ .../src/ui_editor/html_renderer/component.rs | 57 +++ .../src/ui_editor/html_renderer/container.rs | 82 ++++ .../src/ui_editor/html_renderer/image.rs | 156 +++++++ .../src/ui_editor/html_renderer/mod.rs | 142 +++++++ .../src/ui_editor/html_renderer/node.rs | 80 ++++ .../src/ui_editor/html_renderer/text.rs | 77 ++++ .../src-tauri/src/ui_editor/mod.rs | 1 + 19 files changed, 1168 insertions(+) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/ui_html.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/bin/ui-design-to-html.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/container.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/image.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/node.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/text.rs diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index a6936c44b..5c500bae0 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -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" diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 318951ed9..5569adccb 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -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" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs index a2063143b..58e696980 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs @@ -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) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index eeb2f00ff..a6cb76010 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -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" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs index d047701d8..1de96d573 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs @@ -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"), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index 0a58e049f..eb24abcdc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -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" ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index 1478ae0aa..0148f6d0f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -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; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs index 8fc4f477f..bceb052b7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs @@ -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" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/ui_html.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/ui_html.rs new file mode 100644 index 000000000..e0ceceb2a --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/ui_html.rs @@ -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, + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 8ef1b13d1..4c1185a6f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -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!({ diff --git a/apps/ai-game-creator-shell/src-tauri/src/bin/ui-design-to-html.rs b/apps/ai-game-creator-shell/src-tauri/src/bin/ui-design-to-html.rs new file mode 100644 index 000000000..4bd2b2766 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/bin/ui-design-to-html.rs @@ -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::>(); + if args.len() != 2 { + eprintln!("用法:ui-design-to-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 { + 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("")) + 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 { + 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, + "", + comment_safe(&tree_metadata.to_string()) + ) + .unwrap(); + write!(&mut content, "
", html_escape(source), width, height, width, height).unwrap(); + content.push_str(&render_node(state, root, false)?); + content.push_str("
"); + Ok(content) +} + +fn render_node(state: &Value, node: &Value, in_container: bool) -> Result { + 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!( + "", + comment_safe(&node_metadata.to_string()) + ); + if node.get("children_display_mode").and_then(Value::as_str) == Some("Exclusive") { + output.push_str(""); + } + let style = node_style(layout, in_container)?; + let style = format!("{style}border:0;outline:0;background:transparent;overflow:visible;"); + write!( + &mut output, + "
", + 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("
"); + Ok(output) +} + +fn render_component(state: &Value, component: &Value) -> Result { + 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 { + 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 { + 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 { + 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 { + 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) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs new file mode 100644 index 000000000..2b2e3d90c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs @@ -0,0 +1,61 @@ +use maud::{Markup, PreEscaped}; + +pub(super) fn font_face_rule( + font: &crate::ui_editor::resource::font::FontAsset, + family: &str, +) -> Result { + 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!("")) +} + +pub(super) fn asset_url(path: &str) -> Result { + 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 { + if value.is_finite() { + Ok(value) + } else { + Err(format!("{field} 必须是有限数值")) + } +} + +pub(super) fn finite_positive(value: f32, field: &str) -> Result { + 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() +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component.rs new file mode 100644 index 000000000..68ea12110 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component.rs @@ -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 { + 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) {} }, + }) + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/container.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/container.rs new file mode 100644 index 000000000..1d6948fd5 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/container.rs @@ -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 { + 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() + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/image.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/image.rs new file mode 100644 index 000000000..c63b39237 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/image.rs @@ -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> { + 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 { + 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, + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs new file mode 100644 index 000000000..275d1371d --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs @@ -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 { + 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::, _>>()? + .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 { + 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 { + 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::, _>>()? + .into_iter() + .map(|fragment| fragment.into_string()) + .collect::>(); + let children = node + .children + .iter() + .map(|child| { + render_node( + state, + child, + is_container(&node.layout.container).then_some(&node.layout.container), + ) + }) + .collect::, _>>()? + .into_iter() + .map(|fragment| fragment.into_string()) + .collect::>(); + 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())) + } + }) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/node.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/node.rs new file mode 100644 index 000000000..4e609b5e9 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/node.rs @@ -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 { + 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 { + 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 { + 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 + ) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/text.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/text.rs new file mode 100644 index 000000000..5857900e4 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/text.rs @@ -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) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/mod.rs index d002c7611..51fb28ec3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/mod.rs @@ -1,5 +1,6 @@ pub mod commands; pub mod component; +pub(crate) mod html_renderer; pub mod layout; pub mod persistence; pub mod resource;