///|
/// 响应式断点系统:同一设计在手机/平板/桌面三种尺寸下自动适配。
///
/// Agent 创建一个 390px 的手机原型后,调用 `generate_responsive`:
/// - 引擎自动生成 `_tablet`(768px)和 `_desktop`(1200px)变体
/// - 垂直布局 → 平板变 2 列 → 桌面变 3 列(或侧边栏)
/// - 组件尺寸按比例适配(卡片加宽、字号保持可读)
pub(all) struct Breakpoint {
name : String
width : Double
height : Double
columns : Int
description : String
}
///|
pub fn default_breakpoints() -> Array[Breakpoint] {
[
{
name: "mobile",
width: 390.0,
height: 844.0,
columns: 4,
description: "手机",
},
{
name: "tablet",
width: 768.0,
height: 1024.0,
columns: 8,
description: "平板",
},
{
name: "desktop",
width: 1200.0,
height: 800.0,
columns: 12,
description: "桌面",
},
]
}
///|
pub fn breakpoints_json() -> String {
let parts : Array[String] = []
for bp in default_breakpoints().iter() {
let w = bp.width.to_int().to_string()
let h = bp.height.to_int().to_string()
parts.push(
"{\"name\":\"\{bp.name}\",\"width\":\{w},\"height\":\{h},\"columns\":\{bp.columns}}",
)
}
let j = jstr17(parts)
"[\{j}]"
}
///|
/// 为画板生成响应式变体(_tablet / _desktop)
pub fn Project::generate_responsive(
self : Project,
artboard~ : String,
) -> Result[String, String] {
match self.artboards.get(artboard) {
None => Err("unknown_artboard")
Some(base_ab) => {
let created : Array[String] = []
let bps = default_breakpoints()
for bp in bps.iter() {
if bp.name == "mobile" {
continue
}
let vid = artboard + "_" + bp.name
let _ = self.create_artboard(name=vid, width=bp.width, height=bp.height)
match self.artboards.get(vid) {
Some(vab) => {
// 按原树递归重建层级:顶层挂 root,子节点挂回各自父节点。
// 旧实现把所有节点平铺挂到 root 下却保留原 children 表,
// TreeValid 报重复引用、几何错乱——自产变体过不了引擎自身门禁。
match base_ab.doc.nodes.get(base_ab.doc.root_id) {
Some(r) =>
for cid in r.children.iter() {
copy_subtree(base_ab.doc, vab.doc, cid, vab.doc.root_id)
}
None => ()
}
adapt_layout(vab.doc, bp)
created.push("\"\{vid}\"")
}
None => ()
}
}
let j = jstr17(created)
Ok("{\"ok\":true,\"base\":\"\{artboard}\",\"variants\":[\{j}]}")
}
}
}
///|
/// 源文档子树 → 目标文档的递归重建:保持原父子层级与节点 id,
/// insert_node 负责防重复注册(目标文档已存在同 id 时跳过)
fn copy_subtree(
src : Document,
dst : Document,
id : String,
parent_id : String,
) -> Unit {
match src.nodes.get(id) {
None => ()
Some(n) => {
let _ = dst.insert_node({ ..n, children: [], }, parent_id)
for cid in n.children.iter() {
copy_subtree(src, dst, cid, id)
}
}
}
}
///|
/// 响应式适配分发:
/// - 栈布局画板(root 有 layout):走流式重排(改 dir/gap/padding,子项由布局引擎定位)
/// - 绝对定位画板(root 无 layout):走等比几何缩放 + 居中
/// 绝对定位画板若被强行改成栈布局,绝对坐标会被当作流式子项依次排开
/// (x 单调累加越界),生成的变体连引擎自身的 contained_in_parent 门禁都过不了。
fn adapt_layout(doc : Document, bp : Breakpoint) -> Unit {
let root_layout = match doc.nodes.get(doc.root_id) {
Some(r) => r.layout
None => return
}
match root_layout {
Some(_) =>
match bp.name {
"tablet" => adapt_tablet(doc)
"desktop" => adapt_desktop(doc)
_ => ()
}
None => adapt_absolute(doc, bp)
}
}
///|
/// 绝对定位画板:整棵子树等比缩放并居中到目标断点。
/// 顶层子项额外施加平移(其坐标相对画板原点);更深层子项坐标相对父容器,
/// 只做缩放即可保持相对关系,避免重复平移。
fn adapt_absolute(doc : Document, bp : Breakpoint) -> Unit {
let layout = solve(doc)
let mut min_x = 1.0e18
let mut min_y = 1.0e18
let mut max_x = -1.0e18
let mut max_y = -1.0e18
let mut has_geometry = false
for id in doc.nodes.keys() {
if id != doc.root_id {
match layout.rect_of(id) {
Some(r) => {
has_geometry = true
if r.x < min_x {
min_x = r.x
}
if r.y < min_y {
min_y = r.y
}
if r.x + r.width > max_x {
max_x = r.x + r.width
}
if r.y + r.height > max_y {
max_y = r.y + r.height
}
}
None => ()
}
}
}
if !has_geometry {
return
}
let bw = max_x - min_x
let bh = max_y - min_y
if bw <= 0.0 || bh <= 0.0 {
return
}
let margin = match bp.name {
"tablet" => 32.0
"desktop" => 48.0
_ => 24.0
}
let avail_w = bp.width - margin * 2.0
let avail_h = bp.height - margin * 2.0
if avail_w <= 0.0 || avail_h <= 0.0 {
return
}
let sx = avail_w / bw
let sy = avail_h / bh
let mut scale = if sx < sy { sx } else { sy }
// 上限 3x:避免小尺寸设计被放大到失真
if scale > 3.0 {
scale = 3.0
}
let scaled_w = bw * scale
let scaled_h = bh * scale
let ox = margin + (avail_w - scaled_w) / 2.0 - min_x * scale
let oy = margin + (avail_h - scaled_h) / 2.0 - min_y * scale
for id in doc.nodes.keys() {
if id == doc.root_id {
continue
}
let is_top = match doc.find_parent(id) {
Some(pid) => pid == doc.root_id
None => false
}
let _ = doc.update_node(id, fn(n) {
let sx_pos = scale_pos_x(n.x, scale)
let sy_pos = scale_pos_y(n.y, scale)
let nx = if is_top { shift_pos_x(sx_pos, ox) } else { sx_pos }
let ny = if is_top { shift_pos_y(sy_pos, oy) } else { sy_pos }
{
..n,
x: nx,
y: ny,
width: scale_size_spec(n.width, scale),
height: scale_size_spec(n.height, scale),
}
})
}
}
///|
fn scale_pos_x(p : PosSpec, s : Double) -> PosSpec {
match p {
Fixed(v) => Fixed(v * s)
AlignStart(v) => AlignStart(v * s)
AlignEnd(v) => AlignEnd(v)
CenterInParent => CenterInParent
}
}
///|
fn scale_pos_y(p : PosSpec, s : Double) -> PosSpec {
match p {
Fixed(v) => Fixed(v * s)
AlignStart(v) => AlignStart(v * s)
AlignEnd(v) => AlignEnd(v)
CenterInParent => CenterInParent
}
}
///|
fn shift_pos_x(p : PosSpec, d : Double) -> PosSpec {
match p {
Fixed(v) => Fixed(v + d)
AlignStart(v) => AlignStart(v + d)
AlignEnd(v) => AlignEnd(v)
CenterInParent => CenterInParent
}
}
///|
fn shift_pos_y(p : PosSpec, d : Double) -> PosSpec {
match p {
Fixed(v) => Fixed(v + d)
AlignStart(v) => AlignStart(v + d)
AlignEnd(v) => AlignEnd(v)
CenterInParent => CenterInParent
}
}
///|
/// 缩放尺寸规格:Fill/Hug 由布局引擎自适应,仅 Fixed 参与缩放
fn scale_size_spec(s : SizeSpec, factor : Double) -> SizeSpec {
match s {
Fixed(v) => Fixed(v * factor)
Fill => Fill
Hug => Hug
}
}
///|
fn adapt_tablet(doc : Document) -> Unit {
let scale = 768.0 / 390.0
for id in doc.nodes.keys() {
if id != "root" {
let _ = doc.update_node(id, fn(n) {
let w = scale_size(n.width, scale * 0.6)
{ ..n, width: w, }
})
}
}
let _ = doc.update_node("root", fn(n) {
let base = match n.layout {
Some(l) => l
None =>
{
dir: Vertical,
gap: 0.0,
justify: Start,
padding: Padding::uniform(0.0),
}
}
{
..n,
layout: Some({ ..base, gap: 16.0, padding: Padding::uniform(32.0), }),
}
})
}
///|
fn adapt_desktop(doc : Document) -> Unit {
let scale = 1200.0 / 390.0
for id in doc.nodes.keys() {
if id != "root" {
let _ = doc.update_node(id, fn(n) {
let w = scale_size(n.width, scale * 0.4)
{ ..n, width: w, }
})
}
}
let _ = doc.update_node("root", fn(n) {
let base = match n.layout {
Some(l) => l
None =>
{
dir: Vertical,
gap: 0.0,
justify: Start,
padding: Padding::uniform(0.0),
}
}
{
..n,
layout: Some({
..base,
dir: Horizontal,
gap: 24.0,
padding: Padding::uniform(48.0),
}),
}
})
}
///|
fn scale_size(s : SizeSpec, factor : Double) -> SizeSpec {
match s {
Fixed(v) => {
let nw = v * factor
if nw > 700.0 {
Fixed(700.0)
} else {
Fixed(nw)
}
}
Fill => Fill
Hug => Hug
}
}
///|
/// 预览指定断点
pub fn Project::preview_breakpoint(
self : Project,
artboard~ : String,
breakpoint~ : String,
) -> Result[String, String] {
let target = if breakpoint == "mobile" {
artboard
} else {
artboard + "_" + breakpoint
}
match self.artboards.get(target) {
Some(ab) => {
let svg = render_svg(ab.doc)
let w = ab.width.to_int().to_string()
let sl = svg.length().to_string()
Ok(
"{\"breakpoint\":\"\{breakpoint}\",\"width\":\{w},\"svg_length\":\{sl}}",
)
}
None => Err("not_generated: \{target}")
}
}
///|
/// 列出某画板的所有响应式变体
pub fn Project::list_breakpoint_variants(
self : Project,
artboard~ : String,
) -> String {
let variants : Array[String] = []
for bp in default_breakpoints().iter() {
let id = if bp.name == "mobile" {
artboard
} else {
artboard + "_" + bp.name
}
match self.artboards.get(id) {
Some(ab) => {
let w = ab.width.to_int().to_string()
let n = (ab.doc.nodes.size() - 1).to_string()
variants.push(
"{\"breakpoint\":\"\{bp.name}\",\"width\":\{w},\"nodes\":\{n}}",
)
}
None => ()
}
}
let j = jstr17(variants)
"[\{j}]"
}
///|
fn jstr17(parts : Array[String]) -> String {
let sb = StringBuilder::new()
for i in 0.. 0 {
sb.write_string(",")
}
sb.write_string(parts[i])
}
sb.to_string()
}