From 040b779f5f35b7052e3684f30b4523d546b5c41b Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Tue, 10 Nov 2026 22:42:56 +0800 Subject: [PATCH] feat: separate 2d scenes viewports and explicit cameras --- docs/page-surfaces.md | 2 + docs/scene-architecture.md | 18 +- examples/README.md | 1 + examples/scene-viewports.endor | 28 +++ src/bridge/ui_bridge.rs | 43 ++++ src/bridge/ui_bridge/bootstrap.rs | 13 + src/bridge/ui_bridge/calls.rs | 25 ++ src/bridge/ui_bridge/page_input.rs | 85 +++++++ src/bridge/ui_bridge/signatures.rs | 18 ++ src/bridge/ui_bridge/start_page.endor | 56 +++-- src/bridge/ui_bridge/tree.rs | 29 +++ src/main.rs | 2 +- src/model/block.rs | 44 ++++ src/model/document.rs | 68 +++++- src/model/mount.rs | 9 +- src/model/snapshot.rs | 29 +++ src/render/display_list.rs | 20 ++ src/render/layout.rs | 21 +- src/render/mod.rs | 1 + src/render/scroll.rs | 11 + src/render/source_hit.rs | 6 +- src/render/source_paint.rs | 6 + src/render/viewport.rs | 298 +++++++++++++++++++++++ src/runtime/api/guide.rs | 4 +- src/runtime/api/tests.rs | 84 +++++++ src/runtime/api/worker.rs | 23 ++ src/runtime/endor_host/tests.rs | 327 +++++++++++++++++++++++++- src/runtime/events.rs | 16 +- src/shell/app.rs | 92 ++++++-- 29 files changed, 1309 insertions(+), 70 deletions(-) create mode 100644 examples/scene-viewports.endor create mode 100644 src/render/viewport.rs diff --git a/docs/page-surfaces.md b/docs/page-surfaces.md index 2c9117e..ec67637 100644 --- a/docs/page-surfaces.md +++ b/docs/page-surfaces.md @@ -1,5 +1,7 @@ # Endorphin 页面空间与窗口边界 +> 下文的 Page.mode 是前一阶段的兼容接口。当前默认页已迁移到普通 Page、Row/Column、滚动容器和独立 Scene2D / CanvasViewport / Camera2D。新代码请使用 [统一场景架构](scene-architecture.md),不要再把横纵排版或相机作为 Page 的固有模式。 + 本次按参考图的“左侧栏展开/收起”两种状态调整原生宿主。窗口保留系统关闭、最小化和缩放按钮;宿主管理入口集中在左侧栏。收起后只保留展开按钮及系统三键,页面从窗口左上角铺满,不再保留顶部标签条、左侧空轨或内容卡片边距。 页面内容、卡片、文字和操作按钮由 Endor 源码渲染。默认 [开始页](../src/bridge/ui_bridge/start_page.endor) 是真实的画布 Page,其操作创建另外三种 Page;不会把示例的静态截图当作可操作界面。 diff --git a/docs/scene-architecture.md b/docs/scene-architecture.md index da3877e..87580b8 100644 --- a/docs/scene-architecture.md +++ b/docs/scene-architecture.md @@ -19,7 +19,7 @@ | --- | --- | --- | | 基线审查 | 完成 | 保留共享 Block / 独立 Mount、源协议和已合并工作区示例 | | 嵌套布局与盒模型 | 完成 | Row / Column / Overlay、双轴对齐、子项覆盖、外边距、非对称内边距和边框、尺寸策略、裁剪;见下面接口 | -| 独立 2D 场景与视口 | 待实现 | 现有 Page.mode / pan / zoom 仍是迁移前接口 | +| 独立 2D 场景与视口 | 完成 | 独立场景/摄像机、多视口及嵌套、覆盖 UI、投影/命中、原生输入路由、真实 HTTP 与像素回归;旧 Page.mode 仅保留兼容 | | 3D 场景、视口和摄像机 | 待实现 | 尚无可运行的 3D 后端、控制器或拾取 | | 原生综合验收 | 待执行 | Rust / VM / HTTP 回归不能代替鼠标、触控板和窗口外观验收 | | 完成交付 | 待完成 | 完成实现、检查和文档后,更新可评审 PR,等待用户手动合并 | @@ -43,3 +43,19 @@ 尺寸、边距和边框必须有限、非负,受现有 1000000 上限限制。根约束、每个 Mount 的偏好尺寸、分配、收敛判定及源协议缓存均保持独立。示例见 [box-layout.endor](../examples/box-layout.endor)。 布局阶段验证:本地 `cargo test --locked` 的 162 项回归全部通过,`cargo clippy --all-targets --locked -- -D warnings`、格式与差异空白检查通过。新增用例执行实际 Endor 脚本,覆盖共享对象在不同父容器中的对齐、实时边距变更、flex 分配、叠放、尺寸边界、文本换行、有限 fill、无界 fill 拒绝、源 arrange 优先、裁剪命中和可运行示例。本阶段尚未重新打包或进行原生指针验收。 + +## 2D 场景与视口 + +`Scene2D(children...)` 持有世界内容。直接子项的 x/y 是世界位置;Row/Column/Overlay 仍然只解释自己的有限内容布局。给世界内卡片设置 boxWidth,即可在其内部换行和嵌套排版。 + +`CanvasViewport(width, height, scene)` 是可参加普通排版的有限盒子,世界范围不参与它的本征尺寸。视口可使用通用盒模型尺寸、边框、内边距和 flex。每个新建视口拥有自己的 `Camera2D()`;显式赋值 `second.camera = first.camera` 才会共享视角。摄像机 x/y 是可视内容左上角的世界位置,zoom 范围 0.01–100;也可以 `Camera2D(x, y, zoom)` 创建。嵌套投影还受合成变换预算限制。 + +视口的有限内容框裁剪世界绘制和输入,Overlay 中的兄弟 UI 保持固定。场景内可以放另一个 CanvasViewport:布局坐标不变,投影逐层合成,指针逆变换至目标组件坐标。相机改变时复用不变的测量结果。`view.mounts().at(index).fitView()` 使用指定 Mount 的有限尺寸及场景直接子项的有限边界适配摄像机,不把无限世界当作排版边界。 + +滚轮优先交给命中的可滚动 ScrollView;其余平移交给最内层画布。空格加左键或中键拖动锁定开始时的视口,不因指针跨到另一个视口而切换相机;捏合及 Cmd/Ctrl+滚轮围绕指针缩放。`viewport-pan` / `viewport-zoom` 使用真实 Mount 身份及帧版本,通过 HTTP worker 串行修改所属 VM 的摄像机。非法缩放锚点在进入 VM 前拒绝,过期帧返回 409。 + +`ScrollView` 是现有 ScrollBlock 的名义子类型别名,滚动轴和其内容 Row/Column 排版独立。默认开始页已使用普通 Page + 独立场景/视口,纵横示例使用 ScrollView 对应的滚动容器组合;不再设置 Page.mode。原有 Page.mode/panX/panY/zoom 暂保留兼容旧脚本,不用于新架构示例。 + +示例见 [scene-viewports.endor](../examples/scene-viewports.endor)。回归包含两个视口共享同一业务对象、显式共享相机、嵌套变换、真实点击回调、相机修改时测量数为 0、真实 HTTP 链路及软件渲染像素中的缩放/裁剪。它们不能替代原生鼠标和触控板验收。 + +2D 阶段最终验证:169 项本地回归全部通过,all-target Clippy 拒绝 warnings 通过,编辑器未报告错误。默认页的真实按钮事件也已迁移验证。未将原生窗口验收记为完成。 diff --git a/examples/README.md b/examples/README.md index 1a43a9e..e51bd30 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,6 +2,7 @@ | File | Purpose | | --- | --- | +| [scene-viewports.endor](scene-viewports.endor) | One Scene2D in two CanvasViewports with independent Camera2D, a nested viewport, shared state and fixed overlay UI. | | [box-layout.endor](box-layout.endor) | Nested Row/Column/Overlay, shared text, finite border-box sizing, margins, borders, alignment and clipping. | | [gc-lifecycle.endor](gc-lifecycle.endor) | A Page/Block/Task/await-frame cycle: hidden retained Blocks keep running, then releasing the last references permits collection despite rendering caches. | | [scroll-axis.endor](scroll-axis.endor) | One shared TextBlock in vertical and horizontal viewports, live axis changes and independent offsets. | diff --git a/examples/scene-viewports.endor b/examples/scene-viewports.endor new file mode 100644 index 0000000..a7e9e92 --- /dev/null +++ b/examples/scene-viewports.endor @@ -0,0 +1,28 @@ +// The scene has world positions; each finite viewport has its own camera. +count = ui.state(0) +increment = Button("共享计数 +1") +increment.onClick(count.increment) +card = Column(20, 12, TextBlock("世界里的 UI", 2), ui.textOf(count, "value", 3), increment) +card.boxWidth = 220 +card.x = -60 +card.y = 40 +card.borderLeft = 2 +card.borderColor = "#8CA5B8" + +inner = CanvasViewport(200, 100, Scene2D(RectBlock(120, 60, 8, "#D4E6D9"))) +inner.x = 260 +inner.y = 40 +world = Scene2D(card, inner) +left = CanvasViewport(460, 320, world) +left.camera.x = -100 +right = CanvasViewport(320, 320, world) +right.camera.x = -80 +right.camera.zoom = 0.65 +// Set right.camera = left.camera to share a view explicitly. + +views = Row(0, 20, left, right) +hud = Column(16, 8, TextBlock("一个世界,多个视角", 2), TextBlock("滚轮平移 · 空格/中键拖动 · 捏合或 Cmd/Ctrl+滚轮缩放")) +hud.alignSelf = "end" +page = Page() +page.title = "场景与视口" +page.render(Overlay(12, 0, views, hud)) diff --git a/src/bridge/ui_bridge.rs b/src/bridge/ui_bridge.rs index 699ff0a..c3fb478 100644 --- a/src/bridge/ui_bridge.rs +++ b/src/bridge/ui_bridge.rs @@ -38,12 +38,16 @@ pub const TYPE_NAMES: &[&str] = &[ "Block", "ContainerBlock", "ScrollBlock", + "ScrollView", "TextBlock", "RectBlock", "Spacer", "Row", "Column", "Overlay", + "Scene2D", + "CanvasViewport", + "Camera2D", "Button", "Page", "Window", @@ -145,6 +149,9 @@ enum Operation { Row, Column, Overlay, + Scene2D, + CanvasViewport, + Camera2D, Button, Page, SetText, @@ -184,6 +191,9 @@ impl Operation { Increment => (0, 0), TextOf => (2, 4), Scroll => (3, 3), + CanvasViewport => (3, 3), + Camera2D => (0, 3), + Scene2D => (0, MAX_UI_NODES), Text => (1, 3), Rect => (2, 4), MoveTab | MoveWindow | OpenValue | Spacer | SetText | SetColor | Append | Remove @@ -414,6 +424,31 @@ fn new_block( new_typed_block(cx, data, session, None) } +fn new_camera2d( + cx: &mut NativeContext<'_>, + session: &Shared, + requested: Option, + args: &[Value], +) -> Result { + let desktop = session + .lock() + .map_err(|_| invalid("UI session poisoned"))? + .desktop; + let definition = requested.unwrap_or(desktop_type(cx.store(), "Camera2D", desktop)?); + let camera = cx.allocate(crate::viewport::Camera2D)?; + cx.attach_type(camera, definition)?; + for (index, name, fallback) in [(0, "x", 0.0), (1, "y", 0.0), (2, "zoom", 1.0)] { + let value = args + .get(index) + .map(crate::scroll::coordinate) + .transpose()? + .unwrap_or(fallback); + cx.set_attr(camera, name, crate::native_visual::value(value))?; + } + crate::viewport::camera(cx.store(), camera)?; + Ok(camera) +} + fn new_typed_block( cx: &mut NativeContext<'_>, data: Data, @@ -428,6 +463,8 @@ fn new_typed_block( Kind::Row => "Row", Kind::Column => "Column", Kind::Overlay => "Overlay", + Kind::Scene2D => "Scene2D", + Kind::CanvasViewport { .. } => "CanvasViewport", Kind::Button => "Button", Kind::Page => "Page", }; @@ -1197,6 +1234,9 @@ impl UiBridge { if matches!(input.kind.as_str(), "page-pan" | "page-zoom") { return self.dispatch_page_view(vm, input); } + if matches!(input.kind.as_str(), "viewport-pan" | "viewport-zoom") { + return self.dispatch_viewport_view(vm, input); + } if matches!( input.kind.as_str(), "tab-activate" | "tab-close" | "tab-detach" | "tab-drop" @@ -1244,6 +1284,8 @@ impl UiBridge { let delta = input .delta .ok_or_else(|| anyhow::anyhow!("scroll has no delta"))?; + let zoom = data.geometry.view.unwrap_or_default().zoom; + let delta = [delta[0] / zoom, delta[1] / zoom]; let method = vm.bind_attr(mount, "scrollBy")?; let result = match method { Value::Object(function) => vm.call_with_limits( @@ -1849,6 +1891,7 @@ impl UiBridge { .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; crate::scroll::project(vm, &state.mounts, &root, &mut frame)?; + crate::viewport::project(&root, &mut frame)?; } // Native layout may replace the structural Frame; attach chrome only // after either layout path has produced the final page geometry. diff --git a/src/bridge/ui_bridge/bootstrap.rs b/src/bridge/ui_bridge/bootstrap.rs index 4fd58a2..1269ba2 100644 --- a/src/bridge/ui_bridge/bootstrap.rs +++ b/src/bridge/ui_bridge/bootstrap.rs @@ -44,6 +44,9 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { ("row", Operation::Row), ("column", Operation::Column), ("overlay", Operation::Overlay), + ("scene2D", Operation::Scene2D), + ("canvasViewport", Operation::CanvasViewport), + ("camera2D", Operation::Camera2D), ("button", Operation::Button), ("page", Operation::Page), ("present", Operation::Present), @@ -85,9 +88,13 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { ("RectBlock", "Block", Some("rect")), ("Spacer", "Block", Some("spacer")), ("ScrollBlock", "ContainerBlock", Some("scroll")), + ("ScrollView", "ScrollBlock", Some("scroll")), ("Row", "ContainerBlock", Some("row")), ("Column", "ContainerBlock", Some("column")), ("Overlay", "ContainerBlock", Some("overlay")), + ("Scene2D", "ContainerBlock", Some("scene2D")), + ("CanvasViewport", "ContainerBlock", Some("canvasViewport")), + ("Camera2D", "Object", Some("camera2D")), ("Button", "ContainerBlock", Some("button")), ("Page", "ContainerBlock", Some("page")), ("Window", "Object", Some("window-create")), @@ -133,6 +140,9 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { "row" => Operation::Row, "column" => Operation::Column, "overlay" => Operation::Overlay, + "scene2D" => Operation::Scene2D, + "canvasViewport" => Operation::CanvasViewport, + "camera2D" => Operation::Camera2D, "button" => Operation::Button, "page" => Operation::Page, "window-create" => Operation::NewWindow, @@ -236,6 +246,9 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { "row" => Some(Operation::Row), "column" => Some(Operation::Column), "overlay" => Some(Operation::Overlay), + "scene2D" => Some(Operation::Scene2D), + "canvasViewport" => Some(Operation::CanvasViewport), + "camera2D" => Some(Operation::Camera2D), "button" => Some(Operation::Button), "page" => Some(Operation::Page), "window-create" => Some(Operation::NewWindow), diff --git a/src/bridge/ui_bridge/calls.rs b/src/bridge/ui_bridge/calls.rs index 3def9cd..5b43eb9 100644 --- a/src/bridge/ui_bridge/calls.rs +++ b/src/bridge/ui_bridge/calls.rs @@ -16,6 +16,10 @@ impl NativeCallable for Function { if !(minimum..=maximum).contains(&args.len()) { return Err(invalid("incorrect UI method argument count")); } + if matches!(self.operation, Camera2D) { + let camera = new_camera2d(cx, &self.session, self.requested_type, args)?; + return Ok(NativeResult::Return(Value::Object(camera))); + } if matches!(self.operation, Implement | Implements | Supports) { let capability = object(args.get(1))?; let value = match self.operation { @@ -362,6 +366,27 @@ impl NativeCallable for Function { dimension(args.get(1))?, )), Page => Some(Data::new(Kind::Page, vec![], 0.0, 0.0)), + Scene2D => Some(Data::new( + Kind::Scene2D, + args.iter() + .map(|value| object(Some(value))) + .collect::>()?, + 0.0, + 0.0, + )), + CanvasViewport => { + let mut data = Data::new( + Kind::CanvasViewport { + width: dimension(args.first())?, + height: dimension(args.get(1))?, + }, + vec![object(args.get(2))?], + 0.0, + 0.0, + ); + data.camera = Some(new_camera2d(cx, &self.session, None, &[])?); + Some(data) + } Button => { let label = new_block( cx, diff --git a/src/bridge/ui_bridge/page_input.rs b/src/bridge/ui_bridge/page_input.rs index 1b54ba3..c610014 100644 --- a/src/bridge/ui_bridge/page_input.rs +++ b/src/bridge/ui_bridge/page_input.rs @@ -2,6 +2,91 @@ use super::*; impl UiBridge { + pub(super) fn dispatch_viewport_view( + &self, + vm: &mut Vm, + input: &crate::events::Input, + ) -> Result<()> { + let weak = { + let state = self + .session + .lock() + .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + anyhow::ensure!( + state.published_version == input.layout_version, + "obsolete viewport view" + ); + *state + .mounts + .active + .get(&input.mount) + .ok_or_else(|| anyhow::anyhow!("viewport is no longer active"))? + }; + let mount = vm + .upgrade(weak)? + .ok_or_else(|| anyhow::anyhow!("viewport Mount expired"))?; + let result = (|| { + let occurrence = vm.payload::(mount, "Mount")?; + anyhow::ensure!( + occurrence.mounted + && occurrence.version == input.layout_version + && occurrence.geometry.canvas_viewport, + "obsolete viewport occurrence" + ); + let geometry = occurrence.geometry.clone(); + let block = occurrence.block; + let (_, data) = block::resolve(vm.object_store(), block)?; + let data = block::visual_data(vm.object_store(), data)?; + let camera = data + .camera + .ok_or_else(|| anyhow::anyhow!("viewport has no camera"))?; + let old = crate::viewport::camera(vm.object_store(), camera)?; + let delta = input + .delta + .ok_or_else(|| anyhow::anyhow!("viewport input has no delta"))?; + let outer = geometry.view.unwrap_or_default(); + let (mut x, mut y, mut zoom) = (old.x, old.y, old.zoom); + if input.kind == "viewport-zoom" { + let pointer = input + .point + .ok_or_else(|| anyhow::anyhow!("zoom requires anchor"))?; + let pointer = crate::geometry::Point::new(pointer[0], pointer[1]); + anyhow::ensure!( + geometry.contains(pointer), + "zoom anchor is outside viewport" + ); + let pointer = outer.inverse(pointer); + let node = block::snapshot(vm.object_store(), block)?; + let inset = crate::layout::insets(&node); + let local = [ + pointer.x - geometry.x - inset.left, + pointer.y - geometry.y - inset.top, + ]; + anyhow::ensure!( + local[0] >= 0.0 + && local[1] >= 0.0 + && local[0] < geometry.width - inset.horizontal() + && local[1] < geometry.height - inset.vertical(), + "zoom anchor is outside content viewport" + ); + zoom = (old.zoom * delta[0]).clamp(0.01, 100.0); + x += local[0] / old.zoom - local[0] / zoom; + y += local[1] / old.zoom - local[1] / zoom; + } else { + x += delta[0] / outer.zoom / zoom; + y += delta[1] / outer.zoom / zoom; + } + x = x.clamp(-MAX_DIMENSION as f32, MAX_DIMENSION as f32); + y = y.clamp(-MAX_DIMENSION as f32, MAX_DIMENSION as f32); + vm.set_attr(camera, "x", crate::native_visual::value(x))?; + vm.set_attr(camera, "y", crate::native_visual::value(y))?; + vm.set_attr(camera, "zoom", crate::native_visual::value(zoom))?; + Ok(()) + })(); + vm.release(mount)?; + result + } + pub(super) fn dispatch_page_view( &self, vm: &mut Vm, diff --git a/src/bridge/ui_bridge/signatures.rs b/src/bridge/ui_bridge/signatures.rs index 254e4fd..9f0a23c 100644 --- a/src/bridge/ui_bridge/signatures.rs +++ b/src/bridge/ui_bridge/signatures.rs @@ -152,6 +152,24 @@ pub(super) fn operation_signature( CallEffect::ReadOnly, ), Page => (vec![], None, nominal("Page")?, CallEffect::ReadOnly), + Scene2D => ( + vec![], + Some(nominal("Block")?), + nominal("Scene2D")?, + CallEffect::ReadOnly, + ), + CanvasViewport => ( + vec![Number, Number, nominal("Scene2D")?], + None, + nominal("CanvasViewport")?, + CallEffect::ReadOnly, + ), + Camera2D => ( + vec![Number, Number, Number], + None, + nominal("Camera2D")?, + CallEffect::ReadOnly, + ), _ => return Err(invalid("unsupported UI function signature")), }; Ok(CallSignature { diff --git a/src/bridge/ui_bridge/start_page.endor b/src/bridge/ui_bridge/start_page.endor index 2b914dc..2eeaa1b 100644 --- a/src/bridge/ui_bridge/start_page.endor +++ b/src/bridge/ui_bridge/start_page.endor @@ -27,7 +27,6 @@ class StartActions { function blank(self: StartActions, event: Event): Unit { page = Page() page.title = "窗口页面" - page.mode = "window" back = Button("返回画布") back.onClick(self.home.activate) page.render(Column(56, 20, TextBlock("恰好一个窗口", 3), TextBlock("页面跟随可用区域调整。内容和工具栏由 Endor 自己组织。"), back)) @@ -35,26 +34,30 @@ class StartActions { function vertical(self: StartActions, event: Event): Unit { page = Page() page.title = "纵向页面" - page.mode = "vertical" - page.padding = 48 - page.gap = 28 - page.render(Column(0, 12, TextBlock("向下,继续生长", 3), TextBlock("宽度跟随窗口,高度随子组件增长。滚轮向下浏览。"))) - page.append(Note(24, 16, TextBlock("01 / 收集想法", 2), RectBlock(176, 130, 10, "#E2EADB"), TextBlock("从一个小小的念头开始。"), "#F0F3E9")) - page.append(Note(24, 16, TextBlock("02 / 连接片段", 2), RectBlock(176, 130, 10, "#E6DBEF"), TextBlock("内容保留在同一条连续的页面里。"), "#F4EEF7")) - page.append(Note(24, 16, TextBlock("03 / 慢慢展开", 2), RectBlock(176, 130, 10, "#EDDCC5"), TextBlock("追加一个子组件,页面自然延伸。"), "#FAF2E7")) - page.append(Note(24, 16, TextBlock("04 / 留一点空白", 2), TextBlock("不必把所有想法都放在第一屏。"), "#EDF1F6")) + content = Column(48, 28, Column(0, 12, TextBlock("向下,继续生长", 3), TextBlock("纵向滚动和 Column 排版可以独立组合。"))) + content.append(Note(24, 16, TextBlock("01 / 收集想法", 2), RectBlock(176, 130, 10, "#E2EADB"), TextBlock("从一个小小的念头开始。"), "#F0F3E9")) + content.append(Note(24, 16, TextBlock("02 / 连接片段", 2), RectBlock(176, 130, 10, "#E6DBEF"), TextBlock("内容保留在同一条连续的页面里。"), "#F4EEF7")) + content.append(Note(24, 16, TextBlock("03 / 慢慢展开", 2), RectBlock(176, 130, 10, "#EDDCC5"), TextBlock("追加一个子组件,内容自然延伸。"), "#FAF2E7")) + content.append(Note(24, 16, TextBlock("04 / 留一点空白", 2), TextBlock("不必把所有想法都放在第一屏。"), "#EDF1F6")) + view = ScrollBlock(1024, 800, content) + view.axis = "vertical" + view.flex = 1 + page.crossAlign = "stretch" + page.render(view) } function horizontal(self: StartActions, event: Event): Unit { page = Page() page.title = "横向页面" - page.mode = "horizontal" - page.padding = 48 - page.gap = 32 - page.render(Note(24, 16, TextBlock("从左,走向右", 2), RectBlock(176, 180, 10, "#DDE8F1"), TextBlock("高度跟随窗口,宽度由子组件决定。"), "#EDF3F8")) - page.append(Note(24, 16, TextBlock("收集", 2), RectBlock(176, 210, 10, "#D7E5DA"), TextBlock("一张卡片,一个想法。"), "#EFF4EC")) - page.append(Note(24, 16, TextBlock("整理", 2), RectBlock(176, 170, 10, "#E5D9EB"), TextBlock("横向滚动,保留思考的顺序。"), "#F4EFF7")) - page.append(Note(24, 16, TextBlock("继续", 2), RectBlock(176, 230, 10, "#EFDFC7"), TextBlock("滚轮或 Shift + 滚轮浏览更多内容。"), "#FAF3E8")) - page.append(Note(24, 16, TextBlock("不止一屏", 2), TextBlock("右侧仍有空间。"), "#EEF1F5")) + content = Row(48, 32, Note(24, 16, TextBlock("从左,走向右", 2), RectBlock(176, 180, 10, "#DDE8F1"), TextBlock("Row 内仍然可以包含 Column。"), "#EDF3F8")) + content.append(Note(24, 16, TextBlock("收集", 2), RectBlock(176, 210, 10, "#D7E5DA"), TextBlock("一张卡片,一个想法。"), "#EFF4EC")) + content.append(Note(24, 16, TextBlock("整理", 2), RectBlock(176, 170, 10, "#E5D9EB"), TextBlock("横向滚动,保留思考的顺序。"), "#F4EFF7")) + content.append(Note(24, 16, TextBlock("继续", 2), RectBlock(176, 230, 10, "#EFDFC7"), TextBlock("Shift + 滚轮浏览更多内容。"), "#FAF3E8")) + content.append(Note(24, 16, TextBlock("不止一屏", 2), TextBlock("右侧仍有空间。"), "#EEF1F5")) + view = ScrollBlock(1024, 800, content) + view.axis = "horizontal" + view.flex = 1 + page.crossAlign = "stretch" + page.render(view) } function canvas(self: StartActions, event: Event): Unit { page = newTab() @@ -71,7 +74,6 @@ class StartActions { function newTab(): Page { page = Page() page.title = "自由画布" - page.mode = "canvas" actions = StartActions(page) fixed = Button("窗口页面") vertical = Button("纵向延伸") @@ -86,26 +88,30 @@ function newTab(): Page { hero = Column(0, 16, TextBlock("给想法,一点空间。", 3, "#303941"), TextBlock("页面可以是一扇窗口,也可以一直延伸。", 1, "#7D858C"), Spacer(8), Row(0, 10, fixed, vertical), Row(0, 10, horizontal, canvas), shared) hero.x = 316 hero.y = 254 - page.render(hero) + world = Scene2D(hero) left = Note(24, 14, TextBlock("随手记下", 2), RectBlock(176, 100, 10, "#D5E4DA"), TextBlock("想法不必排成一条直线。"), "#EDF3E9") left.x = 36 left.y = 92 - page.append(left) + world.append(left) top = Note(24, 14, TextBlock("四种页面,同一个世界"), TextBlock("窗口 · 纵向 · 横向 · 自由画布", 1, "#7D858C"), "#EFF1F7") top.x = 466 top.y = 58 - page.append(top) - right = Note(24, 14, RectBlock(176, 138, 12, "#DDDAEA"), TextBlock("把片段放在一起", 2), TextBlock("位置属于组件,视角属于页面。"), "#F1EDF5") + world.append(top) + right = Note(24, 14, RectBlock(176, 138, 12, "#DDDAEA"), TextBlock("把片段放在一起", 2), TextBlock("布局属于组件,视角属于摄像机。"), "#F1EDF5") right.x = 826 right.y = 190 - page.append(right) + world.append(right) bottom = Note(24, 14, TextBlock("不止眼前这一屏", 2), TextBlock("触控板或滚轮平移\n空格拖动 / 鼠标中键拖动\n双指捏合 / ⌘ Ctrl + 滚轮缩放"), "#F7F0E5") bottom.x = 104 bottom.y = 524 - page.append(bottom) + world.append(bottom) end = Note(24, 14, TextBlock("继续向外探索", 2), RectBlock(176, 100, 10, "#CEDFE7"), TextBlock("画布上的对象仍是普通 Endor 组件。"), "#EAF1F5") end.x = 716 end.y = 614 - page.append(end) + world.append(end) + view = CanvasViewport(1024, 800, world) + view.flex = 1 + page.crossAlign = "stretch" + page.render(view) return page } diff --git a/src/bridge/ui_bridge/tree.rs b/src/bridge/ui_bridge/tree.rs index f8a1570..4e28117 100644 --- a/src/bridge/ui_bridge/tree.rs +++ b/src/bridge/ui_bridge/tree.rs @@ -28,6 +28,8 @@ impl UiNode { UiKind::Column { children, .. } | UiKind::Overlay { children, .. } | UiKind::Canvas { children, .. } + | UiKind::Scene2D { children, .. } + | UiKind::CanvasViewport { children, .. } | UiKind::Row { children, .. } | UiKind::Button { children, .. } | UiKind::Scroll { children, .. } => children, @@ -79,6 +81,23 @@ impl UiNode { .all(|(a, b)| Arc::ptr_eq(a, b)) }; match (&self.kind, &other.kind) { + ( + UiKind::CanvasViewport { + width: a, + height: b, + camera: c, + .. + }, + UiKind::CanvasViewport { + width: d, + height: e, + camera: f, + .. + }, + ) => a == d && b == e && c == f && same_children(), + (UiKind::Scene2D { positions: a, .. }, UiKind::Scene2D { positions: b, .. }) => { + a == b && same_children() + } (UiKind::Overlay { padding: a, .. }, UiKind::Overlay { padding: b, .. }) => { a == b && same_children() } @@ -161,6 +180,16 @@ impl UiNode { #[derive(Debug, Clone, PartialEq)] pub enum UiKind { + Scene2D { + positions: Vec<[f32; 2]>, + children: Vec>, + }, + CanvasViewport { + width: f32, + height: f32, + camera: crate::viewport::CameraView, + children: Vec>, + }, Overlay { padding: f32, children: Vec>, diff --git a/src/main.rs b/src/main.rs index 2e28118..3c4f015 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,7 @@ pub(crate) use model::{ }; pub(crate) use render::{ box_model, display_list, geometry, layout, native_visual, page, scene, scroll, - software_renderer, source_hit, source_layout, source_paint, text, + software_renderer, source_hit, source_layout, source_paint, text, viewport, }; pub(crate) use runtime::{ api, endor_host, endor_script, event_dispatch, events, io_tasks, tasks, vm_sequence, diff --git a/src/model/block.rs b/src/model/block.rs index 712b629..b11f286 100644 --- a/src/model/block.rs +++ b/src/model/block.rs @@ -33,6 +33,11 @@ pub enum Kind { Row, Column, Overlay, + Scene2D, + CanvasViewport { + width: f32, + height: f32, + }, Button, Page, } @@ -129,6 +134,7 @@ pub struct Data { pub border: crate::layout::Insets, pub border_color: String, pub clip_children: bool, + pub camera: Option, pub scroll_axis: crate::scroll::Axis, pub position: [f32; 2], pub surface: crate::page::Surface, @@ -168,6 +174,7 @@ impl Data { border: crate::layout::Insets::default(), border_color: "#D8DCE2".into(), clip_children: false, + camera: None, scroll_axis: crate::scroll::Axis::Both, position: [0.0, 0.0], surface: crate::page::Surface::new(crate::page::Mode::Window), @@ -187,6 +194,8 @@ impl Data { Kind::Row | Kind::Column | Kind::Overlay + | Kind::Scene2D + | Kind::CanvasViewport { .. } | Kind::Button | Kind::Page | Kind::Scroll { .. } @@ -205,6 +214,9 @@ impl ObjectPayload for Data { "tessera-block-data" } fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) { + if let Some(camera) = self.camera { + visit(camera); + } if let Some(properties) = self.properties { visit(properties); } @@ -403,6 +415,11 @@ pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> { )); } match &data.kind { + Kind::CanvasViewport { width, height } => fields.extend([ + ("width", number(*width)), + ("height", number(*height)), + ("camera", data.camera.map_or(Value::Nil, Value::Object)), + ]), Kind::Page => fields.extend([ ("mode", Value::Text(data.surface.mode.name().into())), ("panX", number(data.surface.pan.x)), @@ -461,6 +478,24 @@ pub fn visual_data(store: &ObjectStore, data: &Data) -> Result { { return Err(VmError::InvalidArgument("box minimum exceeds maximum")); } + if matches!(data.kind, Kind::CanvasViewport { .. }) { + crate::viewport::camera( + store, + data.camera.ok_or(VmError::InvalidArgument( + "CanvasViewport requires a Camera2D", + ))?, + )?; + if data.children.len() != 1 + || !matches!( + resolve(store, data.children[0].block)?.1.kind, + Kind::Scene2D + ) + { + return Err(VmError::InvalidArgument( + "CanvasViewport requires exactly one Scene2D", + )); + } + } Ok(data) } @@ -501,6 +536,13 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu return Ok(()); } match name { + "camera" if matches!(data.kind, Kind::CanvasViewport { .. }) => { + let Value::Object(camera) = value else { + return Err(bad()); + }; + data.camera = Some(*camera); + return Ok(()); + } "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => { let index = match name { "paddingTop" => 0, @@ -646,6 +688,8 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu return Ok(()); } match (&mut data.kind, name) { + (Kind::CanvasViewport { width, .. }, "width") => *width = number()?, + (Kind::CanvasViewport { height, .. }, "height") => *height = number()?, (Kind::Text { value: text, .. }, "text") => { let Value::Text(value) = value else { return Err(bad()); diff --git a/src/model/document.rs b/src/model/document.rs index bdd9f74..e70ca54 100644 --- a/src/model/document.rs +++ b/src/model/document.rs @@ -156,6 +156,16 @@ impl RenderDocument { for clip in self.frame.command_clips.iter().flatten() { validate_rect(clip[0], clip[1], clip[2], clip[3], 0.0)?; } + if !self.frame.command_views.is_empty() + && self.frame.command_views.len() != self.frame.commands.len() + { + bail!("invalid command view table"); + } + for view in self.frame.command_views.iter().flatten() { + if !view.valid() { + bail!("invalid command view transform"); + } + } for command in &self.frame.commands { command.validate()?; } @@ -175,6 +185,9 @@ impl RenderDocument { } let mut mount_ids = HashSet::new(); for mount in &self.frame.mounts { + if mount.view.is_some_and(|view| !view.valid()) { + bail!("invalid Mount view transform"); + } if mount.id == 0 || mount.block_id.is_empty() || !mount_ids.insert(mount.id) { bail!("invalid or duplicate mount identity"); } @@ -182,6 +195,9 @@ impl RenderDocument { if let Some(clip) = mount.clip { validate_rect(clip[0], clip[1], clip[2], clip[3], 0.0)?; } + if let Some(clip) = mount.viewport_clip { + validate_rect(clip[0], clip[1], clip[2], clip[3], 0.0)?; + } if let Some(scroll) = mount.scroll && (!scroll.iter().all(|n| { n.is_finite() && (0.0..=crate::ui_bridge::MAX_DIMENSION as f32).contains(n) @@ -229,7 +245,17 @@ impl RenderDocument { clip[0], clip[1], clip[2], clip[3], scale, ))); } - list.push(command.to_draw_command(scale)?); + let view = frame + .command_views + .get(index) + .copied() + .flatten() + .unwrap_or_default(); + list.push( + command + .to_draw_command(scale * view.zoom)? + .translated(Point::new(view.x * scale, view.y * scale)), + ); if clip.is_some() { list.push(DrawCommand::PopClip); } @@ -245,10 +271,18 @@ impl RenderDocument { } list.push( RenderCommand::StrokeRoundedRect { - x: focus.x, - y: focus.y, - width: focus.width, - height: focus.height, + x: focus + .view + .unwrap_or_default() + .rect(Rect::new(focus.x, focus.y, focus.width, focus.height)) + .x, + y: focus + .view + .unwrap_or_default() + .rect(Rect::new(focus.x, focus.y, focus.width, focus.height)) + .y, + width: focus.width * focus.view.unwrap_or_default().zoom, + height: focus.height * focus.view.unwrap_or_default().zoom, radius: 7.0, stroke_width: 2.0, color: "#4F6FDB".into(), @@ -345,6 +379,10 @@ pub struct Frame { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub command_clips: Vec>, #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub command_views: Vec>, + #[serde(skip)] + pub command_mounts: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub mounts: Vec, #[serde(default)] pub layout_version: u64, @@ -366,6 +404,8 @@ impl Default for Frame { background: default_background(), commands: Vec::new(), command_clips: Vec::new(), + command_views: Vec::new(), + command_mounts: Vec::new(), mounts: Vec::new(), layout_version: 0, session: 0, @@ -379,6 +419,12 @@ impl Default for Frame { /// A position in a published frame. Identifiers are opaque data, never VM handles. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct MountGeometry { + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub canvas_viewport: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub viewport_clip: Option<[f32; 4]>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub view: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub clip: Option<[f32; 4]>, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -397,7 +443,8 @@ pub struct MountGeometry { impl MountGeometry { pub fn contains(&self, point: Point) -> bool { - Rect::new(self.x, self.y, self.width, self.height).contains(point) + let local = self.view.map_or(point, |view| view.inverse(point)); + Rect::new(self.x, self.y, self.width, self.height).contains(local) && self.clip.is_none_or(|c| { c[2] > 0.0 && c[3] > 0.0 && Rect::new(c[0], c[1], c[2], c[3]).contains(point) }) @@ -405,6 +452,15 @@ impl MountGeometry { } impl Frame { + pub fn canvas_at(&self, point: Point) -> Option<&MountGeometry> { + self.mounts.iter().rev().find(|mount| { + mount.canvas_viewport + && mount.contains(point) + && mount.viewport_clip.is_some_and(|clip| { + Rect::new(clip[0], clip[1], clip[2], clip[3]).contains(point) + }) + }) + } pub fn hit_test(&self, point: Point) -> Option<&MountGeometry> { if self.fault.is_some() { return None; diff --git a/src/model/mount.rs b/src/model/mount.rs index 889026c..ba7533f 100644 --- a/src/model/mount.rs +++ b/src/model/mount.rs @@ -55,6 +55,7 @@ enum Query { ScrollBy, ScrollPosition, ScrollIntoView, + FitView, Parent, FirstChild, NextSibling, @@ -99,6 +100,7 @@ impl NativeCallable for Method { | Query::ScrollTo | Query::ScrollBy | Query::ScrollIntoView + | Query::FitView | Query::ScrollPosition ) { @@ -133,6 +135,10 @@ impl NativeCallable for Method { crate::scroll::into_view(cx, self.owner, self.desktop)?; Value::Nil } + Query::FitView => { + crate::viewport::fit(cx, &mount)?; + Value::Nil + } Query::ScrollPosition => { let p = mount .geometry @@ -263,7 +269,7 @@ fn mount_signature( }; let effect = if matches!( query, - Query::ScrollTo | Query::ScrollBy | Query::ScrollIntoView | Query::Focus + Query::ScrollTo | Query::ScrollBy | Query::ScrollIntoView | Query::FitView | Query::Focus ) { CallEffect::Mutating } else { @@ -405,6 +411,7 @@ impl NativeCallable for Sync { ("scrollBy", Query::ScrollBy), ("scrollPosition", Query::ScrollPosition), ("scrollIntoView", Query::ScrollIntoView), + ("fitView", Query::FitView), ("parent", Query::Parent), ("firstChild", Query::FirstChild), ("nextSibling", Query::NextSibling), diff --git a/src/model/snapshot.rs b/src/model/snapshot.rs index a35df6b..3e847ee 100644 --- a/src/model/snapshot.rs +++ b/src/model/snapshot.rs @@ -154,6 +154,14 @@ impl RenderCache { }); } } + if let Some(camera) = data.camera { + for name in ["x", "y", "zoom"] { + dependencies.push(Dependency { + object: camera, + property: Some(name.into()), + }); + } + } if !finish { if !path.insert(data_ref) { return Err(VmError::InvalidArgument("cyclic Block composition")); @@ -259,6 +267,27 @@ impl RenderCache { children, }, Kind::Spacer(size) => UiKind::Spacer(*size), + Kind::CanvasViewport { width, height } => UiKind::CanvasViewport { + width: *width, + height: *height, + camera: crate::viewport::camera( + store, + data.camera + .ok_or(VmError::InvalidArgument("missing camera"))?, + )?, + children, + }, + Kind::Scene2D => UiKind::Scene2D { + positions: data + .children + .iter() + .map(|child| { + let (_, child) = block::resolve(store, child.block)?; + Ok(block::visual_data(store, child)?.position) + }) + .collect::, VmError>>()?, + children, + }, Kind::Overlay => UiKind::Overlay { padding: data.padding, children, diff --git a/src/render/display_list.rs b/src/render/display_list.rs index 46f1b8d..c656750 100644 --- a/src/render/display_list.rs +++ b/src/render/display_list.rs @@ -59,6 +59,26 @@ pub enum DrawCommand { }, } +impl DrawCommand { + pub fn translated(mut self, delta: Point) -> Self { + match &mut self { + Self::WrappedText { rect, .. } + | Self::PushClip(rect) + | Self::FillRoundedRect { rect, .. } + | Self::StrokeRoundedRect { rect, .. } => { + rect.x += delta.x; + rect.y += delta.y; + } + Self::Text { origin, .. } => { + origin.x += delta.x; + origin.y += delta.y; + } + _ => {} + } + self + } +} + #[derive(Clone, Debug, Default)] pub struct DisplayList { commands: Vec, diff --git a/src/render/layout.rs b/src/render/layout.rs index 5807a5d..6efcff1 100644 --- a/src/render/layout.rs +++ b/src/render/layout.rs @@ -209,7 +209,10 @@ pub fn child_constraints(node: &UiNode, parent: [f32; 4]) -> [f32; 4] { content_limit(parent[3], padding.vertical()), ]; } - if matches!(node.kind, UiKind::Canvas { .. }) { + if matches!( + node.kind, + UiKind::Canvas { .. } | UiKind::Scene2D { .. } | UiKind::CanvasViewport { .. } + ) { return root_constraints(None); } if let UiKind::Scroll { width, height, .. } = node.kind { @@ -429,7 +432,8 @@ fn shape(node: &UiNode, children: Vec, constraints: [f32; 4]) -> Result { + UiKind::CanvasViewport { width, height, .. } => Shape::Rect(*width, *height), + UiKind::Canvas { positions, .. } | UiKind::Scene2D { positions, .. } => { let mut size = Size::default(); for (position, child) in positions.iter().zip(children) { size.width = size.width.max(position[0] + child.width); @@ -770,7 +774,15 @@ pub fn arrange_native( }) .collect(); } - if let UiKind::Canvas { positions, .. } = &node.kind { + if matches!(node.kind, UiKind::CanvasViewport { .. }) { + let inset = insets(node); + return Ok(node + .children() + .iter() + .map(|_| (x + inset.left, y + inset.top)) + .collect()); + } + if let UiKind::Canvas { positions, .. } | UiKind::Scene2D { positions, .. } = &node.kind { return Ok(positions .iter() .map(|point| (x + point[0], y + point[1])) @@ -996,6 +1008,9 @@ impl LayoutRuntime { coordinate(x + size.width)?; coordinate(y + size.height)?; mounts.push(MountGeometry { + canvas_viewport: matches!(item.node.kind, UiKind::CanvasViewport { .. }), + viewport_clip: None, + view: None, id: item.id, clip: None, scroll: None, diff --git a/src/render/mod.rs b/src/render/mod.rs index 548b104..3ed1501 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -12,3 +12,4 @@ pub(crate) mod source_hit; pub(crate) mod source_layout; pub(crate) mod source_paint; pub(crate) mod text; +pub(crate) mod viewport; diff --git a/src/render/scroll.rs b/src/render/scroll.rs index c7a8717..13fff0f 100644 --- a/src/render/scroll.rs +++ b/src/render/scroll.rs @@ -96,6 +96,8 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame let mut contexts = HashMap::)>::new(); frame.commands.clear(); frame.command_clips.clear(); + frame.command_mounts.clear(); + frame.command_views.clear(); for (i, m) in frame.mounts.iter_mut().enumerate() { let (dx, dy, clip) = m .parent @@ -176,6 +178,7 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame ) { frame.commands.push(command); frame.command_clips.push(m.clip); + frame.command_mounts.push(m.id); } } if frame.commands.len() > 10_000 { @@ -256,6 +259,14 @@ pub fn into_view( }; let mount = cx.store().payload::(owner, "Mount")?.clone(); parent = mount.parent; + if mount.geometry.canvas_viewport { + rect = [ + mount.geometry.x, + mount.geometry.y, + mount.geometry.width, + mount.geometry.height, + ]; + } if let Some(offset) = mount.geometry.scroll { let delta = |start: f32, length: f32, viewport: f32, size: f32| { if start < viewport { diff --git a/src/render/source_hit.rs b/src/render/source_hit.rs index 0308952..c29c175 100644 --- a/src/render/source_hit.rs +++ b/src/render/source_hit.rs @@ -79,8 +79,10 @@ fn selector( continue; } let geometry = &target.geometry; - let x = point[0] - geometry.x; - let y = point[1] - geometry.y; + let point = crate::geometry::Point::new(point[0], point[1]); + let point = geometry.view.map_or(point, |view| view.inverse(point)); + let x = point.x - geometry.x; + let y = point.y - geometry.y; if x < 0.0 || y < 0.0 || x > geometry.width || y > geometry.height { continue; } diff --git a/src/render/source_paint.rs b/src/render/source_paint.rs index 5a2e675..c3b52e0 100644 --- a/src/render/source_paint.rs +++ b/src/render/source_paint.rs @@ -417,6 +417,8 @@ fn finish(vm: &mut Vm, mut work: Work, reads: Vec) - } let mut commands = Vec::new(); let mut clips = Vec::new(); + let mut views = Vec::new(); + let mut owners = Vec::new(); for mount in &frame.mounts { let before = commands.len(); if let Some(custom) = output.commands.get(&mount.id) { @@ -436,12 +438,16 @@ fn finish(vm: &mut Vm, mut work: Work, reads: Vec) - )); } clips.extend(std::iter::repeat_n(mount.clip, commands.len() - before)); + views.extend(std::iter::repeat_n(mount.view, commands.len() - before)); + owners.extend(std::iter::repeat_n(mount.id, commands.len() - before)); if commands.len() > 10_000 { bail!("paint command budget exceeded"); } } frame.commands = commands; frame.command_clips = clips; + frame.command_views = views; + frame.command_mounts = owners; let registry = object(vm.get_attr(desktop, "types")?)?; let reads = reads .into_iter() diff --git a/src/render/viewport.rs b/src/render/viewport.rs new file mode 100644 index 0000000..027a896 --- /dev/null +++ b/src/render/viewport.rs @@ -0,0 +1,298 @@ +//! Reversible projections keep camera movement independent of layout. +use crate::geometry::{Point, Rect}; +use endor_core::{ObjectPayload, ObjectRef, ObjectStore, VmError}; + +/// Marker payload gives cameras a native nominal identity while their fields +/// remain ordinary traced, bindable VM attributes. +#[derive(Debug)] +pub struct Camera2D; +impl ObjectPayload for Camera2D { + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn kind_name(&self) -> &'static str { + "camera-2d" + } + fn allows_attributes(&self) -> bool { + true + } + fn trace(&self, _: &mut dyn FnMut(ObjectRef)) {} +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CameraView { + pub identity: String, + pub x: f32, + pub y: f32, + pub zoom: f32, +} + +pub fn camera(store: &ObjectStore, object: ObjectRef) -> Result { + store.payload::(object, "Camera2D")?; + let x = crate::scroll::coordinate(&store.get_attr(object, "x")?)?; + let y = crate::scroll::coordinate(&store.get_attr(object, "y")?)?; + let zoom = crate::scroll::coordinate(&store.get_attr(object, "zoom")?)?; + if !(0.01..=100.0).contains(&zoom) { + return Err(VmError::InvalidArgument( + "Camera2D.zoom expects 0.01 through 100", + )); + } + Ok(CameraView { + identity: object.to_string(), + x, + y, + zoom, + }) +} + +/// Fit finite scene bounds using this occurrence's allocation. Sharing a camera +/// is explicit, so fitting one view then intentionally affects all its users. +pub fn fit( + cx: &mut endor_core::NativeContext<'_>, + mount: &crate::mount::Mount, +) -> Result<(), VmError> { + if !mount.geometry.canvas_viewport || mount.layout.is_some() { + return Err(VmError::InvalidArgument( + "fitView requires a committed CanvasViewport Mount", + )); + } + let (_, data) = crate::block::resolve(cx.store(), mount.block)?; + let data = crate::block::visual_data(cx.store(), data)?; + let camera = data + .camera + .ok_or(VmError::InvalidArgument("missing viewport camera"))?; + let root = mount + .first_child + .map(|weak| cx.upgrade(weak)) + .transpose()? + .flatten() + .ok_or(VmError::InvalidArgument("missing scene occurrence"))?; + let scene = cx + .store() + .payload::(root, "Mount")? + .clone(); + let mut next = scene.first_child; + let mut bounds: Option<[f32; 4]> = None; + let mut count = 0; + while let Some(weak) = next { + count += 1; + if count > 10_000 { + return Err(VmError::ExecutionLimit("fitView scene bounds")); + } + let object = cx + .upgrade(weak)? + .ok_or(VmError::InvalidArgument("expired scene child"))?; + let child = cx.store().payload::(object, "Mount")?; + let rect = [ + child.geometry.x - scene.geometry.x, + child.geometry.y - scene.geometry.y, + child.geometry.x - scene.geometry.x + child.geometry.width, + child.geometry.y - scene.geometry.y + child.geometry.height, + ]; + bounds = Some(bounds.map_or(rect, |b| { + [ + b[0].min(rect[0]), + b[1].min(rect[1]), + b[2].max(rect[2]), + b[3].max(rect[3]), + ] + })); + next = child.next_sibling; + } + let node = crate::block::snapshot(cx.store(), mount.block)?; + let inset = crate::layout::insets(&node); + let width = (mount.geometry.width - inset.horizontal()).max(0.0); + let height = (mount.geometry.height - inset.vertical()).max(0.0); + let (x, y, zoom) = if let Some(bounds) = bounds { + let zoom = (width / (bounds[2] - bounds[0] + 48.0)) + .min(height / (bounds[3] - bounds[1] + 48.0)) + .clamp(0.01, 100.0); + ( + (bounds[0] + bounds[2]) / 2.0 - width / (2.0 * zoom), + (bounds[1] + bounds[3]) / 2.0 - height / (2.0 * zoom), + zoom, + ) + } else { + (0.0, 0.0, 1.0) + }; + for (name, value) in [("x", x), ("y", y), ("zoom", zoom)] { + cx.set_attr( + camera, + name, + crate::native_visual::value(value.clamp(-1_000_000.0, 1_000_000.0)), + )?; + } + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Transform { + pub zoom: f32, + pub x: f32, + pub y: f32, +} +impl Default for Transform { + fn default() -> Self { + Self { + zoom: 1.0, + x: 0.0, + y: 0.0, + } + } +} +impl Transform { + pub fn point(self, point: Point) -> Point { + Point::new(point.x * self.zoom + self.x, point.y * self.zoom + self.y) + } + pub fn inverse(self, point: Point) -> Point { + Point::new( + (point.x - self.x) / self.zoom, + (point.y - self.y) / self.zoom, + ) + } + pub fn rect(self, rect: Rect) -> Rect { + let point = self.point(Point::new(rect.x, rect.y)); + Rect::new( + point.x, + point.y, + rect.width * self.zoom, + rect.height * self.zoom, + ) + } + pub fn then(self, outer: Self) -> Self { + Self { + zoom: self.zoom * outer.zoom, + x: self.x * outer.zoom + outer.x, + y: self.y * outer.zoom + outer.y, + } + } + pub fn valid(self) -> bool { + self.zoom.is_finite() + && self.zoom > 0.0 + && self.zoom <= 1_000_000.0 + && self.x.is_finite() + && self.y.is_finite() + && self.x.abs() <= 1_000_000.0 + && self.y.abs() <= 1_000_000.0 + } +} + +fn intersect(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { + let x = a[0].max(b[0]); + let y = a[1].max(b[1]); + [ + x, + y, + ((a[0] + a[2]).min(b[0] + b[2]) - x).max(0.0), + ((a[1] + a[3]).min(b[1] + b[3]) - y).max(0.0), + ] +} + +pub fn project( + root: &crate::ui_bridge::UiNode, + frame: &mut crate::document::Frame, +) -> anyhow::Result<()> { + use crate::ui_bridge::UiKind; + use std::collections::HashMap; + let mut nodes = HashMap::new(); + let mut work = vec![root]; + while let Some(node) = work.pop() { + nodes.insert(node.block_id.as_str(), node); + work.extend(node.children().iter().map(|node| node.as_ref())); + } + let mut contexts = HashMap::)>::new(); + for mount in &mut frame.mounts { + let node = nodes[mount.block_id.as_str()]; + let (view, clip) = mount + .parent + .and_then(|parent| contexts.get(&parent).copied()) + .unwrap_or_default(); + anyhow::ensure!( + view.valid(), + "nested viewport transform exceeds numeric budget" + ); + mount.view = (view != Transform::default()).then_some(view); + mount.clip = clip; + mount.viewport_clip = None; + let mut child_view = view; + let mut child_clip = clip; + let inset = crate::layout::insets(node); + let viewport = if let UiKind::CanvasViewport { camera, .. } = &node.kind { + let x = mount.x + inset.left; + let y = mount.y + inset.top; + child_view = Transform { + zoom: camera.zoom, + x: x * (1.0 - camera.zoom) - camera.x * camera.zoom, + y: y * (1.0 - camera.zoom) - camera.y * camera.zoom, + } + .then(view); + Some(Rect::new( + x, + y, + (mount.width - inset.horizontal()).max(0.0), + (mount.height - inset.vertical()).max(0.0), + )) + } else if node.clip_children { + let border = node.border; + Some(Rect::new( + mount.x + border.left.min(mount.width), + mount.y + border.top.min(mount.height), + (mount.width - border.horizontal()).max(0.0), + (mount.height - border.vertical()).max(0.0), + )) + } else if matches!(node.kind, UiKind::Scroll { .. }) { + Some(Rect::new(mount.x, mount.y, mount.width, mount.height)) + } else { + None + }; + if let Some(rect) = viewport { + let rect = view.rect(rect); + let rect = [rect.x, rect.y, rect.width, rect.height]; + child_clip = Some(clip.map_or(rect, |clip| intersect(clip, rect))); + if mount.canvas_viewport { + mount.viewport_clip = child_clip; + } + } + contexts.insert(mount.id, (child_view, child_clip)); + } + let mounts: HashMap<_, _> = frame.mounts.iter().map(|mount| (mount.id, mount)).collect(); + frame.command_views.clear(); + frame.command_clips.clear(); + anyhow::ensure!( + frame.command_mounts.len() == frame.commands.len(), + "missing command occurrence table" + ); + for owner in &frame.command_mounts { + let mount = mounts + .get(owner) + .ok_or_else(|| anyhow::anyhow!("paint owner is unavailable"))?; + frame.command_views.push(mount.view); + frame.command_clips.push(mount.clip); + } + Ok(()) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn nested_views_compose_and_invert_without_changing_layout_coordinates() { + let inner = Transform { + zoom: 2.0, + x: 12.0, + y: -40.0, + }; + let outer = Transform { + zoom: 0.5, + x: 80.0, + y: 20.0, + }; + let point = Point::new(-5.0, 100.0); + let composed = inner.then(outer); + assert_eq!(composed.point(point), outer.point(inner.point(point))); + assert_eq!(composed.inverse(composed.point(point)), point); + assert_eq!( + composed.rect(Rect::new(-5.0, 100.0, 20.0, 30.0)), + Rect::new(81.0, 100.0, 20.0, 30.0) + ); + } +} diff --git a/src/runtime/api/guide.rs b/src/runtime/api/guide.rs index bc3035e..8b9346e 100644 --- a/src/runtime/api/guide.rs +++ b/src/runtime/api/guide.rs @@ -142,7 +142,9 @@ ScrollBlock.axis is a managed visual attribute: "both" (default), "vertical" or 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. -Finite box layout: Overlay(padding, gap, children...) stacks children (gap is unused); mainAlign aligns horizontally and crossAlign vertically. Row/Column crossAlign accepts "start", "center", "end" or "stretch". Block.alignSelf defaults to "auto" and can override crossAlign per occurrence. marginTop/Right/Bottom/Left participate in measurement, alignment and flex allocation, without collapsing. paddingTop/Right/Bottom/Left override a side of the container padding; "auto" restores its fallback. borderTop/Right/Bottom/Left reserve inner border space; borderColor controls native border paint. Source paint overrides remain authoritative. boxWidth/boxHeight accept "auto", a nonnegative border-box dimension, or "fill" (finite parent required). Main-axis fill shares remaining space with weight 1 unless flex is set; minWidth/minHeight/maxWidth/maxHeight bound each dimension. Stretch changes only auto cross-axis dimensions. overflow is "visible" or "clip"; clipping affects descendant paint and hits. ScrollBlock handles scrolling independently of its content layout. See examples/box-layout.endor and docs/scene-architecture.md. Scene2D/CanvasViewport/Camera2D and Scene3D/Viewport3D are still pending; Page.mode remains a transitional API. +Finite box layout: Overlay(padding, gap, children...) stacks children (gap is unused); mainAlign aligns horizontally and crossAlign vertically. Row/Column crossAlign accepts "start", "center", "end" or "stretch". Block.alignSelf defaults to "auto" and can override crossAlign per occurrence. marginTop/Right/Bottom/Left participate in measurement, alignment and flex allocation, without collapsing. paddingTop/Right/Bottom/Left override a side of the container padding; "auto" restores its fallback. borderTop/Right/Bottom/Left reserve inner border space; borderColor controls native border paint. Source paint overrides remain authoritative. boxWidth/boxHeight accept "auto", a nonnegative border-box dimension, or "fill" (finite parent required). Main-axis fill shares remaining space with weight 1 unless flex is set; minWidth/minHeight/maxWidth/maxHeight bound each dimension. Stretch changes only auto cross-axis dimensions. overflow is "visible" or "clip"; clipping affects descendant paint and hits. ScrollBlock handles scrolling independently of its content layout. See examples/box-layout.endor and docs/scene-architecture.md. Independent 2D scenes are described below; Scene3D/Viewport3D are still pending. Page.mode remains a compatibility API. + +Independent 2D scenes are now implemented: Scene2D(children...) stores world content at child x/y; CanvasViewport(width,height,scene) is a finite layout box with its own Camera2D(). Camera2D(x=0,y=0,zoom=1) fields are ordinary managed attributes; zoom is 0.01–100. Sharing a scene does not share its camera; assigning second.camera=first.camera does. Scene content may contain layout containers and nested finite viewports; Overlay siblings stay fixed. Mount.fitView() fits finite direct scene-child bounds using that occurrence's allocation. Native wheel/pinch/space or middle drag targets the innermost viewport; drag retains its initial target. viewport-pan/viewport-zoom carry the viewport Mount and current frame identities, with signed logical page coordinates. Pan delta is measured before the viewport projection; zoom delta is [factor,0] with an in-content pointer anchor. Worker validation rejects stale/invalid input before invoking the VM. Draw and hit transforms compose consistently while layout geometry and measurement caches remain in world coordinates. ScrollView is a nominal ScrollBlock subtype. The default page uses these APIs; old Page.mode/panX/panY/zoom remain for compatibility. Scene3D/Viewport3D are still pending. See examples/scene-viewports.endor. 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. diff --git a/src/runtime/api/tests.rs b/src/runtime/api/tests.rs index 14377e3..46a618c 100644 --- a/src/runtime/api/tests.rs +++ b/src/runtime/api/tests.rs @@ -581,3 +581,87 @@ page.render(button) assert_eq!(response.0, 400); assert!(namespace.read().unwrap().document.frame.fault.is_none()); } + +#[test] +fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faulting() { + let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); + let namespace = Arc::new(RwLock::new(NamespaceState { + viewport: Some(crate::layout::Size { + width: 600.0, + height: 400.0, + }), + ..NamespaceState::default() + })); + let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap(); + let client = TestServer { + address: server.server_addr().to_ip().unwrap(), + server, + api: Some(api), + }; + let source = r#" + content = Button("inside") + content.x = 40 + content.y = 20 + scene = Scene2D(content) + view = CanvasViewport(200, 100, scene) + view.borderLeft = 10 + page = Page() + page.render(Column(20, 0, view)) + "#; + let response = client.request("PUT", "/v1/namespaces/app.main/code", source); + assert_eq!(response.0, 200, "{}", response.2); + let camera = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| { + let current = namespace.read().unwrap(); + let frame = ¤t.document.frame; + serde_json::to_string(&crate::events::Input { + window: 1, + session: frame.session, + layout_version: frame.layout_version, + mount: frame + .mounts + .iter() + .find(|mount| mount.canvas_viewport) + .unwrap() + .id, + kind: kind.into(), + delta: Some(delta), + point, + press_point: None, + screen_position: None, + tab_drop: None, + }) + .unwrap() + }; + let path = "/v1/namespaces/app.main/events"; + let pan = camera("viewport-pan", [20.0, -10.0], None); + let response = client.request("POST", path, &pan); + assert_eq!(response.0, 200, "{}", response.2); + assert_eq!(client.request("POST", path, &pan).0, 409); + let response = client.request( + "POST", + path, + &camera("viewport-zoom", [2.0, 0.0], Some([80.0, 60.0])), + ); + assert_eq!(response.0, 200, "{}", response.2); + for (expression, expected) in [ + ("view.camera.x", "45"), + ("view.camera.y", "10"), + ("view.camera.zoom", "2"), + ] { + let response = client.request("POST", "/v1/namespaces/app.main/repl", expression); + assert_eq!(response.0, 200, "{}", response.2); + assert_eq!( + serde_json::from_str::(&response.2).unwrap()["value"], + expected + ); + } + for point in [[25.0, 30.0], [220.0, 30.0], [700.0, 700.0]] { + let response = client.request( + "POST", + path, + &camera("viewport-zoom", [2.0, 0.0], Some(point)), + ); + assert_eq!(response.0, 400, "{}", response.2); + assert!(namespace.read().unwrap().document.frame.fault.is_none()); + } +} diff --git a/src/runtime/api/worker.rs b/src/runtime/api/worker.rs index 802e5f7..d15543c 100644 --- a/src/runtime/api/worker.rs +++ b/src/runtime/api/worker.rs @@ -338,6 +338,8 @@ impl Worker { | "scroll" | "page-pan" | "page-zoom" + | "viewport-pan" + | "viewport-zoom" | "tab-activate" | "tab-close" | "tab-detach" @@ -438,6 +440,7 @@ impl Worker { !frame.mounts.iter().any(|mount| { mount.id == input.mount && (mount.interactive + || input.kind.starts_with("viewport-") && mount.canvas_viewport || input.kind == "scroll" && mount.scroll.is_some()) }) } @@ -460,6 +463,26 @@ impl Worker { "camera input is unavailable in this Page mode", )); } + if input.kind == "viewport-zoom" + && !frame.mounts.iter().any(|mount| { + mount.id == input.mount + && input.point.is_some_and(|point| { + let point = crate::geometry::Point::new(point[0], point[1]); + mount.contains(point) + && mount.viewport_clip.is_some_and(|clip| { + point.x >= clip[0] + && point.y >= clip[1] + && point.x < clip[0] + clip[2] + && point.y < clip[1] + clip[3] + }) + }) + }) + { + return RequestProgress::Complete(error_response( + StatusCode(400), + "zoom anchor is outside content viewport", + )); + } Some(input) } else { None diff --git a/src/runtime/endor_host/tests.rs b/src/runtime/endor_host/tests.rs index 0c10651..20cbf4c 100644 --- a/src/runtime/endor_host/tests.rs +++ b/src/runtime/endor_host/tests.rs @@ -4414,6 +4414,300 @@ fn overlay_stretch_allocates_each_occurrence_without_reflowing_its_siblings() { ); } +#[test] +fn two_canvas_viewports_share_a_scene_with_independent_explicit_cameras() { + let (mut session, initial) = ReplSession::load( + r#" + hits = ui.state(0) + button = Button("shared") + button.onClick(hits.increment) + button.x = 40 + button.y = 20 + scene = Scene2D(button) + first = CanvasViewport(200, 100, scene) + second = CanvasViewport(200, 100, scene) + first.camera.x = 20 + first.camera.zoom = 2 + page = Page() + page.render(Row(0, 20, first, second)) + "#, + "app.main", + 304, + 1, + ) + .unwrap(); + initial.validate().unwrap(); + assert_eq!( + session + .execute("button.mounts().count", "app.main") + .unwrap() + .value, + "2" + ); + let buttons: Vec<_> = initial + .frame + .mounts + .iter() + .filter(|mount| mount.interactive) + .collect(); + assert_eq!(buttons.len(), 2); + assert_eq!(buttons[0].view.unwrap().zoom, 2.0); + assert!(buttons[1].view.is_none()); + assert_eq!(buttons[0].clip, Some([0.0, 0.0, 200.0, 100.0])); + assert_eq!(buttons[1].clip, Some([220.0, 0.0, 200.0, 100.0])); + let first_id = buttons[0].id; + let point = [45.0, 45.0]; + assert_eq!( + initial + .frame + .hit_test(crate::geometry::Point::new(point[0], point[1])) + .unwrap() + .id, + first_id + ); + session + .dispatch(&crate::events::Input { + window: crate::window::MAIN_WINDOW, + session: initial.frame.session, + layout_version: initial.frame.layout_version, + mount: first_id, + kind: "click".into(), + point: Some(point), + press_point: Some(point), + delta: None, + tab_drop: None, + screen_position: None, + }) + .unwrap(); + assert_eq!( + session.execute("hits.value", "app.main").unwrap().value, + "1" + ); + let changed = session + .execute("first.camera.x = -40\nfirst.camera.zoom = 1", "app.main") + .unwrap() + .document + .unwrap(); + changed.validate().unwrap(); + let buttons: Vec<_> = changed + .frame + .mounts + .iter() + .filter(|mount| mount.interactive) + .collect(); + assert_eq!(buttons[0].id, first_id); + assert_eq!(buttons[0].view.unwrap().x, 40.0); + assert!(buttons[1].view.is_none()); + assert_eq!(source_measure_stat(&mut session, "measured"), 0); + let shared = session + .execute( + "second.camera = first.camera\nfirst.camera.zoom = 2", + "app.main", + ) + .unwrap() + .document + .unwrap(); + shared.validate().unwrap(); + assert!( + shared + .frame + .mounts + .iter() + .filter(|mount| mount.interactive) + .all(|mount| mount.view.is_some_and(|view| view.zoom == 2.0)) + ); +} + +#[test] +fn nested_canvas_projects_pixels_and_hits_while_overlay_ui_stays_fixed() { + let (mut session, initial) = ReplSession::load( + r##" + button = Button("nested") + button.boxWidth = 20 + button.boxHeight = 20 + button.x = 10 + button.y = 10 + inner = CanvasViewport(80, 60, Scene2D(button)) + inner.camera.zoom = 0.5 + inner.x = 20 + inner.y = 20 + outer = CanvasViewport(200, 200, Scene2D(inner)) + outer.camera.zoom = 2 + hud = RectBlock(10, 10, 0, "#0000FF") + page = Page() + page.render(Overlay(0, 0, outer, hud)) + "##, + "app.main", + 305, + 1, + ) + .unwrap(); + initial.validate().unwrap(); + let button = initial + .frame + .mounts + .iter() + .find(|mount| mount.interactive) + .unwrap(); + assert_eq!( + button.view.unwrap(), + crate::viewport::Transform { + zoom: 1.0, + x: 20.0, + y: 20.0 + } + ); + assert_eq!(button.clip, Some([40.0, 40.0, 160.0, 120.0])); + let point = crate::geometry::Point::new(55.0, 55.0); + assert_eq!(initial.frame.hit_test(point).unwrap().id, button.id); + let inner_id = initial.frame.canvas_at(point).unwrap().id; + let input = crate::events::Input { + window: 1, + session: initial.frame.session, + layout_version: initial.frame.layout_version, + mount: inner_id, + kind: "viewport-pan".into(), + delta: Some([10.0, 0.0]), + point: None, + press_point: None, + tab_drop: None, + screen_position: None, + }; + session.dispatch(&input).unwrap(); + assert_eq!( + session.execute("inner.camera.x", "app.main").unwrap().value, + "10" + ); + assert_eq!( + session.execute("outer.camera.x", "app.main").unwrap().value, + "0" + ); + let document = session + .execute("hud.invalidate(\"paint\")", "app.main") + .unwrap() + .document + .unwrap(); + let mut pixels = vec![0; 200 * 200]; + crate::software_renderer::SoftwareRenderer::render( + &document.display_list(1.0).unwrap(), + &mut pixels, + 200, + 200, + ); + assert_eq!(pixels[5 * 200 + 5], 0x0000FF); + assert_ne!(pixels[55 * 200 + 45], pixels[190 * 200 + 190]); +} + +#[test] +fn canvas_world_paint_is_zoomed_and_clipped_in_the_software_renderer() { + let (_, initial) = ReplSession::load( + r##" + rect = RectBlock(20, 20, 0, "#FF0000") + rect.x = -10 + rect.y = 10 + view = CanvasViewport(50, 50, Scene2D(rect)) + view.camera.x = -10 + view.camera.zoom = 2 + page = Page() + page.render(Overlay(0, 0, view, RectBlock(10, 10, 0, "#0000FF"))) + "##, + "app.main", + 306, + 1, + ) + .unwrap(); + initial.validate().unwrap(); + let mut pixels = vec![0; 80 * 80]; + crate::software_renderer::SoftwareRenderer::render( + &initial.display_list(1.0).unwrap(), + &mut pixels, + 80, + 80, + ); + assert_eq!(pixels[5 * 80 + 5], 0x0000FF); + assert_eq!(pixels[25 * 80 + 5], 0xFF0000); + assert_eq!(pixels[55 * 80 + 5], pixels[79 * 80 + 79]); + assert_eq!(pixels[25 * 80 + 45], pixels[79 * 80 + 79]); +} + +#[test] +fn scene_viewport_example_and_scroll_view_alias_run_as_real_source() { + let (mut session, initial) = ReplSession::load( + include_str!("../../../examples/scene-viewports.endor"), + "app.main", + 307, + 1, + ) + .unwrap(); + initial.validate().unwrap(); + assert_eq!( + session + .execute("increment.mounts().count", "app.main") + .unwrap() + .value, + "2" + ); + session + .execute( + "testScroll = ScrollView(100, 60, Row(0, 0, TextBlock(\"scroll\")))", + "app.main", + ) + .unwrap(); + assert_eq!( + session + .execute("ui.isInstance(testScroll, ScrollBlock)", "app.main") + .unwrap() + .value, + "true" + ); +} + +#[test] +fn fit_view_uses_finite_scene_bounds_and_this_mounts_size() { + let (mut session, initial) = ReplSession::load( + r#" + rect = RectBlock(100, 50) + rect.x = -100 + rect.y = -25 + view = CanvasViewport(296, 196, Scene2D(rect)) + page = Page() + page.render(view) + "#, + "app.main", + 308, + 1, + ) + .unwrap(); + initial.validate().unwrap(); + let document = session + .execute("view.mounts().at(0).fitView()", "app.main") + .unwrap() + .document + .unwrap(); + document.validate().unwrap(); + for (expression, expected) in [ + ("view.camera.zoom", "2"), + ("view.camera.x", "-124"), + ("view.camera.y", "-49"), + ] { + assert_eq!( + session.execute(expression, "app.main").unwrap().value, + expected + ); + } + let rect = document + .frame + .mounts + .iter() + .find(|mount| mount.width == 100.0 && mount.height == 50.0) + .unwrap(); + let screen = rect + .view + .unwrap() + .point(crate::geometry::Point::new(rect.x, rect.y)); + assert_eq!(screen, crate::geometry::Point::new(48.0, 48.0)); +} + #[test] fn scroll_axes_wrap_shared_text_and_clamp_only_the_enabled_offsets() { let (mut session, initial) = ReplSession::load( @@ -6211,7 +6505,7 @@ page.zoom=2 } #[test] -fn bundled_start_page_creates_all_four_real_page_modes() { +fn bundled_start_page_uses_independent_layout_scroll_and_scene_viewports() { let mut session = ReplSession::new_with_viewport( 302, Some(crate::layout::Size { @@ -6225,12 +6519,16 @@ fn bundled_start_page_creates_all_four_real_page_modes() { .unwrap() .document .unwrap(); - assert_eq!(document.frame.surface.mode, crate::page::Mode::Canvas); - for (label, mode) in [ - ("窗口页面", crate::page::Mode::Window), - ("纵向延伸", crate::page::Mode::Vertical), - ("横向延伸", crate::page::Mode::Horizontal), - ] { + assert_eq!(document.frame.surface.mode, crate::page::Mode::Window); + assert!( + document + .frame + .mounts + .iter() + .any(|mount| mount.canvas_viewport) + ); + for (label, scrolling) in [("窗口页面", false), ("纵向延伸", true), ("横向延伸", true)] + { let home_tab = document .frame .tabs @@ -6270,7 +6568,20 @@ fn bundled_start_page_creates_all_four_real_page_modes() { .unwrap() .document .unwrap(); - assert_eq!(document.frame.surface.mode, mode, "{label}"); + assert_eq!( + document.frame.surface.mode, + crate::page::Mode::Window, + "{label}" + ); + assert_eq!( + document + .frame + .mounts + .iter() + .any(|mount| mount.scroll.is_some()), + scrolling, + "{label}" + ); assert!(document.frame.fault.is_none()); let input = crate::events::Input { window: 1, diff --git a/src/runtime/events.rs b/src/runtime/events.rs index f1b0b1e..8d50626 100644 --- a/src/runtime/events.rs +++ b/src/runtime/events.rs @@ -73,7 +73,11 @@ impl Input { { return Err("screen_position is only accepted for tab-detach with bounded coordinates"); } - if matches!(self.kind.as_str(), "page-pan" | "page-zoom") { + if matches!( + self.kind.as_str(), + "page-pan" | "page-zoom" | "viewport-pan" | "viewport-zoom" + ) { + let viewport = self.kind.starts_with("viewport-"); let valid_delta = self.delta.is_some_and(|delta| { delta .iter() @@ -82,17 +86,21 @@ impl Input { let valid_point = self.point.is_none_or(|point| { point .iter() - .all(|v| v.is_finite() && (0.0..=1_000_000.0).contains(v)) + .all(|v| v.is_finite() && v.abs() <= 1_000_000.0 && (viewport || *v >= 0.0)) }); if !valid_delta || !valid_point - || self.mount != 0 + || (if viewport { + self.mount == 0 + } else { + self.mount != 0 + }) || self.press_point.is_some() || self.screen_position.is_some() { return Err("invalid page camera input"); } - if self.kind == "page-zoom" + if self.kind.ends_with("-zoom") && (self.point.is_none() || self .delta diff --git a/src/shell/app.rs b/src/shell/app.rs index 57494cd..f6bdc35 100644 --- a/src/shell/app.rs +++ b/src/shell/app.rs @@ -50,6 +50,7 @@ pub struct TesseraApp { sidebar: crate::sidebar::State, sidebar_keyboard: bool, panning: bool, + pan_viewport: Option, space_down: bool, modifiers: ModifiersState, activity: Activity, @@ -87,6 +88,7 @@ impl TesseraApp { sidebar: crate::sidebar::State::default(), sidebar_keyboard: false, panning: false, + pan_viewport: None, space_down: false, modifiers: ModifiersState::default(), activity, @@ -605,8 +607,21 @@ impl TesseraApp { self.request_redraw(); } + fn begin_pan(&mut self) -> bool { + self.pan_viewport = None; + let Some(point) = self.guest_point() else { + return false; + }; + let Ok(current) = self.namespace.read() else { + return false; + }; + let frame = ¤t.document.frame; + self.pan_viewport = frame.canvas_at(point).map(|mount| mount.id); + self.pan_viewport.is_some() || frame.surface.mode != crate::page::Mode::Window + } + fn page_view(&mut self, kind: &str, delta: [f32; 2]) { - if self.guest_covered(self.pointer) { + if !self.panning && self.guest_covered(self.pointer) { return; } let point = Point::new(self.pointer.x - self.sidebar.left(), self.pointer.y); @@ -614,13 +629,58 @@ impl TesseraApp { let frame = ¤t.document.frame; if frame.session == 0 || frame.fault.is_some() - || frame.surface.mode == crate::page::Mode::Window + || !self.panning + && (point.x < 0.0 + || point.y < 0.0 + || current + .viewport + .is_some_and(|size| point.x >= size.width || point.y >= size.height)) + { + return None; + } + let world = frame.surface.to_world(point); + let viewport = if self.panning { + self.pan_viewport.and_then(|id| { + frame + .mounts + .iter() + .find(|mount| mount.id == id && mount.canvas_viewport) + }) + } else { + frame.canvas_at(world) + }; + if self.panning && self.pan_viewport.is_some() && viewport.is_none() { + return None; + } + if let Some(viewport) = viewport { + return Some(crate::events::Input { + window: self.resource, + session: frame.session, + layout_version: frame.layout_version, + mount: viewport.id, + kind: if kind == "page-zoom" { + "viewport-zoom" + } else { + "viewport-pan" + } + .into(), + point: (kind == "page-zoom").then_some([world.x, world.y]), + press_point: None, + delta: Some([ + delta[0] + / if kind == "page-pan" { + frame.surface.zoom + } else { + 1.0 + }, + delta[1] / frame.surface.zoom, + ]), + tab_drop: None, + screen_position: None, + }); + } + if frame.surface.mode == crate::page::Mode::Window || kind == "page-zoom" && frame.surface.mode != crate::page::Mode::Canvas - || point.x < 0.0 - || point.y < 0.0 - || current - .viewport - .is_some_and(|size| point.x >= size.width || point.y >= size.height) { return None; } @@ -1053,12 +1113,7 @@ impl ApplicationHandler for TesseraApp { button: MouseButton::Middle, .. } => { - self.panning = state == ElementState::Pressed - && self.guest_point().is_some() - && self - .namespace - .read() - .is_ok_and(|n| n.document.frame.surface.mode == crate::page::Mode::Canvas); + self.panning = state == ElementState::Pressed && self.begin_pan(); self.guest_pressed = None; window.request_redraw(); } @@ -1088,7 +1143,7 @@ impl ApplicationHandler for TesseraApp { let _ = window.drag_window(); } } else if self.space_down && self.guest_point().is_some() { - self.panning = true; + self.panning = self.begin_pan(); self.guest_pressed = None; } else { self.sidebar_keyboard = false; @@ -1152,10 +1207,11 @@ impl ApplicationHandler for TesseraApp { window.request_redraw(); return; } - let canvas = self - .namespace - .read() - .is_ok_and(|n| n.document.frame.surface.mode == crate::page::Mode::Canvas); + let point = self.guest_point(); + let canvas = self.namespace.read().is_ok_and(|n| { + n.document.frame.surface.mode == crate::page::Mode::Canvas + || point.is_some_and(|point| n.document.frame.canvas_at(point).is_some()) + }); if canvas && (self.modifiers.super_key() || self.modifiers.control_key()) { self.page_view("page-zoom", [(dy * 0.005).exp().clamp(0.1, 10.0), 0.0]); window.request_redraw();