docs: explain UI bridge callers, control flow and VM ownership

This commit is contained in:
2026-11-12 16:36:11 +08:00
parent f00fd05754
commit fbc5bed19e
8 changed files with 878 additions and 103 deletions
+427 -74
View File
File diff suppressed because it is too large Load Diff
+63 -2
View File
@@ -1,42 +1,63 @@
//! 安装一次 VM 域需要的宿主类型、对象和方法。类型注册表是源代码调用原生能力的入口。
//! 先建立类型与桌面基础设施,再绑定构造器、视觉协议和开发工具工厂;返回的根负责保持这些对象存活。
//!
//! Allocate and connect the managed desktop, type tables and host globals.
//! 调用链:`endor_host::compile` / `ReplSession::new` 创建候选 VM,
//! 随后调用本模块的 `install`;运行时再通过 install_globals 注入源程序使用的 UI 值。
//! 本文件只建立宿主环境。源程序稍后调用构造器或实例方法时,才会进入 `calls::Function::invoke`。
//! 安装返回的 UiBridge 由运行时保存;传入的 roots 由候选运行时持有,并在失败或退役时统一释放。
use super::*;
/// 把本模块的类型、方法或能力注册到所属运行时上下文。
/// 为一次候选程序/REPL 会话安装 Tessera 宿主环境,供运行时后续注入全局值和执行源程序。
///
/// 类型要先创建,签名才能引用它们;因此先分配没有签名的函数,再原地补齐 payload。
/// 所有 `?` 都把安装失败交还候选运行时,不会在这里把半成品替换成活跃程序。
/// `roots` 保存明确的 VM 拥有根;Rust 的 ObjectRef 数值和 Arc<Mutex<Session>> 均不能替代它们。
pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
// 先建立 desktop/ui/tabs 并加入显式 roots,后面初始化失败时由上层运行时统一释放,避免依赖 Rust 句柄保活。
// desktop 是此桥接域的类型注册表和窗口资源入口;它不是操作系统桌面或原生窗口。
let desktop = vm.new_object()?;
roots.push(desktop);
// 安装时尚无窗口尺寸;运行时收到 viewport 后再填充,避免在 VM 引导期间依赖 GUI 状态。
vm.set_attr(desktop, "viewport", Value::Nil)?;
// tabs 是主窗口的托管页面注册表,Presentation 和 window::install 会使用同一字典身份。
let tabs = vm.new_dict()?;
roots.push(tabs);
vm.set_attr(desktop, "tabs", Value::Object(tabs))?;
// ui 是对源代码公开的命名空间;desktop 保存内部记账,二者用途不能混同。
let ui = vm.new_object()?;
roots.push(ui);
// 原生 Session 借用托管身份,主窗口先拥有独立 Presentation;业务对象仍全部位于传入的单一 VM。
let session = Arc::new(Mutex::new(Session {
// 节点弱索引用来检查数量与回收失效记录,不应让已关闭的业务对象继续存活。
nodes: Vec::new(),
// 先登记主窗口上下文;后续窗口通过 create_window 建立各自的 Presentation。
presentations: std::collections::BTreeMap::from([(
crate::window::MAIN_WINDOW,
crate::presentation::Presentation::new(desktop, tabs),
)]),
// Session 的 Deref 按 current 选择布局/挂载状态;初始所有操作都落在主窗口。
current: crate::window::MAIN_WINDOW,
// master 始终指向主 desktop,副窗口仍借用同一 VM 的类型和资源域。
master: desktop,
// 分配器从现有最高窗口 ID 继续增长;Mount 则尚无任何已分配出现位置。
next_window: crate::window::MAIN_WINDOW,
next_mount: 0,
// 关闭后尚未交给 GUI 的原生命令暂存在这里,不能随 Presentation 删除而丢失。
retired_commands: Vec::new(),
// 累计诊断计数由刷新流程更新,供 ui.stats/开发工具观察本会话的实际工作量。
refresh_yields: 0,
refresh_restarts: 0,
layout_cancels: 0,
paint_cancels: 0,
paint_slices: 0,
binding_evaluations: 0,
// 清理阶段会置位,create_window 等操作据此拒绝在退出期间重新创建资源。
exiting: false,
}));
// 先分配函数占位再补签名,因为签名会引用尚未建立的名义类型;保存列表用于第二阶段替换 payload。
// 保存 ui.name 的函数身份及 Operation,等全部类型注册后再为这些函数补齐精确签名。
let mut public_functions = Vec::new();
// 另外记录 TypeConstructor 所用的工厂;它们多接收一个 Type 首参,不能套用自由函数签名。
let mut factories = Vec::new();
for (name, operation) in [
("implement", Operation::Implement),
@@ -63,6 +84,8 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
("viewport", Operation::Viewport),
("window", Operation::Window),
] {
// 每个名字得到自己的 VM callable,实际操作由 Function 根据 operation 分派。
// desktop 放在 captures 中,确保函数仍可达时它所依赖的类型注册表也能被 GC 追踪。
let function = vm.allocate(ContextNativeFunction::new(
name,
Function {
@@ -75,6 +98,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
vec![desktop],
))?;
roots.push(function);
// 属性供源程序的 ui.text/ui.present 等查询;列表供本函数第二阶段补签名,职责不同。
vm.set_attr(ui, name, Value::Object(function))?;
public_functions.push((name, function, operation));
}
@@ -83,6 +107,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
roots.extend([root_types.object, root_types.type_type]);
vm.set_attr(ui, "Object", Value::Object(root_types.object))?;
vm.set_attr(ui, "Type", Value::Object(root_types.type_type))?;
// definitions 汇集全部类型,稍后写入 desktop.types,供 desktop_type 按名称查精确身份。
let mut definitions = vec![
("Object", root_types.object),
("Type", root_types.type_type),
@@ -127,10 +152,13 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
("Task", "Object", None),
("ObjectList", "Object", Some("object-list")),
] {
// 依照表中基类名取得当前 VM 的真实类型,不能用另一个 VM 中的同名 Type 代替。
let base = object(Some(&vm.get_attr(ui, base_name)?))?;
// 根据构造规则选择专用工厂、通用工厂或抽象类型;这里只注册,不分配业务实例。
let constructor = match constructor_name {
// ObjectList 与 Event 有特殊的“类型首参+子类字段”构造规则,单独原生适配;其他类型共用 NativeFactory。
Some("object-list") => {
// ObjectList 构造要保持托管元素身份与字段规则,由 object_list::Constructor 处理。
let factory = vm.allocate(ContextNativeFunction::new(
"object-list",
crate::object_list::Constructor {
@@ -140,17 +168,20 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
vec![desktop],
))?;
roots.push(factory);
// 工厂本身也是语言中的 Function 实例,挂类型后才能通过名义函数检查。
let function_type = object(Some(&vm.get_attr(ui, "Function")?))?;
vm.attach_type(factory, function_type)?;
TypeConstructor::NativeTyped(factory)
}
Some("event") => {
// Event 另有必填事件名;其专用 Constructor 在真正调用时解析事件参数和子类字段。
let factory = vm.allocate(ContextNativeFunction::new(
"event",
crate::event_dispatch::Constructor { signature: None },
vec![],
))?;
roots.push(factory);
// 与列表工厂保持相同函数类型体系;无 desktop 捕获的分配器不额外引入桌面强边。
let function_type = object(Some(&vm.get_attr(ui, "Function")?))?;
vm.attach_type(factory, function_type)?;
TypeConstructor::NativeTyped(factory)
@@ -176,6 +207,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
.map(Operation::SceneObject)
.ok_or(invalid("unknown native type allocator"))?,
};
// 通用 NativeFactory 在调用时区分原生参数与源子类字段,并保留 requested Type。
let factory = vm.allocate(ContextNativeFunction::new(
constructor_name,
NativeFactory {
@@ -187,10 +219,12 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
vec![desktop],
))?;
roots.push(factory);
// 这个函数属于构造器元数据,不直接放在 ui.name;但仍需标为 Function 实例。
let function_type = object(Some(&vm.get_attr(ui, "Function")?))?;
vm.attach_type(factory, function_type)?;
TypeConstructor::NativeTyped(factory)
}
// 没有公开分配规则的类型仍可用于类型检查/协议;直接把它当构造器调用会由 VM 拒绝。
None => TypeConstructor::Abstract,
};
// 记住实际工厂身份,类型构建完成后原地补签名,避免替换类型内指向的构造函数对象。
@@ -198,6 +232,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
{
factories.push((tag, factory));
}
// 生成新 Type 后同时公开到 ui,并加入待提交注册表,两个入口必须指向同一对象。
let definition = vm.new_type(name, base, constructor)?;
roots.push(definition);
vm.set_attr(ui, name, Value::Object(definition))?;
@@ -217,6 +252,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
("PaintCommand", vec![]),
("CameraGesture", vec![]),
] {
// 表里的字段名转成 VM 可检查的字段声明;map 的每次调用只描述一个字段,不执行布局。
let fields = fields
.into_iter()
.map(|name| endor_core::ClassField {
@@ -225,11 +261,13 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
type_name: if matches!(name, "children" | "commands") {
"Object"
} else {
// 余下字段都是测量/几何数值,让 VM 在实例化时先拒绝非数字实参。
"Number"
}
.into(),
})
.collect();
// 这些协议值只携带源字段,使用普通 class 分配;不需要 Block 的原生 payload。
let definition = vm.new_class(
name,
root_types.object,
@@ -243,14 +281,17 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
// The desktop owns the registry through ordinary traced attributes. The
// Rust session only borrows handles and adds no hidden lifetime roots.
// 桌面通过普通属性拥有类型字典;后续 nominal 查询必须使用同一 desktop 中注册的精确类型身份。
// 类型字典由 desktop 属性托管持有;后续桥接调用可从其查找 Block、Event 等精确类型。
let types = vm.new_dict()?;
roots.push(types);
for (name, definition) in definitions {
// 字符串键是宿主查询约定,值才是参与 is_instance/is_subtype 的真实名义身份。
vm.dict_set(types, Value::Text(name.into()), Value::Object(definition))?;
}
vm.set_attr(desktop, "types", Value::Object(types))?;
vm.attach_type(desktop, root_types.object)?;
vm.attach_type(ui, root_types.object)?;
// 安装后续编译函数的默认类型,也用于下方所有方法 wrapper 的 attach_type。
let function_type = object(Some(&vm.get_attr(ui, "Function")?))?;
// 让后续 VM 创建的函数使用宿主 Function 名义类型,并打开源类型检查,再安装完整本机签名。
vm.set_function_type(function_type)?;
@@ -273,6 +314,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
)?;
}
for (name, function) in factories {
// 恢复第一阶段记录的分配器类别;Event/ObjectList 不走 NativeFactory,需要单独补外壳。
let operation = match name {
"text" => Some(Operation::Text),
"rect" => Some(Operation::Rect),
@@ -288,6 +330,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
"button" => Some(Operation::Button),
"page" => Some(Operation::Page),
"window-create" => Some(Operation::NewWindow),
// None 在本轮只表示专用构造器,不表示前一阶段的 Abstract 类型。
"event" | "object-list" => None,
name => Some(
crate::scene3d_model::Constructor::from_function(name)
@@ -296,6 +339,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
),
};
if let Some(operation) = operation {
// options 只描述原生构造选项,调用时由 NativeFactory 在切掉 Type 和子类字段后校验。
let options = operation_signature(vm.object_store(), desktop, operation)?;
// 外层 NativeTyped 工厂接收 Type 首参和任意候选尾参,允许子类字段;NativeFactory 用 options 再核对基构造参数。
let envelope = CallSignature {
@@ -305,6 +349,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
returns: options.returns.clone(),
effect: options.effect,
};
// 初始强边是注册表;随后追加签名引用的类型,供 ContextNativeFunction::trace 使用。
let mut captures = vec![desktop];
// 参数和返回注解中的名义类型也是托管引用,必须纳入 captures,不能只把它们存进 Rust 签名结构。
captures.extend(options.references());
@@ -323,16 +368,20 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
)?;
} else {
// 特殊构造器保留宽变参外壳,再由对应 Constructor 按类型字段数拆实参,返回准确 Event/ObjectList 名义类型。
// 为返回值选取专用名义类型,避免宽变参外壳同时放宽了返回值检查。
let kind = if name == "event" {
"Event"
} else {
"ObjectList"
};
// Event 的 Type 之后还有必填字符串事件名;ObjectList 只有 Type 为必填,其余由实现解释。
let signature = CallSignature {
arity: endor_core::CallArity::range(
// 这里包含 Type 首参;源子类字段由构造器验证,所以最大个数保留开放。
if name == "event" { 2 } else { 1 },
usize::MAX,
),
// 固定前缀与 required 数量保持一致,额外实参由 variadic 和字段验证共同约束。
parameters: if name == "event" {
vec![AnnotationType::Type.into(), AnnotationType::String.into()]
} else {
@@ -344,6 +393,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
effect: CallEffect::ReadOnly,
};
if name == "event" {
// 原地更新 Event 工厂,TypeConstructor 中已保存的对象引用继续有效。
vm.replace_payload(
function,
ContextNativeFunction::new(
@@ -355,6 +405,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
),
)?;
} else {
// 这里只有 ObjectList;保留 desktop 捕获,以便专用分配器给结果附加列表类型。
vm.replace_payload(
function,
ContextNativeFunction::new(
@@ -369,6 +420,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
}
}
}
// 对这批公开入口补 Function 类型身份;不重新分配 callable,ui 属性仍指向原对象。
for name in [
"open",
"text",
@@ -390,6 +442,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
"implements",
"supports",
] {
// 从 ui 取回前面已补完签名的函数;名称表与安装表不一致会在这里报错而非静默漏装。
let function = object(Some(&vm.get_attr(ui, name)?))?;
vm.attach_type(function, function_type)?;
}
@@ -437,7 +490,9 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
("Page", "back", Operation::Back),
("Page", "forward", Operation::Forward),
] {
// expected 同时用于方法所属类型、receiver 校验和签名中的名义参数身份。
let expected = object(Some(&vm.get_attr(ui, type_name)?))?;
// VM 的方法分派负责绑定 receiver;TypeMethod 再验证它并转交 Function 执行业务操作。
let method = vm.allocate(ContextNativeFunction::new(
name,
TypeMethod {
@@ -451,20 +506,26 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
))?;
roots.push(method);
vm.attach_type(method, function_type)?;
// 写到类型方法表而非每个实例的属性,实例与源子类可共享同一个原生实现。
vm.define_method(expected, name, method)?;
}
// 类型与基础方法就绪后再装窗口、默认视觉、事件方法和能力契约,顺序满足各模块查类型/方法的前提。
crate::window::install(vm, roots, desktop, tabs)?;
// 默认 measure/arrange/paint 安装到 Block 体系;源代码之后可按协议覆盖它们。
let block_type = object(Some(&vm.get_attr(ui, "Block")?))?;
crate::native_visual::install(vm, roots, desktop, block_type, function_type)?;
// 事件方法需要识别 Event 实例;订阅返回值则使用 Subscription 身份管理取消连接。
let event_type = object(Some(&vm.get_attr(ui, "Event")?))?;
// token_type 传给事件安装器,不能拿 Event 类型代替,否则取消令牌会违反返回契约。
let token_type = object(Some(&vm.get_attr(ui, "Subscription")?))?;
crate::event_dispatch::install(vm, roots, event_type, token_type, function_type)?;
crate::protocols::install(vm, roots, desktop, ui)?;
// 每个桥接域配套自己的任务 Scheduler,再接工具入口及托管起始页工厂,最后返回可使用的 UiBridge。
// Scheduler 随 UiBridge 交还运行时;它还未开始执行用户任务,只完成 ui 异步 API 注册。
let tasks = crate::tasks::install(vm, roots, ui, desktop)?;
crate::bridge::graphics::install(vm, roots, ui, desktop)?;
developer_pages::install(vm, ui, &session)?;
// 将语言命名空间、展示会话与任务调度打包,运行时随后通过同一 bridge 推进刷新与回调。
let bridge = UiBridge { ui, session, tasks };
developer_pages::install_start_page(vm, &bridge)?;
Ok(bridge)
+169 -7
View File
@@ -1,46 +1,57 @@
//! 把 Endor 方法调用分派到对应宿主操作。这里处理构造、树修改、绑定、状态与页面能力。
//! 先检查参数和只读阶段限制,再修改托管状态;对象引用的生命周期仍交给 NativeContext 和 Aster 管理。
//!
//! Native Block operations executed on the owning VM worker.
//! 调用来源:VM 直接执行 bootstrap 注册的 ui 自由函数,或由 TypeMethod/NativeFactory 剥离接收者/Type 后转交。
//! Function.owner 保存绑定对象,requested_type 保存原生子类构造请求;本文件 args 只包含显式原生参数。
//! 简单分支同步返回 NativeResult::Return;需要执行源协议/事件处理器的分支返回 Invoke,让 VM 接着推进调用栈。
//! NativeContext 追踪本次分配和 upgrade 的临时根,返回/委托时提升输出引用,其余在域退出时释放。
use super::*;
impl NativeCallable for Function {
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
/// VM 在调用边界查询 bootstrap/bind 写入的签名;由外层已校验的临时转交 Function 可返回 None。
fn signature(&self) -> Option<CallSignature> {
self.signature.clone()
}
/// 接收已经剥离 receiver 的显式参数,数量沿用 Operation 的范围。
/// VM 查询自由函数的实参数量,或供转交适配器使用;这里不再给 receiver/Type 首参加一。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(self.operation.arity())
}
/// 统一校验数量与只读限制,再按 Operation 分派构造、树修改、页面、绑定、状态与能力调用,返回托管结果或 VM 续调用。
/// VM 或外层原生调用适配器进入的实际业务分派,返回借用结果或委托;不负责 GUI 帧的原子发布。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
use Operation::*;
// 外层 TypeMethod/NativeFactory 会直接调用本方法,因此这里仍需防御性检查参数数量。
let arity = self.operation.arity();
// 显式区间不包含已剥离的 receiver/Type,后面 args[0] 等索引依赖这次验证。
let (minimum, maximum) = (arity.required, arity.maximum);
// Function 接收已剥离 receiver 的显式参数;先校验数量,后面索引 args 才安全。
if !(minimum..=maximum).contains(&args.len()) {
// 在任何分配或模型修改之前拒绝数量不符,避免越界或带半次副作用的失败。
return Err(invalid("incorrect UI method argument count"));
}
if matches!(self.operation, Camera2D) {
// 独立相机不是 Block,不进入下方的视觉 Data.kind 构造;requested_type 保留源子类。
let camera = new_camera2d(cx, &self.session, self.requested_type, args)?;
return Ok(NativeResult::Return(Value::Object(camera)));
}
// 3D 构造与普通 Block 分开,requested_type 保留源子类名义身份,否则从本 desktop 取默认类型。
if let SceneObject(kind) = self.operation {
// 只复制当前类型域身份,放锁后进入三维分配器;该分配器校验 kind 的实参与子类约束。
let desktop = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?
.desktop;
// 类型构造保留请求身份,自由工厂选择当前 desktop 中 kind.name 对应的默认类型。
let definition =
self.requested_type
.unwrap_or(desktop_type(cx.store(), kind.name(), desktop)?);
// model::scene3d 建立托管几何/场景对象,此刻不投影、不光栅化也不建立 OS 资源。
let object = crate::scene3d_model::allocate(cx, desktop, definition, kind, args)?;
return Ok(NativeResult::Return(Value::Object(object)));
}
if matches!(self.operation, CameraControl) {
// receiver 是控制器,两个显式参数是相机与手势;实际状态更新由 scene3d_model::control 解释。
crate::scene3d_model::control(
cx,
self.owner
@@ -51,6 +62,7 @@ impl NativeCallable for Function {
return Ok(NativeResult::Return(Value::Nil));
}
if matches!(self.operation, Translate3D | Rotate3D) {
// 三分量实参共用转换入口,末尾布尔值区分旋转/位移;对象缺少 owner 时直接报错。
crate::scene3d_model::transform(
cx,
self.owner
@@ -62,9 +74,12 @@ impl NativeCallable for Function {
}
// implement 修改显式能力关系;implements 查类型,supports 查对象实际能力,不靠同名字段鸭子类型推断。
if matches!(self.operation, Implement | Implements | Supports) {
// 第二实参必须是托管 Trait,注册和查询共用身份,但标量 supports 仍需先验证 Trait 本身。
let capability = object(args.get(1))?;
// 此分支计算语言返回值,随后统一包 Return;只有 Implement 真正修改类型关系。
let value = match self.operation {
Implement => {
// 第一参数是 Type,VM 的 implement_trait 检查能力契约并建立托管引用关系。
cx.implement_trait(object(args.first())?, capability)?;
Value::Nil
}
@@ -73,24 +88,30 @@ impl NativeCallable for Function {
.implements_trait(object(args.first())?, capability)?,
),
Supports => {
// Validate the contract even for immediate scalar values.
// 即使第一参数是标量也先验证第二参数真为 Trait;不能用 false 掩盖错误能力对象。
cx.store()
.payload::<endor_core::EndorTraitObject>(capability, "Trait")?;
Value::Bool(match args[0] {
// 对象按实际能力契约查询,标量没有这里的宿主对象能力而返回 false。
Value::Object(value) => cx.store().supports(value, capability)?,
_ => false,
})
}
// 外层 matches 已限定三种操作,其他枚举值到这里表示分派实现内部不一致。
_ => unreachable!(),
};
return Ok(NativeResult::Return(value));
}
if matches!(self.operation, IsInstance | IsSubtype) {
// 当前实现要求两者都解码成对象:签名中的 Any 不等于可在这里传 nil 后获得 false。
let candidate = object(args.first())?;
// expected 必须属于此 VM 的真实类型体系;关系查询不按类型名字字符串比较。
let expected = object(args.get(1))?;
// 实例关系检查对象的类型,子类型关系检查两个 Type 的继承关系,不能混为同一查询。
let value = if matches!(self.operation, IsInstance) {
cx.store().is_instance(candidate, expected)?
} else {
// isSubtype 用于类型间关系,不把 candidate 当实例读取其 type_of。
cx.store().is_subtype(candidate, expected)?
};
return Ok(NativeResult::Return(Value::Bool(value)));
@@ -98,48 +119,61 @@ impl NativeCallable for Function {
// 显式失效属于模型变更,只读阶段拒绝;paint/layout/content 精度不同,后续分别清理对应缓存。
if matches!(self.operation, Invalidate) {
if cx.store().is_read_only() {
// measure/paint 等只读域不能靠显式失效反复制造下一轮更新反馈。
return Err(invalid(
"invalidation is forbidden during read-only rendering",
));
}
// 仅省略参数才默认 layout,错误类型不回退;transpose 保留显式解析失败。
let reason = args
.first()
.map(|value| text(Some(value)))
.transpose()?
.unwrap_or_else(|| "layout".into());
if !matches!(reason.as_str(), "paint" | "layout" | "content") {
// 精度标签必须已知,否则不能可靠决定清哪些缓存和依赖集合。
return Err(invalid(
"invalidation reason must be paint, layout or content",
));
}
// TypeMethod 已把 receiver 移入 owner,没有绑定对象的自由调用不能执行此方法。
let owner = self.owner.ok_or(invalid("invalidation requires a Block"))?;
// 以解析后的共享 Data 身份归并别名,不能只让调用者这一个包装对象失效。
let (data, _) = block::resolve(cx.store(), owner)?;
// 锁内只遍历弱节点并更新派生失效状态,不在这里调用用户布局/绘制方法。
let mut session = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?;
// 去重后的 Block 身份集合供每个窗口判断是否包含同一份受影响的共享业务状态。
let mut affected = std::collections::HashSet::new();
// 找出解析到同一底层 Data 的所有别名,失效共享数据时不能只刷新调用者包装身份。
for weak in &session.nodes {
if let Some(block) = cx.upgrade(*weak)?
&& block::resolve(cx.store(), block)?.0 == data
{
// NativeContext 持有升级临时根直到本次调用结束,不为弱索引永久添加拥有关系。
affected.insert(block);
}
}
for presentation in session.presentations.values_mut() {
// 同一 Data 可以出现在多个窗口,失效通知必须广播,不能仅清 current 的缓存。
presentation.invalidate(cx, &affected, &reason)?;
}
return Ok(NativeResult::Return(Value::Nil));
}
if matches!(self.operation, Mounts) {
// 返回的是这个 Block 的具体展示出现,不能把底层 Data 相同的其他别名也算到 owner 上。
let owner = self.owner.ok_or(invalid("mount query requires a Block"))?;
// 查全部窗口,锁内只读取原生 Mount 表与托管 payload,不执行源回调。
let session = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?;
// 暂留窗口/出现 ID 作为排序键,最后列表只暴露 Mount 对象,不暴露宿主元组。
let mut positions = Vec::new();
for (window, presentation) in &session.presentations {
// 每窗活动表只有当前仍展示的出现,死亡弱项和其他 Block 的出现都不加入结果。
for (id, weak) in &presentation.mounts.active {
if let Some(object) = cx.upgrade(*weak)?
&& cx
@@ -154,6 +188,7 @@ impl NativeCallable for Function {
}
// 跨窗口 Mount 查询按窗口/出现 ID 稳定排序,不依赖 HashMap 遍历顺序;返回每个具体出现而非几何副本。
positions.sort_unstable_by_key(|(window, id, _)| (*window, *id));
// 用专用托管集合保留结果引用和只读访问契约,不把 Rust Vec 直接当源值返回。
let set = crate::mount::collection(
cx,
positions.into_iter().map(|(_, _, object)| object).collect(),
@@ -162,25 +197,31 @@ impl NativeCallable for Function {
return Ok(NativeResult::Return(Value::Object(set)));
}
if matches!(self.operation, NewWindow) {
// 持锁分配新的窗口 ID 并建立 Presentation,create_window 还会检查只读/退出/数量限制。
let mut state = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?;
// Window 只能通过 Type 构造入口创建,缺少 requested_type 说明不是合法的类型调用。
let definition = self
.requested_type
.ok_or(invalid("Window constructor requires a type"))?;
// 返回托管资源对象,原生窗口由随后发布的帧/命令驱动 GUI 创建。
let window = create_window(cx, &mut state, definition)?;
return Ok(NativeResult::Return(Value::Object(window)));
}
if matches!(self.operation, CloseWindow) {
// 资源对象来自 bound receiver,不用 current 猜要关闭哪个窗口。
let window = self
.owner
.ok_or(invalid("window close requires a receiver"))?;
// 用 payload 的资源 ID 定位 Presentation;关闭状态由 window::close 在模型中维护。
let id = cx
.store()
.payload::<crate::window::ManagedWindow>(window, "Window")?
.id;
crate::window::close(cx, window)?;
// model 关闭成功后才改宿主展示状态,关闭失败不能提前标记为已退场。
let mut state = self
.session
.lock()
@@ -195,6 +236,7 @@ impl NativeCallable for Function {
return Ok(NativeResult::Return(Value::Nil));
}
if matches!(self.operation, Window) {
// ui.window() 查询当前选择窗口的资源对象;和 Window() 分配新窗口的路径不同。
let desktop = self
.session
.lock()
@@ -205,15 +247,19 @@ impl NativeCallable for Function {
));
}
if matches!(self.operation, Viewport) {
// ui.viewport() 从当前窗口的输入对象生成结果,不直接读取 winit/OS 尺寸。
let desktop = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?
.desktop;
// 运行时还没提供尺寸时属性为 nil,明确报不可用,不把它伪装成零宽高。
let Value::Object(input) = cx.store().get_attr(desktop, "viewport")? else {
return Err(invalid("window viewport is unavailable"));
};
// 两个普通属性读取参与 VM 依赖跟踪,尺寸变化可让调用方的测量失效。
let width = cx.store().get_attr(input, "width")?;
// 返回 MeasureResult 协议值而非把内部输入对象本身暴露给源程序修改。
let height = cx.store().get_attr(input, "height")?;
return Ok(NativeResult::Return(Value::Object(
crate::native_visual::record(
@@ -227,15 +273,19 @@ impl NativeCallable for Function {
// 诊断会读对象总数与宿主变化统计,禁止在只读渲染中观察并形成反馈依赖。
if matches!(self.operation, Stats) {
if cx.store().is_read_only() {
// 即使签名标 External,这里仍防御直接适配器调用,防止布局自观察计数形成反馈。
return Err(invalid(
"runtime statistics are unavailable during read-only rendering",
));
}
// 各窗口共享的计数与当前展示派生计数一起投影,锁内不推进刷新。
let session = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?;
// blocks/pages 数的是成功升级的存活业务对象,而不是弱表历史长度或 Mount 出现数。
let mut blocks = 0_i64;
// 页面是 Block 的子集,同一页跨窗口迁移不会增加业务页面计数。
let mut pages = 0_i64;
for weak in &session.nodes {
if let Some(object) = cx.upgrade(*weak)? {
@@ -245,8 +295,11 @@ impl NativeCallable for Function {
}
}
}
// 统计对象总数在分配 stats 结果之前采样,避免把本次诊断结果计入自己。
let objects = cx.store().object_count() as i64;
// session 的 Deref 指向 current,tabs/mounts 与布局计数属于当前窗口,而非跨窗求和。
let tabs = cx.store().dict_items(session.tabs)?.len() as i64;
// 新普通对象只装原生数值/文本,不把 VM store 或 Session 句柄暴露给程序。
let stats = cx.new_object()?;
for (name, value) in [
("objects", objects),
@@ -273,8 +326,10 @@ impl NativeCallable for Function {
session.dependency_invalidations as i64,
),
] {
// 统一转成语言整数,字段名字是 ui.stats 的观察接口,不影响调度逻辑。
cx.set_attr(stats, name, Value::Number(value.into()))?;
}
// 单独保留文本失效原因,数值计数不能解释究竟哪条源属性触发了重算。
cx.set_attr(
stats,
"lastInvalidDependency",
@@ -297,6 +352,7 @@ impl NativeCallable for Function {
}
// 状态是普通名义 Object,value 属性经 VM 追踪,set/increment 方法捕获同一对象,不额外建立原生状态副本。
if matches!(self.operation, State) {
// value 及绑定方法都是普通托管属性,GC 能追踪回调和状态之间的真实边。
let state = cx.new_object()?;
attach_named_type(cx, state, "Object", &self.session)?;
cx.set_attr(state, "value", args[0].clone())?;
@@ -305,10 +361,13 @@ impl NativeCallable for Function {
return Ok(NativeResult::Return(Value::Object(state)));
}
if matches!(self.operation, StateSet | Increment) {
// 状态对象由 bind 捕获,不能把 args[0] 当 receiver 再切一次参数。
let owner = self.owner.ok_or(invalid("state method has no owner"))?;
// set 接受任意新值;increment 则对当前 value 做精确整数读取与溢出检查。
let value = if matches!(self.operation, StateSet) {
args[0].clone()
} else {
// 先限制 Number 表示,再从中取可表示 i64 的整数;不截断十进制或二进制小数。
let Value::Number(value) = cx.store().get_attr(owner, "value")? else {
return Err(invalid("increment requires an integer state"));
};
@@ -319,22 +378,28 @@ impl NativeCallable for Function {
.ok_or(invalid("integer state increment exceeds its range"))?;
Value::Number(next.into())
};
// 通过统一属性写入口更新版本,textOf/绑定的依赖失效因此能被下一轮刷新观察。
cx.set_attr(owner, "value", value)?;
return Ok(NativeResult::Return(Value::Nil));
}
if matches!(self.operation, TextOf) {
// 先保存被观察对象和属性名;这里不是把当前值立即格式化成一个永不更新的字符串。
let source = object(args.first())?;
// 属性名必须是真字符串,不能从任意对象 repr 推导不稳定的名字。
let field = text(args.get(1))?;
// textOf 建立前确认字段可读取,具体字符串化与依赖记录发生在快照提取时。
cx.store().get_attr(source, &field)?;
// 字号可选但必须是等级整数,缺省为 1;颜色在后面按独立可选参数解析。
let size = args
.get(2)
.map(|v| integer(Some(v)))
.transpose()?
.unwrap_or(1);
if !(1..=8).contains(&size) {
// 数字能转为整数不代表是受支持等级,在分配 TextBlock 之前拒绝越界。
return Err(invalid("text size must be 1 through 8"));
}
// 初始文字为空,真正内容留给快照读取 source.field;构造不冻结当前属性值。
let mut data = Data::new(
Kind::Text {
value: String::new(),
@@ -355,6 +420,7 @@ impl NativeCallable for Function {
}
// 普通组件先组装 Data,再统一由 new_typed_block 安装 Cell、属性和名义类型;不直接写原生窗口。
let constructor = match self.operation {
// 滚动视口有限宽高和唯一内容 Block;滚动轴/偏移由属性和 Mount 后续处理。
Scroll => Some(Data::new(
Kind::Scroll {
width: dimension(args.first())?,
@@ -364,6 +430,7 @@ impl NativeCallable for Function {
0.0,
0.0,
)),
// 文本是叶节点,size 的 u64→u32 转换和下面统一 1..=8 检查分别限制表示与语义。
Text => Some(Data::new(
Kind::Text {
value: text(args.first())?,
@@ -380,6 +447,7 @@ impl NativeCallable for Function {
0.0,
0.0,
)),
// 宽高必填,圆角缺省零,颜色缺省浅蓝;每个尺寸都先经有限非负检查。
Rect => Some(Data::new(
Kind::Rect {
width: dimension(args.first())?,
@@ -395,18 +463,23 @@ impl NativeCallable for Function {
0.0,
0.0,
)),
// 空白只提供主轴占位,不带孩子或绘图颜色。
Spacer => Some(Data::new(
Kind::Spacer(dimension(args.first())?),
vec![],
0.0,
0.0,
)),
// 三种容器共用参数解码,Kind 决定后续横排/纵排/叠放,不在这里计算子项坐标。
Row | Column | Overlay => Some(Data::new(
if matches!(self.operation, Row) {
// 行布局随后沿水平主轴累计孩子位置。
Kind::Row
} else if matches!(self.operation, Overlay) {
// 叠放布局共享内容原点,不采用线性 gap 累加位置。
Kind::Overlay
} else {
// 此入口仅剩 Column,后续沿纵向排列。
Kind::Column
},
// 线性容器固定前两参为 padding/gap,后续都是孩子对象;组成合法性在 Block 分配前统一检查。
@@ -417,7 +490,9 @@ impl NativeCallable for Function {
dimension(args.first())?,
dimension(args.get(1))?,
)),
// 空 Page 尚未登记到任何窗口,直到 render/open 才安排展示。
Page => Some(Data::new(Kind::Page, vec![], 0.0, 0.0)),
// 二维场景只有孩子对象序列;各对象的 x/y 等世界属性在快照提取时读取。
Scene2D => Some(Data::new(
Kind::Scene2D,
args.iter()
@@ -427,6 +502,7 @@ impl NativeCallable for Function {
0.0,
)),
CanvasViewport => {
// 视口自身参加有限布局,第三实参保存 Scene2D 引用,不把场景孩子展开成行列内容。
let mut data = Data::new(
Kind::CanvasViewport {
width: dimension(args.first())?,
@@ -441,12 +517,15 @@ impl NativeCallable for Function {
Some(data)
}
Viewport3D => {
// 当前 desktop 提供相机/控制器的名义类型,所有默认资源在同一 VM 内创建。
let desktop = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?
.desktop;
// 默认相机使用透视投影;这里分配模型对象,不立即生成场景渲染快照。
let definition = desktop_type(cx.store(), "PerspectiveCamera", desktop)?;
// 空参数选择场景模块的默认姿态,之后源属性仍可显式换相机或改参数。
let camera = crate::scene3d_model::allocate(
cx,
desktop,
@@ -454,6 +533,7 @@ impl NativeCallable for Function {
crate::scene3d_model::Constructor::Perspective,
&[],
)?;
// 普通 children 先为空,世界场景独立存于 scene3d 字段;面板 UI 会在提取时派生。
let mut data = Data::new(
Kind::Viewport3D {
width: dimension(args.first())?,
@@ -465,6 +545,7 @@ impl NativeCallable for Function {
);
data.camera = Some(camera);
data.scene3d = Some(object(args.get(2))?);
// 每个新视口独立控制器,避免两个不同相机的输入共享原生控制状态。
let definition = desktop_type(cx.store(), "OrbitController", desktop)?;
// 三维视口默认独立透视相机及 OrbitController,Scene3D 引用则来自调用者,可显式共享世界。
data.controller = Some(crate::scene3d_model::allocate(
@@ -478,6 +559,7 @@ impl NativeCallable for Function {
}
// 按钮标签也是普通 TextBlock 孩子,布局和生命周期沿同一组成树,不是原生按钮硬编码文字。
Button => {
// 先创建真实文字孩子,让按钮尺寸和文本测量、字体缓存、子树生命周期保持一致。
let label = new_block(
cx,
Data::new(
@@ -494,6 +576,7 @@ impl NativeCallable for Function {
)?;
Some(Data::new(Kind::Button, vec![label], 10.0, 0.0))
}
// 非构造操作继续进入页面/实例方法分派;None 不是失败,也不创建占位对象。
_ => None,
};
// 统一检查文本等级和 UTF-8 字节量,再按 requested_type 生成实际对象,保留子类工厂身份。
@@ -501,6 +584,7 @@ impl NativeCallable for Function {
if let Kind::Text { size, value, .. } = &data.kind
&& (!(1..=8).contains(size) || value.len() > 16_384)
{
// 两条文本工厂共用语义限制;防止类型工厂参数能转 u32 却超出实际支持字号等级。
return Err(invalid("text exceeds size limits"));
}
return Ok(NativeResult::Return(Value::Object(new_typed_block(
@@ -513,7 +597,9 @@ impl NativeCallable for Function {
if matches!(self.operation, OpenValue | Present)
&& let Value::Object(value) = args[0]
{
// 对象值可能实现 Presentable;标量不可能走这一协议路径,稍后按普通文字转换处理。
let (desktop, current) = {
// master 用于共享能力类型,current 用于传给源 present 的窗口资源,二者不能混用。
let state = self
.session
.lock()
@@ -530,15 +616,20 @@ impl NativeCallable for Function {
.is::<block::Block>()
&& cx.store().supports(value, capability)?
{
// 只有非 Block 且显式实现能力的对象才执行源 present,不能凭同名属性鸭子类型推断。
// 能力实现依附于类型,需从该类型的方法表取 present,而非随意的实例属性。
let definition = cx
.store()
.type_of(value)?
.ok_or(invalid("untyped Presentable"))?;
// lookup_method 处理继承规则,缺少实现即能力契约错误。
let function = cx
.store()
.lookup_method(definition, "present")?
.ok_or(invalid("missing present method"))?;
// 源 present 接收实际目标窗口,不能总把主窗口传给副窗口中的调用。
let window = object(Some(&cx.store().get_attr(current, "window")?))?;
// 源方法返回后由 PresentResult 检查真实 Block,并按 open 标志决定是否包装 Page。
let complete = cx.allocate(ContextNativeFunction::new(
"validate presentation",
PresentResult {
@@ -559,6 +650,7 @@ impl NativeCallable for Function {
endor_core::Instruction::new(endor_core::Opcode::Call, 1),
endor_core::Instruction::simple(endor_core::Opcode::ReturnValue),
];
// 常量表按字节码的 0..3 索引依次提供续接、源方法、receiver 和 Window。
let code = endor_core::EndorCode::from_instructions(
&instructions,
vec![
@@ -570,8 +662,11 @@ impl NativeCallable for Function {
endor_core::CodeLayout::default(),
)?
.with_location("Presentable.present", "<tessera>", 1);
// 模板包装成托管代码后,驱动函数的强边负责维持常量/代码存活。
let code = cx.allocate(endor_core::EndorCodeObject::new(code))?;
// 驱动只访问常量,不需要用户命名空间,私有空字典避免污染全局。
let globals = cx.new_dict()?;
// 返回 Invoke 把驱动接入同一 VM 帧循环,不在 NativeContext 内递归进入 VM。
let driver = cx.new_function("Presentable.present", code, globals)?;
attach_desktop_type(cx, driver, "Function", desktop)?;
return Ok(NativeResult::Invoke {
@@ -582,10 +677,13 @@ impl NativeCallable for Function {
}
// ui.open 把普通结果转换为根组件,再在必要时包一层 Page 并展示;ui.present 仅转换,不自动打开页面。
if matches!(self.operation, OpenValue) {
// 协议对象已在上面返回 Invoke;这里仅处理已有 Block 或普通值的文字回退。
let root = present_value(cx, &args[0], &self.session)?;
// Page 可直接保留,普通 Block 外包一层新 Page,以便加入窗口的页面注册表。
let page = if cx.store().payload::<block::Block>(root, "Block")?.page {
root
} else {
// 包装持有原 root,不复制业务对象,因此源对象/状态仍可继续被其他位置展示。
new_block(
cx,
Data::new(Kind::Page, vec![root], 0.0, 0.0),
@@ -596,6 +694,7 @@ impl NativeCallable for Function {
return Ok(NativeResult::Return(Value::Object(page)));
}
if matches!(self.operation, Present) {
// 只做值到视觉的转换,不调用 show,不改变活动页或窗口归属。
return Ok(NativeResult::Return(Value::Object(present_value(
cx,
&args[0],
@@ -605,7 +704,9 @@ impl NativeCallable for Function {
// 从这里起都是需要接收者的 Block/Page 方法,未绑定 owner 时不能继续修改任意全局对象。
let owner = self.owner.ok_or(invalid("method has no owner"))?;
if matches!(self.operation, Bind) {
// 属性名与计算函数分开解码;connect 建立依赖和可取消 Subscription,不立即变成静态值。
let field = text(args.first())?;
// 函数对象的签名/效果由绑定模块检查,不能只因它是 Object 就当作合法计算闭包。
let compute = object(args.get(1))?;
return Ok(NativeResult::Return(Value::Object(
crate::binding::connect(
@@ -622,17 +723,22 @@ impl NativeCallable for Function {
}
// moveTab 重排同窗口页面索引并强制下一帧更新标签;移动的是原 Page,非新页面副本。
if matches!(self.operation, MoveTab) {
// 页面位置是当前顺序下标,不是 Tab ID 或子组成 slot,先做平台 usize 表示检查。
let position = usize::try_from(integer(args.first())?)
.map_err(|_| invalid("tab position out of range"))?;
// 同锁内找所属窗口并重排,不能只用 current:源代码可持有其他窗口的 Page。
let mut state = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?;
// 未登记的 Page 没有可重排标签,不能自动打开它再进行移动。
let id = page_window(cx.store(), &state, owner)?.ok_or(invalid("Page is not open"))?;
// 对真实所属窗口设置强制帧,确保即使内容根没变,标签顺序仍会发布。
let view = state
.presentations
.get_mut(&id)
.ok_or(invalid("missing window"))?;
// model/window 维护实际顺序,Presentation 只负责其派生投影和刷新标记。
let window = object(Some(&cx.store().get_attr(view.desktop, "window")?))?;
crate::window::move_page(cx, window, owner, position)?;
view.force_frame = true;
@@ -640,13 +746,17 @@ impl NativeCallable for Function {
}
// 接收者可能是共享转发别名,解析到最终 Data 后修改副本,最后用一次 replace_payload 写入结构边。
let (data_ref, data) = block::resolve(cx.store(), owner)?;
// 在副本上编辑结构,再经末尾 replace_payload 更新强边与版本,避免直接篡改借用 payload。
let mut data = data.clone();
// 默认返回 Unit/Nil,append/insert/订阅等分支会改成自己的业务结果;查询分支直接返回。
let mut result = Value::Nil;
match self.operation {
SetText => match &mut data.kind {
Kind::Text { value, .. } => {
// 新文字必须为真实字符串且受 UTF-8 字节预算约束,再替换 payload/普通属性。
let next = text(args.first())?;
if next.len() > 16_384 {
// 检查在写属性前,错误输入不会先清掉旧 text_binding 或部分改文字。
return Err(invalid("text exceeds 16 KiB"));
}
*value = next;
@@ -654,21 +764,27 @@ impl NativeCallable for Function {
data.text_binding = None;
cx.set_attr(owner, "text", args[0].clone())?;
}
// receiver 的名义类型校验之外再验证 payload,避免对不兼容原生种类写文本。
_ => return Err(invalid("setText requires a text Block")),
},
SetColor => match &mut data.kind {
Kind::Text { color: value, .. } | Kind::Rect { color: value, .. } => {
// 颜色格式先校验,随后把普通 color 属性与本地 payload 副本保持一致。
*value = color(args.first(), "#242422")?;
cx.set_attr(owner, "color", Value::Text(value.clone()))?;
}
// 容器/空白不支持这一颜色修改接口,不能往无关 payload 中补出虚构颜色字段。
_ => return Err(invalid("this Block has no color")),
},
Append | Insert | Remove | Replace | Move => {
if !data.is_container() {
// 只允许有组成语义的容器;叶 Block 不因调用 append 而隐式变成 Column。
return Err(invalid("expected a container Block"));
}
if matches!(self.operation, Append | Insert) {
// 新增边需要新 slot;append 取末尾,insert 的首参是可插入缝位置。
let (position, argument) = if matches!(self.operation, Insert) {
// 平台索引转换和容器长度检查分开,len 本身允许表示“插到末尾”。
let position = usize::try_from(integer(args.first())?)
.map_err(|_| invalid("child position out of range"))?;
if position > data.children.len() {
@@ -676,11 +792,14 @@ impl NativeCallable for Function {
}
(position, args.get(1))
} else {
// append 不接位置,唯一实参就是新孩子;不会误把它解析成索引。
(data.children.len(), args.first())
};
// 统一孩子对象解码;validate 再检查真实 Block 和无环组成。
let child = object(argument)?;
// 插入前禁止孩子图回到当前 Data,避免循环组成;position 是插入位置,返回值却是稳定 slot。
block::validate(cx.store(), child, Some(data_ref))?;
// 保存当前高水位作为返回身份,再推进 next_slot,删除旧边也不回退该计数。
let slot = data.next_slot;
// slot 单调增加且限制到 i64 正范围,不复用已删除身份;前后插入/重排不会把其他孩子 slot 改号。
data.next_slot = slot
@@ -692,6 +811,7 @@ impl NativeCallable for Function {
} else {
// remove/replace/move 首参为稳定 slot 而非当前数组下标;只有 move 的第二参才是目标位置。
let slot = integer(args.first())?;
// 在当前顺序中查该稳定身份的位置,不能直接把 slot 转 usize 当数组下标。
let index = data
.children
.iter()
@@ -699,31 +819,39 @@ impl NativeCallable for Function {
.ok_or(invalid("child slot does not exist"))?;
match self.operation {
Remove => {
// 删除这一稳定边,不重新编号其余孩子;是否销毁 Block 由托管引用关系决定。
data.children.remove(index);
}
Replace => {
// 新孩子先过无环/类型校验,再替换旧边对象;边 slot 保持不变。
let child = object(args.get(1))?;
block::validate(cx.store(), child, Some(data_ref))?;
// replace 保留该组成边 slot,但换实际 Block;出现键还含 Block 身份,因此可能产生新的 Mount 上下文。
data.children[index].block = child;
}
Move => {
// 第二参是目标顺序下标,首参仍是稳定 slot;目标必须是已有序列内位置。
let to = usize::try_from(integer(args.get(1))?)
.map_err(|_| invalid("child position out of range"))?;
if to >= data.children.len() {
// move 不新增孩子,因此 len 是越界,不同于 insert 允许插到末尾的 len。
return Err(invalid("child position out of range"));
}
// 移动完整 Child 值,slot 与对象身份都保持,不分配新的出现组成键。
let child = data.children.remove(index);
data.children.insert(to, child);
}
// 外层已排除 append/insert,此处只可能是 remove/replace/move。
_ => unreachable!(),
}
}
}
// children 查询返回 slot文本->Block 的托管字典,不把当前顺序假装成稳定 slot 连号数组。
Children => {
// 查询创建新字典但不改原容器;key 是 slot 文本,迭代顺序不作为下标身份保证。
let result = cx.new_dict()?;
for child in data.children {
// VM 字典赋值记录真实 Block 强边,调用结果可安全独立持有孩子而非引用 Rust 临时切片。
cx.dict_set(
result,
Value::Text(child.slot.to_string()),
@@ -734,17 +862,21 @@ impl NativeCallable for Function {
}
Render | Navigate => {
if !matches!(data.kind, Kind::Page) {
// 只有 Page 有根替换与导航历史,不能对普通 ContainerBlock 偷用页面语义。
return Err(invalid("expected a Page"));
}
// 新内容须真实 Block 且不能包含当前 Page 的共享 Data,防止出现循环组成。
let root = object(args.first())?;
block::validate(cx.store(), root, Some(data_ref))?;
if matches!(self.operation, Navigate) {
// 首次 navigate 把当前根纳入历史,之后新导航先截断前进分支,再追加新根,保持浏览历史语义。
if data.history.is_empty() {
if let Some(old) = data.children.first() {
// 首次导航才把现有根作为历史起点;空 Page 没有可保留的起始根。
data.history.push(old.block);
}
} else {
// 从后退位置发起新导航会丢弃原前进分支,不能保留两条互相矛盾的线性历史。
data.history.truncate(data.history_index + 1);
}
data.history.push(root);
@@ -760,12 +892,15 @@ impl NativeCallable for Function {
}
Back | Forward => {
if data.history.is_empty() {
// 无历史是正常无动作,不把索引减法做在空列表上,也不制造一次空展示请求。
return Ok(NativeResult::Return(Value::Nil));
}
// 后退/前进钳到历史首尾,复用已保留根对象而非重新执行旧源码。
let next = if matches!(self.operation, Back) {
// saturating_sub 在首项停住,不绕回列表尾部。
data.history_index.saturating_sub(1)
} else {
// 前进上界为最后已有项,不为不存在的未来页分配对象。
(data.history_index + 1).min(data.history.len() - 1)
};
data.history_index = next;
@@ -777,11 +912,14 @@ impl NativeCallable for Function {
}
// 分离先创建新 Window,再把同一 Page 展示到它;跨窗口出现上下文由后续展示同步重建。
Detach => {
// 短锁中只创建资源,锁释放后再 show_in,避免内部二次锁定同一个 Session。
let window = {
// 新窗口编号和 Presentation 同步建立,使用主注册域的 Window 默认类型。
let mut state = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?;
// detach 创建默认 Window,不继承 Page 的源类型作为窗口类型。
let definition = desktop_type(cx.store(), "Window", state.master)?;
create_window(cx, &mut state, definition)?
};
@@ -789,24 +927,29 @@ impl NativeCallable for Function {
return Ok(NativeResult::Return(Value::Object(window)));
}
PageWindow => {
// 这是查询:按真实注册表找 owner 的归属,不能直接返回当前窗口冒充其宿主。
let state = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?;
// 未打开/已关闭 Page 正常返回 nil,签名为 nullable(Window)。
let value = if let Some(id) = page_window(cx.store(), &state, owner)? {
cx.store()
.get_attr(state.presentations[&id].desktop, "window")?
} else {
// 仍存活的 Page 可以暂时没有窗口归属,不因此新建窗口或报错。
Value::Nil
};
return Ok(NativeResult::Return(value));
}
Open | Activate | MoveWindow => {
// moveToWindow 必须明确目标;open/activate 的缺省则保留原归属或使用当前窗口。
if matches!(self.operation, MoveWindow)
&& matches!(args.first(), None | Some(Value::Nil))
{
return Err(invalid("Page.moveToWindow requires a Window"));
}
// None 与显式 nil 在 open 路径等价;非空实参先解码对象,show_in 再检查 Window payload/域。
let target = match args.first() {
None | Some(Value::Nil) => None,
Some(value) => Some(object(Some(value))?),
@@ -816,20 +959,23 @@ impl NativeCallable for Function {
}
// unmount 清内容但不关闭 Page 标签,也不直接等同于销毁整个业务对象。
Unmount => {
// 内容强边在统一 replace_payload 时撤销,Page 自己仍在标签注册表中可再次 render。
data.children.clear();
}
Close => {
// 关闭 Page 当场取消页面和所属 Mount 任务,即使同一输入稍后又打开它,也不能复活旧异步工作。
crate::tasks::cancel_owner(cx, owner)?;
// 找出现位置并撤登记的过程只操作宿主表/任务,不在锁内执行用户生命周期钩子。
let mut state = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?;
if let Some(id) = page_window(cx.store(), &state, owner)? {
// Closing the tab ends its presentation even if source
// reopens it before the next layout can detach the Mounts.
// 关闭即终止旧出现的任务归属,不等待下一次布局实际卸载才取消。
// 弱 Page 身份用于精确筛选这个页面的 Mount,不能取消整个窗口其他页面任务。
let page = cx.store().downgrade(owner)?;
for weak in state.presentations[&id].mounts.ordered() {
// 升级成功且 Mount.page 匹配才取消;不会把共享 Block 的其他出现一并取消。
if let Some(mount) = cx.upgrade(weak)?
&& cx
.store()
@@ -837,9 +983,11 @@ impl NativeCallable for Function {
.page
== Some(page)
{
// 取消 owner/key 调度资格,旧 I/O 即使稍后完成也不能再向此出现交付。
crate::tasks::cancel_owner(cx, mount)?;
}
}
// 撤注册后为原窗口选择替代页或安排清空,生命周期卸载仍交后续刷新处理。
remove_page(
cx,
state
@@ -852,16 +1000,19 @@ impl NativeCallable for Function {
return Ok(NativeResult::Return(Value::Nil));
}
OnClick => {
// 简便 onClick 保存单个处理器,validate_handler 检查真实 callable 契约而非仅有对象身份。
let handler = object(args.first())?;
crate::event_dispatch::validate_handler(cx.store(), handler)?;
data.click = Some(handler);
result = Value::Object(owner);
}
On => {
// 字符串事件名用于传播匹配,按 UTF-8 字节限制长度,不允许空事件名。
let kind = text(args.first())?;
if kind.is_empty() || kind.len() > 128 {
return Err(invalid("invalid event kind"));
}
// 先校验回调再建立 Connection,避免非法处理器留下不可取消的半条订阅。
let handler = object(args.get(1))?;
crate::event_dispatch::validate_handler(cx.store(), handler)?;
// 订阅默认非捕获;可用 Bool 或 capture/bubble 文本明确阶段,其他值拒绝而不做真值转换。
@@ -875,23 +1026,28 @@ impl NativeCallable for Function {
// 清掉已断开连接后再计算容量,保留仍有效订阅顺序;新 Connection 拥有 handler,返回 token 仅弱引用连接。
let mut active = Vec::new();
for token in &data.subscriptions {
// data.subscriptions 实际保存 Connection 身份,已断开者 handler=None 不再占容量。
if cx
.store()
.payload::<crate::event_dispatch::Connection>(*token, "Connection")?
.handler
.is_some()
{
// 按原序保留仍有效连接,事件路由中的监听顺序因此不因清理而改变。
active.push(*token);
}
}
if active.len() >= MAX_UI_NODES {
// 清理后仍满才拒绝,避免死亡连接永久占满一个 Block 的订阅预算。
return Err(invalid("too many event subscriptions"));
}
// Connection 强持 handler,由 Block 的 subscriptions 拥有;任务和事件生命周期可据此断开。
let connection = cx.allocate(crate::event_dispatch::Connection {
kind,
handler: Some(handler),
capture,
})?;
// 返回的 Subscription 仅弱指连接,不应因用户保留令牌而让整个已卸载组件和回调常驻。
let token = cx.allocate(crate::event_dispatch::Subscription {
connection: cx.store().downgrade(connection)?,
})?;
@@ -903,6 +1059,7 @@ impl NativeCallable for Function {
// 基类事件/挂载回调默认空实现,子类可覆盖;不在这条默认路径生成额外状态。
OnEvent | OnMount | OnUnmount => return Ok(NativeResult::Return(Value::Nil)),
Click | Dispatch => {
// 事件/订阅类型来自当前展示域,传播上下文仍由明确 owner/可选 Mount 指定。
let desktop = self
.session
.lock()
@@ -910,17 +1067,22 @@ impl NativeCallable for Function {
.desktop;
// click 新建种类为 click 的 Event,dispatch 使用调用者提供事件;两者都将可选具体 Mount 交传播模块验证。
let (event, mount) = if matches!(self.operation, Click) {
// click 总创建一份新 Event,调用方只可补具体 Mount;不会把旧事件对象重复分发。
let definition = desktop_type(cx.store(), "Event", desktop)?;
(
crate::event_dispatch::new_event(cx, definition, "click")?,
args.first(),
)
} else {
// dispatch 接现成事件,第二实参才是可选出现;传播模块继续验证两者语义。
(object(args.first())?, args.get(1))
};
// None 保留“未指定出现”含义,显式非法值传播错误;不从任意当前 Mount 猜目标。
let mount = mount.map(|value| object(Some(value))).transpose()?;
// 返回 Invoke/Return 由事件模块决定,源捕获/目标/冒泡回调仍走同一 VM 帧循环。
return crate::event_dispatch::dispatch(cx, owner, event, mount, desktop);
}
// 其余操作应在前面的专用分支处理;漏分派必须显式报错,不能悄悄返回成功 Unit。
_ => return Err(invalid("unsupported Block operation")),
}
// 非提前返回的结构编辑/订阅操作在末尾统一经过写屏障,触发拥有边更新和变更日志。
+25 -7
View File
@@ -1,30 +1,38 @@
//! 把内置开发工具和起始页接入托管 Page。工厂在所属 VM 中编译和执行,而不是在窗口线程另建业务状态。
//!
//! Developer workspaces are managed Pages, using the same roots and placement API.
//! bootstrap::install 调用 install 和 install_start_page;前者注册 ui.developerTools,后者注册 ui.newTab。
//! GUI 发出的 developer-open:* 输入由 UiBridge::dispatch 转成一次 VM 调用,再进入 OpenTool::invoke。
//! 这里管理 Page 的创建与归属;Console/Network 等工具的原生内容由 shell/developer_tools 按页面标记绘制。
use super::*;
#[derive(Debug)]
/// 工具打开能力,保存所属会话和 ui 对象;调用时校验工具名,再在该 VM 中展示页面。
struct OpenTool {
// 查找现有页面和安排 show 时使用同一展示域;不另建开发工具专属 VM。
session: Shared,
// 保存 ui.newTab 工厂入口;ContextNativeFunction 的 captures 同时声明这个托管强边。
ui: ObjectRef,
}
impl NativeCallable for OpenTool {
/// 仅接受一个工具名称字符串,工具类型与白名单在 invoke 检查。
/// VM 在进入 invoke 前检查一个显式参数;这里只有数量检查,字符串类型与白名单由 invoke 检查。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(1))
}
/// 把白名单工具名转换成对应托管 Page;New tab 转交预编译工厂,其余管理页复用已有实例或创建新页。
/// VM 执行 ui.developerTools 时调用:New tab 委托源工厂,其余工具优先激活已有页面,否则创建标记页。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
// 同时验证数量和字符串表示;不能把任意对象的可读表示当作工具名字接受。
let [Value::Text(name)] = args else {
// 错误发生在任何页面分配/登记之前,因此无须撤销一次半完成的工具打开。
return Err(invalid("developerTools requires a tool name"));
};
// 工具名白名单包含四个管理页和特殊新标签动作,拒绝任意字符串进入工厂/工具标记路径。
if !["Console", "Sources", "Network", "Settings", "New tab"].contains(&name.as_str()) {
// 白名单错误交回 VM,不把名字继续写入 _tesseraTool 让 GUI 猜测工具类型。
return Err(invalid("unknown developer tool"));
}
// 新标签交给预编译 ui.newTab 托管工厂,经 NativeResult::Invoke 回到同一 VM 执行。
if name == "New tab" {
// 只解析函数身份,Invoke 让 VM 自己推进源工厂,保留调用栈/预算与托管返回值语义。
let factory = object(Some(&cx.store().get_attr(self.ui, "newTab")?))?;
return Ok(NativeResult::Invoke {
function: factory,
@@ -39,16 +47,19 @@ impl NativeCallable for OpenTool {
.nodes
.clone();
for weak in nodes {
// NativeContext::upgrade 的临时引用由原生调用域管理;这里只保留实际选中的工具页作为返回值。
if let Some(page) = cx.upgrade(weak)?
&& cx.store().get_attr(page, "_tesseraTool").ok() == Some(Value::Text(name.clone()))
{
// 已有同类工具页则激活并聚焦其所属原生窗口,不每次打开都创建重复 Page。
show(cx, page, &self.session)?;
let window = {
// show 可能保留旧归属;重新查所属窗口,Focus 应发给真实窗口而不是当前主窗口。
let state = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?;
// show 成功后仍无登记表示桥接关系异常,不能随意选择其他窗口聚焦。
let id = page_window(cx.store(), &state, page)?
.ok_or(invalid("tool is not open"))?;
object(Some(
@@ -56,6 +67,7 @@ impl NativeCallable for OpenTool {
.get_attr(state.presentations[&id].desktop, "window")?,
))?
};
// 只登记原生命令,GUI 在发布后再执行系统聚焦;VM worker 不直接操作 OS 窗口。
crate::window::enqueue(cx, window, crate::window::Action::Focus)?;
return Ok(NativeResult::Return(Value::Object(page)));
}
@@ -67,14 +79,16 @@ impl NativeCallable for OpenTool {
&self.session,
None,
)?;
// title 是用户可见标签,_tesseraTool 是宿主识别内容的私有标记,不能只设置其中一个。
cx.set_attr(page, "title", Value::Text(name.clone()))?;
cx.set_attr(page, "_tesseraTool", Value::Text(name.clone()))?;
show(cx, page, &self.session)?;
Ok(NativeResult::Return(Value::Object(page)))
}
}
/// 把本模块的类型、方法或能力注册到所属运行时上下文。
/// bootstrap::install 在任务/类型能力就绪后调用,注册开发工具 callable;此时不打开任何页面。
pub(super) fn install(vm: &mut Vm, ui: ObjectRef, session: &Shared) -> Result<()> {
// payload 共享 Session,captures 显式持有 ui;仅存 Rust ObjectRef 不会形成 VM 可追踪强边。
let function = vm.allocate(ContextNativeFunction::new(
"developerTools",
OpenTool {
@@ -89,16 +103,17 @@ pub(super) fn install(vm: &mut Vm, ui: ObjectRef, session: &Shared) -> Result<()
Ok(())
}
/// Compile the bundled Endor page once per VM, in a private module namespace.
/// Its functions/classes and callbacks remain ordinary managed VM objects.
/// 编译并安装 VM 私有的起始页工厂,供新页面动作重复调用。
/// bootstrap 在 UiBridge 组装完成后调用,编译内置起始页模块并把 newTab 工厂挂到 ui。
/// 每个 VM 仅安装一次,后续新标签操作重复调用工厂;源类/闭包仍由正常 VM 对象系统管理。
pub(super) fn install_start_page(vm: &mut Vm, bridge: &UiBridge) -> Result<()> {
// 源码随 binary 嵌入,每个 VM 安装时解析/编译一次;函数工厂之后重复执行不再重复编译模块。
let source = frontend::EndorReader::from_text(
"tessera:start-page.endor",
include_str!("start_page.endor"),
);
// 按完整 Module 解析,保证类型与工厂定义属于一次安装环境,而不是用户 REPL 的多个输入片段。
let ast = frontend::parse_source_ast(&source, frontend::ParseMode::Module)?;
// 这里只生成代码模板,后面的 load 才把模板变成当前 VM 的托管代码对象。
let compiled = compiler::compile_ast(
&ast,
&source,
@@ -111,9 +126,12 @@ pub(super) fn install_start_page(vm: &mut Vm, bridge: &UiBridge) -> Result<()> {
},
)
.map_err(|error| source.annotate(error))?;
// 私有全局字典防止内置页面辅助类污染用户命名;导出闭包可通过托管边继续持有其环境。
let globals = vm.new_dict()?;
// 暂存安装/执行错误,闭包返回之后仍统一释放 globals 的局部拥有根。
let result = (|| -> Result<()> {
bridge.install_globals(vm, globals)?;
// 加载所得代码有独立根,execute 成功/失败后都需 vm.release(code)。
let code = compiled.load(vm)?;
// 执行模块定义类和工厂,使用单次预算;这里不主动调用 newTab,不在安装时打开页面。
let value = vm.execute_with_limits(
+47 -4
View File
@@ -1,11 +1,13 @@
//! 把页面、2D 视口和 3D 视口的相机输入转换成托管对象更新。
//! 输入携带具体出现的 Mount;嵌套世界 UI 先做逆投影,避免把屏幕位移直接当成场景坐标。
//!
//! Camera mutations run on the owning VM worker, just like other page input.
//! UiBridge::dispatch 按输入 kind 分流到下面三个方法:整页、局部二维视口和局部三维场景分别处理。
//! 它们只在所属 VM worker 上修改托管相机属性或调用 controller.update,不直接改 GUI 已发布帧。
//! 姿态变化随后由正常依赖/快照刷新观察;任何新帧仍需走 layout/paint/commit 的完整发布路径。
use super::*;
impl UiBridge {
/// 解码指定 3D 视口的旋转/平移/缩放输入,再调用其托管相机控制器。
/// dispatch 的 scene-pan/orbit/zoom 分支调用,把命中出现位置的输入包装成 CameraGesture 交给控制器。
pub(super) fn dispatch_scene_view(
&self,
vm: &mut Vm,
@@ -13,6 +15,7 @@ impl UiBridge {
) -> Result<()> {
// 锁内只读取当前发布版本与弱 Mount 身份,离开锁后才进入 VM 操作,避免持 session 锁调用用户代码。
let (weak, desktop) = {
// 当前窗口已由外层选定;本锁内同时读版本和 Mount,避免把旧几何与新弱身份混用。
let state = self
.session
.lock()
@@ -30,9 +33,11 @@ impl UiBridge {
state.desktop,
)
};
// upgrade 返回临时拥有根,下方即使任一步失败也通过闭包外的 release 清理。
let mount = vm
.upgrade(weak)?
.ok_or_else(|| anyhow::anyhow!("3D viewport Mount expired"))?;
// 不能直接 ? 返回整个方法,否则错误时跳过 Mount 根释放;用闭包暂存处理结果。
let result = (|| {
// 除 session 发布版本外,再检查目标自身仍挂接、版本匹配且确为 3D 视口,拒绝旧出现操作。
let occurrence = vm.payload::<crate::mount::Mount>(mount, "Mount")?.clone();
@@ -42,8 +47,11 @@ impl UiBridge {
&& occurrence.geometry.scene3d_viewport,
"obsolete 3D viewport"
);
// resolve 跟随 Block 的共享替换关系,再读取实际视觉状态,不能只信任旧 payload。
let (_, data) = block::resolve(vm.object_store(), occurrence.block)?;
// visual_data 合并当前可见属性,后面的相机/控制器来自模型而非原生画面的旧快照。
let data = block::visual_data(vm.object_store(), data)?;
// 没有显式相机就无法解释手势,拒绝更新而不是猜一个默认相机改变场景语义。
let camera = data
.camera
.ok_or_else(|| anyhow::anyhow!("missing 3D camera"))?;
@@ -51,18 +59,25 @@ impl UiBridge {
let controller = data
.controller
.ok_or_else(|| anyhow::anyhow!("3D controller disabled"))?;
// 同一视觉对象的边框/内边距由快照解析,手势锚点与可用尺寸都应扣除这部分。
let node = block::snapshot(vm.object_store(), occurrence.block)?;
// inset 将外框换算成视口内容盒,避免控制器把边框尺寸算成场景宽高。
let inset = crate::layout::insets(&node);
// 几何来自具体 Mount,两个窗口中共享同一场景仍可能有不同视图投影。
let geometry = &occurrence.geometry;
// 外层二维视图逆变换用于锚点定位;没有视图变换时使用恒等变换。
let outer = geometry.view.unwrap_or_default();
// delta 的第一分量还会用作缩放倍率;缺失说明输入信封不满足相机事件要求。
let delta = input
.delta
.ok_or_else(|| anyhow::anyhow!("3D camera delta required"))?;
// 缩放使用倍率而非二维位移;旋转/平移通过 logical_delta 处理嵌套二维/面板投影。
// 不可逆映射时忽略本次连续增量,不在无效坐标上继续更新相机。
let local_delta = if input.kind == "scene-zoom" {
// 缩放不产生平移 dx/dy,只把原第一分量写到 gesture.factor。
[0., 0.]
} else {
// 平移/旋转先撤销外层投影;映射失败表示本帧增量无法可靠应用。
let Some(delta) = geometry.logical_delta(input.point, delta) else {
return Ok(());
};
@@ -79,6 +94,7 @@ impl UiBridge {
));
// 将输入封装为名义 CameraGesture 临时对象;不把原生 Input 或窗口指针直接交给源控制器。
let gesture = vm.new_object()?;
// 临时手势也有独立拥有根;把附类型/写字段/控制器错误暂存,外层始终 release(gesture)。
let result = (|| {
vm.attach_type(
gesture,
@@ -95,10 +111,12 @@ impl UiBridge {
("pointX", point.x - geometry.x - inset.left),
("pointY", point.y - geometry.y - inset.top),
] {
// 全部数值通过统一语言数值编码写属性,控制器读取的是普通托管字段。
vm.set_attr(gesture, name, crate::native_visual::value(value))?;
}
// 绑定 controller.update 保留接收者语义,再带 camera/gesture 两个显式参数进行有界调用。
let function = vm.bind_attr(controller, "update")?;
// callable 校验在 VM 执行边界继续完成,非对象方法值直接作为契约错误返回。
let result = match function {
Value::Object(function) => vm.call_with_limits(
function,
@@ -124,7 +142,7 @@ impl UiBridge {
result
}
/// 把具体 CanvasViewport 的位移或缩放锚点映射到其显式 Camera2D。
/// dispatch 的 viewport-pan/zoom 分支调用,逆投影具体 CanvasViewport 输入并改写其 Camera2D 属性。
pub(super) fn dispatch_viewport_view(
&self,
vm: &mut Vm,
@@ -132,6 +150,7 @@ impl UiBridge {
) -> Result<()> {
// 二维视口同样先校验发布版本,再从活动表捕获原 Mount,不能因目标消失自动换成另一个视口。
let weak = {
// 锁内只检查发布版本并复制弱身份;下面读取相机和写 VM 属性时不持有会话锁。
let state = self
.session
.lock()
@@ -146,10 +165,13 @@ impl UiBridge {
.get(&input.mount)
.ok_or_else(|| anyhow::anyhow!("viewport is no longer active"))?
};
// 临时根保证输入处理期间 Mount 仍可读取,方法尾部无论成功失败都释放。
let mount = vm
.upgrade(weak)?
.ok_or_else(|| anyhow::anyhow!("viewport Mount expired"))?;
// 闭包集中实际处理,使所有 ? 都先回到统一释放路径。
let result = (|| {
// 弱身份能升级不代表仍挂载/版本有效,先验证具体出现上下文。
let occurrence = vm.payload::<crate::mount::Mount>(mount, "Mount")?;
anyhow::ensure!(
occurrence.mounted
@@ -157,32 +179,44 @@ impl UiBridge {
&& occurrence.geometry.canvas_viewport,
"obsolete viewport occurrence"
);
// 复制几何和 Block 身份,结束 occurrence 对 VM 的借用后才能改写相机。
let geometry = occurrence.geometry.clone();
// Block 只用于找相机/边距,输入定位仍来自 Mount 的几何。
let block = occurrence.block;
// 共享替换后的实际视觉状态才包含当前相机引用。
let (_, data) = block::resolve(vm.object_store(), block)?;
// 托管属性覆盖 payload 缺省值,保持与布局/绘制同一视觉读取约定。
let data = block::visual_data(vm.object_store(), data)?;
// 局部视口必须有 Camera2D,不把整页 Surface 当作相机替代。
let camera = data
.camera
.ok_or_else(|| anyhow::anyhow!("viewport has no camera"))?;
// 读取已校验的相机快照作为本次基准;共享相机用户会通过普通属性变更观察到相同更新。
let old = crate::viewport::camera(vm.object_store(), camera)?;
// 该分量在 zoom 时表示倍率,在 pan 时表示外部逻辑位移,随后分支分别解释。
let delta = input
.delta
.ok_or_else(|| anyhow::anyhow!("viewport input has no delta"))?;
// 基于旧快照计算三个新值,完成验证后才依次回写托管属性。
let (mut x, mut y, mut zoom) = (old.x, old.y, old.zoom);
if input.kind == "viewport-zoom" {
// 缩放必须有锚点,不能让缺失指针导致隐式绕视口原点缩放。
let pointer = input
.point
.ok_or_else(|| anyhow::anyhow!("zoom requires anchor"))?;
// 先用屏幕/视图坐标做外框检查,再逆投影到布局逻辑坐标。
let pointer = crate::geometry::Point::new(pointer[0], pointer[1]);
anyhow::ensure!(
geometry.contains(pointer),
"zoom anchor is outside viewport"
);
// 外框命中不保证投影可逆;None 作为错误返回,不能继续使用原屏幕数值。
let pointer = geometry
.logical_point(pointer)
.ok_or_else(|| anyhow::anyhow!("zoom anchor cannot be projected"))?;
// 当前 Block 快照给出布局边距,用它取得正确内容盒的缩放锚点。
let node = block::snapshot(vm.object_store(), block)?;
// inset 与布局模块共用同一规则,避免相机手势与绘制对内容原点理解不同。
let inset = crate::layout::insets(&node);
// 已验证指针落在出现外框还不够;扣除 inset 后必须落入实际内容盒,禁止用边框作为缩放锚点。
let local = [
@@ -201,6 +235,7 @@ impl UiBridge {
x += local[0] / old.zoom - local[0] / zoom;
y += local[1] / old.zoom - local[1] / zoom;
} else {
// 平移没有锚点保持公式,只需逆投影增量再除相机自身缩放。
let Some(delta) = geometry.logical_delta(input.point, delta) else {
return Ok(());
};
@@ -216,11 +251,12 @@ impl UiBridge {
vm.set_attr(camera, "zoom", crate::native_visual::value(zoom))?;
Ok(())
})();
// release 发生在 result? 之前,保证投影/属性写入错误不会泄漏升级得到的 Mount。
vm.release(mount)?;
result
}
/// 处理旧 Page 模式的兼容相机事件;独立视口使用各自的输入路径。
/// dispatch 的 page-pan/zoom 分支调用,兼容旧 Page 表面相机;独立 CanvasViewport 不走此方法。
pub(super) fn dispatch_page_view(
&self,
vm: &mut Vm,
@@ -228,6 +264,7 @@ impl UiBridge {
) -> Result<()> {
// 兼容 Page 相机不通过 Mount 定位,而是读取当前活动页面及其 Surface 副本;发布版本仍必须一致。
let (page, mut surface, viewport) = {
// 只复制当前已发布页的相机与真实视口,不持锁执行下面的坐标计算或 VM 属性写入。
let state = self
.session
.lock()
@@ -244,17 +281,21 @@ impl UiBridge {
state.layout.viewport,
)
};
// 外层信封校验之后仍要求本路径必需的增量字段,防止不完整调用静默改变相机。
let delta = input
.delta
.ok_or_else(|| anyhow::anyhow!("page view requires delta"))?;
if input.kind == "page-zoom" {
// 旧 Page 模式只允许 Canvas 缩放,有限文档或 Window 页面保持各自尺寸语义。
anyhow::ensure!(
surface.mode == crate::page::Mode::Canvas,
"zoom requires canvas mode"
);
// 以光标为缩放锚点,先要求它存在,再检查可用视口范围。
let pointer = input
.point
.ok_or_else(|| anyhow::anyhow!("zoom requires anchor"))?;
// 转为页面几何共用的 Point,便于调用 Surface::to_world 做缩放前逆变换。
let pointer = crate::geometry::Point::new(pointer[0], pointer[1]);
anyhow::ensure!(
pointer.x >= 0.0
@@ -270,6 +311,7 @@ impl UiBridge {
world.y - pointer.y / surface.zoom,
);
} else {
// 平移按当前 zoom 换成世界距离,不直接把屏幕像素累加进世界 pan。
surface.pan.x += delta[0] / surface.zoom;
surface.pan.y += delta[1] / surface.zoom;
}
@@ -277,6 +319,7 @@ impl UiBridge {
if let Some(viewport) = viewport {
surface.clamp(viewport);
}
// 内容模式 clamp 之外再加通用坐标预算;Canvas 可自由漫游,但也不能无限增长偏移。
let limit = MAX_DIMENSION as f32;
surface.pan.x = surface.pan.x.clamp(-limit, limit);
surface.pan.y = surface.pan.y.clamp(-limit, limit);
+60 -4
View File
@@ -1,10 +1,14 @@
//! 声明宿主方法在 Endor 类型系统中的参数和返回契约。
//! 签名用于构造/调用检查,应与 calls 和各 NativeCallable 实现保持一致;这里不执行实际业务操作。
//!
//! Canonical contracts for native Block methods and constructors.
//! bootstrap 在类型安装的第二阶段调用这两个函数,把完整签名写入 TypeMethod、Function 和 NativeFactory。
//! ui_bridge::bind 也会为状态对象的绑定函数调用 operation_signature。
//! VM 随后在调用边界读取这些签名,验证数量、类型及调用效果;业务实现位于 calls/scene3d 等模块。
//! 参数注解并不决定是否必填:Operation::arity 给出实参数量,注解表则检查实际出现的每一个参数。
use super::*;
/// 根据宿主方法类别提供 Endor 方法的签名,供类型与调用检查使用。
/// bootstrap 安装实例方法时调用,构造包含 receiver 的 VM 方法契约。
/// receiver 是方法声明所在类型,不是某次调用的实例;实际接收者可以是该类型的合法子类。
pub(super) fn method_signature(
store: &endor_core::ObjectStore,
desktop: ObjectRef,
@@ -13,11 +17,14 @@ pub(super) fn method_signature(
) -> Result<CallSignature, VmError> {
use AnnotationType::{Any, Function, Nominal, Number, String as TextType, Unit};
use Operation::*;
// 闭包把宿主约定的名字解析成当前 desktop 的真实 Type;查找失败中止安装,不能降级为 Any。
let nominal = |name| desktop_type(store, name, desktop).map(Nominal);
// 先描述显式实参,不含隐式 receiver;后面统一在 parameters 首部补接收者。
// ReadOnly/Mutating/External 是 VM 调用效果契约,不是线程调度策略。
let (arguments, returns, effect) = match operation {
// 三个数分别供位置/旋转操作使用;方法修改现有三维对象,没有额外返回值。
Translate3D | Rotate3D => (vec![Number, Number, Number], Unit, CallEffect::Mutating),
// 控制器处理被控对象和相机手势;手势是类型化协议值,不能接任意文本或标量。
CameraControl => (
vec![nominal("Object")?, nominal("CameraGesture")?],
Unit,
@@ -29,9 +36,13 @@ pub(super) fn method_signature(
nominal("Subscription")?,
CallEffect::Mutating,
),
// 查询已有出现位置,返回托管列表;不修改对象或展示关系。
Mounts => (vec![], nominal("ObjectList")?, CallEffect::ReadOnly),
// Children 的实际值仍由容器实现解释;这里的 Any 不改变只读效果约束。
Children => (vec![], Any, CallEffect::ReadOnly),
// 可选文本用于标记失效类别;是否可省略由 arity 决定,非此处的 vec 长度决定。
Invalidate => (vec![TextType], Unit, CallEffect::Mutating),
// 修改既有文本/颜色,内容合法性(如颜色格式)由调用实现进一步校验。
SetText | SetColor => (vec![TextType], Unit, CallEffect::Mutating),
// append/insert 返回稳定 slot 数值;remove/replace/move 返回 Unit,调用方不能把它们当返回孩子对象的集合方法。
Append => (vec![nominal("Block")?], Number, CallEffect::Mutating),
@@ -40,15 +51,19 @@ pub(super) fn method_signature(
Number,
CallEffect::Mutating,
),
// 数字注解只验证 Number;非负整数、稳定槽位是否存在等语义由 calls 检查。
Remove => (vec![Number], Unit, CallEffect::Mutating),
Replace => (vec![Number, nominal("Block")?], Unit, CallEffect::Mutating),
Move => (vec![Number, Number], Unit, CallEffect::Mutating),
// 页面内容替换/导航接受可视 Block,并改写待展示状态;此处不提前执行布局。
Render | Navigate => (vec![nominal("Block")?], Unit, CallEffect::Mutating),
// 链式 API 返回接收者名义类型,保留子类返回契约,而不是统一降为 Block。
// 链式 API 返回声明 receiver 的名义类型;源子类实例也满足此基类契约,不承诺精确动态子类。
OnClick => (vec![Function], Nominal(receiver), CallEffect::Mutating),
// 事件和生命周期方法可能执行任意用户回调,使用 External,不能冒充只读视觉查询。
OnEvent => (vec![nominal("Event")?], Unit, CallEffect::External),
// Mount 标识一次具体出现位置;同一 Block 的多次展示不能共用一个隐式上下文。
OnMount | OnUnmount => (vec![nominal("Mount")?], Unit, CallEffect::External),
// click/dispatch 返回实际事件,便于调用者观察传播状态;Mount 可选性由 arity 给出。
Click => (
vec![nominal("Mount")?],
nominal("Event")?,
@@ -59,28 +74,38 @@ pub(super) fn method_signature(
nominal("Event")?,
CallEffect::External,
),
// 迁移要求真实 Window 实例并返回页面,窗口是否关闭、是否属于当前运行时由实现继续检查。
MoveWindow => (
vec![nominal("Window")?],
Nominal(receiver),
CallEffect::Mutating,
),
// 标签位置是数值,Activate 无显式实参;二者都改变展示选择并允许链式使用。
MoveTab => (vec![Number], Nominal(receiver), CallEffect::Mutating),
Activate => (vec![], Nominal(receiver), CallEffect::Mutating),
// detach 新建目标窗口,返回 Window 而非当前页面;资源创建属于修改操作。
Detach => (vec![], nominal("Window")?, CallEffect::Mutating),
// 这些操作只修改生命周期或历史状态,均没有业务返回值。
Unmount | Close | CloseWindow | Back | Forward => (vec![], Unit, CallEffect::Mutating),
// 先以 Any 占位,后面用 nullable(Window) 收窄,允许省略/显式 nil 选择默认归属。
Open => (vec![Any], Nominal(receiver), CallEffect::Mutating),
// 页面尚未打开时无所属窗口,稍后把返回 Any 改为 Window | nil。
PageWindow => (vec![], Any, CallEffect::ReadOnly),
// 事件名、回调和可选捕获选项;返回 Subscription 令牌供取消,不直接返回 receiver。
On => (
vec![TextType, Function, Any],
nominal("Subscription")?,
CallEffect::Mutating,
),
// 自由工厂 Operation 不应误装成实例方法;缺少明确契约就拒绝安装,防止默认放宽检查。
_ => return Err(invalid("unsupported native method signature")),
};
// Operation 维护显式参数的必需/最大个数;方法签名整体加一,将 receiver 纳入调用约定。
let arity = operation.arity();
// parameters[0] 固定为 receiver,因此后面细化显式参数时都必须把索引加一。
let mut parameters = vec![Nominal(receiver)];
parameters.extend(arguments);
// 先把简单 AnnotationType 升格为 CallType;下面再改写少数联合/可空位置。
let mut signature = CallSignature {
arity: endor_core::CallArity::range(arity.required + 1, arity.maximum + 1),
parameters: parameters.into_iter().map(CallType::from).collect(),
@@ -91,15 +116,20 @@ pub(super) fn method_signature(
// 少数参数/返回需要联合或可空类型,先用占位 Any 建基础签名,再按下标细化。
// On 第三个显式选项位于 parameters[3],可接受 Bool 或字符串。
match operation {
// 参数允许 nil,但非空值必须是本 desktop 注册的 Window 实例。
Open => signature.parameters[1] = CallType::nullable(nominal("Window")?),
// 没有归属是正常查询结果,与错误窗口对象的类型失败不同。
PageWindow => signature.returns = CallType::nullable(nominal("Window")?),
// 兼容布尔捕获标记和命名阶段字符串;具体字符串语义在事件连接操作中检查。
On => signature.parameters[3] = CallType::Union(vec![AnnotationType::Bool, TextType]),
// 其余操作已经完整表达,不应额外增加可空或联合类型。
_ => {}
}
Ok(signature)
}
/// 将具体宿主 Operation 映射到参数/返回类型契约。
/// bootstrap 和 ui_bridge::bind 调用,描述不含 receiver 的自由函数/状态绑定函数原生参数。
/// NativeFactory 复用本结果作为 options 窄签名,再在外部包上 Type 首参和源子类字段。
pub(super) fn operation_signature(
store: &endor_core::ObjectStore,
desktop: ObjectRef,
@@ -107,27 +137,36 @@ pub(super) fn operation_signature(
) -> Result<CallSignature, VmError> {
use AnnotationType::{Any, Bool, Number, String as TextType, Trait, Type, Unit};
use Operation::*;
// 注册表来自所属 VM 的 desktop;同名但不同身份的外部 Type 不能满足当前名义契约。
let nominal = |name| desktop_type(store, name, desktop).map(AnnotationType::Nominal);
// 自由 UI 操作没有隐式 receiver;同时声明固定参数、可变尾参、返回名义类型和调用效果。
let (parameters, variadic, returns, effect) = match operation {
// 类型关系查询不改变类型表;IsInstance 签名首参为 Any,但 calls 当前仍要求实际值为对象。
IsInstance => (vec![Any, Type], None, Bool, CallEffect::ReadOnly),
IsSubtype => (vec![Type, Type], None, Bool, CallEffect::ReadOnly),
// 注册能力实现会修改类型关系;implements/supports 只查询已有关系。
Implement => (vec![Type, Trait], None, Unit, CallEffect::Mutating),
Implements => (vec![Type, Trait], None, Bool, CallEffect::ReadOnly),
Supports => (vec![Any, Trait], None, Bool, CallEffect::ReadOnly),
// open 可把普通值包装成视觉/Page 并安排展示,返回供继续操作的 Page。
OpenValue => (vec![Any], None, nominal("Page")?, CallEffect::Mutating),
// present 可能转交用户展示协议,效果交由被委托调用确定,不能声明成固定 ReadOnly。
Present => (vec![Any], None, nominal("Block")?, CallEffect::Delegated),
// state 可分配新的状态对象;ReadOnly 允许新对象初始化,不授权修改原有对象。
State => (vec![Any], None, Any, CallEffect::ReadOnly),
// set/increment 的 receiver 已存在 Function.owner 中,不计入本处参数表。
StateSet => (vec![Any], None, Unit, CallEffect::Mutating),
Increment => (vec![], None, Unit, CallEffect::Mutating),
// stats 读取宿主计数而非纯托管属性依赖,按 External 描述,不作为只读布局计算接口。
Stats => (vec![], None, Any, CallEffect::External),
// viewport 返回尺寸协议值;窗口尺寸尚缺失等情形仍由实际实现处理。
Viewport => (
vec![],
None,
nominal("MeasureResult")?,
CallEffect::ReadOnly,
),
// ui.window 查询现有窗口,Window 类型构造则创建新资源,二者不能共享调用效果。
Window => (vec![], None, nominal("Window")?, CallEffect::ReadOnly),
NewWindow => (vec![], None, nominal("Window")?, CallEffect::Mutating),
// 这些构造操作返回新对象,ReadOnly 允许分配并初始化新值,但不允许因此修改已有业务对象。
@@ -137,18 +176,21 @@ pub(super) fn operation_signature(
nominal("TextBlock")?,
CallEffect::ReadOnly,
),
// TextOf 前两个参数表示对象/属性名;Text 直接接收字符串,其后字号和颜色可选。
Text => (
vec![TextType, Number, TextType],
None,
nominal("TextBlock")?,
CallEffect::ReadOnly,
),
// 矩形宽高为必填,后续数值/颜色的缺省规则由 Operation::arity 与构造实现共同约束。
Rect => (
vec![Number, Number, Number, TextType],
None,
nominal("RectBlock")?,
CallEffect::ReadOnly,
),
// 空白块只需要长度;滚动块额外要求宽高和一个 Block 内容。
Spacer => (vec![Number], None, nominal("Spacer")?, CallEffect::ReadOnly),
Scroll => (
vec![Number, Number, nominal("Block")?],
@@ -160,15 +202,20 @@ pub(super) fn operation_signature(
Row | Column | Overlay => (
vec![Number, Number],
Some(nominal("Block")?),
// 共享参数规则,但返回值必须保留具体容器名义类型;matches 只选择类型名,不执行排版。
nominal(if matches!(operation, Row) {
// 主轴水平排列的容器。
"Row"
} else if matches!(operation, Overlay) {
// 多个孩子叠放在同一内容区域的容器。
"Overlay"
} else {
// 此分支入口只有 Row/Column/Overlay,前两者排除后剩 Column。
"Column"
})?,
CallEffect::ReadOnly,
),
// 按钮接收初始文本,Page 无显式参数;两者都只创建值,不自动打开窗口。
Button => (
vec![TextType],
None,
@@ -176,6 +223,7 @@ pub(super) fn operation_signature(
CallEffect::ReadOnly,
),
Page => (vec![], None, nominal("Page")?, CallEffect::ReadOnly),
// 三维视口的宽高和 Scene3D 在调用边界检查;摄像机、场景元素等由场景模块继续验证。
Viewport3D => (
vec![Number, Number, nominal("Scene3D")?],
None,
@@ -184,12 +232,15 @@ pub(super) fn operation_signature(
),
// 3D 构造器参数来自 Kind 元数据:Number/String 映成基础注解,其余名称从本 desktop 注册表查名义类型。
SceneObject(kind) => {
// 元数据与真实构造器共用一份参数定义,避免桥接手工重复参数表后与 3D 实现漂移。
let parameters = kind
.parameters()
.iter()
.map(|name| match *name {
// 基础数值/字符串是语言原生注解,不需要到宿主类型字典查询。
"Number" => Ok(Number),
"String" => Ok(TextType),
// Vec3/Geometry3D 等名称必须解析为本会话真实 Type;未知名让整个签名安装失败。
name => nominal(name),
})
.collect::<Result<Vec<_>, VmError>>()?;
@@ -197,14 +248,17 @@ pub(super) fn operation_signature(
parameters,
// 3D 集合尾参使用 Object 名义约束;各具体构造操作仍需在实现中检查允许的场景对象类别。
if kind.variadic() {
// 允许额外对象参数的场景组等构造器用 Object 作尾参上界。
Some(nominal("Object")?)
} else {
// 固定参数构造器禁止多余实参,不能统一使用 Any 尾参放开上界。
None
},
nominal(kind.name())?,
CallEffect::ReadOnly,
)
}
// 二维场景可接受零到多个 Block;场景视口固定接宽、高、Scene2D,不把孩子直接当场景。
Scene2D => (
vec![],
Some(nominal("Block")?),
@@ -217,12 +271,14 @@ pub(super) fn operation_signature(
nominal("CanvasViewport")?,
CallEffect::ReadOnly,
),
// x/y/zoom 都声明为 Number,但可缺省;缩放范围等数值约束由 new_camera2d 校验。
Camera2D => (
vec![Number, Number, Number],
None,
nominal("Camera2D")?,
CallEffect::ReadOnly,
),
// 实例方法专用 Operation 不在自由函数表内,误用在这里应立即暴露安装错误。
_ => return Err(invalid("unsupported UI function signature")),
};
// 最终 arity 直接沿用 Operation,参数注解与可变尾参分开保存;不在此处执行操作或分配业务对象。
+38
View File
@@ -1,14 +1,18 @@
// The page, cards and actions belong to Endor. The host only owns its sidebar.
// 默认页由真实 Endor 对象组成:宿主负责窗口和侧栏,卡片布局、绘制和按钮回调在源代码中定义。
// developer_pages::install_start_page 在每个 VM 安装此模块;OpenTool 的 New tab 动作调用下面的 newTab。
// 渲染器通过视觉协议调用 measure/paint,按钮事件通过 VM 分派到 StartActions 的绑定方法。
// Note 复用 Column 的内容测量,但固定卡片宽度;paint 只提供背景,孩子仍沿正常树绘制。
class Note: Column {
// 卡片构造的最后一个子类字段保存背景色;前面的变参仍按 Column 的孩子参数处理。
tint: String
// source_layout 调用这个覆盖方法;显式借用 Column 的测量结果,只替换卡片宽度,保留内容自然高度。
function measure(self: Note, mount: Mount, constraints: Constraints): MeasureResult {
// 沿用 Column 内容测量得到高度,但卡片宽固定为 224;不把背景圆角绘制误当布局尺寸。
measured = Column.measure(self, mount, constraints)
return MeasureResult(224, measured.height)
}
// source_paint 在已完成几何上调用;这里返回绘制命令,未直接向 OS 或像素缓冲写入。
function paint(self: Note, mount: Mount, geometry: Bounds): DisplayList {
// 只生成当前卡片背景,命令使用宿主给定 Bounds 的绝对布局位置,孩子仍由各自 paint 绘制。
fill = PaintCommand()
@@ -36,25 +40,35 @@ class StartActions {
home: Page
// 窗口页面是普通有限 Column;可用区域来自宿主发布的逻辑视口。
function blank(self: StartActions, event: Event): Unit {
// “窗口页面”按钮的 click 回调创建独立 Page,标题和内容属于新页面,home 仍指向原画布。
page = Page()
// 标题供 project_tabs 投影到原生侧栏,不影响页面身份。
page.title = "窗口页面"
// 返回按钮保留原 home 的绑定方法,点击时激活已有页而非新建一个画布。
back = Button("返回画布")
// 绑定的是原 home 的 activate 方法;此回调恢复已有页身份,不重新构造主画布。
back.onClick(self.home.activate)
// render 设置内容并安排展示,Column 的 padding/gap 只影响这张页的有限布局。
page.render(Column(56, 20, TextBlock("恰好一个窗口", 3), TextBlock("页面跟随可用区域调整。内容和工具栏由 Endor 自己组织。"), back))
}
// 延伸方向属于容器与滚动组合:Column 安排内容,有限 ScrollBlock 控制可见范围。
function vertical(self: StartActions, event: Event): Unit {
// “纵向延伸”按钮调用;保持 Page 默认有限布局,滚动行为由后面的 ScrollBlock 承担。
page = Page()
// 标签标题和其他示例区分,Page 本身不切换到旧式无限页面模式。
page.title = "纵向页面"
// 外 Column 累加卡片高度,内 Column 组织说明文字;自然内容高度可超过可见视口。
content = Column(48, 28, Column(0, 12, TextBlock("向下,继续生长", 3), TextBlock("纵向滚动和 Column 排版可以独立组合。")))
// 每张 Note 的最后一参对应 tint 子类字段,前面的视觉值仍是 Column 的子项。
content.append(Note(24, 16, TextBlock("01 / 收集想法", 2), RectBlock(176, 130, 10, "#E2EADB"), TextBlock("从一个小小的念头开始。"), "#F0F3E9"))
content.append(Note(24, 16, TextBlock("02 / 连接片段", 2), RectBlock(176, 130, 10, "#E6DBEF"), TextBlock("内容保留在同一条连续的页面里。"), "#F4EEF7"))
content.append(Note(24, 16, TextBlock("03 / 慢慢展开", 2), RectBlock(176, 130, 10, "#EDDCC5"), TextBlock("追加一个子组件,内容自然延伸。"), "#FAF2E7"))
content.append(Note(24, 16, TextBlock("04 / 留一点空白", 2), TextBlock("不必把所有想法都放在第一屏。"), "#EDF1F6"))
// 固定初始视口内装完整内容;下一行限制滚动轴,flex 和页面 stretch 再让视口服从窗口分配。
view = ScrollBlock(1024, 800, content)
// 只消费纵向滚轮,横向内容不会因普通滚动意外平移。
view.axis = "vertical"
// flex 请求父布局分配剩余主轴空间,crossAlign 让有限视口横向填满页面。
view.flex = 1
page.crossAlign = "stretch"
// 最后提交有限视口作为 Page 内容,触发展示请求;真正的测量、绘制与原生发布由宿主刷新流程完成。
@@ -62,18 +76,25 @@ class StartActions {
}
// Row 内可继续放 Column 卡片;横向滚动不要求改变 Page 的身份或生命周期。
function horizontal(self: StartActions, event: Event): Unit {
// “横向延伸”按钮调用;横向排列和可见区域分别交给 Row 与 ScrollBlock。
page = Page()
// 仅设置标签内容,实际页面资源仍在当前 VM 的 Window 注册表中登记。
page.title = "横向页面"
// Row 沿横轴连接 Note;各 Note 保留自己的纵向 Column 测量/背景绘制覆盖。
content = Row(48, 32, Note(24, 16, TextBlock("从左,走向右", 2), RectBlock(176, 180, 10, "#DDE8F1"), TextBlock("Row 内仍然可以包含 Column。"), "#EDF3F8"))
// append 按顺序创建稳定子 slot,横向坐标由布局累加,不需要手工填写 x/y。
content.append(Note(24, 16, TextBlock("收集", 2), RectBlock(176, 210, 10, "#D7E5DA"), TextBlock("一张卡片,一个想法。"), "#EFF4EC"))
content.append(Note(24, 16, TextBlock("整理", 2), RectBlock(176, 170, 10, "#E5D9EB"), TextBlock("横向滚动,保留思考的顺序。"), "#F4EFF7"))
content.append(Note(24, 16, TextBlock("继续", 2), RectBlock(176, 230, 10, "#EFDFC7"), TextBlock("Shift + 滚轮浏览更多内容。"), "#FAF3E8"))
content.append(Note(24, 16, TextBlock("不止一屏", 2), TextBlock("右侧仍有空间。"), "#EEF1F5"))
// 这些尺寸是构造缺省值,flex/stretch 会让视口接受当前窗口的实际可用空间。
view = ScrollBlock(1024, 800, content)
// Row 自然宽度可超出视口,只有横向滚动;卡片内部仍是 Column 的纵向排版。
view.axis = "horizontal"
// 保留横向滚动内容的自然宽度,视口本身仍参加有限页面的弹性布局。
view.flex = 1
page.crossAlign = "stretch"
// 只安排展示,不在 click 回调里递归运行 measure/paint;worker 在回调结束后刷新。
page.render(view)
}
// 重新调用工厂得到独立页面与相机,已有画布继续保留自己的视角。
@@ -85,8 +106,10 @@ class StartActions {
function counter(self: StartActions, event: Event): Unit {
// 共享的是同一个托管状态对象;textOf 两次读取该对象 value,increment 更新后两个展示一起刷新。
count = ui.state(0)
// 按钮保留 increment 的绑定 owner,同一状态可同时驱动两个 textOf 派生视觉对象。
add = Button("增加计数")
add.onClick(count.increment)
// 创建承载两个观察视图的新页面,状态对象不会因函数返回立即消失:绑定/回调仍持有它。
page = Page()
page.title = "共享对象"
page.render(Column(48, 24, TextBlock("一个对象,两个视图", 2), Row(0, 32, ui.textOf(count, "value", 3), ui.textOf(count, "value", 3)), add))
@@ -94,14 +117,21 @@ class StartActions {
}
// 页面工厂:先建立业务对象/动作,再建立世界组成,最后用一个有限视口显示它。
function newTab(): Page {
// OpenTool 的 New tab 与 StartActions.canvas 都调用这个工厂;每次都创建不同页面身份。
page = Page()
// 后续标签投影读此字段,不使用标题作为查找或去重依据。
page.title = "自由画布"
// 先建立引用当前 Page 的动作对象,再绑定按钮;回调中的 home 指向这一轮工厂创建的页面。
actions = StartActions(page)
// 每个按钮只保存动作入口,点击前不提前创建示例 Page。
fixed = Button("窗口页面")
// 绑定 vertical 回调后才会创建纵向滚动内容。
vertical = Button("纵向延伸")
// 横向示例和纵向示例各有独立 Page/ScrollBlock,互不共享滚动偏移。
horizontal = Button("横向延伸")
// 新画布动作再调用工厂,使相机导航状态与此画布隔离。
canvas = Button("新的画布")
// QuietButton 只隐藏原生按钮背景,其 click 事件和孩子文字仍沿普通 Button 协议工作。
shared = QuietButton("试试:一个对象,两个视图 ↗")
// 订阅绑定方法而非提前调用;事件触发后在原 VM 中创建/展示相应示例页。
fixed.on("click", actions.blank)
@@ -109,37 +139,45 @@ function newTab(): Page {
horizontal.on("click", actions.horizontal)
canvas.on("click", actions.canvas)
shared.on("click", actions.counter)
// hero 内部用有限行列排版,只有整个 hero 在世界场景中的摆放采用显式 x/y。
hero = Column(0, 16, TextBlock("给想法,一点空间。", 3, "#303941"), TextBlock("页面可以是一扇窗口,也可以一直延伸。", 1, "#7D858C"), Spacer(8), Row(0, 10, fixed, vertical), Row(0, 10, horizontal, canvas), shared)
// x/y 属于 Scene2D 子组件的世界位置;卡片内部依旧由普通 Row/Column 排版。
hero.x = 316
hero.y = 254
// Scene2D 只规定孩子的世界位置;加入后仍可追加普通卡片,位置不受窗口宽高限制。
world = Scene2D(hero)
// 左侧卡片由 Note 测高并绘制 tint 背景,文字和矩形作为正常孩子分别绘制。
left = Note(24, 14, TextBlock("随手记下", 2), RectBlock(176, 100, 10, "#D5E4DA"), TextBlock("想法不必排成一条直线。"), "#EDF3E9")
left.x = 36
left.y = 92
// 将卡片以稳定 slot 加入场景;其 x/y 在前面单独设置,不依赖追加次序推算位置。
world.append(left)
// 顶部卡片只有文本,Note 仍复用内容测量,不需要为没有矩形的情况增加特殊宿主代码。
top = Note(24, 14, TextBlock("四种页面,同一个世界"), TextBlock("窗口 · 纵向 · 横向 · 自由画布", 1, "#7D858C"), "#EFF1F7")
top.x = 466
top.y = 58
world.append(top)
// 右侧卡片展示“组件布局”和“相机视角”分开:移动相机不改写这个对象的世界坐标。
right = Note(24, 14, RectBlock(176, 138, 12, "#DDDAEA"), TextBlock("把片段放在一起", 2), TextBlock("布局属于组件,视角属于摄像机。"), "#F1EDF5")
right.x = 826
right.y = 190
world.append(right)
// 底部说明中的换行属于源文本内容,不能把它误当实际控制输入或页面模式配置。
bottom = Note(24, 14, TextBlock("不止眼前这一屏", 2), TextBlock("触控板或滚轮平移\n空格拖动 / 鼠标中键拖动\n双指捏合 / ⌘ Ctrl + 滚轮缩放"), "#F7F0E5")
bottom.x = 104
bottom.y = 524
world.append(bottom)
// 场景边缘再放一张普通 Note,展示超出初始屏幕范围的内容仍可通过相机漫游到达。
end = Note(24, 14, TextBlock("继续向外探索", 2), RectBlock(176, 100, 10, "#CEDFE7"), TextBlock("画布上的对象仍是普通 Endor 组件。"), "#EAF1F5")
end.x = 716
end.y = 614
world.append(end)
// 视口参加有限布局并占用剩余空间;移动它的 Camera2D 不改写卡片世界位置。
view = CanvasViewport(1024, 800, world)
// 有限视口申请剩余空间,页面交叉轴拉伸,不把 Scene2D 改造成窗口尺寸的有限容器。
view.flex = 1
page.crossAlign = "stretch"
page.render(view)
// render 已登记/安排展示,返回 Page 给 OpenTool/调用者;返回值不是 GUI 线程的原生窗口对象。
return page
}
+49 -5
View File
@@ -1,7 +1,9 @@
//! 定义从托管对象提取的不可变视觉树。共享业务对象可以有多次展示,树节点与 Mount 出现的身份不是同一概念。
//! 节点保存排版、绘制和协议投影信息;浅比较用于决定是否复用已有快照。
//!
//! Owned immutable visual derivation; no managed VM handles.
//! model::snapshot/Block 快照提取构造 UiNode;render::layout/source_layout、绘制和命中流程读取它。
//! 这里不持有 ObjectRef,因此 Arc 树可作为纯数据派生结果跨线程传递,不授权另一线程访问 VM。
//! shallow_eq 由快照缓存判断能否复用旧 Arc;children/has_flex/allocates_children 供结构遍历和布局选择使用。
use std::sync::Arc;
#[derive(Debug, Clone, PartialEq)]
@@ -9,33 +11,46 @@ use std::sync::Arc;
pub struct UiNode {
// 只有页面根带兼容 Page 相机;其余字段是普通有限盒子的布局策略。
pub surface: Option<crate::page::Surface>,
// 兼容 flex 权重,父布局据此分配剩余主轴空间;不表示节点自身已经测量出的尺寸。
pub flex: f32,
// 显式 grow/shrink 等分配策略;与 flex 数值一起参与缓存相等性和父级分配判断。
pub flex_policy: crate::layout::FlexPolicy,
// 父容器主轴空白的对齐策略,与孩子排列次序和具体尺寸计算分开。
pub main_align: crate::layout::MainAlign,
// 容器为未指定 align_self 的孩子提供的交叉轴缺省策略。
pub cross_align: crate::layout::CrossAlign,
// 孩子对父 cross_align 的单独覆盖;None 表示继承,不能解释为禁止 stretch。
pub align_self: Option<crate::layout::CrossAlign>,
// 父布局分配时需要预留的外边距,不作为孩子内容绘制的本地原点偏移。
pub margin: crate::layout::Insets,
// 两轴的自然/固定/填充尺寸规则,由盒模型参与测量和最终安排。
pub sizing: crate::box_model::Sizing,
// 单边可选覆盖值;None 表示沿用 kind 的统一 padding,不代表边距为零。
pub padding_edges: [Option<f32>; 4],
pub border: crate::layout::Insets,
// 留原始颜色字符串供显示列表解码;颜色变更也必须使 shallow_eq 为 false。
pub border_color: String,
// 是否把后代限制在内容可视区域内,绘制/命中需要采用同一剪裁关系。
pub clip_children: bool,
// 这些字符串只是派生身份/协议版本键,不是可以操作托管堆的 ObjectRef。
pub block_id: String,
// 源视觉协议相关的派生版本键;即使盒模型未变,协议变化仍可能要求重新测量或绘制。
pub protocol_key: String,
// slot 保持组成身份;数组次序确定当前绘制/布局顺序,二者不是同一概念。
pub slots: Vec<u64>,
// 从按钮、点击连接或自定义事件等能力推导的交互资格,供命中宽阶段使用,不代表最终 hitTest 必然成功。
pub interactive: bool,
// 标识原始 pointer 事件能力,与高层 click 的可交互标记分别参与输入分派。
pub pointer_events: bool,
// 具体叶/容器/场景内容,原生测量与软件绘制按此枚举选择规则。
pub kind: UiKind,
}
impl UiNode {
/// 按节点种类返回其视觉子项;叶节点使用空切片,避免为遍历另建组成。
/// 布局、快照和命中结构遍历调用,返回已有孩子切片,不分配新列表或递归复制子树。
pub fn children(&self) -> &[Arc<UiNode>] {
match &self.kind {
// 这些种类都显式包含 children;三维面板 UI 也复用普通 UiNode 子树遍历。
UiKind::Column { children, .. }
| UiKind::Overlay { children, .. }
| UiKind::Canvas { children, .. }
@@ -45,11 +60,12 @@ impl UiNode {
| UiKind::Row { children, .. }
| UiKind::Button { children, .. }
| UiKind::Scroll { children, .. } => children,
// Text/Rect/Spacer 没有视觉子树;返回静态空切片,调用方无需按种类再分支。
_ => &[],
}
}
/// 判断是否需要对子项执行弹性主轴分配。
/// 父布局/allocates_children 查询此孩子是否需要分配空间;判断的是节点需求,不会在此实际改尺寸。
pub fn has_flex(&self) -> bool {
// 不仅旧 flex 数值,非默认 FlexPolicy 或任一轴 Fill 也要求父节点执行分配,不能仅按自然尺寸排布。
self.flex > 0.0
@@ -58,17 +74,18 @@ impl UiNode {
|| self.sizing.height == crate::box_model::Dimension::Fill
}
/// 判断当前节点是否会为子项指定实际分配范围,供源布局选择安排路径。
/// source_layout 选择安排路径时调用,检查任一孩子的弹性需求或交叉轴拉伸是否要求明确分配。
pub fn allocates_children(&self) -> bool {
// 任何孩子需要弹性或拉伸即进入分配路径;align_self 优先于父 cross_align。
self.children().iter().any(|child| {
// any 短路即可确定是否需要分配;align_self 优先级高于容器 cross_align。
child.has_flex()
|| child.align_self.unwrap_or(self.cross_align)
== crate::layout::CrossAlign::Stretch
})
}
/// 比较节点自身配置和直接子项身份,决定能否复用旧 Arc;不是深拷贝业务图。
/// model::snapshot 缓存重建节点后调用,判断可否保留旧 Arc;返回 true 不会修改任何节点或 VM 对象。
pub fn shallow_eq(&self, other: &Self) -> bool {
// 先比较所有公共视觉/协议配置,任一变化都不能复用旧节点 Arc,即使子内容完全相同。
if self.surface != other.surface
@@ -89,6 +106,7 @@ impl UiNode {
|| self.interactive != other.interactive
|| self.pointer_events != other.pointer_events
{
// 共有字段任一不同都足以否决复用,不再比较 kind 或递归访问后代。
return false;
}
// 直接孩子按 Arc 指针比较而不是递归深比较;依赖提取保证未变子树复用 Arc,父级只需验证其身份。
@@ -102,6 +120,7 @@ impl UiNode {
};
// 同类变体再比较其专有数据和直接子项;变体不同必为 false,避免布局种类切换后误复用缓存。
match (&self.kind, &other.kind) {
// a/b 与 d/e 是尺寸,c/f 是场景视图,g/h 是控制开关,i/j 是面板尺寸;孩子还需同身份。
(
UiKind::Viewport3D {
width: a,
@@ -120,6 +139,7 @@ impl UiNode {
..
},
) => a == d && b == e && c == f && g == h && i == j && same_children(),
// 二维视口除了宽高还比较相机快照;相机变了即使场景孩子没变也不能复用旧节点。
(
UiKind::CanvasViewport {
width: a,
@@ -135,14 +155,18 @@ impl UiNode {
},
) => a == d && b == e && c == f && same_children(),
(UiKind::Scene2D { positions: a, .. }, UiKind::Scene2D { positions: b, .. }) => {
// 显式世界位置和孩子身份一起决定组成,不能因同一批对象只移动位置而漏掉刷新。
a == b && same_children()
}
(UiKind::Overlay { padding: a, .. }, UiKind::Overlay { padding: b, .. }) => {
// Overlay 没有 gap/累计主轴位置,专有属性只比 padding,再检查孩子序列。
a == b && same_children()
}
(UiKind::Canvas { positions: a, .. }, UiKind::Canvas { positions: b, .. }) => {
// 兼容 Page Canvas 与 Scene2D 相同地保留显式位置,但仍要求两边变体相同。
a == b && same_children()
}
// 文本叶没有孩子,内容、字号和颜色相等即可;共有字段已在上面验证。
(
UiKind::Text {
value: a,
@@ -155,6 +179,7 @@ impl UiNode {
color: cb,
},
) => a == b && sa == sb && ca == cb,
// 矩形依次比较宽、高、圆角和颜色,不以外框尺寸相同掩盖绘制变化。
(
UiKind::Rect {
width: a,
@@ -169,7 +194,9 @@ impl UiNode {
color: h,
},
) => (a, b, c, d) == (e, f, g, h),
// Spacer 唯一专有属性是占位长度。
(UiKind::Spacer(a), UiKind::Spacer(b)) => a == b,
// 滚动偏移属于 Mount,此处只比轴/有限尺寸/内容规则及孩子,避免共享节点携带单次出现偏移。
(
UiKind::Scroll {
axis: aa,
@@ -188,6 +215,7 @@ impl UiNode {
children: _,
},
) => aa == ab && a == c && b == d && pa == pb && ga == gb && same_children(),
// 这三种容器共用 padding/gap 比较,但模式配对仍只允许 Column 对 Column 等同种比较。
(
UiKind::Column {
padding: a, gap: b, ..
@@ -212,6 +240,7 @@ impl UiNode {
padding: c, gap: d, ..
},
) => a == c && b == d && same_children(),
// 任何不同变体都不能复用,哪怕专有数据恰巧具有相同数值。
_ => false,
}
}
@@ -222,27 +251,36 @@ impl UiNode {
pub enum UiKind {
// 有限 3D 视口包含不可变场景视图和面板 UI 孩子;panels 与 children/slots 按顺序对应。
Viewport3D {
// 有限布局盒子尺寸,不是三维世界的几何范围。
width: f32,
height: f32,
// 已提取的场景、相机和投影数据,render 不需要为每个像素回读 VM。
view: Arc<crate::scene3d::View>,
// 是否存在可用控制器,输入宽阶段据此判断相机手势能力。
controls: bool,
// 各世界 UI 面板的逻辑尺寸,与 children 次序对应,供投影与布局共用。
panels: Vec<crate::layout::Size>,
// 面板 UI 的普通二维树;业务对象身份仍由 UiNode.block_id/slots 描述。
children: Vec<Arc<UiNode>>,
},
// 二维世界组成保留孩子的显式位置,不使用行列的累计偏移定位。
Scene2D {
// 每个孩子的世界 x/y,数组下标与 children 对齐,不按叠放顺序推算位置。
positions: Vec<[f32; 2]>,
children: Vec<Arc<UiNode>>,
},
// 有限布局盒子观察独立 Scene2D,相机仅影响投影,不改变场景节点原始逻辑位置。
CanvasViewport {
// 参加父布局的有限盒子尺寸;不是 Scene2D 孩子的总包围盒。
width: f32,
height: f32,
// 纯数据相机快照,只影响本视口的世界到屏幕映射,不改写原世界树。
camera: crate::viewport::CameraView,
children: Vec<Arc<UiNode>>,
},
// 孩子重叠布置,顺序同时决定叠放次序;普通有限布局与世界 Canvas 不同。
Overlay {
// 统一内边距,单边覆盖仍由 UiNode.padding_edges 提供。
padding: f32,
children: Vec<Arc<UiNode>>,
},
@@ -259,16 +297,21 @@ pub enum UiKind {
},
// 文字叶节点保留源字号等级与颜色文本;排版及最终设备比例由后续布局/显示列表转换处理。
Text {
// 已读取的文本内容,字体测量与软件绘制使用同一字符串。
value: String,
// 宿主字号等级,实际字体像素大小由 text/布局映射规则确定。
size: u32,
// 源颜色格式已在提取路径验证,绘制阶段再转成原生颜色值。
color: String,
},
// 纯绘制叶节点;几何尺寸与圆角供布局/显示列表共用,不含托管对象句柄。
Rect {
width: f32,
height: f32,
radius: f32,
color: String,
},
// Column 沿纵轴累加孩子位置,Row 沿横轴;二者都保存统一内边距和孩子间距。
Column {
padding: f32,
gap: f32,
@@ -283,6 +326,7 @@ pub enum UiKind {
Spacer(f32),
// 滚动盒保存有限分配尺寸、允许轴和普通内容;实际偏移按 Mount 出现分别维护,不放到共享节点里。
Scroll {
// axis 指定允许消费的滚动方向;实际可消费距离还取决于具体 Mount 的内容/视口范围。
axis: crate::scroll::Axis,
width: f32,
height: f32,