From cc0f5a9cafe84e5a6b43859211bb882a43ab00cd Mon Sep 17 00:00:00 2001 From: Jetkwok Date: Tue, 20 Oct 2026 11:18:13 +0800 Subject: [PATCH 1/2] Fix ElyTable completion keys and overlay click-through --- agents.md | 2 + apps/ely-table/src/rules/aids.rs | 6 +- apps/ely-table/src/tests.rs | 1 + apps/ely-table/src/tests/aids.rs | 111 ++++++++++++ crates/ely-lok/src/view/formula.rs | 8 +- crates/ely-lok/src/view/input.rs | 6 + crates/ely-lok/src/view/tests.rs | 1 + crates/ely-lok/src/view/tests/formula.rs | 204 +++++++++++++++++++++++ docs/help.md | 5 + 9 files changed, 339 insertions(+), 5 deletions(-) create mode 100644 apps/ely-table/src/tests/aids.rs create mode 100644 crates/ely-lok/src/view/tests/formula.rs diff --git a/agents.md b/agents.md index 4e3defd..c78e1ce 100644 --- a/agents.md +++ b/agents.md @@ -323,3 +323,5 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-18:ElyMark 公式(R8.4,用户定两项都做)。行内公式照块公式由 RaTeX 排版(原只显示着色的 TeX 原文):gpui 的文本里放不进图片,组件库 `render/math_text.rs` 把每个行内公式换成等宽的不断行空格(U+00A0,按字体量出个数),排好的公式居中画在其上,行高随最高的公式撑开;空格串与词一样整个折到下一行,画在其所在行(折行处的序号 gpui 读作上一行末);按下与右键所在的字节经换算读回公式的原文;排不出的公式照旧显示 TeX 原文,其后以危险色写出 RaTeX 给的原因与位置,记警告(不含公式)。`\(…\)` 读作行内公式,`\[…\]` 读作块公式(ChatGPT 等常写的分隔符;GitHub 不认,Typora 默认不认):解析前逐字节把成对的分隔符换成 `$`(长度不变,位置照旧对得上)。成对只在同一段落、标题或单元格里(跨列表项、CR 空行不成对);不跨代码、公式、HTML 标签、链接地址与引用标签、图片、脚注引用,``、`` 之间(含嵌套;交叉闭合照可视的读法关闭与重开)的文字照原文;`\\(` 不换。公式里与段落里另有的 `$`(含 `\$`)解析时暂写成文本里没有的控制字节,解析后还原,不与换上的 `$` 配对;相邻的两个公式之间暂插一个空格,解析后去掉,仍是两个公式。公式节点的范围是整对分隔符,值是写下的 TeX 原文。独占一段的 `\[…\]` 为块公式(段首可有一至三个空格)。组件库公开 `documents::markdown_tree`,ElyMark 的 HTML、PDF、纯文本导出都经它解析(`markdown_options` 不再公开),纯文本里独占一段的公式照块公式写其 TeX。同时查出(组件库 e095f06):RaTeX 的 SVG 以 pt 定尺寸,usvg 按 px 量墨迹,两者差 4/3,每个公式的画面右侧与下方多出约四分之一的空白(块公式因此偏左)、导出的公式比正文大 4/3;现生成时即把根改为 px。`markdown/tex/tests.rs` 与 `tree/tests.rs` 核对分隔符的各种情形(相邻、`\$`、链接地址、``、跨块),`math_text.rs` 的窗口测试核对公式排进并读回原文、折到第二行的公式画在第二行首(改回按起点定位即失败)、排不出的公式写出原因(组件库 a20e712、5fba7b3、de961eb),`math/tests.rs` 核对画面与公式同宽(改回 pt 即失败),ElyMark 的 `visual.rs` 核对导出与纯文本。Windows 真窗口(`.work/r8/mark/visual2.png`):`$…$`、`\(…\)` 排在行内,`\[…\]` 居中成块,行内代码与转义的 `\\(` 照原文。 - 2026-10-19(用户定):代码与发布从 Gitea 搬到 GitHub 组织 ELY-Open-Source(`ELY-Open-Source/ElyOffice`;组件库 `ELY-Open-Source/Ely-GPUI-Components`,Cargo 的 git 依赖与 `fetch-references.sh` 随之改址,钉的提交不变;Kigi CLI 同在此组织)。历史、标签与发布已迁过,Gitea 停用,GitHub 是唯一的家。发布由 `.github/workflows/release.yml` 在推送 `v*` 标签时运行(只此一种触发,拉取请求到不了自托管机器):macOS 在 `[self-hosted, macos-signing]`(Mac mini 的图形会话,Developer ID 签名并公证),Windows 与 Linux(WSL)在 `[self-hosted, ely-windows]`;各机的常驻克隆 `$HOME/ely-ci/ElyOffice` 从 GitHub 拉取。`scripts/ci.sh` 经 GitHub API 按标签取发布、没有就建(`GITHUB_TOKEN`,`contents: write`),同名文件先删再传:重跑只换本平台的文件。更新检查读 `api.github.com/repos/ELY-Open-Source/ElyOffice/releases/latest` 的 `.json`,清单的下载地址为 `github.com/ELY-Open-Source/ElyOffice/releases/download/v<版本>/<文件>`;0.1.8 及以前装好的应用仍查 Gitea,需手动装一次新版。官网 worker 读 GitHub API(可选机密 `GITHUB_TOKEN` 避开共享出口地址的限流),下载与源码链接指向 GitHub(GitHub 标志);macOS 版已公证,去掉“未经公证”的提示。发布视频的源文件与成片里仍是 Gitea 地址,未改未重渲染。 - 2026-10-19(用户定):自有代码由 AGPL-3.0-or-later 改为 MIT(`LICENSE`,Copyright (c) 2026 Yang Zhang;工作区 `Cargo.toml`、Linux 包的许可字段、`docs/licenses.md`、官网)。第三方照旧:LibreOffice 与 `patches/libreoffice` 为 MPL-2.0 等,Git 为 GPL-2.0,MuPDF 为 AGPL-3.0;ElyPDF 链接 MuPDF,其发行包整体仍须按 AGPL 履行,Linux 包的许可字段据此写为 `MIT AND <引擎许可>`。 + +- 2026-10-20:ElyTable 公式候选由 Tab、Enter 或鼠标接受;接受后立即消耗本地候选,避免等待内核时重复接受。公式候选、参数提示、数据有效性下拉按钮与输入说明阻挡非滚轮鼠标事件,点击浮层不再交给网格。新增 GPUI 模拟窗口回归位于 `crates/ely-lok/src/view/tests/formula.rs` 与 `apps/ely-table/src/tests/aids.rs`;原生构建与界面验证仍待完成。 diff --git a/apps/ely-table/src/rules/aids.rs b/apps/ely-table/src/rules/aids.rs index caeb879..66870e4 100644 --- a/apps/ely-table/src/rules/aids.rs +++ b/apps/ely-table/src/rules/aids.rs @@ -9,8 +9,8 @@ use ely_gpui_component::{ use ely_lok::{LokView, sheet}; use ely_office::prelude::*; use gpui::{ - AnyElement, Context, Entity, IntoElement, ParentElement as _, Pixels, Point, Styled as _, div, - px, + AnyElement, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, + Point, Styled as _, div, px, }; use crate::book::Book; @@ -112,6 +112,7 @@ impl Book { .absolute() .left(corner.x) .top(corner.y) + .block_mouse_except_scroll() .child( IconButton::new("table-choices-open", IconName::ChevronDown) .variant(ButtonVariant::Ghost) @@ -137,6 +138,7 @@ impl Book { .absolute() .left(below.x) .top(below.y) + .block_mouse_except_scroll() .max_w(theme.tooltip_max_width()) .px_2() .py_1() diff --git a/apps/ely-table/src/tests.rs b/apps/ely-table/src/tests.rs index 20fe2fb..f774cbf 100644 --- a/apps/ely-table/src/tests.rs +++ b/apps/ely-table/src/tests.rs @@ -394,6 +394,7 @@ fn a_delete_stays_with_its_workbook() { } mod analysis; +mod aids; mod data; mod menu; mod names; diff --git a/apps/ely-table/src/tests/aids.rs b/apps/ely-table/src/tests/aids.rs new file mode 100644 index 0000000..24e29cb --- /dev/null +++ b/apps/ely-table/src/tests/aids.rs @@ -0,0 +1,111 @@ +use super::data::{cells, open, run}; +use super::*; +use ely_lok::sheet::Validation; +use gpui::{Modifiers, MouseButton, ParentElement as _, Styled as _, VisualTestContext, point, px}; + +struct Grid(Entity); + +impl gpui::Render for Grid { + fn render( + &mut self, + window: &mut gpui::Window, + cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + let grid = self.0.update(cx, |book, cx| book.grid(window, cx)); + gpui::div() + .relative() + .size_full() + .flex() + .flex_col() + .child(grid) + } +} + +fn frames(cx: &mut VisualTestContext) { + for _ in 0..3 { + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + } +} + +#[test] +fn validation_help_and_arrow_do_not_select_the_cells_behind_them() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some((_window, book)) = book(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + cx.update(ely_gpui_component::init_for_tests); + open(&mut cx, &book); + run(&mut cx, &book, "cell.goto", &["E2"]); + let rule = Validation { + kind: "list".into(), + operator: "equal".into(), + list: vec!["Red".into(), "Green".into()], + dropdown: true, + show_input: true, + input_message: "Pick a color".into(), + ..Validation::default() + }; + run(&mut cx, &book, "rules.validation", &[&rule.to_json()]); + settle(&cx, |app| { + book.read(app) + .active_tab() + .and_then(|tab| tab.extra.rule.as_ref()) + .is_some_and(|(at, rule)| at.1 == "E2" && rule.input_message == "Pick a color") + }); + let window = cx.add_window(|_, _| Grid(book.clone())); + let mut shown = VisualTestContext::from_window(window.into(), &cx); + frames(&mut shown); + let (view, help, arrow) = shown.update(|_, app| { + let view = book.read(app).active_tab().expect("a tab").view.clone(); + let read = view.read(app); + let (x, y, w, h) = read.cell().cursor.expect("the cell cursor"); + let help = read.origin() + read.place((x, y + h)) + point(px(8.0), px(8.0)); + let arrow = read.origin() + read.place((x + w, y + h / 2)) + point(px(8.0), px(0.0)); + (view.clone(), help, arrow) + }); + let sent = shown.update(|_, app| view.read(app).sent()); + shown.simulate_click(help, Modifiers::none()); + assert_eq!( + shown.update(|_, app| view.read(app).sent()), + sent, + "help never sends a grid click" + ); + shown.simulate_mouse_down(arrow, MouseButton::Left, Modifiers::none()); + assert_eq!( + shown.update(|_, app| view.read(app).sent()), + sent, + "the arrow press stays outside the grid" + ); + shown.simulate_mouse_up(arrow, MouseButton::Left, Modifiers::none()); + settle(&shown, |app| book.read(app).choices.is_some()); + frames(&mut shown); + shown.update(|_, app| { + assert_eq!(view.read(app).cell().address, "E2"); + assert_eq!( + view.read(app).sent(), + sent, + "the arrow release also stays outside the grid" + ); + assert_eq!( + book.read(app).choices.as_ref().expect("the menu").values, + ["Red", "Green"] + ); + }); + let red = shown + .debug_bounds("menu-row-Red") + .expect("the Red menu item"); + shown.simulate_click(red.center(), Modifiers::none()); + assert_eq!( + cells(&mut cx, &book, "E2"), + "Red", + "the value belongs to the original cell" + ); + assert_eq!( + cells(&mut cx, &book, "F2"), + "", + "the cell behind the arrow is unchanged" + ); +} diff --git a/crates/ely-lok/src/view/formula.rs b/crates/ely-lok/src/view/formula.rs index dd061d9..0c2772c 100644 --- a/crates/ely-lok/src/view/formula.rs +++ b/crates/ely-lok/src/view/formula.rs @@ -5,8 +5,8 @@ use ely_gpui_component::{ primitives::raise, }; use gpui::{ - AnyElement, Bounds, Context, Hsla, IntoElement, Keystroke, Modifiers, ParentElement, Pixels, - anchored, rgb, + AnyElement, Bounds, Context, Hsla, InteractiveElement, IntoElement, Keystroke, Modifiers, + ParentElement, Pixels, anchored, div, rgb, }; use super::{LokView, input::Input}; @@ -67,6 +67,8 @@ impl LokView { }; log::info!("lok: completes {}", item.label); self.send(Input::Complete(item.label.to_string())); + self.cell.functions.clear(); + self.picked = 0; true } @@ -94,7 +96,7 @@ impl LokView { let place = anchored() .position(cell.bottom_left()) .snap_to_window() - .child(hint); + .child(div().block_mouse_except_scroll().child(hint)); Some(raise("lok-view-hints", place).into_any_element()) } } diff --git a/crates/ely-lok/src/view/input.rs b/crates/ely-lok/src/view/input.rs index 5cccb33..6e7c0c4 100644 --- a/crates/ely-lok/src/view/input.rs +++ b/crates/ely-lok/src/view/input.rs @@ -270,6 +270,12 @@ impl LokView { /// Posts a non-text key; text waits for the IME. pub(super) fn key_down(&mut self, keystroke: &Keystroke) -> bool { + if keystroke.key == "enter" + && keystroke.modifiers == Modifiers::none() + && self.complete() + { + return true; + } let Some((char_code, key_code)) = vcl_key(keystroke) else { return false; }; diff --git a/crates/ely-lok/src/view/tests.rs b/crates/ely-lok/src/view/tests.rs index 9b22d82..61be403 100644 --- a/crates/ely-lok/src/view/tests.rs +++ b/crates/ely-lok/src/view/tests.rs @@ -4,6 +4,7 @@ mod borders; mod clip; mod commands; mod fit; +mod formula; mod input; mod large; mod show_borders; diff --git a/crates/ely-lok/src/view/tests/formula.rs b/crates/ely-lok/src/view/tests/formula.rs new file mode 100644 index 0000000..357c0d5 --- /dev/null +++ b/crates/ely-lok/src/view/tests/formula.rs @@ -0,0 +1,204 @@ +use super::*; +use gpui::{Keystroke, TestAppContext}; + +fn settle(cx: &TestAppContext, done: impl Fn(&App) -> bool) -> bool { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while std::time::Instant::now() < deadline { + if !cx.executor().tick() { + cx.executor() + .advance_clock(std::time::Duration::from_millis(20)); + } + if cx.update(|app| done(app)) { + return true; + } + } + false +} + +#[test] +fn enter_completes_the_selected_function_without_leaving_the_cell() { + let Some(install) = crate::client::tests::install() else { + eprintln!("SKIP: no pinned LOK build"); + return; + }; + let mut cx = TestAppContext::single(); + cx.update(ely_gpui_component::init_for_tests); + let (view, _) = cx.update(|app| { + live_view_of( + app, + &install, + "book.fods", + crate::client::tests::input::FODS, + ) + }); + let (_, window) = cx.add_window_view(|_, _| Host(view.clone())); + window.run_until_parked(); + window.update(|window, app| { + let focus = view.read(app).focus_handle().clone(); + focus.focus(window, app); + }); + assert!(settle(window, |app| !view + .read(app) + .cell + .address + .is_empty())); + let address = window.update(|_, app| view.read(app).cell.address.clone()); + view.update(window, |view, _| view.commit(None, "=look")); + assert!(settle(window, |app| { + let functions = &view.read(app).cell.functions; + functions.len() == 1 && functions[0].name == "LOOKUP" + })); + window.simulate_keystrokes("enter"); + assert!( + settle(window, |app| { + let view = view.read(app); + view.settled() && view.cell.formula == "=LOOKUP()" && view.cell.functions.is_empty() + }), + "Enter inserts the selected function" + ); + window.update(|_, app| { + assert_eq!( + view.read(app).cell.address, + address, + "completion stays in the cell" + ); + view.update(app, |view, _| { + view.commit(None, "1,1,1"); + }); + }); + assert!(settle(window, |app| { + let view = view.read(app); + view.settled() && view.cell.formula == "=LOOKUP(1,1,1)" + })); + window.simulate_keystrokes("enter"); + assert!( + settle(window, |app| { + let view = view.read(app); + view.settled() && view.cell.address != address + }), + "Enter without candidates commits and moves to the next cell" + ); +} + +#[test] +fn accepting_a_candidate_consumes_it_before_the_worker_answers() { + let Some(install) = crate::client::tests::install() else { + eprintln!("SKIP: no pinned LOK build"); + return; + }; + let cx = TestAppContext::single(); + let (view, worker) = cx.update(|app| { + live_view_of( + app, + &install, + "book.fods", + crate::client::tests::input::FODS, + ) + }); + cx.update(|app| view.update(app, |view, _| view.commit(None, "=look"))); + assert!(settle(&cx, |app| view + .read(app) + .cell + .functions + .iter() + .any(|item| item.name == "LOOKUP"))); + let held = worker.lock().expect("the worker lock"); + cx.update(|app| { + view.update(app, |view, _| { + let before = view.sent(); + assert!(view.key_down(&Keystroke::parse("enter").expect("Enter"))); + assert!( + view.cell.functions.is_empty(), + "accepted candidates cannot be reused" + ); + assert!( + !view.complete(), + "a second acceptance must not queue another completion" + ); + assert_eq!(view.sent(), before + 1); + assert!(view.key_down(&Keystroke::parse("enter").expect("Enter"))); + assert_eq!( + view.sent(), + before + 2, + "the second Enter is queued normally" + ); + }) + }); + drop(held); + assert!( + settle(&cx, |app| { + let view = view.read(app); + view.settled() && view.cell.address == "A2" + }), + "the second Enter commits and moves down" + ); +} + +struct Host(Entity); + +impl gpui::Render for Host { + fn render( + &mut self, + _: &mut gpui::Window, + _: &mut gpui::Context, + ) -> impl gpui::IntoElement { + self.0.clone() + } +} + +#[test] +fn clicking_a_function_does_not_send_mouse_input_to_the_sheet() { + let Some(install) = crate::client::tests::install() else { + eprintln!("SKIP: no pinned LOK build"); + return; + }; + let mut cx = TestAppContext::single(); + cx.update(ely_gpui_component::init_for_tests); + let (view, _) = cx.update(|app| { + live_view_of( + app, + &install, + "book.fods", + crate::client::tests::input::FODS, + ) + }); + let (_, window) = cx.add_window_view(|_, _| Host(view.clone())); + window.run_until_parked(); + view.update(window, |view, _| view.commit(None, "=look")); + assert!(settle(window, |app| { + let view = view.read(app); + view.settled() && view.cell.functions.len() == 1 && view.cell.functions[0].name == "LOOKUP" + })); + window.run_until_parked(); + let (on, address, sent) = window.update(|_, app| { + let view = view.read(app); + let cell = view.screen_rect(view.cell.cursor.expect("the cell cursor")); + ( + cell.bottom_left() + point(px(12.0), px(12.0)), + view.cell.address.clone(), + view.sent(), + ) + }); + window.simulate_mouse_down(on, gpui::MouseButton::Left, Default::default()); + window.update(|_, app| { + let view = view.read(app); + assert_eq!(view.sent(), sent, "the sheet never receives the press"); + assert!( + !view.dragging, + "the candidate press never starts a sheet drag" + ); + }); + window.simulate_mouse_up(on, gpui::MouseButton::Left, Default::default()); + assert!( + settle(window, |app| { + let view = view.read(app); + view.settled() && view.cell.formula == "=LOOKUP()" + }), + "the candidate receives the click" + ); + window.update(|_, app| { + let view = view.read(app); + assert_eq!(view.cell.address, address, "the active cell stays put"); + assert_eq!(view.sent(), sent + 1, "only completion reaches the sheet"); + }); +} diff --git a/docs/help.md b/docs/help.md index 8f7721c..d2ab159 100644 --- a/docs/help.md +++ b/docs/help.md @@ -14,6 +14,11 @@ - 窗口变窄时,两侧栏先变窄,再先收起右栏、后收起左栏,文档区至少保留 360 点;窗口变宽即回来。侧栏的宽度可拖动内侧边缘调整,按应用记住。 - 侧栏放不下全部文字时,分页按钮只显示图标,悬停可见名称。 +## ElyTable 的公式补全 + +- 单元格内输入公式时,上下键选择函数候选,Tab、Enter 或单击候选接受函数并继续编辑参数。没有函数候选时,Enter 提交输入并移到下一格。 +- 数据有效性的下拉按钮保留当前单元格,选择的值写入该格;点击输入说明不切换到其后被遮住的单元格。 + ## ElyMark 的菜单与快捷键 - 段落与格式菜单照 Typora。菜单里带 Shift 的标点键写成所出的字符,如 Ctrl+{ 即 Ctrl+Shift+[。标题:Ctrl+1 至 Ctrl+6,Ctrl+0 段落,Ctrl+= 与 Ctrl+- 升降一级。块:Ctrl+Shift+K 代码块,Ctrl+Shift+M 公式块,Ctrl+Shift+Q 引用,Ctrl+Shift+[ 有序列表,Ctrl+Shift+] 无序列表,Ctrl+Shift+X 任务列表。 From c5cb29fa34e929e8a00e778d614569f005d9542b Mon Sep 17 00:00:00 2001 From: Jetkwok Date: Tue, 20 Oct 2026 11:42:13 +0800 Subject: [PATCH 2/2] Guard completion callbacks and cancel occluded grid drags --- agents.md | 2 +- apps/ely-table/src/grid.rs | 5 + apps/ely-table/src/tests/aids.rs | 15 ++- crates/ely-lok/src/view.rs | 3 + crates/ely-lok/src/view/formula.rs | 29 +++-- crates/ely-lok/src/view/formula/completion.rs | 116 ++++++++++++++++++ crates/ely-lok/src/view/input.rs | 10 +- crates/ely-lok/src/view/lane.rs | 1 + crates/ely-lok/src/view/tests/formula.rs | 39 ++++-- 9 files changed, 198 insertions(+), 22 deletions(-) create mode 100644 crates/ely-lok/src/view/formula/completion.rs diff --git a/agents.md b/agents.md index c78e1ce..1613cbd 100644 --- a/agents.md +++ b/agents.md @@ -324,4 +324,4 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-19(用户定):代码与发布从 Gitea 搬到 GitHub 组织 ELY-Open-Source(`ELY-Open-Source/ElyOffice`;组件库 `ELY-Open-Source/Ely-GPUI-Components`,Cargo 的 git 依赖与 `fetch-references.sh` 随之改址,钉的提交不变;Kigi CLI 同在此组织)。历史、标签与发布已迁过,Gitea 停用,GitHub 是唯一的家。发布由 `.github/workflows/release.yml` 在推送 `v*` 标签时运行(只此一种触发,拉取请求到不了自托管机器):macOS 在 `[self-hosted, macos-signing]`(Mac mini 的图形会话,Developer ID 签名并公证),Windows 与 Linux(WSL)在 `[self-hosted, ely-windows]`;各机的常驻克隆 `$HOME/ely-ci/ElyOffice` 从 GitHub 拉取。`scripts/ci.sh` 经 GitHub API 按标签取发布、没有就建(`GITHUB_TOKEN`,`contents: write`),同名文件先删再传:重跑只换本平台的文件。更新检查读 `api.github.com/repos/ELY-Open-Source/ElyOffice/releases/latest` 的 `.json`,清单的下载地址为 `github.com/ELY-Open-Source/ElyOffice/releases/download/v<版本>/<文件>`;0.1.8 及以前装好的应用仍查 Gitea,需手动装一次新版。官网 worker 读 GitHub API(可选机密 `GITHUB_TOKEN` 避开共享出口地址的限流),下载与源码链接指向 GitHub(GitHub 标志);macOS 版已公证,去掉“未经公证”的提示。发布视频的源文件与成片里仍是 Gitea 地址,未改未重渲染。 - 2026-10-19(用户定):自有代码由 AGPL-3.0-or-later 改为 MIT(`LICENSE`,Copyright (c) 2026 Yang Zhang;工作区 `Cargo.toml`、Linux 包的许可字段、`docs/licenses.md`、官网)。第三方照旧:LibreOffice 与 `patches/libreoffice` 为 MPL-2.0 等,Git 为 GPL-2.0,MuPDF 为 AGPL-3.0;ElyPDF 链接 MuPDF,其发行包整体仍须按 AGPL 履行,Linux 包的许可字段据此写为 `MIT AND <引擎许可>`。 -- 2026-10-20:ElyTable 公式候选由 Tab、Enter 或鼠标接受;接受后立即消耗本地候选,避免等待内核时重复接受。公式候选、参数提示、数据有效性下拉按钮与输入说明阻挡非滚轮鼠标事件,点击浮层不再交给网格。新增 GPUI 模拟窗口回归位于 `crates/ely-lok/src/view/tests/formula.rs` 与 `apps/ely-table/src/tests/aids.rs`;原生构建与界面验证仍待完成。 +- 2026-10-20:ElyTable 公式候选由 Tab、Enter 或鼠标接受;接受后等待对应输入及其修订的事件快照,期间禁止重复接受旧候选;鼠标按所点函数名接受,不将旧菜单的序号套到新列表。公式候选、参数提示、数据有效性下拉按钮与输入说明阻挡非滚轮鼠标事件,点击浮层不再交给网格;行列调整与填充拖动在遮挡区域松手时取消,避免拖动状态残留。新增 GPUI 模拟窗口回归位于 `crates/ely-lok/src/view/tests/formula.rs` 与 `apps/ely-table/src/tests/aids.rs`;独立补全状态测试已通过;已安装引擎上的临时表格核对了 LOOKUP 补全、参数与提交。修改后的 GUI 原生构建与界面验证仍待完成。 diff --git a/apps/ely-table/src/grid.rs b/apps/ely-table/src/grid.rs index b26f0b0..5850531 100644 --- a/apps/ely-table/src/grid.rs +++ b/apps/ely-table/src/grid.rs @@ -65,6 +65,11 @@ impl Book { .bg(sunken) .on_mouse_move(cx.listener(Self::on_drag_move)) .on_mouse_up(MouseButton::Left, cx.listener(Self::on_drag_end)) + .on_mouse_up_out(MouseButton::Left, cx.listener(|book, _, _, cx| { + if book.drag.take().is_some() { + cx.notify(); + } + })) .child(div().flex().child(corner).child(column_strip)) .child( div().flex_1().min_h_0().flex().child(row_strip).child( diff --git a/apps/ely-table/src/tests/aids.rs b/apps/ely-table/src/tests/aids.rs index 24e29cb..6ee3c04 100644 --- a/apps/ely-table/src/tests/aids.rs +++ b/apps/ely-table/src/tests/aids.rs @@ -58,15 +58,26 @@ fn validation_help_and_arrow_do_not_select_the_cells_behind_them() { let window = cx.add_window(|_, _| Grid(book.clone())); let mut shown = VisualTestContext::from_window(window.into(), &cx); frames(&mut shown); - let (view, help, arrow) = shown.update(|_, app| { + let (view, help, arrow, handle) = shown.update(|_, app| { let view = book.read(app).active_tab().expect("a tab").view.clone(); let read = view.read(app); let (x, y, w, h) = read.cell().cursor.expect("the cell cursor"); let help = read.origin() + read.place((x, y + h)) + point(px(8.0), px(8.0)); let arrow = read.origin() + read.place((x + w, y + h / 2)) + point(px(8.0), px(0.0)); - (view.clone(), help, arrow) + let handle = read.origin() + read.place((x + w, y + h)) - point(px(1.0), px(1.0)); + (view.clone(), help, arrow, handle) }); let sent = shown.update(|_, app| view.read(app).sent()); + shown.simulate_mouse_down(handle, MouseButton::Left, Modifiers::none()); + assert!( + shown.update(|_, app| matches!(book.read(app).drag, Some(crate::grid::Drag::Fill { .. }))) + ); + shown.simulate_mouse_move(help, MouseButton::Left, Modifiers::none()); + shown.simulate_mouse_up(help, MouseButton::Left, Modifiers::none()); + assert!( + shown.update(|_, app| book.read(app).drag.is_none()), + "releasing over help must end the drag" + ); shown.simulate_click(help, Modifiers::none()); assert_eq!( shown.update(|_, app| view.read(app).sent()), diff --git a/crates/ely-lok/src/view.rs b/crates/ely-lok/src/view.rs index 2b5e9bf..94c1407 100644 --- a/crates/ely-lok/src/view.rs +++ b/crates/ely-lok/src/view.rs @@ -123,6 +123,7 @@ pub struct LokView { pointer: gpui::CursorStyle, selection: Vec, cell: crate::Cell, + completion: formula::Completion, /// Whether the kernel was told Ely's scheme. lit: bool, /// The zoom follows the viewport so a page fits. @@ -218,6 +219,7 @@ impl LokView { pointer: gpui::CursorStyle::Arrow, selection: Vec::new(), cell: crate::Cell::default(), + completion: formula::Completion::default(), lit: false, fit: false, turning: None, @@ -392,6 +394,7 @@ impl LokView { self.picked = 0; } self.cell = batch.cell; + self.completion.reported(batch.revision); self.graphic = batch.graphic; self.read_borders(batch.table, batch.revision); self.review = batch.review; diff --git a/crates/ely-lok/src/view/formula.rs b/crates/ely-lok/src/view/formula.rs index 0c2772c..f5b0057 100644 --- a/crates/ely-lok/src/view/formula.rs +++ b/crates/ely-lok/src/view/formula.rs @@ -11,6 +11,9 @@ use gpui::{ use super::{LokView, input::Input}; +mod completion; +pub(super) use completion::Completion; + impl LokView { /// The sheet shown, which reference marks follow. pub fn set_part(&mut self, part: usize) { @@ -48,7 +51,7 @@ impl LokView { /// Up and Down walk the offered functions. pub(super) fn walk(&mut self, keystroke: &Keystroke, cx: &mut Context) -> bool { let count = self.cell.functions.len(); - if count == 0 || keystroke.modifiers != Modifiers::none() { + if count == 0 || self.completion.pending() || keystroke.modifiers != Modifiers::none() { return false; } self.picked = match keystroke.key.as_str() { @@ -62,32 +65,42 @@ impl LokView { /// Takes the chosen function, in order with typing. pub(super) fn complete(&mut self) -> bool { + if self.completion.pending() { + return false; + } let Some(item) = self.offered().into_iter().nth(self.picked) else { return false; }; - log::info!("lok: completes {}", item.label); - self.send(Input::Complete(item.label.to_string())); - self.cell.functions.clear(); + self.complete_function(&item.label) + } + + fn complete_function(&mut self, name: &str) -> bool { + if self.completion.pending() || !self.cell.functions.iter().any(|item| item.name == name) { + return false; + } + log::info!("lok: completes {name}"); + self.send(Input::Complete(name.to_string())); + self.completion.queued(self.sent); self.picked = 0; true } /// The offered functions, else the call's usage, under the cell. pub(super) fn hints(&self, cx: &mut Context) -> Option { + if self.completion.pending() { + return None; + } let cell = self.screen_rect(self.cell.cursor?); let hint = if self.cell.functions.is_empty() { SignatureHelp::new("lok-view-usage", [usage(&self.cell.usage)?]).into_any_element() } else { let this = cx.entity(); let offered = self.offered(); - let labels: Vec<_> = offered.iter().map(|item| item.label.clone()).collect(); CompletionMenu::new("lok-view-functions", offered) .selected(self.picked) .on_pick(move |item, _, cx| { - let at = labels.iter().position(|label| *label == item.label); this.update(cx, |view, cx| { - view.picked = at.expect("a picked item is offered"); - view.complete(); + view.complete_function(&item.label); cx.notify(); }) }) diff --git a/crates/ely-lok/src/view/formula/completion.rs b/crates/ely-lok/src/view/formula/completion.rs new file mode 100644 index 0000000..18ab9eb --- /dev/null +++ b/crates/ely-lok/src/view/formula/completion.rs @@ -0,0 +1,116 @@ +#[derive(Default)] +pub(crate) struct Completion { + pending: Option<(u64, Option)>, + reported: u64, +} + +impl Completion { + pub(crate) fn pending(&self) -> bool { + self.pending.is_some() + } + + pub(crate) fn queued(&mut self, seq: u64) { + self.pending = Some((seq, None)); + } + + pub(crate) fn reported(&mut self, revision: u64) { + self.reported = revision; + self.finish(); + } + + pub(crate) fn landed(&mut self, seq: u64, revision: Option) { + if self.pending.is_some_and(|(waiting, _)| waiting == seq) { + self.pending = revision.map(|revision| (seq, Some(revision))); + self.finish(); + } + } + + fn finish(&mut self) { + // Inline callbacks and serialized output preserve completion revisions. + if self + .pending + .is_some_and(|(_, revision)| revision.is_some_and(|revision| self.reported >= revision)) + { + self.pending = None; + } + } +} + +#[cfg(test)] +mod tests { + use super::Completion; + + #[test] + fn an_old_snapshot_cannot_reopen_an_accepted_menu() { + let mut completion = Completion::default(); + completion.reported(10); + completion.queued(7); + completion.reported(10); + assert!(completion.pending()); + completion.landed(7, Some(11)); + assert!(completion.pending()); + completion.reported(10); + assert!(completion.pending()); + completion.reported(11); + assert!(!completion.pending()); + } + + #[test] + fn a_snapshot_before_the_reply_waits_for_the_reply() { + let mut completion = Completion::default(); + completion.queued(7); + completion.reported(11); + assert!(completion.pending()); + completion.landed(7, Some(11)); + assert!(!completion.pending()); + } + + #[test] + fn a_reply_before_the_snapshot_waits_for_the_snapshot() { + let mut completion = Completion::default(); + completion.queued(7); + completion.landed(7, Some(11)); + assert!(completion.pending()); + completion.reported(11); + assert!(!completion.pending()); + } + + #[test] + fn another_input_cannot_acknowledge_the_completion() { + let mut completion = Completion::default(); + completion.queued(7); + completion.reported(20); + completion.landed(6, Some(20)); + assert!(completion.pending()); + completion.landed(8, None); + assert!(completion.pending()); + completion.landed(7, Some(19)); + assert!(!completion.pending()); + } + + #[test] + fn a_failed_completion_allows_retry() { + let mut completion = Completion::default(); + completion.queued(7); + completion.landed(7, None); + assert!(!completion.pending()); + completion.queued(8); + assert!(completion.pending()); + } + + #[test] + fn the_next_completion_requires_its_own_snapshot() { + let mut completion = Completion::default(); + completion.reported(11); + completion.queued(7); + completion.landed(7, Some(11)); + assert!(!completion.pending()); + completion.queued(8); + completion.landed(8, Some(12)); + assert!(completion.pending()); + completion.reported(11); + assert!(completion.pending()); + completion.reported(12); + assert!(!completion.pending()); + } +} diff --git a/crates/ely-lok/src/view/input.rs b/crates/ely-lok/src/view/input.rs index 6e7c0c4..71b396f 100644 --- a/crates/ely-lok/src/view/input.rs +++ b/crates/ely-lok/src/view/input.rs @@ -313,10 +313,14 @@ impl LokView { _: &mut Window, cx: &mut Context, ) { + let completing = self.completion.pending(); if self.walk(&event.keystroke, cx) || self.turn_key(&event.keystroke) || self.key_down(&event.keystroke) { + if !completing && self.completion.pending() { + cx.notify(); + } cx.stop_propagation(); } } @@ -431,8 +435,10 @@ impl LokView { self.send(Input::Paste); } - pub(super) fn on_next_field(&mut self, _: &NextField, _: &mut Window, _: &mut Context) { - if !self.complete() { + pub(super) fn on_next_field(&mut self, _: &NextField, _: &mut Window, cx: &mut Context) { + if self.complete() { + cx.notify(); + } else { self.key_down(&Keystroke::parse("tab").expect("a keystroke")); } } diff --git a/crates/ely-lok/src/view/lane.rs b/crates/ely-lok/src/view/lane.rs index 3df1557..b0d0b6d 100644 --- a/crates/ely-lok/src/view/lane.rs +++ b/crates/ely-lok/src/view/lane.rs @@ -107,6 +107,7 @@ impl LokView { } self.edited += u64::from(edits); let (outcome, now) = outcome; + self.completion.landed(seq, if outcome.is_ok() { now } else { None }); if edits { self.clock.landed(now); } diff --git a/crates/ely-lok/src/view/tests/formula.rs b/crates/ely-lok/src/view/tests/formula.rs index 357c0d5..b8a0b41 100644 --- a/crates/ely-lok/src/view/tests/formula.rs +++ b/crates/ely-lok/src/view/tests/formula.rs @@ -43,12 +43,20 @@ fn enter_completes_the_selected_function_without_leaving_the_cell() { .address .is_empty())); let address = window.update(|_, app| view.read(app).cell.address.clone()); - view.update(window, |view, _| view.commit(None, "=look")); + view.update(window, |view, _| view.commit(None, "=lo")); assert!(settle(window, |app| { let functions = &view.read(app).cell.functions; - functions.len() == 1 && functions[0].name == "LOOKUP" + functions.iter().any(|item| item.name == "LOOKUP") + && functions.iter().any(|item| item.name == "LOG") })); - window.simulate_keystrokes("enter"); + // Keep two valid offers to exercise keyboard selection. + view.update(window, |view, cx| { + view.cell + .functions + .retain(|item| item.name == "LOG" || item.name == "LOOKUP"); + cx.notify(); + }); + window.simulate_keystrokes("down enter"); assert!( settle(window, |app| { let view = view.read(app); @@ -104,16 +112,18 @@ fn accepting_a_candidate_consumes_it_before_the_worker_answers() { .any(|item| item.name == "LOOKUP"))); let held = worker.lock().expect("the worker lock"); cx.update(|app| { - view.update(app, |view, _| { + view.update(app, |view, cx| { + let old = Events { + revision: view.doc.rev, + cell: view.cell.clone(), + ..Events::default() + }; let before = view.sent(); assert!(view.key_down(&Keystroke::parse("enter").expect("Enter"))); - assert!( - view.cell.functions.is_empty(), - "accepted candidates cannot be reused" - ); + view.apply_batch(old, None, cx); assert!( !view.complete(), - "a second acceptance must not queue another completion" + "an old event must not reoffer an accepted candidate" ); assert_eq!(view.sent(), before + 1); assert!(view.key_down(&Keystroke::parse("enter").expect("Enter"))); @@ -188,6 +198,17 @@ fn clicking_a_function_does_not_send_mouse_input_to_the_sheet() { "the candidate press never starts a sheet drag" ); }); + // Change candidates without repainting the already pressed row. + view.update(window, |view, _| { + view.cell.functions.insert( + 0, + crate::Function { + name: "LOG".into(), + signature: String::new(), + description: String::new(), + }, + ); + }); window.simulate_mouse_up(on, gpui::MouseButton::Left, Default::default()); assert!( settle(window, |app| {