From cc0f5a9cafe84e5a6b43859211bb882a43ab00cd Mon Sep 17 00:00:00 2001 From: Jetkwok Date: Tue, 20 Oct 2026 11:18:13 +0800 Subject: [PATCH] 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 任务列表。