54 Commits
Author SHA1 Message Date
ZacharyZhang-NY 6505f35669 Reviews for D-08 2026-10-10 02:17:18 -04:00
ZacharyZhang-NY 92b515cbf8 Print is off where it is not built; no blocked command runs from the palette (D-08) 2026-10-10 02:17:18 -04:00
ZacharyZhang-NY 58e55cf35a Reviews for D-07 2026-10-10 01:55:39 -04:00
ZacharyZhang-NY 1488d615d1 ElyDoc: a hidden navigator reads nothing on each cursor move (D-07) 2026-10-10 01:55:39 -04:00
ZacharyZhang-NY 0d3d3603c8 Reviews for D-06 2026-10-10 01:50:29 -04:00
ZacharyZhang-NY db9b3975fe ElyDoc: an outline click goes to the heading drawn under it, even after a reread (D-06) 2026-10-10 01:50:28 -04:00
ZacharyZhang-NY 0b478701b5 Reviews for D-05 2026-10-10 01:36:37 -04:00
ZacharyZhang-NY d34f9c72b6 ElyDoc: a failed mail-merge record still closes its kernel document (D-05) 2026-10-10 01:36:37 -04:00
ZacharyZhang-NY 796d6a91aa Reviews for D-04 2026-10-10 01:27:21 -04:00
ZacharyZhang-NY c13787f71d ElyDoc: one unreadable note time spares the other notes and the changes; each is named, read aloud and bounded (D-04) 2026-10-10 01:27:20 -04:00
ZacharyZhang-NY 530a0cd261 Reviews for D-03 2026-10-10 01:08:36 -04:00
ZacharyZhang-NY 46ade03dde ElyDoc: kernel reads for styles, fonts, bookmarks and sections leave the UI thread; a late dialog read never replaces a newer dialog (D-03) 2026-10-10 01:08:36 -04:00
ZacharyZhang-NY baba4593c0 Reviews for P-14 2026-10-10 00:29:44 -04:00
ZacharyZhang-NY ca4a32f2fa ElyPDF worker: text blocks are always valid JSON, whatever the text or font name holds (P-14) 2026-10-10 00:29:44 -04:00
ZacharyZhang-NY dff9ed3bf4 Reviews for P-13 2026-10-10 00:17:21 -04:00
ZacharyZhang-NY a354340237 ElyPDF: an edited document's export does not quote the file's size (P-13) 2026-10-10 00:17:21 -04:00
ZacharyZhang-NY aad589a6ab Reviews for P-12 2026-10-10 00:12:31 -04:00
ZacharyZhang-NY 8429d9e421 ElyPDF worker: kept links are counted as they are kept (P-12) 2026-10-10 00:12:31 -04:00
ZacharyZhang-NY 90f6d17bec Reviews for P-11 2026-10-10 00:09:26 -04:00
ZacharyZhang-NY 6b3ba569dd ElyPDF worker: a failed undo re-reads the history mark (P-11) 2026-10-10 00:09:26 -04:00
ZacharyZhang-NY bc133169e3 Reviews for P-10 2026-10-10 00:02:40 -04:00
ZacharyZhang-NY 3d20036bff ElyPDF: a preview overtaken by an edit says so (P-10) 2026-10-10 00:02:40 -04:00
ZacharyZhang-NY 7e479a96f8 Reviews for P-09 2026-10-09 23:31:19 -04:00
ZacharyZhang-NY 86bb0d4e1c ElyPDF: a refused OCR text layer reports no success (P-09) 2026-10-09 23:31:19 -04:00
ZacharyZhang-NY 2182b71f1f Reviews for P-08 2026-10-09 23:26:40 -04:00
ZacharyZhang-NY bd7bc46d8c ElyPDF: failed reads stay unread until an edit; a save stales a signature read (P-08) 2026-10-09 23:26:40 -04:00
ZacharyZhang-NY 547dbf2cc5 Reviews for P-07 2026-10-09 22:51:33 -04:00
ZacharyZhang-NY a28e618c07 ElyPDF: text blocks with ligatures can be edited (P-07) 2026-10-09 22:51:33 -04:00
ZacharyZhang-NY 15a54bcf2e Reviews for P-06 2026-10-09 22:18:33 -04:00
ZacharyZhang-NY a49b0b5db3 ElyPDF: a document font that cannot take new text asks for another font (P-06) 2026-10-09 22:18:33 -04:00
ZacharyZhang-NY 301365a7e6 Reviews for P-05 2026-10-09 22:02:59 -04:00
ZacharyZhang-NY d7a4c37c1f ElyPDF: quick strokes and clicks queue behind the last edit instead of being refused (P-05) 2026-10-09 22:02:50 -04:00
ZacharyZhang-NY 4cec71703b ElyShow: a refused show start always says why (S-10) 2026-10-09 21:47:31 -04:00
ZacharyZhang-NY f01150be05 ElyShow: a slide size the kernel did not take stops showing (S-09) 2026-10-09 21:23:25 -04:00
ZacharyZhang-NY da81ebd777 ElyTable: test that a failed print says printing failed (T-13 follow-up) 2026-10-09 21:09:47 -04:00
ZacharyZhang-NY b46fedb327 ElyShow: typed notes survive a refused send and a read while typing (S-08) 2026-10-09 21:02:57 -04:00
ZacharyZhang-NY 33df906449 ElyShow: check rows do not jump among masters (S-07) 2026-10-09 20:36:11 -04:00
ZacharyZhang-NY b7e2c56272 ElyShow: a typed 0 in the slide range no longer exports every slide (S-06) 2026-10-09 20:31:13 -04:00
ZacharyZhang-NY 5c345b6f7c ElyShow: an unreadable comment time no longer empties the comments column (S-05) 2026-10-09 20:27:51 -04:00
ZacharyZhang-NY 41a99962aa ElyShow: an open dialog is never pushed aside; a stuck show start is gone (S-04) 2026-10-09 20:17:16 -04:00
ZacharyZhang-NY fd6e651478 ElyTable: a text file's default encoding follows the app's language (T-15) 2026-10-09 19:56:59 -04:00
ZacharyZhang-NY f27e73575d ElyTable: the import scan keeps one row at a time, not the whole file (T-14) 2026-10-09 19:53:50 -04:00
ZacharyZhang-NY 843657a6b3 Printing failures say printing failed, in every app (T-13) 2026-10-09 19:49:08 -04:00
ZacharyZhang-NY 18158a8282 ElyTable: page setup applies with whole-column or whole-row print areas (T-12) 2026-10-09 19:24:50 -04:00
ZacharyZhang-NY 759d84e20b ElyTable: closing a tab closes its dialog too (T-11) 2026-10-09 19:12:08 -04:00
ZacharyZhang-NY 345e248ff7 ElyTable: a CSV import pushed aside by another dialog comes back with its choices (T-10) 2026-10-09 19:06:57 -04:00
ZacharyZhang-NY 6145b5cea2 ElyTable: a large text file no longer freezes the window before its import dialog (T-09) 2026-10-09 18:42:39 -04:00
ZacharyZhang-NY 0c87b0bbf7 ElyTable: a column filter button sorts and filters its own range (T-08) 2026-10-09 18:36:23 -04:00
ZacharyZhang-NY 4d7ec45027 ElyTable: the wheel scrolls past the data without waiting for the kernel (T-06) 2026-10-09 18:30:52 -04:00
ZacharyZhang-NY 798e00571a ElyTable: header clicks and row or column sizes work right after a cell click (T-07) 2026-10-09 18:20:28 -04:00
ZacharyZhang-NY 7a12a28b67 ElyTable: the sheet's size follows every scale change; its end stays reachable after a zoom (T-05) 2026-10-09 18:11:39 -04:00
ZacharyZhang-NY 201aa4fc5a Bug list: UPD-1's last two fixes pass their review (UPD-2) 2026-10-09 16:54:57 -04:00
ZacharyZhang-NY 40cd554953 Bug list: a review item for UPD-1's last two fixes 2026-10-09 16:45:36 -04:00
ZacharyZhang-NY 022978bb9b Tasks: 0.1.2 published on git.zacharyzhang.com 2026-10-09 16:45:15 -04:00
162 changed files with 2474 additions and 443 deletions

No files matched your search

+1
View File
@@ -146,6 +146,7 @@ tests/scripts/lo-unregistered.sh “打开方式”里没有 LibreOffice,构
tests/scripts/lokview.sh 真窗口里的 LokView:输入法、键入、点击、拖动,AX 树里的段落文本;ELY_CLIPBOARD_TEST=1 再测复制粘贴(会覆盖系统剪贴板)
tests/scripts/doc-shell.sh 真窗口 ElyDoc:空白标签进 AX 树,启动后 Cmd+N 打开新建对话框(需辅助功能权限)
tests/scripts/doc-props.sh 真窗口 ElyDoc 属性栏:段前段后连续键入、回车、失焦,读 AX 值与命令数(需辅助功能权限)
tests/scripts/doc-review.sh 真窗口 ElyDoc:无日期的批注逐条写明作者与原因(AX 可读),多条时批注与修订列表仍在窗口内(需辅助功能权限)
tests/scripts/theme.sh 真窗口 ElyTable 以深、浅两种外观启动,截窗口量网格亮度(需屏幕录制权限)
tests/scripts/lag.sh ely-doc 真窗口键入,从调试日志算输入落地、图块、按键到新图块的分位数(需辅助功能权限);ELY_LAG_LOG=路径 留下日志
tests/scripts/pdf-idle.sh 真窗口 ElyPDF 无文件启动时无 worker 子进程
+1
View File
@@ -22,6 +22,7 @@ ely-ui.workspace = true
[dev-dependencies]
ely-ui = { workspace = true, features = ["test-support"] }
ely-gpui-component = { workspace = true, features = ["test-support"] }
env_logger.workspace = true
image.workspace = true
tempfile.workspace = true
+2 -1
View File
@@ -80,7 +80,8 @@ impl Docs {
let shown = self
.active_tab()
.map(|tab| (tab.view.clone(), tab.doc.clone()));
let tab = shown.as_ref().map(|(view, doc)| (view, doc));
// Hidden, it follows nothing: no reads on every move.
let tab = shown.as_ref().filter(|_| self.navigating).map(|(view, doc)| (view, doc));
self.navigator
.update(cx, |navigator, cx| navigator.show(tab, cx));
shown.filter(|_| self.navigating)?;
+19
View File
@@ -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<std::mem::Discriminant<Dialog>>, Option<std::mem::Discriminant<ely_office::Dialog>>, Option<u64>);
/// 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;
}
}
+22 -23
View File
@@ -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<Self>) {
+5 -3
View File
@@ -247,9 +247,11 @@ impl Docs {
let mut worker = worker.lock().expect("the worker lock");
let staged = template.with_file_name(format!("record.{extension}"));
let made = worker.load(&ely_lok::file_url(&template)).and_then(|doc| {
fill(&mut worker, &doc, &table.headers, row)?;
doc.save_as(&mut worker, &ely_lok::file_url(&staged), extension)?;
worker.close_doc(doc)?;
let saved = fill(&mut worker, &doc, &table.headers, row)
.and_then(|()| doc.save_as(&mut worker, &ely_lok::file_url(&staged), extension));
// Closed even when this record failed.
let closed = worker.close_doc(doc);
saved.and(closed)?;
publish(&staged, target)
});
made.map_err(|error| (n + 1, error))?;
+4 -6
View File
@@ -272,15 +272,13 @@ impl Render for Navigator {
if let Some(at) = self.outline.iter().position(|heading| heading.current) {
outline = outline.current(at.to_string());
}
let this = this.clone();
// The places shown: a reread may change the list.
let places: Vec<_> = self.outline.iter().map(|heading| heading.rect).collect();
let docs = self.docs.clone();
outline
.on_select(move |key, _, cx| {
let at: usize = key.parse().expect("an outline index");
let (to, docs) = {
let navigator = this.read(cx);
(navigator.outline[at].rect, navigator.docs.clone())
};
Navigator::jump(&docs, to, cx);
Navigator::jump(&docs, places[at], cx);
})
.into_any_element()
}
+75
View File
@@ -252,3 +252,78 @@ fn pdfs_export_a_range_and_locked_files_ask() {
"the locked text opens"
);
}
#[test]
fn a_hidden_navigator_reads_nothing() {
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;
};
crate::tests::blank(&mut cx, &docs);
let follows = |cx: &mut TestAppContext| cx.update(|app| docs.read(app).navigator.read(app).shown.is_some());
cx.update(|app| docs.update(app, |docs, cx| docs.left_view(cx)));
assert!(follows(&mut cx), "shown: it follows the tab");
cx.update(|app| docs.update(app, |docs, cx| {
docs.navigating = false;
docs.left_view(cx)
}));
assert!(!follows(&mut cx), "hidden: no tab to reread");
cx.update(|app| docs.update(app, |docs, cx| {
docs.navigating = true;
docs.left_view(cx)
}));
assert!(follows(&mut cx), "shown again: it reads once more");
}
/// The navigator alone as a window's view.
struct Host(Entity<Navigator>);
impl gpui::Render for Host {
fn render(&mut self, _: &mut gpui::Window, _: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
gpui::div().size_full().child(self.0.clone())
}
}
#[test]
fn an_outline_click_goes_where_its_row_was_drawn() {
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;
};
cx.update(ely_gpui_component::init_for_tests);
crate::tests::blank(&mut cx, &docs);
let navigator = cx.update(|app| docs.read(app).navigator.clone());
let heading = |n: i32| ely_lok::review::Heading { text: format!("Part {n}"), level: 0, rect: (0, n * 100, 10, 10), current: false };
cx.update(|app| navigator.update(app, |navigator, cx| {
navigator.outline = vec![heading(1), heading(2), heading(3)];
cx.notify();
}));
let (_, vcx) = cx.add_window_view(|_, _| Host(navigator.clone()));
let target = |vcx: &mut gpui::VisualTestContext| vcx.update(|_, app| docs.read(app).going.map(|going| going.3));
// Find where the drawn third row sits.
let third = (0..120)
.map(|step| gpui::point(gpui::px(40.), gpui::px(step as f32 * 4.)))
.find(|at| {
vcx.simulate_click(*at, gpui::Modifiers::none());
target(vcx) == Some((0, 300, 10, 10))
})
.expect("the third row is drawn");
vcx.update(|_, app| docs.update(app, |docs, _| docs.going = None));
// The list shrinks; the click lands on the old frame.
vcx.update(|window, app| {
navigator.update(app, |navigator, cx| {
navigator.outline.truncate(1);
cx.notify();
});
let (position, modifiers, button) = (third, gpui::Modifiers::none(), gpui::MouseButton::Left);
let down = gpui::MouseDownEvent { position, modifiers, button, click_count: 1, first_mouse: false };
window.dispatch_event(gpui::PlatformInput::MouseDown(down), app);
let up = gpui::MouseUpEvent { position, modifiers, button, click_count: 1 };
window.dispatch_event(gpui::PlatformInput::MouseUp(up), app);
});
assert_eq!(target(vcx), Some((0, 300, 10, 10)), "the click goes where its row was");
}
+2 -4
View File
@@ -81,10 +81,8 @@ impl Docs {
let path = source.work.join("print.pdf");
let printing = path.clone();
return self.write_pdf(tab, path, pdf, cx, move |docs, written, cx| match written {
Ok(()) => ely_office::print_later(printing, cx, |docs, error, cx| {
docs.output_failed(&error, cx)
}),
Err(error) => docs.output_failed(&error.to_string(), cx),
Ok(()) => ely_office::print_later(printing, cx),
Err(error) => docs.print_failed(&error.to_string(), cx),
});
}
let Some(dir) = ely_office::folder(source) else {
+38 -26
View File
@@ -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<Self>) {
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<Self>) {
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<Vec<SharedString>> {
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.
+8 -2
View File
@@ -11,13 +11,19 @@ use ely_office::prelude::*;
/// The panel, shown on the active tab.
pub(super) fn panel(cx: &mut TestAppContext, docs: &Entity<Docs>) -> Entity<Props> {
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<Props>, id: &str, values: &[&str]) {
+63 -53
View File
@@ -71,35 +71,51 @@ pub fn items(cx: &App) -> Vec<MenuItem> {
}
impl Docs {
/// A kernel list for the active document; failures toast.
fn read(&self, command: &str, cx: &mut Context<Self>) -> Option<String> {
/// 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<Self>) + 'static,
cx: &mut Context<Self>,
) {
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<Self>) -> Option<Vec<String>> {
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<String>, &mut Window, &mut Context<Self>) + 'static,
cx: &mut Context<Self>,
) {
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<Self>) {
@@ -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<Self>) {
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>) {
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<Self>) {
@@ -141,19 +155,17 @@ impl Docs {
}
pub fn on_reference(&mut self, _: &InsertReference, _: &mut Window, cx: &mut Context<Self>) {
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<Self>) {
@@ -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<Self>) {
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>) {
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<Self>) {
+50
View File
@@ -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<Docs>) -> (Ask, Entity<TextInput>) {
// 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");
}
+69 -26
View File
@@ -8,11 +8,11 @@ use std::{
use ely_gpui_component::{
buttons::{Button, ButtonVariant, IconButton, SegmentedControl},
collab::{Comment, CommentSidebar, Peer, Thread},
feedback::EmptyState,
feedback::{EmptyState, InlineMessage},
forms::{Switch, TextInput},
layout::ScrollArea,
lists::{List, ListItem},
primitives::IconName,
primitives::{IconName, Severity},
theme::ControlSize,
};
use ely_lok::{
@@ -20,7 +20,7 @@ use ely_lok::{
review::{Change, Note},
};
use gpui::{
App, Context, Entity, EventEmitter, IntoElement, Menu, MenuItem, ParentElement as _, Render,
App, Context, Entity, EventEmitter, InteractiveElement as _, IntoElement, Menu, StatefulInteractiveElement as _, MenuItem, ParentElement as _, Render,
SharedString, Styled as _, Subscription, Window, actions, div,
};
@@ -86,6 +86,8 @@ pub struct Review {
shown: Option<Shown>,
notes: Vec<Note>,
threads: Vec<Thread>,
/// Notes whose time does not read: time and why.
unread: Vec<Unread>,
pub(crate) changes: Vec<Change>,
active: Option<SharedString>,
pane: Pane,
@@ -100,6 +102,7 @@ impl Review {
shown: None,
notes: Vec::new(),
threads: Vec::new(),
unread: Vec::new(),
changes: Vec::new(),
active: None,
pane: Pane::Comments,
@@ -117,6 +120,7 @@ impl Review {
self.active = None;
self.notes.clear();
self.threads.clear();
self.unread.clear();
self.changes.clear();
cx.notify();
let Some((view, doc)) = tab else {
@@ -161,21 +165,8 @@ impl Review {
return;
}
shown.reading = false;
let read = read.and_then(|(notes, changes)| {
let threads = threads(&notes).map_err(std::io::Error::other)?;
Ok((notes, threads, changes))
});
match read {
Ok((notes, threads, changes)) => {
log::info!(
"doc: review lists {} notes, {} changes",
notes.len(),
changes.len()
);
review.notes = notes;
review.threads = threads;
review.changes = changes;
}
Ok((notes, changes)) => review.take_lists(notes, changes),
Err(error) => cx.emit(LokError(format!(
"the review lists are unreadable: {error}"
))),
@@ -366,12 +357,61 @@ impl Render for Review {
.p_2()
.child(head)
.child(pane)
// Many bad notes scroll; the lists stay in reach.
.children(self.unread_alert(cx).map(|alert| {
ScrollArea::new("doc-review-unread-scroll").vertical().w_full().max_h(gpui::rems(6.)).flex_none().child(alert)
}))
.child(div().flex_1().min_h_0().child(body))
}
}
/// The notes as threads under their first comment.
fn threads(notes: &[Note]) -> Result<Vec<Thread>, String> {
/// A note whose time does not read, and why.
#[derive(Clone, Debug, PartialEq)]
pub struct Unread {
pub author: String,
pub at: String,
pub error: String,
}
impl Review {
/// The kernel's lists; one bad time spares the rest.
fn take_lists(&mut self, notes: Vec<Note>, changes: Vec<Change>) {
let (threads, unread) = threads(&notes);
for bad in &unread {
log::error!("doc: a bad comment time {:?}: {}", bad.at, bad.error);
}
log::info!("doc: review lists {} notes, {} changes", notes.len(), changes.len());
(self.notes, self.threads, self.unread, self.changes) = (notes, threads, unread, changes);
}
/// Each unreadable note, by its author and the reason.
fn unread_lines(&self, cx: &App) -> Vec<SharedString> {
let line = |bad: &Unread| {
Docs::text(cx, "doc.review.unread", &[("author", &bad.author), ("at", &bad.at), ("error", &bad.error)])
};
self.unread.iter().map(line).collect()
}
/// The lines as one alert assistive tech reads whole.
fn unread_alert(&self, cx: &App) -> Option<gpui::AnyElement> {
let lines = self.unread_lines(cx);
let said: SharedString = lines.join("\n").into();
(!lines.is_empty()).then(|| {
div()
.id("doc-review-unread")
.role(gpui::Role::Alert)
.aria_label(said)
.flex()
.flex_col()
.gap_1()
.children(lines.into_iter().map(|line| InlineMessage::new(Severity::Danger, line)))
.into_any_element()
})
}
}
/// Notes threaded under their first comment; bad times apart.
fn threads(notes: &[Note]) -> (Vec<Thread>, Vec<Unread>) {
let parents: HashMap<&str, &str> = notes
.iter()
.map(|note| (note.id.as_str(), note.parent.as_str()))
@@ -384,13 +424,16 @@ fn threads(notes: &[Note]) -> Result<Vec<Thread>, String> {
id.to_string()
};
let authors: Vec<&str> = notes.iter().map(|note| note.author.as_str()).collect();
let mut threads: Vec<Thread> = Vec::new();
let (mut threads, mut unread): (Vec<Thread>, Vec<Unread>) = (Vec::new(), Vec::new());
for note in notes {
let at = note
.at
.parse::<jiff::civil::DateTime>()
.and_then(|at| at.to_zoned(jiff::tz::TimeZone::system()))
.map_err(|error| format!("a bad comment time {}: {error}", note.at))?;
let read = note.at.parse::<jiff::civil::DateTime>().and_then(|at| at.to_zoned(jiff::tz::TimeZone::system()));
let at = match read {
Ok(at) => at,
Err(error) => {
unread.push(Unread { author: note.author.clone(), at: note.at.clone(), error: error.to_string() });
continue;
}
};
let hue = authors
.iter()
.position(|name| *name == note.author)
@@ -412,7 +455,7 @@ fn threads(notes: &[Note]) -> Result<Vec<Thread>, String> {
}),
}
}
Ok(threads)
(threads, unread)
}
#[cfg(test)]
+37
View File
@@ -82,3 +82,40 @@ fn comments_thread_and_changes_accept_or_reject() {
}
assert!(!text.contains("gone"), "the rejected words go");
}
#[test]
fn one_unreadable_time_spares_the_other_notes() {
let note = |id: &str, parent: &str, at: &str| Note {
id: id.into(),
parent: parent.into(),
author: "A".into(),
text: "t".into(),
resolved: false,
at: at.into(),
anchor: (0, 0, 0, 0),
};
// The kernel writes an empty date as 0-00-00.
let mut undated = note("2", "0", "0-00-00");
undated.author = "Undated".into();
let notes = vec![note("1", "0", "2025-03-01T10:00:00"), undated, note("3", "1", "2025-03-02T09:00:00")];
let (threads, unread) = threads(&notes);
assert_eq!(threads.len(), 1);
assert_eq!(threads[0].comments.len(), 2, "the reply stays in its thread");
assert_eq!((unread.len(), unread[0].author.as_str(), unread[0].at.as_str()), (1, "Undated", "0-00-00"));
// Through the refresh path: changes stay, the line names both.
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = gpui::TestAppContext::single();
let Some(docs) = crate::tests::docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let review = cx.update(|app| docs.read(app).review.clone());
let change = Change { index: 7, author: "B".into(), kind: "Insert".into(), description: String::new(), at: "2025-03-01T10:00:00".into(), anchor: None };
let lines = cx.update(|cx| review.update(cx, |review, cx| {
review.take_lists(notes, vec![change]);
assert_eq!(review.changes.len(), 1, "the changes stay");
review.unread_lines(cx)
}));
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("Undated") && lines[0].contains(&unread[0].error), "{}", lines[0]);
}
+1 -3
View File
@@ -343,9 +343,7 @@ impl Desk {
if !job.lost.whole() {
self.warn_lost(&job.lost, None, cx);
}
return ely_office::print_later(pdf, cx, |desk, why, cx| {
desk.exported(Err(io::Error::other(why)), cx)
});
return ely_office::print_later(pdf, cx);
};
// Never over the open file, not even through a link.
let moved = not_open(self, &to)
+35 -14
View File
@@ -9,7 +9,7 @@ use gpui::{AnyElement, App, Context, IntoElement, Pixels, Point, Styled as _, ca
use crate::content::{Aim, Tool};
use crate::layout::Placed;
use crate::pages::{rect_on, to_view};
use crate::view::PdfView;
use crate::view::{PdfView, Unread};
/// How far a click may move, in window pixels.
pub(crate) const CLICK: f32 = 3.0;
@@ -77,31 +77,49 @@ impl PdfView {
if self.reading_notes {
return;
}
let wanted: Vec<usize> = pages.into_iter().filter(|page| !self.notes.contains_key(page)).take(BATCH).collect();
let wanted: Vec<usize> = (pages.into_iter())
.filter(|page| !self.notes.contains_key(page) && !self.unread.contains(&Unread::Notes(*page)))
.take(BATCH)
.collect();
if wanted.is_empty() {
return;
}
let asked = wanted.clone();
self.reading_notes = true;
let (doc, version) = (self.doc, self.version);
self.off(
// Page by page: one bad page spares the rest.
move |worker| {
(wanted.into_iter())
.map(|page| Ok((page, worker.marks(doc, page)?, worker.links(doc, page)?)))
.collect::<std::io::Result<Vec<_>>>()
let read = (wanted.into_iter())
.map(|page| (page, worker.marks(doc, page).and_then(|marks| Ok((marks, worker.links(doc, page)?)))))
.collect::<Vec<_>>();
Ok(read)
},
move |view, read, cx| {
move |view, read: std::io::Result<Vec<_>>, cx| {
view.reading_notes = false;
if view.version != version {
return cx.notify();
}
match read {
Ok(read) => {
for (page, marks, links) in read {
// No worker at all: every page asked failed.
let read = read.unwrap_or_else(|error| {
let why = error.to_string();
asked.into_iter().map(|page| (page, Err(std::io::Error::other(why.clone())))).collect()
});
let mut first = None;
for (page, page_read) in read {
match page_read {
Ok((marks, links)) => {
view.notes.insert(page, marks);
view.links.insert(page, links);
}
Err(error) => {
view.unread.insert(Unread::Notes(page));
first.get_or_insert(error);
}
}
Err(error) => view.fail("reading annotations", &error, cx),
}
if let Some(error) = first {
view.fail("reading annotations", &error, cx);
}
cx.notify();
},
@@ -111,7 +129,7 @@ impl PdfView {
/// Reads every field name in the form, once an edit.
pub fn ask_names(&mut self, cx: &mut Context<Self>) {
if self.form_names.is_some() || self.reading_names {
if self.form_names.is_some() || self.reading_names || self.unread.contains(&Unread::Names) {
return;
}
self.reading_names = true;
@@ -125,7 +143,10 @@ impl PdfView {
}
match read {
Ok(names) => view.form_names = Some(names),
Err(error) => view.fail("reading field names", &error, cx),
Err(error) => {
view.unread.insert(Unread::Names);
view.fail("reading field names", &error, cx)
}
}
cx.notify();
},
@@ -254,11 +275,11 @@ impl PdfView {
return;
}
let author = ely_ui::settings::current(cx).author.clone();
self.settled_edit(what, cx, move |worker, doc| {
self.edit_at(what, move |worker, doc| {
let name = ely_platform::user::named(&author)?;
annots.iter_mut().for_each(|annot| annot.author = name.clone());
worker.annotate(doc, &annots)
});
}, cx);
}
/// A press with a drawing or form tool.
+6 -3
View File
@@ -15,7 +15,7 @@ use gpui::{
};
use crate::document::{RemoveAttachment, SaveAttachment};
use crate::view::PdfView;
use crate::view::{PdfView, Unread};
use crate::viewer::Viewer;
/// A size as people read it.
@@ -30,7 +30,7 @@ pub fn size(bytes: u64) -> String {
impl PdfView {
/// Reads the attachments once an edit.
pub fn ask_files(&mut self, cx: &mut Context<Self>) {
if self.files.is_some() || self.reading_files {
if self.files.is_some() || self.reading_files || self.unread.contains(&Unread::Files) {
return;
}
self.reading_files = true;
@@ -44,7 +44,10 @@ impl PdfView {
}
match read {
Ok(files) => view.files = Some(files),
Err(error) => view.fail("reading attachments", &error, cx),
Err(error) => {
view.unread.insert(Unread::Files);
view.fail("reading attachments", &error, cx)
}
}
cx.notify();
},
+8 -3
View File
@@ -8,7 +8,7 @@ use gpui::{AnyElement, App, Context, IntoElement, Pixels, Point, Styled as _, di
use crate::layout::Placed;
use crate::pages::rect_on;
use crate::view::PdfView;
use crate::view::{PdfView, Unread};
/// What a press on a page does.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -139,7 +139,9 @@ impl PdfView {
if !self.tool.edits() || self.reading_layer {
return;
}
let Some(page) = pages.iter().copied().find(|page| !self.layers.contains_key(page)) else {
let Some(page) = (pages.iter().copied())
.find(|page| !self.layers.contains_key(page) && !self.unread.contains(&Unread::Layer(*page)))
else {
return;
};
self.reading_layer = true;
@@ -156,7 +158,10 @@ impl PdfView {
Ok(layer) => {
view.layers.insert(page, layer);
}
Err(error) => view.fail("reading what the page holds", &error, cx),
Err(error) => {
view.unread.insert(Unread::Layer(page));
view.fail("reading what the page holds", &error, cx)
}
}
cx.notify();
},
+28 -4
View File
@@ -19,6 +19,8 @@ pub struct Queued {
doc: Doc,
/// The edit may add, drop, move or reshape pages.
pages: bool,
/// Aimed at a point: runs on the state it meets.
follow: bool,
}
/// The document as an edit left it.
@@ -41,6 +43,23 @@ impl PdfView {
self.queue_edit(what, None, false, op, cx);
}
/// Queues a point-aimed edit; only moving pages refuse it.
pub fn edit_at(
&mut self,
what: &'static str,
op: impl FnOnce(&mut Worker, Doc) -> io::Result<String> + Send + 'static,
cx: &mut Context<Self>,
) {
if self.moving || self.queue.iter().any(|queued| queued.pages) {
let text = crate::viewer::Viewer::text(cx, "pdf.pages.changed", &[]);
cx.emit(crate::view::Failed(text.to_string()));
return cx.notify();
}
log::info!("pdf: queues {what}");
self.queue.push_back(Queued { what, op: Box::new(op), pick: None, doc: self.doc, pages: false, follow: true });
self.next_edit(cx);
}
/// Queues an edit to the pages themselves.
pub fn edit_pages(
&mut self,
@@ -62,7 +81,7 @@ impl PdfView {
) {
log::info!("pdf: queues {what}");
// Aimed at what is shown now; later states refuse it.
self.queue.push_back(Queued { what, op: Box::new(op), pick, doc: self.doc, pages });
self.queue.push_back(Queued { what, op: Box::new(op), pick, doc: self.doc, pages, follow: false });
self.next_edit(cx);
}
@@ -70,10 +89,12 @@ impl PdfView {
if self.editing {
return;
}
let Some(Queued { what, op, pick, doc, pages }) = self.queue.pop_front() else {
let Some(Queued { what, op, pick, doc, pages, follow }) = self.queue.pop_front() else {
return;
};
self.editing = true;
// Earlier edits have landed; a point still means the same.
let doc = if follow { self.doc } else { doc };
(self.editing, self.moving) = (true, pages);
self.off(
move |worker| {
let mark = op(worker, doc)?;
@@ -83,7 +104,7 @@ impl PdfView {
Ok(Fresh { doc, mark, pages, first, marks: worker.outline(doc)? })
},
move |view, fresh, cx| {
view.editing = false;
(view.editing, view.moving) = (false, false);
match fresh {
Ok(fresh) => {
view.refresh(fresh, pages, cx);
@@ -128,6 +149,7 @@ impl PdfView {
}
self.marks = fresh.marks;
self.failed.clear();
self.unread.clear();
self.text.clear();
self.asking_text.clear();
self.thumbs_failed.clear();
@@ -151,8 +173,10 @@ impl PdfView {
/// The file now holds history `mark`.
pub fn saved(&mut self, mark: &str, cx: &mut Context<Self>) {
self.saved = mark.to_string();
self.saves += 1;
// The file changed; read it again.
self.signatures = None;
self.unread.retain(|unread| !matches!(unread, crate::view::Unread::Signatures(_)));
cx.emit(Reported);
}
+4 -2
View File
@@ -165,10 +165,12 @@ impl Viewer {
let Some(view) = self.tab(tab).map(|tab| tab.view.clone()).filter(|_| !pages.is_empty()) else {
return self.toast(summary, Severity::Info, cx);
};
self.on_same(&view, run.version, cx, |view, cx| {
let laid = self.on_same(&view, run.version, cx, |view, cx| {
view.edit("adding recognised text", move |worker, doc| worker.text_layer(doc, &pages, replace), cx)
});
self.toast(summary, Severity::Success, cx);
if laid {
self.toast(summary, Severity::Success, cx);
}
}
/// The OCR dialog: the ask, then the progress.
+4 -2
View File
@@ -95,7 +95,7 @@ impl Viewer {
id,
move |worker, doc| worker.get()?.print_copy(*doc, &out),
move |viewer, done, _, cx| match done {
Ok(()) => ely_office::print_later(printing, cx, |viewer, error, cx| viewer.panel_failed(&error, cx)),
Ok(()) => ely_office::print_later(printing, cx),
Err(error) => viewer.failed(&error, cx),
},
cx,
@@ -203,6 +203,8 @@ impl Viewer {
Err(error) => return self.failed(&error, cx),
}
let (work, path, revision) = (tab.work.join("export.out"), tab.session.path().map(PathBuf::from), tab.session.revision());
// Unsaved edits: the file on disk is not "before".
let compared = path.clone().filter(|_| !tab.view.read(cx).modified());
let opened = target.clone();
log::info!("pdf: exports {}", if kind == Kind::Text { "text" } else { "an optimized copy" });
self.read_in(
@@ -214,7 +216,7 @@ impl Viewer {
}
let bytes = std::fs::read(&work)?;
std::fs::remove_file(&work)?;
let before = path.as_deref().map(std::fs::metadata).transpose()?.map(|meta| meta.len());
let before = compared.as_deref().map(std::fs::metadata).transpose()?.map(|meta| meta.len());
ely_files::export(&target, path.as_deref(), &bytes, revision)?;
Ok((before, bytes.len() as u64))
},
+13 -4
View File
@@ -17,7 +17,7 @@ use ely_platform::trust::{self, Trust};
use gpui::{AnyElement, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, StatefulInteractiveElement as _, Styled as _, TaskExt as _, Window, div};
use crate::redact::beside;
use crate::view::PdfView;
use crate::view::{PdfView, Unread};
use crate::viewer::Viewer;
/// A signature field and the system's word on its certificate.
@@ -47,11 +47,13 @@ pub struct Signer {
impl PdfView {
/// Reads and checks the signatures of the file at `path`.
pub fn ask_signatures(&mut self, path: PathBuf, cx: &mut Context<Self>) {
if self.signatures.as_ref().is_some_and(|(read, _)| *read == path) || self.reading_signatures {
let failed = self.unread.contains(&Unread::Signatures(path.clone()));
if self.signatures.as_ref().is_some_and(|(read, _)| *read == path) || self.reading_signatures || failed {
return;
}
self.reading_signatures = true;
let (doc, asked) = (self.doc, path.clone());
// A save in between makes this read stale: dropped.
let (doc, asked, saves) = (self.doc, path.clone(), self.saves);
self.off(
move |worker| {
let fields = worker.signatures(doc, &path)?;
@@ -64,9 +66,16 @@ impl PdfView {
},
move |view, read: io::Result<Vec<Checked>>, cx| {
view.reading_signatures = false;
if view.saves != saves {
log::info!("pdf: a signature read from before a save is dropped");
return cx.notify();
}
match read {
Ok(read) => view.signatures = Some((asked, read)),
Err(error) => view.fail("reading signatures", &error, cx),
Err(error) => {
view.unread.insert(Unread::Signatures(asked));
view.fail("reading signatures", &error, cx)
}
}
cx.notify();
},
+1
View File
@@ -18,6 +18,7 @@ mod notes;
mod organize;
mod output;
mod protect;
mod reads;
mod removal;
mod sign;
mod structure;
+30
View File
@@ -397,3 +397,33 @@ fn a_small_picture_on_text_under_a_page_form_still_aims() {
click(cx, &view, (140.0, 66.0));
assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Text { .. })), "the text, not the form around it");
}
#[test]
fn a_preview_overtaken_by_an_edit_says_so() {
use ely_gpui_component::primitives::Severity;
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = editing(&mut cx, dir.path());
// The window's toasts live on its root.
let root = cx.update(|window, app| {
let root = ely_ui::document_root(ely_platform::AppId::Pdf, viewer.clone().into(), &ely_ui::commands::FILE, Vec::new(), window, app);
viewer.update(app, |viewer, _| viewer.core.root = root.downgrade());
root
});
click(cx, &view, (80.0, 66.0));
type_in(cx, &viewer, "Hi world\nSecond line");
cx.update(|_, app| viewer.update(app, |viewer, cx| {
// A new font asks for a preview first.
viewer.edit_panel.update(cx, |panel, _| panel.font = "times".into());
viewer.write_text(false, cx);
// An edit lands before the preview answers.
if let Some(view) = viewer.active_view() {
view.update(cx, |view, _| view.version += 1);
}
}));
let changed = cx.update(|_, app| Viewer::text(app, "pdf.object.changed", &[]));
settle(cx, |app| root.read(app).toasts(app).iter().any(|(text, severity)| *severity == Some(Severity::Warning) && *text == changed));
frames(cx);
assert!(cx.update(|_, app| viewer.read(app).preview.is_none()), "no stale preview");
assert_eq!(cx.update(|_, app| view.read(app).edited), 0, "nothing written");
}
+45
View File
@@ -315,3 +315,48 @@ fn a_default_name_avoids_a_field_with_no_widget() {
send(cx, &viewer, Change::Design(typed, Vec::new()));
assert_eq!(notes(cx, &view).fields.len(), 1, "the field lands");
}
#[test]
fn quick_strokes_queue_behind_each_other() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = dir.path().join("one.pdf");
let page: (f32, f32, &[&str]) = (612.0, 792.0, &["Hello"]);
std::fs::write(&path, pdf_with(&[page], &[], &Extras::default())).expect("writes");
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
let note = |y: f32| ely_pdf_engine::NewAnnot { page: 0, shape: ely_pdf_engine::Shape::Note((100.0, y)), rgb: None, contents: "n".into(), author: String::new() };
// The second lands while the first still runs.
cx.update(|_, app| view.update(app, |view, cx| {
view.annotate("a first note", vec![note(500.0)], cx);
view.annotate("a second note", vec![note(400.0)], cx);
}));
settle(cx, |app| view.read(app).settled());
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.side.update(cx, |side, cx| {
side.pane = crate::side::Pane::Notes;
cx.notify();
})));
let count = |cx: &mut gpui::VisualTestContext| {
settle(cx, |app| view.read(app).settled() && view.read(app).notes.contains_key(&0));
cx.update(|_, app| view.read(app).notes[&0].annots.len())
};
assert_eq!(count(cx), 2, "both notes landed");
let rotate = |view: &mut PdfView, cx: &mut gpui::Context<PdfView>| {
view.edit_pages("rotating", |worker, doc| worker.rotate_pages(doc, &[0], 90), cx)
};
// A running rotation would move the point: refused.
cx.update(|_, app| view.update(app, |view, cx| {
rotate(view, cx);
view.annotate("a note while turning", vec![note(300.0)], cx);
}));
assert_eq!(count(cx), 2, "refused while pages move");
// A rotation waiting behind another edit: refused too.
cx.update(|_, app| view.update(app, |view, cx| {
view.annotate("a third note", vec![note(250.0)], cx);
rotate(view, cx);
view.annotate("a note behind a turn", vec![note(200.0)], cx);
}));
assert_eq!(count(cx), 3, "only the note before the turn");
cx.update(|_, app| view.update(app, |view, cx| view.annotate("a settled note", vec![note(150.0)], cx)));
assert_eq!(count(cx), 4, "taken once pages are still");
}
+24
View File
@@ -195,3 +195,27 @@ fn a_picture_never_drawn_is_checked_before_an_overwrite() {
settle(cx, |app| viewer.read(app).active_tab().and_then(|tab| tab.risk) == Some(ely_office::Risk::Repaired));
assert_eq!(std::fs::read(&path).expect("reads"), bytes, "Save turned into Save As");
}
#[test]
fn an_edited_document_does_not_quote_the_files_size() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = file(dir.path(), "doc.pdf", &[(612.0, 792.0, &["First"])]);
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
// The window's toasts live on its root.
let root = cx.update(|window, app| {
let root = ely_ui::document_root(ely_platform::AppId::Pdf, viewer.clone().into(), &ely_ui::commands::FILE, Vec::new(), window, app);
viewer.update(app, |viewer, _| viewer.core.root = root.downgrade());
root
});
let note = ely_pdf_engine::NewAnnot { page: 0, shape: ely_pdf_engine::Shape::Note((100.0, 500.0)), rgb: None, contents: "n".into(), author: String::new() };
cx.update(|_, app| view.update(app, |view, cx| view.annotate("a note", vec![note], cx)));
settle(cx, |app| view.read(app).settled() && view.read(app).modified());
let id = cx.update(|_, app| viewer.read(app).core.tabs[0].id);
let out = dir.path().join("small.pdf");
let how = Optimizing { compress: true, from: 300, to: 150 };
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_export(id, Kind::Optimized, how, out.clone(), cx)));
let done = cx.update(|_, app| crate::viewer::Viewer::text(app, "pdf.export.done", &[("name", "small.pdf")]));
settle(cx, |app| root.read(app).toasts(app).iter().any(|(text, _)| *text == done));
}
+98
View File
@@ -0,0 +1,98 @@
//! Reads that fail are kept, not asked each frame (P-08).
use ely_pdf_engine::sample::{Extras, pdf_with};
use gpui::TestAppContext;
use super::{settle, shown, windowed};
/// Two pages; the first's square has an unreadable rectangle.
fn bad_then_good() -> Vec<u8> {
let nines = "9".repeat(50);
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Annots [5 0 R] >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Annots [6 0 R] >>".to_string(),
format!("<< /Type /Annot /Subtype /Square /Rect [0 0 {nines} 100] >>"),
"<< /Type /Annot /Subtype /Text /Rect [100 500 120 520] /Contents (fine) >>".to_string(),
];
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, body) in objects.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend(format!("{} 0 obj\n{body}\nendobj\n", i + 1).bytes());
}
let xref = pdf.len();
pdf.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).bytes());
for offset in offsets {
pdf.extend(format!("{offset:010} 00000 n \n").bytes());
}
pdf.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).bytes());
pdf
}
#[test]
fn one_bad_page_spares_the_rest_of_its_batch() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = dir.path().join("two.pdf");
std::fs::write(&path, bad_then_good()).expect("writes");
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
settle(cx, |app| !view.read(app).reading_notes);
cx.update(|_, app| view.update(app, |view, cx| {
view.notes.clear();
view.unread.clear();
view.ask_notes([0, 1], cx)
}));
settle(cx, |app| !view.read(app).reading_notes);
let (good, bad) = cx.update(|_, app| {
let view = view.read(app);
(view.notes.contains_key(&1), view.unread.contains(&crate::view::Unread::Notes(0)))
});
assert!(bad, "the bad page is kept as failed");
assert!(good, "the good page is read all the same");
assert!(!cx.update(|_, app| view.read(app).unread.contains(&crate::view::Unread::Notes(1))));
}
#[test]
fn a_signature_read_from_before_a_save_counts_for_nothing() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = dir.path().join("one.pdf");
let page: (f32, f32, &[&str]) = (612.0, 792.0, &["Hello"]);
std::fs::write(&path, pdf_with(&[page], &[], &Extras::default())).expect("writes");
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
let gone = dir.path().join("gone.pdf");
// The read fails, but a save lands before its answer.
cx.update(|_, app| view.update(app, |view, cx| {
view.ask_signatures(gone.clone(), cx);
let same = view.saved.clone();
view.saved(&same, cx);
}));
settle(cx, |app| !view.read(app).reading_signatures);
assert!(cx.update(|_, app| view.read(app).unread.is_empty()), "a stale failure blocks nothing");
cx.update(|_, app| view.update(app, |view, cx| view.ask_signatures(gone.clone(), cx)));
assert!(cx.update(|_, app| view.read(app).reading_signatures), "asked again after the save");
}
#[test]
fn a_failed_read_is_not_asked_again() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = dir.path().join("one.pdf");
let page: (f32, f32, &[&str]) = (612.0, 792.0, &["Hello"]);
std::fs::write(&path, pdf_with(&[page], &[], &Extras::default())).expect("writes");
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
assert!(cx.update(|_, app| view.read(app).form_names.is_none()), "not read yet");
// A dead worker fails every read.
let pid = cx.update(|_, app| viewer.read(app).core.worker.lock().expect("the lock").get().expect("a worker").pid());
std::process::Command::new("kill").args(["-9", &pid.to_string()]).status().expect("kills");
cx.update(|_, app| view.update(app, |view, cx| view.ask_names(cx)));
settle(cx, |app| !view.read(app).reading_names);
assert!(cx.update(|_, app| view.read(app).unread.contains(&crate::view::Unread::Names)), "the failure is kept");
cx.update(|_, app| view.update(app, |view, cx| view.ask_names(cx)));
assert!(!cx.update(|_, app| view.read(app).reading_names), "not asked again");
}
+30
View File
@@ -234,3 +234,33 @@ fn a_cancelled_run_cannot_touch_the_next() {
assert!(cx.update(|_, app| viewer.read(app).ocr.is_some()), "the second run goes on");
settle(cx, |app| viewer.read(app).ocr.is_none() && view.read(app).edited == 1);
}
#[test]
fn a_refused_text_layer_reports_no_success() {
use ely_gpui_component::primitives::Severity;
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = scanned(dir.path());
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
// The window's toasts live on its root.
let root = cx.update(|window, app| {
let root = ely_ui::document_root(ely_platform::AppId::Pdf, viewer.clone().into(), &ely_ui::commands::FILE, Vec::new(), window, app);
viewer.update(app, |viewer, _| viewer.core.root = root.downgrade());
root
});
let tab = cx.update(|_, app| viewer.read(app).core.tabs[0].id);
let run = cx.update(|window, app| viewer.update(app, |viewer, cx| {
viewer.ask_ocr(window, cx);
viewer.start_ocr(cx);
viewer.ocr_runs - 1
}));
// An edit still runs when the text is laid.
cx.update(|_, app| view.update(app, |view, _| view.editing = true));
let line = ely_pdf_engine::OcrLine { rect: [72.0, 700.0, 300.0, 720.0], text: "Hello".into() };
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.ocr_read((tab, run, 0), Ok(Some(vec![line])), cx)));
settle(cx, |app| viewer.read(app).ocr.is_none());
let toasts = cx.update(|_, app| root.read(app).toasts(app));
assert!(toasts.iter().any(|(_, severity)| *severity == Some(Severity::Warning)), "{toasts:?}");
assert!(!toasts.iter().any(|(_, severity)| *severity == Some(Severity::Success)), "{toasts:?}");
}
+20
View File
@@ -23,6 +23,17 @@ pub struct Failed(pub String);
/// The document changed what it shows.
pub struct Reported;
/// A page or document read that failed once.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum Unread {
Notes(usize),
Layer(usize),
Names,
Files,
/// The signatures of the file at this path.
Signatures(std::path::PathBuf),
}
pub struct PdfView {
worker: Arc<Mutex<Mupdf>>,
pub doc: Doc,
@@ -41,6 +52,8 @@ pub struct PdfView {
pub painted: f64,
/// Tiles the worker could not draw; not asked again.
pub failed: HashSet<TileKey>,
/// Reads that failed; not asked again until an edit.
pub unread: HashSet<Unread>,
/// Old tiles shown under their page until covered.
pub backdrop: Vec<crate::backdrop::Old>,
pub text: HashMap<usize, Arc<Vec<Char>>>,
@@ -65,9 +78,13 @@ pub struct PdfView {
/// Edits waiting their turn, and whether one runs.
pub queue: VecDeque<crate::edits::Queued>,
pub editing: bool,
/// The running edit may move pages.
pub moving: bool,
/// History marks: now, and at the last load or save.
pub mark: String,
pub saved: String,
/// Saves so far: a read from before one is stale.
pub saves: u64,
/// Page pictures for the side panel, by page and darkness.
pub thumbs: HashMap<(usize, bool), Arc<RenderImage>>,
pub thumbs_failed: HashSet<usize>,
@@ -171,6 +188,7 @@ impl PdfView {
retired: Default::default(),
painted: 0.0,
failed: HashSet::new(),
unread: HashSet::new(),
backdrop: Vec::new(),
text: HashMap::new(),
asking_text: HashSet::new(),
@@ -186,7 +204,9 @@ impl PdfView {
edited: 0,
queue: VecDeque::new(),
editing: false,
moving: false,
saved: loaded.mark.clone(),
saves: 0,
mark: loaded.mark,
thumbs: HashMap::new(),
thumbs_failed: HashSet::new(),
+5 -3
View File
@@ -92,15 +92,17 @@ impl PdfView {
rgb: 0,
bx: [at[0], at[1], self.sizes[page].0 - at[0]],
};
self.settled_edit("placing a typed signature", cx, move |worker, doc| match worker.put_text(doc, page, &writing, 0.0)? {
let write = move |worker: &mut ely_pdf_engine::Worker, doc| match worker.put_text(doc, page, &writing, 0.0)? {
Wrote::Done(mark) => Ok(mark),
Wrote::Missing(c) => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("the font has no “{c}”"))),
Wrote::Unwritable => Err(io::Error::other("the document's font cannot write")),
Wrote::Preview(..) => Err(io::Error::other("a preview where a write was asked")),
});
};
self.edit_at("placing a typed signature", write, cx);
}
Stamp::Picture { path, aspect } => {
let at = placed(aspect, rect, click);
self.settled_edit("placing a signature picture", cx, move |worker, doc| worker.add_image(doc, page, &path, at));
self.edit_at("placing a signature picture", move |worker, doc| worker.add_image(doc, page, &path, at), cx);
}
}
}
+21 -5
View File
@@ -71,7 +71,8 @@ impl Viewer {
let (asked, viewer) = (writing.clone(), cx.weak_entity());
view.update(cx, |view, cx| {
let doc = view.doc;
view.read(
// A stale answer still reaches `previewed`, which says so.
view.off(
move |worker| worker.put_text(doc, page, &asked, PREVIEW),
move |_, answer, cx| {
// The viewer edits this view; wait until it is free.
@@ -95,7 +96,19 @@ impl Viewer {
replacing: bool,
cx: &mut Context<Self>,
) {
let same = self.tab(tab).is_some_and(|open| open.view.read(cx).version == version);
if !same {
let text = Self::text(cx, "pdf.object.changed", &[]);
return self.toast(text, Severity::Warning, cx);
}
match answer {
Ok(Wrote::Unwritable) => {
let note = Self::text(cx, "pdf.text.unwritable", &[]);
self.edit_panel.update(cx, |panel, cx| {
panel.note = Some(note);
cx.notify();
});
}
Ok(Wrote::Missing(c)) => {
let note = Self::text(cx, "pdf.text.missing", &[("char", &c.to_string())]);
self.edit_panel.update(cx, |panel, cx| {
@@ -128,6 +141,7 @@ impl Viewer {
move |worker, doc| match worker.put_text(doc, page, &writing, 0.0)? {
Wrote::Done(mark) => Ok(mark),
Wrote::Missing(c) => Err(io::Error::other(format!("the font has no {c}"))),
Wrote::Unwritable => Err(io::Error::other("the document's font cannot write")),
Wrote::Preview(..) => Err(io::Error::other("a write came back as a preview")),
},
cx,
@@ -168,25 +182,27 @@ impl Viewer {
self.pick(false, cx, move |viewer, path: PathBuf, cx| {
viewer.on_same(&view, version, cx, |view, cx| {
view.edit("replacing an image", move |worker, doc| worker.replace_image(doc, page, index, &path), cx)
})
});
});
}
/// Runs `step` on `view` if no edit came since `version`.
/// Whether `step` ran: no edit came since `version`.
pub(crate) fn on_same(
&self,
view: &gpui::Entity<crate::view::PdfView>,
version: u64,
cx: &mut Context<Self>,
step: impl FnOnce(&mut crate::view::PdfView, &mut Context<crate::view::PdfView>),
) {
) -> bool {
// Landed or queued, an edit since may move the target.
let open = self.core.tabs.iter().any(|tab| tab.view == *view);
if !open || view.read(cx).version != version || !view.read(cx).settled() {
let text = Self::text(cx, "pdf.object.changed", &[]);
return self.toast(text, Severity::Warning, cx);
self.toast(text, Severity::Warning, cx);
return false;
}
view.update(cx, step);
true
}
/// Asks for a picture; it lands mid-page.
+2 -2
View File
@@ -186,8 +186,8 @@ impl Deck {
return log::info!("show: a chart dialog for tab {tab} came late");
}
let editing = Editing::new(tab, chart, window, cx);
deck.dialog = Some(Dialog::Chart(Box::new(editing)));
cx.notify();
// Refused while another dialog is open; it says so.
let _ = deck.open_dialog(Dialog::Chart(Box::new(editing)), cx);
})
})
.detach_and_log_err(cx);
+20 -10
View File
@@ -108,6 +108,21 @@ pub fn findings(check: &Check, slides: &[ShowSlide], missing_fonts: &[String]) -
}
impl Deck {
/// Shows a finding's slide; master view numbers masters instead.
pub(crate) fn go_to_finding(&mut self, part: usize, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
return;
};
if tab.extra.masters {
let text = Deck::text(cx, "show.masters.busy", &[]);
return self.toast(text, Severity::Warning, cx);
}
let view = tab.view.clone();
view.update(cx, |view, _| view.show_part(part));
self.dialog = None;
cx.notify();
}
/// Reads every slide and master, then lists what to fix.
pub fn check_show(&mut self, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
@@ -128,8 +143,8 @@ impl Deck {
Ok((check, slides)) if deck.dialog_due(asked, id) => {
let found = findings(&check, &slides, &missing);
log::info!("show: the check found {} things", found.len());
deck.dialog = Some(Dialog::Check(found));
cx.notify();
// Refused while another dialog is open; it says so.
let _ = deck.open_dialog(Dialog::Check(found), cx);
}
Ok(_) => log::info!("show: the check is no longer due"),
Err(error) => deck.engine_failed(&format!("the check: {error}"), cx),
@@ -183,14 +198,9 @@ impl Deck {
let firsts: Vec<Option<usize>> = found.iter().map(|f| f.slides.first().copied()).collect();
let list = list.on_change(move |keys, _, cx| {
let first = keys.first().and_then(|key| key.parse::<usize>().ok()).and_then(|at| firsts[at]);
this.update(cx, |deck, cx| {
if let (Some(part), Some(tab)) = (first, deck.active_tab()) {
let view = tab.view.clone();
view.update(cx, |view, _| view.show_part(part));
deck.dialog = None;
}
cx.notify();
})
if let Some(part) = first {
this.update(cx, |deck, cx| deck.go_to_finding(part, cx));
}
});
card.child(list).into_any_element()
}
+2 -6
View File
@@ -220,6 +220,7 @@ impl Deck {
}
(tab.extra.reading, tab.extra.read) = (true, Some(seen));
let landed = tab.extra.transition_landed(view.read(cx));
let sized = tab.extra.size_landed(view.read(cx));
let doc = tab.doc.clone();
ely_lok::read_off(
&self.core.worker,
@@ -296,12 +297,6 @@ impl Deck {
{
tab.extra.stale = tab.extra.thumbs.keys().cloned().collect();
}
let read = crate::design::mm100(size);
if tab.extra.size_asked.is_some_and(|(w, h)| {
(w - read.0).abs() <= 50 && (h - read.1).abs() <= 50
}) {
tab.extra.size_asked = None;
}
(tab.extra.slides, tab.extra.design) = (slides, Some(design));
tab.extra.objects = Some(objects);
(tab.extra.notes, tab.extra.remarks) = (Some(notes), remarks);
@@ -309,6 +304,7 @@ impl Deck {
tab.extra.settle_transition(landed);
tab.extra.reads += 1;
tab.extra.size = size;
tab.extra.settle_size(sized);
tab.extra.failed.clear();
deck.paint_next(id, cx);
deck.read_fonts(id, cx);
+12 -3
View File
@@ -59,9 +59,18 @@ impl Deck {
log::info!("show: the slides already measure {w} x {h}");
return;
}
tab.extra.size_asked = Some((w, h));
let (w, h) = (w.to_string(), h.to_string());
self.design_command("slide.size", &[&w, &h, "true"], cx);
let (id, view) = (tab.id, tab.view.clone());
let before = view.read(cx).sent();
tab.extra.size_asked = Some(((w, h), before + 1));
let (sw, sh) = (w.to_string(), h.to_string());
self.run_in(Some(id), "slide.size", &[&sw, &sh, "true"], cx);
// Refused before sending: run_in said so already.
if view.read(cx).sent() == before
&& let Some(tab) = self.tab_mut(id)
{
tab.extra.size_asked = None;
}
self.force_reread(id, cx);
}
/// Enters or leaves the master view.
+16 -3
View File
@@ -1,6 +1,7 @@
//! ElyShow's one open dialog.
use ely_gpui_component::forms::TextInput;
use ely_gpui_component::{forms::TextInput, primitives::Severity};
use ely_office::prelude::*;
use gpui::{App, Context, Entity, IntoElement as _};
use crate::deck::Deck;
@@ -41,6 +42,18 @@ pub(crate) fn edit(
}
impl Deck {
/// Opens `dialog`, or hands it back while another is open.
pub(crate) fn open_dialog(&mut self, dialog: Dialog, cx: &mut Context<Self>) -> Result<(), Dialog> {
if self.dialog.is_some() {
let text = Deck::text(cx, "show.dialog.busy", &[]);
self.toast(text, Severity::Warning, cx);
return Err(dialog);
}
self.dialog = Some(dialog);
cx.notify();
Ok(())
}
/// A read that will open a dialog; earlier ones lapse.
pub fn ask_dialog(&mut self) -> u64 {
self.dialog_asked += 1;
@@ -56,9 +69,9 @@ impl Deck {
}
}
/// Whether read `asked` may open its dialog now.
/// Whether read `asked` still stands; `open_dialog` checks the rest.
pub fn dialog_due(&self, asked: u64, id: u64) -> bool {
self.dialog_asked == asked && self.dialog.is_none() && self.core.active == Some(id)
self.dialog_asked == asked && self.core.active == Some(id)
}
/// The open dialog, drawn.
+19 -3
View File
@@ -37,8 +37,8 @@ pub struct Extra {
pub size: (i64, i64),
/// Undo and redo lists at the last read.
pub history: Option<(Vec<Step>, Vec<Step>)>,
/// A size sent, in 1/100 mm, until read back.
pub size_asked: Option<(i64, i64)>,
/// A size sent (1/100 mm) and its input, until read.
pub size_asked: Option<((i64, i64), u64)>,
/// A transition sent for a slide, until read back.
pub transition_asked: Option<(String, ely_lok::show::Transition, u64)>,
/// Pictures by slide name; stale ones show until repainted.
@@ -60,6 +60,22 @@ impl Extra {
/// The size to show, in 1/100 mm: asked, else read.
pub fn shown_size(&self) -> (i64, i64) {
self.size_asked
.unwrap_or_else(|| crate::design::mm100(self.size))
.map_or_else(|| crate::design::mm100(self.size), |(size, _)| size)
}
/// The size input that landed before this read began.
pub fn size_landed(&self, view: &ely_lok::LokView) -> Option<u64> {
let (_, seq) = self.size_asked?;
view.reached(seq).then_some(seq)
}
/// Forgets the asked size once read back, taken or refused.
pub fn settle_size(&mut self, landed: Option<u64>) {
let read = crate::design::mm100(self.size);
if self.size_asked.is_some_and(|((w, h), seq)| {
landed == Some(seq) || (w - read.0).abs() <= 50 && (h - read.1).abs() <= 50
}) {
self.size_asked = None;
}
}
}
+2 -2
View File
@@ -34,8 +34,8 @@ impl Deck {
match ely_lok::templates("presnt") {
Ok(templates) => {
log::info!("show: {} bundled templates", templates.len());
self.dialog = Some(Dialog::New(Choosing { templates }));
cx.notify();
// Refused while another dialog is open; it says so.
let _ = self.open_dialog(Dialog::New(Choosing { templates }), cx);
}
Err(error) => self.engine_failed(&error.to_string(), cx),
}
+5 -2
View File
@@ -20,7 +20,7 @@ pub struct NotesField {
field: Option<Entity<TextInput>>,
shown: Option<(u64, usize, String)>,
/// The state the shown slide number was read in.
at: Option<ely_lok::Doc>,
pub(crate) at: Option<ely_lok::Doc>,
_events: Option<gpui::Subscription>,
}
@@ -66,7 +66,7 @@ impl Deck {
return log::warn!("show: notes for slide {part} have no read state");
};
log::info!("show: notes for slide {part} changed");
self.notes.shown = Some((tab, part, text.clone()));
// `shown` stays the kernel's until a read confirms the send.
self.run_on(tab, &at, "notes.set", &[&text, &part.to_string()], cx);
self.force_reread(tab, cx);
}
@@ -90,6 +90,9 @@ impl Deck {
if !typing && self.notes.shown.as_ref() != Some(&read) {
field.update(cx, |field, cx| field.set_text(notes.text, cx));
self.notes.shown = Some(read);
} else if let Some(shown) = self.notes.shown.as_mut().filter(|shown| (shown.0, shown.1) == (id, part)) {
// A read while typing moves the base, not the draft.
shown.2 = notes.text;
}
Some(field)
}
+5 -4
View File
@@ -148,7 +148,8 @@ impl Deck {
|worker, doc| ely_lok::show::slide_show(&worker.values(doc, ely_lok::show::SLIDE_SHOW)?),
move |deck, read, _, cx| match read {
Ok(slides) if deck.dialog_due(asked, id) => {
deck.dialog = Some(Dialog::Output(Output {
// Refused while another dialog is open; it says so.
let output = Output {
tab: id,
print,
kind: Kind::Slides,
@@ -156,8 +157,8 @@ impl Deck {
per: 3,
current,
hidden: slides.iter().map(|slide| slide.hidden).collect(),
}));
cx.notify();
};
let _ = deck.open_dialog(Dialog::Output(output), cx);
}
Ok(_) => log::info!("show: the output dialog is no longer due"),
Err(error) => deck.engine_failed(&format!("the slides: {error}"), cx),
@@ -185,7 +186,7 @@ impl Deck {
let path = work.join("print.pdf");
let printing = path.clone();
return self.write_pdf(&output, path, cx, move |_, cx| {
ely_office::print_later(printing, cx, |deck, error, cx| deck.output_failed(&error, cx))
ely_office::print_later(printing, cx)
});
}
let Some(dir) = ely_office::folder(source) else {
+19 -1
View File
@@ -13,6 +13,11 @@ use super::{Kind, Output, Range};
use crate::deck::Deck;
use crate::dialog::Dialog;
/// A typed slide number on the deck; 0 means all.
fn slide_number(value: f64, last: u32) -> u32 {
value.round().clamp(1.0, f64::from(last.max(1))) as u32
}
/// What goes out, which slides, how many a page.
pub fn output_dialog(
this: gpui::Entity<Deck>,
@@ -79,7 +84,7 @@ pub fn output_dialog(
let bound = |id: &'static str, value: u32, first: bool| {
// A typed number counts before blur commits it.
let set = move |this: &gpui::Entity<Deck>, value: f64, cx: &mut gpui::App| {
let value = value.round() as u32;
let value = slide_number(value, last);
change(this, cx, &|output| {
if let Range::Slides(from, to) = &mut output.range {
*(if first { from } else { to }) = value;
@@ -135,3 +140,16 @@ pub fn output_dialog(
.on_click(move |_, _, cx| this.update(cx, |deck, cx| deck.output(cx)))
})
}
#[cfg(test)]
mod tests {
use super::slide_number;
#[test]
fn a_typed_number_stays_on_the_deck() {
assert_eq!(slide_number(0.0, 5), 1, "0 would export every slide");
assert_eq!(slide_number(-3.0, 5), 1);
assert_eq!(slide_number(2.6, 5), 3);
assert_eq!(slide_number(9.0, 5), 5);
}
}
+50 -16
View File
@@ -19,20 +19,21 @@ use gpui::{
use crate::deck::Deck;
use crate::dialog::Dialog;
/// Each comment as a thread quoted with its slide.
fn threads(remarks: &[Remark], cx: &App) -> Result<Vec<Thread>, String> {
/// Threads quoted with their slides; unreadable comments apart.
fn threads(remarks: &[Remark], cx: &App) -> (Vec<Thread>, Vec<(String, String)>) {
let authors: Vec<&str> = remarks
.iter()
.map(|remark| remark.author.as_str())
.collect();
remarks
let (good, bad): (Vec<_>, Vec<_>) = remarks
.iter()
.map(|remark| {
let at = remark
.at
.parse::<jiff::civil::DateTime>()
.and_then(|at| at.to_zoned(jiff::tz::TimeZone::system()))
.map_err(|error| format!("a bad comment time {}: {error}", remark.at))?;
.map_err(|error| (remark.at.clone(), error.to_string()))?
.timestamp();
let hue = authors
.iter()
.position(|name| *name == remark.author)
@@ -43,14 +44,16 @@ fn threads(remarks: &[Remark], cx: &App) -> Result<Vec<Thread>, String> {
quote: Some(Deck::text(cx, "show.remark.slide", &[("n", &slide)])),
comments: vec![Comment {
author: Peer::new(remark.author.clone(), remark.author.clone(), hue),
at: at.timestamp(),
at,
body: remark.text.clone().into(),
reactions: Vec::new(),
}],
resolved: false,
})
})
.collect()
.partition(Result::is_ok);
let bad = bad.into_iter().filter_map(Result::err).collect();
(good.into_iter().filter_map(Result::ok).collect(), bad)
}
impl Deck {
@@ -77,8 +80,8 @@ impl Deck {
input.set_text(text, cx);
input
});
self.dialog = Some(Dialog::Remark { tab, edit, field });
cx.notify();
// Refused while another dialog is open; it says so.
let _ = self.open_dialog(Dialog::Remark { tab, edit, field }, cx);
}
/// Sends the dialog's text: a new comment or an edit.
@@ -166,14 +169,15 @@ impl Deck {
.active_tab()
.map(|tab| tab.extra.remarks.clone())
.unwrap_or_default();
let threads = match threads(&remarks, cx) {
Ok(threads) => threads,
Err(error) => {
log::error!("show: {error}");
let message = InlineMessage::new(Severity::Danger, error);
return div().p_2().child(message).into_any_element();
}
};
let (threads, bad) = threads(&remarks, cx);
for (at, error) in &bad {
log::error!("show: a bad comment time {at}: {error}");
}
// One line however many: the comments below stay in view.
let bad = bad.first().map(|(at, _)| {
let n = bad.len().to_string();
InlineMessage::new(Severity::Danger, Deck::text(cx, "show.remark.bad", &[("n", &n), ("at", at)]))
});
let active = self.shown_remark();
let act = |id: &'static str,
key: &str,
@@ -227,6 +231,7 @@ impl Deck {
.gap_2()
.p_2()
.child(head)
.children(bad)
.child(div().flex_1().min_h_0().child(sidebar))
.into_any_element()
}
@@ -289,3 +294,32 @@ impl Deck {
(!pins.is_empty()).then(|| div().absolute().inset_0().children(pins).into_any_element())
}
}
#[cfg(test)]
mod tests {
use ely_lok::show::Remark;
use gpui::TestAppContext;
use super::threads;
#[test]
fn one_unreadable_time_spares_the_other_comments() {
let cx = TestAppContext::single();
cx.update(|cx| ely_ui::i18n::install_as("en-US", cx));
let remark = |id: &str, at: &str| Remark {
id: id.into(),
author: "A".into(),
text: "t".into(),
at: at.into(),
part: 0,
anchor: (0, 0, 0, 0),
};
// The kernel writes an empty date as 0-00-00.
let remarks = [remark("1", "2025-03-01"), remark("2", "0-00-00"), remark("3", "2025-03-01T10:15:00")];
let (good, bad) = cx.update(|cx| threads(&remarks, cx));
let keys: Vec<_> = good.iter().map(|thread| thread.key.to_string()).collect();
assert_eq!(keys, ["1", "3"]);
assert_eq!(bad.len(), 1);
assert_eq!(bad[0].0, "0-00-00");
}
}
+54 -7
View File
@@ -141,7 +141,9 @@ impl Deck {
/// Plays a copy from the first or shown slide.
pub fn start_show(&mut self, here: bool, cx: &mut Context<Self>) {
if self.show.is_some() || self.show_starting {
return log::info!("show: already playing or starting");
log::info!("show: already playing or starting");
let text = Deck::text(cx, "show.play.busy", &[]);
return self.toast(text, Severity::Warning, cx);
}
let Some(id) = self.core.active else {
return;
@@ -180,7 +182,9 @@ impl Deck {
let Some(first) = first.filter(|_| deck.core.active == Some(id)) else {
discard(worker, doc, path, cx);
if deck.core.active != Some(id) {
return log::info!("show: tab {id} is no longer shown");
log::info!("show: tab {id} is no longer shown");
let text = Deck::text(cx, "show.play.gone", &[]);
return deck.toast(text, Severity::Warning, cx);
}
let text = Deck::text(cx, "show.play.none", &[]);
return deck.toast(text, Severity::Warning, cx);
@@ -196,11 +200,11 @@ impl Deck {
};
match cx.displays().len() {
1 => deck.open_show(starting, cx),
_ => {
deck.show_starting = true;
deck.dialog = Some(Dialog::Show(starting));
cx.notify();
}
_ => match deck.open_dialog(Dialog::Show(starting), cx) {
Ok(()) => deck.show_starting = true,
Err(Dialog::Show(refused)) => discard(worker, refused.doc, refused.copy, cx),
Err(_) => unreachable!("the show dialog comes back"),
},
}
},
cx,
@@ -214,6 +218,8 @@ impl Deck {
let (Some(tab), Some(audience)) = (self.tab(starting.tab), displays.get(starting.audience))
else {
log::warn!("show: the tab or screen {} is gone", starting.audience);
let text = Deck::text(cx, "show.play.gone", &[]);
self.toast(text, Severity::Warning, cx);
return discard(worker, starting.doc, starting.copy, cx);
};
let presenter = (displays.iter())
@@ -347,4 +353,45 @@ mod tests {
};
assert_eq!(super::bounds(area), want);
}
#[test]
fn a_start_whose_screen_is_gone_says_so_and_drops_its_copy() {
use ely_office::prelude::*;
use gpui::AppContext as _;
use crate::tests::{settle, update};
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = gpui::TestAppContext::single();
let Some(deck) = crate::tests::deck(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
update(&mut cx, &deck, |deck, cx| deck.new_blank(cx));
settle(&cx, |app| deck.read(app).active_tab().is_some_and(|tab| !tab.extra.slides.list.is_empty()));
// The window's toasts live on its root.
let handle = cx.update(|app| deck.read(app).core.window).expect("a window");
let root = cx
.update_window(handle, |_, window, app| {
let root = ely_ui::document_root(ely_platform::AppId::Show, deck.clone().into(), &ely_ui::commands::FILE, Vec::new(), window, app);
deck.update(app, |deck, _| deck.core.root = root.downgrade());
root
})
.expect("the window lives");
let path = dir.path().join("copy.odp");
let starting = cx.update(|app| {
let deck = deck.read(app);
let tab = deck.active_tab().expect("a tab");
let mut worker = deck.core.worker.lock().expect("the worker lock");
let (_, doc, slides) = super::copy(&mut worker, &tab.doc, (&path, None)).expect("a copy");
super::Starting { tab: tab.id, slides, first: 0, doc, copy: path.clone(), audience: 99, presenter: false }
});
update(&mut cx, &deck, |deck, cx| {
deck.show_starting = true;
deck.open_show(starting, cx);
});
settle(&cx, |_| !path.exists());
let (toasts, gone) = cx.update(|app| (root.read(app).toasts(app), crate::deck::Deck::text(app, "show.play.gone", &[])));
assert!(toasts.iter().any(|(text, _)| *text == gone), "{toasts:?}");
assert!(cx.update(|app| !deck.read(app).show_starting && deck.read(app).show.is_none()));
}
}
+2 -6
View File
@@ -21,12 +21,8 @@ use crate::dialog::{Dialog, edit};
impl Deck {
pub fn ask_table(&mut self, cx: &mut Context<Self>) {
if let Some(tab) = self.core.active {
self.dialog = Some(Dialog::Table {
tab,
columns: 3,
rows: 3,
});
cx.notify();
// Refused while another dialog is open; it says so.
let _ = self.open_dialog(Dialog::Table { tab, columns: 3, rows: 3 }, cx);
}
}
+3 -2
View File
@@ -9,6 +9,7 @@ use gpui::{AppContext as _, Entity, TestAppContext};
use crate::deck::Deck;
mod motion;
mod notes;
mod objects;
mod output;
mod play;
@@ -44,7 +45,7 @@ pub(super) fn deck(cx: &mut TestAppContext, dir: &Path) -> Option<Entity<Deck>>
/// Steps the scheduler until `done` or ten seconds pass.
#[track_caller]
fn settle(cx: &TestAppContext, done: impl Fn(&gpui::App) -> bool) {
pub(super) fn settle(cx: &TestAppContext, done: impl Fn(&gpui::App) -> bool) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while std::time::Instant::now() < deadline {
if !cx.executor().tick() {
@@ -58,7 +59,7 @@ fn settle(cx: &TestAppContext, done: impl Fn(&gpui::App) -> bool) {
panic!("the state never settled");
}
fn update(
pub(super) fn update(
cx: &mut TestAppContext,
deck: &Entity<Deck>,
f: impl FnOnce(&mut Deck, &mut gpui::Context<Deck>),
+93
View File
@@ -0,0 +1,93 @@
//! The notes field under the slide (SHOW-08).
use ely_office::prelude::*;
use gpui::TestAppContext;
use super::objects::blank;
use super::slides::{in_window, type_in};
use super::{settle, update};
#[test]
fn typed_notes_reach_the_slide() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let notes = |app: &gpui::App| {
deck.read(app)
.active_tab()
.and_then(|tab| tab.extra.notes.clone())
};
settle(&cx, |app| notes(app).is_some());
let field = in_window(&mut cx, &deck, |deck, window, cx| {
deck.notes_input(window, cx)
});
let field = field.expect("the field");
type_in(&mut cx, &field, "Start with the totals");
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
settle(&cx, |app| {
notes(app).is_some_and(|notes| notes.text == "Start with the totals")
});
}
#[test]
fn notes_refused_as_stale_stay_typed_and_go_next_time() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let notes = |app: &gpui::App| deck.read(app).active_tab().and_then(|tab| tab.extra.notes.clone());
let text = |app: &gpui::App| notes(app).map(|notes| notes.text);
settle(&cx, |app| notes(app).is_some());
let field = in_window(&mut cx, &deck, |deck, window, cx| deck.notes_input(window, cx)).expect("the field");
let stale = cx.update(|app| deck.read(app).notes.at.clone());
type_in(&mut cx, &field, "first");
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
settle(&cx, |app| text(app).as_deref() == Some("first"));
in_window(&mut cx, &deck, |deck, window, cx| deck.notes_input(window, cx));
// Typing began before the edit above was read back.
type_in(&mut cx, &field, "second");
update(&mut cx, &deck, |deck, cx| {
deck.notes.at = stale;
deck.commit_notes(cx);
});
settle(&cx, |app| deck.read(app).active_tab().is_some_and(|tab| tab.view.read(app).settled()));
cx.run_until_parked();
in_window(&mut cx, &deck, |deck, window, cx| deck.notes_input(window, cx));
assert_eq!(cx.update(|app| field.read(app).text().to_string()), "second", "the typing stays");
assert_eq!(cx.update(|app| text(app)).as_deref(), Some("first"), "the stale send was refused");
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
settle(&cx, |app| text(app).as_deref() == Some("second"));
}
#[test]
fn a_read_while_typing_keeps_the_new_draft() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let text = |app: &gpui::App| deck.read(app).active_tab().and_then(|tab| tab.extra.notes.clone()).map(|notes| notes.text);
settle(&cx, |app| text(app).is_some());
let field = in_window(&mut cx, &deck, |deck, window, cx| deck.notes_input(window, cx)).expect("the field");
type_in(&mut cx, &field, "first");
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
// Back in the field before the send is read back.
in_window(&mut cx, &deck, |_, window, cx| window.focus(&gpui::Focusable::focus_handle(field.read(cx), cx), cx));
type_in(&mut cx, &field, "second");
settle(&cx, |app| text(app).as_deref() == Some("first"));
in_window(&mut cx, &deck, |deck, window, cx| deck.notes_input(window, cx));
// The blur redraws before its commit runs.
in_window(&mut cx, &deck, |deck, window, cx| {
window.blur(cx);
deck.notes_input(window, cx)
});
assert_eq!(cx.update(|app| field.read(app).text().to_string()), "second", "the draft stays");
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
settle(&cx, |app| text(app).as_deref() == Some("second"));
}
+53
View File
@@ -145,3 +145,56 @@ fn a_click_on_media_finds_it_and_elsewhere_does_not() {
assert_eq!(cx.update(|app| show.read(app).media_at(centre)), Some(0));
assert_eq!(cx.update(|app| show.read(app).media_at((0.01, 0.01))), None);
}
#[test]
fn a_second_start_says_the_show_is_busy() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
// The window's toasts live on its root.
let root = super::slides::in_window(&mut cx, &deck, |deck, window, cx| {
let this = cx.entity();
let root = ely_ui::document_root(ely_platform::AppId::Show, this.into(), &ely_ui::commands::FILE, Vec::new(), window, cx);
deck.core.root = root.downgrade();
root
});
update(&mut cx, &deck, |deck, cx| {
deck.show_starting = true;
deck.start_show(false, cx);
});
cx.run_until_parked();
let (toasts, busy) = cx.update(|app| (root.read(app).toasts(app), Deck::text(app, "show.play.busy", &[])));
assert!(toasts.iter().any(|(text, _)| *text == busy), "{toasts:?}");
}
#[test]
fn a_start_left_for_another_tab_says_so() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let first = cx.update(|app| deck.read(app).core.active.expect("a tab"));
update(&mut cx, &deck, |deck, cx| deck.new_blank(cx));
settle(&cx, |app| deck.read(app).core.tabs.len() == 2 && deck.read(app).core.active != Some(first));
let second = cx.update(|app| deck.read(app).core.active.expect("a tab"));
let root = super::slides::in_window(&mut cx, &deck, |deck, window, cx| {
let this = cx.entity();
let root = ely_ui::document_root(ely_platform::AppId::Show, this.into(), &ely_ui::commands::FILE, Vec::new(), window, cx);
deck.core.root = root.downgrade();
root
});
update(&mut cx, &deck, |deck, cx| {
deck.core.active = Some(first);
deck.start_show(false, cx);
// Another tab comes forward while the copy is read.
deck.core.active = Some(second);
});
let gone = cx.update(|app| Deck::text(app, "show.play.gone", &[]));
settle(&cx, |app| root.read(app).toasts(app).iter().any(|(text, _)| *text == gone));
assert!(cx.update(|app| !deck.read(app).show_starting && deck.read(app).show.is_none()));
}
+71 -18
View File
@@ -11,7 +11,7 @@ use crate::deck::Deck;
use crate::dialog::Dialog;
/// Runs `f` on the deck inside its window.
fn in_window<R>(
pub(super) fn in_window<R>(
cx: &mut TestAppContext,
deck: &Entity<Deck>,
f: impl FnOnce(&mut Deck, &mut Window, &mut gpui::Context<Deck>) -> R,
@@ -25,7 +25,7 @@ fn in_window<R>(
.expect("the window lives")
}
fn type_in(cx: &mut TestAppContext, field: &Entity<TextInput>, text: &str) {
pub(super) fn type_in(cx: &mut TestAppContext, field: &Entity<TextInput>, text: &str) {
let text = text.to_string();
cx.update(|app| field.update(app, |field, cx| field.set_text(text, cx)));
}
@@ -105,28 +105,20 @@ fn the_table_dialog_inserts_a_table() {
}
#[test]
fn typed_notes_reach_the_slide() {
fn an_open_dialog_is_not_pushed_aside() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let notes = |app: &gpui::App| {
deck.read(app)
.active_tab()
.and_then(|tab| tab.extra.notes.clone())
};
settle(&cx, |app| notes(app).is_some());
let field = in_window(&mut cx, &deck, |deck, window, cx| {
deck.notes_input(window, cx)
});
let field = field.expect("the field");
type_in(&mut cx, &field, "Start with the totals");
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
settle(&cx, |app| {
notes(app).is_some_and(|notes| notes.text == "Start with the totals")
});
update(&mut cx, &deck, |deck, cx| deck.ask_table(cx));
update(&mut cx, &deck, |deck, cx| deck.ask_new(cx));
// The chart dialog opens after a background read.
update(&mut cx, &deck, |deck, cx| deck.ask_chart(cx));
cx.run_until_parked();
let kept = cx.update(|app| matches!(deck.read(app).dialog, Some(crate::dialog::Dialog::Table { .. })));
assert!(kept, "the table dialog stays");
}
#[test]
@@ -408,3 +400,64 @@ fn a_zoom_stays_through_later_reports() {
assert_eq!(view.read(app).zoom(), zoom, "the zoom stays");
});
}
#[test]
fn a_check_row_does_not_jump_among_masters() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
update(&mut cx, &deck, |deck, cx| deck.masters(true, cx));
settle(&cx, |app| deck.read(app).active_tab().is_some_and(|tab| tab.extra.masters));
update(&mut cx, &deck, |deck, cx| {
deck.dialog = Some(crate::dialog::Dialog::Check(Vec::new()));
deck.go_to_finding(0, cx);
});
assert!(cx.update(|app| deck.read(app).dialog.is_some()), "the check stays open");
}
#[test]
fn a_refused_size_does_not_stay_shown() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let before = cx.update(|app| deck.read(app).active_tab().expect("a tab").extra.size);
update(&mut cx, &deck, |deck, cx| deck.resize((-500, -500), cx));
settle(&cx, |app| {
deck.read(app).active_tab().is_some_and(|tab| tab.extra.size_asked.is_none())
});
let extra = cx.update(|app| {
let tab = deck.read(app).active_tab().expect("a tab");
(tab.extra.size, tab.extra.shown_size())
});
assert_eq!(extra.0, before, "the kernel kept its size");
assert_eq!(extra.1, crate::design::mm100(before), "the fields show it");
}
#[test]
fn a_size_the_kernel_does_not_take_clears_after_its_read() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
update(&mut cx, &deck, |deck, cx| {
deck.resize((30_000, 19_050), cx);
// As if the kernel refused: it keeps another size.
let id = deck.core.active.expect("a tab");
let tab = deck.tab_mut(id).expect("a tab");
let (_, seq) = tab.extra.size_asked.expect("asked");
tab.extra.size_asked = Some(((40_000, 19_050), seq));
});
settle(&cx, |app| {
deck.read(app).active_tab().is_some_and(|tab| tab.extra.size_asked.is_none())
});
let read = cx.update(|app| crate::design::mm100(deck.read(app).active_tab().expect("a tab").extra.size));
assert!((read.0 - 30_000).abs() <= 50, "the kernel's own size shows: {read:?}");
}
+3 -3
View File
@@ -81,7 +81,7 @@ impl Book {
}
(Asked::Refresh, Ok(_)) => book.warn_key("table.pivot.none", cx),
(_, Ok(Found::Pivot(Some(pivot)))) => {
book.dialog = Some(Dialog::Pivot {
book.open_dialog(Dialog::Pivot {
tab: id,
source: pivot.source,
fields: pivot.fields,
@@ -120,7 +120,7 @@ impl Book {
if picked.top.iter().any(String::is_empty) {
return self.warn_key("table.pivot.headers", cx);
}
self.dialog = Some(Dialog::Pivot {
self.open_dialog(Dialog::Pivot {
tab: id,
source: picked.data,
fields: picked.top,
@@ -146,7 +146,7 @@ impl Book {
range: ely_office::chart::field(range, "table.chart.range", window, cx),
look: ely_office::chart::LookFields::new(&look, window, cx),
};
self.dialog = Some(Dialog::Chart {
self.open_dialog(Dialog::Chart {
tab: id,
chart: chart.map(Box::new),
look: Box::new(look),
+11
View File
@@ -101,6 +101,17 @@ impl Host for Book {
self.sync_bars(view, cx);
}
}
/// A closed tab's dialog and list go with it.
fn tab_closed(&mut self, tab: &ely_office::Tab<Extra, ely_office::Lok>, cx: &mut Context<Self>) {
if self.dialog.as_ref().and_then(crate::dialog::Dialog::tab) == Some(tab.id) {
self.dialog = None;
}
if self.choices.as_ref().is_some_and(|choices| choices.tab == tab.id) {
self.choices = None;
}
cx.notify();
}
}
/// The window root with a blank workbook.
+2 -2
View File
@@ -121,7 +121,7 @@ impl Book {
|worker, doc| doc.selection_text(worker),
move |book, text, _, cx| match text {
Ok(text) if filled(&text) > 1 => {
book.dialog = Some(Dialog::Merge { tab });
book.open_dialog(Dialog::Merge { tab });
cx.notify();
}
Ok(_) => book.run_in(id, "cells.merge", &["false"], cx),
@@ -150,7 +150,7 @@ impl Book {
return self.toast(text, ely_gpui_component::primitives::Severity::Warning, cx);
};
let field = cx.new(|cx| TextInput::new(window, cx));
self.dialog = Some(Dialog::Size { tab, column, field });
self.open_dialog(Dialog::Size { tab, column, field });
cx.notify();
}
}
+7 -3
View File
@@ -228,7 +228,7 @@ impl Book {
fn data_dialog(&mut self, id: u64, asked: Asked, picked: Selection, state: ReadState, cx: &mut Context<Self>) {
log::info!("table: {asked:?} on {}", picked.range);
let header = picked.header;
self.dialog = Some(match asked {
self.open_dialog(match asked {
Asked::Sort => {
let (first, ..) = picked.area.expect("a data area");
Dialog::Sort {
@@ -314,7 +314,7 @@ impl Book {
return book.warn_key("table.columns.same", cx);
}
let other = cx.new(|cx| TextInput::new(window, cx));
book.dialog = Some(Dialog::Columns {
book.open_dialog(Dialog::Columns {
tab: id,
separators: vec!["9".into()],
other,
@@ -394,9 +394,10 @@ impl Book {
.filter(|entry| entry.selected)
.map(|entry| SharedString::from(entry.text.clone()))
.collect();
book.dialog = Some(Dialog::Column {
book.open_dialog(Dialog::Column {
tab: id,
column,
row,
entries: filter.entries,
picked,
});
@@ -413,12 +414,15 @@ impl Book {
let Some(Dialog::Column {
tab,
column,
row,
entries,
picked,
}) = self.dialog.take()
else {
return;
};
// The kernel filters the cursor's range: go to the button.
self.run_in(Some(tab), "cell.goto", &[&sheet::address(column, row)], cx);
match sort {
Some(rising) => {
let key = SortKey {
+31
View File
@@ -94,6 +94,8 @@ pub enum Dialog {
Column {
tab: u64,
column: u32,
/// The button's row: its cell names the filtered range.
row: u32,
entries: Vec<Entry>,
picked: Vec<SharedString>,
},
@@ -150,6 +152,35 @@ pub enum Dialog {
},
}
impl Dialog {
/// The workbook it asks about; imports have none yet.
pub fn tab(&self) -> Option<u64> {
match self {
Dialog::Rename { tab, .. }
| Dialog::Show { tab, .. }
| Dialog::Delete { tab, .. }
| Dialog::Size { tab, .. }
| Dialog::Merge { tab, .. }
| Dialog::Define { tab, .. }
| Dialog::Names { tab, .. }
| Dialog::Sort { tab, .. }
| Dialog::Filter { tab, .. }
| Dialog::Dedupe { tab, .. }
| Dialog::Columns { tab, .. }
| Dialog::Column { tab, .. }
| Dialog::Validation { tab, .. }
| Dialog::Conditional { tab, .. }
| Dialog::Formats { tab, .. }
| Dialog::Pivot { tab, .. }
| Dialog::Chart { tab, .. }
| Dialog::Export { tab, .. }
| Dialog::Page { tab, .. }
| Dialog::Protect { tab, .. } => Some(*tab),
Dialog::Import(_) => None,
}
}
}
impl Book {
/// The define dialog's sheets: its own workbook's.
pub(crate) fn define_scopes(&self) -> Option<Vec<String>> {
+2 -2
View File
@@ -116,7 +116,7 @@ impl Book {
let name = cx.new(|cx| TextInput::new(window, cx));
let range = cx.new(|cx| TextInput::new(window, cx));
range.update(cx, |range, cx| range.set_text(refers, cx));
book.dialog = Some(Dialog::Define {
book.open_dialog(Dialog::Define {
tab: id,
name,
range,
@@ -206,7 +206,7 @@ impl Book {
let range = cx.new(|cx| TextInput::new(window, cx));
let formula = first.formula.clone();
range.update(cx, |range, cx| range.set_text(formula, cx));
book.dialog = Some(Dialog::Names {
book.open_dialog(Dialog::Names {
tab: id,
names,
chosen: 0,
+3 -5
View File
@@ -49,7 +49,7 @@ impl Book {
let Some(tab) = self.core.active else {
return self.warn_key("command.blocked.no_document", cx);
};
self.dialog = Some(Dialog::Export {
self.open_dialog(Dialog::Export {
tab,
format: Format::Pdf,
});
@@ -144,10 +144,8 @@ impl Book {
tab,
move |worker, doc| doc.export_pdf(worker, &url, &WHOLE),
move |book, written, _, cx| match written {
Ok(()) => ely_office::print_later(path, cx, |book, error, cx| {
book.output_failed(&error, cx)
}),
Err(error) => book.output_failed(&error.to_string(), cx),
Ok(()) => ely_office::print_later(path, cx),
Err(error) => book.print_failed(&error.to_string(), cx),
},
cx,
);
+18 -2
View File
@@ -82,6 +82,15 @@ fn cell_ok(cell: &str) -> bool {
&& row_number(digits).is_some()
}
/// Whole columns or rows, like `$A:$D`; one `$` each.
fn whole(from: &str, to: &str, number: fn(&str) -> Option<u32>) -> bool {
let end = |end: &str| {
let end = end.strip_prefix('$').unwrap_or(end);
end.bytes().all(|byte| byte.is_ascii_alphanumeric()).then(|| number(&end.to_ascii_uppercase()))?
};
matches!((end(from), end(to)), (Some(first), Some(last)) if first <= last)
}
/// Areas as the kernel takes them, or None.
fn areas(text: &str) -> Option<String> {
let tokens: Vec<&str> = text
@@ -90,7 +99,9 @@ fn areas(text: &str) -> Option<String> {
.filter(|token| !token.is_empty())
.collect();
let ok = |token: &&str| match token.split_once(':') {
Some((from, to)) => cell_ok(from) && cell_ok(to),
Some((from, to)) => {
cell_ok(from) && cell_ok(to) || whole(from, to, column_number) || whole(from, to, row_number)
}
None => cell_ok(token),
};
tokens.iter().all(ok).then(|| tokens.join(","))
@@ -143,7 +154,7 @@ impl Book {
rows: field(sheet::rows_ref(layout.rows), "table.page.rows_hint", cx),
columns: field(sheet::columns_ref(layout.columns), "table.page.columns_hint", cx),
};
self.dialog = Some(Dialog::Page {
self.open_dialog(Dialog::Page {
tab: id,
landscape: layout.landscape,
scale: layout.scale,
@@ -228,6 +239,11 @@ mod tests {
assert_eq!(areas("A1:B"), None);
assert_eq!(areas("XFE1"), None);
assert_eq!(areas("S!A1"), None);
assert_eq!(areas("$A:$D, 1:3").as_deref(), Some("$A:$D,1:3"));
assert_eq!(areas("A:3"), None);
for bad in ["$$A:$$D", "$$1:$$3", "$A:$$D", "A:D,$$E:$$F", "+1:2", "D:A"] {
assert_eq!(areas(bad), None, "{bad}");
}
assert_eq!(span("$1:$3", row_number), Some(Some((1, 3))));
assert_eq!(span("2", row_number), Some(Some((2, 2))));
assert_eq!(span("3:1", row_number), None);
+1 -1
View File
@@ -58,7 +58,7 @@ impl Book {
confirm: masked("table.protect.confirm", cx),
now,
};
book.dialog = Some(Dialog::Protect {
book.open_dialog(Dialog::Protect {
tab: id,
protecting: Box::new(protecting),
});
+3 -3
View File
@@ -143,7 +143,7 @@ impl Book {
} else {
rule
};
self.dialog = Some(Dialog::Validation {
self.open_dialog(Dialog::Validation {
tab: id,
rule: Box::new(rule),
fields: Box::new(fields),
@@ -275,7 +275,7 @@ impl Book {
};
let value1 = cx.new(|cx| TextInput::new(window, cx));
let value2 = cx.new(|cx| TextInput::new(window, cx));
self.dialog = Some(Dialog::Conditional {
self.open_dialog(Dialog::Conditional {
tab: id,
kind: "cell",
operator: "greater",
@@ -338,7 +338,7 @@ impl Book {
book.toast(text, Severity::Info, cx);
}
Ok(formats) => {
book.dialog = Some(Dialog::Formats {
book.open_dialog(Dialog::Formats {
tab: id,
formats,
chosen: 0,
+1 -1
View File
@@ -17,7 +17,7 @@ use crate::book::Book;
/// The open choices menu: tab, values, request.
pub struct Choices {
tab: u64,
pub(crate) tab: u64,
pub(crate) values: Vec<String>,
request: (u64, Point<Pixels>),
}
+3 -3
View File
@@ -228,7 +228,7 @@ impl Book {
return self.warn("table.show.none", cx);
};
let tab = tab.id;
self.dialog = Some(Dialog::Show {
self.open_dialog(Dialog::Show {
tab,
hidden,
chosen,
@@ -246,7 +246,7 @@ impl Book {
};
let field = cx.new(|cx| TextInput::new(window, cx));
field.update(cx, |field, cx| field.set_text(name, cx));
self.dialog = Some(Dialog::Rename { tab, at, index, field });
self.open_dialog(Dialog::Rename { tab, at, index, field });
cx.notify();
}
@@ -277,7 +277,7 @@ impl Book {
},
move |book, read, _, cx| match read {
Ok((references, at, name)) => {
book.dialog = Some(Dialog::Delete {
book.open_dialog(Dialog::Delete {
tab: id,
at,
index,
+38
View File
@@ -300,3 +300,41 @@ fn the_panel_reads_number_formats_and_find_counts_cells() {
.is_some_and(|finder| finder.all.len() == 3)
});
}
/// Two blocks: keys in A, other numbers in C.
const BLOCKS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
<office:body><office:spreadsheet><table:table table:name="S">
<table:table-row><table:table-cell><text:p>K</text:p></table:table-cell><table:table-cell/><table:table-cell><text:p>L</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell office:value-type="float" office:value="3"><text:p>3</text:p></table:table-cell><table:table-cell/><table:table-cell office:value-type="float" office:value="9"><text:p>9</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell office:value-type="float" office:value="1"><text:p>1</text:p></table:table-cell><table:table-cell/><table:table-cell office:value-type="float" office:value="7"><text:p>7</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell office:value-type="float" office:value="2"><text:p>2</text:p></table:table-cell><table:table-cell/><table:table-cell office:value-type="float" office:value="8"><text:p>8</text:p></table:table-cell></table:table-row>
</table:table></office:spreadsheet></office:body>
</office:document>"#;
#[test]
fn a_column_button_sorts_its_own_block_not_the_cursors() {
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;
};
update(&mut cx, &book, |book, cx| {
book.new_from_text("blocks.fods", BLOCKS, cx)
});
settle(&cx, |app| sheet_names(app, &book) == ["S"]);
run(&mut cx, &book, "cell.goto", &["A1"]);
run(&mut cx, &book, "data.autofilter", &[]);
run(&mut cx, &book, "cell.goto", &["C3"]);
let id = cx.update(|app| book.read(app).core.active.expect("a tab"));
update(&mut cx, &book, |book, cx| {
book.ask_column(id, &Request::Filter { column: 0, row: 0 }, cx)
});
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Column { .. }));
update(&mut cx, &book, |book, cx| book.column_asked(Some(true), cx));
assert_eq!(cells(&mut cx, &book, "A2:A4"), "1\n2\n3");
assert_eq!(cells(&mut cx, &book, "C2:C4"), "9\n7\n8");
}
+24
View File
@@ -44,3 +44,27 @@ fn a_failed_command_still_moves_the_state_on() {
update(&mut cx, &book, |book, cx| book.run_active("sheet.rename", &["Plan", "1"], cx));
settle(&cx, |app| sheet_names(app, &book) == ["Plan"]);
}
#[test]
fn a_failed_print_says_printing_failed() {
use ely_gpui_component::primitives::Severity;
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;
};
// The window's toasts live on its root.
let root = window
.update(&mut cx, |_, window, cx| {
let root = ely_ui::document_root(ely_platform::AppId::Table, book.clone().into(), &ely_ui::commands::FILE, Vec::new(), window, cx);
book.update(cx, |book, _| book.core.root = root.downgrade());
root
})
.expect("the window");
let gone = dir.path().join("gone.pdf");
update(&mut cx, &book, |_, cx| ely_office::print_later(gone, cx));
cx.run_until_parked();
let (toasts, prefix) = cx.update(|app| (root.read(app).toasts(app), ely_office::text(app, "office.print.failed", &[("error", "")])));
assert!(toasts.iter().any(|(text, severity)| *severity == Some(Severity::Danger) && text.starts_with(prefix.as_ref())), "{toasts:?}");
}
+121
View File
@@ -377,3 +377,124 @@ fn text_files_wait_their_turn_for_the_import_dialog() {
names.sort();
assert_eq!(names, ["a.csv", "b.csv"], "both files open");
}
#[test]
fn an_import_waits_behind_a_dialog_that_displaces_it() {
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;
};
blank(&mut cx, &book);
let path = dir.path().join("wait.csv");
std::fs::write(&path, "Id,Ignored\n1E3,hidden\n").expect("writes");
update(&mut cx, &book, |book, cx| book.ask_import(path.clone(), cx));
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(pending) if !pending.reading));
update(&mut cx, &book, |book, _| {
if let Some(Dialog::Import(pending)) = &mut book.dialog {
pending.how.columns = vec![Column::Text, Column::Skip];
}
});
window
.update(&mut cx, |_, window, cx| {
book.update(cx, |book, cx| book.on_export(&ely_ui::commands::Export, window, cx))
})
.expect("the window");
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Export { .. }));
update(&mut cx, &book, |book, cx| {
book.dialog = None;
cx.notify();
});
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(pending) if pending.path.ends_with("wait.csv")));
cx.run_until_parked();
update(&mut cx, &book, |book, cx| book.import_asked(cx));
settle(&cx, |app| book.read(app).active_tab().is_some_and(|tab| tab.name == "wait.csv"));
// Text keeps `1E3`; the skipped column stays empty.
assert_eq!(cells(&mut cx, &book, "A2:B2").trim_end(), "1E3");
}
#[test]
fn closing_a_tab_takes_its_dialog_and_frees_the_import() {
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;
};
blank(&mut cx, &book);
window
.update(&mut cx, |_, window, cx| {
book.update(cx, |book, cx| book.on_export(&ely_ui::commands::Export, window, cx))
})
.expect("the window");
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Export { .. }));
let id = cx.update(|app| book.read(app).core.tabs[0].id);
update(&mut cx, &book, |book, cx| book.close_tab(id, cx));
let path = dir.path().join("after.csv");
std::fs::write(&path, "Name\nx\n").expect("writes");
update(&mut cx, &book, |book, cx| book.ask_import(path.clone(), cx));
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(_)));
}
#[test]
fn a_whole_column_print_area_still_applies() {
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;
};
blank(&mut cx, &book);
update(&mut cx, &book, |book, cx| book.ask_page(cx));
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Page { .. }));
type_in(&mut cx, &book, area, "A:D");
update(&mut cx, &book, |book, cx| book.page_asked(cx));
let layout = read(&mut cx, &book, sheet::print_layout, sheet::LAYOUT);
assert_eq!(layout.areas.len(), 1, "set: {:?}", layout.areas);
// The kernel's own text comes back into the dialog.
update(&mut cx, &book, |book, cx| book.ask_page(cx));
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Page { .. }));
let shown = cx.update(|app| match book.read(app).dialog.as_ref() {
Some(dialog) => area(dialog).read(app).text().to_string(),
None => unreachable!("a page dialog"),
});
assert_eq!(shown, layout.areas[0]);
update(&mut cx, &book, |book, cx| {
if let Some(Dialog::Page { landscape, .. }) = &mut book.dialog {
*landscape = true;
}
book.page_asked(cx)
});
let layout = read(&mut cx, &book, sheet::print_layout, sheet::LAYOUT);
assert!(layout.landscape, "applied with {shown}");
}
#[test]
fn the_default_encoding_follows_the_apps_language() {
use ely_gpui_component::i18n::I18n;
use ely_lok::sheet::csv::Encoding;
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;
};
let path = dir.path().join("gb.csv");
// 你好 in GB18030: not UTF-8.
std::fs::write(&path, [0xC4, 0xE3, 0xBA, 0xC3, b'\n']).expect("writes");
for (tag, expected) in [("zh-CN", Encoding::Gb18030), ("en-US", Encoding::Windows1252)] {
cx.update(|app| I18n::set_locale(tag, app));
update(&mut cx, &book, |book, cx| book.ask_import(path.clone(), cx));
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(_)));
let found = cx.update(|app| match book.read(app).dialog.as_ref() {
Some(Dialog::Import(pending)) => pending.how.encoding,
_ => unreachable!("an import dialog"),
});
assert_eq!(found, expected, "{tag}");
update(&mut cx, &book, |book, cx| {
book.dialog = None;
cx.notify();
});
}
}
+30 -16
View File
@@ -154,22 +154,20 @@ fn decode(bytes: &[u8], encoding: Encoding) -> Option<String> {
.map(|text| text.into_owned())
}
/// Rows as Calc's text import reads them (impex.cxx).
fn split(text: &str, how: &Import) -> Vec<Vec<String>> {
/// Calc's text-import rows (impex.cxx), one at a time.
fn split<'a>(text: &'a str, how: &'a Import) -> impl Iterator<Item = Vec<String>> + 'a {
let mut chars = text.chars().peekable();
let mut rows = Vec::new();
while chars.peek().is_some() {
std::iter::from_fn(move || {
chars.peek()?;
let mut row = Vec::new();
loop {
let (value, more) = field(&mut chars, how);
row.push(value);
if !more {
break;
return Some(row);
}
}
rows.push(row);
}
rows
})
}
/// One field, and whether the record goes on.
@@ -233,15 +231,19 @@ impl Book {
pub(crate) fn ask_import(&mut self, path: PathBuf, cx: &mut Context<Self>) {
let reading = path.clone();
let chinese = ely_ui::i18n::resolve(&ely_platform::locale::system()) == "zh-CN";
// The language the app shows, not the system's.
let chinese = cx.global::<ely_gpui_component::i18n::I18n>().locale().tag == "zh-CN";
cx.spawn(async move |this, cx| {
let bytes = cx
.background_spawn(async move { std::fs::read(&reading) })
// Guessing checks the whole file: off the window thread.
let pending = cx
.background_spawn(async move {
std::fs::read(&reading).map(|bytes| Pending::new(reading, bytes, chinese))
})
.await;
in_window(&this, cx, |book, _, cx| match bytes {
Ok(bytes) => {
log::info!("table: import asks for {} bytes", bytes.len());
book.imports.push_back(Pending::new(path, bytes, chinese));
in_window(&this, cx, |book, _, cx| match pending {
Ok(pending) => {
log::info!("table: import asks for {} bytes", pending.bytes.len());
book.imports.push_back(pending);
book.next_import(cx);
}
Err(error) => book.failed_open(&path.display().to_string(), &error, cx),
@@ -250,14 +252,26 @@ impl Book {
.detach_and_log_err(cx);
}
/// Opens `dialog`; a displaced import waits its turn again.
pub(crate) fn open_dialog(&mut self, dialog: Dialog) {
if let Some(Dialog::Import(pending)) = self.dialog.replace(dialog) {
log::info!("table: import waits behind another dialog");
self.imports.push_front(*pending);
}
}
/// The next waiting import asks, once no dialog is open.
pub(crate) fn next_import(&mut self, cx: &mut Context<Self>) {
if self.dialog.is_some() {
return;
}
if let Some(pending) = self.imports.pop_front() {
// A finished scan keeps the reader's column choices.
let unread = pending.reading;
self.dialog = Some(Dialog::Import(Box::new(pending)));
self.scan_import(cx);
if unread {
self.scan_import(cx);
}
}
}
+9
View File
@@ -98,6 +98,15 @@ impl Doc {
Ok((unpack.i64()?, unpack.i64()?))
}
/// The size and the client zoom it was measured at.
pub fn size_at(&self, worker: &mut Worker) -> io::Result<((i64, i64), (i32, i32))> {
let payload = worker.request(tag::DOC_SIZE, &Pack::new().u64(worker.doc_id(self)?).bytes())?;
let mut unpack = Unpack::new(&payload);
let size = (unpack.i64()?, unpack.i64()?);
let number = |value: i64| i32::try_from(value).map_err(io::Error::other);
Ok((size, (number(unpack.i64()?)?, number(unpack.i64()?)?)))
}
/// Page rectangles in twips; empty without pages.
pub fn pages(&self, worker: &mut Worker) -> io::Result<Vec<Rect>> {
let payload = worker.request(tag::PAGES, &Pack::new().u64(worker.doc_id(self)?).bytes())?;
@@ -256,3 +256,21 @@ fn commands_from_one_read_run_together_and_hovering_changes_nothing() {
assert!(crate::is_stale(&stale), "{stale}");
assert_eq!(only(&mut worker), ["One", "Two", "U"], "nothing else was renamed");
}
#[test]
fn a_header_act_after_a_click_is_not_stale() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_book(SHEETS);
let read = worker.now(&doc).expect("the state");
// A click moves the revision; rows and columns stay.
for kind in [0, 1] {
let click = crate::Mouse { kind, x: 500, y: 500, count: 1, buttons: 1, modifiers: 0 };
worker.post_mouse(&read, click).expect("clicks");
}
assert_ne!(worker.now(&doc).expect("the state"), read, "the click counts as a change");
worker.run(&read, "select.column", &["2", "0"]).expect("the column header still selects");
worker.run(&read, "columns.width", &["800", "3"]).expect("the column's width still sets");
}
+4 -2
View File
@@ -61,7 +61,7 @@ pub struct Command {
/// Arguments that count things as a read saw them.
const NUMBERED: &[&str] = &[
"Index", "nTabNumber", "TabNo", "Part", "Row", "Col", "Column", "StartColumn", "StartRow", "EndColumn", "EndRow",
"Index", "nTabNumber", "TabNo", "Part", "StartColumn", "StartRow", "EndColumn", "EndRow",
];
impl Command {
@@ -306,7 +306,9 @@ mod tests {
assert!(find("sheet.delete").expect("the row").addressed(&["1"]));
assert!(!find("cell.enter").expect("the row").addressed(&["1"]));
assert!(find("notes.set").expect("the row").addressed(&["text", "1"]), "a slide by number");
assert!(find("select.row").expect("the row").addressed(&["3", "0"]), "a row by number");
// Clicks renumber no row or column: never stale.
assert!(!find("select.row").expect("the row").addressed(&["3", "0"]), "a header row");
assert!(!find("columns.width").expect("the row").addressed(&["500", "2"]), "a column's width");
assert!(!find("rows.height_selected").expect("the row").addressed(&["500"]), "a size names nothing");
}
+19
View File
@@ -113,6 +113,8 @@ struct Slot {
result: Option<(String, bool)>,
/// The seq last pushed to the client.
pushed: u64,
/// The client zoom last set: tile pixels, tile twips.
zoom: (i32, i32),
}
impl Slot {
@@ -447,6 +449,23 @@ pub fn wait_cropping(id: u64, timeout: Duration) -> bool {
}
}
/// The client zoom last set on `id`, in tile units.
pub fn zoom(id: u64) -> (i32, i32) {
slots().lock().expect("the events lock").get(&id).map_or((0, 0), |slot| slot.zoom)
}
/// A new client zoom resizes the document unannounced (Calc).
pub fn rescaled(id: u64, zoom: (i32, i32)) {
if let Some(slot) = slots().lock().expect("the events lock").get_mut(&id)
&& slot.zoom != zoom
{
slot.zoom = zoom;
slot.batch.resized += 1;
slot.batch.seq += 1;
}
signal().notify_all();
}
/// Seeds the a11y text from the kernel's snapshot (§7 tail).
pub fn seed_a11y(id: u64, paragraph: &str) {
if let Some(slot) = slots().lock().expect("the events lock").get_mut(&id) {
+6 -1
View File
@@ -18,11 +18,16 @@ pub use client::{
};
#[cfg(feature = "view")]
pub use view::{
CONTEXT, Copy, Cut, Geom, Layout, LokError, LokView, Paste, Reported, TILE_TWIPS, ZOOM_STEPS,
CONTEXT, Copy, Cut, Geom, Layout, LokError, LokView, Paste, Reported, ZOOM_STEPS,
Zoomed, init, layout, paper, read_off, scheme,
};
#[cfg(feature = "view")]
mod view;
/// One tile edge in twips.
pub const TILE_TWIPS: i32 = 3840;
/// Twips per pixel at zoom 1.0 and DPI 1.0.
pub(crate) const TWIPS_PER_PIXEL: f32 = 15.0;
pub use ipc::STALE;
pub use ipc::file_url_of as file_url;
pub use ipc::path_of_file_url as file_path;
+4 -11
View File
@@ -33,10 +33,7 @@ pub use input::{CONTEXT, Copy, Cut, Paste, init};
pub use off::read_off;
pub use render::scheme;
/// One tile edge in twips.
pub const TILE_TWIPS: i32 = 3840;
/// Twips per pixel at zoom 1.0 and DPI 1.0.
pub(crate) const TWIPS_PER_PIXEL: f32 = 15.0;
pub(crate) use crate::{TILE_TWIPS, TWIPS_PER_PIXEL};
/// Document/view conversion under one zoom and DPI (LOK-04).
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -147,7 +144,7 @@ pub struct LokView {
/// A document's size and page frames, in twips.
pub type Layout = ((i64, i64), Vec<Rect>);
type Measured = io::Result<Layout>;
use sheet::{Measured, measure};
/// A page's colour; the kernel's Ely schemes share it.
pub fn paper(cx: &gpui::App) -> gpui::Hsla {
@@ -165,10 +162,6 @@ pub fn layout(worker: &mut Worker, doc: &Doc) -> io::Result<Layout> {
Ok((doc.size(worker)?, doc.pages(worker)?))
}
/// Reads the layout under the worker lock.
fn measure(worker: &Mutex<Worker>, doc: &Doc) -> Measured {
layout(&mut worker.lock().expect("the worker lock"), doc)
}
impl EventEmitter<LokError> for LokView {}
@@ -362,8 +355,8 @@ impl LokView {
// What the view shows is the document at this revision.
self.seen_revision(batch.revision);
match measured {
Some(Ok(((width, height), pages))) => {
self.doc_size = (width as i32, height as i32);
Some(Ok((((width, height), pages), zoom))) => {
self.doc_size = self.at_scale((width as i32, height as i32), zoom);
if pages.len() != self.pages.len() {
log::info!("lok: {} pages", pages.len());
}
+14 -4
View File
@@ -111,10 +111,20 @@ impl LokView {
if self.fit {
return self.doc_size;
}
self.tile_of((
self.doc_size.0.max(self.reach.0),
self.doc_size.1.max(self.reach.1),
))
// The kernel sizes a sheet in tile twips already.
let reach = self.tile_of(self.reach);
let (w, h) = (self.doc_size.0.max(reach.0), self.doc_size.1.max(reach.1));
let Some(sheet) = &self.sheet else {
return (w, h);
};
// Sheets scroll ahead of the kernel, to their end.
let end = self.tile_of((sheet.columns.start(sheet.columns.count()), sheet.rows.start(sheet.rows.count())));
let (fx, fy) = self.frozen_tiles();
let ahead = (
fx + self.scroll.0 + 2 * self.geom.twips_of(self.viewport.0),
fy + self.scroll.1 + 2 * self.geom.twips_of(self.viewport.1),
);
(w.max(ahead.0.min(end.0)), h.max(ahead.1.min(end.1)))
}
/// The print-twips document point under a view position (LOK-04).
+33 -3
View File
@@ -1,9 +1,21 @@
//! Calc reports print twips; tiles and clicks take tile twips.
use std::{io, sync::Mutex};
use gpui::Context;
use super::{LokView, TILE_TWIPS};
use crate::{Rect, sheet::Geometry};
use super::{Layout, LokView, TILE_TWIPS};
use crate::{Doc, Rect, Worker, sheet::Geometry};
/// A layout and the client zoom it was measured at.
pub(super) type Measured = io::Result<(Layout, (i32, i32))>;
/// Reads the layout under the worker lock.
pub(super) fn measure(worker: &Mutex<Worker>, doc: &Doc) -> Measured {
let mut worker = worker.lock().expect("the worker lock");
let (size, zoom) = doc.size_at(&mut worker)?;
Ok(((size, doc.pages(&mut worker)?), zoom))
}
impl LokView {
/// Lays the sheet out the kernel's way.
@@ -51,11 +63,29 @@ impl LokView {
/// Rescales, keeping the scrolling pane's start in place.
pub(super) fn keep_scroll(&mut self, change: impl FnOnce(&mut Self)) {
let start = self.scrolled_start();
change(self);
self.carry_size(change);
let ((x, y), (fx, fy)) = (self.tile_of(start), self.frozen_tiles());
self.scroll = (x - fx, y - fy);
}
/// Rescales; the measured size moves to the new scale too.
pub(super) fn carry_size(&mut self, change: impl FnOnce(&mut Self)) {
// Until measured again, scrolls clamp against this.
let print = self.print_of(self.doc_size);
change(self);
self.doc_size = self.tile_of(print);
}
/// A sheet size the kernel measured at `zoom`, at ours.
pub(super) fn at_scale(&self, size: (i32, i32), (pixels, twips): (i32, i32)) -> (i32, i32) {
let Some(sheet) = self.sheet.as_ref().filter(|_| pixels > 0 && twips > 0) else {
return size;
};
let theirs = f64::from(pixels) / f64::from(twips);
let print = (sheet.columns.print(size.0, theirs), sheet.rows.print(size.1, theirs));
self.tile_of(print)
}
/// Where the scrolling pane starts, in print twips.
pub fn scrolled_start(&self) -> (i32, i32) {
let (fx, fy) = self.frozen_tiles();
+216 -2
View File
@@ -82,14 +82,22 @@ fn far_down(cx: &mut gpui::TestAppContext, install: &std::path::Path) -> (Entity
});
let geometry = crate::sheet::geometry(&json).expect("parsed");
let row = cell(&geometry, 1, 4997);
let bounds = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(800.0), px(600.0)) };
cx.update(|app| {
view.update(app, |view, cx| {
view.geom.dpi = 2.0;
view.set_geometry(geometry.clone(), cx);
view.prepare(Bounds { origin: point(px(0.0), px(0.0)), size: size(px(800.0), px(600.0)) });
view.show(row, cx);
view.prepare(bounds);
})
});
// The first scale's report and size land before the move.
for _ in 0..100 {
cx.executor().tick();
}
cx.update(|app| view.update(app, |view, cx| {
view.show(row, cx);
view.prepare(bounds);
}));
(view, geometry)
}
@@ -192,6 +200,11 @@ fn a_sheet_brings_far_cut_and_frozen_cells_into_view() {
view.set_geometry(crate::sheet::geometry(&json).expect("parsed"), cx);
view.prepare(wide);
assert_eq!(view.spare, (0.0, 0.0), "and stays left after it");
let size = view.doc_size;
view.doc_size = view.tile_of((view.print_of(size).0 + 20 * 1280, size.1));
let extent = view.extent();
assert!(extent.0 >= view.doc_size.0 && extent.1 >= view.doc_size.1, "the kernel's size is uncut");
view.doc_size = size;
view.viewport = (800.0, 200.0);
let (width, height) = (view.geom.twips_of(view.viewport.0), view.geom.twips_of(view.viewport.1));
let shows = |view: &LokView, rect: crate::Rect| {
@@ -254,3 +267,204 @@ fn a_newly_selected_object_comes_into_view_a_moved_one_stays() {
});
});
}
/// Data out to the last cell sets the size.
const FULL_SHEET: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
<office:automatic-styles><style:style style:name="co1" style:family="table-column"><style:table-column-properties style:column-width="0.4cm"/></style:style><style:style style:name="ro1" style:family="table-row"><style:table-row-properties style:row-height="0.5cm" style:use-optimal-row-height="false"/></style:style></office:automatic-styles>
<office:body><office:spreadsheet><table:table table:name="S">
<table:table-column table:style-name="co1" table:number-columns-repeated="2048"/>
<table:table-row table:style-name="ro1" table:number-rows-repeated="5999"><table:table-cell table:number-columns-repeated="2048"/></table:table-row>
<table:table-row table:style-name="ro1"><table:table-cell table:number-columns-repeated="2047"/><table:table-cell office:value-type="float" office:value="1"><text:p>1</text:p></table:table-cell></table:table-row>
</table:table></office:spreadsheet></office:body>
</office:document>"#;
#[test]
fn a_zoom_measures_the_sheet_again() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = gpui::TestAppContext::single();
let (view, _) = cx.update(|app| live_view_of(app, &install, "full.fods", FULL_SHEET));
let bounds = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(400.0), px(200.0)) };
cx.update(|app| view.update(app, |view, _| view.prepare(bounds)));
for _ in 0..100 {
cx.executor().tick();
}
let before = cx.update(|app| view.read(app).doc_size);
cx.update(|app| view.update(app, |view, cx| {
view.set_zoom(1.25, cx);
view.prepare(bounds);
}));
// The kernel's size at the new zoom, read fresh.
let kernel = |cx: &mut gpui::TestAppContext| {
cx.update(|app| {
let read = view.read(app);
let (w, h) = read.doc.size(&mut read.worker.lock().expect("the worker lock")).expect("a size");
(w as i32, h as i32)
})
};
// The kernel takes the zoom, then the view its size.
let (mut seen, mut now) = (before, before);
for _ in 0..2000 {
cx.executor().tick();
(seen, now) = (cx.update(|app| view.read(app).doc_size), kernel(&mut cx));
if now != before && seen == now {
break;
}
}
assert_ne!(now, before, "the zoom changes the kernel's size here");
assert_eq!(seen, now, "the view takes the size the kernel has at the new zoom");
}
#[test]
fn a_zoom_at_the_end_of_a_sheet_keeps_the_row_in_the_middle() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = gpui::TestAppContext::single();
let (view, worker) = cx.update(|app| live_view_of(app, &install, "full.fods", FULL_SHEET));
let json = cx.update(|app| worker.lock().expect("the worker lock").values(&view.read(app).doc, crate::sheet::GEOMETRY).expect("geometry"));
let geometry = crate::sheet::geometry(&json).expect("parsed");
let bounds = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(400.0), px(200.0)) };
cx.update(|app| view.update(app, |view, cx| {
view.set_geometry(geometry.clone(), cx);
view.prepare(bounds);
}));
for _ in 0..100 {
cx.executor().tick();
}
// Scrolled to the end, as by the wheel.
cx.update(|app| view.update(app, |view, cx| {
view.scroll_by(0.0, -1.0e7, cx);
view.prepare(bounds);
}));
for _ in 0..100 {
cx.executor().tick();
}
let middle = |cx: &mut gpui::TestAppContext| {
cx.update(|app| {
let read = view.read(app);
let at = read.origin() + point(px(200.0), px(100.0));
geometry.rows.at(read.doc_point(at).1)
})
};
let before = middle(&mut cx);
cx.update(|app| view.update(app, |view, cx| {
view.set_zoom(1.25, cx);
view.prepare(bounds);
}));
let right_after = middle(&mut cx);
for _ in 0..400 {
cx.executor().tick();
}
let measured = middle(&mut cx);
assert!(before.abs_diff(right_after) <= 1, "row {before} became {right_after} at the zoom");
assert!(before.abs_diff(measured) <= 1, "row {before} became {measured} once measured");
}
#[test]
fn a_late_size_from_the_last_zoom_keeps_the_row() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = gpui::TestAppContext::single();
let (view, worker) = cx.update(|app| live_view_of(app, &install, "full.fods", FULL_SHEET));
let json = cx.update(|app| worker.lock().expect("the worker lock").values(&view.read(app).doc, crate::sheet::GEOMETRY).expect("geometry"));
let geometry = crate::sheet::geometry(&json).expect("parsed");
let bounds = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(400.0), px(200.0)) };
let settle = |cx: &mut gpui::TestAppContext| {
for _ in 0..200 {
cx.executor().tick();
}
};
cx.update(|app| view.update(app, |view, cx| {
view.set_geometry(geometry.clone(), cx);
view.set_zoom(1.25, cx);
view.prepare(bounds);
}));
settle(&mut cx);
cx.update(|app| view.update(app, |view, cx| {
view.scroll_by(0.0, -1.0e7, cx);
view.prepare(bounds);
}));
settle(&mut cx);
// Measured at 125%, landing after the zoom to 150%.
let late = cx.update(|app| super::super::measure(&view.read(app).worker, &view.read(app).doc));
let middle = |cx: &mut gpui::TestAppContext| {
cx.update(|app| {
let read = view.read(app);
geometry.rows.at(read.doc_point(read.origin() + point(px(200.0), px(100.0))).1)
})
};
cx.update(|app| view.update(app, |view, cx| view.set_zoom(1.5, cx)));
let before = middle(&mut cx);
cx.update(|app| view.update(app, |view, cx| view.apply_batch(crate::Events::default(), Some(late), cx)));
let after = middle(&mut cx);
assert!(before.abs_diff(after) <= 1, "row {before} became {after} when the old size landed");
}
#[test]
fn the_first_size_has_a_scale_and_keeps_the_row() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = gpui::TestAppContext::single();
let (view, worker) = cx.update(|app| live_view_of(app, &install, "full.fods", FULL_SHEET));
// Read before any visible area: the load set a scale.
let first = cx.update(|app| super::super::measure(&view.read(app).worker, &view.read(app).doc));
assert_eq!(first.as_ref().map(|(_, zoom)| *zoom).ok(), Some((256, 3840)), "a known scale");
let json = cx.update(|app| worker.lock().expect("the worker lock").values(&view.read(app).doc, crate::sheet::GEOMETRY).expect("geometry"));
let geometry = crate::sheet::geometry(&json).expect("parsed");
let bounds = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(400.0), px(200.0)) };
cx.update(|app| view.update(app, |view, cx| {
view.set_geometry(geometry.clone(), cx);
view.prepare(bounds);
view.scroll_by(0.0, -1.0e7, cx);
}));
let middle = |cx: &mut gpui::TestAppContext| {
cx.update(|app| {
let read = view.read(app);
geometry.rows.at(read.doc_point(read.origin() + point(px(200.0), px(100.0))).1)
})
};
cx.update(|app| view.update(app, |view, cx| view.set_zoom(1.25, cx)));
let before = middle(&mut cx);
cx.update(|app| view.update(app, |view, cx| view.apply_batch(crate::Events::default(), Some(first), cx)));
let after = middle(&mut cx);
assert!(before.abs_diff(after) <= 1, "row {before} became {after} when the first size landed");
}
#[test]
fn an_empty_sheet_scrolls_on_without_waiting() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = gpui::TestAppContext::single();
let (view, worker) = cx.update(|app| live_view_of(app, &install, "short.fods", SHORT_ROWS));
let json = cx.update(|app| worker.lock().expect("the worker lock").values(&view.read(app).doc, crate::sheet::GEOMETRY).expect("geometry"));
let geometry = crate::sheet::geometry(&json).expect("parsed");
let bounds = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(400.0), px(200.0)) };
cx.update(|app| view.update(app, |view, cx| {
view.set_geometry(geometry.clone(), cx);
view.prepare(bounds);
}));
// Each step a viewport down, before the kernel answers.
for step in 1..=20 {
let moved = cx.update(|app| view.update(app, |view, cx| {
let from = view.scroll.1;
view.scroll_by(0.0, -200.0, cx);
view.scroll.1 - from
}));
let full = cx.update(|app| view.read(app).geom.twips_of(200.0));
assert_eq!(moved, full, "step {step} went the whole way");
}
}
+1 -1
View File
@@ -54,7 +54,7 @@ impl LokView {
let under = self.print_of((self.scroll.0 + self.geom.twips_of(sx), self.scroll.1 + self.geom.twips_of(sy)));
self.fit = false;
if (zoom - self.geom.zoom).abs() > 0.0001 {
self.geom.zoom = zoom;
self.carry_size(|view| view.geom.zoom = zoom);
self.retire_tiles();
self.failed.clear();
}
+13 -2
View File
@@ -192,8 +192,12 @@ fn answer(lok: &Lok, tag: u8, id: u64, payload: &[u8]) {
tag::EXPORT_TEXT => text::export(unpack).map(|()| Vec::new()),
tag::DOC_SIZE => unpack
.u64()
.and_then(|id| with_doc(id, |doc| doc.size()))
.map(|(width, height)| Pack::new().i64(width).i64(height).bytes()),
.and_then(|id| Ok((with_doc(id, |doc| doc.size())?, crate::events::zoom(id))))
.map(|((width, height), (pixels, twips))| {
// The scale the kernel measured at; 0 if never set.
let pack = Pack::new().i64(width).i64(height);
pack.i64(i64::from(pixels)).i64(i64::from(twips)).bytes()
}),
tag::SAVE_AS => save(unpack).map(|()| Vec::new()),
tag::EXPORT_PDF => export_pdf(unpack).map(|()| Vec::new()),
tag::CLOSE_DOC => unpack.u64().map(|id| {
@@ -255,6 +259,7 @@ fn answer(lok: &Lok, tag: u8, id: u64, payload: &[u8]) {
doc.set_client_zoom(pixels, twips);
doc.set_visible_area(x, y, w, h)
})
.inspect(|_| crate::events::rescaled(id, (pixels, twips)))
})
.map(|()| Vec::new()),
tag::SELECTION_TEXT => unpack
@@ -331,6 +336,12 @@ fn load(lok: &Lok, url: &str, password: String, filter: &str) -> io::Result<u64>
crate::events::seed_a11y(id, &doc.a11y_paragraph()?);
// Settle the initial layout now; later paints see it (LOK-03).
doc.post_uno(".uno:Repaginate", None)?;
// A sheet's size has a known scale from the start.
if doc.kind() == 1 {
let zoom = (crate::TILE_TWIPS / crate::TWIPS_PER_PIXEL as i32, crate::TILE_TWIPS);
doc.set_client_zoom(zoom.0, zoom.1);
crate::events::rescaled(id, zoom);
}
let mut docs = docs().lock().expect("the docs lock");
docs.insert(id, doc);
log::info!("lok-worker: {} documents open", docs.len());
+7 -6
View File
@@ -242,11 +242,7 @@ pub fn in_window<H: Host, R>(
}
/// Opens the print panel after GPUI releases the app.
pub fn print_later<H: Host>(
pdf: std::path::PathBuf,
cx: &mut Context<H>,
failed: impl FnOnce(&mut H, String, &mut Context<H>) + 'static,
) {
pub fn print_later<H: Tabs>(pdf: std::path::PathBuf, cx: &mut Context<H>) {
// The panel's modal loop calls back into GPUI.
cx.spawn(async move |this, cx| {
let printed = ely_platform::print::print_pdf(&pdf);
@@ -258,7 +254,7 @@ pub fn print_later<H: Host>(
Ok(printed) => log::info!("office: print done, printed {printed}"),
Err(error) => {
if this
.update(cx, |host, cx| failed(host, error.to_string(), cx))
.update(cx, |host, cx| host.print_failed(&error.to_string(), cx))
.is_err()
{
log::error!("office: printing failed after its window closed: {error}");
@@ -293,6 +289,11 @@ pub trait Tabs: Host {
self.toast_unlogged(text, severity, cx);
}
fn print_failed(&self, error: &str, cx: &mut App) {
let text = text(cx, "office.print.failed", &[("error", error)]);
self.toast(text, Severity::Danger, cx);
}
/// A success toast with a button that opens `path`.
fn toast_output(&self, text: SharedString, path: std::path::PathBuf, cx: &mut App) {
log::info!("{}: {text}", Self::KIND.app.slug());
+23 -7
View File
@@ -9,18 +9,34 @@ static const char *stamps[] = { "Approved", "AsIs", "Confidential", "Departmenta
"Expired", "Final", "ForComment", "ForPublicRelease", "NotApproved", "NotForPublicRelease", "Sold",
"TopSecret", NULL };
/* Valid UTF-8 always: a stray byte becomes U+FFFD. */
void ely_json_string(fz_context *ctx, fz_output *out, const char *s)
{
char utf8[FZ_UTFMAX];
int rune;
fz_write_byte(ctx, out, '"');
for (; *s; s++)
while (*s)
{
unsigned char c = (unsigned char)*s;
if (c == '"' || c == '\\')
fz_write_printf(ctx, out, "\\%c", c);
else if (c < 32)
fz_write_printf(ctx, out, "\\u%04x", c);
s += fz_chartorune(&rune, s);
/* A surrogate pair joins; a lone one is no character. */
if (rune >= 0xD800 && rune < 0xDC00)
{
int low;
int n = fz_chartorune(&low, s);
if (low >= 0xDC00 && low < 0xE000)
{
rune = 0x10000 + ((rune - 0xD800) << 10) + (low - 0xDC00);
s += n;
}
}
if ((rune >= 0xD800 && rune < 0xE000) || rune > 0x10FFFF)
rune = FZ_REPLACEMENT_CHARACTER;
if (rune == '"' || rune == '\\')
fz_write_printf(ctx, out, "\\%c", rune);
else if (rune < 32)
fz_write_printf(ctx, out, "\\u%04x", rune);
else
fz_write_byte(ctx, out, c);
fz_write_data(ctx, out, utf8, fz_runetochar(utf8, rune));
}
fz_write_byte(ctx, out, '"');
}
+12 -11
View File
@@ -13,10 +13,10 @@ typedef struct
int page;
} inner_link;
/* Takes page references off the source's annotations, links noted. */
static int unhook(fz_context *ctx, pdf_document *src, inner_link *links, int room)
/* Takes page references off; links noted, `*n` counted as kept. */
static void unhook(fz_context *ctx, pdf_document *src, inner_link *links, int room, int *n)
{
int i, j, n = 0, count = pdf_count_pages(ctx, src);
int i, j, count = pdf_count_pages(ctx, src);
for (i = 0; i < count; i++)
{
pdf_obj *annots = pdf_dict_get(ctx, pdf_lookup_page_obj(ctx, src, i), PDF_NAME(Annots));
@@ -26,7 +26,7 @@ static int unhook(fz_context *ctx, pdf_document *src, inner_link *links, int roo
pdf_obj *page = ely_target(ctx, src, annot);
int to = page ? pdf_lookup_page_number(ctx, src, page) : -1;
pdf_dict_del(ctx, annot, PDF_NAME(P));
if (to < 0 || n == room)
if (to < 0 || *n == room)
continue;
/* The view after the page: /XYZ, /Fit and so on. */
pdf_obj *dest = pdf_dict_get(ctx, annot, PDF_NAME(Dest));
@@ -36,16 +36,17 @@ static int unhook(fz_context *ctx, pdf_document *src, inner_link *links, int roo
dest = pdf_lookup_dest(ctx, src, dest);
if (pdf_is_dict(ctx, dest))
dest = pdf_dict_get(ctx, dest, PDF_NAME(D));
links[n].annot = pdf_keep_obj(ctx, annot);
links[n].rest = pdf_copy_array(ctx, dest);
pdf_array_delete(ctx, links[n].rest, 0);
links[n].page = to;
n++;
/* Counted as soon as kept: a throw still frees it. */
links[*n].annot = pdf_keep_obj(ctx, annot);
links[*n].rest = NULL;
links[*n].page = to;
(*n)++;
links[*n - 1].rest = pdf_copy_array(ctx, dest);
pdf_array_delete(ctx, links[*n - 1].rest, 0);
pdf_dict_del(ctx, annot, PDF_NAME(Dest));
pdf_dict_del(ctx, annot, PDF_NAME(A));
}
}
return n;
}
/* The top of the field tree `widget` belongs to. */
@@ -338,7 +339,7 @@ int ely_insert_file(fz_context *ctx, fz_document *doc, int at, const char *path,
for (i = 0; i < *count; i++)
room += pdf_array_len(ctx, pdf_dict_get(ctx, pdf_lookup_page_obj(ctx, src, i), PDF_NAME(Annots)));
links = fz_calloc(ctx, room + 1, sizeof *links);
n = unhook(ctx, src, links, room);
unhook(ctx, src, links, room, &n);
map = pdf_new_graft_map(ctx, pdf);
pdf_begin_operation(ctx, pdf, "Insert pages");
begun = 1;
+15 -16
View File
@@ -29,13 +29,13 @@ static int unicode_of(pdf_font_desc *desc, int cid)
return n == 1 && ucs[0] > 0 && ucs[0] != FZ_REPLACEMENT_CHARACTER ? ucs[0] : -1;
}
/* Unicode and code pairs of `desc`; its codes are CIDs. */
static void own_codes(fz_context *ctx, pdf_font_desc *desc, ely_pen *p)
/* `desc` Unicode-to-code pairs; -1 when codes are not CIDs. */
static int own_codes(fz_context *ctx, pdf_font_desc *desc, ely_pen *p)
{
pdf_cmap *enc = desc->encoding;
int cid, end;
if (!enc || strcmp(enc->cmap_name, "Identity-H") || enc->codespace_len != 1 || enc->codespace[0].n > 2)
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the document's font uses codes Ely cannot write");
return -1;
p->bytes = enc->codespace[0].n;
end = 1 << (8 * p->bytes);
p->map = fz_malloc_array(ctx, 2 * end, int);
@@ -45,10 +45,11 @@ static void own_codes(fz_context *ctx, pdf_font_desc *desc, ely_pen *p)
if (u > 0 && pdf_font_cid_to_gid(ctx, desc, cid) > 0)
p->map[2 * p->mapped] = u, p->map[2 * p->mapped + 1] = cid, p->mapped++;
}
return 0;
}
/* The page font drawn as `own`, with its codes. */
static void own_font(fz_context *ctx, pdf_page *page, fz_font *own, ely_pen *p)
/* The page font drawn as `own`; -1: cannot write. */
static int own_font(fz_context *ctx, pdf_page *page, fz_font *own, ely_pen *p)
{
pdf_obj *res = pdf_page_resources(ctx, page);
pdf_resource_stack stack = { NULL, res };
@@ -63,16 +64,15 @@ static void own_font(fz_context *ctx, pdf_page *page, fz_font *own, ely_pen *p)
continue;
}
p->desc = desc;
/* A direct font cannot be named again after the edit. */
p->num = pdf_to_num(ctx, pdf_dict_get_val(ctx, fonts, i));
if (!p->num)
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the document's font is not an object of its own");
own_codes(ctx, desc, p);
return p->num ? own_codes(ctx, desc, p) : -1;
}
if (!p->desc)
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the block's font is not a font of its page");
/* Drawn from elsewhere, as in a form: not writable here. */
return -1;
}
void ely_choose_pen(fz_context *ctx, const char *choice, fz_font *own, pdf_page *page, ely_pen *p)
int ely_choose_pen(fz_context *ctx, const char *choice, fz_font *own, pdf_page *page, ely_pen *p)
{
static const struct { const char *choice, *name; } latin[] = {
{ "helvetica", "Helvetica" }, { "times", "Times-Roman" }, { "courier", "Courier" } };
@@ -85,22 +85,21 @@ void ely_choose_pen(fz_context *ctx, const char *choice, fz_font *own, pdf_page
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no script font was given");
p->kind = PEN_CID;
p->font = fz_new_font_from_memory(ctx, "GreatVibes", script_data, (int)script_len, 0, 0);
return;
return 0;
}
if (!strcmp(choice, "original"))
{
if (!own)
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the block has no font of its own");
p->kind = PEN_OWN;
own_font(ctx, page, own, p);
return;
return own_font(ctx, page, own, p);
}
for (i = 0; i < nelem(latin); i++)
if (!strcmp(choice, latin[i].choice))
{
p->kind = PEN_SIMPLE;
p->font = fz_new_base14_font(ctx, latin[i].name);
return;
return 0;
}
for (i = 0; i < nelem(cjk); i++)
if (!strcmp(choice, cjk[i].choice))
@@ -108,7 +107,7 @@ void ely_choose_pen(fz_context *ctx, const char *choice, fz_font *own, pdf_page
p->kind = PEN_CJK;
p->ordering = cjk[i].ordering;
p->font = fz_new_cjk_font(ctx, cjk[i].ordering);
return;
return 0;
}
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no font %s", choice);
}
+1 -1
View File
@@ -76,7 +76,7 @@ typedef struct
int *map, mapped;
} ely_pen;
void ely_choose_pen(fz_context *ctx, const char *choice, fz_font *own, pdf_page *page, ely_pen *p);
int ely_choose_pen(fz_context *ctx, const char *choice, fz_font *own, pdf_page *page, ely_pen *p);
const char *ely_pen_resource(fz_context *ctx, pdf_document *pdf, pdf_page *page, ely_pen *p, char *name, size_t room);
int ely_pen_has(fz_context *ctx, ely_pen *p, int u);
float ely_pen_advance(fz_context *ctx, ely_pen *p, int u, float size);
+52 -7
View File
@@ -4,6 +4,7 @@
#include <string.h>
#include "ely_shim.h"
#include "mupdf/ucdn.h"
/* The page's text, read the same way every time. */
static fz_stext_page *read_text(fz_context *ctx, fz_document *doc, int number)
@@ -58,14 +59,55 @@ static void block_targets(fz_context *ctx, fz_stext_block *block, fz_matrix to_u
}
}
/* `c` as stext spells it (stext-device.c); ligatures split. */
static int spell(int c, int *out)
{
static const char *named[] = { "ff", "fi", "fl", "ffi", "ffl", "st", "st" };
int i, n = 0;
uint32_t lig[18];
if (c >= 0xFB00 && c <= 0xFB06)
{
for (i = 0; named[c - 0xFB00][i]; i++)
out[n++] = named[c - 0xFB00][i];
return n;
}
if ((c >= 0xfb00 && c <= 0xfdff) || (c >= 0xfe70 && c <= 0xfefc))
n = ucdn_compat_decompose((uint32_t)c, lig);
for (i = 0; i < n; i++)
out[i] = (int)lig[i];
if (n > 0)
return n;
out[0] = c;
return 1;
}
/* Targets from `i` one glyph spells: a ligature spells several. */
static int spelled(targets *t, int i, const int *ucs, int ucslen)
{
int j = 1;
if (ucslen < 2 || ucs[0] != t->c[i])
return 1;
while (j < ucslen && i + j < t->n && t->c[i + j] == ucs[j] && (!t->owner || t->owner[i + j] == t->owner[i]))
j++;
return j;
}
/* Removes a glyph drawn at one of the targets. */
static int take_glyph(fz_context *ctx, void *opaque, int *ucs, int ucslen, fz_matrix trm, fz_matrix ctm,
fz_rect bbox, int tr, float ca, float CA)
{
targets *t = opaque;
fz_point at = fz_transform_point(fz_transform_point(fz_make_point(0, 0), trm), ctm);
int i, k, found = 0;
/* MuPDF gives at most PDF_MRANGE_CAP; each spells to 18. */
int i, k, found = 0, spelt[18 * PDF_MRANGE_CAP], n = 0;
t->glyph++;
if (ucslen > PDF_MRANGE_CAP)
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a glyph with %d characters", ucslen);
/* Match what stext reads: ligature code points spelled out. */
for (k = 0; k < ucslen; k++)
n += spell(ucs[k], spelt + n);
ucs = spelt;
ucslen = n;
for (i = 0; i < t->n; i++)
{
int match = 0;
@@ -77,14 +119,14 @@ static int take_glyph(fz_context *ctx, void *opaque, int *ucs, int ucslen, fz_ma
continue;
if (!t->owner)
{
t->hit++;
t->hit += spelled(t, i, ucs, ucslen);
return 1;
}
/* Every block this glyph matches counts it once. */
if (t->seen[t->owner[i]] != t->glyph)
{
t->seen[t->owner[i]] = t->glyph;
t->hits[t->owner[i]]++;
t->hits[t->owner[i]] += spelled(t, i, ucs, ucslen);
}
found = 1;
}
@@ -271,10 +313,13 @@ int ely_text_blocks(fz_context *ctx, fz_document *doc, int number, unsigned char
words = fz_new_buffer(ctx, 256);
block_text(ctx, words, block);
fz_terminate_buffer(ctx, words);
fz_write_printf(ctx, out, "%s{\"rect\":[%g,%g,%g,%g],\"text\":%q,\"font\":%q,\"size\":%g,\"color\":%d,\"editable\":%s}",
first ? "" : ",", block->bbox.x0, block->bbox.y0, block->bbox.x1, block->bbox.y1,
fz_string_from_buffer(ctx, words), fz_font_name(ctx, ch->font), ch->size, (int)(ch->argb & 0xffffff),
editable ? "true" : "false");
fz_write_printf(ctx, out, "%s{\"rect\":[%g,%g,%g,%g],\"text\":",
first ? "" : ",", block->bbox.x0, block->bbox.y0, block->bbox.x1, block->bbox.y1);
ely_json_string(ctx, out, fz_string_from_buffer(ctx, words));
fz_write_string(ctx, out, ",\"font\":");
ely_json_string(ctx, out, fz_font_name(ctx, ch->font));
fz_write_printf(ctx, out, ",\"size\":%g,\"color\":%d,\"editable\":%s}",
ch->size, (int)(ch->argb & 0xffffff), editable ? "true" : "false");
first = 0;
}
fz_write_string(ctx, out, "]");
+5 -4
View File
@@ -156,11 +156,12 @@ static void drop_laid(fz_context *ctx, laid *l)
fz_free(ctx, l->to);
}
/* Lays `a` out from `first`, or names a missing glyph. */
/* Lays `a` out; a missing glyph, or -1: unwritable font. */
static int lay_text(fz_context *ctx, text_args *a, fz_font *own, pdf_page *page, fz_point first, float gap, laid *l)
{
int i;
ely_choose_pen(ctx, a->font, own, page, &l->p);
if (ely_choose_pen(ctx, a->font, own, page, &l->p) < 0)
return -1;
l->n = runes(ctx, a->text, &l->u);
for (i = 0; i < l->n; i++)
if (l->u[i] != '\n' && !ely_pen_has(ctx, &l->p, l->u[i]))
@@ -259,7 +260,7 @@ static void preview(fz_context *ctx, pdf_document *pdf, int number, int block, c
fz_rethrow(ctx);
}
/* Edits, adds or previews text: 0, 2 missing, -1 failed. */
/* Writes or previews text: 0, 2 missing, 3 unwritable, -1. */
int ely_put_text(fz_context *ctx, fz_document *doc, int number, int block, const char *expected, const char *text,
const char *font, float size, int rgb, const float box[3], const char *password, float scale, int *missing,
int *grew, unsigned char **pixels, int *w, int *h)
@@ -325,5 +326,5 @@ int ely_put_text(fz_context *ctx, fz_document *doc, int number, int block, const
*pixels = NULL;
return ely_edit_failed(ctx, pdf, begun);
}
return *missing ? 2 : 0;
return *missing < 0 ? 3 : *missing ? 2 : 0;
}
@@ -59,6 +59,8 @@ pub enum Wrote {
Done,
/// A character the font cannot draw; nothing changed.
Missing(char),
/// The document's own font cannot write; nothing changed.
Unwritable,
/// The would-be result: BGRA, size, and whether it grew.
Preview(Vec<u8>, (u32, u32), bool),
}
@@ -106,6 +108,9 @@ impl Mupdf {
&mut height,
)
})?;
if status == 3 {
return Ok(Wrote::Unwritable);
}
if status == 2 {
let missing = u32::try_from(missing).ok().and_then(char::from_u32);
return Ok(Wrote::Missing(missing.ok_or_else(|| io::Error::other("a missing glyph out of range"))?));
@@ -68,7 +68,11 @@ impl Doc {
return Err(io::Error::other(format!("nothing to {}", if redo { "redo" } else { "undo" })));
}
loop {
mupdf.undo(&self.open, redo)?;
if let Err(error) = mupdf.undo(&self.open, redo) {
// MuPDF may have moved part way; follow it.
self.at = mupdf.state(&self.open)?.0;
return Err(error);
}
let passed = match redo {
true => {
self.at += 1;
@@ -147,6 +151,7 @@ impl Worker {
Ok(match wrote? {
content::Wrote::Done => Pack::new().u64(0).str(&doc.mark()).bytes(),
content::Wrote::Missing(c) => Pack::new().u64(2).u64(u64::from(c)).bytes(),
content::Wrote::Unwritable => Pack::new().u64(3).bytes(),
content::Wrote::Preview(pixels, (w, h), grew) => {
Pack::new().u64(1).u64(u64::from(w)).u64(u64::from(h)).u64(u64::from(grew)).blob(&pixels).bytes()
}
+5 -1
View File
@@ -49,6 +49,8 @@ pub enum Wrote {
Done(String),
/// The font lacks this character; nothing changed.
Missing(char),
/// The document's own font cannot write; nothing changed.
Unwritable,
/// BGRA of the area, its size, whether text grew.
Preview(Vec<u8>, (u32, u32), bool),
}
@@ -113,11 +115,13 @@ impl Worker {
let code = u32::try_from(reply.u64()?).map_err(io::Error::other)?;
Ok(Wrote::Missing(char::from_u32(code).ok_or_else(|| io::Error::other("a bad missing glyph"))?))
}
_ => {
1 => {
let size = (reply.u64()? as u32, reply.u64()? as u32);
let grew = reply.u64()? != 0;
Ok(Wrote::Preview(reply.blob()?, size, grew))
}
3 => Ok(Wrote::Unwritable),
other => Err(io::Error::other(format!("a text reply of kind {other}"))),
}
}
+180
View File
@@ -0,0 +1,180 @@
//! Fonts the editor meets in the wild (PDF-03).
use std::path::Path;
use ely_pdf_engine::{Doc, Worker, Writing, Wrote};
fn worker() -> Worker {
Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker")
}
fn page_text(worker: &mut Worker, doc: Doc) -> String {
worker.text(doc, 0).expect("reads").iter().map(|c| c.c).collect()
}
/// A rewrite of block `block` as `text` in `font`.
fn rewrite(worker: &mut Worker, doc: Doc, block: usize, text: &str, font: &str) -> Writing {
let read = worker.text_blocks(doc, 0).expect("blocks")[block].clone();
Writing {
block: Some(block),
expected: read.text,
text: text.into(),
font: font.into(),
size: read.size,
rgb: read.rgb,
bx: [read.rect[0], read.rect[1], read.rect[2] - read.rect[0]],
}
}
/// A page whose text uses a CMap-encoded CID font.
fn cmap_font_pdf() -> Vec<u8> {
let content = "BT /F1 24 Tf 72 700 Td <00480069> Tj ET";
one_font_page(content, &[
"<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [6 0 R] >>".to_string(),
"<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 2 >> /FontDescriptor 7 0 R >>".to_string(),
"<< /Type /FontDescriptor /FontName /STSong-Light /Flags 4 /FontBBox [0 -200 1000 900] /ItalicAngle 0 /Ascent 880 /Descent -120 /CapHeight 700 /StemV 80 >>".to_string(),
])
}
/// A page drawing `content`; font objects start at 5.
fn one_font_page(content: &str, font: &[String]) -> Vec<u8> {
page_with("<< /F1 5 0 R >>", content, font)
}
/// A page with font resources `fonts`; objects from 5.
fn page_with(fonts: &str, content: &str, font: &[String]) -> Vec<u8> {
let mut objects = vec![
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font {fonts} >> /Contents 4 0 R >>"),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
];
objects.extend_from_slice(font);
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, body) in objects.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend(format!("{} 0 obj\n{body}\nendobj\n", i + 1).bytes());
}
let xref = pdf.len();
pdf.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).bytes());
for offset in offsets {
pdf.extend(format!("{offset:010} 00000 n \n").bytes());
}
pdf.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).bytes());
pdf
}
#[test]
fn a_font_whose_codes_ely_cannot_write_says_so() {
let dir = tempfile::tempdir().expect("a temp dir");
let path = dir.path().join("cmap.pdf");
std::fs::write(&path, cmap_font_pdf()).expect("writes");
let mut worker = worker();
let doc = worker.open(&path).expect("opens").doc;
assert_eq!(page_text(&mut worker, doc), "Hi");
let writing = rewrite(&mut worker, doc, 0, "Yo", "original");
assert_eq!(worker.put_text(doc, 0, &writing, 2.0).expect("answers"), Wrote::Unwritable);
assert_eq!(page_text(&mut worker, doc), "Hi", "nothing changed");
let helvetica = rewrite(&mut worker, doc, 0, "Yo", "helvetica");
assert!(matches!(worker.put_text(doc, 0, &helvetica, 0.0).expect("writes"), Wrote::Done(_)));
}
#[test]
fn a_ligature_block_edits_like_any_other() {
// Code 1 is one ligature glyph, in either Unicode form.
let mut forms = vec![("00660069".to_string(), "find".to_string()), ("FB01".into(), "find".into()), ("FB03".into(), "ffind".into())];
// Long mappings, to MuPDF's cap of 256: none cut short.
forms.push(("0066".repeat(48), format!("{}nd", "f".repeat(48))));
forms.push(("FB03".repeat(256), format!("{}nd", "ffi".repeat(256))));
for (unicode, text) in forms {
let dir = tempfile::tempdir().expect("a temp dir");
let path = dir.path().join("lig.pdf");
let cmap = format!("/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Lig def 1 begincodespacerange <00> <FF> endcodespacerange 3 beginbfchar <01> <{unicode}> <6E> <006E> <64> <0064> endbfchar endcmap CMapName currentdict /CMap defineresource pop end end");
let content = "BT /F1 24 Tf 72 700 Td <016E64> Tj ET";
let pdf = one_font_page(content, &[
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding << /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [1 /fi] >> /ToUnicode 6 0 R >>".to_string(),
format!("<< /Length {} >>\nstream\n{cmap}\nendstream", cmap.len()),
]);
std::fs::write(&path, pdf).expect("writes");
let mut worker = worker();
let doc = worker.open(&path).expect("opens").doc;
let block = worker.text_blocks(doc, 0).expect("blocks").into_iter().next().expect("a block");
assert_eq!(block.text, text);
assert!(block.editable, "{unicode}: {block:?}");
let writing = rewrite(&mut worker, doc, 0, "Yo", "helvetica");
let Wrote::Done(mark) = worker.put_text(doc, 0, &writing, 0.0).expect("writes") else {
panic!("{unicode}: the ligature block was not rewritten");
};
assert_eq!(page_text(&mut worker, doc.at(&mark).expect("a mark")), "Yo");
}
}
#[test]
fn a_control_character_in_text_still_reads() {
let dir = tempfile::tempdir().expect("a temp dir");
let path = dir.path().join("control.pdf");
// Code 1 names U+0001, as broken encodings do.
let content = "BT /F1 24 Tf 72 700 Td <41014243> Tj ET";
let pdf = one_font_page(content, &[
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding << /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [1 /uni0001] >> >>".to_string(),
]);
std::fs::write(&path, pdf).expect("writes");
let mut worker = worker();
let doc = worker.open(&path).expect("opens").doc;
let text = page_text(&mut worker, doc);
let blocks = worker.text_blocks(doc, 0).expect("the blocks parse");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].text, text, "the same text as the page reads");
}
#[test]
fn a_font_name_with_a_stray_byte_still_reads() {
let dir = tempfile::tempdir().expect("a temp dir");
let path = dir.path().join("name.pdf");
// #E9 alone is not UTF-8.
let content = "BT /F1 24 Tf 72 700 Td (ABC) Tj ET";
let pdf = one_font_page(content, &[
"<< /Type /Font /Subtype /Type1 /BaseFont /Review#E9Font /Encoding /WinAnsiEncoding >>".to_string(),
]);
std::fs::write(&path, pdf).expect("writes");
let mut worker = worker();
let doc = worker.open(&path).expect("opens").doc;
let blocks = worker.text_blocks(doc, 0).expect("the blocks parse");
assert_eq!((blocks[0].text.as_str(), blocks[0].font.as_str()), ("ABC", "Review\u{FFFD}Font"));
}
#[test]
fn a_font_name_in_surrogate_halves_still_reads() {
let dir = tempfile::tempdir().expect("a temp dir");
let content = "BT /F1 24 Tf 72 700 Td (ABC) Tj ET";
let mut worker = worker();
// A pair joins; a lone half is no character.
for (base, want) in [("Review#ED#A0#BD#ED#B8#80Font", "Review\u{1F600}Font"), ("Lone#ED#A0#BDFont", "Lone\u{FFFD}Font")] {
let path = dir.path().join(format!("{want}.pdf"));
let font = format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} /Encoding /WinAnsiEncoding >>");
std::fs::write(&path, one_font_page(content, &[font])).expect("writes");
let doc = worker.open(&path).expect("opens").doc;
let blocks = worker.text_blocks(doc, 0).expect("the blocks parse");
assert_eq!(blocks[0].font, want);
}
}
#[test]
fn a_font_written_into_the_page_says_it_cannot_write() {
let dir = tempfile::tempdir().expect("a temp dir");
let path = dir.path().join("direct.pdf");
// The font dictionary sits in the page itself.
let fonts = "<< /F1 << /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /Identity-H /DescendantFonts [5 0 R] >> >>";
let pdf = page_with(fonts, "BT /F1 24 Tf 72 700 Td <00480069> Tj ET", &[
"<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 2 >> /FontDescriptor 6 0 R >>".to_string(),
"<< /Type /FontDescriptor /FontName /STSong-Light /Flags 4 /FontBBox [0 -200 1000 900] /ItalicAngle 0 /Ascent 880 /Descent -120 /CapHeight 700 /StemV 80 >>".to_string(),
]);
std::fs::write(&path, pdf).expect("writes");
let mut worker = worker();
let doc = worker.open(&path).expect("opens").doc;
let writing = rewrite(&mut worker, doc, 0, "Yo", "original");
assert_eq!(worker.put_text(doc, 0, &writing, 2.0).expect("answers"), Wrote::Unwritable);
let helvetica = rewrite(&mut worker, doc, 0, "Yo", "helvetica");
assert!(matches!(worker.put_text(doc, 0, &helvetica, 0.0).expect("writes"), Wrote::Done(_)));
}
+5 -2
View File
@@ -2,6 +2,9 @@
use std::path::Path;
/// This build has a system print panel.
pub const BUILT: bool = cfg!(target_os = "macos");
/// Shows the print panel for `pdf`; false when cancelled.
#[cfg(target_os = "macos")]
pub fn print_pdf(pdf: &Path) -> anyhow::Result<bool> {
@@ -85,8 +88,8 @@ fn paper_for(sizes: &[(f64, f64)]) -> Option<(f64, f64)> {
/// Printing needs a system panel this platform build lacks.
#[cfg(not(target_os = "macos"))]
pub fn print_pdf(pdf: &Path) -> anyhow::Result<bool> {
anyhow::bail!("printing {} is not built on this platform", pdf.display())
pub fn print_pdf(_: &Path) -> anyhow::Result<bool> {
anyhow::bail!("printing is not built on this platform")
}
#[cfg(test)]
+7 -1
View File
@@ -131,6 +131,9 @@ fn label(command: &Command, cx: &App) -> SharedString {
/// Why `id` cannot run; a document lifts the no-document block.
pub fn blocked(id: &str, document: bool, cx: &App) -> Option<SharedString> {
if id == PRINT.id && !ely_platform::print::BUILT {
return Some(i18n::text(cx, "command.blocked.unbuilt", &[]));
}
let command = RENAMING.iter().find(|command| command.id == id)?;
let key = command.blocked.filter(|_| !document)?;
Some(i18n::text(cx, key, &[]))
@@ -187,6 +190,10 @@ pub fn menus(
}
pub fn bind_keys(cx: &mut App) {
// Without a print panel, Print stays off the keyboard.
if ely_platform::print::BUILT {
cx.bind_keys([KeyBinding::new(PRINT.keys, Print, Some(CONTEXT))]);
}
cx.bind_keys([
KeyBinding::new(NEW.keys, New, Some(CONTEXT)),
KeyBinding::new(OPEN.keys, Open, Some(CONTEXT)),
@@ -194,7 +201,6 @@ pub fn bind_keys(cx: &mut App) {
KeyBinding::new(SAVE_AS.keys, SaveAs, Some(CONTEXT)),
KeyBinding::new(RENAME.keys, Rename, Some(CONTEXT)),
KeyBinding::new(EXPORT.keys, Export, Some(CONTEXT)),
KeyBinding::new(PRINT.keys, Print, Some(CONTEXT)),
KeyBinding::new("secondary-w", CloseTab, Some(CONTEXT)),
KeyBinding::new(TOGGLE_PALETTE, ToggleCommandPalette, Some(CONTEXT)),
KeyBinding::new("secondary-q", Quit, None),
+2
View File
@@ -41,6 +41,7 @@ const EN: &[(&str, &str)] = &[
("command.file.export", "Export…"),
("command.file.print", "Print…"),
("command.blocked.no_document", "No open document"),
("command.blocked.unbuilt", "Not built for this platform yet"),
("palette.group.file", "File"),
("gallery.ready", "Ready"),
("gallery.nav", "Navigation"),
@@ -96,6 +97,7 @@ const ZH_CN: &[(&str, &str)] = &[
("command.file.export", "导出…"),
("command.file.print", "打印…"),
("command.blocked.no_document", "没有打开的文档"),
("command.blocked.unbuilt", "此平台尚未构建"),
("palette.group.file", "文件"),
("gallery.ready", "就绪"),
("gallery.nav", "导航"),
+2
View File
@@ -16,6 +16,7 @@ pub(super) const EN: &[(&str, &str)] = &[
"A conversion writes a new file; pick another name",
),
("office.error.changed", "The document changed meanwhile; try again"),
("office.print.failed", "Printing failed: {error}"),
("office.recovery.failed", "A recovery copy was not written: {error}"),
("office.recovery.off", "Recovery copies are off for this run: {error}"),
("office.recovery.lost", "{name} could not be recovered: {error}"),
@@ -101,6 +102,7 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
("office.error.conflict", "{name} 已在磁盘上改动,保存已停止"),
("office.error.convert", "格式转换要写成新文件,请换个名字"),
("office.error.changed", "文档在此间已改动,请重试"),
("office.print.failed", "打印失败:{error}"),
("office.recovery.failed", "恢复副本未能写入:{error}"),
("office.recovery.off", "本次运行不写恢复副本:{error}"),
("office.recovery.lost", "{name} 未能恢复:{error}"),
+4
View File
@@ -67,12 +67,14 @@ pub(super) const EN: &[(&str, &str)] = &[
"pdf.text.missing",
"This font cannot draw “{char}”. Choose another font.",
),
("pdf.text.unwritable", "The document's own font cannot write new text. Choose another font."),
("pdf.object.replace", "Replace Image…"),
("pdf.object.delete", "Delete"),
(
"pdf.object.changed",
"The document changed meanwhile; nothing was done.",
),
("pdf.pages.changed", "Pages are moving; nothing was done. Try again."),
("pdf.tool.edit", "Edit Text and Images"),
("pdf.tool.text", "Add Text"),
("pdf.tool.image", "Add Image…"),
@@ -287,9 +289,11 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
("pdf.text.apply", "应用"),
("pdf.text.erase", "删除文字"),
("pdf.text.missing", "这种字体画不出“{char}”。请换一种字体。"),
("pdf.text.unwritable", "文档自带的字体写不了新字。请换一种字体。"),
("pdf.object.replace", "替换图片…"),
("pdf.object.delete", "删除"),
("pdf.object.changed", "文档已改动,未执行。"),
("pdf.pages.changed", "页面正在变动,未执行,请再试。"),
("pdf.tool.edit", "编辑文字和图片"),
("pdf.tool.text", "添加文字"),
("pdf.tool.image", "添加图片…"),
+4
View File
@@ -10,6 +10,8 @@ pub(super) const EN: &[(&str, &str)] = &[
("show.play.screen", "Screen {number} ({width} × {height})"),
("show.play.start", "Start"),
("show.play.none", "Every slide is hidden"),
("show.play.busy", "The show is already starting or playing"),
("show.play.gone", "The show did not start: its deck or screen changed"),
("show.play.back", "Previous"),
("show.play.next", "Next"),
("show.play.place", "Slide {number} of {count}"),
@@ -70,6 +72,8 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
("show.play.screen", "屏幕 {number}({width} × {height})"),
("show.play.start", "开始放映"),
("show.play.none", "所有幻灯片都已隐藏"),
("show.play.busy", "放映已在开始或进行中"),
("show.play.gone", "放映未开始:文稿或屏幕已变"),
("show.play.back", "上一步"),
("show.play.next", "下一步"),
("show.play.place", "第 {number} 张,共 {count} 张"),
+8
View File
@@ -50,6 +50,10 @@ pub(super) const EN: &[(&str, &str)] = &[
("doc.review.accept_all", "Accept All"),
("doc.review.reject_all", "Reject All"),
("doc.review.panel", "Comments and Changes"),
(
"doc.review.unread",
"A comment by {author} has an unreadable time {at}: {error}",
),
("doc.review.comments", "Comments {n}"),
("doc.review.changes", "Changes {n}"),
("doc.review.accept", "Accept"),
@@ -101,6 +105,10 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
("doc.review.accept_all", "全部接受"),
("doc.review.reject_all", "全部拒绝"),
("doc.review.panel", "批注与修订"),
(
"doc.review.unread",
"{author} 的一条批注时间读不出({at}):{error}",
),
("doc.review.comments", "批注 {n}"),
("doc.review.changes", "修订 {n}"),
("doc.review.accept", "接受"),
+4
View File
@@ -2,6 +2,7 @@
pub(super) const EN: &[(&str, &str)] = &[
("menu.slide", "Slide"),
("show.dialog.busy", "Finish or close the open dialog first"),
("show.slide.new", "New Slide"),
("show.slide.duplicate", "Duplicate Slide"),
("show.slide.delete", "Delete Slide"),
@@ -173,6 +174,7 @@ pub(super) const EN: &[(&str, &str)] = &[
"These notes are formatted; Ely shows them read-only to keep it.",
),
("show.remark.slide", "Slide {n}"),
("show.remark.bad", "Comments with unreadable times: {n}, first {at}"),
("show.remark.text", "Comment"),
("show.remark.new_title", "New Comment"),
("show.remark.edit_title", "Edit Comment"),
@@ -189,6 +191,7 @@ pub(super) const EN: &[(&str, &str)] = &[
pub(super) const ZH_CN: &[(&str, &str)] = &[
("menu.slide", "幻灯片"),
("show.dialog.busy", "请先完成或关闭已打开的对话框"),
("show.slide.new", "新建幻灯片"),
("show.slide.duplicate", "复制幻灯片"),
("show.slide.delete", "删除幻灯片"),
@@ -351,6 +354,7 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
("show.notes.none", "这张幻灯片没有备注框。"),
("show.notes.rich", "备注带格式,为保留格式在此只读。"),
("show.remark.slide", "第 {n} 张幻灯片"),
("show.remark.bad", "时间读不出的批注:{n} 条,第一条为 {at}"),
("show.remark.text", "批注"),
("show.remark.new_title", "新建批注"),
("show.remark.edit_title", "编辑批注"),
+66 -10
View File
@@ -157,16 +157,30 @@ impl Root {
}
fn attempt(&mut self, id: &'static str, cx: &mut Context<Self>) {
if let Some(reason) = commands::blocked(id, self.document, cx) {
log::info!("command: {id} blocked: {reason}");
self.last = Some(format!("{id} blocked").into());
self.toaster.update(cx, |toaster, cx| {
toaster.push(Toast::new(reason).severity(Severity::Warning), cx);
});
cx.notify();
return;
if !self.refused(id, cx) {
self.ran(id, cx);
}
self.ran(id, cx);
}
/// Runs a palette pick; views act first, so check here.
pub fn run_from_palette(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
if !self.refused(id, cx) {
commands::run(id, window, cx);
}
}
/// Whether `id` is blocked here; the reason shows.
fn refused(&mut self, id: &str, cx: &mut Context<Self>) -> bool {
let Some(reason) = commands::blocked(id, self.document, cx) else {
return false;
};
log::info!("command: {id} blocked: {reason}");
self.last = Some(format!("{id} blocked").into());
self.toaster.update(cx, |toaster, cx| {
toaster.push(Toast::new(reason).severity(Severity::Warning), cx);
});
cx.notify();
true
}
/// Shows `text` in the window's toaster.
@@ -305,6 +319,7 @@ impl Render for Root {
if !self.palette {
return frame.into_any_element();
}
let run = cx.entity();
let palette = CommandPalette::new("command-palette", move |_, cx| {
this.update(cx, |this, cx| this.close_palette(cx));
})
@@ -319,7 +334,7 @@ impl Render for Root {
}),
)
.recent(self.recent.clone())
.on_run(move |value, window, cx| commands::run(value, window, cx));
.on_run(move |value, window, cx| run.update(cx, |root, cx| root.run_from_palette(value, window, cx)));
frame.child(raise("palette", palette)).into_any_element()
}
}
@@ -332,3 +347,44 @@ fn view(path: &Path) -> std::io::Result<()> {
}
ely_platform::apps::open_default(path)
}
#[cfg(test)]
mod tests {
use gpui::{FocusHandle, InteractiveElement as _, IntoElement, Render, TestAppContext, div};
/// A focused view that counts the saves it gets.
struct Saves(FocusHandle, usize);
impl Render for Saves {
fn render(&mut self, _: &mut gpui::Window, cx: &mut gpui::Context<Self>) -> impl IntoElement {
div().track_focus(&self.0).on_action(cx.listener(|saves, _: &crate::commands::Save, _, _| saves.1 += 1))
}
}
#[gpui::test]
fn a_blocked_palette_pick_never_reaches_the_view(cx: &mut TestAppContext) {
cx.update(|cx| {
ely_gpui_component::init_for_tests(cx);
crate::settings::install_defaults(cx);
crate::i18n::install(cx);
});
let window = cx.add_window(|_, cx| Saves(cx.focus_handle(), 0));
let (saves, root) = window
.update(cx, |view, window, cx| {
let saves = cx.entity();
let root = super::tool_root(ely_platform::AppId::Tree, saves.clone().into(), Vec::new(), window, cx);
// The root takes focus as it builds; give it back.
window.focus(&view.0, cx);
(saves, root)
})
.expect("the window lives");
cx.run_until_parked();
// Save without a document is blocked; so is Print unbuilt.
window
.update(cx, |_, window, cx| root.update(cx, |root, cx| root.run_from_palette("file.save", window, cx)))
.expect("the window lives");
cx.run_until_parked();
assert_eq!(cx.update(|cx| saves.read(cx).1), 0, "the view never gets it");
assert_eq!(cx.update(|cx| root.read(cx).toasts(cx).len()), 1, "the reason shows");
}
}
+38 -37
View File
@@ -6,55 +6,55 @@
次序:S-03、P-01、X-01、D-01、D-02、P-02、P-03、P-04、M-01、X-02、X-03、K-01(Linux、Windows,本机不能运行)、UPD-1、REL-1、REL-2。K-01 不进 0.1.2:只影响 Linux 与 Windows 的 worker,本机不能运行验证,0.1.2 只发 macOS;留在下面的清单里,未勾。T-05 已改一半(审查查出缩放后尺寸不重测),暂放,补丁存在草稿区。
- [x] UPD-1 自动更新:应用内检查新版本、下载、校验,经用户同意后替换并重启;发布时生成更新清单,不再要点 GitHub 链接 — 启动检查默认关、菜单“检查更新…”、退出后再装、失败记录先于重开、经 Gitea API 找清单;R1–R4 授权、提交点、保存询问、版本绑定、横幅位置、取消退出、记录先后;R5 两处(启动检查失败记日志、以替身 open 断言先后)已修并以变异验证,五轮上限,未再审
- [x] UPD-1 自动更新:应用内检查新版本、下载、校验,经用户同意后替换并重启;发布时生成更新清单,不再要点 GitHub 链接 — 启动检查默认关、菜单“检查更新…”、退出后再装、失败记录先于重开、经 Gitea API 找清单;R1–R4 授权、提交点、保存询问、版本绑定、横幅位置、取消退出、记录先后;R5 两处(启动检查失败记日志、以替身 open 断言先后)已修并以变异验证,五轮上限后由 UPD-2 复核通过
- [x] T-01 内核的图块与命中按像素网格,Ely 的光标、选区、表头按文档 twips,越远越错位 — R2.16 已修(图块 twips 与打印 twips 互换)
- [x] T-02 单元格光标露出边缘即把视图滚到中央,点击与拖选时视图跳动 — R1 拖选中仍滚、超宽格跳回起点;R2 PASS
- [x] T-03 视口比工作表宽时网格先居中再左移一次 — 载入回复带文档种类,表格不居中;R1 测试漏几何到达前的首帧;R2 PASS
- [x] T-04 从不调用 `setClientZoom`,内核命中与几何按默认缩放算 — R2.16 已修(worker 每次报可见区前 setClientZoom)
- [ ] T-05 文档尺寸按内核像素取整,表尾被裁
- [ ] T-06 滚轮向下被内核平铺区卡住
- [ ] T-07 表头点选、行高列宽拖动是“按编号的命令”,而每次点击与拖选都推进修订,紧接着的表头操作被当作过期拒绝
- [ ] T-08 自动筛选的列菜单对光标所在的表而不是按钮所在的表筛选、排序
- [ ] T-09 导入文本文件时整文件的 UTF-8 校验在界面线程跑
- [ ] T-10 CSV 导入对话框开着时打开任何别的对话框,待导入的文件静默消失
- [ ] T-11 对话框比其标签活得久:导出、定义名称、重命名不可见却“开着”,挡住导入;名称表的“转到”激活已关闭的标签
- [ ] T-12 打印区域是整列或整行时页面设置无法应用
- [ ] T-13 打印失败报成“导出失败”(非 macOS 必现)
- [ ] T-14 导入扫描把整个文件的每个字段都建成字符串
- [ ] T-15 非 UTF-8 文本的默认编码跟系统区域而不是界面语言
- [ ] S-01 切换幻灯片后主画布不重画
- [x] T-05 文档尺寸按内核像素取整,表尾被裁 — 内核尺寸按其测量时的比例换到当前比例(加载即设比例、缩放即改比例并重测、迟到的测量也换算);R1–R4 缩放后未重测、表尾锚点被截、连续缩放迟到、首次测量无比例与文件超行;R5 PASS
- [x] T-06 滚轮向下被内核平铺区卡住 — 表格的可滚范围先行两屏、止于表尾;R1 PASS
- [x] T-07 表头点选、行高列宽拖动是“按编号的命令”,而每次点击与拖选都推进修订,紧接着的表头操作被当作过期拒绝 — 行列号不再算作按编号的命令;R1 PASS
- [x] T-08 自动筛选的列菜单对光标所在的表而不是按钮所在的表筛选、排序 — 先把光标移到按钮所在格再排序或筛选;R1 PASS
- [x] T-09 导入文本文件时整文件的 UTF-8 校验在界面线程跑 — 编码与分隔符的猜测随读文件在后台;R1 PASS
- [x] T-10 CSV 导入对话框开着时打开任何别的对话框,待导入的文件静默消失 — 被挤开的导入回到队头,已读完的保留列类型;R2 PASS
- [x] T-11 对话框比其标签活得久:导出、定义名称、重命名不可见却“开着”,挡住导入;名称表的“转到”激活已关闭的标签 — 关标签时一并关掉其对话框与选项列表;R1 PASS
- [x] T-12 打印区域是整列或整行时页面设置无法应用 — 打印区域可为整列、整行(每端至多一个 $);R2 PASS
- [x] T-13 打印失败报成“导出失败”(非 macOS 必现) — print_later 自己以“打印失败”报错,五个应用同;R1 PASS
- [x] T-14 导入扫描把整个文件的每个字段都建成字符串 — split 逐行给出,扫描只留预览行与列标记;R1 PASS
- [x] T-15 非 UTF-8 文本的默认编码跟系统区域而不是界面语言 — 默认编码按应用所显示的语言;R1 PASS
- [x] S-01 切换幻灯片后主画布不重画 — 已由 be31260(R2.4)修好并有单测,此处补勾
- [x] S-02 `deck.rs` 超过 500 行 — 随 S-03 把 Extra 移到 extra.rs,deck.rs 463 行
- [x] S-03 ElyShow 的缩放一两帧后弹回“适合幻灯片”,缩放功能不可用 — 只在标签首次观察时适合一次;R1 PASS
- [ ] S-04 选屏对话框被别的对话框顶掉后,`show_starting` 卡住、放映副本泄漏
- [ ] S-05 一条批注的时间恰为午夜或只有日期,整个批注栏不显示
- [ ] S-06 导出或打印的“从–到”填 0 时导出全部幻灯片
- [ ] S-07 母版视图下演示检查的行按幻灯片序号跳转
- [ ] S-08 刚编辑过幻灯片就进备注栏键入,备注可能丢
- [ ] S-09 改尺寸失败后 `size_asked` 一直留着
- [ ] S-10 开始放映的两处拒绝无提示(规则 2)
- [x] S-04 选屏对话框被别的对话框顶掉后,`show_starting` 卡住、放映副本泄漏 — 有对话框开着时不再顶掉它,提示先完成;选屏对话框被拒即丢副本;R2 PASS
- [x] S-05 一条批注的时间恰为午夜或只有日期,整个批注栏不显示 — 读不出时间的批注只占一行汇总,其余照常显示(jiff 本可读只有日期的时间);R2 PASS
- [x] S-06 导出或打印的“从–到”填 0 时导出全部幻灯片 — 键入的页码夹到 1..=末页;R1 PASS
- [x] S-07 母版视图下演示检查的行按幻灯片序号跳转 — 母版视图下点行提示先关母版,不跳;R1 PASS
- [x] S-08 刚编辑过幻灯片就进备注栏键入,备注可能丢 — 发出的备注待读回确认,键入中读回只移基线不动草稿;R2 PASS
- [x] S-09 改尺寸失败后 `size_asked` 一直留着 — 所要尺寸带输入序号,发送前先记、未入队即清,命令落地后的读回即清;R2 PASS
- [x] S-10 开始放映的两处拒绝无提示(规则 2) — 三处拒绝都提示(另含读回时已换标签);R2 PASS
- [x] P-01 文件系统的 `PermissionDenied` 被拼成不存在的文案键,应用崩溃 — worker 的拒绝改为自有错误类型 Refused;R1 测试不跨平台、不看提示;组件库 f834f55 加 Toaster::shown;R2 PASS
- [x] P-02 每次编辑落地清掉全部图块与缩略图,页面空白、刚画的墨迹消失 — 图块随 R2.6;缩略图在编辑后留到重画;R1、R2 测试未证实际绘制;R3 PASS
- [x] P-03 “编辑文字和图片”先命中图片对象再命中文字;一点抖动就移动了背景图 — 包住文字块的对象让位于文字,移动须过 3 个窗口像素;R1 漏掉同点的小图;R2 PASS
- [x] P-04 `TEXT_BLOCKS` 对每个文字块重跑一遍整页内容过滤,编辑工具长时间无反应 — 整页一遍干跑判定全部文字块(1000 块 1.5 s→0.1 s);R1 转向文字后错位、共用原点只计一块;R2 PASS
- [ ] P-05 连续快画或快点被拒:“文档在此间已改动,未执行”
- [ ] P-06 默认字体“原字体”在 Type 1/Type 3 字体的文件上失败,只给引擎原话
- [ ] P-07 含连字的文字块永远不可编辑
- [ ] P-08 页面读取失败后每帧重试、每帧弹提示
- [ ] P-09 识别文字的写入被拒后仍弹“成功”
- [ ] P-10 预览回复被丢弃时“应用”静默无事
- [ ] P-11 撤销或重做在 MuPDF 侧失败后 worker 的步数不再同步
- [ ] P-12 `unhook` 中途抛出时泄漏保留的对象
- [ ] P-13 优化导出的“之前”大小取磁盘文件而非当前文档
- [ ] P-14 文字块含控制字符时 JSON 回复不可解析
- [x] P-05 连续快画或快点被拒:“文档在此间已改动,未执行” — 按坐标的编辑排队、跑在出队时的状态上,只在页面变动时拒绝;R2 PASS
- [x] P-06 默认字体“原字体”在 Type 1/Type 3 字体的文件上失败,只给引擎原话 — 原字体写不了新字(非 CID 编码、页面内直接字体、不在本页)时面板提示换字体;R2 PASS
- [x] P-07 含连字的文字块永远不可编辑 — 字形按 stext 的写法展开(多字字形、连字码点),整页、单块与删除同一判法;R3 PASS
- [x] P-08 页面读取失败后每帧重试、每帧弹提示 — 失败的读取按页或种类记下,编辑后重读;签名读取以保存计数判过期
- [x] P-09 识别文字的写入被拒后仍弹“成功” — on_same 回报是否执行;被拒时只有警告
- [x] P-10 预览回复被丢弃时“应用”静默无事 — 预览回复照常交回,版本已变即提示,不显示过期预览
- [x] P-11 撤销或重做在 MuPDF 侧失败后 worker 的步数不再同步 — 撤销失败后重读历史标记
- [x] P-12 `unhook` 中途抛出时泄漏保留的对象 — 保留的链接随保留随计数
- [x] P-13 优化导出的“之前”大小取磁盘文件而非当前文档 — 有未保存编辑时不与磁盘文件比大小
- [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 锁读内核(样式、字体、书签、公式、分节名)
- [ ] D-04 一条批注的时间读不出,全部批注与修订都不显示
- [ ] D-05 邮件合并某条记录失败时内核文档泄漏
- [ ] D-06 导航栏大纲点击按当前列表取下标,后台重读后可能越界
- [ ] D-07 导航栏隐藏时仍随每次光标移动重读大纲与书签
- [ ] D-08 Linux、Windows 上“打印…”可用,导出 PDF 后才报“此平台未构建”
- [x] D-03 界面线程同步持 worker 锁读内核(样式、字体、书签、公式、分节名) — 后台读取,回来核对标签、视图与对话框请求号
- [x] D-04 一条批注的时间读不出,全部批注与修订都不显示 — 逐条解析,读不出的批注写明作者与原因(AX 可读、可滚动),修订照留
- [x] D-05 邮件合并某条记录失败时内核文档泄漏 — 失败的记录照样关闭内核文档,再传出首个错误
- [x] D-06 导航栏大纲点击按当前列表取下标,后台重读后可能越界 — 点击按绘制时的列表跳转;补点击路径的回归测试
- [x] D-07 导航栏隐藏时仍随每次光标移动重读大纲与书签 — 隐藏时不跟随标签、不读;再显示时读一次
- [x] D-08 Linux、Windows 上“打印…”可用,导出 PDF 后才报“此平台未构建” — 未构建平台禁用打印、不绑快捷键、错误不带路径;命令面板先查禁用
- [x] M-01 键入期间外部以相同内容重写文件,丢掉上次停顿之后键入的全部文字 — 先等页面送出未送的键;同文只接过文件与字节、标签为已保存;磁盘回到上次保存的文字时保留编辑并恢复自动保存;R1–R4 字节、自动保存、在途写入、已保存基线;R5 PASS
- [ ] M-02 `refresh` 归零版本计数却不换代,在途的一键让标签一直“未落定”
- [ ] M-03 干净标签接过磁盘文字后关闭仍问“是否保存”
@@ -104,5 +104,6 @@
## 0.1.2 发布
- [x] UPD-2 复核 UPD-1 第五轮的两处修复:启动检查(含 Gitea 查询)失败写日志;失败记录先于重开,以替身 open 断言 — R1 PASS
- [x] REL-1 dmg 里有指向“应用程序”的快捷方式,可直接拖入安装(用户 2026-10-09 报) — release.sh 在 dmg 里放“应用程序”替身,随 UPD-1 审查
- [ ] REL-2 修完上列各条后打包、发布 0.1.2,官网指向新发布页
- [x] REL-2 修完上列各条后打包、发布 0.1.2,官网指向新发布页 — v0.1.2 发在 git.zacharyzhang.com(六个 dmg、两个源码包、六份更新清单;release-macos.sh 通过;经 API 取清单与 dmg 散列核对一致),官网显示 0.1.2
+3
View File
@@ -0,0 +1,3 @@
FAIL
apps/ely-doc/src/refs.rs:88:异步读取返回时仅由 ask_in 核对活动标签号,随后直接执行打开对话框的回调;迟到的书签、交叉引用或公式请求会覆盖较新的对话框,丢失用户输入。具体复现:worker 忙时点击 Insert → Bookmark…,等待期间点击 Hyperlink… 并填写 https://example.org/draft;worker 恢复后,超链接对话框被 Insert Bookmark 替换,地址丢失。真实 macOS 窗口中暂停测试 worker 后执行上述菜单操作,AX 树记录了完整替换过程(/tmp/d03-r1-native-filled.log、/tmp/d03-r1-native-after.log);使用真实内核的隔离探针连续两次复现,副本中加入较新对话框的返回守卫后该探针通过。现有 cargo test -p ely-doc -- --test-threads=1 的 52 项测试全部通过,覆盖缺少这个延迟场景。修复方向:为异步对话框请求记录发起视图与请求身份,打开或取消较新的对话框时使先前请求失效,返回时核对身份再更新 dialog;补充 worker 锁等待期间打开并填写新对话框的回归测试。
+3
View File
@@ -0,0 +1,3 @@
FAIL
apps/ely-doc/src/docs.rs:197:`watch_dialogs` 的快照遗漏共享外壳的 `core.dialog`,关闭询问等外壳对话框打开、取消后,旧读取的请求编号仍有效;`refs.rs:93` 只检查返回当时的外壳对话框,迟到请求会重新弹框并抢走焦点。具体复现:在已编辑且输入已落地的文档中暂停 worker,点击 Insert → Bookmark…,按 Cmd+W 打开“Save changes to Untitled 1?”,点击 Cancel,再恢复 worker;取消后已回到文档,随后旧的 Insert Bookmark 对话框弹出。真实 macOS 窗口连续两次复现,AX 证据见 `/tmp/d03-r2-native-shell-open-1.log`、`/tmp/d03-r2-native-shell-cancel-1.log`、`/tmp/d03-r2-native-shell-return-1.log`,第二次为相同名称的 `-2.log`。上一轮超链接保留及关闭后保持关闭的场景均实测通过;本轮自行构建 worker 和 ElyDoc,`cargo test -p ely-doc -- --test-threads=1` 的 53 项测试全部通过,现有回归测试缺少外壳对话框的取消场景。修复方向:将共享外壳对话框的状态变化纳入请求失效计数,并补充上述回归测试。
+1
View File
@@ -0,0 +1 @@
PASS
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff. Show more