///|
/// 页面模板库:Agent 说"做一个登录页",引擎直接生成完整画板。
///
/// 每个模板是一个 (Project) → String 的函数:
/// 输入当前项目(共享令牌/组件),输出新画板 ID。
/// 模板内部使用设计令牌保证视觉一致性,组件间距遵循 Material 3 规范。
///
/// 这是"原型设计引擎"区别于"组件放置器"的关键:
/// 模板理解页面语义——登录页有什么、仪表盘有什么、设置页有什么。
pub(all) struct TemplateInfo {
id : String
name : String
description : String
category : String // "auth" | "dashboard" | "content" | "navigation"
tags : Array[String]
device : String // "mobile" | "web" | "pc"
}
///|
/// 可用模板清单(Agent 发现用)
pub fn list_templates() -> Array[TemplateInfo] {
[
{
id: "login",
name: "登录页",
description: "Logo + 标题 + 邮箱 + 密码 + 登录按钮 + 注册链接",
category: "auth",
tags: ["form", "auth", "centered"],
device: "mobile",
},
{
id: "signup",
name: "注册页",
description: "标题 + 姓名 + 邮箱 + 密码 + 确认密码 + 注册按钮",
category: "auth",
tags: ["form", "auth"],
device: "mobile",
},
{
id: "dashboard",
name: "仪表盘",
description: "App Bar + 统计卡片×4 + 图表区域 + 最近活动列表",
category: "dashboard",
tags: ["data", "cards", "grid"],
device: "mobile",
},
{
id: "profile",
name: "个人主页",
description: "头像 + 姓名 + 简介 + 统计 + 操作按钮 + 内容列表",
category: "content",
tags: ["user", "avatar", "social"],
device: "mobile",
},
{
id: "settings",
name: "设置页",
description: "App Bar + 分组设置项(通知/隐私/关于)+ 退出按钮",
category: "navigation",
tags: ["list", "toggle", "grouped"],
device: "mobile",
},
{
id: "list",
name: "列表页",
description: "App Bar + 搜索栏 + 分类标签 + 条目列表(行底分割线)+ 底部导航",
category: "content",
tags: ["list", "search", "feed"],
device: "mobile",
},
{
id: "detail",
name: "详情页",
description: "返回栏 + 大图区 + 标题 + 规格参数 + 描述 + 底部主按钮",
category: "content",
tags: ["detail", "product", "hero"],
device: "mobile",
},
{
id: "checkout",
name: "确认订单",
description: "返回栏 + 订单摘要 + 表单字段 + 费用明细(行底分割线)+ 提交按钮",
category: "commerce",
tags: ["checkout", "order", "form"],
device: "mobile",
},
{
id: "list_detail",
name: "聊天列表",
description: "会话列表(头像 + 消息预览)+ 底部详情占位卡(IM 语义专用)",
category: "content",
tags: ["chat", "im", "master-detail"],
device: "mobile",
},
{
id: "onboarding",
name: "引导页",
description: "三页滑动引导:插图 + 标题 + 描述 + 跳过/下一步",
category: "navigation",
tags: ["wizard", "carousel"],
device: "mobile",
},
{
id: "empty_state",
name: "空状态页",
description: "插图占位 + 主文案 + 副文案 + 操作按钮",
category: "content",
tags: ["placeholder", "cta"],
device: "mobile",
},
{
id: "web_landing",
name: "Web 落地页",
description: "顶部导航 + Hero 双栏 + 三特性卡 + Footer(1280×800)",
category: "navigation",
tags: ["web", "hero", "marketing"],
device: "web",
},
{
id: "web_login",
name: "Web 登录",
description: "左品牌半屏 + 右居中表单卡(1280×800)",
category: "auth",
tags: ["web", "split", "brand"],
device: "web",
},
{
id: "web_dashboard",
name: "Web 仪表盘",
description: "侧边栏 + 顶栏 + 统计卡×4 + 柱状图 + 活动面板 + 数据表(1280×800)",
category: "dashboard",
tags: ["web", "sidebar", "charts"],
device: "web",
},
{
id: "pc_app",
name: "PC 桌面应用",
description: "标题栏 + 侧边栏 + 工具栏 + 图层/画布/属性三面板 + 状态栏(1280×800)",
category: "navigation",
tags: ["pc", "studio", "panels"],
device: "pc",
},
{
id: "adaptive_landing",
name: "自适应落地页",
description: "结构式模板:同一结构自动切换 手机/平板/桌面 布局(可指定宽度)",
category: "navigation",
tags: ["adaptive", "structural", "responsive"],
device: "adaptive",
},
{
id: "login_v2",
name: "登录页 v2",
description: "结构式登录:满宽卡(窄)/居中窄卡(宽),阴影+现代排版",
category: "auth",
tags: ["adaptive", "structural"],
device: "adaptive",
},
]
}
///|
/// 模板清单 JSON
pub fn list_templates_json() -> String {
let parts : Array[String] = []
for t in list_templates().iter() {
let tags : Array[String] = []
for tag in t.tags.iter() {
tags.push("\"\{tag}\"")
}
let tj = jstr9(tags)
parts.push(
"{\"id\":\"\{t.id}\",\"name\":\"\{t.name}\",\"description\":\"\{t.description}\",\"category\":\"\{t.category}\",\"device\":\"\{t.device}\",\"tags\":[\{tj}]}",
)
}
let j = jstr9(parts)
"[\{j}]"
}
///|
/// 实例化模板 → 完整画板。
/// width/height > 0 时覆盖模板默认尺寸(结构式模板按宽度选择结构分支)
pub fn Project::apply_template(
self : Project,
template_id~ : String,
name? : String = "",
width? : Double = 0.0,
height? : Double = 0.0,
) -> Result[String, String] {
let ab_name = if name == "" { template_id } else { name }
let result = match template_id {
"login" => Ok(self.template_login(ab_name))
"signup" => Ok(self.template_signup(ab_name))
"dashboard" => Ok(self.template_dashboard(ab_name))
"profile" => Ok(self.template_profile(ab_name))
"settings" => Ok(self.template_settings(ab_name))
"list" => Ok(self.template_list(ab_name))
"detail" => Ok(self.template_detail(ab_name))
"checkout" => Ok(self.template_checkout(ab_name))
"list_detail" => Ok(self.template_list_detail(ab_name))
"onboarding" => Ok(self.template_onboarding(ab_name))
"empty_state" => Ok(self.template_empty_state(ab_name))
"web_landing" => Ok(self.template_web_landing(ab_name))
"web_login" => Ok(self.template_web_login(ab_name))
"web_dashboard" => Ok(self.template_web_dashboard(ab_name))
"pc_app" => Ok(self.template_pc_app(ab_name))
"adaptive_landing" => {
let w = if width > 0.0 { width } else { 1280.0 }
Ok(self.template_adaptive_landing(ab_name, w))
}
"login_v2" => {
let w = if width > 0.0 { width } else { 390.0 }
Ok(
self.build_artboard(
name=ab_name,
width=w,
height=if height > 0.0 { height } else { 844.0 },
tree=login_tree(w),
),
)
}
_ => Err("unknown_template: \{template_id}")
}
match result {
Err(e) => Err(e)
Ok(ab) => {
// 模板规范化:被兄弟完全包含的节点收编为子节点(坐标转相对)。
// 宽屏模板以平铺表达视觉嵌套(交通灯在标题栏上、文件项在侧栏内)。
// 收编保证树结构与查询面正确;过门由几何修正+谓词豁免共同承担
// (契约测试「14 模板 × AgentGate 零违规」锚定)。
self.normalize_nesting(ab)
Ok(ab)
}
}
}
// ---------------------------------------------------------------------------
// 登录页模板
// ---------------------------------------------------------------------------
///|
fn Project::template_login(self : Project, name : String) -> String {
let ab = self.create_artboard(name~, width=390.0, height=844.0)
let t = self.tokens
// Logo 区域(圆形占位)
let _ = self.place_comp(
ab,
"badge",
"logo",
variant="primary",
x=163.0,
y=120.0,
)
self.resize_node(ab, "logo", 64.0, 64.0, 999.0) // 圆形
// 标题
let _ = self.place_comp(
ab,
"heading",
"welcome_title",
variant="h2",
x=95.0,
y=220.0,
)
self.set_text(ab, "welcome_title", "Welcome Back")
self.resize_node(ab, "welcome_title", 200.0, 36.0, 0.0)
// 副标题
let _ = self.place_comp(
ab,
"body_text",
"subtitle",
variant="caption",
x=95.0,
y=258.0,
)
self.set_text(ab, "subtitle", "Sign in to continue")
self.resize_node(ab, "subtitle", 200.0, 18.0, 0.0)
// 邮箱输入框
let _ = self.place_comp(
ab,
"text_input",
"email_input",
variant="filled",
x=24.0,
y=340.0,
)
self.set_text(ab, "email_input", "Email address")
self.resize_node(ab, "email_input", 342.0, 52.0, 12.0)
// 密码输入框
let _ = self.place_comp(
ab,
"text_input",
"password_input",
variant="filled",
x=24.0,
y=404.0,
)
self.set_text(ab, "password_input", "Password")
self.resize_node(ab, "password_input", 342.0, 52.0, 12.0)
// 忘记密码链接
let _ = self.place_comp(
ab,
"body_text",
"forgot_link",
variant="caption",
x=230.0,
y=468.0,
)
self.set_text(ab, "forgot_link", "Forgot password?")
self.resize_node(ab, "forgot_link", 140.0, 18.0, 0.0)
// 登录按钮
let _ = self.place_comp(
ab,
"button",
"login_btn",
variant="primary",
x=24.0,
y=520.0,
)
self.set_text(ab, "login_btn", "Sign In")
self.resize_node(ab, "login_btn", 342.0, 52.0, 12.0)
// 分隔线
let _ = self.place_comp(ab, "divider", "divider1", x=24.0, y=600.0)
self.resize_node(ab, "divider1", 342.0, 1.0, 0.0)
// 注册按钮
let _ = self.place_comp(
ab,
"button",
"signup_btn",
variant="secondary",
x=24.0,
y=630.0,
)
self.set_text(ab, "signup_btn", "Create Account")
self.resize_node(ab, "signup_btn", 342.0, 52.0, 12.0)
// 导航流:只在目标画板已经存在时写入,避免单页模板生成悬空引用。
if self.artboards.contains("signup") {
self.add_flow(from=ab, to="signup", trigger="tap:signup_btn")
}
if self.artboards.contains("forgot_password") {
self.add_flow(from=ab, to="forgot_password", trigger="tap:forgot_link")
}
let _ = t
ab
}
// ---------------------------------------------------------------------------
// 注册页模板
// ---------------------------------------------------------------------------
///|
fn Project::template_signup(self : Project, name : String) -> String {
let ab = self.create_artboard(name~, width=390.0, height=844.0)
let _ = self.place_comp(ab, "heading", "title", variant="h2", x=24.0, y=60.0)
self.set_text(ab, "title", "Create Account")
self.resize_node(ab, "title", 342.0, 36.0, 0.0)
let fields = ["Full Name", "Email", "Password", "Confirm Password"]
let mut y = 140.0
for i in 0.. String {
let ab = self.create_artboard(name~, width=390.0, height=844.0)
// App Bar
let _ = self.place_comp(
ab,
"app_bar",
"top_bar",
variant="primary",
x=0.0,
y=0.0,
)
self.resize_node(ab, "top_bar", 390.0, 56.0, 0.0)
self.set_text(ab, "top_bar", "Dashboard")
// 统计卡片 2×2 网格
let stats = [
("Revenue", "$12.4K"),
("Users", "8,421"),
("Orders", "1,203"),
("Growth", "+18%"),
]
let mut i = 0
for row in 0..<2 {
for col in 0..<2 {
let cid = "stat_\{i}"
let x = 24.0 + col.to_double() * 179.0
let y = 80.0 + row.to_double() * 110.0
let _ = self.place_comp(ab, "card", cid, variant="elevated", x~, y~)
self.resize_node(ab, cid, 163.0, 96.0, 12.0)
self.set_text(ab, cid, "\{stats[i].0}: \{stats[i].1}")
i = i + 1
}
}
// 图表区域
let _ = self.place_comp(
ab,
"card",
"chart_area",
variant="elevated",
x=24.0,
y=310.0,
)
self.resize_node(ab, "chart_area", 342.0, 200.0, 12.0)
self.set_text(ab, "chart_area", "Weekly Revenue")
// 最近活动列表
let _ = self.place_comp(
ab,
"heading",
"activity_title",
variant="h4",
x=24.0,
y=540.0,
)
self.set_text(ab, "activity_title", "Recent Activity")
self.resize_node(ab, "activity_title", 200.0, 24.0, 0.0)
let activities = [
"New order #1042", "User signup: alice@", "Payment received", "Refund processed",
]
for j in 0..<4 {
let aid = "activity_\{j}"
let _ = self.place_comp(
ab,
"card",
aid,
variant="elevated",
x=24.0,
y=576.0 + j.to_double() * 62.0,
)
self.resize_node(ab, aid, 342.0, 52.0, 8.0)
self.set_text(ab, aid, activities[j])
}
// 底部导航
let _ = self.place_comp(ab, "divider", "bottom_div", x=0.0, y=820.0)
self.resize_node(ab, "bottom_div", 390.0, 1.0, 0.0)
ab
}
// ---------------------------------------------------------------------------
// 个人主页模板
// ---------------------------------------------------------------------------
///|
fn Project::template_profile(self : Project, name : String) -> String {
let ab = self.create_artboard(name~, width=390.0, height=844.0)
// App Bar
let _ = self.place_comp(
ab,
"app_bar",
"top_bar",
variant="surface",
x=0.0,
y=0.0,
)
self.resize_node(ab, "top_bar", 390.0, 56.0, 0.0)
self.set_text(ab, "top_bar", "Profile")
// 头像(圆形)
let _ = self.place_comp(
ab,
"badge",
"avatar",
variant="primary",
x=143.0,
y=90.0,
)
self.resize_node(ab, "avatar", 104.0, 104.0, 999.0)
self.set_text(ab, "avatar", "AB")
// 姓名 + 简介
let _ = self.place_comp(
ab,
"heading",
"user_name",
variant="h3",
x=95.0,
y=214.0,
)
self.set_text(ab, "user_name", "Alice Baker")
self.resize_node(ab, "user_name", 200.0, 28.0, 0.0)
let _ = self.place_comp(
ab,
"body_text",
"user_bio",
variant="caption",
x=95.0,
y=246.0,
)
self.set_text(ab, "user_bio", "Product Designer @ TechCorp")
self.resize_node(ab, "user_bio", 200.0, 18.0, 0.0)
// 统计行
let stats = [("Posts", "128"), ("Followers", "4.2K"), ("Following", "389")]
for i in 0..<3 {
let sid = "stat_\{i}"
let x = 44.0 + i.to_double() * 110.0
let _ = self.place_comp(ab, "card", sid, variant="elevated", x~, y=290.0)
self.resize_node(ab, sid, 90.0, 60.0, 8.0)
self.set_text(ab, sid, "\{stats[i].0}: \{stats[i].1}")
}
// 操作按钮
let _ = self.place_comp(
ab,
"button",
"follow_btn",
variant="primary",
x=24.0,
y=380.0,
)
self.set_text(ab, "follow_btn", "Follow")
self.resize_node(ab, "follow_btn", 163.0, 44.0, 10.0)
let _ = self.place_comp(
ab,
"button",
"message_btn",
variant="secondary",
x=203.0,
y=380.0,
)
self.set_text(ab, "message_btn", "Message")
self.resize_node(ab, "message_btn", 163.0, 44.0, 10.0)
// 内容列表
let _ = self.place_comp(
ab,
"heading",
"posts_title",
variant="h4",
x=24.0,
y=450.0,
)
self.set_text(ab, "posts_title", "Posts")
self.resize_node(ab, "posts_title", 100.0, 24.0, 0.0)
for j in 0..<3 {
let pid = "post_\{j}"
let _ = self.place_comp(
ab,
"card",
pid,
variant="elevated",
x=24.0,
y=486.0 + j.to_double() * 110.0,
)
self.resize_node(ab, pid, 342.0, 96.0, 12.0)
self.set_text(ab, pid, "Post \{j + 1}")
}
ab
}
// ---------------------------------------------------------------------------
// 设置页模板
// ---------------------------------------------------------------------------
///|
fn Project::template_settings(self : Project, name : String) -> String {
let ab = self.create_artboard(name~, width=390.0, height=844.0)
let _ = self.place_comp(
ab,
"app_bar",
"top_bar",
variant="surface",
x=0.0,
y=0.0,
)
self.resize_node(ab, "top_bar", 390.0, 56.0, 0.0)
self.set_text(ab, "top_bar", "Settings")
let sections = [
("Notifications", ["Push Notifications", "Email Alerts", "SMS Updates"]),
("Privacy", ["Location Access", "Camera Permission", "Data Sharing"]),
("Appearance", ["Dark Mode", "Font Size", "Language"]),
("About", ["Version", "Terms of Service", "Privacy Policy"]),
]
// 紧凑布局:4 组(标题 26 + 3 项 × 46 间距 + 组距 6 = 170/组)
// 从 72 起步共 680,退出按钮落在 758..798,严格包含在 844 画板内。
let mut y = 72.0
for si in 0.. String {
let ab = self.create_artboard(name~, width=390.0, height=844.0)
self.place_required(ab, "app_bar", "top_bar", x=0.0, y=0.0, w=390.0, h=56.0)
self.set_text(ab, "top_bar", "Discover")
// 搜索栏(锚在 app_bar 下方;autofill 的配方会认这个位置)
self.place_required(ab, "search_bar", "search_bar", x=16.0, y=68.0, w=358.0, h=40.0)
self.set_text(ab, "search_bar", "Search")
// 分类横向标签(chip 档:高度按 NORM 的 chip 28)
// chip 宽度必须**按规范预留**:norm 要求宽 ≥ 文案宽 + 24(左右各 12)。
// 中文按 1 字 ≈ 14pt、拉丁按 1 字 ≈ 8pt 保守估计,加 24 留白。
// 间距用 12:auto_fix 会把不达标的 chip 撑到合规宽度,如果排布时贴得太紧
// (曾用 72 宽 + 80 间距),撑宽后就会与右侧邻居重叠,整页被
// no_sibling_overlap 判债——生成完的页面再也不能接受任何新 flow。
let cats = ["All", "Outdoor", "Camping", "Gear"]
let mut cx = 16.0
for ci in 0.. String {
let ab = self.create_artboard(name~, width=390.0, height=844.0)
// 顶栏带返回钮(引擎内置返回会识别,无需 flow)
self.place_required(ab, "app_bar", "top_bar", x=0.0, y=0.0, w=390.0, h=56.0)
self.set_text(ab, "top_bar", "Back")
// 大图区(占位 rect,Agent 可换成 image / 改 fill)
self.place_required(ab, "rect", "hero", x=0.0, y=56.0)
self.resize_node(ab, "hero", 390.0, 260.0, 0.0)
// 标题 + 副标题
self.place_required(ab, "heading", "title", x=24.0, y=332.0)
self.resize_node(ab, "title", 342.0, 32.0, 0.0)
self.set_text(ab, "title", "Product Name")
self.place_required(ab, "body_text", "subtitle", x=24.0, y=370.0)
self.resize_node(ab, "subtitle", 342.0, 20.0, 0.0)
self.set_text(ab, "subtitle", "Short supporting line")
// 规格参数(三行 key-value,行底分割线)
let specs = [("Spec", "Value"), ("Size", "Standard"), ("Stock", "Available")]
let mut y = 402.0
for i in 0.. String {
let ab = self.create_artboard(name~, width=390.0, height=844.0)
self.place_required(ab, "app_bar", "top_bar", x=0.0, y=0.0, w=390.0, h=56.0)
self.set_text(ab, "top_bar", "Back")
// 订单摘要卡
self.place_required(ab, "card", "summary", x=16.0, y=72.0)
self.resize_node(ab, "summary", 358.0, 96.0, 12.0)
self.set_text(ab, "summary", "Order summary — 2 items")
// 表单字段(确认订单的真实形态:联系人/时间/地点)
let fields = [
("field_contact", "Contact"),
("field_time", "Pickup time"),
("field_place", "Pickup location"),
]
let mut y = 184.0
for f in fields {
let (fid, label) = f
self.place_required(ab, "text_input", fid, x=16.0, y~)
self.resize_node(ab, fid, 358.0, 52.0, 12.0)
self.set_text(ab, fid, label)
y = y + 60.0
}
// 费用明细(三行 + 行间分割线:齐缝列表的 norms 纪律)
let rows = [("Subtotal", "¥ 320"), ("Deposit", "¥ 200"), ("Total", "¥ 520")]
y = y + 8.0
for i in 0.. String {
let ab = self.create_artboard(name~, width=390.0, height=844.0)
let _ = self.place_comp(
ab,
"app_bar",
"top_bar",
variant="surface",
x=0.0,
y=0.0,
)
self.resize_node(ab, "top_bar", 390.0, 56.0, 0.0)
self.set_text(ab, "top_bar", "Chats")
// 会话列表(头像 + 标题 + 预览)
let chats = [
("Alice", "See you tomorrow!"),
("Bob", "The prototype looks great"),
("Carol", "Design sync at 3pm"),
("Dave", "Sent the files over"),
("Eve", "Let me check and reply"),
]
let mut y = 68.0
for ci in 0.. String {
let ab = self.create_artboard(name~, width=390.0, height=844.0)
let _ = self.place_comp(
ab,
"heading",
"ob_title",
variant="h2",
x=24.0,
y=180.0,
)
self.set_text(ab, "ob_title", "Welcome aboard")
self.resize_node(ab, "ob_title", 342.0, 40.0, 0.0)
let _ = self.place_comp(
ab,
"body_text",
"ob_sub",
variant="body",
x=24.0,
y=236.0,
)
self.set_text(
ab, "ob_sub", "Design together with your agent — one plain-text source of truth for humans and AI.",
)
self.resize_node(ab, "ob_sub", 342.0, 72.0, 0.0)
let _ = self.place_comp(
ab,
"button",
"ob_next",
variant="primary",
x=24.0,
y=720.0,
)
self.set_text(ab, "ob_next", "Get Started")
self.resize_node(ab, "ob_next", 342.0, 48.0, 12.0)
let _ = self.place_comp(
ab,
"button",
"ob_skip",
variant="secondary",
x=24.0,
y=780.0,
)
self.set_text(ab, "ob_skip", "Skip")
self.resize_node(ab, "ob_skip", 342.0, 40.0, 8.0)
ab
}
// ---------------------------------------------------------------------------
// 空状态页模板
// ---------------------------------------------------------------------------
///|
fn Project::template_empty_state(self : Project, name : String) -> String {
let ab = self.create_artboard(name~, width=390.0, height=844.0)
// 插图占位(大圆角矩形)
let _ = self.place_comp(
ab,
"card",
"illustration",
variant="elevated",
x=95.0,
y=250.0,
)
self.resize_node(ab, "illustration", 200.0, 200.0, 100.0)
self.set_text(ab, "illustration", "")
// 主文案
let _ = self.place_comp(
ab,
"heading",
"main_text",
variant="h3",
x=95.0,
y=480.0,
)
self.set_text(ab, "main_text", "No Items Yet")
self.resize_node(ab, "main_text", 200.0, 28.0, 0.0)
// 副文案
let _ = self.place_comp(
ab,
"body_text",
"sub_text",
variant="body",
x=75.0,
y=516.0,
)
self.set_text(ab, "sub_text", "Items you add will appear here")
self.resize_node(ab, "sub_text", 240.0, 20.0, 0.0)
// 操作按钮
let _ = self.place_comp(
ab,
"button",
"cta_btn",
variant="primary",
x=95.0,
y=570.0,
)
self.set_text(ab, "cta_btn", "Add Item")
self.resize_node(ab, "cta_btn", 200.0, 48.0, 12.0)
ab
}
// ---------------------------------------------------------------------------
// 辅助方法
// ---------------------------------------------------------------------------
///|
fn Project::place_comp(
self : Project,
ab : String,
comp : String,
id : String,
variant? : String = "",
x? : Double = 0.0,
y? : Double = 0.0,
) -> Result[String, String] {
self.place_component(
artboard=ab,
component_id=comp,
instance_id=id,
variant~,
x~,
y~,
)
}
///|
/// place_comp 的**强制版本**:放置失败即 panic。
///
/// 为什么需要它:模板里到处写 `let _ = self.place_comp(...)`,一旦组件名或
/// variant 写错(2026-10-26 真实事故:新模板用了不存在的 variant="surface",
/// 5 个节点全被拒绝却静默通过),产出画板只剩少量节点却没有任何报错,
/// 直到用户看到"空页面"才发现。模板是确定性构造,失败必然是实现错误——
/// 让它在这里炸掉,而不是把半成品交出去。
fn Project::place_required(
self : Project,
ab : String,
comp : String,
id : String,
variant? : String = "",
x? : Double = 0.0,
y? : Double = 0.0,
w? : Double = 0.0,
h? : Double = 0.0,
) -> Unit {
// w/h 在**放置时**传入(不是放完再 resize):门评估的是 place 那一刻的 bbox,
// 组件默认尺寸往往大于目标槽位,先放后缩会让默认尺寸与邻居重叠而被拒
// (2026-10-26 真实事故:新模板全部 place 失败,节点静默丢失)。
match
self.place_component(
artboard=ab,
component_id=comp,
instance_id=id,
variant~,
x~,
y~,
w~,
h~,
) {
Ok(_) => ()
Err(e) => abort("template_place_failed: \{comp} in \{ab}: \{e}")
}
}
///|
fn Project::resize_node(
self : Project,
ab : String,
id : String,
w : Double,
h : Double,
radius : Double,
) -> Unit {
match self.artboards.get(ab) {
Some(art) => {
let _ = art.doc.update_node(id, fn(n) {
{
..n,
width: Fixed(w),
height: Fixed(h),
style: { ..n.style, corner_radius: radius, },
}
})
}
None => ()
}
}
///|
fn Project::set_text(
self : Project,
ab : String,
id : String,
text : String,
) -> Unit {
match self.artboards.get(ab) {
Some(art) => {
let _ = art.doc.update_node(id, fn(n) { { ..n, text, } })
}
None => ()
}
}
///|
fn jstr9(parts : Array[String]) -> String {
let sb = StringBuilder::new()
for i in 0.. 0 {
sb.write_string(",")
}
sb.write_string(parts[i])
}
sb.to_string()
}
///|
/// 模板结构规范化:把「被兄弟完全包含」的节点收编为该兄弟的子节点。
///
/// 设计意图:宽屏模板用平铺节点表达视觉嵌套(标题栏上的交通灯、侧栏里的
/// 文件项、卡片上的文字)。收编让树结构表达真实包含(#13 parent 读面、
/// ContainedInParent 检查都依赖它),坐标转为父相对、视觉不变。
/// 注:NoSiblingOverlap 自 5a5641a 起豁免「兄弟全包含」对,纯为过门而
/// 收编的必要性下降——但收编仍承载树语义与查询面,不可移除;
/// 模板债回归锚见 wasm/contract_test「14 模板 × AgentGate」。
///
/// 选择「最小的完全包含者」作为新父(最紧拟和);仅收编 rect 严格被包含
/// 且包含者非纯装饰尺寸过小者(宽高 ≥ 包含节点)的节点。单趟扫描即可,
/// 模板层级至多两层嵌套。
pub fn Project::normalize_nesting(self : Project, artboard_id : String) -> Unit {
let ab = match self.artboards.get(artboard_id) {
Some(a) => a
None => return
}
let doc = ab.doc
let layout = solve(doc)
// 收集 (child, new_parent) 决策;先全部决定再执行,避免中途改树影响判定
let moves : Array[String] = [] // child_id
let new_parents : Array[String] = [] // 与 moves 平行
for pid in doc.nodes.keys() {
let kids = match doc.nodes.get(pid) {
Some(p) => p.children.copy()
None => continue
}
if kids.length() < 2 {
continue
}
// 每个孩子找「最小的完全包含它的兄弟」
for k in kids.iter() {
let rk = match layout.rect_of(k) {
Some(r) => r
None => continue
}
let mut best : String? = None
let mut best_area = 1.0e18
for other in kids.iter() {
if other == k {
continue
}
let ro = match layout.rect_of(other) {
Some(r) => r
None => continue
}
// 严格包含:other 的矩形完全覆盖 k
if ro.x <= rk.x &&
ro.y <= rk.y &&
ro.x + ro.width >= rk.x + rk.width &&
ro.y + ro.height >= rk.y + rk.height {
let area = ro.width * ro.height
if area < best_area && area > 0.0 {
best_area = area
best = Some(other)
}
}
}
match best {
Some(b) => {
moves.push(k)
new_parents.push(b)
}
None => ()
}
}
}
// 执行收编:坐标转为新父相对
for i = 0; i < moves.length(); i = i + 1 {
let child_id = moves[i]
let parent_id = new_parents[i]
match doc.nodes.get(child_id) {
None => continue
Some(_) => ()
}
let old_parent = match doc.find_parent(child_id) {
Some(p) => p
None => continue
}
if old_parent == parent_id {
continue
}
match doc.nodes.get(parent_id) {
None => continue
Some(_) => ()
}
// 跳过会成环的情况(parent 在 child 子树内)
if in_subtree(doc, child_id, parent_id) {
continue
}
let layout2 = solve(doc)
let rc = match layout2.rect_of(child_id) {
Some(r) => r
None => continue
}
let rp = match layout2.rect_of(parent_id) {
Some(r) => r
None => continue
}
// 从旧父摘除
let _ = doc.update_node(old_parent, fn(p) {
{ ..p, children: p.children.filter(c => c != child_id), }
})
// 相对坐标 + 挂到新父
let nx = rc.x - rp.x
let ny = rc.y - rp.y
let _ = doc.update_node(child_id, fn(c) {
{ ..c, x: Fixed(nx), y: Fixed(ny), }
})
let _ = doc.update_node(parent_id, fn(p) {
let cs = p.children.copy()
cs.push(child_id)
{ ..p, children: cs, }
})
}
if moves.length() > 0 {
self.revision = self.revision + 1
}
}
///|
/// id 是否在 root 的子树内(含 root 自身)
fn in_subtree(doc : Document, root : String, id : String) -> Bool {
if root == id {
return true
}
match doc.nodes.get(root) {
None => false
Some(n) => {
let mut found = false
for c in n.children.iter() {
if in_subtree(doc, c, id) {
found = true
break
}
}
found
}
}
}