mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 21:11:52 +00:00
M4: bind property drafts with edit-session and replay UI scenarios (#4594)
Binds Properties dialog drafts to the vendored edit-session crate (byte-identical to PerchAnton/edit-session@033f7c3a, ledger in vendor/README) and adds replayable UI scenarios. Includes a merge of main and a test covering the clean Properties inspection-tab live refresh.
This commit is contained in:
1 parent
8e6ec925c8
commit
d47adeff89
35 files changed
+1832
-62
No files matched your search
Generated
+5
@@ -1725,6 +1725,10 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "edit-session"
|
||||
version = "0.1.0-alpha.2"
|
||||
|
||||
[[package]]
|
||||
name = "eframe"
|
||||
version = "0.37.0"
|
||||
@@ -4774,6 +4778,7 @@ dependencies = [
|
||||
"craft-quit",
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
"edit-session",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
|
||||
@@ -12,6 +12,8 @@ rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/pdfcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Small, revision-bound UI drafts; the existing engine remains the owner of PDF edits/history.
|
||||
edit-session = { path = "vendor/edit-session", version = "=0.1.0-alpha.2" }
|
||||
# Shared frontend components; engines remain independent of egui.
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["serde"] }
|
||||
# Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`.
|
||||
|
||||
+13
-1
@@ -16,7 +16,19 @@ sha256 = "cd7032ea33b71427f188e34ffb4aee9d6868a57fd196037c5c8b2be03a2a0352"
|
||||
[[document]]
|
||||
source = "NOTICE"
|
||||
destination = "NOTICE"
|
||||
sha256 = "d9ba162ad2bc95d22ab9885065e253bb823bd6795068828e7b39858344b660eb"
|
||||
sha256 = "a7620386409bc691c7e223917db186ec447362f655fd81a1196d9853dcac1330"
|
||||
|
||||
[[document]]
|
||||
source = "vendor/edit-session/LICENSE-MIT"
|
||||
licences = ["MIT"]
|
||||
destination = "vendor/edit-session/LICENSE-MIT"
|
||||
sha256 = "665a942f9317a358930b470ad522a22a58ff6c346db0395463ec84dd3e19d775"
|
||||
|
||||
[[document]]
|
||||
source = "vendor/edit-session/LICENSE-APACHE"
|
||||
licences = ["Apache-2.0"]
|
||||
destination = "vendor/edit-session/LICENSE-APACHE"
|
||||
sha256 = "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30"
|
||||
|
||||
[[document]]
|
||||
source = "assets/app-icon/LICENSE-document.txt"
|
||||
|
||||
@@ -9,6 +9,8 @@ docs/brand/LICENSE-brand.txt.
|
||||
|
||||
Third-party material
|
||||
|
||||
- edit-session 0.1.0-alpha.2, Copyright (c) 2026 PerchAnton. See vendor/edit-session/LICENSE-MIT and vendor/edit-session/LICENSE-APACHE.
|
||||
|
||||
This product bundles third-party material under its own licenses, listed below. The per-file
|
||||
list (author, source, license) is in ATTRIBUTION.md.
|
||||
|
||||
|
||||
@@ -636,6 +636,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
- **2026-11-10 (M4, #4594):** Reconcile current main and keep the original draft-safety entry in this progress log instead of duplicating it in the shared roadmap header. Preserve inactive password cleanup and the Properties guard; behavior and estimates unchanged.
|
||||
- **2026-11-09 (M4, #4594):** Reconcile upstream changes in the license ledger, roadmap and UI test registry; retain the vendored draft guard, local interaction replay and upstream split-view tests. Behavior and estimates unchanged; maintainer review remains pending.
|
||||
|
||||
- **2026-10-28 (M10, #1595):** Export to Markdown: `doc_export_office` with a `.md` or `.markdown` path writes CommonMark with GitHub pipe tables (headings, paragraphs with bold and italic, tables with their first row as the header, a rule between pages); text that Markdown would read as markup is escaped. Images, lists and links are not in the export's layout model and are left out. No menu item yet. Estimates unchanged.
|
||||
- **2026-10-12 (M10, `ocr.ocr-web`):** OCR in the browser, partly. The web zip carries the text recognition models in `models/` beside `index.html`; the first time Recognize Text is needed the web build downloads them (about 12 MB, with a spinner, an error and a retry). Pages are rendered on the page and read in Web Workers (up to two at once, so the page keeps responding), built with WebAssembly SIMD: 8.5 s a page instead of 20.5 s in wasmtime, still about 17× native. Where workers can't start, the page reads the pages itself. No automated browser test yet. Estimates unchanged.
|
||||
- **2026-10-12 (M10, `ocr.ocr-web`):** Web OCR shipped: reading scanned pages in Web Workers was checked by hand in a browser (4-page synthetic scan). Still no automated browser test, and a page reads about 17× slower than native.
|
||||
@@ -866,6 +869,10 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
- M13: common command, batch, inspection and bounded preview tools share the existing confined automation path. MCP tools carry complete hints and reject unknown top-level keys as invalid parameters.
|
||||
- **2026-10-10 (UI docking):** Tools and Inspector can be regrouped, split beside the PDF, or floated inside the app window. Existing toggles reopen panels at their saved locations; UI commands arrange/reset panels, and preferences retain their layout. Read/full-screen modes preserve the focused document view. No milestone percentage change.
|
||||
|
||||
- **2026-10-25 (M4, #4594 review):** Documented the session bridge and replay adapter; the stale notice now participates in translation-gap scanning, with a regression test. The original proposal depended on a shared release exception. The revised proposal vendors the Rust source and keeps the Python replay local. Maintainer review is pending.
|
||||
|
||||
- **2026-10-25 (M4):** Document Properties drafts now reject changed document identities/revisions, including reload and Undo/Redo, through pinned edit-session; real UI JSONL scenarios replay with interaction-check. Broader dialog invalidation remains separate (#3917); estimates unchanged.
|
||||
|
||||
## Revision history
|
||||
|
||||
**2026-10-27 · minor:** M10 #5005: vector printing preserves annotation optional-content membership.
|
||||
|
||||
@@ -10,6 +10,7 @@ rust-version = "1.95"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
edit-session = { workspace = true }
|
||||
craft-ui = { workspace = true }
|
||||
# The menus: native on macOS, an egui bar elsewhere, from one definition (menus.rs).
|
||||
craft-menu = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
//! JSONL adapter for interaction-check, driving the real Document Properties dialog.
|
||||
//!
|
||||
//! Each process owns two synthetic PDFs. Widget edits/clicks use egui_kittest; external
|
||||
//! document edits, reloads and save/reopen use the real engine. No network or shipped API.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::io::{BufRead, Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use pdfcraft_engine::{DocId, Edit, Session};
|
||||
use pdfcraft_ui_egui::{Dialog, PdfCraftApp};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const LIMIT: u64 = 1024 * 1024;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Request {
|
||||
protocol: u64,
|
||||
id: u64,
|
||||
event: Event,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
|
||||
enum Event {
|
||||
Open,
|
||||
Type { field: String, value: String },
|
||||
Click { label: String },
|
||||
Escape,
|
||||
ExternalEdit { key: String, value: String },
|
||||
ExternalEditUndo { key: String, value: String },
|
||||
Switch { document: usize },
|
||||
Reload { title: String },
|
||||
CloseDocument,
|
||||
Undo,
|
||||
Redo,
|
||||
SaveReopen,
|
||||
Inspect,
|
||||
}
|
||||
|
||||
/// Build and save a synthetic two-page PDF with known standard and custom metadata.
|
||||
fn fixture(title: &str) -> Result<Arc<Vec<u8>>, String> {
|
||||
let mut session = Session::new();
|
||||
let bytes = session.create_blank(200.0, 300.0, 2).map_err(|e| e.to_string())?;
|
||||
let id = session.open("fixture.pdf", None, bytes, None).map_err(|e| e.to_string())?;
|
||||
session
|
||||
.apply(
|
||||
id,
|
||||
Edit::Batch {
|
||||
label: "Fixture metadata".into(),
|
||||
edits: vec![
|
||||
Edit::SetInfo { key: "Title".into(), value: title.into() },
|
||||
Edit::SetInfo { key: "Author".into(), value: "Original author".into() },
|
||||
Edit::SetInfo { key: "Untouched".into(), value: "Keep me".into() },
|
||||
],
|
||||
},
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
session.save_full_bytes(id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Expose actual engine metadata, edit generation and history labels for subset assertions.
|
||||
fn document_state(doc: &pdfcraft_engine::Document) -> Value {
|
||||
let custom: serde_json::Map<String, Value> = doc.custom_info().into_iter().map(|(k, v)| (k, json!(v))).collect();
|
||||
let custom_count = custom.len();
|
||||
json!({
|
||||
"title": doc.info_value("Title"), "author": doc.info_value("Author"),
|
||||
"subject": doc.info_value("Subject"), "keywords": doc.info_value("Keywords"),
|
||||
"custom": custom, "custom_count": custom_count, "display_title": doc.initial_view().display_title,
|
||||
"dirty": doc.dirty, "undo": doc.can_undo(), "redo": doc.can_redo(),
|
||||
"generation": doc.edit_generation(), "pages": doc.info.pages.len(),
|
||||
})
|
||||
}
|
||||
|
||||
struct Adapter {
|
||||
h: Harness<'static, PdfCraftApp>,
|
||||
originals: Vec<(DocId, Arc<Vec<u8>>)>,
|
||||
reopened: Option<Value>,
|
||||
artifacts: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Adapter {
|
||||
/// Open two independent fixtures and create a deterministic 60 Hz headless UI harness.
|
||||
fn new(artifacts: Option<PathBuf>) -> Result<Self, String> {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en")?;
|
||||
let mut originals = Vec::new();
|
||||
for (name, title) in [("A.pdf", "Original"), ("B.pdf", "Second document")] {
|
||||
let bytes = fixture(title)?;
|
||||
app.open_bytes(name, None, bytes.as_ref().clone()).map_err(|e| e.to_string())?;
|
||||
let id = app.active_ids().map(|(_, id)| id).ok_or("fixture did not open")?;
|
||||
originals.push((id, bytes));
|
||||
}
|
||||
app.active = Some(0);
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| app);
|
||||
h.run_steps(4);
|
||||
if let Some(path) = &artifacts {
|
||||
std::fs::create_dir_all(path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(Self { h, originals, reopened: None, artifacts })
|
||||
}
|
||||
|
||||
/// Resolve the current document, returning a protocol error when no tab is active.
|
||||
fn active(&self) -> Result<DocId, String> {
|
||||
self.h.state().active_ids().map(|(_, id)| id).ok_or_else(|| "no active document".into())
|
||||
}
|
||||
|
||||
/// Report whether the active modal is a Document Properties tab.
|
||||
fn properties_open(&self) -> bool {
|
||||
matches!(self.h.state().dialog, Some(Dialog::Properties(_)))
|
||||
}
|
||||
|
||||
/// Replace an enabled property field through focus and keyboard events; never set its buffer directly.
|
||||
fn type_text(&mut self, field: &str, value: &str) -> Result<(), String> {
|
||||
if !self.properties_open() || !matches!(field, "Title" | "Author" | "Subject" | "Keywords" | "Name:" | "Value:") {
|
||||
return Err("no editable property field".into());
|
||||
}
|
||||
if value.len() > 65536 {
|
||||
return Err("property input exceeds 64 KiB".into());
|
||||
}
|
||||
{
|
||||
let mut nodes = self.h.query_all_by_role_and_label(Role::TextInput, field);
|
||||
let node = nodes.next().ok_or_else(|| format!("missing field: {field}"))?;
|
||||
if nodes.next().is_some() || node.accesskit_node().is_disabled() {
|
||||
return Err(format!("ambiguous or disabled field: {field}"));
|
||||
}
|
||||
node.focus();
|
||||
}
|
||||
self.h.run_steps(1);
|
||||
self.h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
|
||||
self.h.run_steps(1);
|
||||
self.h.key_press(egui::Key::Backspace);
|
||||
self.h.event(egui::Event::Text(value.to_owned()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Click one enabled button or radio control; reject unsupported or ambiguous targets.
|
||||
fn click(&mut self, label: &str) -> Result<(), String> {
|
||||
if !self.properties_open() {
|
||||
return Err("properties dialog is closed".into());
|
||||
}
|
||||
if !matches!(
|
||||
label,
|
||||
"OK" | "Cancel"
|
||||
| "Close"
|
||||
| "Description"
|
||||
| "Initial View"
|
||||
| "Custom"
|
||||
| "Advanced"
|
||||
| "Space Audit"
|
||||
| "Add"
|
||||
| "Update"
|
||||
| "Delete"
|
||||
| "Document Title"
|
||||
| "File Name"
|
||||
) {
|
||||
return Err("unsupported property action".into());
|
||||
}
|
||||
let role = if matches!(label, "Document Title" | "File Name") { Role::RadioButton } else { Role::Button };
|
||||
let mut nodes = self.h.query_all_by_role_and_label(role, label);
|
||||
let node = nodes.next().ok_or_else(|| format!("missing action: {label}"))?;
|
||||
if nodes.next().is_some() || node.accesskit_node().is_disabled() {
|
||||
return Err(format!("ambiguous or disabled action: {label}"));
|
||||
}
|
||||
node.click();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dispatch a semantic event through UI controls or the documented external-change engine path.
|
||||
fn event(&mut self, event: Event) -> Result<(), String> {
|
||||
match event {
|
||||
Event::Open => {
|
||||
self.active()?;
|
||||
if self.properties_open() {
|
||||
return Err("properties dialog is already open".into());
|
||||
}
|
||||
self.h.state_mut().run_command("file.properties");
|
||||
}
|
||||
Event::Type { field, value } => self.type_text(&field, &value)?,
|
||||
Event::Click { label } => self.click(&label)?,
|
||||
Event::Escape => self.h.key_press(egui::Key::Escape),
|
||||
Event::ExternalEdit { key, value } => {
|
||||
let id = self.active()?;
|
||||
self.h.state_mut().session.apply(id, Edit::SetInfo { key, value }).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Event::ExternalEditUndo { key, value } => {
|
||||
let id = self.active()?;
|
||||
self.h.state_mut().session.apply(id, Edit::SetInfo { key, value }).map_err(|e| e.to_string())?;
|
||||
self.h.state_mut().session.undo(id).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Event::Switch { document } => {
|
||||
// Scenario document numbers are 1-based, like PdfCraft's automation pages.
|
||||
let index = document.checked_sub(1).ok_or("document numbers start at 1")?;
|
||||
if self.h.state().views.get(index).is_none() {
|
||||
return Err("document is not open".into());
|
||||
}
|
||||
self.h.state_mut().active = Some(index);
|
||||
}
|
||||
Event::Reload { title } => {
|
||||
let id = self.active()?;
|
||||
self.h.state_mut().session.reload(id, fixture(&title)?).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Event::CloseDocument => {
|
||||
let id = self.active()?;
|
||||
let app = self.h.state_mut();
|
||||
app.session.close(id);
|
||||
app.views.retain(|view| view.id != id);
|
||||
app.active = (!app.views.is_empty()).then_some(0);
|
||||
}
|
||||
Event::Undo | Event::Redo => {
|
||||
let id = self.active()?;
|
||||
let doc = self.h.state().session.get(id).ok_or("document is not open")?;
|
||||
let undo = matches!(event, Event::Undo);
|
||||
if (if undo { doc.can_undo() } else { doc.can_redo() }).is_none() {
|
||||
return Err("history action is unavailable".into());
|
||||
}
|
||||
self.h.state_mut().run_command(if undo { "edit.undo" } else { "edit.redo" });
|
||||
}
|
||||
Event::SaveReopen => {
|
||||
let id = self.active()?;
|
||||
let bytes = self.h.state().session.save_bytes(id).map_err(|e| e.to_string())?;
|
||||
if let Some(path) = &self.artifacts {
|
||||
std::fs::write(path.join("saved.pdf"), bytes.as_slice()).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let mut fresh = Session::new();
|
||||
let reopened = fresh.open("reopened.pdf", None, bytes, None).map_err(|e| e.to_string())?;
|
||||
self.reopened = fresh.get(reopened).map(document_state);
|
||||
}
|
||||
Event::Inspect => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Report engine and draft state after an event, retaining any action error for the runner to assert.
|
||||
fn state(&self, error: Option<String>) -> Value {
|
||||
let app = self.h.state();
|
||||
let doc = app.active_ids().and_then(|(_, id)| app.session.get(id));
|
||||
let original_bytes =
|
||||
doc.is_some_and(|doc| self.originals.iter().any(|(id, bytes)| *id == doc.id && bytes.as_slice() == doc.bytes.as_slice()));
|
||||
let draft = app.props_draft.as_ref().map(|(_, fields)| {
|
||||
let custom: serde_json::Map<String, Value> =
|
||||
app.custom_draft.as_ref().into_iter().flat_map(|(_, d)| d.props.iter().map(|(k, v)| (k.clone(), json!(v)))).collect();
|
||||
json!({
|
||||
"title": fields.first(), "author": fields.get(1), "subject": fields.get(2), "keywords": fields.get(3),
|
||||
"custom": custom, "display_title": app.view_draft.as_ref().map(|(_, v)| v.display_title),
|
||||
})
|
||||
});
|
||||
json!({
|
||||
"error": error, "properties_open": self.properties_open(), "active": doc.map(|d| &d.name),
|
||||
"notice": app.toast.as_ref().map(|(message, _)| message),
|
||||
"document": doc.map(document_state), "draft": draft, "original_bytes": original_bytes,
|
||||
"reopened": self.reopened,
|
||||
})
|
||||
}
|
||||
|
||||
/// Optionally render a local PNG, reporting renderer setup or file errors as failures.
|
||||
fn snapshot(&mut self, step: u64) -> Result<(), String> {
|
||||
let Some(path) = &self.artifacts else { return Ok(()) };
|
||||
// kittest's optional renderer panics when no GPU/software adapter is available.
|
||||
// Report that setup failure without turning it into a successful scenario.
|
||||
let image = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.h.render()))
|
||||
.map_err(|_| "headless renderer failed; check GPU/software-renderer availability".to_owned())??;
|
||||
image.save(path.join(format!("step-{step:03}.png"))).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve bounded protocol-1 JSONL requests in order, returning one state per request until EOF.
|
||||
fn main() -> Result<(), String> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let artifacts = match args.next().as_deref() {
|
||||
None => None,
|
||||
Some("--artifacts") => Some(PathBuf::from(args.next().ok_or("--artifacts needs a directory")?)),
|
||||
_ => return Err("usage: properties_scenarios [--artifacts DIR]".into()),
|
||||
};
|
||||
if args.next().is_some() {
|
||||
return Err("unexpected command-line argument".into());
|
||||
}
|
||||
let mut adapter = Adapter::new(artifacts)?;
|
||||
let mut input = std::io::stdin().lock();
|
||||
let mut output = std::io::stdout().lock();
|
||||
let mut expected_id = 1_u64;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let read = input.by_ref().take(LIMIT + 1).read_line(&mut line).map_err(|e| e.to_string())?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
if read as u64 > LIMIT {
|
||||
return Err("request exceeds 1 MiB".into());
|
||||
}
|
||||
let request: Request = serde_json::from_str(&line).map_err(|e| e.to_string())?;
|
||||
if request.protocol != 1 || request.id != expected_id {
|
||||
return Err("expected protocol 1 and consecutive request IDs starting at 1".into());
|
||||
}
|
||||
let error = adapter.event(request.event).err();
|
||||
// Tabs animate the modal's height and position. Advance a bounded 16 frames
|
||||
// (about 267 ms of simulated time) before inspecting or targeting the next control.
|
||||
adapter.h.run_steps(16);
|
||||
adapter.snapshot(request.id)?;
|
||||
serde_json::to_writer(&mut output, &json!({"protocol": 1, "id": request.id, "state": adapter.state(error)})).map_err(|e| e.to_string())?;
|
||||
writeln!(output).map_err(|e| e.to_string())?;
|
||||
output.flush().map_err(|e| e.to_string())?;
|
||||
expected_id = expected_id.checked_add(1).ok_or("too many requests")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -64,7 +64,7 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct an application with the default session, panels, theme and control state.
|
||||
/// Construct default session, panels, theme and control state with no active property-edit session.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
panes: Default::default(),
|
||||
@@ -211,6 +211,7 @@ impl PdfCraftApp {
|
||||
attachment_description_draft: None,
|
||||
view_draft: None,
|
||||
custom_draft: None,
|
||||
props_session: None,
|
||||
requests: Default::default(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pickers: Default::default(),
|
||||
|
||||
@@ -29,7 +29,7 @@ mod tests;
|
||||
pub(crate) use prompts::{PromptButton, save_prompt_key, shorten_middle};
|
||||
use prompts::{full_screen_prompt, link_prompt, password, remote_link_prompt, save_prompt, unlock};
|
||||
|
||||
const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"];
|
||||
use crate::properties_session::INFO_KEYS;
|
||||
|
||||
/// Drop (and zeroize) password drafts as soon as their owning dialog is inactive.
|
||||
/// Commands and control requests also call this: close/reopen can happen between frames.
|
||||
@@ -117,18 +117,19 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
if app.dialog != Some(Dialog::FieldTable) {
|
||||
app.field_table_draft = None;
|
||||
}
|
||||
if !matches!(app.dialog, Some(Dialog::Properties(_))) {
|
||||
app.discard_properties();
|
||||
}
|
||||
discard_inactive_password_drafts(app);
|
||||
// A movable window, not this modal: the page under it shows the table as it changes.
|
||||
if app.dialog == Some(Dialog::FieldTable) {
|
||||
crate::field_table_ui::show(app, ctx);
|
||||
return;
|
||||
}
|
||||
let Some(dialog) = app.dialog else {
|
||||
app.props_draft = None;
|
||||
app.view_draft = None;
|
||||
app.custom_draft = None;
|
||||
let Some(dialog) = app.dialog else { return };
|
||||
if matches!(dialog, Dialog::Properties(_)) && !app.prepare_properties() {
|
||||
return;
|
||||
};
|
||||
}
|
||||
// Properties ▸ Fonts shows the fonts as they are now, after any content edits.
|
||||
if let (Dialog::Properties(PropsTab::Fonts), Some((_, id))) = (dialog, app.active_ids()) {
|
||||
app.session.refresh_fonts(id);
|
||||
@@ -137,15 +138,6 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
if dialog == Dialog::Properties(PropsTab::SpaceAudit) {
|
||||
app.refresh_space_audit();
|
||||
}
|
||||
// Seed the editable Description fields from the document when the dialog opens.
|
||||
if let (Dialog::Properties(_), Some((_, id))) = (dialog, app.active_ids())
|
||||
&& app.props_draft.as_ref().is_none_or(|(d, _)| *d != id)
|
||||
&& let Some(doc) = app.session.get(id)
|
||||
{
|
||||
app.props_draft = Some((id, INFO_KEYS.map(|k| doc.info_value(k).unwrap_or_default())));
|
||||
app.view_draft = Some((id, doc.initial_view()));
|
||||
app.custom_draft = Some((id, crate::CustomDraft { props: doc.custom_info(), ..Default::default() }));
|
||||
}
|
||||
let mut req = Pending::default();
|
||||
// Every dialog shares this modal. A different dialog than the one shown before starts from
|
||||
// its own size, not from the last one's: rows that fill the height they are offered would
|
||||
@@ -1793,7 +1785,8 @@ fn dialog_pin_tool(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending)
|
||||
/// Draw the active dialog's footer and record any requested action in `req`.
|
||||
fn dialog_footer(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) {
|
||||
ui.add_space(16.0);
|
||||
let changed = draft_changes(app).is_some_and(|c| !c.is_empty());
|
||||
app.stage_properties();
|
||||
let changed = app.properties_changed();
|
||||
// One row high: on its own, a right-to-left layout centres the buttons in all the height
|
||||
// left, which after a taller Properties tab is the room that tab left behind.
|
||||
ui.horizontal(|ui| {
|
||||
@@ -2196,10 +2189,8 @@ fn finish_edits(app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) {
|
||||
if let Some(by) = req.split_now.take() {
|
||||
app.split_active(&by);
|
||||
}
|
||||
if req.apply
|
||||
&& let Some(edits) = draft_changes(app)
|
||||
{
|
||||
app.apply_edit(Edit::Batch { label: "Change document properties".into(), edits });
|
||||
if req.apply && matches!(dialog, Dialog::Properties(_)) {
|
||||
req.close = app.finish_properties();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2218,9 +2209,7 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req:
|
||||
if dialog == Dialog::AttachmentDescription {
|
||||
app.attachment_description_draft = None;
|
||||
}
|
||||
app.props_draft = None;
|
||||
app.view_draft = None;
|
||||
app.custom_draft = None;
|
||||
app.discard_properties();
|
||||
app.bulk_field_props = None;
|
||||
app.unmarked_page = Default::default();
|
||||
} else {
|
||||
@@ -2261,37 +2250,6 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req:
|
||||
}
|
||||
}
|
||||
|
||||
/// Info edits needed to make the document match the Description draft.
|
||||
fn draft_changes(app: &PdfCraftApp) -> Option<Vec<Edit>> {
|
||||
let (id, draft) = app.props_draft.as_ref()?;
|
||||
let doc = app.session.get(*id)?;
|
||||
let mut edits: Vec<Edit> = INFO_KEYS
|
||||
.iter()
|
||||
.zip(draft.iter())
|
||||
.filter(|(k, v)| doc.info_value(k).unwrap_or_default().trim() != v.trim())
|
||||
.map(|(k, v)| Edit::SetInfo { key: (*k).to_string(), value: v.clone() })
|
||||
.collect();
|
||||
if let Some((vid, v)) = &app.view_draft
|
||||
&& vid == id
|
||||
&& *v != doc.initial_view()
|
||||
{
|
||||
edits.push(Edit::SetInitialView(Box::new(v.clone())));
|
||||
}
|
||||
// Custom: deleted properties are cleared, then added and changed ones are set.
|
||||
if let Some((cid, d)) = &app.custom_draft
|
||||
&& cid == id
|
||||
{
|
||||
let before = doc.custom_info();
|
||||
for (k, _) in before.iter().filter(|(k, _)| !d.props.iter().any(|(n, _)| n == k)) {
|
||||
edits.push(Edit::SetInfo { key: k.clone(), value: String::new() });
|
||||
}
|
||||
for (k, v) in d.props.iter().filter(|(k, v)| !before.iter().any(|(n, w)| n == k && w == v)) {
|
||||
edits.push(Edit::SetInfo { key: k.clone(), value: v.clone() });
|
||||
}
|
||||
}
|
||||
Some(edits)
|
||||
}
|
||||
|
||||
fn yes(b: bool) -> String {
|
||||
tl!(if b { "Yes" } else { "No" }).to_string()
|
||||
}
|
||||
|
||||
@@ -1557,6 +1557,12 @@ fn date_languages_are_the_interface_languages() {
|
||||
assert_eq!(dates, interface);
|
||||
}
|
||||
|
||||
/// The stale-draft notice remains discoverable after the UI modules are reorganized.
|
||||
#[test]
|
||||
fn properties_stale_notice_is_in_translation_scan() {
|
||||
assert!(ui_literals().contains("The document changed. Reopen Document Properties to edit the current version."));
|
||||
}
|
||||
|
||||
/// A report, not a check: the notifications pasting an image and pinning a tool show, in each
|
||||
/// language that has no translation for them yet (they show in English).
|
||||
#[test]
|
||||
|
||||
@@ -42,6 +42,7 @@ pub use combine_ui::{Columns as CombineColumns, CombineView, Lock as CombineLock
|
||||
mod commands;
|
||||
pub mod control;
|
||||
mod menus;
|
||||
mod properties_session;
|
||||
/// Header & footer / watermark / background dialog types (tests and automation).
|
||||
pub mod marks {
|
||||
pub use crate::marks_ui::{MarksDraft, PageRange, Subset};
|
||||
@@ -585,6 +586,8 @@ pub struct PdfCraftApp {
|
||||
pub view_draft: Option<(DocId, pdfcraft_engine::InitialView)>,
|
||||
/// Document Properties ▸ Custom being edited.
|
||||
pub custom_draft: Option<(DocId, CustomDraft)>,
|
||||
/// Original values and document revision for the editable Properties tabs.
|
||||
pub(crate) props_session: Option<properties_session::Draft>,
|
||||
/// Files picked asynchronously for combine / insert (web).
|
||||
pub requests: files::Requests,
|
||||
/// Native file pickers in flight (they never block the frame; see `pickers`).
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Document Properties keeps its widget buffers local and commits one revision-bound draft.
|
||||
|
||||
use edit_session::EditSession;
|
||||
use pdfcraft_engine::{DocId, Document, Edit, InitialView};
|
||||
|
||||
use crate::{CustomDraft, PdfCraftApp};
|
||||
|
||||
pub(crate) const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"];
|
||||
|
||||
/// The small, accepted values shared by the Description, Initial View and Custom tabs.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Values {
|
||||
description: [String; 4],
|
||||
view: InitialView,
|
||||
custom: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl PartialEq for Values {
|
||||
/// Compare accepted values using the dialog's whitespace and unordered Custom-row rules.
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
// Preserve the dialog's existing whitespace/no-op rules, without stripping spaces
|
||||
// from the text widgets while the user types. Custom row order is not a PDF edit.
|
||||
self.description.iter().zip(&other.description).all(|(a, b)| a.trim() == b.trim())
|
||||
&& self.view == other.view
|
||||
&& self.custom.len() == other.custom.len()
|
||||
&& self.custom.iter().all(|entry| other.custom.contains(entry))
|
||||
}
|
||||
}
|
||||
|
||||
impl Values {
|
||||
/// Snapshot the editable values; PDF bytes and undo history remain owned by the engine.
|
||||
fn read(doc: &Document) -> Self {
|
||||
Self { description: INFO_KEYS.map(|key| doc.info_value(key).unwrap_or_default()), view: doc.initial_view(), custom: doc.custom_info() }
|
||||
}
|
||||
|
||||
/// Translate an accepted draft into existing engine edits, preserving unrelated PDF metadata.
|
||||
fn edits_to(&self, after: &Self) -> Vec<Edit> {
|
||||
let mut edits: Vec<Edit> = INFO_KEYS
|
||||
.iter()
|
||||
.zip(self.description.iter().zip(&after.description))
|
||||
.filter(|(_, (before, after))| before.trim() != after.trim())
|
||||
.map(|(key, (_, value))| Edit::SetInfo { key: (*key).into(), value: value.clone() })
|
||||
.collect();
|
||||
if self.view != after.view {
|
||||
edits.push(Edit::SetInitialView(Box::new(after.view.clone())));
|
||||
}
|
||||
for (key, _) in self.custom.iter().filter(|(key, _)| !after.custom.iter().any(|(name, _)| name == key)) {
|
||||
edits.push(Edit::SetInfo { key: key.clone(), value: String::new() });
|
||||
}
|
||||
for (key, value) in after.custom.iter().filter(|entry| !self.custom.contains(entry)) {
|
||||
edits.push(Edit::SetInfo { key: key.clone(), value: value.clone() });
|
||||
}
|
||||
edits
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes to permissions, Undo/Redo and in-place reloads invalidate the original draft too.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Context {
|
||||
document: DocId,
|
||||
generation: u64,
|
||||
editable: bool,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
/// Capture the identity, monotonic generation and permission that bind the draft.
|
||||
fn read(doc: &Document) -> Self {
|
||||
Self { document: doc.id, generation: doc.edit_generation(), editable: doc.allows_modification() }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) type Draft = EditSession<Values, Context>;
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// Discard local form buffers and their session, without issuing an engine command.
|
||||
pub(crate) fn discard_properties(&mut self) {
|
||||
self.props_session = None;
|
||||
self.props_draft = None;
|
||||
self.view_draft = None;
|
||||
self.custom_draft = None;
|
||||
}
|
||||
|
||||
/// Bind edits to one document revision. Clean inspection tabs may follow same-document
|
||||
/// edits, preserving the existing live Fonts/Space Audit behavior without rebasing input.
|
||||
pub(crate) fn prepare_properties(&mut self) -> bool {
|
||||
self.stage_properties();
|
||||
let live = self.active_ids().and_then(|(_, id)| self.session.get(id)).map(|doc| (Values::read(doc), Context::read(doc)));
|
||||
let mut stale = match (&self.props_session, &live) {
|
||||
(Some(draft), Some((value, context))) => draft.check_live(value, context).is_err(),
|
||||
(Some(_), None) => true,
|
||||
(None, _) => false,
|
||||
};
|
||||
let clean_inspector = matches!(
|
||||
self.dialog,
|
||||
Some(crate::Dialog::Properties(
|
||||
crate::PropsTab::Fonts | crate::PropsTab::Advanced | crate::PropsTab::SpaceAudit | crate::PropsTab::Security
|
||||
))
|
||||
) && self.props_session.as_ref().is_some_and(|draft| {
|
||||
draft.is_valid()
|
||||
&& draft.original() == draft.preview()
|
||||
&& live.as_ref().is_some_and(|(_, context)| context.document == draft.context().document)
|
||||
}) && self.custom_draft.as_ref().is_some_and(|(_, custom)| custom.name.is_empty() && custom.value.is_empty());
|
||||
if stale && clean_inspector {
|
||||
self.discard_properties();
|
||||
stale = false;
|
||||
}
|
||||
if stale || live.is_none() {
|
||||
self.discard_properties();
|
||||
self.dialog = None;
|
||||
if stale {
|
||||
self.notify(tl!("The document changed. Reopen Document Properties to edit the current version.").to_string());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if self.props_session.is_none()
|
||||
&& let Some((values, context)) = live
|
||||
{
|
||||
self.props_draft = Some((context.document, values.description.clone()));
|
||||
self.view_draft = Some((context.document, values.view.clone()));
|
||||
self.custom_draft = Some((context.document, CustomDraft { props: values.custom.clone(), ..Default::default() }));
|
||||
self.props_session = Some(EditSession::new(values, context));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Stage widget values after their existing per-control validation. The Custom tab's
|
||||
/// Name/Value input boxes are not accepted properties until Add or Update is pressed.
|
||||
pub(crate) fn stage_properties(&mut self) {
|
||||
let expected = self.props_session.as_ref().map(|draft| draft.context().document);
|
||||
let proposal = match (&self.props_draft, &self.view_draft, &self.custom_draft) {
|
||||
(Some((id, description)), Some((vid, view)), Some((cid, custom))) if Some(*id) == expected && id == vid && id == cid => {
|
||||
Ok(Values { description: description.clone(), view: view.clone(), custom: custom.props.clone() })
|
||||
}
|
||||
_ => Err(()),
|
||||
};
|
||||
if let Some(draft) = &mut self.props_session {
|
||||
let _ = draft.propose(proposal);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a valid proposal needs OK/Cancel instead of the unchanged dialog's Close button.
|
||||
pub(crate) fn properties_changed(&self) -> bool {
|
||||
self.props_session.as_ref().is_some_and(|draft| draft.is_valid() && draft.original() != draft.preview())
|
||||
}
|
||||
|
||||
/// Check and consume once on the serialized UI command path. An engine failure retains
|
||||
/// the widget values and restores the uncommitted session, so the user can correct them.
|
||||
pub(crate) fn finish_properties(&mut self) -> bool {
|
||||
if !self.prepare_properties() {
|
||||
return true;
|
||||
}
|
||||
self.stage_properties();
|
||||
let Some((live, context)) = self.active_ids().and_then(|(_, id)| self.session.get(id)).map(|doc| (Values::read(doc), Context::read(doc)))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if self.props_session.as_ref().is_none_or(|draft| draft.check(&live, &context).is_err()) {
|
||||
return false;
|
||||
}
|
||||
let Some(draft) = self.props_session.take() else { return false };
|
||||
match draft.finish(&live, &context) {
|
||||
Ok(Some(change)) => {
|
||||
let edits = change.before.edits_to(&change.after);
|
||||
if self.apply_edit(Edit::Batch { label: "Change document properties".into(), edits }) {
|
||||
true
|
||||
} else {
|
||||
// Session::apply is atomic on error; do not rebase to any newer document.
|
||||
let mut retry = EditSession::new(change.before, context);
|
||||
let _ = retry.propose::<()>(Ok(change.after));
|
||||
self.props_session = Some(retry);
|
||||
false
|
||||
}
|
||||
}
|
||||
Ok(None) => true,
|
||||
Err(_) => {
|
||||
self.discard_properties();
|
||||
self.notify(tl!("The document changed. Reopen Document Properties to edit the current version.").to_string());
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Create a one-page document with a pending title edit for direct commit-path regressions.
|
||||
fn app() -> PdfCraftApp {
|
||||
let mut app = PdfCraftApp::new();
|
||||
let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap();
|
||||
app.open_bytes("properties.pdf", None, bytes.as_ref().clone()).unwrap();
|
||||
app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Description));
|
||||
assert!(app.prepare_properties());
|
||||
app.props_draft.as_mut().unwrap().1[0] = "Draft title".into();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A concurrent edit must be rejected at commit even before another UI frame checks freshness.
|
||||
fn commit_rechecks_revision_without_an_intervening_frame() {
|
||||
let mut app = app();
|
||||
let (_, id) = app.active_ids().unwrap();
|
||||
app.session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Newer title".into() }).unwrap();
|
||||
let generation = app.session.get(id).unwrap().edit_generation();
|
||||
assert!(app.finish_properties());
|
||||
let doc = app.session.get(id).unwrap();
|
||||
assert_eq!(doc.info_value("Title").as_deref(), Some("Newer title"));
|
||||
assert_eq!(doc.edit_generation(), generation);
|
||||
assert!(app.props_session.is_none());
|
||||
assert!(app.props_draft.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// An invalid view must roll back the title too, retain input, and permit one corrected undo step.
|
||||
fn engine_refusal_retains_draft_and_retry_is_one_atomic_edit() {
|
||||
let mut app = app();
|
||||
let (_, id) = app.active_ids().unwrap();
|
||||
let original_bytes = app.session.get(id).unwrap().bytes.clone();
|
||||
let generation = app.session.get(id).unwrap().edit_generation();
|
||||
// The public control buffers can receive an invalid page even though the widget
|
||||
// bounds its input. The engine rejects the entire title + view batch atomically.
|
||||
app.view_draft.as_mut().unwrap().1.page = usize::MAX;
|
||||
assert!(!app.finish_properties());
|
||||
let doc = app.session.get(id).unwrap();
|
||||
assert_eq!(doc.bytes, original_bytes);
|
||||
assert_eq!(doc.info_value("Title"), None);
|
||||
assert_eq!(doc.edit_generation(), generation);
|
||||
assert!(!doc.dirty && doc.can_undo().is_none());
|
||||
assert_eq!(app.props_draft.as_ref().unwrap().1[0], "Draft title");
|
||||
assert!(app.props_session.is_some());
|
||||
|
||||
app.view_draft.as_mut().unwrap().1.page = 0;
|
||||
assert!(app.finish_properties());
|
||||
assert_eq!(app.session.get(id).unwrap().info_value("Title").as_deref(), Some("Draft title"));
|
||||
assert_eq!(app.session.get(id).unwrap().can_undo(), Some("Change document properties"));
|
||||
app.session.undo(id).unwrap();
|
||||
assert_eq!(app.session.get(id).unwrap().info_value("Title"), None);
|
||||
assert!(app.session.get(id).unwrap().can_undo().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Matching form-buffer identities must also belong to the session's original document.
|
||||
fn buffers_from_another_document_cannot_commit() {
|
||||
let mut app = app();
|
||||
let (_, id) = app.active_ids().unwrap();
|
||||
let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap();
|
||||
let other = app.session.open("other.pdf", None, bytes, None).unwrap();
|
||||
app.props_draft.as_mut().unwrap().0 = other;
|
||||
app.view_draft.as_mut().unwrap().0 = other;
|
||||
app.custom_draft.as_mut().unwrap().0 = other;
|
||||
assert!(!app.finish_properties());
|
||||
for doc_id in [id, other] {
|
||||
let doc = app.session.get(doc_id).unwrap();
|
||||
assert_eq!(doc.info_value("Title"), None);
|
||||
assert!(!doc.dirty && doc.can_undo().is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,7 @@ mod prepare;
|
||||
mod print;
|
||||
mod print_custom_paper_names;
|
||||
mod print_late_printer_list;
|
||||
mod properties_session;
|
||||
mod protect;
|
||||
mod reading_colors;
|
||||
mod recent_files;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Properties drafts belong to the document revision on which the user opened them.
|
||||
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use pdfcraft_engine::Edit;
|
||||
use pdfcraft_ui_egui::PdfCraftApp;
|
||||
|
||||
/// Open the real Properties dialog and type a title that has not entered document history.
|
||||
fn harness() -> Harness<'static, PdfCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_eframe(|_| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap();
|
||||
app.open_bytes("properties.pdf", None, bytes.as_ref().clone()).unwrap();
|
||||
app.set_option("dialog", "properties").unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
h.get_by_role_and_label(Role::TextInput, "Title").focus();
|
||||
h.get_by_role_and_label(Role::TextInput, "Title").type_text("Old draft");
|
||||
h.run_steps(2);
|
||||
h
|
||||
}
|
||||
|
||||
/// Reproduce the baseline stale commit; a guarded dialog has already dismissed itself.
|
||||
fn confirm_if_still_open(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
// On the baseline this commits the stale text. A guarded dialog has already closed.
|
||||
if h.state().dialog.is_some() {
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A pending title must never replace metadata written by a later document edit.
|
||||
fn properties_session_does_not_overwrite_a_newer_document_edit() {
|
||||
let mut h = harness();
|
||||
let id = h.state().views[0].id;
|
||||
h.state_mut().session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Newer title".into() }).unwrap();
|
||||
let generation = h.state().session.get(id).unwrap().edit_generation();
|
||||
h.run_steps(2);
|
||||
confirm_if_still_open(&mut h);
|
||||
let doc = h.state().session.get(id).unwrap();
|
||||
assert_eq!(doc.info_value("Title").as_deref(), Some("Newer title"));
|
||||
assert_eq!(doc.edit_generation(), generation, "stale confirmation issues no edit");
|
||||
assert!(h.state().dialog.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Returning to the original value via Undo must still invalidate the draft and preserve Redo.
|
||||
fn properties_session_rejects_undo_back_to_the_original_value() {
|
||||
let mut h = harness();
|
||||
let id = h.state().views[0].id;
|
||||
h.state_mut().session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Temporary".into() }).unwrap();
|
||||
h.state_mut().session.undo(id).unwrap();
|
||||
let generation = h.state().session.get(id).unwrap().edit_generation();
|
||||
h.run_steps(2);
|
||||
confirm_if_still_open(&mut h);
|
||||
let doc = h.state().session.get(id).unwrap();
|
||||
assert_eq!(doc.info_value("Title"), None);
|
||||
assert_eq!(doc.edit_generation(), generation);
|
||||
assert!(doc.can_redo().is_some(), "stale confirmation preserves Redo");
|
||||
assert!(h.state().dialog.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Inspection refresh must not rebase accepted edits or Custom input awaiting Add.
|
||||
fn properties_session_inspection_tabs_do_not_rebase_pending_input() {
|
||||
for unaccepted_custom in [false, true] {
|
||||
let mut h = harness();
|
||||
let id = h.state().views[0].id;
|
||||
if unaccepted_custom {
|
||||
// Name/Value are typed input, even before Add has accepted a property.
|
||||
let app = h.state_mut();
|
||||
app.props_draft.as_mut().unwrap().1[0].clear();
|
||||
let custom = &mut app.custom_draft.as_mut().unwrap().1;
|
||||
custom.name = "Project".into();
|
||||
custom.value = "Unaccepted".into();
|
||||
}
|
||||
h.state_mut().set_option("dialog", "space-audit").unwrap();
|
||||
h.run_steps(3);
|
||||
h.state_mut().session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Newer title".into() }).unwrap();
|
||||
let generation = h.state().session.get(id).unwrap().edit_generation();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().dialog.is_none());
|
||||
assert!(h.state().props_draft.is_none());
|
||||
let doc = h.state().session.get(id).unwrap();
|
||||
assert_eq!(doc.info_value("Title").as_deref(), Some("Newer title"));
|
||||
assert_eq!(doc.edit_generation(), generation);
|
||||
assert!(doc.custom_info().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A clean inspection tab keeps the dialog open and follows a same-document edit.
|
||||
fn properties_session_clean_inspection_tab_follows_a_newer_edit() {
|
||||
let mut h = harness();
|
||||
let id = h.state().views[0].id;
|
||||
// No pending input: the typed title is cleared again before switching tabs.
|
||||
h.state_mut().props_draft.as_mut().unwrap().1[0].clear();
|
||||
h.state_mut().set_option("dialog", "space-audit").unwrap();
|
||||
h.run_steps(3);
|
||||
h.state_mut().session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Newer title".into() }).unwrap();
|
||||
let generation = h.state().session.get(id).unwrap().edit_generation();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().dialog.is_some(), "a clean inspection tab stays open");
|
||||
assert_eq!(h.state().props_draft.as_ref().unwrap().1[0], "Newer title", "the draft is reseeded from the newer revision");
|
||||
assert_eq!(h.state().session.get(id).unwrap().edit_generation(), generation, "refreshing issues no edit");
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
# Document Properties sessions and interaction replay
|
||||
|
||||
Status: proposed integration, updated 2026-11-10.
|
||||
|
||||
Document Properties binds its Description, Initial View and accepted Custom values to
|
||||
the document identity, edit generation and modification permission at opening. A
|
||||
different document, reload, edit or Undo/Redo closes the stale dialog with a notice.
|
||||
Returning to the original values through Undo still invalidates the old draft.
|
||||
The notice is included in the translation-gap scan; catalogs without its translation
|
||||
retain the standard English fallback.
|
||||
Clean inspection tabs (Fonts, Advanced, Security, Space Audit) retain live refresh for
|
||||
the same document; they never rebase accepted edits or pending Custom Name/Value input.
|
||||
|
||||
[`edit-session`](https://github.com/PerchAnton/edit-session) 0.1.0-alpha.2 is a
|
||||
dependency-free `no_std` library. The [vendor ledger](../vendor/README.md#edit-session) records the snapshot and its source revision.
|
||||
It checks freshness and consumes a draft once. PdfCraft retains its form validation,
|
||||
permission checks, PDF serialization and undo history. The UI bridge is
|
||||
`crates/ui-egui/src/properties_session.rs`; it stores small property values, not PDF bytes.
|
||||
|
||||
Typing does not edit the document. OK rechecks freshness on the serialized UI command
|
||||
path and dispatches one existing `Edit::Batch`. An engine refusal leaves the draft open
|
||||
for correction without applying part of the batch. Cancel/Escape, a no-op and stale
|
||||
dismissal dispatch no edit. Description whitespace and Custom name validation retain
|
||||
their existing behavior; Custom Name/Value input becomes part of the draft only through
|
||||
Add/Update. No new Reset control or metadata field is introduced.
|
||||
|
||||
The same document changes remain available through `Session::apply`, `doc_set_info`
|
||||
and `doc_initial_view`. The existing opt-in `ui.set`, `ui.type`, `ui.click` and `ui.key`
|
||||
control channel reaches the dialog. This patch adds no server or automation permission.
|
||||
It covers Document Properties only; the broader dialog lifecycle issue #3917 and OS
|
||||
open queueing are separate work.
|
||||
|
||||
## Replaying the real dialog
|
||||
|
||||
The consumer owns `crates/ui-egui/examples/properties_scenarios.rs`, built in PdfCraft's
|
||||
workspace so it uses the same vendored patches and UI as the app. It is a development
|
||||
example, not a shipped executable. It runs the real egui controls through `egui_kittest`,
|
||||
then reports actual draft/document/history state as JSONL protocol 1.
|
||||
|
||||
[`interaction-check`](https://github.com/PerchAnton/interaction-check) is a standard-library
|
||||
Python runner for optional local replay. The commands below pin it to `7722d66c0ede7855ff58d1df3d1632fd4e5e66a5`. It sends only
|
||||
events; expected values stay in the runner. Each scenario starts a fresh adapter process
|
||||
with two synthetic, two-page PDFs. No corpus, network, proprietary fonts or committed
|
||||
PDF fixture is required.
|
||||
|
||||
From this repository, with Python 3.10+ and Rust 1.99.1:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/PerchAnton/interaction-check ../interaction-check
|
||||
git -C ../interaction-check checkout 7722d66c0ede7855ff58d1df3d1632fd4e5e66a5
|
||||
cargo build --locked -j 2 -p pdfcraft-ui-egui --example properties_scenarios
|
||||
mkdir -p log/properties-interactions
|
||||
PDFCRAFT_SYSTEM_FONTS=0 PDFCRAFT_LOCALE=en-US \
|
||||
python3 ../interaction-check/interaction_check.py --timeout 30 \
|
||||
--report log/properties-interactions/report.json \
|
||||
--junit log/properties-interactions/junit.xml \
|
||||
tests/interactions/document-properties/*.json -- target/debug/examples/properties_scenarios
|
||||
```
|
||||
|
||||
On Windows, use `properties_scenarios.exe` and set environment variables with your shell.
|
||||
The commands write JSON and JUnit reports under `log/properties-interactions/`. Replay runs headlessly.
|
||||
|
||||
Events `open`, `type`, `click`, `escape`, `undo` and `redo` use actual UI commands/controls.
|
||||
`external_edit`, `external_edit_undo`, `reload`, `switch` and `close_document` simulate
|
||||
changes arriving outside that modal via the real session or tab selection. `save_reopen`
|
||||
serializes with the engine and opens the resulting bytes in a fresh session. `inspect`
|
||||
only advances frames and reports state. Document numbers are 1-based. Unsupported or
|
||||
disabled controls return a state error; the scenarios assert the resulting state after each action.
|
||||
Sixteen simulated frames after each event let the modal's animated layout settle.
|
||||
|
||||
Ten scenarios cover cross-tab commit/save/reopen, one-step Undo/Redo, Cancel/Escape,
|
||||
no-op with Redo, intervening edits, edit-then-Undo, same-ID reload, tab switch, target
|
||||
close, Custom validation and live inspection without rebasing pending input. Rust tests additionally cover an engine refusal/retry and
|
||||
the freshness check immediately before commit without an intervening UI frame.
|
||||
|
||||
For optional local screenshots and `saved.pdf`, append `--artifacts DIR` after the
|
||||
adapter executable while running **one** scenario. This requires an available GPU or
|
||||
software renderer. Keep `PDFCRAFT_SYSTEM_FONTS=0`; generated artifacts stay under ignored
|
||||
`log/`. The headless UI proves widget/engine behavior, not native OS input or PDF fidelity
|
||||
in a separate reader. Existing renderer and interoperability tests remain necessary.
|
||||
|
||||
## Dependency review
|
||||
|
||||
The app builds from the vendored Rust source. The shared Cargo source rules apply to the entire lockfile.
|
||||
The ordinary Rust suite exercises stale edits, Undo and inspection tabs. Running the extended replay is
|
||||
an explicit developer choice. It uses a separate checkout and runs outside the app and its shared CI.
|
||||
Maintainers can review the snapshot or choose to implement the small draft guard in PdfCraft itself.
|
||||
This integration can be evaluated independently of craft-common-actions #84.
|
||||
|
||||
## Revision history
|
||||
|
||||
- 2026-11-10: reconcile current main's roadmap header; preserve both inactive password cleanup and the Properties draft guard. The vendored library and local replay contract are unchanged. Retain both new upstream print test modules alongside the Properties-session tests.
|
||||
- 2026-11-09: reconcile current main, preserving the vendor snapshot, local replay, both projects' license notices and the Properties-session and current upstream test modules.
|
||||
|
||||
- 2026-11-05: vendor the original Rust revision with its lifecycle tests and licenses; keep the ten-scenario Python replay local.
|
||||
|
||||
- 2026-10-25: document the pending dependency decisions and shared release prerequisite;
|
||||
add function documentation for the session bridge, adapter and regression tests,
|
||||
and cover discovery of the stale notice by the translation scanner.
|
||||
- 2026-10-25: initial Document Properties integration and real UI replay adapter.
|
||||
+5
-2
@@ -245,7 +245,7 @@ total matches the parity estimate.
|
||||
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. A custom text match that ends on a reconstructed separator stops before the next glyph (#1315). Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
|
||||
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Literal page-label prefix whitespace is preserved; nine public-inspection cases cover prefix spaces, encodings and empty-prefix controls |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). |
|
||||
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Ctrl-drag (Option on macOS) on a grid thumbnail copies the pages into the drop gap, with a "+" badge and the copies selected (#2997). Initial-view window and interface options resolve indirect boolean preferences. Done since: #3309: Stack-safe named-destination pruning visits shared/cyclic indirect nodes once, without a name-count limit. Synthetic deep-tree and 60,000-name tests verify deletion, surviving targets, undo/redo, both save modes and reopening. Organize drags cancel after document edits and Undo/Redo (#3916 partial). Missing: page transitions, recovery on the web |
|
||||
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Ctrl-drag (Option on macOS) on a grid thumbnail copies the pages into the drop gap, with a "+" badge and the copies selected (#2997). Initial-view window and interface options resolve indirect boolean preferences. Done since: #3309: Stack-safe named-destination pruning visits shared/cyclic indirect nodes once, without a name-count limit. Synthetic deep-tree and 60,000-name tests verify deletion, surviving targets, undo/redo, both save modes and reopening. Organize drags cancel after document edits and Undo/Redo (#3916 partial). Document Properties dismisses stale drafts after edits, reloads or tab changes; JSONL replay covers commit, cancellation and save/reopen. Missing: page transitions, recovery on the web. Maintainers are reviewing the vendored Document Properties runtime and the optional local scenario replay. |
|
||||
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Rotation queries and edits resolve indirect values on pages and ancestors. Refreshed against upstream `99941daa` (2026-10-15). Repeated page indices accumulate rotations from the latest local value (#1919); saved/reopened regressions retain inherited angles and siblings. Missing: page transitions, recovery on the web |
|
||||
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Rotation queries and edits resolve indirect values on pages and ancestors. Missing: page transitions, recovery on the web |
|
||||
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. |
|
||||
@@ -325,6 +325,8 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
|
||||
|
||||
## Revision history
|
||||
|
||||
**2026-11-09 · patch:** M4 #4594: reconcile upstream license notices, roadmap entries and UI test modules without changing the Document Properties draft guard or dependency policy. Estimates unchanged.
|
||||
|
||||
**2026-10-27 · minor:** M10 #5005: vector printing preserves annotation optional-content membership.
|
||||
Source resources and shared appearances remain unchanged.
|
||||
Estimates unchanged.
|
||||
@@ -337,7 +339,7 @@ Estimates unchanged.
|
||||
**2026-10-31 · minor:** M3/M4 #5330: direct quick-toolbar Crop pages action reuses command permissions and the existing crop tool. Estimates unchanged.
|
||||
|
||||
**2026-10-26 · patch:** M2/PR #4172: reconcile current main and preserve upstream Japanese serif/weight selection alongside the installed CJK viewer and preview fallback. Estimates unchanged.
|
||||
|
||||
- **2026-10-25 · minor:** Document Properties drafts now reject changed document identities/revisions, including reload and Undo/Redo, through pinned edit-session; real UI JSONL scenarios replay with interaction-check. Broader dialog invalidation remains separate (#3917); estimates unchanged.
|
||||
**2026-10-25 · minor:** M14 localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Estimates unchanged.
|
||||
|
||||
**2026-10-23 · minor:** M11: image downsampling uses physical resolution after page-local `/UserUnit` scaling, including shared nested forms.
|
||||
@@ -381,6 +383,7 @@ Estimates unchanged.
|
||||
| 2026-10-29 | minor | M7 #2517: widening a paragraph rejoins a wrap with the spaces that wrap consumed, including a typed run and none for a Chinese or Japanese word split, after an incremental save and a full rewrite. Estimates unchanged. |
|
||||
| 2026-10-29 | minor | M2: non-interpolated images drawn below their resolution are area-averaged to whole device pixels instead of nearest-neighbour (scanned text kept one sample in N per axis); page-scan-size images are smoothed when enlarged. Estimates unchanged. |
|
||||
| 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. |
|
||||
| 2026-10-25 | minor | M4, #4594 review: function documentation, stale-notice translation scanning and explicit dependency-policy/shared-release prerequisites; draft behavior and estimates unchanged. |
|
||||
| 2026-10-20 | minor | M9 #79 review, M2, M8: /ActualText that marks no glyph extracts where it paints, as far as it is visible; redaction strips /ActualText around paint it clips; named-layer recognition needs the layer form to draw n1 and n3. Estimates unchanged. |
|
||||
| 2026-10-25 | minor | M10: vector printing resolves annotation rectangle coordinates; synthetic regression evidence, estimates unchanged. |
|
||||
| 2026-10-26 | minor | M10: annotation rectangles, coordinates and appearance-state names follow up to 64 indirect references with cycle detection; standalone-object save/reopen and malformed-chain regressions, estimates unchanged. |
|
||||
|
||||
@@ -3550,8 +3550,8 @@ milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = ["file.properties"]
|
||||
tools = ["doc_info"]
|
||||
evidence = ["crates/ui-egui/tests/it/editing.rs::document_properties_edit_is_one_undoable_step", "crates/ui-egui/tests/it/editing.rs::cancelling_properties_discards_the_draft", "crates/organize/src/tests.rs::document_info_falls_back_to_the_xmp_packet", "crates/engine/src/tests.rs::document_information_stated_only_in_xmp_is_shown", "crates/ui-egui/tests/it/dialog_chrome.rs::document_properties_values_line_up_and_long_fields_wrap", "crates/ui-egui/tests/it/dialog_chrome.rs::space_audit_tab_measures_the_file_and_stays_in_the_card"]
|
||||
notes = "Description, Initial View, Security, Fonts, Custom (organize.custom-properties) and Advanced, plus a Space Audit tab Acrobat does not have (its audit is only in the PDF Optimizer); the audit runs when the tab opens and again after an edit. Title, Author, Subject and Keywords (and Creator, Producer in doc_info) come from /Info, else from the XMP packet (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) for files that state them only there; edits write both. Missing: a UTF-16 XMP packet isn't read, and /Info kept only in an older revision's trailer isn't looked up"
|
||||
evidence = ["crates/ui-egui/tests/it/properties_session.rs::properties_session_does_not_overwrite_a_newer_document_edit", "crates/ui-egui/tests/it/properties_session.rs::properties_session_rejects_undo_back_to_the_original_value", "crates/ui-egui/tests/it/editing.rs::document_properties_edit_is_one_undoable_step", "crates/ui-egui/tests/it/editing.rs::cancelling_properties_discards_the_draft", "crates/organize/src/tests.rs::document_info_falls_back_to_the_xmp_packet", "crates/engine/src/tests.rs::document_information_stated_only_in_xmp_is_shown", "crates/ui-egui/tests/it/dialog_chrome.rs::document_properties_values_line_up_and_long_fields_wrap", "crates/ui-egui/tests/it/dialog_chrome.rs::space_audit_tab_measures_the_file_and_stays_in_the_card"]
|
||||
notes = "Document Properties binds its draft to document identity, generation and permissions; newer edits, Undo/Redo and reload dismiss stale values before commit. Other dialogs are outside this guard. Description, Initial View, Security, Fonts, Custom (organize.custom-properties) and Advanced, plus a Space Audit tab Acrobat does not have (its audit is only in the PDF Optimizer); the audit runs when the tab opens and again after an edit. Title, Author, Subject and Keywords (and Creator, Producer in doc_info) come from /Info, else from the XMP packet (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) for files that state them only there; edits write both. Missing: a UTF-16 XMP packet isn't read, and /Info kept only in an older revision's trailer isn't looked up"
|
||||
|
||||
[[feature]]
|
||||
id = "organize.edit-metadata"
|
||||
@@ -3562,8 +3562,8 @@ milestone = "M4"
|
||||
status = "shipped"
|
||||
commands = ["file.properties"]
|
||||
tools = ["doc_set_info"]
|
||||
evidence = ["crates/organize/src/tests.rs::document_info_edits_round_trip_with_unicode", "crates/organize/src/tests.rs::document_info_edits_update_the_xmp_packet", "crates/ui-egui/tests/it/editing.rs::document_properties_edit_is_one_undoable_step"]
|
||||
notes = "When the file has an XMP packet, the matching property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed or removed with the /Info entry; a packet that isn't UTF-8 is left as it was. Not yet: xmp:ModifyDate and xmp:MetadataDate on save, other languages of a title kept apart (the new value replaces them)."
|
||||
evidence = ["crates/ui-egui/tests/it/properties_session.rs::properties_session_does_not_overwrite_a_newer_document_edit", "crates/ui-egui/tests/it/properties_session.rs::properties_session_rejects_undo_back_to_the_original_value", "crates/organize/src/tests.rs::document_info_edits_round_trip_with_unicode", "crates/organize/src/tests.rs::document_info_edits_update_the_xmp_packet", "crates/ui-egui/tests/it/editing.rs::document_properties_edit_is_one_undoable_step"]
|
||||
notes = "Document Properties binds its draft to document identity, generation and permissions; newer edits, Undo/Redo and reload dismiss stale values before commit. Other dialogs are outside this guard. When the file has an XMP packet, the matching property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed or removed with the /Info entry; a packet that isn't UTF-8 is left as it was. Not yet: xmp:ModifyDate and xmp:MetadataDate on save, other languages of a title kept apart (the new value replaces them)."
|
||||
|
||||
[[feature]]
|
||||
id = "organize.fonts-list"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"name": "Document Properties: cancel-and-escape",
|
||||
"steps": [
|
||||
{
|
||||
"event": {"op": "open"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "type", "field": "Title", "value": "Discard by Cancel"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "click", "label": "Cancel"},
|
||||
"expect": {"error": null, "properties_open": false, "draft": null, "document": {"title": "Original", "author": "Original author", "custom": {"Untouched": "Keep me"}, "display_title": false, "dirty": false, "undo": null, "redo": null, "pages": 2, "custom_count": 1}, "original_bytes": true}
|
||||
},
|
||||
{
|
||||
"event": {"op": "open"},
|
||||
"expect": {"error": null, "draft": {"title": "Original"}}
|
||||
},
|
||||
{
|
||||
"event": {"op": "type", "field": "Author", "value": "Discard by Escape"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "escape"},
|
||||
"expect": {"error": null, "properties_open": false, "draft": null, "document": {"title": "Original", "author": "Original author", "custom": {"Untouched": "Keep me"}, "display_title": false, "dirty": false, "undo": null, "redo": null, "pages": 2, "custom_count": 1}, "original_bytes": true}
|
||||
},
|
||||
{
|
||||
"event": {"op": "open"},
|
||||
"expect": {"error": null, "draft": {"author": "Original author"}}
|
||||
},
|
||||
{
|
||||
"event": {"op": "click", "label": "Close"},
|
||||
"expect": {"error": null, "properties_open": false, "draft": null, "document": {"title": "Original", "author": "Original author", "custom": {"Untouched": "Keep me"}, "display_title": false, "dirty": false, "undo": null, "redo": null, "pages": 2, "custom_count": 1}, "original_bytes": true}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"name": "Document Properties: close-document",
|
||||
"steps": [
|
||||
{
|
||||
"event": {"op": "open"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "type", "field": "Title", "value": "Stale"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "close_document"},
|
||||
"expect": {"error": null, "properties_open": false, "draft": null, "active": "B.pdf", "document": {"title": "Second document", "dirty": false, "undo": null, "redo": null}, "original_bytes": true, "notice": "The document changed. Reopen Document Properties to edit the current version."}
|
||||
},
|
||||
{
|
||||
"event": {"op": "open"},
|
||||
"expect": {"error": null, "draft": {"title": "Second document"}}
|
||||
},
|
||||
{
|
||||
"event": {"op": "click", "label": "Close"},
|
||||
"expect": {"error": null}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"name": "Document Properties: commit-save-undo",
|
||||
"steps": [
|
||||
{
|
||||
"event": {"op": "open"},
|
||||
"expect": {"error": null, "properties_open": true, "document": {"title": "Original", "author": "Original author", "custom": {"Untouched": "Keep me"}, "display_title": false, "dirty": false, "undo": null, "redo": null, "pages": 2, "custom_count": 1}, "original_bytes": true}
|
||||
},
|
||||
{
|
||||
"event": {"op": "type", "field": "Title", "value": "Preview 1"},
|
||||
"expect": {"error": null, "draft": {"title": "Preview 1"}, "document": {"title": "Original", "author": "Original author", "custom": {"Untouched": "Keep me"}, "display_title": false, "dirty": false, "undo": null, "redo": null, "pages": 2, "custom_count": 1}, "original_bytes": true}
|
||||
},
|
||||
{
|
||||
"event": {"op": "type", "field": "Title", "value": "Published title"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "type", "field": "Author", "value": "Review team"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "click", "label": "Custom"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "type", "field": "Name:", "value": "Project"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "type", "field": "Value:", "value": "PdfCraft"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "click", "label": "Add"},
|
||||
"expect": {"error": null, "draft": {"custom": {"Project": "PdfCraft", "Untouched": "Keep me"}}, "document": {"title": "Original", "author": "Original author", "custom": {"Untouched": "Keep me"}, "display_title": false, "dirty": false, "undo": null, "redo": null, "pages": 2, "custom_count": 1}, "original_bytes": true}
|
||||
},
|
||||
{
|
||||
"event": {"op": "click", "label": "Initial View"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "click", "label": "Document Title"},
|
||||
"expect": {"error": null, "draft": {"display_title": true}, "document": {"title": "Original", "author": "Original author", "custom": {"Untouched": "Keep me"}, "display_title": false, "dirty": false, "undo": null, "redo": null, "pages": 2, "custom_count": 1}, "original_bytes": true}
|
||||
},
|
||||
{
|
||||
"event": {"op": "click", "label": "OK"},
|
||||
"expect": {"error": null, "properties_open": false, "draft": null, "document": {"title": "Published title", "author": "Review team", "custom": {"Project": "PdfCraft", "Untouched": "Keep me"}, "display_title": true, "dirty": true, "undo": "Change document properties", "redo": null, "custom_count": 2}}
|
||||
},
|
||||
{
|
||||
"event": {"op": "undo"},
|
||||
"expect": {"error": null, "document": {"title": "Original", "author": "Original author", "custom": {"Untouched": "Keep me"}, "display_title": false, "undo": null, "redo": "Change document properties", "custom_count": 1}}
|
||||
},
|
||||
{
|
||||
"event": {"op": "redo"},
|
||||
"expect": {"error": null, "document": {"title": "Published title", "author": "Review team", "display_title": true, "undo": "Change document properties", "redo": null}}
|
||||
},
|
||||
{
|
||||
"event": {"op": "save_reopen"},
|
||||
"expect": {"error": null, "reopened": {"title": "Published title", "author": "Review team", "custom": {"Project": "PdfCraft", "Untouched": "Keep me"}, "display_title": true, "dirty": false, "pages": 2, "undo": null, "redo": null, "custom_count": 2}}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"name": "Document Properties: custom-validation",
|
||||
"steps": [
|
||||
{
|
||||
"event": {"op": "open"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "click", "label": "Custom"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "type", "field": "Name:", "value": "Title"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "type", "field": "Value:", "value": "Not an accepted property"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "click", "label": "Add"},
|
||||
"expect": {"error": "ambiguous or disabled action: Add", "properties_open": true, "document": {"title": "Original", "author": "Original author", "custom": {"Untouched": "Keep me"}, "display_title": false, "dirty": false, "undo": null, "redo": null, "pages": 2, "custom_count": 1}, "original_bytes": true}
|
||||
},
|
||||
{
|
||||
"event": {"op": "type", "field": "Name:", "value": "Project"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "type", "field": "Value:", "value": "Accepted"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "click", "label": "Add"},
|
||||
"expect": {"error": null, "draft": {"custom": {"Untouched": "Keep me", "Project": "Accepted"}}, "document": {"title": "Original", "author": "Original author", "custom": {"Untouched": "Keep me"}, "display_title": false, "dirty": false, "undo": null, "redo": null, "pages": 2, "custom_count": 1}, "original_bytes": true}
|
||||
},
|
||||
{
|
||||
"event": {"op": "click", "label": "OK"},
|
||||
"expect": {"error": null, "document": {"title": "Original", "custom": {"Untouched": "Keep me", "Project": "Accepted"}, "undo": "Change document properties", "custom_count": 2}}
|
||||
},
|
||||
{
|
||||
"event": {"op": "save_reopen"},
|
||||
"expect": {"error": null, "reopened": {"title": "Original", "custom": {"Untouched": "Keep me", "Project": "Accepted"}, "pages": 2, "custom_count": 2}}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"name": "Document Properties: inspection tabs refresh only without pending input",
|
||||
"steps": [
|
||||
{
|
||||
"event": {"op": "open"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "click", "label": "Space Audit"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "external_edit", "key": "Title", "value": "Updated"},
|
||||
"expect": {"error": null, "properties_open": true, "draft": {"title": "Updated"}, "document": {"title": "Updated", "undo": "Change Title"}}
|
||||
},
|
||||
{
|
||||
"event": {"op": "click", "label": "Description"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "type", "field": "Title", "value": "Pending"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "click", "label": "Space Audit"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "external_edit", "key": "Subject", "value": "New subject"},
|
||||
"expect": {"error": null, "properties_open": false, "draft": null, "notice": "The document changed. Reopen Document Properties to edit the current version.", "document": {"title": "Updated", "subject": "New subject", "undo": "Change Subject"}}
|
||||
},
|
||||
{
|
||||
"event": {"op": "open"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "click", "label": "Custom"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "type", "field": "Name:", "value": "Project"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "type", "field": "Value:", "value": "Unaccepted"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "click", "label": "Space Audit"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "external_edit", "key": "Author", "value": "New author"},
|
||||
"expect": {"error": null, "properties_open": false, "draft": null, "notice": "The document changed. Reopen Document Properties to edit the current version.", "document": {"title": "Updated", "author": "New author", "custom": {"Untouched": "Keep me"}, "custom_count": 1}}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"name": "Document Properties: newer-edit",
|
||||
"steps": [
|
||||
{
|
||||
"event": {"op": "open"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "type", "field": "Title", "value": "Stale"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "external_edit", "key": "Title", "value": "Newer"},
|
||||
"expect": {"error": null, "properties_open": false, "draft": null, "document": {"title": "Newer", "undo": "Change Title", "redo": null}, "notice": "The document changed. Reopen Document Properties to edit the current version."}
|
||||
},
|
||||
{
|
||||
"event": {"op": "open"},
|
||||
"expect": {"error": null, "draft": {"title": "Newer"}}
|
||||
},
|
||||
{
|
||||
"event": {"op": "click", "label": "Close"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "undo"},
|
||||
"expect": {"error": null, "document": {"title": "Original", "undo": null}}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"name": "Document Properties: no-op-preserves-redo",
|
||||
"steps": [
|
||||
{
|
||||
"event": {"op": "external_edit_undo", "key": "Subject", "value": "Future"},
|
||||
"expect": {"error": null, "document": {"title": "Original", "subject": null, "undo": null, "redo": "Change Subject"}}
|
||||
},
|
||||
{
|
||||
"event": {"op": "open"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "type", "field": "Title", "value": "Transient"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "type", "field": "Title", "value": " Original "},
|
||||
"expect": {"error": null, "draft": {"title": " Original "}, "document": {"title": "Original", "undo": null, "redo": "Change Subject"}}
|
||||
},
|
||||
{
|
||||
"event": {"op": "click", "label": "Close"},
|
||||
"expect": {"error": null, "properties_open": false, "draft": null, "document": {"title": "Original", "undo": null, "redo": "Change Subject"}}
|
||||
},
|
||||
{
|
||||
"event": {"op": "redo"},
|
||||
"expect": {"error": null, "document": {"subject": "Future"}}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"name": "Document Properties: reload-same-document",
|
||||
"steps": [
|
||||
{
|
||||
"event": {"op": "open"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "type", "field": "Title", "value": "Stale"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "reload", "title": "Reloaded"},
|
||||
"expect": {"error": null, "properties_open": false, "draft": null, "active": "A.pdf", "document": {"title": "Reloaded", "dirty": false, "undo": null, "redo": null}, "notice": "The document changed. Reopen Document Properties to edit the current version."}
|
||||
},
|
||||
{
|
||||
"event": {"op": "open"},
|
||||
"expect": {"error": null, "draft": {"title": "Reloaded"}}
|
||||
},
|
||||
{
|
||||
"event": {"op": "type", "field": "Title", "value": "After reload"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "click", "label": "OK"},
|
||||
"expect": {"error": null, "document": {"title": "After reload"}}
|
||||
},
|
||||
{
|
||||
"event": {"op": "undo"},
|
||||
"expect": {"error": null, "document": {"title": "Reloaded", "undo": null}}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"name": "Document Properties: switch-document",
|
||||
"steps": [
|
||||
{
|
||||
"event": {"op": "open"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "type", "field": "Title", "value": "Stale"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "switch", "document": 2},
|
||||
"expect": {"error": null, "properties_open": false, "draft": null, "active": "B.pdf", "document": {"title": "Second document", "dirty": false, "undo": null, "redo": null}, "original_bytes": true, "notice": "The document changed. Reopen Document Properties to edit the current version."}
|
||||
},
|
||||
{
|
||||
"event": {"op": "switch", "document": 1},
|
||||
"expect": {"error": null, "active": "A.pdf", "document": {"title": "Original", "author": "Original author", "custom": {"Untouched": "Keep me"}, "display_title": false, "dirty": false, "undo": null, "redo": null, "pages": 2, "custom_count": 1}, "original_bytes": true}
|
||||
},
|
||||
{
|
||||
"event": {"op": "open"},
|
||||
"expect": {"error": null, "draft": {"title": "Original"}}
|
||||
},
|
||||
{
|
||||
"event": {"op": "click", "label": "Close"},
|
||||
"expect": {"error": null, "document": {"title": "Original", "author": "Original author", "custom": {"Untouched": "Keep me"}, "display_title": false, "dirty": false, "undo": null, "redo": null, "pages": 2, "custom_count": 1}}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"name": "Document Properties: undo-aba",
|
||||
"steps": [
|
||||
{
|
||||
"event": {"op": "open"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "type", "field": "Title", "value": "Stale"},
|
||||
"expect": {"error": null}
|
||||
},
|
||||
{
|
||||
"event": {"op": "external_edit_undo", "key": "Title", "value": "Temporary"},
|
||||
"expect": {"error": null, "properties_open": false, "draft": null, "document": {"title": "Original", "undo": null, "redo": "Change Title"}, "notice": "The document changed. Reopen Document Properties to edit the current version."}
|
||||
},
|
||||
{
|
||||
"event": {"op": "redo"},
|
||||
"expect": {"error": null, "document": {"title": "Temporary", "undo": "Change Title", "redo": null}}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+16
@@ -1,5 +1,21 @@
|
||||
# Vendored crates
|
||||
|
||||
## edit-session
|
||||
|
||||
Document Properties uses the dependency-free draft state machine from
|
||||
[edit-session 0.1.0-alpha.2](https://github.com/PerchAnton/edit-session/tree/033f7c3a67b825b8f222288788cc61234c0c945d).
|
||||
The manifest, library, lifecycle tests and both licenses match that revision byte for byte.
|
||||
The local README links here and rustfmt settings preserve upstream formatting. PdfCraft owns document identity, permissions and Undo.
|
||||
Review upstream changes before replacing the snapshot and run the lifecycle and Document Properties tests.
|
||||
|
||||
| File | SHA-256 |
|
||||
| --- | --- |
|
||||
| [Cargo.toml](edit-session/Cargo.toml) | `19d3c1ee04b087a12975732b76f8c3bb14d2b158ec48dfa4dadc54aa73e964ba` |
|
||||
| [src/lib.rs](edit-session/src/lib.rs) | `b86bab15e5787bb2442ebf7aa17cadb75d18bbd10f1abd7a870a9a4bb2af66e3` |
|
||||
| [tests/lifecycle.rs](edit-session/tests/lifecycle.rs) | `34930f3942721491f8c96f251bcdf3304f9eddf39bcf0469334ab66a89c94e2a` |
|
||||
| [LICENSE-MIT](edit-session/LICENSE-MIT) | `665a942f9317a358930b470ad522a22a58ff6c346db0395463ec84dd3e19d775` |
|
||||
| [LICENSE-APACHE](edit-session/LICENSE-APACHE) | `cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30` |
|
||||
|
||||
## Window-stack provenance after the egui 0.37 migration
|
||||
|
||||
The checked-in, resolved Cargo graph pins `winit` to storytold/winit
|
||||
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "edit-session"
|
||||
version = "0.1.0-alpha.2"
|
||||
edition = "2024"
|
||||
rust-version = "1.88"
|
||||
description = "Toolkit-independent preview, validation and commit lifecycle"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/PerchAnton/edit-session"
|
||||
readme = "README.md"
|
||||
publish = false
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[lints.clippy]
|
||||
unwrap_used = "deny"
|
||||
expect_used = "deny"
|
||||
panic = "deny"
|
||||
Vendored
+202
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 PerchAnton
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
# edit-session in PdfCraft
|
||||
|
||||
The Document Properties dialog uses this local copy of edit-session 0.1.0-alpha.2.
|
||||
The [vendor ledger](../README.md#edit-session) records its upstream revision, licenses and verification steps.
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
# Preserve upstream formatting in this snapshot.
|
||||
max_width = 100
|
||||
use_small_heuristics = "Default"
|
||||
Vendored
+168
@@ -0,0 +1,168 @@
|
||||
//! A local draft of a value, independent of rendering and the application's undo engine.
|
||||
//!
|
||||
//! The host validates proposals and owns all document mutations. Dropping a session cancels
|
||||
//! only its local draft. Store small parameters here, not a full document per pointer event.
|
||||
//! A context must include stable document/target identity and a revision that changes on
|
||||
//! every relevant edit (including undo/redo). Comparing values alone cannot detect ABA edits.
|
||||
#![no_std]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
/// Why a draft cannot be committed to the current host state.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FinishError {
|
||||
/// The most recent proposal was rejected. Correct it before finishing.
|
||||
InvalidProposal,
|
||||
/// Document, target, selection or relevant revision has changed.
|
||||
ContextChanged,
|
||||
/// The live value changed without the context being updated.
|
||||
ValueChanged,
|
||||
}
|
||||
|
||||
impl core::fmt::Display for FinishError {
|
||||
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
formatter.write_str(match self {
|
||||
Self::InvalidProposal => "the latest proposal is invalid",
|
||||
Self::ContextChanged => "the edit context has changed",
|
||||
Self::ValueChanged => "the live value has changed",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl core::error::Error for FinishError {}
|
||||
|
||||
/// A proposed change. The caller must apply it through its existing command engine.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Change<T> {
|
||||
pub before: T,
|
||||
pub after: T,
|
||||
}
|
||||
|
||||
/// A single-use edit. It never writes to a document or records history itself.
|
||||
///
|
||||
/// This type intentionally does not implement `Clone`. `finish` consumes it, so the same
|
||||
/// session cannot accidentally emit two commits. A failed finish also consumes the draft;
|
||||
/// call `check` first if invalid input should remain editable in the UI.
|
||||
#[must_use = "a draft must be retained, finished, or deliberately cancelled"]
|
||||
#[derive(Debug)]
|
||||
pub struct EditSession<T, C> {
|
||||
original: T,
|
||||
preview: T,
|
||||
context: C,
|
||||
valid: bool,
|
||||
}
|
||||
|
||||
impl<T: Clone, C> EditSession<T, C> {
|
||||
/// Begin from a value already validated by the application.
|
||||
pub fn new(original: T, context: C) -> Self {
|
||||
Self {
|
||||
preview: original.clone(),
|
||||
original,
|
||||
context,
|
||||
valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore the original preview and clear invalid input, keeping the draft open.
|
||||
///
|
||||
/// The original value and context are retained: this does not rebase a stale draft
|
||||
/// or change the live document. The host must refresh its displayed preview itself.
|
||||
pub fn reset(&mut self) {
|
||||
self.preview = self.original.clone();
|
||||
self.valid = true;
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, C> EditSession<T, C> {
|
||||
/// The validated value captured when editing began.
|
||||
pub fn original(&self) -> &T {
|
||||
&self.original
|
||||
}
|
||||
|
||||
/// Last valid preview, even when a later incomplete/invalid input is present.
|
||||
pub fn preview(&self) -> &T {
|
||||
&self.preview
|
||||
}
|
||||
|
||||
/// The host context captured when editing began; never changed by this session.
|
||||
pub fn context(&self) -> &C {
|
||||
&self.context
|
||||
}
|
||||
|
||||
/// Whether the latest proposal was accepted. This does not check live host state.
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.valid
|
||||
}
|
||||
|
||||
/// Accept a host-validated proposal. Errors invalidate commit without replacing the
|
||||
/// last valid preview. Units, mixed values, finite checks and bounds belong to the host.
|
||||
pub fn propose<E>(&mut self, proposal: Result<T, E>) -> Result<(), E> {
|
||||
match proposal {
|
||||
Ok(value) => {
|
||||
self.preview = value;
|
||||
self.valid = true;
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
self.valid = false;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Discard the local draft. No rollback commands or history entries are generated.
|
||||
pub fn cancel(self) {}
|
||||
}
|
||||
|
||||
impl<T: PartialEq, C: PartialEq> EditSession<T, C> {
|
||||
/// Check whether the draft still belongs to the current host state, independently
|
||||
/// of invalid input. Useful when deciding whether an open editor must be dismissed.
|
||||
///
|
||||
/// This never returns `InvalidProposal` and does not authorize a commit. Use `check`
|
||||
/// or `finish` immediately before applying on the host's serialized command path.
|
||||
/// Context conflicts take precedence over value conflicts. No lock is acquired.
|
||||
pub fn check_live(&self, live: &T, context: &C) -> Result<(), FinishError> {
|
||||
if *context != self.context {
|
||||
Err(FinishError::ContextChanged)
|
||||
} else if *live != self.original {
|
||||
Err(FinishError::ValueChanged)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Check live host state and proposal validity immediately before applying on the
|
||||
/// host's serialized command path. Live conflicts take precedence over invalid input.
|
||||
/// This comparison is not a lock and is not a distributed transaction.
|
||||
pub fn check(&self, live: &T, context: &C) -> Result<(), FinishError> {
|
||||
self.check_live(live, context)?;
|
||||
if !self.valid {
|
||||
Err(FinishError::InvalidProposal)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit at most one change; a no-op emits none, preserving the host's redo stack.
|
||||
/// The host still validates and applies the command and handles execution failure.
|
||||
pub fn finish(self, live: &T, context: &C) -> Result<Option<Change<T>>, FinishError> {
|
||||
self.check(live, context)?;
|
||||
if self.original == self.preview {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(Change {
|
||||
before: self.original,
|
||||
after: self.preview,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A session cannot be finished twice.
|
||||
///
|
||||
/// ```compile_fail
|
||||
/// use edit_session::EditSession;
|
||||
/// let draft = EditSession::new(100, ("document", 7));
|
||||
/// let _ = draft.finish(&100, &("document", 7));
|
||||
/// let _ = draft.finish(&100, &("document", 7));
|
||||
/// ```
|
||||
const _: () = ();
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
use edit_session::{Change, EditSession, FinishError};
|
||||
|
||||
#[test]
|
||||
fn many_previews_produce_one_final_change() {
|
||||
let mut draft = EditSession::new(100, ("photo", "layer-1", 9));
|
||||
for value in 0..1000 {
|
||||
assert_eq!(draft.propose::<()>(Ok(value)), Ok(()));
|
||||
assert_eq!(*draft.original(), 100);
|
||||
}
|
||||
assert_eq!(
|
||||
draft.finish(&100, &("photo", "layer-1", 9)),
|
||||
Ok(Some(Change {
|
||||
before: 100,
|
||||
after: 999
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_input_retains_preview_but_blocks_commit_until_corrected() {
|
||||
let mut draft = EditSession::new(10, 0);
|
||||
assert_eq!(draft.propose::<&str>(Ok(12)), Ok(()));
|
||||
assert_eq!(
|
||||
draft.propose(Err("incomplete number")),
|
||||
Err("incomplete number")
|
||||
);
|
||||
assert_eq!(*draft.preview(), 12);
|
||||
assert!(!draft.is_valid());
|
||||
assert_eq!(draft.check(&10, &0), Err(FinishError::InvalidProposal));
|
||||
assert_eq!(draft.propose::<&str>(Ok(14)), Ok(()));
|
||||
assert_eq!(
|
||||
draft.finish(&10, &0),
|
||||
Ok(Some(Change {
|
||||
before: 10,
|
||||
after: 14
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_and_return_to_original_have_no_commit() {
|
||||
assert_eq!(EditSession::new(20, 0).finish(&20, &0), Ok(None));
|
||||
let mut draft = EditSession::new(20, 0);
|
||||
assert_eq!(draft.propose::<()>(Ok(50)), Ok(()));
|
||||
assert_eq!(draft.propose::<()>(Ok(20)), Ok(()));
|
||||
assert_eq!(draft.finish(&20, &0), Ok(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_document_target_or_revision_cannot_commit() {
|
||||
for context in [(2, 5, 8), (1, 6, 8), (1, 5, 9)] {
|
||||
let draft = EditSession::new(100, (1, 5, 8));
|
||||
assert_eq!(
|
||||
draft.finish(&100, &context),
|
||||
Err(FinishError::ContextChanged)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unversioned_value_change_is_also_rejected() {
|
||||
assert_eq!(
|
||||
EditSession::new(100, 1).finish(&50, &1),
|
||||
Err(FinishError::ValueChanged)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_back_to_equal_value_is_stale_when_revision_has_advanced() {
|
||||
assert_eq!(
|
||||
EditSession::new(100, 1).finish(&100, &3),
|
||||
Err(FinishError::ContextChanged)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_final_input_does_not_silently_commit_last_valid_value() {
|
||||
let mut draft = EditSession::new(10, 0);
|
||||
assert_eq!(draft.propose::<&str>(Ok(12)), Ok(()));
|
||||
assert!(draft.propose(Err("not finite")).is_err());
|
||||
assert_eq!(draft.finish(&10, &0), Err(FinishError::InvalidProposal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_values_preserve_inheritance_instead_of_flattening_it() {
|
||||
let mut draft = EditSession::new(None::<u16>, 0);
|
||||
assert_eq!(draft.propose::<()>(Ok(Some(20))), Ok(()));
|
||||
assert_eq!(draft.propose::<()>(Ok(None)), Ok(()));
|
||||
assert_eq!(draft.finish(&None, &0), Ok(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_does_not_touch_host_value() {
|
||||
let live = String::from("original");
|
||||
let mut draft = EditSession::new(live.clone(), 1);
|
||||
assert_eq!(draft.propose::<()>(Ok("preview".into())), Ok(()));
|
||||
draft.cancel();
|
||||
assert_eq!(live, "original");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_input_can_remain_editable_while_live_context_is_current() {
|
||||
let mut draft = EditSession::new(10, 7);
|
||||
assert!(draft.propose(Err("incomplete number")).is_err());
|
||||
assert_eq!(draft.check_live(&10, &7), Ok(()));
|
||||
assert_eq!(draft.check(&10, &7), Err(FinishError::InvalidProposal));
|
||||
for (live, context, error) in [
|
||||
(10, 8, FinishError::ContextChanged),
|
||||
(11, 8, FinishError::ContextChanged),
|
||||
(11, 7, FinishError::ValueChanged),
|
||||
] {
|
||||
assert_eq!(draft.check_live(&live, &context), Err(error));
|
||||
assert_eq!(draft.check(&live, &context), Err(error));
|
||||
}
|
||||
assert!(!draft.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_invalid_input_and_confirming_it_is_a_noop() {
|
||||
// Reset only needs the value to be Clone, not the host's context.
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Context(u64);
|
||||
let mut draft = EditSession::new(String::from("original"), Context(7));
|
||||
assert_eq!(draft.propose::<()>(Ok(String::from("preview"))), Ok(()));
|
||||
assert!(draft.propose(Err("invalid")).is_err());
|
||||
draft.reset();
|
||||
assert!(draft.is_valid());
|
||||
assert_eq!(draft.preview(), "original");
|
||||
assert_eq!(draft.original(), "original");
|
||||
assert_eq!(draft.context(), &Context(7));
|
||||
assert_eq!(
|
||||
draft.finish(&String::from("original"), &Context(7)),
|
||||
Ok(None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_allows_further_editing_without_changing_the_original() {
|
||||
let mut draft = EditSession::new(10, 7);
|
||||
assert_eq!(draft.propose::<()>(Ok(12)), Ok(()));
|
||||
draft.reset();
|
||||
draft.reset();
|
||||
assert_eq!(draft.propose::<()>(Ok(15)), Ok(()));
|
||||
assert_eq!(
|
||||
draft.finish(&10, &7),
|
||||
Ok(Some(Change {
|
||||
before: 10,
|
||||
after: 15
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_does_not_rebase_a_stale_draft_or_hide_a_live_value_conflict() {
|
||||
for (live, context, error) in [
|
||||
(10, 9, FinishError::ContextChanged), // Edit then Undo back to the original.
|
||||
(12, 7, FinishError::ValueChanged),
|
||||
] {
|
||||
let mut draft = EditSession::new(10, 7);
|
||||
assert_eq!(draft.propose::<()>(Ok(12)), Ok(()));
|
||||
draft.reset();
|
||||
assert_eq!(draft.finish(&live, &context), Err(error));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_errors_support_standard_error_reporting() {
|
||||
for (error, message) in [
|
||||
(
|
||||
FinishError::InvalidProposal,
|
||||
"the latest proposal is invalid",
|
||||
),
|
||||
(FinishError::ContextChanged, "the edit context has changed"),
|
||||
(FinishError::ValueChanged, "the live value has changed"),
|
||||
] {
|
||||
let reported: &dyn core::error::Error = &error;
|
||||
assert_eq!(reported.to_string(), message);
|
||||
assert!(reported.source().is_none());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user