Fix ElyTable completion keys and overlay click-through
This commit is contained in:
1 parent
bddf765f77
commit
cc0f5a9caf
9 files changed
+339
-5
No files matched your search
@@ -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`;原生构建与界面验证仍待完成。
|
||||
@@ -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()
|
||||
|
||||
@@ -394,6 +394,7 @@ fn a_delete_stays_with_its_workbook() {
|
||||
}
|
||||
|
||||
mod analysis;
|
||||
mod aids;
|
||||
mod data;
|
||||
mod menu;
|
||||
mod names;
|
||||
|
||||
@@ -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,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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ mod borders;
|
||||
mod clip;
|
||||
mod commands;
|
||||
mod fit;
|
||||
mod formula;
|
||||
mod input;
|
||||
mod large;
|
||||
mod show_borders;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -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 任务列表。
|
||||
|
||||
Reference in new issue
Block a user