Guard completion callbacks and cancel occluded grid drags

This commit is contained in:
Jetkwok committed 2026-10-20 11:42:13 +08:00
1 parent cc0f5a9caf
commit c5cb29fa34
9 files changed
+198 -22

No files matched your search

+1 -1
View File
@@ -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` 的 `<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`;原生构建与界面验证仍待完成。
- 2026-10-20:ElyTable 公式候选由 Tab、Enter 或鼠标接受;接受后等待对应输入及其修订的事件快照,期间禁止重复接受旧候选;鼠标按所点函数名接受,不将旧菜单的序号套到新列表。公式候选、参数提示、数据有效性下拉按钮与输入说明阻挡非滚轮鼠标事件,点击浮层不再交给网格;行列调整与填充拖动在遮挡区域松手时取消,避免拖动状态残留。新增 GPUI 模拟窗口回归位于 `crates/ely-lok/src/view/tests/formula.rs` 与 `apps/ely-table/src/tests/aids.rs`;独立补全状态测试已通过;已安装引擎上的临时表格核对了 LOOKUP 补全、参数与提交。修改后的 GUI 原生构建与界面验证仍待完成。
+5
View File
@@ -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(
+13 -2
View File
@@ -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()),
+3
View File
@@ -123,6 +123,7 @@ pub struct LokView {
pointer: gpui::CursorStyle,
selection: Vec<Rect>,
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;
+21 -8
View File
@@ -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<Self>) -> 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<Self>) -> Option<AnyElement> {
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();
})
})
@@ -0,0 +1,116 @@
#[derive(Default)]
pub(crate) struct Completion {
pending: Option<(u64, Option<u64>)>,
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<u64>) {
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());
}
}
+8 -2
View File
@@ -313,10 +313,14 @@ impl LokView {
_: &mut Window,
cx: &mut Context<Self>,
) {
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<Self>) {
if !self.complete() {
pub(super) fn on_next_field(&mut self, _: &NextField, _: &mut Window, cx: &mut Context<Self>) {
if self.complete() {
cx.notify();
} else {
self.key_down(&Keystroke::parse("tab").expect("a keystroke"));
}
}
+1
View File
@@ -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);
}
+30 -9
View File
@@ -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| {