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| {