diff --git a/ROADMAP.md b/ROADMAP.md index 98936d0999..40f62ab883 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -158,6 +158,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log - **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design. +- 2026-10-12 · M3: selected page text can be copied from Edit and the canvas context menu (#1225). Five focused regressions, full CI and native Unicode clipboard checks pass; floating Copy shares the document permission gate. Estimates unchanged. + - **2026-10-13 (M2, page-label prefixes):** Preserve literal leading and trailing whitespace in page-label prefixes without changing generic metadata normalization. Nine original public-inspection cases cover prefix spaces, encodings and empty-prefix controls. Existing empty-prefix fallback remains a compatibility policy, not a conformance claim. Estimates unchanged. - **2026-10-14 (M14, Italian catalog):** Italian gains the 201 entries the other catalogs had and the code looks up: the sentences the accessibility checker, preflight, signature validation and page-range parser word, certificate and calendar labels, digital-ID dialog messages and five plurals. It now has 2,528 entries. 64 keys other catalogs carry but no code looks up (tooltip texts with a fixed shortcut, older wordings, generic labels only Lithuanian has) were left out. AI-assisted, written from the meaning of the English; native review welcome. Estimates unchanged. - **2026-10-15 (M3, #2166 part 1):** Save asks before writing over a version of the file the document did not come from (something else wrote it since the open or the last save), with Save as… to keep it, and the changed-on-disk notice says what Reload and Save each do; a file that is open already comes to the front instead of opening twice; a running instance's autosaves are locked, so a second instance neither offers nor discards them. Part 2 (every writer checked and atomic) is #2182. diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 414cf7553f..45c58302eb 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -213,6 +213,8 @@ pub const COMMANDS: &[CommandSpec] = &[ // Acrobat's column select: drag a rectangle and select only the text inside it. Alt/Option- // drag with the Select tool does the same; this is the way in where the desktop takes // Alt-drag for moving windows (several Linux window managers). + // Copy events stay with the frontend so focused text inputs and page clipboard routing keep ownership. + c("edit.copy", "Copy", EDIT, None, Copying, "copy"), c("edit.column_select", "Column select", EDIT, None, Document, "text-select"), c("edit.snapshot", "Take a snapshot", EDIT, None, Copying, "camera"), c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"), diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3920a5dc9a..708d8c1737 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2498,6 +2498,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let redact_mark = (app.redact_prefs.hover_fill, app.redact_prefs.opacity); let units = app.units; let allowed = doc.allows_annotation(); + let can_copy = doc.allows_copying(); + let mut copy_selected = false; let comments_hidden = doc.comments_hidden(); let form = doc.form.clone(); let can_fill = doc.allows_form_filling(); @@ -3359,6 +3361,15 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } return; } + if view.comments.selected.is_none() { + let selected = view.selected_text().is_some_and(|t| !t.is_empty()); + let key = crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY); + if ui.add_enabled(can_copy && selected, egui::Button::new(tl!("Copy")).shortcut_text(key)).clicked() { + copy_selected = true; + ui.close(); + } + ui.separator(); + } // Over a picture (and no comment): Copy Image, as in Acrobat's page menu. let mut copy_image = None; if view.comments.selected.is_none() @@ -3402,7 +3413,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.viewport_screen = out.inner_rect; view.auto_scroll.paint(ui, avail); if unobstructed && matches!(tool, QuickTool::Select | QuickTool::ColumnSelect) { - selection_actions(ui, avail, view, info, prefs, allowed && !comments_hidden); + copy_selected |= selection_actions(ui, avail, view, info, prefs, allowed && !comments_hidden, can_copy); } // Collect all main-page demand, including cached surfaces, for byte admission later. @@ -3697,6 +3708,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if let Some((name, action)) = app.views[index].forms.button.take() { run_button(app, index, &name, action); } + if copy_selected { + app.execute("edit.copy"); + } quick_bar(app, avail, ui); if let Some((source, file, destination)) = remote_open { app.open_remote_link(source, file, destination); @@ -3712,21 +3726,29 @@ enum SelectionAction { } /// Actions on an existing text selection, next to the selected words. -fn selection_actions(ui: &mut egui::Ui, viewport: Rect, view: &mut DocView, info: &DocInfo, prefs: &comments::CommentPrefs, can_comment: bool) { +fn selection_actions( + ui: &mut egui::Ui, + viewport: Rect, + view: &mut DocView, + info: &DocInfo, + prefs: &comments::CommentPrefs, + can_comment: bool, + can_copy: bool, +) -> bool { if ui.input(|input| input.pointer.is_decidedly_dragging()) { - return; + return false; } - let Some(selection) = view.selection else { return }; - let Some(text) = view.texts.get(&selection.page) else { return }; - let Some(xf) = view.page_xform(selection.page) else { return }; - let Some(last) = selection.rects(text).last().copied() else { return }; + let Some(selection) = view.selection else { return false }; + let Some(text) = view.texts.get(&selection.page) else { return false }; + let Some(xf) = view.page_xform(selection.page) else { return false }; + let Some(last) = selection.rects(text).last().copied() else { return false }; let selected = selection.text(text); if selected.is_empty() { - return; + return false; } let rect = xf.view_rect(last); if !viewport.intersects(rect) { - return; + return false; } let x = rect.left().clamp(viewport.left() + 8.0, (viewport.right() - 520.0).max(viewport.left() + 8.0)); let y = if rect.bottom() + 48.0 < viewport.bottom() { rect.bottom() + 6.0 } else { (rect.top() - 44.0).max(viewport.top() + 6.0) }; @@ -3745,18 +3767,17 @@ fn selection_actions(ui: &mut egui::Ui, viewport: Rect, view: &mut DocView, info chosen = Some(action); } } - if ui.button(tl!("Copy")).clicked() { + if ui.add_enabled(can_copy, egui::Button::new(tl!("Copy"))).clicked() { chosen = Some(SelectionAction::Copy); } }); }); }); - let Some(action) = chosen else { return }; + let Some(action) = chosen else { return false }; if let SelectionAction::Copy = action { - ui.ctx().copy_text(selected); - return; + return true; } - let Some((page, quads)) = view.selection_quads(info) else { return }; + let Some((page, quads)) = view.selection_quads(info) else { return false }; let last = quads.last().copied().unwrap_or_default(); let at = [last[2].max(last[6]), last[1].max(last[3])]; view.clear_selection(); @@ -3775,7 +3796,7 @@ fn selection_actions(ui: &mut egui::Ui, viewport: Rect, view: &mut DocView, info } SelectionAction::Markup(tool) => { // Every markup button carries a markup tool; anything else adds nothing. - let Some(kind) = tool.markup() else { return }; + let Some(kind) = tool.markup() else { return false }; view.pending_edit = Some(Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { page, shape: pdfcraft_engine::Shape::TextMarkup { kind, quads }, @@ -3784,9 +3805,10 @@ fn selection_actions(ui: &mut egui::Ui, viewport: Rect, view: &mut DocView, info author: prefs.author.clone(), })); } - // Copied above. + // Copy is dispatched through the app after the view borrow ends. SelectionAction::Copy => {} } + false } /// Run a push button's action (the ones that need no JavaScript engine). diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index a989f60353..18aee27327 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -85,6 +85,8 @@ impl PdfCraftApp { return self.combine_can_undo(spec.needs == commands::Needs::Undo); } commands::is_enabled(spec, &self.session, self.active_ids().map(|(_, id)| id)) + && (spec.id != "edit.copy" + || self.active.and_then(|i| self.views.get(i)).is_some_and(|v| !v.organize && v.selected_text().is_some_and(|t| !t.is_empty()))) && (spec.needs != commands::Needs::TwoPageView || self.active.and_then(|i| self.views.get(i)).is_some_and(crate::DocView::cover_applies)) && match spec.id { "view.previous_view" => self.active.and_then(|i| self.views.get(i)).is_some_and(|v| !v.back.is_empty()), @@ -527,6 +529,12 @@ impl PdfCraftApp { self.quick_tool = crate::QuickTool::Snapshot; self.notify_tr("Drag a rectangle around the area to copy"); } + "edit.copy" => { + if let (Some(i), Some(ctx)) = (active, self.ctx.clone()) { + return self.copy_selected_text(i, &ctx); + } + return false; + } "page.copy" => self.copy_pages(false), "page.cut" => self.copy_pages(true), "page.paste" => self.paste_pages(), @@ -785,7 +793,7 @@ pub(crate) fn registry_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui, menu: &str } continue; } - let shortcut = spec.shortcut.map(|s| shortcut_label(ui.ctx(), s)).unwrap_or_default(); + let shortcut = spec.shortcut.or((spec.id == "edit.copy").then_some(COPY)).map(|s| shortcut_label(ui.ctx(), s)).unwrap_or_default(); let enabled = app.command_enabled(spec); let resp = ui.add_enabled(enabled, egui::Button::new(label).shortcut_text(shortcut)); if resp.clicked() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 15d60ef4b2..e5e4729794 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2833,10 +2833,19 @@ impl PdfCraftApp { if !copy || ctx.egui_wants_keyboard_input() { return; } - let Some((id, text)) = self.views.get(i).and_then(|v| Some((v.id, v.selected_text()?))) else { return }; - if self.may_copy(id) { - ctx.copy_text(text); + self.copy_selected_text(i, ctx); + } + + /// Shared permission gate for copying selected page text from menus and keyboard events. + 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, v.selected_text()?))).filter(|(_, text)| !text.is_empty()) else { + return false; + }; + if !self.may_copy(id) { + return false; } + ctx.copy_text(text); + true } /// The quick tools' keys, as in Acrobat: V selects, H pans, and holding Space pans until it diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index 8a313f44ac..5afb4db558 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -71,7 +71,8 @@ const PICKERS: &[&str] = &[ #[test] fn every_registered_command_is_implemented() { for spec in COMMANDS { - if PICKERS.contains(&spec.id) { + // Page-text Copy needs a loaded view and egui context, covered by ui_group::text_select. + if PICKERS.contains(&spec.id) || spec.id == "edit.copy" { continue; } let mut app = PdfCraftApp::new(); diff --git a/crates/ui-egui/tests/ui_group/text_select.rs b/crates/ui-egui/tests/ui_group/text_select.rs index 4b9013a575..9ed4e9d5d4 100644 --- a/crates/ui-egui/tests/ui_group/text_select.rs +++ b/crates/ui-egui/tests/ui_group/text_select.rs @@ -7,7 +7,10 @@ use pdfcraft_ui_egui::PdfCraftApp; /// One 300×200 page with two lines of Helvetica text. fn two_lines() -> Vec { - let content = "BT /F1 14 Tf 20 150 Td (The quick brown fox) Tj 0 -24 Td (jumps over the dog) Tj ET"; + text_pdf("BT /F1 14 Tf 20 150 Td (The quick brown fox) Tj 0 -24 Td (jumps over the dog) Tj ET") +} + +fn text_pdf(content: &str) -> Vec { format!( "%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj @@ -16,7 +19,7 @@ fn two_lines() -> Vec { 4 0 obj << /Length {} >> stream {content} endstream endobj -5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj +5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj trailer << /Root 1 0 R >> %%EOF", content.len() @@ -181,6 +184,161 @@ fn multiclick_selects_on_pointer_press_before_release() { assert_eq!(h.state().views[0].selected_text().as_deref(), Some("The quick brown fox"), "line stays selected on release"); } +// Issue #1225: page-text Copy belongs in both menus and observes PDF permissions. +fn copied(h: &Harness<'static, PdfCraftApp>, want: &str) -> bool { + h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(t) if t == want)) +} + +#[test] +fn selected_text_copy_edit_and_context_menus() { + use egui_kittest::kittest::Queryable; + let (mut h, at) = harness(|_| {}); + click(&mut h, at); + click(&mut h, at); + h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Hand; + h.run_steps(2); // Hide floating Copy so this test can only reach menu entries. + h.get_by_label("Menu").click(); + h.run_steps(2); + h.get_by_label("Edit ⏵").hover(); + h.run_steps(3); + h.get_by_label_contains("Copy").click(); + let mut did_copy = false; + for _ in 0..4 { + h.step(); + did_copy |= copied(&h, "quick"); + } + assert!(did_copy, "Edit Copy emits the selected text"); + h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Hand; + h.run_steps(2); // Hide the floating bar: the remaining Copy must be in the context menu. + h.event(Event::PointerMoved(at)); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(2); + h.get_by_label_contains("Copy").click(); + let mut did_copy = false; + for _ in 0..4 { + h.step(); + did_copy |= copied(&h, "quick"); + } + assert!(did_copy, "context Copy emits the selected text"); + assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick")); +} + +#[test] +fn selected_text_copy_command_requires_selection() { + let (mut h, at) = harness(|_| {}); + assert!(!h.state_mut().execute("edit.copy")); + click(&mut h, at); + click(&mut h, at); + assert!(h.state_mut().execute("edit.copy")); + h.step(); + assert!(copied(&h, "quick")); + let mut app = PdfCraftApp::new(); + assert!(!app.execute("edit.copy")); +} + +#[test] +fn selected_text_copy_floating_button_honours_permissions() { + use egui_kittest::kittest::Queryable; + for password in ["pw", "owner"] { + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(two_lines())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "pw", + owner_password: "owner", + permissions: -1 ^ 0b1_0000, + encrypt_metadata: true, + seed: [4; 32], + }) + .unwrap(); + let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).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("no-copy.pdf", None, bytes).unwrap(); + app.submit_password(Some(password.into())); + app.set_option("left", "closed").unwrap(); + app + }); + let mut loaded = false; + for _ in 0..200 { + h.run_steps(2); + if h.state_mut().views[0].select_all() { + loaded = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert!(loaded); + h.run_steps(2); + h.get_by_label("Copy").click(); + let mut did_copy = false; + for _ in 0..4 { + h.step(); + did_copy |= copied(&h, "The quick brown fox\njumps over the dog"); + } + assert_eq!(did_copy, password == "owner", "floating Copy with {password} password"); + assert_eq!(h.state_mut().execute("edit.copy"), password == "owner"); + } +} + +#[test] +fn selected_text_copy_preserves_unicode_and_page_clipboard() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.system_clipboard = false; + app.open_bytes("unicode.pdf", None, text_pdf(r"BT /F1 14 Tf 20 150 Td (caf\351) Tj ET")).unwrap(); + app + }); + let mut loaded = false; + for _ in 0..200 { + h.run_steps(2); + if h.state_mut().views[0].select_all() { + loaded = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert!(loaded); + assert_eq!(h.state().views[0].selected_text().as_deref(), Some("café")); + assert!(h.state_mut().execute("edit.copy")); + h.step(); + assert!(copied(&h, "café")); + assert!(h.state().page_clipboard.is_none()); + assert!(h.state_mut().execute("page.organize")); + h.run_steps(2); + assert!(!h.state_mut().execute("edit.copy"), "Organize retains page clipboard ownership"); + assert!(h.state_mut().execute("page.copy")); + assert_eq!(h.state().page_clipboard.as_ref().map(|c| c.pages.clone()), Some(vec![0])); +} + +#[test] +fn selected_text_copy_keeps_focused_input_clipboard_ownership() { + let (mut h, at) = harness(|_| {}); + click(&mut h, at); + click(&mut h, at); + h.state_mut().views[0].open_find(); + h.state_mut().views[0].find.as_mut().unwrap().query = "widget café".into(); + h.run_steps(3); + h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("find-input"))); + h.run_steps(2); + h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A); + h.run_steps(2); + h.event(Event::Copy); + let mut widget_copy = false; + let mut page_copy = false; + for _ in 0..4 { + h.step(); + widget_copy |= copied(&h, "widget café"); + page_copy |= copied(&h, "quick"); + } + assert!(widget_copy, "the focused text input copies its selected text"); + assert!(!page_copy, "page text must not also consume the Copy event"); +} + /// `n` pages of 300×200, each ending with one line of Helvetica text near its bottom edge. fn bottom_lines(n: usize) -> Vec { let mut objects = String::new(); diff --git a/docs/roadmap.md b/docs/roadmap.md index 3acd0ca8b2..0ea0212daa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -99,7 +99,7 @@ total matches the parity estimate. | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | -| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation.
Candidate: coordinate control clicks pair press/release across slow frames; coalesced primary presses retain text multi-click selection. Regression cases cover slow-frame button activation, disabled controls and text multi-click selection; estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 957ac5c3e1..7edc529a00 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1536,10 +1536,10 @@ area = "B" tier = "P0" milestone = "M3" status = "shipped" -commands = [] -tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::drag_selects_text_and_copy_returns_it"] -notes = "" +commands = ["edit.copy"] +tools = ["text_extract"] +evidence = ["crates/ui-egui/tests/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/ui_group/text_select.rs::selected_text_copy_edit_and_context_menus", "crates/ui-egui/tests/ui_group/text_select.rs::selected_text_copy_floating_button_honours_permissions", "crates/ui-egui/tests/ui_group/text_select.rs::selected_text_copy_keeps_focused_input_clipboard_ownership"] +notes = "Keyboard, Edit-menu, page-context-menu and floating-selection Copy honour document copy permissions. Menus require selected page text; focused text inputs and Organize page copying retain their own clipboard handling. Headless text extraction uses text_extract." [[feature]] id = "view.select-all"