From 46ade03dde25da7ba68f0340407c69a3cfa7469e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 10 Oct 2026 01:08:36 -0400 Subject: [PATCH] ElyDoc: kernel reads for styles, fonts, bookmarks and sections leave the UI thread; a late dialog read never replaces a newer dialog (D-03) --- apps/ely-doc/src/docs.rs | 19 ++++++ apps/ely-doc/src/insert.rs | 45 ++++++------- apps/ely-doc/src/props.rs | 64 +++++++++++------- apps/ely-doc/src/props/tests.rs | 10 ++- apps/ely-doc/src/refs.rs | 116 +++++++++++++++++--------------- apps/ely-doc/src/refs/tests.rs | 50 ++++++++++++++ tasks/BUGS.md | 2 +- 7 files changed, 201 insertions(+), 105 deletions(-) diff --git a/apps/ely-doc/src/docs.rs b/apps/ely-doc/src/docs.rs index 21fc43c..9932c27 100644 --- a/apps/ely-doc/src/docs.rs +++ b/apps/ely-doc/src/docs.rs @@ -46,8 +46,14 @@ pub struct Docs { pub back: Vec<(u64, ely_lok::Rect)>, /// Last jump: tab, input, prior cursor moves, target. pub going: Option<(u64, u64, u64, ely_lok::Rect)>, + /// Dialog reads asked; a dialog or tab change voids older. + pub dialog_asked: u64, + dialog_seen: Seen, } +/// Open dialog kinds, Ely's and the shell's, and the tab. +type Seen = (Option>, Option>, Option); + /// The right column's panels. #[derive(Clone, Copy, Debug, PartialEq)] pub enum Side { @@ -171,6 +177,7 @@ impl Docs { failed(docs, error, cx) }) .detach(); + cx.observe_self(|docs, _| docs.watch_dialogs()).detach(); Docs { core: Core::new(worker, work, cx), dialog: None, @@ -183,6 +190,18 @@ impl Docs { navigating: true, back: Vec::new(), going: None, + dialog_asked: 0, + dialog_seen: (None, None, None), + } + } + + /// A dialog or tab change ends earlier dialog reads. + fn watch_dialogs(&mut self) { + let ely = self.dialog.as_ref().map(std::mem::discriminant); + let now = (ely, self.core.dialog.as_ref().map(std::mem::discriminant), self.core.active); + if self.dialog_seen != now { + self.dialog_seen = now; + self.dialog_asked += 1; } } diff --git a/apps/ely-doc/src/insert.rs b/apps/ely-doc/src/insert.rs index d947f34..7364b32 100644 --- a/apps/ely-doc/src/insert.rs +++ b/apps/ely-doc/src/insert.rs @@ -128,29 +128,28 @@ impl Docs { let text = Docs::text(cx, "command.blocked.no_document", &[]); return self.toast(text, Severity::Warning, cx); }; - let listed = self - .core - .worker - .lock() - .expect("the worker lock") - .values(&tab.doc, ".uno:StyleApply") - .and_then(|json| ely_lok::commands::styles(&json, "8")); - let names = match listed { - Ok(names) => names, - Err(error) => { - let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]); - return self.toast(text, Severity::Danger, cx); - } - }; - let name = (2..) - .map(|n| Docs::text(cx, "doc.section.name", &[("n", &n.to_string())]).to_string()) - .find(|name| !names.contains(name)) - .expect("a free section name"); - log::info!("doc: a new section"); - self.run_active("style.new", &[&name, "8"], cx); - self.run_active("page.section", &["3", &name], cx); - self.props.update(cx, |props, _| props.refresh_lists()); - cx.notify(); + let id = tab.id; + // Page style names are read off the window thread. + self.ask_in( + id, + |worker, doc| ely_lok::commands::styles(&worker.values(doc, ".uno:StyleApply")?, "8"), + move |docs, listed, _, cx| { + let names = match listed { + Ok(names) => names, + Err(error) => return docs.engine_failed(&error.to_string(), cx), + }; + let name = (2..) + .map(|n| Docs::text(cx, "doc.section.name", &[("n", &n.to_string())]).to_string()) + .find(|name| !names.contains(name)) + .expect("a free section name"); + log::info!("doc: a new section"); + docs.run_in(Some(id), "style.new", &[&name, "8"], cx); + docs.run_in(Some(id), "page.section", &["3", &name], cx); + docs.props.update(cx, |props, _| props.refresh_lists()); + cx.notify(); + }, + cx, + ); } pub fn on_page_number(&mut self, _: &InsertPageNumber, _: &mut Window, cx: &mut Context) { diff --git a/apps/ely-doc/src/props.rs b/apps/ely-doc/src/props.rs index b87c2ac..48ccfc6 100644 --- a/apps/ely-doc/src/props.rs +++ b/apps/ely-doc/src/props.rs @@ -113,43 +113,55 @@ impl Props { self.read_styles(cx); } + /// The installed fonts, read off the window thread. fn read_fonts(&mut self, doc: &Doc, cx: &mut Context) { - let read = self - .worker - .lock() - .expect("the worker lock") - .values(doc, ".uno:CharFontName") - .and_then(|json| ely_lok::commands::fonts(&json)); - match read { - Ok(fonts) => self.fonts = fonts.into_iter().map(Into::into).collect(), - Err(error) => cx.emit(LokError(format!("the font list is unreadable: {error}"))), - } + ely_lok::read_off( + &self.worker, + doc, + |worker, doc| ely_lok::commands::fonts(&worker.values(doc, ".uno:CharFontName")?), + |props: &mut Props, read, cx| match read { + Ok(fonts) => { + props.fonts = fonts.into_iter().map(Into::into).collect(); + cx.notify(); + } + Err(error) => cx.emit(LokError(format!("the font list is unreadable: {error}"))), + }, + cx, + ); } + /// The shown document's style lists, read off the window thread. fn read_styles(&mut self, cx: &mut Context) { - let Some(shown) = self.shown.as_mut() else { + let Some(shown) = self.shown.as_ref() else { return; }; - let read = self - .worker - .lock() - .expect("the worker lock") - .values(&shown.doc, ".uno:StyleApply") - .and_then(|json| { + let view = shown.view.entity_id(); + ely_lok::read_off( + &self.worker, + &shown.doc, + |worker, doc| { + let json = worker.values(doc, ".uno:StyleApply")?; let list = |family| -> std::io::Result> { let names = ely_lok::commands::styles(&json, family)?; Ok(names.into_iter().map(Into::into).collect()) }; Ok((list("2")?, list("1")?, list("8")?)) - }); - match read { - Ok((paragraph, character, pages)) => { - shown.paragraph = paragraph; - shown.character = character; - shown.pages = pages; - } - Err(error) => cx.emit(LokError(format!("the style list is unreadable: {error}"))), - } + }, + move |props: &mut Props, read, cx| { + // Another tab shown since: its own read follows. + let Some(shown) = props.shown.as_mut().filter(|shown| shown.view.entity_id() == view) else { + return; + }; + match read { + Ok((paragraph, character, pages)) => { + (shown.paragraph, shown.character, shown.pages) = (paragraph, character, pages); + cx.notify(); + } + Err(error) => cx.emit(LokError(format!("the style list is unreadable: {error}"))), + } + }, + cx, + ); } /// Re-reads the style lists once queued input lands. diff --git a/apps/ely-doc/src/props/tests.rs b/apps/ely-doc/src/props/tests.rs index 1511d3e..35a62c2 100644 --- a/apps/ely-doc/src/props/tests.rs +++ b/apps/ely-doc/src/props/tests.rs @@ -11,13 +11,19 @@ use ely_office::prelude::*; /// The panel, shown on the active tab. pub(super) fn panel(cx: &mut TestAppContext, docs: &Entity) -> Entity { - cx.update(|app| { + let props = cx.update(|app| { let tab = docs.read(app).active_tab().expect("a tab"); let (view, doc) = (tab.view.clone(), tab.doc.clone()); let props = docs.read(app).props.clone(); props.update(app, |props, cx| props.show(Some((&view, &doc)), cx)); props - }) + }); + // The lists come from a background read. + settle(cx, |app| { + let props = props.read(app); + !props.fonts.is_empty() && props.shown.as_ref().is_some_and(|shown| !shown.paragraph.is_empty()) + }); + props } fn run(cx: &mut TestAppContext, props: &Entity, id: &str, values: &[&str]) { diff --git a/apps/ely-doc/src/refs.rs b/apps/ely-doc/src/refs.rs index c5c44fc..81011b8 100644 --- a/apps/ely-doc/src/refs.rs +++ b/apps/ely-doc/src/refs.rs @@ -71,35 +71,51 @@ pub fn items(cx: &App) -> Vec { } impl Docs { - /// A kernel list for the active document; failures toast. - fn read(&self, command: &str, cx: &mut Context) -> Option { + /// Reads a kernel list off the window thread, then `then`. + fn read_then( + &mut self, + command: &'static str, + then: impl FnOnce(&mut Self, String, &mut Window, &mut Context) + 'static, + cx: &mut Context, + ) { let Some(tab) = self.active_tab() else { let text = Docs::text(cx, "command.blocked.no_document", &[]); - self.toast(text, Severity::Warning, cx); - return None; + return self.toast(text, Severity::Warning, cx); }; - let read = self - .core - .worker - .lock() - .expect("the worker lock") - .values(&tab.doc, command); - read.map_err(|error| { - let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]); - self.toast(text, Severity::Danger, cx); - }) - .ok() + let id = tab.id; + self.dialog_asked += 1; + let asked = self.dialog_asked; + self.ask_in( + id, + move |worker, doc| worker.values(doc, command), + move |docs, read, window, cx| { + // A dialog opened since, or a later ask, wins. + if docs.dialog_asked != asked || docs.dialog.is_some() || docs.core.dialog.is_some() { + return log::info!("doc: a dialog read for tab {id} lapsed"); + } + match read { + Ok(json) => then(docs, json, window, cx), + Err(error) => docs.engine_failed(&error.to_string(), cx), + } + }, + cx, + ); } - fn bookmarks(&self, cx: &mut Context) -> Option> { - let listed = ely_lok::review::bookmarks(&self.read(".uno:Bookmarks", cx)?); - listed - .map(|marks| marks.into_iter().map(|mark| mark.name).collect()) - .map_err(|error| { - let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]); - self.toast(text, Severity::Danger, cx); - }) - .ok() + /// The bookmarks' names, then `then`. + fn with_bookmarks( + &mut self, + then: impl FnOnce(&mut Self, Vec, &mut Window, &mut Context) + 'static, + cx: &mut Context, + ) { + self.read_then( + ".uno:Bookmarks", + move |docs, json, window, cx| match ely_lok::review::bookmarks(&json) { + Ok(marks) => then(docs, marks.into_iter().map(|mark| mark.name).collect(), window, cx), + Err(error) => docs.engine_failed(&error.to_string(), cx), + }, + cx, + ); } fn ask(&mut self, ask: Ask, text: &str, window: &mut Window, cx: &mut Context) { @@ -126,10 +142,8 @@ impl Docs { self.run_active("note.endnote", &[], cx); } - pub fn on_bookmark(&mut self, _: &InsertBookmark, window: &mut Window, cx: &mut Context) { - if let Some(taken) = self.bookmarks(cx) { - self.ask(Ask::Bookmark(taken), "", window, cx); - } + pub fn on_bookmark(&mut self, _: &InsertBookmark, _: &mut Window, cx: &mut Context) { + self.with_bookmarks(|docs, taken, window, cx| docs.ask(Ask::Bookmark(taken), "", window, cx), cx); } pub fn on_link(&mut self, _: &InsertLink, window: &mut Window, cx: &mut Context) { @@ -141,19 +155,17 @@ impl Docs { } pub fn on_reference(&mut self, _: &InsertReference, _: &mut Window, cx: &mut Context) { - let Some(marks) = self.bookmarks(cx) else { - return; - }; - let Some(mark) = marks.first().cloned() else { - let text = Docs::text(cx, "doc.reference.none", &[]); - return self.toast(text, Severity::Warning, cx); - }; - self.dialog = Some(Dialog::Reference { - marks, - mark, - show: "2", - }); - cx.notify(); + self.with_bookmarks( + |docs, marks, _, cx| { + let Some(mark) = marks.first().cloned() else { + let text = Docs::text(cx, "doc.reference.none", &[]); + return docs.toast(text, Severity::Warning, cx); + }; + docs.dialog = Some(Dialog::Reference { marks, mark, show: "2" }); + cx.notify(); + }, + cx, + ); } pub fn on_caption(&mut self, _: &InsertCaption, window: &mut Window, cx: &mut Context) { @@ -173,18 +185,16 @@ impl Docs { self.ask(Ask::Caption(category), "", window, cx); } - pub fn on_formula(&mut self, _: &InsertFormula, window: &mut Window, cx: &mut Context) { - let Some(json) = self.read(".uno:Formula", cx) else { - return; - }; - match ely_lok::review::formula(&json) { - Ok(Some(formula)) => self.ask(Ask::Formula(true), &formula, window, cx), - Ok(None) => self.ask(Ask::Formula(false), "", window, cx), - Err(error) => { - let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]); - self.toast(text, Severity::Danger, cx); - } - } + pub fn on_formula(&mut self, _: &InsertFormula, _: &mut Window, cx: &mut Context) { + self.read_then( + ".uno:Formula", + |docs, json, window, cx| match ely_lok::review::formula(&json) { + Ok(Some(formula)) => docs.ask(Ask::Formula(true), &formula, window, cx), + Ok(None) => docs.ask(Ask::Formula(false), "", window, cx), + Err(error) => docs.engine_failed(&error.to_string(), cx), + }, + cx, + ); } pub fn on_comment(&mut self, _: &InsertComment, window: &mut Window, cx: &mut Context) { diff --git a/apps/ely-doc/src/refs/tests.rs b/apps/ely-doc/src/refs/tests.rs index 4755195..0fb4217 100644 --- a/apps/ely-doc/src/refs/tests.rs +++ b/apps/ely-doc/src/refs/tests.rs @@ -5,6 +5,8 @@ use crate::tests::{blank, docs, settle, settled, text_of, type_into, with_window /// The open prompt's request and field. fn asked(cx: &TestAppContext, docs: &Entity) -> (Ask, Entity) { + // Prompts open once their kernel read comes back. + cx.run_until_parked(); cx.update(|app| match &docs.read(app).dialog { Some(Dialog::Ask { ask, field }) => (ask.clone(), field.clone()), _ => panic!("a prompt is open"), @@ -35,6 +37,7 @@ fn references_go_in_through_their_dialogs() { with_window(&mut cx, &docs, |docs, window, cx| { docs.on_reference(&InsertReference, window, cx) }); + cx.run_until_parked(); assert!( cx.update(|app| docs.read(app).dialog.is_none()), "no bookmark, no cross-reference dialog" @@ -55,6 +58,7 @@ fn references_go_in_through_their_dialogs() { with_window(&mut cx, &docs, |docs, window, cx| { docs.on_reference(&InsertReference, window, cx) }); + cx.run_until_parked(); cx.update(|app| docs.update(app, |docs, cx| docs.insert_reference(cx))); with_window(&mut cx, &docs, |docs, window, cx| { docs.on_link(&InsertLink, window, cx) @@ -188,3 +192,49 @@ fn a_long_formula_prefills_whole() { let (_, field) = asked(&cx, &docs); assert_eq!(cx.update(|app| field.read(app).text().to_string()), long); } + +#[test] +fn a_late_dialog_read_never_replaces_a_newer_dialog() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + blank(&mut cx, &docs); + // The link dialog opens before the bookmarks come back. + with_window(&mut cx, &docs, |docs, window, cx| { + docs.on_bookmark(&InsertBookmark, window, cx); + docs.on_link(&InsertLink, window, cx); + }); + cx.run_until_parked(); + assert!(cx.update(|app| matches!(docs.read(app).dialog, Some(Dialog::Link { .. }))), "the link dialog stays"); + // Opened and closed meanwhile: the old ask lapses too. + with_window(&mut cx, &docs, |docs, window, cx| { + docs.dialog = None; + cx.notify(); + docs.on_bookmark(&InsertBookmark, window, cx); + docs.on_link(&InsertLink, window, cx); + }); + cx.update(|app| docs.update(app, |docs, cx| { + docs.dialog = None; + cx.notify(); + })); + cx.run_until_parked(); + assert!(cx.update(|app| docs.read(app).dialog.is_none()), "nothing pops up later"); + // A close prompt shown and cancelled meanwhile counts too. + let id = cx.update(|app| docs.read(app).core.active.expect("a tab")); + type_into(&mut cx, &docs, "edited"); + settled(&cx, &docs); + with_window(&mut cx, &docs, |docs, window, cx| { + docs.on_bookmark(&InsertBookmark, window, cx); + docs.request_close(id, ely_office::After::Tab, window, cx); + }); + assert!(cx.update(|app| docs.read(app).core.dialog.is_some()), "the close asks"); + cx.update(|app| docs.update(app, |docs, cx| { + docs.core.dialog = None; + cx.notify(); + })); + cx.run_until_parked(); + assert!(cx.update(|app| docs.read(app).dialog.is_none()), "no bookmark prompt after the cancel"); +} diff --git a/tasks/BUGS.md b/tasks/BUGS.md index 1e5d995..9326b94 100644 --- a/tasks/BUGS.md +++ b/tasks/BUGS.md @@ -49,7 +49,7 @@ - [x] P-14 文字块含控制字符时 JSON 回复不可解析 — 共用 JSON 写入:控制字符转义,非法字节与孤立代理为 U+FFFD - [x] D-01 导出或打印对话框开着时关闭其标签即崩溃 — Docs 实现 tab_closed,关标签即撤下其导出对话框;R1 PASS - [x] D-02 邮件合并以当前标签为模板,预览后生成的每份都是同一条记录 — 选数据时记下模板标签,预览副本不作模板;R1 测试绕过选 CSV 入口;R2 PASS -- [ ] D-03 界面线程同步持 worker 锁读内核(样式、字体、书签、公式、分节名) +- [x] D-03 界面线程同步持 worker 锁读内核(样式、字体、书签、公式、分节名) — 后台读取,回来核对标签、视图与对话框请求号 - [ ] D-04 一条批注的时间读不出,全部批注与修订都不显示 - [ ] D-05 邮件合并某条记录失败时内核文档泄漏 - [ ] D-06 导航栏大纲点击按当前列表取下标,后台重读后可能越界