Fix ElyTable completion keys and overlay click-through

This commit is contained in:
Jetkwok committed 2026-10-20 11:18:13 +08:00
1 parent bddf765f77
commit cc0f5a9caf
9 files changed
+339 -5

No files matched your search

+2
View File
@@ -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 标签、链接地址与引用标签、图片、脚注引用,`<code>`、`<kbd>` 之间(含嵌套;交叉闭合照可视的读法关闭与重开)的文字照原文;`\\(` 不换。公式里与段落里另有的 `$`(含 `\$`)解析时暂写成文本里没有的控制字节,解析后还原,不与换上的 `$` 配对;相邻的两个公式之间暂插一个空格,解析后去掉,仍是两个公式。公式节点的范围是整对分隔符,值是写下的 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` 核对分隔符的各种情形(相邻、`\$`、链接地址、`<code>`、跨块),`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` 的 `<slug>.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`;原生构建与界面验证仍待完成。
+4 -2
View File
@@ -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()
+1
View File
@@ -394,6 +394,7 @@ fn a_delete_stays_with_its_workbook() {
}
mod analysis;
mod aids;
mod data;
mod menu;
mod names;
+111
View File
@@ -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<Book>);
impl gpui::Render for Grid {
fn render(
&mut self,
window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> 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"
);
}
+5 -3
View File
@@ -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())
}
}
+6
View File
@@ -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;
};
+1
View File
@@ -4,6 +4,7 @@ mod borders;
mod clip;
mod commands;
mod fit;
mod formula;
mod input;
mod large;
mod show_borders;
+204
View File
@@ -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<LokView>);
impl gpui::Render for Host {
fn render(
&mut self,
_: &mut gpui::Window,
_: &mut gpui::Context<Self>,
) -> 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");
});
}
+5
View File
@@ -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 任务列表。