diff --git a/README.md b/README.md index f76b10c..9f06329 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,10 @@ Tessera is a native desktop UI host embedding [Aster](vendor/aster/README.md). Agents submit ordinary Endor scripts through a local namespace API. Aster parses, compiles and executes them; `ui.page().render(root)` selects a Tessera UI tree. Tessera extracts a native tree, lays it out and draws it using the existing software renderer. -The initial page is empty. +The application starts with one managed New tab, implemented in Endor. + +For the current architecture, UML diagrams and implementation status, start with +the [中文架构与进展导览](docs/architecture/README.md). ## Source and library integration @@ -110,9 +113,10 @@ not Endor keyword argument syntax. | `ui.page` | No arguments; creates a page with a bound `render(root)` member | Colors are `#RRGGBB` or `#RRGGBBAA`. Dimensions are finite numbers in 0..1000000. -A namespace has one displayed page. If a script creates several pages or renders -more than once, its final successful render selects the visible root. Rendering -does not publish partial execution: a later error rejects the entire submission. +A namespace retains one active program with multiple managed Pages and windows. +Each window projects its ordered Page registry and currently selected Page. +Rendering an existing Page updates that Page without replacing shared business +objects. A complete-source PUT does not publish partial execution: a later error rejects the entire submission. Aster keeps its exact numeric representation; conversion occurs only at the UI boundary. Text is shaped and antialiased with COSMIC Text and Swash, using @@ -166,7 +170,9 @@ candidate and returns 500, retaining the previous document. This stage supports live mounted UI for both complete programs and Console inputs, with immutable window frames on the existing worker. Native mouse/keyboard activation and code-driven callbacks share onClick/click. Managed Mount objects expose each occurrence, and property dependencies and measurement caches avoid recomputing clean visual subtrees. Arrangement still traverses occurrences. -Tab chrome/multiple windows, trait definitions and complete visual type/effect contracts and process isolation are still being implemented. Worker threads, +Managed tab chrome and multiple windows are available. Typed capability contracts +are installed for host protocols; the complete language/type/effect surface and +process isolation remain incomplete. Worker threads, source budgets and VM instruction limits are not a complete untrusted-code sandbox. ## Developer tools @@ -277,7 +283,7 @@ chrome animation retains only projected scalar IDs and rectangles. Tabs are capp 208 logical pixels wide, with a 40-pixel strip and equal 4-pixel vertical insets. The leading page symbol and close control have matching slots and edge spacing. -The default page is implemented in `src/ui_bridge/start_page.endor`, compiled once +The default page is implemented in `src/bridge/ui_bridge/start_page.endor`, compiled once per VM into a private namespace. Its buttons create a Page, open a working shared counter, or show composed Blocks. `ui.newTab()` constructs the same ordinary Page used by native + and the keyboard shortcut. Layout, callbacks, state and bindings @@ -373,7 +379,7 @@ Sources shows the full source snapshot or Console transcript with line numbers, AST and selected source ranges. Selecting an AST node scrolls to its location while keeping the entire file available. Cloud files has a separate file navigator and source viewer, initially disconnected. The `CloudSourceProvider` boundary in -`src/sources.rs` supplies file identities/paths and full source on open; there is no +`src/shell/sources.rs` supplies file identities/paths and full source on open; there is no cloud backend, fake file listing or source persistence in this release. Network observes only HTTP requests handled by this Tessera process on @@ -391,7 +397,7 @@ Very bright custom colors are darkened to keep accents visible on white. Executi separators, selected tabs and focus indicators use the accent, while text keeps its readable neutral/error/success colors. Settings and source remain in memory only. -Management controls use the internal [Tessera System UI](src/system_ui/README.md) +Management controls use the internal [Tessera System UI](src/shell/system_ui/README.md) widget library, independent of Endor-controlled page widgets. Buttons, tabs, toggles, rows, input fields, color swatches and drag handles share hover, pressed, selected, focus and disabled presentation. Buttons activate on release inside; diff --git a/docs/architecture/01-modules.md b/docs/architecture/01-modules.md new file mode 100644 index 0000000..91a84bb --- /dev/null +++ b/docs/architecture/01-modules.md @@ -0,0 +1,86 @@ +# 01 · 模块、依赖与部署 + +## 项目目录 + +```text +Tessera/ +├── Cargo.toml / Cargo.lock Rust 依赖和固定解析结果 +├── src/ +│ ├── main.rs 组合入口、事件循环和共享发布状态 +│ ├── shell/ 原生窗口、标签、开发工具、管理控件 +│ ├── runtime/ 本机 API、VM worker、执行、事件与异步任务 +│ ├── model/ 托管对象、挂载、绑定、依赖和发布数据 +│ ├── render/ 布局、绘制、命中、字体与软件光栅化 +│ └── bridge/ Endor 与宿主 UI 类型及行为的连接 +├── examples/ Endor 示例程序,保留原文件名与入口 +├── docs/ +│ ├── architecture/ 本套架构说明和 UML 源文件 +│ └── media/ 已有建模视频与字幕 +├── scripts/ 原生打包及已有远程检查入口 +└── vendor/aster/ 独立 Git 子模块:语言、编译器、VM +``` + +`target/` 是忽略的构建产物,不是源码目录。示例与媒体保留原位置,避免破坏已有引用。完整源码职责见 [逐文件清单](file-map.md)。 + +## 组件关系 + +箭头表示主要运行时调用/数据流,不宣称是无环的 Rust import 图。 + +```mermaid +flowchart LR + Agent[本机客户端 / Console] --> API[runtime: HTTP API] + API --> Worker[runtime: 串行 VM worker] + Worker --> Aster[Aster frontend / compiler / core] + Worker --> Bridge[bridge: UI 类型和行为] + Bridge <--> Model[model: 对象与展示状态] + Bridge --> Render[render: 布局与画面提取] + Worker --> Published[NamespaceState / RenderDocument] + Published --> Shell[shell: 原生工作区] + Shell --> Scene[render: 场景合成与光栅化] + Scene --> OS[winit / softbuffer / 操作系统] + Shell -->|序列化输入| API + Worker --> IO[runtime: 有界 I/O provider] + IO -->|纯数据完成消息| Worker +``` + +标准 UML 组件表示见 [components.puml](uml/components.puml)。`System UI` 只依赖低层几何、文本和画图,不导入 Endor VM 或 guest bridge;开发工具的执行逻辑由外层提供。 + +## 线程与进程 + +| 执行位置 | 核心所有物 | 交互方式 | +| --- | --- | --- | +| GUI / winit 主线程 | TesseraApp、原生 Window、Surface、标签动画、工具交互 | 读已发布快照;发 viewport、输入和窗口命令 | +| `tessera-agent-api` | tiny_http 接收循环、请求观察 | 对写入做校验并进入最多 8 条的构建队列;读取当前发布状态 | +| `tessera-endor-worker` | 活跃 ReplSession、候选 VM、托管对象图 | 串行执行程序、回调、布局/绘制协议及任务完成交付 | +| 输入发送线程 | 有界输入客户端 | 把原生输入转为本机 HTTP 请求;不执行 VM | +| 按需 I/O 池 | 文件/HTTP 工作及取消控制 | 不持有托管根;通过 mailbox 回传文本/错误 | +| 系统 curl 子进程 | `fetchText` 的实际 HTTP 传输 | 有超时、大小和协议限制;不是业务服务 | + +```mermaid +flowchart TB + subgraph Desktop[Tessera 单个桌面进程] + GUI[GUI 主线程] + HTTP[本机 API 接收线程] + VM[唯一执行 worker] + IO[I/O 池] + State[(Arc / RwLock NamespaceState)] + HTTP -->|有界队列| VM + VM -->|原子发布| State + State -->|只读快照| GUI + GUI -->|输入客户端| HTTP + VM <--> IO + end + External[本机 agent] -->|127.0.0.1:4737| HTTP + IO --> Curl[系统 curl 子进程] + GUI --> Native[原生窗口 / 软件像素缓冲] +``` + +可编辑部署图:[deployment.puml](uml/deployment.puml)。Aster 作为 Cargo path dependency 编译到同一应用,不是额外 VM 服务;Lyra 和 Hestia 不在应用依赖链中。 + +## 入口定位 + +- 启动:`src/main.rs` → `runtime/api::start` → `shell/app::TesseraApp`。 +- 请求路由和有界队列:`runtime/api.rs`;VM 所有权与发布:`runtime/api/worker.rs`。 +- 启动 Endor 主程序/Console:`runtime/endor_host.rs`。 +- 类型安装:`bridge/ui_bridge/bootstrap.rs`;原生 Block 方法:`calls.rs`。 +- 组合最终画面:`render/scene.rs`;写入像素:`render/software_renderer.rs`。 diff --git a/docs/architecture/02-object-model.md b/docs/architecture/02-object-model.md new file mode 100644 index 0000000..d0f7080 --- /dev/null +++ b/docs/architecture/02-object-model.md @@ -0,0 +1,115 @@ +# 02 · 对象模型、展示位置与所有权 + +## 托管语义与原生快照是两种东西 + +业务对象、Block、Window、Mount、Task 等语义对象位于 Aster 堆中。Rust 的 `ObjectRef` 只是受检查的堆身份;能否存活由 VM 根及 `trace` 边决定。窗口侧的 `Arc`、Frame、Tab 是提取后的原生值,不能用来复活或直接修改语义对象。 + +下面是简化 UML 类图;实线关联表示有强引用/直接持有,虚线标明弱引用或派生关系。它省略类型表、回调捕获边和桌面的内部字典,不能当成完整 GC 根枚举。 + +```mermaid +classDiagram + class ReplSession { + id: u64 + candidate: Candidate + bridge: UiBridge + documents: Map + faulted: bool + } + class Candidate { + vm: Vm + roots: ObjectRef[] + globals: ObjectRef + } + class ManagedWindow { + id: u64 + pages: ObjectRef[] + closed: bool + } + class Block { + state: ObjectRef + page: bool + } + class Cell + class Data { + kind: Kind + children: Child[] + properties: ObjectRef + bindings: ObjectRef[] + } + class Child { + slot: u64 + block: ObjectRef + } + class Mount { + block: ObjectRef + page: WeakObjectRef + parent: WeakObjectRef + geometry: MountGeometry + mounted: bool + } + class Presentation { + desktop: ObjectRef + active: ObjectRef + mounts: MountTable + render_cache: RenderCache + } + class UiNode { + block_id: String + slots: u64[] + kind: UiKind + } + class RenderDocument { + source: String + ui_root: Arc + frame: Frame + windows: Map + } + ReplSession *-- Candidate + ReplSession *-- UiBridge + UiBridge *-- Presentation : Session 中逐窗口保存 + ManagedWindow --> Block : pages / tabs 保留 Page + Block --> Cell : state + Cell --> Data : 普通内容 + Cell --> Block : 共享赋值转发时 + Data *-- Child + Child --> Block + Mount --> Block + Mount ..> Mount : parent / siblings 弱引用 + Presentation *-- MountTable + Presentation ..> UiNode : 缓存派生树 + RenderDocument o-- UiNode : 不可变原生值 + RenderDocument *-- Frame + RenderDocument *-- RenderDocument : 次级窗口 +``` + +可编辑来源:[domain.puml](uml/domain.puml)。`Page` 是具有 Page 行为的托管 Block,不是另一棵浏览器 DOM;`ManagedWindow` 是 VM 中的窗口语义,和 winit 的原生 Window 有对应关系,但不是同一对象。 + +## 同一对象的两次出现 + +例如两个成绩页都引用同一个课程对象,并通过 `ui.textOf(course, "grade")` 展示字段。字段改变后,两处绑定都能更新。即使同一个可视 Block 出现两次,也必须分别布局:这两次出现对应不同 Mount,具有不同坐标、父位置、滚动偏移和焦点上下文。 + +`Child.slot` 是稳定的子出现标识,不能简单用数组下标替代。插入、移动子项时,这有助于保持原来的展示身份,而不是把所有后续位置都当作新出现。 + +## 谁保留什么 + +| 关系 | 保留/释放含义 | +| --- | --- | +| Candidate 的 globals / service roots | 保持当前程序全局对象和宿主服务可达 | +| Block → Cell → Data,或 Cell → Block | 支持共享赋值与别名;内容变化不会要求窗口侧重建对象身份 | +| Data → children / bindings / callbacks / history | 实际托管强边,交给 Aster GC 追踪 | +| Window 的 Page registry | 打开的 Page 被保留;关闭标签释放 registry 边 | +| 桌面内部 mount 字典 → Mount | 保留活跃展示位置;Mount 强引用其 Block | +| Mount → page / parent / siblings | 弱反向边,避免共享内容把 Page 留住 | +| MountTable、对象诊断索引、任务索引 | 使用弱身份,不单独延长对象寿命 | +| Subscription token → connection | 弱引用;丢弃 token 不等于取消监听,需 disconnect | +| 原生 Frame / Tab / DrawCommand | 无 VM handles;只是绘制和交互所需的值 | + +关闭标签不必然销毁 Page:脚本仍引用它时可以重新打开。反之,关闭后不可达的对象图可以回收;没有“固定页面池”。重置 Console、删除程序或成功替换程序会触发活跃运行时的清理路径。 + +## 三种版本号不能混用 + +- `submission`:已接受写入工作的先后次序,用于撤销旧候选的发布资格。 +- `revision`:已提交文档的修订,失败构建不应冒充成功修订。 +- `runtime_session` / Frame.session 与 `layout_version`:界面输入属于哪一代运行时、哪份布局;旧点击和旧拖放不能作用到新对象。 + +对象模型源文件集中在 `src/model/`。实际分配、根清理和回调预算由 `runtime/endor_host.rs` 与 bridge 协作完成。 diff --git a/docs/architecture/03-runtime.md b/docs/architecture/03-runtime.md new file mode 100644 index 0000000..2b383ea --- /dev/null +++ b/docs/architecture/03-runtime.md @@ -0,0 +1,102 @@ +# 03 · Endor 执行、并发请求与发布 + +## 完整程序提交的时序 + +```mermaid +sequenceDiagram + actor Client as 本机客户端 + participant API as api.rs + participant Queue as 有界队列 + participant Worker as api/worker.rs + participant Aster as Reader/Parser/Compiler/VM + participant Bridge as UiBridge + participant State as NamespaceState + participant GUI as TesseraApp + Client->>API: PUT /v1/namespaces/app.main/code + API->>API: 校验命名空间/大小/入队资格 + API->>Queue: BuildRequest + submission + Queue->>Worker: 串行接收 + Worker->>Aster: 在新候选中解析、编译和执行 + Aster->>Bridge: 构建对象、Page 和回调 + loop 可恢复的刷新切片 + Worker->>Bridge: 绑定、快照、布局、绘制、挂载同步 + Bridge-->>Worker: Pending / 完成的原生文档 + end + Worker->>State: 核对提交代际 + alt 仍有效且构建成功 + Worker->>State: 提交 RenderDocument / 窗口命令 + Worker->>Worker: 候选成为活跃 ReplSession + Worker-->>GUI: DocumentUpdated + Worker-->>Client: 成功响应与修订信息 + else 已过期或错误 + Worker->>Worker: 丢弃候选,保留已有有效程序 + Worker-->>Client: 409 / 422 / 500 等错误 + end +``` + +图简化了 HTTP Response 的持有:原始请求在入队后由 worker 完成响应,不是 GUI 执行 HTTP 请求。可编辑来源:[publication.puml](uml/publication.puml)。 + +## 不同入口的语义 + +| 入口 | VM / 发布行为 | +| --- | --- | +| `GET /agent` | 按当前 namespace 生成使用指南,不执行 Endor | +| `GET .../code` | 读取最后成功发布的源码文本 | +| `PUT .../code` | 新候选执行;成功后接替活跃运行时,失败保留原程序 | +| `POST .../repl` | 在活跃 Console/程序 VM 内执行,不靠重放历史恢复对象 | +| `DELETE .../repl` | 清理 Console 上下文,重新建立可用会话 | +| `DELETE .../code` | 清空并使旧工作失去发布资格 | +| `POST .../events` | 校验窗口、运行时代际与布局身份,再在拥有对象的 VM 中分发 | +| `POST .../evaluate` | 独立表达式求值;不是替换当前页面的入口 | + +在线契约以 `GET http://127.0.0.1:4737/agent` 为准,静态正文实现位于 `runtime/api/guide.rs`。 + +Console 的编译错误发生在执行前;运行期错误则可能保留此前已经完成的赋值,所以它**不是数据库事务回滚**。故障运行时有单独的停用/清理规则;完整 PUT 的隔离候选和 REPL 的活对象修改不能混为一谈。 + +## 代际与失败边界 + +```mermaid +stateDiagram-v2 + [*] --> Candidate: 接受完整 PUT + Candidate --> Refreshing: 编译执行成功 + Candidate --> Rejected: 编译/执行错误 + Refreshing --> Refreshing: 让出并继续刷新 + Refreshing --> Active: 文档有效且 submission 匹配 + Refreshing --> Superseded: 更新的写入或删除 + Active --> Active: Console / 输入 / 任务完成 + Active --> Faulted: 活跃回调或刷新故障 + Active --> Retired: 成功替换 / reset / delete / shutdown + Faulted --> Retired: 清理或替换 + Rejected --> [*] + Superseded --> [*] + Retired --> [*] +``` + +这是候选和活跃运行时的概念状态图,并非源码中一个同名 enum。刷新具体由 `RefreshWork`、`PendingLayout`、paint Work 与 worker 的 PendingRequest 协作,不能在挂起时换一个 VM 恢复。 + +## 异步任务 + +`ui.after`、`ui.readText`、`ui.fetchText` 和协作任务由托管 Task 表示。回调及 scope 是 VM 强边,调度索引保留弱身份。I/O 池只接收路径/URL等原生数据、取消控制及完成消息,不拿着 ObjectRef 在其他线程调用 VM。 + +```mermaid +stateDiagram-v2 + [*] --> Pending + Pending --> Running: 到期 / 可以执行 + Pending --> Completed: provider 完成且无需回调 + Pending --> Failed: provider 返回错误 + Pending --> Cancelled: owner 失效 / key 替换 / 主动取消 + Running --> Completed: 成功交付 + Running --> Failed: 传输或执行错误 + Running --> Cancelled: 取消及生命周期清理 + Completed --> [*] + Failed --> [*] + Cancelled --> [*] +``` + +源码的 Task 状态为 pending、running、completed、cancelled、failed;具体 provider 可能在 Pending 期间已开展后台 I/O,图中的 Running 不表示后台 I/O 一定此时才启动。见 [tasks.puml](uml/tasks.puml)。 + +## 预算与实际边界 + +源码上限 1 MiB;AST 访问上限 10000、深度 64;VM 栈帧上限 128;指令预算、UI 对象和页面预算各有上限。完整脚本各输入共享分摊总指令额度;Console 还检查累计来源与活对象限制。队列上限是 8,不是无限吞吐。 + +这些预算帮助限制失控工作,**不构成执行任意不可信代码的安全沙箱**。当前程序仍运行在桌面进程内,文件和网络能力没有完成产品级权限治理。 diff --git a/docs/architecture/04-rendering.md b/docs/architecture/04-rendering.md new file mode 100644 index 0000000..1b50b1d --- /dev/null +++ b/docs/architecture/04-rendering.md @@ -0,0 +1,56 @@ +# 04 · 布局、绘制、输入与刷新 + +## 一帧不是重新执行整个程序 + +活跃 VM 中的属性修改先被依赖系统观察,必要时刷新绑定、派生 UiNode、布局、paint 和 Mount 几何,再发布原生 Frame。干净的派生子树和测量结果可以复用;这不意味着所有帧都只访问变化节点,arrange、出现遍历和版本校验仍有成本。 + +| 环节 | 文件 | 产物 / 约束 | +| --- | --- | --- | +| 绑定与变化依赖 | `model/binding.rs`、`snapshot.rs`、`presentation.rs` | 属性/方法身份依赖、缓存失效 | +| 不可变视觉提取 | `bridge/ui_bridge/tree.rs`、`model/block.rs` | 无 VM handles 的 UiNode 树,可有共享子树 | +| native 布局 | `render/layout.rs` | 测量、排列、稳定出现身份与预算 | +| source 布局协议 | `render/source_layout.rs`、`native_visual.rs` | 只读 measure/arrange 调用、约束和缓存 | +| source/native 绘制 | `render/source_paint.rs` | 检查绘制命令、文字换行界限、依赖与预算 | +| 滚动 / 命中 | `render/scroll.rs`、`source_hit.rs` | 每 Mount 偏移、剪裁、逆绘制顺序选目标 | +| 窗口画面合成 | `render/scene.rs` | guest 页面 + 标签 + 当前开发工具 | +| 文本 / 光栅化 | `render/text.rs`、`software_renderer.rs` | 系统字体 shaping、抗锯齿 glyph、像素和嵌套剪裁 | + +GUI 在 redraw 时组合已发布的画面和宿主 chrome,并按实际屏幕倍率写入 softbuffer。需要运行 source 方法的布局或 paint 由 worker 处理;viewport 变化也会送回 worker,GUI 线程不执行这些 VM 方法。 + +## 点击如何回到对象 + +```mermaid +sequenceDiagram + actor User as 用户 + participant GUI as TesseraApp + participant Client as events::Client + participant API as API / worker + participant VM as ReplSession + participant Dispatch as event_dispatch + participant Publish as 刷新并发布 + User->>GUI: 鼠标按下 / 松开 + GUI->>GUI: 已发布几何命中,检查 press/release 目标 + GUI->>Client: Input(window, session, layout_version, mount) + Client->>API: POST /events + API->>API: 拒绝过期会话、窗口和拖放目标 + API->>VM: dispatch_incremental + VM->>Dispatch: 解析当前 Mount 和 Block + Dispatch->>Dispatch: capture → target → bubble + Dispatch->>VM: 受预算的 handler / onEvent / onClick + VM->>Publish: 刷新共享对象的相关展示 + Publish-->>GUI: 新 Frame / DocumentUpdated +``` + +可编辑来源:[input.puml](uml/input.puml)。没有 Mount 的代码主动 dispatch 只访问目标;有 Mount 的事件沿该次出现的父路径传播。事件监听器连接由 Block 保留,Subscription token 是弱控制句柄。 + +`stopPropagation`、`stopImmediatePropagation`、`preventDefault` 有各自语义;`preventDefault` 目前没有额外宿主默认动作可取消。原生输入不应绕过同一分发器直接调用另一个回调机制。 + +## 为什么 Mount、剪裁与 source 协议必须一起看 + +一个 Block 在两个容器出现时,源方法接收不同 Mount 的约束和几何。滚动需要更新该次出现的坐标、剪裁和祖先选择;命中顺序必须对应画出来的顺序。仅修改 paint 而不修改 hit/layout 会导致“看得到点不到”,反过来也会产生不可见点击区域。 + +只读协议及签名约束用于保护这些阶段,但不应表述为语言的所有 effect/trait 特性都已完成。当前具体原生签名和检查见 `bridge/ui_bridge/signatures.rs`、`model/protocols.rs` 及 Aster 固定版本。 + +## 刷新中的过时工作 + +尺寸、对象内容或运行时代际在刷新中变化时,旧 layout/paint 可以取消或重启。worker 负责完成当前清理并重新对齐 viewport,不能把旧 Mount 几何发布为新布局。当前刷新主协调仍在 `runtime/endor_host.rs` 与 `bridge/ui_bridge.rs`,后续可继续拆出独立 coordinator;本次没有改变该调度算法。 diff --git a/docs/architecture/05-workspace.md b/docs/architecture/05-workspace.md new file mode 100644 index 0000000..02df236 --- /dev/null +++ b/docs/architecture/05-workspace.md @@ -0,0 +1,47 @@ +# 05 · 标签、窗口、默认页与开发工具 + +## 三层身份 + +| 层次 | 负责什么 | 不负责什么 | +| --- | --- | --- | +| 托管 Page / ManagedWindow | 页面内容、打开/关闭、所属窗口、脚本覆盖行为和对象可达性 | 像素级 hover、原生按钮位置 | +| 已发布 Tab / Frame / Window Envelope | 传输不透明 ID、标题、活动状态、窗口命令和版本 | 保留 VM 堆对象 | +| TesseraApp / tabs::Motion / winit Window | 绘制、命中、拖动、滚动、原生资源及即时交互 | 执行业务回调、复制业务对象图 | + +```mermaid +stateDiagram-v2 + [*] --> Open: Page 打开并被 registry 保留 + Open --> Selected: activate / 点击标签 + Selected --> Open: 选择别页 + Open --> Closed: close 释放 registry 边 + Selected --> Closed: 关闭活动页 + Closed --> Open: 脚本仍引用 Page 时重新打开 + Closed --> Collected: 不再有托管强边且 GC 回收 + Collected --> [*] +``` + +该图是**可达性与展示状态的说明**,不是一个源码 enum。关闭原生窗口、移除 Page 展示和回收对象不是同一动作。跨窗口拖放携带来源/目标窗口、布局版本、插入位置等元数据,worker 拒绝过期目标,再更新托管 registry;GUI 不直接移动堆对象。可编辑来源:[windows.puml](uml/windows.puml)。 + +## 已完成的标签行为 + +- 启动有一个默认 Page;`+`、Cmd/Ctrl+T 新建,Cmd/Ctrl+W 关闭活动页。 +- 顶部与左侧宿主区域共用背景;内容区单独作为页面表面,未实现用户暂不需要的左右页面分栏。 +- 40 逻辑像素栏高、32 像素标签、88–208 像素宽度;保留高密度和鼠标/触控板滚动,移除两侧点击箭头。 +- 非活动标签之间有短竖线,活动/hover 邻边按状态隐藏或渐变。 +- 新建/关闭/自动揭示使用独立几何动画;hover 背景、关闭按钮和分隔线使用 160ms 颜色过渡,点击选中即时生效。 +- macOS 原生红黄绿保留系统行为,并与标签中心线对齐;工具栏空白处可拖动和双击放大/还原。 + +`native_chrome.rs` 通过 AppKit 操作现有窗口与系统按钮。双击使用 NSEvent.clickCount 和 NSWindow.performZoom,而不是画一个假的最大化动画。读取系统偏好:最小化/禁用遵循分支;Fill 目前映射到公开 zoom API,没有调用 Chrome 使用的私有 `_zoomFill`,不保证复刻所有新系统平铺边距。 + +## 默认页与四个工具页 + +默认页源码是 [start_page.endor](../../src/bridge/ui_bridge/start_page.endor),位于独立私有模块;创建页、共享计数和组合示例都是真实 Endor 对象操作。它不覆盖用户程序 globals,也没有把四个工具页当作默认内容。 + +Console、Sources、Network、Settings 各自有独立页面实现和窗口内交互状态。目前通过 `ui.developerTools("Console")` 等入口与现有快捷键进入,工具发现菜单仍延后。四页不是新平台业务模块,也不代表已经接入云服务。 + +- Console:真实 VM 输入、输出和本机请求活动;不是只显示介绍的文本区域。 +- Sources:当前源码/Console transcript、AST 与选择;云文件 provider 默认未连接。 +- Network:仅本 Tessera API 请求的有界记录,不是浏览器或操作系统抓包。 +- Settings:内存内主题强调色;没有跨进程持久化。 + +源码位置:`shell/developer_tools/`。宿主管理控件在 `shell/system_ui/`,和 guest 的 Button/Block 类型有明确区别。 diff --git a/docs/architecture/06-progress.md b/docs/architecture/06-progress.md new file mode 100644 index 0000000..4c52f5f --- /dev/null +++ b/docs/architecture/06-progress.md @@ -0,0 +1,43 @@ +# 06 · 当前进展、限制与验证 + +截至 2026-10-25,可以用 Endor 建立共享对象、多 Page 的原生交互应用,在 Console 中持续修改它们,并用已有开发工具观察源码和本机 API。这是可工作的桌面宿主及语言 UI 集成阶段,不是完整 LMS、浏览器或云 IDE。 + +## 进展矩阵 + +| 领域 | 已有实现与证据位置 | 尚未完成 / 不能据此推断 | +| --- | --- | --- | +| 语言运行 | 固定版本 Aster Reader、Parser、Compiler、VM;完整程序和持久 Console,`runtime/endor_host.rs` | 不等于 Endor 全语言、全部数值指令或所有 trait/effect 语法均已完整实现 | +| 对象与类型 | 托管 Block/Cell/Data、原生名义类型、源类与原生子类、方法签名和能力检查,`bridge` / `model` | 仍受当前 Aster 版本的表达式、注解和 effect 实现子集限制 | +| 活界面 | 共享对象绑定、Mount、source/native measure/arrange/paint/hitTest、缓存及刷新预算 | 不保证每次刷新只做局部 O(变更数) 工作;无 GPU 渲染后端 | +| 页面/窗口 | 默认页、真实多 Page、标签关闭重开、原生多窗口、带代际校验的拖放 | 混合 DPI 跨屏、真实触屏与动画帧时序没有完整硬件验收 | +| 桌面交互 | 统一 chrome、原生按钮对齐、双击 zoom/restore、高密度滚动、hover 淡入淡出 | Fill 并未接 macOS 私有精确平铺接口;非 macOS 构建本轮未验证 | +| 事件 | capture/target/bubble、代码/native 共用分发、Subscription、Mount 生命周期回调 | preventDefault 尚无额外宿主默认动作可取消 | +| 异步 | timer、文件读取、fetchText、协作调度、owner/key 取消 | 不是完整浏览器网络栈;fetchText 使用系统 curl,无完整产品权限体系 | +| 开发工具 | Console/Sources/Network/Settings 四个独立 Page | 云 source provider 默认断开;工具发现 UI 延后;状态不持久化 | +| 示例 | 学生成绩示例、七页 Campus LMS、共享提交/成绩更新、建模录屏 | 全部虚构数据;预设评分,不连接 Canvas,不具备真实认证/上传/成绩计算服务 | +| 本机 API | 仅 loopback、有界队列、版本和失败保留,`runtime/api*` | loopback 不是完整授权系统,执行预算不是不可信代码沙箱 | +| 分发 | 可构建 Finder 可启动的 macOS 开发 bundle | 没有发行签名、公证、安装器和本轮跨平台发布验证 | + +## 推荐看这几个实物 + +1. [默认页源码](../../src/bridge/ui_bridge/start_page.endor):理解“页面本身就是 Endor 程序”。 +2. 默认页的“共享对象示例”入口:展示一个状态对象的两处绑定;更多源码见 [examples README](../../examples/README.md)。 +3. [Campus LMS 源码](../../examples/campus-lms.endor) 与 [操作说明](../campus-lms-demo.md):七个真实 Page 共享课程和提交对象。 +4. [建模视频](../media/endor-campus-modeling.mp4) 与 [中文字幕](../media/endor-campus-modeling.zh.srt):已有实际窗口操作记录,静音、采样帧率有说明。 +5. `GET /agent`:运行中的实时 API 指南,优先于长期静态样例推断接口。 + +## 本轮验证方式 + +本轮是整理,不扩展业务功能。沿用工作约定:格式、`cargo clippy --all-targets --offline -- -D warnings`、原生 release 打包、真实窗口和少量人工 API 操作;不执行 cargo test、自动 smoke 脚本或 Docker。Clippy 会编译检查已有测试代码,但不代表测试已经执行。 + +最终实际执行结果见 [整理清单的验收记录](file-map.md#验收记录)。没有在本机安装 PlantUML/Graphviz 或额外测试依赖;Mermaid/PlantUML 图是可编辑文档源码,未宣称完成图形渲染器验收。 + +## 后续建议顺序 + +以下是后续建议,不是本轮已经实现或已承诺排期的功能。 + +1. 把多 Page、关闭重开、跨窗拖放、共享字段刷新作为固定人工验收场景,优先暴露状态与生命周期问题。 +2. 沿稳定接口继续拆分 `ui_bridge` 的刷新协调和 `app` 的输入路由,减少大模块;不要仅为“分层”额外复制对象模型。 +3. 明确保存/恢复与文件 provider 的产品语义,再选择持久化格式和后端。 +4. 根据真实应用需求推进 Aster 运算与类型能力,避免用 UI 层预设值冒充完整业务计算。 +5. 在增加可信外部运行能力前明确权限、执行隔离与分发机制。 diff --git a/docs/architecture/07-decisions.md b/docs/architecture/07-decisions.md new file mode 100644 index 0000000..011667e --- /dev/null +++ b/docs/architecture/07-decisions.md @@ -0,0 +1,30 @@ +# 07 · 本轮整理的设计决策 + +## 职责目录,不冒充已经解耦的层 + +采用 `shell / runtime / model / render / bridge` 是为了让入口、对象、执行和绘制更容易定位。当前仍是一个 Rust binary crate:model 需要 bridge 派生类型与 render 几何,bridge 需要 model 和 render,runtime/API 也会通知 shell 的 AppEvent。组件图因此画的是主要行为流,不是严格单向依赖约束。 + +`main.rs` 的 `pub(crate) use` 是内部路径兼容门面,使现有 `crate::block` 等调用继续指向新目录。它不增加公共库 API、不产生第二份对象,也不隐藏多份实现。代价是调用路径暂时不能单独反映目录所属;需要打开 `main.rs` 或源码导航确定归属。后续若要拆 crate,应先定义跨边界协议并梳理实际循环依赖,而不是机械删掉别名。 + +## 拆分的是职责,不是复制代码 + +- API 的 worker 负责活跃 VM、候选请求、增量执行、发布与关闭;HTTP 路由和活动记录仍在 `api.rs`。 +- 在线 agent 指南独立到 `api/guide.rs`,仍由当前 NamespaceState 生成;嵌入例子的相对路径已随目录调整。 +- bridge 的 `tree.rs` 只放无托管句柄的派生画面数据;`signatures.rs` 负责规范签名;`calls.rs` 实现 NativeCallable;`bootstrap.rs` 安装类型表及 globals。 +- bridge 主模块保留 Session、窗口 Presentation 和挂起布局等协调。这里仍接近 2000 行;本轮没有声称已把全部大模块拆完。 +- 原有 tests.rs 随所属模块移动,保持原测试含义,只修正编译时样例文件路径。没有删减现有测试来让检查通过。 + +这些子模块共享父模块的内部类型;`use super::*` 表达它们是同一实现单元的拆分,而不是独立公共服务。worker 只向 API 父作用域暴露必要协调接口和现有测试所需状态;其他请求处理细节继续私有。 + +## 本轮保持的边界 + +- 不更改 Endor 示例、API 路由、语法、运行时预算、窗口视觉参数和交互效果。 +- 不更新 Aster 子模块,不改变 vendor 历史。 +- 不移动构建产物、已有示例与媒体目录,不修改用户未提交的示例工作。 +- 不引入新的产品依赖、UML 渲染服务或构建工具。 + +## 文档的可信度 + +本套 UML 以实际 Rust 类型、线程名、队列、方法和引用语义为依据。概念状态机、主要调用流及省略字段都在图旁标明;它们不是自动从源码生成的完整模型。进展矩阵区分代码已有、此前人工验证、本轮验证及未完成项。 + +更改执行/生命周期语义后应同步更新 `02/03/04` 与对应 `.puml`;更改 shell 交互后更新 `05`;发布阶段结果更新 `06` 和本轮验收记录。逐文件清单是本次迁移快照,不要求以后每次新增模块都伪装成同一轮迁移。 diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 0000000..faced32 --- /dev/null +++ b/docs/architecture/README.md @@ -0,0 +1,33 @@ +# Tessera 架构与进展导览 + +核对日期:2026-10-25。范围:本仓库当前实现;Aster 子模块固定在 `f3e1670`,本次不更新其代码或版本。 + +Tessera 目前是**运行 Endor 对象程序的原生桌面宿主**。Endor 程序创建和共享对象,Page 把对象组织成可交互界面;Tessera 负责窗口、输入、布局、绘制、调试入口和本机 API。它已经能运行多页面示例,但还不是带持久化、权限和业务后端的完整应用平台。 + +建议按下面顺序阅读。图表示真实结构或经过标注的简化流程,不把后续计划画成已经存在的组件。 + +| 文档 | 回答的问题 | +| --- | --- | +| [01 模块与部署](01-modules.md) | 目录为什么这样分?有哪些线程与外部依赖? | +| [02 对象模型与所有权](02-object-model.md) | Block、Page、Window、Mount 分别是什么?谁保留对象? | +| [03 执行与发布](03-runtime.md) | Endor 如何变成界面?错误和过期请求怎么处理? | +| [04 布局、绘制与输入](04-rendering.md) | 修改对象后如何刷新?点击如何回到同一个 VM? | +| [05 桌面工作区](05-workspace.md) | 标签、原生窗口、默认页、开发工具如何配合? | +| [06 当前进展](06-progress.md) | 已经做到什么?还有哪些限制?怎样复现? | +| [07 设计决策](07-decisions.md) | 为什么使用这些边界?整理还没有解决什么? | +| [逐文件整理清单](file-map.md) | 每个文件迁移到哪里、负责什么,本次拆出了什么? | + +UML 图直接嵌在上述 Markdown 中,支持 Mermaid 的阅读器可直接显示。另提供七份可编辑的 [PlantUML 源文件](uml/README.md),包括组件、部署、类、两条时序和两类状态机。两种表示都由当前代码人工核对,尚未接入自动生成或同步检查。 + +## 先抓住四个概念 + +1. **对象是程序状态**:共享内容留在 Aster 管理的对象堆内;切换标签不靠重放源码恢复对象。 +2. **Mount 是一次展示位置**:同一个 Block 可以在不同位置出现,每个位置有自己的几何、滚动和焦点上下文。 +3. **Frame 是发布给窗口的值**:窗口收到命令、文本、标量 ID 等原生数据,不拿到 VM 堆引用。 +4. **Page 和标签不是两套页面系统**:标签是托管 Page 的窗口侧投影;默认页也是 Endor Page。 + +## 本轮整理结果 + +`src` 的实现收进 `shell / runtime / model / render / bridge` 五个目录,根目录保留启动入口和源码导航。`api.rs` 从约 1800 行拆成请求处理、worker 与在线指南;`ui_bridge.rs` 从约 3450 行拆出画面树、签名、原生方法调用和类型初始化,主文件保留会话与展示刷新协调。 + +这些目录是**职责分组,不是已经隔离的五个 crate**。目前 model、bridge、render 仍有协作依赖,主窗口事件与 worker 之间也有协议耦合。根目录保留 crate 私有模块别名,让本次迁移不同时改写全部调用路径;详见设计决策。 diff --git a/docs/architecture/file-map.md b/docs/architecture/file-map.md new file mode 100644 index 0000000..4d26e09 --- /dev/null +++ b/docs/architecture/file-map.md @@ -0,0 +1,131 @@ +# 逐文件整理清单 + +本轮仅重排 Tessera 宿主实现;以下覆盖所有迁移源码、测试和默认页文件。未修改 `vendor/aster`、用户已有的 `examples/README.md` 改动及未跟踪的 `examples/workspace.endor`。 + +主入口 `src/main.rs` 改为五组目录模块及 crate 私有兼容别名;`src/README.md` 改为中文源码导航;根 `README.md` 更新架构入口、实际初始页/多页面状态和迁移后的路径。既有 Endor 示例、媒体和打包脚本保留原位置。 + +## shell + +| 原路径 | 新路径 | 用途与本次变化 | +| --- | --- | --- | +| `src/activity.rs` | [src/shell/activity.rs](../../src/shell/activity.rs) | 有界 HTTP 活动记录与允许的头部预览 | +| `src/app.rs` | [src/shell/app.rs](../../src/shell/app.rs) | GUI 事件循环、窗口/viewport、输入与 chrome 调度 | +| `src/clipboard.rs` | [src/shell/clipboard.rs](../../src/shell/clipboard.rs) | 原生 UTF-8 剪贴板适配 | +| `src/developer_tools.rs` | [src/shell/developer_tools.rs](../../src/shell/developer_tools.rs) | 四个工具 Page 的状态、焦点与管理交互协调 | +| `src/developer_tools/console.rs` | [src/shell/developer_tools/console.rs](../../src/shell/developer_tools/console.rs) | Console 提示符、执行输出与输入布局 | +| `src/developer_tools/network.rs` | [src/shell/developer_tools/network.rs](../../src/shell/developer_tools/network.rs) | 本机 API Network 检查视图 | +| `src/developer_tools/painter.rs` | [src/shell/developer_tools/painter.rs](../../src/shell/developer_tools/painter.rs) | 工具页面绘制辅助 | +| `src/developer_tools/selection.rs` | [src/shell/developer_tools/selection.rs](../../src/shell/developer_tools/selection.rs) | 文本选择和光标几何 | +| `src/developer_tools/settings.rs` | [src/shell/developer_tools/settings.rs](../../src/shell/developer_tools/settings.rs) | 内存内主题设置视图 | +| `src/developer_tools/sources.rs` | [src/shell/developer_tools/sources.rs](../../src/shell/developer_tools/sources.rs) | 源码、AST 和文件来源视图 | +| `src/native_chrome.rs` | [src/shell/native_chrome.rs](../../src/shell/native_chrome.rs) | macOS 原生按钮对齐和工具栏双击系统动作 | +| `src/sources.rs` | [src/shell/sources.rs](../../src/shell/sources.rs) | 默认断开的云文件 provider 接口及工作区 | +| `src/system_ui/README.md` | [src/shell/system_ui/README.md](../../src/shell/system_ui/README.md) | 管理控件职责说明及更新后的源码路径 | +| `src/system_ui/interaction.rs` | [src/shell/system_ui/interaction.rs](../../src/shell/system_ui/interaction.rs) | 宿主管理控件的 ID、命中、焦点和交互状态 | +| `src/system_ui/mod.rs` | [src/shell/system_ui/mod.rs](../../src/shell/system_ui/mod.rs) | 管理控件模块导出 | +| `src/system_ui/theme.rs` | [src/shell/system_ui/theme.rs](../../src/shell/system_ui/theme.rs) | 管理表面颜色和主题 | +| `src/system_ui/widgets.rs` | [src/shell/system_ui/widgets.rs](../../src/shell/system_ui/widgets.rs) | 宿主可复用控件绘制 | +| `src/tabs.rs` | [src/shell/tabs.rs](../../src/shell/tabs.rs) | 共享标签绘制/命中、密集布局、滚动、拖放及动画 | +| `src/tabs/hover.rs` | [src/shell/tabs/hover.rs](../../src/shell/tabs/hover.rs) | 标签、关闭和加号的颜色过渡 | +| `src/tabs/motion.rs` | [src/shell/tabs/motion.rs](../../src/shell/tabs/motion.rs) | 标签布局动画及 hover 重绘期限 | + +## runtime + +| 原路径 | 新路径 | 用途与本次变化 | +| --- | --- | --- | +| `src/api.rs` | [src/runtime/api.rs](../../src/runtime/api.rs) | HTTP 请求路由、队列、请求观察和响应;worker/指南拆为子模块 | +| `src/api/tests.rs` | [src/runtime/api/tests.rs](../../src/runtime/api/tests.rs) | 保留原 API 测试;只调整嵌入示例路径,不执行测试 | +| `src/endor_host.rs` | [src/runtime/endor_host.rs](../../src/runtime/endor_host.rs) | 候选/活跃 VM、编译执行、增量刷新、清理和诊断 | +| `src/endor_host/tests.rs` | [src/runtime/endor_host/tests.rs](../../src/runtime/endor_host/tests.rs) | 保留原宿主测试;只调整嵌入示例路径,不执行测试 | +| `src/endor_script.rs` | [src/runtime/endor_script.rs](../../src/runtime/endor_script.rs) | 组织 Aster Script/Module 输入及原始源码范围 | +| `src/event_dispatch.rs` | [src/runtime/event_dispatch.rs](../../src/runtime/event_dispatch.rs) | 托管事件、订阅和 capture/target/bubble 分发 | +| `src/events.rs` | [src/runtime/events.rs](../../src/runtime/events.rs) | 原生输入的有界 loopback 传输 | +| `src/io_tasks.rs` | [src/runtime/io_tasks.rs](../../src/runtime/io_tasks.rs) | 有界文件/HTTP provider、纯数据完成消息和取消 | +| `src/tasks.rs` | [src/runtime/tasks.rs](../../src/runtime/tasks.rs) | 托管 Task、owner/key 取消和协作任务调度 | +| `src/vm_sequence.rs` | [src/runtime/vm_sequence.rs](../../src/runtime/vm_sequence.rs) | 共享 VM 调用序列及同帧迭代驱动 | + +## model + +| 原路径 | 新路径 | 用途与本次变化 | +| --- | --- | --- | +| `src/binding.rs` | [src/model/binding.rs](../../src/model/binding.rs) | 属性依赖、只读计算与托管绑定 | +| `src/block.rs` | [src/model/block.rs](../../src/model/block.rs) | Block/Cell/Data 语义、稳定子 slot 和共享赋值 | +| `src/document.rs` | [src/model/document.rs](../../src/model/document.rs) | NamespaceState、RenderDocument、Frame、发布校验与原生命令 | +| `src/mount.rs` | [src/model/mount.rs](../../src/model/mount.rs) | 每次展示出现的托管上下文、生命周期及弱索引 | +| `src/object_list.rs` | [src/model/object_list.rs](../../src/model/object_list.rs) | 托管结果集合的类型封装与只读访问 | +| `src/presentation.rs` | [src/model/presentation.rs](../../src/model/presentation.rs) | 逐窗口布局、Mount、缓存、读依赖和失效状态 | +| `src/protocols.rs` | [src/model/protocols.rs](../../src/model/protocols.rs) | Visual/EventTarget/Children/Presentable 能力契约安装 | +| `src/snapshot.rs` | [src/model/snapshot.rs](../../src/model/snapshot.rs) | 变化依赖和不可变视觉提取缓存 | +| `src/window.rs` | [src/model/window.rs](../../src/model/window.rs) | 托管 Window、Page registry、命令及生命周期 | + +## render + +| 原路径 | 新路径 | 用途与本次变化 | +| --- | --- | --- | +| `src/display_list.rs` | [src/render/display_list.rs](../../src/render/display_list.rs) | 原生绘制原语和颜色 | +| `src/geometry.rs` | [src/render/geometry.rs](../../src/render/geometry.rs) | 点和矩形值 | +| `src/layout.rs` | [src/render/layout.rs](../../src/render/layout.rs) | 原生测量、排列、出现几何及预算 | +| `src/native_visual.rs` | [src/render/native_visual.rs](../../src/render/native_visual.rs) | 托管原生视觉协议、值转换和签名检查 | +| `src/scene.rs` | [src/render/scene.rs](../../src/render/scene.rs) | 页面、标签、插入标记和开发工具画面合成 | +| `src/scroll.rs` | [src/render/scroll.rs](../../src/render/scroll.rs) | 逐 Mount 偏移、嵌套滚动和剪裁投影 | +| `src/software_renderer.rs` | [src/render/software_renderer.rs](../../src/render/software_renderer.rs) | 软件光栅化、抗锯齿覆盖和嵌套剪裁 | +| `src/source_hit.rs` | [src/render/source_hit.rs](../../src/render/source_hit.rs) | 源/原生命中协议与匹配目标 | +| `src/source_layout.rs` | [src/render/source_layout.rs](../../src/render/source_layout.rs) | 源 measure/arrange、约束、挂起工作和测量缓存 | +| `src/source_paint.rs` | [src/render/source_paint.rs](../../src/render/source_paint.rs) | 源 paint 调用、受限命令转换和依赖 | +| `src/text.rs` | [src/render/text.rs](../../src/render/text.rs) | 系统字体、shaping、字形和测量缓存 | + +## bridge + +| 原路径 | 新路径 | 用途与本次变化 | +| --- | --- | --- | +| `src/ui_bridge.rs` | [src/bridge/ui_bridge.rs](../../src/bridge/ui_bridge.rs) | Endor 宿主类型、窗口展示会话、布局/绘制发布协调;本次拆出四个实现子模块 | +| `src/ui_bridge/developer_pages.rs` | [src/bridge/ui_bridge/developer_pages.rs](../../src/bridge/ui_bridge/developer_pages.rs) | 托管默认页与工具页的建立入口 | +| `src/ui_bridge/start_page.endor` | [src/bridge/ui_bridge/start_page.endor](../../src/bridge/ui_bridge/start_page.endor) | 默认页和共享/组合交互的 Endor 源码 | + +## 新增模块与拆分 + +| 新文件 | 职责 / 来源 | +| --- | --- | +| `src/shell/mod.rs` | 声明 shell 组内模块,形成明确的目录所有权 | +| `src/runtime/mod.rs` | 声明 runtime 组内模块,形成明确的目录所有权 | +| `src/model/mod.rs` | 声明 model 组内模块,形成明确的目录所有权 | +| `src/render/mod.rs` | 声明 render 组内模块,形成明确的目录所有权 | +| `src/bridge/mod.rs` | 声明 bridge 组内模块,形成明确的目录所有权 | +| [src/runtime/api/worker.rs](../../src/runtime/api/worker.rs) | 从 api.rs 提取 VM worker、增量请求状态、候选/活跃发布及关闭协调,内部可见性限制在父 API 模块 | +| [src/runtime/api/guide.rs](../../src/runtime/api/guide.rs) | 从 api.rs 提取当前 namespace 的在线 agent 使用指南;修正 include_str 示例路径 | +| [src/bridge/ui_bridge/tree.rs](../../src/bridge/ui_bridge/tree.rs) | 从 ui_bridge.rs 提取不可变 UiNode / UiKind 与比较逻辑 | +| [src/bridge/ui_bridge/signatures.rs](../../src/bridge/ui_bridge/signatures.rs) | 提取宿主方法/构造操作的规范签名 | +| [src/bridge/ui_bridge/calls.rs](../../src/bridge/ui_bridge/calls.rs) | 提取 NativeCallable for Function 的 Block 操作分发 | +| [src/bridge/ui_bridge/bootstrap.rs](../../src/bridge/ui_bridge/bootstrap.rs) | 提取 desktop、类型表与宿主 globals 安装 | + +## 新增架构文档 + +| 文件 | 说明 | +| --- | --- | +| `docs/architecture/README.md` | 中文阅读入口与本轮范围 | +| `01-modules.md` | 职责目录、组件和线程部署 | +| `02-object-model.md` | 对象、Mount、强弱引用与版本 | +| `03-runtime.md` | PUT/Console/API、候选发布与任务状态 | +| `04-rendering.md` | 依赖、布局、paint、命中和输入回路 | +| `05-workspace.md` | Page、标签、原生窗口和四个工具页 | +| `06-progress.md` | 已实现、限制、示例和后续建议 | +| `07-decisions.md` | 路径兼容门面与未完全解耦的边界 | +| `file-map.md` | 本逐文件迁移和拆分清单、验收记录 | +| `uml/README.md` | UML 文件导航及本地导出说明 | +| `uml/components.puml` | 组件 UML 源码 | +| `uml/deployment.puml` | 部署 UML 源码 | +| `uml/domain.puml` | 对象类 UML 源码 | +| `uml/publication.puml` | PUT 发布时序 UML 源码 | +| `uml/input.puml` | 输入分发时序 UML 源码 | +| `uml/tasks.puml` | 任务状态 UML 源码 | +| `uml/windows.puml` | Page 可达性/展示状态 UML 源码 | + +## 验收记录 + +- `cargo fmt --check`、`cargo clippy --all-targets --offline -- -D warnings`、`scripts/build-macos-app.sh --offline` 通过。 +- 原生启动通过;人工提交 Campus Console 输入成功,七页正常显示。实际点击提交/评分后,仪表盘共享成绩更新为 88.5%。 +- 完整 Campus PUT 返回 HTTP 200;故意错误的 PUT 返回 422;随后 GET 返回 HTTP 200,源码与原成功提交逐字节相同。 +- 在线指南完整 GET 返回 HTTP 200;新增架构文档、源码导航和项目入口的本地文件链接检查通过。 +- 原有 API/宿主测试随模块迁移并通过编译检查;未执行 cargo test、自动 smoke 或 Docker。 +- Aster 子模块仍为 `f3e1670`。用户已有的示例变更保留且不提交。 +- 未安装 UML 渲染器或调用在线渲染服务;PlantUML/Mermaid 图没有完成渲染器/版面验收,图中省略范围均有说明。 diff --git a/docs/architecture/uml/README.md b/docs/architecture/uml/README.md new file mode 100644 index 0000000..a7ea488 --- /dev/null +++ b/docs/architecture/uml/README.md @@ -0,0 +1,15 @@ +# 可编辑 UML 源文件 + +| 文件 | UML 类型与内容 | 对应说明 | +| --- | --- | --- | +| [components.puml](components.puml) | 组件图:主要调用与数据流;不冒充完整 import 图 | [01](../01-modules.md) | +| [deployment.puml](deployment.puml) | 部署图:线程、共享状态、原生窗口与 curl 子进程 | [01](../01-modules.md) | +| [domain.puml](domain.puml) | 类图:托管对象、弱边、Mount、不可变快照 | [02](../02-object-model.md) | +| [publication.puml](publication.puml) | 时序图:完整 PUT 的候选执行与代际发布 | [03](../03-runtime.md) | +| [input.puml](input.puml) | 时序图:原生输入回到活跃 VM 与刷新 | [04](../04-rendering.md) | +| [tasks.puml](tasks.puml) | 状态图:Task 完成、失败与取消 | [03](../03-runtime.md) | +| [windows.puml](windows.puml) | 状态图:Page 打开、关闭、重开与回收 | [05](../05-workspace.md) | + +这些 `.puml` 是可编辑的标准 UML 源码;Markdown 中另有 Mermaid 阅读版本。它们均为人工维护,当前没有自动同步生成步骤。概念图已标注其简化范围。 + +本轮没有安装 PlantUML/Graphviz 或调用在线渲染服务,因此未验证具体渲染器的版面输出。已有本地 PlantUML 环境时,可从项目根目录执行 `plantuml docs/architecture/uml/*.puml` 导出 SVG/PNG;不要为查看架构而上传整个仓库或私有对象数据。 diff --git a/docs/architecture/uml/components.puml b/docs/architecture/uml/components.puml new file mode 100644 index 0000000..3b7661c --- /dev/null +++ b/docs/architecture/uml/components.puml @@ -0,0 +1,37 @@ +@startuml +left to right direction +title Tessera — implemented components (principal runtime flows) +component "shell +windows / tabs / developer tools" as Shell +component "runtime/api +HTTP + queue" as API +component "runtime/api/worker +serial VM ownership" as Worker +component "bridge/ui_bridge +types / native calls / refresh" as Bridge +component "model +objects / mounts / bindings" as Model +component "render +layout / paint / hit / raster" as Render +component "Aster +frontend / compiler / core" as Aster +component "runtime/io_tasks +plain-data completion" as IO +database "NamespaceState +owned document + frame values" as State +Shell --> API : serialized input / Console +API --> Worker : bounded BuildRequest queue +Worker --> Aster : compile / execute +Worker --> Bridge : install / refresh / dispatch +Bridge <--> Model : managed graph + dependencies +Bridge --> Render : derive frame +Worker --> State : validate epoch + publish +State --> Shell : immutable snapshot +Shell --> Render : compose chrome + rasterize +Worker <--> IO : request / completion +note bottom of Model +Directory groups are not separate crates. +Imports still cross groups; this is not +an exhaustive or acyclic import graph. +end note +@enduml diff --git a/docs/architecture/uml/deployment.puml b/docs/architecture/uml/deployment.puml new file mode 100644 index 0000000..9c5d417 --- /dev/null +++ b/docs/architecture/uml/deployment.puml @@ -0,0 +1,32 @@ +@startuml +title Tessera — local process and thread deployment +node "User computer" { + artifact "Local agent client" as Agent + node "Tessera desktop process" { + node "GUI main thread +winit + softbuffer" as GUI + node "tessera-agent-api +HTTP receiver" as API + node "tessera-endor-worker +ReplSession / Candidate / Aster heap" as Worker + node "Input sender thread" as Input + node "Lazy bounded I/O pool" as IO + database "Arc> +no VM handles in published frames" as State + API --> Worker : bounded queue (8 pending) + Worker --> State : commit document + State --> GUI : native snapshots + GUI --> Input : Input values + Input --> API : loopback HTTP + Worker <--> IO : mailbox / cancellation + } + node "System curl subprocess" as Curl + artifact "OS window + pixel buffer" as Window + Agent --> API : 127.0.0.1:4737 + GUI --> Window + IO --> Curl : fetchText transport +} +note bottom of Worker +Aster libraries are linked into Tessera. +No Lyra/Hestia process is required. +@enduml diff --git a/docs/architecture/uml/domain.puml b/docs/architecture/uml/domain.puml new file mode 100644 index 0000000..dc1779c --- /dev/null +++ b/docs/architecture/uml/domain.puml @@ -0,0 +1,85 @@ +@startuml +title Tessera — managed identity versus published values +hide empty methods +class ReplSession { + id: u64 + faulted: bool +} +class Candidate { + vm: Vm + roots: ObjectRef[] + globals: ObjectRef +} +class UiBridge +class Presentation { + desktop: ObjectRef + active: ObjectRef + render_cache: RenderCache +} +class ManagedWindow { + id: u64 + pages: ObjectRef[] + closed: bool +} +class Block { + state: ObjectRef + page: bool +} +class Cell +class Data { + kind: Kind + children: Child[] + bindings: ObjectRef[] +} +class Child { + slot: u64 + block: ObjectRef +} +class Mount { + block: ObjectRef + page: WeakObjectRef + parent: WeakObjectRef + geometry: MountGeometry +} +class MountTable { + active: Map +} +class UiNode <> { + block_id: String + slots: u64[] + kind: UiKind +} +class Frame <> { + session: u64 + layout_version: u64 + commands: RenderCommand[] + tabs: Tab[] +} +class RenderDocument <> { + source: String + ui_root: Arc + windows: Map +} +ReplSession *-- Candidate +ReplSession *-- UiBridge +UiBridge *-- Presentation : shared Session +ManagedWindow --> Block : open Pages retained +Block --> Cell : state +Cell --> Data : ordinary content +Cell --> Block : shared assignment forwarding +Data *-- Child +Child --> Block +Mount --> Block : strong +Mount ..> Mount : weak reverse/sibling links +Presentation *-- MountTable +MountTable ..> Mount : weak index +Presentation ..> UiNode : cached derivation +RenderDocument o-- UiNode +RenderDocument *-- Frame +RenderDocument *-- RenderDocument : secondary windows +note bottom of Block +Page is a managed Block kind/nominal type. +This omits type tables, service dictionaries, +captured roots and many fields: not a full GC graph. +end note +@enduml diff --git a/docs/architecture/uml/input.puml b/docs/architecture/uml/input.puml new file mode 100644 index 0000000..749862d --- /dev/null +++ b/docs/architecture/uml/input.puml @@ -0,0 +1,25 @@ +@startuml +title Native input — one VM and one event dispatcher +actor User +participant TesseraApp as GUI +participant "events::Client" as Client +participant "API / worker" as Worker +participant ReplSession as VM +participant event_dispatch as Dispatch +participant "refresh / publication" as Refresh +User -> GUI : pointer press / release +GUI -> GUI : published geometry; matching target +GUI -> Client : Input(window, session, layout_version, mount) +Client -> Worker : POST /events +Worker -> Worker : reject stale source/destination identities +Worker -> VM : dispatch_incremental +VM -> Dispatch : target Block + optional Mount +Dispatch -> Dispatch : capture -> target -> bubble +Dispatch -> VM : budgeted handler / onEvent / onClick +VM -> Refresh : update related presentations +Refresh --> GUI : Frame / DocumentUpdated +note over Dispatch +Without a Mount, dispatch visits only the target. +With a Mount, traversal follows that occurrence. +end note +@enduml diff --git a/docs/architecture/uml/publication.puml b/docs/architecture/uml/publication.puml new file mode 100644 index 0000000..88d6b2f --- /dev/null +++ b/docs/architecture/uml/publication.puml @@ -0,0 +1,35 @@ +@startuml +title Complete PUT — isolated execution and epoch-checked publication +actor Client +participant "api.rs" as API +queue "BuildRequest queue" as Queue +participant "api/worker.rs" as Worker +participant "Aster candidate VM" as VM +participant UiBridge +participant NamespaceState as State +participant TesseraApp as GUI +Client -> API : PUT /v1/namespaces/app.main/code +API -> API : validate / assign accepted submission +API -> Queue : bounded enqueue +Queue -> Worker : receive request +Worker -> VM : parse / compile / execute in fresh candidate +VM -> UiBridge : allocate objects / Pages +loop incremental refresh slices + Worker -> UiBridge : bindings / layout / paint / mounts + UiBridge --> Worker : Pending or native document +end +Worker -> State : verify submission epoch +alt valid and successful + Worker -> State : commit document + window commands + Worker -> Worker : candidate becomes active ReplSession + Worker --> GUI : DocumentUpdated + Worker --> Client : response + committed revision +else superseded / failed + Worker -> Worker : discard candidate; keep valid live owner + Worker --> Client : 409 / 422 / 500 as applicable +end +note over API,Worker +Worker retains the observed request and responds. +GUI does not execute the HTTP request or VM. +end note +@enduml diff --git a/docs/architecture/uml/tasks.puml b/docs/architecture/uml/tasks.puml new file mode 100644 index 0000000..5e2e2a9 --- /dev/null +++ b/docs/architecture/uml/tasks.puml @@ -0,0 +1,21 @@ +@startuml +title Managed Task — lifecycle states from tasks.rs +[*] --> Pending +Pending --> Running : due / delivery eligible +Pending --> Completed : provider done; no callback +Pending --> Failed : provider error +Pending --> Cancelled : owner invalid / key replacement / cancel +Running --> Completed : successful delivery +Running --> Failed : transport or execution error +Running --> Cancelled : cancel / lifecycle cleanup +Completed --> [*] +Failed --> [*] +Cancelled --> [*] +note right of Pending +Provider I/O may already run while the Task +is Pending. Running does not necessarily +mean the I/O starts at that instant. +Scheduler index is weak; callbacks/scopes +are traced managed edges. +end note +@enduml diff --git a/docs/architecture/uml/windows.puml b/docs/architecture/uml/windows.puml new file mode 100644 index 0000000..5f215ee --- /dev/null +++ b/docs/architecture/uml/windows.puml @@ -0,0 +1,16 @@ +@startuml +title Page visibility and reachability (conceptual states) +[*] --> Open : Page registered +Open --> Selected : activate / click tab +Selected --> Open : select another Page +Open --> Closed : close / release registry edge +Selected --> Closed : close active Page +Closed --> Open : still referenced; reopen +Closed --> Collected : no strong managed path; GC +Collected --> [*] +note right of Closed +Closing a tab is not destroying an object. +Cross-window moves update managed registries +only after session/layout/drop validation. +end note +@enduml diff --git a/src/README.md b/src/README.md index bcf6e51..f81fc9a 100644 --- a/src/README.md +++ b/src/README.md @@ -1,266 +1,86 @@ -# Tessera source +# Tessera 源码导航 -| Module | Responsibility | +整体设计和当前进展从 [架构导览](../docs/architecture/README.md) 开始;本轮每个文件的迁移对应见 [逐文件清单](../docs/architecture/file-map.md)。 + +`main.rs` 只负责组合启动,并保留 crate 私有模块路径别名。五组目录仍在同一个 binary crate 中,不是严格单向分层。 + +| 目录 | 责任 | | --- | --- | -| main.rs | Native window event loop, namespace state and API startup | -| api.rs | Code CRUD, live Console/reset and compatibility evaluation endpoints, worker-owned session, request observation, coalesced native viewport/background-task updates, epoch checks, stale source/destination tab-drop rejection and diagnostics | -| activity.rs | Bounded in-memory HTTP exchanges with marked previews and allowlisted request headers | -| endor_host.rs | Aster compilation, fresh PUT candidates retained after successful publication, shared Console/program VM, asynchronous completion/fault boundaries, bounded execution, compiled source/call-graph effect query and rebinding coverage, root cleanup and AST developer tree for function/class/member assignment nodes | -| endor_script.rs | Batch complete inputs for Aster's Interactive parser/compiler, retain Module entry, and rebase Aster spans to the original snapshot | -| ui_bridge.rs | Inheritable native nominal constructors with source fields, shared type method tables and bound receiver dispatch, configured unified Function classification and source/field/override checks with deferred typed public/native constructor signatures and split option/field preflight plus shared native arity intervals plus resolved types/unions/effects on methods, tab registry, stable-slot insertion, source-dispatched tab reorder/drop and callback registration checks, Page lifecycle/navigation, Event returns/effect bounds, intrinsic handler recognition and immutable frame extraction | -| binding.rs | Target-owned managed property bindings, weak Subscription tokens, zero-argument callback preflight, read-only compute and borrowed dependency/output caches | -| block.rs | Aster-managed Block/Cell/data graph, stable child slots, content-owned field storage, shared assignment, cycle validation and explicit text bindings | -| snapshot.rs | Property and visual-method identity dependencies across shared field-storage paths, typed method attribute validity and protocol function payload-version dependencies, reverse parent links and cached immutable visual derivation | -| mount.rs | Typed per-occurrence methods and nominal Bounds results, managed contexts, weak reverse links, ordered mount/unmount hooks, geometry and focus queries | -| events.rs | Bounded native input transport with kind-specific tab-drop/desktop-position metadata to the owning VM | -| event_dispatch.rs | Typed Event factory and managed Event/Subscription methods, weak Subscription tokens, Block-owned connections and occurrence-specific capture/target/bubble through a budgeted VM driver | -| protocols.rs | Register typed Visual/EventTarget/Children (including insert)/Presentable managed trait requirements, using canonical signatures and explicit native type declarations | -| presentation.rs | Per-window layout, Mount/focus/tab state and read dependencies; VM changes fan out before any view consumes work | -| io_tasks.rs | Lazy bounded file-provider pool, plain-data mailbox and cancellation leases without managed roots | -| tasks.rs | Typed Task/ui.after/ui.readText/ui.fetchText/result/error methods, managed owner scopes and callbacks, weak timer index, request-key replacement, lifecycle cancellation and budgeted worker delivery | -| tabs.rs | Immutable tab chrome, shared draw/hit geometry, overflow navigation, drag gaps/anchors/insertion markers and opaque published Page identities | -| window.rs | Typed native Window queries/effects, construction/close, context ownership, ordered Page roots, read-only guards, Page reordering, physical desktop placement and coalesced GUI projections | -| vm_sequence.rs | Shared validated call-sequence builder and EXTENDED_ARG-aware same-frame repetition for layout, event and lifecycle work | -| source_layout.rs | Read-only source layout, repeated constraint/geometry solving with cycle detection, per-Mount measurement caches with subcall dependencies and actual scroll-viewport cross-axis constraints, viewport constraints, typed/slot validation, concrete intrinsic recognition and occurrence geometry | -| source_hit.rs | Read-only source/native pointer target selectors, reverse paint order, strict Bool results and matching press/release targets | -| source_paint.rs | Read-only source paint dispatch with concrete intrinsic recognition, shared VM budget, paired text-wrap bounds and checked command conversion and versioned object dependencies | -| native_visual.rs | Managed native visual protocol queries with resolved type/read-only signatures, occurrence validation, concrete implementation/operation identity, arranged-viewport child constraints, value conversion and bounded results | -| object_list.rs | Typed factory envelopes, read-only indexed access and traced results shared by Mount queries and visual protocol results | -| layout.rs | Shared native geometry/paint functions, iterative measurement/arrangement, stable occurrence IDs, measurement cache, solve-pass counters, geometry budgets and width-sensitive native text measurement | -| document.rs | Published source/AST/UI snapshot, revisions, invalidation, frame validation, single-line fault banner (full cause retained in Frame), validated optional text-wrap bounds and display commands | -| app.rs | Window events, logical viewport publication, redraw, native multi-window tab dragging and system widget pointer/keyboard/IME routing | -| clipboard.rs | UTF-8 macOS clipboard adapter independent of Finder locale, with strict decoding and surfaced errors | -| system_ui/ | Host-only management widget library: stable IDs, interaction, theme and reusable control drawing | -| scroll.rs | Per-Mount offsets, nested scrollIntoView, shared geometry/clip projection and wheel ancestor selection | -| scene.rs | Page, tab-drop marker and developer-tools display-list composition | -| developer_tools.rs | Unified Console prompt/output stream, caret/IME mapping, full Sources, Network inspector, theme, scrolling, overlay splitters and focus | -| sources.rs | Disconnected cloud workspace and provider boundary for listing/opening complete source files | -| display_list.rs | Rendering primitives, bounded wrapped text and color representation | -| geometry.rs | Native points and rectangles | -| software_renderer.rs | Software rasterization, per-text bounds and nested rectangular clipping for independently scrolling panes | -| text.rs | Shared system font discovery, shaping, real measurements, antialiased glyphs and bounded caches keyed by wrap width | -| README.md | Module map | - -Aster is an independent source submodule pinned to the published managed type/native-call implementation. Language parsing, AST definitions, -compilation, bytecode and VM execution come from its libraries. Tessera implements -only host UI behavior and layout. No CLI, HTTP server or Docker process is needed -to execute Endor in the application. - -A complete-PUT candidate owns the desktop and UI service roots, with dynamic -Block allocation through scoped NativeContext. Block -> Cell -> data and all -child/history/forwarding references are managed VM edges. Native functions declare -their captured owner edges. The bridge holds only weak diagnostic references to -allocated Blocks; the tab registry owns open Pages. Closing a tab releases that -edge, without destroying a still-referenced Page. Console retains the actual VM; -a successful complete PUT also retains its candidate as the active program, accessible from Console and native input. Failed PUT preserves the previous heap. Cleanup releases -globals/service roots, collects cycles and requires an empty VM. Window frames -contain only immutable native data. - -Tests in api/tests.rs execute real HTTP CRUD, persistent Console/reset/PUT isolation and a controlled queued-PUT/DELETE race -without opening a window. Tests in endor_host/tests.rs cover lifecycle, direct script and -module entry, page methods, stable-slot insertion and tab drop/override/stale-target behavior, multiline inputs, original source ranges, malformed input, -owner/task cancellation, hidden-object delivery, stale request and callback failure, entry checks, source function/declared-field annotation checks, constructor side-effect prevention, native callback signature/pre-registration validation, shared field forwarding/function identity and REPL class redefinition, budgets, failure preservation, live object identity, explicit rendering, -compile-before-execute behavior and stale-result invalidation. They -run only in remote CI. Font tests in text.rs and software_renderer.rs cover real -advances, multiline height, fractional scaling, CJK fallback, grapheme-safe fitting, -antialiasing and transparency. A process-wide mutex keeps font discovery, shaping -and glyph caches shared between the worker's layout and window redraw. The -256-string/512-KiB source cache and 4096-glyph/32-MiB image cache are discarded -when their budgets are exceeded. See the [project guide](../README.md) for public behavior. - -The expression endpoint deliberately uses Aster's Expression entry in a fresh VM; -it has no host UI, no history replay and no published document. API observation -notifies the window for accepted and completed requests, including failures. The -worker still owns source compilation and snapshot publication. Console sends real -loopback HTTP requests on a separate client thread and reports transport failures -through the native event loop. CloudSourceProvider is an optional host-side adapter, -not a cloud service. A future network adapter should deliver cached/asynchronous -results to the workspace rather than block the native event loop during I/O. - -The [System UI library](system_ui/README.md) owns management control appearance and -interaction. It imports no Endor runtime or guest UI bridge; developer tools supply -typed actions and keep all execution/source behavior outside the widget layer. - -Console sessions use `endor_host::ReplSession` on the same worker thread. Real VM -globals, live Blocks and `_` survive between inputs. Parse/compile precede execution; -only a successful input commits a pending mount or publishes changes to an existing mount. -Runtime-failed input can retain earlier completed bindings, but cannot publish its -pending render. Session budgets cover cumulative source/AST and live Block counts. -page.unmount releases current content; unreachable object cycles are reclaimed between inputs. -Explicit reset drops the VM. Successful PUT replaces it with the fresh program; failed PUT restores the displayed program’s event epoch. Code DELETE invalidates the context. Source snapshots carry annotated AST ranges for the -actual Console transcript; source history is never rerun to reconstruct memory. -The Aster pin includes independent Assignment syntax and generic opt-in assign_from support. -Live reference tests cover alias updates, nested mounts, cycle rejection, detached branch release and unmount/remount. - -Stopped runtimes have a separate host-only secondary-window teardown path. It detaches -Mounts and releases window/Page roots without executing source lifecycle callbacks, -and does not resume the failed VM. Preserved old-generation pictures can be closed -without borrowing a new Console VM's same-number resource. Secondary viewport inputs -are filtered by their published runtime generation. Native window/surface failures -are returned to the host diagnostics rather than panicking the event loop; main -window initialization errors propagate to process exit. The stopped-window and -cross-generation regression sources are compiled, not locally executed. - -Native visual scalar parameters live in the Block's managed property storage. -Snapshot extraction validates and reads those fields, and records exact property -invalidation keys. Payload kind values remain constructor defaults; drawing uses -the property projection. Binding refresh traverses the weak visual registry and -deduplicates forwarded content, including hidden objects. It records object-level -read versions and borrowed output values for equality without adding GC roots; -cache updates do not mutate the connection's business version. Target content -traces the compute function. Subscription tokens are weak and also support bindings. -All binding work settles before published frames; compute failure stops the VM. - -The host registers Trait definitions through Aster's checked nominal capability -API. Source subclasses validate inherited method arity before class publication. -Presentable dispatch uses a generated validated bytecode continuation inside the -current call budget, passes the current managed Window, and validates a real -Block result before opening it. Native base visual methods and runtime result -checks remain the rendering boundary. Matching present names without an explicit -implementation keep the summary fallback and do not execute guest methods. - -ScrollBlock keeps viewport dimensions in the managed Block properties and keeps -scroll offsets on Mounts. Layout measures content without a finite viewport bound; -a post-layout projection clamps offsets, translates descendants and intersects -ancestor clips before committing Mount geometry. It rebuilds native commands and -per-command clips; source painting preserves the same clip table. Input, focus -outlines and public hit queries consume this projection. Wheel transport carries -finite signed deltas, validates the frame/window/session, and invokes the actual -Mount.scrollBy method without changing guest focus. Mount requests mark a -per-window projection refresh; snapshots/caches never own business objects. - -Tab drops validate source and destination window/session/layout plus the actual VM Page order before callbacks. The bridge invokes managed Page methods, respecting source overrides that decline or redirect a window move. Reordering within a window retains Mounts; moving windows retains Page identity and replaces Mounts. Native drag feedback is host state, with no additional managed roots. Desktop placement is an asynchronous physical outer-origin request, protected by the existing session/commit boundary. Regression sources cover the HTTP event allowlist, stale frame reuse, stable insertion, source overrides and signed positions; these were compiled, not locally executed. - -NativeCallable implementations publish positional metadata, including internal layout/paint/hit/event continuations. The VM validates it before entering Rust. Event delivery uses the same source/native/bound arity query; binding and Task registration reject incompatible or unknown metadata before changing ownership. Native parameter/return types and effects remain checked by existing host boundaries rather than this count-only metadata. - -Native TextBlock measurement and painting now share width-sensitive shaping. Column/Button cross-axis constraints feed both the native fast path and source base calls; direct base measurement also uses its supplied viewport. RenderCommand text has optional wrap_bounds, converted to a clipped WrappedText primitive. Source PaintCommand uses paired wrapWidth/wrapHeight and legacy text remains unwrapped when both are absent. Source-arranged dimensions now participate in repeated constraint solving; general flex allocation, resumable work and axis-specific ScrollBlock wrapping remain future work. - -Layout convergence uses exact finite snapshots of constraints/sizes/bounds and source-selected limits. The same immutable driver loops within one shared VM budget, clears all provisional measurement geometry between passes and rejects nonconsecutive repeated states. Aster ReadAccess distinguishes annotation validation from normal reads; the bridge skips only validation-only Mount reads after independently validating the live Mount/owner context. Actual Mount reads remain volatile; all other recorded dependencies are retained. - -ScrollBlock.axis is stored in its managed properties and projected into the visual snapshot. Axis-aware child constraints use constructor/clamped size for initial measurement and actual occurrence bounds after arrangement. Scroll projection clamps disabled offsets/limits; the same geometry feeds paint and input. - -Container mainAlign projects from managed properties into UiNode and its shallow equality. Native arrangement receives the actual allocated Size, computes positive remaining main-axis space and keeps child sizes unchanged. - -Block.flex is a nonnegative managed weight, default 0. In native containers, fixed children retain measured main-axis sizes; positive weights share the remaining allocated space after padding/gaps with zero basis. This does not request extra parent size. Actual allocated child bounds feed back into measurement before publication. Source arrangement overrides remain authoritative. The additional flexBasis, flexMin, flexMax and flexShrink attributes control bounded allocation and opt-in shrinking along the parent main axis; see the policy below. - -Trees with positive flex weights use the settling driver even when all layout methods are native. This remeasures wrapping and ancestor geometry before publishing. Fast-path eligibility checks actual intrinsic implementations, rather than comparing against a mutable Block method table. - - -Flex main-axis policy: flexBasis is "auto" by default (zero basis when flex > 0, otherwise the first measured size of this occurrence in the current solve); a number supplies an explicit basis. flexMin defaults to 0, flexMax to 1000000, and flexShrink to 0. All numbers are finite/nonnegative and at most 1000000; flexMin must not exceed flexMax. The parent clamps bases to min/max, distributes positive space by flex, and redistributes space left after a maximum is reached. Deficits shrink by flexShrink times the original basis, freezing at minima. A fixed child can opt into shrink without growth. Unresolvable minimum overflow is preserved, and remaining positive space follows mainAlign. The policy applies to width in a Row and height in Column-like containers; these are not independent width/height bounds. Source arrange overrides remain authoritative. Preferred sizes are derived per occurrence and reset for each new solve, never shared through Block state. See examples/flex-constraints.endor. Layout continuation remains unfinished. - - -When an occurrence's cross-axis constraint changes during settling, its preferred main-axis size is invalidated and remeasured. Any obsolete native flex allocation on that axis is removed for the replacement measurement, so narrowing a nested Column can increase wrapped text height. Source arrange allocations remain explicit. Preferred-size presence and values participate in convergence/cycle detection. - - -The source layout driver now uses Aster's resumable read-only call API with a soft quantum of 256 instructions. Pauses retain the same VM frames, operand/local roots, return contracts and read tracking; total instruction/frame limits still apply across slices. ui.stats().layoutSlices counts slices in the most recent solve (native fast path: 1). The worker currently resumes Pending immediately after yielding its OS time slice. Parking the solve across worker turns, scheduling other work and handling newer inputs before completion are not implemented yet. Native callbacks are not preempted. Only the completed settled frame is published. - - -source_layout::start now returns either Complete(Draft) or Pending(Work). The host-owned Work retains the unpublished frame, derived runtime/cache snapshots, convergence plan and prepare root without borrowing caller frame/cache variables. Work::resume consumes the saved job and advances the same VM continuation; Work::cancel closes that continuation and releases the prepare root without merging a draft. A second start is rejected while any read-only execution is active. Every pending Work must be resumed or explicitly cancelled with its owning VM; dropping it is not cancellation. Synchronous ReplSession operations drain the same saved refresh state. Mount preparation/lifecycle cancellation and parking jobs in the execution worker remain unfinished. - - -UiBridge::begin_layout_frame/resume_layout_frame now expose PendingLayout around the source job. This bridge-level continuation also owns its target window, page snapshot, tab projection, viewport restoration and retained lifecycle hook root. Only the Complete branch projects scroll/focus, captures dependency versions, merges derived caches, commits Mount geometry and runs deferred hooks. Resume restores the saved window context, and failure releases hook roots. ReplSession uses this protocol through its saved refresh state. Viewport refresh supports cross-worker-turn scheduling and prepared-Mount rollback; callers must not drop a pending bridge layout as a substitute for cancellation. - - -ReplSession now owns a resumable RefreshWork containing window traversal, pending bridge layout, unpublished per-window documents and the cumulative refresh work budget. poll_refresh advances one refresh stage or VM quantum; only a complete stable pass replaces the session's full document map. Synchronous callers drain the same state machine. Failure stops the domain, releases any pending bridge work and preserves the externally published picture. refreshYields counts cooperative refresh returns, not wall-clock time. Only still-pending Mount preparation is rolled back; completed callbacks remain committed. - - -Viewport refresh now advances through ReplSession's saved refresh state on worker idle turns. A turn advances one traversal stage or a 256-instruction layout quantum; ready layout/paint or coroutine work checks the request queue without a timed wait and yields to other ready OS threads between turns. An accepted pending input/candidate also yields without adding a fixed millisecond delay per VM quantum; control, epoch and viewport checks still run on every turn. When only external completion/timers are pending, the normal idle wait remains 100 ms. Tasks/window commands and document publication wait for complete refresh. ui.stats().refreshYields reports cooperative idle returns. Mutating requests wait across worker turns for the existing job, so they cannot run inside its read-only scope. Mutating requests are serialized; newer viewport targets coalesce by cancelling unpublished layout. Initial source execution remains atomic; its layout can park across worker turns. Explicit REPL/event refresh can park across worker turns. Idle tasks deliver at most one callback per turn and park subsequent layout; callbacks and individual native nodes remain atomic. Large intrinsic trees use the sliced driver; small intrinsic trees keep the fast path. - - -Runtime mutation entry points reject direct calls while refresh work is pending, before executing source or changing metadata; task polling defers. The API worker drains the saved refresh before accepting those mutations, keeping the runtime usable. Incremental refresh keeps the prior complete document map until a stable pass, including across window traversal boundaries where no VM read-only frame is currently active. - - -Mount preparation now has an explicit checkpoint that roots the previous dictionary, Mount payloads and metadata values. It can restore geometry/relations/registry after cancelling an unpublished layout, or release its roots after completion. Task cancellation moves from speculative preparation to immediately before the actual onUnmount callback, so cancelling preparation does not destroy the old occurrence's tasks. - - -Pending bridge layouts now retain a Mount checkpoint. cancel_layout_frame closes the VM continuation, restores the old Mount payloads/links/dictionary and marks the presentation dirty for a new solve; discarded lifecycle hooks do not run. Successful finalization releases checkpoint roots after callbacks. This restores speculative Mount preparation, not arbitrary application writes or already completed lifecycle callbacks. - - -New viewport targets now coalesce pending refresh work. The runtime cancels only an unfinished bridge layout, restores its Mount checkpoint, preserves completed per-window drafts/callback effects and restarts traversal with the latest viewport map. refreshRestarts counts target replacements; layoutCancels counts replacements that actually closed a pending VM layout. Unchanged targets resume the same job. A new external target resets its convergence budget; internal settling does not. Ordinary source/event mutations still serialize behind refresh rather than roll back application effects. - - -Cancellation checkpoints own explicit VM roots until restored or released. Initial presentations may have no mount dictionary; cancellation restores that absence too. Saved metadata values retain object-valued attributes. A pending refresh can restart while keeping already completed window drafts; no onMount/onUnmount callback is replayed merely because the viewport target changed. Mount-owned tasks are cancelled immediately before an actual onUnmount callback, never during abandoned preparation. - - -source_layout uses the same resumable node driver for trees over 64 occurrences even when measure/arrange are intrinsic. Small intrinsic trees retain LayoutRuntime::frame. The larger path keeps LayoutRuntime measurement caches and yields between node calls; structural extraction, bytecode preparation, a single node, settling and paint remain atomic. - - -Worker idle polls use ReplSession::poll_tasks_incremental with the current viewport map. Scheduler::poll accepts a checked 1..=64 work limit, counting both delivery and ineligible-task cancellation; idle uses one, synchronous compatibility uses 64. Once delivery begins layout, further tasks and window-command projection wait until the saved refresh completes. The viewport stamp allows cancellation of task-induced pending layout without repeating callback effects. - - -REPL and event mutations now have incremental refresh entry points. They execute source/callbacks once, retain scalar response metadata, then park layout with the current viewport target. While pending, mutation, command extraction and garbage collection wait; layout can be retargeted by a newer native size. Synchronous helpers remain for host regression coverage. Discarding a ReplSession explicitly cancels any still-pending bridge layout before the Candidate releases service/global roots. This does not preempt source/callback execution, structural preparation or paint. - - -The API worker now owns a pending HTTP request plus its scalar result while REPL/event layout is parked. It advances the layout with current native viewport targets and responds only after the stable full-window publication and command extraction. Later mutation requests remain queued; GET /code can observe the last complete publication. A newer accepted PUT invalidates queued inputs but lets the already-running owner settle before candidate construction, so failed replacement can preserve its consistent heap. DELETE revokes that owner and prevents late publication. This queue ordering does not roll back already-executed source effects. - - -Complete-source candidates now separate one-time compilation/source execution from initial layout. load_incremental owns the isolated VM while its first layout is pending; the current live VM stays separate. Cancellation uses the same Mount checkpoint/continuation cleanup as later refresh. Synchronous load helpers remain for host regression coverage. This does not slice parsing, compilation, mutating source execution, structural preparation or final command projection. - - -The API keeps the old runtime alongside a pending PUT candidate. Newer submissions cancel only the obsolete candidate; failures restore the published runtime's submission owner. Background epoch checks distinguish accepted replacements from DELETE revocation, so queued/superseded candidates do not destroy the previous live heap. Candidate resumption reads only the primary shell viewport, avoiding reuse of an old secondary window's dimensions for a new VM's resource with the same numeric ID. Success publishes the complete candidate document and commands together, then replaces the old runtime. - - -Source paint now uses a resumable read-only VM call with the same 256-instruction quantum and a cumulative 1,000,000-instruction budget. The pending job owns the unpublished frame, visual snapshot, decoded command buffer and prepare root. The refresh worker resumes paint before advancing to another window and publishes only the full stable window set. New viewport targets cancel unfinished paint, discard partial commands and force that window to refresh. Already completed layout/Mount geometry and lifecycle callbacks remain committed; cancelling paint does not replay or undo them. Failure/discard explicitly closes the read-only continuation and releases its roots. paintSlices reports the most recently completed paint solve (1 when the solve completes within its first quantum); paintCancels counts viewport replacements that closed pending paint. Layout checkpoint rollback remains limited to unfinished layout preparation. Native callback bodies, driver preparation, command decoding/projection and rasterization remain atomic. - - -Layout and paint drivers now prepare one occurrence at a time. Their fixed-size outer bytecode repeats on the same VM frame; a short-lived helper retains only the current call, its bounds/arguments and collector. The helper is released before the next occurrence. Layout keeps bottom-up measure, top-down arrange and the same settling loop. Paint advances even intrinsic method classification through VM quanta. The weak occurrence list and native scratch/output data remain proportional to the actual tree, but preparation no longer allocates managed callables/Bounds for the whole tree before the first slice. All helpers share the original read-only scope and cumulative instruction/frame limits. Weak-list copying, structural extraction, settling and final projection remain bounded atomic native work. - - -Presentation dependency diagnostics record a counter and the last invalidated object kind/identity and version transition. ui.stats exposes dependencyInvalidations and lastInvalidDependency without retaining the dependency or rendering its data. windowDependencyDiagnostics includes nonempty reasons from every window, prefixed by window identity. Snapshot replacement also records its last invalidating object/property or change-journal overflow, so cache invalidation can be distinguished from source layout/paint dependencies. These help distinguish expected input invalidation from repeated background refresh. - - -ui.readText(owner,key,path[,callback]) returns an owner-scoped Task and reads a regular UTF-8 file off the VM thread. Paths use native filesystem syntax; absolute paths are recommended. The optional callback takes zero arguments and must return Unit; it runs only on successful read and can use task.result(). Without a callback, poll status then read result from code. result() is available while a success callback is running or after completion (timers return Unit); error() returns an empty String or the provider/callback error. A provider failure marks the task failed and skips its callback without stopping the VM; a source callback failure still stops the shared domain. Owner/key replacement, explicit cancellation, close/reset/VM discard and GC drop the provider lease, remove queued completion and reject late delivery. In-progress OS reads may finish, but cannot deliver after cancellation. Only the owning VM worker updates managed results and rechecks owner/key identity. No VM handles enter provider threads beyond opaque weak identities. - -The lazy shared pool has four threads and a 64-job queue. Each runtime permits 16 outstanding I/O requests (one temporary replacement reservation), and each read is capped at 1 MiB by metadata plus actual streaming length; non-regular files, non-UTF-8 data and over-limit content fail rather than truncate. These limits do not constitute a full filesystem sandbox. The provider returns text in the same managed Task, not a second application-state store. Task await is supported through ui.spawn; see the await contract below. See examples/async-file.endor; from REPL call reader.read("/absolute/path.txt") to update its actual TextBlock, even when that object is hidden but retained. - - -ui.fetchText(owner,key,url[,callback]) performs an asynchronous HTTP(S) GET using the same Task, owner/key replacement and success-only callback protocol as readText. Example: fetcher.fetch("http://127.0.0.1:4737/agent") after loading examples/async-network.endor. The example retains the complete result in fetcher.response and displays completion status; it does not put arbitrary network content into a TextBlock, whose own 16 KiB limit is independent of the provider limit. Only 2xx responses produce a UTF-8 result; HTTP failures, invalid UTF-8, oversize bodies and transport errors set status() to failed with error(). It verifies HTTPS certificates, follows at most five HTTP(S) redirects, has a 10-second connect and 30-second total transfer timeout (starting when a worker takes the request), and bounds decoded text to 1 MiB without truncation. URLs are limited to 8192 bytes and must not contain credentials or control characters. This GET API does not accept request bodies, custom headers or authentication. - -The current transport uses a separate system curl process per request (/usr/bin/curl on Unix, curl.exe on Windows); missing or incompatible curl fails the Task. User curlrc and URL globbing are disabled; URL data is passed through escaped stdin configuration rather than command arguments. TLS and normal system proxy behavior remain enabled. Cancellation kills an active HTTP process immediately and the provider worker reaps it; the VM thread never waits for the process. All file and HTTP requests share the per-runtime 16-task limit and global four-worker/64-job queue. In-progress file reads remain cooperatively cancelled at delivery. Network access is not a security sandbox, and this is a text GET provider rather than a general HTTP client. Task await is supported through ui.spawn; see below. - - -ui.spawn(owner,key,function) creates an owner-scoped cooperative Task for a zero-argument function; its returned value is retained as the Task result. Each owning VM poll resumes at most a 256-instruction quantum per selected Task, rotates runnable tasks fairly, and retains source frames in an Aster-managed coroutine edge. Calls and writes may interleave between quanta; prior writes are not rolled back on cancellation or error. Pending/running Task replacement, owner closure, GC and reset release parked frames through the same managed graph. Native calls remain atomic and all quanta share the 128-frame/100000-instruction callback limit; a native entry may run during preparation. result() is unavailable until spawn completes. A source error still stops the shared runtime. See examples/cooperative-tasks.endor. Endor await expressions now suspend these calls on a Task without replaying the operand. See the await contract below. - - -Inside a function run by ui.spawn, `value = await task` waits for any timer/file/network/spawn Task and resumes at the same expression with its retained result. A source function may return a Number, String, managed Object or Unit; normal source annotations still apply. Task.result() and await share the result. `ui.sleep(owner,key,milliseconds)` creates a callback-free timer Task returning Unit. Plain calls/REPL can await an already completed Task; a pending await requires a coroutine and fails explicitly rather than blocking. There is no new async function declaration: ui.spawn explicitly schedules an ordinary Endor function. See examples/await-file.endor. - -A waiting coroutine retains its evaluated operand and frames as managed edges; waiting performs no VM execution or active polling. Source side effects and IO initiation before await are not replayed. When the dependency completes, the owning VM worker resumes the waiter through the existing fair quantum scheduler. Cancelling/replacing/closing the waiter releases its frames and operand; it does not automatically cancel an independently owned or shared dependency. Dependency cancellation propagates cancellation to its waiters without running the remainder. An unhandled dependency failure becomes a source await error (with the original provider message), fails the waiter and follows the existing shared-runtime fault rule; provider failure alone still only fails its own Task. Self-await and cyclic Task waits fail explicitly. Read-only layout/paint cannot await. Waiting state is reported as running, while active() remains true; result() is unavailable until completion. These APIs do not introduce try/finally or change the existing exception model. - - -While a native tab drag is held over an overflow arrow area, the strip waits 350ms then reveals one hidden tab every 120ms, including in a compatible destination window. The preview scroll does not activate Pages or change business state. Drawing, hit testing and insertion gaps share the same transient visible range; release uses its full-order insertion index. Leaving the strip stops the timer; release, Escape, a new press or runtime generation change restores the normal active-tab-centered strip. A resize or changing tab count clamps the range. Native drag/mixed-DPI behavior still requires platform validation; application-exit cleanup is described below. - -CI regression fixtures use the published frame's session identity for input, -current Endor statement boundaries/type names and the retained runtime's actual -layout/paint pipeline. Failed first Console rendering discards unpublished Page -registrations through host-only cleanup, preventing the next successful query -from silently opening a previously failed pending render. Earlier ordinary -bindings remain live; explicit page.render can subsequently publish the Page. - - -Application exit now uses a private VM-worker control channel, independent of HTTP queue capacity. Main native close and a managed primary Window.close request asynchronous cleanup; duplicate requests coalesce and mutation requests are rejected with 503 while exiting. An unpublished candidate/input refresh is cancelled without replay. The current VM cancels all Task entries, closes managed windows, detaches occurrences child-before-parent per window, and invokes normal onUnmount hooks once. It does not run layout/paint during shutdown. All windows are closed before hooks run; new windows and tasks are forbidden. A stopped/faulted VM detaches without source hooks; reset/discarded pictures cannot revive an old VM. A hook error aborts remaining source hooks, releases prepared hook roots, drops the VM and reports failure before native exit. Completed shutdown is acknowledged to the GUI, which then ends the event loop. An OS exit bypassing CloseRequested uses a five-second bounded final fallback; non-cooperative native calls remain atomic and force-kill/crash cannot guarantee notifications. Native stderr records the window/mount/task-entry counts and whether source hooks were enabled, or the cleanup error. See examples/exit-lifecycle.endor. Cross-platform exit/drag validation remains broader follow-up work. - - -Foreground REPL/events now retain a BeforeInput request while an existing idle layout/paint refresh advances one stage/quantum per worker turn. They execute their source only after that refresh settles, so mutations never enter its read-only scope. New task callbacks are not delivered ahead of this accepted input. Shutdown can cancel the waiting request with 503, and superseded submissions still return 409. Reset cancels/discards the old VM instead of synchronously draining its unpublished refresh. Source parsing/compilation and ordinary synchronous source/native calls remain atomic boundaries; this change removes the worker's synchronous pre-input refresh drain. - - -Source layout/paint now validate name-level read observations from Aster: each globals/builtins lookup records its text key and observed presence/value, while dictionary kind checks remain separate. Updating the REPL result `_` or another unread global no longer invalidates these frames solely because the whole globals dictionary version changed. Reading `_` still observes its value; adding a previously missing global correctly invalidates a builtin fallback. Ordinary native dictionary iteration/content reads retain whole-object version dependencies. Measurement cache hits replay their recorded observations into the outer solve; they do not drop dependencies merely because source execution was skipped. Cache observations contain borrowed handles (including object-valued names), add no GC roots, and never write comparison snapshots back into language objects. Invalid-name diagnostics report only identity/key, not captured values. General Block binding and other legacy readers keep their existing conservative object dependencies. See examples/global-dependencies.endor. - -Source layout/paint and measurement caches exclude every presentation's private desktop identity, including the primary desktop captured by shared native visual methods. Per-window bookkeeping must not invalidate another window's source output. This exclusion does not cover managed Window objects, viewport input records, type definitions or business objects; their observed changes remain dependencies. - -The gc-lifecycle example and endor_host regression combine a mounted Page/Block cycle, source measure/paint dependencies and an owner-scoped coroutine suspended on sleep. Closing the tab preserves a referenced Block's workflow; releasing the external Page/Task holders lets normal collection reclaim the cycle before the wait completes. Regression assertions check both the same-program object baseline and weak identities. - -The API worker logs the submission identity once when a newly accepted input enters BeforeInput waiting. It omits source and runtime values, and resume quanta do not repeat it. This distinguishes cancellation of an accepted pre-input wait from cancellation of an unpublished candidate or a request still in the transport queue. - -Property binding caches now consume synchronous precise ReadSet dependencies, replay validation through ObjectStore and exclude the same private presentation desktops as source layout/paint. Cached output equality still repairs a direct overwrite even when its compute dependencies are unchanged. ui.stats().bindingEvaluations counts computations in completed binding refreshes for this VM, including initial/hidden bindings, so unrelated REPL inputs can be checked without adding effects to read-only source compute functions. - - -Window workspace modules: -- `tabs.rs`: common tab/close/+ geometry, overflow and shared hover/active capsules. -- `tabs/motion.rs`: 200 ms eased geometry shared by painting and pointer hits. -- `ui_bridge/start_page.endor`: the functional default Page, layout, callbacks and examples in Endor. -- `ui_bridge/developer_pages.rs`: loads the private start-page module and delegates new-tab creation to its managed factory. -- `ui_bridge/developer_pages.rs`: managed tool/start Pages, identity-preserving reopen and focus. -- `developer_tools/{console,sources,network,settings}.rs`: separate page views. -- `developer_tools/painter.rs`: reusable pane painting and response formatting. -- `developer_tools/selection.rs`: independent read-only source selection. -- `endor_host/tests.rs` and `api/tests.rs`: existing regression sources, moved with corrected include paths. +| `shell/` | 原生工作区、窗口输入、标签、开发工具和独立宿主管理控件 | +| `runtime/` | 本机 API、串行 VM 执行、事件和异步任务 | +| `model/` | 托管对象、绑定、Mount、窗口语义、依赖与发布值 | +| `render/` | 几何、布局、source/native 视觉协议、绘制、命中、字体和软件渲染 | +| `bridge/` | Endor 类型/方法与宿主能力及展示刷新之间的连接 | + +## shell + +| 模块 | 职责 | +| --- | --- | +| [activity](shell/activity.rs) | 有界 HTTP 活动记录与允许的头部预览 | +| [app](shell/app.rs) | GUI 事件循环、窗口/viewport、输入与 chrome 调度 | +| [clipboard](shell/clipboard.rs) | 原生 UTF-8 剪贴板适配 | +| [developer_tools](shell/developer_tools.rs) | 四个工具 Page 的状态、焦点与管理交互协调 | +| [native_chrome](shell/native_chrome.rs) | macOS 原生按钮对齐和工具栏双击系统动作 | +| [sources](shell/sources.rs) | 默认断开的云文件 provider 接口及工作区 | +| [system_ui](shell/system_ui/mod.rs) | 独立宿主管理控件、交互和主题 | +| [tabs](shell/tabs.rs) | 共享标签绘制/命中、密集布局、滚动、拖放及动画 | + +## runtime + +| 模块 | 职责 | +| --- | --- | +| [api](runtime/api.rs) | HTTP 请求路由、队列、请求观察和响应;worker/指南拆为子模块 | +| [endor_host](runtime/endor_host.rs) | 候选/活跃 VM、编译执行、增量刷新、清理和诊断 | +| [endor_script](runtime/endor_script.rs) | 组织 Aster Script/Module 输入及原始源码范围 | +| [events](runtime/events.rs) | 原生输入的有界 loopback 传输 | +| [event_dispatch](runtime/event_dispatch.rs) | 托管事件、订阅和 capture/target/bubble 分发 | +| [io_tasks](runtime/io_tasks.rs) | 有界文件/HTTP provider、纯数据完成消息和取消 | +| [tasks](runtime/tasks.rs) | 托管 Task、owner/key 取消和协作任务调度 | +| [vm_sequence](runtime/vm_sequence.rs) | 共享 VM 调用序列及同帧迭代驱动 | + +## model + +| 模块 | 职责 | +| --- | --- | +| [binding](model/binding.rs) | 属性依赖、只读计算与托管绑定 | +| [block](model/block.rs) | Block/Cell/Data 语义、稳定子 slot 和共享赋值 | +| [document](model/document.rs) | NamespaceState、RenderDocument、Frame、发布校验与原生命令 | +| [mount](model/mount.rs) | 每次展示出现的托管上下文、生命周期及弱索引 | +| [object_list](model/object_list.rs) | 托管结果集合的类型封装与只读访问 | +| [presentation](model/presentation.rs) | 逐窗口布局、Mount、缓存、读依赖和失效状态 | +| [protocols](model/protocols.rs) | Visual/EventTarget/Children/Presentable 能力契约安装 | +| [snapshot](model/snapshot.rs) | 变化依赖和不可变视觉提取缓存 | +| [window](model/window.rs) | 托管 Window、Page registry、命令及生命周期 | + +## render + +| 模块 | 职责 | +| --- | --- | +| [display_list](render/display_list.rs) | 原生绘制原语和颜色 | +| [geometry](render/geometry.rs) | 点和矩形值 | +| [layout](render/layout.rs) | 原生测量、排列、出现几何及预算 | +| [native_visual](render/native_visual.rs) | 托管原生视觉协议、值转换和签名检查 | +| [scene](render/scene.rs) | 页面、标签、插入标记和开发工具画面合成 | +| [scroll](render/scroll.rs) | 逐 Mount 偏移、嵌套滚动和剪裁投影 | +| [software_renderer](render/software_renderer.rs) | 软件光栅化、抗锯齿覆盖和嵌套剪裁 | +| [source_hit](render/source_hit.rs) | 源/原生命中协议与匹配目标 | +| [source_layout](render/source_layout.rs) | 源 measure/arrange、约束、挂起工作和测量缓存 | +| [source_paint](render/source_paint.rs) | 源 paint 调用、受限命令转换和依赖 | +| [text](render/text.rs) | 系统字体、shaping、字形和测量缓存 | + +## bridge + +| 模块 | 职责 | +| --- | --- | +| [ui_bridge](bridge/ui_bridge.rs) | Endor 宿主类型、窗口展示会话、布局/绘制发布协调 | + +## 主要入口和运行约束 + +- HTTP 路由:`runtime/api.rs`;worker:`runtime/api/worker.rs`;在线指南:`runtime/api/guide.rs`。 +- Bridge 安装:`bridge/ui_bridge/bootstrap.rs`;Block 调用:`calls.rs`;签名:`signatures.rs`;不可变树:`tree.rs`。 +- 默认页:`bridge/ui_bridge/start_page.endor`;Console/Sources/Network/Settings:`shell/developer_tools/`。 +- 现有 API 和宿主测试:`runtime/api/tests.rs`、`runtime/endor_host/tests.rs`;其他模块中也保留原单元测试。 + +VM 和托管根只由执行 worker 操作。原生窗口收到已发布值,不收到 ObjectRef。完整 PUT 成功才接替活跃程序;Console 保留实际 VM;失败和清理语义见架构文档。 + +本机遵循既有检查约定:格式、离线 Clippy、原生 release 构建和人工运行检查;不执行 cargo test、自动 smoke 脚本或 Docker。已有远程检查脚本保留原位置,此次不运行或追逐 CI。 diff --git a/src/api.rs b/src/api.rs deleted file mode 100644 index 0e8f733..0000000 --- a/src/api.rs +++ /dev/null @@ -1,1802 +0,0 @@ -use std::{ - io::Read, - ops::{Deref, DerefMut}, - panic::{AssertUnwindSafe, catch_unwind}, - sync::{ - Arc, RwLock, - mpsc::{Receiver, SyncSender, TrySendError, sync_channel}, - }, - sync::{ - Condvar, Mutex, - atomic::{AtomicBool, Ordering}, - }, - thread, -}; - -use anyhow::{Context as _, Result}; -use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; -use winit::event_loop::EventLoopProxy; - -use crate::{ - activity::{Activity, ActivityLog}, - app::AppEvent, - document::NamespaceState, - endor_host, -}; - -pub const API_ADDRESS: &str = "127.0.0.1:4737"; -const MAX_CODE_SIZE: u64 = endor_host::MAX_SOURCE_BYTES as u64; -type Notify = Arc; - -#[derive(Clone, Debug)] -pub struct ShutdownOutcome { - pub summary: String, - pub error: Option, -} -type ShutdownCompletion = Arc<(Mutex>, Condvar)>; -#[derive(Clone)] -pub struct RuntimeControl { - sender: SyncSender<()>, - requested: Arc, - completion: ShutdownCompletion, -} -impl RuntimeControl { - pub fn request_shutdown(&self) -> bool { - if self.requested.swap(true, Ordering::AcqRel) { - return true; - } - if self.sender.try_send(()).is_ok() { - true - } else { - self.requested.store(false, Ordering::Release); - false - } - } - pub fn is_exiting(&self) -> bool { - self.requested.load(Ordering::Acquire) - } - pub fn wait_shutdown(&self, timeout: std::time::Duration) -> Option { - let (lock, ready) = &*self.completion; - let result = lock.lock().ok()?; - let (result, _) = ready - .wait_timeout_while(result, timeout, |result| result.is_none()) - .ok()?; - result.clone() - } -} -struct Shutdown { - receiver: Receiver<()>, - control: RuntimeControl, - proxy: EventLoopProxy, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum RequestKind { - Event, - Code, - Expression, - Repl, - ResetRepl, -} - -struct BuildRequest { - request: ObservedRequest, - kind: RequestKind, - source: String, - namespace: String, - submission: u64, -} - -pub fn start( - namespace: Arc>, - proxy: EventLoopProxy, -) -> Result<(Activity, RuntimeControl)> { - let (sender, receiver) = sync_channel(1); - let control = RuntimeControl { - sender, - requested: Arc::new(AtomicBool::new(false)), - completion: Arc::new((Mutex::new(None), Condvar::new())), - }; - let shutdown = Shutdown { - receiver, - control: control.clone(), - proxy: proxy.clone(), - }; - let server = Server::http(API_ADDRESS) - .map_err(|error| anyhow::anyhow!(error)) - .with_context(|| format!("bind Tessera API to {API_ADDRESS}"))?; - let notify: Notify = Arc::new(move || { - let _ = proxy.send_event(AppEvent::DocumentUpdated); - }); - let activity = Arc::new(Mutex::new(ActivityLog::default())); - start_server_observed( - Arc::new(server), - namespace, - notify.clone(), - activity.clone(), - notify, - Some(shutdown), - )?; - Ok((activity, control)) -} - -#[cfg(test)] -fn start_server( - server: Arc, - namespace: Arc>, - notify: Notify, -) -> Result> { - start_server_observed( - server, - namespace, - notify, - Arc::new(Mutex::new(ActivityLog::default())), - Arc::new(|| {}), - None, - ) -} - -fn start_server_observed( - server: Arc, - namespace: Arc>, - notify: Notify, - activity: Activity, - observer_notify: Notify, - shutdown: Option, -) -> Result> { - let (sender, receiver) = sync_channel::(8); - let worker_namespace = namespace.clone(); - let worker_notify = notify.clone(); - let exiting = shutdown - .as_ref() - .map(|shutdown| shutdown.control.requested.clone()); - thread::Builder::new() - .name("tessera-endor-worker".into()) - .spawn(move || { - let mut worker = Worker::default(); - let mut pending: Option<(BuildRequest, PendingRequest)> = None; - loop { - if let Some(shutdown) = &shutdown - && shutdown.receiver.try_recv().is_ok() - { - if let Some((job, input)) = pending.take() { - drop(input); - let _ = job - .request - .respond(error_response(StatusCode(503), "application is exiting")); - } - let outcome = worker.shutdown_runtime(); - while let Ok(job) = receiver.try_recv() { - let _ = job - .request - .respond(error_response(StatusCode(503), "application is exiting")); - } - if let Ok(mut current) = worker_namespace.write() { - current.runtime_session = 0; - current.window_commands.clear(); - current.document.frame.session = 0; - current.document.frame.focus = None; - for view in current.document.windows.values_mut() { - view.frame.session = 0; - view.frame.focus = None; - } - } - eprintln!("{}", outcome.summary); - let (lock, ready) = &*shutdown.control.completion; - if let Ok(mut result) = lock.lock() { - *result = Some(outcome.clone()); - ready.notify_all(); - } - let _ = shutdown.proxy.send_event(AppEvent::ShutdownComplete { - error: outcome.error, - }); - break; - } - if let Some((job, input)) = pending.take() { - match worker.resume_request(&job, input, &worker_namespace, &worker_notify) { - RequestProgress::Pending(input) => { - pending = Some((job, input)); - // A soft VM quantum is a control boundary, not a - // minimum-duration time slice. Yield to other ready - // threads without charging a timer delay per quantum. - thread::yield_now(); - } - RequestProgress::Complete(response) => { - let _ = job.request.respond(response); - } - } - continue; - } - worker.refresh_viewport(&worker_namespace, &worker_notify); - let wait = if worker.repl.as_ref().is_some_and(|(_, runtime)| { - runtime.has_pending_refresh() || runtime.has_cooperative_tasks() - }) { - std::time::Duration::ZERO - } else { - std::time::Duration::from_millis(100) - }; - match receiver.recv_timeout(wait) { - Ok(job) => { - match worker.begin_request(&job, &worker_namespace, &worker_notify) { - RequestProgress::Pending(input) => { - if matches!(&input, PendingRequest::BeforeInput) { - eprintln!( - "Tessera worker: submission {} waits for active refresh", - job.submission - ); - } - pending = Some((job, input)); - } - RequestProgress::Complete(response) => { - let _ = job.request.respond(response); - } - } - } - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { - if worker.repl.as_ref().is_some_and(|(epoch, runtime)| { - worker_namespace.read().is_ok_and(|current| { - current.submission != *epoch - && current.runtime_session != runtime.id - }) - }) { - worker.repl = None; - } - if wait.is_zero() { - thread::yield_now(); - } - } - } - } - }) - .context("start Aster execution worker")?; - thread::Builder::new() - .name("tessera-agent-api".to_owned()) - .spawn(move || { - for request in server.incoming_requests() { - if exiting - .as_ref() - .is_some_and(|flag| flag.load(Ordering::Acquire)) - && !matches!(request.method(), Method::Get | Method::Options) - { - let _ = - request.respond(error_response(StatusCode(503), "application is exiting")); - continue; - } - handle( - request, - &namespace, - ¬ify, - &sender, - &activity, - &observer_notify, - ); - } - }) - .context("start Tessera agent API thread") -} - -// Capture only allowlisted headers: credentials and cookies never enter the inspector. -struct ObservedRequest { - inner: Request, - activity: Activity, - id: u64, - notify: Notify, -} -impl ObservedRequest { - fn new(inner: Request, activity: Activity, notify: Notify) -> Self { - let headers = inner - .headers() - .iter() - .filter(|h| h.field.equiv("Content-Type") || h.field.equiv("Content-Length")) - .map(ToString::to_string) - .collect::>() - .join("\n"); - let origin = if inner - .headers() - .iter() - .any(|h| h.field.equiv("X-Tessera-Origin") && h.value.as_str() == "console") - { - "Console" - } else { - "HTTP" - } - .to_owned(); - let id = activity.lock().unwrap().begin( - inner.method().to_string(), - inner.url().to_owned(), - origin, - headers, - ); - notify(); - Self { - inner, - activity, - id, - notify, - } - } - fn record_body(&self, body: &str) { - self.activity.lock().unwrap().body(self.id, body); - (self.notify)(); - } - fn respond(self, response: Response>>) -> std::io::Result<()> { - let status = response.status_code(); - let headers = response.headers().to_vec(); - let length = response.data_length(); - let reader = response.into_reader(); - self.activity.lock().unwrap().finish( - self.id, - status.0, - &String::from_utf8_lossy(reader.get_ref()), - headers - .iter() - .map(ToString::to_string) - .collect::>() - .join("\n"), - ); - (self.notify)(); - self.inner - .respond(Response::new(status, headers, reader, length, None)) - } -} -impl Deref for ObservedRequest { - type Target = Request; - fn deref(&self) -> &Request { - &self.inner - } -} -impl DerefMut for ObservedRequest { - fn deref_mut(&mut self) -> &mut Request { - &mut self.inner - } -} - -fn handle( - request: Request, - namespace: &Arc>, - notify: &Notify, - sender: &SyncSender, - activity: &Activity, - observer_notify: &Notify, -) { - let request = ObservedRequest::new(request, activity.clone(), observer_notify.clone()); - let method = request.method().clone(); - let path = request - .url() - .split('?') - .next() - .unwrap_or(request.url()) - .to_owned(); - - let evaluation = namespace_from_route(&path, "/evaluate"); - let repl = namespace_from_route(&path, "/repl"); - let event = namespace_from_route(&path, "/events"); - let route = if method == Method::Put { - namespace_from_path(&path).map(|name| (name, RequestKind::Code)) - } else if method == Method::Post { - event - .map(|name| (name, RequestKind::Event)) - .or_else(|| repl.map(|name| (name, RequestKind::Repl))) - .or(evaluation.map(|name| (name, RequestKind::Expression))) - } else if method == Method::Delete { - repl.map(|name| (name, RequestKind::ResetRepl)) - } else { - None - }; - if let Some((addressed, kind)) = route { - submit(request, addressed, namespace, sender, kind); - return; - } - let response = if method == Method::Get && path == "/agent" { - match namespace.read() { - Ok(namespace) => markdown(StatusCode(200), agent_guide(&namespace)), - Err(_) => error_response(StatusCode(500), "namespace lock is poisoned"), - } - } else if let Some(addressed_namespace) = namespace_from_path(&path).map(str::to_owned) { - code_request(method, &addressed_namespace, namespace, notify) - } else if method == Method::Options { - text(StatusCode(204), "", "text/plain") - } else { - error_response(StatusCode(404), "route not found; start with GET /agent") - }; - - let _ = request.respond(response); -} - -fn code_request( - method: Method, - addressed_namespace: &str, - namespace: &Arc>, - notify: &Notify, -) -> Response>> { - let Ok(current) = namespace.read() else { - return error_response(StatusCode(500), "namespace lock is poisoned"); - }; - if addressed_namespace != current.name { - return error_response( - StatusCode(404), - format!( - "namespace `{addressed_namespace}` is not active; current namespace is `{}`", - current.name - ), - ); - } - - if method == Method::Get { - let mut response = text( - StatusCode(200), - ¤t.document.source, - "text/plain; charset=utf-8", - ); - add_header( - &mut response, - "X-Tessera-Revision", - ¤t.revision.to_string(), - ); - add_header( - &mut response, - "X-Tessera-Session", - ¤t.document.frame.session.to_string(), - ); - add_header( - &mut response, - "X-Tessera-Window", - ¤t.document.frame.window.to_string(), - ); - add_header( - &mut response, - "X-Tessera-Layout-Version", - ¤t.document.frame.layout_version.to_string(), - ); - if let Some(viewport) = current.viewport { - add_header( - &mut response, - "X-Tessera-Viewport-Width", - &viewport.width.to_string(), - ); - add_header( - &mut response, - "X-Tessera-Viewport-Height", - &viewport.height.to_string(), - ); - } - add_header( - &mut response, - "X-Tessera-Source-Kind", - if current - .document - .root - .as_ref() - .is_some_and(|root| root.kind == "repl-session") - { - "repl-transcript" - } else { - "source-snapshot" - }, - ); - return response; - } - drop(current); - - match method { - Method::Delete => match namespace.write() { - Ok(mut current) => { - let revision = match current.clear() { - Ok(revision) => revision, - Err(error) => return error_response(StatusCode(500), error.to_string()), - }; - drop(current); - notify(); - json( - StatusCode(200), - serde_json::json!({ - "ok": true, - "namespace": addressed_namespace, - "revision": revision - }), - ) - } - Err(_) => error_response(StatusCode(500), "namespace lock is poisoned"), - }, - Method::Options => text(StatusCode(204), "", "text/plain"), - _ => error_response(StatusCode(405), "code supports GET, PUT, and DELETE"), - } -} - -fn submit( - mut request: ObservedRequest, - addressed: &str, - namespace: &Arc>, - sender: &SyncSender, - kind: RequestKind, -) { - let source = match read_code(&mut request) { - Ok(source) => source, - Err(error) => { - let _ = request.respond(error_response(StatusCode(400), error.to_string())); - return; - } - }; - request.record_body(&source); - let Ok(mut current) = namespace.write() else { - let _ = request.respond(error_response( - StatusCode(500), - "namespace lock is poisoned", - )); - return; - }; - if current.name != addressed { - let _ = request.respond(error_response(StatusCode(404), "namespace is not active")); - return; - } - let submission = match if kind == RequestKind::Code { - current.next_submission() - } else { - Ok(current.submission) - } { - Ok(id) => id, - Err(error) => { - let _ = request.respond(error_response(StatusCode(500), error.to_string())); - return; - } - }; - let job = BuildRequest { - request, - kind, - source, - namespace: addressed.into(), - submission, - }; - match sender.try_send(job) { - // A rejected queue entry never invalidates accepted work. The worker - // uses this same lock when committing, so publication is atomic. - Ok(()) => { - if kind == RequestKind::Code { - current.submission = submission; - } - } - Err(TrySendError::Full(job)) => { - let _ = job - .request - .respond(error_response(StatusCode(503), "build queue is full")); - } - Err(TrySendError::Disconnected(job)) => { - let _ = job.request.respond(error_response( - StatusCode(503), - "execution worker is unavailable", - )); - } - } -} - -type HttpResponse = Response>>; -type InputOutcome = - std::thread::Result)>>; - -type CandidateOutcome = std::thread::Result< - Result<( - endor_host::ReplSession, - crate::document::RenderDocument, - Vec, - )>, ->; - -struct PendingInput { - epoch: u64, - id: u64, - event: bool, - result: endor_host::ReplResult, -} - -enum PendingRequest { - BeforeInput, - Input(Box), - Candidate(Box), -} - -enum RequestProgress { - Pending(PendingRequest), - Complete(HttpResponse), -} - -fn current_viewports( - current: &NamespaceState, -) -> std::collections::BTreeMap { - let mut viewports: std::collections::BTreeMap<_, _> = current - .window_viewports - .iter() - .filter(|(id, _)| { - **id == crate::window::MAIN_WINDOW - || (current.runtime_session != 0 - && current - .document - .windows - .get(id) - .is_some_and(|view| view.frame.session == current.runtime_session)) - }) - .map(|(id, size)| (*id, *size)) - .collect(); - if let Some(viewport) = current.viewport { - viewports.insert(crate::window::MAIN_WINDOW, viewport); - } - viewports -} - -#[derive(Default)] -struct Worker { - repl: Option<(u64, endor_host::ReplSession)>, - next_session: u64, -} -impl Worker { - fn shutdown_runtime(&mut self) -> ShutdownOutcome { - let Some((_, mut runtime)) = self.repl.take() else { - return ShutdownOutcome { - summary: "Tessera shutdown: no live runtime".into(), - error: None, - }; - }; - let callbacks = !runtime.is_faulted(); - let result = catch_unwind(AssertUnwindSafe(|| runtime.shutdown())); - match result { - Ok(Ok((windows, mounts, tasks))) => ShutdownOutcome { - summary: format!( - "Tessera shutdown: {windows} windows, {mounts} mounts, {tasks} task entries; callbacks={callbacks}" - ), - error: None, - }, - result => { - let error = match result { - Ok(Err(error)) => format!("shutdown cleanup failed: {error:#}"), - _ => "shutdown cleanup failed after an internal panic".into(), - }; - let error: String = error.chars().take(4096).collect(); - ShutdownOutcome { - summary: error.clone(), - error: Some(error), - } - } - } - } - - fn close_inactive_window( - &mut self, - input: &crate::events::Input, - epoch: u64, - namespace: &Arc>, - notify: &Notify, - ) -> Response>> { - if input.window == crate::window::MAIN_WINDOW { - return error_response( - StatusCode(409), - "close the primary shell with its OS window control", - ); - } - let owns_runtime = self.repl.as_ref().is_some_and(|(owner, runtime)| { - *owner == epoch && runtime.id == input.session && input.session != 0 - }); - let mut discarded = false; - if owns_runtime { - let result = catch_unwind(AssertUnwindSafe(|| { - self.repl - .as_mut() - .expect("owned runtime") - .1 - .retire_window(input.window) - })); - if !matches!(result, Ok(Ok(()))) { - // Never keep a half-cleaned VM live or borrow another generation. - self.repl = None; - discarded = true; - } - } - let Ok(mut current) = namespace.write() else { - return error_response(StatusCode(500), "namespace lock poisoned"); - }; - if current.submission != epoch { - return superseded(epoch); - } - let Some(view) = current.document.windows.get(&input.window) else { - return error_response(StatusCode(409), "window is no longer active"); - }; - if view.frame.session != input.session { - return error_response( - StatusCode(409), - "window belongs to another runtime generation", - ); - } - let mut document = current.document.clone(); - document.windows.remove(&input.window); - if discarded { - document.frame.session = 0; - document.frame.focus = None; - for view in document.windows.values_mut() { - view.frame.session = 0; - view.frame.focus = None; - } - current.runtime_session = 0; - current.window_commands.clear(); - } - match current.commit(epoch, document) { - Ok(Some(_)) => {} - Ok(None) => return superseded(epoch), - Err(error) => return error_response(StatusCode(500), error.to_string()), - } - current.window_viewports.remove(&input.window); - drop(current); - notify(); - json( - StatusCode(200), - serde_json::json!({"ok":true,"execution":"window-retire","window":input.window,"callbacks":false,"runtime_discarded":discarded}), - ) - } - - fn refresh_viewport(&mut self, namespace: &Arc>, notify: &Notify) { - self.refresh_viewport_mode(namespace, notify, false); - } - - fn refresh_viewport_mode( - &mut self, - namespace: &Arc>, - notify: &Notify, - before_input: bool, - ) { - let (epoch, viewports) = match namespace.read() { - Ok(current) => { - let viewports = current_viewports(¤t); - (current.submission, viewports) - } - Err(_) => return, - }; - let Some((owner, runtime)) = &mut self.repl else { - return; - }; - if *owner != epoch { - if let Ok(current) = namespace.read() { - self.preserve_live_owner(¤t); - } - return; - } - let id = runtime.id; - let result = catch_unwind(AssertUnwindSafe(|| -> Result<_> { - if runtime.is_faulted() { - runtime.poll_tasks()?; - return Ok((None, Vec::new())); - } - let resized = runtime.resize_windows_incremental(&viewports, false)?; - if runtime.has_pending_refresh() { - return Ok((None, Vec::new())); - } - // An accepted foreground input waits only for the existing - // refresh; do not keep placing new callbacks ahead of it. - let completed = if before_input { - None - } else { - runtime.poll_tasks_incremental(&viewports)? - }; - if runtime.has_pending_refresh() { - // A complete earlier resize may publish; task effects and their - // native commands wait until the task's refresh has settled. - return Ok((resized, Vec::new())); - } - Ok((completed.or(resized), runtime.take_window_commands()?)) - })); - let mut current = match namespace.write() { - Ok(current) => current, - Err(_) => return, - }; - if current.submission != epoch || current.runtime_session != id { - return; - } - let (document, commands) = match result { - Ok(Ok((document, commands))) => { - if let Some(document) = document { - (document, Some(commands)) - } else { - let projected = !commands.is_empty(); - current.publish_window_commands(id, commands); - drop(current); - if projected { - notify(); - } - return; - } - } - failure => { - let mut document = current.document.clone(); - document.frame.fault = Some(match failure { - Ok(Err(error)) => format!("Runtime background update failed: {error:#}") - .chars() - .take(1024) - .collect(), - _ => { - self.repl = None; - current.runtime_session = 0; - current.window_commands.clear(); - "Runtime discarded after an internal background failure".into() - } - }); - for view in document.windows.values_mut() { - view.frame.fault = document.frame.fault.clone(); - } - (document, None) - } - }; - let changed = matches!(current.commit(epoch, document), Ok(Some(_))); - if changed && let Some(commands) = commands { - current.publish_window_commands(id, commands); - } - drop(current); - if changed { - notify(); - } - } - - fn begin_request( - &mut self, - job: &BuildRequest, - namespace: &Arc>, - notify: &Notify, - ) -> RequestProgress { - if job.kind == RequestKind::Code { - return self.begin_program_request(job, namespace, notify); - } - if job.kind == RequestKind::Expression { - return RequestProgress::Complete(build(job, namespace, notify)); - } - // Advance one existing refresh quantum, then park the HTTP request. - // No input statement executes inside the read-only scope; shutdown and - // newer viewport targets can be observed at every worker turn. - self.refresh_viewport_mode(namespace, notify, true); - let epoch = match namespace.read() { - Ok(current) => current.submission, - Err(_) => { - return RequestProgress::Complete(error_response( - StatusCode(500), - "namespace lock is poisoned", - )); - } - }; - if epoch != job.submission { - return RequestProgress::Complete(superseded(job.submission)); - } - if job.kind == RequestKind::ResetRepl { - self.repl = None; - if let Ok(mut current) = namespace.write() { - current.document.frame.session = 0; - current.document.frame.focus = None; - for document in current.document.windows.values_mut() { - document.frame.session = 0; - document.frame.focus = None; - } - current.runtime_session = 0; - current.window_commands.clear(); - } - notify(); - return RequestProgress::Complete(json( - StatusCode(200), - serde_json::json!({"ok":true,"execution":"repl-reset","persistent":true,"session_reset":true,"message":"Console memory cleared; displayed page preserved"}), - )); - } - if self - .repl - .as_ref() - .is_some_and(|(_, runtime)| runtime.has_pending_refresh()) - { - return RequestProgress::Pending(PendingRequest::BeforeInput); - } - let event = if job.kind == RequestKind::Event { - let input: crate::events::Input = match serde_json::from_str(&job.source) { - Ok(input) => input, - Err(error) => { - return RequestProgress::Complete(error_response( - StatusCode(400), - error.to_string(), - )); - } - }; - if !input.kind.starts_with("developer-open:") - && !matches!( - input.kind.as_str(), - "click" - | "focus" - | "scroll" - | "tab-activate" - | "tab-close" - | "tab-detach" - | "tab-drop" - | "window-close" - ) - { - return RequestProgress::Complete(error_response( - StatusCode(400), - "unsupported event kind", - )); - } - if let Err(message) = input.validate() { - return RequestProgress::Complete(error_response(StatusCode(400), message)); - } - let current = match namespace.read() { - Ok(current) => current, - Err(_) => { - return RequestProgress::Complete(error_response( - StatusCode(500), - "namespace lock poisoned", - )); - } - }; - let frame = if input.window == crate::window::MAIN_WINDOW { - ¤t.document.frame - } else { - let Some(document) = current.document.windows.get(&input.window) else { - return RequestProgress::Complete(error_response( - StatusCode(409), - "window is no longer active", - )); - }; - &document.frame - }; - if input.kind == "window-close" - && frame.window == input.window - && frame.session == input.session - { - let owns = self.repl.as_ref().is_some_and(|(owner, runtime)| { - *owner == epoch && runtime.id == input.session && input.session != 0 - }); - let stopped = frame.fault.is_some() - || !owns - || self - .repl - .as_ref() - .is_some_and(|(_, runtime)| runtime.is_faulted()); - if stopped { - drop(current); - return RequestProgress::Complete( - self.close_inactive_window(&input, epoch, namespace, notify), - ); - } - } - if let Some(target) = &input.tab_drop { - let target_frame = if target.window == crate::window::MAIN_WINDOW { - Some(¤t.document.frame) - } else { - current - .document - .windows - .get(&target.window) - .map(|document| &document.frame) - }; - if target_frame.is_none_or(|frame| { - frame.window != target.window - || frame.session != input.session - || frame.layout_version != target.layout_version - || frame.fault.is_some() - || target.index > frame.tabs.len() - }) || self - .repl - .as_ref() - .is_none_or(|(_, runtime)| !runtime.accepts_tab_drop(&input)) - { - return RequestProgress::Complete(error_response( - StatusCode(409), - "drop targets an obsolete or inactive frame", - )); - } - } - if frame.window != input.window - || frame.session != input.session - || (input.kind != "window-close" && frame.layout_version != input.layout_version) - || frame.fault.is_some() - || if input.kind == "window-close" || input.kind.starts_with("developer-open:") { - input.mount != 0 - } else if matches!( - input.kind.as_str(), - "tab-activate" | "tab-close" | "tab-detach" | "tab-drop" - ) { - !frame.tabs.iter().any(|tab| tab.id == input.mount) - } else { - !frame.mounts.iter().any(|mount| { - mount.id == input.mount - && (mount.interactive - || input.kind == "scroll" && mount.scroll.is_some()) - }) - } - || self.repl.as_ref().is_none_or(|(context, runtime)| { - *context != epoch - || runtime.id != input.session - || (input.kind != "window-close" && !runtime.accepts_window(input.window)) - }) - { - return RequestProgress::Complete(error_response( - StatusCode(409), - "event targets an obsolete or inactive frame", - )); - } - Some(input) - } else { - None - }; - if self - .repl - .as_ref() - .is_none_or(|(current, _)| *current != epoch) - { - self.next_session = match self.next_session.checked_add(1) { - Some(id) => id, - None => { - return RequestProgress::Complete(error_response( - StatusCode(500), - "session identities exhausted", - )); - } - }; - let viewport = namespace.read().ok().and_then(|current| current.viewport); - match endor_host::ReplSession::new_with_viewport(self.next_session, viewport) { - Ok(session) => { - if let Ok(mut current) = namespace.write() - && current.submission == epoch - { - current.publish_window_commands(session.id, Vec::new()); - } - self.repl = Some((epoch, session)); - } - Err(error) => { - return RequestProgress::Complete(error_response( - StatusCode(500), - error.to_string(), - )); - } - } - } - let viewports = match namespace.read() { - Ok(current) => current_viewports(¤t), - Err(_) => { - return RequestProgress::Complete(error_response( - StatusCode(500), - "namespace lock is poisoned", - )); - } - }; - let session = &mut self.repl.as_mut().expect("initialized REPL").1; - let id = session.id; - let result = catch_unwind(AssertUnwindSafe(|| -> Result<_> { - let result = if let Some(event) = &event { - session.dispatch_incremental(event, &viewports)? - } else { - session.execute_incremental(&job.source, &job.namespace, &viewports)? - }; - let commands = if session.has_pending_refresh() { - Vec::new() - } else { - session.take_window_commands()? - }; - Ok((result, commands)) - })); - let faulted = session.is_faulted(); - if session.has_pending_refresh() { - if let Ok(Ok((result, _))) = result { - return RequestProgress::Pending(PendingRequest::Input(Box::new(PendingInput { - epoch, - id, - event: event.is_some(), - result, - }))); - } - // A failed start must never leave a request waiting on a broken VM. - self.repl = None; - } - RequestProgress::Complete(self.finish_input( - job, - namespace, - notify, - epoch, - id, - event.is_some(), - faulted, - result, - )) - } - - fn resume_request( - &mut self, - job: &BuildRequest, - pending: PendingRequest, - namespace: &Arc>, - notify: &Notify, - ) -> RequestProgress { - match pending { - PendingRequest::BeforeInput => self.begin_request(job, namespace, notify), - PendingRequest::Input(input) => self.resume_input(job, input, namespace, notify), - PendingRequest::Candidate(runtime) => { - self.resume_candidate(job, runtime, namespace, notify) - } - } - } - - fn resume_input( - &mut self, - job: &BuildRequest, - mut input: Box, - namespace: &Arc>, - notify: &Notify, - ) -> RequestProgress { - let viewports = match namespace.read() { - Ok(current) - if current.submission == input.epoch || current.runtime_session == input.id => - { - current_viewports(¤t) - } - Ok(_) => { - self.repl = None; - return RequestProgress::Complete(superseded(job.submission)); - } - Err(_) => { - self.repl = None; - return RequestProgress::Complete(error_response( - StatusCode(500), - "namespace lock is poisoned", - )); - } - }; - let Some((epoch, session)) = &mut self.repl else { - return RequestProgress::Complete(error_response( - StatusCode(409), - "input runtime was discarded", - )); - }; - if *epoch != input.epoch || session.id != input.id { - return RequestProgress::Complete(superseded(job.submission)); - } - let resumed = catch_unwind(AssertUnwindSafe(|| -> Result<_> { - let document = session.resize_windows_incremental(&viewports, false)?; - if session.has_pending_refresh() { - return Ok(None); - } - Ok(Some((document, session.take_window_commands()?))) - })); - let faulted = session.is_faulted(); - let result = match resumed { - Ok(Ok(None)) => return RequestProgress::Pending(PendingRequest::Input(input)), - Ok(Ok(Some((document, commands)))) => { - input.result.document = document; - Ok(Ok((input.result, commands))) - } - Ok(Err(error)) => Ok(Err(error)), - Err(panic) => Err(panic), - }; - RequestProgress::Complete(self.finish_input( - job, - namespace, - notify, - input.epoch, - input.id, - input.event, - faulted, - result, - )) - } - - #[allow(clippy::too_many_arguments)] - fn finish_input( - &mut self, - job: &BuildRequest, - namespace: &Arc>, - notify: &Notify, - epoch: u64, - id: u64, - event: bool, - faulted: bool, - result: InputOutcome, - ) -> HttpResponse { - let Ok(mut current) = namespace.write() else { - return error_response(StatusCode(500), "namespace lock is poisoned"); - }; - let epoch = if current.submission != epoch { - if current.runtime_session != id { - drop(current); - self.repl = None; - return superseded(job.submission); - } - // Accepted PUTs invalidate queued inputs, not a mutation already - // executing on this owner. Settle it before building the candidate - // so a failed replacement can preserve a consistent live heap. - if let Some((owner, runtime)) = &mut self.repl - && runtime.id == id - { - *owner = current.submission; - } - current.submission - } else { - epoch - }; - match result { - Ok(Ok((result, commands))) => { - let projected = !commands.is_empty(); - let rendered = result.document.is_some(); - if let Some(document) = result.document { - match current.commit(epoch, document) { - Ok(Some(_)) => {} - Ok(None) => return superseded(job.submission), - Err(error) => return error_response(StatusCode(500), error.to_string()), - } - } - current.publish_window_commands(id, commands); - let revision = current.revision; - drop(current); - if rendered || projected { - notify(); - } - json( - StatusCode(200), - serde_json::json!({"ok":true,"execution":if event { "event" } else { "repl" },"persistent":true,"session":id,"input":result.input,"value":result.value,"bindings":result.bindings,"rendered":rendered,"revision":revision}), - ) - } - Ok(Err(error)) => { - if event || faulted { - let mut document = current.document.clone(); - // Preserve the first failure of this VM; subsequent - // rejected Console input must not hide the callback cause. - // A preserved picture from another generation is unrelated. - let fault = (document.frame.session == id) - .then(|| document.frame.fault.clone()) - .flatten() - .unwrap_or_else(|| format!("{error:#}").chars().take(1024).collect()); - document.frame.fault = Some(fault.clone()); - for view in document.windows.values_mut() { - view.frame.fault = Some(fault.clone()); - } - let _ = current.commit(epoch, document); - drop(current); - notify(); - } - compile_error(error) - } - Err(_) => { - let mut document = current.document.clone(); - document.frame.fault = - Some("Runtime discarded after an internal execution failure".into()); - for view in document.windows.values_mut() { - view.frame.fault = document.frame.fault.clone(); - } - current.runtime_session = 0; - current.window_commands.clear(); - let _ = current.commit(epoch, document); - drop(current); - self.repl = None; - notify(); - json( - StatusCode(500), - serde_json::json!({"ok":false,"session_reset":true,"error":"Console execution failed internally; session discarded, displayed page preserved"}), - ) - } - } - } - - /// An accepted PUT changes the submission, but does not revoke the old - /// runtime. DELETE clears runtime_session and does revoke it. - fn preserve_live_owner(&mut self, current: &NamespaceState) { - if let Some((epoch, runtime)) = &mut self.repl { - if current.runtime_session == runtime.id { - *epoch = current.submission; - } else { - self.repl = None; - } - } - } - - fn begin_program_request( - &mut self, - job: &BuildRequest, - namespace: &Arc>, - notify: &Notify, - ) -> RequestProgress { - let viewport = match namespace.read() { - Ok(current) if current.submission == job.submission => current.viewport, - Ok(current) => { - self.preserve_live_owner(¤t); - return RequestProgress::Complete(superseded(job.submission)); - } - Err(_) => { - return RequestProgress::Complete(error_response( - StatusCode(500), - "namespace lock poisoned", - )); - } - }; - self.next_session = match self.next_session.checked_add(1) { - Some(id) => id, - None => { - return RequestProgress::Complete(error_response( - StatusCode(500), - "session identities exhausted", - )); - } - }; - let id = self.next_session; - let started = catch_unwind(AssertUnwindSafe(|| { - endor_host::ReplSession::load_incremental( - &job.source, - &job.namespace, - job.submission, - id, - viewport, - ) - })); - let result = match started { - Ok(Ok((runtime, None))) => { - return RequestProgress::Pending(PendingRequest::Candidate(Box::new(runtime))); - } - Ok(Ok((runtime, Some(document)))) => match runtime.take_window_commands() { - Ok(commands) => Ok(Ok((runtime, document, commands))), - Err(error) => Ok(Err(error)), - }, - Ok(Err(error)) => Ok(Err(error)), - Err(panic) => Err(panic), - }; - RequestProgress::Complete(self.finish_candidate(job, namespace, notify, id, result)) - } - - fn resume_candidate( - &mut self, - job: &BuildRequest, - mut runtime: Box, - namespace: &Arc>, - notify: &Notify, - ) -> RequestProgress { - let viewports = match namespace.read() { - Ok(current) if current.submission == job.submission => { - // Secondary native resources still belong to the old VM. Only - // the primary shell's current viewport applies to a candidate. - current - .viewport - .map(|size| (crate::window::MAIN_WINDOW, size)) - .into_iter() - .collect() - } - Ok(current) => { - self.preserve_live_owner(¤t); - return RequestProgress::Complete(superseded(job.submission)); - } - Err(_) => { - return RequestProgress::Complete(error_response( - StatusCode(500), - "namespace lock poisoned", - )); - } - }; - let id = runtime.id; - let resumed = catch_unwind(AssertUnwindSafe(|| -> Result<_> { - let document = runtime.resize_windows_incremental(&viewports, false)?; - if runtime.has_pending_refresh() { - return Ok(None); - } - let document = - document.ok_or_else(|| anyhow::anyhow!("program did not publish a Page"))?; - Ok(Some((document, runtime.take_window_commands()?))) - })); - let result = match resumed { - Ok(Ok(None)) => return RequestProgress::Pending(PendingRequest::Candidate(runtime)), - Ok(Ok(Some((document, commands)))) => Ok(Ok((*runtime, document, commands))), - Ok(Err(error)) => Ok(Err(error)), - Err(panic) => Err(panic), - }; - RequestProgress::Complete(self.finish_candidate(job, namespace, notify, id, result)) - } - - fn finish_candidate( - &mut self, - job: &BuildRequest, - namespace: &Arc>, - notify: &Notify, - id: u64, - result: CandidateOutcome, - ) -> HttpResponse { - let Ok(mut current) = namespace.write() else { - return error_response(StatusCode(500), "namespace lock poisoned"); - }; - if current.submission != job.submission { - self.preserve_live_owner(¤t); - return superseded(job.submission); - } - match result { - Ok(Ok((runtime, document, commands))) => match current.commit(job.submission, document) - { - Ok(Some(revision)) => { - current.publish_window_commands(id, commands); - self.repl = Some((job.submission, runtime)); - drop(current); - notify(); - json( - StatusCode(200), - serde_json::json!({"ok":true,"namespace":job.namespace,"submission":job.submission,"revision":revision,"diagnostics":[],"session":id,"persistent":true}), - ) - } - Ok(None) => superseded(job.submission), - Err(error) => error_response(StatusCode(500), error.to_string()), - }, - failed => { - // The candidate never mutated the displayed program's heap. - // Restore that program's event epoch when a replacement fails. - self.preserve_live_owner(¤t); - drop(current); - match failed { - Ok(Err(error)) => compile_error(error), - _ => error_response( - StatusCode(500), - "candidate failed internally; previous runtime preserved", - ), - } - } - } - } -} - -fn build( - job: &BuildRequest, - namespace: &Arc>, - notify: &Notify, -) -> Response>> { - if job.kind == RequestKind::Expression { - return match catch_unwind(AssertUnwindSafe(|| endor_host::evaluate(&job.source))) { - Ok(Ok(value)) => json( - StatusCode(200), - serde_json::json!({"ok":true,"value":value,"execution":"isolated-expression","persistent":false}), - ), - Ok(Err(error)) => compile_error(error), - Err(_) => error_response(StatusCode(500), "expression execution failed internally"), - }; - } - match namespace.read() { - Ok(current) if current.submission != job.submission => return superseded(job.submission), - Err(_) => return error_response(StatusCode(500), "namespace lock is poisoned"), - _ => {} - } - // Only an independent native snapshot crosses into the published state. - // A fresh VM is discarded on success, error and unwind alike. - let result = catch_unwind(AssertUnwindSafe(|| { - endor_host::compile(&job.source, &job.namespace, job.submission) - })); - let Ok(mut current) = namespace.write() else { - return error_response(StatusCode(500), "namespace lock is poisoned"); - }; - if current.submission != job.submission { - return superseded(job.submission); - } - match result { - Ok(Ok(document)) => match current.commit(job.submission, document) { - Ok(Some(revision)) => { - drop(current); - notify(); - json( - StatusCode(200), - serde_json::json!({ "ok": true, "namespace": job.namespace, - "submission": job.submission, "revision": revision, "diagnostics": [] }), - ) - } - Ok(None) => superseded(job.submission), - Err(error) => error_response(StatusCode(500), error.to_string()), - }, - Ok(Err(error)) => compile_error(error), - Err(_) => error_response( - StatusCode(500), - "candidate execution failed internally; previous document preserved", - ), - } -} - -fn superseded(submission: u64) -> Response>> { - json( - StatusCode(409), - serde_json::json!({ "ok": false, "submission": submission, - "error": "submission superseded by a newer PUT or DELETE" }), - ) -} - -fn namespace_from_route<'a>(path: &'a str, suffix: &str) -> Option<&'a str> { - path.strip_prefix("/v1/namespaces/")? - .strip_suffix(suffix) - .filter(|namespace| !namespace.is_empty() && !namespace.contains('/')) -} -fn namespace_from_path(path: &str) -> Option<&str> { - namespace_from_route(path, "/code") -} - -fn read_code(request: &mut Request) -> Result { - if request - .body_length() - .is_some_and(|length| length as u64 > MAX_CODE_SIZE) - { - anyhow::bail!("code exceeds the 1 MiB limit"); - } - let mut code = String::new(); - request - .as_reader() - .take(MAX_CODE_SIZE + 1) - .read_to_string(&mut code) - .context("read code")?; - if code.len() as u64 > MAX_CODE_SIZE { - anyhow::bail!("code exceeds the 1 MiB limit"); - } - Ok(code) -} - -fn agent_guide(namespace: &NamespaceState) -> String { - format!( - r##"# Tessera Agent API - -Current namespace: `{name}` -Current revision: `{revision}` - -Submit an ordinary Endor script. Tessera embeds Aster's frontend, compiler and VM. -The namespace comes from the request path. Top-level bindings share the candidate -VM's globals; `ui.page().render(root)` selects the visible UI. No main function is -required. Typed helper functions and the older main-returning-View form also work. - -## Code CRUD - -- Read: `GET http://{address}/v1/namespaces/{name}/code` -- Create or update: `PUT http://{address}/v1/namespaces/{name}/code` -- Delete: `DELETE http://{address}/v1/namespaces/{name}/code` - -Send PUT bodies as `text/plain; charset=utf-8`. - -```endor -{example}``` - -## Live Console session - -`POST http://{address}/v1/namespaces/{name}/repl` accepts statements, function - definitions and expressions as text/plain. Consecutive requests use one real VM -and globals dictionary on the execution worker. `title = ui.text("Hello")` creates -an object without displaying it. A later `page.render(root)` mounts its page. UI references share replacement state: after mounting, assigning a new UI value to a reference updates its aliases and mounted positions. `page.unmount()` releases the mount. -`let title = ...` is also supported. `_` retains the actual last value. No input -history is replayed. Responses include session/input IDs, value, binding count, -rendered flag and revision. Before the first mount the page/revision stay unchanged; afterwards successful inputs publish changed mounted references automatically. - -`DELETE http://{address}/v1/namespaces/{name}/repl` resets Console objects and bindings, -leaving the displayed native snapshot intact. Parsing/compilation happen before -execution. On runtime error earlier statements in that input may remain live, but -its pending render is discarded; previous displayed page is preserved. - -Session limits: 256 executed inputs, 1 MiB transcript, 10000 cumulative AST nodes, -4096 retained visual objects and 64 retained pages. Aster collects unreachable objects/cycles between inputs; there is no fixed UI pool. No VM handles cross to the window. -A newer accepted complete-source PUT or code DELETE invalidates queued Console inputs. A running input settles before a queued PUT candidate is built; code DELETE revokes that runtime immediately. -A successful PUT retains its fresh VM as the live program for Console and GUI events; -a failed PUT preserves the previous program. DELETE releases the live context. -GET /code returns the source of the displayed snapshot: exact submitted source for -PUT, an annotated Console transcript after a REPL render. X-Tessera-Source-Kind -identifies source-snapshot versus repl-transcript; a transcript is not a replay log. - -The compatibility `POST http://{address}/v1/namespaces/{name}/evaluate` endpoint -still accepts one isolated expression in a fresh VM without ui or previous bindings. -It does not publish a page or change revision. Console does not use this endpoint. -All execution endpoints share the eight-entry queue. These are in-process bounded -host executions, not a complete untrusted-code sandbox or a process-isolation design. - -## Managed visual types - -`Object`, `Type`, `Function`, `Task`, `Block`, `ContainerBlock`, `TextBlock`, `RectBlock`, -`Spacer`, `Row`, `Column`, `Button`, `Page`, `Mount`, and `Event` are managed nominal -Type objects. Use `page = Page()` and `label = TextBlock("Hello")` in Endor source. -The existing ui constructors return these same typed objects. `page.type`, -`Page.base`, `Page.name`, `isInstance(page, Block)` and `isSubtype(Page, Block)` -expose actual type relations. Types, bases, instances and native constructors live -in the same GC graph. Ordinary source classes support `class Child: Base`, typed field -declarations, explicit first `self` parameters, positional field construction and -member assignment. Inherited methods bind the actual receiver; overridden methods -are dynamically selected through self. Concrete native visual subclasses reuse real native allocators; abstract bases remain unconstructible. Explicit traits and source parameter/return annotation checks are enabled; declared-field construction/write checks are enabled alongside source function checks; complete initialization and static/effect typing remain pending. Native Block/Page methods use the same type method tables and binding path. - -Window is a host-provided managed resource: ui.window(), page.window(), page.open(window), window.pages()/id()/viewport(), resize(width,height), moveTo(x,y), setTitle(text) and focus(). Requests project to their actual native window only after successful input/candidate completion and carry the current runtime session. Resize is asynchronous and the reported viewport is authoritative; this host accepts 1..4096 logical dimensions with a native 720x480 minimum. Failed input/candidates discard pending requests and read-only rendering rejects window effects. Open Pages appear in native tab chrome, with page.title, click activation/close, Ctrl+Tab cycling and Cmd/Ctrl+W close when page keyboard focus is active. Overflow arrows reach additional pages. These operations call actual Page methods in the same VM; tab IDs are weak runtime projections and add no GC roots. The 36-logical-pixel bar reduces Page constraint height; window/ui viewport reports the full inner size and native pointer positions are translated into content coordinates. Window() now creates additional native windows in the same VM, up to 16 open resources. Page.open(window)/moveToWindow(window) preserve Page and business identity while replacing Mounts; detachToWindow() creates and returns a new Window. Dragging a tab away from its strip invokes the same method. Window.close()/secondary OS close remove Page roots, unmount, and retire the resource context; retained Pages can reopen in another live window, and closed Window resources reject viewport/resize/title/focus operations. Closing the primary OS shell exits the application. Secondary viewport inputs and frame publication use the same worker. Secondary close after callback/rendering failure uses host-only teardown without invoking source callbacks or resuming execution. After reset/discard, closing an old picture never touches a fresh VM resource with the same number; viewport inputs are also generation-scoped. A teardown failure discards the affected VM and disables remaining pictures. Native initialization/surface errors are reported rather than panicking; failed secondary creation leaves a managed resource closable from Console. Native tab reordering, existing-window drops and release-point placement use the managed methods described below. Application-exit cleanup is described below. - -Application exit now uses a private VM-worker control channel, independent of HTTP queue capacity. Main native close and a managed primary Window.close request asynchronous cleanup; duplicate requests coalesce and mutation requests are rejected with 503 while exiting. An unpublished candidate/input refresh is cancelled without replay. The current VM cancels all Task entries, closes managed windows, detaches occurrences child-before-parent per window, and invokes normal onUnmount hooks once. It does not run layout/paint during shutdown. All windows are closed before hooks run; new windows and tasks are forbidden. A stopped/faulted VM detaches without source hooks; reset/discarded pictures cannot revive an old VM. A hook error aborts remaining source hooks, releases prepared hook roots, drops the VM and reports failure before native exit. Completed shutdown is acknowledged to the GUI, which then ends the event loop. An OS exit bypassing CloseRequested uses a five-second bounded final fallback; non-cooperative native calls remain atomic and force-kill/crash cannot guarantee notifications. Native stderr records the window/mount/task-entry counts and whether source hooks were enabled, or the cleanup error. See examples/exit-lifecycle.endor. Cross-platform exit/drag validation remains broader follow-up work. - -Foreground REPL/events now retain a BeforeInput request while an existing idle layout/paint refresh advances one stage/quantum per worker turn. They execute their source only after that refresh settles, so mutations never enter its read-only scope. New task callbacks are not delivered ahead of this accepted input. Shutdown can cancel the waiting request with 503, and superseded submissions still return 409. Reset cancels/discards the old VM instead of synchronously draining its unpublished refresh. Source parsing/compilation and ordinary synchronous source/native calls remain atomic boundaries; this change removes the worker's synchronous pre-input refresh drain. Native stderr records the submission once when a request first waits for an active refresh; it does not log source text or repeat the line for each quantum. Ready layout/paint/coroutine quanta and pending input/candidates yield to ready OS threads without a fixed timer delay; queue, shutdown, generation and viewport checks still run between quanta. Only idle work or external waits use the normal 100 ms polling wait. - -Source layout/paint now validate name-level read observations from Aster: each globals/builtins lookup records its text key and observed presence/value, while dictionary kind checks remain separate. Updating the REPL result `_` or another unread global no longer invalidates these frames solely because the whole globals dictionary version changed. Reading `_` still observes its value; adding a previously missing global correctly invalidates a builtin fallback. Ordinary native dictionary iteration/content reads retain whole-object version dependencies. Measurement cache hits replay their recorded observations into the outer solve; they do not drop dependencies merely because source execution was skipped. Cache observations contain borrowed handles (including object-valued names), add no GC roots, and never write comparison snapshots back into language objects. Invalid-name diagnostics report only identity/key, not captured values. Block binding uses the same precise observations through the synchronous read-only API; other legacy callers of the object-list API remain conservative. See examples/global-dependencies.endor. - - - - -While a native tab drag is held over an overflow arrow area, the strip waits 350ms then reveals one hidden tab every 120ms, including in a compatible destination window. The preview scroll does not activate Pages or change business state. Drawing, hit testing and insertion gaps share the same transient visible range; release uses its full-order insertion index. Leaving the strip stops the timer; release, Escape, a new press or runtime generation change restores the normal active-tab-centered strip. A resize or changing tab count clamps the range. Native drag/mixed-DPI behavior still requires platform validation; application-exit cleanup is described below. - - -ContainerBlock.insert(position,block) accepts a zero-based position through the current count, returns a new stable slot and preserves existing slots/Mounts. Invalid indices and child cycles fail before changing the child list. Page.moveTab(position) uses a final zero-based index in its owning window. Native drops invoke the actual Page moveToWindow/moveTab/activate methods; a declined or redirected window move skips destination reordering. A drop allows 300000 instructions per method. Source overrides participate; earlier writes do not roll back after failure. Window.moveTo(x,y) queues the physical desktop outer origin, with signed integers within +/-1000000. Detached placement preserves the saved grab offset; the window manager may adjust it and unsupported global placement reports a diagnostic. Dragging uses a 12-logical-pixel threshold, visible insertion marker and Escape cancellation. Overflow auto-scroll, external-app occlusion and mixed-DPI verification remain pending. See examples/tab-placement.endor. - -Source measure and arrange overrides execute bottom-up and top-down in one read-only VM layout call. Structural Mount links exist before first measurement; bounds queries reject premature geometry. Native base layout uses current occurrence child measurements, and Arrangement must assign every stable child slot exactly once. Layout dependencies changed by lifecycle hooks trigger another pass before publication. Page constraints now use the current logical window viewport. Column/Button propagate cross-axis width and Row propagates cross-axis height; desired sizes are clamped. ui.viewport() provides a managed snapshot whose private input participates in rendering dependencies. Native resize updates are coalesced on the same worker without adding source inputs. Source measurement caches use Mount/method/children/constraints and actual subcall object reads, including returned-result decoding; Mount-reading methods conservatively bypass caching. ui.stats() exposes sourceMeasureHits/sourceMeasureMisses outside read-only rendering. Native TextBlock wrapping now uses the occurrence width in measurement/cache/painting; source measurement/arrangement now repeats until constraints and geometry settle; additional allocation policies remain pending; ScrollBlock scrolling is described below. - -Source layout solves repeat in one read-only call until constraints/sizes/bounds are unchanged, reusing one bytecode frame. Source-arranged changed dimensions become exact per-axis constraints for this solve; source container measurements differing from native results constrain content using the same container policy. Selected limits reset on later inputs/resizes. Nonconsecutive repeated states report a cycle. No fixed pass/hop cutoff is used; all passes share the 1000000-instruction/128-frame budget and budget failure is explicit. Measurement clears provisional Mount geometry, and only complete settled frames publish. ui.stats().layoutPasses reports work; source/cache counters include all passes. VM annotation checks distinguish validation-only from content reads, preserving Mount-typed independent measurement caching; actual Mount reads still bypass conservatively. Resumable solving remains pending. See examples/layout-negotiation.endor. - -TextBlock now wraps words/glyphs at its available width and measures the resulting height. Native and source-base measurement paths use current constraints; native shape/font caches include wrap width. Painting carries allocated width/height, clips glyphs and keeps ancestor scroll clipping. Source text PaintCommand may supply both wrapWidth/wrapHeight (finite nonnegative logical dimensions up to 1000000); neither preserves legacy unwrapped paint, and a partial pair is rejected. Native TextBlock.paint supplies the pair. These are derived draw bounds, not another business object. Management UI text remains unwrapped. Resumable solving and broad fractional-DPI validation remain pending. See examples/wrapped-text.endor. - -Source paint overrides execute in the actual frame pipeline under a read-only heap policy. Source hitTest overrides similarly refine native pointer targets in VM paint order; Point.inside(bounds) and Point.inCircle(cx, cy, radius) provide primitive shape queries. Managed DisplayList/ObjectList/PaintCommand and Constraints/Bounds/Point results are ordinary runtime objects. - -ScrollBlock(width,height,child) is a ContainerBlock with managed viewport dimensions and independent per-Mount offsets. mount.scrollTo(x,y), scrollBy(dx,dy), scrollPosition() and scrollIntoView() operate on actual occurrences. Position snapshots expose x/y/maxX/maxY from committed layout; relative requests accumulate and offsets clamp after content/viewport changes. Detached Mounts and read-only rendering mutations are rejected. scrollIntoView and requestFocus adjust nested scrollable ancestors. Native/source paint, focus outlines, hit testing and pointer selection share translated geometry and ancestor clips. Remounting starts a new offset. Paint x/y may be signed after scrolling; dimensions/radii remain nonnegative and coordinate budgets still apply. See examples/scroll.endor. Wheel chooses one eligible ancestor (not per-axis split); native scrollbar dragging, touch inertia and PageUp/PageDown remain pending. - -Native wheel POST /events uses kind scroll with the normal window/session/layout_version, a scrollable mount ID, point=[x,y], delta=[dx,dy], and no press_point. Deltas are signed finite logical pixels within +/-1000000; positive moves toward the end of content. Other kinds must omit delta. Native Shift+wheel maps to horizontal. The host checks the active committed frame, target attachment and clipped viewport before invoking Mount.scrollBy. These fields are transport metadata; source code uses the Endor methods above. - -ui.after(owner,key,milliseconds,callback) returns Task. The zero-argument callback runs on the owning VM worker after at least the integer 0..86400000 ms delay and must return Unit. Idle polling can add up to 100 ms; execution load may delay delivery further. Task.cancel/active/status expose cancellation, eligibility and pending/running/completed/cancelled/failed state. One owner/key replaces the prior pending task. Owner scopes and callback captures are managed strong edges; scheduler and owner links are weak. ui is an application owner. Hidden live objects continue; Page close cancels its and its Mount tasks, even close/reopen within one input. Page movement preserves Page tasks but replaces Mounts. Window close cancels its Pages/tasks; reset/DELETE/program replacement discard old scheduler/VM and generation checks reject stale publication. Failed PUT preserves old tasks. Callback failure stops the domain and cancels remaining tasks; earlier writes do not roll back. Read-only rendering cannot schedule/cancel. Limits: 64 pending per owner, 1024 per runtime, 64 delivered per poll, 128 frames/100000 instructions per callback. Providers support timers, asynchronous text-file reads and HTTP(S) text GET; await is supported in ui.spawn calls. - -Source function parameter annotations are checked before the body and return annotations before returning. Object accepts any live value; Unit accepts nil; Number, String/Text and Bool match scalar kinds. Function includes source/native functions and bound methods, while callable class definitions remain Type. View is a compatibility alias for Block. Other names resolve actual Type/Trait objects in the function's globals then captured builtins at each call. The method's own class uses the receiver's actual owning definition, preserving old bound methods after REPL redefinition. Other type names are not frozen at definition. UnknownAnnotation and TypeMismatch report failures; bad returns do not roll back earlier writes. Declared-field construction and set_attr writes are also checked, as described below. Static inference, generic and static effect typing remain pending. - -Declared fields are checked at construction and set_attr writes through the same enabled type-check switch. Native subclass fields are validated before native allocation/opening effects. Failure preserves the field; declared deletion is rejected. Inherited fields use their declaring definition/environment, the declaring class name keeps its actual identity after REPL redefinition, and other names resolve dynamically in declaration globals/builtins. Shared Block forwarding selects the current content type/storage. This does not scan old objects or revalidate all referring fields after shared-reference changes; trusted payload/storage/attach operations and complete initialization remain host responsibilities. Static/effect typing and local/captured type annotations remain pending. See examples/field-contracts.endor. - -Native methods now publish positional CallArity intervals, checked by the VM before invocation. Source defaults and bound receivers participate in the same query. Traits validate acceptance of their canonical count including self and reject native methods without metadata. Event listeners/onClick require Function metadata accepting zero arguments or one Event, preferring Event if both work. Binding computes and Task callbacks require zero-argument acceptance before registration/replacement. Generic Type constructor reflection and complete native signature coverage remain separate gaps. See examples/native-signatures.endor. - -Native CallSignature checks resolved parameter/return types for visual measure/arrange/paint/hitTest, Point queries, Window/Block/Container/Page/Mount/Task/Event/Subscription methods and ObjectList.at. Signature definitions are traced managed edges. Parameters fail before dispatch; final results delegated to source/native functions must satisfy all pending native return contracts, with rooted definitions and the same frame/instruction budgets. Wrong returns release scoped results but do not undo earlier writes. CallEffect ReadOnly allows result allocation; Delegated allows a read-only native wrapper while delegated targets keep the same VM read-only/budget scope (ui.present); Mutating/External calls are rejected before invocation during read-only execution. Block.click returns its generated Event; Block.dispatch returns the delivered Event. Dispatch/click and lifecycle handler contracts permit External effects; the default no-op does not constrain native overrides to ReadOnly. Existing heap barriers still apply, and this does not sandbox trusted native I/O. CallType unions preserve every branch definition. Page.open accepts Window or nil; page.window returns Window or nil; Block.on accepts Bool or String for its optional phase, while phase names retain domain validation. Mount relation queries are nullable, and bounds() is a real Bounds object usable by Point.inside. Native union metadata does not add Endor union syntax. Source method overrides now enforce parameter contravariance, return covariance and accepted-count preservation at class creation/method replacement, with call-time rechecks for dynamic annotation names. Ancestor replacement checks overriding descendants atomically. Unknown forward names may defer until invocation; known incompatibilities reject before effects. Known effects may only shrink; conservative source/call-graph ReadOnly proofs supplement runtime barriers. See examples/method-contracts.endor. Public ui functions, captured state methods and native factories publish signatures after the registry exists. Factories check the Type receiver/general result/effect and split native options from requested-class fields before validating both; omitted Rect/Text options and Row/Column variable children cannot absorb subclass fields. Event/ObjectList keep dedicated member/field checks. Generic Type reflection still has no single fixed constructor signature. See examples/constructor-contracts.endor. Current global/builtins/closure/receiver call graphs may prove ReadOnly without execution; unresolved targets/results, shared writes and Delegated targets remain unknown. Unknown legacy metadata remains unknown. See examples/visual-protocols.endor and examples/layout-negotiation.endor. - -Trait definitions Visual, EventTarget, Children and Presentable are managed nominal capabilities. ui.implement(type,capability) explicitly declares an implementation after required-method/positional-arity/type/known-native-effect checks; ui.implements(type,capability) and ui.supports(value,capability) query it. Block declares Visual/EventTarget; ContainerBlock declares Children, inherited by Page. Same-name methods alone never declare an implementation. Source subclass construction and host method replacement validate inherited/affected descendant contracts. Source/native positional intervals are checked, and missing native arity metadata prevents a capability declaration; source parameter/return annotations are checked at invocation. These capabilities use typed signatures: registration checks parameter contravariance, return covariance and known native effects. Actual calls recheck dynamic names and final results across delegation, with rooted return definitions. Explicit typed method annotations remain checked even if a different host disables global source checks; concrete implementations keep wider inputs and optional convenience forms. General source effects and formal trait syntax remain pending. EventTarget requires canonical on/dispatch/onEvent arities 3/2/1 after self; native optional convenience forms remain. - -For a plain object explicitly implementing Presentable, present(self, window: Window): Block is invoked by ui.present(value) or ui.open(value), using the current managed Window and the same VM budget. The result must be an actual Block. Blocks pass through directly; unregistered values retain the summary fallback. Source views retain their business object via managed fields/callbacks/bindings, as in examples/presentable.endor. Viewing function definitions does not itself execute them. Rendering methods still run read-only with nominal result checks. - -Block.bind(property, compute) binds an existing managed Block field to a zero-argument function and returns Subscription (disconnect/isConnected). This includes source-defined fields and native TextBlock.text/size/color, RectBlock.width/height/radius/color, Spacer.size and container padding/gap. Native fields can also be assigned directly. The target content owns compute/captures; tokens and host registry are weak. Rebinding disconnects the old connection; disconnect keeps the last value. Actual reads track global-name presence/value separately from whole-object reads; ordinary dictionary content access remains conservative. No explicit source-list parameter is required. Unrelated global updates reuse valid compute results, while direct output overwrites still trigger repair. ui.stats().bindingEvaluations counts computations in completed refreshes over this VM lifetime, including initial and hidden bindings. Hidden retained Blocks update at successful input/event stabilization boundaries before all windows publish. Computes are read-only for existing objects and may allocate results. They have 100000 instructions/128 frames each, 128 active bindings per Block and a 10000-computation refresh limit that errors rather than truncates propagation. Binding failure stops the live VM and preserves published pictures. Non-Block targets, explicit dependency lists and general type/effect checking remain pending. See examples/property-bindings.endor. - -Native input POST /events is a transport envelope, not Endor source. Each frame and input carries an opaque runtime window identity; omitted input window defaults to 1 for existing clients. GET /code exposes X-Tessera-Window. A window/session/layout identity mismatch is rejected before invoking code. Tab actions use kind tab-activate/tab-close/tab-detach/tab-drop and the mount wire field carries the opaque tab ID from frame.tabs; point/press_point must be omitted. tab-drop additionally requires tab_drop={{window,layout_version,index}}, where index is a published destination gap from 0 through its tab count (at most 64). Both source and destination orders must still match their published frames. tab-detach alone accepts optional screen_position=[x,y] as signed integer physical desktop coordinates within +/-1000000; other kinds must omit it. All spatial/tab actions validate session and layout_version before dispatch. Resource window-close uses mount=0 and no points; it validates the live window/session rather than its old layout version. The window -sends current session, layout_version and Mount identity; stale positions fail without calling a callback. Pointer clicks also include point and press_point [x,y] in logical page coordinates. Both targets are resolved through read-only hitTest, with fallback behind a rejected candidate; a click is dispatched only when both resolve to the same Mount. Keyboard/code clicks omit coordinates and remain semantic activation. GET /code also exposes X-Tessera-Session and X-Tessera-Layout-Version headers for transport clients. Native mouse, Tab, Enter and Space delegate to the same -managed click/focus methods used by code. Events appear in Network, not Console source history. - -ScrollBlock.axis is a managed visual attribute: "both" (default), "vertical" or "horizontal". The default preserves two-axis unconstrained content. Vertical restricts content width to the actual occurrence viewport minus padding; horizontal restricts height. Source-arranged viewport dimensions are used in subsequent measurement. The disabled scroll axis has zero offset/limit and is clamped after updates. Composition remains vertical; a Row child can arrange horizontal content. See examples/scroll-axis.endor. - -ContainerBlock.mainAlign accepts "start" (default), "center", "end" or "space-between". Native arrangement uses allocated Bounds after padding/minimum gap, distributes positive remaining main-axis space and keeps child sizes unchanged. Overflow keeps start placement. Explicit source arrange placements remain authoritative; this is alignment, not flex sizing. - -Block.flex is a nonnegative weight (default 0). Native containers reserve fixed measured children, padding and gaps, then share remaining allocated main-axis space between positive weights with zero basis. Actual child allocation feeds remeasurement. It does not request parent expansion. Further controls are described below. - - -Flex main-axis policy: flexBasis is "auto" by default (zero basis when flex > 0, otherwise the first measured size of this occurrence in the current solve); a number supplies an explicit basis. flexMin defaults to 0, flexMax to 1000000, and flexShrink to 0. All numbers are finite/nonnegative and at most 1000000; flexMin must not exceed flexMax. The parent clamps bases to min/max, distributes positive space by flex, and redistributes space left after a maximum is reached. Deficits shrink by flexShrink times the original basis, freezing at minima. A fixed child can opt into shrink without growth. Unresolvable minimum overflow is preserved, and remaining positive space follows mainAlign. The policy applies to width in a Row and height in Column-like containers; these are not independent width/height bounds. Source arrange overrides remain authoritative. Preferred sizes are derived per occurrence and reset for each new solve, never shared through Block state. See examples/flex-constraints.endor. Layout continuation remains unfinished. - - -When an occurrence's cross-axis constraint changes during settling, its preferred main-axis size is invalidated and remeasured. Any obsolete native flex allocation on that axis is removed for the replacement measurement, so narrowing a nested Column can increase wrapped text height. Source arrange allocations remain explicit. Preferred-size presence and values participate in convergence/cycle detection. - - -The source layout driver now uses Aster's resumable read-only call API with a soft quantum of 256 instructions. Pauses retain the same VM frames, operand/local roots, return contracts and read tracking; total instruction/frame limits still apply across slices. ui.stats().layoutSlices counts slices in the most recent solve (native fast path: 1). The worker currently resumes Pending immediately after yielding its OS time slice. Parking the solve across worker turns, scheduling other work and handling newer inputs before completion are not implemented yet. Native callbacks are not preempted. Only the completed settled frame is published. - - -source_layout::start now returns either Complete(Draft) or Pending(Work). The host-owned Work retains the unpublished frame, derived runtime/cache snapshots, convergence plan and prepare root without borrowing caller frame/cache variables. Work::resume consumes the saved job and advances the same VM continuation; Work::cancel closes that continuation and releases the prepare root without merging a draft. A second start is rejected while any read-only execution is active. Every pending Work must be resumed or explicitly cancelled with its owning VM; dropping it is not cancellation. Synchronous ReplSession operations drain the same saved refresh state. The worker parks refresh jobs; cancellation restores still-pending Mount preparation. - - -UiBridge::begin_layout_frame/resume_layout_frame now expose PendingLayout around the source job. This bridge-level continuation also owns its target window, page snapshot, tab projection, viewport restoration and retained lifecycle hook root. Only the Complete branch projects scroll/focus, captures dependency versions, merges derived caches, commits Mount geometry and runs deferred hooks. Resume restores the saved window context, and failure releases hook roots. ReplSession uses this protocol through its saved refresh state. Viewport refresh supports cross-worker-turn scheduling and prepared-Mount rollback; callers must not drop a pending bridge layout as a substitute for cancellation. - - -Viewport refresh now advances through ReplSession's saved refresh state on worker idle turns. A turn advances one traversal stage or a 256-instruction layout quantum; a pending job shortens the next worker wait to 1 ms. Tasks/window commands and document publication wait for complete refresh. ui.stats().refreshYields reports cooperative idle returns. Mutating requests wait across worker turns for the existing job, so they cannot run inside its read-only scope. Mutating requests are serialized; newer viewport targets coalesce by cancelling unpublished layout. Initial source execution remains atomic; initial layout parks across worker turns. Explicit REPL/event refresh parks across worker turns. Idle tasks deliver at most one callback per turn and park subsequent layout; callbacks and individual native nodes remain atomic. Large intrinsic trees use the sliced driver; small intrinsic trees keep the fast path. - - -Runtime mutation entry points reject direct calls while refresh work is pending, before executing source or changing metadata; task polling defers. The API worker drains the saved refresh before accepting those mutations, keeping the runtime usable. Incremental refresh keeps the prior complete document map until a stable pass, including across window traversal boundaries where no VM read-only frame is currently active. - - -New viewport targets now coalesce pending refresh work. The runtime cancels only an unfinished bridge layout, restores its Mount checkpoint, preserves completed per-window drafts/callback effects and restarts traversal with the latest viewport map. refreshRestarts counts target replacements; layoutCancels counts replacements that actually closed a pending VM layout. Unchanged targets resume the same job. A new external target resets its convergence budget; internal settling does not. Ordinary source/event mutations still serialize behind refresh rather than roll back application effects. - - -Cancellation checkpoints own explicit VM roots until restored or released. Initial presentations may have no mount dictionary; cancellation restores that absence too. Saved metadata values retain object-valued attributes. A pending refresh can restart while keeping already completed window drafts; no onMount/onUnmount callback is replayed merely because the viewport target changed. Mount-owned tasks are cancelled immediately before an actual onUnmount callback, never during abandoned preparation. - -REPL/event requests retain their HTTP request and scalar result until the complete window set settles. The worker advances one stage/quantum per turn, takes current native viewport targets each time and keeps later mutations queued. GET /code keeps returning the last complete publication. Response metadata and native commands publish together after settling; no source/event callback is replayed. A running input can finish before a newer queued PUT, preserving a consistent old runtime if that candidate fails; DELETE revokes the owner and prevents its late publication. Source execution/callbacks and structural preparation and final command projection remain atomic; source paint execution can yield. - -Complete-source PUT also parks an isolated candidate VM during initial layout. The worker retains its HTTP request and the prior live VM, advances one candidate stage/quantum per turn and commits only after its full window set settles. New PUT/DELETE submissions discard obsolete candidates and explicitly cancel their pending layout. Failure keeps the prior live owner; accepting a replacement alone does not revoke that owner. Candidate viewport updates use only the surviving primary shell; secondary native resource IDs and sizes still belong to the old VM until successful publication. Parsing, compilation, source execution, structural preparation and final command projection remain atomic; source paint execution can yield. - -Source paint now uses a resumable read-only VM call with the same 256-instruction quantum and a cumulative 1,000,000-instruction budget. The pending job owns the unpublished frame, visual snapshot, decoded command buffer and prepare root. The refresh worker resumes paint before advancing to another window and publishes only the full stable window set. New viewport targets cancel unfinished paint, discard partial commands and force that window to refresh. Already completed layout/Mount geometry and lifecycle callbacks remain committed; cancelling paint does not replay or undo them. Failure/discard explicitly closes the read-only continuation and releases its roots. paintSlices reports the most recently completed paint solve (1 when the solve completes within its first quantum); paintCancels counts viewport replacements that closed pending paint. Layout checkpoint rollback remains limited to unfinished layout preparation. Native callback bodies, driver preparation, command decoding/projection and rasterization remain atomic. - -Layout and paint drivers now prepare one occurrence at a time. Their fixed-size outer bytecode repeats on the same VM frame; a short-lived helper retains only the current call, its bounds/arguments and collector. The helper is released before the next occurrence. Layout keeps bottom-up measure, top-down arrange and the same settling loop. Paint advances even intrinsic method classification through VM quanta. The weak occurrence list and native scratch/output data remain proportional to the actual tree, but preparation no longer allocates managed callables/Bounds for the whole tree before the first slice. All helpers share the original read-only scope and cumulative instruction/frame limits. Weak-list copying, structural extraction, settling and final projection remain bounded atomic native work. - -ui.stats().dependencyInvalidations counts observed layout/paint dependency mismatches. lastInvalidDependency records the phase, object kind/identity and old/current versions without rendering its fields or retaining it. An input changing its globals is an expected invalidation; compare idle document versions before diagnosing a refresh loop. - -ui.readText(owner,key,path[,callback]) returns an owner-scoped Task and reads a regular UTF-8 file off the VM thread. Paths use native filesystem syntax; absolute paths are recommended. The optional callback takes zero arguments and must return Unit; it runs only on successful read and can use task.result(). Without a callback, poll status then read result from code. result() is available while a success callback is running or after completion (timers return Unit); error() returns an empty String until failed, then the provider/callback error. A provider failure marks the task failed and skips its callback without stopping the VM; a source callback failure still stops the shared domain. Owner/key replacement, explicit cancellation, close/reset/VM discard and GC drop the provider lease, remove queued completion and reject late delivery. In-progress OS reads may finish, but cannot deliver after cancellation. Only the owning VM worker updates managed results and rechecks owner/key identity. No VM handles enter provider threads beyond opaque weak identities. - -The lazy shared pool has four threads and a 64-job queue. Each runtime permits 16 outstanding I/O requests (one temporary replacement reservation), and each read is capped at 1 MiB by metadata plus actual streaming length; non-regular files, non-UTF-8 data and over-limit content fail rather than truncate. These limits do not constitute a full filesystem sandbox. The provider returns text in the same managed Task, not a second application-state store. Task await is supported through ui.spawn; see the await contract below. See examples/async-file.endor; from REPL call reader.read("/absolute/path.txt") to update its actual TextBlock, even when that object is hidden but retained. - -ui.fetchText(owner,key,url[,callback]) performs an asynchronous HTTP(S) GET using the same Task, owner/key replacement and success-only callback protocol as readText. Example: fetcher.fetch("http://127.0.0.1:4737/agent") after loading examples/async-network.endor. The example retains the complete result in fetcher.response and displays completion status; it does not put arbitrary network content into a TextBlock, whose own 16 KiB limit is independent of the provider limit. Only 2xx responses produce a UTF-8 result; HTTP failures, invalid UTF-8, oversize bodies and transport errors set status() to failed with error(). It verifies HTTPS certificates, follows at most five HTTP(S) redirects, has a 10-second connect and 30-second total transfer timeout (starting when a worker takes the request), and bounds decoded text to 1 MiB without truncation. URLs are limited to 8192 bytes and must not contain credentials or control characters. This GET API does not accept request bodies, custom headers or authentication. - -The current transport uses a separate system curl process per request (/usr/bin/curl on Unix, curl.exe on Windows); missing or incompatible curl fails the Task. User curlrc and URL globbing are disabled; URL data is passed through escaped stdin configuration rather than command arguments. TLS and normal system proxy behavior remain enabled. Cancellation kills an active HTTP process immediately and the provider worker reaps it; the VM thread never waits for the process. All file and HTTP requests share the per-runtime 16-task limit and global four-worker/64-job queue. In-progress file reads remain cooperatively cancelled at delivery. Network access is not a security sandbox, and this is a text GET provider rather than a general HTTP client. Task await is supported through ui.spawn; see below. - -ui.spawn(owner,key,function) creates an owner-scoped cooperative Task for a zero-argument function; its returned value is retained as the Task result. Each owning VM poll resumes at most a 256-instruction quantum per selected Task, rotates runnable tasks fairly, and retains source frames in an Aster-managed coroutine edge. Calls and writes may interleave between quanta; prior writes are not rolled back on cancellation or error. Pending/running Task replacement, owner closure, GC and reset release parked frames through the same managed graph. Native calls remain atomic and all quanta share the 128-frame/100000-instruction callback limit; a native entry may run during preparation. result() is unavailable until spawn completes. A source error still stops the shared runtime. See examples/cooperative-tasks.endor. Endor await expressions now suspend these calls on a Task without replaying the operand. See the await contract below. - -Inside a function run by ui.spawn, `value = await task` waits for any timer/file/network/spawn Task and resumes at the same expression with its retained result. A source function may return a Number, String, managed Object or Unit; normal source annotations still apply. Task.result() and await share the result. `ui.sleep(owner,key,milliseconds)` creates a callback-free timer Task returning Unit. Plain calls/REPL can await an already completed Task; a pending await requires a coroutine and fails explicitly rather than blocking. There is no new async function declaration: ui.spawn explicitly schedules an ordinary Endor function. See examples/await-file.endor. - -A waiting coroutine retains its evaluated operand and frames as managed edges; waiting performs no VM execution or active polling. Source side effects and IO initiation before await are not replayed. When the dependency completes, the owning VM worker resumes the waiter through the existing fair quantum scheduler. Cancelling/replacing/closing the waiter releases its frames and operand; it does not automatically cancel an independently owned or shared dependency. Dependency cancellation propagates cancellation to its waiters without running the remainder. An unhandled dependency failure becomes a source await error (with the original provider message), fails the waiter and follows the existing shared-runtime fault rule; provider failure alone still only fails its own Task. Self-await and cyclic Task waits fail explicitly. Read-only layout/paint cannot await. Waiting state is reported as running, while active() remains true; result() is unavailable until completion. These APIs do not introduce try/finally or change the existing exception model. - - - - -## UI host object - -Positional arguments only: - -- `ui.text(value, size = 1, color = "#242422")`; integer size 1..8. -- `ui.rect(width, height, radius = 0, color = "#DCE8FF")`. -- `ui.column(padding, gap, child...)` and `ui.row(padding, gap, child...)`. -- `ui.spacer(size)`. -- `ui.page()` creates a page; `page.render(root)` mounts its root; `page.unmount()` releases the mount. The last render - in a successful submission selects the page shown in this namespace. - -Defaults above describe the host API; do not write keyword arguments in Endor. -Colors use #RRGGBB or #RRGGBBAA. Dimensions are finite numbers in 0..1000000. -Use newlines between complete top-level statements. Multiline calls, function -bodies and expression continuations are passed to Aster as complete inputs. -Limits: 1 MiB source, 4096 constructed nodes, 64 pages, 64 UI levels, 10000 expanded -layout nodes, 16 KiB per text, 128 VM frames. Script statements each receive an -equal share of a total 1000000-instruction allowance. Older module initialization -and main execution each retain their own 1000000-instruction allowance. - -PUT waits for its worker result. Errors return 422 with diagnostics and preserve -the last successful revision. A newer accepted PUT or DELETE supersedes older -work (409); a full eight-entry queue returns 503. DELETE immediately clears the -document. GET remains available while Aster executes. The window redraws from an -owned snapshot; resize does not rerun Endor. Text uses system fonts, antialiasing -and font fallback; Chinese needs an installed CJK font. Size 1 is 14 logical pixels -with 20-pixel line height; larger sizes multiply both. Native Block methods include -setText/setColor, container append/insert/remove/replace/move, Page open/close/navigation, -and code-driven button.onClick/click. ui.state and ui.textOf provide explicit text -binding. Mouse routing, tab chrome/multiple windows and incremental layout remain -in progress; process isolation is future work. Console retains objects -only in its explicitly resettable in-memory VM. It is not an untrusted-code sandbox. -"##, - name = namespace.name, - revision = namespace.revision, - address = API_ADDRESS, - example = include_str!("../examples/page.endor"), - ) -} - -fn compile_error(error: anyhow::Error) -> Response>> { - let mut diagnostic = - serde_json::json!({ "severity": "error", "message": format!("{error:#}") }); - if let Some(source) = error.downcast_ref::() { - diagnostic["line"] = source.location.line.into(); - diagnostic["column"] = source.location.column.into(); - diagnostic["start_byte"] = source.diagnostic.span.start.into(); - diagnostic["end_byte"] = source.diagnostic.span.end.into(); - } - json( - StatusCode(422), - serde_json::json!({ - "ok": false, - "diagnostics": [diagnostic] - }), - ) -} - -fn error_response( - status: StatusCode, - message: impl Into, -) -> Response>> { - json(status, serde_json::json!({ "error": message.into() })) -} - -fn json(status: StatusCode, value: serde_json::Value) -> Response>> { - text( - status, - &value.to_string(), - "application/json; charset=utf-8", - ) -} - -fn markdown(status: StatusCode, value: String) -> Response>> { - text(status, &value, "text/markdown; charset=utf-8") -} - -fn text(status: StatusCode, value: &str, content_type: &str) -> Response>> { - let mut response = Response::from_string(value).with_status_code(status); - add_header(&mut response, "Content-Type", content_type); - add_header( - &mut response, - "Access-Control-Allow-Origin", - "http://127.0.0.1", - ); - add_header( - &mut response, - "Access-Control-Allow-Methods", - "GET, PUT, POST, DELETE, OPTIONS", - ); - add_header( - &mut response, - "Access-Control-Allow-Headers", - "Content-Type, X-Tessera-Origin", - ); - response -} - -fn add_header(response: &mut Response>>, name: &str, value: &str) { - if let Ok(header) = Header::from_bytes(name, value) { - response.add_header(header); - } -} - -#[cfg(test)] -mod tests; diff --git a/src/bridge/mod.rs b/src/bridge/mod.rs new file mode 100644 index 0000000..00773de --- /dev/null +++ b/src/bridge/mod.rs @@ -0,0 +1,2 @@ +// Module ownership follows the directory; crate-root aliases keep internal call sites stable. +pub(crate) mod ui_bridge; diff --git a/src/ui_bridge.rs b/src/bridge/ui_bridge.rs similarity index 52% rename from src/ui_bridge.rs rename to src/bridge/ui_bridge.rs index 8b33785..b7bb371 100644 --- a/src/ui_bridge.rs +++ b/src/bridge/ui_bridge.rs @@ -1,5 +1,12 @@ //! Tessera's native visual protocols over Aster's managed object graph. +mod bootstrap; +mod calls; mod developer_pages; +mod signatures; +mod tree; + +pub use bootstrap::install; +use signatures::{method_signature, operation_signature}; use std::sync::{Arc, Mutex}; use anyhow::{Result, bail}; @@ -59,165 +66,7 @@ pub const QUERY_NAMES: &[&str] = &[ "supports", ]; -#[derive(Debug, Clone, PartialEq)] -pub struct UiNode { - pub flex: f32, - pub flex_policy: crate::layout::FlexPolicy, - pub main_align: crate::layout::MainAlign, - pub block_id: String, - pub protocol_key: String, - pub slots: Vec, - pub interactive: bool, - pub kind: UiKind, -} - -impl UiNode { - pub fn children(&self) -> &[Arc] { - match &self.kind { - UiKind::Column { children, .. } - | UiKind::Row { children, .. } - | UiKind::Button { children, .. } - | UiKind::Scroll { children, .. } => children, - _ => &[], - } - } - - pub fn has_flex(&self) -> bool { - self.flex > 0.0 || self.flex_policy != crate::layout::FlexPolicy::default() - } - - pub fn shallow_eq(&self, other: &Self) -> bool { - if self.flex_policy != other.flex_policy - || self.flex != other.flex - || self.main_align != other.main_align - || self.block_id != other.block_id - || self.protocol_key != other.protocol_key - || self.slots != other.slots - || self.interactive != other.interactive - { - return false; - } - let same_children = || { - self.children().len() == other.children().len() - && self - .children() - .iter() - .zip(other.children()) - .all(|(a, b)| Arc::ptr_eq(a, b)) - }; - match (&self.kind, &other.kind) { - ( - UiKind::Text { - value: a, - size: sa, - color: ca, - }, - UiKind::Text { - value: b, - size: sb, - color: cb, - }, - ) => a == b && sa == sb && ca == cb, - ( - UiKind::Rect { - width: a, - height: b, - radius: c, - color: d, - }, - UiKind::Rect { - width: e, - height: f, - radius: g, - color: h, - }, - ) => (a, b, c, d) == (e, f, g, h), - (UiKind::Spacer(a), UiKind::Spacer(b)) => a == b, - ( - UiKind::Scroll { - axis: aa, - width: a, - height: b, - padding: pa, - gap: ga, - children: _, - }, - UiKind::Scroll { - axis: ab, - width: c, - height: d, - padding: pb, - gap: gb, - children: _, - }, - ) => aa == ab && a == c && b == d && pa == pb && ga == gb && same_children(), - ( - UiKind::Column { - padding: a, gap: b, .. - }, - UiKind::Column { - padding: c, gap: d, .. - }, - ) - | ( - UiKind::Row { - padding: a, gap: b, .. - }, - UiKind::Row { - padding: c, gap: d, .. - }, - ) - | ( - UiKind::Button { - padding: a, gap: b, .. - }, - UiKind::Button { - padding: c, gap: d, .. - }, - ) => a == c && b == d && same_children(), - _ => false, - } - } -} - -#[derive(Debug, Clone, PartialEq)] -pub enum UiKind { - Button { - padding: f32, - gap: f32, - children: Vec>, - }, - Text { - value: String, - size: u32, - color: String, - }, - Rect { - width: f32, - height: f32, - radius: f32, - color: String, - }, - Column { - padding: f32, - gap: f32, - children: Vec>, - }, - Row { - padding: f32, - gap: f32, - children: Vec>, - }, - Spacer(f32), - Scroll { - axis: crate::scroll::Axis, - width: f32, - height: f32, - padding: f32, - gap: f32, - children: Vec>, - }, -} +pub use tree::{UiKind, UiNode}; #[derive(Debug)] struct Session { @@ -351,166 +200,6 @@ impl Operation { } } -fn method_signature( - store: &endor_core::ObjectStore, - desktop: ObjectRef, - receiver: ObjectRef, - operation: Operation, -) -> Result { - use AnnotationType::{Any, Function, Nominal, Number, String as TextType, Unit}; - use Operation::*; - let nominal = |name| desktop_type(store, name, desktop).map(Nominal); - let (arguments, returns, effect) = match operation { - Bind => ( - vec![TextType, Function], - nominal("Subscription")?, - CallEffect::Mutating, - ), - Mounts => (vec![], nominal("ObjectList")?, CallEffect::ReadOnly), - Children => (vec![], Any, CallEffect::ReadOnly), - Invalidate => (vec![TextType], Unit, CallEffect::Mutating), - SetText | SetColor => (vec![TextType], Unit, CallEffect::Mutating), - Append => (vec![nominal("Block")?], Number, CallEffect::Mutating), - Insert => ( - vec![Number, nominal("Block")?], - Number, - CallEffect::Mutating, - ), - Remove => (vec![Number], Unit, CallEffect::Mutating), - Replace => (vec![Number, nominal("Block")?], Unit, CallEffect::Mutating), - Move => (vec![Number, Number], Unit, CallEffect::Mutating), - Render | Navigate => (vec![nominal("Block")?], Unit, CallEffect::Mutating), - OnClick => (vec![Function], Nominal(receiver), CallEffect::Mutating), - OnEvent => (vec![nominal("Event")?], Unit, CallEffect::External), - OnMount | OnUnmount => (vec![nominal("Mount")?], Unit, CallEffect::External), - Click => ( - vec![nominal("Mount")?], - nominal("Event")?, - CallEffect::External, - ), - Dispatch => ( - vec![nominal("Event")?, nominal("Mount")?], - nominal("Event")?, - CallEffect::External, - ), - MoveWindow => ( - vec![nominal("Window")?], - Nominal(receiver), - CallEffect::Mutating, - ), - MoveTab => (vec![Number], Nominal(receiver), CallEffect::Mutating), - Activate => (vec![], Nominal(receiver), CallEffect::Mutating), - Detach => (vec![], nominal("Window")?, CallEffect::Mutating), - Unmount | Close | CloseWindow | Back | Forward => (vec![], Unit, CallEffect::Mutating), - Open => (vec![Any], Nominal(receiver), CallEffect::Mutating), - PageWindow => (vec![], Any, CallEffect::ReadOnly), - On => ( - vec![TextType, Function, Any], - nominal("Subscription")?, - CallEffect::Mutating, - ), - _ => return Err(invalid("unsupported native method signature")), - }; - let arity = operation.arity(); - let mut parameters = vec![Nominal(receiver)]; - parameters.extend(arguments); - let mut signature = CallSignature { - arity: endor_core::CallArity::range(arity.required + 1, arity.maximum + 1), - parameters: parameters.into_iter().map(CallType::from).collect(), - variadic: None, - returns: returns.into(), - effect, - }; - match operation { - 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) -} - -fn operation_signature( - store: &endor_core::ObjectStore, - desktop: ObjectRef, - operation: Operation, -) -> Result { - use AnnotationType::{Any, Bool, Number, String as TextType, Trait, Type, Unit}; - use Operation::*; - let nominal = |name| desktop_type(store, name, desktop).map(AnnotationType::Nominal); - let (parameters, variadic, returns, effect) = match operation { - IsInstance => (vec![Any, Type], None, Bool, CallEffect::ReadOnly), - IsSubtype => (vec![Type, Type], None, Bool, CallEffect::ReadOnly), - Implement => (vec![Type, Trait], None, Unit, CallEffect::Mutating), - Implements => (vec![Type, Trait], None, Bool, CallEffect::ReadOnly), - Supports => (vec![Any, Trait], None, Bool, CallEffect::ReadOnly), - OpenValue => (vec![Any], None, nominal("Page")?, CallEffect::Mutating), - Present => (vec![Any], None, nominal("Block")?, CallEffect::Delegated), - State => (vec![Any], None, Any, CallEffect::ReadOnly), - StateSet => (vec![Any], None, Unit, CallEffect::Mutating), - Increment => (vec![], None, Unit, CallEffect::Mutating), - Stats => (vec![], None, Any, CallEffect::External), - Viewport => ( - vec![], - None, - nominal("MeasureResult")?, - CallEffect::ReadOnly, - ), - Window => (vec![], None, nominal("Window")?, CallEffect::ReadOnly), - NewWindow => (vec![], None, nominal("Window")?, CallEffect::Mutating), - TextOf => ( - vec![Any, TextType, Number, TextType], - None, - nominal("TextBlock")?, - CallEffect::ReadOnly, - ), - Text => ( - vec![TextType, Number, TextType], - None, - nominal("TextBlock")?, - CallEffect::ReadOnly, - ), - Rect => ( - vec![Number, Number, Number, TextType], - None, - nominal("RectBlock")?, - CallEffect::ReadOnly, - ), - Spacer => (vec![Number], None, nominal("Spacer")?, CallEffect::ReadOnly), - Scroll => ( - vec![Number, Number, nominal("Block")?], - None, - nominal("ScrollBlock")?, - CallEffect::ReadOnly, - ), - Row | Column => ( - vec![Number, Number], - Some(nominal("Block")?), - nominal(if matches!(operation, Row) { - "Row" - } else { - "Column" - })?, - CallEffect::ReadOnly, - ), - Button => ( - vec![TextType], - None, - nominal("Button")?, - CallEffect::ReadOnly, - ), - Page => (vec![], None, nominal("Page")?, CallEffect::ReadOnly), - _ => return Err(invalid("unsupported UI function signature")), - }; - Ok(CallSignature { - arity: operation.arity(), - parameters: parameters.into_iter().map(CallType::from).collect(), - variadic: variadic.map(CallType::from), - returns: returns.into(), - effect, - }) -} - #[derive(Debug)] struct Function { signature: Option, @@ -962,793 +651,6 @@ fn present_value( ) } -impl NativeCallable for Function { - fn signature(&self) -> Option { - self.signature.clone() - } - fn arity(&self) -> Option { - Some(self.operation.arity()) - } - - fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { - use Operation::*; - let arity = self.operation.arity(); - let (minimum, maximum) = (arity.required, arity.maximum); - if !(minimum..=maximum).contains(&args.len()) { - return Err(invalid("incorrect UI method argument count")); - } - if matches!(self.operation, Implement | Implements | Supports) { - let capability = object(args.get(1))?; - let value = match self.operation { - Implement => { - cx.implement_trait(object(args.first())?, capability)?; - Value::Nil - } - Implements => Value::Bool( - cx.store() - .implements_trait(object(args.first())?, capability)?, - ), - Supports => { - // Validate the contract even for immediate scalar values. - cx.store() - .payload::(capability, "Trait")?; - Value::Bool(match args[0] { - Value::Object(value) => cx.store().supports(value, capability)?, - _ => false, - }) - } - _ => unreachable!(), - }; - return Ok(NativeResult::Return(value)); - } - if matches!(self.operation, IsInstance | IsSubtype) { - let candidate = object(args.first())?; - let expected = object(args.get(1))?; - let value = if matches!(self.operation, IsInstance) { - cx.store().is_instance(candidate, expected)? - } else { - cx.store().is_subtype(candidate, expected)? - }; - return Ok(NativeResult::Return(Value::Bool(value))); - } - if matches!(self.operation, Invalidate) { - if cx.store().is_read_only() { - return Err(invalid( - "invalidation is forbidden during read-only rendering", - )); - } - 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", - )); - } - let owner = self.owner.ok_or(invalid("invalidation requires a Block"))?; - let (data, _) = block::resolve(cx.store(), owner)?; - let mut session = self - .session - .lock() - .map_err(|_| invalid("UI session poisoned"))?; - let mut affected = std::collections::HashSet::new(); - for weak in &session.nodes { - if let Some(block) = cx.upgrade(*weak)? - && block::resolve(cx.store(), block)?.0 == data - { - affected.insert(block); - } - } - for presentation in session.presentations.values_mut() { - presentation.invalidate(cx, &affected, &reason)?; - } - return Ok(NativeResult::Return(Value::Nil)); - } - if matches!(self.operation, Mounts) { - let owner = self.owner.ok_or(invalid("mount query requires a Block"))?; - let session = self - .session - .lock() - .map_err(|_| invalid("UI session poisoned"))?; - let mut positions = Vec::new(); - for (window, presentation) in &session.presentations { - for (id, weak) in &presentation.mounts.active { - if let Some(object) = cx.upgrade(*weak)? - && cx - .store() - .payload::(object, "Mount")? - .block - == owner - { - positions.push((*window, *id, object)); - } - } - } - positions.sort_unstable_by_key(|(window, id, _)| (*window, *id)); - let set = crate::mount::collection( - cx, - positions.into_iter().map(|(_, _, object)| object).collect(), - session.desktop, - )?; - return Ok(NativeResult::Return(Value::Object(set))); - } - if matches!(self.operation, NewWindow) { - let mut state = self - .session - .lock() - .map_err(|_| invalid("UI session poisoned"))?; - let definition = self - .requested_type - .ok_or(invalid("Window constructor requires a type"))?; - let window = create_window(cx, &mut state, definition)?; - return Ok(NativeResult::Return(Value::Object(window))); - } - if matches!(self.operation, CloseWindow) { - let window = self - .owner - .ok_or(invalid("window close requires a receiver"))?; - let id = cx - .store() - .payload::(window, "Window")? - .id; - crate::window::close(cx, window)?; - let mut state = self - .session - .lock() - .map_err(|_| invalid("UI session poisoned"))?; - if let Some(view) = state.presentations.get_mut(&id) { - cx.set_attr(view.desktop, "pending", Value::Nil)?; - view.pending = Some(None); - view.force_frame = true; - view.closing = true; - } - return Ok(NativeResult::Return(Value::Nil)); - } - if matches!(self.operation, Window) { - let desktop = self - .session - .lock() - .map_err(|_| invalid("UI session poisoned"))? - .desktop; - return Ok(NativeResult::Return( - cx.store().get_attr(desktop, "window")?, - )); - } - if matches!(self.operation, Viewport) { - let desktop = self - .session - .lock() - .map_err(|_| invalid("UI session poisoned"))? - .desktop; - let Value::Object(input) = cx.store().get_attr(desktop, "viewport")? else { - return Err(invalid("window viewport is unavailable")); - }; - let width = cx.store().get_attr(input, "width")?; - let height = cx.store().get_attr(input, "height")?; - return Ok(NativeResult::Return(Value::Object( - crate::native_visual::record( - cx, - desktop, - "MeasureResult", - vec![("width", width), ("height", height)], - )?, - ))); - } - if matches!(self.operation, Stats) { - if cx.store().is_read_only() { - return Err(invalid( - "runtime statistics are unavailable during read-only rendering", - )); - } - let session = self - .session - .lock() - .map_err(|_| invalid("UI session poisoned"))?; - let mut blocks = 0_i64; - let mut pages = 0_i64; - for weak in &session.nodes { - if let Some(object) = cx.upgrade(*weak)? { - blocks += 1; - if cx.store().payload::(object, "Block")?.page { - pages += 1; - } - } - } - let objects = cx.store().object_count() as i64; - let tabs = cx.store().dict_items(session.tabs)?.len() as i64; - let stats = cx.new_object()?; - for (name, value) in [ - ("objects", objects), - ("blocks", blocks), - ("pages", pages), - ("tabs", tabs), - ("mounts", session.mounts.active.len() as i64), - ("snapshotVisited", session.snapshot_visited as i64), - ("snapshotReused", session.snapshot_reused as i64), - ("measured", session.layout.measured as i64), - ("sourceMeasureHits", session.measure_cache.hits as i64), - ("sourceMeasureMisses", session.measure_cache.misses as i64), - ("arranged", session.layout.arranged as i64), - ("layoutPasses", session.layout.settle_passes as i64), - ("layoutSlices", session.layout.slices as i64), - ("refreshYields", session.refresh_yields as i64), - ("refreshRestarts", session.refresh_restarts as i64), - ("layoutCancels", session.layout_cancels as i64), - ("paintCancels", session.paint_cancels as i64), - ("paintSlices", session.paint_slices as i64), - ("bindingEvaluations", session.binding_evaluations as i64), - ( - "dependencyInvalidations", - session.dependency_invalidations as i64, - ), - ] { - cx.set_attr(stats, name, Value::Number(value.into()))?; - } - cx.set_attr( - stats, - "lastInvalidDependency", - Value::Text(session.last_invalid_dependency.clone()), - )?; - cx.set_attr( - stats, - "windowDependencyDiagnostics", - Value::Text( - session - .presentations - .iter() - .filter(|(_, view)| !view.last_invalid_dependency.is_empty()) - .map(|(id, view)| format!("{id}: {}", view.last_invalid_dependency)) - .collect::>() - .join("; "), - ), - )?; - return Ok(NativeResult::Return(Value::Object(stats))); - } - if matches!(self.operation, State) { - let state = cx.new_object()?; - attach_named_type(cx, state, "Object", &self.session)?; - cx.set_attr(state, "value", args[0].clone())?; - bind(cx, state, "set", StateSet, &self.session)?; - bind(cx, state, "increment", Increment, &self.session)?; - return Ok(NativeResult::Return(Value::Object(state))); - } - if matches!(self.operation, StateSet | Increment) { - let owner = self.owner.ok_or(invalid("state method has no owner"))?; - let value = if matches!(self.operation, StateSet) { - args[0].clone() - } else { - let Value::Number(value) = cx.store().get_attr(owner, "value")? else { - return Err(invalid("increment requires an integer state")); - }; - let next = value - .as_i64() - .and_then(|n| n.checked_add(1)) - .ok_or(invalid("integer state increment exceeds its range"))?; - Value::Number(next.into()) - }; - cx.set_attr(owner, "value", value)?; - return Ok(NativeResult::Return(Value::Nil)); - } - if matches!(self.operation, TextOf) { - let source = object(args.first())?; - let field = text(args.get(1))?; - cx.store().get_attr(source, &field)?; - let size = args - .get(2) - .map(|v| integer(Some(v))) - .transpose()? - .unwrap_or(1); - if !(1..=8).contains(&size) { - return Err(invalid("text size must be 1 through 8")); - } - let mut data = Data::new( - Kind::Text { - value: String::new(), - size: size as u32, - color: color(args.get(3), "#242422")?, - }, - vec![], - 0.0, - 0.0, - ); - data.text_binding = Some((source, field)); - return Ok(NativeResult::Return(Value::Object(new_block( - cx, - data, - &self.session, - )?))); - } - let constructor = match self.operation { - Scroll => Some(Data::new( - Kind::Scroll { - width: dimension(args.first())?, - height: dimension(args.get(1))?, - }, - vec![object(args.get(2))?], - 0.0, - 0.0, - )), - Text => Some(Data::new( - Kind::Text { - value: text(args.first())?, - size: args - .get(1) - .map(|v| integer(Some(v))) - .transpose()? - .unwrap_or(1) - .try_into() - .map_err(|_| invalid("text size exceeds limits"))?, - color: color(args.get(2), "#242422")?, - }, - vec![], - 0.0, - 0.0, - )), - Rect => Some(Data::new( - Kind::Rect { - width: dimension(args.first())?, - height: dimension(args.get(1))?, - radius: args - .get(2) - .map(|v| dimension(Some(v))) - .transpose()? - .unwrap_or(0.0), - color: color(args.get(3), "#DCE8FF")?, - }, - vec![], - 0.0, - 0.0, - )), - Spacer => Some(Data::new( - Kind::Spacer(dimension(args.first())?), - vec![], - 0.0, - 0.0, - )), - Row | Column => Some(Data::new( - if matches!(self.operation, Row) { - Kind::Row - } else { - Kind::Column - }, - args[2..] - .iter() - .map(|v| object(Some(v))) - .collect::>()?, - dimension(args.first())?, - dimension(args.get(1))?, - )), - Page => Some(Data::new(Kind::Page, vec![], 0.0, 0.0)), - Button => { - let label = new_block( - cx, - Data::new( - Kind::Text { - value: text(args.first())?, - size: 1, - color: "#243047".into(), - }, - vec![], - 0.0, - 0.0, - ), - &self.session, - )?; - Some(Data::new(Kind::Button, vec![label], 10.0, 0.0)) - } - _ => None, - }; - if let Some(data) = constructor { - if let Kind::Text { size, value, .. } = &data.kind - && (!(1..=8).contains(size) || value.len() > 16_384) - { - return Err(invalid("text exceeds size limits")); - } - return Ok(NativeResult::Return(Value::Object(new_typed_block( - cx, - data, - &self.session, - self.requested_type, - )?))); - } - if matches!(self.operation, OpenValue | Present) - && let Value::Object(value) = args[0] - { - let (desktop, current) = { - let state = self - .session - .lock() - .map_err(|_| invalid("UI session poisoned"))?; - (state.master, state.desktop) - }; - let capability = desktop_type(cx.store(), "Presentable", desktop)?; - if !cx - .store() - .object(value)? - .payload() - .as_any() - .is::() - && cx.store().supports(value, capability)? - { - let definition = cx - .store() - .type_of(value)? - .ok_or(invalid("untyped Presentable"))?; - let function = cx - .store() - .lookup_method(definition, "present")? - .ok_or(invalid("missing present method"))?; - let window = object(Some(&cx.store().get_attr(current, "window")?))?; - let complete = cx.allocate(ContextNativeFunction::new( - "validate presentation", - PresentResult { - open: matches!(self.operation, OpenValue), - session: self.session.clone(), - }, - vec![desktop], - ))?; - let instructions = [ - endor_core::Instruction::new(endor_core::Opcode::LoadConst, 0), - endor_core::Instruction::simple(endor_core::Opcode::PushNull), - endor_core::Instruction::new(endor_core::Opcode::LoadConst, 1), - endor_core::Instruction::simple(endor_core::Opcode::PushNull), - endor_core::Instruction::new(endor_core::Opcode::LoadConst, 2), - endor_core::Instruction::new(endor_core::Opcode::LoadConst, 3), - endor_core::Instruction::new(endor_core::Opcode::Call, 2), - endor_core::Instruction::new(endor_core::Opcode::Call, 1), - endor_core::Instruction::simple(endor_core::Opcode::ReturnValue), - ]; - let code = endor_core::EndorCode::from_instructions( - &instructions, - vec![ - Value::Object(complete), - Value::Object(function), - Value::Object(value), - Value::Object(window), - ], - endor_core::CodeLayout::default(), - )? - .with_location("Presentable.present", "", 1); - let code = cx.allocate(endor_core::EndorCodeObject::new(code))?; - let globals = cx.new_dict()?; - let driver = cx.new_function("Presentable.present", code, globals)?; - attach_desktop_type(cx, driver, "Function", desktop)?; - return Ok(NativeResult::Invoke { - function: driver, - arguments: vec![], - }); - } - } - if matches!(self.operation, OpenValue) { - let root = present_value(cx, &args[0], &self.session)?; - let page = if cx.store().payload::(root, "Block")?.page { - root - } else { - new_block( - cx, - Data::new(Kind::Page, vec![root], 0.0, 0.0), - &self.session, - )? - }; - show(cx, page, &self.session)?; - return Ok(NativeResult::Return(Value::Object(page))); - } - if matches!(self.operation, Present) { - return Ok(NativeResult::Return(Value::Object(present_value( - cx, - &args[0], - &self.session, - )?))); - } - let owner = self.owner.ok_or(invalid("method has no owner"))?; - if matches!(self.operation, Bind) { - let field = text(args.first())?; - let compute = object(args.get(1))?; - return Ok(NativeResult::Return(Value::Object( - crate::binding::connect( - cx, - owner, - &field, - compute, - self.session - .lock() - .map_err(|_| invalid("UI session poisoned"))? - .master, - )?, - ))); - } - if matches!(self.operation, MoveTab) { - let position = usize::try_from(integer(args.first())?) - .map_err(|_| invalid("tab position out of range"))?; - let mut state = self - .session - .lock() - .map_err(|_| invalid("UI session poisoned"))?; - 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"))?; - let window = object(Some(&cx.store().get_attr(view.desktop, "window")?))?; - crate::window::move_page(cx, window, owner, position)?; - view.force_frame = true; - return Ok(NativeResult::Return(Value::Object(owner))); - } - let (data_ref, data) = block::resolve(cx.store(), owner)?; - let mut data = data.clone(); - let mut result = Value::Nil; - match self.operation { - SetText => match &mut data.kind { - Kind::Text { value, .. } => { - let next = text(args.first())?; - if next.len() > 16_384 { - return Err(invalid("text exceeds 16 KiB")); - } - *value = next; - data.text_binding = None; - cx.set_attr(owner, "text", args[0].clone())?; - } - _ => return Err(invalid("setText requires a text Block")), - }, - SetColor => match &mut data.kind { - Kind::Text { color: value, .. } | Kind::Rect { color: value, .. } => { - *value = color(args.first(), "#242422")?; - cx.set_attr(owner, "color", Value::Text(value.clone()))?; - } - _ => return Err(invalid("this Block has no color")), - }, - Append | Insert | Remove | Replace | Move => { - if !data.is_container() { - return Err(invalid("expected a container Block")); - } - if matches!(self.operation, Append | Insert) { - let (position, argument) = if matches!(self.operation, Insert) { - let position = usize::try_from(integer(args.first())?) - .map_err(|_| invalid("child position out of range"))?; - if position > data.children.len() { - return Err(invalid("child position out of range")); - } - (position, args.get(1)) - } else { - (data.children.len(), args.first()) - }; - let child = object(argument)?; - block::validate(cx.store(), child, Some(data_ref))?; - let slot = data.next_slot; - data.next_slot = slot - .checked_add(1) - .filter(|next| *next <= i64::MAX as u64) - .ok_or(VmError::IdentityExhausted)?; - data.children.insert(position, Child { slot, block: child }); - result = Value::Number((slot as i64).into()); - } else { - let slot = integer(args.first())?; - let index = data - .children - .iter() - .position(|v| v.slot == slot) - .ok_or(invalid("child slot does not exist"))?; - match self.operation { - Remove => { - data.children.remove(index); - } - Replace => { - let child = object(args.get(1))?; - block::validate(cx.store(), child, Some(data_ref))?; - data.children[index].block = child; - } - Move => { - let to = usize::try_from(integer(args.get(1))?) - .map_err(|_| invalid("child position out of range"))?; - if to >= data.children.len() { - return Err(invalid("child position out of range")); - } - let child = data.children.remove(index); - data.children.insert(to, child); - } - _ => unreachable!(), - } - } - } - Children => { - let result = cx.new_dict()?; - for child in data.children { - cx.dict_set( - result, - Value::Text(child.slot.to_string()), - Value::Object(child.block), - )?; - } - return Ok(NativeResult::Return(Value::Object(result))); - } - Render | Navigate => { - if !matches!(data.kind, Kind::Page) { - return Err(invalid("expected a Page")); - } - let root = object(args.first())?; - block::validate(cx.store(), root, Some(data_ref))?; - if matches!(self.operation, Navigate) { - if data.history.is_empty() { - if let Some(old) = data.children.first() { - data.history.push(old.block); - } - } else { - data.history.truncate(data.history_index + 1); - } - data.history.push(root); - data.history_index = data.history.len() - 1; - } - data.children = vec![Child { - slot: 0, - block: root, - }]; - data.next_slot = data.next_slot.max(1); - show(cx, owner, &self.session)?; - } - Back | Forward => { - if data.history.is_empty() { - return Ok(NativeResult::Return(Value::Nil)); - } - let next = if matches!(self.operation, Back) { - data.history_index.saturating_sub(1) - } else { - (data.history_index + 1).min(data.history.len() - 1) - }; - data.history_index = next; - data.children = vec![Child { - slot: 0, - block: data.history[next], - }]; - show(cx, owner, &self.session)?; - } - Detach => { - let window = { - let mut state = self - .session - .lock() - .map_err(|_| invalid("UI session poisoned"))?; - let definition = desktop_type(cx.store(), "Window", state.master)?; - create_window(cx, &mut state, definition)? - }; - show_in(cx, owner, &self.session, Some(window))?; - return Ok(NativeResult::Return(Value::Object(window))); - } - PageWindow => { - let state = self - .session - .lock() - .map_err(|_| invalid("UI session poisoned"))?; - let value = if let Some(id) = page_window(cx.store(), &state, owner)? { - cx.store() - .get_attr(state.presentations[&id].desktop, "window")? - } else { - Value::Nil - }; - return Ok(NativeResult::Return(value)); - } - Open | Activate | MoveWindow => { - if matches!(self.operation, MoveWindow) - && matches!(args.first(), None | Some(Value::Nil)) - { - return Err(invalid("Page.moveToWindow requires a Window")); - } - let target = match args.first() { - None | Some(Value::Nil) => None, - Some(value) => Some(object(Some(value))?), - }; - show_in(cx, owner, &self.session, target)?; - return Ok(NativeResult::Return(Value::Object(owner))); - } - Unmount => { - data.children.clear(); - } - Close => { - 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. - let page = cx.store().downgrade(owner)?; - for weak in state.presentations[&id].mounts.ordered() { - if let Some(mount) = cx.upgrade(weak)? - && cx - .store() - .payload::(mount, "Mount")? - .page - == Some(page) - { - crate::tasks::cancel_owner(cx, mount)?; - } - } - remove_page( - cx, - state - .presentations - .get_mut(&id) - .ok_or(invalid("missing window"))?, - owner, - )?; - } - return Ok(NativeResult::Return(Value::Nil)); - } - OnClick => { - let handler = object(args.first())?; - crate::event_dispatch::validate_handler(cx.store(), handler)?; - data.click = Some(handler); - result = Value::Object(owner); - } - On => { - let kind = text(args.first())?; - if kind.is_empty() || kind.len() > 128 { - return Err(invalid("invalid event kind")); - } - let handler = object(args.get(1))?; - crate::event_dispatch::validate_handler(cx.store(), handler)?; - let capture = match args.get(2) { - None | Some(Value::Bool(false)) => false, - Some(Value::Bool(true)) => true, - Some(Value::Text(phase)) if phase == "capture" => true, - Some(Value::Text(phase)) if phase == "bubble" => false, - _ => return Err(invalid("phase must be capture or bubble")), - }; - let mut active = Vec::new(); - for token in &data.subscriptions { - if cx - .store() - .payload::(*token, "Connection")? - .handler - .is_some() - { - active.push(*token); - } - } - if active.len() >= MAX_UI_NODES { - return Err(invalid("too many event subscriptions")); - } - let connection = cx.allocate(crate::event_dispatch::Connection { - kind, - handler: Some(handler), - capture, - })?; - let token = cx.allocate(crate::event_dispatch::Subscription { - connection: cx.store().downgrade(connection)?, - })?; - attach_named_type(cx, token, "Subscription", &self.session)?; - active.push(connection); - data.subscriptions = active; - result = Value::Object(token); - } - OnEvent | OnMount | OnUnmount => return Ok(NativeResult::Return(Value::Nil)), - Click | Dispatch => { - let desktop = self - .session - .lock() - .map_err(|_| invalid("UI session poisoned"))? - .desktop; - let (event, mount) = if matches!(self.operation, Click) { - let definition = desktop_type(cx.store(), "Event", desktop)?; - ( - crate::event_dispatch::new_event(cx, definition, "click")?, - args.first(), - ) - } else { - (object(args.first())?, args.get(1)) - }; - let mount = mount.map(|value| object(Some(value))).transpose()?; - return crate::event_dispatch::dispatch(cx, owner, event, mount, desktop); - } - _ => return Err(invalid("unsupported Block operation")), - } - cx.replace_payload(data_ref, data)?; - Ok(NativeResult::Return(result)) - } -} - #[must_use = "pending layout must be resumed on its owning VM"] pub enum LayoutProgress { Pending(Box), @@ -3017,401 +1919,6 @@ impl UiBridge { } } -pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { - let desktop = vm.new_object()?; - roots.push(desktop); - vm.set_attr(desktop, "viewport", Value::Nil)?; - let tabs = vm.new_dict()?; - roots.push(tabs); - vm.set_attr(desktop, "tabs", Value::Object(tabs))?; - let ui = vm.new_object()?; - roots.push(ui); - let session = Arc::new(Mutex::new(Session { - nodes: Vec::new(), - presentations: std::collections::BTreeMap::from([( - crate::window::MAIN_WINDOW, - crate::presentation::Presentation::new(desktop, tabs), - )]), - current: crate::window::MAIN_WINDOW, - master: desktop, - next_window: crate::window::MAIN_WINDOW, - next_mount: 0, - retired_commands: Vec::new(), - refresh_yields: 0, - refresh_restarts: 0, - layout_cancels: 0, - paint_cancels: 0, - paint_slices: 0, - binding_evaluations: 0, - exiting: false, - })); - let mut public_functions = Vec::new(); - let mut factories = Vec::new(); - for (name, operation) in [ - ("implement", Operation::Implement), - ("implements", Operation::Implements), - ("supports", Operation::Supports), - ("isInstance", Operation::IsInstance), - ("isSubtype", Operation::IsSubtype), - ("open", Operation::OpenValue), - ("text", Operation::Text), - ("rect", Operation::Rect), - ("spacer", Operation::Spacer), - ("row", Operation::Row), - ("column", Operation::Column), - ("button", Operation::Button), - ("page", Operation::Page), - ("present", Operation::Present), - ("state", Operation::State), - ("textOf", Operation::TextOf), - ("stats", Operation::Stats), - ("viewport", Operation::Viewport), - ("window", Operation::Window), - ] { - let function = vm.allocate(ContextNativeFunction::new( - name, - Function { - signature: None, - operation, - owner: None, - session: session.clone(), - requested_type: None, - }, - vec![desktop], - ))?; - roots.push(function); - vm.set_attr(ui, name, Value::Object(function))?; - public_functions.push((name, function, operation)); - } - let root_types = vm.bootstrap_types()?; - 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))?; - let mut definitions = vec![ - ("Object", root_types.object), - ("Type", root_types.type_type), - ]; - for (name, base_name, constructor_name) in [ - ("Trait", "Object", None), - ("Function", "Object", None), - ("Block", "Object", None), - ("ContainerBlock", "Block", None), - ("TextBlock", "Block", Some("text")), - ("RectBlock", "Block", Some("rect")), - ("Spacer", "Block", Some("spacer")), - ("ScrollBlock", "ContainerBlock", Some("scroll")), - ("Row", "ContainerBlock", Some("row")), - ("Column", "ContainerBlock", Some("column")), - ("Button", "ContainerBlock", Some("button")), - ("Page", "ContainerBlock", Some("page")), - ("Window", "Object", Some("window-create")), - ("Mount", "Object", None), - ("Event", "Object", Some("event")), - ("Subscription", "Object", None), - ("Task", "Object", None), - ("ObjectList", "Object", Some("object-list")), - ] { - let base = object(Some(&vm.get_attr(ui, base_name)?))?; - let constructor = match constructor_name { - Some("object-list") => { - let factory = vm.allocate(ContextNativeFunction::new( - "object-list", - crate::object_list::Constructor { - desktop, - signature: None, - }, - vec![desktop], - ))?; - roots.push(factory); - let function_type = object(Some(&vm.get_attr(ui, "Function")?))?; - vm.attach_type(factory, function_type)?; - TypeConstructor::NativeTyped(factory) - } - Some("event") => { - let factory = vm.allocate(ContextNativeFunction::new( - "event", - crate::event_dispatch::Constructor { signature: None }, - vec![], - ))?; - roots.push(factory); - let function_type = object(Some(&vm.get_attr(ui, "Function")?))?; - vm.attach_type(factory, function_type)?; - TypeConstructor::NativeTyped(factory) - } - Some(constructor_name) => { - let operation = match constructor_name { - "text" => Operation::Text, - "rect" => Operation::Rect, - "spacer" => Operation::Spacer, - "scroll" => Operation::Scroll, - "row" => Operation::Row, - "column" => Operation::Column, - "button" => Operation::Button, - "page" => Operation::Page, - "window-create" => Operation::NewWindow, - _ => return Err(invalid("unknown native type allocator").into()), - }; - let factory = vm.allocate(ContextNativeFunction::new( - constructor_name, - NativeFactory { - signature: None, - options: None, - operation, - session: session.clone(), - }, - vec![desktop], - ))?; - roots.push(factory); - let function_type = object(Some(&vm.get_attr(ui, "Function")?))?; - vm.attach_type(factory, function_type)?; - TypeConstructor::NativeTyped(factory) - } - None => TypeConstructor::Abstract, - }; - if let (Some(tag), TypeConstructor::NativeTyped(factory)) = (constructor_name, constructor) - { - factories.push((tag, factory)); - } - let definition = vm.new_type(name, base, constructor)?; - roots.push(definition); - vm.set_attr(ui, name, Value::Object(definition))?; - definitions.push((name, definition)); - } - for (name, fields) in [ - ( - "Constraints", - vec!["minWidth", "minHeight", "maxWidth", "maxHeight"], - ), - ("MeasureResult", vec!["width", "height"]), - ("Bounds", vec!["x", "y", "width", "height"]), - ("Point", vec!["x", "y"]), - ("Arrangement", vec!["children"]), - ("DisplayList", vec!["commands"]), - ("PaintCommand", vec![]), - ] { - let fields = fields - .into_iter() - .map(|name| endor_core::ClassField { - name: name.into(), - type_name: if matches!(name, "children" | "commands") { - "Object" - } else { - "Number" - } - .into(), - }) - .collect(); - let definition = vm.new_class( - name, - root_types.object, - fields, - std::collections::BTreeMap::new(), - )?; - roots.push(definition); - vm.set_attr(ui, name, Value::Object(definition))?; - definitions.push((name, definition)); - } - // The desktop owns the registry through ordinary traced attributes. The - // Rust session only borrows handles and adds no hidden lifetime roots. - let types = vm.new_dict()?; - roots.push(types); - for (name, definition) in definitions { - 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)?; - let function_type = object(Some(&vm.get_attr(ui, "Function")?))?; - vm.set_function_type(function_type)?; - vm.set_source_type_checks(true)?; - for (name, function, operation) in public_functions { - vm.replace_payload( - function, - ContextNativeFunction::new( - name, - Function { - signature: Some(operation_signature(vm.object_store(), desktop, operation)?), - operation, - owner: None, - session: session.clone(), - requested_type: None, - }, - vec![desktop], - ), - )?; - } - for (name, function) in factories { - let operation = match name { - "text" => Some(Operation::Text), - "rect" => Some(Operation::Rect), - "spacer" => Some(Operation::Spacer), - "scroll" => Some(Operation::Scroll), - "row" => Some(Operation::Row), - "column" => Some(Operation::Column), - "button" => Some(Operation::Button), - "page" => Some(Operation::Page), - "window-create" => Some(Operation::NewWindow), - "event" | "object-list" => None, - _ => return Err(invalid("unknown deferred factory").into()), - }; - if let Some(operation) = operation { - let options = operation_signature(vm.object_store(), desktop, operation)?; - let envelope = CallSignature { - arity: endor_core::CallArity::range(options.arity.required + 1, usize::MAX), - parameters: vec![AnnotationType::Type.into()], - variadic: Some(AnnotationType::Any.into()), - returns: options.returns.clone(), - effect: options.effect, - }; - let mut captures = vec![desktop]; - captures.extend(options.references()); - vm.replace_payload( - function, - ContextNativeFunction::new( - name, - NativeFactory { - signature: Some(envelope), - options: Some(options), - operation, - session: session.clone(), - }, - captures, - ), - )?; - } else { - let kind = if name == "event" { - "Event" - } else { - "ObjectList" - }; - let signature = CallSignature { - arity: endor_core::CallArity::range( - if name == "event" { 2 } else { 1 }, - usize::MAX, - ), - parameters: if name == "event" { - vec![AnnotationType::Type.into(), AnnotationType::String.into()] - } else { - vec![AnnotationType::Type.into()] - }, - variadic: Some(AnnotationType::Any.into()), - returns: AnnotationType::Nominal(desktop_type(vm.object_store(), kind, desktop)?) - .into(), - effect: CallEffect::ReadOnly, - }; - if name == "event" { - vm.replace_payload( - function, - ContextNativeFunction::new( - name, - crate::event_dispatch::Constructor { - signature: Some(signature), - }, - vec![], - ), - )?; - } else { - vm.replace_payload( - function, - ContextNativeFunction::new( - name, - crate::object_list::Constructor { - desktop, - signature: Some(signature), - }, - vec![desktop], - ), - )?; - } - } - } - for name in [ - "open", - "text", - "rect", - "spacer", - "row", - "column", - "button", - "page", - "present", - "state", - "textOf", - "stats", - "viewport", - "window", - "isInstance", - "isSubtype", - "implement", - "implements", - "supports", - ] { - let function = object(Some(&vm.get_attr(ui, name)?))?; - vm.attach_type(function, function_type)?; - } - for (type_name, name, operation) in [ - ("Block", "bind", Operation::Bind), - ("Block", "mounts", Operation::Mounts), - ("Block", "invalidate", Operation::Invalidate), - ("Block", "onClick", Operation::OnClick), - ("Block", "on", Operation::On), - ("Block", "dispatch", Operation::Dispatch), - ("Block", "onEvent", Operation::OnEvent), - ("Block", "onMount", Operation::OnMount), - ("Block", "onUnmount", Operation::OnUnmount), - ("Block", "click", Operation::Click), - ("TextBlock", "setText", Operation::SetText), - ("TextBlock", "setColor", Operation::SetColor), - ("RectBlock", "setColor", Operation::SetColor), - ("ContainerBlock", "append", Operation::Append), - ("ContainerBlock", "insert", Operation::Insert), - ("ContainerBlock", "remove", Operation::Remove), - ("ContainerBlock", "replace", Operation::Replace), - ("ContainerBlock", "move", Operation::Move), - ("ContainerBlock", "children", Operation::Children), - ("Window", "close", Operation::CloseWindow), - ("Page", "moveToWindow", Operation::MoveWindow), - ("Page", "moveTab", Operation::MoveTab), - ("Page", "detachToWindow", Operation::Detach), - ("Page", "render", Operation::Render), - ("Page", "unmount", Operation::Unmount), - ("Page", "open", Operation::Open), - ("Page", "window", Operation::PageWindow), - ("Page", "close", Operation::Close), - ("Page", "activate", Operation::Activate), - ("Page", "navigate", Operation::Navigate), - ("Page", "back", Operation::Back), - ("Page", "forward", Operation::Forward), - ] { - let expected = object(Some(&vm.get_attr(ui, type_name)?))?; - let method = vm.allocate(ContextNativeFunction::new( - name, - TypeMethod { - signature: method_signature(vm.object_store(), desktop, expected, operation)?, - operation, - expected, - session: session.clone(), - }, - vec![expected, desktop], - ))?; - roots.push(method); - vm.attach_type(method, function_type)?; - vm.define_method(expected, name, method)?; - } - crate::window::install(vm, roots, desktop, tabs)?; - let block_type = object(Some(&vm.get_attr(ui, "Block")?))?; - crate::native_visual::install(vm, roots, desktop, block_type, function_type)?; - let event_type = object(Some(&vm.get_attr(ui, "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)?; - let tasks = crate::tasks::install(vm, roots, ui, desktop)?; - developer_pages::install(vm, ui, &session)?; - let bridge = UiBridge { ui, session, tasks }; - developer_pages::install_start_page(vm, &bridge)?; - Ok(bridge) -} - fn attach_named_type( cx: &mut NativeContext<'_>, object: ObjectRef, diff --git a/src/bridge/ui_bridge/bootstrap.rs b/src/bridge/ui_bridge/bootstrap.rs new file mode 100644 index 0000000..89cc254 --- /dev/null +++ b/src/bridge/ui_bridge/bootstrap.rs @@ -0,0 +1,397 @@ +//! Allocate and connect the managed desktop, type tables and host globals. +use super::*; + +pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { + let desktop = vm.new_object()?; + roots.push(desktop); + vm.set_attr(desktop, "viewport", Value::Nil)?; + let tabs = vm.new_dict()?; + roots.push(tabs); + vm.set_attr(desktop, "tabs", Value::Object(tabs))?; + let ui = vm.new_object()?; + roots.push(ui); + let session = Arc::new(Mutex::new(Session { + nodes: Vec::new(), + presentations: std::collections::BTreeMap::from([( + crate::window::MAIN_WINDOW, + crate::presentation::Presentation::new(desktop, tabs), + )]), + current: crate::window::MAIN_WINDOW, + master: desktop, + next_window: crate::window::MAIN_WINDOW, + next_mount: 0, + retired_commands: Vec::new(), + refresh_yields: 0, + refresh_restarts: 0, + layout_cancels: 0, + paint_cancels: 0, + paint_slices: 0, + binding_evaluations: 0, + exiting: false, + })); + let mut public_functions = Vec::new(); + let mut factories = Vec::new(); + for (name, operation) in [ + ("implement", Operation::Implement), + ("implements", Operation::Implements), + ("supports", Operation::Supports), + ("isInstance", Operation::IsInstance), + ("isSubtype", Operation::IsSubtype), + ("open", Operation::OpenValue), + ("text", Operation::Text), + ("rect", Operation::Rect), + ("spacer", Operation::Spacer), + ("row", Operation::Row), + ("column", Operation::Column), + ("button", Operation::Button), + ("page", Operation::Page), + ("present", Operation::Present), + ("state", Operation::State), + ("textOf", Operation::TextOf), + ("stats", Operation::Stats), + ("viewport", Operation::Viewport), + ("window", Operation::Window), + ] { + let function = vm.allocate(ContextNativeFunction::new( + name, + Function { + signature: None, + operation, + owner: None, + session: session.clone(), + requested_type: None, + }, + vec![desktop], + ))?; + roots.push(function); + vm.set_attr(ui, name, Value::Object(function))?; + public_functions.push((name, function, operation)); + } + let root_types = vm.bootstrap_types()?; + 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))?; + let mut definitions = vec![ + ("Object", root_types.object), + ("Type", root_types.type_type), + ]; + for (name, base_name, constructor_name) in [ + ("Trait", "Object", None), + ("Function", "Object", None), + ("Block", "Object", None), + ("ContainerBlock", "Block", None), + ("TextBlock", "Block", Some("text")), + ("RectBlock", "Block", Some("rect")), + ("Spacer", "Block", Some("spacer")), + ("ScrollBlock", "ContainerBlock", Some("scroll")), + ("Row", "ContainerBlock", Some("row")), + ("Column", "ContainerBlock", Some("column")), + ("Button", "ContainerBlock", Some("button")), + ("Page", "ContainerBlock", Some("page")), + ("Window", "Object", Some("window-create")), + ("Mount", "Object", None), + ("Event", "Object", Some("event")), + ("Subscription", "Object", None), + ("Task", "Object", None), + ("ObjectList", "Object", Some("object-list")), + ] { + let base = object(Some(&vm.get_attr(ui, base_name)?))?; + let constructor = match constructor_name { + Some("object-list") => { + let factory = vm.allocate(ContextNativeFunction::new( + "object-list", + crate::object_list::Constructor { + desktop, + signature: None, + }, + vec![desktop], + ))?; + roots.push(factory); + let function_type = object(Some(&vm.get_attr(ui, "Function")?))?; + vm.attach_type(factory, function_type)?; + TypeConstructor::NativeTyped(factory) + } + Some("event") => { + let factory = vm.allocate(ContextNativeFunction::new( + "event", + crate::event_dispatch::Constructor { signature: None }, + vec![], + ))?; + roots.push(factory); + let function_type = object(Some(&vm.get_attr(ui, "Function")?))?; + vm.attach_type(factory, function_type)?; + TypeConstructor::NativeTyped(factory) + } + Some(constructor_name) => { + let operation = match constructor_name { + "text" => Operation::Text, + "rect" => Operation::Rect, + "spacer" => Operation::Spacer, + "scroll" => Operation::Scroll, + "row" => Operation::Row, + "column" => Operation::Column, + "button" => Operation::Button, + "page" => Operation::Page, + "window-create" => Operation::NewWindow, + _ => return Err(invalid("unknown native type allocator").into()), + }; + let factory = vm.allocate(ContextNativeFunction::new( + constructor_name, + NativeFactory { + signature: None, + options: None, + operation, + session: session.clone(), + }, + vec![desktop], + ))?; + roots.push(factory); + let function_type = object(Some(&vm.get_attr(ui, "Function")?))?; + vm.attach_type(factory, function_type)?; + TypeConstructor::NativeTyped(factory) + } + None => TypeConstructor::Abstract, + }; + if let (Some(tag), TypeConstructor::NativeTyped(factory)) = (constructor_name, constructor) + { + factories.push((tag, factory)); + } + let definition = vm.new_type(name, base, constructor)?; + roots.push(definition); + vm.set_attr(ui, name, Value::Object(definition))?; + definitions.push((name, definition)); + } + for (name, fields) in [ + ( + "Constraints", + vec!["minWidth", "minHeight", "maxWidth", "maxHeight"], + ), + ("MeasureResult", vec!["width", "height"]), + ("Bounds", vec!["x", "y", "width", "height"]), + ("Point", vec!["x", "y"]), + ("Arrangement", vec!["children"]), + ("DisplayList", vec!["commands"]), + ("PaintCommand", vec![]), + ] { + let fields = fields + .into_iter() + .map(|name| endor_core::ClassField { + name: name.into(), + type_name: if matches!(name, "children" | "commands") { + "Object" + } else { + "Number" + } + .into(), + }) + .collect(); + let definition = vm.new_class( + name, + root_types.object, + fields, + std::collections::BTreeMap::new(), + )?; + roots.push(definition); + vm.set_attr(ui, name, Value::Object(definition))?; + definitions.push((name, definition)); + } + // The desktop owns the registry through ordinary traced attributes. The + // Rust session only borrows handles and adds no hidden lifetime roots. + let types = vm.new_dict()?; + roots.push(types); + for (name, definition) in definitions { + 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)?; + let function_type = object(Some(&vm.get_attr(ui, "Function")?))?; + vm.set_function_type(function_type)?; + vm.set_source_type_checks(true)?; + for (name, function, operation) in public_functions { + vm.replace_payload( + function, + ContextNativeFunction::new( + name, + Function { + signature: Some(operation_signature(vm.object_store(), desktop, operation)?), + operation, + owner: None, + session: session.clone(), + requested_type: None, + }, + vec![desktop], + ), + )?; + } + for (name, function) in factories { + let operation = match name { + "text" => Some(Operation::Text), + "rect" => Some(Operation::Rect), + "spacer" => Some(Operation::Spacer), + "scroll" => Some(Operation::Scroll), + "row" => Some(Operation::Row), + "column" => Some(Operation::Column), + "button" => Some(Operation::Button), + "page" => Some(Operation::Page), + "window-create" => Some(Operation::NewWindow), + "event" | "object-list" => None, + _ => return Err(invalid("unknown deferred factory").into()), + }; + if let Some(operation) = operation { + let options = operation_signature(vm.object_store(), desktop, operation)?; + let envelope = CallSignature { + arity: endor_core::CallArity::range(options.arity.required + 1, usize::MAX), + parameters: vec![AnnotationType::Type.into()], + variadic: Some(AnnotationType::Any.into()), + returns: options.returns.clone(), + effect: options.effect, + }; + let mut captures = vec![desktop]; + captures.extend(options.references()); + vm.replace_payload( + function, + ContextNativeFunction::new( + name, + NativeFactory { + signature: Some(envelope), + options: Some(options), + operation, + session: session.clone(), + }, + captures, + ), + )?; + } else { + let kind = if name == "event" { + "Event" + } else { + "ObjectList" + }; + let signature = CallSignature { + arity: endor_core::CallArity::range( + if name == "event" { 2 } else { 1 }, + usize::MAX, + ), + parameters: if name == "event" { + vec![AnnotationType::Type.into(), AnnotationType::String.into()] + } else { + vec![AnnotationType::Type.into()] + }, + variadic: Some(AnnotationType::Any.into()), + returns: AnnotationType::Nominal(desktop_type(vm.object_store(), kind, desktop)?) + .into(), + effect: CallEffect::ReadOnly, + }; + if name == "event" { + vm.replace_payload( + function, + ContextNativeFunction::new( + name, + crate::event_dispatch::Constructor { + signature: Some(signature), + }, + vec![], + ), + )?; + } else { + vm.replace_payload( + function, + ContextNativeFunction::new( + name, + crate::object_list::Constructor { + desktop, + signature: Some(signature), + }, + vec![desktop], + ), + )?; + } + } + } + for name in [ + "open", + "text", + "rect", + "spacer", + "row", + "column", + "button", + "page", + "present", + "state", + "textOf", + "stats", + "viewport", + "window", + "isInstance", + "isSubtype", + "implement", + "implements", + "supports", + ] { + let function = object(Some(&vm.get_attr(ui, name)?))?; + vm.attach_type(function, function_type)?; + } + for (type_name, name, operation) in [ + ("Block", "bind", Operation::Bind), + ("Block", "mounts", Operation::Mounts), + ("Block", "invalidate", Operation::Invalidate), + ("Block", "onClick", Operation::OnClick), + ("Block", "on", Operation::On), + ("Block", "dispatch", Operation::Dispatch), + ("Block", "onEvent", Operation::OnEvent), + ("Block", "onMount", Operation::OnMount), + ("Block", "onUnmount", Operation::OnUnmount), + ("Block", "click", Operation::Click), + ("TextBlock", "setText", Operation::SetText), + ("TextBlock", "setColor", Operation::SetColor), + ("RectBlock", "setColor", Operation::SetColor), + ("ContainerBlock", "append", Operation::Append), + ("ContainerBlock", "insert", Operation::Insert), + ("ContainerBlock", "remove", Operation::Remove), + ("ContainerBlock", "replace", Operation::Replace), + ("ContainerBlock", "move", Operation::Move), + ("ContainerBlock", "children", Operation::Children), + ("Window", "close", Operation::CloseWindow), + ("Page", "moveToWindow", Operation::MoveWindow), + ("Page", "moveTab", Operation::MoveTab), + ("Page", "detachToWindow", Operation::Detach), + ("Page", "render", Operation::Render), + ("Page", "unmount", Operation::Unmount), + ("Page", "open", Operation::Open), + ("Page", "window", Operation::PageWindow), + ("Page", "close", Operation::Close), + ("Page", "activate", Operation::Activate), + ("Page", "navigate", Operation::Navigate), + ("Page", "back", Operation::Back), + ("Page", "forward", Operation::Forward), + ] { + let expected = object(Some(&vm.get_attr(ui, type_name)?))?; + let method = vm.allocate(ContextNativeFunction::new( + name, + TypeMethod { + signature: method_signature(vm.object_store(), desktop, expected, operation)?, + operation, + expected, + session: session.clone(), + }, + vec![expected, desktop], + ))?; + roots.push(method); + vm.attach_type(method, function_type)?; + vm.define_method(expected, name, method)?; + } + crate::window::install(vm, roots, desktop, tabs)?; + let block_type = object(Some(&vm.get_attr(ui, "Block")?))?; + crate::native_visual::install(vm, roots, desktop, block_type, function_type)?; + let event_type = object(Some(&vm.get_attr(ui, "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)?; + let tasks = crate::tasks::install(vm, roots, ui, desktop)?; + developer_pages::install(vm, ui, &session)?; + let bridge = UiBridge { ui, session, tasks }; + developer_pages::install_start_page(vm, &bridge)?; + Ok(bridge) +} diff --git a/src/bridge/ui_bridge/calls.rs b/src/bridge/ui_bridge/calls.rs new file mode 100644 index 0000000..12c3fbe --- /dev/null +++ b/src/bridge/ui_bridge/calls.rs @@ -0,0 +1,789 @@ +//! Native Block operations executed on the owning VM worker. +use super::*; + +impl NativeCallable for Function { + fn signature(&self) -> Option { + self.signature.clone() + } + fn arity(&self) -> Option { + Some(self.operation.arity()) + } + + fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { + use Operation::*; + let arity = self.operation.arity(); + let (minimum, maximum) = (arity.required, arity.maximum); + if !(minimum..=maximum).contains(&args.len()) { + return Err(invalid("incorrect UI method argument count")); + } + if matches!(self.operation, Implement | Implements | Supports) { + let capability = object(args.get(1))?; + let value = match self.operation { + Implement => { + cx.implement_trait(object(args.first())?, capability)?; + Value::Nil + } + Implements => Value::Bool( + cx.store() + .implements_trait(object(args.first())?, capability)?, + ), + Supports => { + // Validate the contract even for immediate scalar values. + cx.store() + .payload::(capability, "Trait")?; + Value::Bool(match args[0] { + Value::Object(value) => cx.store().supports(value, capability)?, + _ => false, + }) + } + _ => unreachable!(), + }; + return Ok(NativeResult::Return(value)); + } + if matches!(self.operation, IsInstance | IsSubtype) { + let candidate = object(args.first())?; + let expected = object(args.get(1))?; + let value = if matches!(self.operation, IsInstance) { + cx.store().is_instance(candidate, expected)? + } else { + cx.store().is_subtype(candidate, expected)? + }; + return Ok(NativeResult::Return(Value::Bool(value))); + } + if matches!(self.operation, Invalidate) { + if cx.store().is_read_only() { + return Err(invalid( + "invalidation is forbidden during read-only rendering", + )); + } + 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", + )); + } + let owner = self.owner.ok_or(invalid("invalidation requires a Block"))?; + let (data, _) = block::resolve(cx.store(), owner)?; + let mut session = self + .session + .lock() + .map_err(|_| invalid("UI session poisoned"))?; + let mut affected = std::collections::HashSet::new(); + for weak in &session.nodes { + if let Some(block) = cx.upgrade(*weak)? + && block::resolve(cx.store(), block)?.0 == data + { + affected.insert(block); + } + } + for presentation in session.presentations.values_mut() { + presentation.invalidate(cx, &affected, &reason)?; + } + return Ok(NativeResult::Return(Value::Nil)); + } + if matches!(self.operation, Mounts) { + let owner = self.owner.ok_or(invalid("mount query requires a Block"))?; + let session = self + .session + .lock() + .map_err(|_| invalid("UI session poisoned"))?; + let mut positions = Vec::new(); + for (window, presentation) in &session.presentations { + for (id, weak) in &presentation.mounts.active { + if let Some(object) = cx.upgrade(*weak)? + && cx + .store() + .payload::(object, "Mount")? + .block + == owner + { + positions.push((*window, *id, object)); + } + } + } + positions.sort_unstable_by_key(|(window, id, _)| (*window, *id)); + let set = crate::mount::collection( + cx, + positions.into_iter().map(|(_, _, object)| object).collect(), + session.desktop, + )?; + return Ok(NativeResult::Return(Value::Object(set))); + } + if matches!(self.operation, NewWindow) { + let mut state = self + .session + .lock() + .map_err(|_| invalid("UI session poisoned"))?; + let definition = self + .requested_type + .ok_or(invalid("Window constructor requires a type"))?; + let window = create_window(cx, &mut state, definition)?; + return Ok(NativeResult::Return(Value::Object(window))); + } + if matches!(self.operation, CloseWindow) { + let window = self + .owner + .ok_or(invalid("window close requires a receiver"))?; + let id = cx + .store() + .payload::(window, "Window")? + .id; + crate::window::close(cx, window)?; + let mut state = self + .session + .lock() + .map_err(|_| invalid("UI session poisoned"))?; + if let Some(view) = state.presentations.get_mut(&id) { + cx.set_attr(view.desktop, "pending", Value::Nil)?; + view.pending = Some(None); + view.force_frame = true; + view.closing = true; + } + return Ok(NativeResult::Return(Value::Nil)); + } + if matches!(self.operation, Window) { + let desktop = self + .session + .lock() + .map_err(|_| invalid("UI session poisoned"))? + .desktop; + return Ok(NativeResult::Return( + cx.store().get_attr(desktop, "window")?, + )); + } + if matches!(self.operation, Viewport) { + let desktop = self + .session + .lock() + .map_err(|_| invalid("UI session poisoned"))? + .desktop; + let Value::Object(input) = cx.store().get_attr(desktop, "viewport")? else { + return Err(invalid("window viewport is unavailable")); + }; + let width = cx.store().get_attr(input, "width")?; + let height = cx.store().get_attr(input, "height")?; + return Ok(NativeResult::Return(Value::Object( + crate::native_visual::record( + cx, + desktop, + "MeasureResult", + vec![("width", width), ("height", height)], + )?, + ))); + } + if matches!(self.operation, Stats) { + if cx.store().is_read_only() { + return Err(invalid( + "runtime statistics are unavailable during read-only rendering", + )); + } + let session = self + .session + .lock() + .map_err(|_| invalid("UI session poisoned"))?; + let mut blocks = 0_i64; + let mut pages = 0_i64; + for weak in &session.nodes { + if let Some(object) = cx.upgrade(*weak)? { + blocks += 1; + if cx.store().payload::(object, "Block")?.page { + pages += 1; + } + } + } + let objects = cx.store().object_count() as i64; + let tabs = cx.store().dict_items(session.tabs)?.len() as i64; + let stats = cx.new_object()?; + for (name, value) in [ + ("objects", objects), + ("blocks", blocks), + ("pages", pages), + ("tabs", tabs), + ("mounts", session.mounts.active.len() as i64), + ("snapshotVisited", session.snapshot_visited as i64), + ("snapshotReused", session.snapshot_reused as i64), + ("measured", session.layout.measured as i64), + ("sourceMeasureHits", session.measure_cache.hits as i64), + ("sourceMeasureMisses", session.measure_cache.misses as i64), + ("arranged", session.layout.arranged as i64), + ("layoutPasses", session.layout.settle_passes as i64), + ("layoutSlices", session.layout.slices as i64), + ("refreshYields", session.refresh_yields as i64), + ("refreshRestarts", session.refresh_restarts as i64), + ("layoutCancels", session.layout_cancels as i64), + ("paintCancels", session.paint_cancels as i64), + ("paintSlices", session.paint_slices as i64), + ("bindingEvaluations", session.binding_evaluations as i64), + ( + "dependencyInvalidations", + session.dependency_invalidations as i64, + ), + ] { + cx.set_attr(stats, name, Value::Number(value.into()))?; + } + cx.set_attr( + stats, + "lastInvalidDependency", + Value::Text(session.last_invalid_dependency.clone()), + )?; + cx.set_attr( + stats, + "windowDependencyDiagnostics", + Value::Text( + session + .presentations + .iter() + .filter(|(_, view)| !view.last_invalid_dependency.is_empty()) + .map(|(id, view)| format!("{id}: {}", view.last_invalid_dependency)) + .collect::>() + .join("; "), + ), + )?; + return Ok(NativeResult::Return(Value::Object(stats))); + } + if matches!(self.operation, State) { + let state = cx.new_object()?; + attach_named_type(cx, state, "Object", &self.session)?; + cx.set_attr(state, "value", args[0].clone())?; + bind(cx, state, "set", StateSet, &self.session)?; + bind(cx, state, "increment", Increment, &self.session)?; + return Ok(NativeResult::Return(Value::Object(state))); + } + if matches!(self.operation, StateSet | Increment) { + let owner = self.owner.ok_or(invalid("state method has no owner"))?; + let value = if matches!(self.operation, StateSet) { + args[0].clone() + } else { + let Value::Number(value) = cx.store().get_attr(owner, "value")? else { + return Err(invalid("increment requires an integer state")); + }; + let next = value + .as_i64() + .and_then(|n| n.checked_add(1)) + .ok_or(invalid("integer state increment exceeds its range"))?; + Value::Number(next.into()) + }; + cx.set_attr(owner, "value", value)?; + return Ok(NativeResult::Return(Value::Nil)); + } + if matches!(self.operation, TextOf) { + let source = object(args.first())?; + let field = text(args.get(1))?; + cx.store().get_attr(source, &field)?; + let size = args + .get(2) + .map(|v| integer(Some(v))) + .transpose()? + .unwrap_or(1); + if !(1..=8).contains(&size) { + return Err(invalid("text size must be 1 through 8")); + } + let mut data = Data::new( + Kind::Text { + value: String::new(), + size: size as u32, + color: color(args.get(3), "#242422")?, + }, + vec![], + 0.0, + 0.0, + ); + data.text_binding = Some((source, field)); + return Ok(NativeResult::Return(Value::Object(new_block( + cx, + data, + &self.session, + )?))); + } + let constructor = match self.operation { + Scroll => Some(Data::new( + Kind::Scroll { + width: dimension(args.first())?, + height: dimension(args.get(1))?, + }, + vec![object(args.get(2))?], + 0.0, + 0.0, + )), + Text => Some(Data::new( + Kind::Text { + value: text(args.first())?, + size: args + .get(1) + .map(|v| integer(Some(v))) + .transpose()? + .unwrap_or(1) + .try_into() + .map_err(|_| invalid("text size exceeds limits"))?, + color: color(args.get(2), "#242422")?, + }, + vec![], + 0.0, + 0.0, + )), + Rect => Some(Data::new( + Kind::Rect { + width: dimension(args.first())?, + height: dimension(args.get(1))?, + radius: args + .get(2) + .map(|v| dimension(Some(v))) + .transpose()? + .unwrap_or(0.0), + color: color(args.get(3), "#DCE8FF")?, + }, + vec![], + 0.0, + 0.0, + )), + Spacer => Some(Data::new( + Kind::Spacer(dimension(args.first())?), + vec![], + 0.0, + 0.0, + )), + Row | Column => Some(Data::new( + if matches!(self.operation, Row) { + Kind::Row + } else { + Kind::Column + }, + args[2..] + .iter() + .map(|v| object(Some(v))) + .collect::>()?, + dimension(args.first())?, + dimension(args.get(1))?, + )), + Page => Some(Data::new(Kind::Page, vec![], 0.0, 0.0)), + Button => { + let label = new_block( + cx, + Data::new( + Kind::Text { + value: text(args.first())?, + size: 1, + color: "#243047".into(), + }, + vec![], + 0.0, + 0.0, + ), + &self.session, + )?; + Some(Data::new(Kind::Button, vec![label], 10.0, 0.0)) + } + _ => None, + }; + if let Some(data) = constructor { + if let Kind::Text { size, value, .. } = &data.kind + && (!(1..=8).contains(size) || value.len() > 16_384) + { + return Err(invalid("text exceeds size limits")); + } + return Ok(NativeResult::Return(Value::Object(new_typed_block( + cx, + data, + &self.session, + self.requested_type, + )?))); + } + if matches!(self.operation, OpenValue | Present) + && let Value::Object(value) = args[0] + { + let (desktop, current) = { + let state = self + .session + .lock() + .map_err(|_| invalid("UI session poisoned"))?; + (state.master, state.desktop) + }; + let capability = desktop_type(cx.store(), "Presentable", desktop)?; + if !cx + .store() + .object(value)? + .payload() + .as_any() + .is::() + && cx.store().supports(value, capability)? + { + let definition = cx + .store() + .type_of(value)? + .ok_or(invalid("untyped Presentable"))?; + let function = cx + .store() + .lookup_method(definition, "present")? + .ok_or(invalid("missing present method"))?; + let window = object(Some(&cx.store().get_attr(current, "window")?))?; + let complete = cx.allocate(ContextNativeFunction::new( + "validate presentation", + PresentResult { + open: matches!(self.operation, OpenValue), + session: self.session.clone(), + }, + vec![desktop], + ))?; + let instructions = [ + endor_core::Instruction::new(endor_core::Opcode::LoadConst, 0), + endor_core::Instruction::simple(endor_core::Opcode::PushNull), + endor_core::Instruction::new(endor_core::Opcode::LoadConst, 1), + endor_core::Instruction::simple(endor_core::Opcode::PushNull), + endor_core::Instruction::new(endor_core::Opcode::LoadConst, 2), + endor_core::Instruction::new(endor_core::Opcode::LoadConst, 3), + endor_core::Instruction::new(endor_core::Opcode::Call, 2), + endor_core::Instruction::new(endor_core::Opcode::Call, 1), + endor_core::Instruction::simple(endor_core::Opcode::ReturnValue), + ]; + let code = endor_core::EndorCode::from_instructions( + &instructions, + vec![ + Value::Object(complete), + Value::Object(function), + Value::Object(value), + Value::Object(window), + ], + endor_core::CodeLayout::default(), + )? + .with_location("Presentable.present", "", 1); + let code = cx.allocate(endor_core::EndorCodeObject::new(code))?; + let globals = cx.new_dict()?; + let driver = cx.new_function("Presentable.present", code, globals)?; + attach_desktop_type(cx, driver, "Function", desktop)?; + return Ok(NativeResult::Invoke { + function: driver, + arguments: vec![], + }); + } + } + if matches!(self.operation, OpenValue) { + let root = present_value(cx, &args[0], &self.session)?; + let page = if cx.store().payload::(root, "Block")?.page { + root + } else { + new_block( + cx, + Data::new(Kind::Page, vec![root], 0.0, 0.0), + &self.session, + )? + }; + show(cx, page, &self.session)?; + return Ok(NativeResult::Return(Value::Object(page))); + } + if matches!(self.operation, Present) { + return Ok(NativeResult::Return(Value::Object(present_value( + cx, + &args[0], + &self.session, + )?))); + } + let owner = self.owner.ok_or(invalid("method has no owner"))?; + if matches!(self.operation, Bind) { + let field = text(args.first())?; + let compute = object(args.get(1))?; + return Ok(NativeResult::Return(Value::Object( + crate::binding::connect( + cx, + owner, + &field, + compute, + self.session + .lock() + .map_err(|_| invalid("UI session poisoned"))? + .master, + )?, + ))); + } + if matches!(self.operation, MoveTab) { + let position = usize::try_from(integer(args.first())?) + .map_err(|_| invalid("tab position out of range"))?; + let mut state = self + .session + .lock() + .map_err(|_| invalid("UI session poisoned"))?; + 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"))?; + let window = object(Some(&cx.store().get_attr(view.desktop, "window")?))?; + crate::window::move_page(cx, window, owner, position)?; + view.force_frame = true; + return Ok(NativeResult::Return(Value::Object(owner))); + } + let (data_ref, data) = block::resolve(cx.store(), owner)?; + let mut data = data.clone(); + let mut result = Value::Nil; + match self.operation { + SetText => match &mut data.kind { + Kind::Text { value, .. } => { + let next = text(args.first())?; + if next.len() > 16_384 { + return Err(invalid("text exceeds 16 KiB")); + } + *value = next; + data.text_binding = None; + cx.set_attr(owner, "text", args[0].clone())?; + } + _ => return Err(invalid("setText requires a text Block")), + }, + SetColor => match &mut data.kind { + Kind::Text { color: value, .. } | Kind::Rect { color: value, .. } => { + *value = color(args.first(), "#242422")?; + cx.set_attr(owner, "color", Value::Text(value.clone()))?; + } + _ => return Err(invalid("this Block has no color")), + }, + Append | Insert | Remove | Replace | Move => { + if !data.is_container() { + return Err(invalid("expected a container Block")); + } + if matches!(self.operation, Append | Insert) { + let (position, argument) = if matches!(self.operation, Insert) { + let position = usize::try_from(integer(args.first())?) + .map_err(|_| invalid("child position out of range"))?; + if position > data.children.len() { + return Err(invalid("child position out of range")); + } + (position, args.get(1)) + } else { + (data.children.len(), args.first()) + }; + let child = object(argument)?; + block::validate(cx.store(), child, Some(data_ref))?; + let slot = data.next_slot; + data.next_slot = slot + .checked_add(1) + .filter(|next| *next <= i64::MAX as u64) + .ok_or(VmError::IdentityExhausted)?; + data.children.insert(position, Child { slot, block: child }); + result = Value::Number((slot as i64).into()); + } else { + let slot = integer(args.first())?; + let index = data + .children + .iter() + .position(|v| v.slot == slot) + .ok_or(invalid("child slot does not exist"))?; + match self.operation { + Remove => { + data.children.remove(index); + } + Replace => { + let child = object(args.get(1))?; + block::validate(cx.store(), child, Some(data_ref))?; + data.children[index].block = child; + } + Move => { + let to = usize::try_from(integer(args.get(1))?) + .map_err(|_| invalid("child position out of range"))?; + if to >= data.children.len() { + return Err(invalid("child position out of range")); + } + let child = data.children.remove(index); + data.children.insert(to, child); + } + _ => unreachable!(), + } + } + } + Children => { + let result = cx.new_dict()?; + for child in data.children { + cx.dict_set( + result, + Value::Text(child.slot.to_string()), + Value::Object(child.block), + )?; + } + return Ok(NativeResult::Return(Value::Object(result))); + } + Render | Navigate => { + if !matches!(data.kind, Kind::Page) { + return Err(invalid("expected a Page")); + } + let root = object(args.first())?; + block::validate(cx.store(), root, Some(data_ref))?; + if matches!(self.operation, Navigate) { + if data.history.is_empty() { + if let Some(old) = data.children.first() { + data.history.push(old.block); + } + } else { + data.history.truncate(data.history_index + 1); + } + data.history.push(root); + data.history_index = data.history.len() - 1; + } + data.children = vec![Child { + slot: 0, + block: root, + }]; + data.next_slot = data.next_slot.max(1); + show(cx, owner, &self.session)?; + } + Back | Forward => { + if data.history.is_empty() { + return Ok(NativeResult::Return(Value::Nil)); + } + let next = if matches!(self.operation, Back) { + data.history_index.saturating_sub(1) + } else { + (data.history_index + 1).min(data.history.len() - 1) + }; + data.history_index = next; + data.children = vec![Child { + slot: 0, + block: data.history[next], + }]; + show(cx, owner, &self.session)?; + } + Detach => { + let window = { + let mut state = self + .session + .lock() + .map_err(|_| invalid("UI session poisoned"))?; + let definition = desktop_type(cx.store(), "Window", state.master)?; + create_window(cx, &mut state, definition)? + }; + show_in(cx, owner, &self.session, Some(window))?; + return Ok(NativeResult::Return(Value::Object(window))); + } + PageWindow => { + let state = self + .session + .lock() + .map_err(|_| invalid("UI session poisoned"))?; + let value = if let Some(id) = page_window(cx.store(), &state, owner)? { + cx.store() + .get_attr(state.presentations[&id].desktop, "window")? + } else { + Value::Nil + }; + return Ok(NativeResult::Return(value)); + } + Open | Activate | MoveWindow => { + if matches!(self.operation, MoveWindow) + && matches!(args.first(), None | Some(Value::Nil)) + { + return Err(invalid("Page.moveToWindow requires a Window")); + } + let target = match args.first() { + None | Some(Value::Nil) => None, + Some(value) => Some(object(Some(value))?), + }; + show_in(cx, owner, &self.session, target)?; + return Ok(NativeResult::Return(Value::Object(owner))); + } + Unmount => { + data.children.clear(); + } + Close => { + 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. + let page = cx.store().downgrade(owner)?; + for weak in state.presentations[&id].mounts.ordered() { + if let Some(mount) = cx.upgrade(weak)? + && cx + .store() + .payload::(mount, "Mount")? + .page + == Some(page) + { + crate::tasks::cancel_owner(cx, mount)?; + } + } + remove_page( + cx, + state + .presentations + .get_mut(&id) + .ok_or(invalid("missing window"))?, + owner, + )?; + } + return Ok(NativeResult::Return(Value::Nil)); + } + OnClick => { + let handler = object(args.first())?; + crate::event_dispatch::validate_handler(cx.store(), handler)?; + data.click = Some(handler); + result = Value::Object(owner); + } + On => { + let kind = text(args.first())?; + if kind.is_empty() || kind.len() > 128 { + return Err(invalid("invalid event kind")); + } + let handler = object(args.get(1))?; + crate::event_dispatch::validate_handler(cx.store(), handler)?; + let capture = match args.get(2) { + None | Some(Value::Bool(false)) => false, + Some(Value::Bool(true)) => true, + Some(Value::Text(phase)) if phase == "capture" => true, + Some(Value::Text(phase)) if phase == "bubble" => false, + _ => return Err(invalid("phase must be capture or bubble")), + }; + let mut active = Vec::new(); + for token in &data.subscriptions { + if cx + .store() + .payload::(*token, "Connection")? + .handler + .is_some() + { + active.push(*token); + } + } + if active.len() >= MAX_UI_NODES { + return Err(invalid("too many event subscriptions")); + } + let connection = cx.allocate(crate::event_dispatch::Connection { + kind, + handler: Some(handler), + capture, + })?; + let token = cx.allocate(crate::event_dispatch::Subscription { + connection: cx.store().downgrade(connection)?, + })?; + attach_named_type(cx, token, "Subscription", &self.session)?; + active.push(connection); + data.subscriptions = active; + result = Value::Object(token); + } + OnEvent | OnMount | OnUnmount => return Ok(NativeResult::Return(Value::Nil)), + Click | Dispatch => { + let desktop = self + .session + .lock() + .map_err(|_| invalid("UI session poisoned"))? + .desktop; + let (event, mount) = if matches!(self.operation, Click) { + let definition = desktop_type(cx.store(), "Event", desktop)?; + ( + crate::event_dispatch::new_event(cx, definition, "click")?, + args.first(), + ) + } else { + (object(args.first())?, args.get(1)) + }; + let mount = mount.map(|value| object(Some(value))).transpose()?; + return crate::event_dispatch::dispatch(cx, owner, event, mount, desktop); + } + _ => return Err(invalid("unsupported Block operation")), + } + cx.replace_payload(data_ref, data)?; + Ok(NativeResult::Return(result)) + } +} diff --git a/src/ui_bridge/developer_pages.rs b/src/bridge/ui_bridge/developer_pages.rs similarity index 100% rename from src/ui_bridge/developer_pages.rs rename to src/bridge/ui_bridge/developer_pages.rs diff --git a/src/bridge/ui_bridge/signatures.rs b/src/bridge/ui_bridge/signatures.rs new file mode 100644 index 0000000..0713506 --- /dev/null +++ b/src/bridge/ui_bridge/signatures.rs @@ -0,0 +1,162 @@ +//! Canonical contracts for native Block methods and constructors. +use super::*; + +pub(super) fn method_signature( + store: &endor_core::ObjectStore, + desktop: ObjectRef, + receiver: ObjectRef, + operation: Operation, +) -> Result { + use AnnotationType::{Any, Function, Nominal, Number, String as TextType, Unit}; + use Operation::*; + let nominal = |name| desktop_type(store, name, desktop).map(Nominal); + let (arguments, returns, effect) = match operation { + Bind => ( + vec![TextType, Function], + nominal("Subscription")?, + CallEffect::Mutating, + ), + Mounts => (vec![], nominal("ObjectList")?, CallEffect::ReadOnly), + Children => (vec![], Any, CallEffect::ReadOnly), + Invalidate => (vec![TextType], Unit, CallEffect::Mutating), + SetText | SetColor => (vec![TextType], Unit, CallEffect::Mutating), + Append => (vec![nominal("Block")?], Number, CallEffect::Mutating), + Insert => ( + vec![Number, nominal("Block")?], + Number, + CallEffect::Mutating, + ), + Remove => (vec![Number], Unit, CallEffect::Mutating), + Replace => (vec![Number, nominal("Block")?], Unit, CallEffect::Mutating), + Move => (vec![Number, Number], Unit, CallEffect::Mutating), + Render | Navigate => (vec![nominal("Block")?], Unit, CallEffect::Mutating), + OnClick => (vec![Function], Nominal(receiver), CallEffect::Mutating), + OnEvent => (vec![nominal("Event")?], Unit, CallEffect::External), + OnMount | OnUnmount => (vec![nominal("Mount")?], Unit, CallEffect::External), + Click => ( + vec![nominal("Mount")?], + nominal("Event")?, + CallEffect::External, + ), + Dispatch => ( + vec![nominal("Event")?, nominal("Mount")?], + nominal("Event")?, + CallEffect::External, + ), + MoveWindow => ( + vec![nominal("Window")?], + Nominal(receiver), + CallEffect::Mutating, + ), + MoveTab => (vec![Number], Nominal(receiver), CallEffect::Mutating), + Activate => (vec![], Nominal(receiver), CallEffect::Mutating), + Detach => (vec![], nominal("Window")?, CallEffect::Mutating), + Unmount | Close | CloseWindow | Back | Forward => (vec![], Unit, CallEffect::Mutating), + Open => (vec![Any], Nominal(receiver), CallEffect::Mutating), + PageWindow => (vec![], Any, CallEffect::ReadOnly), + On => ( + vec![TextType, Function, Any], + nominal("Subscription")?, + CallEffect::Mutating, + ), + _ => return Err(invalid("unsupported native method signature")), + }; + let arity = operation.arity(); + let mut parameters = vec![Nominal(receiver)]; + parameters.extend(arguments); + let mut signature = CallSignature { + arity: endor_core::CallArity::range(arity.required + 1, arity.maximum + 1), + parameters: parameters.into_iter().map(CallType::from).collect(), + variadic: None, + returns: returns.into(), + effect, + }; + match operation { + 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) +} + +pub(super) fn operation_signature( + store: &endor_core::ObjectStore, + desktop: ObjectRef, + operation: Operation, +) -> Result { + use AnnotationType::{Any, Bool, Number, String as TextType, Trait, Type, Unit}; + use Operation::*; + let nominal = |name| desktop_type(store, name, desktop).map(AnnotationType::Nominal); + let (parameters, variadic, returns, effect) = match operation { + IsInstance => (vec![Any, Type], None, Bool, CallEffect::ReadOnly), + IsSubtype => (vec![Type, Type], None, Bool, CallEffect::ReadOnly), + Implement => (vec![Type, Trait], None, Unit, CallEffect::Mutating), + Implements => (vec![Type, Trait], None, Bool, CallEffect::ReadOnly), + Supports => (vec![Any, Trait], None, Bool, CallEffect::ReadOnly), + OpenValue => (vec![Any], None, nominal("Page")?, CallEffect::Mutating), + Present => (vec![Any], None, nominal("Block")?, CallEffect::Delegated), + State => (vec![Any], None, Any, CallEffect::ReadOnly), + StateSet => (vec![Any], None, Unit, CallEffect::Mutating), + Increment => (vec![], None, Unit, CallEffect::Mutating), + Stats => (vec![], None, Any, CallEffect::External), + Viewport => ( + vec![], + None, + nominal("MeasureResult")?, + CallEffect::ReadOnly, + ), + Window => (vec![], None, nominal("Window")?, CallEffect::ReadOnly), + NewWindow => (vec![], None, nominal("Window")?, CallEffect::Mutating), + TextOf => ( + vec![Any, TextType, Number, TextType], + None, + nominal("TextBlock")?, + CallEffect::ReadOnly, + ), + Text => ( + vec![TextType, Number, TextType], + None, + nominal("TextBlock")?, + CallEffect::ReadOnly, + ), + Rect => ( + vec![Number, Number, Number, TextType], + None, + nominal("RectBlock")?, + CallEffect::ReadOnly, + ), + Spacer => (vec![Number], None, nominal("Spacer")?, CallEffect::ReadOnly), + Scroll => ( + vec![Number, Number, nominal("Block")?], + None, + nominal("ScrollBlock")?, + CallEffect::ReadOnly, + ), + Row | Column => ( + vec![Number, Number], + Some(nominal("Block")?), + nominal(if matches!(operation, Row) { + "Row" + } else { + "Column" + })?, + CallEffect::ReadOnly, + ), + Button => ( + vec![TextType], + None, + nominal("Button")?, + CallEffect::ReadOnly, + ), + Page => (vec![], None, nominal("Page")?, CallEffect::ReadOnly), + _ => return Err(invalid("unsupported UI function signature")), + }; + Ok(CallSignature { + arity: operation.arity(), + parameters: parameters.into_iter().map(CallType::from).collect(), + variadic: variadic.map(CallType::from), + returns: returns.into(), + effect, + }) +} diff --git a/src/ui_bridge/start_page.endor b/src/bridge/ui_bridge/start_page.endor similarity index 100% rename from src/ui_bridge/start_page.endor rename to src/bridge/ui_bridge/start_page.endor diff --git a/src/bridge/ui_bridge/tree.rs b/src/bridge/ui_bridge/tree.rs new file mode 100644 index 0000000..f9c23e6 --- /dev/null +++ b/src/bridge/ui_bridge/tree.rs @@ -0,0 +1,162 @@ +//! Owned immutable visual derivation; no managed VM handles. +use std::sync::Arc; + +#[derive(Debug, Clone, PartialEq)] +pub struct UiNode { + pub flex: f32, + pub flex_policy: crate::layout::FlexPolicy, + pub main_align: crate::layout::MainAlign, + pub block_id: String, + pub protocol_key: String, + pub slots: Vec, + pub interactive: bool, + pub kind: UiKind, +} + +impl UiNode { + pub fn children(&self) -> &[Arc] { + match &self.kind { + UiKind::Column { children, .. } + | UiKind::Row { children, .. } + | UiKind::Button { children, .. } + | UiKind::Scroll { children, .. } => children, + _ => &[], + } + } + + pub fn has_flex(&self) -> bool { + self.flex > 0.0 || self.flex_policy != crate::layout::FlexPolicy::default() + } + + pub fn shallow_eq(&self, other: &Self) -> bool { + if self.flex_policy != other.flex_policy + || self.flex != other.flex + || self.main_align != other.main_align + || self.block_id != other.block_id + || self.protocol_key != other.protocol_key + || self.slots != other.slots + || self.interactive != other.interactive + { + return false; + } + let same_children = || { + self.children().len() == other.children().len() + && self + .children() + .iter() + .zip(other.children()) + .all(|(a, b)| Arc::ptr_eq(a, b)) + }; + match (&self.kind, &other.kind) { + ( + UiKind::Text { + value: a, + size: sa, + color: ca, + }, + UiKind::Text { + value: b, + size: sb, + color: cb, + }, + ) => a == b && sa == sb && ca == cb, + ( + UiKind::Rect { + width: a, + height: b, + radius: c, + color: d, + }, + UiKind::Rect { + width: e, + height: f, + radius: g, + color: h, + }, + ) => (a, b, c, d) == (e, f, g, h), + (UiKind::Spacer(a), UiKind::Spacer(b)) => a == b, + ( + UiKind::Scroll { + axis: aa, + width: a, + height: b, + padding: pa, + gap: ga, + children: _, + }, + UiKind::Scroll { + axis: ab, + width: c, + height: d, + padding: pb, + gap: gb, + children: _, + }, + ) => aa == ab && a == c && b == d && pa == pb && ga == gb && same_children(), + ( + UiKind::Column { + padding: a, gap: b, .. + }, + UiKind::Column { + padding: c, gap: d, .. + }, + ) + | ( + UiKind::Row { + padding: a, gap: b, .. + }, + UiKind::Row { + padding: c, gap: d, .. + }, + ) + | ( + UiKind::Button { + padding: a, gap: b, .. + }, + UiKind::Button { + padding: c, gap: d, .. + }, + ) => a == c && b == d && same_children(), + _ => false, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum UiKind { + Button { + padding: f32, + gap: f32, + children: Vec>, + }, + Text { + value: String, + size: u32, + color: String, + }, + Rect { + width: f32, + height: f32, + radius: f32, + color: String, + }, + Column { + padding: f32, + gap: f32, + children: Vec>, + }, + Row { + padding: f32, + gap: f32, + children: Vec>, + }, + Spacer(f32), + Scroll { + axis: crate::scroll::Axis, + width: f32, + height: f32, + padding: f32, + gap: f32, + children: Vec>, + }, +} diff --git a/src/main.rs b/src/main.rs index 943c3e9..0bc1869 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,41 +1,25 @@ -mod activity; -mod api; -mod app; -mod binding; -mod block; -mod clipboard; -mod developer_tools; -mod display_list; -mod document; -mod endor_host; -mod endor_script; -mod event_dispatch; -mod events; -mod geometry; -mod io_tasks; -mod layout; -mod mount; +//! Application composition root. Directory modules own implementation; these +//! crate-private aliases keep existing internal paths stable during reorganization. +mod bridge; +mod model; +mod render; +mod runtime; +mod shell; + +pub(crate) use bridge::ui_bridge; +pub(crate) use model::{ + binding, block, document, mount, object_list, presentation, protocols, snapshot, window, +}; +pub(crate) use render::{ + display_list, geometry, layout, native_visual, scene, scroll, software_renderer, source_hit, + source_layout, source_paint, text, +}; +pub(crate) use runtime::{ + api, endor_host, endor_script, event_dispatch, events, io_tasks, tasks, vm_sequence, +}; #[cfg(target_os = "macos")] -mod native_chrome; -mod native_visual; -mod object_list; -mod presentation; -mod protocols; -mod scene; -mod scroll; -mod snapshot; -mod software_renderer; -mod source_hit; -mod source_layout; -mod source_paint; -mod sources; -mod system_ui; -mod tabs; -mod tasks; -mod text; -mod ui_bridge; -mod vm_sequence; -mod window; +pub(crate) use shell::native_chrome; +pub(crate) use shell::{activity, app, clipboard, developer_tools, sources, system_ui, tabs}; use std::sync::{Arc, RwLock}; diff --git a/src/binding.rs b/src/model/binding.rs similarity index 100% rename from src/binding.rs rename to src/model/binding.rs diff --git a/src/block.rs b/src/model/block.rs similarity index 100% rename from src/block.rs rename to src/model/block.rs diff --git a/src/document.rs b/src/model/document.rs similarity index 100% rename from src/document.rs rename to src/model/document.rs diff --git a/src/model/mod.rs b/src/model/mod.rs new file mode 100644 index 0000000..f17c3b6 --- /dev/null +++ b/src/model/mod.rs @@ -0,0 +1,10 @@ +// Module ownership follows the directory; crate-root aliases keep internal call sites stable. +pub(crate) mod binding; +pub(crate) mod block; +pub(crate) mod document; +pub(crate) mod mount; +pub(crate) mod object_list; +pub(crate) mod presentation; +pub(crate) mod protocols; +pub(crate) mod snapshot; +pub(crate) mod window; diff --git a/src/mount.rs b/src/model/mount.rs similarity index 100% rename from src/mount.rs rename to src/model/mount.rs diff --git a/src/object_list.rs b/src/model/object_list.rs similarity index 100% rename from src/object_list.rs rename to src/model/object_list.rs diff --git a/src/presentation.rs b/src/model/presentation.rs similarity index 100% rename from src/presentation.rs rename to src/model/presentation.rs diff --git a/src/protocols.rs b/src/model/protocols.rs similarity index 100% rename from src/protocols.rs rename to src/model/protocols.rs diff --git a/src/snapshot.rs b/src/model/snapshot.rs similarity index 100% rename from src/snapshot.rs rename to src/model/snapshot.rs diff --git a/src/window.rs b/src/model/window.rs similarity index 100% rename from src/window.rs rename to src/model/window.rs diff --git a/src/display_list.rs b/src/render/display_list.rs similarity index 100% rename from src/display_list.rs rename to src/render/display_list.rs diff --git a/src/geometry.rs b/src/render/geometry.rs similarity index 100% rename from src/geometry.rs rename to src/render/geometry.rs diff --git a/src/layout.rs b/src/render/layout.rs similarity index 100% rename from src/layout.rs rename to src/render/layout.rs diff --git a/src/render/mod.rs b/src/render/mod.rs new file mode 100644 index 0000000..096cfbf --- /dev/null +++ b/src/render/mod.rs @@ -0,0 +1,12 @@ +// Module ownership follows the directory; crate-root aliases keep internal call sites stable. +pub(crate) mod display_list; +pub(crate) mod geometry; +pub(crate) mod layout; +pub(crate) mod native_visual; +pub(crate) mod scene; +pub(crate) mod scroll; +pub(crate) mod software_renderer; +pub(crate) mod source_hit; +pub(crate) mod source_layout; +pub(crate) mod source_paint; +pub(crate) mod text; diff --git a/src/native_visual.rs b/src/render/native_visual.rs similarity index 100% rename from src/native_visual.rs rename to src/render/native_visual.rs diff --git a/src/scene.rs b/src/render/scene.rs similarity index 100% rename from src/scene.rs rename to src/render/scene.rs diff --git a/src/scroll.rs b/src/render/scroll.rs similarity index 100% rename from src/scroll.rs rename to src/render/scroll.rs diff --git a/src/software_renderer.rs b/src/render/software_renderer.rs similarity index 100% rename from src/software_renderer.rs rename to src/render/software_renderer.rs diff --git a/src/source_hit.rs b/src/render/source_hit.rs similarity index 100% rename from src/source_hit.rs rename to src/render/source_hit.rs diff --git a/src/source_layout.rs b/src/render/source_layout.rs similarity index 100% rename from src/source_layout.rs rename to src/render/source_layout.rs diff --git a/src/source_paint.rs b/src/render/source_paint.rs similarity index 100% rename from src/source_paint.rs rename to src/render/source_paint.rs diff --git a/src/text.rs b/src/render/text.rs similarity index 100% rename from src/text.rs rename to src/render/text.rs diff --git a/src/runtime/api.rs b/src/runtime/api.rs new file mode 100644 index 0000000..f9b5c75 --- /dev/null +++ b/src/runtime/api.rs @@ -0,0 +1,680 @@ +mod guide; +mod worker; + +use guide::agent_guide; +use worker::{PendingRequest, RequestProgress, Worker}; + +use std::{ + io::Read, + ops::{Deref, DerefMut}, + panic::{AssertUnwindSafe, catch_unwind}, + sync::{ + Arc, RwLock, + mpsc::{Receiver, SyncSender, TrySendError, sync_channel}, + }, + sync::{ + Condvar, Mutex, + atomic::{AtomicBool, Ordering}, + }, + thread, +}; + +use anyhow::{Context as _, Result}; +use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; +use winit::event_loop::EventLoopProxy; + +use crate::{ + activity::{Activity, ActivityLog}, + app::AppEvent, + document::NamespaceState, + endor_host, +}; + +pub const API_ADDRESS: &str = "127.0.0.1:4737"; +const MAX_CODE_SIZE: u64 = endor_host::MAX_SOURCE_BYTES as u64; +type Notify = Arc; + +#[derive(Clone, Debug)] +pub struct ShutdownOutcome { + pub summary: String, + pub error: Option, +} +type ShutdownCompletion = Arc<(Mutex>, Condvar)>; +#[derive(Clone)] +pub struct RuntimeControl { + sender: SyncSender<()>, + requested: Arc, + completion: ShutdownCompletion, +} +impl RuntimeControl { + pub fn request_shutdown(&self) -> bool { + if self.requested.swap(true, Ordering::AcqRel) { + return true; + } + if self.sender.try_send(()).is_ok() { + true + } else { + self.requested.store(false, Ordering::Release); + false + } + } + pub fn is_exiting(&self) -> bool { + self.requested.load(Ordering::Acquire) + } + pub fn wait_shutdown(&self, timeout: std::time::Duration) -> Option { + let (lock, ready) = &*self.completion; + let result = lock.lock().ok()?; + let (result, _) = ready + .wait_timeout_while(result, timeout, |result| result.is_none()) + .ok()?; + result.clone() + } +} +struct Shutdown { + receiver: Receiver<()>, + control: RuntimeControl, + proxy: EventLoopProxy, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RequestKind { + Event, + Code, + Expression, + Repl, + ResetRepl, +} + +struct BuildRequest { + request: ObservedRequest, + kind: RequestKind, + source: String, + namespace: String, + submission: u64, +} + +pub fn start( + namespace: Arc>, + proxy: EventLoopProxy, +) -> Result<(Activity, RuntimeControl)> { + let (sender, receiver) = sync_channel(1); + let control = RuntimeControl { + sender, + requested: Arc::new(AtomicBool::new(false)), + completion: Arc::new((Mutex::new(None), Condvar::new())), + }; + let shutdown = Shutdown { + receiver, + control: control.clone(), + proxy: proxy.clone(), + }; + let server = Server::http(API_ADDRESS) + .map_err(|error| anyhow::anyhow!(error)) + .with_context(|| format!("bind Tessera API to {API_ADDRESS}"))?; + let notify: Notify = Arc::new(move || { + let _ = proxy.send_event(AppEvent::DocumentUpdated); + }); + let activity = Arc::new(Mutex::new(ActivityLog::default())); + start_server_observed( + Arc::new(server), + namespace, + notify.clone(), + activity.clone(), + notify, + Some(shutdown), + )?; + Ok((activity, control)) +} + +#[cfg(test)] +fn start_server( + server: Arc, + namespace: Arc>, + notify: Notify, +) -> Result> { + start_server_observed( + server, + namespace, + notify, + Arc::new(Mutex::new(ActivityLog::default())), + Arc::new(|| {}), + None, + ) +} + +fn start_server_observed( + server: Arc, + namespace: Arc>, + notify: Notify, + activity: Activity, + observer_notify: Notify, + shutdown: Option, +) -> Result> { + let (sender, receiver) = sync_channel::(8); + let worker_namespace = namespace.clone(); + let worker_notify = notify.clone(); + let exiting = shutdown + .as_ref() + .map(|shutdown| shutdown.control.requested.clone()); + thread::Builder::new() + .name("tessera-endor-worker".into()) + .spawn(move || { + let mut worker = Worker::default(); + let mut pending: Option<(BuildRequest, PendingRequest)> = None; + loop { + if let Some(shutdown) = &shutdown + && shutdown.receiver.try_recv().is_ok() + { + if let Some((job, input)) = pending.take() { + drop(input); + let _ = job + .request + .respond(error_response(StatusCode(503), "application is exiting")); + } + let outcome = worker.shutdown_runtime(); + while let Ok(job) = receiver.try_recv() { + let _ = job + .request + .respond(error_response(StatusCode(503), "application is exiting")); + } + if let Ok(mut current) = worker_namespace.write() { + current.runtime_session = 0; + current.window_commands.clear(); + current.document.frame.session = 0; + current.document.frame.focus = None; + for view in current.document.windows.values_mut() { + view.frame.session = 0; + view.frame.focus = None; + } + } + eprintln!("{}", outcome.summary); + let (lock, ready) = &*shutdown.control.completion; + if let Ok(mut result) = lock.lock() { + *result = Some(outcome.clone()); + ready.notify_all(); + } + let _ = shutdown.proxy.send_event(AppEvent::ShutdownComplete { + error: outcome.error, + }); + break; + } + if let Some((job, input)) = pending.take() { + match worker.resume_request(&job, input, &worker_namespace, &worker_notify) { + RequestProgress::Pending(input) => { + pending = Some((job, input)); + // A soft VM quantum is a control boundary, not a + // minimum-duration time slice. Yield to other ready + // threads without charging a timer delay per quantum. + thread::yield_now(); + } + RequestProgress::Complete(response) => { + let _ = job.request.respond(response); + } + } + continue; + } + worker.refresh_viewport(&worker_namespace, &worker_notify); + let wait = if worker.repl.as_ref().is_some_and(|(_, runtime)| { + runtime.has_pending_refresh() || runtime.has_cooperative_tasks() + }) { + std::time::Duration::ZERO + } else { + std::time::Duration::from_millis(100) + }; + match receiver.recv_timeout(wait) { + Ok(job) => { + match worker.begin_request(&job, &worker_namespace, &worker_notify) { + RequestProgress::Pending(input) => { + if matches!(&input, PendingRequest::BeforeInput) { + eprintln!( + "Tessera worker: submission {} waits for active refresh", + job.submission + ); + } + pending = Some((job, input)); + } + RequestProgress::Complete(response) => { + let _ = job.request.respond(response); + } + } + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + if worker.repl.as_ref().is_some_and(|(epoch, runtime)| { + worker_namespace.read().is_ok_and(|current| { + current.submission != *epoch + && current.runtime_session != runtime.id + }) + }) { + worker.repl = None; + } + if wait.is_zero() { + thread::yield_now(); + } + } + } + } + }) + .context("start Aster execution worker")?; + thread::Builder::new() + .name("tessera-agent-api".to_owned()) + .spawn(move || { + for request in server.incoming_requests() { + if exiting + .as_ref() + .is_some_and(|flag| flag.load(Ordering::Acquire)) + && !matches!(request.method(), Method::Get | Method::Options) + { + let _ = + request.respond(error_response(StatusCode(503), "application is exiting")); + continue; + } + handle( + request, + &namespace, + ¬ify, + &sender, + &activity, + &observer_notify, + ); + } + }) + .context("start Tessera agent API thread") +} + +// Capture only allowlisted headers: credentials and cookies never enter the inspector. +struct ObservedRequest { + inner: Request, + activity: Activity, + id: u64, + notify: Notify, +} +impl ObservedRequest { + fn new(inner: Request, activity: Activity, notify: Notify) -> Self { + let headers = inner + .headers() + .iter() + .filter(|h| h.field.equiv("Content-Type") || h.field.equiv("Content-Length")) + .map(ToString::to_string) + .collect::>() + .join("\n"); + let origin = if inner + .headers() + .iter() + .any(|h| h.field.equiv("X-Tessera-Origin") && h.value.as_str() == "console") + { + "Console" + } else { + "HTTP" + } + .to_owned(); + let id = activity.lock().unwrap().begin( + inner.method().to_string(), + inner.url().to_owned(), + origin, + headers, + ); + notify(); + Self { + inner, + activity, + id, + notify, + } + } + fn record_body(&self, body: &str) { + self.activity.lock().unwrap().body(self.id, body); + (self.notify)(); + } + fn respond(self, response: Response>>) -> std::io::Result<()> { + let status = response.status_code(); + let headers = response.headers().to_vec(); + let length = response.data_length(); + let reader = response.into_reader(); + self.activity.lock().unwrap().finish( + self.id, + status.0, + &String::from_utf8_lossy(reader.get_ref()), + headers + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"), + ); + (self.notify)(); + self.inner + .respond(Response::new(status, headers, reader, length, None)) + } +} +impl Deref for ObservedRequest { + type Target = Request; + fn deref(&self) -> &Request { + &self.inner + } +} +impl DerefMut for ObservedRequest { + fn deref_mut(&mut self) -> &mut Request { + &mut self.inner + } +} + +fn handle( + request: Request, + namespace: &Arc>, + notify: &Notify, + sender: &SyncSender, + activity: &Activity, + observer_notify: &Notify, +) { + let request = ObservedRequest::new(request, activity.clone(), observer_notify.clone()); + let method = request.method().clone(); + let path = request + .url() + .split('?') + .next() + .unwrap_or(request.url()) + .to_owned(); + + let evaluation = namespace_from_route(&path, "/evaluate"); + let repl = namespace_from_route(&path, "/repl"); + let event = namespace_from_route(&path, "/events"); + let route = if method == Method::Put { + namespace_from_path(&path).map(|name| (name, RequestKind::Code)) + } else if method == Method::Post { + event + .map(|name| (name, RequestKind::Event)) + .or_else(|| repl.map(|name| (name, RequestKind::Repl))) + .or(evaluation.map(|name| (name, RequestKind::Expression))) + } else if method == Method::Delete { + repl.map(|name| (name, RequestKind::ResetRepl)) + } else { + None + }; + if let Some((addressed, kind)) = route { + submit(request, addressed, namespace, sender, kind); + return; + } + let response = if method == Method::Get && path == "/agent" { + match namespace.read() { + Ok(namespace) => markdown(StatusCode(200), agent_guide(&namespace)), + Err(_) => error_response(StatusCode(500), "namespace lock is poisoned"), + } + } else if let Some(addressed_namespace) = namespace_from_path(&path).map(str::to_owned) { + code_request(method, &addressed_namespace, namespace, notify) + } else if method == Method::Options { + text(StatusCode(204), "", "text/plain") + } else { + error_response(StatusCode(404), "route not found; start with GET /agent") + }; + + let _ = request.respond(response); +} + +fn code_request( + method: Method, + addressed_namespace: &str, + namespace: &Arc>, + notify: &Notify, +) -> Response>> { + let Ok(current) = namespace.read() else { + return error_response(StatusCode(500), "namespace lock is poisoned"); + }; + if addressed_namespace != current.name { + return error_response( + StatusCode(404), + format!( + "namespace `{addressed_namespace}` is not active; current namespace is `{}`", + current.name + ), + ); + } + + if method == Method::Get { + let mut response = text( + StatusCode(200), + ¤t.document.source, + "text/plain; charset=utf-8", + ); + add_header( + &mut response, + "X-Tessera-Revision", + ¤t.revision.to_string(), + ); + add_header( + &mut response, + "X-Tessera-Session", + ¤t.document.frame.session.to_string(), + ); + add_header( + &mut response, + "X-Tessera-Window", + ¤t.document.frame.window.to_string(), + ); + add_header( + &mut response, + "X-Tessera-Layout-Version", + ¤t.document.frame.layout_version.to_string(), + ); + if let Some(viewport) = current.viewport { + add_header( + &mut response, + "X-Tessera-Viewport-Width", + &viewport.width.to_string(), + ); + add_header( + &mut response, + "X-Tessera-Viewport-Height", + &viewport.height.to_string(), + ); + } + add_header( + &mut response, + "X-Tessera-Source-Kind", + if current + .document + .root + .as_ref() + .is_some_and(|root| root.kind == "repl-session") + { + "repl-transcript" + } else { + "source-snapshot" + }, + ); + return response; + } + drop(current); + + match method { + Method::Delete => match namespace.write() { + Ok(mut current) => { + let revision = match current.clear() { + Ok(revision) => revision, + Err(error) => return error_response(StatusCode(500), error.to_string()), + }; + drop(current); + notify(); + json( + StatusCode(200), + serde_json::json!({ + "ok": true, + "namespace": addressed_namespace, + "revision": revision + }), + ) + } + Err(_) => error_response(StatusCode(500), "namespace lock is poisoned"), + }, + Method::Options => text(StatusCode(204), "", "text/plain"), + _ => error_response(StatusCode(405), "code supports GET, PUT, and DELETE"), + } +} + +fn submit( + mut request: ObservedRequest, + addressed: &str, + namespace: &Arc>, + sender: &SyncSender, + kind: RequestKind, +) { + let source = match read_code(&mut request) { + Ok(source) => source, + Err(error) => { + let _ = request.respond(error_response(StatusCode(400), error.to_string())); + return; + } + }; + request.record_body(&source); + let Ok(mut current) = namespace.write() else { + let _ = request.respond(error_response( + StatusCode(500), + "namespace lock is poisoned", + )); + return; + }; + if current.name != addressed { + let _ = request.respond(error_response(StatusCode(404), "namespace is not active")); + return; + } + let submission = match if kind == RequestKind::Code { + current.next_submission() + } else { + Ok(current.submission) + } { + Ok(id) => id, + Err(error) => { + let _ = request.respond(error_response(StatusCode(500), error.to_string())); + return; + } + }; + let job = BuildRequest { + request, + kind, + source, + namespace: addressed.into(), + submission, + }; + match sender.try_send(job) { + // A rejected queue entry never invalidates accepted work. The worker + // uses this same lock when committing, so publication is atomic. + Ok(()) => { + if kind == RequestKind::Code { + current.submission = submission; + } + } + Err(TrySendError::Full(job)) => { + let _ = job + .request + .respond(error_response(StatusCode(503), "build queue is full")); + } + Err(TrySendError::Disconnected(job)) => { + let _ = job.request.respond(error_response( + StatusCode(503), + "execution worker is unavailable", + )); + } + } +} + +fn superseded(submission: u64) -> Response>> { + json( + StatusCode(409), + serde_json::json!({ "ok": false, "submission": submission, + "error": "submission superseded by a newer PUT or DELETE" }), + ) +} + +fn namespace_from_route<'a>(path: &'a str, suffix: &str) -> Option<&'a str> { + path.strip_prefix("/v1/namespaces/")? + .strip_suffix(suffix) + .filter(|namespace| !namespace.is_empty() && !namespace.contains('/')) +} +fn namespace_from_path(path: &str) -> Option<&str> { + namespace_from_route(path, "/code") +} + +fn read_code(request: &mut Request) -> Result { + if request + .body_length() + .is_some_and(|length| length as u64 > MAX_CODE_SIZE) + { + anyhow::bail!("code exceeds the 1 MiB limit"); + } + let mut code = String::new(); + request + .as_reader() + .take(MAX_CODE_SIZE + 1) + .read_to_string(&mut code) + .context("read code")?; + if code.len() as u64 > MAX_CODE_SIZE { + anyhow::bail!("code exceeds the 1 MiB limit"); + } + Ok(code) +} + +fn compile_error(error: anyhow::Error) -> Response>> { + let mut diagnostic = + serde_json::json!({ "severity": "error", "message": format!("{error:#}") }); + if let Some(source) = error.downcast_ref::() { + diagnostic["line"] = source.location.line.into(); + diagnostic["column"] = source.location.column.into(); + diagnostic["start_byte"] = source.diagnostic.span.start.into(); + diagnostic["end_byte"] = source.diagnostic.span.end.into(); + } + json( + StatusCode(422), + serde_json::json!({ + "ok": false, + "diagnostics": [diagnostic] + }), + ) +} + +fn error_response( + status: StatusCode, + message: impl Into, +) -> Response>> { + json(status, serde_json::json!({ "error": message.into() })) +} + +fn json(status: StatusCode, value: serde_json::Value) -> Response>> { + text( + status, + &value.to_string(), + "application/json; charset=utf-8", + ) +} + +fn markdown(status: StatusCode, value: String) -> Response>> { + text(status, &value, "text/markdown; charset=utf-8") +} + +fn text(status: StatusCode, value: &str, content_type: &str) -> Response>> { + let mut response = Response::from_string(value).with_status_code(status); + add_header(&mut response, "Content-Type", content_type); + add_header( + &mut response, + "Access-Control-Allow-Origin", + "http://127.0.0.1", + ); + add_header( + &mut response, + "Access-Control-Allow-Methods", + "GET, PUT, POST, DELETE, OPTIONS", + ); + add_header( + &mut response, + "Access-Control-Allow-Headers", + "Content-Type, X-Tessera-Origin", + ); + response +} + +fn add_header(response: &mut Response>>, name: &str, value: &str) { + if let Ok(header) = Header::from_bytes(name, value) { + response.add_header(header); + } +} + +#[cfg(test)] +mod tests; diff --git a/src/runtime/api/guide.rs b/src/runtime/api/guide.rs new file mode 100644 index 0000000..b8ef6a2 --- /dev/null +++ b/src/runtime/api/guide.rs @@ -0,0 +1,216 @@ +//! Live agent-facing guide generated from the current namespace. +use super::*; + +pub(super) fn agent_guide(namespace: &NamespaceState) -> String { + format!( + r##"# Tessera Agent API + +Current namespace: `{name}` +Current revision: `{revision}` + +Submit an ordinary Endor script. Tessera embeds Aster's frontend, compiler and VM. +The namespace comes from the request path. Top-level bindings share the candidate +VM's globals; `ui.page().render(root)` selects the visible UI. No main function is +required. Typed helper functions and the older main-returning-View form also work. + +## Code CRUD + +- Read: `GET http://{address}/v1/namespaces/{name}/code` +- Create or update: `PUT http://{address}/v1/namespaces/{name}/code` +- Delete: `DELETE http://{address}/v1/namespaces/{name}/code` + +Send PUT bodies as `text/plain; charset=utf-8`. + +```endor +{example}``` + +## Live Console session + +`POST http://{address}/v1/namespaces/{name}/repl` accepts statements, function + definitions and expressions as text/plain. Consecutive requests use one real VM +and globals dictionary on the execution worker. `title = ui.text("Hello")` creates +an object without displaying it. A later `page.render(root)` mounts its page. UI references share replacement state: after mounting, assigning a new UI value to a reference updates its aliases and mounted positions. `page.unmount()` releases the mount. +`let title = ...` is also supported. `_` retains the actual last value. No input +history is replayed. Responses include session/input IDs, value, binding count, +rendered flag and revision. Before the first mount the page/revision stay unchanged; afterwards successful inputs publish changed mounted references automatically. + +`DELETE http://{address}/v1/namespaces/{name}/repl` resets Console objects and bindings, +leaving the displayed native snapshot intact. Parsing/compilation happen before +execution. On runtime error earlier statements in that input may remain live, but +its pending render is discarded; previous displayed page is preserved. + +Session limits: 256 executed inputs, 1 MiB transcript, 10000 cumulative AST nodes, +4096 retained visual objects and 64 retained pages. Aster collects unreachable objects/cycles between inputs; there is no fixed UI pool. No VM handles cross to the window. +A newer accepted complete-source PUT or code DELETE invalidates queued Console inputs. A running input settles before a queued PUT candidate is built; code DELETE revokes that runtime immediately. +A successful PUT retains its fresh VM as the live program for Console and GUI events; +a failed PUT preserves the previous program. DELETE releases the live context. +GET /code returns the source of the displayed snapshot: exact submitted source for +PUT, an annotated Console transcript after a REPL render. X-Tessera-Source-Kind +identifies source-snapshot versus repl-transcript; a transcript is not a replay log. + +The compatibility `POST http://{address}/v1/namespaces/{name}/evaluate` endpoint +still accepts one isolated expression in a fresh VM without ui or previous bindings. +It does not publish a page or change revision. Console does not use this endpoint. +All execution endpoints share the eight-entry queue. These are in-process bounded +host executions, not a complete untrusted-code sandbox or a process-isolation design. + +## Managed visual types + +`Object`, `Type`, `Function`, `Task`, `Block`, `ContainerBlock`, `TextBlock`, `RectBlock`, +`Spacer`, `Row`, `Column`, `Button`, `Page`, `Mount`, and `Event` are managed nominal +Type objects. Use `page = Page()` and `label = TextBlock("Hello")` in Endor source. +The existing ui constructors return these same typed objects. `page.type`, +`Page.base`, `Page.name`, `isInstance(page, Block)` and `isSubtype(Page, Block)` +expose actual type relations. Types, bases, instances and native constructors live +in the same GC graph. Ordinary source classes support `class Child: Base`, typed field +declarations, explicit first `self` parameters, positional field construction and +member assignment. Inherited methods bind the actual receiver; overridden methods +are dynamically selected through self. Concrete native visual subclasses reuse real native allocators; abstract bases remain unconstructible. Explicit traits and source parameter/return annotation checks are enabled; declared-field construction/write checks are enabled alongside source function checks; complete initialization and static/effect typing remain pending. Native Block/Page methods use the same type method tables and binding path. + +Window is a host-provided managed resource: ui.window(), page.window(), page.open(window), window.pages()/id()/viewport(), resize(width,height), moveTo(x,y), setTitle(text) and focus(). Requests project to their actual native window only after successful input/candidate completion and carry the current runtime session. Resize is asynchronous and the reported viewport is authoritative; this host accepts 1..4096 logical dimensions with a native 720x480 minimum. Failed input/candidates discard pending requests and read-only rendering rejects window effects. Open Pages appear in native tab chrome, with page.title, click activation/close, Ctrl+Tab cycling and Cmd/Ctrl+W close when page keyboard focus is active. Overflow arrows reach additional pages. These operations call actual Page methods in the same VM; tab IDs are weak runtime projections and add no GC roots. The 36-logical-pixel bar reduces Page constraint height; window/ui viewport reports the full inner size and native pointer positions are translated into content coordinates. Window() now creates additional native windows in the same VM, up to 16 open resources. Page.open(window)/moveToWindow(window) preserve Page and business identity while replacing Mounts; detachToWindow() creates and returns a new Window. Dragging a tab away from its strip invokes the same method. Window.close()/secondary OS close remove Page roots, unmount, and retire the resource context; retained Pages can reopen in another live window, and closed Window resources reject viewport/resize/title/focus operations. Closing the primary OS shell exits the application. Secondary viewport inputs and frame publication use the same worker. Secondary close after callback/rendering failure uses host-only teardown without invoking source callbacks or resuming execution. After reset/discard, closing an old picture never touches a fresh VM resource with the same number; viewport inputs are also generation-scoped. A teardown failure discards the affected VM and disables remaining pictures. Native initialization/surface errors are reported rather than panicking; failed secondary creation leaves a managed resource closable from Console. Native tab reordering, existing-window drops and release-point placement use the managed methods described below. Application-exit cleanup is described below. + +Application exit now uses a private VM-worker control channel, independent of HTTP queue capacity. Main native close and a managed primary Window.close request asynchronous cleanup; duplicate requests coalesce and mutation requests are rejected with 503 while exiting. An unpublished candidate/input refresh is cancelled without replay. The current VM cancels all Task entries, closes managed windows, detaches occurrences child-before-parent per window, and invokes normal onUnmount hooks once. It does not run layout/paint during shutdown. All windows are closed before hooks run; new windows and tasks are forbidden. A stopped/faulted VM detaches without source hooks; reset/discarded pictures cannot revive an old VM. A hook error aborts remaining source hooks, releases prepared hook roots, drops the VM and reports failure before native exit. Completed shutdown is acknowledged to the GUI, which then ends the event loop. An OS exit bypassing CloseRequested uses a five-second bounded final fallback; non-cooperative native calls remain atomic and force-kill/crash cannot guarantee notifications. Native stderr records the window/mount/task-entry counts and whether source hooks were enabled, or the cleanup error. See examples/exit-lifecycle.endor. Cross-platform exit/drag validation remains broader follow-up work. + +Foreground REPL/events now retain a BeforeInput request while an existing idle layout/paint refresh advances one stage/quantum per worker turn. They execute their source only after that refresh settles, so mutations never enter its read-only scope. New task callbacks are not delivered ahead of this accepted input. Shutdown can cancel the waiting request with 503, and superseded submissions still return 409. Reset cancels/discards the old VM instead of synchronously draining its unpublished refresh. Source parsing/compilation and ordinary synchronous source/native calls remain atomic boundaries; this change removes the worker's synchronous pre-input refresh drain. Native stderr records the submission once when a request first waits for an active refresh; it does not log source text or repeat the line for each quantum. Ready layout/paint/coroutine quanta and pending input/candidates yield to ready OS threads without a fixed timer delay; queue, shutdown, generation and viewport checks still run between quanta. Only idle work or external waits use the normal 100 ms polling wait. + +Source layout/paint now validate name-level read observations from Aster: each globals/builtins lookup records its text key and observed presence/value, while dictionary kind checks remain separate. Updating the REPL result `_` or another unread global no longer invalidates these frames solely because the whole globals dictionary version changed. Reading `_` still observes its value; adding a previously missing global correctly invalidates a builtin fallback. Ordinary native dictionary iteration/content reads retain whole-object version dependencies. Measurement cache hits replay their recorded observations into the outer solve; they do not drop dependencies merely because source execution was skipped. Cache observations contain borrowed handles (including object-valued names), add no GC roots, and never write comparison snapshots back into language objects. Invalid-name diagnostics report only identity/key, not captured values. Block binding uses the same precise observations through the synchronous read-only API; other legacy callers of the object-list API remain conservative. See examples/global-dependencies.endor. + + + + +While a native tab drag is held over an overflow arrow area, the strip waits 350ms then reveals one hidden tab every 120ms, including in a compatible destination window. The preview scroll does not activate Pages or change business state. Drawing, hit testing and insertion gaps share the same transient visible range; release uses its full-order insertion index. Leaving the strip stops the timer; release, Escape, a new press or runtime generation change restores the normal active-tab-centered strip. A resize or changing tab count clamps the range. Native drag/mixed-DPI behavior still requires platform validation; application-exit cleanup is described below. + + +ContainerBlock.insert(position,block) accepts a zero-based position through the current count, returns a new stable slot and preserves existing slots/Mounts. Invalid indices and child cycles fail before changing the child list. Page.moveTab(position) uses a final zero-based index in its owning window. Native drops invoke the actual Page moveToWindow/moveTab/activate methods; a declined or redirected window move skips destination reordering. A drop allows 300000 instructions per method. Source overrides participate; earlier writes do not roll back after failure. Window.moveTo(x,y) queues the physical desktop outer origin, with signed integers within +/-1000000. Detached placement preserves the saved grab offset; the window manager may adjust it and unsupported global placement reports a diagnostic. Dragging uses a 12-logical-pixel threshold, visible insertion marker and Escape cancellation. Overflow auto-scroll, external-app occlusion and mixed-DPI verification remain pending. See examples/tab-placement.endor. + +Source measure and arrange overrides execute bottom-up and top-down in one read-only VM layout call. Structural Mount links exist before first measurement; bounds queries reject premature geometry. Native base layout uses current occurrence child measurements, and Arrangement must assign every stable child slot exactly once. Layout dependencies changed by lifecycle hooks trigger another pass before publication. Page constraints now use the current logical window viewport. Column/Button propagate cross-axis width and Row propagates cross-axis height; desired sizes are clamped. ui.viewport() provides a managed snapshot whose private input participates in rendering dependencies. Native resize updates are coalesced on the same worker without adding source inputs. Source measurement caches use Mount/method/children/constraints and actual subcall object reads, including returned-result decoding; Mount-reading methods conservatively bypass caching. ui.stats() exposes sourceMeasureHits/sourceMeasureMisses outside read-only rendering. Native TextBlock wrapping now uses the occurrence width in measurement/cache/painting; source measurement/arrangement now repeats until constraints and geometry settle; additional allocation policies remain pending; ScrollBlock scrolling is described below. + +Source layout solves repeat in one read-only call until constraints/sizes/bounds are unchanged, reusing one bytecode frame. Source-arranged changed dimensions become exact per-axis constraints for this solve; source container measurements differing from native results constrain content using the same container policy. Selected limits reset on later inputs/resizes. Nonconsecutive repeated states report a cycle. No fixed pass/hop cutoff is used; all passes share the 1000000-instruction/128-frame budget and budget failure is explicit. Measurement clears provisional Mount geometry, and only complete settled frames publish. ui.stats().layoutPasses reports work; source/cache counters include all passes. VM annotation checks distinguish validation-only from content reads, preserving Mount-typed independent measurement caching; actual Mount reads still bypass conservatively. Resumable solving remains pending. See examples/layout-negotiation.endor. + +TextBlock now wraps words/glyphs at its available width and measures the resulting height. Native and source-base measurement paths use current constraints; native shape/font caches include wrap width. Painting carries allocated width/height, clips glyphs and keeps ancestor scroll clipping. Source text PaintCommand may supply both wrapWidth/wrapHeight (finite nonnegative logical dimensions up to 1000000); neither preserves legacy unwrapped paint, and a partial pair is rejected. Native TextBlock.paint supplies the pair. These are derived draw bounds, not another business object. Management UI text remains unwrapped. Resumable solving and broad fractional-DPI validation remain pending. See examples/wrapped-text.endor. + +Source paint overrides execute in the actual frame pipeline under a read-only heap policy. Source hitTest overrides similarly refine native pointer targets in VM paint order; Point.inside(bounds) and Point.inCircle(cx, cy, radius) provide primitive shape queries. Managed DisplayList/ObjectList/PaintCommand and Constraints/Bounds/Point results are ordinary runtime objects. + +ScrollBlock(width,height,child) is a ContainerBlock with managed viewport dimensions and independent per-Mount offsets. mount.scrollTo(x,y), scrollBy(dx,dy), scrollPosition() and scrollIntoView() operate on actual occurrences. Position snapshots expose x/y/maxX/maxY from committed layout; relative requests accumulate and offsets clamp after content/viewport changes. Detached Mounts and read-only rendering mutations are rejected. scrollIntoView and requestFocus adjust nested scrollable ancestors. Native/source paint, focus outlines, hit testing and pointer selection share translated geometry and ancestor clips. Remounting starts a new offset. Paint x/y may be signed after scrolling; dimensions/radii remain nonnegative and coordinate budgets still apply. See examples/scroll.endor. Wheel chooses one eligible ancestor (not per-axis split); native scrollbar dragging, touch inertia and PageUp/PageDown remain pending. + +Native wheel POST /events uses kind scroll with the normal window/session/layout_version, a scrollable mount ID, point=[x,y], delta=[dx,dy], and no press_point. Deltas are signed finite logical pixels within +/-1000000; positive moves toward the end of content. Other kinds must omit delta. Native Shift+wheel maps to horizontal. The host checks the active committed frame, target attachment and clipped viewport before invoking Mount.scrollBy. These fields are transport metadata; source code uses the Endor methods above. + +ui.after(owner,key,milliseconds,callback) returns Task. The zero-argument callback runs on the owning VM worker after at least the integer 0..86400000 ms delay and must return Unit. Idle polling can add up to 100 ms; execution load may delay delivery further. Task.cancel/active/status expose cancellation, eligibility and pending/running/completed/cancelled/failed state. One owner/key replaces the prior pending task. Owner scopes and callback captures are managed strong edges; scheduler and owner links are weak. ui is an application owner. Hidden live objects continue; Page close cancels its and its Mount tasks, even close/reopen within one input. Page movement preserves Page tasks but replaces Mounts. Window close cancels its Pages/tasks; reset/DELETE/program replacement discard old scheduler/VM and generation checks reject stale publication. Failed PUT preserves old tasks. Callback failure stops the domain and cancels remaining tasks; earlier writes do not roll back. Read-only rendering cannot schedule/cancel. Limits: 64 pending per owner, 1024 per runtime, 64 delivered per poll, 128 frames/100000 instructions per callback. Providers support timers, asynchronous text-file reads and HTTP(S) text GET; await is supported in ui.spawn calls. + +Source function parameter annotations are checked before the body and return annotations before returning. Object accepts any live value; Unit accepts nil; Number, String/Text and Bool match scalar kinds. Function includes source/native functions and bound methods, while callable class definitions remain Type. View is a compatibility alias for Block. Other names resolve actual Type/Trait objects in the function's globals then captured builtins at each call. The method's own class uses the receiver's actual owning definition, preserving old bound methods after REPL redefinition. Other type names are not frozen at definition. UnknownAnnotation and TypeMismatch report failures; bad returns do not roll back earlier writes. Declared-field construction and set_attr writes are also checked, as described below. Static inference, generic and static effect typing remain pending. + +Declared fields are checked at construction and set_attr writes through the same enabled type-check switch. Native subclass fields are validated before native allocation/opening effects. Failure preserves the field; declared deletion is rejected. Inherited fields use their declaring definition/environment, the declaring class name keeps its actual identity after REPL redefinition, and other names resolve dynamically in declaration globals/builtins. Shared Block forwarding selects the current content type/storage. This does not scan old objects or revalidate all referring fields after shared-reference changes; trusted payload/storage/attach operations and complete initialization remain host responsibilities. Static/effect typing and local/captured type annotations remain pending. See examples/field-contracts.endor. + +Native methods now publish positional CallArity intervals, checked by the VM before invocation. Source defaults and bound receivers participate in the same query. Traits validate acceptance of their canonical count including self and reject native methods without metadata. Event listeners/onClick require Function metadata accepting zero arguments or one Event, preferring Event if both work. Binding computes and Task callbacks require zero-argument acceptance before registration/replacement. Generic Type constructor reflection and complete native signature coverage remain separate gaps. See examples/native-signatures.endor. + +Native CallSignature checks resolved parameter/return types for visual measure/arrange/paint/hitTest, Point queries, Window/Block/Container/Page/Mount/Task/Event/Subscription methods and ObjectList.at. Signature definitions are traced managed edges. Parameters fail before dispatch; final results delegated to source/native functions must satisfy all pending native return contracts, with rooted definitions and the same frame/instruction budgets. Wrong returns release scoped results but do not undo earlier writes. CallEffect ReadOnly allows result allocation; Delegated allows a read-only native wrapper while delegated targets keep the same VM read-only/budget scope (ui.present); Mutating/External calls are rejected before invocation during read-only execution. Block.click returns its generated Event; Block.dispatch returns the delivered Event. Dispatch/click and lifecycle handler contracts permit External effects; the default no-op does not constrain native overrides to ReadOnly. Existing heap barriers still apply, and this does not sandbox trusted native I/O. CallType unions preserve every branch definition. Page.open accepts Window or nil; page.window returns Window or nil; Block.on accepts Bool or String for its optional phase, while phase names retain domain validation. Mount relation queries are nullable, and bounds() is a real Bounds object usable by Point.inside. Native union metadata does not add Endor union syntax. Source method overrides now enforce parameter contravariance, return covariance and accepted-count preservation at class creation/method replacement, with call-time rechecks for dynamic annotation names. Ancestor replacement checks overriding descendants atomically. Unknown forward names may defer until invocation; known incompatibilities reject before effects. Known effects may only shrink; conservative source/call-graph ReadOnly proofs supplement runtime barriers. See examples/method-contracts.endor. Public ui functions, captured state methods and native factories publish signatures after the registry exists. Factories check the Type receiver/general result/effect and split native options from requested-class fields before validating both; omitted Rect/Text options and Row/Column variable children cannot absorb subclass fields. Event/ObjectList keep dedicated member/field checks. Generic Type reflection still has no single fixed constructor signature. See examples/constructor-contracts.endor. Current global/builtins/closure/receiver call graphs may prove ReadOnly without execution; unresolved targets/results, shared writes and Delegated targets remain unknown. Unknown legacy metadata remains unknown. See examples/visual-protocols.endor and examples/layout-negotiation.endor. + +Trait definitions Visual, EventTarget, Children and Presentable are managed nominal capabilities. ui.implement(type,capability) explicitly declares an implementation after required-method/positional-arity/type/known-native-effect checks; ui.implements(type,capability) and ui.supports(value,capability) query it. Block declares Visual/EventTarget; ContainerBlock declares Children, inherited by Page. Same-name methods alone never declare an implementation. Source subclass construction and host method replacement validate inherited/affected descendant contracts. Source/native positional intervals are checked, and missing native arity metadata prevents a capability declaration; source parameter/return annotations are checked at invocation. These capabilities use typed signatures: registration checks parameter contravariance, return covariance and known native effects. Actual calls recheck dynamic names and final results across delegation, with rooted return definitions. Explicit typed method annotations remain checked even if a different host disables global source checks; concrete implementations keep wider inputs and optional convenience forms. General source effects and formal trait syntax remain pending. EventTarget requires canonical on/dispatch/onEvent arities 3/2/1 after self; native optional convenience forms remain. + +For a plain object explicitly implementing Presentable, present(self, window: Window): Block is invoked by ui.present(value) or ui.open(value), using the current managed Window and the same VM budget. The result must be an actual Block. Blocks pass through directly; unregistered values retain the summary fallback. Source views retain their business object via managed fields/callbacks/bindings, as in examples/presentable.endor. Viewing function definitions does not itself execute them. Rendering methods still run read-only with nominal result checks. + +Block.bind(property, compute) binds an existing managed Block field to a zero-argument function and returns Subscription (disconnect/isConnected). This includes source-defined fields and native TextBlock.text/size/color, RectBlock.width/height/radius/color, Spacer.size and container padding/gap. Native fields can also be assigned directly. The target content owns compute/captures; tokens and host registry are weak. Rebinding disconnects the old connection; disconnect keeps the last value. Actual reads track global-name presence/value separately from whole-object reads; ordinary dictionary content access remains conservative. No explicit source-list parameter is required. Unrelated global updates reuse valid compute results, while direct output overwrites still trigger repair. ui.stats().bindingEvaluations counts computations in completed refreshes over this VM lifetime, including initial and hidden bindings. Hidden retained Blocks update at successful input/event stabilization boundaries before all windows publish. Computes are read-only for existing objects and may allocate results. They have 100000 instructions/128 frames each, 128 active bindings per Block and a 10000-computation refresh limit that errors rather than truncates propagation. Binding failure stops the live VM and preserves published pictures. Non-Block targets, explicit dependency lists and general type/effect checking remain pending. See examples/property-bindings.endor. + +Native input POST /events is a transport envelope, not Endor source. Each frame and input carries an opaque runtime window identity; omitted input window defaults to 1 for existing clients. GET /code exposes X-Tessera-Window. A window/session/layout identity mismatch is rejected before invoking code. Tab actions use kind tab-activate/tab-close/tab-detach/tab-drop and the mount wire field carries the opaque tab ID from frame.tabs; point/press_point must be omitted. tab-drop additionally requires tab_drop={{window,layout_version,index}}, where index is a published destination gap from 0 through its tab count (at most 64). Both source and destination orders must still match their published frames. tab-detach alone accepts optional screen_position=[x,y] as signed integer physical desktop coordinates within +/-1000000; other kinds must omit it. All spatial/tab actions validate session and layout_version before dispatch. Resource window-close uses mount=0 and no points; it validates the live window/session rather than its old layout version. The window +sends current session, layout_version and Mount identity; stale positions fail without calling a callback. Pointer clicks also include point and press_point [x,y] in logical page coordinates. Both targets are resolved through read-only hitTest, with fallback behind a rejected candidate; a click is dispatched only when both resolve to the same Mount. Keyboard/code clicks omit coordinates and remain semantic activation. GET /code also exposes X-Tessera-Session and X-Tessera-Layout-Version headers for transport clients. Native mouse, Tab, Enter and Space delegate to the same +managed click/focus methods used by code. Events appear in Network, not Console source history. + +ScrollBlock.axis is a managed visual attribute: "both" (default), "vertical" or "horizontal". The default preserves two-axis unconstrained content. Vertical restricts content width to the actual occurrence viewport minus padding; horizontal restricts height. Source-arranged viewport dimensions are used in subsequent measurement. The disabled scroll axis has zero offset/limit and is clamped after updates. Composition remains vertical; a Row child can arrange horizontal content. See examples/scroll-axis.endor. + +ContainerBlock.mainAlign accepts "start" (default), "center", "end" or "space-between". Native arrangement uses allocated Bounds after padding/minimum gap, distributes positive remaining main-axis space and keeps child sizes unchanged. Overflow keeps start placement. Explicit source arrange placements remain authoritative; this is alignment, not flex sizing. + +Block.flex is a nonnegative weight (default 0). Native containers reserve fixed measured children, padding and gaps, then share remaining allocated main-axis space between positive weights with zero basis. Actual child allocation feeds remeasurement. It does not request parent expansion. Further controls are described below. + + +Flex main-axis policy: flexBasis is "auto" by default (zero basis when flex > 0, otherwise the first measured size of this occurrence in the current solve); a number supplies an explicit basis. flexMin defaults to 0, flexMax to 1000000, and flexShrink to 0. All numbers are finite/nonnegative and at most 1000000; flexMin must not exceed flexMax. The parent clamps bases to min/max, distributes positive space by flex, and redistributes space left after a maximum is reached. Deficits shrink by flexShrink times the original basis, freezing at minima. A fixed child can opt into shrink without growth. Unresolvable minimum overflow is preserved, and remaining positive space follows mainAlign. The policy applies to width in a Row and height in Column-like containers; these are not independent width/height bounds. Source arrange overrides remain authoritative. Preferred sizes are derived per occurrence and reset for each new solve, never shared through Block state. See examples/flex-constraints.endor. Layout continuation remains unfinished. + + +When an occurrence's cross-axis constraint changes during settling, its preferred main-axis size is invalidated and remeasured. Any obsolete native flex allocation on that axis is removed for the replacement measurement, so narrowing a nested Column can increase wrapped text height. Source arrange allocations remain explicit. Preferred-size presence and values participate in convergence/cycle detection. + + +The source layout driver now uses Aster's resumable read-only call API with a soft quantum of 256 instructions. Pauses retain the same VM frames, operand/local roots, return contracts and read tracking; total instruction/frame limits still apply across slices. ui.stats().layoutSlices counts slices in the most recent solve (native fast path: 1). The worker currently resumes Pending immediately after yielding its OS time slice. Parking the solve across worker turns, scheduling other work and handling newer inputs before completion are not implemented yet. Native callbacks are not preempted. Only the completed settled frame is published. + + +source_layout::start now returns either Complete(Draft) or Pending(Work). The host-owned Work retains the unpublished frame, derived runtime/cache snapshots, convergence plan and prepare root without borrowing caller frame/cache variables. Work::resume consumes the saved job and advances the same VM continuation; Work::cancel closes that continuation and releases the prepare root without merging a draft. A second start is rejected while any read-only execution is active. Every pending Work must be resumed or explicitly cancelled with its owning VM; dropping it is not cancellation. Synchronous ReplSession operations drain the same saved refresh state. The worker parks refresh jobs; cancellation restores still-pending Mount preparation. + + +UiBridge::begin_layout_frame/resume_layout_frame now expose PendingLayout around the source job. This bridge-level continuation also owns its target window, page snapshot, tab projection, viewport restoration and retained lifecycle hook root. Only the Complete branch projects scroll/focus, captures dependency versions, merges derived caches, commits Mount geometry and runs deferred hooks. Resume restores the saved window context, and failure releases hook roots. ReplSession uses this protocol through its saved refresh state. Viewport refresh supports cross-worker-turn scheduling and prepared-Mount rollback; callers must not drop a pending bridge layout as a substitute for cancellation. + + +Viewport refresh now advances through ReplSession's saved refresh state on worker idle turns. A turn advances one traversal stage or a 256-instruction layout quantum; a pending job shortens the next worker wait to 1 ms. Tasks/window commands and document publication wait for complete refresh. ui.stats().refreshYields reports cooperative idle returns. Mutating requests wait across worker turns for the existing job, so they cannot run inside its read-only scope. Mutating requests are serialized; newer viewport targets coalesce by cancelling unpublished layout. Initial source execution remains atomic; initial layout parks across worker turns. Explicit REPL/event refresh parks across worker turns. Idle tasks deliver at most one callback per turn and park subsequent layout; callbacks and individual native nodes remain atomic. Large intrinsic trees use the sliced driver; small intrinsic trees keep the fast path. + + +Runtime mutation entry points reject direct calls while refresh work is pending, before executing source or changing metadata; task polling defers. The API worker drains the saved refresh before accepting those mutations, keeping the runtime usable. Incremental refresh keeps the prior complete document map until a stable pass, including across window traversal boundaries where no VM read-only frame is currently active. + + +New viewport targets now coalesce pending refresh work. The runtime cancels only an unfinished bridge layout, restores its Mount checkpoint, preserves completed per-window drafts/callback effects and restarts traversal with the latest viewport map. refreshRestarts counts target replacements; layoutCancels counts replacements that actually closed a pending VM layout. Unchanged targets resume the same job. A new external target resets its convergence budget; internal settling does not. Ordinary source/event mutations still serialize behind refresh rather than roll back application effects. + + +Cancellation checkpoints own explicit VM roots until restored or released. Initial presentations may have no mount dictionary; cancellation restores that absence too. Saved metadata values retain object-valued attributes. A pending refresh can restart while keeping already completed window drafts; no onMount/onUnmount callback is replayed merely because the viewport target changed. Mount-owned tasks are cancelled immediately before an actual onUnmount callback, never during abandoned preparation. + +REPL/event requests retain their HTTP request and scalar result until the complete window set settles. The worker advances one stage/quantum per turn, takes current native viewport targets each time and keeps later mutations queued. GET /code keeps returning the last complete publication. Response metadata and native commands publish together after settling; no source/event callback is replayed. A running input can finish before a newer queued PUT, preserving a consistent old runtime if that candidate fails; DELETE revokes the owner and prevents its late publication. Source execution/callbacks and structural preparation and final command projection remain atomic; source paint execution can yield. + +Complete-source PUT also parks an isolated candidate VM during initial layout. The worker retains its HTTP request and the prior live VM, advances one candidate stage/quantum per turn and commits only after its full window set settles. New PUT/DELETE submissions discard obsolete candidates and explicitly cancel their pending layout. Failure keeps the prior live owner; accepting a replacement alone does not revoke that owner. Candidate viewport updates use only the surviving primary shell; secondary native resource IDs and sizes still belong to the old VM until successful publication. Parsing, compilation, source execution, structural preparation and final command projection remain atomic; source paint execution can yield. + +Source paint now uses a resumable read-only VM call with the same 256-instruction quantum and a cumulative 1,000,000-instruction budget. The pending job owns the unpublished frame, visual snapshot, decoded command buffer and prepare root. The refresh worker resumes paint before advancing to another window and publishes only the full stable window set. New viewport targets cancel unfinished paint, discard partial commands and force that window to refresh. Already completed layout/Mount geometry and lifecycle callbacks remain committed; cancelling paint does not replay or undo them. Failure/discard explicitly closes the read-only continuation and releases its roots. paintSlices reports the most recently completed paint solve (1 when the solve completes within its first quantum); paintCancels counts viewport replacements that closed pending paint. Layout checkpoint rollback remains limited to unfinished layout preparation. Native callback bodies, driver preparation, command decoding/projection and rasterization remain atomic. + +Layout and paint drivers now prepare one occurrence at a time. Their fixed-size outer bytecode repeats on the same VM frame; a short-lived helper retains only the current call, its bounds/arguments and collector. The helper is released before the next occurrence. Layout keeps bottom-up measure, top-down arrange and the same settling loop. Paint advances even intrinsic method classification through VM quanta. The weak occurrence list and native scratch/output data remain proportional to the actual tree, but preparation no longer allocates managed callables/Bounds for the whole tree before the first slice. All helpers share the original read-only scope and cumulative instruction/frame limits. Weak-list copying, structural extraction, settling and final projection remain bounded atomic native work. + +ui.stats().dependencyInvalidations counts observed layout/paint dependency mismatches. lastInvalidDependency records the phase, object kind/identity and old/current versions without rendering its fields or retaining it. An input changing its globals is an expected invalidation; compare idle document versions before diagnosing a refresh loop. + +ui.readText(owner,key,path[,callback]) returns an owner-scoped Task and reads a regular UTF-8 file off the VM thread. Paths use native filesystem syntax; absolute paths are recommended. The optional callback takes zero arguments and must return Unit; it runs only on successful read and can use task.result(). Without a callback, poll status then read result from code. result() is available while a success callback is running or after completion (timers return Unit); error() returns an empty String until failed, then the provider/callback error. A provider failure marks the task failed and skips its callback without stopping the VM; a source callback failure still stops the shared domain. Owner/key replacement, explicit cancellation, close/reset/VM discard and GC drop the provider lease, remove queued completion and reject late delivery. In-progress OS reads may finish, but cannot deliver after cancellation. Only the owning VM worker updates managed results and rechecks owner/key identity. No VM handles enter provider threads beyond opaque weak identities. + +The lazy shared pool has four threads and a 64-job queue. Each runtime permits 16 outstanding I/O requests (one temporary replacement reservation), and each read is capped at 1 MiB by metadata plus actual streaming length; non-regular files, non-UTF-8 data and over-limit content fail rather than truncate. These limits do not constitute a full filesystem sandbox. The provider returns text in the same managed Task, not a second application-state store. Task await is supported through ui.spawn; see the await contract below. See examples/async-file.endor; from REPL call reader.read("/absolute/path.txt") to update its actual TextBlock, even when that object is hidden but retained. + +ui.fetchText(owner,key,url[,callback]) performs an asynchronous HTTP(S) GET using the same Task, owner/key replacement and success-only callback protocol as readText. Example: fetcher.fetch("http://127.0.0.1:4737/agent") after loading examples/async-network.endor. The example retains the complete result in fetcher.response and displays completion status; it does not put arbitrary network content into a TextBlock, whose own 16 KiB limit is independent of the provider limit. Only 2xx responses produce a UTF-8 result; HTTP failures, invalid UTF-8, oversize bodies and transport errors set status() to failed with error(). It verifies HTTPS certificates, follows at most five HTTP(S) redirects, has a 10-second connect and 30-second total transfer timeout (starting when a worker takes the request), and bounds decoded text to 1 MiB without truncation. URLs are limited to 8192 bytes and must not contain credentials or control characters. This GET API does not accept request bodies, custom headers or authentication. + +The current transport uses a separate system curl process per request (/usr/bin/curl on Unix, curl.exe on Windows); missing or incompatible curl fails the Task. User curlrc and URL globbing are disabled; URL data is passed through escaped stdin configuration rather than command arguments. TLS and normal system proxy behavior remain enabled. Cancellation kills an active HTTP process immediately and the provider worker reaps it; the VM thread never waits for the process. All file and HTTP requests share the per-runtime 16-task limit and global four-worker/64-job queue. In-progress file reads remain cooperatively cancelled at delivery. Network access is not a security sandbox, and this is a text GET provider rather than a general HTTP client. Task await is supported through ui.spawn; see below. + +ui.spawn(owner,key,function) creates an owner-scoped cooperative Task for a zero-argument function; its returned value is retained as the Task result. Each owning VM poll resumes at most a 256-instruction quantum per selected Task, rotates runnable tasks fairly, and retains source frames in an Aster-managed coroutine edge. Calls and writes may interleave between quanta; prior writes are not rolled back on cancellation or error. Pending/running Task replacement, owner closure, GC and reset release parked frames through the same managed graph. Native calls remain atomic and all quanta share the 128-frame/100000-instruction callback limit; a native entry may run during preparation. result() is unavailable until spawn completes. A source error still stops the shared runtime. See examples/cooperative-tasks.endor. Endor await expressions now suspend these calls on a Task without replaying the operand. See the await contract below. + +Inside a function run by ui.spawn, `value = await task` waits for any timer/file/network/spawn Task and resumes at the same expression with its retained result. A source function may return a Number, String, managed Object or Unit; normal source annotations still apply. Task.result() and await share the result. `ui.sleep(owner,key,milliseconds)` creates a callback-free timer Task returning Unit. Plain calls/REPL can await an already completed Task; a pending await requires a coroutine and fails explicitly rather than blocking. There is no new async function declaration: ui.spawn explicitly schedules an ordinary Endor function. See examples/await-file.endor. + +A waiting coroutine retains its evaluated operand and frames as managed edges; waiting performs no VM execution or active polling. Source side effects and IO initiation before await are not replayed. When the dependency completes, the owning VM worker resumes the waiter through the existing fair quantum scheduler. Cancelling/replacing/closing the waiter releases its frames and operand; it does not automatically cancel an independently owned or shared dependency. Dependency cancellation propagates cancellation to its waiters without running the remainder. An unhandled dependency failure becomes a source await error (with the original provider message), fails the waiter and follows the existing shared-runtime fault rule; provider failure alone still only fails its own Task. Self-await and cyclic Task waits fail explicitly. Read-only layout/paint cannot await. Waiting state is reported as running, while active() remains true; result() is unavailable until completion. These APIs do not introduce try/finally or change the existing exception model. + + + + +## UI host object + +Positional arguments only: + +- `ui.text(value, size = 1, color = "#242422")`; integer size 1..8. +- `ui.rect(width, height, radius = 0, color = "#DCE8FF")`. +- `ui.column(padding, gap, child...)` and `ui.row(padding, gap, child...)`. +- `ui.spacer(size)`. +- `ui.page()` creates a page; `page.render(root)` mounts its root; `page.unmount()` releases the mount. The last render + in a successful submission selects the page shown in this namespace. + +Defaults above describe the host API; do not write keyword arguments in Endor. +Colors use #RRGGBB or #RRGGBBAA. Dimensions are finite numbers in 0..1000000. +Use newlines between complete top-level statements. Multiline calls, function +bodies and expression continuations are passed to Aster as complete inputs. +Limits: 1 MiB source, 4096 constructed nodes, 64 pages, 64 UI levels, 10000 expanded +layout nodes, 16 KiB per text, 128 VM frames. Script statements each receive an +equal share of a total 1000000-instruction allowance. Older module initialization +and main execution each retain their own 1000000-instruction allowance. + +PUT waits for its worker result. Errors return 422 with diagnostics and preserve +the last successful revision. A newer accepted PUT or DELETE supersedes older +work (409); a full eight-entry queue returns 503. DELETE immediately clears the +document. GET remains available while Aster executes. The window redraws from an +owned snapshot; resize does not rerun Endor. Text uses system fonts, antialiasing +and font fallback; Chinese needs an installed CJK font. Size 1 is 14 logical pixels +with 20-pixel line height; larger sizes multiply both. Native Block methods include +setText/setColor, container append/insert/remove/replace/move, Page open/close/navigation, +and code-driven button.onClick/click. ui.state and ui.textOf provide explicit text +binding. Mouse routing, tab chrome/multiple windows and incremental layout remain +in progress; process isolation is future work. Console retains objects +only in its explicitly resettable in-memory VM. It is not an untrusted-code sandbox. +"##, + name = namespace.name, + revision = namespace.revision, + address = API_ADDRESS, + example = include_str!("../../../examples/page.endor"), + ) +} diff --git a/src/api/tests.rs b/src/runtime/api/tests.rs similarity index 98% rename from src/api/tests.rs rename to src/runtime/api/tests.rs index 70a3ec7..555a3c7 100644 --- a/src/api/tests.rs +++ b/src/runtime/api/tests.rs @@ -9,7 +9,7 @@ use std::{ #[test] fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() { let (_, mut picture) = endor_host::ReplSession::load( - include_str!("../../examples/windows.endor"), + include_str!("../../../examples/windows.endor"), "app.main", 1, 150, @@ -250,7 +250,7 @@ fn console_keeps_memory_until_reset_or_complete_put_without_erasing_the_page() { assert_eq!(client.request("POST", repl, "retained = 42").0, 200); assert_eq!( client - .request("PUT", code, include_str!("../../examples/page.endor")) + .request("PUT", code, include_str!("../../../examples/page.endor")) .0, 200 ); @@ -274,7 +274,7 @@ fn http_executes_real_endor_preserves_failed_updates_and_supports_delete() { api: Some(api), }; let route = "/v1/namespaces/app.main/code"; - let example = include_str!("../../examples/page.endor"); + let example = include_str!("../../../examples/page.endor"); let (status, _, guide) = client.request("GET", "/agent", ""); assert_eq!(status, 200); assert!(guide.contains("let myui = ui.page()")); @@ -392,7 +392,7 @@ fn http_delete_remains_available_and_invalidates_queued_put() { let release = Release(release_tx); let client = &client; let route = "/v1/namespaces/app.main/code"; - let example = include_str!("../../examples/page.endor"); + let example = include_str!("../../../examples/page.endor"); let first = scope.spawn(move || client.request("PUT", route, example)); entered_rx.recv_timeout(Duration::from_secs(10)).unwrap(); let pending = scope.spawn(move || client.request("PUT", route, example)); diff --git a/src/runtime/api/worker.rs b/src/runtime/api/worker.rs new file mode 100644 index 0000000..3eada4e --- /dev/null +++ b/src/runtime/api/worker.rs @@ -0,0 +1,920 @@ +//! Serial VM ownership, incremental requests, publication and shutdown. +use super::*; + +type HttpResponse = Response>>; +type InputOutcome = + std::thread::Result)>>; + +type CandidateOutcome = std::thread::Result< + Result<( + endor_host::ReplSession, + crate::document::RenderDocument, + Vec, + )>, +>; + +pub(super) struct PendingInput { + epoch: u64, + id: u64, + event: bool, + result: endor_host::ReplResult, +} + +pub(super) enum PendingRequest { + BeforeInput, + Input(Box), + Candidate(Box), +} + +pub(super) enum RequestProgress { + Pending(PendingRequest), + Complete(HttpResponse), +} + +fn current_viewports( + current: &NamespaceState, +) -> std::collections::BTreeMap { + let mut viewports: std::collections::BTreeMap<_, _> = current + .window_viewports + .iter() + .filter(|(id, _)| { + **id == crate::window::MAIN_WINDOW + || (current.runtime_session != 0 + && current + .document + .windows + .get(id) + .is_some_and(|view| view.frame.session == current.runtime_session)) + }) + .map(|(id, size)| (*id, *size)) + .collect(); + if let Some(viewport) = current.viewport { + viewports.insert(crate::window::MAIN_WINDOW, viewport); + } + viewports +} + +#[derive(Default)] +pub(super) struct Worker { + pub(super) repl: Option<(u64, endor_host::ReplSession)>, + pub(super) next_session: u64, +} +impl Worker { + pub(super) fn shutdown_runtime(&mut self) -> ShutdownOutcome { + let Some((_, mut runtime)) = self.repl.take() else { + return ShutdownOutcome { + summary: "Tessera shutdown: no live runtime".into(), + error: None, + }; + }; + let callbacks = !runtime.is_faulted(); + let result = catch_unwind(AssertUnwindSafe(|| runtime.shutdown())); + match result { + Ok(Ok((windows, mounts, tasks))) => ShutdownOutcome { + summary: format!( + "Tessera shutdown: {windows} windows, {mounts} mounts, {tasks} task entries; callbacks={callbacks}" + ), + error: None, + }, + result => { + let error = match result { + Ok(Err(error)) => format!("shutdown cleanup failed: {error:#}"), + _ => "shutdown cleanup failed after an internal panic".into(), + }; + let error: String = error.chars().take(4096).collect(); + ShutdownOutcome { + summary: error.clone(), + error: Some(error), + } + } + } + } + + pub(super) fn close_inactive_window( + &mut self, + input: &crate::events::Input, + epoch: u64, + namespace: &Arc>, + notify: &Notify, + ) -> Response>> { + if input.window == crate::window::MAIN_WINDOW { + return error_response( + StatusCode(409), + "close the primary shell with its OS window control", + ); + } + let owns_runtime = self.repl.as_ref().is_some_and(|(owner, runtime)| { + *owner == epoch && runtime.id == input.session && input.session != 0 + }); + let mut discarded = false; + if owns_runtime { + let result = catch_unwind(AssertUnwindSafe(|| { + self.repl + .as_mut() + .expect("owned runtime") + .1 + .retire_window(input.window) + })); + if !matches!(result, Ok(Ok(()))) { + // Never keep a half-cleaned VM live or borrow another generation. + self.repl = None; + discarded = true; + } + } + let Ok(mut current) = namespace.write() else { + return error_response(StatusCode(500), "namespace lock poisoned"); + }; + if current.submission != epoch { + return superseded(epoch); + } + let Some(view) = current.document.windows.get(&input.window) else { + return error_response(StatusCode(409), "window is no longer active"); + }; + if view.frame.session != input.session { + return error_response( + StatusCode(409), + "window belongs to another runtime generation", + ); + } + let mut document = current.document.clone(); + document.windows.remove(&input.window); + if discarded { + document.frame.session = 0; + document.frame.focus = None; + for view in document.windows.values_mut() { + view.frame.session = 0; + view.frame.focus = None; + } + current.runtime_session = 0; + current.window_commands.clear(); + } + match current.commit(epoch, document) { + Ok(Some(_)) => {} + Ok(None) => return superseded(epoch), + Err(error) => return error_response(StatusCode(500), error.to_string()), + } + current.window_viewports.remove(&input.window); + drop(current); + notify(); + json( + StatusCode(200), + serde_json::json!({"ok":true,"execution":"window-retire","window":input.window,"callbacks":false,"runtime_discarded":discarded}), + ) + } + + pub(super) fn refresh_viewport( + &mut self, + namespace: &Arc>, + notify: &Notify, + ) { + self.refresh_viewport_mode(namespace, notify, false); + } + + pub(super) fn refresh_viewport_mode( + &mut self, + namespace: &Arc>, + notify: &Notify, + before_input: bool, + ) { + let (epoch, viewports) = match namespace.read() { + Ok(current) => { + let viewports = current_viewports(¤t); + (current.submission, viewports) + } + Err(_) => return, + }; + let Some((owner, runtime)) = &mut self.repl else { + return; + }; + if *owner != epoch { + if let Ok(current) = namespace.read() { + self.preserve_live_owner(¤t); + } + return; + } + let id = runtime.id; + let result = catch_unwind(AssertUnwindSafe(|| -> Result<_> { + if runtime.is_faulted() { + runtime.poll_tasks()?; + return Ok((None, Vec::new())); + } + let resized = runtime.resize_windows_incremental(&viewports, false)?; + if runtime.has_pending_refresh() { + return Ok((None, Vec::new())); + } + // An accepted foreground input waits only for the existing + // refresh; do not keep placing new callbacks ahead of it. + let completed = if before_input { + None + } else { + runtime.poll_tasks_incremental(&viewports)? + }; + if runtime.has_pending_refresh() { + // A complete earlier resize may publish; task effects and their + // native commands wait until the task's refresh has settled. + return Ok((resized, Vec::new())); + } + Ok((completed.or(resized), runtime.take_window_commands()?)) + })); + let mut current = match namespace.write() { + Ok(current) => current, + Err(_) => return, + }; + if current.submission != epoch || current.runtime_session != id { + return; + } + let (document, commands) = match result { + Ok(Ok((document, commands))) => { + if let Some(document) = document { + (document, Some(commands)) + } else { + let projected = !commands.is_empty(); + current.publish_window_commands(id, commands); + drop(current); + if projected { + notify(); + } + return; + } + } + failure => { + let mut document = current.document.clone(); + document.frame.fault = Some(match failure { + Ok(Err(error)) => format!("Runtime background update failed: {error:#}") + .chars() + .take(1024) + .collect(), + _ => { + self.repl = None; + current.runtime_session = 0; + current.window_commands.clear(); + "Runtime discarded after an internal background failure".into() + } + }); + for view in document.windows.values_mut() { + view.frame.fault = document.frame.fault.clone(); + } + (document, None) + } + }; + let changed = matches!(current.commit(epoch, document), Ok(Some(_))); + if changed && let Some(commands) = commands { + current.publish_window_commands(id, commands); + } + drop(current); + if changed { + notify(); + } + } + + pub(super) fn begin_request( + &mut self, + job: &BuildRequest, + namespace: &Arc>, + notify: &Notify, + ) -> RequestProgress { + if job.kind == RequestKind::Code { + return self.begin_program_request(job, namespace, notify); + } + if job.kind == RequestKind::Expression { + return RequestProgress::Complete(build(job, namespace, notify)); + } + // Advance one existing refresh quantum, then park the HTTP request. + // No input statement executes inside the read-only scope; shutdown and + // newer viewport targets can be observed at every worker turn. + self.refresh_viewport_mode(namespace, notify, true); + let epoch = match namespace.read() { + Ok(current) => current.submission, + Err(_) => { + return RequestProgress::Complete(error_response( + StatusCode(500), + "namespace lock is poisoned", + )); + } + }; + if epoch != job.submission { + return RequestProgress::Complete(superseded(job.submission)); + } + if job.kind == RequestKind::ResetRepl { + self.repl = None; + if let Ok(mut current) = namespace.write() { + current.document.frame.session = 0; + current.document.frame.focus = None; + for document in current.document.windows.values_mut() { + document.frame.session = 0; + document.frame.focus = None; + } + current.runtime_session = 0; + current.window_commands.clear(); + } + notify(); + return RequestProgress::Complete(json( + StatusCode(200), + serde_json::json!({"ok":true,"execution":"repl-reset","persistent":true,"session_reset":true,"message":"Console memory cleared; displayed page preserved"}), + )); + } + if self + .repl + .as_ref() + .is_some_and(|(_, runtime)| runtime.has_pending_refresh()) + { + return RequestProgress::Pending(PendingRequest::BeforeInput); + } + let event = if job.kind == RequestKind::Event { + let input: crate::events::Input = match serde_json::from_str(&job.source) { + Ok(input) => input, + Err(error) => { + return RequestProgress::Complete(error_response( + StatusCode(400), + error.to_string(), + )); + } + }; + if !input.kind.starts_with("developer-open:") + && !matches!( + input.kind.as_str(), + "click" + | "focus" + | "scroll" + | "tab-activate" + | "tab-close" + | "tab-detach" + | "tab-drop" + | "window-close" + ) + { + return RequestProgress::Complete(error_response( + StatusCode(400), + "unsupported event kind", + )); + } + if let Err(message) = input.validate() { + return RequestProgress::Complete(error_response(StatusCode(400), message)); + } + let current = match namespace.read() { + Ok(current) => current, + Err(_) => { + return RequestProgress::Complete(error_response( + StatusCode(500), + "namespace lock poisoned", + )); + } + }; + let frame = if input.window == crate::window::MAIN_WINDOW { + ¤t.document.frame + } else { + let Some(document) = current.document.windows.get(&input.window) else { + return RequestProgress::Complete(error_response( + StatusCode(409), + "window is no longer active", + )); + }; + &document.frame + }; + if input.kind == "window-close" + && frame.window == input.window + && frame.session == input.session + { + let owns = self.repl.as_ref().is_some_and(|(owner, runtime)| { + *owner == epoch && runtime.id == input.session && input.session != 0 + }); + let stopped = frame.fault.is_some() + || !owns + || self + .repl + .as_ref() + .is_some_and(|(_, runtime)| runtime.is_faulted()); + if stopped { + drop(current); + return RequestProgress::Complete( + self.close_inactive_window(&input, epoch, namespace, notify), + ); + } + } + if let Some(target) = &input.tab_drop { + let target_frame = if target.window == crate::window::MAIN_WINDOW { + Some(¤t.document.frame) + } else { + current + .document + .windows + .get(&target.window) + .map(|document| &document.frame) + }; + if target_frame.is_none_or(|frame| { + frame.window != target.window + || frame.session != input.session + || frame.layout_version != target.layout_version + || frame.fault.is_some() + || target.index > frame.tabs.len() + }) || self + .repl + .as_ref() + .is_none_or(|(_, runtime)| !runtime.accepts_tab_drop(&input)) + { + return RequestProgress::Complete(error_response( + StatusCode(409), + "drop targets an obsolete or inactive frame", + )); + } + } + if frame.window != input.window + || frame.session != input.session + || (input.kind != "window-close" && frame.layout_version != input.layout_version) + || frame.fault.is_some() + || if input.kind == "window-close" || input.kind.starts_with("developer-open:") { + input.mount != 0 + } else if matches!( + input.kind.as_str(), + "tab-activate" | "tab-close" | "tab-detach" | "tab-drop" + ) { + !frame.tabs.iter().any(|tab| tab.id == input.mount) + } else { + !frame.mounts.iter().any(|mount| { + mount.id == input.mount + && (mount.interactive + || input.kind == "scroll" && mount.scroll.is_some()) + }) + } + || self.repl.as_ref().is_none_or(|(context, runtime)| { + *context != epoch + || runtime.id != input.session + || (input.kind != "window-close" && !runtime.accepts_window(input.window)) + }) + { + return RequestProgress::Complete(error_response( + StatusCode(409), + "event targets an obsolete or inactive frame", + )); + } + Some(input) + } else { + None + }; + if self + .repl + .as_ref() + .is_none_or(|(current, _)| *current != epoch) + { + self.next_session = match self.next_session.checked_add(1) { + Some(id) => id, + None => { + return RequestProgress::Complete(error_response( + StatusCode(500), + "session identities exhausted", + )); + } + }; + let viewport = namespace.read().ok().and_then(|current| current.viewport); + match endor_host::ReplSession::new_with_viewport(self.next_session, viewport) { + Ok(session) => { + if let Ok(mut current) = namespace.write() + && current.submission == epoch + { + current.publish_window_commands(session.id, Vec::new()); + } + self.repl = Some((epoch, session)); + } + Err(error) => { + return RequestProgress::Complete(error_response( + StatusCode(500), + error.to_string(), + )); + } + } + } + let viewports = match namespace.read() { + Ok(current) => current_viewports(¤t), + Err(_) => { + return RequestProgress::Complete(error_response( + StatusCode(500), + "namespace lock is poisoned", + )); + } + }; + let session = &mut self.repl.as_mut().expect("initialized REPL").1; + let id = session.id; + let result = catch_unwind(AssertUnwindSafe(|| -> Result<_> { + let result = if let Some(event) = &event { + session.dispatch_incremental(event, &viewports)? + } else { + session.execute_incremental(&job.source, &job.namespace, &viewports)? + }; + let commands = if session.has_pending_refresh() { + Vec::new() + } else { + session.take_window_commands()? + }; + Ok((result, commands)) + })); + let faulted = session.is_faulted(); + if session.has_pending_refresh() { + if let Ok(Ok((result, _))) = result { + return RequestProgress::Pending(PendingRequest::Input(Box::new(PendingInput { + epoch, + id, + event: event.is_some(), + result, + }))); + } + // A failed start must never leave a request waiting on a broken VM. + self.repl = None; + } + RequestProgress::Complete(self.finish_input( + job, + namespace, + notify, + epoch, + id, + event.is_some(), + faulted, + result, + )) + } + + pub(super) fn resume_request( + &mut self, + job: &BuildRequest, + pending: PendingRequest, + namespace: &Arc>, + notify: &Notify, + ) -> RequestProgress { + match pending { + PendingRequest::BeforeInput => self.begin_request(job, namespace, notify), + PendingRequest::Input(input) => self.resume_input(job, input, namespace, notify), + PendingRequest::Candidate(runtime) => { + self.resume_candidate(job, runtime, namespace, notify) + } + } + } + + fn resume_input( + &mut self, + job: &BuildRequest, + mut input: Box, + namespace: &Arc>, + notify: &Notify, + ) -> RequestProgress { + let viewports = match namespace.read() { + Ok(current) + if current.submission == input.epoch || current.runtime_session == input.id => + { + current_viewports(¤t) + } + Ok(_) => { + self.repl = None; + return RequestProgress::Complete(superseded(job.submission)); + } + Err(_) => { + self.repl = None; + return RequestProgress::Complete(error_response( + StatusCode(500), + "namespace lock is poisoned", + )); + } + }; + let Some((epoch, session)) = &mut self.repl else { + return RequestProgress::Complete(error_response( + StatusCode(409), + "input runtime was discarded", + )); + }; + if *epoch != input.epoch || session.id != input.id { + return RequestProgress::Complete(superseded(job.submission)); + } + let resumed = catch_unwind(AssertUnwindSafe(|| -> Result<_> { + let document = session.resize_windows_incremental(&viewports, false)?; + if session.has_pending_refresh() { + return Ok(None); + } + Ok(Some((document, session.take_window_commands()?))) + })); + let faulted = session.is_faulted(); + let result = match resumed { + Ok(Ok(None)) => return RequestProgress::Pending(PendingRequest::Input(input)), + Ok(Ok(Some((document, commands)))) => { + input.result.document = document; + Ok(Ok((input.result, commands))) + } + Ok(Err(error)) => Ok(Err(error)), + Err(panic) => Err(panic), + }; + RequestProgress::Complete(self.finish_input( + job, + namespace, + notify, + input.epoch, + input.id, + input.event, + faulted, + result, + )) + } + + #[allow(clippy::too_many_arguments)] + fn finish_input( + &mut self, + job: &BuildRequest, + namespace: &Arc>, + notify: &Notify, + epoch: u64, + id: u64, + event: bool, + faulted: bool, + result: InputOutcome, + ) -> HttpResponse { + let Ok(mut current) = namespace.write() else { + return error_response(StatusCode(500), "namespace lock is poisoned"); + }; + let epoch = if current.submission != epoch { + if current.runtime_session != id { + drop(current); + self.repl = None; + return superseded(job.submission); + } + // Accepted PUTs invalidate queued inputs, not a mutation already + // executing on this owner. Settle it before building the candidate + // so a failed replacement can preserve a consistent live heap. + if let Some((owner, runtime)) = &mut self.repl + && runtime.id == id + { + *owner = current.submission; + } + current.submission + } else { + epoch + }; + match result { + Ok(Ok((result, commands))) => { + let projected = !commands.is_empty(); + let rendered = result.document.is_some(); + if let Some(document) = result.document { + match current.commit(epoch, document) { + Ok(Some(_)) => {} + Ok(None) => return superseded(job.submission), + Err(error) => return error_response(StatusCode(500), error.to_string()), + } + } + current.publish_window_commands(id, commands); + let revision = current.revision; + drop(current); + if rendered || projected { + notify(); + } + json( + StatusCode(200), + serde_json::json!({"ok":true,"execution":if event { "event" } else { "repl" },"persistent":true,"session":id,"input":result.input,"value":result.value,"bindings":result.bindings,"rendered":rendered,"revision":revision}), + ) + } + Ok(Err(error)) => { + if event || faulted { + let mut document = current.document.clone(); + // Preserve the first failure of this VM; subsequent + // rejected Console input must not hide the callback cause. + // A preserved picture from another generation is unrelated. + let fault = (document.frame.session == id) + .then(|| document.frame.fault.clone()) + .flatten() + .unwrap_or_else(|| format!("{error:#}").chars().take(1024).collect()); + document.frame.fault = Some(fault.clone()); + for view in document.windows.values_mut() { + view.frame.fault = Some(fault.clone()); + } + let _ = current.commit(epoch, document); + drop(current); + notify(); + } + compile_error(error) + } + Err(_) => { + let mut document = current.document.clone(); + document.frame.fault = + Some("Runtime discarded after an internal execution failure".into()); + for view in document.windows.values_mut() { + view.frame.fault = document.frame.fault.clone(); + } + current.runtime_session = 0; + current.window_commands.clear(); + let _ = current.commit(epoch, document); + drop(current); + self.repl = None; + notify(); + json( + StatusCode(500), + serde_json::json!({"ok":false,"session_reset":true,"error":"Console execution failed internally; session discarded, displayed page preserved"}), + ) + } + } + } + + /// An accepted PUT changes the submission, but does not revoke the old + /// runtime. DELETE clears runtime_session and does revoke it. + pub(super) fn preserve_live_owner(&mut self, current: &NamespaceState) { + if let Some((epoch, runtime)) = &mut self.repl { + if current.runtime_session == runtime.id { + *epoch = current.submission; + } else { + self.repl = None; + } + } + } + + fn begin_program_request( + &mut self, + job: &BuildRequest, + namespace: &Arc>, + notify: &Notify, + ) -> RequestProgress { + let viewport = match namespace.read() { + Ok(current) if current.submission == job.submission => current.viewport, + Ok(current) => { + self.preserve_live_owner(¤t); + return RequestProgress::Complete(superseded(job.submission)); + } + Err(_) => { + return RequestProgress::Complete(error_response( + StatusCode(500), + "namespace lock poisoned", + )); + } + }; + self.next_session = match self.next_session.checked_add(1) { + Some(id) => id, + None => { + return RequestProgress::Complete(error_response( + StatusCode(500), + "session identities exhausted", + )); + } + }; + let id = self.next_session; + let started = catch_unwind(AssertUnwindSafe(|| { + endor_host::ReplSession::load_incremental( + &job.source, + &job.namespace, + job.submission, + id, + viewport, + ) + })); + let result = match started { + Ok(Ok((runtime, None))) => { + return RequestProgress::Pending(PendingRequest::Candidate(Box::new(runtime))); + } + Ok(Ok((runtime, Some(document)))) => match runtime.take_window_commands() { + Ok(commands) => Ok(Ok((runtime, document, commands))), + Err(error) => Ok(Err(error)), + }, + Ok(Err(error)) => Ok(Err(error)), + Err(panic) => Err(panic), + }; + RequestProgress::Complete(self.finish_candidate(job, namespace, notify, id, result)) + } + + fn resume_candidate( + &mut self, + job: &BuildRequest, + mut runtime: Box, + namespace: &Arc>, + notify: &Notify, + ) -> RequestProgress { + let viewports = match namespace.read() { + Ok(current) if current.submission == job.submission => { + // Secondary native resources still belong to the old VM. Only + // the primary shell's current viewport applies to a candidate. + current + .viewport + .map(|size| (crate::window::MAIN_WINDOW, size)) + .into_iter() + .collect() + } + Ok(current) => { + self.preserve_live_owner(¤t); + return RequestProgress::Complete(superseded(job.submission)); + } + Err(_) => { + return RequestProgress::Complete(error_response( + StatusCode(500), + "namespace lock poisoned", + )); + } + }; + let id = runtime.id; + let resumed = catch_unwind(AssertUnwindSafe(|| -> Result<_> { + let document = runtime.resize_windows_incremental(&viewports, false)?; + if runtime.has_pending_refresh() { + return Ok(None); + } + let document = + document.ok_or_else(|| anyhow::anyhow!("program did not publish a Page"))?; + Ok(Some((document, runtime.take_window_commands()?))) + })); + let result = match resumed { + Ok(Ok(None)) => return RequestProgress::Pending(PendingRequest::Candidate(runtime)), + Ok(Ok(Some((document, commands)))) => Ok(Ok((*runtime, document, commands))), + Ok(Err(error)) => Ok(Err(error)), + Err(panic) => Err(panic), + }; + RequestProgress::Complete(self.finish_candidate(job, namespace, notify, id, result)) + } + + fn finish_candidate( + &mut self, + job: &BuildRequest, + namespace: &Arc>, + notify: &Notify, + id: u64, + result: CandidateOutcome, + ) -> HttpResponse { + let Ok(mut current) = namespace.write() else { + return error_response(StatusCode(500), "namespace lock poisoned"); + }; + if current.submission != job.submission { + self.preserve_live_owner(¤t); + return superseded(job.submission); + } + match result { + Ok(Ok((runtime, document, commands))) => match current.commit(job.submission, document) + { + Ok(Some(revision)) => { + current.publish_window_commands(id, commands); + self.repl = Some((job.submission, runtime)); + drop(current); + notify(); + json( + StatusCode(200), + serde_json::json!({"ok":true,"namespace":job.namespace,"submission":job.submission,"revision":revision,"diagnostics":[],"session":id,"persistent":true}), + ) + } + Ok(None) => superseded(job.submission), + Err(error) => error_response(StatusCode(500), error.to_string()), + }, + failed => { + // The candidate never mutated the displayed program's heap. + // Restore that program's event epoch when a replacement fails. + self.preserve_live_owner(¤t); + drop(current); + match failed { + Ok(Err(error)) => compile_error(error), + _ => error_response( + StatusCode(500), + "candidate failed internally; previous runtime preserved", + ), + } + } + } + } +} + +fn build( + job: &BuildRequest, + namespace: &Arc>, + notify: &Notify, +) -> Response>> { + if job.kind == RequestKind::Expression { + return match catch_unwind(AssertUnwindSafe(|| endor_host::evaluate(&job.source))) { + Ok(Ok(value)) => json( + StatusCode(200), + serde_json::json!({"ok":true,"value":value,"execution":"isolated-expression","persistent":false}), + ), + Ok(Err(error)) => compile_error(error), + Err(_) => error_response(StatusCode(500), "expression execution failed internally"), + }; + } + match namespace.read() { + Ok(current) if current.submission != job.submission => return superseded(job.submission), + Err(_) => return error_response(StatusCode(500), "namespace lock is poisoned"), + _ => {} + } + // Only an independent native snapshot crosses into the published state. + // A fresh VM is discarded on success, error and unwind alike. + let result = catch_unwind(AssertUnwindSafe(|| { + endor_host::compile(&job.source, &job.namespace, job.submission) + })); + let Ok(mut current) = namespace.write() else { + return error_response(StatusCode(500), "namespace lock is poisoned"); + }; + if current.submission != job.submission { + return superseded(job.submission); + } + match result { + Ok(Ok(document)) => match current.commit(job.submission, document) { + Ok(Some(revision)) => { + drop(current); + notify(); + json( + StatusCode(200), + serde_json::json!({ "ok": true, "namespace": job.namespace, + "submission": job.submission, "revision": revision, "diagnostics": [] }), + ) + } + Ok(None) => superseded(job.submission), + Err(error) => error_response(StatusCode(500), error.to_string()), + }, + Ok(Err(error)) => compile_error(error), + Err(_) => error_response( + StatusCode(500), + "candidate execution failed internally; previous document preserved", + ), + } +} diff --git a/src/endor_host.rs b/src/runtime/endor_host.rs similarity index 100% rename from src/endor_host.rs rename to src/runtime/endor_host.rs diff --git a/src/endor_host/tests.rs b/src/runtime/endor_host/tests.rs similarity index 98% rename from src/endor_host/tests.rs rename to src/runtime/endor_host/tests.rs index 975b1f0..fbd93c4 100644 --- a/src/endor_host/tests.rs +++ b/src/runtime/endor_host/tests.rs @@ -1,8 +1,8 @@ use super::*; use crate::document::{NamespaceState, RenderCommand}; -const EXAMPLE: &str = include_str!("../../examples/hello.endor"); -const PAGE: &str = include_str!("../../examples/page.endor"); +const EXAMPLE: &str = include_str!("../../../examples/hello.endor"); +const PAGE: &str = include_str!("../../../examples/page.endor"); #[test] fn actual_mount_version_reads_still_bypass_measurement_cache() { @@ -36,7 +36,7 @@ page.render(rect) #[test] fn source_allocations_and_measurement_limits_settle_before_publication() { let (mut session, initial) = ReplSession::load_with_viewport( - include_str!("../../examples/layout-negotiation.endor"), + include_str!("../../../examples/layout-negotiation.endor"), "app.main", 1, 240, @@ -283,7 +283,7 @@ page.render(Column(0,8,Column(0,8,paragraph,tail),Column(50,8,paragraph))) #[test] fn native_signatures_validate_registration_and_deliver_the_event_argument() { let (mut session, _) = ReplSession::load( - include_str!("../../examples/native-signatures.endor"), + include_str!("../../../examples/native-signatures.endor"), "app.main", 1, 220, @@ -349,7 +349,7 @@ fn native_signatures_validate_registration_and_deliver_the_event_argument() { #[test] fn checked_fields_reject_native_constructor_side_effects_and_preserve_live_values() { let (mut session, _) = ReplSession::load( - include_str!("../../examples/field-contracts.endor"), + include_str!("../../../examples/field-contracts.endor"), "app.main", 1, 210, @@ -515,7 +515,7 @@ fn positional_insert_preserves_existing_slots_and_mounts_and_rejects_invalid_gra #[test] fn tab_drop_preserves_page_identity_calls_source_methods_and_rejects_stale_targets() { let (mut session, initial) = ReplSession::load( - include_str!("../../examples/tab-placement.endor"), + include_str!("../../../examples/tab-placement.endor"), "app.main", 1, 201, @@ -1009,7 +1009,7 @@ fn orphan_task_scope_is_collectable_and_callback_failure_stops_remaining_work() #[test] fn source_annotations_classify_functions_and_reject_before_body() { let (mut session, _) = ReplSession::load( - include_str!("../../examples/function-types.endor"), + include_str!("../../../examples/function-types.endor"), "app.main", 1, 180, @@ -1180,7 +1180,7 @@ fn stopped_window_teardown_detaches_mounts_without_running_source_hooks() { #[test] fn native_windows_share_business_state_move_pages_and_release_closed_contexts() { let (mut session, initial) = ReplSession::load( - include_str!("../../examples/windows.endor"), + include_str!("../../../examples/windows.endor"), "app.main", 1, 130, @@ -1279,7 +1279,7 @@ fn native_windows_share_business_state_move_pages_and_release_closed_contexts() #[test] fn failed_input_after_window_close_cannot_leave_owned_mounts_in_closed_context() { let (mut session, _) = ReplSession::load( - include_str!("../../examples/windows.endor"), + include_str!("../../../examples/windows.endor"), "app.main", 1, 131, @@ -1633,7 +1633,7 @@ page2.moveToWindow(window2) #[test] fn source_measure_cache_reuses_independent_results_and_invalidates_model_changes() { let (mut session, initial) = ReplSession::load_with_viewport( - include_str!("../../examples/layout-cache.endor"), + include_str!("../../../examples/layout-cache.endor"), "app.main", 101, 1, @@ -1748,7 +1748,7 @@ fn source_measure_cache_tracks_fields_of_an_existing_returned_result() { #[test] fn viewport_resize_changes_constraints_and_preserves_mount_identity_without_source_input() { let (mut session, initial) = ReplSession::load_with_viewport( - include_str!("../../examples/layout-cache.endor"), + include_str!("../../../examples/layout-cache.endor"), "app.main", 102, 1, @@ -1803,7 +1803,7 @@ fn viewport_resize_changes_constraints_and_preserves_mount_identity_without_sour assert!(!session.is_faulted()); } -const SOURCE_LAYOUT: &str = include_str!("../../examples/source-layout.endor"); +const SOURCE_LAYOUT: &str = include_str!("../../../examples/source-layout.endor"); #[test] fn source_layout_uses_initial_mount_links_child_measurements_and_model_dependencies() { @@ -1937,7 +1937,7 @@ fn source_hit_filters_shape_falls_back_and_requires_matching_press_release() { ([9999.0, 9999.0], [9999.0, 9999.0], "0", "0"), ] { let (mut session, initial) = ReplSession::load( - include_str!("../../examples/source-hit.endor"), + include_str!("../../../examples/source-hit.endor"), "app.main", 81, 1, @@ -1986,7 +1986,7 @@ fn source_hit_rejects_wrong_result_and_business_writes_before_callbacks() { "read-only", ), ] { - let source = include_str!("../../examples/source-hit.endor") + let source = include_str!("../../../examples/source-hit.endor") .replace("return point.inCircle(40, 40, 40)", body); let (mut session, initial) = ReplSession::load(&source, "app.main", 82, 1).unwrap(); let circle = initial @@ -2063,8 +2063,8 @@ fn resumable_paint_source(copies: usize, locals: usize) -> String { #[test] fn collecting_lazy_helpers_does_not_invalidate_stable_layout_or_paint() { for source in [ - include_str!("../../examples/flex-constraints.endor"), - include_str!("../../examples/source-paint.endor"), + include_str!("../../../examples/flex-constraints.endor"), + include_str!("../../../examples/source-paint.endor"), ] { let (mut session, initial) = ReplSession::load(source, "app.main", 281, 1).unwrap(); session @@ -2257,7 +2257,7 @@ fn source_paint_can_rebind_old_plain_reference_to_new_result_without_mutating_it #[test] fn source_paint_overrides_native_commands_and_reacts_to_heap_field_writes() { let (mut session, initial) = ReplSession::load( - include_str!("../../examples/source-paint.endor"), + include_str!("../../../examples/source-paint.endor"), "app.main", 71, 1, @@ -2334,13 +2334,13 @@ fn source_paint_rejects_business_mutation_before_it_happens_and_restores_scope() .set_attr(rect, "tint", Value::Text("#526AC7".into())) .unwrap(); let bad_color = - include_str!("../../examples/source-paint.endor").replace("#ED6A5A", "invalid color"); + include_str!("../../../examples/source-paint.endor").replace("#ED6A5A", "invalid color"); assert!(ReplSession::load(&bad_color, "app.main", 73, 1).is_err()); } #[test] fn source_paint_is_stable_when_mount_notifications_overflow_the_change_batch() { - let fixture = include_str!("../../examples/source-paint.endor"); + let fixture = include_str!("../../../examples/source-paint.endor"); let mut source = fixture[..fixture.find("root = Column").unwrap()].to_owned(); source.push_str("root = Column(0, 0"); for _ in 0..1100 { @@ -2355,7 +2355,7 @@ fn source_paint_is_stable_when_mount_notifications_overflow_the_change_batch() { #[test] fn constructor_options_and_subclass_fields_have_independent_contracts() { let (mut session, document) = ReplSession::load( - include_str!("../../examples/constructor-contracts.endor"), + include_str!("../../../examples/constructor-contracts.endor"), "app.main", 166, 1, @@ -2455,7 +2455,7 @@ fn constructor_options_and_subclass_fields_have_independent_contracts() { #[test] fn source_overrides_accept_wider_inputs_and_more_specific_results() { - let source = include_str!("../../examples/method-contracts.endor"); + let source = include_str!("../../../examples/method-contracts.endor"); let (mut session, document) = ReplSession::load(source, "app.main", 164, 1).unwrap(); document.validate().unwrap(); assert_eq!( @@ -2497,7 +2497,7 @@ fn source_overrides_accept_wider_inputs_and_more_specific_results() { #[test] fn nullable_page_methods_union_event_options_and_nominal_mount_bounds_compose() { let (mut session, _) = ReplSession::load( - include_str!("../../examples/visual-protocols.endor"), + include_str!("../../../examples/visual-protocols.endor"), "app.main", 163, 1, @@ -2534,7 +2534,7 @@ fn nullable_page_methods_union_event_options_and_nominal_mount_bounds_compose() #[test] fn native_contracts_reject_wrong_types_before_geometry_or_window_effects() { let (mut session, _) = ReplSession::load( - include_str!("../../examples/visual-protocols.endor"), + include_str!("../../../examples/visual-protocols.endor"), "app.main", 162, 1, @@ -2586,7 +2586,7 @@ fn native_contracts_reject_wrong_types_before_geometry_or_window_effects() { #[test] fn native_visual_methods_use_managed_values_and_match_real_geometry() { let (mut session, initial) = ReplSession::load( - include_str!("../../examples/visual-protocols.endor"), + include_str!("../../../examples/visual-protocols.endor"), "app.main", 61, 1, @@ -2647,7 +2647,7 @@ fn native_visual_methods_use_managed_values_and_match_real_geometry() { #[test] fn explicit_invalidation_publishes_once_preserves_mount_and_remeasures_only_when_requested() { let (mut session, _) = ReplSession::load( - include_str!("../../examples/visual-protocols.endor"), + include_str!("../../../examples/visual-protocols.endor"), "app.main", 62, 1, @@ -2708,7 +2708,7 @@ fn explicit_invalidation_publishes_once_preserves_mount_and_remeasures_only_when #[test] fn mount_hooks_run_per_occurrence_settle_and_unmount_before_parent() { let (mut session, initial) = ReplSession::load( - include_str!("../../examples/mount-lifecycle.endor"), + include_str!("../../../examples/mount-lifecycle.endor"), "app.main", 51, 1, @@ -2787,7 +2787,7 @@ fn mount_hooks_run_per_occurrence_settle_and_unmount_before_parent() { #[test] fn page_switch_replaces_mount_context_even_for_shared_root() { let (mut session, _) = ReplSession::load( - include_str!("../../examples/mount-lifecycle.endor"), + include_str!("../../../examples/mount-lifecycle.endor"), "app.main", 52, 1, @@ -2857,7 +2857,11 @@ fn lifecycle_hook_can_edit_container_without_session_lock_and_failure_stops_doma } fn event_session(extra: &str) -> (ReplSession, RenderDocument) { - let source = format!("{}\n{}", include_str!("../../examples/events.endor"), extra); + let source = format!( + "{}\n{}", + include_str!("../../../examples/events.endor"), + extra + ); ReplSession::load(&source, "app.main", 41, 1).unwrap() } @@ -3061,7 +3065,7 @@ fn event_failure_releases_dispatch_guard_and_token_does_not_retain_target() { #[test] fn source_native_subclasses_share_live_fields_and_release_replaced_storage() { let (mut session, initial) = ReplSession::load( - include_str!("../../examples/native-subclasses.endor"), + include_str!("../../../examples/native-subclasses.endor"), "app.main", 31, 1, @@ -3143,7 +3147,7 @@ fn source_native_subclasses_share_live_fields_and_release_replaced_storage() { #[test] fn loaded_program_console_and_native_events_share_unique_source_ids() { let (mut session, initial) = ReplSession::load( - include_str!("../../examples/managed-types.endor"), + include_str!("../../../examples/managed-types.endor"), "app.main", 1, 1, @@ -3194,7 +3198,7 @@ fn loaded_program_console_and_native_events_share_unique_source_ids() { #[test] fn scroll_occurrences_are_independent_and_clipped_pointer_input_is_ignored() { let (mut session, initial) = ReplSession::load( - include_str!("../../examples/scroll.endor"), + include_str!("../../../examples/scroll.endor"), "app.main", 1, 240, @@ -3626,7 +3630,7 @@ fn idle_tasks_wait_for_prior_callback_refresh_before_next_delivery() { #[test] fn incremental_viewport_refresh_retains_published_documents_until_complete() { let (mut session, _) = ReplSession::load( - include_str!("../../examples/flex-constraints.endor"), + include_str!("../../../examples/flex-constraints.endor"), "app.main", 271, 1, @@ -3967,7 +3971,7 @@ fn main_alignment_updates_live_geometry_without_changing_sizes() { #[test] fn scroll_axes_wrap_shared_text_and_clamp_only_the_enabled_offsets() { let (mut session, initial) = ReplSession::load( - include_str!("../../examples/scroll-axis.endor"), + include_str!("../../../examples/scroll-axis.endor"), "app.main", 260, 1, @@ -4606,7 +4610,7 @@ fn presentable_type_contracts_reject_before_registration_or_body_effects() { #[test] fn presentable_dispatch_preserves_source_identity_and_reactive_display() { let (mut session, initial) = ReplSession::load( - include_str!("../../examples/presentable.endor"), + include_str!("../../../examples/presentable.endor"), "app.main", 1, 230, @@ -4855,7 +4859,7 @@ page.render(rect) #[test] fn source_render_caches_do_not_root_a_page_block_waiting_frame_cycle() { let (mut session, _) = ReplSession::load( - include_str!("../../examples/gc-lifecycle.endor"), + include_str!("../../../examples/gc-lifecycle.endor"), "app.main", 902, 1, @@ -5245,7 +5249,7 @@ fn top_level_goal_script_renders_without_a_main_or_synthetic_wrapper() { matches!(&document.frame.commands[1], RenderCommand::RoundedRect { x: 24.0, y: 76.0, width: 240.0, height: 80.0, radius: 12.0, color } if color == "#DCE8FF") ); assert_eq!(document.display_list(2.0).unwrap().commands().len(), 3); - let wrapped = compile(include_str!("../../examples/main.endor"), "app.main", 10).unwrap(); + let wrapped = compile(include_str!("../../../examples/main.endor"), "app.main", 10).unwrap(); assert_eq!( serde_json::to_value(&document.frame.commands).unwrap(), serde_json::to_value(&wrapped.frame.commands).unwrap() diff --git a/src/endor_script.rs b/src/runtime/endor_script.rs similarity index 100% rename from src/endor_script.rs rename to src/runtime/endor_script.rs diff --git a/src/event_dispatch.rs b/src/runtime/event_dispatch.rs similarity index 100% rename from src/event_dispatch.rs rename to src/runtime/event_dispatch.rs diff --git a/src/events.rs b/src/runtime/events.rs similarity index 100% rename from src/events.rs rename to src/runtime/events.rs diff --git a/src/io_tasks.rs b/src/runtime/io_tasks.rs similarity index 100% rename from src/io_tasks.rs rename to src/runtime/io_tasks.rs diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs new file mode 100644 index 0000000..0cb49b4 --- /dev/null +++ b/src/runtime/mod.rs @@ -0,0 +1,9 @@ +// Module ownership follows the directory; crate-root aliases keep internal call sites stable. +pub(crate) mod api; +pub(crate) mod endor_host; +pub(crate) mod endor_script; +pub(crate) mod event_dispatch; +pub(crate) mod events; +pub(crate) mod io_tasks; +pub(crate) mod tasks; +pub(crate) mod vm_sequence; diff --git a/src/tasks.rs b/src/runtime/tasks.rs similarity index 100% rename from src/tasks.rs rename to src/runtime/tasks.rs diff --git a/src/vm_sequence.rs b/src/runtime/vm_sequence.rs similarity index 100% rename from src/vm_sequence.rs rename to src/runtime/vm_sequence.rs diff --git a/src/activity.rs b/src/shell/activity.rs similarity index 100% rename from src/activity.rs rename to src/shell/activity.rs diff --git a/src/app.rs b/src/shell/app.rs similarity index 100% rename from src/app.rs rename to src/shell/app.rs diff --git a/src/clipboard.rs b/src/shell/clipboard.rs similarity index 100% rename from src/clipboard.rs rename to src/shell/clipboard.rs diff --git a/src/developer_tools.rs b/src/shell/developer_tools.rs similarity index 100% rename from src/developer_tools.rs rename to src/shell/developer_tools.rs diff --git a/src/developer_tools/console.rs b/src/shell/developer_tools/console.rs similarity index 100% rename from src/developer_tools/console.rs rename to src/shell/developer_tools/console.rs diff --git a/src/developer_tools/network.rs b/src/shell/developer_tools/network.rs similarity index 100% rename from src/developer_tools/network.rs rename to src/shell/developer_tools/network.rs diff --git a/src/developer_tools/painter.rs b/src/shell/developer_tools/painter.rs similarity index 100% rename from src/developer_tools/painter.rs rename to src/shell/developer_tools/painter.rs diff --git a/src/developer_tools/selection.rs b/src/shell/developer_tools/selection.rs similarity index 100% rename from src/developer_tools/selection.rs rename to src/shell/developer_tools/selection.rs diff --git a/src/developer_tools/settings.rs b/src/shell/developer_tools/settings.rs similarity index 100% rename from src/developer_tools/settings.rs rename to src/shell/developer_tools/settings.rs diff --git a/src/developer_tools/sources.rs b/src/shell/developer_tools/sources.rs similarity index 100% rename from src/developer_tools/sources.rs rename to src/shell/developer_tools/sources.rs diff --git a/src/shell/mod.rs b/src/shell/mod.rs new file mode 100644 index 0000000..d32d309 --- /dev/null +++ b/src/shell/mod.rs @@ -0,0 +1,10 @@ +// Module ownership follows the directory; crate-root aliases keep internal call sites stable. +pub(crate) mod activity; +pub(crate) mod app; +pub(crate) mod clipboard; +pub(crate) mod developer_tools; +#[cfg(target_os = "macos")] +pub(crate) mod native_chrome; +pub(crate) mod sources; +pub(crate) mod system_ui; +pub(crate) mod tabs; diff --git a/src/native_chrome.rs b/src/shell/native_chrome.rs similarity index 100% rename from src/native_chrome.rs rename to src/shell/native_chrome.rs diff --git a/src/sources.rs b/src/shell/sources.rs similarity index 100% rename from src/sources.rs rename to src/shell/sources.rs diff --git a/src/system_ui/README.md b/src/shell/system_ui/README.md similarity index 97% rename from src/system_ui/README.md rename to src/shell/system_ui/README.md index fe02c72..9cae595 100644 --- a/src/system_ui/README.md +++ b/src/shell/system_ui/README.md @@ -54,7 +54,7 @@ leaving the editor. Rows and drag handles are excluded from Tab traversal. Text editing, clipboard and IME remain host adapters; the widget layer owns their chrome and focus presentation. `inline_input` registers a text-input target without a background or focus border; a terminal-style host draws its own caret in the same -scrollable stream as output. `src/clipboard.rs` guarantees UTF-8 for macOS clipboard +scrollable stream as output. `src/shell/clipboard.rs` guarantees UTF-8 for macOS clipboard subprocesses, independent of Finder's locale. Tessera's developer tools are the first consumer. Domain actions, HTTP calls, diff --git a/src/system_ui/interaction.rs b/src/shell/system_ui/interaction.rs similarity index 100% rename from src/system_ui/interaction.rs rename to src/shell/system_ui/interaction.rs diff --git a/src/system_ui/mod.rs b/src/shell/system_ui/mod.rs similarity index 100% rename from src/system_ui/mod.rs rename to src/shell/system_ui/mod.rs diff --git a/src/system_ui/theme.rs b/src/shell/system_ui/theme.rs similarity index 100% rename from src/system_ui/theme.rs rename to src/shell/system_ui/theme.rs diff --git a/src/system_ui/widgets.rs b/src/shell/system_ui/widgets.rs similarity index 100% rename from src/system_ui/widgets.rs rename to src/shell/system_ui/widgets.rs diff --git a/src/tabs.rs b/src/shell/tabs.rs similarity index 100% rename from src/tabs.rs rename to src/shell/tabs.rs diff --git a/src/tabs/hover.rs b/src/shell/tabs/hover.rs similarity index 100% rename from src/tabs/hover.rs rename to src/shell/tabs/hover.rs diff --git a/src/tabs/motion.rs b/src/shell/tabs/motion.rs similarity index 100% rename from src/tabs/motion.rs rename to src/shell/tabs/motion.rs