diff --git a/AGENTS.md b/AGENTS.md index c228c4185b..b68d8c8921 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,7 +173,7 @@ PdfCraft opens files from strangers, and people trust it with their work. A malf - **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd. `cos`, `filters`, `crypt` and `arlington` stay standalone. - **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state. `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it. - **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim`, `crates/ui-egui/src/canvas/page_cursor.rs`) and never set it themselves. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it. -- **Page presses.** A page module decides what is under a point in one hit test (`Hit` in `crates/ui-egui/src/canvas/page_router.rs`) and acts on a press only through that router. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it. +- **Page presses.** A page module decides what is under a point in one hit test (`Hit` in `crates/ui-egui/src/canvas/page_router.rs`) and acts on a press only through that router. Modules never read the router; they are told. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it. - **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it. - **Fonts** are the optional build input `CRAFT_FONTS_DIR`. Code using `pdfcraft_fonts::CRAFT_FONTS` must work when it is empty. - **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check. New workspace crates include `workspace = true` under `[lints]`. Scope `cargo clippy -p --all-targets -- -D warnings` and `cargo test -p ` to the crates you touched; the workspace sweep is CI's job. diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs index 9e93d4c347..c950e72f05 100644 --- a/crates/automation/src/json.rs +++ b/crates/automation/src/json.rs @@ -375,6 +375,9 @@ pub(crate) fn info(d: &Document) -> Value { })).collect::>(), "warnings": i.warnings, "repairs": d.repair_log(), + // Objects that could not be read (each reads as null): nothing was repaired, what they held is + // lost. Last, so it includes what reading the fields above failed on. + "unreadable_objects": d.unreadable_objects(), }) } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 1f381102a8..84354f51d7 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -164,7 +164,7 @@ pub fn tools() -> Vec { .cmd("file.open") .with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])), t("doc_list", "List open documents", "List the open documents with their ids, page counts and unsaved state.").ro().with(schema(json!({}), &[])), - t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, the destination it opens at, attachments, fonts, security and repair notes.") + t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, the destination it opens at, attachments, fonts, security, repair notes and the objects that could not be read.") .ro() .cmd("file.properties") .with(schema(json!({ "doc": doc() }), &["doc"])), diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index 13f24013cd..92e54c99a6 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -17,3 +17,4 @@ mod optimize_content_boundaries; mod redact_after_stray_q_form; mod redact_text_after_q; mod render_warnings; +mod unreadable_objects; diff --git a/crates/automation/tests/it/unreadable_objects.rs b/crates/automation/tests/it/unreadable_objects.rs new file mode 100644 index 0000000000..f19a19078a --- /dev/null +++ b/crates/automation/tests/it/unreadable_objects.rs @@ -0,0 +1,64 @@ +//! `doc_info` lists the objects of a file that could not be read: each reads as null, so the file +//! opens, but what it held is lost and nothing was repaired. + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +/// A one-page PDF whose document information dictionary (object 4) is `info`. +fn with_info(info: &str) -> Vec { + let bodies = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>", + info, + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R /Info 4 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes()); + out +} + +fn info_of(a: &mut Automation, path: &str) -> Value { + let doc = match a.call("doc_open", &json!({ "path": path })).unwrap().remove(0) { + Content::Json(v) => v["doc"].as_u64().unwrap(), + other => panic!("expected JSON: {other:?}"), + }; + match a.call("doc_info", &json!({ "doc": doc })).unwrap().remove(0) { + Content::Json(v) => v, + other => panic!("expected JSON: {other:?}"), + } +} + +/// An unparseable `/Info` is listed with its number and why, the rest of the file reads, and +/// nothing is reported as repaired. +#[test] +fn doc_info_lists_an_object_that_could_not_be_read() { + let dir = std::env::temp_dir().join(format!("pdfcraft-unreadable-objects-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("broken.pdf"), with_info("<< /Title (Report) /Author ) >>")).unwrap(); + std::fs::write(dir.join("sound.pdf"), with_info("<< /Title (Report) >>")).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + + let info = info_of(&mut a, "broken.pdf"); + let lines = info["unreadable_objects"].as_array().unwrap(); + assert_eq!(lines.len(), 1, "{info}"); + let line = lines[0].as_str().unwrap(); + assert!(line.starts_with("object 4 0 could not be read: syntax error"), "{line}"); + assert_eq!(info["repairs"], json!([]), "nothing was repaired"); + assert_eq!(info["pages"].as_array().unwrap().len(), 1, "the rest of the file reads"); + + let info = info_of(&mut a, "sound.pdf"); + assert_eq!(info["unreadable_objects"], json!([]), "{info}"); + assert_eq!(info["title"], "Report"); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 2133f720e6..9fc72599ed 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -25,7 +25,7 @@ pub(crate) use reader::find; #[cfg(test)] use reader::rfind; pub(crate) use state::ObjectReader; -use state::{ObjStm, Slot}; +use state::{ObjStm, Slot, Unreadable}; pub use state::{ReadState, SourceIdentity}; #[cfg(test)] use state::{SECTION_READS, held_bytes, objstm_member}; @@ -53,6 +53,9 @@ pub struct Document { /// decodes the stream again (`Document::load`). cache: Arc>, objstms: Arc>>>, + /// See [`Self::unreadable_objects`]. Shared by clones, like the caches: it describes the + /// source, and is reset wherever they are. + unreadable: Arc>, /// What `/JBIG2Globals` streams of the file decode to, by object number: the images of a scan /// share them, and each would otherwise decode them again (see `Document::jbig2_globals`). jbig2_globals: Arc>>>>, diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 900e960702..6eabb2c5b3 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -90,6 +90,39 @@ impl Document { &self.repair_log } + /// Objects of the file that could not be read, lowest number first, as lines such as + /// "object 11 0 could not be read: syntax error at byte 512: ...". [`Self::get`] reads each + /// as null (ISO 32000-2 §7.3.10), so a document with one opens, but what it held is lost: + /// unlike [`Self::repair_log`], nothing was repaired. Lists the objects read so far, by this + /// document, its clones or a scan of it, that have not since been edited; past 100, a last + /// line says there were more. Opening again, authenticating or rebuilding the index starts + /// the list afresh, as it does the object cache. + pub fn unreadable_objects(&self) -> Vec { + let Ok(unreadable) = self.unreadable.lock() else { return Vec::new() }; + unreadable.lines(|num| self.overlay.contains_key(&num)) + } + + /// Record `num` as unreadable. `reason` is built only for an object not yet listed, so a file + /// whose broken object is asked for again and again pays for it once. + fn note_unreadable(&self, num: u32, reason: impl FnOnce() -> String) { + // A poisoned lock only means the object goes unlisted; the read itself still fails. + let Ok(mut unreadable) = self.unreadable.lock() else { return }; + let hidden = |n: u32| self.overlay.contains_key(&n); + if unreadable.wants(num, hidden) { + unreadable.note(num, self.generation(num), &reason(), hidden); + } + } + + /// Why an object listed at `offset` could not be read: what parsing it there, without resolving + /// an indirect `/Length`, found. A best account, not the only one: the reader also tried + /// elsewhere. + fn why_unreadable(&self, offset: usize) -> String { + match parse_indirect_shared(&self.data, offset, &|_| None, &self.endstreams) { + Err(e) => e.to_string(), + Ok(_) => "no readable copy was found at its cross-reference offset or elsewhere in the file".into(), + } + } + /// Add a line to [`Self::repair_log`]: a leniency applied while reading that the user should /// know about (fidelity: never silent). pub fn note_repair(&mut self, line: String) { @@ -516,6 +549,11 @@ impl Document { let _scope = crate::security::FollowScope::enter(); let edits_read = overlay_reads(); let loaded = self.load(num, 0).map(Arc::new); + if let Err(e) = &loaded + && !matches!(e, CosError::Poisoned) + { + self.note_unreadable(num, || e.to_string()); + } // A failure is kept like an object, or every read of a damaged object loads it again. A // poisoned lock says nothing about the object. Clones share the cache, so a load that read // this document's edits (an edited object stream or `/Length`) is not kept. @@ -663,7 +701,10 @@ impl Document { .or_else(|| if self.header_offset == 0 { None } else { at(off.saturating_add(self.header_offset)) }) .or_else(|| self.located.get(&num).and_then(|(o, _)| usize::try_from(*o).ok()).and_then(at)) .or_else(|| self.scan_for(num).map(|(id, o)| self.decrypted(id, o))) - .ok_or(CosError::MissingObject(num)) + .ok_or_else(|| { + self.note_unreadable(num, || self.why_unreadable(off)); + CosError::MissingObject(num) + }) } Some(XrefEntry::InStream { stream, index }) => { let stm = self.objstm(stream)?; diff --git a/crates/cos/src/document/open.rs b/crates/cos/src/document/open.rs index 3bf6677e99..b628cea89c 100644 --- a/crates/cos/src/document/open.rs +++ b/crates/cos/src/document/open.rs @@ -23,6 +23,7 @@ impl Document { repair_log: Arc::new(Vec::new()), cache: Arc::default(), objstms: Arc::default(), + unreadable: Arc::default(), jbig2_globals: Arc::default(), stream_limit: crate::object::MAX_DECODED, overlay: BTreeMap::new(), @@ -110,6 +111,7 @@ impl Document { repair_log: Arc::new(Vec::new()), cache: Arc::default(), objstms: Arc::default(), + unreadable: Arc::default(), jbig2_globals: Arc::default(), stream_limit: stream_limit.min(crate::object::MAX_DECODED), overlay: BTreeMap::new(), @@ -216,6 +218,7 @@ impl Document { // Objects read while locating the catalog were read before decryption was possible. self.cache = Arc::default(); self.objstms = Arc::default(); + self.unreadable = Arc::default(); Ok(()) } diff --git a/crates/cos/src/document/reader.rs b/crates/cos/src/document/reader.rs index e1691ace90..2797a04e9a 100644 --- a/crates/cos/src/document/reader.rs +++ b/crates/cos/src/document/reader.rs @@ -1,5 +1,6 @@ #[cfg(test)] use super::state::SECTION_READS; +use super::state::Unreadable; use super::xref::{ MAX_COMPACT_XREF_BYTES, MAX_COMPACT_XREF_ROWS, Revision, XrefEntry, XrefIndex, XrefRange, XrefRevisionSection, XrefSection, XrefSectionData, }; @@ -648,6 +649,7 @@ impl Document { } self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries.clone()), Arc::default())); self.cache.lock().map_err(|_| CosError::Poisoned)?.clear(); + *self.unreadable.lock().map_err(|_| CosError::Poisoned)? = Unreadable::default(); // Objects inside object streams. for num in objstms { if let Ok(o) = self.try_get(num) @@ -664,6 +666,8 @@ impl Document { } let object_count = entries.len(); self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries), Arc::default())); + // Failures from the scan stand: the members added change no scanned entry, and a + // `/Length` found only now cannot make a stream unreadable. self.cache.lock().map_err(|_| CosError::Poisoned)?.clear(); // Trailer: the last `trailer` dictionary or cross-reference stream dictionary with a // usable /Root (last in the file, whichever kind), else the catalog itself. One whose diff --git a/crates/cos/src/document/state.rs b/crates/cos/src/document/state.rs index e4cf987245..2f35174824 100644 --- a/crates/cos/src/document/state.rs +++ b/crates/cos/src/document/state.rs @@ -81,6 +81,60 @@ pub(super) fn objstm_member(num: i64, offset: i64, first: Option, len: us (start < len).then_some((num, start)) } +/// Objects the reader could not read, each with the reason (see `Document::unreadable_objects`). +/// Bounded: a document records past [`Unreadable::MAX`] of the objects it shows only the fact that +/// there were more. Entries a clone has edited are hidden from it and do not count, so the whole +/// list holds at most `MAX` plus the objects edited in clones. +#[derive(Debug, Default)] +pub(super) struct Unreadable { + pub(super) objects: BTreeMap, + pub(super) more: bool, +} + +impl Unreadable { + pub(super) const MAX: usize = 100; + /// Characters of a reason kept: a parser's message can quote the bytes it stopped at. + const REASON: usize = 160; + + /// Whether `num` is to be recorded: not listed yet and there is room among the entries the + /// asking document still shows (`hidden` are the ones it has edited, as in [`Self::lines`]), + /// so a clone's edits never use up its own allowance. A full list notes that there were more, + /// so a caller asks this before building the reason. + pub(super) fn wants(&mut self, num: u32, hidden: impl Fn(u32) -> bool) -> bool { + if self.objects.contains_key(&num) { + return false; + } + if self.objects.keys().filter(|n| !hidden(**n)).count() >= Self::MAX { + self.more = true; + return false; + } + true + } + + /// Record `num` once; the first reason given is kept. + pub(super) fn note(&mut self, num: u32, generation: u16, reason: &str, hidden: impl Fn(u32) -> bool) { + if self.wants(num, hidden) { + self.objects.insert(num, (generation, reason.chars().take(Self::REASON).collect())); + } + } + + /// One line per recorded object not `hidden`, then one saying there were more, if there were. + pub(super) fn lines(&self, hidden: impl Fn(u32) -> bool) -> Vec { + // Clones share the list and each hides its own edits, so the list may hold more than + // `MAX` entries in all; each document still shows at most `MAX`. + let mut shown = self.objects.iter().filter(|(num, _)| !hidden(**num)); + let mut lines: Vec = shown + .by_ref() + .take(Self::MAX) + .map(|(num, (generation, reason))| format!("object {num} {generation} could not be read: {reason}")) + .collect(); + if self.more || shown.next().is_some() { + lines.push(format!("more objects could not be read; only the first {} are listed", Self::MAX)); + } + lines + } +} + /// Where an overlay slot came from. #[derive(Clone, Debug, PartialEq)] pub(super) enum Slot { diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index bb177e79e6..2cab25e7f1 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -749,6 +749,9 @@ fn an_unreadable_object_is_searched_for_once() { let mut edited = doc.clone(); assert_eq!(edited.try_get(3), first); assert_eq!(FILE_SCANS.with(std::cell::Cell::get), 1, "searches for an object that isn't there"); + let report = doc.unreadable_objects(); + assert_eq!(report.len(), 1, "cached failures must stay listed exactly once: {report:?}"); + assert_eq!(edited.unreadable_objects(), report, "clones retain the cached failure diagnostic"); edited.set(ObjRef::new(3, 0), Object::Int(7)); assert_eq!(edited.try_get(3).as_deref(), Ok(&Object::Int(7))); assert_eq!(doc.try_get(3), first, "the edit is the clone's alone"); @@ -2015,6 +2018,105 @@ fn a_stream_whose_length_is_itself_and_never_ends_loads_quickly() { rx.recv_timeout(std::time::Duration::from_secs(20)).expect("loading must not retry exponentially"); } +/// A file whose object 3 (its `/Info`) can't be parsed: the rest reads. +fn with_unreadable_info() -> Vec { + build( + &["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Title (Report) /Author ) >>"], + "/Root 1 0 R /Info 3 0 R", + ) +} + +/// An object that can't be parsed reads as null and is listed once, by number and generation, +/// with why. Clones and scans share the list, an edit takes the object off it (in that document +/// only), and a sound file lists nothing. +#[test] +fn unreadable_objects_are_listed_with_the_reason() { + let doc = Document::open(Arc::new(with_unreadable_info())).unwrap(); + assert!(doc.unreadable_objects().is_empty(), "nothing has read object 3 yet"); + assert_eq!(*doc.get(ObjRef::new(3, 0)), Object::Null); + assert!(doc.try_get(3).is_err()); + let lines = doc.unreadable_objects(); + assert_eq!(lines.len(), 1, "read twice, listed once: {lines:?}"); + assert!(lines[0].starts_with("object 3 0 could not be read: syntax error"), "{lines:?}"); + let scanned = doc.clone(); + assert_eq!(scanned.scan_objects_checked().filter(|(_, o)| o.is_err()).count(), 1); + assert_eq!(doc.unreadable_objects(), lines, "the scan found the same object"); + let unread = Document::open(Arc::new(with_unreadable_info())).unwrap(); + let scanned = unread.clone(); + assert_eq!(scanned.scan_objects_checked().filter(|(_, o)| o.is_err()).count(), 1); + assert_eq!(unread.unreadable_objects(), lines, "a clone's scan reaches the original"); + let mut edited = doc.clone(); + edited.set(ObjRef::new(3, 0), Object::Dict(Dict::new())); + assert!(edited.unreadable_objects().is_empty(), "the edit replaced it"); + assert_eq!(doc.unreadable_objects(), lines); + let sound = + build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Title (Report) >>"], "/Root 1 0 R /Info 3 0 R"); + let sound = Document::open(Arc::new(sound)).unwrap(); + assert_eq!(sound.scan_objects_checked().filter(|(_, o)| o.is_err()).count(), 0); + assert!(sound.unreadable_objects().is_empty()); +} + +/// A file rebuilt from its object headers still lists an object the scan could not read. +#[test] +fn a_rebuilt_file_lists_the_objects_its_scan_could_not_read() { + let text = "%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n3 0 obj\n<< /A ) >>\nendobj\n%%EOF\n"; + let doc = Document::open(Arc::new(text.as_bytes().to_vec())).unwrap(); + assert!(doc.repair_log().iter().any(|l| l.starts_with("rebuilt cross-reference index")), "{:?}", doc.repair_log()); + let lines = doc.unreadable_objects(); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert!(lines[0].starts_with("object 3 0 could not be read: syntax error"), "{lines:?}"); +} + +/// The list keeps at most 100 objects and then says there were more; an object whose +/// cross-reference offset holds no object is listed with that as its reason. +#[test] +fn the_unreadable_object_list_is_bounded() { + let mut bodies = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"]; + bodies.extend(std::iter::repeat_n("<< /A ) >>", 120)); + let doc = Document::open(Arc::new(build(&bodies, "/Root 1 0 R"))).unwrap(); + assert_eq!(doc.scan_objects_checked().filter(|(_, o)| o.is_err()).count(), 120); + let lines = doc.unreadable_objects(); + assert_eq!(lines.len(), 101, "{:?}", lines.last()); + assert!(lines[0].starts_with("object 3 0 could not be read"), "{}", lines[0]); + assert_eq!(lines[100], "more objects could not be read; only the first 100 are listed"); + + let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R"); + let text = String::from_utf8_lossy(&base).into_owned(); + let gone = text.replacen("3 0 obj\n(three)", "9 0 obj\n(three)", 1); + let doc = Document::open(Arc::new(gone.into_bytes())).unwrap(); + assert!(doc.try_get(3).is_err()); + assert_eq!( + doc.unreadable_objects(), + ["object 3 0 could not be read: no readable copy was found at its cross-reference offset or elsewhere in the file"] + ); +} + +/// The unreadable list is shared by clones, and each hides what it has edited. A clone that edited +/// every listed object still lists its own failures: hidden entries don't use up its allowance. +#[test] +fn objects_a_clone_edited_do_not_use_up_its_unreadable_allowance() { + let mut bodies = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"]; + bodies.extend(std::iter::repeat_n("<< /A ) >>", 110)); + let doc = Document::open(Arc::new(build(&bodies, "/Root 1 0 R"))).unwrap(); + for num in 3..103 { + assert!(doc.try_get(num).is_err()); + } + assert_eq!(doc.unreadable_objects().len(), 100); + let mut edited = doc.clone(); + for num in 3..103 { + edited.set(ObjRef::new(num, 0), Object::Null); + } + for num in 103..113 { + assert!(edited.try_get(num).is_err()); + } + let lines = edited.unreadable_objects(); + assert_eq!(lines.len(), 10, "{lines:?}"); + assert!(lines[0].starts_with("object 103 0 could not be read"), "{}", lines[0]); + let original = doc.unreadable_objects(); + assert_eq!(original.len(), 101, "the original still shows at most 100, then says there were more"); + assert_eq!(original[100], "more objects could not be read; only the first 100 are listed"); +} + #[test] fn a_restored_revision_keeps_what_the_saved_file_held_in_object_streams() { // The earlier revision: an empty document read back from a file whose catalog and pages diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 279dc8d563..03c4f7ace8 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -631,6 +631,12 @@ impl Document { pub fn repair_log(&self) -> Vec { self.editor.as_ref().map(|e| e.cos.repair_log().to_vec()).unwrap_or_default() } + + /// Objects of the file that could not be read and read as null, one line each (see + /// [`pdfcraft_cos::Document::unreadable_objects`]): only those read so far. + pub fn unreadable_objects(&self) -> Vec { + self.editor.as_ref().map(|e| e.cos.unreadable_objects()).unwrap_or_default() + } } /// A title that names no document: what a web browser writes when it saves a blank page or a diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 50257d8248..12a1e3aecd 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -175,6 +175,11 @@ impl PageText { .collect() } + /// Index of the first glyph whose box holds a view-space point: the text under the pointer. + pub fn glyph_at(&self, x: f32, y: f32) -> Option { + self.glyphs.iter().position(|g| x >= g.rect[0] && x <= g.rect[2] && y >= g.rect[1] && y <= g.rect[3]) + } + /// Index of the glyph nearest to a view-space point (for selection anchors). /// /// A glyph whose drawn em box (`quad`) holds the point wins. Skewed or turned text has bounding diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index a1af42a8d0..8776dfeb72 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -1965,3 +1965,18 @@ fn text_outside_the_crop_box_is_clipped() { assert!(text.is_empty(), "expected no glyphs on {crop:?}, got {:?}", text.plain_text()); } } + +/// The glyph under a point is the first whose box holds it, edges included; between glyphs, none. +#[test] +fn the_glyph_at_a_point_is_the_first_whose_box_holds_it() { + let text = PageText { + glyphs: vec![em_box("a", 0.0, 0.0, 10.0, 10.0), em_box("b", 8.0, 0.0, 20.0, 10.0)], + line_of: vec![0, 0], + space_before: vec![false, false], + }; + assert_eq!(text.glyph_at(5.0, 5.0), Some(0)); + assert_eq!(text.glyph_at(9.0, 5.0), Some(0), "the first of overlapping boxes"); + assert_eq!(text.glyph_at(20.0, 10.0), Some(1), "an edge is in"); + assert_eq!(text.glyph_at(25.0, 5.0), None); + assert_eq!(PageText::default().glyph_at(0.0, 0.0), None); +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 0d210e2e04..dc9c4fd35c 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -608,9 +608,15 @@ impl eframe::App for PdfCraftApp { // opening a modal/palette, or returning to a window that lost focus. let blocked = self.modal_open() || self.palette_open || !ctx.input(|i| i.focused); for (index, view) in self.views.iter_mut().enumerate() { - view.objects.set_input_blocked(blocked || self.active != Some(index)); - if blocked || self.active != Some(index) { + let shut = blocked || self.active != Some(index); + view.objects.set_input_blocked(shut); + if shut { view.auto_scroll.cancel(); + // Edit text and Add text's drags end while their input is blocked rather than wait, to + // land wherever the pointer is once it opens again. + if matches!(self.quick_tool, QuickTool::EditText | QuickTool::AddText) { + view.cancel_edit_text_gestures(); + } } } // A field that refused its value this frame keeps the shortcut from saving or printing diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9004d88205..5ee2a7272d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -255,22 +255,6 @@ impl ImageObb { } } -/// Whether pointer `p` is on an image being cropped, its crop box or its handles (#5835). -pub(crate) fn crop_contains_point( - img: &pdfcraft_engine::PageImage, - crop: &crate::edit_text_ui::CropMode, - xf: &PageXform, - info: &DocInfo, - page: usize, - p: Pos2, -) -> bool { - let obb = ImageObb::from_image(img, xf, info, page); - let full_obb = ImageObb::from_matrix(img.matrix, xf, info, page); - crate::edit_text_ui::quad_contains(obb.corners(), p) - || crate::edit_text_ui::quad_contains(full_obb.corners(), p) - || crate::edit_text_ui::crop_controls_contain(img, crop, xf, info, page, p) -} - /// The page display as it was before full screen, put back on leaving it /// ([`DocView::enter_presentation`]). #[derive(Clone, Copy, Debug, PartialEq)] @@ -676,8 +660,13 @@ pub struct DocView { pub(crate) signature_drag: crate::signature_drag::SignatureDrag, /// What the page modules claim for the cursor this frame; `document_area` sets it once. pub(crate) cursor: crate::page_cursor::PageCursor, - /// Who the press the pages hold belongs to. - pub(crate) router: crate::page_router::PageRouter, + /// Who the press the pages hold belongs to. Only the page view reads it: it tells each page + /// module what the press is to its page (`PagePress`). + pub(in crate::canvas) router: crate::page_router::PageRouter, + /// What the last right-click on a page went to, and on which page (`None` off the pages, and for + /// Add text, which takes every right-click): the page's menu shows its entries when it has a menu + /// and is the one selected, or else the page's own. + pub(in crate::canvas) menu_for: Option<(usize, crate::page_router::Owner)>, /// Escape cancelled the press the pages hold: the page ignores it until the button is up, so /// its release neither clicks nor ends a drag. pub(crate) press_cancelled: bool, @@ -906,13 +895,20 @@ impl DocView { if let Some(q) = &mut self.camera { q.grab = None; } - self.block_drag = None; self.org_drag = None; self.panel_drag = None; self.selection_drag = false; self.router.cancel(); self.auto_scroll.cancel(); self.prepare.cancel_pointer_gesture(); + self.cancel_edit_text_gestures(); + } + + /// Abandon Edit text's pointer gestures (a paragraph moved or rewrapped, a picture moved, + /// resized, turned or cropped, added content moved, the objects' area selection or move), as + /// [`Self::cancel_pointer_gestures`] does: what they were previewing is not applied. + pub(crate) fn cancel_edit_text_gestures(&mut self) { + self.block_drag = None; self.content.cancel_pointer_gesture(); self.objects.cancel_pointer_gesture(); if let Some(image) = &mut self.image_selection { @@ -1060,6 +1056,7 @@ impl DocView { signature_drag: Default::default(), cursor: Default::default(), router: Default::default(), + menu_for: None, press_cancelled: false, } } @@ -1272,11 +1269,10 @@ struct PageFrame<'a> { } impl PageFrame<'_> { - /// Where this frame's right-click is, on the page at `rect`, for the first page it is on: pages - /// that touch (the gaps hidden) can share a row of pixels, and a right-click there is one - /// page's, as a press is. - fn right_click_on(&mut self, rect: Rect) -> Option { - let p = self.pointer.filter(|p| !self.right_clicked && self.resp.secondary_clicked() && rect.contains(*p))?; + /// Where this frame's right-click is, for the first page it is `on`: pages that touch (the gaps + /// hidden) can share a row of pixels, and a right-click there is one page's, as a press is. + fn right_click_on(&mut self, on: impl FnOnce(Pos2) -> bool) -> Option { + let p = self.pointer.filter(|p| !self.right_clicked && self.resp.secondary_clicked() && on(*p))?; self.right_clicked = true; Some(p) } @@ -1575,10 +1571,17 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i // A cancelled press takes nothing until the button is up, though the page's menu still opens // where a right-click is. let input = if view.press_cancelled { - if let Some(p) = frame.right_click_on(xf.rect) { + if let Some(p) = frame.right_click_on(|p| xf.rect.contains(p)) { comments::anchor(&pcx, p, view); } - PageInput { consumed: true, area_tool: false, hover: None, released_over: false } + PageInput { + press: crate::page_router::PagePress::Elsewhere, + text_drag: false, + consumed: true, + area_tool: false, + hover: None, + released_over: false, + } } else { page_tool_input(ui, view, frame, &slot) }; @@ -1638,8 +1641,6 @@ fn draw_pages( prefs, preparing, selection_wheel, - ppp, - rects, single_paging, tool, unobstructed, @@ -1708,54 +1709,10 @@ fn draw_pages( view.links.context = None; // Set again below by the page right-clicked: a right-click off the pages (between them, // beside one) anchors no page's menu, which then acts on the current page. It deselects the - // comment too (below), as one on a page's empty part does. A selected Prepare field keeps - // its menu, off the pages as on them. + // comment, and Edit text's picture, too (below), as one on a page's empty part does. A + // selected Prepare field stays selected, and the menu is the page's there too. view.comments.context_at = None; - let mut over_image = false; - if tool == QuickTool::EditText { - let pointer_pos = ptr.pos; - if let Some(p) = pointer_pos { - for &page_idx in visible_pages { - let r = snap_to_pixels(rects[page_idx].translate(origin.to_vec2()), ppp); - if !r.intersects(visible) { - continue; - } - let xf = - PageXform { rect: r, rot: view.rotation, pw: info.pages[page_idx].width.max(1.0), ph: info.pages[page_idx].height.max(1.0) }; - let images = cached_edit_images(view, doc, page_idx).to_vec(); - // Crop controls own the click even when another image overlaps them. - if view.image_selection.as_ref().is_some_and(|s| { - s.page == page_idx - && s.crop - .as_ref() - .zip(images.get(s.index)) - .is_some_and(|(crop, img)| crop_contains_point(img, crop, &xf, info, page_idx, p)) - }) { - over_image = true; - break; - } - for (idx, img) in images.iter().enumerate().rev() { - let obb = ImageObb::from_image(img, &xf, info, page_idx); - let b = image_screen_rect(img, &xf, info, page_idx); - let selected_crop = view.image_selection.as_ref().is_some_and(|s| s.page == page_idx && s.index == idx && s.crop.is_some()); - let hit = if selected_crop { crate::edit_text_ui::quad_contains(obb.corners(), p) } else { obb.contains(b, p) }; - if hit { - over_image = true; - if view.image_selection.as_ref().is_none_or(|s| s.page != page_idx || s.index != idx) { - view.image_selection = Some(crate::edit_text_ui::ImageSelection::at(page_idx, idx)); - } - break; - } - } - if over_image { - break; - } - } - } - } - if !over_image && tool == QuickTool::EditText { - view.image_selection = None; - } + view.menu_for = None; } let mut frame = PageFrame { env: *env, @@ -1793,9 +1750,13 @@ fn draw_pages( right_clicked, .. } = frame; - // A right-click no page took deselects the comment, so the page's menu opens, not the comment's. + // A right-click no page took deselects the comment, so the page's menu opens, not the comment's, + // and with Edit text the picture (one on a page went to that page, `page_input`). if resp.secondary_clicked() && !right_clicked { view.comments.selected = None; + if tool == QuickTool::EditText { + view.image_selection = None; + } } let PageOut { mut canvas_action, @@ -1867,28 +1828,34 @@ fn draw_pages( if view.continuous { view.current = current; } + // The menu of what the right-click went to (`menu_for`), or else the page's. + use crate::page_router::{Owner, PicturePart}; + let menu_for = view.menu_for.map(|(_, owner)| owner); + // A right-click while a press is held leaves the selections alone, so the menu is its owner's only + // when that owner is the one selected. + let on_selected_field = match menu_for { + Some(Owner::PrepareField { field, widget }) => { + form.get(field).is_some_and(|f| view.prepare.selected.as_ref().is_some_and(|(name, w)| *name == f.name && *w == widget)) + } + Some(Owner::PrepareCorner { .. }) => true, + _ => false, + }; // The row as it is now: in continuous view the current page may have changed above. let row_on_screen = shown_row(view, env.rects).iter().filter_map(|&i| env.rects.get(i)).map(|r| r.translate(origin.to_vec2())).reduce(Rect::union); resp.context_menu(|ui| { - let is_crop_over_image = tool == QuickTool::EditText - && view.image_selection.clone().is_some_and(|sel| { - let Some(ref crop) = sel.crop else { return false }; - let pointer_pos = ptr.pos; - let Some(p) = pointer_pos else { return false }; - let Some((_, xf)) = view.screen_xforms.iter().find(|(page_idx, _)| *page_idx == sel.page).cloned() else { return false }; - let images = cached_edit_images(view, doc, sel.page).to_vec(); - let Some(img) = images.get(sel.index) else { return false }; - crop_contains_point(img, crop, &xf, info, sel.page, p) - }); - if is_crop_over_image { - ui.ctx().data_mut(|d| d.insert_temp(crate::edit_text_ui::image_menu_id(), false)); + // Crop mode has no menu: a right-click on the picture being cropped, its box or its grips is + // the crop's (#5835). + if matches!(menu_for, Some(Owner::Picture { part: PicturePart::Crop(_), .. })) { egui::Popup::close_all(ui.ctx()); ui.close(); return; } - // Preparing a form: the selected field's menu. - if preparing && let Some((name, wi)) = view.prepare.selected.clone() { + // Preparing a form: the selected field's menu, on a field. + if preparing + && on_selected_field + && let Some((name, wi)) = view.prepare.selected.clone() + { // Several fields: Align, Center, Distribute, Set Fields to Same Size. if !view.prepare.also.is_empty() { use crate::prepare::Arrange as A; @@ -1935,7 +1902,9 @@ fn draw_pages( } return; } - if let Some((page, index)) = view.links.context { + if matches!(menu_for, Some(Owner::Link { .. })) + && let Some((page, index)) = view.links.context + { if ui.button(tl!("Properties…")).clicked() { view.links.open_existing = Some((page, index)); ui.close(); @@ -1956,31 +1925,24 @@ fn draw_pages( return; } let can_paste_image = env.can_paste_image; - let is_image_menu = tool == QuickTool::EditText - && (ui.ctx().data_mut(|d| d.get_temp::(crate::edit_text_ui::image_menu_id())).unwrap_or(false) - || view.image_selection.clone().is_some_and(|sel| { - let pointer_pos = ptr.pos; - let Some(p) = pointer_pos else { return false }; - let Some((_, xf)) = view.screen_xforms.iter().find(|(page_idx, _)| *page_idx == sel.page).cloned() else { return false }; - let images = cached_edit_images(view, doc, sel.page).to_vec(); - let Some(img) = images.get(sel.index) else { return false }; - let obb = ImageObb::from_image(img, &xf, info, sel.page); - let b = image_screen_rect(img, &xf, info, sel.page); - obb.contains(b, p) - })); - if is_image_menu && let Some(sel) = view.image_selection.clone() { + if tool == QuickTool::EditText + && let Some((page, Owner::Picture { index, .. })) = view.menu_for + && let Some(sel) = view.image_selection.clone().filter(|s| (s.page, s.index) == (page, index)) + { let images = cached_edit_images(view, doc, sel.page).to_vec(); - // Paste lands where the menu opened only on the picture's page: where pages touch, the - // first page takes the right-click, and its point means nothing on the second. + // Paste lands where the menu opened, on the picture's page: the right-click that opened + // it went to that page (`page_input`), and another page's point means nothing there. let at = view.comments.context_at.filter(|(page, _)| *page == sel.page).map(|(_, at)| at); let rotation = (view.rotation % 360 + info.pages.get(sel.page).map_or(0, |p| p.rotation % 360)) % 360; crate::edit_text_ui::image_context_menu(ui, view, &images, sel.page, sel.index, can_paste_image, &mut image_action, at, rotation); return; } - // A selected comment, or connected lines being drawn (Complete, Undo last point): the - // comment menu. + // The comment the right-click went to, when it is the one selected, or connected lines being + // drawn (Complete, Undo last point): the comment menu. + let on_selected_comment = + matches!(view.menu_for, Some((page, Owner::Comment { index, .. })) if view.comments.selected == Some((page, index))); let drawing_points = matches!(&view.comments.gesture, Some(comments::Gesture::Draw { tool, .. }) if tool.clicks_points()); - if view.comments.selected.is_some() || drawing_points { + if on_selected_comment || drawing_points { canvas_action = comments::context_menu( ui, view, @@ -2037,9 +1999,6 @@ fn draw_pages( }; default_page_context_menu(ui, page, at, perms, &mut page_action); }); - if !resp.context_menu_opened() { - ui.ctx().data_mut(|d| d.insert_temp(crate::edit_text_ui::image_menu_id(), false)); - } // Copy comments / Paste comments from the page's menu or a Comments panel card. let copy_comments = std::mem::take(&mut view.comments.copy_request); let paste_comments_at = view.comments.paste_request.take(); diff --git a/crates/ui-egui/src/canvas/image_pick.rs b/crates/ui-egui/src/canvas/image_pick.rs index cc938170da..e96a2b8430 100644 --- a/crates/ui-egui/src/canvas/image_pick.rs +++ b/crates/ui-egui/src/canvas/image_pick.rs @@ -3,7 +3,7 @@ //! other image as a lossless PNG (with its transparency). The clipboard gets the file, as when a //! file is copied in Finder or Explorer; Copy Image copies the pixels instead. -use egui::{Color32, CornerRadius, Pos2, Stroke}; +use egui::{Color32, CornerRadius, Stroke}; use crate::PdfCraftApp; use crate::canvas::{DocView, PageXform}; @@ -22,23 +22,26 @@ pub struct PickedImage { const BLUE: Color32 = Color32::from_rgb(0x3A, 0x7B, 0xF0); /// A click on page `page` (Select tool, not taken by anything else) selects the picture under -/// it, even with text drawn over it (scans with an OCR layer, captions); dragging or a double -/// click still selects text, and clears the picture. +/// it, even with text drawn over it (scans with an OCR layer, captions), with `pick` (a press +/// nobody holds: a link's click follows the link only); dragging or a double click still selects +/// text, and clears the picture. +#[allow(clippy::too_many_arguments)] pub(crate) fn page_input( resp: &egui::Response, xf: &PageXform, page: usize, info: &DocInfo, doc: &pdfcraft_engine::Document, - pointer: Option, + ptr: &crate::page_pointer::PagePointer, + pick: bool, view: &mut DocView, ) { - let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return }; - if resp.drag_started() || resp.double_clicked() || resp.triple_clicked() { + let Some(p) = ptr.pos.filter(|p| xf.rect.contains(*p)) else { return }; + if ptr.drag_started || resp.double_clicked() || resp.triple_clicked() { view.picked_image = None; return; } - if !resp.clicked() { + if !resp.clicked() || !pick { return; } let Some(info_page) = info.pages.get(page) else { return }; diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index e1c9719668..1e2b1e567d 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -12,7 +12,13 @@ use crate::{QuickTool, comments, icons}; /// What the page's tools did with the pointer this frame. pub(super) struct PageInput { - /// A tool, a field or a comment took the pointer: text selection must leave it alone. + /// What the press the pages hold is to this page. + pub(super) press: PagePress, + /// The press's drag is the page's text's: nobody holds it, or its owner lets its drag go (text + /// markup: a click selects it, a drag selects the text under it). + pub(super) text_drag: bool, + /// A tool, a field or a comment took the pointer this frame (its own press, or a gesture of the + /// active tool's): text selection and image pick leave it alone. pub(super) consumed: bool, /// Redact or Highlight draws boxes off text. pub(super) area_tool: bool, @@ -37,8 +43,9 @@ pub(crate) struct PageToolContext<'a> { /// The tools' input on one page. The hits at the press point decide who holds the press, and the /// modules are told what the press is to this page (`PagePress`): their own, nobody's (the active -/// tool's: `tool_input`, and the tools still called here), or another page's. The Edit text group -/// still asks the router, until it is told too. Text selection runs after, on what nobody took. +/// tool's: `tool_input`, and the tools still called here), or another page's. Edit text's group +/// paints from what lies under the pointer too (`hovered`). Text selection runs after, on what +/// nobody took. pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) -> PageInput { let PageEnv { added, @@ -91,16 +98,47 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut images: &images, clip: ui.clip_rect(), }; - if let Some(o) = ptr.pressed().filter(|o| xf.rect.contains(*o)) { - let at = hits.at(view, o); - view.router.press(i, at, tool); + if let Some(o) = ptr.pressed() { + // A press with Shift or ⌘ in Edit text is the objects', whatever lies under it + // (`object_ui::modified_hit`): the page's objects are read for it alone. + let adds = tool == QuickTool::EditText + && can_modify + && xf.rect.contains(o) + && crate::object_ui::adds(ptr.modifiers) + && !crate::object_ui::menu_open(ui, resp); + let objects = adds.then(|| view.objects.inventory(doc, i)); + let at = hits.at(view, o, objects.as_ref()); + // A press is the page's it lies on, or past its edge the one whose selected picture has its + // rotate knob or a grip there (crop mode's grips too). + let past_edge = |h: &crate::page_router::Hit| { + use crate::page_router::PicturePart; + matches!(h.owner, Owner::Picture { part: PicturePart::Knob | PicturePart::Corner(_) | PicturePart::Crop(_), .. }) + }; + if xf.rect.contains(o) || at.iter().any(past_edge) { + view.router.press(i, at, tool); + } + } + // A drag that the press's owner declines is nobody's once it starts. + if ptr.drag_started { + view.router.drag_began(); } // What this page has to do with the press: its owner's, the active tool's, or another page's. let press = view.router.press_for(i); // What lies under the pointer before the tools act, on the frames that need it: for a click's - // release, a right-click's tool checks, and Fill & Sign's preview. - let wants_under = resp.clicked() || resp.secondary_clicked() || matches!(tool, QuickTool::Fill(_)) && !ptr.any_down; - let under = pointer.filter(|p| wants_under && xf.rect.contains(*p)).map(|p| hits.at(view, p)).unwrap_or_default(); + // release, a right-click's tool checks, Fill & Sign's preview, and Edit text's hover outlines. + let editing = matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify; + let wants_under = editing || resp.clicked() || resp.secondary_clicked() || matches!(tool, QuickTool::Fill(_)) && !ptr.any_down; + let under = pointer.filter(|p| wants_under && xf.rect.contains(*p)).map(|p| hits.at(view, p, None)).unwrap_or_default(); + let hovered = top(under.iter().cloned(), tool); + // A double-click on the objects selected together is the object's its press was on: the + // double-click leaves the group (`object_ui`), and added text opens for typing, a picture or a + // paragraph is the one taken (the user's ruling). + let press = match press { + PagePress::Mine(Owner::Objects { part: crate::page_router::ObjectsPart::Selection }) if resp.double_clicked() => { + view.router.under_objects().map_or(PagePress::Free, PagePress::Mine) + } + press => press, + }; let released_over = match press { PagePress::Mine(owner) if resp.clicked() => under.iter().any(|h| h.owner == owner), _ => false, @@ -110,12 +148,27 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // released, the right-click's own frame included, so the button is what tells. A touch's long // press, which egui reports as a right-click with the finger still down, opens its menu. let press_held = ptr.primary_down && !resp.long_touched(); - let context = frame.right_click_on(xf.rect).and_then(|p| right_click(&hits, &under, view, p, press_held)); + // A right-click is the page's it lies on, or past its edge the one whose crop grip lies there, + // as a press is (#5835): what a right-click there would go to is asked only off the page. + let right = frame.right_click_on(|p| { + use crate::page_router::PicturePart; + xf.rect.contains(p) || matches!(hits.context_at(view, p, &[]), Some(Owner::Picture { part: PicturePart::Crop(_), .. })) + }); + let context = right.and_then(|p| right_click(&hits, &under, view, p, press_held)); + // Edit text: a right-click on a picture selects it for its menu, and one on anything else on the + // page unselects it. + if right.is_some() && tool == QuickTool::EditText && can_modify && !press_held { + let picture = match context { + Some(Owner::Picture { index, .. }) => Some(index), + _ => None, + }; + crate::edit_text_ui::picture_context(i, picture, view); + } // A mark already placed takes its own press (comments, below), as does one being moved on this // page until its move ends; with no button down the Fill & Sign tool's preview hides where a // press would be taken (a mark, its handles, a Prepare field). let over_mark = matches!(tool, QuickTool::Fill(_)) - && (matches!(press, PagePress::Mine(_)) || comments::moving_on(view, i) || !ptr.any_down && top(under.iter().cloned(), tool).is_some()); + && (matches!(press, PagePress::Mine(_)) || comments::moving_on(view, i) || !ptr.any_down && hovered.is_some()); tool_input(ui, view, frame, slot, &pcx, press, over_mark); let on_field = if preparing && !placing { let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, press, view); @@ -132,61 +185,45 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let text = view.page_text(i); let over_text = |p: Pos2| { let (vx, vy) = xf.screen_to_view(p); - text.as_ref().is_some_and(|t| t.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3])) + text.as_ref().is_some_and(|t| t.glyph_at(vx, vy).is_some()) }; crate::redact_ui::page_input(cx, over_text, press == PagePress::Free, view) }; - let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, view); - // Add text acts on the page's own paragraphs over them, and on the one its press was on until - // the button is up: a drag that leaves the box still ends, and no new box takes it over. - let over_source = tool == QuickTool::AddText - && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Paragraph { .. })) - || pointer.is_some_and(|p| { - !crate::content_ui::hits_added(&xf, info, i, added, p) && crate::edit_text_ui::over_paragraph(&xf, info, i, &lines, p) - })); - let source_blocked = over_source && view.content.hold_blocked(); - if over_source && resp.clicked() && !source_blocked { - if let Some(edit) = view.content.finish_draft(added) { - view.pending_edit = Some(edit); - } - view.content.selected = None; + // Edit text's group: the objects selected together, added content, the paragraphs and the + // pictures, each told what the press is to this page. Edit text ignores input while a dialog, + // the palette, another window or another tab has it (`gate`), as before. + let gate = !(tool == QuickTool::EditText && view.objects.input_blocked()); + if tool == QuickTool::EditText && can_modify && gate { + crate::edit_text_ui::picture_keys(ui.ctx(), view, i, &images, ptr.any_down); } - let on_content = !on_objects && !placing && editing_content && can_modify && { - source_blocked - || crate::content_ui::page_input( - cx, - added, - tool == QuickTool::AddText && !over_source, - matches!(tool, QuickTool::AddText | QuickTool::EditText), - text_style, - view, - ) - || tool == QuickTool::AddText && !over_source + let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, press, view); + let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText); + let ccx = crate::content_ui::ContentCx { + added, + press, + gate, + picks_up, + adding_text: tool == QuickTool::AddText, + edit_on_click: matches!(tool, QuickTool::AddText | QuickTool::EditText), + style: text_style, }; - // Added content owns its gesture; its source text must not queue a second edit. - // Edit text & images boxes every paragraph and image whenever the tool is on. Only the - // input waits while selected objects or added content own the pointer, or while page - // input is blocked (another tab, a dialog, the window unfocused): the boxes stay drawn - // instead of vanishing until the pointer comes back. - let others_own_pointer = on_objects || on_content; - let on_edit_text = (tool == QuickTool::EditText || over_source) && can_modify && { - // Editable text takes precedence over background/overlapping artwork. Both - // inventories are painted every frame, even when text owns the pointer. - let text_used = crate::edit_text_ui::page_input(cx, &lines, view, !others_own_pointer); - let image_cx = crate::edit_text_ui::ImageCx { - ui, - resp, - xf: &xf, - ptr, - page: i, - info, - allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used), - }; + let on_content = !placing && editing_content && can_modify && crate::content_ui::page_input(cx, ccx, view); + // Add text acts on the page's own paragraphs: the one its press was on, until the button is up, + // and the one under the pointer. Edit text boxes every paragraph and picture. + let paragraph = |o: Option| matches!(o, Some(Owner::Paragraph { .. })); + let source = tool == QuickTool::AddText && (paragraph(press.owner()) || paragraph(hovered.as_ref().map(|h| h.owner))); + let on_edit_text = (tool == QuickTool::EditText || source) && can_modify && { + let adding = (tool == QuickTool::AddText).then_some(added.as_slice()); + let pcx = crate::edit_text_ui::ParagraphCx { press, gate, hover: hovered.as_ref(), adding }; + let text_used = crate::edit_text_ui::page_input(cx, &lines, pcx, view); + let image_cx = crate::edit_text_ui::ImageCx { ui, resp, xf: &xf, ptr, page: i, info, press, gate }; let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view); - !others_own_pointer && (text_used || image_used) + text_used || image_used }; if tool == QuickTool::EditText { - if others_own_pointer { + // A press the objects or added content took leaves the picture unselected; so does one + // while Edit text's input is blocked. Otherwise added content follows the picture selected. + if on_objects || on_content { if ptr.pressed().is_some() || resp.clicked() || resp.secondary_clicked() { view.image_selection = None; } @@ -204,20 +241,19 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut } // Comments, every frame: their own presses, a commenting tool's, and a click elsewhere that // leaves them unselected. A press nobody holds that another tool took this frame (Prepare's - // selection rectangle, added content, which still decide for themselves) is not also a - // commenting tool's, as before. + // selection rectangle, added content) is not also a commenting tool's, as before. let taken = on_objects || on_edit_text || on_link || on_content || boxing || on_field; let on_comment = comments::page_input(ui, resp, &pcx, press, taken, view); let consumed = taken || on_comment; let hover = pointer.and_then(|p| claim_under(&hits, view, p, frame.env.unobstructed)); - PageInput { consumed, area_tool, hover, released_over } + PageInput { press, text_drag: !view.router.keeps_drag(), consumed, area_tool, hover, released_over } } /// What lies under the pointer at `p`, after the tools act: its top, what a press there would take, /// for hover painting. With `claim` (nothing drawn over the pages, such as the palette), its cursor /// and tip too, after the tools claimed theirs (the last claim on a layer stands). fn claim_under(hits: &PageHits<'_>, view: &mut DocView, p: Pos2, claim: bool) -> Option { - let under = hits.at(view, p); + let under = hits.at(view, p, None); if claim { for h in &under { view.cursor.claim_with_tip(h.layer, h.icon, h.tip.clone()); @@ -334,7 +370,8 @@ fn tool_input( /// A right-click at `p`: the page's menu opens there, and it goes to what lies on top there /// ([`PageHits::context_at`]), which the menu then acts on. A comment there is selected, and none -/// elsewhere; a Prepare field is selected; links take theirs from what this returns. +/// elsewhere; a Prepare field is selected; links and Edit text's pictures take theirs from what this +/// returns. /// `under`: the hits at `p` before the tools act. `press_held`: the primary button is held, and the /// comment and Prepare selections its press acts on are left alone until its release; links still /// take theirs, as before. @@ -342,16 +379,16 @@ fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mu let context = hits.context_at(view, p, under); // Delete page, Paste and Copy Image act where the menu opened, whoever takes the right-click. comments::anchor(hits.pcx, p, view); + // The menu is what the right-click went to (Add text's is the page's), when that is selected: + // under a held press, the selections stay as they are. + view.menu_for = context.filter(|_| !(hits.tool == QuickTool::AddText && hits.can_modify)).map(|o| (hits.page, o)); if press_held { return context; } - // Add text takes every right-click on a page it can change, Edit text those on its own - // paragraphs and pictures (the objects selected together, and crop mode's picture, among them), - // the Link tool those on a link, and a Prepare field tool the whole page, as before: comments - // see none of them. + // Add text takes every right-click on a page it can change, the Link tool those on a link, and a + // Prepare field tool the whole page, as before: comments see none of them. let tool_takes_it = match hits.tool { QuickTool::AddText => hits.can_modify, - QuickTool::EditText => under.iter().any(|h| matches!(h.owner, Owner::Paragraph { .. } | Owner::Picture { .. } | Owner::Objects { .. })), QuickTool::Link => hits.can_modify && matches!(context, Some(Owner::Link { .. })), _ => hits.field_tool.is_some(), }; @@ -413,14 +450,26 @@ struct PageHits<'a> { } impl PageHits<'_> { - /// The hits at `p`, bottom up ([`Self::hits`]). - fn at(&self, view: &DocView, p: Pos2) -> Vec { - self.hits(view, p, false) + /// Edit text's crop mode is on for a picture on this page. + fn cropping(&self, view: &DocView) -> bool { + self.tool == QuickTool::EditText && self.can_modify && view.image_selection.as_ref().is_some_and(|s| s.page == self.page && s.crop.is_some()) + } + + /// The hits at `p`, bottom up ([`Self::hits`]). For a press with Shift or ⌘ in Edit text, the + /// page's `objects` ([`crate::object_ui::ObjectSelection::inventory`]), which take it whole. + fn at(&self, view: &DocView, p: Pos2, objects: Option<&Result, String>>) -> Vec { + self.hits(view, p, false, objects) } /// What a right-click at `p` is on: what lies on top there, as for a press, but with every - /// tool reaching comments and links, as a comment's menu shows whatever the tool. A highlight - /// over a link is the highlight's ([`comments::context_hit`]). + /// tool reaching comments, as a comment's menu shows whatever the tool, and a Prepare field + /// whatever the field tool. Links count for the tools that reach them (Select; the Link tool, + /// below). Edit text's paragraphs have no menu, and give way to the picture under them (the + /// user's ruling): with Edit text, a picture under a link or a paragraph takes the right-click. + /// Nor have added content or an open paragraph's frame: those give way to a picture under them, + /// and to nothing else. (Where objects are selected together, the page view unselects the picture + /// a right-click selects, and the menu is the page's.) + /// A highlight over a link is the highlight's ([`comments::context_hit`]). /// The Link tool edits links and reaches no comment: on a document it can change, its /// right-click is what its press there would take (`under`, the hits at `p`), when that is /// anything. @@ -431,17 +480,36 @@ impl PageHits<'_> { { return Some(press.owner); } - top(self.hits(view, p, true), QuickTool::Select).map(|h| h.owner) + let reached: Vec<_> = self + .hits(view, p, true, None) + .into_iter() + .filter(|h| (h.layer != Layer::Link || h.layer.reached_by(self.tool)) && !matches!(h.owner, Owner::Paragraph { .. })) + .collect(); + let on_top = top(reached.iter().cloned(), QuickTool::Select)?.owner; + if self.tool == QuickTool::EditText && matches!(on_top, Owner::Added { .. } | Owner::Objects { part: crate::page_router::ObjectsPart::Frame }) + { + let pictures = reached.into_iter().filter(|h| matches!(h.owner, Owner::Picture { .. })); + return Some(top(pictures, QuickTool::Select).map_or(on_top, |h| h.owner)); + } + Some(on_top) } /// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text /// on top of artwork takes the press), added content, the objects selected together and the /// open paragraph's frame, the selected picture's grips over them all; the camera tool's corners; /// then form fields (Prepare's, being moved, over the comments; filled ones under added content - /// and the comments that move). In crop mode, only the picture being cropped. + /// and the comments that move). For a press with Shift or ⌘ in Edit text, only the objects + /// (`objects`, the page's); in crop mode, only the picture being cropped, which a right-click + /// reaches only on the crop. /// For a right-click (`context`), comments by [`comments::context_hit`], and no camera corner (it /// has no menu). - fn hits(&self, view: &DocView, p: Pos2, context: bool) -> Vec { + fn hits( + &self, + view: &DocView, + p: Pos2, + context: bool, + objects: Option<&Result, String>>, + ) -> Vec { let PageHits { page, xf, @@ -461,13 +529,24 @@ impl PageHits<'_> { images, clip, } = *self; - // Crop mode owns every press on its page (`edit_text_ui::crop_hit`). - if tool == QuickTool::EditText - && can_modify - && let Some(crop) = crate::edit_text_ui::crop_hit(view, page) - { - return vec![crop]; + // A press with Shift or ⌘ is the objects', crop mode's page included (`object_ui::modified_hit`). + if let Some(objects) = objects { + return crate::object_ui::modified_hit(view, xf, info, page, objects, clip, p).into_iter().collect(); } + // Crop mode holds every press on its page (`edit_text_ui::crop_hit`). A right-click is the + // crop's only on the picture being cropped, its box or its grips (#5835), where it keeps crop + // mode and opens no menu; elsewhere it goes to what lies there, as without crop mode. + if self.cropping(view) { + use crate::page_router::{CropPart, PicturePart}; + let crop = crate::edit_text_ui::crop_hit(xf, page, info, images, view, p); + let on_crop = crop.as_ref().is_some_and(|h| !matches!(h.owner, Owner::Picture { part: PicturePart::Crop(CropPart::Page), .. })); + if !context || on_crop { + return crop.into_iter().collect(); + } + } + // A right-click reaches a Prepare field whatever the field tool, and while a table is set up, + // on a document that can be changed. + let (field_tool, prepare_allowed) = if context { (None, can_modify) } else { (field_tool, prepare_allowed) }; let fields = if preparing { crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p) } else if tool == QuickTool::Select { @@ -484,9 +563,9 @@ impl PageHits<'_> { .filter(|e| !e.has_changes()) .and_then(|e| lines.get(e.block)) .map(|l| xf.user_rect(info, page, l.rect.map(|v| v as f32)).expand(2.0)); - // Add text acts on the page's paragraphs only in their boxes, edges included (it - // finishes the box being typed there, `over_source`): not on pictures, nor beside a - // paragraph, where a click starts a new box. + // Add text acts on the page's paragraphs only in their boxes, edges included (its click + // there finishes the box being typed and opens the paragraph, `ParagraphCx::adding`): + // not on pictures, nor beside a paragraph, where a click starts a new box. let edit_text = tool == QuickTool::EditText; let on_paragraphs = edit_text || crate::edit_text_ui::over_paragraph(xf, info, page, lines, p); ( @@ -511,7 +590,7 @@ impl PageHits<'_> { /// Text layer: find matches, selection, I-beam and drag-to-select. pub(super) fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { - let PageEnv { info, selects_text, tool, .. } = frame.env; + let PageEnv { selects_text, tool, .. } = frame.env; let PageSlot { i, r, xf } = *slot; let PageFrame { resp, ptr, pointer, .. } = *frame; if let Some(text) = view.texts.get(&i).cloned() { @@ -520,7 +599,11 @@ pub(super) fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut Page && !input.consumed && let Some(p) = pointer.filter(|p| r.contains(*p)) { - text_pointer_input(ui, view, &text, TextPointer { page: i, xf, rect: r, info, tool, resp, ptr: ptr.clone(), pointer: p }); + // The I-beam drops where a press would go to a link (ruling E: the tools that select a + // link's text show it there). + let over_link = input.hover.as_ref().is_some_and(|h| matches!(h.owner, Owner::Link { .. })); + let pointer = TextPointer { page: i, xf, rect: r, tool, resp, ptr: ptr.clone(), pointer: p, over_link, text_drag: input.text_drag }; + text_pointer_input(ui, view, &text, pointer); } } } @@ -565,11 +648,14 @@ struct TextPointer<'a> { page: usize, xf: PageXform, rect: Rect, - info: &'a DocInfo, tool: QuickTool, resp: &'a egui::Response, ptr: PagePointer, pointer: Pos2, + /// A press under the pointer would go to a link. + over_link: bool, + /// The press's drag is the page's text's ([`PageInput::text_drag`]). + text_drag: bool, } struct TextGesture<'a> { @@ -584,16 +670,16 @@ struct TextGesture<'a> { vy: f32, over_text: bool, over_link: bool, + text_drag: bool, column: bool, } fn text_pointer_input(ui: &mut egui::Ui, view: &mut DocView, text: &PageText, pointer: TextPointer<'_>) { - let TextPointer { page, xf, rect, info, tool, resp, ptr, pointer: screen_pointer } = pointer; + let TextPointer { page, xf, rect, tool, resp, ptr, pointer: screen_pointer, over_link, text_drag } = pointer; let (vx, vy) = xf.screen_to_view(screen_pointer); - let over_text = text.glyphs.iter().any(|glyph| vx >= glyph.rect[0] && vx <= glyph.rect[2] && vy >= glyph.rect[1] && vy <= glyph.rect[3]); - let over_link = info.links.iter().any(|link| link.page == page && xf.user_rect(info, page, link.rect).contains(screen_pointer)); + let over_text = text.glyph_at(vx, vy).is_some(); let column = tool == QuickTool::ColumnSelect || ui.input(|input| input.modifiers.alt); - let gesture = TextGesture { page, xf, rect, tool, ptr, resp, pointer: screen_pointer, vx, vy, over_text, over_link, column }; + let gesture = TextGesture { page, xf, rect, tool, ptr, resp, pointer: screen_pointer, vx, vy, over_text, over_link, text_drag, column }; update_text_cursor(view, &gesture); update_selection_on_press(ui, view, text, &gesture); update_dragged_selection(view, text, &gesture); @@ -613,7 +699,7 @@ fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText, // A press held by what keeps its drag (a link, a comment that moves) selects no text; one on // text markup lets its drag select the text underneath. let press_here = gesture.ptr.origin.is_some_and(|origin| gesture.rect.contains(origin)); - if gesture.ptr.pressed().is_some() && press_here && !view.router.keeps_drag() { + if gesture.ptr.pressed().is_some() && press_here && gesture.text_drag { let now = ui.input(|input| input.time); let (max_delay, max_dist) = ui.ctx().options(|options| (options.input_options.max_double_click_delay, options.input_options.max_click_dist)); let consecutive = now - view.last_press_time <= max_delay && view.last_press_pos.is_some_and(|pos| pos.distance(gesture.pointer) <= max_dist); @@ -628,7 +714,7 @@ fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText, view.selection = Some(Selection::run(gesture.page, first, last)); } } - if gesture.resp.drag_started() && press_here && !view.router.keeps_drag() { + if gesture.ptr.drag_started && press_here && gesture.text_drag { view.actions_dismissed = None; view.selection_drag = true; let origin = gesture.ptr.origin.unwrap_or(gesture.pointer); @@ -649,7 +735,7 @@ fn selection_click_count(clicks: u32, tool: QuickTool) -> u32 { fn update_dragged_selection(view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { let clicks = selection_click_count(view.clicks, gesture.tool); - if gesture.resp.dragged() + if gesture.resp.dragged_by(egui::PointerButton::Primary) && let Some(selection) = view.selection.as_mut().filter(|selection| selection.page == gesture.page) { match &mut selection.span { @@ -700,7 +786,7 @@ fn finish_text_pointer_gesture(ui: &egui::Ui, view: &mut DocView, text: &PageTex && let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor)) { view.selection = Some(Selection::run(gesture.page, first, last)); - } else if gesture.resp.clicked() && view.clicks < 2 && !view.router.keeps_drag() { + } else if gesture.resp.clicked() && view.clicks < 2 && gesture.text_drag { let shift = ui.input(|input| input.modifiers.shift); match ( view.selection.as_mut().filter(|selection| shift && selection.page == gesture.page).map(|selection| &mut selection.span), @@ -740,8 +826,13 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P let painter = ui.painter(); let tool_cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; - if tool == QuickTool::Select && !input.consumed { - crate::image_pick::page_input(resp, &xf, i, info, doc, pointer, view); + // A press nobody holds picks the picture under it: a link's click follows the link only (ruling + // 5), though its drag or double-click still lets the picked picture go, as does a drag the page + // it is over first sees, whichever page it began on. + let pick_sees = + matches!(input.press, PagePress::Free | PagePress::Mine(Owner::Link { .. })) || input.press == PagePress::Elsewhere && ptr.drag_started; + if tool == QuickTool::Select && pick_sees && !input.consumed { + crate::image_pick::page_input(resp, &xf, i, info, doc, ptr, input.press == PagePress::Free, view); } crate::image_pick::drop_stale(doc, view); crate::image_pick::paint(painter, &xf, i, info, view); @@ -797,7 +888,7 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P && resp.clicked() && tool == QuickTool::Select && !input.consumed - && let Some(Owner::Link { index }) = view.router.owner(i) + && let PagePress::Mine(Owner::Link { index }) = input.press && input.released_over && let Some(l) = info.links.iter().find(|l| l.page == i && l.index == index) { diff --git a/crates/ui-egui/src/canvas/page_pointer.rs b/crates/ui-egui/src/canvas/page_pointer.rs index 38b028de24..f3a1b13647 100644 --- a/crates/ui-egui/src/canvas/page_pointer.rs +++ b/crates/ui-egui/src/canvas/page_pointer.rs @@ -11,7 +11,7 @@ //! Dialogs are drawn after the pages, so on the frame one opens egui has not met its layer yet: //! the reading also takes whether one is open. -use egui::{Event, PointerButton, Pos2, Rect}; +use egui::{Event, Modifiers, PointerButton, Pos2, Rect}; /// A primary-button press or release, and where it happened. #[derive(Clone, Copy, Debug, PartialEq)] @@ -46,13 +46,19 @@ pub(crate) struct PagePointer { pub primary_down: bool, /// Some button is down, wherever it was pressed. pub any_down: bool, + /// The modifier keys held at this frame's first primary press, or else now: a press with Shift + /// or ⌘ adds to a selection. + pub modifiers: Modifiers, + /// A primary press the pages hold turned into a drag this frame. egui's own `drag_started()` + /// counts every button, so a right-button drag would start what a left-button drag does. + pub drag_started: bool, } impl PagePointer { /// The pointer for `resp`, the content widget that holds every page, allocated in `ui`, while /// a dialog is open over the window or not (`modal`). pub(crate) fn read(ui: &egui::Ui, resp: &egui::Response, modal: bool) -> Self { - let (at, origin, edges, released, primary_down, any_down) = ui.input(|i| { + let (at, origin, edges, released, primary_down, any_down, modifiers) = ui.input(|i| { ( i.pointer.interact_pos(), i.pointer.press_origin(), @@ -60,6 +66,7 @@ impl PagePointer { last_release(&i.events), i.pointer.primary_down(), i.pointer.any_down(), + press_modifiers(&i.events).unwrap_or(i.modifiers), ) }); // egui's own answer to "do the pages hold a press?": from the press on until the frame @@ -75,9 +82,10 @@ impl PagePointer { resp.dragged() || resp.drag_stopped() || (!modal && (resp.interact_pointer_pos().is_some() || press_origin.is_some_and(|p| on_top(&p)))); let pos = at.filter(|p| held || on_top(p)); if !held { - return Self { pos, origin: None, edges: Vec::new(), released: None, primary_down, any_down }; + return Self { pos, origin: None, edges: Vec::new(), released: None, primary_down, any_down, modifiers, drag_started: false }; } - Self { pos, origin: press_origin, edges, released, primary_down, any_down } + let drag_started = resp.drag_started_by(PointerButton::Primary); + Self { pos, origin: press_origin, edges, released, primary_down, any_down, modifiers, drag_started } } /// Where the pointer is, when it is inside `r`. @@ -108,6 +116,14 @@ fn first_press(edges: &[Edge]) -> Option { }) } +/// The modifier keys held at the first primary press among `events`. +fn press_modifiers(events: &[Event]) -> Option { + events.iter().find_map(|e| match e { + Event::PointerButton { button: PointerButton::Primary, pressed: true, modifiers, .. } => Some(*modifiers), + _ => None, + }) +} + /// Where the last primary release among `events` was, however many came before it. fn last_release(events: &[Event]) -> Option { events.iter().rev().find_map(|e| match e { diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 3f5d1e105f..21698e7f95 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -207,7 +207,7 @@ const PAGE_MODULES: [&str; 37] = [ /// What a file that acts on the pages uses: the pointer, a tool's or the comments' page context, /// what a press is to a page, the router's hits and owners, or the router itself. Such a file is a /// page module. One that acts on the pages through the page view's response and a bare point alone -/// uses none of them (`canvas/image_pick.rs`): PAGE_MODULES lists it by hand. +/// would use none of them, and PAGE_MODULES would list it by hand. const PAGE_MODULE_MARKS: [&str; 6] = ["PagePointer", "PageToolContext", "PageCx", "PagePress", "page_router::", ".router."]; /// The router's own files, which define what page modules use. @@ -260,7 +260,11 @@ fn declares(line: &str) -> Option<&str> { let mut rest = line; loop { let before = rest; - for prefix in ["pub(crate) ", "pub(super) ", "pub ", "const ", "async ", "unsafe "] { + // `pub(crate)`, `pub(super)`, `pub(in crate::canvas)` and the like. + if let Some(scoped) = rest.strip_prefix("pub(") { + rest = scoped.split_once(") ").map_or(rest, |(_, after)| after); + } + for prefix in ["pub ", "const ", "async ", "unsafe "] { rest = rest.strip_prefix(prefix).unwrap_or(rest); } if rest == before { @@ -271,6 +275,19 @@ fn declares(line: &str) -> Option<&str> { rest.find(|c: char| !(c.is_alphanumeric() || c == '_')).map(|end| &rest[..end]) } +/// egui's drag start, which counts every button: a right-button drag would start what a left one +/// does. Page modules read `PagePointer::drag_started`, the primary button's, or ask for that button +/// (`drag_started_by`). +const ANY_BUTTON_DRAG: [&str; 1] = [".drag_started()"]; + +/// Functions in the page modules that start a drag of a widget of their own, not of the pages: +/// (file, function, why). +const NOT_PAGE_DRAGS: [(&str, &str, &str); 3] = [ + ("canvas/organize.rs", "drag_pages", "Organize Pages drags thumbnails, not pages"), + ("tools/fill_sign.rs", "signature_pad", "the signature dialog's drawing pad"), + ("tools/fill_sign/scan.rs", "scan_tab", "the signature dialog's Scan tab"), +]; + /// Ways to set egui's cursor. const CURSOR_WRITES: [&str; 4] = ["set_cursor_icon(", ".cursor_icon = ", "on_hover_cursor(", "on_hover_and_drag_cursor("]; @@ -462,6 +479,14 @@ fn page_modules_read_the_pointer_only_through_page_pointer() { } } } + problems.extend(misuses( + &ANY_BUTTON_DRAG, + &NOT_PAGE_DRAGS, + "starts a drag with any button: a right-button drag would start what a left one does. Read \ + `PagePointer::drag_started` (the primary button's) instead. A widget of its own, not the pages? Add the \ + function to NOT_PAGE_DRAGS in src/canvas/page_pointer/tests.rs, with why.", + "starts no drag of its own any more: remove it from NOT_PAGE_DRAGS.", + )); assert!(problems.is_empty(), "{}", problems.join("\n")); } @@ -483,20 +508,22 @@ fn page_modules_claim_the_cursor_and_never_set_it() { } /// What decides, keeps or drops the press the pages hold. -const ROUTER_WRITES: [&str; 8] = [ +const ROUTER_WRITES: [&str; 10] = [ "router.begin(", "router.press(", + "router.drag_began(", "router.settle(", "router.cancel(", "PageRouter::begin(", "PageRouter::press(", + "PageRouter::drag_began(", "PageRouter::settle(", "PageRouter::cancel(", ]; /// The functions that decide, keep or drop the press the pages hold: (file, function, why). const ROUTER_WRITERS: [(&str, &str, &str); 3] = [ - ("canvas/page_input.rs", "page_tool_input", "the hits at the press point decide its owner, once per press"), + ("canvas/page_input.rs", "page_tool_input", "the hits at the press point decide its owner, once per press, and a declined drag lets it go"), ("canvas.rs", "draw_pages", "the press is over once the button is up and its release seen, and a new one drops the last owner"), ("canvas.rs", "cancel_pointer_gestures", "every pointer gesture's cancel: Escape, automation's cancel"), ]; @@ -523,11 +550,12 @@ fn hit_test_calls(line: &str) -> Vec { /// The functions that call a hit test: (file, function, why). Hit tests are found by name (a /// function named `hit` or `…_hit`, called by path, bare or as a method). The comments' point test -/// has a list of its own (`COMMENT_POINT_TESTERS`); the others by other names (added content's, -/// Edit text's paragraphs', Redact's text test, the text layer's links) are not listed yet, and -/// most are PR 4b's. `PageHits::hits` is the one: the hits -/// at the press point decide its owner, and those under the pointer give its cursor, its tip, hover -/// painting and a right-click. +/// has a list of its own (`COMMENT_POINT_TESTERS`). Point tests by other names are not seen: what is +/// left of them paints (a picture's hover outline), keeps a behaviour no hit stands for (added +/// text's click with the tools that don't pick it up, crop mode's controls) or tells a tool that a +/// press nobody holds began on its page (the text layer's `press_here`, redaction's). `PageHits::hits` +/// is the one: the hits at the press point decide its owner, and those under the pointer give its +/// cursor, its tip, hover painting and a right-click. const HIT_TESTERS: [(&str, &str, &str); 1] = [( "canvas/page_input.rs", "hits", @@ -566,27 +594,38 @@ const COMMENT_POINT_TESTERS: [(&str, &str, &str); 3] = [ ]; /// What reads who holds the press the pages hold. -const ROUTER_READS: [&str; 6] = - ["router.press_for(", "router.owner(", "router.keeps_drag(", "PageRouter::press_for(", "PageRouter::owner(", "PageRouter::keeps_drag("]; +const ROUTER_READS: [&str; 4] = ["router.press_for(", "router.keeps_drag(", "PageRouter::press_for(", "PageRouter::keeps_drag("]; /// The functions that read who holds the press: (file, function, why). The page view asks once per -/// page and tells each module what the press is to its page (`PagePress`); the Edit text group, the -/// text layer and link following still ask, until they are told too (PR 4b), and an unused entry -/// fails the test. -const ROUTER_READERS: [(&str, &str, &str); 8] = [ - ( - "canvas/page_input.rs", - "page_tool_input", - "the page view: what the press is to each page (`press_for`); Add text's paragraph and the pictures until 4b", - ), - ("canvas/page_input.rs", "update_selection_on_press", "the text layer selects no text under a press held by what keeps its drag (4b)"), - ("canvas/page_input.rs", "finish_text_pointer_gesture", "a click held by what keeps its drag leaves the text selection alone (4b)"), - ("canvas/page_input.rs", "page_overlays", "a click follows the link that holds its press (4b)"), - ("tools/content_ui.rs", "page_input", "added text and pictures, until they are told (4b)"), - ("tools/edit_text_ui.rs", "page_input", "Edit text's paragraphs, until they are told (4b)"), - ("tools/edit_text_ui/image.rs", "image_input", "Edit text's pictures, until they are told (4b)"), - ("tools/object_ui.rs", "input", "the objects selected together, until they are told (4b)"), -]; +/// page and tells each module what the press is to its page (`PagePress`): modules never read the +/// router, and outside `canvas` the compiler refuses them (its methods are `pub(in crate::canvas)`). +/// An unused entry fails the test. +const ROUTER_READERS: [(&str, &str, &str); 1] = [( + "canvas/page_input.rs", + "page_tool_input", + "the page view: what the press is to each page (`press_for`), and whether its drag is the text's (`keeps_drag`)", +)]; + +/// Words a page module's functions are not named with: a function that grabs or owns a press decides +/// for itself whether the press is its own, which the page view tells it (`PagePress`). +const PRESS_CLAIMING_WORDS: [&str; 2] = ["grabs", "owns"]; + +/// The function `line` declares, when one of the words of its name is one of +/// [`PRESS_CLAIMING_WORDS`]. +fn press_claiming_fn(line: &str) -> Vec { + declares(line.trim_start()) + .filter(|name| name.split('_').any(|word| PRESS_CLAIMING_WORDS.contains(&word))) + .map(str::to_owned) + .into_iter() + .collect() +} + +/// Whether `line` takes the router whole (`let r = &view.router;`), which the checks by method +/// name would not see through: anything but a method call on it. +fn takes_the_router(line: &str) -> bool { + line.match_indices(".router") + .any(|(at, name)| line[at + name.len()..].chars().next().is_none_or(|c| c != '.' && !(c.is_alphanumeric() || c == '_'))) +} /// What ignores the press the pages hold until the button is up. const PRESS_IGNORES: [&str; 1] = ["press_cancelled = true"]; @@ -595,34 +634,6 @@ const PRESS_IGNORES: [&str; 1] = ["press_cancelled = true"]; const PRESS_IGNORERS: [(&str, &str, &str); 1] = [("canvas/page_input.rs", "cancel_on_escape", "Escape abandons the gesture the press drives, whichever tool's")]; -/// The module hit tests, called by path: a press handler calls none of them (HIT_TESTERS says who -/// may). -const PAGE_HIT_CALLS: [&str; 12] = [ - "link_ui::hit(", - "prepare::hit(", - "forms_ui::hit(", - "comments::hit(", - "comments::context_hit(", - "camera_ui::hit(", - "edit_text_ui::hit(", - "edit_text_ui::picture_hit(", - "edit_text_ui::picture_handle_hit(", - "edit_text_ui::crop_hit(", - "object_ui::hit(", - "content_ui::hit(", -]; - -/// Whether a press handler's `code` acts on the owner the router captured, and calls no hit test -/// of its own to choose one. -fn routed_press_handler(code: &str) -> bool { - let code = code.lines().filter(|line| !line.trim_start().starts_with("//")).collect::>().join("\n"); - code.contains("router.owner(") - && !PAGE_HIT_CALLS.iter().any(|call| code.contains(call)) - && !uses(&code, "hit(") - && !uses(&code, "body_hit(") - && !uses(&code, "handle_hit(") -} - /// Page modules act on a press only when the router says it is theirs. The hits at the press /// point decide its owner once, in one place, and a `Hit` comes only from a module's hit test, /// so what the cursor promised is what the press does. Where each module tested the press @@ -653,7 +664,7 @@ fn page_modules_act_on_a_press_only_through_the_router() { // functions use it. let known: Vec<&str> = ROUTER_WRITES.iter().chain(ROUTER_READS.iter()).copied().collect(); let unlisted = page_module_matches(|line| { - let router = uses(line, ".router.") || uses(line, "PageRouter::"); + let router = uses(line, ".router.") || uses(line, "PageRouter::") || takes_the_router(line); if router && !known.iter().any(|name| uses(line, name)) { vec!["router".to_owned()] } else { Vec::new() } }); problems.extend( @@ -687,6 +698,13 @@ fn page_modules_act_on_a_press_only_through_the_router() { place that must ask? Add the function to PAGE_HITS_ASKERS in src/canvas/page_pointer/tests.rs, with why.", "no longer asks the page view's hits: remove it from PAGE_HITS_ASKERS.", )); + // A module is told what the press is to its page; none decides it for itself. + problems.extend(page_module_matches(press_claiming_fn).into_iter().map(|(file, _, n, name)| { + format!( + "src/{file}:{n}: `fn {name}` decides whether a press is its module's. The page view tells each module what the \ + press is to its page (`PagePress`): act on that, in a function named for what it does." + ) + })); problems.extend(misuses( &PRESS_IGNORES, &PRESS_IGNORERS, @@ -694,19 +712,6 @@ fn page_modules_act_on_a_press_only_through_the_router() { `DocView::cancel_pointer_gestures` instead.", "no longer ignores a press: remove it from PRESS_IGNORERS.", )); - let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); - for (file, function) in [ - ("tools/content_ui.rs", "page_input"), - ("tools/edit_text_ui.rs", "page_input"), - ("tools/edit_text_ui/image.rs", "image_input"), - ("tools/object_ui.rs", "input"), - ] { - let source = std::fs::read_to_string(src.join(file)).expect("page input module"); - let body = source.lines().skip_while(|line| declares(line) != Some(function)).take_while(|line| *line != "}").collect::>().join("\n"); - if !routed_press_handler(&body) { - problems.push(format!("src/{file}: {function} must consume the captured router owner without repeating a hit test")); - } - } // A hit test is a function named `hit` or `…_hit`: one answer to "what of mine is here?", // which gives the module's cursor, its tip and its press. problems.extend(misuses_in( @@ -720,14 +725,6 @@ fn page_modules_act_on_a_press_only_through_the_router() { "calls no hit test any more: remove it from HIT_TESTERS.", )); problems.extend(hit_test_aliases()); - // An entry no page module calls any more no longer guards anything. - let called = page_module_uses(&PAGE_HIT_CALLS); - problems.extend( - PAGE_HIT_CALLS - .iter() - .filter(|call| !called.iter().any(|(_, _, _, name)| name == *call)) - .map(|call| format!("no page module calls `{call}` any more: remove it from PAGE_HIT_CALLS.")), - ); let builds = page_module_uses(&HIT_BUILDS); assert!(!builds.is_empty(), "no page module makes a `Hit` any more: update HIT_BUILDS"); problems.extend(builds.into_iter().filter(|(_, function, _, _)| !function.as_deref().is_some_and(is_hit_test)).map(|(file, _, n, name)| { @@ -758,6 +755,15 @@ fn the_page_guards_read_hit_tests_split_calls_and_aliases() { assert!(hit_test_calls("let all = self.hits(view, p, false);").is_empty()); assert_eq!(declares("pub(crate) const fn crop_hit<'a>(view: &DocView) {"), Some("crop_hit")); assert_eq!(declares("let hit = 1;"), None); + assert_eq!(press_claiming_fn(" pub(crate) fn fill_grabs(view: &DocView) -> bool {"), ["fill_grabs"]); + assert_eq!(press_claiming_fn("fn owns_press() {"), ["owns_press"]); + assert_eq!(press_claiming_fn(" pub(in crate::canvas) fn link_grabs(view: &DocView) {"), ["link_grabs"]); + assert!(press_claiming_fn("fn dropdowns() {").is_empty(), "a word that ends in one"); + assert_eq!(declares("pub(in crate::canvas) fn drag_began(&mut self) {"), Some("drag_began")); + assert!(takes_the_router("let r = &view.router;") && takes_the_router("keep(view.router, 1)")); + assert!(!takes_the_router("view.router.press_for(i)") && !takes_the_router("view.routers.len()")); + assert!(press_claiming_fn("fn holds(&self, page: usize) -> bool {").is_empty()); + assert!(press_claiming_fn("let grabs = 1;").is_empty()); let source = "impl PageHits<'_> {\n fn at(&self) {\n self\n .hits(view, p, false)\n }\n}\nfn after() {\n // view.router.owner(0)\n let _ = view\n .router\n .owner(0);\n}\n"; assert_eq!( code_lines(source), @@ -777,13 +783,3 @@ fn the_page_guards_read_hit_tests_split_calls_and_aliases() { let aliases: Vec<(String, String)> = [("hit", "corner_under"), ("picture_hit", "under")].map(|(a, b)| (a.to_owned(), b.to_owned())).to_vec(); assert_eq!(aliased_hit_tests(source), aliases); } - -#[test] -fn the_page_press_guard_rejects_missing_owners_and_second_hit_tests() { - assert!(routed_press_handler("let owner = view.router.owner(page);")); - assert!(!routed_press_handler("// view.router.owner(page)\nlet owner = None;")); - assert!(!routed_press_handler("let owner = view.router.owner(page);\nlet again = cx.hit(origin);")); - for call in PAGE_HIT_CALLS { - assert!(!routed_press_handler(&format!("let owner = view.router.owner(page);\n{call}origin);")), "{call}"); - } -} diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index e3b88d1a2c..30a3f2126f 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -6,10 +6,13 @@ //! press point decide who captures it ([`PageRouter::press`]), by the same table: the highest layer //! the active tool reaches. Until the button is up only the owner acts on the press, its drag and //! its release, so what the cursor promised is what the press does, and nothing takes a gesture -//! back halfway. Each page is told what it has to do with the press ([`PagePress`]): its owner's, -//! the active tool's, or none of its business. +//! back halfway; an owner that declines the drag (text markup, a link with the Link tool) lets it +//! go as it starts, and the drag is nobody's. Each page is told what it has to do with the press +//! ([`PagePress`]): its owner's, the active tool's, or none of its business. Only the page view +//! reads the router: its methods are `canvas`'s alone. use egui::CursorIcon; +use pdfcraft_engine::ObjectTarget; use crate::QuickTool; use crate::page_cursor::Layer; @@ -45,10 +48,31 @@ pub(crate) enum Owner { pub(crate) enum PicturePart { /// The picture: a click selects it, a drag moves the selected one. Body, + /// A picture lying over the selected one, within the selected one's frame: a click selects the + /// one on top, a drag moves the selected one. + OverSelected, /// A resize grip in `ImageObb::handles` order (four corners and four edge midpoints). Corner(usize), /// The selected picture's rotate knob: a drag turns it. Knob, + /// The picture being cropped, which holds every press on its page. + Crop(CropPart), +} + +/// What of a picture being cropped a hit is on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CropPart { + /// A grip of the box kept, in `crop_handles` order (four corners, then the top, right, bottom + /// and left edge midpoints): a drag moves that side or corner. + Grip(usize), + /// The box kept: a drag moves it over the picture. + Keep, + /// The picture outside the box kept, shown or cut away: a press there moves nothing, and a + /// right-click is the crop's, as on the box and its grips (#5835). + Cut, + /// The rest of the page, its Done and Cancel controls among it: a press there is crop mode's, + /// and moves nothing. + Page, } /// What of the objects a hit is on. @@ -58,6 +82,15 @@ pub(crate) enum ObjectsPart { Selection, /// The frame drawn around the paragraph open for typing: a click selects the box, a drag moves it. Frame, + /// A press with Shift or ⌘ (Ctrl): it adds the object under it to the objects selected + /// together, or takes it out of them, or on none starts an area selection that adds to them. + Toggle(Option), + /// A press with Shift or ⌘ while text being typed is open (a new box, or a paragraph with + /// changes), which must be finished or discarded first: it is held until the button is up, and + /// selects nothing. + Held, + /// A press with Shift or ⌘ on a page whose objects can't be selected together: it says so. + Refused, } /// The part of a paragraph a hit is on. @@ -102,7 +135,7 @@ impl Hit { /// The hit that wins among `hits` with `tool`: the highest layer the tool reaches, the last of /// equals (modules are asked bottom up, in paint order). -pub(crate) fn top(hits: impl IntoIterator, tool: QuickTool) -> Option { +pub(in crate::canvas) fn top(hits: impl IntoIterator, tool: QuickTool) -> Option { hits.into_iter().filter(|h| h.layer.reached_by(tool)).fold(None, |best: Option, h| match best { Some(b) if b.layer > h.layer => Some(b), _ => Some(h), @@ -115,13 +148,15 @@ pub(crate) enum PagePress { /// The press began on this page, and the owner's hit won it: its module acts on it. Others /// only let go of their own state (a click on anything but the selected comment unselects it). Mine(Owner), - /// No button is down, or the press began on this page and nobody's hit won it: the active - /// tool's. Also every page's while a press the page view does not hold is down (one that began + /// No button is down, or the press began on this page and nobody's hit won it, or its owner let + /// its drag go ([`PageRouter::drag_began`]): the active tool's. Also every page's while a + /// press the page view does not hold is down (one that began /// over a panel), and after the press was dropped ([`PageRouter::cancel`]) until the button is /// up (`Elsewhere` instead when Escape came in the press's own frame; page input skips a /// cancelled press either way). Free, - /// The press began on another page, or off the pages (between them, or beside one). + /// The press began on another page, or off the pages (between them, or beside one) where no + /// page has anything under it. Elsewhere, } @@ -137,9 +172,12 @@ impl PagePress { /// A press the pages hold, and who it belongs to. #[derive(Clone, Debug, PartialEq)] -pub(crate) struct Capture { +struct Capture { pub owner: Owner, pub declines_drag: bool, + /// What lay on top at the press with the objects selected together aside: a double-click on + /// them reaches it. + under_objects: Option, } /// The press the pages hold, from its frame until the button is up. @@ -153,25 +191,28 @@ pub(crate) struct PageRouter { impl PageRouter { /// A new press on the pages: the last one's owner goes, and until a page claims it the press is /// between the pages. - pub(crate) fn begin(&mut self) { + pub(in crate::canvas) fn begin(&mut self) { self.capture = None; self.began = Some(None); } - /// A press on `page`: `hits` at the press point decide who it belongs to. A press nobody's - /// hit claims is held by nobody (the page's text, or a tool that acts anywhere on the page). + /// A press on `page`, or past its edge where `hits` are under it (the selected picture's rotate + /// knob): `hits` at the press point decide who it belongs to. A press nobody's hit claims is held + /// by nobody (the page's text, or a tool that acts anywhere on the page). /// Only a press just begun ([`Self::begin`]) is claimed, by the first page that does: pages /// that touch (the gaps hidden) can share a row of pixels, and a press there is one page's. - pub(crate) fn press(&mut self, page: usize, hits: impl IntoIterator, tool: QuickTool) { + pub(in crate::canvas) fn press(&mut self, page: usize, hits: impl IntoIterator, tool: QuickTool) { if self.began != Some(None) { return; } - self.capture = top(hits, tool).map(|h| Capture { owner: h.owner, declines_drag: h.declines_drag }); + let hits: Vec = hits.into_iter().collect(); + let under_objects = top(hits.iter().filter(|h| !matches!(h.owner, Owner::Objects { .. })).cloned(), tool).map(|h| h.owner); + self.capture = top(hits, tool).map(|h| Capture { owner: h.owner, declines_drag: h.declines_drag, under_objects }); self.began = Some(Some(page)); } /// What `page` has to do with the press the pages hold. - pub(crate) fn press_for(&self, page: usize) -> PagePress { + pub(in crate::canvas) fn press_for(&self, page: usize) -> PagePress { match (self.began, &self.capture) { (None, _) => PagePress::Free, (Some(Some(p)), Some(capture)) if p == page => PagePress::Mine(capture.owner), @@ -180,19 +221,28 @@ impl PageRouter { } } - /// Who holds the press on `page`. - pub(crate) fn owner(&self, page: usize) -> Option { - self.press_for(page).owner() + /// What lay on top at the press with the objects selected together aside. + pub(in crate::canvas) fn under_objects(&self) -> Option { + self.capture.as_ref().and_then(|c| c.under_objects) } /// Whether the press is held by someone who keeps its drag: then the page's text is not /// selected by it. - pub(crate) fn keeps_drag(&self) -> bool { + pub(in crate::canvas) fn keeps_drag(&self) -> bool { self.capture.as_ref().is_some_and(|c| !c.declines_drag) } + /// The press turned into a drag: an owner that declines its drag lets it go, and from then on + /// the press is nobody's (a drag across a highlight selects the text under it; with the Link + /// tool, a drag from a link draws a new one). Its click stayed the owner's. + pub(in crate::canvas) fn drag_began(&mut self) { + if self.capture.as_ref().is_some_and(|c| c.declines_drag) { + self.capture = None; + } + } + /// The press is over once the button is up and its release has been seen. - pub(crate) fn settle(&mut self, any_down: bool, edges: bool) { + pub(in crate::canvas) fn settle(&mut self, any_down: bool, edges: bool) { if !any_down && !edges { self.capture = None; self.began = None; @@ -201,7 +251,7 @@ impl PageRouter { /// Drops the press (Escape, automation's cancel): every page is the active tool's again /// ([`PagePress::Free`]). Page input skips a press Escape cancelled until the button is up. - pub(crate) fn cancel(&mut self) { + pub(in crate::canvas) fn cancel(&mut self) { self.capture = None; self.began = None; } @@ -230,16 +280,16 @@ mod tests { let mut r = PageRouter::default(); r.begin(); r.press(2, [hit(Layer::Comment, 7)], QuickTool::Select); - assert_eq!(r.owner(2), Some(hit(Layer::Comment, 7).owner)); - assert_eq!(r.owner(1), None, "only on its page"); + assert_eq!(r.press_for(2).owner(), Some(hit(Layer::Comment, 7).owner)); + assert_eq!(r.press_for(1).owner(), None, "only on its page"); assert!(r.keeps_drag()); r.settle(false, true); - assert!(r.owner(2).is_some(), "the release frame still belongs to the press"); + assert!(r.press_for(2).owner().is_some(), "the release frame still belongs to the press"); r.settle(false, false); - assert_eq!(r.owner(2), None); + assert_eq!(r.press_for(2).owner(), None); r.begin(); r.press(0, [], QuickTool::Select); - assert_eq!(r.owner(0), None, "a press nobody claims is held by nobody"); + assert_eq!(r.press_for(0).owner(), None, "a press nobody claims is held by nobody"); assert!(!r.keeps_drag()); } @@ -281,8 +331,23 @@ mod tests { let mut r = PageRouter::default(); r.begin(); r.press(0, [Hit { declines_drag: true, ..hit(Layer::Comment, 0) }], QuickTool::Select); - assert!(r.owner(0).is_some() && !r.keeps_drag()); + assert!(r.press_for(0).owner().is_some() && !r.keeps_drag()); r.cancel(); - assert_eq!(r.owner(0), None); + assert_eq!(r.press_for(0).owner(), None); + } + + /// A drag the owner declines is nobody's once it starts, on the page it began on; a drag the + /// owner keeps stays its own. + #[test] + fn a_declined_drag_is_nobodys_once_it_starts() { + let mut r = PageRouter::default(); + r.begin(); + r.press(1, [Hit { declines_drag: true, ..hit(Layer::Comment, 0) }], QuickTool::Select); + r.drag_began(); + assert_eq!((r.press_for(1), r.press_for(0)), (PagePress::Free, PagePress::Elsewhere)); + r.begin(); + r.press(1, [hit(Layer::Comment, 2)], QuickTool::Select); + r.drag_began(); + assert_eq!(r.press_for(1), PagePress::Mine(hit(Layer::Comment, 2).owner)); } } diff --git a/crates/ui-egui/src/tools/camera_ui.rs b/crates/ui-egui/src/tools/camera_ui.rs index 817d77bb9f..8da5351ee0 100644 --- a/crates/ui-egui/src/tools/camera_ui.rs +++ b/crates/ui-egui/src/tools/camera_ui.rs @@ -102,14 +102,14 @@ pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointe // began, a click where it came up (egui has cleared the press's origin by its release, and the // pointer may have moved on in the same frame). let at = if resp.clicked() { ptr.released } else { origin }; - if (resp.drag_started() || resp.clicked()) + if (ptr.drag_started || resp.clicked()) && press == PagePress::Free && at.is_some_and(|p| xf.rect.contains(p)) && view.camera.is_none_or(|q| q.page != page) { view.camera = Some(CameraQuad::inset(page, xf.pw, xf.ph)); } - if resp.drag_started() + if ptr.drag_started && let PagePress::Mine(Owner::CameraCorner { corner }) = press && let Some(q) = view.camera.as_mut().filter(|q| q.page == page) { diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index fa56f7a231..8e21f1219a 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -12,8 +12,8 @@ use crate::page_router::{CommentPart, Hit, Owner, PagePress}; /// the press the pages hold: a press on a comment where the tool reaches it ([`hit`]: the Select /// tool, Fill & Sign over its marks, a shape tool over the selected shape's handles) is that /// comment's, and one nobody holds is a commenting tool's. When another tool took this frame -/// (`taken`: Prepare, added content, which still decide for themselves), the commenting tools leave -/// it to that tool, as before; the Select tool's click there still unselects. Returns `true` when +/// (`taken`: Prepare, added content), the commenting tools leave it to that tool, as before; the +/// Select tool's click there still unselects. Returns `true` when /// the pointer gesture belongs to commenting (text selection must ignore it). pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, press: PagePress, taken: bool, view: &mut DocView) -> bool { let (pointer, origin) = (cx.ptr.pos, cx.ptr.origin); @@ -227,7 +227,7 @@ fn select_input( }; let cv = &mut view.comments; let mut consumed = false; - if resp.drag_started() + if cx.ptr.drag_started && let (Some((a, part)), Some(o)) = (owner, origin) { match part { @@ -272,9 +272,17 @@ fn select_input( CommentPart::Body => {} } } - if matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page) { + let live = + matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page); + if live { consumed = true; } + // A move, resize or reshape ends where its drag stops. One whose release the pages missed (the + // button is up, and no drag stopped here) ends with nothing applied: where it came up is not + // known, and the pointer may be anywhere by now. + if live && !resp.drag_stopped() && !cx.ptr.primary_down { + cv.gesture = None; + } if resp.drag_stopped() && let Some(p) = pointer { @@ -374,7 +382,8 @@ fn finish_select_gesture(ui: &egui::Ui, cx: &PageCx<'_>, view: &mut DocView, p: /// Text asks for the replacement. pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView) { let QuickTool::Comment(tool) = cx.tool else { return }; - if tool == CommentTool::ReplaceText && cx.allowed && (resp.drag_stopped() || resp.double_clicked()) { + let finished = resp.drag_stopped_by(egui::PointerButton::Primary) || resp.double_clicked(); + if tool == CommentTool::ReplaceText && cx.allowed && finished { if let Some((page, quads)) = view.selection_quads(cx.info).filter(|(p, _)| *p == cx.page) { view.clear_selection(); let last = quads.last().copied().unwrap_or_default(); @@ -384,7 +393,7 @@ pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut } return; } - if tool.markup().is_none() || !cx.allowed || !(resp.drag_stopped() || resp.double_clicked()) { + if tool.markup().is_none() || !cx.allowed || !finished { return; } if let Some(kind) = tool.markup() @@ -429,7 +438,7 @@ impl Gestures<'_> { if over_page { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } - if resp.drag_started() + if cx.ptr.drag_started && pressed_here && let Some(o) = origin { @@ -567,7 +576,7 @@ impl Gestures<'_> { if over_page { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } - if resp.drag_started() + if cx.ptr.drag_started && pressed_here && let Some(o) = origin { @@ -648,7 +657,7 @@ impl Gestures<'_> { // A text box also takes a drag, as in Acrobat: the box is drawn at that size and its // text wraps inside it (#2669). if tool == CommentTool::TextBox { - if resp.drag_started() + if cx.ptr.drag_started && pressed_here && let Some(o) = origin { diff --git a/crates/ui-egui/src/tools/content_ui.rs b/crates/ui-egui/src/tools/content_ui.rs index 5b5154f90a..a5cfbd4c2e 100644 --- a/crates/ui-egui/src/tools/content_ui.rs +++ b/crates/ui-egui/src/tools/content_ui.rs @@ -11,7 +11,7 @@ use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageToolContext, PageXform}; use crate::page_cursor::Layer; -use crate::page_router::{Hit, Owner}; +use crate::page_router::{Hit, Owner, PagePress}; use crate::theme::Tokens; use crate::widgets; @@ -108,6 +108,10 @@ pub struct ContentView { /// Select the item an add creates (the page's item count before it). pub select_added: Option<(usize, usize)>, grab: Option<(usize, Grab, Pos2)>, + /// Where the press nobody holds began, on which page: Add text's click starts its new box there. + pressed_at: Option<(usize, Pos2)>, + /// A button is down on the pages this frame (for [`keys`], which runs after them). + held: bool, undrawable: UndrawableMemo, layout: Option<(AddedText, f32, f64, [f64; 4])>, } @@ -116,6 +120,12 @@ impl ContentView { /// Forget an in-progress pointer grab for content editing. pub(crate) fn cancel_pointer_gesture(&mut self) { self.grab = None; + self.pressed_at = None; + } + + /// An added item is being moved or resized on `page`. + pub(crate) fn moving_on(&self, page: usize) -> bool { + self.grab.is_some_and(|(p, ..)| p == page) } pub(crate) fn finish_draft(&mut self, added: &[Added]) -> Option { @@ -372,17 +382,34 @@ pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, added: &[Added], Some(Hit::new(Owner::Added { index: *index, corner: None }, Layer::Placed, egui::CursorIcon::Move)) } -/// Pointer input on one page in Edit mode (`adding_text` with the Text tool). `true` when it -/// used the input. -pub(crate) fn page_input( - cx: PageToolContext<'_>, - added: &[Added], - adding_text: bool, - edit_on_click: bool, - style: &AddedText, - view: &mut DocView, -) -> bool { +/// What added content is told on one page. +#[derive(Clone, Copy)] +pub(crate) struct ContentCx<'a> { + pub added: &'a [Added], + /// What the press the pages hold is to this page. With the Edit panel open, a press on added + /// text is its own with the tools that pick it up ([`hit`]). + pub press: PagePress, + /// Edit text's input is open: no dialog, palette, other window or other tab has it. + pub gate: bool, + /// The tool picks added content up (Select, Add text, Edit text): a drag moves it or resizes it. + pub picks_up: bool, + /// The Add text tool: a click nobody holds starts a new box where it was pressed. + pub adding_text: bool, + /// A click on added text opens it for typing (Add text, Edit text); otherwise a double-click does. + pub edit_on_click: bool, + /// The style of new text. + pub style: &'a AddedText, +} + +/// Pointer input on one page in Edit mode, on the presses that are added content's ([`hit`]) and, +/// with Add text, the clicks nobody holds. `true` when it used the input. +pub(crate) fn page_input(cx: PageToolContext<'_>, ccx: ContentCx<'_>, view: &mut DocView) -> bool { let PageToolContext { resp, xf, ptr, page, info, .. } = cx; + let ContentCx { added, press, gate, picks_up, adding_text, edit_on_click, style } = ccx; + view.content.held = ptr.any_down; + if !gate { + return false; + } let mut consumed = false; let pointer = ptr.pos; let cv = &mut view.content; @@ -412,19 +439,28 @@ pub(crate) fn page_input( } return consumed; } + // Where a press nobody holds begins on this page: Add text's click starts its box there. + if let Some(o) = ptr.pressed().filter(|_| press != PagePress::Elsewhere) { + cv.pressed_at = (press == PagePress::Free).then_some((page, o)); + } let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return consumed }; let items: Vec<(usize, &Added)> = on_page(added, page).into_iter().filter(|(_, a)| matches!(a.content, AddedContent::Text(_))).collect(); - let hit = items.iter().rev().find(|(_, a)| screen_rect(xf, info, page, a.content.rect()).expand(2.0).contains(p)).map(|(i, a)| (*i, *a)); - let selected_rect = cv - .selected - .filter(|s| s.0 == page) - .and_then(|(_, si)| items.iter().find(|(i, _)| *i == si)) - .map(|(_, a)| screen_rect(xf, info, page, a.content.rect())); - let corner = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(p) <= 6.0)); - if adding_text && hit.is_none() { + // What the press was on ([`hit`]): an item, or a corner of the selected one. + let owned = match press { + PagePress::Mine(Owner::Added { index, corner }) => Some((index, corner)), + _ => None, + }; + // The tools that don't pick added content up have no hit on it: with the Edit panel open their + // clicks on added text still select it and their presses there are its, by this test at the + // pointer, as before. + let under = (!picks_up) + .then(|| items.iter().rev().find(|(_, a)| screen_rect(xf, info, page, a.content.rect()).expand(2.0).contains(p)).map(|(i, _)| *i)) + .flatten(); + let target = if picks_up { owned } else { under.map(|i| (i, None)) }; + if adding_text { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Text); // A new box where nothing drawn over the page holds the press. - if resp.clicked() && view.router.owner(page).is_none() { + if resp.clicked() && press == PagePress::Free { // Text that can't be drawn stays where it is being typed (#125). if cv.hold_blocked() { return true; @@ -434,7 +470,9 @@ pub(crate) fn page_input( let typed = cv.draft.take().and_then(|d| finish(cv, d, added)); // The Format panel styles the new text, not the box just kept. (cv.selected, cv.select_added) = (None, None); - let at = to_display(xf, info, page, p); + // Where the click was pressed, as every owner acts where its press was. + let start = cv.pressed_at.take().filter(|(pg, _)| *pg == page).map_or(p, |(_, o)| o); + let at = to_display(xf, info, page, start); let mut style = style.clone(); style.text.clear(); let page_width = info.pages.get(page).map_or(f64::INFINITY, |p| p.width as f64); @@ -445,29 +483,31 @@ pub(crate) fn page_input( if typed.is_some() { view.pending_edit = typed; } + return consumed; } - return consumed; } - // A drag on what the press was on ([`hit`]): a corner resizes the selected item, the item - // itself moves. - if resp.drag_started() { + // A drag on what the press was on: a corner resizes the selected item, the item itself moves, + // with the tools that pick added content up. + if ptr.drag_started { let origin = ptr.origin.unwrap_or(p); - match view.router.owner(page) { - Some(Owner::Added { corner: Some((left, top)), .. }) => { + match owned.filter(|_| picks_up) { + Some((_, Some((left, top)))) => { cv.grab = Some((page, Grab::Corner(left, top), origin)); consumed = true; } - Some(Owner::Added { index, corner: None }) => { + Some((index, None)) => { cv.selected = Some((page, index)); cv.grab = Some((page, Grab::Move, origin)); consumed = true; } - _ => {} + None => {} } - return consumed; + return consumed || owned.is_some(); } - if (resp.double_clicked() || edit_on_click && resp.clicked() && corner.is_none()) - && let Some((i, a)) = hit + // A double-click, or with `edit_on_click` a click, opens the text the press was on for typing. + if (resp.double_clicked() || edit_on_click && resp.clicked()) + && let Some((i, None)) = target + && let Some((_, a)) = items.iter().find(|(index, _)| *index == i) && let AddedContent::Text(t) = &a.content { if cv.hold_blocked() { @@ -493,17 +533,21 @@ pub(crate) fn page_input( consumed = true; return consumed; } + // A click selects the item its press was on, keeps the selected one when on a corner of it, and + // on anything else on this page (but the objects selected together, which take the selection + // themselves) leaves it unselected. if resp.clicked() { - match hit { - Some((i, _)) => { + match (target, press) { + (Some((i, None)), _) => { cv.selected = Some((page, i)); consumed = true; } - None if corner.is_none() => cv.selected = None, - None => consumed = true, + (Some((_, Some(_))), _) => consumed = true, + (None, PagePress::Elsewhere | PagePress::Mine(Owner::Objects { .. })) => {} + (None, _) => cv.selected = None, } } - consumed || hit.is_some() && resp.is_pointer_button_down_on() + consumed || owned.is_some() || under.is_some() && resp.is_pointer_button_down_on() } /// Outlines of added items (hover and selection with handles) and the box being dragged, the @@ -593,7 +637,11 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, image_controller_act return; } let (del, esc) = ctx.input(|i| (i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace), i.key_pressed(egui::Key::Escape))); - if del && let Some((page, index)) = view.content.selected.take() { + // Not while a button is down: the press held would then name another item. + if del + && !view.content.held + && let Some((page, index)) = view.content.selected.take() + { view.pending_edit = Some(Edit::DeleteContent { page, index }); } else if esc { view.content.selected = None; diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index f520a91f86..f2833de356 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -14,17 +14,17 @@ use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageToolContext, PageXform}; use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; -use crate::page_router::{Hit, Owner, ParagraphPart}; +use crate::page_router::{Hit, Owner, PagePress, ParagraphPart}; mod background; mod image; mod layout; pub(crate) use background::TextBackground; -pub use image::{CropMode, DragTarget, ImageAction, ImageDrag, ImageHandle, ImageSelection, image_menu_id}; +pub use image::{CropMode, DragTarget, ImageAction, ImageDrag, ImageHandle, ImageSelection}; pub(crate) use image::{ - ImageCx, Matrix, body_hit as picture_hit, crop_controls_contain, crop_hit, handle_hit as picture_handle_hit, image_context_menu, image_input, - quad_contains, user_to_screen, + ImageCx, Matrix, body_hit as picture_hit, crop_hit, handle_hit as picture_handle_hit, image_context_menu, image_input, picture_context, + picture_keys, quad_contains, user_to_screen, }; #[cfg(test)] use image::{rotate_knob, turn_degrees, turn_degrees_delta}; @@ -659,10 +659,26 @@ fn open_editor(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::TextBlock], bo }); } -/// Lines and their screen boxes for a page; hover outlines, click opens the editor. Returns -/// `true` when the pointer was used. -pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::TextBlock], view: &mut DocView, allow_interaction: bool) -> bool { +/// What Edit text's paragraphs are told on one page. +#[derive(Clone, Copy)] +pub(crate) struct ParagraphCx<'a> { + /// What the press the pages hold is to this page. + pub press: PagePress, + /// Edit text's input is open: no dialog, palette, other window or other tab has it. + pub gate: bool, + /// What a press under the pointer would take (the top hit there): the paragraph outlined. + pub hover: Option<&'a Hit>, + /// With Add text, the page's added content: a click on a paragraph finishes the text being + /// typed before it opens the paragraph. + pub adding: Option<&'a [pdfcraft_engine::Added]>, +} + +/// Lines and their screen boxes for a page: the paragraph under the pointer is outlined, and the +/// press that is a paragraph's (`press`) moves it, rewraps it or, a click, opens it in the editor. +/// Returns `true` when the pointer was used. +pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::TextBlock], pcx: ParagraphCx<'_>, view: &mut DocView) -> bool { let PageToolContext { ui, resp, xf, ptr, page, doc, .. } = cx; + let ParagraphCx { press, gate, hover, adding } = pcx; let info = &doc.info; if view.block_drag.is_some_and(|d| d.generation != doc.edit_generation()) { view.block_drag = None; @@ -679,7 +695,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text } // A box a switching click asked for opens now, once the edit queued with that click has // applied and `lines`/`boxes` are the document's new inventory (#2022). - if allow_interaction + if gate && active.is_none() && let Some((open_page, key)) = view.pending_text_open.clone() && open_page == page @@ -691,7 +707,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text None => view.pending_text_open = None, } } - if !allow_interaction { + if !gate { return false; } let upright = has_edges(info, page); @@ -750,12 +766,15 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text // What it grabs is what the press was on ([`hit`]), not what is under the pointer now: egui // only calls it a drag once the pointer has moved a few pixels (or been held a moment), and a // quick flick has left the edge by then. - let owner = match view.router.owner(page) { - Some(Owner::Paragraph { block, part }) => Some((block, part)), + let owner = match press { + PagePress::Mine(Owner::Paragraph { block, part }) => Some((block, part)), _ => None, }; - if resp.drag_started() + // Add text holds text that can't be drawn where it is being typed (#125). + let held_draft = adding.is_some() && view.content.blocked().is_some(); + if ptr.drag_started && active.is_none() + && !held_draft && let Some(o) = ptr.origin && let Some((block, part)) = owner { @@ -767,18 +786,35 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text view.block_drag = Some(BlockDrag { page, block, generation: doc.edit_generation(), start: o, grip }); return true; } - let Some(p) = ptr.within(xf.rect) else { return false }; - // The paragraph under the pointer: outlined, with its width handles when closed. - let Some((hovered, _)) = grab_at(&boxes, p, upright) else { return false }; - if active != Some(hovered) { - painter.rect_stroke(boxes[hovered], CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); - } - if upright && active.is_none() { - draw_handles(boxes[hovered]); + // The paragraph under the pointer, where a press would take it: outlined, with its width + // handles when closed. Not while the objects selected together or added content being moved + // hold the page. + let hovered = match hover.map(|h| h.owner) { + Some(Owner::Paragraph { block, .. }) => Some(block), + _ => None, + }; + if let Some(b) = hovered.filter(|_| !view.objects.holds(page) && !view.content.moving_on(page)).and_then(|h| boxes.get(h).map(|b| (h, *b))) { + if active != Some(b.0) { + painter.rect_stroke(b.1, CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + } + if upright && active.is_none() { + draw_handles(b.1); + } } // A click opens the paragraph the press was on. - let Some((hit, _)) = owner else { return true }; + let Some((hit, _)) = owner else { return hovered.is_some() }; if resp.clicked() { + // Add text: text that can't be drawn stays where it is being typed (#125), and text being + // typed is finished first. + if let Some(added) = adding { + if view.content.hold_blocked() { + return true; + } + if let Some(edit) = view.content.finish_draft(added) { + view.pending_edit = Some(edit); + } + view.content.selected = None; + } view.image_selection = None; // A click that moves the editor to another paragraph applies the open one first, so its // typed text is not dropped with the switched-away editor (#2022). A click on the open diff --git a/crates/ui-egui/src/tools/edit_text_ui/image.rs b/crates/ui-egui/src/tools/edit_text_ui/image.rs index d3c76f94e2..07ca017798 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -2,7 +2,7 @@ use super::{ACCENT, Color32, CornerRadius, DocInfo, DocView, Edit, MAX_OUTLINES, use crate::canvas::{ImageObb, PainterExt, image_screen_rect}; use crate::image_engine::{CROP_ACCENT, apply_crop, apply_resize, apply_resize_proportional, draw_handle, draw_rule_of_thirds}; use crate::page_cursor::Layer; -use crate::page_router::{Hit, Owner, PicturePart}; +use crate::page_router::{CropPart, Hit, Owner, PagePress, PicturePart}; /// What the pointer and the page are, for the image tools on one page. #[derive(Clone, Copy)] @@ -13,7 +13,10 @@ pub(crate) struct ImageCx<'a> { pub ptr: &'a PagePointer, pub page: usize, pub info: &'a DocInfo, - pub allow_interaction: bool, + /// What the press the pages hold is to this page. + pub press: PagePress, + /// Edit text's input is open: no dialog, palette, other window or other tab has it. + pub gate: bool, } /// The 8 resize handles on an image (4 corners + 4 edge centers). @@ -116,14 +119,29 @@ pub struct CropMode { pub keep: [f64; 4], /// A drag in progress: the grip (0–7 handles, 8 inside), where it started and the box then. pub(crate) drag: Option<(usize, Pos2, [f64; 4])>, + /// Where its Done, Cancel and Remove Crop controls were drawn last (screen): the hand shows + /// over them ([`crop_hit`]). + pub(crate) controls: Vec, } impl CropMode { pub fn new(keep: [f64; 4]) -> Self { - Self { keep, drag: None } + Self { keep, drag: None, controls: Vec::new() } } } +/// How far from a crop grip a press still takes it, and the pointer shows its cursor (screen). +const CROP_GRIP_REACH: f32 = 10.0; + +/// The picture `image` being cropped to `keep`, on screen: its whole frame, and the box kept +/// within it. `None` when either has no area. +fn crop_frames(xf: &PageXform, info: &DocInfo, page: usize, image: &pdfcraft_engine::PageImage, keep: [f64; 4]) -> Option<(Rect, Rect)> { + let full = xf.user_rect(info, page, image.rect.map(|v| v as f32)); + let b = if full.is_positive() { full } else { xf.user_rect(info, page, image.visible_rect.unwrap_or(image.rect).map(|v| v as f32)) }; + let k = xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b); + (b.is_finite() && b.is_positive() && b.size().is_finite() && k.is_finite() && k.is_positive()).then_some((b, k)) +} + pub(crate) use pdfcraft_content::Matrix; /// The displayed crop frame, expressed in page user space, rather than the full image placement. @@ -246,50 +264,31 @@ fn same_rect(a: [f64; 4], b: [f64; 4]) -> bool { a.into_iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3) } -fn crop_bounds(image: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Rect { - let full = xf.user_rect(info, page, image.rect.map(|v| v as f32)); - if full.is_positive() { full } else { xf.user_rect(info, page, image.visible_rect.unwrap_or(image.rect).map(|v| v as f32)) } -} - -/// Whether `p` is on the crop box or one of its handles (#5835). The Done, Cancel and Remove -/// Crop pills are widgets of their own, so they take their clicks before the canvas does. -pub(crate) fn crop_controls_contain( - image: &pdfcraft_engine::PageImage, - mode: &CropMode, - xf: &PageXform, - info: &DocInfo, - page: usize, - p: Pos2, -) -> bool { - let b = crop_bounds(image, xf, info, page); - let k = xf.user_rect(info, page, mode.keep.map(|v| v as f32)).intersect(b); - if !b.is_finite() || !b.is_positive() || !b.size().is_finite() || !k.is_finite() || !k.is_positive() { - return false; - } - k.contains(p) || crop_handles(k).iter().any(|h| h.distance(p) < 10.0) -} - /// Crop mode for the selected image `i` whose visible frame is `b` (screen). Returns `true` -/// (the pointer and keys are the crop's while it lasts). +/// (the pointer is the crop's while it lasts; its keys are read by [`picture_keys`]). #[allow(clippy::cognitive_complexity)] fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_engine::PageImage) -> bool { - let ImageCx { ui, resp, xf, ptr, page, info, allow_interaction } = *cx; + let ImageCx { ui, resp, xf, ptr, page, info, press, gate } = *cx; let painter = ui.painter(); let Some(mode) = view.image_selection.as_mut().and_then(|s| s.crop.as_mut()) else { return false }; let hover_pos = ptr.pos; - let b = crop_bounds(image, xf, info, page); - let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b); - let k = keep_screen(mode.keep); - if !b.is_finite() || !b.is_positive() || !b.size().is_finite() || !k.is_finite() || !k.is_positive() { + let Some((b, k)) = crop_frames(xf, info, page, image, mode.keep) else { mode.drag = None; return true; - } + }; + let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b); - if allow_interaction - && resp.drag_started() + // A drag takes what its press was on ([`crop_hit`]). + if gate + && ptr.drag_started && let Some(o) = ptr.origin + && let PagePress::Mine(Owner::Picture { part: PicturePart::Crop(part), .. }) = press { - let grip = crop_handles(k).iter().position(|h| h.distance(o) < 10.0).or_else(|| k.contains(o).then_some(8)); + let grip = match part { + CropPart::Grip(g) => Some(g), + CropPart::Keep => Some(8), + CropPart::Cut | CropPart::Page => None, + }; if let Some(g) = grip { mode.drag = Some((g, o, mode.keep)); } @@ -352,7 +351,7 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e // Crop handles for (idx, h) in crop_handles(k).iter().enumerate() { let is_active = mode.drag.as_ref().is_some_and(|d| d.0 == idx); - let is_hovered = hover_pos.is_some_and(|p| p.distance(*h) < 10.0); + let is_hovered = hover_pos.is_some_and(|p| p.distance(*h) < CROP_GRIP_REACH); draw_handle(painter, *h, is_active, is_hovered, true, CROP_ACCENT); } @@ -385,6 +384,7 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e let done = Rect::from_min_size(Pos2::new(right - done_w, y), egui::vec2(done_w, 22.0)); let cancel = Rect::from_min_size(Pos2::new(done.min.x - spacing - cancel_w, y), egui::vec2(cancel_w, 22.0)); let reset = reset_w.map(|w| Rect::from_min_size(Pos2::new(cancel.min.x - spacing - w, y), egui::vec2(w, 22.0))); + mode.controls = [Some(done), Some(cancel), reset].into_iter().flatten().collect(); let done_id = ui.scope_id().with("crop_pill_done").with(page).with(i); let cancel_id = ui.scope_id().with("crop_pill_cancel").with(page).with(i); @@ -393,10 +393,10 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e let done_resp = ui.interact(done, done_id, egui::Sense::click()); let cancel_resp = ui.interact(cancel, cancel_id, egui::Sense::click()); let reset_resp = reset.map(|r| ui.interact(r, reset_id, egui::Sense::click())); - done_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, allow_interaction, tl!("Done"))); - cancel_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, allow_interaction, tl!("Cancel"))); + done_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, gate, tl!("Done"))); + cancel_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, gate, tl!("Cancel"))); if let Some(response) = &reset_resp { - response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, allow_interaction, tl!("Remove Crop"))); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, gate, tl!("Remove Crop"))); } let t = crate::theme::Tokens::get(ui.ctx()); @@ -430,56 +430,83 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e neutral_pill(r, response, tl!("Remove Crop")); } - if let Some(p) = hover_pos { - let icon = if done_resp.hovered() - || cancel_resp.hovered() - || reset_resp.as_ref().is_some_and(|r| r.hovered()) - || done.contains(p) - || cancel.contains(p) - || reset.is_some_and(|r| r.contains(p)) - { - egui::CursorIcon::PointingHand - } else if let Some(&g) = crop_handles(k).iter().position(|h| h.distance(p) < 8.0).and_then(|g| GRIPS.get(g)) { - g.cursor_icon() - } else if k.contains(p) { - if mode.drag.is_some() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab } - } else { - egui::CursorIcon::Default - }; - view.cursor.claim(Layer::Editor, icon); + // The cursor is the hit's ([`crop_hit`]), and a grip's or the box's being dragged. + if let Some((grip, ..)) = mode.drag { + view.cursor.claim(Layer::Drag, GRIPS.get(grip).map_or(egui::CursorIcon::Grabbing, |g| g.cursor_icon())); } - let keys = |key: egui::Key| allow_interaction && ui.input(|inp| inp.key_pressed(key)) && !ui.ctx().egui_wants_keyboard_input(); - let done_clicked = allow_interaction && (done_resp.clicked() || (resp.clicked() && hover_pos.is_some_and(|p| done.contains(p)))); - let cancel_clicked = allow_interaction && (cancel_resp.clicked() || (resp.clicked() && hover_pos.is_some_and(|p| cancel.contains(p)))); - let reset_clicked = allow_interaction + let done_clicked = gate && (done_resp.clicked() || (resp.clicked() && hover_pos.is_some_and(|p| done.contains(p)))); + let cancel_clicked = gate && (cancel_resp.clicked() || (resp.clicked() && hover_pos.is_some_and(|p| cancel.contains(p)))); + let reset_clicked = gate && reset .is_some_and(|r| reset_resp.as_ref().is_some_and(|res| res.clicked()) || (resp.clicked() && hover_pos.is_some_and(|p| r.contains(p)))); if reset_clicked { mode.keep = if image.has_editor_crop() { image.rect } else { initial_keep }; mode.drag = None; - } else if done_clicked || keys(egui::Key::Enter) { - let keep = mode.keep; - if let Some(s) = view.image_selection.as_mut() { - s.crop = None; - } - let is_full = same_rect(keep, image.rect); - if is_full { - if image.has_editor_crop() { - view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::RemoveCrop }); - } - } else if image.visible_rect != Some(keep) { - view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Crop(keep) }); - } - } else if (cancel_clicked || keys(egui::Key::Escape)) - && let Some(s) = view.image_selection.as_mut() - { + } else if done_clicked { + apply_crop_mode(view, image); + } else if cancel_clicked && let Some(s) = view.image_selection.as_mut() { s.crop = None; } true } +/// Done in crop mode: it ends, and the picture `image` of the selection keeps the box kept (or +/// all of itself again). +fn apply_crop_mode(view: &mut DocView, image: &pdfcraft_engine::PageImage) { + let Some(s) = view.image_selection.as_mut() else { return }; + let Some(keep) = s.crop.take().map(|mode| mode.keep) else { return }; + let (page, index) = (s.page, s.index); + if same_rect(keep, image.rect) { + if image.has_editor_crop() { + view.pending_edit = Some(Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::RemoveCrop }); + } + } else if image.visible_rect != Some(keep) { + view.pending_edit = Some(Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::Crop(keep) }); + } +} + +/// The keys of the picture selected on `page`, a page being drawn, so the page grid and the Pages +/// panel have had theirs; nothing while the page is off the screen, as pages near it are drawn too. +/// Delete or Backspace removes it, wherever on the pages the pointer is, unless it is being cropped or +/// a button is down: the press held would then name another picture. In crop mode +/// Enter applies the crop and Escape leaves it, though not while a button is down (`held`): Escape +/// then cancels the press the pages hold, if any (`canvas::cancel_on_escape`), and crop mode stays. +/// Nothing while a text field has the keyboard. +pub(crate) fn picture_keys(ctx: &egui::Context, view: &mut DocView, page: usize, images: &[pdfcraft_engine::PageImage], held: bool) { + if ctx.egui_wants_keyboard_input() || view.page_screen_rect(page).is_none() { + return; + } + let Some(s) = view.image_selection.as_ref().filter(|s| s.page == page) else { return }; + let index = s.index; + let key = |k: egui::Key| ctx.input(|i| i.key_pressed(k)); + let image = images.get(index); + if s.crop.is_some() { + if key(egui::Key::Enter) { + if let Some(image) = image { + apply_crop_mode(view, image); + } + } else if !held + && key(egui::Key::Escape) + && let Some(s) = view.image_selection.as_mut() + { + s.crop = None; + } + } else if !held && !s.dragging() && !s.turning && (key(egui::Key::Delete) || key(egui::Key::Backspace)) && image.is_some() { + // The key is the picture's: added content's own Delete runs later this frame. + ctx.input_mut(|i| { + i.consume_key(egui::Modifiers::NONE, egui::Key::Delete); + i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace); + }); + view.image_selection = None; + // Added content's selection follows the picture's (`content_ui::sync_image_selection`), and + // goes with it: once the picture is gone it would be the next added item's. + view.content.selected = None; + view.pending_edit = Some(Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::Delete }); + } +} + /// The page raster's picture of the box `from`, stretched into `to`, over blanks at `blank` (where /// the screen may still show the picture). fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::TextureId, blank: &[Rect], from: Rect, to: Rect) { @@ -541,29 +568,63 @@ pub(crate) fn handle_hit( Some(Hit::new(picture(PicturePart::Corner(grip)), Layer::Handle, obb.handle_cursor(handle))) } -/// The picture being cropped on `page`: crop mode owns every press there, the controls beside its -/// frame included, so the paragraphs under it and the empty page's marquee never take a crop -/// button's press before the picture does. `None` when no picture on `page` is being cropped. -pub(crate) fn crop_hit(view: &DocView, page: usize) -> Option { - let selection = view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_some())?; - Some(Hit::new(Owner::Picture { index: selection.index, part: PicturePart::Body }, Layer::Handle, egui::CursorIcon::Default)) +/// The picture being cropped on `page`, under `p`: crop mode holds every press on its page, the +/// controls beside its frame included, so the paragraphs under it and the empty page's marquee +/// never take a crop button's press before the picture does. A grip of the box kept (within +/// [`CROP_GRIP_REACH`], past the page's edge too), the box itself, the picture outside it (its frame +/// shown or its whole frame), or the rest of the page; and the controls past the page's edge, where +/// they show the hand. `None` when no picture on `page` is being cropped, or `p` is off the page, +/// its grips and its controls: the box and the picture stop at the page's edge, as a clipped +/// artwork's whole frame can reach over the next pages. +pub(crate) fn crop_hit(xf: &PageXform, page: usize, info: &DocInfo, images: &[pdfcraft_engine::PageImage], view: &DocView, p: Pos2) -> Option { + let s = view.image_selection.as_ref().filter(|s| s.page == page)?; + let mode = s.crop.as_ref()?; + let crop = |part, icon| Some(Hit::new(Owner::Picture { index: s.index, part: PicturePart::Crop(part) }, Layer::Editor, icon)); + if mode.controls.iter().any(|r| r.contains(p)) { + return crop(CropPart::Page, egui::CursorIcon::PointingHand); + } + let image = images.get(s.index); + let kept = image.and_then(|image| crop_frames(xf, info, page, image, mode.keep)).map(|(_, k)| k); + // The grips reach past the page's edge, where those of a picture filling its page lie half. + if let Some(g) = kept.and_then(|k| crop_handles(k).iter().position(|h| h.distance(p) < CROP_GRIP_REACH)) { + return crop(CropPart::Grip(g), GRIPS.get(g).map_or(egui::CursorIcon::Default, |grip| grip.cursor_icon())); + } + if !xf.rect.contains(p) { + return None; + } + if kept.is_some_and(|k| k.contains(p)) { + return crop(CropPart::Keep, egui::CursorIcon::Grab); + } + let on_picture = image.is_some_and(|image| { + let shown = ImageObb::from_image(image, xf, info, page); + let whole = ImageObb::from_matrix(image.matrix, xf, info, page); + quad_contains(shown.corners(), p) || quad_contains(whole.corners(), p) + }); + crop(if on_picture { CropPart::Cut } else { CropPart::Page }, egui::CursorIcon::Default) } -/// The picture under `p` (its shape, not only its box): a click selects it, a drag moves the -/// selected one. On the text layer, asked before the paragraphs: editable text on top of a -/// picture takes the press. +/// The picture under `p` (its shape, not only its box; for the selected one, its frame, a hole in it +/// included): a click selects it, a drag moves the selected one. On the text layer, asked before +/// the paragraphs: editable text on top of a picture takes the press. pub(crate) fn body_hit(xf: &PageXform, page: usize, info: &DocInfo, images: &[pdfcraft_engine::PageImage], view: &DocView, p: Pos2) -> Option { if !xf.rect.contains(p) { return None; } let (x, y) = xf.screen_to_view(p); let user = info.pages.get(page)?.view_to_user(x, y).map(f64::from); - let index = images - .iter() - .rposition(|im| im.visible_rect.is_some_and(|r| xf.user_rect(info, page, r.map(|v| v as f32)).contains(p)) && im.contains(user))?; + let on_picture = |im: &pdfcraft_engine::PageImage| { + im.visible_rect.is_some_and(|r| xf.user_rect(info, page, r.map(|v| v as f32)).contains(p)) && im.contains(user) + }; + // The selected picture's frame is its own, a hole in it and a picture over it included. + let framed = + view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_none()).map(|s| s.index).filter(|i| { + images.get(*i).is_some_and(|image| on_picture(image) || quad_contains(ImageObb::from_image(image, xf, info, page).corners(), p)) + }); + let index = images.iter().rposition(on_picture).or(framed)?; let selected = view.image_selection.as_ref().is_some_and(|s| s.page == page && s.index == index); let icon = if selected { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand }; - Some(Hit::new(Owner::Picture { index, part: PicturePart::Body }, Layer::Text, icon)) + let part = if framed.is_some_and(|i| i != index) { PicturePart::OverSelected } else { PicturePart::Body }; + Some(Hit::new(Owner::Picture { index, part }, Layer::Text, icon)) } /// What a right-click on a selected image asks for. @@ -620,15 +681,12 @@ pub(super) fn turn_degrees(centre: Pos2, pointer: Pos2, snap: bool) -> f32 { turn_degrees_delta(centre, centre - egui::vec2(0.0, 10.0), pointer, snap) } -#[doc(hidden)] -pub fn image_menu_id() -> egui::Id { - egui::Id::unique("image-menu") -} - -/// Images on a page: select, move, resize, right-click. Returns `true` when the pointer was used. +/// Images on a page: the press that is a picture's (`press`, [`body_hit`] and [`handle_hit`]) +/// selects it, a drag of the selected one moves, resizes or turns it. Returns `true` when the +/// pointer was used. #[allow(clippy::cognitive_complexity)] pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage], view: &mut DocView) -> bool { - let ImageCx { ui, resp, xf, ptr, page, info, allow_interaction } = *cx; + let ImageCx { ui, resp, xf, ptr, page, info, press, gate } = *cx; let boxes: Vec = images.iter().map(|im| crate::canvas::image_screen_rect(im, xf, info, page)).collect(); let painter = ui.painter(); let hover_pos = ptr.pos; @@ -724,56 +782,25 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage } } - let mut handle_drag = None; - for (h, h_pos) in obb.handles() { - let handle_rect = Rect::from_center_size(h_pos, egui::vec2(18.0, 18.0)); - let handle_resp = ui.interact(handle_rect, ui.scope_id().with("img_handle").with(h), egui::Sense::drag()); - if allow_interaction && handle_resp.drag_started() { - handle_drag = Some(h); - } - } - let rot_id = ui.scope_id().with("img_handle").with(8); - let rot_rect = Rect::from_center_size(shown_knob, egui::vec2(12.0, 12.0)); - let rot_resp = ui.interact(rot_rect, rot_id, egui::Sense::drag()); - let rot_drag = allow_interaction && rot_resp.drag_started(); - - if allow_interaction && let Some(p) = hover_pos { - if shown_knob.distance(p) < 8.0 { - view.cursor.claim(Layer::Handle, egui::CursorIcon::Grab); - } else if let Some((h, _)) = obb.handles().iter().find(|(_, pos)| pos.distance(p) < 8.0) { - let icon = obb.handle_cursor(*h); - view.cursor.claim(Layer::Handle, icon); - } - } - - let origin = ptr.origin; - if allow_interaction { - if let Some(h) = handle_drag { - if let Some(s) = view.image_selection.as_mut() { - let o = origin.unwrap_or_else(|| obb.handle_pos(h)); - s.drag = Some(ImageDrag { - start: o, - target: DragTarget::Handle(h), - initial_rect: b, - initial_matrix: resize_frame(&obb, xf, info, page).0, - }); - } - } else if rot_drag { - if let Some(s) = view.image_selection.as_mut() { - let o = origin.unwrap_or(shown_knob); + // A drag of the selected picture takes what its press was on ([`body_hit`], [`handle_hit`]): + // the rotate knob turns it, a grip resizes it, the picture itself moves. The knob and the + // grips take it from the press, so a move too short to be a drag of the page counts; the + // picture waits for that, as a click selects. + let at_press = |part| matches!(part, PicturePart::Knob | PicturePart::Corner(_)); + if gate + && let Some(o) = ptr.origin + && let PagePress::Mine(Owner::Picture { index, part }) = press + && (index == i || part == PicturePart::OverSelected) + && (ptr.drag_started || at_press(part) && ptr.pressed().is_some()) + && let Some(s) = view.image_selection.as_mut().filter(|s| s.drag.is_none()) + { + match part { + PicturePart::Knob => { s.drag = Some(ImageDrag { start: o, target: DragTarget::Rotate, initial_rect: b, initial_matrix: image.matrix }); s.turning = true; } - } else if resp.drag_started() - && let Some(o) = origin - { - if shown_knob.distance(o) < 8.0 { - if let Some(s) = view.image_selection.as_mut() { - s.drag = Some(ImageDrag { start: o, target: DragTarget::Rotate, initial_rect: b, initial_matrix: image.matrix }); - s.turning = true; - } - } else if let Some((h, _)) = obb.handles().iter().find(|(_, pos)| pos.distance(o) < 8.0) { - if let Some(s) = view.image_selection.as_mut() { + PicturePart::Corner(grip) => { + if let Some((h, _)) = obb.handles().get(grip) { s.drag = Some(ImageDrag { start: o, target: DragTarget::Handle(*h), @@ -781,11 +808,11 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage initial_matrix: resize_frame(&obb, xf, info, page).0, }); } - } else if (image_contains(i, o) || quad_contains(obb.corners(), o)) - && let Some(s) = view.image_selection.as_mut() - { + } + PicturePart::Body | PicturePart::OverSelected => { s.drag = Some(ImageDrag { start: o, target: DragTarget::Move, initial_rect: b, initial_matrix: image.matrix }); } + PicturePart::Crop(_) => {} } } @@ -949,59 +976,35 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage view.cursor.claim(Layer::Drag, cursor_icon); return true; } - if allow_interaction - && ui.input(|inp| inp.key_pressed(egui::Key::Delete) || inp.key_pressed(egui::Key::Backspace)) - && !ui.ctx().egui_wants_keyboard_input() - { - view.image_selection = None; - view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Delete }); - return true; + } + // A click selects the picture its press was on; its cursor is the hit's. One nobody holds on this + // page leaves it unselected (where the page's objects can be selected together, the area + // selection the press starts has done so already). + let owner = match press { + PagePress::Mine(Owner::Picture { index, .. }) => Some(index), + _ => None, + }; + if gate && resp.clicked() { + match owner { + Some(index) => view.image_selection = Some(ImageSelection::at(page, index)), + None if press == PagePress::Free && ptr.released.is_some_and(|p| xf.rect.contains(p)) => view.image_selection = None, + None => {} } } - if !allow_interaction { - return false; - } - let menu_key = image_menu_id(); - let Some(p) = hover_pos.filter(|p| xf.rect.contains(*p)) else { return false }; - let Some(hit) = boxes.iter().enumerate().rposition(|(i, b)| { - b.is_positive() && images.get(i).is_some_and(|img| ImageObb::from_image(img, xf, info, page).contains(*b, p)) && image_contains(i, p) - }) else { - if resp.secondary_clicked() || resp.clicked() { - view.image_selection = None; - ui.ctx().data_mut(|d| d.insert_temp(menu_key, false)); - } - return false; - }; - let Some(hit_img) = images.get(hit) else { return false }; - let hit_obb = ImageObb::from_image(hit_img, xf, info, page); - if selected != Some(hit) { - if hit_obb.is_rotated { - let pts = hit_obb.corners(); - for k in 0..4 { - painter.line_segment([pts[k], pts[(k + 1) % 4]], Stroke::new(1.5, ACCENT.gamma_multiply(0.7))); - } - } else if let Some(b) = boxes.get(hit).copied() { - painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside); + owner.is_some() +} + +/// A right-click on `page` that went to its picture `index` (on it or a grip of it) selects it for +/// its menu, keeping it as it is if it was selected (crop mode included); one that went to anything +/// else (`None`) unselects the picture selected. +pub(crate) fn picture_context(page: usize, index: Option, view: &mut DocView) { + match index { + Some(index) if view.image_selection.as_ref().is_none_or(|s| (s.page, s.index) != (page, index)) => { + view.image_selection = Some(ImageSelection::at(page, index)); } + Some(_) => {} + None => view.image_selection = None, } - let grip = hit_obb.handles().into_iter().find(|(_, point)| point.distance(p) < 10.0); - let cursor = if selected == Some(hit) { - grip.map_or(egui::CursorIcon::Grab, |(handle, _)| hit_obb.handle_cursor(handle)) - } else { - egui::CursorIcon::PointingHand - }; - view.cursor.claim(Layer::Text, cursor); - let clicked = match view.router.owner(page) { - Some(Owner::Picture { index, .. }) if resp.clicked() => Some(index), - _ => (resp.clicked() || resp.secondary_clicked()).then_some(hit), - }; - if let Some(index) = clicked { - view.image_selection = Some(ImageSelection::at(page, index)); - } - if resp.secondary_clicked() { - ui.ctx().data_mut(|d| d.insert_temp(menu_key, true)); - } - true } /// The right-click context menu for a selected image. @@ -1025,7 +1028,6 @@ pub(crate) fn image_context_menu( let picked = |ui: &mut egui::Ui, entry: egui::Response| { let clicked = entry.clicked(); if clicked { - ui.ctx().data_mut(|d| d.insert_temp(image_menu_id(), false)); egui::Popup::close_all(ui.ctx()); ui.close(); } diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index 63db81b9f3..f2e880983d 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -95,7 +95,8 @@ fn target_label(info: &DocInfo, target: &LinkTarget) -> String { /// Link tool input on one page: `free` when nobody holds the press and it began on this page, /// `owner` the link that holds it (by its `/Annots` index), and `context` the link a right-click is -/// on. A drag from either draws a new link. Returns `true` when the gesture belongs to the tool. +/// on. A drag nobody holds draws a new link: a link lets its drag go. Returns `true` when the +/// gesture belongs to the tool. pub(crate) fn page_input(cx: PageToolContext<'_>, free: bool, owner: Option, context: Option, view: &mut DocView) -> bool { let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; let pointer = ptr.pos; @@ -114,11 +115,11 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, free: bool, owner: Option>, pub notice: Option, pub hold_editor: bool, - blocked_page: Option, input_blocked: bool, /// A paragraph (page, block) to select once the document is at hand: Esc in the inline /// editor asks for it, and the next `prepare` selects it. @@ -40,23 +39,32 @@ struct Gesture { } impl ObjectSelection { - /// Clear any object-selection gesture and the page it was blocking. + /// Clear any object-selection gesture. pub(crate) fn cancel_pointer_gesture(&mut self) { self.gesture = None; - self.blocked_page = None; } pub fn count(&self) -> usize { self.selected.len() } + /// Edit text's input is blocked: a dialog, the palette, another window or another tab has it. + pub(crate) fn input_blocked(&self) -> bool { + self.input_blocked + } + + /// The objects selected together are on `page`, or a gesture of theirs runs there: the hover + /// outlines of what lies under them wait. + pub(crate) fn holds(&self, page: usize) -> bool { + self.page == Some(page) && (!self.selected.is_empty() || self.gesture.is_some()) + } + /// A preview belongs to the active, focused document. Returning from another tab or a /// modal must not turn a release that happened elsewhere into a document edit. pub fn set_input_blocked(&mut self, blocked: bool) { self.input_blocked = blocked; if blocked { self.gesture = None; - self.blocked_page = None; } } @@ -65,7 +73,6 @@ impl ObjectSelection { self.selected.clear(); self.gesture = None; self.pending = None; - self.blocked_page = None; self.select_paragraph = None; } @@ -139,7 +146,9 @@ impl ObjectSelection { Edit::MoveObjects { page, objects: self.selected.iter().map(|o| o.target).collect(), offset } } - fn inventory(&mut self, doc: &Document, page: usize) -> Result, String> { + /// The objects of `page` that can be selected together, as `doc.editable_objects` lists them, or + /// why they can't be. + pub(crate) fn inventory(&mut self, doc: &Document, page: usize) -> Result, String> { if self.cache.len() >= 8 && !self.cache.contains_key(&page) { self.cache.clear(); } @@ -151,18 +160,15 @@ fn screen(xf: &PageXform, info: &DocInfo, page: usize, object: &EditableObject) xf.user_rect(info, page, object.rect.map(|v| v as f32)) } -fn modified(ui: &egui::Ui) -> bool { - ui.input(|i| { - let modifiers = i - .events - .iter() - .find_map(|e| match e { - egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: true, modifiers, .. } => Some(*modifiers), - _ => None, - }) - .unwrap_or(i.modifiers); - modifiers.shift || modifiers.ctrl || modifiers.command - }) +/// A menu is open (the page's, or another popup): the objects selected together leave the pointer +/// alone, and a press with Shift or ⌘ is not theirs. +pub(crate) fn menu_open(ui: &egui::Ui, resp: &egui::Response) -> bool { + resp.context_menu_opened() || egui::Popup::is_any_open(ui.ctx()) +} + +/// Whether `modifiers` make a press add to the objects selected together, or take from them. +pub(crate) fn adds(modifiers: egui::Modifiers) -> bool { + modifiers.shift || modifiers.ctrl || modifiers.command } fn single(view: &DocView, page: usize) -> Option { @@ -187,6 +193,10 @@ fn leave_single(view: &mut DocView) { /// takes hold of the box as in Acrobat: a click selects it, a drag moves it, its text keeps the /// caret. pub(crate) fn hit(view: &DocView, xf: &PageXform, info: &DocInfo, page: usize, open: Option, p: Pos2) -> Option { + // Past the page's edge is no object's: the next page lies there. + if !xf.rect.contains(p) { + return None; + } let s = &view.objects; if s.page == Some(page) && !s.selected.is_empty() && s.selected.iter().fold(Rect::NOTHING, |r, o| r.union(screen(xf, info, page, o))).contains(p) { @@ -195,27 +205,75 @@ pub(crate) fn hit(view: &DocView, xf: &PageXform, info: &DocInfo, page: usize, o open.filter(|r| on_frame(r.shrink(2.0), p)).map(|_| Hit::new(Owner::Objects { part: ObjectsPart::Frame }, Layer::Handle, egui::CursorIcon::Move)) } -/// Returns true while group selection owns the interaction. Ordinary clicks still reach the -/// single-object text/resize tools. A text draft is finished or discarded through its editor. -pub(crate) fn page_input(cx: PageToolContext<'_>, view: &mut DocView) -> bool { - let PageToolContext { ui, resp, xf, ptr, page, .. } = cx; +/// The object under `p` among the page's `objects`: added content over text over pictures, so a +/// page-sized background picture does not hide editable labels or added notes, and within each +/// kind the last, as the single-item tools take it. +fn object_at(xf: &PageXform, info: &DocInfo, page: usize, objects: &[EditableObject], p: Pos2) -> Option { + [ObjectKind::Added, ObjectKind::Text, ObjectKind::Image] + .into_iter() + .find_map(|kind| objects.iter().rev().find(|o| o.target.kind == kind && screen(xf, info, page, o).expand(2.0).contains(p))) + .map(|o| o.target) +} + +/// What a press with Shift or ⌘ at `p` takes on `page` in Edit text, whatever lies there: the +/// object under it among the page's `objects` ([`ObjectSelection::inventory`]), which it adds to +/// the objects selected together or takes out of them, or none, where it starts an area selection +/// that adds to them. Held while text being typed is open, and refused on a page whose objects +/// can't be selected together. `None` off the page and outside the pages' visible area (`clip`). +pub(crate) fn modified_hit( + view: &DocView, + xf: &PageXform, + info: &DocInfo, + page: usize, + objects: &Result, String>, + clip: Rect, + p: Pos2, +) -> Option { + if !xf.rect.contains(p) || !clip.contains(p) { + return None; + } + let part = if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) { + ObjectsPart::Held + } else { + match objects { + Ok(objects) => ObjectsPart::Toggle(object_at(xf, info, page, objects, p)), + Err(_) => ObjectsPart::Refused, + } + }; + Some(Hit::new(Owner::Objects { part }, Layer::Editor, egui::CursorIcon::Default)) +} + +/// The objects selected together, on one page, told what the press is to it (`press`). Returns +/// true while they own the interaction: while input is blocked, while a press with Shift or ⌘ is +/// held ([`modified_hit`]), and while objects on this page are selected or a gesture of theirs +/// runs. Ordinary presses on a paragraph, a picture or added content still reach their own tools. +/// A text draft is finished or discarded through its editor. +pub(crate) fn page_input(cx: PageToolContext<'_>, press: PagePress, view: &mut DocView) -> bool { + let PageToolContext { ui, resp, ptr, page, .. } = cx; if view.objects.input_blocked { return true; } - if resp.context_menu_opened() || egui::Popup::is_any_open(ui.ctx()) { + if menu_open(ui, resp) { return false; } - if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) { - let blocked = view.objects.blocked_page == Some(page); - let modifier_press = ptr.pressed().is_some() && modified(ui) && ptr.pos.is_some_and(|p| xf.rect.contains(p) && ui.clip_rect().contains(p)); - if blocked || modifier_press { + match press { + // Text being typed keeps the keyboard, and its editor stays open, until the button is up. + PagePress::Mine(Owner::Objects { part: ObjectsPart::Held }) => { view.objects.hold_editor = true; - view.objects.blocked_page = ptr.primary_down.then_some(page); - if modifier_press { + if ptr.pressed().is_some() { view.objects.notice = Some(tl!("Finish or discard the text being edited before selecting several objects.").into()); } return true; } + PagePress::Mine(Owner::Objects { part: ObjectsPart::Refused }) => { + if ptr.pressed().is_some() { + view.objects.notice = Some(tl!("This page contains objects that cannot be selected together.").into()); + } + return true; + } + _ => {} + } + if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) { return false; } // Delete or Backspace removes the selected objects on this page as one edit, @@ -235,12 +293,12 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, view: &mut DocView) -> bool { } } let mut selection = std::mem::take(&mut view.objects); - let result = input(&mut selection, cx, view); + let result = input(&mut selection, cx, press, view); view.objects = selection; result } -fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, view: &mut DocView) -> bool { +fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, press: PagePress, view: &mut DocView) -> bool { let PageToolContext { ui, resp, xf, ptr, page, doc, .. } = cx; let pointer = ptr.pos; let (pressed, down) = (ptr.pressed().is_some(), ptr.primary_down); @@ -298,73 +356,68 @@ fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, view: &mut DocView) - let Some(p) = pointer.filter(|p| xf.rect.contains(*p) && ui.clip_rect().contains(*p) && resp.rect.contains(*p)) else { return s.page == Some(page) && !s.selected.is_empty(); }; - if !pressed { + // Nor a press another page holds, on the row of pixels pages share with the gaps hidden: it may + // have just selected something there. + if !pressed || press == PagePress::Elsewhere { return s.page == Some(page) && !s.selected.is_empty(); } - let objects = match s.inventory(doc, page) { - Ok(objects) => objects, - Err(_) => { - if modified(ui) { - s.notice = Some(tl!("This page contains objects that cannot be selected together.").into()); + let Ok(objects) = s.inventory(doc, page) else { return false }; + // What the press was on ([`hit`], [`modified_hit`]). + match press { + // With Shift or ⌘: the object it was on joins the selection or leaves it, and on none an + // area selection adds to it. + PagePress::Mine(Owner::Objects { part: ObjectsPart::Toggle(target) }) => { + if s.page != Some(page) { + s.clear(); + s.page = Some(page); + if let Some(single) = single(view, page) + && let Some(object) = objects.iter().find(|o| o.target == single) + { + s.selected.push(object.clone()); + } + } + match target.and_then(|t| objects.iter().find(|o| o.target == t)) { + Some(object) => { + if let Some(index) = s.selected.iter().position(|o| o.target == object.target) { + s.selected.remove(index); + } else if s.selected.len() < MAX_MOVE_OBJECTS { + s.selected.push(object.clone()); + } else { + s.notice = Some(tl!("Select at most 1000 objects at a time.").into()); + } + } + None => s.gesture = Some(Gesture { start: p, now: p, marquee: true, additive: true }), + } + leave_single(view); + view.current = page; + return true; + } + // The selection moves. + PagePress::Mine(Owner::Objects { part: ObjectsPart::Selection }) => { + s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false }); + return true; + } + // The frame of the paragraph open for typing selects its box (for the arrow keys) and + // moves it. + PagePress::Mine(Owner::Objects { part: ObjectsPart::Frame }) => { + if let Some(block) = view.line_editor.as_ref().filter(|e| e.page == page && !e.has_changes()).map(|e| e.block) + && let Some(object) = objects.iter().find(|o| o.target == ObjectTarget { kind: ObjectKind::Text, index: block }) + { + s.clear(); + s.page = Some(page); + s.selected.push(object.clone()); + s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false }); + leave_single(view); + view.current = page; return true; } - return false; } - }; - let additive = modified(ui); - // A page-sized background image must not hide editable labels or added notes. - // Within each kind, prefer the last object as the ordinary single-item tools do. - let hit = [ObjectKind::Added, ObjectKind::Text, ObjectKind::Image] - .into_iter() - .find_map(|kind| objects.iter().rev().find(|o| o.target.kind == kind && screen(xf, info, page, o).expand(2.0).contains(p))); - if additive { - if s.page != Some(page) { - s.clear(); - s.page = Some(page); - if let Some(target) = single(view, page) - && let Some(object) = objects.iter().find(|o| o.target == target) - { - s.selected.push(object.clone()); - } - } - if let Some(object) = hit { - if let Some(index) = s.selected.iter().position(|o| o.target == object.target) { - s.selected.remove(index); - } else if s.selected.len() < MAX_MOVE_OBJECTS { - s.selected.push(object.clone()); - } else { - s.notice = Some(tl!("Select at most 1000 objects at a time.").into()); - } - } else { - s.gesture = Some(Gesture { start: p, now: p, marquee: true, additive: true }); - } - leave_single(view); - view.current = page; - return true; - } - // What the press was on ([`hit`]): the selection moves; the frame of the paragraph open for - // typing selects its box (for the arrow keys) and moves it. - let owner = view.router.owner(page); - if owner == Some(Owner::Objects { part: ObjectsPart::Selection }) && !resp.double_clicked() { - s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false }); - return true; - } - if owner == Some(Owner::Objects { part: ObjectsPart::Frame }) - && let Some(block) = view.line_editor.as_ref().filter(|e| e.page == page && !e.has_changes()).map(|e| e.block) - && let Some(object) = objects.iter().find(|o| o.target == ObjectTarget { kind: ObjectKind::Text, index: block }) - { - s.clear(); - s.page = Some(page); - s.selected.push(object.clone()); - s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false }); - leave_single(view); - view.current = page; - return true; + _ => {} } s.clear(); // A press on something (a paragraph, its edge reaching past its box #2276, a picture, added - // content) is its own; only one on the empty page starts an area selection. - if hit.is_some() || view.router.owner(page).is_some() { + // content) is its own; only one nobody holds starts an area selection. + if press != PagePress::Free { return false; } leave_single(view); @@ -589,7 +642,7 @@ mod tests { }, |ui| { let resp = ui.interact(ui.available_rect_before_wrap(), egui::Id::unique("page"), egui::Sense::click()); - page_input(PageToolContext { ui, resp: &resp, xf: &xf, ptr: &ptr, page: 0, doc, info: &info }, &mut view); + page_input(PageToolContext { ui, resp: &resp, xf: &xf, ptr: &ptr, page: 0, doc, info: &info }, PagePress::Free, &mut view); }, ); output.textures_delta.clear(); diff --git a/crates/ui-egui/src/tools/prepare.rs b/crates/ui-egui/src/tools/prepare.rs index 602358d1e0..405ce5ac84 100644 --- a/crates/ui-egui/src/tools/prepare.rs +++ b/crates/ui-egui/src/tools/prepare.rs @@ -380,7 +380,8 @@ pub(crate) fn page_input( // The Select tool: what the press is on ([`hit`]), a corner handle or a field; a press nobody // holds draws a selection rectangle, or a field with a field tool. A press held by something - // drawn over the fields (a comment) is not Prepare's. + // drawn over the fields (a comment) is not Prepare's, though its drag is once that something + // lets it go (a highlight's, `PageRouter::drag_began`). let free = press == PagePress::Free; let held = matches!(press, PagePress::Mine(Owner::PrepareField { .. } | Owner::PrepareCorner { .. })); let hit = match press { @@ -390,7 +391,7 @@ pub(crate) fn page_input( // A drag starts from where its press began on this page: egui may see it only once the pointer // is past the page's edge. if allowed - && resp.drag_started() + && ptr.drag_started && (held || free) && let Some(origin) = ptr.origin.filter(|o| xf.rect.contains(*o)) { diff --git a/crates/ui-egui/src/tools/redact_ui.rs b/crates/ui-egui/src/tools/redact_ui.rs index 345e7e5d77..01c74c46ea 100644 --- a/crates/ui-egui/src/tools/redact_ui.rs +++ b/crates/ui-egui/src/tools/redact_ui.rs @@ -269,7 +269,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bo /// A finished text selection with the Redact tool becomes a mark. pub(crate) fn after_text(resp: &egui::Response, page: usize, info: &DocInfo, view: &mut DocView) { - if !(resp.drag_stopped() || resp.double_clicked()) || view.redact_drag.is_some() { + if !(resp.drag_stopped_by(egui::PointerButton::Primary) || resp.double_clicked()) || view.redact_drag.is_some() { return; } if let Some((p, quads)) = view.selection_quads(info).filter(|(p, _)| *p == page) { diff --git a/crates/ui-egui/tests/it/document_links.rs b/crates/ui-egui/tests/it/document_links.rs index 8405446df2..eb2bfa7afe 100644 --- a/crates/ui-egui/tests/it/document_links.rs +++ b/crates/ui-egui/tests/it/document_links.rs @@ -717,14 +717,22 @@ fn form_widget_keeps_priority_over_highlighted_link() { assert_eq!(h.state().last_opened_url, None); } +/// A comment's gesture keeps the page's input until its button is up: a press held through it and +/// released on a highlighted link follows no link. (A gesture left over once the button is up +/// ends: `a_comment_move_whose_release_the_pages_missed_ends_once_the_button_is_up`.) #[test] fn active_comment_gesture_keeps_highlighted_link_click() { let _gpu = gpu(); let mut h = overlapping_annotation_harness(overlapping_annotation_fixture("Highlight", false)); let r = h.state().views[0].page_screen_rect(0).unwrap(); let at = egui::pos2(r.min.x + r.width() * 0.5, r.min.y + r.height() * (1.0 - 325.0 / 400.0)); + h.hover_at(at); + h.run_steps(2); + h.drag_at(at); + h.run_steps(1); h.state_mut().views[0].comments.gesture = Some(pdfcraft_ui_egui::comments::Gesture::Move { page: 0, index: 1, from: at }); - click_at(&mut h, at); + h.drop_at(at); + h.run_steps(4); assert_eq!(h.state().pending_link, None); assert_eq!(h.state().last_opened_url, None); } diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index c4231d0f69..aaad18b48e 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -2588,7 +2588,7 @@ fn protected_with(algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, p } /// `bytes` rewritten encrypted. -fn encrypted(bytes: Vec, algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, permissions: i32) -> Vec { +pub(crate) fn encrypted(bytes: Vec, algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, permissions: i32) -> Vec { let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); doc.set_encryption(&pdfcraft_cos::NewEncryption { algorithm, @@ -6503,6 +6503,15 @@ fn clipped_artwork_with_labels() -> Vec { /// `own_clip` draws the image alone in its clip (`q re W n cm Do Q`), which moves with it (#1049). fn clipped_artwork_with_labels_drawn(own_clip: bool) -> Vec { + artwork_drawn(if own_clip { + &b"q 30 170 130 80 re W* n 800 0 0 900 -300 -400 cm /Im0 Do Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET BT /F1 12 Tf 40 220 Td (Overlay) Tj ET BT /F1 9 Tf 154 249 Td (Corner label) Tj ET"[..] + } else { + &b"q 30 170 130 80 re W* n q 800 0 0 900 -300 -400 cm /Im0 Do Q Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET BT /F1 12 Tf 40 220 Td (Overlay) Tj ET BT /F1 9 Tf 154 249 Td (Corner label) Tj ET"[..] + }) +} + +/// [`fixture`]'s two pages, each drawn by `content`, with a one-pixel picture as `/Im0`. +fn artwork_drawn(content: &[u8]) -> Vec { use pdfcraft_cos::{Dict, Document, Object, Stream, write_incremental}; let mut doc = Document::open(std::sync::Arc::new(fixture(2))).unwrap(); let mut image = Dict::new(); @@ -6514,12 +6523,7 @@ fn clipped_artwork_with_labels_drawn(own_clip: bool) -> Vec { image.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); let image = doc.add(Object::Stream(Stream::flate(image, &[220, 235, 245]))); for page in pdfcraft_model::pages(&doc) { - let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), - if own_clip { - &b"q 30 170 130 80 re W* n 800 0 0 900 -300 -400 cm /Im0 Do Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET BT /F1 12 Tf 40 220 Td (Overlay) Tj ET BT /F1 9 Tf 154 249 Td (Corner label) Tj ET"[..] - } else { - &b"q 30 170 130 80 re W* n q 800 0 0 900 -300 -400 cm /Im0 Do Q Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET BT /F1 12 Tf 40 220 Td (Overlay) Tj ET BT /F1 9 Tf 154 249 Td (Corner label) Tj ET"[..] - }))); + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), content))); let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); let mut xo = Dict::new(); xo.set(b"Im0".to_vec(), Object::Ref(image)); @@ -7230,6 +7234,848 @@ fn cropping_a_picture_with_its_handles() { assert!(h.state().views[0].image_selection.as_ref().is_none_or(|s| s.crop.is_none())); } +/// Both pages of [`clipped_artwork_with_labels`] at 100% in Edit text, the picture on page 0 +/// selected (and being cropped, with `crop`). +fn picture_pages(crop: bool) -> Harness<'static, PdfCraftApp> { + picture_pages_of(clipped_artwork_with_labels(), crop) +} + +/// [`picture_pages`], of `pdf`. +fn picture_pages_of(pdf: Vec, crop: bool) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("clipped.pdf", None, pdf).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "100").unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(3); + let id = h.state().views[0].id; + let keep = h.state().session.get(id).unwrap().page_images(0)[0].visible_rect.unwrap(); + let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0); + selection.crop = crop.then(|| pdfcraft_ui_egui::CropMode::new(keep)); + h.state_mut().views[0].image_selection = Some(selection); + h.run_steps(3); + h +} + +/// The screen point at PDF (x, y) of `page` of [`picture_pages`]. +fn page_at(h: &Harness<'static, PdfCraftApp>, page: usize, x: f32, y: f32) -> egui::Pos2 { + let r = h.state().views[0].page_screen_rect(page).expect("both pages on screen"); + egui::pos2(r.left() + x / 200.0 * r.width(), r.top() + (300.0 - y) / 300.0 * r.height()) +} + +fn cursor_over(h: &mut Harness<'static, PdfCraftApp>, p: egui::Pos2) -> egui::CursorIcon { + h.hover_at(p); + h.run_steps(2); + h.output().platform_output.cursor_icon +} + +/// Crop mode holds every press on its page, and its cursor stays there: the grips show theirs and +/// the box kept the hand that drags it, and on the next page the pointer shows what a press there +/// takes (a paragraph's move arrow). Crop mode's cursor used to win on every page. +#[test] +fn crop_modes_cursor_stays_on_its_page() { + let mut h = picture_pages(true); + // The visible picture is x 30..160, y 170..250. + for (page, x, y, cursor, what) in [ + (0, 160.0, 170.0, egui::CursorIcon::ResizeNwSe, "a corner grip"), + (0, 95.0, 210.0, egui::CursorIcon::Grab, "the box kept"), + (0, 65.0, 134.0, egui::CursorIcon::Default, "crop mode holds its page's paragraphs"), + (1, 65.0, 134.0, egui::CursorIcon::Move, "the next page's paragraph"), + ] { + let p = page_at(&h, page, x, y); + assert_eq!(cursor_over(&mut h, p), cursor, "{what}"); + } +} + +/// A press on a crop grip a little outside the picture drags that grip, and Escape in the middle +/// of the drag cancels it: the box goes back and crop mode stays. Escape once the button is up +/// leaves crop mode. +#[test] +fn escape_during_a_crop_drag_keeps_crop_mode() { + let mut h = picture_pages(true); + let before = h.state().views[0].image_selection.as_ref().unwrap().crop.as_ref().unwrap().keep; + let grip = page_at(&h, 0, 160.0, 170.0) + egui::vec2(5.0, 5.0); + h.hover_at(grip); + h.run_steps(1); + h.drag_at(grip); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(grip - egui::vec2(10.0, 10.0) * k as f32); + h.run_steps(1); + } + let keep = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].image_selection.as_ref().and_then(|s| s.crop.as_ref()).map(|c| c.keep); + assert!(keep(&h).is_some_and(|k| k[2] < before[2] - 5.0 && k[1] > before[1] + 5.0), "the grip outside the picture is dragged"); + h.key_press(Key::Escape); + h.run_steps(2); + assert_eq!(keep(&h), Some(before), "Escape cancels the drag, and crop mode stays"); + h.drop_at(grip - egui::vec2(40.0, 40.0)); + h.run_steps(2); + assert_eq!(keep(&h), Some(before), "the cancelled drag's release crops nothing"); + h.key_press(Key::Escape); + h.run_steps(2); + assert_eq!(keep(&h), None, "Escape leaves crop mode"); + assert!(!dirty(&h)); +} + +/// With Edit text, a right-click on text lying over a picture is the picture's, which is selected +/// for its menu: paragraphs have no menu of their own (the user's ruling). +#[test] +fn a_right_click_on_text_over_a_picture_selects_the_picture() { + let mut h = picture_pages(false); + h.state_mut().views[0].image_selection = None; + h.run_steps(2); + // "Overlay" lies on the picture, at x 40, y 220. + let overlay = page_at(&h, 0, 50.0, 224.0); + right_click_at(&mut h, overlay); + let selected = h.state().views[0].image_selection.as_ref().map(|s| (s.page, s.index)); + assert_eq!(selected, Some((0, 0)), "the picture under the text is selected for its menu"); +} + +/// With Edit text, a right-click on added text over a picture is the picture's, which is selected +/// for its menu: added text has no menu of its own. +#[test] +fn a_right_click_on_added_text_over_a_picture_selects_the_picture() { + let mut h = picture_pages(false); + h.state_mut().views[0].image_selection = None; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { + page: 0, + text: pdfcraft_engine::AddedText { rect: [60.0, 190.0, 150.0, 205.0], text: "Added".into(), ..Default::default() }, + })); + h.run_steps(3); + let on_it = page_at(&h, 0, 100.0, 198.0); + right_click_at(&mut h, on_it); + let selected = h.state().views[0].image_selection.as_ref().map(|s| (s.page, s.index)); + assert_eq!(selected, Some((0, 0)), "the picture under the added text is selected for its menu"); + assert!(h.query_by_label("Flip horizontal").is_some(), "its menu opens"); +} + +/// With Edit text, a right-click on the frame of a paragraph open for typing over a picture is the +/// picture's too. +#[test] +fn a_right_click_on_an_open_paragraphs_frame_over_a_picture_selects_the_picture() { + let mut h = picture_pages(false); + h.state_mut().views[0].image_selection = None; + h.run_steps(2); + // "Overlay" lies on the picture, at x 40, y 220. + let overlay = page_at(&h, 0, 50.0, 224.0); + slipping_click(&mut h, overlay, overlay); + assert!(h.state().views[0].line_editor.is_some(), "the paragraph is open for typing"); + let id = h.state().views[0].id; + let blocks = h.state().session.get(id).unwrap().text_blocks(0); + let block = + blocks.iter().find(|b| b.rect[0] < 50.0 && 50.0 < b.rect[2] && b.rect[1] < 224.0 && 224.0 < b.rect[3]).expect("the paragraph opened").clone(); + // Its frame runs just outside its box. + let frame = page_at(&h, 0, block.rect[0] as f32, ((block.rect[1] + block.rect[3]) / 2.0) as f32) - egui::vec2(3.0, 0.0); + right_click_at(&mut h, frame); + let selected = h.state().views[0].image_selection.as_ref().map(|s| (s.page, s.index)); + assert_eq!(selected, Some((0, 0)), "the picture under the frame is selected for its menu"); + assert!(h.query_by_label("Flip horizontal").is_some(), "its menu opens"); +} + +/// A drag from the selected picture moves it, though another picture lies over it there; a click +/// there selects the one on top. +#[test] +fn a_drag_from_the_selected_picture_under_another_moves_the_selected_one() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("two.pdf", None, two_image_fixture()).unwrap(); + app + }); + h.run_steps(4); + // The second picture lies over the right half of the first. + h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage { + page: 0, + index: 1, + change: pdfcraft_engine::ImageEdit::Move([110.0, 100.0, 130.0, 120.0]), + }); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(4); + h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::ImageSelection::at(0, 0)); + h.run_steps(2); + let id = h.state().views[0].id; + let rects = |h: &Harness<'static, PdfCraftApp>| { + let images = h.state().session.get(id).unwrap().page_images(0); + (images[0].rect, images[1].rect) + }; + let before = rects(&h); + let xf = h.state().views[0].page_xform(0).unwrap(); + let doc = h.state().session.get(id).unwrap(); + let images = doc.page_images(0); + let frame = |i: usize| pdfcraft_ui_egui::canvas::image_screen_rect(&images[i], &xf, &doc.info, 0); + let overlap = frame(0).intersect(frame(1)).center(); + h.hover_at(overlap); + h.run_steps(1); + h.drag_at(overlap); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(overlap + egui::vec2(8.0 * k as f32, 0.0)); + h.run_steps(1); + } + h.drop_at(overlap + egui::vec2(32.0, 0.0)); + h.run_steps(3); + let after = rects(&h); + assert_ne!(after.0, before.0, "the selected picture moves"); + assert_eq!(after.1, before.1, "the one over it stays"); + let selected = h.state().views[0].image_selection.as_ref().map(|s| s.index); + assert_eq!(selected, Some(0), "and stays selected"); + // After the move the two still overlap, a little further along. + let xf = h.state().views[0].page_xform(0).unwrap(); + let doc = h.state().session.get(id).unwrap(); + let images = doc.page_images(0); + let frame = |i: usize| pdfcraft_ui_egui::canvas::image_screen_rect(&images[i], &xf, &doc.info, 0); + let overlap = frame(0).intersect(frame(1)).center(); + slipping_click(&mut h, overlap, overlap); + let selected = h.state().views[0].image_selection.as_ref().map(|s| s.index); + assert_eq!(selected, Some(1), "a click selects the one on top"); +} + +/// A move too short to be a drag of the page still resizes the selected picture from its grip, as +/// the grip takes its drag from the press. +#[test] +fn a_short_drag_from_a_picture_grip_resizes_it() { + let mut h = picture_pages(false); + let id = h.state().views[0].id; + let before = h.state().session.get(id).unwrap().page_images(0)[0].matrix; + // The picture shows at x 30..160, y 170..250: its lower right grip, moved 3 px. + let grip = page_at(&h, 0, 160.0, 170.0); + h.hover_at(grip); + h.run_steps(1); + h.drag_at(grip); + h.run_steps(1); + h.hover_at(grip + egui::vec2(3.0, 0.0)); + h.run_steps(1); + h.drop_at(grip + egui::vec2(3.0, 0.0)); + h.run_steps(3); + assert_ne!(h.state().session.get(id).unwrap().page_images(0)[0].matrix, before, "the picture is resized"); +} + +/// A click on a selected picture's grip that doesn't move resizes nothing: no edit, and the picture +/// stays selected. +#[test] +fn a_click_on_a_picture_grip_changes_nothing() { + let mut h = picture_pages(false); + let id = h.state().views[0].id; + let before = h.state().session.get(id).unwrap().edit_generation(); + let grip = page_at(&h, 0, 160.0, 170.0); + slipping_click(&mut h, grip, grip); + assert_eq!(h.state().session.get(id).unwrap().edit_generation(), before, "no edit"); + assert!(h.state().views[0].image_selection.is_some(), "the picture stays selected"); +} + +/// With Add text holding text that can't be drawn, a drag from a paragraph moves nothing: the draft +/// stays where it is being typed (#125), as a click there leaves it. +#[test] +fn add_text_moves_no_paragraph_while_it_holds_a_draft_it_cant_draw() { + if crate::support::skip_without_glyphs("add_text_moves_no_paragraph_while_it_holds_a_draft_it_cant_draw", "en", "Hello 世界") { + return; + } + let mut h = edit_page(pdfcraft_ui_egui::QuickTool::AddText); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let id = h.state().views[0].id; + let block = h.state().session.get(id).unwrap().text_blocks(0)[0].clone(); + let blank = point_on(&h, block.rect[0], block.rect[3] + 12.0); + slipping_click(&mut h, blank, blank); + h.event(egui::Event::Text("Hello 世界".into())); + h.run_steps(2); + assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Hello 世界"), "the draft holds the text"); + let rect = h.state().views[0].content.draft.as_ref().map(|d| d.rect).unwrap(); + let on_text = point_on(&h, (block.rect[0] + block.rect[2]) / 2.0, (block.rect[1] + block.rect[3]) / 2.0); + h.hover_at(on_text); + h.run_steps(1); + h.drag_at(on_text); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(on_text + egui::vec2(10.0 * k as f32, 0.0)); + h.run_steps(1); + } + h.drop_at(on_text + egui::vec2(40.0, 0.0)); + h.run_steps(3); + assert_eq!(h.state().session.get(id).unwrap().text_blocks(0)[0].rect, block.rect, "the paragraph stays"); + let draft = h.state().views[0].content.draft.as_ref().map(|d| (d.text.clone(), d.rect)); + assert_eq!(draft, Some(("Hello 世界".to_string(), rect)), "and the draft stays where it is typed"); +} + +/// With Edit text, a right-click on a comment over a picture opens the comment's menu, and the +/// picture is not selected for its own. +#[test] +fn a_right_click_on_a_comment_over_a_picture_opens_its_menu() { + let mut h = picture_pages(false); + h.state_mut().views[0].image_selection = None; + let shape = pdfcraft_engine::Shape::Rectangle { rect: [60.0, 190.0, 150.0, 230.0] }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 0, + style: pdfcraft_engine::Style::default_for(&shape), + shape, + contents: "Reviewed".into(), + author: "Tester".into(), + }))); + h.state_mut().views[0].comments.selected = None; + h.run_steps(3); + let on_it = page_at(&h, 0, 100.0, 210.0); + right_click_at(&mut h, on_it); + assert!(h.state().views[0].comments.selected.is_some(), "the comment is selected"); + assert!(h.state().views[0].image_selection.is_none(), "the picture is not"); + assert!(h.query_by_label("Properties…").is_some(), "the comment's menu opens"); +} + +/// In crop mode, a right-click on the next page goes to what lies there, though a clipped artwork's +/// whole frame reaches over it: crop mode ends and the page's menu opens. +#[test] +fn a_right_click_on_the_next_page_leaves_crop_mode() { + let mut h = picture_pages(true); + let on_two = page_at(&h, 1, 100.0, 40.0); + right_click_at(&mut h, on_two); + assert!(h.state().views[0].image_selection.is_none(), "crop mode ends"); + assert!(h.query_by_label("Marquee Zoom").is_some(), "the page's menu opens"); +} + +/// A crop grip takes a press as far off as its cursor shows: 9 px from the grip, outside the box. +#[test] +fn a_crop_grip_takes_a_press_within_its_reach() { + let mut h = picture_pages(true); + let keep = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].image_selection.as_ref().and_then(|s| s.crop.as_ref()).map(|c| c.keep); + let before = keep(&h); + // The box kept is the picture's frame shown, x 30..160, y 170..250: its upper left corner. + let press = page_at(&h, 0, 30.0, 250.0) - egui::vec2(9.0, 0.0); + h.hover_at(press); + h.run_steps(1); + h.drag_at(press); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(press + egui::vec2(8.0 * k as f32, 8.0 * k as f32)); + h.run_steps(1); + } + h.drop_at(press + egui::vec2(32.0, 32.0)); + h.run_steps(2); + assert_ne!(keep(&h), before, "the grip takes the drag"); +} + +/// With Add text, a click on another paragraph keeps both what was typed in the open paragraph and +/// the text being added: neither edit takes the other's place. +#[test] +fn add_text_keeps_a_changed_paragraph_and_a_draft_when_it_moves_on() { + let mut h = picture_pages(false); + h.state_mut().views[0].image_selection = None; + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let id = h.state().views[0].id; + let blocks = h.state().session.get(id).unwrap().text_blocks(0); + assert!(blocks.len() >= 2, "two paragraphs: {blocks:?}"); + let centre = |r: [f64; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); + let (ax, ay) = centre(blocks[0].rect); + let (bx, by) = centre(blocks[1].rect); + let a = page_at(&h, 0, ax as f32, ay as f32); + let b = page_at(&h, 0, bx as f32, by as f32); + slipping_click(&mut h, a, a); + assert!(h.state().views[0].line_editor.is_some(), "the first paragraph is open"); + h.event(egui::Event::Text("X".into())); + h.run_steps(3); + // A click on the page's empty part, below the picture, starts a box. + let blank = page_at(&h, 0, 180.0, 20.0); + slipping_click(&mut h, blank, blank); + assert!(h.state().views[0].line_editor.is_none(), "that click applied the paragraph"); + h.event(egui::Event::Text("Added words".into())); + h.run_steps(3); + slipping_click(&mut h, b, b); + h.run_steps(4); + let doc = h.state().session.get(id).unwrap(); + let texts: Vec = doc.text_blocks(0).iter().map(|b| b.text.clone()).collect(); + let added: Vec = doc + .added() + .iter() + .filter_map(|a| match &a.content { + pdfcraft_engine::AddedContent::Text(t) => Some(t.text.clone()), + _ => None, + }) + .collect(); + assert!(added.iter().any(|t| t == "Added words"), "the added text is kept: {added:?}"); + assert!(texts.iter().any(|t| t.contains('X')), "the paragraph's typing is kept: {texts:?}"); +} + +/// In the page grid, Delete deletes the page chosen there. The picture selected before the grid +/// opened is not on screen, and stays. +#[test] +fn delete_in_the_page_grid_deletes_the_page_and_not_the_hidden_picture() { + let mut h = picture_pages(false); + let id = h.state().views[0].id; + let pictures = h.state().session.get(id).unwrap().page_images(0).len(); + let pages = h.state().session.get(id).unwrap().info.pages.len(); + assert!(h.state_mut().execute("page.organize")); + h.run_steps(4); + h.get_by_label("Select page 2").click(); + h.run_steps(2); + h.key_press(egui::Key::Delete); + h.run_steps(4); + let doc = h.state().session.get(id).unwrap(); + assert_eq!(doc.info.pages.len(), pages - 1, "the page chosen in the grid is deleted"); + assert_eq!(doc.page_images(0).len(), pictures, "the picture stays"); +} + +/// With the picture's page not on screen, its keys do nothing: Delete removes no picture the reader +/// can't see, and Enter applies no crop. +#[test] +fn a_selected_pictures_keys_wait_for_its_page_to_be_on_screen() { + use pdfcraft_ui_egui::canvas::PageLayout; + for (crop, key) in [(false, egui::Key::Delete), (true, egui::Key::Enter)] { + let mut h = picture_pages(crop); + let id = h.state().views[0].id; + let before = h.state().session.get(id).unwrap().page_images(0); + if crop { + let keep = before[0].visible_rect.unwrap(); + let half = [keep[0], keep[1], (keep[0] + keep[2]) / 2.0, keep[3]]; + h.state_mut().views[0].image_selection.as_mut().unwrap().crop.as_mut().unwrap().keep = half; + } + h.state_mut().views[0].layout = PageLayout::Single; + h.state_mut().views[0].continuous = false; + h.state_mut().views[0].go_to_page(1); + h.run_steps(4); + assert!(h.state().views[0].page_screen_rect(0).is_none(), "page 1 is off the screen"); + h.key_press(key); + h.run_steps(4); + let after = h.state().session.get(id).unwrap().page_images(0); + assert_eq!(after.len(), before.len(), "the picture stays"); + assert_eq!(after[0].visible_rect, before[0].visible_rect, "and is not cropped"); + } + // A page just off the screen, in a continuous view: it is still run for what lies near it. + let mut h = picture_pages(false); + let id = h.state().views[0].id; + let pictures = h.state().session.get(id).unwrap().page_images(0).len(); + h.set_size(egui::vec2(1400.0, 400.0)); + h.state_mut().views[0].go_to_page(1); + h.run_steps(6); + // Going to a page leaves a margin above it: a little further, past page 1's foot. + let centre = h.state().views[0].viewport_rect().center(); + h.hover_at(centre); + h.run_steps(1); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -60.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Trackpad, + }); + h.run_steps(6); + assert!(h.state().views[0].continuous, "a continuous view"); + assert!(h.state().views[0].page_screen_rect(0).is_none(), "page 1 is off the screen"); + h.key_press(egui::Key::Delete); + h.run_steps(4); + assert_eq!(h.state().session.get(id).unwrap().page_images(0).len(), pictures, "the picture stays"); +} + +/// Delete waits for the button to come up: the press held would then name another item, whose drag or +/// click would follow. +#[test] +fn delete_waits_for_the_button_to_come_up() { + let hold_and_delete = |h: &mut Harness<'static, PdfCraftApp>, on: egui::Pos2| { + h.hover_at(on); + h.run_steps(1); + h.drag_at(on); + h.run_steps(1); + h.key_press(egui::Key::Delete); + h.run_steps(2); + h.drop_at(on); + h.run_steps(3); + }; + let mut h = picture_pages(false); + let id = h.state().views[0].id; + let pictures = h.state().session.get(id).unwrap().page_images(0).len(); + let on_picture = page_at(&h, 0, 100.0, 180.0); + hold_and_delete(&mut h, on_picture); + assert_eq!(h.state().session.get(id).unwrap().page_images(0).len(), pictures, "the picture stays"); + + let mut h = edit_page(pdfcraft_ui_egui::QuickTool::Select); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { + page: 0, + text: pdfcraft_engine::AddedText { rect: [40.0, 250.0, 140.0, 265.0], text: "Added".into(), ..Default::default() }, + })); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Select; + h.run_steps(3); + let on_added = point_on(&h, 90.0, 258.0); + slipping_click(&mut h, on_added, on_added); + assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "the added text is selected"); + hold_and_delete(&mut h, on_added); + let id = h.state().views[0].id; + assert_eq!(h.state().session.get(id).unwrap().added().len(), 1, "the added text stays"); +} + +/// A drag the page it is over first sees lets the picked picture go, whichever page it began on. +#[test] +fn a_drag_first_seen_on_the_next_page_lets_the_picked_picture_go() { + let mut h = picture_pages(false); + h.state_mut().views[0].image_selection = None; + h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Select; + h.run_steps(3); + let on_picture = page_at(&h, 0, 100.0, 180.0); + slipping_click(&mut h, on_picture, on_picture); + assert!(h.state().views[0].picked_image.is_some(), "the picture is picked"); + let press = page_at(&h, 0, 180.0, 20.0); + let release = page_at(&h, 1, 180.0, 280.0); + h.hover_at(press); + h.run_steps(1); + h.drag_at(press); + h.run_steps(1); + h.hover_at(release); + h.run_steps(1); + h.drop_at(release); + h.run_steps(4); + assert!(h.state().views[0].picked_image.is_none(), "the drag lets it go"); +} + +/// With the pointer on the Pages panel, Delete is the panel's: a page goes, though a picture is +/// selected on the pages. +#[test] +fn delete_with_the_pointer_on_the_pages_panel_deletes_a_page() { + let mut h = picture_pages(false); + h.state_mut().set_option("panel", "pages").unwrap(); + h.run_steps(4); + let id = h.state().views[0].id; + let pages = h.state().session.get(id).unwrap().info.pages.len(); + assert!(h.state().views[0].image_selection.is_some(), "the picture is selected"); + assert!(h.state().views[0].page_screen_rect(0).is_some(), "on a page on screen"); + let viewport = h.state().views[0].viewport_rect(); + assert!(viewport.right() < 1300.0, "the panel lies right of the pages: {viewport:?}"); + h.hover_at(egui::pos2(viewport.right() + 60.0, viewport.center().y)); + h.run_steps(2); + h.key_press(egui::Key::Delete); + h.run_steps(4); + assert_eq!(h.state().session.get(id).unwrap().info.pages.len(), pages - 1, "a page goes"); +} + +/// In crop mode, a right-click on another picture selects it for its menu, as without crop mode. It +/// went to crop mode's whole-page hit, which unselected every picture. +#[test] +fn in_crop_mode_a_right_click_on_another_picture_selects_it() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("two.pdf", None, two_image_fixture()).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(4); + let id = h.state().views[0].id; + let rect = h.state().session.get(id).unwrap().page_images(0)[0].rect; + let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0); + selection.crop = Some(pdfcraft_ui_egui::CropMode::new(rect)); + h.state_mut().views[0].image_selection = Some(selection); + h.run_steps(2); + let xf = h.state().views[0].page_xform(0).unwrap(); + let doc = h.state().session.get(id).unwrap(); + let other = pdfcraft_ui_egui::canvas::image_screen_rect(&doc.page_images(0)[1], &xf, &doc.info, 0); + crop_secondary_click(&mut h, other.center()); + let selection = h.state().views[0].image_selection.clone(); + assert!(selection.as_ref().is_some_and(|s| s.index == 1 && s.crop.is_none()), "the other picture is selected: {selection:?}"); + assert!(h.query_by_label("Flip horizontal").is_some(), "its menu opens"); +} + +/// A drag from a hole in the selected picture (drawn through a clip with a hole in it) moves the +/// picture: its frame is its own. +#[test] +fn a_drag_from_a_hole_in_the_selected_picture_moves_it() { + // The picture shows at x 30..160, y 170..250, around a hole at x 70..120, y 190..230. + let mut h = picture_pages_of(artwork_drawn(b"q 30 170 130 80 re 70 190 50 40 re W* n 800 0 0 900 -300 -400 cm /Im0 Do Q"), false); + let id = h.state().views[0].id; + let before = h.state().session.get(id).unwrap().page_images(0)[0].rect; + let hole = page_at(&h, 0, 95.0, 210.0); + h.hover_at(hole); + h.run_steps(1); + h.drag_at(hole); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(hole + egui::vec2(5.0 * k as f32, 0.0)); + h.run_steps(1); + } + h.drop_at(hole + egui::vec2(20.0, 0.0)); + h.run_steps(4); + let after = h.state().session.get(id).unwrap().page_images(0)[0].rect; + assert!(after[0] - before[0] > 5.0, "the picture moved right: {before:?} → {after:?}"); +} + +/// A right-click on a crop grip past the page's edge, where those of a picture filling its page lie +/// half, is the crop's, as on the page: crop mode stays and no menu opens (#5835). +#[test] +fn a_right_click_on_a_crop_grip_past_the_page_edge_keeps_crop_mode() { + let mut png = Vec::new(); + image::RgbImage::from_pixel(80, 40, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("picture.png", None, png.clone()).expect("opens"); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let id = h.state().views[0].id; + let keep = h.state().session.get(id).unwrap().page_images(0)[0].visible_rect.unwrap(); + let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0); + selection.crop = Some(pdfcraft_ui_egui::CropMode::new(keep)); + h.state_mut().views[0].image_selection = Some(selection); + h.run_steps(3); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + crop_secondary_click(&mut h, r.right_bottom() + egui::vec2(4.0, 4.0)); + assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_some()), "crop mode stays"); + for label in ["Flip horizontal", "Rotate view clockwise"] { + assert!(h.query_all_by_label(label).next().is_none(), "no menu: {label}"); + } +} + +/// Crop mode's grips reach past the page's edge, where those of a picture filling its page lie +/// half: a drag from just outside the page's corner trims the picture. +#[test] +fn a_crop_grip_past_the_page_edge_drags() { + let mut png = Vec::new(); + image::RgbImage::from_pixel(80, 40, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("picture.png", None, png.clone()).expect("opens"); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let id = h.state().views[0].id; + let before = h.state().session.get(id).unwrap().page_images(0)[0].visible_rect.unwrap(); + let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0); + selection.crop = Some(pdfcraft_ui_egui::CropMode::new(before)); + h.state_mut().views[0].image_selection = Some(selection); + h.run_steps(3); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let outside = r.right_bottom() + egui::vec2(4.0, 4.0); + let to = r.right_bottom() - egui::vec2(r.width() / 4.0, r.height() / 4.0); + h.hover_at(outside); + h.run_steps(1); + h.drag_at(outside); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(outside + (to - outside) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(2); + let keep = h.state().views[0].image_selection.as_ref().and_then(|s| s.crop.as_ref()).map(|c| c.keep); + assert!(keep.is_some_and(|k| k[2] < before[2] - 1.0 && k[1] > before[1] + 1.0), "trimmed from the corner: {keep:?} of {before:?}"); +} + +/// Delete removes the selected picture wherever the pointer is, on a paragraph too: the key is the +/// app's, not the pointer's. +#[test] +fn delete_removes_the_selected_picture_with_the_pointer_on_a_paragraph() { + let mut h = picture_pages(false); + let caption = page_at(&h, 0, 65.0, 134.0); + assert_eq!(cursor_over(&mut h, caption), egui::CursorIcon::Move, "on the caption's paragraph"); + h.key_press(Key::Delete); + h.run_steps(3); + let id = h.state().views[0].id; + assert!(h.state().session.get(id).unwrap().page_images(0).is_empty(), "the picture is deleted"); + assert!(h.state().views[0].image_selection.is_none()); +} + +/// Delete on a selected added picture deletes it alone: the added text placed after it stays. The +/// key's second reader, added content's own Delete, took the text that the picture's index then +/// named. +#[test] +fn delete_on_a_selected_added_picture_deletes_it_alone() { + let mut h = edit_page(pdfcraft_ui_egui::QuickTool::EditText); + let mut png = Vec::new(); + image::RgbImage::from_pixel(30, 20, image::Rgb([20, 90, 200])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + h.state_mut().add_image("logo.png".into(), png); + h.run_steps(3); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { + page: 0, + text: pdfcraft_engine::AddedText { rect: [20.0, 20.0, 120.0, 35.0], text: "Caption".into(), ..Default::default() }, + })); + h.run_steps(3); + let id = h.state().views[0].id; + let images = h.state().session.get(id).unwrap().page_images(0).len(); + assert_eq!(images, 1, "the added picture is the page's picture"); + h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::ImageSelection::at(0, 0)); + h.run_steps(3); + h.key_press(Key::Delete); + h.run_steps(4); + let doc = h.state().session.get(id).unwrap(); + assert!(doc.page_images(0).is_empty(), "the picture is deleted"); + let texts: Vec<&str> = doc + .added() + .iter() + .filter_map(|a| if let pdfcraft_engine::AddedContent::Text(t) = &a.content { Some(t.text.as_str()) } else { None }) + .collect(); + assert_eq!(texts, ["Caption"], "and the text placed after it stays"); +} + +/// With a dialog open, Delete and Backspace are the dialog's: the selected picture stays. +#[test] +fn a_dialog_keeps_its_delete_while_a_picture_is_selected() { + let mut h = picture_pages(false); + let on_picture = page_at(&h, 0, 95.0, 210.0); + h.hover_at(on_picture); + h.run_steps(1); + assert!(h.state_mut().execute("app.preferences")); + h.run_steps(3); + for key in [Key::Delete, Key::Backspace] { + h.key_press(key); + h.run_steps(3); + } + let id = h.state().views[0].id; + assert_eq!(h.state().session.get(id).unwrap().page_images(0).len(), 1, "the picture stays under the dialog"); + assert!(h.state().views[0].image_selection.is_some()); + assert_eq!(h.state().dialog, Some(Dialog::Preferences)); +} + +/// A click that slips a few pixels between its press and its release, within egui's click distance. +fn slipping_click(h: &mut Harness<'static, PdfCraftApp>, press: egui::Pos2, release: egui::Pos2) { + h.hover_at(press); + h.run_steps(1); + h.drag_at(press); + h.run_steps(1); + h.drop_at(release); + h.run_steps(4); +} + +/// [`harness`]'s page 1 in Edit text (`tool`), and the screen point at PDF (x, y) on it. +fn edit_page(tool: pdfcraft_ui_egui::QuickTool) -> Harness<'static, PdfCraftApp> { + let mut h = harness(2, |_| {}); + h.state_mut().quick_tool = tool; + h.run_steps(3); + h +} + +fn point_on(h: &Harness<'static, PdfCraftApp>, x: f64, y: f64) -> egui::Pos2 { + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let k = r.width() / 200.0; + egui::pos2(r.left() + x as f32 * k, r.top() + (300.0 - y as f32) * k) +} + +/// A click on a paragraph opens it where it was pressed, though the button came up just below its +/// box: an owner acts where its press was. It opened nothing. +#[test] +fn a_click_on_a_paragraph_released_just_below_it_opens_it() { + let mut h = edit_page(pdfcraft_ui_egui::QuickTool::EditText); + let id = h.state().views[0].id; + let block = h.state().session.get(id).unwrap().text_blocks(0)[0].clone(); + // The box reaches 2 px below the text: pressed 1 px inside it, released 3 px below it. + let bottom = point_on(&h, (block.rect[0] + block.rect[2]) / 2.0, block.rect[1]) + egui::vec2(0.0, 2.0); + slipping_click(&mut h, bottom - egui::vec2(0.0, 1.0), bottom + egui::vec2(0.0, 3.0)); + assert!(h.state().views[0].line_editor.is_some(), "the paragraph pressed is open for typing"); +} + +/// With the Select tool and the Edit panel open, a click on added text selects it where it was +/// pressed, though the button came up just beside it. It left nothing selected. +#[test] +fn a_click_on_added_text_released_just_beside_it_selects_it() { + let mut h = edit_page(pdfcraft_ui_egui::QuickTool::Select); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { + page: 0, + text: pdfcraft_engine::AddedText { rect: [40.0, 250.0, 140.0, 265.0], text: "Added".into(), ..Default::default() }, + })); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Select; + h.run_steps(3); + // The item takes presses 2 px past its box: pressed 1 px inside that, released 3 px past it. + let edge = point_on(&h, 140.0, 258.0) + egui::vec2(2.0, 0.0); + slipping_click(&mut h, edge - egui::vec2(1.0, 0.0), edge + egui::vec2(3.0, 0.0)); + assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "the added text pressed is selected"); +} + +/// With Add text, a click pressed beside a paragraph and released inside its box starts a new box +/// where it was pressed. It finished the text being typed and placed nothing. +#[test] +fn an_add_text_click_pressed_beside_a_paragraph_starts_a_box_there() { + let mut h = edit_page(pdfcraft_ui_egui::QuickTool::AddText); + let id = h.state().views[0].id; + let block = h.state().session.get(id).unwrap().text_blocks(0)[0].clone(); + // The box reaches 2 px past the text: pressed 2 px left of it, released 2 px inside it. + let left = point_on(&h, block.rect[0], (block.rect[1] + block.rect[3]) / 2.0) - egui::vec2(2.0, 0.0); + slipping_click(&mut h, left - egui::vec2(2.0, 0.0), left + egui::vec2(2.0, 0.0)); + let draft = h.state().views[0].content.draft.as_ref().map(|d| d.rect); + let x = draft.map(|r| r[0]); + assert!(x.is_some_and(|x| x < block.rect[0]), "a new box at the press, left of the paragraph: {x:?} of {:?}", block.rect); + assert!(h.state().views[0].line_editor.is_none(), "the paragraph stays closed"); +} + +/// A paragraph drag in Edit text ends when Edit text's input is blocked (the window loses focus), as +/// a drag of the objects selected together does: nothing moves once the input comes back. It +/// waited, and moved the paragraph to wherever the pointer then was. +#[test] +fn a_paragraph_drag_ends_when_the_window_loses_focus() { + for tool in [pdfcraft_ui_egui::QuickTool::EditText, pdfcraft_ui_egui::QuickTool::AddText] { + let mut h = edit_page(tool); + let id = h.state().views[0].id; + let before = h.state().session.get(id).unwrap().text_blocks(0)[0].rect; + let from = point_on(&h, (before[0] + before[2]) / 2.0, (before[1] + before[3]) / 2.0); + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(from + egui::vec2(0.0, 10.0 * k as f32)); + h.run_steps(1); + } + assert!(h.state().views[0].block_drag.is_some(), "the paragraph is being moved"); + h.input_mut().focused = false; + h.run_steps(2); + h.drop_at(from + egui::vec2(0.0, 40.0)); + h.run_steps(2); + h.input_mut().focused = true; + h.hover_at(from + egui::vec2(0.0, 80.0)); + h.run_steps(4); + assert!(h.state().views[0].block_drag.is_none(), "the drag is over"); + assert_eq!(h.state().session.get(id).unwrap().text_blocks(0)[0].rect, before, "nothing moved"); + assert!(!dirty(&h)); + } +} + +/// The rotate knob of a picture that fills its page lies past the page's edge, and a drag there +/// still turns the picture: the press is the page's whose knob it is. +#[test] +fn dragging_the_rotate_knob_past_the_page_edge_turns_the_picture() { + let mut png = Vec::new(); + image::RgbImage::from_pixel(80, 40, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("picture.png", None, png.clone()).expect("opens"); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::ImageSelection::at(0, 0)); + h.run_steps(3); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + // Above the top edge, or below the bottom one when the view has no room above. + let knob = [egui::pos2(r.center().x, r.top() - 22.0), egui::pos2(r.center().x, r.bottom() + 22.0)] + .into_iter() + .find(|p| cursor_over(&mut h, *p) == egui::CursorIcon::Grab) + .expect("the knob past the page's edge"); + assert!(!r.contains(knob)); + let to = egui::pos2(r.right() + 60.0, r.center().y); + h.hover_at(knob); + h.run_steps(1); + h.drag_at(knob); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(knob + (to - knob) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(4); + let id = h.state().views[0].id; + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Rotate image")); +} + /// Organize pages has Acrobat's panel whenever the grid shows, however it was opened, and the /// panel's Close ends Organize: the grid closes and All tools comes back. #[test] @@ -8021,6 +8867,34 @@ fn test_image_context_menu_crop_overlapping_image_and_handle() { } } +/// A right-click on the part of a picture its crop cuts away is the crop's, as one on the box kept: +/// crop mode stays and no menu opens (#5835). +#[test] +fn a_right_click_on_the_part_a_crop_cuts_away_keeps_crop_mode() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("cut.pdf", None, two_image_fixture()).unwrap(); + app + }); + h.run_steps(4); + let id = h.state().views[0].id; + let rect = h.state().session.get(id).unwrap().page_images(0)[0].rect; + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(4); + // Keep the picture's left half. + let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0); + selection.crop = Some(pdfcraft_ui_egui::CropMode::new([rect[0], rect[1], (rect[0] + rect[2]) / 2.0, rect[3]])); + h.state_mut().views[0].image_selection = Some(selection); + h.run_steps(2); + let xf = h.state().views[0].page_xform(0).unwrap(); + let doc = h.state().session.get(id).unwrap(); + let b = pdfcraft_ui_egui::canvas::image_screen_rect(&doc.page_images(0)[0], &xf, &doc.info, 0); + assert!(b.width() > 60.0, "the picture is wide enough for a point clear of the grips: {b:?}"); + crop_secondary_click(&mut h, b.lerp_inside(egui::vec2(0.8, 0.5))); + assert_crop_context_preserved(&h); +} + #[test] fn test_image_context_menu_crop_page_edge_pills() { for rotation in [0, 90, 180, 270] { diff --git a/crates/ui-egui/tests/it/object_selection.rs b/crates/ui-egui/tests/it/object_selection.rs index fc2a8e4fe7..21ebc90b69 100644 --- a/crates/ui-egui/tests/it/object_selection.rs +++ b/crates/ui-egui/tests/it/object_selection.rs @@ -385,3 +385,122 @@ fn modifier_selection_picks_foreground_text_and_added_items_over_background_artw assert!(new.rect.iter().enumerate().all(|(i, value)| (value - old.rect[i] - shift[i % 2]).abs() < 0.01), "{old:?} -> {new:?}"); } } + +/// A press with Shift while a paragraph with changes is open is held until the button is up: it +/// says why, the paragraph keeps its text, and nothing under the press is moved or selected, not +/// even once the press turns into a drag. +#[test] +fn a_shift_click_with_a_changed_paragraph_open_shows_the_notice_and_moves_nothing() { + let mut h = harness(); + let before = objects(&h); + let p = at(&h, 55.0, 70.0); + click(&mut h, p, Modifiers::NONE); + h.state_mut().views[0].line_editor.as_mut().unwrap().text = "A draft still being edited".into(); + let (a, b) = (at(&h, 60.0, 145.0), at(&h, 120.0, 200.0)); + h.hover_at(a); + h.run_steps(2); + h.event_modifiers( + egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::SHIFT }, + Modifiers::SHIFT, + ); + h.run_steps(2); + let notice = "Finish or discard the text being edited before selecting several objects."; + assert!(h.state().toast.as_ref().is_some_and(|t| t.0.contains(notice)), "the notice says why"); + for k in 1..=3 { + h.hover_at(a + (b - a) * (k as f32 / 3.0)); + h.run_steps(2); + } + h.drop_at(b); + h.run_steps(4); + assert_eq!(h.state().views[0].line_editor.as_ref().map(|e| e.text.as_str()), Some("A draft still being edited")); + assert_eq!(objects(&h), before, "nothing moved"); + assert!(h.state().views[0].image_selection.is_none() && h.state().views[0].content.selected.is_none(), "nothing selected"); + assert!( + h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(), + "no objects selected" + ); +} + +/// A press beside a picture, within the reach the objects selected together are found in but +/// outside the picture itself, is nobody's: it starts an area selection. It used to start nothing. +#[test] +fn a_press_just_outside_a_picture_starts_an_area_selection() { + let mut h = harness(); + // The figure is x 40..100, y 130..160 from the page's top; the added note x 130..230, y 130..144. + let (a, b) = (at(&h, 38.5, 140.0), at(&h, 235.0, 175.0)); + h.hover_at(a); + h.run_steps(2); + h.drag_at(a); + h.run_steps(2); + h.hover_at(b); + h.run_steps(2); + h.drop_at(b); + h.run_steps(4); + h.get_by_label("2 objects selected"); +} + +/// A double-click on one of the objects selected together leaves the group and reaches that one: +/// added text opens for typing. +#[test] +fn a_double_click_on_a_grouped_object_reaches_it() { + let mut h = harness(); + marquee(&mut h); + // The added note is x 130..230, y 130..144 from the page's top. + let note = at(&h, 180.0, 137.0); + crate::support::input::double_click(&mut h, note); + h.run_steps(2); + assert!(h.state().views[0].content.draft.as_ref().is_some_and(|d| d.index == Some(0)), "the added note opens for typing"); +} + +/// A double-click on a paragraph selected with others opens the one its press was on, though the +/// button came up just off it. +#[test] +fn a_double_click_on_a_grouped_paragraph_released_just_off_it_opens_it() { + let mut h = harness(); + marquee(&mut h); + let (inside, outside) = { + let app = h.state(); + let view = &app.views[0]; + let xf = view.page_xform(0).unwrap(); + let doc = app.session.get(view.id).unwrap(); + let block = doc.text_blocks(0).into_iter().find(|b| b.text.contains("Group headline")).expect("the headline"); + let frame = xf.user_rect(&doc.info, 0, block.rect.map(|v| v as f32)); + (egui::pos2(frame.center().x, frame.top() + 1.0), egui::pos2(frame.center().x, frame.top() - 4.0)) + }; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.hover_at(inside); + h.run_steps(1); + crate::support::input::one_frame(&mut h, [button(inside, true), button(inside, false)]); + crate::support::input::one_frame(&mut h, [button(inside, true), egui::Event::PointerMoved(outside), button(outside, false)]); + h.run_steps(2); + assert!(h.state().views[0].line_editor.is_some(), "the paragraph pressed is open for typing"); +} + +/// A press counts its own modifiers, whatever the frame's are: Shift in the press event adds to the +/// selection, though the frame's modifiers show none. +#[test] +fn a_press_counts_the_modifiers_of_its_own_event() { + let mut h = harness(); + let p = at(&h, 55.0, 70.0); + click(&mut h, p, Modifiers::NONE); + assert!(h.state().views[0].line_editor.is_some()); + let p = at(&h, 60.0, 145.0); + let button = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::SHIFT }; + h.hover_at(p); + h.run_steps(2); + crate::support::input::one_frame(&mut h, [button(true), button(false)]); + h.run_steps(4); + h.get_by_label("2 objects selected"); +} + +/// A double-click on a paragraph selected with others opens it for typing (the user's ruling). +#[test] +fn a_double_click_on_a_grouped_paragraph_opens_it() { + let mut h = harness(); + marquee(&mut h); + // "Group headline" is at x 40, its baseline y 430 of 500. + let headline = at(&h, 80.0, 65.0); + crate::support::input::double_click(&mut h, headline); + h.run_steps(2); + assert!(h.state().views[0].line_editor.is_some(), "the paragraph is open for typing"); +} diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 50801d903f..e0021aa7be 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -65,6 +65,47 @@ fn linked_text_pdf() -> Vec { ]) } +/// A 300×400 page with a picture at x 50..250, y 250..350 and a web link over its middle, at +/// x 100..200, y 280..320. +fn linked_picture_pdf() -> Vec { + let picture = + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\nA\nendstream"; + let content = stream("q 200 0 0 100 50 250 cm /Im0 Do Q"); + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /XObject << /Im0 6 0 R >> >> /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [100 280 200 320] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>", + content.as_str(), + picture, + ]) +} + +/// Two 300×400 pages, the first with a line of text at its bottom edge, at y 4, the second with one +/// near its top, at y 380. +fn bottom_line_pages_pdf() -> Vec { + let bottom = stream("BT /F1 12 Tf 40 4 Td (Bottom line) Tj ET"); + let top = stream("BT /F1 12 Tf 40 380 Td (Top line) Tj ET"); + pdf(&[ + CATALOG, + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 7 0 R >> >> >>", + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 7 0 R >> >> >>", + bottom.as_str(), + top.as_str(), + HELVETICA, + ]) +} + +/// A 300×400 page with a picture at x 50..250, y 250..350, and graphics states nested deeper than +/// the objects selected together are read through: they can't be selected together on this page. +fn ungroupable_picture_pdf() -> Vec { + let picture = + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\nA\nendstream"; + let content = stream(&format!("q 200 0 0 100 50 250 cm /Im0 Do Q {}{}", "q ".repeat(1030), "Q ".repeat(1030))); + pdf(&[CATALOG, PAGES, "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >>", content.as_str(), picture]) +} + /// A 300×400 page with a line of text at y≈300 and a rectangle comment over it. fn comment_on_text_pdf() -> Vec { let text = stream(LINE); @@ -229,16 +270,13 @@ fn tip_says(h: &Harness<'_, PdfCraftApp>, text: &str) -> bool { } /// Hovers `p` until `want` accepts the cursor there (text layers and paragraph boxes arrive from -/// worker threads), and returns the last cursor seen. -fn wait_for_cursor(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2, want: impl Fn(egui::CursorIcon) -> bool) -> egui::CursorIcon { +/// worker threads, `support::step_until`), and returns the last cursor seen. +fn wait_for_cursor(h: &mut Harness<'static, PdfCraftApp>, p: egui::Pos2, want: impl Fn(egui::CursorIcon) -> bool) -> egui::CursorIcon { let mut cursor = egui::CursorIcon::Default; - for _ in 0..50 { + let _ = crate::support::step_until(h, |h| { cursor = cursor_at(h, p); - if want(cursor) { - break; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - } + want(cursor) + }); cursor } @@ -344,6 +382,35 @@ fn a_drag_from_text_onto_a_link_selects_the_text() { assert!(selected.contains("example.org"), "the drag selected across the link: {selected:?}"); } +/// With the Select tool a click on a link over a picture follows the link and picks no picture; one +/// on the picture beside the link picks it. The link's click used to pick the picture under it too. +#[test] +fn a_click_on_a_link_over_a_picture_follows_it_and_picks_no_picture() { + let mut h = open(linked_picture_pdf(), "150", false); + let beside = at(&h, 70.0, 300.0); + mouse_click(&mut h, beside); + assert!(h.state().views[0].picked_image.is_some(), "the picture beside the link is picked"); + h.state_mut().views[0].picked_image = None; + let on_link = at(&h, 150.0, 300.0); + mouse_click(&mut h, on_link); + assert_eq!(pending_url(&h).as_deref(), Some("https://example.org/"), "the link is followed"); + assert!(h.state().views[0].picked_image.is_none(), "and no picture is picked"); +} + +/// The I-beam drops over a link only where a press would go to it: with the Select tool the link's +/// hand shows there, and with the highlighter, whose drag selects the link's text, the I-beam. The +/// highlighter's I-beam used to drop over every link. +#[test] +fn the_i_beam_drops_over_a_link_only_where_the_press_goes_to_it() { + let mut h = open(linked_text_pdf(), "150", false); + let (plain, linked) = (at(&h, 200.0, 303.0), at(&h, 100.0, 303.0)); + assert_eq!(wait_for_cursor(&mut h, plain, |c| c == egui::CursorIcon::Text), egui::CursorIcon::Text, "the page's text has arrived"); + assert_eq!(cursor_at(&mut h, linked), egui::CursorIcon::PointingHand, "the Select tool follows the link"); + h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Highlight); + h.run_steps(2); + assert_eq!(cursor_at(&mut h, linked), egui::CursorIcon::Text, "the highlighter selects its text"); +} + /// Over two comments the tip names the one drawn on top (#3620), and a click should select that /// one too. The renderer paints in `/Annots` order, but the hit test takes the last comment in /// inspection's order, which is by top edge, and selects the one underneath. @@ -363,7 +430,7 @@ fn a_comments_tip_shows_with_every_tool_beside_its_cursor() { let mut h = open(comment_on_text_pdf(), "150", false); // Inside the rectangle comment by "Reviewer": where there is no text, and over a word. let (blank, on_text) = (at(&h, 120.0, 315.0), at(&h, 60.0, 303.0)); - let hover = |h: &mut Harness<'_, PdfCraftApp>, tool: QuickTool, p: egui::Pos2, want: egui::CursorIcon| { + let hover = |h: &mut Harness<'static, PdfCraftApp>, tool: QuickTool, p: egui::Pos2, want: egui::CursorIcon| { h.state_mut().quick_tool = tool; h.run_steps(4); let cursor = wait_for_cursor(h, p, |c| c == want); @@ -1396,6 +1463,114 @@ fn a_right_click_on_a_comment_under_a_camera_corner_opens_the_comments_menu() { assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected for its menu"); } +/// A right-button drag from `a` to `b`, in frames of their own. +fn right_drag(h: &mut Harness<'_, PdfCraftApp>, a: egui::Pos2, b: egui::Pos2) { + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }; + h.hover_at(a); + h.run_steps(1); + h.event(button(a, true)); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(a + (b - a) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.event(button(b, false)); + h.run_steps(3); +} + +// A right-button drag starts nothing a left-button drag does. egui's drag start counts every +// button, and each of these started on it. + +/// With the Rectangle tool, a right-button drag draws no rectangle. +#[test] +fn a_right_button_drag_draws_no_rectangle() { + let mut h = open(text_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle); + h.run_steps(4); + let (a, b) = (at(&h, 60.0, 150.0), at(&h, 200.0, 80.0)); + right_drag(&mut h, a, b); + let s = h.state(); + assert!(s.session.get(s.views[0].id).unwrap().info.annotations.is_empty(), "no rectangle is drawn"); +} + +/// With Prepare open, a right-button drag around a field selects it by no selection rectangle. +#[test] +fn a_right_button_drag_selects_no_field() { + let mut h = open(comment_and_checkbox_pdf(), "150", false); + assert!(h.state_mut().execute("form.prepare")); + h.run_steps(4); + let (a, b) = (at(&h, 60.0, 250.0), at(&h, 140.0, 180.0)); + right_drag(&mut h, a, b); + assert!(h.state().views[0].prepare.selected.is_none(), "no field is selected"); +} + +/// With the Link tool, a right-button drag draws no link. +#[test] +fn a_right_button_drag_draws_no_link() { + let mut h = open(text_pdf(), "150", false); + link_tool(&mut h); + let (a, b) = (at(&h, 60.0, 150.0), at(&h, 200.0, 80.0)); + right_drag(&mut h, a, b); + assert!(h.state().link_draft.is_none() && h.state().dialog != Some(Dialog::LinkProps), "no link is drawn"); +} + +/// With the camera, a right-button drag on another page leaves the quadrilateral where it is. +#[test] +fn a_right_button_drag_leaves_the_camera_quadrilateral() { + let mut h = open(two_pages_pdf(), "100", false); + h.state_mut().quick_tool = QuickTool::EnhanceCamera; + h.run_steps(4); + assert_eq!(h.state().views[0].camera.as_ref().map(|q| q.page), Some(0)); + let two = h.state().views[0].page_screen_rect(1).expect("page 2 on screen"); + right_drag(&mut h, two.min + egui::vec2(40.0, 40.0), two.min + egui::vec2(120.0, 100.0)); + assert_eq!(h.state().views[0].camera.as_ref().map(|q| q.page), Some(0), "the quadrilateral stays on page 1"); +} + +/// A text selection, then `tool` and a right-button drag across more of the line. The harness, and +/// the text selected before the drag. +fn right_drag_over_a_selection(tool: QuickTool) -> (Harness<'static, PdfCraftApp>, Option) { + let mut h = open(text_pdf(), "150", false); + let (a, b) = (at(&h, 45.0, 303.0), at(&h, 120.0, 303.0)); + assert_eq!(wait_for_cursor(&mut h, a, |c| c == egui::CursorIcon::Text), egui::CursorIcon::Text, "the page's text has arrived"); + mouse_drag(&mut h, a, b - a); + let selected = h.state().views[0].selected_text(); + assert!(selected.is_some(), "some text is selected"); + h.state_mut().quick_tool = tool; + h.run_steps(2); + let (c, d) = (at(&h, 150.0, 303.0), at(&h, 230.0, 303.0)); + right_drag(&mut h, c, d); + (h, selected) +} + +/// With the Select tool, a right-button drag leaves the text selected as it is. It moved the +/// selection's end to the pointer. +#[test] +fn a_right_button_drag_leaves_a_text_selection_alone() { + let (h, selected) = right_drag_over_a_selection(QuickTool::Select); + assert_eq!(h.state().views[0].selected_text(), selected, "the selection stays as it was"); +} + +/// A right-button drag with a markup tool or Redact turns no selection left from another tool into +/// a comment: its end finished the selection whichever button it was. +#[test] +fn a_right_button_drag_makes_no_mark_of_a_text_selection() { + for tool in [QuickTool::Comment(CommentTool::Highlight), QuickTool::Redact] { + let (h, _) = right_drag_over_a_selection(tool); + let s = h.state(); + assert!(s.session.get(s.views[0].id).unwrap().info.annotations.is_empty(), "no mark with {tool:?}"); + } +} + +/// With the Select tool, a right-button drag across text selects none. +#[test] +fn a_right_button_drag_selects_no_text() { + let mut h = open(text_pdf(), "150", false); + let (a, b) = (at(&h, 45.0, 303.0), at(&h, 200.0, 303.0)); + assert_eq!(wait_for_cursor(&mut h, a, |c| c == egui::CursorIcon::Text), egui::CursorIcon::Text, "the page's text has arrived"); + right_drag(&mut h, a, b); + assert!(h.state().views[0].selected_text().is_none(), "no text is selected"); +} + /// A 300×400 page with a web link along its left edge, x 0..100, y 300..350. fn edge_link_pdf() -> Vec { pdf(&[ @@ -1418,19 +1593,42 @@ fn the_link_tools_hand_stops_at_the_pages_edge() { assert_ne!(cursor_at(&mut h, beside), egui::CursorIcon::PointingHand, "just off the page"); } -/// With Edit text a right-click on a paragraph is Edit text's, as before, though a comment lies -/// over it: the comment is not selected. The page's menu is anchored there all the same (Paste on -/// the picture menu, Delete page); it used to stay where the last right-click comments saw was. +/// With Edit text a right-click goes to what lies on top under it, as with every tool: on a +/// paragraph under a comment, the comment, selected for its menu; on the paragraph beside it, the +/// paragraph, and the comment is unselected. The page's menu is anchored there either way (Paste on +/// the picture menu, Delete page). Edit text used to keep the right-clicks on its paragraphs, and +/// left the comment over them unselected. #[test] -fn a_right_click_with_edit_text_on_a_paragraph_under_a_comment_selects_no_comment() { +fn a_right_click_with_edit_text_on_a_paragraph_under_a_comment_selects_the_comment() { let mut h = open(comment_on_text_pdf(), "150", false); h.state_mut().quick_tool = QuickTool::EditText; h.run_steps(4); let on_text = at(&h, 100.0, 303.0); wait_for_cursor(&mut h, on_text, |c| c != egui::CursorIcon::Default); right_click(&mut h, on_text); - assert_eq!(h.state().views[0].comments.selected, None); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment over the paragraph is selected"); + assert!(h.query_by_label("Properties…").is_some(), "its menu opens"); assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(0), "the menu is anchored on the page"); + // The rectangle ends at x 200; the line goes on. + let beside = at(&h, 230.0, 303.0); + right_click(&mut h, beside); + assert_eq!(h.state().views[0].comments.selected, None, "the paragraph beside the comment leaves it unselected"); +} + +/// With Edit text, a right-click on added text over a comment selects no comment: what has no menu +/// gives way to a picture under it and to nothing else, as before. +#[test] +fn a_right_click_with_edit_text_on_added_text_over_a_comment_selects_no_comment() { + let mut h = open(comment_on_text_pdf(), "150", false); + assert!(h.state_mut().execute("edit.edit_text")); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { + page: 0, + text: pdfcraft_engine::AddedText { rect: [110.0, 296.0, 190.0, 311.0], text: "Added".into(), ..Default::default() }, + })); + h.run_steps(4); + let on_added = at(&h, 150.0, 303.0); + right_click(&mut h, on_added); + assert_eq!(h.state().views[0].comments.selected, None, "the comment under the added text is not selected"); } /// With a Prepare field tool a right-click is the tool's, as before: a comment under it is not @@ -1518,6 +1716,29 @@ fn a_right_click_selects_its_comment_though_a_gesture_was_left_over() { assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "the note under the right-click is selected"); } +/// A comment's move ends once its button is up, though the pages missed its release, and applies +/// nothing: where it came up is not known, and the pointer has moved on. A drag elsewhere later moves +/// nothing either. The move waited for the next drag's end on the page, and took the comment there. +#[test] +fn a_comment_move_whose_release_the_pages_missed_ends_once_the_button_is_up() { + let mut h = open(stacked_comments_pdf(), "150", false); + let rect = |h: &Harness<'_, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.index == 0).map(|a| a.rect) + }; + let before = rect(&h); + let bottom = at(&h, 60.0, 160.0); + h.state_mut().views[0].comments.gesture = Some(pdfcraft_ui_egui::comments::Gesture::Move { page: 0, index: 0, from: bottom }); + let elsewhere = at(&h, 250.0, 100.0); + h.hover_at(elsewhere); + h.run_steps(2); + assert!(h.state().views[0].comments.gesture.is_none(), "the move is over"); + assert_eq!(rect(&h), before, "and moved nothing to where the pointer turned up"); + let blank = at(&h, 250.0, 60.0); + mouse_drag(&mut h, blank, egui::vec2(30.0, 0.0)); + assert_eq!(rect(&h), before, "nothing moved the comment"); +} + /// A click that began off the page (in the gutter beside it) is no page's: the selected comment /// stays selected, as the Link tool's and Prepare's selections do. It used to unselect it. #[test] @@ -1591,8 +1812,8 @@ fn a_right_click_after_escape_cancelled_the_press_anchors_the_menu_on_its_page() assert_eq!(menu_page_after_a_right_click_with_the_button_held(true), Some(1), "the menu is page 2's"); } -/// With Edit text a right-click on added text reaches comments, as before (Edit text keeps those on -/// its own paragraphs and pictures): the selected comment is unselected. +/// With Edit text a right-click on added text reaches comments, as before: the selected comment is +/// unselected. #[test] fn a_right_click_with_edit_text_on_added_text_unselects_the_comment() { let mut h = open(comment_on_text_pdf(), "150", false); @@ -1673,6 +1894,198 @@ fn with_prepare_a_click_that_began_off_the_page_keeps_the_selected_field() { assert!(h.state().views[0].prepare.selected.is_some(), "a click that began off the page leaves it selected"); } +/// With Prepare open, a drag from a highlight draws the selection rectangle: a highlight lets a drag +/// go, so nobody holds it and it is Prepare's. +#[test] +fn with_prepare_a_drag_from_a_highlight_draws_the_selection_rectangle() { + let mut h = open(highlighted_checkbox_pdf(), "150", false); + assert!(h.state_mut().execute("form.prepare")); + h.run_steps(4); + // On the highlight, beside the box; the drag ends past the box's other corner. + let (from, to) = (at(&h, 97.0, 197.0), at(&h, 130.0, 230.0)); + mouse_drag(&mut h, from, to - from); + assert!(h.state().views[0].prepare.selected.is_some(), "the rectangle selects the field inside it"); +} + +/// A right-click while the primary button is held opens the menu of the comment it went to, when that +/// is the one selected, and leaves the selection as it is. +#[test] +fn a_right_click_with_the_button_held_opens_the_selected_comments_menu() { + let mut h = open(comment_on_text_pdf(), "150", false); + // On the rectangle's left edge: an unfilled one takes a press on its border. + let edge = at(&h, 30.0, 303.0); + wait_for_cursor(&mut h, edge, |c| c != egui::CursorIcon::Default); + mouse_click(&mut h, edge); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected"); + h.hover_at(edge); + h.run_steps(1); + h.drag_at(edge); + h.run_steps(1); + let moved = edge + egui::vec2(20.0, 0.0); + h.hover_at(moved); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: moved, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(3); + assert!(h.query_by_label("Properties…").is_some(), "the comment's menu"); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the selection stays"); + h.drop_at(moved); + h.run_steps(2); +} + +/// With the page gaps hidden, a Shift-click on the row two pages share is the first page's: what it +/// selects there stays selected, and the second page leaves it alone. +#[test] +fn a_shift_click_where_two_pages_touch_keeps_what_it_selected() { + let mut h = open(bottom_line_pages_pdf(), "100", false); + h.state_mut().views[0].show_page_gaps = false; + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(4); + let (one, two) = { + let v = &h.state().views[0]; + (v.page_screen_rect(0).expect("page 1 on screen"), v.page_screen_rect(1).expect("page 2 on screen")) + }; + // "Bottom line" runs from x 40, a few points above page 1's foot. + let seam = egui::pos2(one.min.x + 70.0, one.max.y); + assert!(one.contains(seam) && two.contains(seam), "the pages touch: {one:?} {two:?}"); + h.hover_at(seam); + h.run_steps(2); + for pressed in [true, false] { + h.event_modifiers( + egui::Event::PointerButton { pos: seam, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::SHIFT }, + egui::Modifiers::SHIFT, + ); + } + h.run_steps(4); + assert!(h.query_by_label_contains("1 object").is_some(), "the line is selected"); +} + +/// With Prepare open on a document that can't be changed, a right-click on a field selects nothing +/// and opens the page's menu, whose field entries would only be refused. +#[test] +fn with_prepare_on_a_locked_document_a_right_click_on_a_field_selects_nothing() { + let locked = crate::editing::encrypted(comment_and_checkbox_pdf(), pdfcraft_cos::Algorithm::Aes256, "", "owner", -1 ^ 8); + let mut h = open(locked, "150", false); + // Prepare doesn't open on such a document; it stays open on a tab switched to one. + h.state_mut().left = pdfcraft_ui_egui::LeftPanel::Tool("form"); + h.state_mut().left_open = true; + h.run_steps(4); + assert!(h.state().is_preparing(), "Prepare is open"); + let id = h.state().views[0].id; + assert!(!h.state().session.get(id).unwrap().allows_modification(), "the document can't be changed"); + let on_box = at(&h, 110.0, 210.0); + right_click(&mut h, on_box); + assert!(h.state().views[0].prepare.selected.is_none(), "no field is selected"); + assert!(h.query_by_label("Duplicate…").is_none(), "no field menu"); +} + +/// With Prepare open and a field selected, a right-click on the empty page opens the page's menu, and +/// one on the field the field's: the menu is what the right-click went to. The field's menu opened +/// wherever the page was right-clicked. +#[test] +fn with_prepare_a_right_click_beside_the_selected_field_opens_the_pages_menu() { + let mut h = open(comment_and_checkbox_pdf(), "150", false); + assert!(h.state_mut().execute("form.prepare")); + h.run_steps(4); + let on_box = at(&h, 110.0, 210.0); + mouse_click(&mut h, on_box); + assert!(h.state().views[0].prepare.selected.is_some(), "the field is selected"); + let blank = at(&h, 60.0, 300.0); + right_click(&mut h, blank); + assert!(h.query_by_label("Duplicate…").is_none(), "no field menu on the empty page"); + assert!(h.query_by_label("Marquee Zoom").is_some(), "the page's menu"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + right_click(&mut h, on_box); + assert!(h.query_by_label("Duplicate…").is_some(), "the field's menu on the field"); +} + +/// With a Prepare field tool picked, a right-click on a field selects it and opens its menu, as with +/// Prepare's Select tool: the tool's hit leaves fields out (it draws new ones over them), but a +/// right-click reaches them. +#[test] +fn with_a_field_tool_a_right_click_on_a_field_opens_its_menu() { + let mut h = open(comment_and_checkbox_pdf(), "150", false); + assert!(h.state_mut().execute("form.prepare")); + h.run_steps(4); + assert!(h.state_mut().execute("form.add.text")); + h.run_steps(4); + let on_box = at(&h, 110.0, 210.0); + right_click(&mut h, on_box); + assert!(h.state().views[0].prepare.selected.is_some(), "the field is selected"); + assert!(h.query_by_label("Duplicate…").is_some(), "the field's menu"); +} + +/// With Edit text, a right-click on a picture under a link is the picture's (Edit text doesn't +/// follow links): it is selected for its menu. +#[test] +fn with_edit_text_a_right_click_on_a_picture_under_a_link_selects_the_picture() { + let mut h = open(linked_picture_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::EditText; + h.run_steps(4); + let on_link = at(&h, 150.0, 300.0); + right_click(&mut h, on_link); + let selected = h.state().views[0].image_selection.as_ref().map(|s| (s.page, s.index)); + assert_eq!(selected, Some((0, 0)), "the picture is selected for its menu"); +} + +/// With the gaps between pages hidden, a drag from the top of page 2 under page 1's open paragraph is +/// page 2's: its area selection takes the line there. The paragraph's frame, which reaches past +/// page 1's edge, took the press and did nothing with it. +#[test] +fn a_drag_past_a_pages_edge_under_its_open_paragraph_is_the_next_pages() { + let mut h = open(bottom_line_pages_pdf(), "100", false); + h.state_mut().views[0].show_page_gaps = false; + h.state_mut().quick_tool = QuickTool::EditText; + h.run_steps(4); + let (one, two) = { + let v = &h.state().views[0]; + (v.page_screen_rect(0).expect("page 1 on screen"), v.page_screen_rect(1).expect("page 2 on screen")) + }; + let line = egui::pos2(one.min.x + 60.0, one.max.y - 8.0); + mouse_click(&mut h, line); + assert!(h.state().views[0].line_editor.is_some(), "the bottom line is open"); + let below = egui::pos2(one.min.x + 60.0, two.min.y + 3.0); + mouse_drag(&mut h, below, egui::vec2(200.0, 30.0)); + // What the area selection took, Delete removes. + h.key_press(egui::Key::Delete); + h.run_steps(4); + let s = h.state(); + assert!(s.session.get(s.views[0].id).unwrap().text_blocks(1).is_empty(), "page 2's area selection took its line"); +} + +/// With Edit text, a click on the empty page leaves the selected picture unselected, on a page whose +/// objects can't be selected together too, where no area selection starts to do it. It stayed. +#[test] +fn a_click_beside_the_picture_unselects_it_where_objects_cant_be_grouped() { + let mut h = open(ungroupable_picture_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::EditText; + h.run_steps(4); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.page_images(0).len(), 1, "the picture"); + assert!(doc.editable_objects(0).is_err(), "and no objects to group"); + h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::ImageSelection::at(0, 0)); + h.run_steps(2); + let blank = at(&h, 150.0, 100.0); + mouse_click(&mut h, blank); + assert!(h.state().views[0].image_selection.is_none(), "the picture is unselected"); +} + +/// With the Select tool a drag that starts on a link lets the picked picture go, as a drag elsewhere +/// does, though the link's click picks none. +#[test] +fn a_drag_from_a_link_lets_the_picked_picture_go() { + let mut h = open(linked_picture_pdf(), "150", false); + let beside = at(&h, 70.0, 300.0); + mouse_click(&mut h, beside); + assert!(h.state().views[0].picked_image.is_some(), "the picture is picked"); + let on_link = at(&h, 150.0, 300.0); + mouse_drag(&mut h, on_link, egui::vec2(30.0, 0.0)); + assert!(h.state().views[0].picked_image.is_none(), "the drag lets it go"); +} + /// With the Edit panel open and the Note tool, a click on added text is the added text's: no note is /// started under it, as before. #[test] @@ -1691,8 +2104,9 @@ fn a_note_tool_click_on_added_text_starts_no_note() { assert!(h.state().views[0].comments.composer.is_none(), "no note is started"); } -/// With Fill & Sign, a mark being moved on the page keeps the page's presses, as before: a click while -/// its move is still live (its release never seen) places no new mark. +/// With Fill & Sign, a mark being moved on the page keeps the page's presses, as before: a press held +/// through its move and released places no new mark. (A move left over once the button is up ends: +/// `a_comment_move_whose_release_the_pages_missed_ends_once_the_button_is_up`.) #[test] fn a_fill_and_sign_click_places_nothing_while_a_mark_is_moved() { let mut h = open(comment_on_text_pdf(), "150", false); @@ -1704,8 +2118,13 @@ fn a_fill_and_sign_click_places_nothing_while_a_mark_is_moved() { }; let before = marks(&h); let blank = at(&h, 150.0, 100.0); + h.hover_at(blank); + h.run_steps(1); + h.drag_at(blank); + h.run_steps(1); h.state_mut().views[0].comments.gesture = Some(pdfcraft_ui_egui::comments::Gesture::Move { page: 0, index: 0, from: blank }); - mouse_click(&mut h, blank); + h.drop_at(blank); + h.run_steps(4); assert_eq!(marks(&h), before, "no mark is placed"); } @@ -1757,11 +2176,12 @@ fn a_right_click_between_the_pages_opens_the_page_menu_not_the_selected_comments assert!(h.query_by_label("Reply").is_none(), "not the comment's"); } -/// Where pages touch, a right-click on the row they share is page 1's, and a picture on page 2 there -/// opens its menu, whose Paste then places the image as Ctrl+V does. It used to centre the image on -/// page 1's point, which on page 2 lies at its foot. +/// Where pages touch, a right-click on the row they share is page 1's, whatever lies there on page 2: +/// page 2's picture across its top edge opens no menu, and page 1's menu pastes on page 1, where it +/// opened. The page view's own picture scan used to open page 2's picture's menu there, whose Paste +/// centred the image on page 1's point, which on page 2 lies at its foot. #[test] -fn paste_from_a_picture_menu_where_two_pages_touch_keeps_off_the_first_pages_point() { +fn paste_from_a_right_click_where_two_pages_touch_keeps_to_the_first_page() { let (mut h, seam) = touching_pages(|app| { app.system_clipboard = false; app.quick_tool = QuickTool::EditText; @@ -1774,16 +2194,18 @@ fn paste_from_a_picture_menu_where_two_pages_touch_keeps_off_the_first_pages_poi h.run_steps(4); right_click(&mut h, seam); assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(0), "page 1 takes the right-click"); - assert_eq!(h.state().views[0].image_selection.as_ref().map(|s| s.page), Some(1), "page 2's picture opens its menu"); - h.get_by_label("Paste").click(); + assert!(h.state().views[0].image_selection.is_none(), "page 2's picture is not selected for its menu"); + h.get_by_label_contains("Paste").click(); h.run_steps(3); - let pasted = { + let images = |page| { let s = h.state(); - s.session.get(s.views[0].id).unwrap().page_images(1) + s.session.get(s.views[0].id).unwrap().page_images(page) }; - assert_eq!(pasted.len(), 2, "the image is pasted on page 2"); - let r = pasted[1].rect; - assert!((r[1] + r[3]) / 2.0 > 100.0, "not centred at page 2's foot: {r:?}"); + assert_eq!(images(1).len(), 1, "nothing is pasted on page 2"); + let pasted = images(0); + assert_eq!(pasted.len(), 1, "the image is pasted on page 1"); + let r = pasted[0].rect; + assert!((r[1] + r[3]) / 2.0 < 100.0, "at page 1's foot, where its menu opened: {r:?}"); } /// With the camera tool, a click on another page moves the quadrilateral there, as a drag from it diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 450ba06260..7582b3f6bc 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -70,8 +70,8 @@ milestone = "M1" status = "shipped" commands = [] tools = ["doc_info"] -evidence = ["crates/ui-egui/tests/it/viewer.rs::damaged_files_say_they_were_repaired", "crates/cos/tests/it/generations.rs::contradictory_xref_generation_recovers_the_matching_header_and_records_repair", "crates/cos/tests/it/generations.rs::generation_repair_diagnostics_are_bounded_without_disabling_recovery", "crates/cos/tests/it/generations.rs::retained_generation_disagreements_are_reported_in_materialized_mode_too", "crates/cos/tests/it/generations.rs::hybrid_generation_diagnostics_observe_rows_before_the_table_hides_them", "crates/cos/tests/it/generations.rs::discarded_xref_repair_notes_do_not_survive_object_header_reconstruction"] -notes = "a notice says the file was repaired; Document Properties ▸ Advanced lists the repairs; doc_info reports them. COS records bounded live xref/header generation disagreements on open, including retained materialized and hybrid rows before folding; this follow-up validates the COS policy, without a fresh GUI claim." +evidence = ["crates/ui-egui/tests/it/viewer.rs::damaged_files_say_they_were_repaired", "crates/cos/tests/it/generations.rs::contradictory_xref_generation_recovers_the_matching_header_and_records_repair", "crates/cos/tests/it/generations.rs::generation_repair_diagnostics_are_bounded_without_disabling_recovery", "crates/cos/tests/it/generations.rs::retained_generation_disagreements_are_reported_in_materialized_mode_too", "crates/cos/tests/it/generations.rs::hybrid_generation_diagnostics_observe_rows_before_the_table_hides_them", "crates/cos/tests/it/generations.rs::discarded_xref_repair_notes_do_not_survive_object_header_reconstruction", "crates/cos/src/document/tests.rs::unreadable_objects_are_listed_with_the_reason", "crates/automation/tests/it/unreadable_objects.rs::doc_info_lists_an_object_that_could_not_be_read"] +notes = "a notice says the file was repaired; Document Properties ▸ Advanced lists the repairs; doc_info reports them, and lists apart (unreadable_objects) each object that could not be read and reads as null, with its number and why: only objects read so far, including any object a scan, save or audit read, at most 100; a stream whose own data fails to decode is not listed, but the objects of an object stream that cannot be decoded are. COS records bounded live xref/header generation disagreements on open, including retained materialized and hybrid rows before folding; this follow-up validates the COS policy, without a fresh GUI claim." [[feature]] id = "core.xref-streams"