///|
/// 交互原型系统:原型不是静态图——它有状态、有事件、有转场。
///
/// 核心概念:
/// - Trigger:什么触发了交互(tap / 长按 / 滑动 / 键盘)
/// - Action:交互发生什么(导航 / 切换状态 / 更新文字 / 提交表单)
/// - Transition:页面如何过渡(push / modal / fade)
/// - ComponentState:交互组件的多态(default / pressed / disabled / loading)
///
/// 运行时引擎(runtime.mbt)消费这些定义,驱动可交互的原型。
// ---------------------------------------------------------------------------
// 触发器
// ---------------------------------------------------------------------------
pub(all) enum Trigger {
Tap // 单击
LongPress(Double) // 长按(秒)
SwipeLeft // 左滑
SwipeRight // 右滑
SwipeUp // 上滑
SwipeDown // 下滑
ScrollEnd // 滚动到底
KeyboardEnter // 回车键
Focus // 获得焦点
Blur // 失去焦点
}
///|
pub fn Trigger::name(self : Trigger) -> String {
match self {
Tap => "tap"
LongPress(_) => "long_press"
SwipeLeft => "swipe_left"
SwipeRight => "swipe_right"
SwipeUp => "swipe_up"
SwipeDown => "swipe_down"
ScrollEnd => "scroll_end"
KeyboardEnter => "keyboard_enter"
Focus => "focus"
Blur => "blur"
}
}
// ---------------------------------------------------------------------------
// 动作
// ---------------------------------------------------------------------------
///|
pub(all) enum Action {
NavigateTo(String) // 跳转到画板
Back // 返回上一页(弹出导航栈)
ToggleState(String, String) // 切换组件状态 (node_id, state_name)
SetState(String, String) // 设置组件状态 (node_id, state_name)
SetText(String, String) // 更新文字 (node_id, new_text)
SubmitForm(Array[String]) // 提交表单(收集输入框 node_ids)
ShowToast(String) // 显示临时消息
HapticFeedback // 触觉反馈
PlaySound(String) // 播放音效
}
///|
pub fn Action::describe(self : Action) -> String {
match self {
NavigateTo(target) => "navigate_to:\{target}"
Back => "back"
ToggleState(id, _) => "toggle_state:\{id}"
SetState(id, state) => "set_state:\{id}:\{state}"
SetText(id, text) => "set_text:\{id}:\"\{text}\""
SubmitForm(ids) => "submit_form:[\{ids.length()} fields]"
ShowToast(msg) => "show_toast:\"\{msg}\""
HapticFeedback => "haptic"
PlaySound(name) => "play_sound:\{name}"
}
}
// ---------------------------------------------------------------------------
// 转场
// ---------------------------------------------------------------------------
///|
pub(all) enum Transition {
Push // 从右滑入
Pop // 从左滑出(返回)
ModalPresent // 从底部滑入(模态)
ModalDismiss // 向底部滑出(关闭模态)
SheetPresent // 部分滑入(半屏 sheet)
Fade // 交叉淡入淡出
NoTransition // 无动画
}
///|
pub fn Transition::name(self : Transition) -> String {
match self {
Push => "push"
Pop => "pop"
ModalPresent => "modal_present"
ModalDismiss => "modal_dismiss"
SheetPresent => "sheet_present"
Fade => "fade"
NoTransition => "none"
}
}
// ---------------------------------------------------------------------------
// 交互定义
// ---------------------------------------------------------------------------
///|
pub(all) struct InteractionDef {
node_id : String // 触发交互的节点
trigger : Trigger // 触发条件
action : Action // 执行动作
transition : Transition // 页面转场方式
}
///|
/// 组件多态(同一组件的不同视觉状态)
pub(all) struct ComponentStateDef {
node_id : String
states : Map[String, NodePatch] // "default" → base, "pressed" → darker fill...
mut current : String
}
// ---------------------------------------------------------------------------
// 交互注册与管理
// ---------------------------------------------------------------------------
///|
/// 为节点添加交互定义
pub fn Project::add_interaction(
self : Project,
artboard~ : String,
node_id~ : String,
trigger~ : Trigger,
action~ : Action,
transition? : Transition = Push,
) -> Result[String, String] {
match self.artboards.get(artboard) {
None => Err("unknown_artboard")
Some(ab) => {
if !ab.doc.has(node_id) {
return Err("unknown_node: \{node_id}")
}
let inter : InteractionDef = { node_id, trigger, action, transition, }
match ab.doc.nodes.get(node_id) {
Some(n) => {
// 把交互信息序列化到节点的 metadata(v1:存 text 前缀标记)
let marker = "⚡\{trigger.name()}→\{action.describe()}"
let _ = ab.doc.update_node(node_id, fn(x) {
{ ..x, name: "\{x.name} \{marker}", }
})
Ok(
"{\"ok\":true,\"interaction\":\"\{node_id}:\{trigger.name()}→\{action.describe()}\"}",
)
}
None => Err("node_not_found")
}
}
}
}
///|
/// 为组件定义多态状态(真实存储补丁,而非仅写标记)。
///
/// 状态作用于渲染/命中前的节点变换:default 为该节点的基准补丁,
/// pressed/disabled 等为相对基准的覆盖。此前该函数接收 patch 却完全丢弃,
/// 只留一个无人读取的名字标记——签名承诺的能力实际不存在。
pub fn Project::define_component_state(
self : Project,
artboard~ : String,
node_id~ : String,
state_name~ : String,
patch~ : NodePatch,
) -> Result[String, String] {
let ab = match self.artboards.get(artboard) {
Some(a) => a
None => return Err("unknown_artboard: \{artboard}")
}
if !ab.doc.has(node_id) {
return Err("unknown_node: \{node_id}")
}
let def = match self.node_states.get(node_id) {
Some(d) => d
None => { node_id, states: Map::new(), current: "default", }
}
def.states.set(state_name, patch)
self.node_states.set(node_id, def)
// 状态定义必须随 canonical MBT 往返:序列化到节点名标记([state:...])。
// 仅存内存会在每次 load-mbt-b64 重建 Project 时静默丢失。
let count = def.states.size().to_string()
let markers = states_to_markers(def)
let cur = if def.current != "default" {
["[cur:" + def.current + "]"]
} else {
[]
}
for m in cur {
markers.push(m)
}
let _ = ab.doc.update_node(node_id, fn(n) {
{ ..n, name: rebuild_name(n.name, markers), }
})
self.revision = self.revision + 1
Ok(
"{\"ok\":true,\"node\":\"\{node_id}\",\"state\":\"\{state_name}\",\"states\":\{count}}",
)
}
///|
/// 状态定义 → 名字标记列表:`[state::k=v,k=v]`
fn states_to_markers(def : ComponentStateDef) -> Array[String] {
let out : Array[String] = []
for name in def.states.keys() {
match def.states.get(name) {
Some(p) => out.push("[state:" + name + ":" + patch_to_spec(p) + "]")
None => ()
}
}
out
}
///|
/// 状态标记 spec 值的转义协议(标记格式自有,与 decl 层转义无关):
/// `\` → `\\`、`,` → `\,`、`[` → `\[`、`]` → `\]`。text 值经此写入
/// [state:name:spec] 标记——裸写时 split_commas 逢逗号即切、标记解析
/// 逢 ] 即收尾,含逗号/方括号的状态文本每次 canonical 重建都被截断。
fn spec_escape(s : String) -> String {
let sb = StringBuilder::new()
for c in s.iter() {
match c {
'\\' => sb.write_string("\\\\")
',' => sb.write_string("\\,")
'[' => sb.write_string("\\[")
']' => sb.write_string("\\]")
_ => sb.write_char(c)
}
}
sb.to_string()
}
///|
/// spec_escape 的逆(成对存在)。尾部孤立反斜杠原样保留,不做有损丢弃。
fn spec_unescape(s : String) -> String {
let sb = StringBuilder::new()
let mut esc = false
for c in s.iter() {
if esc {
sb.write_char(c)
esc = false
} else if c == '\\' {
esc = true
} else {
sb.write_char(c)
}
}
if esc {
sb.write_char('\\')
}
sb.to_string()
}
///|
/// NodePatch → 紧凑规格 "k=v,k=v"(与 marker_to_patch 对称)。
fn patch_to_spec(p : NodePatch) -> String {
let parts : Array[String] = []
match p.fill {
Some(v) => parts.push("fill=" + v)
None => ()
}
match p.text_color {
Some(v) => parts.push("text_color=" + v)
None => ()
}
match p.stroke {
Some(v) => parts.push("stroke=" + v)
None => ()
}
match p.text {
Some(v) => parts.push("text=" + spec_escape(v))
None => ()
}
match p.corner_radius {
Some(v) => parts.push("radius=" + double_text(v))
None => ()
}
match p.font_size {
Some(v) => parts.push("font_size=" + double_text(v))
None => ()
}
match p.layout_gap {
Some(v) => parts.push("gap=" + double_text(v))
None => ()
}
let sb = StringBuilder::new()
for i = 0; i < parts.length(); i = i + 1 {
if i > 0 {
sb.write_string(",")
}
sb.write_string(parts[i])
}
sb.to_string()
}
///|
/// 从 name 中提取交互标记("⚡..."),无则空串。
pub fn extract_interaction_marker(name : String) -> String {
match name.find("⚡") {
None => ""
Some(i) => {
let tail = name.substring(start=i, end=name.length())
// 交互标记到下一个状态标记为止
match tail.find("[state:") {
None => tail.trim().to_string()
Some(j) => tail.substring(start=0, end=j).trim().to_string()
}
}
}
}
///|
/// 用新的状态标记重建节点名:保留原始名与交互标记,替换全部状态标记。
pub fn rebuild_name(name : String, state_markers : Array[String]) -> String {
let base = strip_all_markers(name)
let inter = extract_interaction_marker(name)
let sb = StringBuilder::new()
sb.write_string(base)
if inter != "" {
if base != "" {
sb.write_string(" ")
}
sb.write_string(inter)
}
for m in state_markers.iter() {
if sb.to_string() != "" {
sb.write_string(" ")
}
sb.write_string(m)
}
sb.to_string()
}
///|
/// 去掉节点名中的全部标记(交互 ⚡ 与状态 [state:),还原原始名。
pub fn strip_all_markers(name : String) -> String {
let mut cut = name.length()
match name.find("⚡") {
Some(i) => if i < cut { cut = i }
None => ()
}
match name.find("[state:") {
Some(i) => if i < cut { cut = i }
None => ()
}
match name.find("[cur:") {
Some(i) => if i < cut { cut = i }
None => ()
}
let head = name.substring(start=0, end=cut)
let mut end = head.length()
while end > 0 && head.substring(start=end - 1, end~) == " " {
end = end - 1
}
head.substring(start=0, end~)
}
///|
/// 扫描节点名标记,重建 Project 的状态表(load 后调用)。
/// 状态定义随名字往返,但内存表需在重建 Project 后重新填充。
pub fn Project::rebuild_node_states(self : Project, artboard~ : String) -> Unit {
let ab = match self.artboards.get(artboard) {
Some(a) => a
None => return
}
for id in ab.doc.nodes.keys() {
match ab.doc.nodes.get(id) {
Some(n) =>
match parse_state_markers(n.name) {
Some(states) => {
let def = match self.node_states.get(id) {
Some(d) => d
None => { node_id: id, states: Map::new(), current: "default", }
}
for kv in states.iter() {
let (sname, patch) = kv
def.states.set(sname, patch)
}
// [cur:]:重载时恢复当前激活状态(定位转义感知——
// 前面 state spec 的 text 值里可有 \[cur: 形态的转义文本)
match find_unescaped_seq(n.name, 0, "[cur:") {
Some(ci) => {
let rest_name = n.name.substring(start=ci + 5)
match rest_name.find("]") {
Some(e) => {
let cname = rest_name.substring(start=0, end=e)
if cname != "" && def.states.contains(cname) {
def.current = cname
}
}
None => ()
}
}
None => ()
}
self.node_states.set(id, def)
}
None => ()
}
None => ()
}
}
}
///|
/// 解析名字中的全部 [state:name:spec] 标记 → (state_name, NodePatch) 列表。
/// 标记定位转义感知(find_unescaped_seq):spec 的 text 值经 spec_escape
/// 写入,值里的 \] / \[state: 不是结构字符。
pub fn parse_state_markers(name : String) -> Array[(String, NodePatch)]? {
let out : Array[(String, NodePatch)] = []
let mut rest = name
while true {
match find_unescaped_seq(rest, 0, "[state:") {
None => break
Some(i) => {
let tail = rest.substring(start=i + 7, end=rest.length())
match find_unescaped_seq(tail, 0, "]") {
None => break
Some(j) => {
let body = tail.substring(start=0, end=j)
match body.find(":") {
None => ()
Some(k) => {
let sname = body.substring(start=0, end=k)
let spec = body.substring(start=k + 1, end=body.length())
if sname != "" {
out.push((sname, spec_to_patch(spec)))
}
}
}
rest = tail.substring(start=j + 1, end=tail.length())
}
}
}
}
}
if out.length() == 0 {
None
} else {
Some(out)
}
}
///|
/// 紧凑规格 "k=v,k=v" → NodePatch(与 patch_to_spec 对称)。
fn spec_to_patch(spec : String) -> NodePatch {
let mut fill : String? = None
let mut text_color : String? = None
let mut stroke : String? = None
let mut text : String? = None
let mut radius : Double? = None
let mut font_size : Double? = None
let mut gap : Double? = None
for kv in split_commas(spec).iter() {
match kv.find("=") {
None => ()
Some(eq) => {
let k = kv.substring(start=0, end=eq)
// 值反转义(text 经 spec_escape 写入;其余值不含转义字符,原样通过)
let v = spec_unescape(kv.substring(start=eq + 1, end=kv.length()))
match k {
"fill" => fill = Some(v)
"text_color" => text_color = Some(v)
"stroke" => stroke = Some(v)
"text" => text = Some(v)
"radius" => radius = Some(parse_double(v))
"font_size" => font_size = Some(parse_double(v))
"gap" => gap = Some(parse_double(v))
_ => ()
}
}
}
}
{
..NodePatch::empty(),
fill,
text_color,
stroke,
text,
corner_radius: radius,
font_size,
layout_gap: gap,
}
}
///|
/// 转义感知的逗号切分:`\,` 不切(spec_escape 协议),其余逗号照切。
/// 反斜杠保留在 token 里,由调用方对值做 spec_unescape。
fn split_commas(s : String) -> Array[String] {
let out : Array[String] = []
let cur = StringBuilder::new()
let mut esc = false
for c in s.iter() {
if esc {
cur.write_char(c)
esc = false
} else if c == '\\' {
cur.write_char(c)
esc = true
} else if c == ',' {
out.push(cur.to_string())
cur.reset()
} else {
cur.write_char(c)
}
}
out.push(cur.to_string())
out
}
///|
/// 自 start 起查找未被反斜杠转义的子串 seq 首现下标(无则 None)。
/// 状态标记 spec 的 text 值走 spec_escape 协议——裸 find 会把值里的
/// \] 当成标记收尾、\[state: 当成下一个标记,往返即截断/误读。
fn find_unescaped_seq(s : String, start : Int, seq : String) -> Int? {
let n = s.length()
let m = seq.length()
if m == 0 || m > n {
return None
}
let first = match seq.get_char(0) {
Some(c) => c
None => return None
}
let mut esc = false
let mut i = start
while i < n {
match s.get_char(i) {
Some(c) =>
if esc {
esc = false
} else if c == '\\' {
esc = true
} else if c == first &&
i + m <= n &&
s.substring(start=i, end=i + m) == seq {
return Some(i)
}
None => esc = false
}
i = i + 1
}
None
}
///|
/// 数值 → 紧凑文本(整数省略小数点,与 decl 层 num_text 视觉一致)
fn double_text(d : Double) -> String {
if d == d.to_int().to_double() {
d.to_int().to_string()
} else {
d.to_string()
}
}
///|
/// 切换/设置节点当前状态并返回变更后的节点。
/// ToggleState:在 default 与目标状态间切换;SetState:直接设为目标状态。
pub fn Project::set_node_state(
self : Project,
node_id~ : String,
state_name~ : String,
toggle? : Bool = false,
) -> Result[String, String] {
let def = match self.node_states.get(node_id) {
Some(d) => d
None => return Err("no_states: \{node_id}")
}
if !def.states.contains(state_name) {
return Err("unknown_state: \{state_name}")
}
let next = if toggle && def.current == state_name {
"default"
} else {
state_name
}
def.current = next
self.node_states.set(node_id, def)
// current 写回节点名标记:[cur:],随 canonical MBT 往返。
// 此前仅改内存,重载后永远回 default。
let artboard_id = self.find_node_artboard(node_id)
match artboard_id {
Some(aid) =>
match self.artboards.get(aid) {
Some(ab) => {
let markers = states_to_markers(def)
if next != "default" {
markers.push("[cur:" + next + "]")
}
let _ = ab.doc.update_node(node_id, fn(n) {
{ ..n, name: rebuild_name(n.name, markers), }
})
}
None => ()
}
None => ()
}
self.revision = self.revision + 1
Ok("{\"ok\":true,\"node\":\"\{node_id}\",\"current\":\"\{next}\"}")
}
///|
/// 找到节点所在的画板 id(状态表是项目级的,名字标记在画板文档里)
fn Project::find_node_artboard(self : Project, node_id : String) -> String? {
for aid in self.artboards.keys() {
match self.artboards.get(aid) {
Some(ab) => if ab.doc.has(node_id) { return Some(aid) }
None => ()
}
}
None
}
///|
/// 当前状态下节点应有的补丁(default 缺省则返回空补丁)。
pub fn Project::active_state_patch(
self : Project,
node_id : String,
) -> NodePatch? {
match self.node_states.get(node_id) {
None => None
Some(def) =>
match def.states.get(def.current) {
Some(p) => Some(p)
None =>
match def.states.get("default") {
Some(p) => Some(p)
None => None
}
}
}
}
///|
/// 结构化回读:节点的全部已定义状态与其当前值。
pub fn Project::node_states_json(
self : Project,
artboard~ : String,
) -> Result[String, String] {
match self.artboards.get(artboard) {
None => Err("unknown_artboard: \{artboard}")
Some(ab) => {
let items : Array[String] = []
for id in ab.doc.nodes.keys() {
match self.node_states.get(id) {
Some(def) => {
let names : Array[String] = []
for s in def.states.keys() {
names.push("\"\{s}\"")
}
items.push(
"{\"node\":\"\{id}\",\"current\":\"\{def.current}\",\"states\":[\{jstr12(names)}]}",
)
}
None => ()
}
}
Ok("[\{jstr12(items)}]")
}
}
}
// ---------------------------------------------------------------------------
// 导航栈(运行时状态)
// ---------------------------------------------------------------------------
///|
pub(all) struct NavigationState {
mut stack : Array[String] // 当前导航栈
mut current : String // 当前画板 ID
}
///|
pub fn NavigationState::new(initial : String) -> NavigationState {
{ stack: [initial], current: initial, }
}
///|
pub fn NavigationState::navigate(
self : NavigationState,
target : String,
) -> String {
self.stack.push(target)
self.current = target
target
}
///|
pub fn NavigationState::go_back(self : NavigationState) -> String? {
if self.stack.length() > 1 {
let _ = self.stack.pop()
match self.stack.get(self.stack.length() - 1) {
Some(top) => {
self.current = top
Some(top)
}
None => None
}
} else {
None
}
}
// ---------------------------------------------------------------------------
// 交互清单(Agent 审阅用)
// ---------------------------------------------------------------------------
///|
/// 列出画板的所有交互定义(从节点名解析)
pub fn Project::list_interactions(
self : Project,
artboard~ : String,
) -> Result[String, String] {
match self.artboards.get(artboard) {
None => Err("unknown_artboard")
Some(ab) => {
let items : Array[String] = []
for id in ab.doc.nodes.keys() {
match ab.doc.nodes.get(id) {
Some(n) =>
if n.name.contains("⚡") {
// id/name/artboard 过 json_escape(与 query_node_json 同一
// 约定):update name= 值可含字面双引号,裸插值产出非法
// JSON 信封(issue #10 同类事故源)
items.push(
"{\"node\":\"\{json_escape(id)}\",\"name\":\"\{json_escape(n.name)}\",\"artboard\":\"\{json_escape(artboard)}\"}",
)
}
None => ()
}
}
// 内置返回热区:back-like 节点即使无 ⚡ 标记也导出(runtime 对其 tap
// 有内置返回兜底)——宿主据此绘制热区,无需各自维护启发式
for id in ab.doc.nodes.keys() {
match ab.doc.nodes.get(id) {
Some(n) =>
if !n.name.contains("⚡") && is_back_like_node(n) {
items.push(
"{\"node\":\"\{json_escape(id)}\",\"name\":\"\{json_escape(n.name)}\",\"artboard\":\"\{json_escape(artboard)}\",\"builtin\":\"back\"}",
)
}
None => ()
}
}
let j = jstr12(items)
Ok("[\{j}]")
}
}
}
///|
fn jstr12(parts : Array[String]) -> String {
let sb = StringBuilder::new()
for i in 0.. 0 {
sb.write_string(",")
}
sb.write_string(parts[i])
}
sb.to_string()
}
// ---------------------------------------------------------------------------
// 交互标记解析(运行时消费 / CLI 构造共用)
// ---------------------------------------------------------------------------
///|
/// 从节点名提取交互标记 → (trigger, action_spec)。
/// 标记由 add_interaction 追加,格式 "…⚡→"。
/// 用字符迭代 + ASCII 白名单提取 trigger,避免多字节 emoji 的字节偏移歧义。
pub fn parse_interaction_marker(name : String) -> (String, String)? {
let before = StringBuilder::new()
let seen_arrow : Ref[Bool] = Ref::new(false)
let after = StringBuilder::new()
for c in name.iter() {
if c == '→' {
seen_arrow.val = true
continue
}
if seen_arrow.val {
after.write_char(c)
} else {
before.write_char(c)
}
}
if !seen_arrow.val {
return None
}
// trigger = 箭头前最后一个空白分隔 token 中的 ASCII 标识符部分
let chars : Array[Char] = []
for c in before.to_string().iter() {
chars.push(c)
}
let mut last_space = -1
for i = 0; i < chars.length(); i = i + 1 {
if chars[i] == ' ' || chars[i] == '\t' {
last_space = i
}
}
let trig = StringBuilder::new()
for i = last_space + 1; i < chars.length(); i = i + 1 {
let c = chars[i]
if (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '_' {
trig.write_char(c)
}
}
let t = trig.to_string()
// action 到下一个状态标记为止:交互与状态标记共存于同一 name,
// 不截断会把 "[state:...]" 当成 action 的一部分。
let raw_after = after.to_string()
let a = match raw_after.find("[state:") {
Some(i) => raw_after.substring(start=0, end=i).trim().to_string()
None => raw_after.trim().to_string()
}
if t == "" || a == "" {
return None
}
Some((t, a))
}
///|
/// trigger 规格 → Trigger(CLI / Agent 语法共用)。
/// 别名与 Trigger::name 保持一致;keyboard_enter 接受 key_enter 简写。
pub fn trigger_from_spec(spec : String) -> Trigger? {
match spec {
"tap" => Some(Tap)
"long_press" => Some(LongPress(0.5))
"swipe_left" => Some(SwipeLeft)
"swipe_right" => Some(SwipeRight)
"swipe_up" => Some(SwipeUp)
"swipe_down" => Some(SwipeDown)
"scroll_end" => Some(ScrollEnd)
"key_enter" | "keyboard_enter" => Some(KeyboardEnter)
"focus" => Some(Focus)
"blur" => Some(Blur)
_ => None
}
}
///|
/// action 规格 → Action。规格语法与 Action::describe 的输出对称:
/// back | haptic | navigate_to: | show_toast:"msg" | play_sound:
/// | toggle_state: | set_state:: | set_text::"text"
pub fn action_from_spec(spec : String) -> Action? {
match spec {
"back" => Some(Back)
"haptic" => Some(HapticFeedback)
_ =>
match spec.find(":") {
None => None
Some(i) => {
let head = spec.substring(start=0, end=i)
let rest = spec.substring(start=i + 1, end=spec.length())
match head {
"navigate_to" =>
if rest == "" {
None
} else {
Some(NavigateTo(rest))
}
"show_toast" =>
if rest == "" {
None
} else {
Some(ShowToast(unquote_spec(rest)))
}
"play_sound" =>
if rest == "" {
None
} else {
Some(PlaySound(rest))
}
"toggle_state" =>
if rest == "" {
None
} else {
Some(ToggleState(rest, ""))
}
"set_text" =>
match rest.find(":") {
None => None
Some(j) => {
let id = rest.substring(start=0, end=j)
let txt = rest.substring(start=j + 1, end=rest.length())
if id == "" {
None
} else {
Some(SetText(id, unquote_spec(txt)))
}
}
}
"set_state" =>
match rest.find(":") {
None => None
Some(j) => {
let id = rest.substring(start=0, end=j)
let st = rest.substring(start=j + 1, end=rest.length())
if id == "" {
None
} else {
Some(SetState(id, st))
}
}
}
_ => None
}
}
}
}
}
///|
/// 去掉规格值两端的双引号(show_toast:"msg" / set_text:n:"txt")。
fn unquote_spec(s : String) -> String {
if s.length() >= 2 &&
s.substring(start=0, end=1) == "\"" &&
s.substring(start=s.length() - 1, end=s.length()) == "\"" {
s.substring(start=1, end=s.length() - 1)
} else {
s
}
}
///|
/// 追加交互标记到节点名(幂等:同名标记不重复追加)。
pub fn Project::attach_interaction(
self : Project,
artboard~ : String,
node_id~ : String,
trigger~ : String,
action_spec~ : String,
) -> Result[String, String] {
let ab = match self.artboards.get(artboard) {
Some(a) => a
None => return Err("unknown_artboard: \{artboard}")
}
let node = match ab.doc.nodes.get(node_id) {
Some(n) => n
None => return Err("unknown_node: \{node_id}")
}
let marker = "⚡\{trigger}→\{action_spec}"
if node.name.contains(marker) {
return Ok("{\"ok\":true,\"node\":\"\{node_id}\",\"unchanged\":true}")
}
// 先清理旧标记,保证一个节点只有一个生效交互且标记不累积
let base = strip_markers(node.name)
let updated = if base == "" { marker } else { base + " " + marker }
let _ = ab.doc.update_node(node_id, fn(n) { { ..n, name: updated, } })
self.revision = self.revision + 1
Ok(
"{\"ok\":true,\"node\":\"\{node_id}\",\"trigger\":\"\{trigger}\",\"action\":\"\{action_spec}\"}",
)
}
///|
/// 移除节点名的全部交互标记(保留原始名)。
pub fn Project::detach_interaction(
self : Project,
artboard~ : String,
node_id~ : String,
) -> Result[String, String] {
let ab = match self.artboards.get(artboard) {
Some(a) => a
None => return Err("unknown_artboard: \{artboard}")
}
let node = match ab.doc.nodes.get(node_id) {
Some(n) => n
None => return Err("unknown_node: \{node_id}")
}
let base = strip_markers(node.name)
let _ = ab.doc.update_node(node_id, fn(n) { { ..n, name: base, } })
self.revision = self.revision + 1
Ok("{\"ok\":true,\"node\":\"\{node_id}\",\"cleared\":true}")
}
///|
/// 去掉名称里的交互标记(含 marker 与其前的分隔空格)。
pub fn strip_markers(name : String) -> String {
let idx = name.find("⚡")
match idx {
None => name
Some(i) => {
let head = name.substring(start=0, end=i)
// 修剪标记前遗留的尾随空格
let mut end = head.length()
while end > 0 && head.substring(start=end - 1, end~) == " " {
end = end - 1
}
head.substring(start=0, end~)
}
}
}
///|
/// 结构化交互清单:解析标记 → trigger/action 字段(供 Agent 审阅与回读)。
pub fn Project::interactions_json(
self : Project,
artboard~ : String,
) -> Result[String, String] {
match self.artboards.get(artboard) {
None => Err("unknown_artboard: \{artboard}")
Some(ab) => {
let items : Array[String] = []
for id in ab.doc.nodes.keys() {
match ab.doc.nodes.get(id) {
Some(n) =>
match parse_interaction_marker(n.name) {
Some((trig, spec)) =>
// trigger/action spec 来自节点名,值可含引号——与
// query_node_json 同约定过 json_escape(信封合法性)
items.push(
"{\"node\":\"\{json_escape(id)}\",\"trigger\":\"\{json_escape(trig)}\",\"action\":\"\{json_escape(spec)}\"}",
)
None => ()
}
None => ()
}
}
// 内置返回热区:back-like 节点即使无 ⚡ 标记也导出(runtime 对其 tap
// 有内置返回兜底)——宿主据此绘制热区/点击,无需各自维护启发式
for id in ab.doc.nodes.keys() {
match ab.doc.nodes.get(id) {
Some(n) =>
if !n.name.contains("⚡") && is_back_like_node(n) {
items.push(
"{\"node\":\"\{id}\",\"trigger\":\"tap\",\"action\":\"back\",\"builtin\":\"back\"}",
)
}
None => ()
}
}
Ok("[\{jstr12(items)}]")
}
}
}