From db41a9b7694a660df5aa421d8d40cbe7ba94817b Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Thu, 10 Sep 2026 23:15:16 +0800 Subject: [PATCH] Batch ORBIT actions and add reusable scroll performance checks Coalesce action updates, retain native interaction behavior and validate complete mixed action cycles against fresh rendering. Extend the shared evaluator with committed-input parity, nested owner selection, cold prefix measurements and GC counters. Document supported invocations and test cleanup and failure reporting. --- AGENTS.md | 2 +- README.md | 53 ++- README.zh-CN.md | 59 +++ examples/layer-reference.ebox | 16 +- examples/layer-reference.el | 149 ++++---- .../ebox-playground-flex-resize-evaluator.el | 297 +++++++++++---- tests/ebox-playground-layer-tests.el | 90 +++++ tests/ebox-playground-tests.el | 348 +++++++++++++++++- 8 files changed, 860 insertions(+), 154 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1bd3d3b..56eb793 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Before simplifying examples, preserve the current files as a comparison baseline For before/after example comparisons, use `ebox-playground-compare-example-directories` from `scripts/ebox-playground-flex-resize-evaluator.el`; it accepts baseline/current example directories and optional viewport widths/file names. This is the shared entry point for future example simplification checks. See README for the batch invocation. -For scrolling performance across examples, use `ebox-playground-scroll-evaluator-run` from that same evaluator. Pass one `.ebox` path or a list, viewport width, sample count, and viewport height; it measures cached top/midpoint publication and verifies fresh-render parity while preserving existing GUI previews. Consult this entry point before adding another scroll probe, and extend it when the diagnostic coverage is missing. See README for the invocation and measurement boundaries. +For scrolling performance across examples, use `ebox-playground-scroll-evaluator-run` from that same evaluator. Pass one `.ebox` path or a list, viewport width, sample count, viewport height, and optional options (`:region-id` selects a semantic nested owner; `:cold t` measures forward steps without prewarming). It measures publication and verifies committed-input fresh-render parity while preserving existing GUI previews. Consult this entry point before adding another scroll probe, and extend it when the diagnostic coverage is missing. See README for the invocation and measurement boundaries. For repeated interaction behavior or command performance, use `ebox-playground-interaction-evaluator-run` from `scripts/ebox-playground-flex-resize-evaluator.el`. Pass the `.ebox` file and visible control label, with optional key, sample count, width, and height. It performs native key lookup without repositioning after updates, reports command-loop timings/GC/publications and layout/projection work counters, and verifies committed-snapshot render parity. Read fragment call counts with their input-character totals to distinguish local slices from full-surface work. For native click dispatch in an already visible buffer, use the same file's `ebox-playground-interaction-evaluator-click` with buffer, label, and optional in-label offset; it resolves an Emacs event and calls the interactive command, not an OS mouse click. Consult and extend these entry points before adding another interaction probe; see README for inputs, cleanup, the between-sample deadline, and measurement limits. diff --git a/README.md b/README.md index bfa5c62..046fff7 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,9 @@ application state: Visit the `.ebox` file and press `C-c C-c` for the source/preview split. Every control supports click, `RET`, and `SPC`, with native help and hover paint. All text uses one font size. +Toolbar hover paint belongs to each button, including its own padding; gaps +and unused row space stay unchanged. Deliberate block interaction areas, such +as the covered note and root menu choices, retain their own hover paint. - **EXPLODE STACK** spreads the three cards apart and returns them to their original positions without moving the following sections. @@ -73,7 +76,13 @@ All text uses one font size. `layer-reference.ebox` contains layout data and state references; `layer-reference.el` owns state and business actions. It uses only public -`ebox-keymap-create`, `ebox-help-create`, and `ebox-region-update` APIs. +`ebox-keymap-create`, `ebox-help-create`, `ebox-region-update`, and +`ebox-call-with-update-batch` APIs. Each action groups its region changes into +one batch, including the status update. The batch helper joins an existing +batch without flushing and otherwise publishes its pending changes once. +Its rollback covers queued Ebox updates, not arbitrary changes to the +companion's business state; see the [batch API +contract](../ebox/docs/user/ebox-api-reference.en.md#grouping-region-updates). Offsets use inline `ch` and block `lh` units. The text renderer composes whole rows, and positioned content must share its host's line height. @@ -92,6 +101,23 @@ For repeated command timing, use the existing interaction evaluator with `"examples/layer-reference.ebox"` and a toolbar label such as `"UPDATE COVERED NOTE"`; see the invocation below. +For the spatial desktop's expand/collapse path, run: + +```sh +emacs -Q --batch -L . -L ../ebox -L ../ecss -L ../tp \ + --eval '(setq load-prefer-newer t native-comp-jit-compilation nil)' \ + -l scripts/ebox-playground-flex-resize-evaluator.el \ + --eval '(let ((r (ebox-playground-interaction-evaluator-run "examples/layer-reference.ebox" "EXPLODE STACK" "RET" 6 104 100))) (cl-remf r :text) (pp r))' +``` + +With the root menu closed, the three desktop toolbar commands publish the +affected layer host and status text together, without a full candidate render. +The layer host still recomposes its stacking group; this is owner-level +incremental work, not a minimal pixel rectangle. Visible root portals and +content-dependent geometry keep the broader composition fallback. Regression +tests require one publication per command and reject full candidate rendering; +wall-clock timings are diagnostic, not a CI threshold. + ## Native interaction lab @@ -174,7 +200,8 @@ publication count, final plain text, and parity with an independent render of the committed snapshot. Work counters `:layout-calls`, `:surface-plans`, `:fragment-scans`, and `:node-registrations` count calls to layout rendering, surface planning, fragment extraction (including local slices), and node-tree -registration. `:fragment-characters` sums the characters passed to fragment +registration. Planning also runs for local patches; `:full-surface-renders` +separately counts full candidate renders. `:fragment-characters` sums the characters passed to fragment extraction. Read the call counts alongside this character total to distinguish local work from full-surface scanning; call counts are not affected-node counts. A parity mismatch fails. @@ -305,10 +332,13 @@ emacs -Q --batch -L . -L ../ebox -L ../ecss -L ../tp \ ``` The arguments are one file path or a list of paths, viewport width in pixels, -sample count, and viewport height in lines. Each file needs a scrollable root. +sample count, viewport height in lines, and an optional options plist. +By default, each file needs a scrollable root. Content is fully cached before alternating one-line motions at the top and -midpoint. Results report mean/p95/max milliseconds, full root renders, node style -visits, style computations, and fresh-render parity; incomplete motion or changed +midpoint. Results report mean/p95/max milliseconds, measurement-loop GC count/time, +full root renders, node style +visits, style computations, prefix rendering, full content materialization, and +fresh-render parity; incomplete motion or changed output fails the run. The independent render comparison ignores runtime identity and zero-pixel spacers beside identical font faces; height carriers are retained. This measures retained TP scroll publication, excluding @@ -318,6 +348,19 @@ Emacs. It uses undisplayed temporary buffers, preserves live previews and window and cleans its mounts, advice, and timers even on errors or interruption. This entry point supersedes temporary per-example scroll timing probes. +Pass `(:region-id "log")` to measure a nested scroll box declared with +`:id "log"`. Pass `(:cold t)` to measure consecutive forward steps from the +initial render without materializing or warming the content first; combine +both options for a nested cold run. Cold mode requires enough forward scroll +capacity for the requested samples and does not seek to the midpoint. The +initial render can already include lookahead rows; use enough steps to cross +that prefix and inspect `:prefix-renders` and `:materializations` when measuring +a cache miss. Completed-cache no-ops do not count as materialization. The +fresh oracle renders the committed input with its scroll offsets, preserving +callback and keymap identity. It does not reload companion application state. +These checks cover Ebox publication work; use an actual displayed window when +measuring input delivery and Emacs redisplay latency. + Size data uses `(unit number)` with `px`, `%`, `vw`, `vh`, `ch`, and `lh`, plus `calc`, `min`, `max`, and `clamp`. The [shared unit-constraint table](../ebox/docs/user/ebox-user-guide.en.md#size-unit-constraints) defines which diff --git a/README.zh-CN.md b/README.zh-CN.md index aedd664..6f57b3f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -24,6 +24,64 @@ ECSS 0.1.0 与 TP 1.0.1 是互相独立的包,安装顺序任意;两者都 运行 `make size-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs`,可检查核心尺寸契约、 示例的实际属性覆盖、已知计算结果、背景所指示的尺寸差异、完整渲染及保留状态的视口更新。`make check` 也包含此示例的回归测试。 +## 滚动性能验证 + +使用共享 `scripts/ebox-playground-flex-resize-evaluator.el` 的入口: + +```sh +emacs -Q --batch -L . -L ../ebox -L ../ecss -L ../tp \ + --eval '(setq load-prefer-newer t native-comp-jit-compilation nil)' \ + -l scripts/ebox-playground-flex-resize-evaluator.el \ + --eval '(ebox-playground-scroll-evaluator-run "examples/flex-reference.ebox" 104 6 36)' +``` + +参数依次为文件路径或路径列表、视口像素宽度、采样次数、视口行高及可选 options +plist。默认完全缓存根滚动内容,再测量顶部和中间位置的往返单行滚动。传入 +`(:region-id "log")` 可选择声明了 `:id "log"` 的嵌套滚动框;`(:cold t)` 则从 +初始渲染开始连续向前滚动,不预热、不主动物化全部内容,也不跳到中间位置。 +两者可以组合,所选区域必须有足够的滚动空间完成采样。 + +初始渲染可能已包含前瞻缓存行;要测量缓存未命中,采样次数应超过该前缀,并检查 +`:prefix-renders`(实际前缀渲染)和 `:materializations`(完整内容生产)计数。 +已完成缓存的空操作不计入完整物化次数。 + +结果报告 mean/p95/max 毫秒、采样循环的 GC 次数/耗时、完整根渲染、样式计算及缓存生产次数,并与独立渲染已提交输入、 +恢复其滚动位置后的结果比较。比较保留 callback 和 keymap 身份,不重新加载业务 +状态;移动未完成或绘制不一致都会失败。测试使用临时 buffer,并清理挂载、advice +和定时器,不改变现有预览。测量包含 Ebox/TP 发布,不包含 GUI 输入传递、原生窗口 +滚动或 Emacs redisplay。此入口取代临时的逐示例滚动探针。 + +## 图层与原生菜单示例 + +打开 `examples/layer-reference.ebox` 并按 `C-c C-c`,可查看 ORBIT 空间桌面、 +保留内容的重叠面板及锚定菜单;`examples/layer-minimal.ebox` 保留最小重叠示例。 +工具栏每个按钮支持点击、`RET` 和 `SPC`。悬停绘制仅覆盖按钮及其自身 padding, +按钮间隙和行内剩余空间保持不变;被覆盖笔记、根菜单选项等有意设置为块级交互的 +区域仍保留自己的悬停绘制。 + +`layer-reference.ebox` 保存布局,`layer-reference.el` 保存业务状态与操作。每个 +操作通过公共 `ebox-call-with-update-batch` 将 region 变化与状态栏更新合并。 +helper 加入已有 batch 时不 flush;自己拥有 batch 时只发布一次待处理变化。 +回滚覆盖排队的 Ebox 更新,不撤销 companion 中任意业务状态变化,详见 +[批量更新契约](../ebox/docs/user/ebox-api-reference.zh.md#合并-region-更新)。 + +运行 `make layer-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs`, +验证图层契约、实际原生命令及已提交快照的独立渲染一致性,无需打开 GUI 窗口。 + +使用共享入口测量桌面展开/收起路径: + +```sh +emacs -Q --batch -L . -L ../ebox -L ../ecss -L ../tp \ + --eval '(setq load-prefer-newer t native-comp-jit-compilation nil)' \ + -l scripts/ebox-playground-flex-resize-evaluator.el \ + --eval '(let ((r (ebox-playground-interaction-evaluator-run "examples/layer-reference.ebox" "EXPLODE STACK" "RET" 6 104 100))) (cl-remf r :text) (pp r))' +``` + +根菜单关闭时,桌面三个工具栏命令只将受影响的叠层宿主和状态文字一起发布,不再 +完整渲染候选树。宿主内部仍会重新合成堆叠组;这是 owner 级增量,还不是像素级 +最小脏矩形。可见根浮层和依赖内容的几何仍保留扩大合成范围的回退。回归测试要求 +每次命令只发布一次,并禁止完整候选渲染;耗时是诊断数据,不作为 CI 时间阈值。 + ## 原生交互实验室 @@ -94,6 +152,7 @@ emacs -Q --batch -L . -L ../ebox -L ../ecss -L ../tp \ 纯文本,以及与独立渲染已提交快照的结果是否一致。计算计数器 `:layout-calls`、 `:surface-plans`、`:fragment-scans` 和 `:node-registrations` 分别统计布局渲染、 surface 规划、fragment 提取(包含局部切片)和节点树注册的调用次数。 +局部补丁也会执行规划;`:full-surface-renders` 单独统计完整候选渲染次数。 `:fragment-characters` 累计传给 fragment 提取的字符数;结合调用次数与字符总量, 区分局部处理和全 surface 扫描。调用次数不是受影响节点数。快照对比不一致则检查失败。 diff --git a/examples/layer-reference.ebox b/examples/layer-reference.ebox index 1f09a07..59d5bab 100644 --- a/examples/layer-reference.ebox +++ b/examples/layer-reference.ebox @@ -14,15 +14,17 @@ (box :padding-top (lh 1) :color "#AEB6CE" "A desktop of intersecting ideas.\nPull it apart. Shift the focus. Change the atmosphere.")) (flex :flex-wrap wrap :gap ((lh 1) (ch 1)) :padding-block (lh 1) - :pointer hand :font-weight bold :color "#101117" - :hover-style (:background-color "#FFFFFF") + :font-weight bold :color "#101117" (box :padding-inline (ch 1) :background-color "#D7FA73" + :pointer hand :hover-style (:background-color "#FFFFFF") :help-echo "Separate the three cards, then bring them back into their original stack." :keymap ,(layer-demo-action demo 'explode) "EXPLODE STACK") (box :padding-inline (ch 1) :background-color "#A99BFF" + :pointer hand :hover-style (:background-color "#FFFFFF") :help-echo "Bring Studio, Next, and Signal to the front in turn." :keymap ,(layer-demo-action demo 'focus) "CYCLE FOCUS") (box :padding-inline (ch 1) :background-color "#B9E9F2" + :pointer hand :hover-style (:background-color "#FFFFFF") :help-echo "Switch between electric violet / ice / lime and a warmer studio palette." :keymap ,(layer-demo-action demo 'mood) "SWITCH MOOD")) @@ -142,17 +144,20 @@ (box "01 / LAYERED DESK absolute position + local stacking") (flex :flex-wrap wrap :gap ((lh 1) (ch 1)) :padding-block (lh 1) - :pointer hand :hover-style (:background-color "#505B7A") (box :padding-inline (ch 1) :background-color "#343B52" + :pointer hand :hover-style (:background-color "#505B7A") :help-echo "Hide or show Panel A. Revealing a lower layer shows its latest content." :keymap ,(layer-demo-action demo 'toggle) "TOGGLE PANEL A") (box :padding-inline (ch 1) :background-color "#343B52" + :pointer hand :hover-style (:background-color "#505B7A") :help-echo "Move Panel A between left 4ch / top 2lh and left 10ch / top 0lh." :keymap ,(layer-demo-action demo 'move) "MOVE PANEL A") (box :padding-inline (ch 1) :background-color "#343B52" + :pointer hand :hover-style (:background-color "#505B7A") :help-echo "Switch Panel A between z=2 and z=4; Panel B stays at z=3." :keymap ,(layer-demo-action demo 'raise) "RAISE / LOWER A") (box :padding-inline (ch 1) :background-color "#343B52" + :pointer hand :hover-style (:background-color "#505B7A") :help-echo "Update the lower note even when every part of it is covered." :keymap ,(layer-demo-action demo 'update-lower) "UPDATE COVERED NOTE")) (box :id "layer-desk" :height (lh 10) :background-color "#222637" @@ -201,12 +206,13 @@ (box :padding-block (lh 1) "The owner below clips to one row. Its menu stays a logical child\n" "but paints in the root layer, outside that local clip.") - (flex :gap ((lh 0) (ch 1)) :padding-bottom (lh 1) :pointer hand - :hover-style (:background-color "#505B7A") + (flex :gap ((lh 0) (ch 1)) :padding-bottom (lh 1) (box :padding-inline (ch 1) :background-color "#343B52" + :pointer hand :hover-style (:background-color "#505B7A") :help-echo "Cycle bottom-start, bottom-end, top-start, and top-end. Open the menu to compare." :keymap ,(layer-demo-action demo 'placement) "CYCLE PLACEMENT") (box :padding-inline (ch 1) :background-color "#343B52" + :pointer hand :hover-style (:background-color "#505B7A") :help-echo "Move the relative trigger from left 4ch to 12ch. The open menu follows its anchor." :keymap ,(layer-demo-action demo 'anchor) "MOVE ANCHOR")) (box :height (lh 3) :background-color "#1C2430" diff --git a/examples/layer-reference.el b/examples/layer-reference.el index 32c10fd..9982d50 100644 --- a/examples/layer-reference.el +++ b/examples/layer-reference.el @@ -59,75 +59,86 @@ (ebox-keymap-create :activate (lambda () - (pcase action - ('explode - (setf (layer-demo-exploded demo) (not (layer-demo-exploded demo))) - (let ((expanded (layer-demo-exploded demo))) - (ebox-region-update "orbit-violet" - :left (if expanded '(ch 2) '(ch 4)) - :top (if expanded '(lh 1) '(lh 3))) - (ebox-region-update "orbit-signal" - :left (if expanded '(ch 26) '(ch 20)) - :top (if expanded '(lh 11) '(lh 7))) - (ebox-region-update "orbit-lime" - :left (if expanded '(ch 52) '(ch 46)) - :top (if expanded '(lh 0) '(lh 2))))) - ('focus - (setf (layer-demo-focus demo) (% (1+ (layer-demo-focus demo)) 3)) - (cl-loop for id in '("orbit-violet" "orbit-lime" "orbit-signal") - for index from 0 - do (ebox-region-update id :z-index - (if (= index (layer-demo-focus demo)) - 6 (+ index 2))))) - ('mood - (setf (layer-demo-warm demo) (not (layer-demo-warm demo))) - (cl-loop for id in '("orbit-violet" "orbit-signal" "orbit-lime") - for color in (if (layer-demo-warm demo) - '("#FFADCF" "#FFD49B" "#B8F1CD") - '("#A99BFF" "#B9E9F2" "#D7FA73")) - do (ebox-region-update id :background-color color))) - ('toggle - (setf (layer-demo-visible demo) (not (layer-demo-visible demo))) - (ebox-region-update "panel-a" :visibility - (if (layer-demo-visible demo) 'visible 'hidden))) - ('move - (setf (layer-demo-moved demo) (not (layer-demo-moved demo))) - (ebox-region-update "panel-a" - :left (if (layer-demo-moved demo) '(ch 10) '(ch 4)) - :top (if (layer-demo-moved demo) '(lh 0) '(lh 2)))) - ('raise - (setf (layer-demo-raised demo) (not (layer-demo-raised demo))) - (ebox-region-update "panel-a" :z-index - (if (layer-demo-raised demo) 4 2))) - ('update-lower - (cl-incf (layer-demo-revision demo)) - (ebox-region-update "lower-note" :content (layer-demo-lower-content demo))) - ('lower - (cl-incf (layer-demo-lower-clicks demo)) - (ebox-region-update "lower-note" :content (layer-demo-lower-content demo))) - ('menu - (setf (layer-demo-menu-visible demo) (not (layer-demo-menu-visible demo))) - (ebox-region-update "root-menu" :visibility - (if (layer-demo-menu-visible demo) 'visible 'hidden))) - ('placement - (setf (layer-demo-placement demo) (% (1+ (layer-demo-placement demo)) 4)) - (ebox-region-update "root-menu" :placement - (aref layer-demo-placements (layer-demo-placement demo)))) - ('anchor - (setf (layer-demo-anchor-moved demo) (not (layer-demo-anchor-moved demo))) - (ebox-region-update "menu-trigger" :left - (if (layer-demo-anchor-moved demo) '(ch 12) '(ch 4)))) - ((or 'sage 'amber) - (setf (layer-demo-selection demo) (symbol-name action) - (layer-demo-menu-visible demo) nil) - (ebox-region-update "menu-result" :content - (format "Selected %s from the anchored menu." action) - :color (if (eq action 'sage) "#286347" "#895314")) - (ebox-region-update "root-menu" :visibility 'hidden)) - (_ (error "Unknown layer-lab action: %S" action))) - (if (memq action '(explode focus mood)) - (ebox-region-update "orbit-state" :content (layer-demo-orbit-status demo)) - (ebox-region-update "layer-status" :content (layer-demo-status demo)))))) + (ebox-call-with-update-batch + (lambda () + (pcase action + ('explode + (setf (layer-demo-exploded demo) (not (layer-demo-exploded demo))) + (let ((expanded (layer-demo-exploded demo))) + (ebox-region-update "orbit-violet" + :left (if expanded '(ch 2) '(ch 4)) + :top (if expanded '(lh 1) '(lh 3))) + (ebox-region-update "orbit-signal" + :left (if expanded '(ch 26) '(ch 20)) + :top (if expanded '(lh 11) '(lh 7))) + (ebox-region-update "orbit-lime" + :left (if expanded '(ch 52) '(ch 46)) + :top (if expanded '(lh 0) '(lh 2))))) + ('focus + (setf (layer-demo-focus demo) (% (1+ (layer-demo-focus demo)) 3)) + (cl-loop for id in '("orbit-violet" "orbit-lime" "orbit-signal") + for index from 0 + do (ebox-region-update id :z-index + (if (= index (layer-demo-focus demo)) + 6 (+ index 2))))) + ('mood + (setf (layer-demo-warm demo) (not (layer-demo-warm demo))) + (cl-loop for id in '("orbit-violet" "orbit-signal" "orbit-lime") + for color in (if (layer-demo-warm demo) + '("#FFADCF" "#FFD49B" "#B8F1CD") + '("#A99BFF" "#B9E9F2" "#D7FA73")) + do (ebox-region-update id :background-color color))) + ('toggle + (setf (layer-demo-visible demo) (not (layer-demo-visible demo))) + (ebox-region-update "panel-a" :visibility + (if (layer-demo-visible demo) 'visible 'hidden))) + ('move + (setf (layer-demo-moved demo) (not (layer-demo-moved demo))) + (ebox-region-update "panel-a" + :left (if (layer-demo-moved demo) '(ch 10) '(ch 4)) + :top (if (layer-demo-moved demo) '(lh 0) '(lh 2)))) + ('raise + (setf (layer-demo-raised demo) (not (layer-demo-raised demo))) + (ebox-region-update "panel-a" :z-index + (if (layer-demo-raised demo) 4 2))) + ('update-lower + (cl-incf (layer-demo-revision demo)) + (ebox-region-update "lower-note" :content + (layer-demo-lower-content demo))) + ('lower + (cl-incf (layer-demo-lower-clicks demo)) + (ebox-region-update "lower-note" :content + (layer-demo-lower-content demo))) + ('menu + (setf (layer-demo-menu-visible demo) + (not (layer-demo-menu-visible demo))) + (ebox-region-update "root-menu" :visibility + (if (layer-demo-menu-visible demo) + 'visible 'hidden))) + ('placement + (setf (layer-demo-placement demo) (% (1+ (layer-demo-placement demo)) 4)) + (ebox-region-update "root-menu" :placement + (aref layer-demo-placements + (layer-demo-placement demo)))) + ('anchor + (setf (layer-demo-anchor-moved demo) + (not (layer-demo-anchor-moved demo))) + (ebox-region-update "menu-trigger" :left + (if (layer-demo-anchor-moved demo) + '(ch 12) '(ch 4)))) + ((or 'sage 'amber) + (setf (layer-demo-selection demo) (symbol-name action) + (layer-demo-menu-visible demo) nil) + (ebox-region-update "menu-result" :content + (format "Selected %s from the anchored menu." action) + :color (if (eq action 'sage) "#286347" "#895314")) + (ebox-region-update "root-menu" :visibility 'hidden)) + (_ (error "Unknown layer-lab action: %S" action))) + (if (memq action '(explode focus mood)) + (ebox-region-update "orbit-state" :content + (layer-demo-orbit-status demo)) + (ebox-region-update "layer-status" :content + (layer-demo-status demo)))))))) (provide 'layer-demo-reference) diff --git a/scripts/ebox-playground-flex-resize-evaluator.el b/scripts/ebox-playground-flex-resize-evaluator.el index c9d2383..f6fc869 100644 --- a/scripts/ebox-playground-flex-resize-evaluator.el +++ b/scripts/ebox-playground-flex-resize-evaluator.el @@ -243,10 +243,13 @@ No files, windows, buffers, or persistent render state are modified." ebox-playground-flex-resize-evaluator--output-lines) condition) -(defun ebox-playground-scroll-evaluator--measure (region-id steps) - "Measure STEPS alternating cached one-line scrolls of REGION-ID." +(defun ebox-playground-scroll-evaluator--measure (region-id steps &optional cold) + "Measure STEPS one-line scrolls of REGION-ID. +Alternate cached motion by default; COLD advances without retracing prior rows." (let ((root-renders 0) (node-styles 0) (style-computations 0) - samples installed) + (prefix-renders 0) (materializations 0) + (deadline (+ (float-time) 30)) + samples installed gc-start gc-time-start) (unwind-protect (progn (dolist (entry @@ -256,73 +259,164 @@ No files, windows, buffers, or persistent render state are modified." (cons 'ebox-surface--apply-node-style (lambda (&rest _) (cl-incf node-styles))) (cons 'ebox-surface--compute-node-style - (lambda (&rest _) (cl-incf style-computations))))) + (lambda (&rest _) (cl-incf style-computations))) + (cons 'ebox--render-scroll-window-source + (lambda (&rest _) (cl-incf prefix-renders))) + (cons 'ebox--scroll-state-materialize-lines + (lambda (_region state) + ;; The entry point also accepts already-complete + ;; state; count only actual producer invocations. + (when (plist-get state :materialize-content-lines) + (cl-incf materializations)))))) (advice-add (car entry) :before (cdr entry)) (push entry installed)) + (setq gc-start gcs-done gc-time-start gc-elapsed) (dotimes (index steps) - (let* ((delta (if (zerop (% index 2)) 1 -1)) + (when (> (float-time) deadline) + (error "Scroll evaluator exceeded 30 seconds after %d/%d samples" + index steps)) + (let* ((delta (if (or cold (zerop (% index 2))) 1 -1)) (start (float-time)) (consumed (ebox--scroll-region-by region-id delta 1))) (push (* 1000.0 (- (float-time) start)) samples) (unless (equal consumed delta) - (error "Scroll evaluator moved %S lines, expected %S" - consumed delta)))) - (list :mean-ms (/ (apply #'+ samples) (float steps)) + (if cold + (error + "Cold scroll stopped at sample %d/%d after %d completed steps: moved %S, expected 1 (capacity may be exhausted)" + (1+ index) steps index consumed) + (error "Scroll evaluator moved %S lines, expected %S" + consumed delta))))) + (list :gc-count (- gcs-done gc-start) + :gc-ms (* 1000.0 (- gc-elapsed gc-time-start)) + :mean-ms (/ (apply #'+ samples) (float steps)) :p95-ms (ebox-playground-flex-resize-evaluator--percentile samples 0.95) :max-ms (apply #'max samples) :root-renders root-renders :node-styles node-styles - :style-computations style-computations)) + :style-computations style-computations + :prefix-renders prefix-renders :materializations materializations)) (dolist (entry installed) (advice-remove (car entry) (cdr entry)))))) (defun ebox-playground-scroll-evaluator--fresh-parity (buffer file) - "Compare BUFFER with an independent FILE render at its root scroll offset." + "Compare BUFFER with its committed input at every committed scroll offset. +FILE is retained for caller compatibility; the oracle never reloads source or +recreates the companion's business state, callbacks, or keymaps." + (ignore file) (let* ((state (ebox--buffer-render-state buffer)) + (snapshot (ebox-surface-buffer-snapshot buffer)) + (input (plist-get snapshot :input)) (ebox-viewport-width (plist-get state :viewport-width)) (ebox-viewport-height (plist-get state :viewport-height)) - (region-id (plist-get (plist-get state :root-node) :region-id)) - (offset (plist-get (gethash region-id (plist-get state :scroll-state-table)) - :scroll-offset)) - offset-set - (set-offset + (published-root (plist-get state :root-node)) + (offsets (make-hash-table :test #'eql)) + (source-ids (make-hash-table :test #'eq)) + (rendered-owners (make-hash-table :test #'eql)) + rendered-root + (set-offsets (lambda (root) - (unless offset-set - (setq offset-set t) - (ebox-put root :scroll-offset (or offset 0)) - (ebox-put root :ebox-scroll-offset-controlled-p t)))) + (unless rendered-root + (when (eq root published-root) + (error "Fresh scroll oracle reached the published root")) + (setq rendered-root root) + ;; Snapshots omit runtime node IDs. Preserved opaque source + ;; handles bridge each committed ID to its detached counterpart. + (cl-labels + ((visit (node) + (when-let* ((node-id (gethash (ebox-node-source-handle node) + source-ids))) + (when (gethash node-id rendered-owners) + (error "Fresh scroll oracle found duplicate owner %s" node-id)) + (ebox-put node :scroll-offset (gethash node-id offsets)) + (ebox-put node :ebox-scroll-offset-controlled-p t) + (puthash node-id node rendered-owners)) + (ebox-tree-for-each-direct-child node #'visit))) + (visit root))))) expected) + (maphash + (lambda (region scroll-state) + (let* ((node-id (ebox-runtime-index-get + region (plist-get state :region-node-table))) + (node (and node-id (ebox-runtime-index-get + node-id (plist-get state :node-table)))) + (source (and node (ebox-node-source-handle node))) + (offset (plist-get scroll-state :scroll-offset))) + (unless (and source (integerp offset) (>= offset 0) + (equal offset (ebox-get node :scroll-offset))) + (error "Fresh scroll oracle requires a consistent committed offset for region %s" + region)) + (when (gethash source source-ids) + (error "Fresh scroll oracle found ambiguous source for region %s" region)) + (puthash node-id offset offsets) + (puthash source node-id source-ids))) + (plist-get state :scroll-state-table)) ;; Scroll offset is runtime state, not an authorable DSL style property. ;; Set it on the independent candidate immediately before its first render. (unwind-protect (progn - (advice-add 'ebox--render-layout :before set-offset) - (setq expected (ebox-render (ebox-playground-view - file ebox-viewport-width))) - (unless offset-set - (error "Fresh scroll oracle did not reach the root layout"))) - (advice-remove 'ebox--render-layout set-offset)) + (advice-add 'ebox--render-layout :before set-offsets) + (setq expected (ebox-render input)) + (unless (and rendered-root + (= (hash-table-count offsets) + (hash-table-count rendered-owners))) + (error "Fresh scroll oracle did not reach every committed scroll owner")) + (maphash + (lambda (node-id offset) + (unless (equal offset (ebox-get (gethash node-id rendered-owners) + :scroll-offset)) + (error "Fresh scroll oracle changed offset for owner %s" node-id))) + offsets)) + (advice-remove 'ebox--render-layout set-offsets)) (with-current-buffer buffer (let* ((actual (ebox-playground-scroll-evaluator--visual-text (buffer-string))) (fresh (ebox-playground-scroll-evaluator--visual-text expected)) (same (equal-including-properties actual fresh))) (unless same - (let ((position - (cl-loop for index below (min (length actual) (length fresh)) - unless (equal-including-properties - (substring actual index (1+ index)) - (substring fresh index (1+ index))) - return index)) - (print-level 4) (print-length 12)) - (princ (format " render lengths=%s/%s first-difference=%S %S -> %S\n" - (length actual) (length fresh) position - (and position (substring actual position - (1+ position))) - (and position (substring fresh position - (1+ position))))))) + (ebox-playground-scroll-evaluator--report-mismatch actual fresh)) same)))) +(defun ebox-playground-scroll-evaluator--report-mismatch (actual fresh) + "Print bounded differences between ACTUAL and FRESH without opaque values. +Report character codes, changed property names, line widths, and face summaries; +never print callback environments or keymap contents." + (let* ((limit (min (length actual) (length fresh))) + (position + (or (cl-loop for index below limit + unless (equal-including-properties + (substring actual index (1+ index)) + (substring fresh index (1+ index))) + return index) + limit)) + (left (and (< position (length actual)) + (text-properties-at position actual))) + (right (and (< position (length fresh)) + (text-properties-at position fresh))) + (keys (delete-dups + (append (cl-loop for (key _value) on left by #'cddr collect key) + (cl-loop for (key _value) on right by #'cddr collect key)))) + (changed (cl-remove-if + (lambda (key) (equal (plist-get left key) (plist-get right key))) + keys))) + (cl-labels + ((bounded (value) + (let* ((print-level 3) (print-length 8) (print-circle t) + (print-escape-newlines t) + (text (prin1-to-string value))) + (if (> (length text) 160) (concat (substring text 0 157) "...") text))) + (line-width (text) + (let ((start (or (cl-position ?\n text :end position :from-end t) -1)) + (end (or (cl-position ?\n text :start position) (length text)))) + (ebox-string-pixel-width (substring text (1+ start) end))))) + (princ + (format + " render lengths=%d/%d first-difference=%d char-codes=%S/%S changed-properties=%s line-widths=%s/%s faces=%s -> %s\n" + (length actual) (length fresh) position + (and (< position (length actual)) (aref actual position)) + (and (< position (length fresh)) (aref fresh position)) + (bounded changed) (line-width actual) (line-width fresh) + (bounded (plist-get left 'face)) (bounded (plist-get right 'face))))))) + (defun ebox-playground-scroll-evaluator--visual-text (text) "Normalize visible TEXT, ignoring identity and inert zero-pixel spacers. Fresh layout may retain empty padding spaces that materialized scroll content @@ -420,7 +514,8 @@ Cleanup runs on success, error and quit; stop between samples after 30 seconds." (ebox--deferred-render-gc-timer nil) (ebox--deferred-render-gc-depth 0) (ebox--deferred-render-gc-generation 0) - (work (list :layout-calls 0 :surface-plans 0 :fragment-scans 0 + (work (list :layout-calls 0 :surface-plans 0 :full-surface-renders 0 + :fragment-scans 0 :fragment-characters 0 :node-registrations 0)) samples installed) @@ -447,6 +542,7 @@ Cleanup runs on success, error and quit; stop between samples after 30 seconds." (key-description keys))) (dolist (entry '((ebox--render-layout . :layout-calls) (ebox-surface--surface-plan . :surface-plans) + (ebox-surface--render-candidate . :full-surface-renders) (ebox-surface--ensure-node-tree . :node-registrations))) (let* ((key (cdr entry)) (advice (lambda (&rest _) @@ -500,30 +596,68 @@ Cleanup runs on success, error and quit; stop between samples after 30 seconds." (when (timerp ebox--deferred-render-gc-timer) (cancel-timer ebox--deferred-render-gc-timer))))) +(defun ebox-playground-scroll-evaluator--max-offset (state) + "Return STATE's final maximum offset, or nil for an incomplete lazy prefix." + (when (or (plist-get state :content-lines-complete-p) + (not (or (plist-get state :render-content-prefix) + (plist-get state :materialize-content-lines)))) + (max 0 (- (length (plist-get state :content-lines)) + (plist-get state :content-height))))) + (defun ebox-playground-scroll-evaluator-run - (files &optional width steps height) - "Profile cached root scrolling for FILES at WIDTH with STEPS and HEIGHT. + (files &optional width steps height options) + "Profile scrolling for FILES at WIDTH with STEPS, HEIGHT, and OPTIONS. FILES is one `.ebox' path or a list of paths. WIDTH defaults to 720 pixels, STEPS to 20 alternating one-line motions, and HEIGHT to 36 viewport lines. -Each file must have a scrollable root. Fully materialize its content outside -the samples, then measure cached top and midpoint motion. Return bounded -plists with mean, p95 and maximum milliseconds, full root render counts, node -style visits, style computations, and fresh-render parity for each position. +OPTIONS accepts :region-id, an author semantic ID resolved in each mount, +and :cold, either t or nil. An omitted or nil ID selects the root. Runtime +numeric region metadata is not accepted as an address; integer IDs mean author +IDs. The selected node must be scrollable. + +By default fully materialize selected content outside the samples, then measure +cached top and midpoint motion. With :cold t, measure only forward one-line +motion from the initial mounted offset, with no explicit prewarming, +materialization, or seek. Signal an error identifying the failed sample if +capacity runs out before STEPS completes; do not report incomplete success. + +Return bounded plists with :mode, semantic :region-id, :start-offset, +:end-offset, :content-complete-p, timings, full root render counts, node style +visits, style computations, and fresh-render parity for each position. +:prefix-renders counts calls to the bounded scroll source renderer; +:materializations counts full-content producer invocations during samples. +:position is top/middle for cached samples or cold for advancing samples. +:max-offset is nil unless the selected content is fully materialized. +The oracle independently renders the committed snapshot, preserving opaque +callbacks and keymaps, and reapplies every committed scroll owner's offset. Signal an error on missing scroll capacity, incomplete motion or render drift. Use the current Emacs frame's font metrics without displaying temporary buffers or changing windows. This measures retained TP publication; native window scrolling, idle prewarming and redisplay are excluded. Existing live buffers, timers and runtime tables are preserved. Temporary mounts and advice -are cleaned on success, error or quit; no global runtime reset is performed." +are cleaned on success, error or quit; no global runtime reset is performed. +Each sample loop checks a 30-second deadline between motions." (setq files (if (stringp files) (list files) files) width (or width 720) steps (or steps 20) height (or height 36)) (unless (and files (proper-list-p files) (cl-every #'stringp files)) (error "Scroll evaluator needs a file path or nonempty list of paths")) + (unless (and (proper-list-p options) (zerop (% (length options) 2))) + (error "Scroll evaluator OPTIONS must be an even property list")) + (let (seen) + (cl-loop for (key _value) on options by #'cddr do + (unless (and (memq key '(:region-id :cold)) (not (memq key seen))) + (error "Scroll evaluator OPTIONS has an unknown or repeated key")) + (push key seen))) + (unless (and (memq (plist-get options :cold) '(nil t)) + (let ((id (plist-get options :region-id))) + (or (null id) (stringp id) (symbolp id) (integerp id)))) + (error "Scroll evaluator needs a semantic :region-id and boolean :cold")) (dolist (value (list width steps height)) (unless (and (integerp value) (> value 0)) (error "Scroll evaluator dimensions and steps must be positive integers"))) - (let ((ebox-viewport-width width) + (let ((selected-id (plist-get options :region-id)) + (cold (plist-get options :cold)) + (ebox-viewport-width width) (ebox-viewport-height height) (ebox-native-buffer-scroll nil) (ebox-runtime-idle-prewarm nil) @@ -545,33 +679,45 @@ are cleaned on success, error or quit; no global runtime reset is performed." (ebox-playground-open-file file (buffer-name buffer)) (with-current-buffer buffer (let* ((runtime (ebox--buffer-render-state buffer)) - (region-id (plist-get (plist-get runtime :root-node) - :region-id)) + (region-id + (if selected-id + (cdr (ebox-selector--region-target + (ebox-region-resolve buffer selected-id))) + (plist-get (plist-get runtime :root-node) :region-id))) (table (plist-get runtime :scroll-state-table)) (state (and region-id (gethash region-id table))) max-offset) (unless state - (error "Scroll evaluator requires a scrollable root: %s" + (error "Scroll evaluator requires a scrollable selected owner: %s" file)) - (setq state (ebox--scroll-state-materialize-lines - region-id state)) - (puthash region-id state table) - (setq max-offset - (max 0 (- (length (plist-get state :content-lines)) - (plist-get state :content-height)))) - (unless (> max-offset 0) - (error "Scroll evaluator root has no scroll capacity: %s" - file)) - ;; Publish warmed content before measuring cached motion. - (ebox--scroll-region-by region-id 1 1) - (ebox--scroll-region-by region-id -1 1) - (dolist (position '(top middle)) - (ebox--surface-scroll-to-offset - buffer region-id - (if (eq position 'top) 0 (/ max-offset 2))) - (let* ((measurement + (unless cold + (setq state (ebox--scroll-state-materialize-lines + region-id state)) + (puthash region-id state table) + (setq max-offset + (ebox-playground-scroll-evaluator--max-offset state)) + (unless max-offset + (error "Scroll evaluator could not materialize selected content")) + (unless (> max-offset 0) + (error "Scroll evaluator selected owner has no scroll capacity: %s" + file)) + ;; Publish warmed content before measuring cached motion. + (ebox--scroll-region-by region-id 1 1) + (ebox--scroll-region-by region-id -1 1)) + (dolist (position (if cold '(cold) '(top middle))) + (unless cold + (ebox--surface-scroll-to-offset + buffer region-id + (if (eq position 'top) 0 (/ max-offset 2)))) + (let* ((start-offset + (plist-get (ebox--scroll-get-state region-id) + :scroll-offset)) + (measurement (ebox-playground-scroll-evaluator--measure - region-id steps)) + region-id steps cold)) + (final-state (ebox--scroll-get-state region-id)) + (final-max-offset + (ebox-playground-scroll-evaluator--max-offset final-state)) (parity (ebox-playground-scroll-evaluator--fresh-parity buffer file)) @@ -579,20 +725,29 @@ are cleaned on success, error or quit; no global runtime reset is performed." (append (list :file (expand-file-name file) :width width :height height :steps steps - :position position :max-offset max-offset + :region-id selected-id :mode (if cold 'cold 'cached) + :position position + :start-offset start-offset + :end-offset (plist-get final-state :scroll-offset) + :content-complete-p (numberp final-max-offset) + :max-offset final-max-offset :fresh-render-parity parity) measurement))) (princ (format - "%s scroll %s %s mean=%.3fms p95=%.3fms max=%.3fms roots=%d node-styles=%d style-computations=%d parity=%S\n" + "%s scroll %s %s mean=%.3fms p95=%.3fms max=%.3fms gc=%d/%.3fms roots=%d node-styles=%d style-computations=%d prefix-renders=%d materializations=%d parity=%S\n" (if parity "PASS" "FAIL") (file-name-nondirectory file) position (plist-get measurement :mean-ms) (plist-get measurement :p95-ms) (plist-get measurement :max-ms) + (plist-get measurement :gc-count) + (plist-get measurement :gc-ms) (plist-get measurement :root-renders) (plist-get measurement :node-styles) - (plist-get measurement :style-computations) parity)) + (plist-get measurement :style-computations) + (plist-get measurement :prefix-renders) + (plist-get measurement :materializations) parity)) (push result results) (unless parity (error "Scroll evaluator fresh-render mismatch: %s %s" diff --git a/tests/ebox-playground-layer-tests.el b/tests/ebox-playground-layer-tests.el index 71f2c24..09e8cb5 100644 --- a/tests/ebox-playground-layer-tests.el +++ b/tests/ebox-playground-layer-tests.el @@ -108,6 +108,77 @@ tail (cdr tail)))) nodes))) +(defun ebox-playground-layer-test--toolbar-hover (labels) + "Assert that LABELS own separate hover groups with inert toolbar gaps." + (let (groups bindings rows) + (dolist (label labels) + (let* ((position (ebox-playground-layer-test--position label)) + (hover (get-text-property position 'mouse-face)) + (map (get-text-property position 'keymap))) + (should hover) + (should (keymapp map)) + (should (eq (get-text-property position 'pointer) 'hand)) + ;; Equal colors still need separate native mouse-face identities. + (should-not (memq hover groups)) + (push hover groups) + (push (cons map hover) bindings) + (save-excursion + (goto-char position) + (cl-pushnew (cons (line-beginning-position) (line-end-position)) + rows :test #'equal)))) + (dolist (row rows) + (cl-loop for position from (car row) below (cdr row) + for map = (get-text-property position 'keymap) + do (if map + (progn + (should (assq map bindings)) + (should (eq (get-text-property position 'mouse-face) + (cdr (assq map bindings)))) + (should (eq (get-text-property position 'pointer) 'hand))) + ;; This includes gaps between buttons and the row's tail. + (dolist (property '(mouse-face pointer keymap)) + (should-not (get-text-property position property)))))))) + +(defun ebox-playground-layer-test--lower-note-padding-hover (label) + "Assert that LABEL's intentional block hover includes its blank padding." + (let* ((position (ebox-playground-layer-test--position label)) + (hover (get-text-property position 'mouse-face)) + (map (get-text-property position 'keymap))) + (should hover) + (should (keymapp map)) + (save-excursion + (goto-char position) + (let ((padding (cl-loop for at from (line-beginning-position) + below (line-end-position) + when (and (get-text-property at 'ebox-pl) + (eq map (get-text-property at 'keymap))) + return at))) + (should padding) + (should (string-match-p + "\\`[[:space:]]+\\'" + (buffer-substring-no-properties padding (1+ padding)))) + (should (eq hover (get-text-property padding 'mouse-face))) + (should (eq map (get-text-property padding 'keymap))) + (should (eq (get-text-property padding 'pointer) 'hand)))))) + +(ert-deftest ebox-playground-layer-hover-matches-action-and-block-scopes () + "Toolbar buttons hover separately while the note keeps its full surface." + (dolist (width '(60 720)) + (ebox-playground-layer-test--with-preview (buffer width) + (let ((toolbars '(("EXPLODE STACK" "CYCLE FOCUS" "SWITCH MOOD") + ("TOGGLE PANEL A" "MOVE PANEL A" "RAISE / LOWER A" + "UPDATE COVERED NOTE") + ("CYCLE PLACEMENT" "MOVE ANCHOR")))) + (dolist (labels toolbars) + (ebox-playground-layer-test--toolbar-hover labels)) + (ebox-playground-layer-test--activate "EXPLODE STACK") + (dolist (labels toolbars) + (ebox-playground-layer-test--toolbar-hover labels))) + (ebox-playground-layer-test--activate "TOGGLE PANEL A") + (ebox-playground-layer-test--lower-note-padding-hover "LOWER REV 00") + (ebox-playground-layer-test--activate "UPDATE COVERED NOTE") + (ebox-playground-layer-test--lower-note-padding-hover "LOWER REV 01")))) + (ert-deftest ebox-playground-layer-reference-declares-layers-with-one-font-size () "Actual layout data contains the taught contracts and no font-size override." (let* ((form (ebox-playground--evaluate-form @@ -303,5 +374,24 @@ (dolist (line (ebox-string-lines (buffer-string))) (should (<= (ebox-string-pixel-width line) width)))))) +(ert-deftest ebox-playground-layer-orbit-commands-publish-local-owners-once () + "Orbit commands publish once without preparing the unrelated full surface." + (ebox-playground-layer-test--with-preview (buffer 104) + (dolist (label '("EXPLODE STACK" "EXPLODE STACK" + "CYCLE FOCUS" "CYCLE FOCUS" "CYCLE FOCUS" + "CYCLE FOCUS" "CYCLE FOCUS" "CYCLE FOCUS" + "SWITCH MOOD" "EXPLODE STACK" "CYCLE FOCUS" + "EXPLODE STACK" "SWITCH MOOD")) + (let ((revision (plist-get (ebox-surface-buffer-snapshot buffer) :revision))) + (cl-letf (((symbol-function 'ebox-surface--render-candidate) + (lambda (&rest _) + (ert-fail "Orbit command prepared the full surface"))) + ((symbol-function 'ebox-playground-layer-test--parity) + #'ignore)) + (ebox-playground-layer-test--activate label)) + (ebox-playground-layer-test--parity) + (should (= (plist-get (ebox-surface-buffer-snapshot buffer) :revision) + (1+ revision))))))) + (provide 'ebox-playground-layer-tests) ;;; ebox-playground-layer-tests.el ends here diff --git a/tests/ebox-playground-tests.el b/tests/ebox-playground-tests.el index adf39fe..68024cb 100644 --- a/tests/ebox-playground-tests.el +++ b/tests/ebox-playground-tests.el @@ -394,7 +394,8 @@ (should (string-match-p "03" (plist-get report :text))) (should (= (plist-get report :publications) 3)) (dolist (key '(:mean-ms :p95-ms :max-ms :gc-count :gc-ms :cons-cells - :layout-calls :surface-plans :fragment-scans + :layout-calls :surface-plans :full-surface-renders + :fragment-scans :fragment-characters :node-registrations)) (should (>= (plist-get report key) 0)))) (cl-letf (((symbol-function 'ebox-region-update) @@ -446,7 +447,8 @@ (should (plist-get report :fresh-render-parity)) (should (= (plist-get report :steps) 3)) (dolist (key '(:mean-ms :p95-ms :max-ms :root-renders - :node-styles :style-computations)) + :node-styles :style-computations + :prefix-renders :materializations)) (should (>= (plist-get report key) 0)))) (should (eq runtime (ebox--buffer-render-state live))) (should (eq scroll-state (ebox--scroll-get-state region-id))) @@ -469,7 +471,9 @@ (mapcar (lambda (symbol) (cons symbol (symbol-function symbol))) '(ebox-surface--render-candidate ebox-surface--apply-node-style - ebox-surface--compute-node-style))) + ebox-surface--compute-node-style + ebox--render-scroll-window-source + ebox--scroll-state-materialize-lines))) (buffers (buffer-list))) (unwind-protect (progn @@ -518,6 +522,216 @@ (concat carrier "\nA"))) 2)))) +(ert-deftest ebox-playground-scroll-evaluator-selects-nested-owner-in-both-modes () + "Semantic selection profiles only the nested owner, including cold forward work." + (let ((file (make-temp-file "ebox-scroll-evaluator-nested-" nil ".ebox")) + (buffers (buffer-list))) + (unwind-protect + (progn + (with-temp-file file + (prin1 + ''(column :width (px 120) + (box "Before") + (row + (box :id "selected" :width (px 60) :height (lh 3) :overflow scroll + "0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11") + (box :width (px 40) :height (lh 3) :overflow scroll + "a\nb\nc\nd\ne\nf\ng")) + (box "After")) + (current-buffer))) + (dolist (cold '(nil t)) + (let* ((standard-output (lambda (_character))) + (scroll (symbol-function 'ebox--scroll-region-by)) + (materialize (symbol-function 'ebox--scroll-state-materialize-lines)) + (measure (symbol-function 'ebox-playground-scroll-evaluator--measure)) + (open (symbol-function 'ebox-playground-open-file)) + mounted measuring calls reports) + (cl-letf (((symbol-function 'ebox-playground-open-file) + (lambda (&rest arguments) + (prog1 (apply open arguments) (setq mounted t)))) + ((symbol-function 'ebox-playground-scroll-evaluator--measure) + (lambda (&rest arguments) + (setq measuring t) + (unwind-protect (apply measure arguments) + (setq measuring nil)))) + ((symbol-function 'ebox--scroll-state-materialize-lines) + (lambda (&rest arguments) + (when (and cold mounted (not measuring)) + (ert-fail "Cold evaluator eagerly materialized content")) + (apply materialize arguments))) + ((symbol-function 'ebox--scroll-region-by) + (lambda (region delta &optional budget) + (when cold (should measuring)) + (push delta calls) + (funcall scroll region delta budget)))) + (setq reports (ebox-playground-scroll-evaluator-run + file 121 3 8 (list :region-id "selected" :cold cold)))) + (should (= (length reports) (if cold 1 2))) + (when cold (should (equal calls '(1 1 1)))) + (dolist (report reports) + (should (plist-get report :fresh-render-parity)) + (should (equal (plist-get report :region-id) "selected")) + (should (eq (plist-get report :mode) (if cold 'cold 'cached))) + (should (= (- (plist-get report :end-offset) + (plist-get report :start-offset)) + (if cold 3 1))) + (when cold + (should (eq (plist-get report :position) 'cold)) + (should (= (plist-get report :start-offset) 0))) + (when (plist-get report :content-complete-p) + (should (= (plist-get report :max-offset) 9)))))) + (should (equal buffers (buffer-list)))) + (delete-file file)))) + +(ert-deftest ebox-playground-scroll-evaluator-cold-extends-initial-prefix () + "Cold samples cross the mounted cache boundary without full materialization." + (let ((file (make-temp-file "ebox-scroll-evaluator-prefix-" nil ".ebox")) + (ebox-scroll-lazy-prefix-lookahead-lines 8) + (ebox-wheel-scroll-step 16) + (measure (symbol-function 'ebox-playground-scroll-evaluator--measure)) + (buffers (buffer-list)) + initial-capacity reports) + (unwind-protect + (progn + (with-temp-file file + (prin1 + (list 'quote + `(column :width (px 120) :height (vh 100) :overflow scroll + ,@(cl-loop for row below 100 + collect `(box ,(format "row-%02d" row))))) + (current-buffer))) + (let ((standard-output (lambda (_character)))) + (cl-letf (((symbol-function 'ebox-playground-scroll-evaluator--measure) + (lambda (region steps &optional cold) + (let ((state (ebox--scroll-get-state region))) + (should-not (plist-get state :content-lines-complete-p)) + (should (plist-get state :render-content-prefix)) + (setq initial-capacity + (- (length (plist-get state :content-lines)) + (plist-get state :content-height))) + (should (> steps initial-capacity))) + (funcall measure region steps cold)))) + (setq reports (ebox-playground-scroll-evaluator-run + file 121 40 8 '(:cold t))))) + (should (= (length reports) 1)) + (let ((report (car reports))) + (should (= (plist-get report :start-offset) 0)) + (should (= (plist-get report :end-offset) 40)) + (should (> (plist-get report :end-offset) initial-capacity)) + (should (> (plist-get report :prefix-renders) 0)) + (should (= (plist-get report :materializations) 0)) + (should (= (plist-get report :root-renders) 0)) + (should-not (plist-get report :content-complete-p)) + (should-not (plist-get report :max-offset)) + (should (plist-get report :fresh-render-parity))) + (should (equal buffers (buffer-list)))) + (delete-file file)))) + +(ert-deftest ebox-playground-scroll-evaluator-counts-materialization-producers () + "A full-content producer is counted once, and its completed-state no-op is not." + (let ((state (list :materialize-content-lines + (lambda (&rest _) + (list :content-lines '("0" "1" "2") :content-height 1)))) + (ebox--scroll-global-state (make-hash-table :test 'eql))) + (cl-letf (((symbol-function 'ebox--scroll-region-by) + (lambda (region delta &optional _budget) + (setq state (ebox--scroll-state-materialize-lines region state)) + delta))) + (let ((report (ebox-playground-scroll-evaluator--measure -1 2 t))) + (should (= (plist-get report :materializations) 1)) + (should (= (plist-get report :prefix-renders) 0)) + (should (plist-get state :content-lines-complete-p)))))) + +(ert-deftest ebox-playground-scroll-oracle-replays-named-and-anonymous-offsets () + "Fresh input restores root and nested offsets without depending on author IDs." + (let ((buffer (generate-new-buffer " *ebox-scroll-oracle-nested*"))) + (unwind-protect + (progn + (ebox-render-to-buffer + buffer + (ebox-build + '(column :width (px 120) :height (lh 7) :overflow scroll + (box "before\nbefore") + (row + (box :id "selected" :width (px 60) :height (lh 3) :overflow scroll + "0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11") + (box :width (px 40) :height (lh 3) :overflow scroll + "a\nb\nc\nd\ne\nf\ng")) + (box "after\nafter\nafter\nafter\nafter\nafter\nafter")))) + (let* ((state (ebox--buffer-render-state buffer)) + (root-region (plist-get (plist-get state :root-node) :region-id)) + (selected (cdr (ebox-selector--region-target + (ebox-region-resolve buffer "selected")))) + (regions (hash-table-keys (plist-get state :scroll-state-table)))) + (should (= (length regions) 3)) + (dolist (region regions) + (unless (= region root-region) + (should (= (ebox--scroll-region-by region (if (= region selected) 2 1) 2) + (if (= region selected) 2 1))))) + (should (= (ebox--scroll-region-by root-region 1 1) 1)) + (should (ebox-playground-scroll-evaluator--fresh-parity buffer nil)) + (let* ((state (ebox--buffer-render-state buffer)) + (node (ebox-runtime-index-get + (ebox-runtime-index-get selected (plist-get state :region-node-table)) + (plist-get state :node-table))) + (offset (ebox-get node :scroll-offset))) + (unwind-protect + (progn + (ebox-put node :scroll-offset 0) + (should-error + (ebox-playground-scroll-evaluator--fresh-parity buffer nil))) + (ebox-put node :scroll-offset offset))) + (should (ebox-playground-scroll-evaluator--fresh-parity buffer nil)))) + (when (buffer-live-p buffer) (kill-buffer buffer))))) + +(ert-deftest ebox-playground-scroll-evaluator-cold-capacity-error-and-options () + "Cold exhaustion reports the exact incomplete sample and releases resources." + (let ((file (make-temp-file "ebox-scroll-evaluator-capacity-" nil ".ebox")) + (buffers (buffer-list)) + (render (symbol-function 'ebox--render-layout))) + (unwind-protect + (progn + (with-temp-file file + (prin1 ''(column :width (px 120) :height (lh 2) :overflow scroll + (box "0\n1\n2\n3")) + (current-buffer))) + (let ((failure (should-error + (ebox-playground-scroll-evaluator-run file 121 4 8 '(:cold t))))) + (should (string-match-p "sample 3/4 after 2 completed steps" + (error-message-string failure)))) + (dolist (options '((:cold yes) (:cold) (:unknown t) + (:region-id (invalid)) (:cold t :cold nil))) + (should-error (ebox-playground-scroll-evaluator-run file 121 3 8 options))) + (should-error (ebox-playground-scroll-evaluator-run + file 121 3 8 '(:region-id "missing"))) + (should (equal buffers (buffer-list))) + (should (eq render (symbol-function 'ebox--render-layout)))) + (delete-file file)))) + +(ert-deftest ebox-playground-scroll-evaluator-timeout-restores-instrumentation () + "A deadline between motions fails explicitly and removes temporary counters." + (let ((clock 0) + (motions 0) + (functions + (mapcar (lambda (symbol) (cons symbol (symbol-function symbol))) + '(ebox-surface--render-candidate ebox-surface--apply-node-style + ebox-surface--compute-node-style + ebox--render-scroll-window-source + ebox--scroll-state-materialize-lines)))) + (cl-letf (((symbol-function 'float-time) + (lambda (&optional _) (cl-incf clock 11))) + ((symbol-function 'ebox--scroll-region-by) + (lambda (_region delta &optional _budget) + (cl-incf motions) + delta))) + (let ((failure (should-error + (ebox-playground-scroll-evaluator--measure 0 3 t)))) + (should (string-match-p "exceeded 30 seconds after 1/3 samples" + (error-message-string failure))))) + (should (= motions 1)) + (dolist (entry functions) + (should (eq (cdr entry) (symbol-function (car entry))))))) + (ert-deftest ebox-playground-effective-viewport-prefers-window-text-width () "Interactive viewport resolution should prefer the window text width." (let ((ebox-viewport-width nil)) @@ -1042,6 +1256,134 @@ (ebox-playground-close (format "*Ebox Preview: %s*" file)) (delete-directory directory t)))) +(ert-deftest ebox-playground-scroll-oracle-retains-committed-business-capabilities () + "A scroll oracle keeps mutable callback identity and detects real property drift." + (ebox-playground-test--call-with-companion-files + (lambda (file companion preview) + (with-temp-file companion + (insert ";;; -*- lexical-binding: t; -*-\n") + (prin1 + '(progn + (cl-incf ebox-playground-test--companion-loads) + (setq ebox-playground-test--companion-value + (let ((business (list 0 (make-symbol "fresh-business-state")))) + (list 'column :width '(px 120) :height '(lh 4) :overflow 'scroll + :help-echo (ebox-help-create + (lambda () (format "state=%d" (car business)))) + :keymap (ebox-keymap-create + :activate (lambda () (cl-incf (car business)))) + (list 'box :id "message" + "0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11"))))) + (current-buffer))) + (with-temp-file file (insert "ebox-playground-test--companion-value")) + (let* ((standard-output (lambda (_character))) + (reports (ebox-playground-scroll-evaluator-run file 121 3 8))) + (should (= ebox-playground-test--companion-loads 1)) + (should (= (length reports) 2)) + (dolist (report reports) (should (plist-get report :fresh-render-parity)))) + (let* ((standard-output (lambda (_character))) + (reports (ebox-playground-scroll-evaluator-run file 121 3 8 '(:cold t)))) + (should (= ebox-playground-test--companion-loads 2)) + (should (= (length reports) 1)) + (should (plist-get (car reports) :fresh-render-parity)) + (should (= (plist-get (car reports) :end-offset) 3))) + (ebox-playground-open-file file preview) + (with-current-buffer preview + (let ((command (lookup-key (get-text-property (point-min) 'keymap) + (kbd "RET")))) + (call-interactively command) + (should (equal (funcall (get-text-property (point-min) 'help-echo) + nil (current-buffer) (point-min)) + "state=1"))) + (ebox-region-update "message" :color "#123456") + (let* ((state (ebox--buffer-render-state (current-buffer))) + (region (plist-get (plist-get state :root-node) :region-id))) + (should (= (ebox--scroll-region-by region 1 1) 1))) + (let* ((buffer (current-buffer)) + (revision (ebox-surface-buffer-revision buffer)) + (state (ebox--buffer-render-state buffer)) + (root (plist-get state :root-node)) + (offset (ebox-get root :scroll-offset)) + (render (symbol-function 'ebox--render-layout)) + (properties (copy-sequence (text-properties-at (point-min))))) + (cl-letf (((symbol-function 'ebox-playground-view) + (lambda (&rest _) (ert-fail "Oracle reloaded source")))) + (should (ebox-playground-scroll-evaluator--fresh-parity buffer file))) + (should (= ebox-playground-test--companion-loads 3)) + (should (eq state (ebox--buffer-render-state buffer))) + (should (= revision (ebox-surface-buffer-revision buffer))) + (should (= offset (ebox-get root :scroll-offset))) + (should (eq render (symbol-function 'ebox--render-layout))) + (dolist (change + (list (cons 'keymap '(keymap (13 . ignore))) + ;; This callback returns the same help as the committed + ;; one. Its different identity must still be detected. + (cons 'help-echo (lambda (&rest _) "state=1")) + (cons 'pointer 'hand) + (cons 'face '(:foreground "#654321")) + (cons 'display '(space :width (99))))) + (unwind-protect + (let ((inhibit-read-only t)) + (put-text-property (point-min) (1+ (point-min)) + (car change) (cdr change)) + (let ((output + (with-output-to-string + (should-not + (ebox-playground-scroll-evaluator--fresh-parity + buffer file))))) + (should (< (length output) 1000)) + (should (string-match-p "char-codes=" output)) + (should (string-match-p (symbol-name (car change)) output)) + (should (string-match-p "line-widths=" output)) + (should-not (string-match-p "closure\\|byte-code\\|fresh-business-state" + output)))) + (let ((inhibit-read-only t)) + (set-text-properties (point-min) (1+ (point-min)) properties)))) + (should (ebox-playground-scroll-evaluator--fresh-parity buffer file))))))) + +(ert-deftest ebox-playground-scroll-oracle-offset-and-error-cleanup () + "Reject inconsistent offsets and remove temporary advice after render errors." + (let ((buffer (generate-new-buffer " *ebox-scroll-oracle-error*")) + (render (symbol-function 'ebox--render-layout))) + (unwind-protect + (progn + (ebox-render-to-buffer + buffer (ebox-build '(column :width (px 120) :height (lh 2) + :overflow scroll (box "0\n1\n2\n3\n4")))) + (let* ((state (ebox--buffer-render-state buffer)) + (root (plist-get state :root-node)) + (offset (ebox-get root :scroll-offset))) + (unwind-protect + (progn + (ebox-put root :scroll-offset (1+ offset)) + (should-error + (ebox-playground-scroll-evaluator--fresh-parity buffer nil))) + (ebox-put root :scroll-offset offset))) + (cl-letf (((symbol-function 'ebox-render) + (lambda (_input) (error "Injected fresh render failure")))) + (should-error + (ebox-playground-scroll-evaluator--fresh-parity buffer nil))) + (should (eq render (symbol-function 'ebox--render-layout))) + (should (ebox-playground-scroll-evaluator--fresh-parity buffer nil))) + (when (buffer-live-p buffer) (kill-buffer buffer))))) + +(ert-deftest ebox-playground-scroll-oracle-mismatch-output-is-bounded () + "Mismatch output omits opaque capability bodies and handles a missing suffix." + (let* ((payload (make-string 5000 ?x)) + (callback (lambda () payload)) + (actual (propertize "A" 'keymap (list 'keymap (cons 13 callback)) + 'help-echo callback)) + (fresh (propertize "A" 'keymap '(keymap (13 . ignore)) + 'help-echo "different")) + (output (with-output-to-string + (ebox-playground-scroll-evaluator--report-mismatch actual fresh) + (ebox-playground-scroll-evaluator--report-mismatch "A" "AB")))) + (should (< (length output) 1000)) + (should (string-match-p "changed-properties=.*keymap" output)) + (should (string-match-p "help-echo" output)) + (should (string-match-p "char-codes=nil/66" output)) + (should-not (string-match-p "closure\\|byte-code\\|xxxxx" output)))) + (ert-deftest ebox-playground-companion-reloads-exact-source-before-file-evaluation () "Reload an edited sibling source, regardless of bytecode or current directory." (ebox-playground-test--call-with-companion-files