From 82f9256a842bc4a346ebe318caf1c226fa974846 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Tue, 10 Nov 2026 16:18:47 +0800 Subject: [PATCH] feat: add page surfaces and collapsible sidebar --- README.md | 34 +- docs/architecture/01-modules.md | 4 +- docs/architecture/06-progress.md | 2 + docs/page-surfaces.md | 57 ++ examples/README.md | 6 +- src/README.md | 7 +- src/bridge/ui_bridge.rs | 48 +- src/bridge/ui_bridge/page_input.rs | 66 ++ src/bridge/ui_bridge/start_page.endor | 145 +++-- src/bridge/ui_bridge/tree.rs | 12 +- src/main.rs | 8 +- src/model/block.rs | 40 ++ src/model/document.rs | 15 + src/model/presentation.rs | 3 + src/model/snapshot.rs | 21 + src/render/display_list.rs | 38 ++ src/render/geometry.rs | 2 +- src/render/layout.rs | 51 +- src/render/mod.rs | 1 + src/render/native_visual.rs | 4 +- src/render/page.rs | 190 ++++++ src/render/scene.rs | 90 ++- src/render/source_layout.rs | 6 +- src/runtime/api/guide.rs | 29 +- src/runtime/api/tests.rs | 102 +++ src/runtime/api/worker.rs | 15 +- src/runtime/endor_host/tests.rs | 209 +++++- src/runtime/events.rs | 33 +- src/shell/app.rs | 887 ++++++++------------------ src/shell/mod.rs | 1 + src/shell/native_chrome.rs | 2 +- src/shell/sidebar.rs | 162 +++++ src/shell/tabs.rs | 671 +------------------ src/shell/tabs/hover.rs | 63 -- src/shell/tabs/motion.rs | 207 ------ 35 files changed, 1480 insertions(+), 1751 deletions(-) create mode 100644 docs/page-surfaces.md create mode 100644 src/bridge/ui_bridge/page_input.rs create mode 100644 src/render/page.rs create mode 100644 src/shell/sidebar.rs delete mode 100644 src/shell/tabs/hover.rs delete mode 100644 src/shell/tabs/motion.rs diff --git a/README.md b/README.md index d5fb093..c91dbea 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ 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. -Managed tab chrome and multiple windows are available. Typed capability contracts +Sidebar page navigation 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. @@ -235,13 +235,13 @@ The solver detects a previously visited nonconsecutive state as a cycle. It has Mount.bounds() rejects reads during measurement, including old geometry on an existing Mount. The current occurrence's allocated Bounds become available while its arrange method runs. Structural contexts are prepared before layout, geometry is committed after successful layout, and onMount/onUnmount notifications then run before paint/publication. Both layout phases share one read-only VM call and instruction budget; temporary results can be modified, existing business state cannot. Read versions are captured before hooks, so a hook changing a measured dependency schedules another pass before publication. Dependencies are currently tracked per object. Source measurement results are cached by Mount identity, method, child sizes, constraints and actual object reads, including decoding an existing returned MeasureResult. Cache entries contain borrowed identities and versions, adding no GC roots. Mount-reading measurements currently bypass the cache conservatively because object-level tracking cannot distinguish layoutVersion/geometry from stable structure. Global dictionary writes or changes to another field on a read object can also cause conservative misses. Native measurements keep their shape cache, and arrangement still visits each active occurrence. ui.stats() exposes sourceMeasureHits/sourceMeasureMisses; diagnostic statistics are rejected inside read-only rendering because allocation/count observations are not stable layout inputs. -`Window: Object` represents a native window resource. `Window()` creates another window in the same Aster VM, up to 16 open windows per runtime. `ui.window()` returns the current event/render window, with the main window as the top-level Console default. `page.window()` reports its actual owner or nil. `page.open(window)` and `page.moveToWindow(window)` move the same Page, including navigation and business references, to that window. Omitting the open target keeps an existing owner; an unowned Page uses the calling view. `page.detachToWindow()` creates a new Window, moves the Page there and returns the resource. Dragging a Tab away from its strip invokes that same Page method. Moving a Page unmounts the old occurrence and creates destination Mounts. `window.pages()` returns a traced ObjectList in open order. See [windows.endor](examples/windows.endor). +`Window: Object` represents a native window resource. `Window()` creates another window in the same Aster VM, up to 16 open windows per runtime. `ui.window()` returns the current event/render window, with the main window as the top-level Console default. `page.window()` reports its actual owner or nil. `page.open(window)` and `page.moveToWindow(window)` move the same Page, including navigation and business references, to that window. Omitting the open target keeps an existing owner; an unowned Page uses the calling view. `page.detachToWindow()` creates a new Window, moves the Page there and returns the resource. Moving a Page unmounts the old occurrence and creates destination Mounts. `window.pages()` returns a traced ObjectList in open order. See [windows.endor](examples/windows.endor). `container.insert(position, block)` inserts at a zero-based position from 0 through the current child count and returns a new stable slot. Existing slots and attached Mount identities survive insertion. Invalid positions and cyclic child graphs fail before replacing the child list. `page.moveTab(position)` moves the same Page to a final zero-based position in its owning window; a code reorder does not implicitly activate it. -Native dragging starts after 12 logical pixels and shows a blue insertion marker. Same-window drops call the actual Page `moveTab` and `activate`; cross-window drops first call `moveToWindow`. Source overrides participate. An override that declines or redirects the window move prevents the remaining destination reorder. Page identity and Page-owned tasks survive movement, while a cross-window move replaces Mounts and cancels old Mount tasks. Invalid source/target generations, layout versions or unpublished tab orders are rejected before callbacks. Callback failure stops the domain; earlier writes do not roll back. Escape cancels a drag. Overflow drops use visible gaps; drag auto-scroll remains pending. See [tab-placement.endor](examples/tab-placement.endor). +The version-checked tab-drop transport remains available for managed Page movement. Same-window requests call moveTab/activate and cross-window requests call moveToWindow first. Source overrides participate; generation/layout mismatches reject before callbacks. The sidebar currently supports selection/closure, with pointer dragging deferred. See [tab-placement.endor](examples/tab-placement.endor). -`window.moveTo(x, y)` queues a native outer-window origin in signed physical desktop pixels, within +/-1000000 per axis. A detached tab uses the release point and saved grab offset to place its new window. The window manager may adjust placement. Global coordinates are required for cross-window drops; unsupported native placement reports a diagnostic. This host tracks its own focus order when resolving overlapping windows; external-app occlusion and mixed-DPI dragging have not been fully verified. +`window.moveTo(x, y)` queues a native outer-window origin in signed physical desktop pixels, within +/-1000000 per axis. The window manager can adjust placement; unsupported native placement reports a diagnostic. Sidebar cross-window pointer dragging and mixed-DPI gestures remain unverified. `window.close()` and a secondary native close button call the same managed method. Closing first removes Page ownership, then commits unmounts and removes that context from the runtime registry; the native resource disappears after successful publication. Retained Pages can be opened in another live Window; otherwise Aster reclaims them normally. Window resource objects may remain referenced after closing, but reject resource operations; id/pages remain queryable. A retained leaf Mount uses a weak context link and cannot keep a closed window alive through its query methods. The primary shell still defines the application's lifetime: closing its OS window exits Tessera. Full application-exit notifications remain follow-up work. @@ -249,9 +249,9 @@ A secondary window can also be closed after a callback/rendering failure or Cons Each window has its own Presentation, layout/Mount/focus state, caches and native viewport. A single VM change batch fans out to all views, all affected frames settle before publication, and shared business references are never copied. Frames and native inputs carry window/session/layout identities; stale requests are rejected. Existing event clients default to window 1, exposed by X-Tessera-Window. Secondary native resize feeds the same worker and updates only its window constraints. Console reset preserves all old pictures with input disabled; replacement sessions discard the old native resources before projecting new resources with the same numeric IDs. -Open Pages appear in the native shell tab bar. Set `page.title` to label a tab; omitted titles use their open-order number. Clicking a tab or its close control calls the actual Page's `activate()` or `close()` method in the existing VM; source overrides participate. Ctrl+Tab / Ctrl+Shift+Tab cycle pages, and Cmd/Ctrl+W closes the active page when the page has keyboard focus. Overflow arrows expose additional pages. Closed Pages follow managed references and GC; a retained Page can be reopened. The tab projection contains opaque session-local IDs and text, with weak reverse lookup in the runtime, and adds no Page roots. Requests carry the published layout version; an old tab event is rejected. Switching creates new presentation Mounts while preserving the business objects. See [tabs.endor](examples/tabs.endor). +Open Pages appear in the left sidebar. Set `page.title` to label a page; clicking its row or close control calls the actual Page `activate()` or `close()` method in the same VM. Ctrl+Tab / Ctrl+Shift+Tab cycle pages, Cmd/Ctrl+T creates one and Cmd/Ctrl+W closes the active page. Sidebar rows scroll when needed. Source overrides, managed references, GC and stale-event rejection remain in use. See [tabs.endor](examples/tabs.endor). -The macOS title strip occupies 44 logical pixels and shares its row with the native window controls. The left rail and title strip form one neutral chrome surface. Content is inset by a 56-pixel rail, an 8-pixel top gap, and 8 pixels at the right and bottom. Page constraints use the remaining height; published Mount geometry and event coordinates remain relative to the content area. Window viewport snapshots continue to report the full native inner size. The host translates drawing and native pointer coordinates at this boundary. Console reset preserves the last picture with input disabled until a new live frame is published. +The host has one collapsible left sidebar, with page navigation and Console/Sources/Network/Settings. Cmd/Ctrl+B toggles it. Collapsed, the host draws only its expand button alongside the three real macOS window controls; content fills the native inner window with no top strip or surrounding inset. Only this small control area intercepts page input. `ui.viewport()` / `window.viewport()` and the API viewport headers report the available page area, including the width recovered when the sidebar collapses. Page drawings and hit tests share one camera transform. See [page surfaces](docs/page-surfaces.md). `window.resize(width, height)`, `setTitle(text)` and `focus()` project requests to the GUI thread. `id()` returns an opaque runtime resource ID and `viewport()` reports actual logical dimensions. Requests are asynchronous and coalesced by kind; queried dimensions change when the native window reports them, and platform bounds can adjust requested sizes. This host accepts sizes from 1 to 4096 logical pixels per axis, with a native minimum of 720 by 480. Titles are UTF-8 text up to 4096 bytes with no control characters. See [window.endor](examples/window.endor). @@ -259,7 +259,7 @@ Native creation and commands remain local until the input/candidate succeeds. Fa The native window publishes its current logical content size to the existing VM worker on startup, resize and scale changes. The worker coalesces the latest size, updates the managed viewport input, and refreshes the same runtime without adding Console source/history entries. ui.viewport() returns a fresh managed width/height snapshot; reads during rendering depend on the private input object. User changes to a returned snapshot do not resize the window. Unchanged sizes cause no frame; a fresh or unopened Console runtime updates its inputs without replacing a preserved displayed page. Failed resize layouts stop the affected runtime and preserve the displayed frame. -The default Column/Button policy propagates available cross-axis width minus padding and leaves height unconstrained; Row propagates available height and leaves width unconstrained. Page uses both viewport bounds. Native and source desired sizes are clamped to their supplied constraints, while source arrange can still allocate explicit child bounds. The window clips overflow and pointer targeting to its extent; TextBlock wraps to its available width; source layout now settles constraints over repeated measurement/arrangement passes as described below. See [layout-cache.endor](examples/layout-cache.endor). GET code also exposes X-Tessera-Viewport-Width/Height headers without evaluating source. +Column/Button propagate available cross-axis width and Row propagates available height. Page constraints follow `page.mode`: `window` fills the available area, `vertical` grows downward, `horizontal` grows rightward and `canvas` arranges direct children using their signed `x` / `y` attributes. Native and source desired sizes still obey constraints and budgets. The host clips paint/input to the visible area; TextBlock wraps to its available width. See [page surfaces](docs/page-surfaces.md) and [layout-cache.endor](examples/layout-cache.endor). Native pointer clicks now resolve source `hitTest(self, mount, point): Bool` overrides in reverse paint order, then dispatch through the existing capture/target/bubble route. The Point is local to that occurrence; `point.inside(bounds)` uses a half-open rectangle and `point.inCircle(cx, cy, radius)` includes the circle boundary. Each candidate is first restricted to its Mount rectangle, so hit shapes can refine that area, but cannot expand it. A rejected target falls through to another interactive occurrence, including a parent. Ordinary Blocks become interactive through a live click subscription or a source onEvent override; disconnecting the final click subscription updates that status. See [source-hit.endor](examples/source-hit.endor). @@ -275,25 +275,11 @@ A source subclass can override `paint(self, mount, geometry): DisplayList`. The Renderer-dispatched paint runs in a read-only Aster call: existing heap objects cannot be modified; fresh result graphs may be allocated and filled. Existing globals, model fields, containers, Mounts and types remain protected. invalidate is rejected in this phase. All overrides share one VM budget. Read dependencies are currently recorded per object, with versions used to detect changes without depending on bounded change-notification delivery. Native frames keep their existing dependency/cache behavior. Derived allocations do not produce application change notifications, preventing temporary results from causing redraw feedback. This protects managed heap writes; trusted Rust host implementations must also respect read-only calls for external state and I/O. Paint errors stop the affected runtime before publication; failed PUT candidates preserve the previously running heap and frame. -The application starts with one managed New tab. The + button and Cmd/Ctrl+T -create additional managed Pages; Cmd/Ctrl+W closes the active Page. New tabs expand -from left to right while existing tabs interpolate to their new bounds when space -becomes limited. Hover and selection use the same capsule geometry, and animated -pointer hits use the rendered bounds. Source and page identity remain in the VM; -chrome animation retains only projected scalar IDs and rectangles. Tabs are capped at -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/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 -are Endor objects; the host does not paint an introduction over the guest page. +The application starts with one Endor canvas page. Its four actions open window, vertical, horizontal or canvas Pages. The default page is implemented in [start_page.endor](src/bridge/ui_bridge/start_page.endor), compiled once per VM in a private namespace. `ui.newTab()` and the sidebar New page action use the same factory. Cards, copy, layout, actions and the shared-state example are Endor objects. The host owns the sidebar and native window controls. Console, Sources, Network and Settings are independent managed Pages with separate view modules and window-local input/focus/scroll/selection state. They are not -opened by default and have no permanent rail/menu launchers. Their future -discovery UI is intentionally deferred. Existing integrations can call +opened by default; the left sidebar provides their launchers. Existing integrations can call `ui.developerTools("Console")`, `"Sources"`, `"Network"` or `"Settings"`. F12, Cmd-Option-I and Cmd-Shift-I on macOS, or Ctrl-Shift-I elsewhere, open/focus Console. Page closure, placement and GC use the ordinary Page methods. @@ -607,7 +593,7 @@ Inside a function run by ui.spawn, `value = await task` waits for any timer/file 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. +The former horizontal tab-strip gestures and animation have been replaced by sidebar page navigation. Page.moveTab, moveToWindow and detachToWindow remain available through Endor/API; sidebar pointer drag/reorder and cross-window drag are not implemented in this revision. 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. diff --git a/docs/architecture/01-modules.md b/docs/architecture/01-modules.md index d1ca178..df2c2e7 100644 --- a/docs/architecture/01-modules.md +++ b/docs/architecture/01-modules.md @@ -7,7 +7,7 @@ Tessera/ ├── Cargo.toml / Cargo.lock Rust 依赖和固定解析结果 ├── src/ │ ├── main.rs 组合入口、事件循环和共享发布状态 -│ ├── shell/ 原生窗口、标签、开发工具、管理控件 +│ ├── shell/ 原生窗口、可收起侧栏、开发工具、管理控件 │ ├── runtime/ 本机 API、VM worker、执行、事件与异步任务 │ ├── model/ 托管对象、挂载、绑定、依赖和发布数据 │ ├── render/ 布局、绘制、命中、字体与软件光栅化 @@ -36,7 +36,7 @@ Tessera/ | 执行位置 | 核心所有物 | 交互方式 | | --- | --- | --- | -| GUI / winit 主线程 | TesseraApp、原生 Window、Surface、标签动画、工具交互 | 读已发布快照;发 viewport、输入和窗口命令 | +| GUI / winit 主线程 | TesseraApp、原生 Window、Surface、侧栏、页面视角交互 | 读已发布快照;发 viewport、输入和窗口命令 | | `tessera-agent-api` | tiny_http 接收循环、请求观察 | 对写入做校验并进入最多 8 条的构建队列;读取当前发布状态 | | `tessera-endor-worker` | 活跃 ReplSession、候选 VM、托管对象图 | 串行执行程序、回调、布局/绘制协议及任务完成交付 | | 输入发送线程 | 有界输入客户端 | 把原生输入转为本机 HTTP 请求;不执行 VM | diff --git a/docs/architecture/06-progress.md b/docs/architecture/06-progress.md index 84767df..61a83e1 100644 --- a/docs/architecture/06-progress.md +++ b/docs/architecture/06-progress.md @@ -28,6 +28,8 @@ ## 当前本地开发方式(2026-11-10) +页面/宿主设计更新:支持 window、vertical、horizontal、canvas 四种空间;顶部标签条由可收起左侧栏替代。实现、测试与原生验收边界见 [页面空间说明](../page-surfaces.md)。下文 150 项测试及原窗口结构属于前一次移除 CI 的交付记录。 + 项目已移除 CI/CD 工作流、Docker 工具链定义及远程验证脚本。开发、构建、现有测试与原生窗口验收改在本机执行,命令见 [项目验证说明](../../README.md#validation)。以下保留 2026-10-25 整理时的验收记录,不代表本次检查结果。 本次使用 macOS / Rust 1.91.1:格式检查、150 项现有测试、all-target Clippy(拒绝 warnings)、打包脚本语法检查及 release app 构建均通过。应用已启动,`GET /agent` 与 `GET /code` 返回正常,窗口报告 1280 × 800 的逻辑 viewport;通过实际 REPL 创建/关闭临时标签页,页面数为 1 → 2 → 1。首次页面查询误用了不存在的 `ui.pages()`,返回 422;改为 `ui.window().pages().count` 后通过。原生 UI 检查工具启动失败,本次没有窗口外观、真实鼠标交互或跨屏验收证据。Aster 保持固定版本,未修改源码或 gitlink。 diff --git a/docs/page-surfaces.md b/docs/page-surfaces.md new file mode 100644 index 0000000..2c9117e --- /dev/null +++ b/docs/page-surfaces.md @@ -0,0 +1,57 @@ +# Endorphin 页面空间与窗口边界 + +本次按参考图的“左侧栏展开/收起”两种状态调整原生宿主。窗口保留系统关闭、最小化和缩放按钮;宿主管理入口集中在左侧栏。收起后只保留展开按钮及系统三键,页面从窗口左上角铺满,不再保留顶部标签条、左侧空轨或内容卡片边距。 + +页面内容、卡片、文字和操作按钮由 Endor 源码渲染。默认 [开始页](../src/bridge/ui_bridge/start_page.endor) 是真实的画布 Page,其操作创建另外三种 Page;不会把示例的静态截图当作可操作界面。 + +## 四种页面模式 + +| `page.mode` | 布局 | 浏览方式 | +| --- | --- | --- | +| `window`(默认) | 根 Page 等于可用区域,子组件按纵向流组织 | 应用自行安排内部布局或 ScrollBlock | +| `vertical` | 宽度跟随可用区域,高度至少一屏并随子组件增长 | 纵向滚动,偏移限制到内容范围 | +| `horizontal` | 高度跟随可用区域,宽度至少一屏并随子组件增长 | 横向滚动;普通纵向滚轮也可推进横向页面 | +| `canvas` | 直接子组件使用各自的 `x`、`y` 世界坐标 | 双向平移、缩放,视角不限制到内容边界 | + +侧栏展开占用 256 个逻辑像素;收起归还全部宽度。`ui.viewport()`、`window.viewport()` 和 GET code 的 viewport headers 均报告实际可用页面区域。窗口变更重排同一个运行时,不重放源码。窗口模式保证根 Page 填满,但不会强迫每个子组件拉伸。 + +```endor +page = Page() +page.mode = "canvas" +page.title = "我的画布" +card = Button("一个真正的 Endor 组件") +card.x = -120 +card.y = 90 +page.render(card) +page.panX = -200 +page.panY = 0 +page.zoom = 1 +``` + +`page.render(root)` 替换内容;`page.append(child)` 继续添加。直接画布子组件的 `x/y` 决定位置,内部 Row/Column 仍使用原来的流式布局;纵向、横向和窗口页面不使用子组件的 `x/y` 排列。共享 Block 可以有多个 Mount,不会为画布复制业务对象。 + +## 交互与坐标 + +- 滚轮或触控板平移;嵌套 ScrollBlock 优先处理其可滚动方向。 +- 画布支持空格+左键拖动、鼠标中键拖动;已有按钮键盘焦点保留 Space 激活。 +- 画布支持捏合、Cmd/Ctrl+滚轮缩放,以指针位置为锚点。 +- Cmd/Ctrl+B 展开/收起侧栏;侧栏提供新建、切换和关闭页面及四个开发工具入口。 +- Cmd/Ctrl+T 新建,Cmd/Ctrl+W 关闭,Ctrl+Tab / Ctrl+Shift+Tab 切换页面。 + +`panX/panY` 是可视区域左上角的世界坐标,`zoom` 为 0.1–4。变换是 `screen = (world - pan) * zoom`;绘制使用正变换,原生点击/滚动使用逆变换。VM 中 Mount 始终使用世界坐标,worker 根据同一视角重新检查可见性。Page 自己保存视角,切换后仍保留;有限页面在内容缩短或窗口变大时限制偏移。 + +画布没有内容边界裁剪,但保留现有运行预算:坐标为有限数且绝对值不超过 1,000,000,维度、对象数和绘制命令数仍有上限。“无限”指可以向内容以外导航,不代表无限内存或数值范围。 + +原生相机事件 `page-pan/page-zoom` 经过真实 HTTP worker,再由所属 VM 更新 Page;它们使用 `mount=0` 和当前 window/session/layout_version。平移 delta 使用可视区域像素;缩放 delta 为 `[factor,0]`,point 是可视区域内的锚点。普通点击与嵌套滚动的 point 使用世界坐标。过期事件返回 409;不适用于当前模式或参数非法的相机事件在执行前拒绝。 + +## 实现位置与验证边界 + +- [page.rs](../src/render/page.rs):模式约束、视角、正逆坐标变换和有限页面偏移限制。 +- [sidebar.rs](../src/shell/sidebar.rs):唯一宿主管理栏及折叠后点击遮挡范围。 +- [page_input.rs](../src/bridge/ui_bridge/page_input.rs):串行 VM 内的视角变更。 +- [API 回归](../src/runtime/api/tests.rs):真实 HTTP 平移、指针锚定缩放、负世界坐标点击、过期/非法事件。 +- [宿主回归](../src/runtime/endor_host/tests.rs):四种模式、实时内容增长、共享对象坐标、视角恢复和默认页真实按钮。 + +本地格式、153 项测试和 all-target Clippy 均通过;release app 已构建并实际启动。运行中平移与缩放事件返回 200,并读回 Page.panX=80、Page.zoom=2,随后恢复初始视角。最初 HTTP worker 漏接相机事件导致 400,现由完整链路回归覆盖。 + +macOS 当前未授予本次工具模拟输入和窗口截图权限。模拟滚轮未生效,无法据此声称真实鼠标/触控板、捏合或窗口外观验收通过。侧栏指针拖放、跨窗口拖放尚未迁移;原有 `moveTab/moveToWindow/detachToWindow` 与版本校验的 API 保留。没有修改 Aster 或引入新的 GUI/语言依赖。 diff --git a/examples/README.md b/examples/README.md index 0f7965b..d0bf8d6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -25,8 +25,10 @@ Submit a file as the UTF-8 body of a namespace PUT request. No namespace, scene, render block or imports belong in the source. -The live `/agent` guide embeds page.endor exactly. Remote CI executes the examples through -Aster and checks the resulting coordinates and source ranges after VM cleanup. +The live `/agent` guide embeds page.endor exactly. Local tests execute examples through +Aster and check the resulting coordinates and source ranges after VM cleanup. +For Page window/vertical/horizontal/canvas modes, see [page surfaces](../docs/page-surfaces.md) +and the running start page's four working actions. The file is client-side sample material; Tessera's API stores source by namespace. Return to the [project guide](../README.md). diff --git a/src/README.md b/src/README.md index e78b684..586271b 100644 --- a/src/README.md +++ b/src/README.md @@ -23,7 +23,8 @@ | [native_chrome](shell/native_chrome.rs) | macOS 原生按钮对齐和工具栏双击系统动作 | | [sources](shell/sources.rs) | 默认断开的云文件 provider 接口及工作区 | | [system_ui](shell/system_ui/mod.rs) | 独立宿主管理控件、交互和主题 | -| [tabs](shell/tabs.rs) | 共享标签绘制/命中、密集布局、滚动、拖放及动画 | +| [tabs](shell/tabs.rs) | 页面注册表的不可变 ID、标题和当前选择投影 | +| [sidebar](shell/sidebar.rs) | 页面/开发工具入口、收起状态与最小宿主控制区 | ## runtime @@ -60,7 +61,8 @@ | [geometry](render/geometry.rs) | 点和矩形值 | | [layout](render/layout.rs) | 原生测量、排列、出现几何及预算 | | [native_visual](render/native_visual.rs) | 托管原生视觉协议、值转换和签名检查 | -| [scene](render/scene.rs) | 页面、标签、插入标记和开发工具画面合成 | +| [scene](render/scene.rs) | 页面视角、可见裁剪、侧栏和开发工具画面合成 | +| [page](render/page.rs) | 四种页面空间、约束、平移缩放及正逆坐标变换 | | [scroll](render/scroll.rs) | 逐 Mount 偏移、嵌套滚动和剪裁投影 | | [software_renderer](render/software_renderer.rs) | 软件光栅化、抗锯齿覆盖和嵌套剪裁 | | [source_hit](render/source_hit.rs) | 源/原生命中协议与匹配目标 | @@ -79,6 +81,7 @@ - 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/`。 +- 页面模式与宿主边界见 [页面空间说明](../docs/page-surfaces.md);相机输入:`bridge/ui_bridge/page_input.rs`。 - 现有 API 和宿主测试:`runtime/api/tests.rs`、`runtime/endor_host/tests.rs`;其他模块中也保留原单元测试。 VM 和托管根只由执行 worker 操作。原生窗口收到已发布值,不收到 ObjectRef。完整 PUT 成功才接替活跃程序;Console 保留实际 VM;失败和清理语义见架构文档。 diff --git a/src/bridge/ui_bridge.rs b/src/bridge/ui_bridge.rs index b7bb371..449271a 100644 --- a/src/bridge/ui_bridge.rs +++ b/src/bridge/ui_bridge.rs @@ -2,6 +2,7 @@ mod bootstrap; mod calls; mod developer_pages; +mod page_input; mod signatures; mod tree; @@ -658,6 +659,7 @@ pub enum LayoutProgress { } struct LayoutContext { + surface: crate::page::Surface, root: Arc, tabs: Vec, full_viewport: Option, @@ -1189,13 +1191,16 @@ impl UiBridge { vm.release_value(page)?; return Ok(()); } + if matches!(input.kind.as_str(), "page-pan" | "page-zoom") { + return self.dispatch_page_view(vm, input); + } if matches!( input.kind.as_str(), "tab-activate" | "tab-close" | "tab-detach" | "tab-drop" ) { return self.dispatch_tab(vm, input); } - let (weak, desktop, candidates, viewport) = { + let (weak, desktop, candidates, viewport, surface) = { let state = self .session .lock() @@ -1211,13 +1216,8 @@ impl UiBridge { .ok_or_else(|| anyhow::anyhow!("Mount is no longer active"))?, state.desktop, state.mounts.ordered(), - state.layout.viewport.map(|size| crate::layout::Size { - width: (size.width - crate::tabs::RAIL - crate::tabs::INSET).max(0.0), - height: (size.height - - crate::tabs::INSET - - crate::tabs::height(&state.last_tabs)) - .max(0.0), - }), + state.layout.viewport, + state.surface.clone(), ) }; if input.kind == "scroll" { @@ -1231,8 +1231,7 @@ impl UiBridge { .ok_or_else(|| anyhow::anyhow!("scroll has no pointer"))?; if !data.mounted || data.version != input.layout_version - || viewport - .is_some_and(|size| point[0] >= size.width || point[1] >= size.height) + || !surface.contains(point, viewport) || !data .geometry .contains(crate::geometry::Point::new(point[0], point[1])) @@ -1265,11 +1264,10 @@ impl UiBridge { return result; } let mount = if let (Some(press), Some(release)) = (input.press_point, input.point) { - if viewport.is_some_and(|size| { - [press, release] - .iter() - .any(|point| point[0] >= size.width || point[1] >= size.height) - }) { + if [press, release] + .iter() + .any(|point| !surface.contains(*point, viewport)) + { return Ok(()); } let Some(mount) = crate::source_hit::resolve(vm, desktop, candidates, press, release)? @@ -1477,6 +1475,7 @@ impl UiBridge { state.force_frame = false; state.last_snapshot = snapshot; state.last_tabs = frame.tabs.clone(); + state.surface = frame.surface.clone(); state.published_version = frame.layout_version; // Hooks may call Page/container APIs; never hold the session mutex // across user code. Publish only after the refresh loop settles. @@ -1649,10 +1648,8 @@ impl UiBridge { project_tabs(vm, &mut state)? }; let full_viewport = layout.viewport; - if let Some(size) = &mut layout.viewport { - size.width = (size.width - crate::tabs::RAIL - crate::tabs::INSET).max(0.0); - size.height = (size.height - crate::tabs::height(&tabs) - crate::tabs::INSET).max(0.0); - } + let surface = root.surface.clone().unwrap_or_default(); + layout.page_mode = root.surface.as_ref().map(|surface| surface.mode); { let state = self .session @@ -1688,6 +1685,7 @@ impl UiBridge { (hooks, state.mounts.ordered(), checkpoint) }; let context = LayoutContext { + surface, root: root.clone(), tabs, full_viewport, @@ -1736,6 +1734,7 @@ impl UiBridge { fn discard_layout_context(&self, vm: &mut Vm, context: LayoutContext) -> Result<()> { let LayoutContext { + surface: _, desktop, window, hooks, @@ -1824,6 +1823,7 @@ impl UiBridge { draft: crate::source_layout::Draft, ) -> Result { let LayoutContext { + mut surface, root, tabs, full_viewport, @@ -1851,6 +1851,16 @@ impl UiBridge { // after either layout path has produced the final page geometry. frame.tabs = tabs; frame.window = window; + if let Some(root) = frame.mounts.first() { + surface.extent = crate::layout::Size { + width: root.width, + height: root.height, + }; + } + if let Some(viewport) = full_viewport { + surface.clamp(viewport); + } + frame.surface = surface; // Capture versions before lifecycle hooks can change a dependency. let desktops = self .session diff --git a/src/bridge/ui_bridge/page_input.rs b/src/bridge/ui_bridge/page_input.rs new file mode 100644 index 0000000..1b54ba3 --- /dev/null +++ b/src/bridge/ui_bridge/page_input.rs @@ -0,0 +1,66 @@ +//! Camera mutations run on the owning VM worker, just like other page input. +use super::*; + +impl UiBridge { + pub(super) fn dispatch_page_view( + &self, + vm: &mut Vm, + input: &crate::events::Input, + ) -> Result<()> { + let (page, mut surface, viewport) = { + let state = self + .session + .lock() + .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + anyhow::ensure!( + state.published_version == input.layout_version, + "obsolete page view" + ); + ( + state + .active + .ok_or_else(|| anyhow::anyhow!("no active Page"))?, + state.surface.clone(), + state.layout.viewport, + ) + }; + let delta = input + .delta + .ok_or_else(|| anyhow::anyhow!("page view requires delta"))?; + if input.kind == "page-zoom" { + anyhow::ensure!( + surface.mode == crate::page::Mode::Canvas, + "zoom requires canvas mode" + ); + let pointer = input + .point + .ok_or_else(|| anyhow::anyhow!("zoom requires anchor"))?; + let pointer = crate::geometry::Point::new(pointer[0], pointer[1]); + anyhow::ensure!( + pointer.x >= 0.0 + && pointer.y >= 0.0 + && viewport.is_none_or(|v| pointer.x < v.width && pointer.y < v.height), + "zoom anchor is outside viewport" + ); + let world = surface.to_world(pointer); + surface.zoom = (surface.zoom * delta[0]).clamp(0.1, 4.0); + surface.pan = crate::geometry::Point::new( + world.x - pointer.x / surface.zoom, + world.y - pointer.y / surface.zoom, + ); + } else { + surface.pan.x += delta[0] / surface.zoom; + surface.pan.y += delta[1] / surface.zoom; + } + if let Some(viewport) = viewport { + surface.clamp(viewport); + } + let limit = MAX_DIMENSION as f32; + surface.pan.x = surface.pan.x.clamp(-limit, limit); + surface.pan.y = surface.pan.y.clamp(-limit, limit); + vm.set_attr(page, "panX", crate::native_visual::value(surface.pan.x))?; + vm.set_attr(page, "panY", crate::native_visual::value(surface.pan.y))?; + vm.set_attr(page, "zoom", crate::native_visual::value(surface.zoom))?; + Ok(()) + } +} diff --git a/src/bridge/ui_bridge/start_page.endor b/src/bridge/ui_bridge/start_page.endor index 44b4944..2b914dc 100644 --- a/src/bridge/ui_bridge/start_page.endor +++ b/src/bridge/ui_bridge/start_page.endor @@ -1,39 +1,63 @@ -// Tessera's start page is an ordinary Endor Page, including its layout and actions. -// This module has its own globals; creating tabs does not replace user bindings. -class StartPage: Page { - function measure(self: StartPage, mount: Mount, constraints: Constraints): MeasureResult { - return MeasureResult(constraints.maxWidth, constraints.maxHeight) +// The page, cards and actions belong to Endor. The host only owns its sidebar. +class Note: Column { + tint: String + function measure(self: Note, mount: Mount, constraints: Constraints): MeasureResult { + measured = Column.measure(self, mount, constraints) + return MeasureResult(224, measured.height) + } + function paint(self: Note, mount: Mount, geometry: Bounds): DisplayList { + fill = PaintCommand() + fill.kind = "roundedRect" + fill.x = geometry.x + fill.y = geometry.y + fill.width = geometry.width + fill.height = geometry.height + fill.radius = 14 + fill.color = self.tint + return DisplayList(ObjectList(fill)) } } -class StartRow: Row { - function measure(self: StartRow, mount: Mount, constraints: Constraints): MeasureResult { - measured = Row.measure(self, mount, constraints) - return MeasureResult(constraints.maxWidth, measured.height) - } -} -// Quiet action rows retain native hit targets and keyboard focus without card chrome. -class StartButton: Button { - function measure(self: StartButton, mount: Mount, constraints: Constraints): MeasureResult { - return MeasureResult(360, 40) - } - function paint(self: StartButton, mount: Mount, geometry: Bounds): DisplayList { +class QuietButton: Button { + function paint(self: QuietButton, mount: Mount, geometry: Bounds): DisplayList { return DisplayList(ObjectList()) } } -class StartLine: Row { - function measure(self: StartLine, mount: Mount, constraints: Constraints): MeasureResult { - measured = Row.measure(self, mount, constraints) - return MeasureResult(360, measured.height) - } -} class StartActions { home: Page function blank(self: StartActions, event: Event): Unit { page = Page() - page.title = "未命名页面" - back = Button("返回开始页") + page.title = "窗口页面" + page.mode = "window" + back = Button("返回画布") back.onClick(self.home.activate) - page.render(Column(32, 16, TextBlock("未命名页面", 2), TextBlock("一个新的 Page 对象,已加入当前窗口。"), TextBlock("用 Endor 的 page.render(...) 组织内容。"), back)) + page.render(Column(56, 20, TextBlock("恰好一个窗口", 3), TextBlock("页面跟随可用区域调整。内容和工具栏由 Endor 自己组织。"), back)) + } + 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")) + } + 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")) + } + function canvas(self: StartActions, event: Event): Unit { + page = newTab() } function counter(self: StartActions, event: Event): Unit { count = ui.state(0) @@ -41,40 +65,47 @@ class StartActions { add.onClick(count.increment) page = Page() page.title = "共享对象" - page.render(Column(32, 16, TextBlock("一个对象,两个视图", 2), TextBlock("点击一次,两处展示同时更新。切换标签后,状态仍然保留。"), Row(0, 32, ui.textOf(count, "value", 3), ui.textOf(count, "value", 3)), add)) - } - function compose(self: StartActions, event: Event): Unit { - label = TextBlock("对象可以被多处引用") - page = Page() - page.title = "组合页面" - page.render(Column(32, 20, TextBlock("把对象组合成界面", 2), TextBlock("Column 组织纵向内容,Row 组织横向内容。"), Row(0, 16, RectBlock(100, 64, 12, "#DCE8FF"), RectBlock(100, 64, 12, "#E3ECDC"), RectBlock(100, 64, 12, "#F3E4DB")), label, Row(0, 24, label, label))) + page.render(Column(48, 24, TextBlock("一个对象,两个视图", 2), Row(0, 32, ui.textOf(count, "value", 3), ui.textOf(count, "value", 3)), add)) } } function newTab(): Page { - page = StartPage() - page.title = "新标签页" + page = Page() + page.title = "自由画布" + page.mode = "canvas" actions = StartActions(page) - blank = StartButton("+ 创建页面") - counter = StartButton("↗ 共享对象示例") - compose = StartButton("◇ 界面组合示例") - blank.on("click", actions.blank) - counter.on("click", actions.counter) - compose.on("click", actions.compose) - heading = StartLine(0, 0, TextBlock("Endor", 2, "#30343B")) - heading.mainAlign = "center" - subtitle = StartLine(0, 0, TextBlock("创建页面,或从一个示例开始。", 1, "#80858D")) - subtitle.mainAlign = "center" - newHint = StartLine(0, 0, TextBlock("新建标签页", 1, "#8A8F97"), TextBlock("⌘ T / Ctrl + T", 1, "#747B85")) - newHint.mainAlign = "space-between" - closeHint = StartLine(0, 0, TextBlock("关闭标签页", 1, "#8A8F97"), TextBlock("⌘ W / Ctrl + W", 1, "#747B85")) - closeHint.mainAlign = "space-between" - content = Column(0, 8, - heading, subtitle, Spacer(20), - blank, counter, compose, Spacer(20), - newHint, closeHint) - row = StartRow(24, 0, content) - row.mainAlign = "center" - page.mainAlign = "center" - page.render(row) + fixed = Button("窗口页面") + vertical = Button("纵向延伸") + horizontal = Button("横向延伸") + canvas = Button("新的画布") + shared = QuietButton("试试:一个对象,两个视图 ↗") + fixed.on("click", actions.blank) + vertical.on("click", actions.vertical) + horizontal.on("click", actions.horizontal) + canvas.on("click", actions.canvas) + shared.on("click", actions.counter) + 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) + left = Note(24, 14, TextBlock("随手记下", 2), RectBlock(176, 100, 10, "#D5E4DA"), TextBlock("想法不必排成一条直线。"), "#EDF3E9") + left.x = 36 + left.y = 92 + page.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") + right.x = 826 + right.y = 190 + page.append(right) + bottom = Note(24, 14, TextBlock("不止眼前这一屏", 2), TextBlock("触控板或滚轮平移\n空格拖动 / 鼠标中键拖动\n双指捏合 / ⌘ Ctrl + 滚轮缩放"), "#F7F0E5") + bottom.x = 104 + bottom.y = 524 + page.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) return page } diff --git a/src/bridge/ui_bridge/tree.rs b/src/bridge/ui_bridge/tree.rs index f9c23e6..e346354 100644 --- a/src/bridge/ui_bridge/tree.rs +++ b/src/bridge/ui_bridge/tree.rs @@ -3,6 +3,7 @@ use std::sync::Arc; #[derive(Debug, Clone, PartialEq)] pub struct UiNode { + pub surface: Option, pub flex: f32, pub flex_policy: crate::layout::FlexPolicy, pub main_align: crate::layout::MainAlign, @@ -17,6 +18,7 @@ impl UiNode { pub fn children(&self) -> &[Arc] { match &self.kind { UiKind::Column { children, .. } + | UiKind::Canvas { children, .. } | UiKind::Row { children, .. } | UiKind::Button { children, .. } | UiKind::Scroll { children, .. } => children, @@ -29,7 +31,8 @@ impl UiNode { } pub fn shallow_eq(&self, other: &Self) -> bool { - if self.flex_policy != other.flex_policy + if self.surface != other.surface + || self.flex_policy != other.flex_policy || self.flex != other.flex || self.main_align != other.main_align || self.block_id != other.block_id @@ -48,6 +51,9 @@ impl UiNode { .all(|(a, b)| Arc::ptr_eq(a, b)) }; match (&self.kind, &other.kind) { + (UiKind::Canvas { positions: a, .. }, UiKind::Canvas { positions: b, .. }) => { + a == b && same_children() + } ( UiKind::Text { value: a, @@ -124,6 +130,10 @@ impl UiNode { #[derive(Debug, Clone, PartialEq)] pub enum UiKind { + Canvas { + positions: Vec<[f32; 2]>, + children: Vec>, + }, Button { padding: f32, gap: f32, diff --git a/src/main.rs b/src/main.rs index 0bc1869..85bf173 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,15 +11,17 @@ 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, + display_list, geometry, layout, native_visual, page, 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")] pub(crate) use shell::native_chrome; -pub(crate) use shell::{activity, app, clipboard, developer_tools, sources, system_ui, tabs}; +pub(crate) use shell::{ + activity, app, clipboard, developer_tools, sidebar, sources, system_ui, tabs, +}; use std::sync::{Arc, RwLock}; diff --git a/src/model/block.rs b/src/model/block.rs index 7551cd7..2185a8f 100644 --- a/src/model/block.rs +++ b/src/model/block.rs @@ -121,6 +121,8 @@ pub struct Data { pub flex_policy: crate::layout::FlexPolicy, pub main_align: crate::layout::MainAlign, pub scroll_axis: crate::scroll::Axis, + pub position: [f32; 2], + pub surface: crate::page::Surface, pub next_slot: u64, pub history: Vec, pub history_index: usize, @@ -150,6 +152,8 @@ impl Data { flex_policy: crate::layout::FlexPolicy::default(), main_align: crate::layout::MainAlign::Start, scroll_axis: crate::scroll::Axis::Both, + position: [0.0, 0.0], + surface: crate::page::Surface::new(crate::page::Mode::Window), next_slot, history: vec![], history_index: 0, @@ -320,6 +324,8 @@ pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> { Value::Number((value as f64).into()) } let mut fields = vec![ + ("x", number(data.position[0])), + ("y", number(data.position[1])), ("flex", number(data.flex)), ( "flexBasis", @@ -332,6 +338,12 @@ pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> { ("flexShrink", number(data.flex_policy.shrink)), ]; match &data.kind { + Kind::Page => fields.extend([ + ("mode", Value::Text(data.surface.mode.name().into())), + ("panX", number(data.surface.pan.x)), + ("panY", number(data.surface.pan.y)), + ("zoom", number(data.surface.zoom)), + ]), Kind::Text { value, size, color } => fields.extend([ ("text", Value::Text(value.clone())), ("size", Value::Number((*size as i64).into())), @@ -393,6 +405,30 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu } Ok(n) }; + if matches!(name, "x" | "y" | "panX" | "panY" | "zoom") { + let n = crate::scroll::coordinate(value)?; + match name { + "x" => data.position[0] = n, + "y" => data.position[1] = n, + "panX" if matches!(data.kind, Kind::Page) => data.surface.pan.x = n, + "panY" if matches!(data.kind, Kind::Page) => data.surface.pan.y = n, + "zoom" if matches!(data.kind, Kind::Page) => { + if !(0.1..=4.0).contains(&n) { + return Err(invalid_zoom()); + } + data.surface.zoom = n; + } + _ => {} + } + return Ok(()); + } + if matches!(data.kind, Kind::Page) && name == "mode" { + let Value::Text(name) = value else { + return Err(bad()); + }; + data.surface.mode = crate::page::Mode::parse(name)?; + return Ok(()); + } match name { "flexBasis" => { data.flex_policy.basis = if matches!(value, Value::Nil) @@ -472,3 +508,7 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu } Ok(()) } + +fn invalid_zoom() -> VmError { + VmError::InvalidArgument("Page.zoom expects a number from 0.1 to 4") +} diff --git a/src/model/document.rs b/src/model/document.rs index ec2e907..bdd9f74 100644 --- a/src/model/document.rs +++ b/src/model/document.rs @@ -113,6 +113,18 @@ pub struct RenderDocument { impl RenderDocument { pub fn validate(&self) -> Result<()> { + let surface = &self.frame.surface; + if !surface.zoom.is_finite() + || !(0.1..=4.0).contains(&surface.zoom) + || ![surface.pan.x, surface.pan.y] + .iter() + .all(|v| v.is_finite() && v.abs() <= 1_000_000.0) + || ![surface.extent.width, surface.extent.height] + .iter() + .all(|v| v.is_finite() && (0.0..=1_000_000.0).contains(v)) + { + bail!("invalid page camera or extent"); + } if self.windows.len() > 15 { bail!("too many window projections"); } @@ -322,6 +334,8 @@ impl CodeNode { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Frame { + #[serde(default)] + pub surface: crate::page::Surface, #[serde(default = "default_window")] pub window: u64, #[serde(default = "default_background")] @@ -347,6 +361,7 @@ pub struct Frame { impl Default for Frame { fn default() -> Self { Self { + surface: crate::page::Surface::default(), window: crate::window::MAIN_WINDOW, background: default_background(), commands: Vec::new(), diff --git a/src/model/presentation.rs b/src/model/presentation.rs index 6302248..3460f7e 100644 --- a/src/model/presentation.rs +++ b/src/model/presentation.rs @@ -16,6 +16,7 @@ pub(crate) struct Presentation { pub tab_ids: Vec<(WeakObjectRef, u64)>, pub next_tab: u64, pub last_tabs: Vec, + pub surface: crate::page::Surface, pub render_cache: crate::snapshot::RenderCache, pub layout: crate::layout::LayoutRuntime, pub mounts: crate::mount::MountTable, @@ -122,6 +123,7 @@ impl Presentation { tab_ids: Vec::new(), next_tab: 0, last_tabs: Vec::new(), + surface: crate::page::Surface::default(), render_cache: crate::snapshot::RenderCache::default(), layout: crate::layout::LayoutRuntime::default(), mounts: crate::mount::MountTable::default(), @@ -177,6 +179,7 @@ mod tests { height: 160.0, }); let root = UiNode { + surface: None, flex: 0.0, flex_policy: crate::layout::FlexPolicy::default(), main_align: crate::layout::MainAlign::Start, diff --git a/src/model/snapshot.rs b/src/model/snapshot.rs index af29141..f4b0542 100644 --- a/src/model/snapshot.rs +++ b/src/model/snapshot.rs @@ -264,6 +264,22 @@ impl RenderCache { gap: data.gap, children, }, + Kind::Page if data.surface.mode == crate::page::Mode::Canvas => UiKind::Canvas { + positions: data + .children + .iter() + .map(|child| { + let (_, data) = block::resolve(store, child.block)?; + Ok(block::visual_data(store, data)?.position) + }) + .collect::, VmError>>()?, + children, + }, + Kind::Page if data.surface.mode == crate::page::Mode::Horizontal => UiKind::Row { + padding: data.padding, + gap: data.gap, + children, + }, Kind::Column | Kind::Page => UiKind::Column { padding: data.padding, gap: data.gap, @@ -276,6 +292,11 @@ impl RenderCache { }, }; let next = UiNode { + surface: matches!(data.kind, Kind::Page).then(|| { + let mut surface = data.surface.clone(); + surface.page = block.to_string(); + surface + }), flex: data.flex, flex_policy: data.flex_policy, main_align: data.main_align, diff --git a/src/render/display_list.rs b/src/render/display_list.rs index bce4370..46f1b8d 100644 --- a/src/render/display_list.rs +++ b/src/render/display_list.rs @@ -65,6 +65,44 @@ pub struct DisplayList { } impl DisplayList { + pub fn scale(&mut self, scale: f32) { + for command in &mut self.commands { + let rect = match command { + DrawCommand::WrappedText { rect, style, .. } => { + *style = style.scaled(scale); + Some(rect) + } + DrawCommand::PushClip(rect) => Some(rect), + DrawCommand::FillRoundedRect { rect, radius, .. } => { + *radius *= scale; + Some(rect) + } + DrawCommand::StrokeRoundedRect { + rect, + radius, + width, + .. + } => { + *radius *= scale; + *width *= scale; + Some(rect) + } + DrawCommand::Text { origin, style, .. } => { + origin.x *= scale; + origin.y *= scale; + *style = style.scaled(scale); + None + } + _ => None, + }; + if let Some(rect) = rect { + rect.x *= scale; + rect.y *= scale; + rect.width *= scale; + rect.height *= scale; + } + } + } pub fn push(&mut self, command: DrawCommand) { self.commands.push(command); } diff --git a/src/render/geometry.rs b/src/render/geometry.rs index baeda4f..2736bee 100644 --- a/src/render/geometry.rs +++ b/src/render/geometry.rs @@ -1,4 +1,4 @@ -#[derive(Clone, Copy, Debug, Default, PartialEq)] +#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Point { pub x: f32, pub y: f32, diff --git a/src/render/layout.rs b/src/render/layout.rs index 46b2273..d77d37c 100644 --- a/src/render/layout.rs +++ b/src/render/layout.rs @@ -59,7 +59,7 @@ impl Default for FlexPolicy { } } -#[derive(Default, Debug, Clone, Copy, PartialEq)] +#[derive(Default, Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Size { pub width: f32, pub height: f32, @@ -90,6 +90,7 @@ pub struct LayoutRuntime { pub settle_passes: usize, pub slices: usize, pub viewport: Option, + pub page_mode: Option, } struct Item<'a> { @@ -107,6 +108,13 @@ fn bounded(value: f32) -> Result { Ok(value) } +fn coordinate(value: f32) -> Result { + if !value.is_finite() || value.abs() > MAX_DIMENSION as f32 { + bail!("UI coordinate exceeds the 1000000 limit"); + } + Ok(value) +} + pub fn root_constraints(viewport: Option) -> [f32; 4] { let size = viewport.unwrap_or(Size { width: MAX_DIMENSION as f32, @@ -115,6 +123,9 @@ pub fn root_constraints(viewport: Option) -> [f32; 4] { [0.0, 0.0, size.width, size.height] } pub fn child_constraints(node: &UiNode, parent: [f32; 4]) -> [f32; 4] { + if matches!(node.kind, UiKind::Canvas { .. }) { + return root_constraints(None); + } if let UiKind::Scroll { width, height, .. } = node.kind { return arranged_child_constraints( node, @@ -229,6 +240,14 @@ fn measure_shape(shape: &Shape) -> Result { fn shape(node: &UiNode, children: Vec, constraints: [f32; 4]) -> Result { Ok(match &node.kind { + UiKind::Canvas { positions, .. } => { + let mut size = Size::default(); + for (position, child) in positions.iter().zip(children) { + size.width = size.width.max(position[0] + child.width); + size.height = size.height.max(position[1] + child.height); + } + Shape::Rect(size.width, size.height) + } UiKind::Text { value, size, .. } => Shape::Text(value.clone(), *size, constraints[2]), UiKind::Rect { width, height, .. } => Shape::Rect(*width, *height), UiKind::Spacer(size) => Shape::Rect(*size, *size), @@ -399,6 +418,12 @@ pub fn arrange_native( bounds: Size, children: &[Size], ) -> Result> { + if let UiKind::Canvas { positions, .. } = &node.kind { + return Ok(positions + .iter() + .map(|point| (x + point[0], y + point[1])) + .collect()); + } let Some((row, padding, gap)) = container(node) else { return Ok(vec![]); }; @@ -427,10 +452,10 @@ pub fn arrange_native( } else { (x + padding, y + cursor) }; - bounded(point.0)?; - bounded(point.1)?; - bounded(point.0 + child.width)?; - bounded(point.1 + child.height)?; + coordinate(point.0)?; + coordinate(point.1)?; + coordinate(point.0 + child.width)?; + coordinate(point.1 + child.height)?; points.push(point); cursor += if row { child.width } else { child.height }; cursor += gap + distributed_gap; @@ -439,6 +464,12 @@ pub fn arrange_native( } impl LayoutRuntime { + pub fn root_constraints(&self) -> [f32; 4] { + self.page_mode.map_or_else( + || root_constraints(self.viewport), + |mode| mode.constraints(self.viewport), + ) + } pub fn invalidate(&mut self, block: &str) { self.sizes.remove(block); } @@ -495,7 +526,7 @@ impl LayoutRuntime { self.settle_passes = usize::from(!structural); self.slices = usize::from(!structural); let mut items = Vec::>::new(); - let mut work = vec![(root, None::, 0, root_constraints(self.viewport))]; + let mut work = vec![(root, None::, 0, self.root_constraints())]; let mut used = HashSet::new(); while let Some((node, parent, slot, constraints)) = work.pop() { if items.len() >= MAX_LAYOUT_VISITS { @@ -568,10 +599,10 @@ impl LayoutRuntime { self.arranged += usize::from(!structural); let (x, y) = points[index]; let size = sizes[index]; - bounded(x)?; - bounded(y)?; - bounded(x + size.width)?; - bounded(y + size.height)?; + coordinate(x)?; + coordinate(y)?; + coordinate(x + size.width)?; + coordinate(y + size.height)?; mounts.push(MountGeometry { id: item.id, clip: None, diff --git a/src/render/mod.rs b/src/render/mod.rs index 096cfbf..431072b 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod display_list; pub(crate) mod geometry; pub(crate) mod layout; pub(crate) mod native_visual; +pub(crate) mod page; pub(crate) mod scene; pub(crate) mod scroll; pub(crate) mod software_renderer; diff --git a/src/render/native_visual.rs b/src/render/native_visual.rs index 107c93c..0eb1c74 100644 --- a/src/render/native_visual.rs +++ b/src/render/native_visual.rs @@ -202,8 +202,8 @@ impl NativeCallable for Method { )?) } Query::Arrange => { - let x = number(cx, input, "x", false)?; - let y = number(cx, input, "y", false)?; + let x = number(cx, input, "x", true)?; + let y = number(cx, input, "y", true)?; let width = number(cx, input, "width", false)?; let height = number(cx, input, "height", false)?; if x + width > MAX_DIMENSION as f32 || y + height > MAX_DIMENSION as f32 { diff --git a/src/render/page.rs b/src/render/page.rs new file mode 100644 index 0000000..b450fcd --- /dev/null +++ b/src/render/page.rs @@ -0,0 +1,190 @@ +//! Page space is independent of the native window. The camera is applied once +//! for painting and inverted once for input; layout and Mounts stay in world units. +use crate::{geometry::Point, layout::Size}; +use endor_core::VmError; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Mode { + #[default] + Window, + Vertical, + Horizontal, + Canvas, +} + +impl Mode { + pub fn name(self) -> &'static str { + match self { + Self::Window => "window", + Self::Vertical => "vertical", + Self::Horizontal => "horizontal", + Self::Canvas => "canvas", + } + } + + pub fn parse(name: &str) -> Result { + match name { + "window" => Ok(Self::Window), + "vertical" => Ok(Self::Vertical), + "horizontal" => Ok(Self::Horizontal), + "canvas" => Ok(Self::Canvas), + _ => Err(VmError::InvalidArgument( + "Page.mode expects window, vertical, horizontal or canvas", + )), + } + } + + pub fn constraints(self, viewport: Option) -> [f32; 4] { + let Some(size) = viewport else { + return crate::layout::root_constraints(None); + }; + let limit = crate::ui_bridge::MAX_DIMENSION as f32; + match self { + Self::Window => [size.width, size.height, size.width, size.height], + Self::Vertical => [size.width, size.height, size.width, limit], + Self::Horizontal => [size.width, size.height, limit, size.height], + Self::Canvas => [0.0, 0.0, limit, limit], + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Surface { + pub mode: Mode, + pub page: String, + pub pan: Point, + #[serde(default = "unit")] + pub zoom: f32, + pub extent: Size, +} + +fn unit() -> f32 { + 1.0 +} + +impl Default for Surface { + fn default() -> Self { + Self { + mode: Mode::Window, + page: String::new(), + pan: Point::default(), + zoom: 1.0, + extent: Size::default(), + } + } +} + +impl Surface { + pub fn new(mode: Mode) -> Self { + Self { + mode, + zoom: 1.0, + ..Self::default() + } + } + + pub fn to_world(&self, point: Point) -> Point { + Point::new( + point.x / self.zoom + self.pan.x, + point.y / self.zoom + self.pan.y, + ) + } + + pub fn to_view(&self, point: Point) -> Point { + Point::new( + (point.x - self.pan.x) * self.zoom, + (point.y - self.pan.y) * self.zoom, + ) + } + + pub fn contains(&self, point: [f32; 2], viewport: Option) -> bool { + let point = self.to_view(Point::new(point[0], point[1])); + point.x >= 0.0 + && point.y >= 0.0 + && viewport.is_none_or(|size| point.x < size.width && point.y < size.height) + } + + pub fn clamp(&mut self, viewport: Size) { + match self.mode { + Mode::Window => { + self.pan = Point::default(); + self.zoom = 1.0; + } + Mode::Vertical => { + self.zoom = 1.0; + self.pan.x = 0.0; + self.pan.y = self + .pan + .y + .clamp(0.0, (self.extent.height - viewport.height).max(0.0)); + } + Mode::Horizontal => { + self.zoom = 1.0; + self.pan.y = 0.0; + self.pan.x = self + .pan + .x + .clamp(0.0, (self.extent.width - viewport.width).max(0.0)); + } + Mode::Canvas => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn camera_inverse_and_cursor_anchored_zoom_preserve_world_position() { + let mut view = Surface::new(Mode::Canvas); + view.pan = Point::new(-240.0, 120.0); + view.zoom = 0.75; + let pointer = Point::new(300.0, 180.0); + let world = view.to_world(pointer); + assert_eq!(view.to_view(world), pointer); + view.zoom = 2.0; + view.pan = Point::new( + world.x - pointer.x / view.zoom, + world.y - pointer.y / view.zoom, + ); + assert_eq!(view.to_view(world), pointer); + assert!(view.contains( + [world.x, world.y], + Some(Size { + width: 500.0, + height: 400.0 + }) + )); + assert!(!view.contains( + [-1000.0, -1000.0], + Some(Size { + width: 500.0, + height: 400.0 + }) + )); + } + + #[test] + fn finite_pages_clamp_after_content_shrinks() { + let mut view = Surface::new(Mode::Vertical); + view.pan = Point::new(25.0, 900.0); + view.extent = Size { + width: 400.0, + height: 800.0, + }; + view.clamp(Size { + width: 400.0, + height: 600.0, + }); + assert_eq!(view.pan, Point::new(0.0, 200.0)); + view.extent.height = 100.0; + view.clamp(Size { + width: 400.0, + height: 600.0, + }); + assert_eq!(view.pan.y, 0.0); + } +} diff --git a/src/render/scene.rs b/src/render/scene.rs index 13f218a..7de0554 100644 --- a/src/render/scene.rs +++ b/src/render/scene.rs @@ -1,8 +1,9 @@ use crate::{ activity::Activity, developer_tools::{self, DeveloperToolsLayout, Workspace as DeveloperToolsState}, - display_list::DisplayList, + display_list::{DisplayList, DrawCommand}, document::RenderDocument, + geometry::{Point, Rect}, }; #[derive(Clone, Debug, Default)] @@ -17,7 +18,7 @@ pub fn build_scene( document: &RenderDocument, developer_tools_state: &mut DeveloperToolsState, activity: &Activity, - tab_chrome: crate::tabs::Chrome, + sidebar: &mut crate::sidebar::State, ) -> DisplayScene { let tool = document .frame @@ -27,69 +28,54 @@ pub fn build_scene( .and_then(|tab| tab.developer_tool.as_deref()); developer_tools_state.select(tool); developer_tools_state.document_updated(document); - let mut display_list = document.display_list(scale).unwrap_or_else(|_| { + let left = sidebar.left(); + let surface = &document.frame.surface; + let content = Rect::new( + left * scale, + 0.0, + (width as f32 - left * scale).max(0.0), + height as f32, + ); + let zoom = if tool.is_some() { 1.0 } else { surface.zoom }; + let mut page = document.display_list(scale * zoom).unwrap_or_else(|_| { RenderDocument::default() .display_list(scale) .expect("default document is valid") }); - let offset = crate::tabs::height(&document.frame.tabs); - let inset = crate::tabs::INSET; - let left = crate::tabs::RAIL; - let content = crate::geometry::Rect::new( - left * scale, - offset * scale, - (width as f32 - (left + inset) * scale).max(0.0), - (height as f32 - (offset + inset) * scale).max(0.0), - ); - display_list.translate(crate::geometry::Point::new(left * scale, offset * scale)); - let mut framed = DisplayList::default(); - use crate::display_list::DrawCommand; - framed.push(DrawCommand::Clear(crate::system_ui::theme::BAR)); - framed.push(DrawCommand::PushClip(content)); - for command in display_list.commands() { - if let DrawCommand::Clear(color) = command { - framed.push(DrawCommand::FillRoundedRect { - rect: content, - radius: 10.0 * scale, - color: *color, - }); - } else { - framed.push(command.clone()); + page.translate(Point::new( + (left - surface.pan.x * zoom) * scale, + -surface.pan.y * zoom * scale, + )); + let mut display_list = DisplayList::default(); + display_list.push(DrawCommand::Clear( + crate::document::parse_color(&document.frame.background) + .unwrap_or(crate::system_ui::theme::BG), + )); + display_list.push(DrawCommand::PushClip(content)); + for command in page.commands() { + if !matches!(command, DrawCommand::Clear(_)) { + display_list.push(command.clone()); } } - framed.push(DrawCommand::PopClip); - let mut display_list = framed; - crate::tabs::draw( - tab_chrome.offset, - &mut display_list, - &document.frame.tabs, - width as f32 / scale, - scale, - tab_chrome.pointer, - &tab_chrome.visual, - ); - if let Some(index) = tab_chrome.insertion { - crate::tabs::draw_insertion( - tab_chrome.offset, - &mut display_list, - &document.frame.tabs, - width as f32 / scale, - index, - scale, - ); - } + display_list.push(DrawCommand::PopClip); let (mut developer_list, mut developer_layout) = developer_tools::build( - width.saturating_sub(((left + inset) * scale) as u32), - height.saturating_sub(((offset + inset) * scale) as u32), + width.saturating_sub((left * scale) as u32), + height, scale, document, developer_tools_state, activity, ); - developer_list.translate(crate::geometry::Point::new(left * scale, offset * scale)); - developer_layout.translate(left, offset); + developer_list.translate(Point::new(left * scale, 0.0)); + developer_layout.translate(left, 0.0); display_list.extend(developer_list); - + let mut chrome = sidebar.draw( + &document.frame.tabs, + height as f32 / scale, + developer_tools_state.accent, + ); + chrome.scale(scale); + display_list.extend(chrome); DisplayScene { display_list, layout: SceneLayout { diff --git a/src/render/source_layout.rs b/src/render/source_layout.rs index ded9eeb..fcc7ac9 100644 --- a/src/render/source_layout.rs +++ b/src/render/source_layout.rs @@ -458,8 +458,8 @@ impl NativeCallable for Collect { return Err(invalid("duplicate arrangement slot")); } let bounds = [ - number(cx, *item, "x", false)?, - number(cx, *item, "y", false)?, + number(cx, *item, "x", true)?, + number(cx, *item, "y", true)?, number(cx, *item, "width", false)?, number(cx, *item, "height", false)?, ]; @@ -876,7 +876,7 @@ pub fn start( children[indices[&parent]].push(index); } } - let mut constraints = vec![layout::root_constraints(runtime.viewport); nodes.len()]; + let mut constraints = vec![runtime.root_constraints(); nodes.len()]; for index in 0..nodes.len() { let next = layout::child_constraints(&nodes[index], constraints[index]); for child in &children[index] { diff --git a/src/runtime/api/guide.rs b/src/runtime/api/guide.rs index b8ef6a2..9b26d62 100644 --- a/src/runtime/api/guide.rs +++ b/src/runtime/api/guide.rs @@ -54,6 +54,29 @@ It does not publish a page or change revision. Console does not use this endpoin 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. +## Page modes and camera + +Page.mode is "window" (default), "vertical", "horizontal" or "canvas". +Window fills the available page viewport. Vertical constrains width and grows +height with children; horizontal constrains height and grows width. Scroll +finite pages with the wheel; the offset clamps to their actual extent. +Canvas arranges direct children at signed child.x / child.y world positions. +page.render(root) replaces content; page.append(child) adds another child. +Changing these attributes updates live occurrences, without copying objects. + +Canvas uses Page.panX / panY as the world origin and Page.zoom (0.1..4). +Wheel/trackpad pans, Space+left drag or middle drag pans, pinch or Cmd/Ctrl+wheel +zooms around the pointer. Positions are finite and bounded to +/-1000000 by the +existing renderer budget; canvas navigation is not clipped to content extent. +The camera belongs to the Page and survives page switching. +Nested ScrollBlock input is handled before page panning. + +Native transport kinds "page-pan" and "page-zoom" use mount=0 and current +window/session/layout_version. Pan delta is a two-component viewport-pixel +displacement. Zoom uses delta=[positive_scale_factor,0] and point=[x,y] in +viewport coordinates. Ordinary click/scroll coordinates remain world coordinates; +the host inverts the camera and the worker rechecks visibility and frame identity. + ## Managed visual types `Object`, `Type`, `Function`, `Task`, `Block`, `ContainerBlock`, `TextBlock`, `RectBlock`, @@ -67,7 +90,7 @@ declarations, explicit first `self` parameters, positional field construction an 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. +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(). Each window has a collapsible left sidebar for pages and Console/Sources/Network/Settings. Cmd/Ctrl+B toggles it. Collapsed, only the sidebar toggle and native macOS window controls remain over page content. Viewport queries report the actual available page area, with no top strip or content inset. Page selection/closure calls actual managed Page methods; Ctrl+Tab cycles pages, Cmd/Ctrl+T creates one and Cmd/Ctrl+W closes the active page. Window() creates another native resource in the same VM, up to 16. Page.open(window), moveToWindow(window) and detachToWindow() preserve business identity and replace occurrence Mounts. Window.close() releases Page roots and retires its presentation; primary native close exits the application. Resource changes publish only after successful input/candidate completion. Frame/session/window generations reject stale events and commands. Sidebar pointer drag/reorder is not implemented. 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. @@ -78,10 +101,10 @@ Source layout/paint now validate name-level read observations from Aster: each g -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. +Page navigation is in the left sidebar; the former horizontal strip and its drag/overflow animation are removed. Managed moveTab/moveToWindow/detachToWindow and the version-checked tab-drop API remain available. Sidebar pointer drag/reorder and mixed-DPI cross-window gestures are not implemented in this revision. -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. +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. These movement methods remain callable from Endor and transport clients; the sidebar currently provides selection and closure rather than pointer dragging. 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. diff --git a/src/runtime/api/tests.rs b/src/runtime/api/tests.rs index 555a3c7..14377e3 100644 --- a/src/runtime/api/tests.rs +++ b/src/runtime/api/tests.rs @@ -479,3 +479,105 @@ page.render(TextBlock("input priority")) let value = runtime.execute("count.value", "app.main").unwrap().value; assert_eq!(value, "1"); } + +#[test] +fn canvas_camera_crosses_real_http_route_and_preserves_negative_world_clicks() { + 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#" +count=ui.state(0) +button=Button("world") +button.onClick(count.increment) +button.x=-160 +button.y=-80 +page=Page() +page.mode="canvas" +page.render(button) +"#; + 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 frame = &namespace.read().unwrap().document.frame; + serde_json::to_string(&crate::events::Input { + window: 1, + session: frame.session, + layout_version: frame.layout_version, + mount: 0, + kind: kind.into(), + delta: Some(delta), + point, + press_point: None, + screen_position: None, + tab_drop: None, + }) + .unwrap() + }; + let event_path = "/v1/namespaces/app.main/events"; + let pan = camera("page-pan", [-240.0, -160.0], None); + let response = client.request("POST", event_path, &pan); + assert_eq!(response.0, 200, "{}", response.2); + assert_eq!( + namespace.read().unwrap().document.frame.surface.pan, + crate::geometry::Point::new(-240.0, -160.0) + ); + assert_eq!(client.request("POST", event_path, &pan).0, 409); + let response = client.request( + "POST", + event_path, + &camera("page-zoom", [2.0, 0.0], Some([120.0, 90.0])), + ); + assert_eq!(response.0, 200, "{}", response.2); + { + let current = namespace.read().unwrap(); + assert_eq!(current.document.frame.surface.zoom, 2.0); + assert_eq!( + current.document.frame.surface.pan, + crate::geometry::Point::new(-180.0, -115.0) + ); + } + let input = { + let current = namespace.read().unwrap(); + let frame = ¤t.document.frame; + let mount = frame.mounts.iter().find(|m| m.interactive).unwrap(); + assert!(mount.x < 0.0 && mount.y < 0.0); + crate::events::Input { + window: 1, + session: frame.session, + layout_version: frame.layout_version, + mount: mount.id, + kind: "click".into(), + point: Some([-150.0, -70.0]), + press_point: Some([-150.0, -70.0]), + delta: None, + screen_position: None, + tab_drop: None, + } + }; + let response = client.request("POST", event_path, &serde_json::to_string(&input).unwrap()); + assert_eq!(response.0, 200, "{}", response.2); + let response = client.request("POST", "/v1/namespaces/app.main/repl", "count.value"); + assert_eq!(response.0, 200, "{}", response.2); + assert_eq!( + serde_json::from_str::(&response.2).unwrap()["value"], + "1" + ); + let response = client.request( + "POST", + event_path, + &camera("page-zoom", [0.0, 0.0], Some([120.0, 90.0])), + ); + assert_eq!(response.0, 400); + assert!(namespace.read().unwrap().document.frame.fault.is_none()); +} diff --git a/src/runtime/api/worker.rs b/src/runtime/api/worker.rs index 3eada4e..802e5f7 100644 --- a/src/runtime/api/worker.rs +++ b/src/runtime/api/worker.rs @@ -336,6 +336,8 @@ impl Worker { "click" | "focus" | "scroll" + | "page-pan" + | "page-zoom" | "tab-activate" | "tab-close" | "tab-detach" @@ -422,7 +424,10 @@ impl Worker { || 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:") { + || if input.kind == "window-close" + || input.kind.starts_with("developer-open:") + || matches!(input.kind.as_str(), "page-pan" | "page-zoom") + { input.mount != 0 } else if matches!( input.kind.as_str(), @@ -447,6 +452,14 @@ impl Worker { "event targets an obsolete or inactive frame", )); } + if input.kind == "page-zoom" && frame.surface.mode != crate::page::Mode::Canvas + || input.kind == "page-pan" && frame.surface.mode == crate::page::Mode::Window + { + return RequestProgress::Complete(error_response( + StatusCode(400), + "camera input is unavailable in this Page mode", + )); + } Some(input) } else { None diff --git a/src/runtime/endor_host/tests.rs b/src/runtime/endor_host/tests.rs index 52d41b7..47bf4c5 100644 --- a/src/runtime/endor_host/tests.rs +++ b/src/runtime/endor_host/tests.rs @@ -207,10 +207,10 @@ page.render(Column(0,8,Column(0,8,paragraph,tail),Column(50,8,paragraph))) }) .collect(); assert_eq!(commands.len(), 2); - // The 240px window reserves 56px for the rail and 8px for its inset. - // The second occurrence additionally has 50px padding on each side. - assert_eq!(commands[0][0], 176.0); - assert_eq!(commands[1][0], 76.0); + // The viewport is already the available page area; there is no implicit + // rail/inset subtraction. The second occurrence has 50px padding per side. + assert_eq!(commands[0][0], 240.0); + assert_eq!(commands[1][0], 140.0); assert!(commands[1][1] > commands[0][1]); let tail = initial .frame @@ -251,7 +251,7 @@ page.render(Column(0,8,Column(0,8,paragraph,tail),Column(50,8,paragraph))) _ => None, }) .collect(); - assert_eq!(widths, [336.0, 236.0]); + assert_eq!(widths, [400.0, 300.0]); let heights: Vec<_> = resized .frame .commands @@ -1774,14 +1774,14 @@ fn viewport_resize_changes_constraints_and_preserves_mount_identity_without_sour }), ) .unwrap(); - // Page content excludes the 56px rail, 8px inset and 24px root padding - // on each side, both initially and after resizing the outer window. + // The viewport is the actual page area; only the Endor root's 24px padding + // on each side is removed, both initially and after resizing. assert!( initial .frame .mounts .iter() - .any(|mount| mount.width == 688.0 && mount.height == 24.0) + .any(|mount| mount.width == 752.0 && mount.height == 24.0) ); let ids: Vec<_> = initial.frame.mounts.iter().map(|mount| mount.id).collect(); let document = session @@ -1805,7 +1805,7 @@ fn viewport_resize_changes_constraints_and_preserves_mount_identity_without_sour .frame .mounts .iter() - .any(|mount| mount.width == 248.0 && mount.height == 24.0) + .any(|mount| mount.width == 312.0 && mount.height == 24.0) ); assert_eq!(session.inputs, 0); assert!( @@ -5675,3 +5675,194 @@ fn shutdown_hooks_cannot_create_new_background_work() { assert!(format!("{error:#}").contains("runtime is exiting; new tasks are forbidden")); assert!(session.is_faulted()); } + +#[test] +fn page_modes_grow_on_the_declared_axis_and_follow_live_children() { + for (mode, expected) in [ + ("window", (400.0, 300.0)), + ("vertical", (400.0, 710.0)), + ("horizontal", (910.0, 300.0)), + ] { + let source = format!( + r#"page=Page() +page.mode="{mode}" +page.gap=10 +page.render(RectBlock(450,350)) +page.append(RectBlock(450,350))"# + ); + let (mut session, document) = ReplSession::load_with_viewport( + &source, + "app.main", + 1, + 300, + Some(crate::layout::Size { + width: 400.0, + height: 300.0, + }), + ) + .unwrap(); + let root = &document.frame.mounts[0]; + assert_eq!((root.width, root.height), expected, "{mode}"); + if mode == "vertical" { + let next = session + .execute("page.append(RectBlock(450,350))", "app.main") + .unwrap() + .document + .unwrap(); + assert_eq!(next.frame.mounts[0].height, 1070.0); + } + if mode == "horizontal" { + let next = session + .execute("page.append(RectBlock(450,350))", "app.main") + .unwrap() + .document + .unwrap(); + assert_eq!(next.frame.mounts[0].width, 1370.0); + } + } +} + +#[test] +fn canvas_positions_are_live_signed_and_shared_without_copying_objects() { + let (mut session, initial) = ReplSession::load_with_viewport( + r#" +item=Button("same object") +item.x=-120 +item.y=90 +page=Page() +page.mode="canvas" +page.render(item) +page.append(item) +page.panX=-160 +page.zoom=2 +"#, + "app.main", + 1, + 301, + Some(crate::layout::Size { + width: 400.0, + height: 300.0, + }), + ) + .unwrap(); + let mounts: Vec<_> = initial + .frame + .mounts + .iter() + .filter(|mount| mount.interactive) + .collect(); + assert_eq!(mounts.len(), 2); + assert_eq!(mounts[0].block_id, mounts[1].block_id); + assert_ne!(mounts[0].id, mounts[1].id); + assert_eq!((mounts[0].x, mounts[0].y), (-120.0, 90.0)); + assert_eq!(initial.frame.surface.pan.x, -160.0); + assert_eq!(initial.frame.surface.zoom, 2.0); + let next = session + .execute("item.x=500\nitem.y=-60", "app.main") + .unwrap() + .document + .unwrap(); + assert!( + next.frame + .mounts + .iter() + .filter(|m| m.interactive) + .all(|m| m.x == 500.0 && m.y == -60.0) + ); + let old_page = next.frame.surface.page.clone(); + let window = session + .execute( + "other=Page()\nother.render(TextBlock(\"other\"))", + "app.main", + ) + .unwrap() + .document + .unwrap(); + assert_eq!(window.frame.surface.mode, crate::page::Mode::Window); + let restored = session + .execute("page.activate()", "app.main") + .unwrap() + .document + .unwrap(); + assert_eq!(restored.frame.surface.page, old_page); + assert_eq!(restored.frame.surface.pan.x, -160.0); + assert_eq!(restored.frame.surface.zoom, 2.0); +} + +#[test] +fn bundled_start_page_creates_all_four_real_page_modes() { + let mut session = ReplSession::new_with_viewport( + 302, + Some(crate::layout::Size { + width: 1024.0, + height: 800.0, + }), + ) + .unwrap(); + let mut document = session + .execute("ui.newTab()", "app.main") + .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), + ] { + let home_tab = document + .frame + .tabs + .iter() + .find(|tab| tab.title == "自由画布") + .unwrap() + .id; + let point = document + .frame + .commands + .iter() + .find_map(|command| match command { + RenderCommand::Text { x, y, value, .. } if value == label => { + Some(crate::geometry::Point::new(x + 2.0, y + 2.0)) + } + _ => None, + }) + .unwrap(); + let mount = document + .frame + .hit_test(point) + .expect("button label must be clickable") + .id; + document = session + .dispatch(&crate::events::Input { + window: 1, + session: document.frame.session, + layout_version: document.frame.layout_version, + mount, + kind: "click".into(), + point: Some([point.x, point.y]), + press_point: Some([point.x, point.y]), + delta: None, + tab_drop: None, + screen_position: None, + }) + .unwrap() + .document + .unwrap(); + assert_eq!(document.frame.surface.mode, mode, "{label}"); + assert!(document.frame.fault.is_none()); + let input = crate::events::Input { + window: 1, + session: document.frame.session, + layout_version: document.frame.layout_version, + mount: home_tab, + kind: "tab-activate".into(), + point: None, + press_point: None, + delta: None, + tab_drop: None, + screen_position: None, + }; + document = session.dispatch(&input).unwrap().document.unwrap(); + } +} diff --git a/src/runtime/events.rs b/src/runtime/events.rs index 5c7d020..f1b0b1e 100644 --- a/src/runtime/events.rs +++ b/src/runtime/events.rs @@ -73,12 +73,41 @@ 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") { + let valid_delta = self.delta.is_some_and(|delta| { + delta + .iter() + .all(|v| v.is_finite() && v.abs() <= 1_000_000.0) + }); + let valid_point = self.point.is_none_or(|point| { + point + .iter() + .all(|v| v.is_finite() && (0.0..=1_000_000.0).contains(v)) + }); + if !valid_delta + || !valid_point + || self.mount != 0 + || self.press_point.is_some() + || self.screen_position.is_some() + { + return Err("invalid page camera input"); + } + if self.kind == "page-zoom" + && (self.point.is_none() + || self + .delta + .is_none_or(|delta| !(0.1..=10.0).contains(&delta[0]) || delta[1] != 0.0)) + { + return Err("page-zoom requires an anchor and positive scale factor"); + } + return Ok(()); + } if self.kind == "scroll" { return match (self.point, self.press_point, self.delta) { (Some(point), None, Some(delta)) if 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) && delta .iter() .all(|v| v.is_finite() && v.abs() <= 1_000_000.0) => @@ -98,7 +127,7 @@ impl Input { && point .into_iter() .chain(press) - .all(|value| value.is_finite() && (0.0..=1_000_000.0).contains(&value)) => + .all(|value| value.is_finite() && value.abs() <= 1_000_000.0) => { Ok(()) } diff --git a/src/shell/app.rs b/src/shell/app.rs index 0a53aaa..57494cd 100644 --- a/src/shell/app.rs +++ b/src/shell/app.rs @@ -1,7 +1,7 @@ use std::{ num::NonZeroU32, sync::{Arc, RwLock}, - time::{Duration, Instant}, + time::Duration, }; use softbuffer::{Context, Surface}; @@ -9,7 +9,7 @@ use winit::{ application::ApplicationHandler, dpi::{LogicalPosition, LogicalSize, PhysicalPosition}, event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent}, - event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy}, + event_loop::{ActiveEventLoop, EventLoopProxy}, keyboard::{Key, ModifiersState, NamedKey}, window::{Window, WindowAttributes, WindowId}, }; @@ -31,14 +31,6 @@ pub enum AppEvent { ConsoleFailed { message: String, source: String }, } -struct TabScrollHover { - window: u64, - session: u64, - point: Point, - direction: i8, - next: Instant, -} - pub struct TesseraApp { resource: u64, control: crate::api::RuntimeControl, @@ -55,21 +47,14 @@ pub struct TesseraApp { developer_tools: DeveloperToolsState, layout: SceneLayout, pointer: Point, - pointer_inside: bool, - new_tab_pressed: bool, + sidebar: crate::sidebar::State, + sidebar_keyboard: bool, + panning: bool, + space_down: bool, modifiers: ModifiersState, activity: Activity, event_client: crate::events::Client, guest_pressed: Option, - tab_pressed: Option, - tab_drag_origin: Option, - tab_drag_grab: Option, - tab_drop_index: Option, - tab_offset: Option, - tab_motion: crate::tabs::Motion, - tab_visual: crate::tabs::Visual, - tab_scroll_hover: Option, - window_order: Vec, guest_focus: Option, guest_key: Option<(NamedKey, crate::events::Input)>, guest_keyboard: bool, @@ -99,21 +84,14 @@ impl TesseraApp { developer_tools: DeveloperToolsState::new(proxy), layout: SceneLayout::default(), pointer: Point::default(), - pointer_inside: false, - new_tab_pressed: false, + sidebar: crate::sidebar::State::default(), + sidebar_keyboard: false, + panning: false, + space_down: false, modifiers: ModifiersState::default(), activity, event_client, guest_pressed: None, - tab_pressed: None, - tab_drag_origin: None, - tab_drag_grab: None, - tab_drop_index: None, - tab_offset: None, - tab_motion: crate::tabs::Motion::default(), - tab_visual: crate::tabs::Visual::default(), - tab_scroll_hover: None, - window_order: vec![crate::window::MAIN_WINDOW], guest_focus: None, guest_key: None, guest_keyboard: true, @@ -258,7 +236,9 @@ impl TesseraApp { } if let Ok(mut namespace) = self.namespace.write() { let size = crate::layout::Size { - width: size.width.min(crate::ui_bridge::MAX_DIMENSION as f32), + width: (size.width - self.sidebar.left()) + .max(0.0) + .min(crate::ui_bridge::MAX_DIMENSION as f32), height: size.height.min(crate::ui_bridge::MAX_DIMENSION as f32), }; namespace.viewport = Some(size); @@ -329,18 +309,6 @@ impl TesseraApp { .tabs .iter() .any(|tab| tab.active && tab.developer_tool.is_some()); - if self.tab_pressed.as_ref().is_some_and(|pressed| { - frame.session != pressed.session - || frame.fault.is_some() - || !frame.tabs.iter().any(|tab| tab.id == pressed.mount) - }) { - self.tab_pressed = None; - self.tab_drag_origin = None; - self.tab_drag_grab = None; - self.tab_drop_index = None; - self.tab_offset = None; - self.tab_scroll_hover = None; - } self.guest_focus = frame .focus @@ -362,6 +330,58 @@ impl TesseraApp { } fn handle_keyboard(&mut self, event: &winit::event::KeyEvent) { + let command = self.modifiers.super_key() || self.modifiers.control_key(); + if command + && event.state == ElementState::Pressed + && !event.repeat + && matches!(&event.logical_key, Key::Character(key) if key.eq_ignore_ascii_case("b")) + { + self.sidebar_action(crate::sidebar::Action::Toggle); + return; + } + if event.logical_key == Key::Named(NamedKey::Escape) { + self.panning = false; + self.space_down = false; + self.guest_pressed = None; + } + if event.logical_key == Key::Named(NamedKey::Space) + && self.guest_focus.is_none() + && self + .namespace + .read() + .is_ok_and(|n| n.document.frame.surface.mode == crate::page::Mode::Canvas) + { + self.space_down = event.state == ElementState::Pressed; + if !self.space_down { + self.panning = false; + } + return; + } + if self.sidebar_keyboard { + if event.logical_key == Key::Named(NamedKey::Tab) + && event.state == ElementState::Pressed + { + self.sidebar + .interaction + .focus_next(&self.sidebar.targets, self.modifiers.shift_key()); + self.request_redraw(); + return; + } + if matches!( + event.logical_key, + Key::Named(NamedKey::Enter | NamedKey::Space) + ) { + if event.state == ElementState::Pressed { + self.sidebar.interaction.key_press(&self.sidebar.targets); + } else if let Some(action) = + self.sidebar.interaction.key_release(&self.sidebar.targets) + { + self.sidebar_action(action); + } + self.request_redraw(); + return; + } + } if event.state == ElementState::Pressed && !event.repeat && (self.modifiers.super_key() || self.modifiers.control_key()) @@ -453,6 +473,9 @@ impl TesseraApp { } fn guest_covered(&self, point: Point) -> bool { + if self.sidebar.covers(point) { + return true; + } let panel = self.layout.developer_tools.panel; self.developer_tools.open && crate::geometry::Rect::new( @@ -464,28 +487,30 @@ impl TesseraApp { .contains(point) } - fn guest_hit(&self) -> Option { + fn guest_point(&self) -> Option { if self.guest_covered(self.pointer) { return None; } let current = self.namespace.read().ok()?; - if current.viewport.is_some_and(|size| { - self.pointer.x < 0.0 - || self.pointer.y < 0.0 - || self.pointer.x >= size.width - || self.pointer.y >= size.height - }) { + let point = Point::new(self.pointer.x - self.sidebar.left(), self.pointer.y); + if point.x < 0.0 + || point.y < 0.0 + || current + .viewport + .is_some_and(|size| point.x >= size.width || point.y >= size.height) + { return None; } + Some(current.document.frame.surface.to_world(point)) + } + + fn guest_hit(&self) -> Option { + let point = self.guest_point()?; + let current = self.namespace.read().ok()?; let frame = ¤t.document.frame; if frame.session == 0 { return None; } - let offset = crate::tabs::height(&frame.tabs); - if self.pointer.y < offset { - return None; - } - let point = Point::new(self.pointer.x - crate::tabs::RAIL, self.pointer.y - offset); let mount = frame.hit_test(point)?; Some(crate::events::Input { window: self.resource, @@ -494,8 +519,8 @@ impl TesseraApp { mount: mount.id, kind: "click".into(), point: Some([point.x, point.y]), - delta: None, press_point: Some([point.x, point.y]), + delta: None, tab_drop: None, screen_position: None, }) @@ -531,9 +556,6 @@ impl TesseraApp { if let Some(input) = input { self.guest_pressed = None; self.guest_key = None; - self.tab_pressed = None; - self.tab_drag_origin = None; - self.tab_drag_grab = None; self.send_guest(input); true } else { @@ -541,55 +563,84 @@ impl TesseraApp { } } - fn new_tab_hit(&self) -> bool { - let Some(window) = &self.window else { - return false; - }; - self.namespace.read().is_ok_and(|n| { - (if self.tab_visual.plus.width > 0.0 { - self.tab_visual.plus - } else { - crate::tabs::plus_rect( - self.tab_offset, - &n.document.frame.tabs, - window.inner_size().width as f32 / window.scale_factor() as f32, - ) - }) - .contains(self.pointer) - }) + fn sidebar_action(&mut self, action: crate::sidebar::Action) { + use crate::sidebar::Action; + match action { + Action::Toggle => { + self.sidebar.open = !self.sidebar.open; + self.sidebar.interaction.cancel(); + self.sidebar_keyboard = self.sidebar.open; + self.guest_pressed = None; + self.publish_viewport(); + } + Action::NewPage => self.open_developer_tool("New tab"), + Action::Tool(name) => self.open_developer_tool(name), + Action::Activate(id) | Action::Close(id) => { + let input = self + .namespace + .read() + .ok() + .map(|current| crate::events::Input { + window: self.resource, + session: current.document.frame.session, + layout_version: current.document.frame.layout_version, + mount: id, + kind: if matches!(action, Action::Close(_)) { + "tab-close" + } else { + "tab-activate" + } + .into(), + point: None, + press_point: None, + delta: None, + tab_drop: None, + screen_position: None, + }); + if let Some(input) = input { + self.send_guest(input); + } + } + } + self.request_redraw(); } - fn tab_hit(&self) -> Option { + fn page_view(&mut self, kind: &str, delta: [f32; 2]) { if self.guest_covered(self.pointer) { - return None; + return; } - let window = self.window.as_ref()?; - let namespace = self.namespace.read().ok()?; - let frame = &namespace.document.frame; - if frame.session == 0 || frame.fault.is_some() { - return None; + let point = Point::new(self.pointer.x - self.sidebar.left(), self.pointer.y); + let input = self.namespace.read().ok().and_then(|current| { + let frame = ¤t.document.frame; + if frame.session == 0 + || frame.fault.is_some() + || 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; + } + Some(crate::events::Input { + window: self.resource, + session: frame.session, + layout_version: frame.layout_version, + mount: 0, + kind: kind.into(), + point: (kind == "page-zoom").then_some([point.x, point.y]), + press_point: None, + delta: Some(delta), + tab_drop: None, + screen_position: None, + }) + }); + if let Some(input) = input { + self.guest_pressed = None; + self.send_guest(input); } - let width = window.inner_size().width as f32 / window.scale_factor() as f32; - let (id, close) = if self.tab_visual.rects.is_empty() { - crate::tabs::hit(self.tab_offset, &frame.tabs, width, self.pointer) - } else { - self.tab_visual.hit(self.pointer) - }?; - if !frame.tabs.iter().any(|tab| tab.id == id) { - return None; - } - Some(crate::events::Input { - window: self.resource, - session: frame.session, - layout_version: frame.layout_version, - mount: id, - kind: if close { "tab-close" } else { "tab-activate" }.into(), - point: None, - delta: None, - press_point: None, - tab_drop: None, - screen_position: None, - }) } fn send_guest(&mut self, event: crate::events::Input) { @@ -668,379 +719,6 @@ impl TesseraApp { false } - fn apps(&self) -> impl Iterator { - std::iter::once(self).chain(self.children.values()) - } - - fn drag_anchor(&self, input: &crate::events::Input) -> Option { - let window = self.window.as_ref()?; - let current = self.namespace.read().ok()?; - if let Some((_, rect)) = self - .tab_visual - .rects - .iter() - .find(|(id, _)| *id == input.mount) - { - return Some(Point::new(self.pointer.x - rect.x, self.pointer.y)); - } - crate::tabs::drag_anchor( - self.tab_offset, - ¤t.document.frame.tabs, - window.inner_size().width as f32 / window.scale_factor() as f32, - input.mount, - self.pointer, - ) - } - - fn clear_drag_feedback(&mut self, target: Option<(u64, usize)>) { - let index = target - .filter(|(window, _)| *window == self.resource) - .map(|(_, index)| index); - if self.tab_drop_index != index { - self.tab_drop_index = index; - self.request_redraw(); - } - for child in self.children.values_mut() { - child.clear_drag_feedback(target); - } - } - - fn clear_tab_scroll(&mut self) { - self.tab_scroll_hover = None; - if self.tab_offset.take().is_some() { - self.request_redraw(); - } - for child in self.children.values_mut() { - child.clear_tab_scroll(); - } - } - - fn tick_tab_scroll(&mut self, event_loop: &ActiveEventLoop) { - let Some(mut hover) = self.tab_scroll_hover.take() else { - return; - }; - let dragging = self.apps().any(|app| { - app.tab_drag_origin.is_some() - && app.tab_pressed.as_ref().is_some_and(|input| { - input.session == hover.session - && app.namespace.read().ok().is_some_and(|current| { - let frame = ¤t.document.frame; - frame.session == input.session - && frame.fault.is_none() - && frame.tabs.iter().any(|tab| tab.id == input.mount) - }) - }) - }); - if !dragging { - self.clear_tab_scroll(); - return; - } - let now = Instant::now(); - if now < hover.next { - event_loop.set_control_flow(ControlFlow::WaitUntil(hover.next)); - self.tab_scroll_hover = Some(hover); - return; - } - let target = if self.resource == hover.window { - Some(&mut *self) - } else { - self.children.get_mut(&hover.window) - }; - let feedback = target.and_then(|target| { - let window = target.window.as_ref()?; - let width = window.inner_size().width as f32 / window.scale_factor() as f32; - let current = target.namespace.read().ok()?; - let frame = ¤t.document.frame; - if frame.session != hover.session - || frame.fault.is_some() - || target.guest_covered(hover.point) - { - return None; - } - let direction = - crate::tabs::edge_direction(&frame.tabs, width, target.tab_offset, hover.point); - if direction != hover.direction { - return None; - } - let first = - crate::tabs::scroll_start(&frame.tabs, width, target.tab_offset, direction)?; - let index = crate::tabs::insertion_index(Some(first), &frame.tabs, width, hover.point)?; - drop(current); - target.tab_offset = Some(first); - target.request_redraw(); - Some((hover.window, index)) - }); - if let Some(feedback) = feedback { - self.clear_drag_feedback(Some(feedback)); - hover.next = now + Duration::from_millis(120); - event_loop.set_control_flow(ControlFlow::WaitUntil(hover.next)); - self.tab_scroll_hover = Some(hover); - } - } - - fn native_tab_drag(&mut self, window_id: WindowId, event: &WindowEvent) { - if self.resource != crate::window::MAIN_WINDOW { - return; - } - let Some(receiver) = self.apps().find(|app| { - app.window - .as_ref() - .is_some_and(|window| window.id() == window_id) - }) else { - return; - }; - let receiver_id = receiver.resource; - if matches!(event, WindowEvent::CursorLeft { .. }) { - if self - .tab_scroll_hover - .as_ref() - .is_some_and(|hover| hover.window == receiver_id) - { - self.tab_scroll_hover = None; - self.clear_drag_feedback(None); - } - return; - } - if matches!( - event, - WindowEvent::Focused(true) - | WindowEvent::MouseInput { - state: ElementState::Pressed, - button: MouseButton::Left, - .. - } - ) { - self.window_order.retain(|id| *id != receiver_id); - self.window_order.push(receiver_id); - } - if matches!( - event, - WindowEvent::MouseInput { - state: ElementState::Pressed, - button: MouseButton::Left, - .. - } - ) || matches!(event,WindowEvent::KeyboardInput { event,.. } if event.state==ElementState::Pressed && event.logical_key==Key::Named(NamedKey::Escape)) - { - self.tab_pressed = None; - self.tab_drag_origin = None; - self.tab_drag_grab = None; - for child in self.children.values_mut() { - child.tab_pressed = None; - child.tab_drag_origin = None; - child.tab_drag_grab = None; - } - self.clear_drag_feedback(None); - self.clear_tab_scroll(); - return; - } - let release = matches!( - event, - WindowEvent::MouseInput { - state: ElementState::Released, - button: MouseButton::Left, - .. - } - ); - if !release && !matches!(event, WindowEvent::CursorMoved { .. }) { - return; - } - let Some(source) = self - .apps() - .find(|app| app.tab_pressed.is_some() && app.tab_drag_origin.is_some()) - else { - return; - }; - let source_id = source.resource; - let source_window = source.window.as_ref().expect("dragging native window"); - let source_scale = source_window.scale_factor(); - let origin = source.tab_drag_origin.expect("drag origin"); - let receiver = self - .apps() - .find(|app| app.resource == receiver_id) - .expect("event window"); - let receiver_window = receiver.window.as_ref().expect("event native window"); - let point = if let WindowEvent::CursorMoved { position, .. } = event { - Point::new( - (position.x / receiver_window.scale_factor()) as f32, - (position.y / receiver_window.scale_factor()) as f32, - ) - } else { - receiver.pointer - }; - let screen = receiver_window.inner_position().ok().map(|position| { - [ - position.x as f64 + point.x as f64 * receiver_window.scale_factor(), - position.y as f64 + point.y as f64 * receiver_window.scale_factor(), - ] - }); - let distance = if let (Some(screen), Ok(start)) = (screen, source_window.inner_position()) { - ((screen[0] - start.x as f64 - origin.x as f64 * source_scale) - .hypot(screen[1] - start.y as f64 - origin.y as f64 * source_scale) - / source_scale) as f32 - } else if source_id == receiver_id { - (point.x - origin.x).hypot(point.y - origin.y) - } else { - 0.0 - }; - if distance <= 12.0 { - if release { - self.clear_drag_feedback(None); - } - return; - } - let mut input = source.tab_pressed.clone().expect("pressed tab"); - let valid = source.namespace.read().ok().is_some_and(|current| { - let frame = ¤t.document.frame; - if frame.session != input.session - || frame.fault.is_some() - || !frame.tabs.iter().any(|tab| tab.id == input.mount) - { - return false; - } - // Opaque tab identities survive a redraw; use release-time geometry - // while preserving the pressed Page and runtime identity. - input.layout_version = frame.layout_version; - true - }); - if !valid { - self.tab_pressed = None; - self.tab_drag_origin = None; - self.tab_drag_grab = None; - for child in self.children.values_mut() { - child.tab_pressed = None; - child.tab_drag_origin = None; - child.tab_drag_grab = None; - } - self.clear_drag_feedback(None); - self.clear_tab_scroll(); - return; - } - let mut apps: Vec<_> = self.apps().collect(); - apps.sort_by_key(|app| { - self.window_order - .iter() - .position(|id| *id == app.resource) - .unwrap_or(0) - }); - let front = apps.into_iter().rev().find_map(|app| { - let window = app.window.as_ref()?; - if window.is_visible() == Some(false) || window.is_minimized() == Some(true) { - return None; - } - let relative = if let Some(screen) = screen { - let start = window.inner_position().ok()?; - Point::new( - ((screen[0] - start.x as f64) / window.scale_factor()) as f32, - ((screen[1] - start.y as f64) / window.scale_factor()) as f32, - ) - } else if app.resource == source_id && receiver_id == source_id { - point - } else { - return None; - }; - let size = window.inner_size().to_logical::(window.scale_factor()); - let inside = relative.x >= 0.0 - && relative.y >= 0.0 - && relative.x < size.width - && relative.y < size.height; - let chrome = screen - .zip(window.outer_position().ok()) - .is_some_and(|(screen, outer)| { - let size = window.outer_size(); - screen[0] >= outer.x as f64 - && screen[1] >= outer.y as f64 - && screen[0] < outer.x as f64 + size.width as f64 - && screen[1] < outer.y as f64 + size.height as f64 - }); - (inside || chrome).then_some((app, relative, size, inside)) - }); - // An obscuring window (including its chrome/developer panel) prevents - // dropping into a lower window that happens to share these coordinates. - let mut scroll = None; - let target = front.and_then(|(app, relative, size, inside)| { - if !inside || app.guest_covered(relative) { - return None; - } - let current = app.namespace.read().ok()?; - let frame = ¤t.document.frame; - if frame.session != input.session || frame.fault.is_some() { - return None; - } - let index = if frame.tabs.is_empty() { - 0 - } else { - crate::tabs::insertion_index(app.tab_offset, &frame.tabs, size.width, relative)? - }; - let direction = - crate::tabs::edge_direction(&frame.tabs, size.width, app.tab_offset, relative); - if direction != 0 { - scroll = Some((app.resource, frame.session, relative, direction)); - } - Some(crate::events::TabDrop { - window: app.resource, - layout_version: frame.layout_version, - index, - }) - }); - let position = screen.and_then(|point| { - let anchor = source.tab_drag_grab?; - let inner = source_window.inner_position().ok()?; - let outer = source_window.outer_position().ok()?; - let x = point[0] - anchor.x as f64 * source_scale - (inner.x - outer.x) as f64; - let y = point[1] - anchor.y as f64 * source_scale - (inner.y - outer.y) as f64; - [x, y] - .iter() - .all(|value| value.is_finite() && value.abs() <= 1_000_000.0) - .then_some([x.round() as i32, y.round() as i32]) - }); - self.tab_scroll_hover = scroll.map(|(window, session, point, direction)| { - let next = self - .tab_scroll_hover - .as_ref() - .filter(|old| { - old.window == window && old.session == session && old.direction == direction - }) - .map(|old| old.next) - .unwrap_or_else(|| Instant::now() + Duration::from_millis(350)); - TabScrollHover { - window, - session, - point, - direction, - next, - } - }); - self.clear_drag_feedback(target.as_ref().map(|target| (target.window, target.index))); - if !release { - return; - } - input.kind = if target.is_some() { - "tab-drop" - } else { - "tab-detach" - } - .into(); - input.tab_drop = target; - input.screen_position = if input.tab_drop.is_none() { - position - } else { - None - }; - let source = if self.resource == source_id { - &mut *self - } else { - self.children.get_mut(&source_id).expect("drag source") - }; - source.tab_pressed = None; - source.tab_drag_origin = None; - source.tab_drag_grab = None; - source.guest_pressed = None; - source.send_guest(input); - self.clear_drag_feedback(None); - self.clear_tab_scroll(); - } - fn create_native(&mut self, event_loop: &ActiveEventLoop) -> anyhow::Result<()> { if self.window.is_some() { return Ok(()); @@ -1092,7 +770,6 @@ impl TesseraApp { if self.exit_requested { return; } - self.clear_tab_scroll(); self.guest_pressed = None; self.guest_key = None; if self.control.request_shutdown() { @@ -1134,9 +811,12 @@ impl TesseraApp { } fn try_redraw(&mut self) -> anyhow::Result<()> { - let pointer = self.pointer_inside.then_some(self.pointer); - let hover = pointer.and_then(|_| self.tab_hit().map(|input| input.mount)); - let guest_cursor = self.guest_hit().is_some() || hover.is_some() || self.new_tab_hit(); + let guest_cursor = self.guest_hit().is_some() + || self + .sidebar + .interaction + .hit(&self.sidebar.targets) + .is_some(); let (Some(window), Some(surface)) = (self.window.as_ref(), self.surface.as_mut()) else { return Ok(()); }; @@ -1150,17 +830,6 @@ impl TesseraApp { let Ok(namespace) = self.namespace.read() else { return Ok(()); }; - self.tab_visual = self.tab_motion.sample( - &namespace.document.frame.tabs, - self.tab_offset, - size.width as f32 / window.scale_factor() as f32, - ); - self.tab_offset = self.tab_motion.offset; - self.tab_motion.sample_hover( - &mut self.tab_visual, - &namespace.document.frame.tabs, - pointer, - ); let scene = scene::build_scene( size.width, size.height, @@ -1168,12 +837,7 @@ impl TesseraApp { &namespace.document, &mut self.developer_tools, &self.activity, - crate::tabs::Chrome { - offset: self.tab_offset, - insertion: self.tab_drop_index, - pointer, - visual: self.tab_visual.clone(), - }, + &mut self.sidebar, ); drop(namespace); self.layout = scene.layout; @@ -1227,27 +891,7 @@ impl ApplicationHandler for TesseraApp { } } - fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { - event_loop.set_control_flow(ControlFlow::Wait); - self.tick_tab_scroll(event_loop); - let now = Instant::now(); - let mut next = match event_loop.control_flow() { - ControlFlow::WaitUntil(deadline) => Some(deadline), - _ => None, - }; - for app in std::iter::once(&*self).chain(self.children.values()) { - if let Some(deadline) = app.tab_motion.next_frame { - if now >= deadline { - app.request_redraw(); - } - let deadline = deadline.max(now + Duration::from_millis(1)); - next = Some(next.map_or(deadline, |previous| previous.min(deadline))); - } - } - if let Some(deadline) = next { - event_loop.set_control_flow(ControlFlow::WaitUntil(deadline)); - } - } + fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {} fn resumed(&mut self, event_loop: &ActiveEventLoop) { if self.resource == crate::window::MAIN_WINDOW @@ -1312,7 +956,6 @@ impl ApplicationHandler for TesseraApp { if self.control.is_exiting() { return; } - self.native_tab_drag(window_id, &event); let Some(window) = self.window.as_ref().cloned() else { return; }; @@ -1366,8 +1009,15 @@ impl ApplicationHandler for TesseraApp { let scale = window.scale_factor() as f32; let next = Point::new(x as f32 / scale, y as f32 / scale); let delta = Point::new(next.x - self.pointer.x, next.y - self.pointer.y); - self.pointer_inside = true; self.pointer = next; + self.sidebar + .interaction + .pointer_moved(next, &self.sidebar.targets); + if self.panning { + self.page_view("page-pan", [-delta.x, -delta.y]); + window.request_redraw(); + return; + } self.developer_tools.move_pointer( next, delta, @@ -1375,7 +1025,12 @@ impl ApplicationHandler for TesseraApp { window.inner_size().width as f32 / scale, ); window.set_cursor( - if self.guest_hit().is_some() || self.tab_hit().is_some() || self.new_tab_hit() + if self.guest_hit().is_some() + || self + .sidebar + .interaction + .hit(&self.sidebar.targets) + .is_some() { winit::window::CursorIcon::Pointer } else { @@ -1386,86 +1041,79 @@ impl ApplicationHandler for TesseraApp { window.request_redraw(); } WindowEvent::CursorLeft { .. } => { - self.pointer_inside = false; self.guest_pressed = None; - if self.tab_drag_origin.is_none() { - self.tab_pressed = None; - } + self.panning = false; + self.sidebar.interaction.pointer_left(); self.developer_tools.pointer_left(); window.set_cursor(winit::window::CursorIcon::Default); window.request_redraw(); } + WindowEvent::MouseInput { + state, + 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.guest_pressed = None; + window.request_redraw(); + } WindowEvent::MouseInput { state, button: MouseButton::Left, .. } => { if state == ElementState::Pressed { - if self.new_tab_hit() { - self.new_tab_pressed = true; - window.request_redraw(); - return; - } - if self.pointer.y < crate::tabs::HEIGHT - && self.pointer.x >= crate::tabs::LEADING - && self.tab_hit().is_none() - { - #[cfg(target_os = "macos")] - if crate::native_chrome::titlebar_double_click(&window) { - return; + if self.sidebar.covers(self.pointer) { + self.sidebar_keyboard = true; + self.sidebar.interaction.press(&self.sidebar.targets); + self.guest_pressed = None; + if self.sidebar.open + && self.pointer.y < crate::sidebar::HEADER + && self.pointer.x >= crate::sidebar::CONTROLS + && self + .sidebar + .interaction + .hit(&self.sidebar.targets) + .is_none() + { + #[cfg(target_os = "macos")] + if crate::native_chrome::titlebar_double_click(&window) { + return; + } + let _ = window.drag_window(); } - let _ = window.drag_window(); - } - if self.pointer.y >= crate::tabs::height(&[]) { + } else if self.space_down && self.guest_point().is_some() { + self.panning = true; + self.guest_pressed = None; + } else { + self.sidebar_keyboard = false; + self.sidebar.interaction.blur(); self.guest_keyboard = !self.guest_covered(self.pointer); if self.guest_keyboard { self.developer_tools.blur(); } + self.guest_pressed = self.guest_hit(); + self.developer_tools.press( + &self.layout.developer_tools, + self.pointer, + &self.activity, + ); } - self.tab_pressed = self.tab_hit(); - self.tab_drag_grab = self - .tab_pressed - .as_ref() - .filter(|input| input.kind == "tab-activate") - .and_then(|input| self.drag_anchor(input)); - self.tab_drag_origin = self.tab_drag_grab.map(|_| self.pointer); - self.guest_pressed = self.guest_hit(); - self.developer_tools.press( - &self.layout.developer_tools, - self.pointer, - &self.activity, - ); } else { - if self.new_tab_pressed { - self.new_tab_pressed = false; - if self.new_tab_hit() { - self.open_developer_tool("New tab"); - } + if self.panning { + self.panning = false; + self.guest_pressed = None; window.request_redraw(); return; } - if let Some(mut pressed) = self.tab_pressed.take() { - let width = window.inner_size().width as f32 / window.scale_factor() as f32; - let dragged = self.tab_drag_origin.take().is_some_and(|origin| { - (self.pointer.x - origin.x).hypot(self.pointer.y - origin.y) > 12.0 - && (self.pointer.y < 0.0 - || self.pointer.y >= crate::tabs::HEIGHT + 12.0 - || self.pointer.x < 0.0 - || self.pointer.x >= width) - }); - if dragged { - pressed.kind = "tab-detach".into(); - self.send_guest(pressed); - } else if let Some(released) = self.tab_hit() - && released.session == pressed.session - && released.layout_version == pressed.layout_version - && released.mount == pressed.mount - && released.kind == pressed.kind - { - self.send_guest(released); - } - } - if let Some(pressed) = self.guest_pressed.take() + if let Some(action) = self.sidebar.interaction.release(&self.sidebar.targets) { + self.sidebar_action(action); + } else if let Some(pressed) = self.guest_pressed.take() && let Some(mut released) = self.guest_hit() && released.session == pressed.session && released.layout_version == pressed.layout_version @@ -1481,6 +1129,10 @@ impl ApplicationHandler for TesseraApp { } window.request_redraw(); } + WindowEvent::PinchGesture { delta, .. } => { + self.page_view("page-zoom", [(delta as f32).exp(), 0.0]); + window.request_redraw(); + } WindowEvent::MouseWheel { delta, .. } => { let (mut dx, mut dy) = match delta { MouseScrollDelta::LineDelta(x, y) => (x * 38.0, y * 38.0), @@ -1493,46 +1145,30 @@ impl ApplicationHandler for TesseraApp { dx = dy; dy = 0.0; } - let width = window.inner_size().width as f32 / window.scale_factor() as f32; - if self.pointer.y >= 0.0 - && self.pointer.y < crate::tabs::HEIGHT - && self.pointer.x >= crate::tabs::LEADING - && self.pointer.x < width - 44.0 - { - // Trackpad horizontal deltas and ordinary vertical mouse wheels both - // browse the strip. OS momentum arrives as further pixel deltas. - if let Ok(namespace) = self.namespace.read() { - let delta = if dx.abs() > dy.abs() { dx } else { dy }; - let offset = crate::tabs::wheel_offset( - &namespace.document.frame.tabs, - width, - self.tab_offset, - -delta, - ); - self.tab_offset = Some(offset); - self.tab_motion.scroll_to(offset); + if self.sidebar.covers(self.pointer) { + if self.sidebar.open && self.pointer.y >= 333.0 { + self.sidebar.scroll -= dy; } window.request_redraw(); return; } - if !self.guest_covered(self.pointer) { - let input = self.namespace.read().ok().and_then(|namespace| { + let canvas = self + .namespace + .read() + .is_ok_and(|n| n.document.frame.surface.mode == crate::page::Mode::Canvas); + 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(); + return; + } + let point = self.guest_point(); + let input = point.and_then(|point| { + self.namespace.read().ok().and_then(|namespace| { let frame = &namespace.document.frame; - let offset = crate::tabs::height(&frame.tabs); - if frame.session == 0 - || frame.fault.is_some() - || self.pointer.y < offset - || namespace.viewport.is_some_and(|s| { - self.pointer.x < 0.0 - || self.pointer.x >= s.width - || self.pointer.y >= s.height - }) - { + if frame.session == 0 || frame.fault.is_some() { return None; } - let point = - Point::new(self.pointer.x - crate::tabs::RAIL, self.pointer.y - offset); - let delta = [-dx, -dy]; + let delta = [-dx / frame.surface.zoom, -dy / frame.surface.zoom]; let mount = crate::scroll::wheel_target(frame, point, delta)?; Some(crate::events::Input { window: self.resource, @@ -1546,10 +1182,22 @@ impl ApplicationHandler for TesseraApp { screen_position: None, delta: Some(delta), }) + }) + }); + if let Some(input) = input { + self.send_guest(input); + } else { + let horizontal = self.namespace.read().is_ok_and(|n| { + n.document.frame.surface.mode == crate::page::Mode::Horizontal }); - if let Some(input) = input { - self.send_guest(input); - } + self.page_view( + "page-pan", + if horizontal && dx.abs() < 0.1 { + [-dy, 0.0] + } else { + [-dx, -dy] + }, + ); } self.developer_tools .wheel(&self.layout.developer_tools, self.pointer, dx, dy); @@ -1570,6 +1218,9 @@ impl ApplicationHandler for TesseraApp { self.guest_pressed = None; self.guest_key = None; self.developer_tools.blur(); + self.sidebar.interaction.blur(); + self.panning = false; + self.space_down = false; self.modifiers = ModifiersState::default(); window.request_redraw(); } diff --git a/src/shell/mod.rs b/src/shell/mod.rs index d32d309..92ac019 100644 --- a/src/shell/mod.rs +++ b/src/shell/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod clipboard; pub(crate) mod developer_tools; #[cfg(target_os = "macos")] pub(crate) mod native_chrome; +pub(crate) mod sidebar; pub(crate) mod sources; pub(crate) mod system_ui; pub(crate) mod tabs; diff --git a/src/shell/native_chrome.rs b/src/shell/native_chrome.rs index e8e7dc5..4b55f19 100644 --- a/src/shell/native_chrome.rs +++ b/src/shell/native_chrome.rs @@ -68,7 +68,7 @@ pub fn align_controls(window: &Window) { return; } let bounds = view.bounds(); - let center = f64::from(crate::tabs::HEIGHT) / 2.0; + let center = f64::from(crate::sidebar::HEADER) / 2.0; let content_y = if view.isFlipped() { bounds.origin.y + center } else { diff --git a/src/shell/sidebar.rs b/src/shell/sidebar.rs new file mode 100644 index 0000000..0dbd193 --- /dev/null +++ b/src/shell/sidebar.rs @@ -0,0 +1,162 @@ +//! The only persistent host toolbar. A collapsed sidebar owns just its toggle +//! and the native traffic-light safe area; the page owns the rest of the window. +use crate::{ + display_list::{DisplayList, DrawCommand}, + geometry::{Point, Rect}, + system_ui::{ + Button, Interaction, Target, Ui, + theme::{self, Theme}, + }, + tabs::Tab, +}; + +pub const WIDTH: f32 = 256.0; +pub const HEADER: f32 = 40.0; +pub const CONTROLS: f32 = if cfg!(target_os = "macos") { 86.0 } else { 0.0 }; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Action { + Toggle, + NewPage, + Tool(&'static str), + Activate(u64), + Close(u64), +} + +pub struct State { + pub open: bool, + pub interaction: Interaction, + pub targets: Vec>, + pub scroll: f32, +} + +impl Default for State { + fn default() -> Self { + Self { + open: true, + interaction: Interaction::default(), + targets: Vec::new(), + scroll: 0.0, + } + } +} + +impl State { + pub fn left(&self) -> f32 { + if self.open { WIDTH } else { 0.0 } + } + + pub fn covers(&self, point: Point) -> bool { + if self.open { + point.x >= 0.0 && point.x < WIDTH + } else { + Rect::new(0.0, 0.0, CONTROLS + 44.0, HEADER).contains(point) + } + } + + pub fn draw( + &mut self, + tabs: &[Tab], + height: f32, + accent: crate::display_list::Color, + ) -> DisplayList { + let mut ui = Ui::new(Theme::new(accent), self.interaction.clone()); + if self.open { + ui.fill(Rect::new(0.0, 0.0, WIDTH, height), theme::BAR); + } + let toggle_x = if self.open { + WIDTH - 40.0 + } else { + CONTROLS + 4.0 + }; + // Draw a sidebar icon with primitives so its shape does not depend on fonts. + ui.button( + Rect::new(toggle_x, 6.0, 30.0, 28.0), + Action::Toggle, + Button::new(""), + ); + ui.list.push(DrawCommand::StrokeRoundedRect { + rect: Rect::new(toggle_x + 7.0, 13.0, 16.0, 13.0), + radius: 2.0, + width: 1.0, + color: theme::MUTED, + }); + ui.fill(Rect::new(toggle_x + 12.0, 14.0, 1.0, 11.0), theme::MUTED); + if self.open { + ui.label(20.0, 61.0, "ENDORPHIN", theme::TEXT, false); + ui.button( + Rect::new(12.0, 95.0, WIDTH - 24.0, 34.0), + Action::NewPage, + Button::new("+ 新建页面"), + ); + for (index, (name, label)) in [ + ("Console", "Console"), + ("Sources", "Sources"), + ("Network", "Network"), + ("Settings", "Settings"), + ] + .into_iter() + .enumerate() + { + let selected = tabs + .iter() + .any(|tab| tab.active && tab.developer_tool.as_deref() == Some(name)); + ui.button( + Rect::new(12.0, 147.0 + index as f32 * 34.0, WIDTH - 24.0, 30.0), + Action::Tool(name), + Button::new(label).selected(selected), + ); + } + ui.label(20.0, 303.0, "页面", theme::MUTED, false); + let area = Rect::new(8.0, 333.0, WIDTH - 16.0, (height - 383.0).max(0.0)); + let pages: Vec<_> = tabs + .iter() + .filter(|tab| tab.developer_tool.is_none()) + .collect(); + self.scroll = self + .scroll + .clamp(0.0, (pages.len() as f32 * 36.0 - area.height).max(0.0)); + ui.list.push(DrawCommand::PushClip(area)); + for (index, tab) in pages.into_iter().enumerate() { + let y = area.y + index as f32 * 36.0 - self.scroll; + // Register only fully visible rows: paint clipping must match input. + if y < area.y || y + 32.0 > area.y + area.height { + continue; + } + ui.button( + Rect::new(12.0, y, WIDTH - 56.0, 32.0), + Action::Activate(tab.id), + Button::new(&tab.title).selected(tab.active), + ); + ui.button( + Rect::new(WIDTH - 40.0, y, 28.0, 32.0), + Action::Close(tab.id), + Button::new("×"), + ); + } + ui.list.push(DrawCommand::PopClip); + ui.label(20.0, height - 32.0, "本地工作区", theme::MUTED, false); + } + self.targets = ui.targets; + self.interaction.reconcile(&self.targets); + ui.list + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn collapsing_removes_every_host_target_except_the_toggle() { + let mut sidebar = State::default(); + sidebar.draw(&[], 800.0, theme::PALETTE[0]); + assert!(sidebar.targets.len() > 1); + sidebar.open = false; + sidebar.draw(&[], 800.0, theme::PALETTE[0]); + assert_eq!(sidebar.targets.len(), 1); + assert_eq!(sidebar.targets[0].action, Some(Action::Toggle)); + assert_eq!(sidebar.left(), 0.0); + assert!(!sidebar.covers(Point::new(400.0, 10.0))); + assert!(!sidebar.covers(Point::new(10.0, 80.0))); + } +} diff --git a/src/shell/tabs.rs b/src/shell/tabs.rs index eee96fc..03a598f 100644 --- a/src/shell/tabs.rs +++ b/src/shell/tabs.rs @@ -1,30 +1,7 @@ -//! Immutable tab chrome. IDs are session-local projections, never heap handles. -use crate::{ - display_list::{Color, DisplayList, DrawCommand}, - geometry::{Point, Rect}, - text::TextStyle, -}; +//! Immutable page registry projections. Host navigation is rendered in sidebar. +//! IDs are session-local data and never own managed VM objects. use serde::{Deserialize, Serialize}; -mod hover; -mod motion; -pub use motion::{Motion, Visual}; - -// Native traffic lights and all tab controls share this row's center line. -pub const HEIGHT: f32 = 40.0; -pub const INSET: f32 = 8.0; -const NEW_TAB_SPACE: f32 = 44.0; -const MIN_WIDTH: f32 = 88.0; -pub const RAIL: f32 = 56.0; -pub const CONTROLS: f32 = if cfg!(target_os = "macos") { 86.0 } else { 0.0 }; -pub const LEADING: f32 = CONTROLS + 12.0; -#[derive(Clone, Default)] -pub struct Chrome { - pub offset: Option, - pub insertion: Option, - pub pointer: Option, - pub visual: Visual, -} #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Tab { pub id: u64, @@ -33,647 +10,3 @@ pub struct Tab { #[serde(default, skip_serializing_if = "Option::is_none")] pub developer_tool: Option, } - -pub fn height(tabs: &[Tab]) -> f32 { - let _ = tabs; - HEIGHT -} - -/// Scroll offsets are logical pixels, never a discrete page index. -fn viewport(width: f32) -> Rect { - Rect::new( - LEADING, - 0.0, - (width - NEW_TAB_SPACE - LEADING).max(0.0), - HEIGHT, - ) -} -fn tab_width(tabs: &[Tab], width: f32) -> f32 { - (viewport(width).width / tabs.len().max(1) as f32) - .clamp(MIN_WIDTH, 208.0) - .min(viewport(width).width) -} -fn scroll_offset(tabs: &[Tab], width: f32, start: Option) -> f32 { - let tab_width = tab_width(tabs, width); - let available = viewport(width).width; - let maximum = (tabs.len() as f32 * tab_width - available).max(0.0); - let active = tabs.iter().position(|tab| tab.active).unwrap_or(0); - start - .unwrap_or(((active + 1) as f32 * tab_width - available).max(0.0)) - .clamp(0.0, maximum) -} -#[cfg(test)] -fn visible_range(tabs: &[Tab], width: f32, start: Option) -> std::ops::Range { - let tab_width = tab_width(tabs, width); - if tab_width <= 0.0 { - return 0..0; - } - let offset = scroll_offset(tabs, width, start); - (offset / tab_width).floor() as usize - ..(((offset + viewport(width).width) / tab_width).ceil() as usize).min(tabs.len()) -} - -/// Invisible drag edge zones only; ordinary clicks never scroll or activate another tab. -pub fn edge_direction(tabs: &[Tab], width: f32, start: Option, point: Point) -> i8 { - if !viewport(width).contains(point) || point.y >= HEIGHT { - return 0; - } - let offset = scroll_offset(tabs, width, start); - let maximum = (tabs.len() as f32 * tab_width(tabs, width) - viewport(width).width).max(0.0); - if point.x < LEADING + 24.0 && offset > 0.0 { - -1 - } else if point.x > width - NEW_TAB_SPACE - 24.0 && offset < maximum { - 1 - } else { - 0 - } -} -pub fn wheel_offset(tabs: &[Tab], width: f32, start: Option, delta: f32) -> f32 { - scroll_offset(tabs, width, Some(scroll_offset(tabs, width, start) + delta)) -} -pub fn scroll_start(tabs: &[Tab], width: f32, start: Option, direction: i8) -> Option { - let current = scroll_offset(tabs, width, start); - let next = wheel_offset(tabs, width, Some(current), direction as f32 * MIN_WIDTH); - (next != current).then_some(next) -} -struct Item<'a> { - tab: &'a Tab, - rect: Rect, -} -/// Retain all IDs, including clipped tabs, for continuous movement through the viewport. -fn tab_rects(start: Option, tabs: &[Tab], width: f32) -> Vec<(u64, Rect)> { - let tab_width = tab_width(tabs, width); - let offset = scroll_offset(tabs, width, start); - tabs.iter() - .enumerate() - .map(|(index, tab)| { - ( - tab.id, - Rect::new( - LEADING + index as f32 * tab_width - offset, - 0.0, - tab_width, - HEIGHT, - ), - ) - }) - .collect() -} -fn items(start: Option, tabs: &[Tab], width: f32) -> Vec> { - let viewport = viewport(width); - tabs.iter() - .zip(tab_rects(start, tabs, width)) - .filter(|(_, (_, rect))| { - rect.x + rect.width > viewport.x && rect.x < viewport.x + viewport.width - }) - .map(|(tab, (_, rect))| Item { tab, rect }) - .collect() -} -fn pill_rect(rect: Rect) -> Rect { - Rect::new(rect.x + 2.0, 4.0, (rect.width - 4.0).max(0.0), HEIGHT - 8.0) -} -fn close_rect(rect: Rect) -> Rect { - Rect::new( - rect.x + rect.width - 25.0, - (HEIGHT - 18.0) / 2.0, - 18.0, - 18.0, - ) -} - -pub fn hit(start: Option, tabs: &[Tab], width: f32, point: Point) -> Option<(u64, bool)> { - if point.x < LEADING - || point.x >= width - NEW_TAB_SPACE - || point.y < 0.0 - || point.y >= height(tabs) - { - return None; - } - items(start, tabs, width) - .into_iter() - .find(|item| pill_rect(item.rect).contains(point)) - .map(|item| (item.tab.id, close_rect(item.rect).contains(point))) -} - -/// Gap index in the complete published order, including overflow-hidden tabs. -pub fn insertion_index( - start: Option, - tabs: &[Tab], - width: f32, - point: Point, -) -> Option { - if point.x < LEADING || point.x >= width - NEW_TAB_SPACE || point.y < 0.0 || point.y >= HEIGHT { - return None; - } - if tabs.is_empty() { - return Some(0); - } - let visible: Vec<_> = items(start, tabs, width).into_iter().collect(); - for item in &visible { - if point.x < item.rect.x + item.rect.width / 2.0 { - return tabs.iter().position(|tab| tab.id == item.tab.id); - } - } - visible - .last() - .and_then(|last| tabs.iter().position(|tab| tab.id == last.tab.id)) - .map(|index| index + 1) -} - -pub fn drag_anchor( - start: Option, - tabs: &[Tab], - width: f32, - id: u64, - point: Point, -) -> Option { - if !viewport(width).contains(point) { - return None; - } - let item = items(start, tabs, width) - .into_iter() - .find(|item| item.tab.id == id && pill_rect(item.rect).contains(point))?; - (!close_rect(item.rect).contains(point)).then(|| Point::new(point.x - item.rect.x, point.y)) -} - -pub fn draw_insertion( - start: Option, - list: &mut DisplayList, - tabs: &[Tab], - width: f32, - index: usize, - scale: f32, -) { - let visible: Vec<_> = items(start, tabs, width).into_iter().collect(); - let x = if tabs.is_empty() { - Some(LEADING + 8.0) - } else { - visible - .iter() - .find(|item| tabs.get(index).is_some_and(|tab| tab.id == item.tab.id)) - .map(|item| item.rect.x) - .or_else(|| { - visible - .last() - .filter(|item| { - tabs.iter().position(|tab| tab.id == item.tab.id) == index.checked_sub(1) - }) - .map(|item| item.rect.x + item.rect.width) - }) - }; - if let Some(x) = x { - list.push(DrawCommand::FillRoundedRect { - rect: Rect::new( - x.clamp(1.5, (width - 1.5).max(1.5)) * scale - 1.5 * scale, - 3.0 * scale, - 3.0 * scale, - (HEIGHT - 6.0) * scale, - ), - radius: scale, - color: Color::rgb(56, 95, 220), - }); - } -} - -pub fn plus_rect(start: Option, tabs: &[Tab], width: f32) -> Rect { - if tabs.len() as f32 * tab_width(tabs, width) > viewport(width).width { - return Rect::new(width - 34.0, (HEIGHT - 28.0) / 2.0, 24.0, 28.0); - } - let end = items(start, tabs, width) - .iter() - .map(|item| item.rect.x + item.rect.width) - .fold(LEADING, f32::max); - Rect::new( - (end + 4.0).min(width - 34.0), - (HEIGHT - 28.0) / 2.0, - 24.0, - 28.0, - ) -} - -fn tab_style() -> TextStyle { - TextStyle { - font_size: 13.0, - line_height: 18.0, - ..TextStyle::ui(1) - } -} - -pub fn draw( - _start: Option, - list: &mut DisplayList, - tabs: &[Tab], - width: f32, - scale: f32, - _pointer: Option, - visual: &Visual, -) { - let scaled = |rect: Rect| { - Rect::new( - rect.x * scale, - rect.y * scale, - rect.width * scale, - rect.height * scale, - ) - }; - list.push(DrawCommand::FillRoundedRect { - rect: scaled(Rect::new(0.0, 0.0, width, HEIGHT)), - radius: 0.0, - color: crate::system_ui::theme::BAR, - }); - let plus = visual.plus; - if visual.plus_hover > 0.0 { - list.push(DrawCommand::FillRoundedRect { - rect: scaled(plus), - radius: 6.0 * scale, - color: crate::system_ui::theme::mix( - crate::system_ui::theme::BAR, - crate::system_ui::theme::TEXT, - 0.06 * visual.plus_hover, - ), - }); - } - list.push(DrawCommand::Text { - origin: Point::new( - (plus.x + (plus.width - crate::text::measure("+", tab_style()).width) / 2.0) * scale, - ((HEIGHT - tab_style().line_height) / 2.0) * scale, - ), - value: "+".into(), - style: tab_style().scaled(scale), - color: crate::system_ui::theme::MUTED, - }); - let mut paint_items: Vec<_> = tabs - .iter() - .map(|tab| Item { - tab, - rect: visual.rect(tab.id, Rect::default()), - }) - .collect(); - paint_items.extend( - visual - .exiting - .iter() - .map(|(tab, rect)| Item { tab, rect: *rect }), - ); - for item in paint_items { - { - if item.rect.width <= 0.0 - || item.rect.x + item.rect.width <= visual.viewport.x - || item.rect.x >= visual.viewport.x + visual.viewport.width - { - continue; - } - list.push(DrawCommand::PushClip(scaled(visual.viewport))); - list.push(DrawCommand::PushClip(scaled(pill_rect(item.rect)))); - } - let pill = scaled(pill_rect(item.rect)); - let active = item.tab.active; - let hover = visual.hover(item.tab.id); - let background = if active { - crate::system_ui::theme::BG - } else { - crate::system_ui::theme::mix( - crate::system_ui::theme::BAR, - crate::system_ui::theme::TEXT, - 0.06 * hover, - ) - }; - if active || hover > 0.0 { - list.push(DrawCommand::FillRoundedRect { - rect: pill, - radius: 6.0 * scale, - color: background, - }); - } - { - // Match the close button with an 18px slot and 7px edge inset. - let icon = "◇"; - list.push(DrawCommand::Text { - origin: Point::new( - (item.rect.x - + 7.0 - + (18.0 - crate::text::measure(icon, tab_style()).width) / 2.0) - * scale, - ((HEIGHT - tab_style().line_height) / 2.0) * scale, - ), - value: icon.into(), - style: tab_style().scaled(scale), - color: crate::system_ui::theme::MUTED, - }); - } - let title = &item.tab.title; - let text_rect = Rect::new( - item.rect.x + 30.0, - (HEIGHT - tab_style().line_height) / 2.0, - (item.rect.width - 58.0).max(0.0), - tab_style().line_height, - ); - list.push(DrawCommand::PushClip(scaled(text_rect))); - list.push(DrawCommand::Text { - origin: Point::new( - (item.rect.x + 30.0) * scale, - ((HEIGHT - tab_style().line_height) / 2.0) * scale, - ), - value: title.into(), - style: tab_style().scaled(scale), - color: if active { - crate::system_ui::theme::TEXT - } else { - crate::system_ui::theme::MUTED - }, - }); - list.push(DrawCommand::PopClip); - if item.rect.width >= 44.0 { - let close_hover = visual.close_hover(item.tab.id); - if close_hover > 0.0 { - list.push(DrawCommand::FillRoundedRect { - rect: scaled(close_rect(item.rect)), - radius: 5.0 * scale, - color: crate::system_ui::theme::mix( - background, - crate::system_ui::theme::mix( - crate::system_ui::theme::BAR, - crate::system_ui::theme::TEXT, - 0.12, - ), - close_hover, - ), - }); - } - list.push(DrawCommand::Text { - origin: Point::new( - (close_rect(item.rect).x - + (close_rect(item.rect).width - - crate::text::measure("×", tab_style()).width) - / 2.0) - * scale, - ((HEIGHT - tab_style().line_height) / 2.0) * scale, - ), - value: "×".into(), - style: tab_style().scaled(scale), - color: crate::system_ui::theme::MUTED, - }); - } - { - list.push(DrawCommand::PopClip); - list.push(DrawCommand::PopClip); - } - } - // Quiet boundaries only between two idle tabs; the active/hovered pill owns its edges. - list.push(DrawCommand::PushClip(scaled(visual.viewport))); - for pair in tabs.windows(2) { - let left = visual.rect(pair[0].id, Rect::default()); - let hover = visual.hover(pair[0].id).max(visual.hover(pair[1].id)); - if !pair[0].active && !pair[1].active && hover < 1.0 { - list.push(DrawCommand::FillRoundedRect { - rect: scaled(Rect::new( - left.x + left.width - 0.5, - (HEIGHT - 12.0) / 2.0, - 1.0, - 12.0, - )), - radius: 0.5 * scale, - color: crate::system_ui::theme::mix( - crate::system_ui::theme::BAR, - crate::system_ui::theme::TEXT, - 0.18 * (1.0 - hover), - ), - }); - } - } - list.push(DrawCommand::PopClip); -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn insertion_gaps_share_visible_tab_geometry_and_exclude_close_drags() { - let tabs: Vec<_> = (1..=4) - .map(|id| Tab { - id, - title: id.to_string(), - developer_tool: None, - active: id == 1, - }) - .collect(); - assert_eq!( - insertion_index( - None, - &tabs, - LEADING + NEW_TAB_SPACE + 1000.0, - Point::new(LEADING + 10.0, 18.0) - ), - Some(0) - ); - assert_eq!( - insertion_index( - None, - &tabs, - LEADING + NEW_TAB_SPACE + 1000.0, - Point::new(LEADING + 215.0, 18.0) - ), - Some(1) - ); - assert_eq!( - insertion_index( - None, - &tabs, - LEADING + NEW_TAB_SPACE + 1000.0, - Point::new(LEADING + 990.0, 18.0) - ), - Some(4) - ); - assert!( - drag_anchor( - None, - &tabs, - LEADING + NEW_TAB_SPACE + 1000.0, - 1, - Point::new(LEADING + 190.0, 18.0) - ) - .is_none() - ); - assert!( - drag_anchor( - None, - &tabs, - LEADING + NEW_TAB_SPACE + 1000.0, - 1, - Point::new(LEADING + 25.0, 18.0) - ) - .is_some() - ); - assert_eq!( - insertion_index( - None, - &tabs, - LEADING + NEW_TAB_SPACE + 1000.0, - Point::new(LEADING + 20.0, HEIGHT) - ), - None - ); - assert!( - drag_anchor( - None, - &tabs, - LEADING + NEW_TAB_SPACE + 400.0, - 3, - Point::new(LEADING + 390.0, 18.0) - ) - .is_none() - ); - assert_eq!( - insertion_index( - None, - &[], - LEADING + NEW_TAB_SPACE + 720.0, - Point::new(LEADING + 20.0, 18.0) - ), - Some(0) - ); - } - - #[test] - fn overflow_keeps_active_page_visible_without_navigation_buttons() { - let tabs: Vec<_> = (1..=64) - .map(|id| Tab { - id, - title: format!("Page {id}"), - developer_tool: None, - active: id == 32, - }) - .collect(); - let visible = items(None, &tabs, LEADING + NEW_TAB_SPACE + 720.0); - assert!(visible.iter().any(|item| item.tab.active)); - for item in &visible { - let point = Point::new((item.rect.x + 12.0).max(LEADING + 3.0), 18.0); - assert_eq!( - hit(None, &tabs, LEADING + NEW_TAB_SPACE + 720.0, point), - Some((item.tab.id, close_rect(item.rect).contains(point))) - ); - } - let tab = visible.iter().find(|item| item.tab.active).unwrap(); - assert_eq!( - hit( - None, - &tabs, - LEADING + NEW_TAB_SPACE + 720.0, - Point::new(tab.rect.x + tab.rect.width - 15.0, 18.0) - ), - Some((32, true)) - ); - assert_eq!( - hit( - None, - &tabs, - LEADING + NEW_TAB_SPACE + 720.0, - Point::new(LEADING + 50.0, HEIGHT) - ), - None - ); - } - - #[test] - fn drag_viewport_reaches_hidden_ends_without_changing_active_page() { - let tabs: Vec<_> = (1..=64) - .map(|id| Tab { - id, - title: id.to_string(), - developer_tool: None, - active: id == 32, - }) - .collect(); - let width = LEADING + NEW_TAB_SPACE + 720.0; - let mut start = None; - while let Some(next) = scroll_start(&tabs, width, start, 1) { - start = Some(next); - } - assert_eq!(visible_range(&tabs, width, start), 55..64); - assert_eq!( - edge_direction(&tabs, width, start, Point::new(LEADING + 710.0, 18.0)), - 0 - ); - assert_eq!( - insertion_index(start, &tabs, width, Point::new(LEADING + 710.0, 18.0)), - Some(64) - ); - while let Some(next) = scroll_start(&tabs, width, start, -1) { - start = Some(next); - } - assert_eq!(visible_range(&tabs, width, start), 0..9); - assert_eq!( - edge_direction(&tabs, width, start, Point::new(LEADING + 10.0, 18.0)), - 0 - ); - assert_eq!( - insertion_index(start, &tabs, width, Point::new(LEADING + 10.0, 18.0)), - Some(0) - ); - assert_eq!( - tabs.iter() - .filter(|tab| tab.active) - .map(|tab| tab.id) - .collect::>(), - vec![32] - ); - assert!(items(None, &tabs, width).iter().any(|item| item.tab.active)); - } - - #[test] - fn scrolled_hit_and_gap_geometry_survive_resize_and_list_shrink() { - let tabs: Vec<_> = (1..=16) - .map(|id| Tab { - id, - title: id.to_string(), - developer_tool: None, - active: id == 1, - }) - .collect(); - let start = Some(4.0 * MIN_WIDTH); - assert_eq!( - hit( - start, - &tabs, - LEADING + NEW_TAB_SPACE + 720.0, - Point::new(LEADING + 40.0, 18.0) - ), - Some((5, false)) - ); - assert_eq!( - insertion_index( - start, - &tabs, - LEADING + NEW_TAB_SPACE + 720.0, - Point::new(LEADING + 40.0, 18.0) - ), - Some(4) - ); - assert_eq!( - visible_range(&tabs[..3], LEADING + NEW_TAB_SPACE + 720.0, start), - 0..3 - ); - assert_eq!( - visible_range(&tabs, LEADING + NEW_TAB_SPACE + 1920.0, start), - 0..16 - ); - assert_eq!( - edge_direction( - &tabs, - LEADING + NEW_TAB_SPACE + 720.0, - start, - Point::new(LEADING + 710.0, HEIGHT) - ), - 0 - ); - assert_eq!( - edge_direction( - &tabs, - LEADING + NEW_TAB_SPACE + 720.0, - start, - Point::new(LEADING + 725.0, 18.0) - ), - 0 - ); - } -} diff --git a/src/shell/tabs/hover.rs b/src/shell/tabs/hover.rs deleted file mode 100644 index 574dca1..0000000 --- a/src/shell/tabs/hover.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! Hover color transitions are independent of tab layout and selected state. -use super::{Point, Tab, Visual}; -use std::{collections::BTreeMap, time::Instant}; - -#[derive(Default)] -struct Fade { - from: f32, - target: f32, - started: Option, -} - -impl Fade { - fn sample(&mut self, hovered: bool, now: Instant) -> (f32, bool) { - let t = self.started.map_or(1.0, |start| { - (now.duration_since(start).as_secs_f32() / 0.16).min(1.0) - }); - let value = self.from + (self.target - self.from) * (1.0 - (1.0 - t).powi(3)); - let target = if hovered { 1.0 } else { 0.0 }; - if target != self.target { - // Reversing mid-fade starts at the currently visible color. - self.from = value; - self.target = target; - self.started = Some(now); - (value, true) - } else { - (value, t < 1.0) - } - } -} - -#[derive(Default)] -pub(super) struct Hover { - tabs: BTreeMap, - plus: Fade, -} - -impl Hover { - pub fn sample( - &mut self, - visual: &mut Visual, - tabs: &[Tab], - pointer: Option, - now: Instant, - ) -> bool { - let hit = pointer.and_then(|point| visual.hit(point)); - let (plus, mut animating) = self.plus.sample( - pointer.is_some_and(|point| visual.plus.contains(point)), - now, - ); - visual.plus_hover = plus; - self.tabs - .retain(|id, _| tabs.iter().any(|tab| tab.id == *id)); - visual.hovers.clear(); - for tab in tabs { - let (body, close) = self.tabs.entry(tab.id).or_default(); - let (body, body_animating) = body.sample(hit.is_some_and(|hit| hit.0 == tab.id), now); - let (close, close_animating) = close.sample(hit == Some((tab.id, true)), now); - visual.hovers.push((tab.id, body, close)); - animating |= body_animating || close_animating; - } - animating - } -} diff --git a/src/shell/tabs/motion.rs b/src/shell/tabs/motion.rs deleted file mode 100644 index aeb24c0..0000000 --- a/src/shell/tabs/motion.rs +++ /dev/null @@ -1,207 +0,0 @@ -//! Interpolated chrome geometry is shared by painting and pointer hit testing. -use super::*; -use std::time::{Duration, Instant}; - -#[derive(Clone, Default)] -pub struct Visual { - pub rects: Vec<(u64, Rect)>, - pub exiting: Vec<(Tab, Rect)>, - pub viewport: Rect, - pub plus: Rect, - pub hovers: Vec<(u64, f32, f32)>, - pub plus_hover: f32, -} -impl Visual { - pub fn hover(&self, id: u64) -> f32 { - self.hovers - .iter() - .find(|entry| entry.0 == id) - .map_or(0.0, |entry| entry.1) - } - pub fn close_hover(&self, id: u64) -> f32 { - self.hovers - .iter() - .find(|entry| entry.0 == id) - .map_or(0.0, |entry| entry.2) - } - pub fn rect(&self, id: u64, fallback: Rect) -> Rect { - self.rects - .iter() - .find(|(key, _)| *key == id) - .map_or(fallback, |(_, rect)| *rect) - } - pub fn hit(&self, point: Point) -> Option<(u64, bool)> { - if !self.viewport.contains(point) { - return None; - } - self.rects - .iter() - .rev() - .find(|(_, rect)| pill_rect(*rect).contains(point)) - .map(|(id, rect)| (*id, close_rect(*rect).contains(point))) - } -} -#[derive(Default)] -pub struct Motion { - hover: super::hover::Hover, - target: Visual, - from: Visual, - current: Visual, - published: Vec, - initialized: bool, - direct_scroll: bool, - started: Option, - pub offset: Option, - pub next_frame: Option, -} -fn blend(a: Rect, b: Rect, t: f32) -> Rect { - Rect::new( - a.x + (b.x - a.x) * t, - a.y + (b.y - a.y) * t, - a.width + (b.width - a.width) * t, - a.height + (b.height - a.height) * t, - ) -} -impl Motion { - pub fn sample_hover(&mut self, visual: &mut Visual, tabs: &[Tab], pointer: Option) { - let now = Instant::now(); - if self.hover.sample(visual, tabs, pointer, now) { - let next = now + Duration::from_millis(16); - self.next_frame = Some(self.next_frame.map_or(next, |old| old.min(next))); - } - } - pub fn scroll_to(&mut self, offset: f32) { - self.offset = Some(offset); - self.direct_scroll = true; - } - - fn advance(&mut self, now: Instant) { - let t = self.started.map_or(1.0, |start| { - (now.duration_since(start).as_secs_f32() / 0.22).min(1.0) - }); - let eased = 1.0 - (1.0 - t).powi(3); - self.current = Visual { - rects: self - .target - .rects - .iter() - .map(|(id, target)| (*id, blend(self.from.rect(*id, *target), *target, eased))) - .collect(), - exiting: self - .target - .exiting - .iter() - .filter(|_| t < 1.0) - .map(|(tab, target)| { - let from = self - .from - .exiting - .iter() - .find(|(old, _)| old.id == tab.id) - .map_or(*target, |(_, rect)| *rect); - (tab.clone(), blend(from, *target, eased)) - }) - .collect(), - viewport: blend(self.from.viewport, self.target.viewport, eased), - plus: blend(self.from.plus, self.target.plus, eased), - ..Visual::default() - }; - self.next_frame = (t < 1.0).then(|| now + Duration::from_millis(16)); - } - - pub fn sample(&mut self, tabs: &[Tab], offset: Option, width: f32) -> Visual { - let now = Instant::now(); - // Retarget from the geometry at this instant, even during rapid repeated clicks. - self.advance(now); - let mut offset = scroll_offset(tabs, width, offset.or(self.offset)); - let selection_changed = self - .published - .iter() - .find(|tab| tab.active) - .map(|tab| tab.id) - != tabs.iter().find(|tab| tab.active).map(|tab| tab.id); - if selection_changed { - let active = tabs.iter().position(|tab| tab.active).unwrap_or(0) as f32; - let tab_width = tab_width(tabs, width); - let left = active * tab_width; - let right = left + tab_width; - offset = offset.min(left).max(right - viewport(width).width).max(0.0); - } - self.offset = Some(offset); - let mut target = Visual { - rects: tab_rects(Some(offset), tabs, width), - viewport: viewport(width), - plus: plus_rect(Some(offset), tabs, width), - ..Visual::default() - }; - if !self.initialized || self.direct_scroll { - self.direct_scroll = false; - self.started = None; - self.next_frame = None; - // Startup is already laid out; it is not an insertion of every restored tab. - self.initialized = true; - self.current = target.clone(); - self.from = target.clone(); - self.target = target; - self.published = tabs.to_vec(); - return self.current.clone(); - } - if self.target.rects != target.rects - || self.target.plus != target.plus - || self.target.viewport != target.viewport - { - self.from = self.current.clone(); - for (index, (id, rect)) in target.rects.iter().enumerate() { - if !self.from.rects.iter().any(|(old, _)| old == id) { - // Grow out of the previous trailing edge, not the final compressed x. - let x = target.rects[..index] - .iter() - .rev() - .find_map(|(previous, _)| { - self.from - .rects - .iter() - .find(|(old, _)| old == previous) - .map(|(_, previous)| previous.x + previous.width) - }) - .unwrap_or(rect.x); - self.from - .rects - .push((*id, Rect::new(x, rect.y, 0.0, rect.height))); - } - } - for tab in &self.published { - if !tabs.iter().any(|live| live.id == tab.id) { - self.from - .exiting - .push((tab.clone(), self.current.rect(tab.id, Rect::default()))); - } - } - // A closed tab shrinks with the gap; it never remains an interactive target. - target.exiting = self - .from - .exiting - .iter() - .filter(|(tab, _)| !tabs.iter().any(|live| live.id == tab.id)) - .map(|(tab, rect)| { - let next = self - .published - .iter() - .position(|old| old.id == tab.id) - .and_then(|index| { - self.published[index + 1..] - .iter() - .find(|old| tabs.iter().any(|live| live.id == old.id)) - }); - let x = next.map_or(target.plus.x - 4.0, |next| target.rect(next.id, *rect).x); - (tab.clone(), Rect::new(x, rect.y, 0.0, rect.height)) - }) - .collect(); - self.target = target; - self.started = Some(now); - } - self.published = tabs.to_vec(); - self.advance(now); - self.current.clone() - } -}