Guard completion callbacks and cancel occluded grid drags
This commit is contained in:
1 parent
cc0f5a9caf
commit
c5cb29fa34
9 files changed
+198
-22
No files matched your search
@@ -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 原生构建与界面验证仍待完成。
|
||||
@@ -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(
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
Reference in new issue
Block a user