diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b7abd2bb79..36552ddd1a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -981,6 +981,7 @@ impl DocView { self.waiting_since.clear(); self.last_queue.clear(); self.selection = None; + self.all_text = false; if let Some(f) = self.find.as_mut() { f.matches.clear(); f.current = None; @@ -1067,6 +1068,7 @@ impl DocView { /// Select text on `page` from glyph `from` to glyph `to` (tests and automation). pub fn select_text(&mut self, page: usize, from: usize, to: usize) { + self.all_text = false; self.selection = Some(Selection::run(page, from, to)); } @@ -1367,6 +1369,7 @@ impl DocView { self.all_text = true; return self.selected_text().is_some(); } + self.all_text = false; let page = self.current; let Some(t) = self.texts.get(&page) else { return false }; if t.glyphs.is_empty() { @@ -1435,6 +1438,8 @@ impl DocView { pub fn set_continuous(&mut self, continuous: bool) { if self.continuous != continuous { self.continuous = continuous; + // Select All of the document belongs to continuous scrolling. + self.all_text = false; self.goto = Some((self.current, 0.0)); self.reset_coarse_state(); } @@ -3301,6 +3306,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.signature_drag.prepare(ui.ctx(), doc, preview_target, scale); view.signature_drag.paint(painter, &pcx, &view.comments, view.pending_edit.as_ref()); + // Any press on the page ends Select All of the document: text read or not, any tool, + // on a link too. + if view.all_text && ui.input(|inp| inp.pointer.primary_pressed() && inp.pointer.press_origin().is_some_and(|o| r.contains(o))) { + view.all_text = false; + } // Text layer: find matches, selection, I-beam and drag-to-select. let to_screen = |g: [f32; 4]| xf.view_rect(g); if let Some(text) = view.texts.get(&i).cloned() { @@ -3373,8 +3383,6 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { .is_some_and(|o| r.contains(o)); let pressed = ui.input(|inp| inp.pointer.primary_pressed()); if pressed && press_here && !over_link { - // A press on a page starts over: Select All of the document is dropped. - view.all_text = false; let now = ui.input(|inp| inp.time); // The same limits egui uses to count clicks on release. let (max_delay, max_dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist)); @@ -5754,10 +5762,87 @@ trailer << /Root 1 0 R >> assert!(!v.select_all()); assert!(v.all_text && v.selection.is_none()); assert_eq!(v.pages_without_text(), vec![0, 1, 2]); - let text = app.selection_text(&app.views[0]).expect("text to copy"); + // Copy reads the pages off the UI thread; the clipboard gets the text once they're read. + let ctx = egui::Context::default(); + assert!(app.copy_selected_text(0, &ctx), "the copy starts"); + assert!(app.document_copy.is_some(), "reading the pages, not on this thread"); + let mut text = None; + for _ in 0..500 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| app.finish_document_copy(ui.ctx())); + out.textures_delta.clear(); + text = out.platform_output.commands.iter().find_map(|c| match c { + egui::OutputCommand::CopyText(t) => Some(t.clone()), + _ => None, + }); + if text.is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let text = text.expect("the copied text arrives"); + assert!(app.document_copy.is_none(), "done"); let at = |w: &str| text.find(w).unwrap_or_else(|| panic!("{w:?} missing from {text:?}")); assert!(at("Alpha") < at("Bravo") && at("Bravo") < at("Charlie"), "page order: {text:?}"); assert_eq!(text.lines().count(), 3, "a line per page: {text:?}"); + // A new scope ends it: one page at a time, Select All is the page's again. + app.views[0].set_continuous(false); + assert!(!app.views[0].all_text, "not after switching to one page at a time"); + // So do a page selection and a change of the document's content (an edit, undo). + app.views[0].set_continuous(true); + app.views[0].select_all(); + app.views[0].select_text(0, 0, 0); + assert!(!app.views[0].all_text, "a page selection replaces it"); + app.views[0].select_all(); + app.views[0].invalidate_content(); + assert!(!app.views[0].all_text, "not past an edit of the document"); + } + + /// A press anywhere on a page ends Select All of the document, whatever the tool (here the + /// Hand, which selects no text) and whether the page's text is read yet. + #[test] + fn a_press_on_a_page_ends_select_all_of_the_document() { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.open_bytes("three.pdf", None, text_pages_pdf(&["Alpha one", "Bravo two", "Charlie three"])).unwrap(); + app + }); + let mut on_screen = None; + for _ in 0..600 { + h.run_steps(2); + on_screen = h.state().views[0].page_screen_rect(0); + if on_screen.is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let r = on_screen.expect("the first page is on screen"); + h.state_mut().quick_tool = crate::QuickTool::Hand; + h.state_mut().views[0].select_all(); + assert!(h.state().views[0].all_text); + let at = r.center(); + h.event(egui::Event::PointerMoved(at)); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + assert!(!h.state().views[0].all_text, "a press on the page ends it"); + } + + /// The pages of a whole-document copy come out in order whatever was read before, and a + /// cancelled copy gives nothing (a new copy or a closed document cancels the old one). + #[test] + fn a_document_copy_reads_in_page_order_and_stops_when_cancelled() { + let mut app = PdfCraftApp::new(); + app.open_bytes("three.pdf", None, text_pages_pdf(&["Alpha one", "Bravo two", "Charlie three"])).unwrap(); + // The middle page's text read for display already; the others read by the copy. + let doc = app.session.get(app.views[0].id).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default()); + let middle = r.render(RenderRequest { page: 1, kind: RequestKind::Text, ..Default::default() }).text.unwrap(); + app.views[0].texts.insert(1, middle); + let job = || crate::DocumentCopyJob::of(&app.views[0], app.session.get(app.views[0].id).unwrap()); + let text = job().text(&std::sync::atomic::AtomicBool::new(false)).unwrap(); + assert!(text.find("Alpha").unwrap() < text.find("Bravo").unwrap() && text.find("Bravo").unwrap() < text.find("Charlie").unwrap(), "{text:?}"); + assert_eq!(job().text(&std::sync::atomic::AtomicBool::new(true)), None, "cancelled"); } /// Select All keeps to the page shown one page at a time, and in a document of one page; of diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2923788dea..6d1f7e70a6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -384,6 +384,121 @@ pub struct PageClip { pub pages: Vec, } +/// One page of a whole-document copy: its text as read for display, to read, or not to try +/// (reading it failed before). +enum CopyPage { + Read(std::sync::Arc), + ToRead, + Skip, +} + +/// What a whole-document copy needs, taken from the view on the UI thread: each page's text +/// layer as far as it was read, and the document to read the others from. +struct DocumentCopyJob { + pages: Vec, + bytes: std::sync::Arc>, + password: Option>, +} + +impl DocumentCopyJob { + fn of(v: &DocView, doc: &pdfcraft_engine::Document) -> DocumentCopyJob { + let missing = v.pages_without_text(); + let pages = (0..v.page_count()) + .map(|p| match v.texts.get(&p) { + Some(t) => CopyPage::Read(t.clone()), + None if missing.contains(&p) => CopyPage::ToRead, + None => CopyPage::Skip, + }) + .collect(); + DocumentCopyJob { pages, bytes: doc.bytes.clone(), password: doc.password.as_deref().map(std::sync::Arc::from) } + } + + /// The document's text in page order, a page per line run. Each page read here is let go + /// once its text is taken, so a large document never holds every page's text layer at once. + /// `None` when `cancel` was set. + fn text(self, cancel: &std::sync::atomic::AtomicBool) -> Option { + let mut renderer: Option = None; + let mut out = String::new(); + let mut push = |text: String| { + if !text.is_empty() { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(&text); + } + }; + for (page, p) in self.pages.into_iter().enumerate() { + if cancel.load(std::sync::atomic::Ordering::Relaxed) { + return None; + } + match p { + CopyPage::Read(t) => push(t.text_of(0..t.glyphs.len())), + CopyPage::ToRead => { + let r = renderer.get_or_insert_with(|| { + let config = pdfcraft_render::RenderConfig { password: self.password.clone(), ..Default::default() }; + pdfcraft_render::PageRenderer::new(self.bytes.clone(), config) + }); + let req = pdfcraft_render::RenderRequest { + page, + kind: pdfcraft_render::RequestKind::Text, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }; + if let Some(t) = r.render(req).text { + push(t.text_of(0..t.glyphs.len())); + } + } + CopyPage::Skip => {} + } + } + Some(out) + } +} + +/// A whole-document copy reading its pages off the UI thread (in place on the web, which has no +/// threads). Dropping it (a new copy, the document closed) stops the reading. +struct DocumentCopy { + doc: DocId, + rx: std::sync::mpsc::Receiver, + cancel: std::sync::Arc, +} + +impl DocumentCopy { + fn start(doc: DocId, job: DocumentCopyJob, ctx: &egui::Context) -> DocumentCopy { + let (tx, rx) = std::sync::mpsc::channel(); + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + #[cfg(not(target_arch = "wasm32"))] + { + let (stop, ctx) = (cancel.clone(), ctx.clone()); + let spawned = std::thread::Builder::new().name("copy-document-text".into()).spawn(move || { + if let Some(text) = job.text(&stop) { + let _ = tx.send(text); + ctx.request_repaint(); + } + }); + if let Err(e) = spawned { + log::warn!("copying the document's text: {e}"); + } + } + #[cfg(target_arch = "wasm32")] + { + let _ = ctx; + if let Some(text) = job.text(&cancel) { + let _ = tx.send(text); + } + } + DocumentCopy { doc, rx, cancel } + } +} + +impl Drop for DocumentCopy { + fn drop(&mut self) { + self.cancel.store(true, std::sync::atomic::Ordering::Relaxed); + } +} + /// Files delivered asynchronously: (name, bytes). pub type Inbox = std::sync::Arc)>>>; @@ -602,6 +717,8 @@ pub struct PdfCraftApp { /// Whether the window currently has the app's header bar (`None` until the first frame). app_frame_shown: Option, pub password_prompt: Option, + /// Copy of a whole document's text (#2639) whose pages are being read off the UI thread. + document_copy: Option, pub unlock_prompt: Option, pub full_screen: bool, /// Files delivered asynchronously (web drag-and-drop, web file picker). @@ -957,6 +1074,7 @@ impl PdfCraftApp { system_title_bar: false, app_frame_shown: None, password_prompt: None, + document_copy: None, unlock_prompt: None, full_screen: false, inbox: Default::default(), @@ -2852,6 +2970,7 @@ impl PdfCraftApp { object_ui::nudge_keys(ctx, &mut self.views[i], doc); } canvas::shortcuts(&mut self.views[i], ctx, self.scroll_speed); + self.finish_document_copy(ctx); self.copy_key(i, ctx); self.image_pick_keys(i, ctx); } @@ -2886,35 +3005,22 @@ impl PdfCraftApp { self.copy_selected_text(i, ctx); } - /// The text Copy takes from view `v`: its selection, or after Select All of the document every - /// page's text, the pages not read for display yet read here (#2639). - fn selection_text(&self, v: &DocView) -> Option { - if !v.all_text { - return v.selected_text(); - } - let missing = v.pages_without_text(); - let mut more = std::collections::HashMap::new(); - if !missing.is_empty() - && let Some(doc) = self.session.get(v.id) - { - let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..Default::default() }; - let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); - for page in missing { - let req = - pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }; - if let Some(t) = r.render(req).text { - more.insert(page, t); - } - } - } - Some(v.whole_text(&more)) - } - /// Shared permission gate for copying selected page text from menus and keyboard events. + /// After Select All of the document (#2639), pages whose text wasn't read for display are read + /// off the UI thread ([`DocumentCopy`]), and the clipboard gets the text when they're done. pub(crate) fn copy_selected_text(&mut self, i: usize, ctx: &egui::Context) -> bool { - let Some((id, text)) = self.views.get(i).and_then(|v| Some((v.id, self.selection_text(v)?))).filter(|(_, text)| !text.is_empty()) else { - return false; - }; + let Some(v) = self.views.get(i) else { return false }; + let id = v.id; + if v.all_text && !v.pages_without_text().is_empty() { + // Checked before any page is read: a document that refuses copying reads nothing. + if !self.may_copy(id) { + return false; + } + let Some(job) = self.views.get(i).and_then(|v| Some(DocumentCopyJob::of(v, self.session.get(id)?))) else { return false }; + self.document_copy = Some(DocumentCopy::start(id, job, ctx)); + return true; + } + let Some(text) = v.selected_text().filter(|text| !text.is_empty()) else { return false }; if !self.may_copy(id) { return false; } @@ -2922,6 +3028,26 @@ impl PdfCraftApp { true } + /// The clipboard text of a whole-document copy, once its pages are read; dropped with the + /// document it was copied from. + fn finish_document_copy(&mut self, ctx: &egui::Context) { + let Some(c) = &self.document_copy else { return }; + if !self.views.iter().any(|v| v.id == c.doc) { + self.document_copy = None; + return; + } + match c.rx.try_recv() { + Ok(text) => { + self.document_copy = None; + if !text.is_empty() { + ctx.copy_text(text); + } + } + Err(std::sync::mpsc::TryRecvError::Empty) => ctx.request_repaint_after(std::time::Duration::from_millis(50)), + Err(std::sync::mpsc::TryRecvError::Disconnected) => self.document_copy = None, + } + } + /// The quick tools' keys, as in Acrobat: V selects, H pans, and holding Space pans until it /// is released. Plain letters, so not while a text field, a form field, a dialog or the /// palette has the keyboard.