mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
* Engine: reload a document from new bytes, keeping its id * Issue #431: reload a document when its file changes on disk --------- Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
1 parent
e4c9cc1a19
commit
57c9e3a9b4
27 files changed
+814
-16
No files matched your search
+16
-16
@@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "a562bce9a9ba1be6bc464ef74104bbf53f88291a5ec522d18ba3f607e8490cf6"
|
||||
sha256 = "7936028709844d28d14489d608810480a1af5a707e883032161bc2fe95ff5d67"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
|
||||
@@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "3d9dde3978c43dea4ad461288c859d466520888aa4fce0ea43514f8206bc084f"
|
||||
sha256 = "72d037149b24ad84a83cc7f0a7d27b1ecae93451d4cdc19ed861899789abbb74"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/cs.tsv"
|
||||
@@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "a84c17c7a8f297bef07a613e63116f9a8bb05aa02fd8442a684648df3a768224"
|
||||
sha256 = "bda5289ace1c4c959f0b4fc2482cb8ae3b4198733994b05042a584ece827dd3a"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pt-br.tsv"
|
||||
@@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "59fbeb2f5c912f7b77c1f117e0f68b8806bb015e0d187721bc77b6708cb3e088"
|
||||
sha256 = "781ac07860770313bc2a74cb1e7b312a5e027f3299d137c84d799829345f1eee"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/es.tsv"
|
||||
@@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "47a294326663323e42e92b4286d68b42741d0f6c8bb5df2023ed6c5697595332"
|
||||
sha256 = "12c812d37a92633a81f4dab6da7808cb6c55eb7bb6fa318f0c79a58ede065bce"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/fr.tsv"
|
||||
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "15aada11b884a55869b2080f79a6902134a4cdeea1c2c011484b22b5c5f53747"
|
||||
sha256 = "cec6518ba35fdd26669c200283b3a46e908f2ba940f6917b414bf1f68bcb19ce"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/de.tsv"
|
||||
@@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "6945b0d3b6b6bcd8cad936b8b440484903fa71ce439a5da6fddf595ffa9f0233"
|
||||
sha256 = "386d5e5e5dcdf80487d304f914a0ac0f7e17c5d2134ff5df37b477b8cb5bf3ca"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/te.tsv"
|
||||
@@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "8ee5d4a658dd29e41e171103343eb223cc360956fb533788cec8676dcdf1c6ec"
|
||||
sha256 = "0c52c43e61ebc4d9486ee699fb442442ed4813a0f9cf365a48e2029cfe7cb950"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/it.tsv"
|
||||
@@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "884adac1fca5f8a4bb14d947e9feabdee2ca18e86c3a1dbc2e7a042512c37ec2"
|
||||
sha256 = "771b72fbed8f7ef9a5689fd84322b2feb27e36295f7f0228f755fee678c34774"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pl.tsv"
|
||||
@@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "5e794e7ab841956a509ecdf1701d3e61ced122d10e962d20ce14068cdd8d4df4"
|
||||
sha256 = "4be664cc867d6b2a8cb74a87a5163530249de36a46b39115ff071f2f4402b1cb"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ru.tsv"
|
||||
@@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "f83db805a29b5ac05bea6c89b7542d7afdb8fab8edf578a8d976aca7e18cd348"
|
||||
sha256 = "3b37271fc6aed595b01ab42fd39cefe3dfd0e5e0e25905620590d9be5bc29c02"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/hu.tsv"
|
||||
@@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "ad66bf99115990740d7a65ced3495d3dc954cd7c229ac35e3f136c20c439420b"
|
||||
sha256 = "8e6549bb81c37757822776e3f8f46b3479688bd950508c9c311a3723a1a7b173"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/bg.tsv"
|
||||
@@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "f0f5451f8602e2d9ac27386e3fabde563bcc69d357c235db7babfeee8cac6cb6"
|
||||
sha256 = "8d8ec98406f3d8b44766c3daad04af5beac39a5e69cceedfb1b881bd20457023"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ja.tsv"
|
||||
@@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "ca0f9ea68fa5df68c468dd535baf85aa11e5e7bcd704149f7d2df44c284a0cff"
|
||||
sha256 = "b995a67d0fdac2b2d567e2f37d2688a31e5a6222ffca59a95c304c7f331e18ad"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/uk.tsv"
|
||||
@@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "d5eb9db7c07041b0dca02dbe5ea27ca10de9ce2d3a519d7b8e2dc8dedf5078c4"
|
||||
sha256 = "32f471f14b9f3cff21d57a3f69336717c697f8cd6bf502c7aa01591d69c42e90"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ar.tsv"
|
||||
@@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "df5deac92b0ff5b7b40251d3eee21c6d3c345fa62a6b726001a730c16fabbfb8"
|
||||
sha256 = "d46933f539a253fce67e81c06b6bf9d295cc7805cd36842c9d189fcc57b17950"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/accessibility.svg"
|
||||
|
||||
@@ -2798,6 +2798,29 @@ impl Session {
|
||||
Self::refresh(doc)
|
||||
}
|
||||
|
||||
/// The document's file changed on disk (a LaTeX or Typst build rewrote it, #431): open
|
||||
/// `bytes`, the file as it is now, in its place. Everything opening does runs again (XFA
|
||||
/// layout and scripts, signatures, repairs), but the document keeps its id, name and path,
|
||||
/// so its tab and view stay with it. Undo history goes, as with Revert. The password it was
|
||||
/// opened with is tried; on any failure the document is left as it was.
|
||||
pub fn reload(&mut self, id: DocId, bytes: Arc<Vec<u8>>) -> Result<(), OpenError> {
|
||||
let old = self.docs.iter().position(|d| d.id == id).ok_or_else(|| OpenError::Invalid("the document is not open".into()))?;
|
||||
let doc = &self.docs[old];
|
||||
let (name, path, generation) = (doc.name.clone(), doc.path.clone(), doc.generation);
|
||||
let password = doc.editor.as_ref().and_then(|e| e.keys.reopen.clone()).or_else(|| doc.password.clone());
|
||||
let new_id = self.open(name, path, bytes, password.as_deref())?;
|
||||
let at = self.docs.iter().position(|d| d.id == new_id).ok_or_else(|| OpenError::Invalid("the new version was not kept".into()))?;
|
||||
let mut doc = self.docs.remove(at);
|
||||
doc.id = id;
|
||||
// Caches keyed by the edit generation (edit lines, images, measurements) must see a new
|
||||
// one; there is nothing new to autosave.
|
||||
doc.generation = generation + 1;
|
||||
doc.snapshot_generation = doc.generation;
|
||||
// `open` pushed the new version last, so the old one is still at `old`.
|
||||
*self.docs.get_mut(old).ok_or_else(|| OpenError::Invalid("the document is not open".into()))? = doc;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The page count of another PDF (Replace Pages, Insert Pages dialogs).
|
||||
pub fn page_count_of(&self, name: &str, bytes: &Arc<Vec<u8>>) -> Result<usize, EditError> {
|
||||
Ok(pdfcraft_organize::page_count(&open_source(name, bytes)?)?)
|
||||
|
||||
@@ -1662,6 +1662,25 @@ fn added_images_rotate_flip_and_crop_as_drawn() {
|
||||
assert!(red(colour_at(&s, 130, 150)), "the right half cropped away: red fills the box");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_reads_the_new_file_into_the_same_document() {
|
||||
let (mut s, id) = session_with(2);
|
||||
let other = s.open("other.pdf", None, Arc::new(fixture(1)), None).unwrap();
|
||||
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
|
||||
let before = s.get(id).unwrap().edit_generation();
|
||||
s.reload(id, Arc::new(fixture(3))).unwrap();
|
||||
let d = s.get(id).unwrap();
|
||||
assert_eq!((d.id, d.name.as_str()), (id, "fixture.pdf"), "same document, same name");
|
||||
assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 3"], "the new version");
|
||||
assert!(!d.dirty && d.can_undo().is_none(), "nothing unsaved, nothing to undo");
|
||||
assert!(d.edit_generation() > before, "caches keyed by the generation see a change");
|
||||
assert_eq!(s.docs().iter().map(|d| d.id).collect::<Vec<_>>(), [id, other], "no new tab, same order");
|
||||
// A file that isn't a PDF (yet) leaves the document as it was.
|
||||
assert!(s.reload(id, Arc::new(b"%PDF-1.7 half written".to_vec())).is_err());
|
||||
assert_eq!(page_texts(&s, id).len(), 3);
|
||||
assert_eq!(s.docs().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revert_goes_back_to_the_saved_version() {
|
||||
let (mut s, id) = session_with(2);
|
||||
|
||||
@@ -296,6 +296,9 @@ pub struct DocView {
|
||||
pub highlight_fields: bool,
|
||||
pub page_input: String,
|
||||
pub notice_dismissed: bool,
|
||||
/// The file changed on disk while this document had unsaved changes: the notice bar asks
|
||||
/// before replacing it (#431).
|
||||
pub disk_changed: bool,
|
||||
/// Two-page view: show the first page alone, as a cover (View ▸ Page display).
|
||||
pub cover: bool,
|
||||
/// Previous view / Next view: pages visited before (and after, once going back).
|
||||
@@ -547,6 +550,7 @@ impl DocView {
|
||||
highlight_fields: defaults.highlight_fields,
|
||||
page_input: "1".into(),
|
||||
notice_dismissed: false,
|
||||
disk_changed: false,
|
||||
cover: false,
|
||||
back: Vec::new(),
|
||||
forward: Vec::new(),
|
||||
@@ -649,6 +653,38 @@ impl DocView {
|
||||
self.panel_drag = None;
|
||||
}
|
||||
|
||||
/// The document was replaced by a newer version of its file (#431). The reader's place
|
||||
/// stays: zoom, layout, rotation, page, scroll, view history and the find bar, and the page
|
||||
/// images on screen until their replacements are drawn, so nothing flashes. Everything that
|
||||
/// pointed into the old version (a selected comment, a field being typed in, text being
|
||||
/// edited, a drag, page selection) starts afresh, so no stale index reaches the new one.
|
||||
pub fn document_replaced(&mut self, info: &DocInfo) {
|
||||
let mut fresh = Self::new(self.id, info, ViewDefaults::default());
|
||||
fresh.zoom = self.zoom;
|
||||
fresh.fit = self.fit;
|
||||
fresh.layout = self.layout;
|
||||
fresh.rotation = self.rotation;
|
||||
fresh.current = self.current;
|
||||
fresh.organize = self.organize;
|
||||
fresh.grid_zoom = self.grid_zoom;
|
||||
// Same document id, so the scroll offset egui keeps for it is still this document's.
|
||||
fresh.shown = self.shown;
|
||||
fresh.highlight_fields = self.highlight_fields;
|
||||
fresh.notice_dismissed = self.notice_dismissed;
|
||||
fresh.cover = self.cover;
|
||||
fresh.back = std::mem::take(&mut self.back);
|
||||
fresh.forward = std::mem::take(&mut self.forward);
|
||||
fresh.find = self.find.take();
|
||||
fresh.pages = std::mem::take(&mut self.pages);
|
||||
fresh.thumbs = std::mem::take(&mut self.thumbs);
|
||||
fresh.viewport_w = self.viewport_w;
|
||||
fresh.viewport_h = self.viewport_h;
|
||||
fresh.viewport_screen = self.viewport_screen;
|
||||
*self = fresh;
|
||||
// Marks the kept images out of date and clamps the page to the new page count.
|
||||
self.document_changed(info);
|
||||
}
|
||||
|
||||
/// Pages an organize command acts on: the selection, or the current page.
|
||||
pub fn target_pages(&self) -> Vec<usize> {
|
||||
if self.selected.is_empty() { vec![self.current] } else { self.selected.iter().copied().collect() }
|
||||
@@ -1837,6 +1873,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
let secured = doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble())));
|
||||
let repaired = !doc.repair_log().is_empty();
|
||||
match notices(view, doc, secured, repaired, crate::sign_ui::banner(&doc.signatures), ui, &t) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
Some(Notice::Reload) => app.watch.reload_request = Some(view.id),
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Some(Notice::Reload) => {}
|
||||
Some(Notice::Repairs) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Advanced)),
|
||||
Some(Notice::Security) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Security)),
|
||||
Some(Notice::Signatures) => app.right = Some(RightPanel::Signatures),
|
||||
@@ -3149,6 +3189,8 @@ enum FieldMenu {
|
||||
|
||||
/// What the notice bar's buttons ask for.
|
||||
enum Notice {
|
||||
/// Reload the file that changed on disk, dropping unsaved changes.
|
||||
Reload,
|
||||
Security,
|
||||
Signatures,
|
||||
Repairs,
|
||||
@@ -3169,6 +3211,27 @@ fn notices(
|
||||
) -> Option<Notice> {
|
||||
let info = &doc.info;
|
||||
let xfa = doc.xfa.as_ref();
|
||||
// The file changed on disk while this document has unsaved changes (#431): ask before
|
||||
// replacing it, ahead of every other notice.
|
||||
if view.disk_changed {
|
||||
let mut reload = false;
|
||||
egui::Frame::NONE.fill(t.accent_soft).inner_margin(egui::Margin::symmetric(14, 7)).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(icons::image("rotate-cw", 16.0, t.accent_text));
|
||||
ui.label(
|
||||
egui::RichText::new(tl!("This file changed on disk. Reload it to see the new version; your unsaved changes will be lost."))
|
||||
.color(t.text),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if icons::button(ui, "x", 22.0, false, tl!("Dismiss")).clicked() {
|
||||
view.disk_changed = false;
|
||||
}
|
||||
reload = crate::widgets::pill_button(ui, tl!("Reload"), false).clicked();
|
||||
});
|
||||
});
|
||||
});
|
||||
return reload.then_some(Notice::Reload);
|
||||
}
|
||||
if let Some((icon, color, template, arg)) = signed {
|
||||
let mut open = false;
|
||||
notice_bar(ui, t, icon, color, crate::i18n::fmt(tl!(template), &[("by", &arg)]), |ui| {
|
||||
|
||||
@@ -537,6 +537,8 @@ impl PdfCraftApp {
|
||||
self.notify_fmt("Couldn't save {name}: {e}", &[("name", dest), ("e", &e.to_string())]);
|
||||
return false;
|
||||
}
|
||||
// Our own write is not a change on disk to reload (#431).
|
||||
self.watch.known_now(id, dest);
|
||||
match self.session.mark_saved(id, bytes, Some(dest.to_string())) {
|
||||
Ok(()) => {
|
||||
self.forget_recovery(id);
|
||||
@@ -544,6 +546,8 @@ impl PdfCraftApp {
|
||||
&& let Some(view) = self.views.iter_mut().find(|v| v.id == id)
|
||||
{
|
||||
view.document_changed(&doc.info);
|
||||
// Saving over a version that changed on disk keeps this one: nothing to ask.
|
||||
view.disk_changed = false;
|
||||
}
|
||||
self.notify_fmt("Saved {name}", &[("name", &short_name(dest))]);
|
||||
true
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
//! Reload a document when its file changes on disk (#431): a LaTeX or Typst build rewriting the
|
||||
//! PDF that is open shows its new version without reopening it.
|
||||
//!
|
||||
//! Cheap by design. One worker thread looks at each open file's metadata (no reading) every
|
||||
//! [`IDLE`], and blocks without waking at all while no open document has a file. A changed file
|
||||
//! is read only once it has stopped changing ([`SETTLE`] apart) and ends like a whole PDF: its
|
||||
//! last KiB is read first, so a file still being written costs one small read. The new bytes go
|
||||
//! to the UI thread, which is woken for them: a clean document is reloaded in place, keeping its
|
||||
//! tab, page, zoom and scroll; one with unsaved changes gets a notice instead. PdfCraft's own
|
||||
//! saves are recognised by the file's stamp after writing, so they never reload.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{File, Metadata};
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::sync::mpsc::{self, RecvTimeoutError};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use pdfcraft_engine::DocId;
|
||||
|
||||
use crate::PdfCraftApp;
|
||||
|
||||
/// How often an unchanged file is looked at.
|
||||
pub const IDLE: Duration = Duration::from_millis(250);
|
||||
/// How long a changed file must stay the same before it is read: writers pause between chunks.
|
||||
pub const SETTLE: Duration = Duration::from_millis(40);
|
||||
/// How much of the end of a changed file is read to see whether it is a whole PDF.
|
||||
const TAIL: usize = 1024;
|
||||
|
||||
/// One version of a file, told apart without reading it: its length and modification time, and
|
||||
/// what the platform adds (Unix: device, inode and change time; Windows: creation and last-write
|
||||
/// times in 100 ns ticks). The modification time alone is coarse on FAT and some network
|
||||
/// drives, and a rebuild can keep the length.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Stamp {
|
||||
len: u64,
|
||||
modified: Option<SystemTime>,
|
||||
extra: [u64; 4],
|
||||
}
|
||||
|
||||
impl Stamp {
|
||||
fn of(meta: &Metadata) -> Self {
|
||||
#[cfg(unix)]
|
||||
let extra = {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
[meta.dev(), meta.ino(), meta.ctime().cast_unsigned(), meta.ctime_nsec().cast_unsigned()]
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let extra = {
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
[meta.creation_time(), meta.last_write_time(), 0, 0]
|
||||
};
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let extra = [0; 4];
|
||||
Self { len: meta.len(), modified: meta.modified().ok(), extra }
|
||||
}
|
||||
|
||||
/// The file at `path` now; `None` when it is missing, not a file or can't be read.
|
||||
pub fn at(path: &str) -> Option<Self> {
|
||||
std::fs::metadata(path).ok().filter(Metadata::is_file).map(|m| Self::of(&m))
|
||||
}
|
||||
}
|
||||
|
||||
/// A file to watch, as the UI thread sees it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Watched {
|
||||
pub doc: DocId,
|
||||
pub path: String,
|
||||
/// The version PdfCraft last read or wrote (`None`: the first one the watcher sees).
|
||||
pub known: Option<Stamp>,
|
||||
}
|
||||
|
||||
/// A new version of a watched file, read and ready to open.
|
||||
pub struct Changed {
|
||||
pub doc: DocId,
|
||||
pub stamp: Stamp,
|
||||
pub bytes: Arc<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// The watcher's record of one file.
|
||||
struct Entry {
|
||||
doc: DocId,
|
||||
path: String,
|
||||
known: Option<Stamp>,
|
||||
/// A changed version seen once, waiting to be seen unchanged again.
|
||||
pending: Option<Stamp>,
|
||||
}
|
||||
|
||||
/// Looks at the watched files: on the worker thread, or on the UI thread in tests.
|
||||
#[derive(Default)]
|
||||
pub struct Watcher {
|
||||
files: Vec<Entry>,
|
||||
}
|
||||
|
||||
impl Watcher {
|
||||
/// Watch `list` from now on. A file watched already keeps its pending change; a version the
|
||||
/// UI thread knows (it just read or saved the file) replaces the watcher's own.
|
||||
fn set(&mut self, list: Vec<Watched>) {
|
||||
let old = std::mem::take(&mut self.files);
|
||||
self.files = list
|
||||
.into_iter()
|
||||
.map(|w| {
|
||||
let prev = old.iter().find(|e| e.doc == w.doc && e.path == w.path);
|
||||
Entry { known: w.known.or(prev.and_then(|e| e.known)), pending: prev.and_then(|e| e.pending), doc: w.doc, path: w.path }
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Look at every file once: the new versions found, and whether a file is still changing
|
||||
/// (look again after [`SETTLE`] rather than [`IDLE`]).
|
||||
fn tick(&mut self) -> (Vec<Changed>, bool) {
|
||||
let mut found = Vec::new();
|
||||
let mut settling = false;
|
||||
for e in &mut self.files {
|
||||
// Missing (deleted, or being replaced) or unreadable: the document stays as it is.
|
||||
let Some(now) = Stamp::at(&e.path) else {
|
||||
e.pending = None;
|
||||
continue;
|
||||
};
|
||||
let known = *e.known.get_or_insert(now);
|
||||
if now == known {
|
||||
e.pending = None;
|
||||
continue;
|
||||
}
|
||||
if e.pending != Some(now) {
|
||||
// Changed since the last look: read it once it stops changing.
|
||||
e.pending = Some(now);
|
||||
settling = true;
|
||||
continue;
|
||||
}
|
||||
e.pending = None;
|
||||
match read_whole_pdf(&e.path, now) {
|
||||
Version::Ready(bytes) => {
|
||||
e.known = Some(now);
|
||||
found.push(Changed { doc: e.doc, stamp: now, bytes });
|
||||
}
|
||||
// Not a whole PDF in this version (a build that failed or paused): wait for the
|
||||
// next one.
|
||||
Version::Incomplete => e.known = Some(now),
|
||||
// Changed again while being read, or held by its writer: look again soon.
|
||||
Version::Busy => settling = true,
|
||||
}
|
||||
}
|
||||
(found, settling)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a changed file turned out to be.
|
||||
enum Version {
|
||||
/// A whole PDF, read.
|
||||
Ready(Arc<Vec<u8>>),
|
||||
/// Not a whole PDF: a build that failed or paused.
|
||||
Incomplete,
|
||||
/// Changing, replaced or held by its writer: look again.
|
||||
Busy,
|
||||
}
|
||||
|
||||
/// Read `path` if it is still the version `stamp` and ends like a whole PDF (`%%EOF` in its last
|
||||
/// KiB, read first). The whole file is then read into a buffer of exactly its size.
|
||||
fn read_whole_pdf(path: &str, stamp: Stamp) -> Version {
|
||||
// Windows refuses to open a file its writer holds exclusively: not ready yet.
|
||||
let Ok(mut file) = File::open(path) else { return Version::Busy };
|
||||
let Ok(meta) = file.metadata() else { return Version::Busy };
|
||||
if Stamp::of(&meta) != stamp {
|
||||
return Version::Busy;
|
||||
}
|
||||
let len = meta.len();
|
||||
let mut end = [0u8; TAIL];
|
||||
let tail = usize::try_from(len).map_or(TAIL, |l| l.min(TAIL));
|
||||
let (Some(end), Ok(back)) = (end.get_mut(..tail), i64::try_from(tail)) else { return Version::Incomplete };
|
||||
if file.seek(SeekFrom::End(-back)).is_err() || file.read_exact(end).is_err() {
|
||||
return Version::Busy;
|
||||
}
|
||||
if !end.windows(5).any(|w| w == b"%%EOF") {
|
||||
return Version::Incomplete;
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
// Too big to hold: the open version stays.
|
||||
let Ok(size) = usize::try_from(len) else { return Version::Incomplete };
|
||||
if bytes.try_reserve_exact(size).is_err() {
|
||||
return Version::Incomplete;
|
||||
}
|
||||
if file.seek(SeekFrom::Start(0)).is_err() || file.read_to_end(&mut bytes).is_err() {
|
||||
return Version::Busy;
|
||||
}
|
||||
// Still that version, under that name: a writer may have replaced the file meanwhile.
|
||||
if bytes.len() != size || Stamp::at(path) != Some(stamp) {
|
||||
return Version::Busy;
|
||||
}
|
||||
Version::Ready(Arc::new(bytes))
|
||||
}
|
||||
|
||||
/// The worker thread: watches the newest list the UI thread sent and hands back new versions,
|
||||
/// waking the UI for them. It ends when the app (its inbox's sender) is gone.
|
||||
fn run(inbox: mpsc::Receiver<Vec<Watched>>, out: Arc<Mutex<Vec<Changed>>>, ctx: egui::Context) {
|
||||
let mut watcher = Watcher::default();
|
||||
// `None`: nothing to watch, so block until there is.
|
||||
let mut wait = None;
|
||||
loop {
|
||||
let message = match wait {
|
||||
None => inbox.recv().map_err(|_| RecvTimeoutError::Disconnected),
|
||||
Some(wait) => inbox.recv_timeout(wait),
|
||||
};
|
||||
match message {
|
||||
Ok(list) => watcher.set(list),
|
||||
Err(RecvTimeoutError::Timeout) => {}
|
||||
Err(RecvTimeoutError::Disconnected) => return,
|
||||
}
|
||||
while let Ok(list) = inbox.try_recv() {
|
||||
watcher.set(list);
|
||||
}
|
||||
if watcher.files.is_empty() {
|
||||
wait = None;
|
||||
continue;
|
||||
}
|
||||
let (found, settling) = watcher.tick();
|
||||
if !found.is_empty() {
|
||||
out.lock().unwrap_or_else(PoisonError::into_inner).extend(found);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
wait = Some(if settling { SETTLE } else { IDLE });
|
||||
}
|
||||
}
|
||||
|
||||
/// The app's side of the watcher.
|
||||
#[derive(Default)]
|
||||
pub struct FileWatch {
|
||||
/// The worker's inbox (`None` until a document with a file is open).
|
||||
inbox: Option<mpsc::Sender<Vec<Watched>>>,
|
||||
found: Arc<Mutex<Vec<Changed>>>,
|
||||
/// What the watcher was last told.
|
||||
sent: Vec<Watched>,
|
||||
/// In tests ([`PdfCraftApp::run_inline`]) there is no thread: each frame looks once.
|
||||
inline: Watcher,
|
||||
/// The version of each document's file that PdfCraft last read or wrote.
|
||||
known: HashMap<DocId, Stamp>,
|
||||
/// The notice bar's Reload was pressed for this document.
|
||||
pub reload_request: Option<DocId>,
|
||||
}
|
||||
|
||||
impl FileWatch {
|
||||
/// PdfCraft read or wrote `path` for `doc`: that version is not a change.
|
||||
pub fn known_now(&mut self, doc: DocId, path: &str) {
|
||||
if let Some(stamp) = Stamp::at(path) {
|
||||
self.known.insert(doc, stamp);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remember `stamp`, read before the file was, for `doc`.
|
||||
pub fn known_as(&mut self, doc: DocId, stamp: Option<Stamp>) {
|
||||
if let Some(stamp) = stamp {
|
||||
self.known.insert(doc, stamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// The documents to watch: every open one with a file, while the preference is on.
|
||||
fn watched(&self) -> impl Iterator<Item = (DocId, &str, Option<Stamp>)> {
|
||||
self.views.iter().filter(|_| self.reload_changed_files).filter_map(|v| {
|
||||
let path = self.session.get(v.id)?.path.as_deref()?;
|
||||
Some((v.id, path, self.watch.known.get(&v.id).copied()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Every frame: tell the watcher about files opened, closed or saved (without allocating
|
||||
/// when nothing changed), and reload what changed on disk.
|
||||
pub(crate) fn watch_files(&mut self) {
|
||||
if let Some(id) = self.watch.reload_request.take() {
|
||||
self.reload_from_disk(id);
|
||||
}
|
||||
let mut sent = self.watch.sent.iter();
|
||||
let unchanged = self.watched().all(|(doc, path, known)| sent.next().is_some_and(|w| w.doc == doc && w.path == path && w.known == known))
|
||||
&& sent.next().is_none();
|
||||
if !unchanged {
|
||||
let list: Vec<Watched> = self.watched().map(|(doc, path, known)| Watched { doc, path: path.to_owned(), known }).collect();
|
||||
let open: Vec<DocId> = self.views.iter().map(|v| v.id).collect();
|
||||
self.watch.known.retain(|doc, _| open.contains(doc));
|
||||
if self.send_to_watcher(list.clone()) {
|
||||
self.watch.sent = list;
|
||||
}
|
||||
}
|
||||
if self.run_inline {
|
||||
let (found, _) = self.watch.inline.tick();
|
||||
self.watch.found.lock().unwrap_or_else(PoisonError::into_inner).extend(found);
|
||||
}
|
||||
let found = std::mem::take(&mut *self.watch.found.lock().unwrap_or_else(PoisonError::into_inner));
|
||||
for c in found {
|
||||
self.file_changed(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand `list` to the watcher, starting it when needed. `false`: not delivered (no context
|
||||
/// yet, or the worker couldn't start or ended); the next frame tries again.
|
||||
fn send_to_watcher(&mut self, list: Vec<Watched>) -> bool {
|
||||
if self.run_inline {
|
||||
self.watch.inline.set(list);
|
||||
return true;
|
||||
}
|
||||
if self.watch.inbox.is_none() {
|
||||
if list.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let Some(ctx) = self.ctx.clone() else { return false };
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let out = self.watch.found.clone();
|
||||
if std::thread::Builder::new().name("pdfcraft-watch".into()).spawn(move || run(rx, out, ctx)).is_err() {
|
||||
return false;
|
||||
}
|
||||
self.watch.inbox = Some(tx);
|
||||
}
|
||||
let delivered = self.watch.inbox.as_ref().is_some_and(|tx| tx.send(list).is_ok());
|
||||
if !delivered {
|
||||
// The worker ended (it panicked): start a new one next frame.
|
||||
self.watch.inbox = None;
|
||||
}
|
||||
delivered
|
||||
}
|
||||
|
||||
/// A watched file has a new version: reload its document, or ask first when it has unsaved
|
||||
/// changes.
|
||||
fn file_changed(&mut self, c: Changed) {
|
||||
let Some(i) = self.views.iter().position(|v| v.id == c.doc) else { return };
|
||||
// PdfCraft wrote this version itself (a save), or it was handled already.
|
||||
if self.watch.known.get(&c.doc) == Some(&c.stamp) {
|
||||
return;
|
||||
}
|
||||
self.watch.known.insert(c.doc, c.stamp);
|
||||
if self.has_unsaved_work(i) {
|
||||
if let Some(view) = self.views.get_mut(i) {
|
||||
view.disk_changed = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.reload_view(i, c.bytes);
|
||||
}
|
||||
|
||||
/// The notice bar's Reload: read the file now and replace the document, unsaved changes and
|
||||
/// all.
|
||||
fn reload_from_disk(&mut self, doc: DocId) {
|
||||
let Some(i) = self.views.iter().position(|v| v.id == doc) else { return };
|
||||
let Some(path) = self.session.get(doc).and_then(|d| d.path.clone()) else { return };
|
||||
let stamp = Stamp::at(&path);
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => {
|
||||
self.watch.known_as(doc, stamp);
|
||||
self.reload_view(i, Arc::new(bytes));
|
||||
}
|
||||
Err(e) => {
|
||||
let name = self.session.get(doc).map(|d| d.name.clone()).unwrap_or_default();
|
||||
self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open `bytes` in place of tab `i`'s document, keeping the tab and the reader's place. A
|
||||
/// version that can't be opened leaves the document as it was, and says so.
|
||||
fn reload_view(&mut self, i: usize, bytes: Arc<Vec<u8>>) {
|
||||
let Some(id) = self.views.get(i).map(|v| v.id) else { return };
|
||||
match self.session.reload(id, bytes) {
|
||||
Ok(()) => {
|
||||
// Whatever was unsaved is gone, so there is nothing left to recover.
|
||||
self.forget_recovery(id);
|
||||
if let (Some(view), Some(doc)) = (self.views.get_mut(i), self.session.get(id)) {
|
||||
view.document_replaced(&doc.info);
|
||||
}
|
||||
// What the form's scripts said while it opened, as on opening.
|
||||
let out = self.session.take_js_output(id);
|
||||
self.handle_js(id, out);
|
||||
}
|
||||
Err(e) => {
|
||||
let name = self.session.get(id).map(|d| d.name.clone()).unwrap_or_default();
|
||||
self.notify_fmt("Couldn't reload {name}: {e}", &[("name", &name), ("e", &e.to_string())]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2232,6 +2232,10 @@ key Space مسافة
|
||||
Panels اللوحات
|
||||
Resize panels تغيير حجم اللوحات
|
||||
Resize panel window تغيير حجم نافذة اللوحة
|
||||
Reload documents when their files change on disk إعادة تحميل المستندات عند تغيّر ملفاتها على القرص
|
||||
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. تغيّر هذا الملف على القرص. أعد تحميله لعرض النسخة الجديدة؛ ستفقد تغييراتك غير المحفوظة.
|
||||
Reload إعادة تحميل
|
||||
Couldn't reload {name}: {e} تعذّرت إعادة تحميل {name}: {e}
|
||||
Select page {label} تحديد الصفحة {label}
|
||||
Rotate page {label} counterclockwise تدوير الصفحة {label} عكس اتجاه عقارب الساعة
|
||||
Rotate page {label} clockwise تدوير الصفحة {label} باتجاه عقارب الساعة
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2174,6 +2174,10 @@ key Space Интервал
|
||||
Resize panel window Преоразмери прозореца на панела
|
||||
Tools Инструменти
|
||||
Inspector Инспектор
|
||||
Reload documents when their files change on disk Презареждай документите, когато файловете им се променят на диска
|
||||
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Този файл е променен на диска. Презаредете го, за да видите новата версия; незаписаните ви промени ще бъдат загубени.
|
||||
Reload Презареди
|
||||
Couldn't reload {name}: {e} Неуспешно презареждане на {name}: {e}
|
||||
Select page {label} Избиране на страница {label}
|
||||
Rotate page {label} counterclockwise Завъртане на страница {label} обратно на часовниковата стрелка
|
||||
Rotate page {label} clockwise Завъртане на страница {label} по часовниковата стрелка
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -121,6 +121,10 @@
|
||||
Fit one full page Celá jedna stránka
|
||||
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Používá se, když PDF nepožaduje rozložení ani zvětšení. Plynulé posouvání mezi stránkami neskáče.
|
||||
Reopen the files that were open when PdfCraft last closed Znovu otevřít soubory, které byly otevřené při posledním zavření aplikace PdfCraft
|
||||
Reload documents when their files change on disk Znovu načítat dokumenty při změně jejich souborů na disku
|
||||
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Tento soubor se na disku změnil. Znovu jej načtěte, abyste viděli novou verzi; neuložené změny budou ztraceny.
|
||||
Reload Znovu načíst
|
||||
Couldn't reload {name}: {e} Soubor {name} nelze znovu načíst: {e}
|
||||
Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Vyberte obrázek PNG nebo JPEG (nejvýše 4 MiB). Průhlednost bude zachována.
|
||||
Couldn't import the signature image: {e} Obrázek podpisu nelze importovat: {e}
|
||||
Portable mode is off: {folder} can't be written ({error}). Settings are kept in your user folder instead. Přenosný režim je vypnutý: do složky {folder} nelze zapisovat ({error}). Nastavení se místo toho ukládá do vaší uživatelské složky.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 81.
|
@@ -2216,6 +2216,10 @@ key Space Leertaste
|
||||
Resize panel window Größe des Bereichsfensters ändern
|
||||
Tools Werkzeuge
|
||||
Inspector Inspektor
|
||||
Reload documents when their files change on disk Dokumente neu laden, wenn sich ihre Dateien auf dem Datenträger ändern
|
||||
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Diese Datei wurde auf dem Datenträger geändert. Lade sie neu, um die neue Version zu sehen; deine nicht gespeicherten Änderungen gehen dabei verloren.
|
||||
Reload Neu laden
|
||||
Couldn't reload {name}: {e} {name} konnte nicht neu geladen werden: {e}
|
||||
Mouse wheel scroll speed Scrollgeschwindigkeit des Mausrads
|
||||
scroll speed Slow Langsam
|
||||
scroll speed Normal Normal
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 51.
|
@@ -2022,6 +2022,10 @@
|
||||
Default zoom Zoom predeterminado
|
||||
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Se usa cuando un PDF no pide una presentación ni un zoom. El desplazamiento continuo nunca salta de página en página.
|
||||
Reopen the files that were open when PdfCraft last closed Volver a abrir los archivos que estaban abiertos al cerrar PdfCraft por última vez
|
||||
Reload documents when their files change on disk Recargar los documentos cuando sus archivos cambien en el disco
|
||||
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Este archivo ha cambiado en el disco. Recárgalo para ver la nueva versión; se perderán tus cambios sin guardar.
|
||||
Reload Recargar
|
||||
Couldn't reload {name}: {e} No se pudo recargar {name}: {e}
|
||||
Created a PDF from {n} files Se creó un PDF a partir de {n} archivos
|
||||
PDF, images and text PDF, imágenes y texto
|
||||
Insert a file before page {label} Insertar un archivo antes de la página {label}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 60.
|
@@ -1948,6 +1948,10 @@
|
||||
Default zoom Zoom par défaut
|
||||
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Utilisé lorsqu'un PDF ne demande ni présentation ni zoom. Le défilement continu ne saute jamais d'une page à l'autre.
|
||||
Reopen the files that were open when PdfCraft last closed Rouvrir les fichiers qui étaient ouverts lors de la dernière fermeture de PdfCraft
|
||||
Reload documents when their files change on disk Recharger les documents lorsque leurs fichiers sont modifiés sur le disque
|
||||
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Ce fichier a été modifié sur le disque. Rechargez-le pour voir la nouvelle version ; vos modifications non enregistrées seront perdues.
|
||||
Reload Recharger
|
||||
Couldn't reload {name}: {e} Impossible de recharger {name} : {e}
|
||||
Show Afficher
|
||||
Sort Trier
|
||||
Reverse the order Inverser l'ordre
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 39.
|
@@ -2210,6 +2210,10 @@ key Space Szóköz
|
||||
Resize panel window Panelablak átméretezése
|
||||
Tools Eszközök
|
||||
Inspector Tulajdonságvizsgáló
|
||||
Reload documents when their files change on disk Dokumentumok újratöltése, ha a fájljuk megváltozik a lemezen
|
||||
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Ez a fájl megváltozott a lemezen. Töltsd újra az új változat megtekintéséhez; a nem mentett módosításaid elvesznek.
|
||||
Reload Újratöltés
|
||||
Couldn't reload {name}: {e} {name} újratöltése nem sikerült: {e}
|
||||
Select page {label} {label}. oldal kijelölése
|
||||
Rotate page {label} counterclockwise {label}. oldal elforgatása az óramutató járásával ellentétesen
|
||||
Rotate page {label} clockwise {label}. oldal elforgatása az óramutató járásával megegyezően
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 44.
|
@@ -2219,6 +2219,10 @@ key Space Spazio
|
||||
Panels Pannelli
|
||||
Resize panels Ridimensiona pannelli
|
||||
Resize panel window Ridimensiona finestra pannello
|
||||
Reload documents when their files change on disk Ricarica i documenti quando i loro file cambiano sul disco
|
||||
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Questo file è cambiato sul disco. Ricaricalo per vedere la nuova versione; le modifiche non salvate andranno perse.
|
||||
Reload Ricarica
|
||||
Couldn't reload {name}: {e} Impossibile ricaricare {name}: {e}
|
||||
Select page {label} Seleziona pagina {label}
|
||||
Rotate page {label} counterclockwise Ruota pagina {label} in senso antiorario
|
||||
Rotate page {label} clockwise Ruota pagina {label} in senso orario
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2212,6 +2212,10 @@ key Space スペース
|
||||
Resize panel window パネルウィンドウのサイズを変更
|
||||
Tools ツール
|
||||
Inspector インスペクター
|
||||
Reload documents when their files change on disk ディスク上のファイルが変更されたら文書を再読み込みする
|
||||
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. このファイルはディスク上で変更されました。再読み込みすると新しいバージョンが表示されますが、未保存の変更は失われます。
|
||||
Reload 再読み込み
|
||||
Couldn't reload {name}: {e} {name} を再読み込みできませんでした: {e}
|
||||
Select page {label} ページ {label} を選択
|
||||
Rotate page {label} counterclockwise ページ {label} を反時計回りに回転
|
||||
Rotate page {label} clockwise ページ {label} を時計回りに回転
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2205,6 +2205,10 @@ key Space Spacja
|
||||
Text used as a clipping path cannot be moved with this selection. Tekstu używanego jako ścieżka przycinania nie można przenieść razem z tym zaznaczeniem.
|
||||
These objects could not be moved. Select them again and try a smaller move. Nie udało się przenieść tych obiektów. Zaznacz je ponownie i spróbuj mniejszego przesunięcia.
|
||||
This page contains objects that cannot be selected together. Ta strona zawiera obiekty, których nie można zaznaczyć razem.
|
||||
Reload documents when their files change on disk Wczytuj ponownie dokumenty, gdy ich pliki zmienią się na dysku
|
||||
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Ten plik zmienił się na dysku. Wczytaj go ponownie, aby zobaczyć nową wersję; niezapisane zmiany zostaną utracone.
|
||||
Reload Wczytaj ponownie
|
||||
Couldn't reload {name}: {e} Nie można ponownie wczytać {name}: {e}
|
||||
Select page {label} Wybierz stronę {label}
|
||||
Rotate page {label} counterclockwise Obróć stronę {label} w lewo
|
||||
Rotate page {label} clockwise Obróć stronę {label} w prawo
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2213,6 +2213,10 @@ key Space Espaço
|
||||
Resize panel window Redimensionar janela do painel
|
||||
Tools Ferramentas
|
||||
Inspector Inspetor
|
||||
Reload documents when their files change on disk Recarregar documentos quando os arquivos mudarem no disco
|
||||
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Este arquivo foi alterado no disco. Recarregue-o para ver a nova versão; suas alterações não salvas serão perdidas.
|
||||
Reload Recarregar
|
||||
Couldn't reload {name}: {e} Não foi possível recarregar {name}: {e}
|
||||
Select page {label} Selecionar página {label}
|
||||
Rotate page {label} counterclockwise Girar página {label} no sentido anti-horário
|
||||
Rotate page {label} clockwise Girar página {label} no sentido horário
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2017,6 +2017,10 @@
|
||||
Default zoom Масштаб по умолчанию
|
||||
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Используется, когда PDF не запрашивает макет или масштаб. Непрерывная прокрутка никогда не привязывается между страницами.
|
||||
Reopen the files that were open when PdfCraft last closed Повторно открывать файлы, которые были открыты при последнем закрытии PdfCraft
|
||||
Reload documents when their files change on disk Перезагружать документы при изменении их файлов на диске
|
||||
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Этот файл был изменён на диске. Перезагрузите его, чтобы увидеть новую версию; несохранённые изменения будут потеряны.
|
||||
Reload Перезагрузить
|
||||
Couldn't reload {name}: {e} Не удалось перезагрузить {name}: {e}
|
||||
Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Выберите изображение PNG или JPEG (до 4 МиБ). Прозрачность сохраняется.
|
||||
Couldn't import the signature image: {e} Не удалось импортировать изображение подписи: {e}
|
||||
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -1939,6 +1939,10 @@ action wizard Start ప్రారంభించు
|
||||
Default zoom డిఫాల్ట్ జూమ్
|
||||
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. PDF ఏ లేఅవుట్ను గానీ జూమ్ను గానీ కోరనప్పుడు ఉపయోగించబడుతుంది. నిరంతర స్క్రోలింగ్ పేజీల మధ్య ఎప్పుడూ స్నాప్ అవ్వదు.
|
||||
Reopen the files that were open when PdfCraft last closed PdfCraftను చివరిసారి మూసివేసినప్పుడు తెరిచి ఉన్న ఫైల్లను మళ్లీ తెరువు
|
||||
Reload documents when their files change on disk డిస్క్లో వాటి ఫైల్లు మారినప్పుడు పత్రాలను మళ్లీ లోడ్ చేయి
|
||||
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. ఈ ఫైల్ డిస్క్లో మారింది. కొత్త వెర్షన్ను చూడటానికి దాన్ని మళ్లీ లోడ్ చేయండి; సేవ్ చేయని మీ మార్పులు పోతాయి.
|
||||
Reload మళ్లీ లోడ్ చేయి
|
||||
Couldn't reload {name}: {e} {name}ను మళ్లీ లోడ్ చేయడం సాధ్యం కాలేదు: {e}
|
||||
About పరిచయం
|
||||
Contributors సహకారులు
|
||||
Models మోడల్లు
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 13 and column 16.
|
@@ -2233,6 +2233,10 @@ key Space Пробіл
|
||||
Panels Панелі
|
||||
Resize panels Змінити розмір панелей
|
||||
Resize panel window Змінити розмір вікна панелі
|
||||
Reload documents when their files change on disk Перезавантажувати документи, коли їхні файли змінюються на диску
|
||||
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Цей файл змінено на диску. Перезавантажте його, щоб побачити нову версію; незбережені зміни буде втрачено.
|
||||
Reload Перезавантажити
|
||||
Couldn't reload {name}: {e} Не вдалося перезавантажити {name}: {e}
|
||||
Select page {label} Вибрати сторінку {label}
|
||||
Rotate page {label} counterclockwise Повернути сторінку {label} проти годинникової стрілки
|
||||
Rotate page {label} clockwise Повернути сторінку {label} за годинниковою стрілкою
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2051,6 +2051,10 @@
|
||||
Used when opening PDFs. An explicit launch or control mode takes precedence for the session. 用于打开 PDF 时。如果启动参数或控制通道明确指定了模式,则本次会话优先使用该模式。
|
||||
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. PDF 未指定布局或缩放时使用。连续滚动时页面不会逐页吸附。
|
||||
Reopen the files that were open when PdfCraft last closed 重新打开上次关闭 PdfCraft 时打开的文件
|
||||
Reload documents when their files change on disk 文件在磁盘上更改时重新加载对应的文档
|
||||
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. 此文件已在磁盘上更改。请重新加载以查看新版本;未保存的更改将会丢失。
|
||||
Reload 重新加载
|
||||
Couldn't reload {name}: {e} 无法重新加载{name}:{e}
|
||||
Write this email? 是否撰写此邮件?
|
||||
{who} in this document wants to open this address in your browser: 此文档中的{who}请求在浏览器中打开以下地址:
|
||||
{who} in this document wants to open your email app with this address: 此文档中的{who}请求打开邮件应用并使用以下地址:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2097,6 +2097,10 @@ key Space 空格
|
||||
Resize panel window 調整面板視窗大小
|
||||
Tools 工具
|
||||
Inspector 檢查器
|
||||
Reload documents when their files change on disk 檔案在磁碟上變更時重新載入對應的文件
|
||||
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. 此檔案已在磁碟上變更。請重新載入以查看新版本;未儲存的變更將會遺失。
|
||||
Reload 重新載入
|
||||
Couldn't reload {name}: {e} 無法重新載入{name}:{e}
|
||||
Select page {label} 選取頁面 {label}
|
||||
Rotate page {label} counterclockwise 逆時針旋轉頁面 {label}
|
||||
Rotate page {label} clockwise 順時針旋轉頁面 {label}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -327,6 +327,9 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok
|
||||
// The web build has no file paths to reopen.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
ui.checkbox(&mut app.reopen_last_session, tl!("Reopen the files that were open when PdfCraft last closed"));
|
||||
// The web build has no files on disk to watch.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
ui.checkbox(&mut app.reload_changed_files, tl!("Reload documents when their files change on disk"));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Mouse wheel scroll speed"));
|
||||
for speed in crate::scroll_speed::ScrollSpeed::ALL {
|
||||
|
||||
@@ -72,6 +72,8 @@ pub mod bulk_fields;
|
||||
mod dialogs;
|
||||
mod edit_text_ui;
|
||||
mod editing;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod file_watch;
|
||||
mod files;
|
||||
pub mod fill_sign;
|
||||
pub mod folders_ui;
|
||||
@@ -474,6 +476,11 @@ pub struct PdfCraftApp {
|
||||
pub last_session: last_session::LastSession,
|
||||
/// Quitting closes unsaved tabs one by one: what was open when the quit began.
|
||||
quit_session: Option<last_session::LastSession>,
|
||||
/// Preferences: reload a document when its file changes on disk (#431).
|
||||
pub reload_changed_files: bool,
|
||||
/// Open documents' files, watched for changes on disk.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) watch: file_watch::FileWatch,
|
||||
/// Folders pinned to Home, and what they held when last listed.
|
||||
pub pinned: folders_ui::PinnedFolders,
|
||||
pub toast: Option<(String, f64)>,
|
||||
@@ -777,6 +784,9 @@ impl PdfCraftApp {
|
||||
reopen_last_session: false,
|
||||
last_session: Default::default(),
|
||||
quit_session: None,
|
||||
reload_changed_files: true,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
watch: Default::default(),
|
||||
pinned: Default::default(),
|
||||
toast: None,
|
||||
integrated_titlebar: false,
|
||||
@@ -1211,10 +1221,17 @@ impl PdfCraftApp {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn open_path(&mut self, path: &str) {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string());
|
||||
// Taken before reading: a build that rewrites the file meanwhile still counts as a change.
|
||||
let stamp = file_watch::Stamp::at(path);
|
||||
let tabs = self.views.len();
|
||||
match std::fs::read(path) {
|
||||
Ok(bytes) => {
|
||||
if let Err(e) = self.open_bytes(&name, Some(path.to_string()), bytes) {
|
||||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e.to_string())]);
|
||||
} else if self.views.len() > tabs
|
||||
&& let Some(doc) = self.views.last().map(|v| v.id)
|
||||
{
|
||||
self.watch.known_as(doc, stamp);
|
||||
}
|
||||
}
|
||||
Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]),
|
||||
@@ -1528,6 +1545,7 @@ impl PdfCraftApp {
|
||||
"reopen_last_session": self.reopen_last_session,
|
||||
// Kept only while the preference is on.
|
||||
"last_session": self.reopen_last_session.then(|| self.session_to_save()),
|
||||
"reload_changed_files": self.reload_changed_files,
|
||||
"pinned_folders": self.pinned.folders,
|
||||
"theme": self.theme_preference,
|
||||
"scroll_speed": self.scroll_speed,
|
||||
@@ -1605,6 +1623,9 @@ impl PdfCraftApp {
|
||||
self.reopen_last_session = on;
|
||||
}
|
||||
self.last_session = last_session::LastSession::from_json(&v["last_session"]);
|
||||
if let Some(on) = v["reload_changed_files"].as_bool() {
|
||||
self.reload_changed_files = on;
|
||||
}
|
||||
self.pinned.restore(&v["pinned_folders"]);
|
||||
if let Ok(preference) = serde_json::from_value::<ThemePreference>(v["theme"].clone()) {
|
||||
self.set_theme_preference(preference);
|
||||
@@ -2229,6 +2250,9 @@ impl eframe::App for PdfCraftApp {
|
||||
self.guard_quit(ctx);
|
||||
let now = ctx.input(|i| i.time);
|
||||
self.autosave_tick(now);
|
||||
// Reload documents whose files a build rewrote (#431).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.watch_files();
|
||||
if self.host_dirty.is_some() {
|
||||
let dirty = self.first_dirty().is_some();
|
||||
if let Some(report) = self.host_dirty.as_mut() {
|
||||
|
||||
@@ -424,6 +424,9 @@ impl PdfCraftApp {
|
||||
}
|
||||
let signed = app.session.sign(doc_id, &id, opts).map_err(|e| e.to_string())?;
|
||||
crate::editing::write_atomically(&path.to_string_lossy(), signed.as_slice()).map_err(|e| format!("Could not save: {e}"))?;
|
||||
// Our own write is not a change on disk to reload (#431).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
app.watch.known_now(doc_id, &path.to_string_lossy());
|
||||
app.session.mark_signed(doc_id, signed, Some(path.to_string_lossy().into_owned())).map_err(|e| e.to_string())?;
|
||||
app.sign_graphic = choice;
|
||||
if let Some(view) = app.views.iter_mut().find(|v| v.id == doc_id) {
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
//! A document reloads when its file changes on disk (#431): a LaTeX or Typst build rewriting the
|
||||
//! open PDF. Here the watcher looks once per frame on the test's thread (`run_inline`); in the
|
||||
//! app a worker thread looks every quarter second.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use pdfcraft_ui_egui::{PdfCraftApp, SaveTarget};
|
||||
|
||||
/// An `n`-page document with a proper xref table; page `i` shows "Page i+1", and with `notes`
|
||||
/// each page has a sticky note.
|
||||
fn fixture_with(n: usize, notes: bool) -> Vec<u8> {
|
||||
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
|
||||
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect();
|
||||
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")));
|
||||
objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into());
|
||||
let annots = if notes { " /Annots [<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (note) >>]" } else { "" };
|
||||
for i in 0..n {
|
||||
objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >>{annots} >>", 5 + 2 * i));
|
||||
let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1);
|
||||
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()));
|
||||
}
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn fixture(n: usize) -> Vec<u8> {
|
||||
fixture_with(n, false)
|
||||
}
|
||||
|
||||
/// A PDF in a fresh temp folder, removed when the test ends.
|
||||
struct TempPdf {
|
||||
dir: PathBuf,
|
||||
path: String,
|
||||
builds: std::cell::Cell<u64>,
|
||||
}
|
||||
|
||||
impl TempPdf {
|
||||
fn new(test: &str, bytes: &[u8]) -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-reload-{test}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("thesis.pdf").to_string_lossy().into_owned();
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
Self { dir, path, builds: std::cell::Cell::new(0) }
|
||||
}
|
||||
|
||||
/// Rewrite it as a build does: new contents, and a later modification time every time.
|
||||
fn rebuild(&self, bytes: &[u8]) {
|
||||
std::fs::write(&self.path, bytes).unwrap();
|
||||
self.builds.set(self.builds.get() + 1);
|
||||
let file = std::fs::File::options().write(true).open(&self.path).unwrap();
|
||||
file.set_modified(SystemTime::now() + Duration::from_secs(60 * self.builds.get())).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempPdf {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn harness(path: &str) -> Harness<'static, PdfCraftApp> {
|
||||
let path = path.to_owned();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.run_inline = true;
|
||||
app.open_path(&path);
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn pages(h: &Harness<'static, PdfCraftApp>) -> usize {
|
||||
let app = h.state();
|
||||
app.session.get(app.views[0].id).unwrap().info.pages.len()
|
||||
}
|
||||
|
||||
fn dirty(h: &Harness<'static, PdfCraftApp>) -> bool {
|
||||
let app = h.state();
|
||||
app.session.get(app.views[0].id).unwrap().dirty
|
||||
}
|
||||
|
||||
fn rotate_first_page(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
h.state_mut().views[0].select_pages(&[0]);
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rebuilt_file_reloads_in_place_keeping_the_page_and_zoom() {
|
||||
let file = TempPdf::new("rebuild", &fixture(3));
|
||||
let mut h = harness(&file.path);
|
||||
let id = h.state().views[0].id;
|
||||
h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::None;
|
||||
h.state_mut().views[0].zoom = 1.5;
|
||||
h.state_mut().views[0].go_to_page(2);
|
||||
h.run_steps(4);
|
||||
file.rebuild(&fixture(4));
|
||||
// One look sees the change, the next sees it settled and reloads.
|
||||
h.run_steps(3);
|
||||
let app = h.state();
|
||||
assert_eq!(pages(&h), 4, "the new version shows");
|
||||
assert_eq!((app.views.len(), app.views[0].id), (1, id), "in the same tab");
|
||||
assert_eq!((app.views[0].current, app.views[0].zoom), (2, 1.5), "at the same page and zoom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_half_written_file_waits_until_the_pdf_is_whole() {
|
||||
let file = TempPdf::new("partial", &fixture(2));
|
||||
let mut h = harness(&file.path);
|
||||
let whole = fixture(5);
|
||||
// A build part-way through: whole objects up to the fourth page, no cross-reference table
|
||||
// or end yet. The lenient parser would open this as a document with pages missing.
|
||||
let cut = whole.windows(9).position(|w| w == b"\n10 0 obj").unwrap();
|
||||
file.rebuild(&whole[..cut]);
|
||||
h.run_steps(4);
|
||||
assert_eq!(pages(&h), 2, "half a file is not loaded");
|
||||
file.rebuild(&whole);
|
||||
h.run_steps(3);
|
||||
assert_eq!(pages(&h), 5, "the whole one is");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_does_not_reload_or_lose_undo() {
|
||||
let file = TempPdf::new("save", &fixture(2));
|
||||
let mut h = harness(&file.path);
|
||||
rotate_first_page(&mut h);
|
||||
h.state_mut().save_active(SaveTarget::InPlace);
|
||||
h.run_steps(4);
|
||||
let app = h.state();
|
||||
let doc = app.session.get(app.views[0].id).unwrap();
|
||||
assert!(!doc.dirty, "saved");
|
||||
assert!(doc.can_undo().is_some(), "a reload would have cleared the undo history");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsaved_changes_are_kept_until_reload_is_chosen() {
|
||||
let file = TempPdf::new("dirty", &fixture(2));
|
||||
let mut h = harness(&file.path);
|
||||
rotate_first_page(&mut h);
|
||||
file.rebuild(&fixture(4));
|
||||
h.run_steps(3);
|
||||
assert_eq!((pages(&h), dirty(&h)), (2, true), "not replaced behind the user's back");
|
||||
h.get_by_label_contains("This file changed on disk");
|
||||
h.get_by_label("Reload").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!((pages(&h), dirty(&h)), (4, false), "replaced when asked");
|
||||
assert!(h.query_by_label_contains("This file changed on disk").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deleted_file_leaves_the_document_open() {
|
||||
let file = TempPdf::new("deleted", &fixture(2));
|
||||
let mut h = harness(&file.path);
|
||||
std::fs::remove_file(&file.path).unwrap();
|
||||
h.run_steps(4);
|
||||
assert_eq!((h.state().views.len(), pages(&h)), (1, 2), "still open, as it was");
|
||||
file.rebuild(&fixture(3));
|
||||
h.run_steps(3);
|
||||
assert_eq!(pages(&h), 3, "and the next build shows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_reloads_with_the_preference_off() {
|
||||
assert!(PdfCraftApp::new().reload_changed_files, "on by default");
|
||||
let file = TempPdf::new("off", &fixture(2));
|
||||
let mut h = harness(&file.path);
|
||||
h.state_mut().reload_changed_files = false;
|
||||
h.run_steps(1);
|
||||
file.rebuild(&fixture(3));
|
||||
h.run_steps(4);
|
||||
assert_eq!(pages(&h), 2);
|
||||
let mut restarted = PdfCraftApp::new();
|
||||
restarted.restore(&h.state().persist());
|
||||
assert!(!restarted.reload_changed_files, "the choice is kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shorter_new_version_drops_what_pointed_into_the_old_one() {
|
||||
let file = TempPdf::new("shorter", &fixture_with(5, true));
|
||||
let mut h = harness(&file.path);
|
||||
h.state_mut().views[0].go_to_page(4);
|
||||
h.state_mut().views[0].select_pages(&[3, 4]);
|
||||
h.state_mut().views[0].comments.selected = Some((4, 0));
|
||||
h.run_steps(2);
|
||||
file.rebuild(&fixture(1));
|
||||
h.run_steps(4);
|
||||
let view = &h.state().views[0];
|
||||
assert_eq!(pages(&h), 1);
|
||||
assert_eq!(view.current, 0, "the page is clamped");
|
||||
assert!(view.selected.is_empty() && view.comments.selected.is_none(), "no selection points past the new version");
|
||||
}
|
||||
@@ -625,6 +625,18 @@ tools = []
|
||||
evidence = ["crates/ui-egui/tests/viewer.rs::revert_after_confirming", "crates/engine/src/tests.rs::revert_goes_back_to_the_saved_version"]
|
||||
notes = "with confirmation; clears undo, as Acrobat"
|
||||
|
||||
[[feature]]
|
||||
id = "core.reload-changed-files"
|
||||
title = "Reload a document when its file changes on disk"
|
||||
area = "A"
|
||||
tier = "P2"
|
||||
milestone = "M4"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/engine/src/tests.rs::reload_reads_the_new_file_into_the_same_document", "crates/ui-egui/tests/reload.rs::a_rebuilt_file_reloads_in_place_keeping_the_page_and_zoom", "crates/ui-egui/tests/reload.rs::a_half_written_file_waits_until_the_pdf_is_whole", "crates/ui-egui/tests/reload.rs::saving_does_not_reload_or_lose_undo", "crates/ui-egui/tests/reload.rs::unsaved_changes_are_kept_until_reload_is_chosen", "crates/ui-egui/tests/reload.rs::a_deleted_file_leaves_the_document_open", "crates/ui-egui/tests/reload.rs::nothing_reloads_with_the_preference_off", "crates/ui-egui/tests/reload.rs::a_shorter_new_version_drops_what_pointed_into_the_old_one"]
|
||||
notes = "Desktop, on by default (Preferences ▸ Documents and view, #431), for LaTeX/Typst builds: a worker thread checks each open file's metadata every 250 ms and reads a change once it has settled and ends in %%EOF; a clean document reloads in place (same tab, page, zoom, scroll; old page images stay until new ones are drawn), one with unsaved changes gets a Reload notice; PdfCraft's own saves never reload. Measured on Windows: one check 11 µs per file, reload of a 417-page 0.9 MB pdflatex file 16 ms. Not yet: OS change notifications (polling adds up to 250 ms), the web build; a reload clears undo history, like Revert."
|
||||
|
||||
[[feature]]
|
||||
id = "core.signed-save-policy"
|
||||
title = "Force incremental saves for signed documents"
|
||||
|
||||
Reference in new issue
Block a user