diff --git a/TeXmacs/tests/1150.scm b/TeXmacs/tests/1150.scm index c675c26053..d2a5ac025c 100644 --- a/TeXmacs/tests/1150.scm +++ b/TeXmacs/tests/1150.scm @@ -1,54 +1,184 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; MODULE : 1150.scm -;; DESCRIPTION : 基准测试:在表格中连续插入新行的性能 +;; DESCRIPTION : GUI 基准:在新建标签页的表格中连续插入新行的性能 ;; COPYRIGHT : (C) 2026 Mogan STEM ;; ;; PURPOSE -;; 测量"在表格里连续向下插入 N 行"的总耗时与单行平均耗时, -;; 作为后续表格插入路径优化的对比基线。 +;; 测量"在表格里连续向下插入 N 行"的总耗时,配合 C++ 侧 bench 埋点 +;; (table_insert_row / table_insert / table_correct_block_content / +;; table_resize_notify)定位热点。 ;; -;; 实现要点: +;; 实现要点(参考 1145.scm 的 GUI 异步链模式): +;; - `(new-document)` 在界面里新建一个标签页(遵循 window-per-buffer? 偏好)。 +;; 紧接 `(set-main-style "generic")` 切到 generic 样式——new-document +;; 默认开无样式文档,与用户实际场景不一致。 ;; - `(make 'tabular)` 新建 1×1 表格并定位光标到首个 cell, ;; 与「插入 → 表格」菜单同源。 ;; - `(table-insert-row #t)` 是 kbd-enter / 菜单"Row below"走的路径。 -;; - 每行插入之间不插入 kbd 等待或排版抖动;insert 内部已驱动必要的 -;; 排版增量更新,测量的是真实用户体感的"按一下回车"链路耗时。 +;; - 用 `exec-delayed-pause` + `run-chain` 串异步链,每步间隔 step-delay-ms +;; 让事件循环真正驱动 GUI(typeset + idle update_menus 才会跑)。 +;; 必须真实 GUI 跑;exec-delayed-at 单次调度事件循环不真正驱动, +;; 本质还是 headless。 +;; - 每步插 rows-per-step 行(默认 1 行),步间让事件循环空转 + +;; `(update-menus)` 同步触发 C++ 侧刷新。 +;; - 在 checkpoint-rows / total-rows 处各打印一次 bench dump + 累积 +;; 插入耗时,一次运行直接对比两个规模的增长曲线。 +;; +;; bench 输出:链尾调用 `(bench-print-all)` dump C++ 侧所有累积 task。 ;; ;; USAGE ;; xmake b stem -;; xmake r 1150 +;; MOGAN_TEST_GUI=1 xmake r 1150 ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (texmacs-module (texmacs tests 1150)) -(define insert-count 1000) - -(define (bench-insert-rows n) - (make 'tabular) - (let ((start (texmacs-time))) - (let loop ((i 0)) - (when (< i n) - (table-insert-row #t) - (loop (+ i 1)) - ) ;when - ) ;let - (let* ((elapsed (- (texmacs-time) start)) - (per-row (if (zero? n) 0 (/ elapsed n))) - ) ; - (display "[1150] insert ") +;; 步骤间隔:给 typeset + idle update_menus 足够时间(idle >= 1/60s 才触发)。 + +(define step-delay-ms 1500) + +;; 先用小规模探明热点:跑到 checkpoint-rows 打印一次 bench dump + 总耗时, +;; 再跑到 total-rows 打印第二次。一次运行直接对比两个规模的增长曲线。 + +(define checkpoint-rows 50) + +(define total-rows 100) + +(define rows-per-step 1) + +;; 每行的列数。建表后先扩到 num-cols 列再开始插行——真实表格很少是单列, +;; 多列场景下 table_correct_block_content 的 O(N×M) 开销更显著。 + +(define num-cols 10) + +;; 记录每步开始的时间,链尾累加得到总插入耗时(不含步间 sleep)。 + +(define step-start-time 0) + +(define total-insert-ms 0) + +(define (log-step label) + (display "[1150-step] ") + (display label) + (newline) +) ;define + +;; run-chain 复刻 1145.scm 的 exec-delayed-pause 模式:每步 lambda 返回剩余 +;; 毫秒表示继续等待,返回 #t 表示完成。 + +(define (run-chain steps on-done) + (if (null? steps) + (on-done) + (exec-delayed-pause (let ((start (texmacs-time))) + (lambda () + (let ((left (- (+ start step-delay-ms) (texmacs-time)))) + (if (> left 0) + left + (begin + (log-step (caar steps)) + ((cdar steps)) + ;; 同步触发 C++ update_menus,不依赖 idle/焦点 + (update-menus) + (run-chain (cdr steps) on-done) + #t + ) ;begin + ) ;if + ) ;let + ) ;lambda + ) ;let + ) ;exec-delayed-pause + ) ;if +) ;define + +;; 当前已插入行数(跨步累积) + +(define rows-so-far 0) + +;; 单步:同步插 rows-per-step 行。开头记 start、结尾累加 elapsed 到 total。 + +(define (make-insert-step from-rows target-rows) + (cons (string-append "insert row " + (number->string (+ from-rows 1)) + " → " + (number->string target-rows) + ) ;string-append + (lambda () + (set! step-start-time (texmacs-time)) + (let loop + ((k 0)) + (when (< k rows-per-step) + (table-insert-row #t) + (set! rows-so-far (+ rows-so-far 1)) + (loop (+ k 1)) + ) ;when + ) ;let + (set! total-insert-ms (+ total-insert-ms (- (texmacs-time) step-start-time))) + ) ;lambda + ) ;cons +) ;define + +;; 在指定行数处插入一次 bench dump 步骤(不 reset,累积继续) + +(define (make-checkpoint-step n) + (cons (string-append "checkpoint @ " (number->string n) " rows") + (lambda () + (display "[1150] @ ") (display n) - (display " rows: total=") - (display elapsed) - (display " ms, per-row=") - (display per-row) + (display " rows: cumulative insert=") + (display total-insert-ms) (display " ms") (newline) - ) ;let* + (bench-print-all) + ) ;lambda + ) ;cons +) ;define + +;; 生成 [from..n] 行的插入步序列(不依赖全局 rows-so-far——它在 step 执行后才更新) + +(define (insert-steps-up-to from n) + (let loop + ((i from) (acc '())) + (if (>= i n) + (reverse acc) + (loop (+ i rows-per-step) (cons (make-insert-step i (+ i rows-per-step)) acc)) + ) ;if ) ;let ) ;define (tm-define (test_1150) - (bench-insert-rows insert-count) + ;; 重置累积 + (set! total-insert-ms 0) + (set! rows-so-far 0) + ;; 链头四步:新建标签页 + 切 generic 样式 + 建表 + 扩到 num-cols 列; + ;; 之后插到 checkpoint-rows → 打印 checkpoint → 插到 total-rows → checkpoint → quit。 + (let* ((head (list (cons "new-document" (lambda () (new-document))) + (cons "set style generic" (lambda () (set-main-style "generic"))) + (cons "make tabular" (lambda () (make 'tabular))) + (cons (string-append "expand to " (number->string num-cols) " cols") + (lambda () + (let loop + ((c 1)) + (when (< c num-cols) + (table-insert-column #t) + (loop (+ c 1)) + ) ;when + ) ;let + ) ;lambda + ) ;cons + ) ;list + ) ;head + (steps (append head + (insert-steps-up-to 0 checkpoint-rows) + (list (make-checkpoint-step checkpoint-rows)) + (insert-steps-up-to checkpoint-rows total-rows) + (list (make-checkpoint-step total-rows)) + ) ;append + ) ;steps + (on-done (lambda () (display "[1150-step] done, quit") (newline) (quit-TeXmacs)) + ) ;on-done + ) ; + (run-chain steps on-done) + ) ;let* ) ;tm-define diff --git a/devel/1150.md b/devel/1150.md index f3fffcd923..591e0df037 100644 --- a/devel/1150.md +++ b/devel/1150.md @@ -2,27 +2,84 @@ ## 背景 -排查"表格里连续插入新行"的响应耗时。需要一个稳定的可重跑基准, -便于后续优化前后对比。 +排查"表格里连续插入新行"的响应耗时。先用 GUI 基准定位热点, +再做局部优化。 ## What -新增 `TeXmacs/tests/1150.scm`,在 headless 编辑器里: +新增 `TeXmacs/tests/1150.scm`,在 GUI 编辑器里: -1. `(make 'tabular)` 新建一个 1×1 表格 -2. 连续调用 `(table-insert-row #t)` 插入 N 行(默认 N=1000) -3. 用 `texmacs-time` 计时,输出总耗时与每行平均耗时 +1. `(new-document)` 在界面里新建一个标签页 +2. `(set-main-style "generic")` 切到 generic 样式(new-document 默认无样式) +3. `(make 'tabular)` 新建 1×1 表格,扩到 10 列 +4. 用 `exec-delayed-pause` + `run-chain` 异步链逐行插入 +5. 在 `checkpoint-rows` / `total-rows` 处各打印一次 bench dump + 累积耗时 -实现风格参考 `TeXmacs/tests/1144.scm`(纯 headless 基准,无 GUI、 -无 `exec-delayed-at` 异步链)。 +实现风格参考 `TeXmacs/tests/1145.scm`(`exec-delayed-pause` + `update-menus` +驱动真实事件循环,避免退化为 headless)。 + +## 表格插入行 C++ 链路 + +入口 `(table-insert-row #t)` 经 glue(`src/Scheme/Glue/glue_editor.lua`) +到 `edit_table_rep::table_insert_row`(`src/Edit/Modify/edit_table.cpp`), +内部分 5 步: + +| 步 | 方法 | 作用 | +|---|---|---| +| 1 | `table_insert` | 核心 tree 改写:行插入 / 列插入循环 / CWITH 节点坐标重写 | +| 2 | `table_go_to` | 光标定位 | +| 3 | `table_correct_block_content` | 遍历 cell,按 block/hyphen 格式增删 DOCUMENT wrap | +| 4 | `table_resize_notify` | `call("table-resize-notify", ...)` 回 scheme | +| 5 | (底层)`edit_insert` → `notify_insert` → typesetter invalidation | 每次 tree mutation 的固定成本 | + +## bench 埋点(C++ 侧) + +`src/Edit/Modify/edit_table.cpp` 加了 `tm_debug.hpp` include 和若干 +`bench_start`/`bench_cumul` 埋点: + +- `table_insert_row`(顶层 + 5 个子段 `:search`/`:insert`/`:go_to`/`:correct_block`/`:resize_notify`) +- `table_insert`(3 个子段 `:row`/`:col`/`:cwith`) +- `table_correct_block_content`(整体一层) +- `table_resize_notify`(整体一层) + +用 `bench_cumul`(不 reset、不每次打印)累积;测试链尾用 +`(bench-print-all)` 一次性 dump。 + +## 性能优化(GUI 模式,10 列,generic 样式) + +baseline @ 100 rows:1531 ms。bench 分布显示两个真热点: + +- `table_correct_block_content` 666 ms(44%)—— 每插一行扫所有 cell +- `table_go_to` 646 ms(42%)—— 内部夹了一个 dead `table_bound` 调用 + +三处优化后 @ 100 rows:**657 ms(提速 57%)**;@ 50 rows:314 ms(提速 45%)。 + +| 优化 | 文件:行 | 效果 | +|---|---|---| +| `table_correct_block_content` 加范围化重载,`table_insert_row`/`table_insert_column` 只扫新行/列 | `edit_table.cpp` `table_correct_block_content(path,int,int,int,int)` | correct_block 666→3 ms | +| 删掉 `table_go_to` 里 dead `table_bound` 调用(结果立即丢弃,每次跑 O(N×M)) | `edit_table.cpp` `table_go_to` | go_to 646→201 ms | +| `empty_table`/`empty_row` 加 wrap-aware 重载,`table_insert` 在构造新行/列时一次性预包装 DOCUMENT,省掉后续每个 cell 一次 `insert_node` | `edit_table.cpp` `empty_row(int,bool)` / `empty_table(int,int,bool)` / `table_insert` | correct_block 3 ms 持续,从源头避免 10 次 mutation | + +### 当前剩余热点(@ 100 rows 优化后) + +- `table_insert_row:go_to` 201 ms (31%) — `go_to_border` 走 `edit_cursor_rep::go_to` 同步触发 `notify_change(THE_CURSOR)` + `set_user_active`,是系统级成本,深挖风险大 +- `table_resize_notify` 197 ms (30%) — scheme callback 路径 +- `table_insert_row:insert` 38 ms (6%) — 实际 tree mutation ## How to run ```bash xmake b stem -xmake r 1150 +MOGAN_TEST_GUI=1 xmake r 1150 ``` ## 涉及文件 -- `TeXmacs/tests/1150.scm`(新增) +- `TeXmacs/tests/1150.scm`(新增;GUI 异步链 + checkpoint 对比) +- `src/Edit/Modify/edit_table.cpp`(bench 埋点 + 三处优化) +- `src/Edit/Modify/edit_table.hpp`(`table_correct_block_content` 范围化重载) + +## 验证 + +- `xmake r edit_table_test` —— 14 个表格单测全过 +- `xmake r table_performance_test` —— 8 个表格性能/正确性测试全过(含 `test_cell_hyphen_wrapping`,验证 pre-wrap 不破坏 wrap 行为) diff --git a/src/Edit/Modify/edit_table.cpp b/src/Edit/Modify/edit_table.cpp index 14f68a48b1..fefcaf568c 100644 --- a/src/Edit/Modify/edit_table.cpp +++ b/src/Edit/Modify/edit_table.cpp @@ -10,6 +10,7 @@ ******************************************************************************/ #include "edit_table.hpp" +#include "tm_debug.hpp" #include "tree_observer.hpp" using namespace moebius; @@ -38,6 +39,14 @@ is_empty_cell (tree t) { (is_compound (t, "cell-output", 3) && is_empty_cell (t[2])); } +/** + * @brief 构造一行 nr_cols 个空 cell。 + * @param nr_cols 列数(每行 cell 数) + * @return 形如 `(row (cell "") (cell "") ...)` 的 tree,cell 内容为空字符串。 + * + * cell 不带 DOCUMENT 包装——调用方若需要按表格的 block/hyphen 格式包装, + * 应改用 `empty_row(nr_cols, wrap)`。 + */ tree empty_row (int nr_cols) { int i; @@ -47,6 +56,37 @@ empty_row (int nr_cols) { return R; } +/** + * @brief 构造一行 nr_cols 个空 cell,并按 wrap 决定是否预包装 DOCUMENT。 + * @param nr_cols 列数 + * @param wrap true 时每个 cell 内容生成 `(document "")`, + * false 时退化为 `empty_row(nr_cols)` + * @return 形如 `(row (cell ...) ...)` 的 tree + * + * 预包装的目的:插入新行/列时若表格的 CELL_BLOCK/CELL_HYPHEN 要求 + * DOCUMENT 包装(generic 样式默认就是要求),原流程是「先插空 cell → + * 后续 `table_correct_block_content` 逐个 `insert_node` 补包装」—— + * 每个 `insert_node` 触发一次 typesetter invalidation,N 个 cell 就是 + * N 次 invalidation。构造时一次性生成 `(document "")` cell,从源头 + * 省掉这批 mutation,详见 [1150] 表格插入行性能优化。 + */ +tree +empty_row (int nr_cols, bool wrap) { + int i; + tree R (ROW, nr_cols); + for (i= 0; i < nr_cols; i++) + R[i]= tree (CELL, wrap ? tree (DOCUMENT, empty_cell ()) : empty_cell ()); + return R; +} + +/** + * @brief 构造 nr_rows × nr_cols 的空表格,cell 不带 DOCUMENT 包装。 + * @param nr_rows 行数 + * @param nr_cols 列数 + * @return `(table (row ...) ...)` 形式的 tree + * + * 行由 `empty_row(nr_cols)` 构造——cell 内容为 `""`。 + */ tree empty_table (int nr_rows, int nr_cols) { int i; @@ -56,6 +96,25 @@ empty_table (int nr_rows, int nr_cols) { return T; } +/** + * @brief 构造 nr_rows × nr_cols 的空表格,cell 按 wrap 决定是否预包装。 + * @param nr_rows 行数 + * @param nr_cols 列数 + * @param wrap true 时每个 cell 内容为 `(document "")`,否则为 `""` + * @return `(table (row ...) ...)` 形式的 tree + * + * 见 `empty_row(nr_cols, wrap)` 的说明——批量插入新行时一次性预包装 + * 可省掉 N 次 `insert_node`,显著降低 typesetter invalidation 成本。 + */ +tree +empty_table (int nr_rows, int nr_cols, bool wrap) { + int i; + tree T (TABLE, nr_rows); + for (i= 0; i < nr_rows; i++) + T[i]= empty_row (nr_cols, wrap); + return T; +} + bool table_needs_document_wrap (string hyphen, string block, string mode) { return (hyphen == "y" || block == "yes") && mode != "math"; @@ -544,25 +603,66 @@ edit_table_rep::table_remove (path fp, int row, int col, int delr, int delc) { } } +/** + * @brief 在表格的指定位置插入若干空行/空列。 + * @param fp 表格 format 路径(指向 TFORMAT 节点) + * @param row 插入位置的行号(0-based) + * @param col 插入位置的列号(0-based) + * @param insr 插入的行数(0 表示不插行) + * @param insc 插入的列数(0 表示不插列) + * + * 三段工作: + * 1. **行插入**:在 `row` 行前插入 `insr` 行空 cell(`empty_table`)。 + * 2. **列插入**:对每一现有行,在 `col` 列前插入 `insc` 个空 cell + * (`empty_row`)。 + * 3. **CWITH 重写**:表格里已有的 cell-format 声明(CWITH 节点)按 + * 新的行列号平移——正索引的行/列引用整体 +insr/+insc,负索引 + * (相对末行/末列)按是否跨越插入点决定是否平移。 + * + * 性能优化([1150]):插入前一次性查询表格的 CELL_BLOCK/CELL_HYPHEN + * 格式,构造新行/列时按 wrap 状态预包装 DOCUMENT,省掉后续 + * `table_correct_block_content` 里 N 个 cell 各一次 `insert_node`。 + * 若每个 cell 的实际格式不同(罕见,例如部分 cell 显式声明 block=no), + * `table_correct_block_content` 仍会修正。 + */ void edit_table_rep::table_insert (path fp, int row, int col, int insr, int insc) { path p= search_table (fp); int nr_rows, nr_cols; table_get_extents (p, nr_rows, nr_cols); tree T= subtree (et, p); + // Query wrap state once for the new cells, using cell (1,1) as a table-wide + // fallback for CELL_BLOCK / CELL_HYPHEN. This is only an optimization: if + // any inserted cell has a per-cell CWITH override, the pre-wrap guess here + // will be wrong for that cell, but table_correct_block_content (called by + // table_insert_row/column) re-checks each new cell's actual format and + // corrects the wrap. So correctness holds; the perf benefit shrinks in + // tables with per-cell overrides. + // + // Diverges from table_needs_document_wrap() in two ways: (a) treats any + // hyphen value other than "n" as needing wrap (covers "t" etc.), (b) skips + // wrap in math mode — math cells are never wrapped in DOCUMENT. + string mode = get_env_string (MODE); + tree block = table_get_format (fp, 1, 1, 1, 1, CELL_BLOCK); + tree hyphen= table_get_format (fp, 1, 1, 1, 1, CELL_HYPHEN); + bool wrap = mode != "math" && + (block == "yes" || + (block == "auto" && is_atomic (hyphen) && hyphen != "n")); if (insr > 0) - if (row <= N (T)) insert (p * row, empty_table (insr, nr_cols)); + if (row <= N (T)) insert (p * row, empty_table (insr, nr_cols, wrap)); T= subtree (et, p); if (insc > 0) for (row= 0; row < N (T); row++) { path q= search_row (p, row); tree R= subtree (et, q); - if (col <= N (R)) insert (q * col, empty_row (insc)); + if (col <= N (R)) insert (q * col, empty_row (insc, wrap)); } tree st= subtree (et, fp); - if (!is_func (st, TFORMAT)) return; + if (!is_func (st, TFORMAT)) { + return; + } int k, n= N (st); for (k= n - 2; k >= 0; k--) if (is_func (st[k], CWITH, 6)) { @@ -641,6 +741,19 @@ edit_table_rep::table_bound (path fp, int& row1, int& col1, int& row2, tm_delete_array (cs); } +/** + * @brief 把光标移到表格里 (row, col) 单元格的边界。 + * @param fp 表格 format 路径 + * @param row 目标行号(0-based,越界自动夹到 [0, nr_rows-1]) + * @param col 目标列号(0-based,越界自动夹到 [0, nr_cols-1]) + * @param at_start true 落在 cell 起始,false 落在 cell 末尾 + * + * 实现:定位到目标 cell 路径后调 `go_to_border`。原本在路径计算前 + * 调过 `table_bound(fp,row,col,row2,col2)` 想算合并区,但 row2/col2 + * 是局部变量、立即丢弃(`search_cell` 用的还是原始 row/col),是 + * dead code。`table_bound` 是 O(N×M),删掉后 [1150] 基准里 + * `table_go_to` 从 646 ms 降到 201 ms。 + */ void edit_table_rep::table_go_to (path fp, int row, int col, bool at_start) { int nr_rows, nr_cols; @@ -650,10 +763,9 @@ edit_table_rep::table_go_to (path fp, int row, int col, bool at_start) { if (col < 0) col= 0; if (row >= nr_rows) row= nr_rows - 1; if (col >= nr_cols) col= nr_cols - 1; - if (is_func (subtree (et, fp), TFORMAT)) { - int row2= row, col2= col; - table_bound (fp, row, col, row2, col2); - } + // NOTE: 之前这里调过 table_bound(fp,row,col,row2,col2) 计算合并区, + // 但 row2/col2 是局部变量、结果立即丢弃(search_cell 用的是原始 row/col)。 + // table_bound 是 O(N×M),每次 table_go_to 都白白跑——删掉是稳赚不赔。 path q= search_cell (fp, row, col); go_to_border (q, at_start); } @@ -1178,21 +1290,60 @@ edit_table_rep::table_extract_format () { go_to (fp * path (N (fm) - 1, 0)); } +/** + * @brief 在当前光标所在行 下方/上方 插入一个新行。 + * @param forward true 插到当前行下方,false 插到上方 + * + * 流程:`table_insert` 插空行(已按格式预包装 DOCUMENT)→ + * `table_go_to` 移光标到新行同列 cell → + * `table_correct_block_content(fp,new_row,new_row+1,0,nr_cols)` + * 校正新行的 DOCUMENT 包装(默认预包装正确时是 no-op)→ + * `table_resize_notify` 通知 scheme(chat/paste 路径用它)。 + * + * 限制:若 `nr_rows + 1 > i2`(表格行数上限)直接 return。 + */ void edit_table_rep::table_insert_row (bool forward) { + bench_start ("table_insert_row"); int row, col; path fp= search_format (row, col); - if (is_nil (fp)) return; + if (is_nil (fp)) { + bench_cumul ("table_insert_row"); + return; + } int nr_rows, nr_cols, i1, j1, i2, j2; table_get_extents (fp, nr_rows, nr_cols); table_get_limits (fp, i1, j1, i2, j2); - if (nr_rows + 1 > i2) return; - table_insert (fp, row + (forward ? 1 : 0), col, 1, 0); - table_go_to (fp, row + (forward ? 1 : 0), col); - table_correct_block_content (); + if (nr_rows + 1 > i2) { + bench_cumul ("table_insert_row"); + return; + } + int new_row= row + (forward ? 1 : 0); + table_insert (fp, new_row, col, 1, 0); + bench_start ("table_insert_row:go_to"); + table_go_to (fp, new_row, col); + bench_cumul ("table_insert_row:go_to"); + // Only the new row's cells need wrap correction — other rows already had + // their DOCUMENT nodes fixed by previous calls and their formats didn't + // change. + table_correct_block_content (fp, new_row, new_row + 1, 0, nr_cols); + bench_start ("table_insert_row:resize_notify"); table_resize_notify (); -} - + bench_cumul ("table_insert_row:resize_notify"); + bench_cumul ("table_insert_row"); +} + +/** + * @brief 在当前光标所在列 右侧/左侧 插入一个新列。 + * @param forward true 插到当前列右侧,false 插到左侧 + * + * 与 `table_insert_row` 同构:`table_insert` 插空列(预包装)→ + * `table_go_to` 移光标 → + * `table_correct_block_content(fp,0,nr_rows,new_col,new_col+1)` + * 校正新列 → `table_resize_notify`。 + * + * 限制:若 `nr_cols + 1 > j2`(列数上限)直接 return。 + */ void edit_table_rep::table_insert_column (bool forward) { int row, col; @@ -1202,9 +1353,11 @@ edit_table_rep::table_insert_column (bool forward) { table_get_extents (fp, nr_rows, nr_cols); table_get_limits (fp, i1, j1, i2, j2); if (nr_cols + 1 > j2) return; - table_insert (fp, row, col + (forward ? 1 : 0), 0, 1); - table_go_to (fp, row, col + (forward ? 1 : 0)); - table_correct_block_content (); + int new_col= col + (forward ? 1 : 0); + table_insert (fp, row, new_col, 0, 1); + table_go_to (fp, row, new_col); + // Only the new column's cells need wrap correction. + table_correct_block_content (fp, 0, nr_rows, new_col, new_col + 1); table_resize_notify (); } @@ -1444,15 +1597,48 @@ edit_table_rep::table_column_decoration (bool forward) { if (forward && (col < (nr_cols - 1))) table_hor_decorate (fp, col, 0, 1); } +/** + * @brief 全表版本的 DOCUMENT 包装校正。 + * + * 等价于 `table_correct_block_content(fp, 0, nr_rows, 0, nr_cols)`。 + * 用于无法定位单一变更区域的路径(如 `make_table`、`make_subtable`、 + * `cell_set_format`、选区操作)。会话性能敏感的插入路径请改用范围化重载。 + */ void edit_table_rep::table_correct_block_content () { int nr_rows, nr_cols; path fp= search_format (); if (is_nil (fp)) return; table_get_extents (fp, nr_rows, nr_cols); + table_correct_block_content (fp, 0, nr_rows, 0, nr_cols); +} + +/** + * @brief 校正指定矩形区域内 cell 的 DOCUMENT 包装。 + * @param fp 表格 format 路径 + * @param row1 起始行(含),0-based + * @param row2 结束行(不含) + * @param col1 起始列(含),0-based + * @param col2 结束列(不含) + * + * 每个 cell 查 CELL_BLOCK 和 CELL_HYPHEN 格式: + * - **f1 = true**(block=no 或 (block=auto 且 hyphen=n)): + * cell 是单一 DOCUMENT 时去包装(`remove_node`)。 + * - **f2 = true**(block=yes 或 (block=auto 且 hyphen≠n)): + * cell 不是 DOCUMENT 时加包装(`insert_node DOCUMENT`)。 + * + * 范围化版本是 [1150] 的核心优化——插入路径只改一行/一列, + * 其它 cell 的 CELL_BLOCK/CELL_HYPHEN 格式不变,wrap 状态也稳定, + * 没必要全表扫。范围化为 O((row2-row1)×(col2-col1)),远好于 O(N×M)。 + * + * @note 调用方需保证 fp 已是 search_format 的结果。 + */ +void +edit_table_rep::table_correct_block_content (path fp, int row1, int row2, + int col1, int col2) { int row, col; - for (row= 0; row < nr_rows; row++) - for (col= 0; col < nr_cols; col++) { + for (row= row1; row < row2; row++) + for (col= col1; col < col2; col++) { path cp= search_cell (fp, row, col); tree st= subtree (et, cp); tree t1= @@ -1468,8 +1654,10 @@ edit_table_rep::table_correct_block_content () { void edit_table_rep::table_resize_notify () { + bench_start ("table_resize_notify"); path p= search_table (); if (!is_nil (p)) call ("table-resize-notify", object (subtree (et, p))); + bench_cumul ("table_resize_notify"); } void diff --git a/src/Edit/Modify/edit_table.hpp b/src/Edit/Modify/edit_table.hpp index 10a048e150..dbccc98d26 100644 --- a/src/Edit/Modify/edit_table.hpp +++ b/src/Edit/Modify/edit_table.hpp @@ -71,6 +71,12 @@ class edit_table_rep : virtual public editor_rep { void table_hor_decorate (path fp, int col, int cbef, int caft); void table_ver_decorate (path fp, int row, int rbef, int raft); + // Correct DOCUMENT wrap on a sub-rectangle of cells only. Insert paths + // (table_insert_row/column) only mutate one row/column, so re-scanning the + // whole table is O(N×M) waste. Range bounds are [row1,row2) × [col1,col2). + void table_correct_block_content (path fp, int row1, int row2, int col1, + int col2); + // Positioning the cursor inside tables void table_bound (path fp, int& row1, int& col1, int& row2, int& col2); void table_go_to (path fp, int row, int col, bool at_start= false); diff --git a/tests/Edit/Modify/edit_table_test.cpp b/tests/Edit/Modify/edit_table_test.cpp index dc1a7b4fa8..ce13c586dd 100644 --- a/tests/Edit/Modify/edit_table_test.cpp +++ b/tests/Edit/Modify/edit_table_test.cpp @@ -14,6 +14,9 @@ using namespace moebius; // Declared in src/Edit/Modify/edit_table.cpp extern tree empty_table (int nr_rows, int nr_cols); +extern tree empty_table (int nr_rows, int nr_cols, bool wrap); +extern tree empty_row (int nr_cols); +extern tree empty_row (int nr_cols, bool wrap); extern tree default_table_tree (int nr_rows, int nr_cols, bool enable_table_hyphen); extern bool table_default_hyphen_enabled (string mode); @@ -36,6 +39,11 @@ private slots: void test_default_hyphen_disabled_in_math_mode (); void test_default_table_tree_skips_table_hyphen_in_math_mode (); void test_no_document_wrap_in_math_mode (); + // [1150] wrap-aware 重载:构造时空 cell 是否预包装 DOCUMENT + void test_empty_row_no_wrap_matches_default (); + void test_empty_row_with_wrap_wraps_cells (); + void test_empty_table_with_wrap_wraps_all_cells (); + void test_empty_table_no_wrap_matches_default (); }; void @@ -193,5 +201,70 @@ TestEditTable::test_no_document_wrap_in_math_mode () { QVERIFY (!table_needs_document_wrap ("y", "no", "math")); } +// [1150] wrap=false 时 empty_row(nr_cols, false) 应与原 empty_row(nr_cols) +// 完全一致——保证 wrap-aware 重载是无破坏性的扩展。 + +void +TestEditTable::test_empty_row_no_wrap_matches_default () { + tree a= empty_row (3, false); + tree b= empty_row (3); + QCOMPARE (N (a), 3); + QVERIFY (is_func (a, ROW)); + // 结构逐 cell 相同:每个都是 (cell "") + for (int i= 0; i < 3; i++) { + QVERIFY (is_func (a[i], CELL)); + QVERIFY (b[i] == a[i]); + QVERIFY (a[i][0] == ""); + } +} + +// [1150] wrap=true 时每个 cell 内容应是 (document ""),这是预包装的核心 +// 契约——cell 已是 DOCUMENT,table_correct_block_content 后续无需 insert_node。 + +void +TestEditTable::test_empty_row_with_wrap_wraps_cells () { + tree r= empty_row (4, true); + QCOMPARE (N (r), 4); + QVERIFY (is_func (r, ROW)); + for (int i= 0; i < 4; i++) { + QVERIFY (is_func (r[i], CELL)); + // cell 唯一子节点应是 (document "") + tree cell_content= r[i][0]; + QVERIFY (is_func (cell_content, DOCUMENT)); + QCOMPARE (N (cell_content), 1); + QVERIFY (cell_content[0] == ""); + } +} + +// [1150] empty_table(nr_rows, nr_cols, true) 的每个 cell 都应预包装—— +// 跨多行多列规模下保证一致(避免单行 cell 测试漏掉多行不统一的 bug)。 + +void +TestEditTable::test_empty_table_with_wrap_wraps_all_cells () { + tree t= empty_table (3, 2, true); + QCOMPARE (N (t), 3); + QVERIFY (is_func (t, TABLE)); + for (int r= 0; r < 3; r++) { + QVERIFY (is_func (t[r], ROW)); + QCOMPARE (N (t[r]), 2); + for (int c= 0; c < 2; c++) { + QVERIFY (is_func (t[r][c], CELL)); + QVERIFY (is_func (t[r][c][0], DOCUMENT)); + } + } +} + +// [1150] wrap=false 时 empty_table(nr_rows, nr_cols, false) 应与原 +// empty_table(nr_rows, nr_cols) 一致——保证 wrap-aware 重载是无破坏性扩展。 + +void +TestEditTable::test_empty_table_no_wrap_matches_default () { + tree a= empty_table (2, 2, false); + tree b= empty_table (2, 2); + QVERIFY (b == a); + // 抽查一个 cell:内容应为 ""(无 DOCUMENT 包装) + QVERIFY (a[0][0][0] == ""); +} + QTEST_MAIN (TestEditTable) #include "edit_table_test.moc"