diff --git a/Cargo.lock b/Cargo.lock index 6d55eff0..b7749a4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3243,6 +3243,7 @@ dependencies = [ name = "printcraft-ui-egui" version = "0.1.0" dependencies = [ + "arboard", "base64", "eframe", "egui", diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 46fa69b0..3becb446 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -145,7 +145,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ item("Single file", "file-input", "create.file", Ready), item("Multiple files", "files", "create.multiple", Planned("M10")), item("Images", "image", "create.images", Ready), - item("Clipboard", "copy-plus", "create.clipboard", Planned("M10")), + item("Clipboard", "copy-plus", "create.clipboard", Ready), item("Blank page", "file-plus-2", "create.blank", Ready), ], }], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index e2774988..386deac4 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -149,6 +149,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("create.blank", "New blank PDF", FILE, None, Nothing, "file-plus-2"), c("create.file", "Create PDF from file…", FILE, None, Nothing, "file-input"), c("create.images", "Create PDF from images…", FILE, None, Nothing, "image"), + c("create.clipboard", "Create PDF from clipboard", FILE, None, Nothing, "copy-plus"), c("page.combine", "Combine files…", FILE, None, Nothing, "files"), c("file.save", "Save", FILE, Some(Shortcut::cmd("S")), Document, "save"), c("file.save_as", "Save as…", FILE, Some(Shortcut::cmd_shift("S")), Document, "save"), diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index fc1baaa8..4919dd81 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -25,6 +25,8 @@ base64 = "0.22" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # Control-channel tokens. getrandom = "0.4" +# Create ▸ Clipboard (images; already in the tree through egui-winit). +arboard = { version = "3.6", default-features = false, features = ["image-data"] } [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures = "0.4" diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 036f9d5d..fed726dc 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -333,6 +333,7 @@ impl PrintCraftApp { "create.blank" => self.create_blank(), "create.file" => self.open_dialog(), "create.images" => self.create_from_images_dialog(), + "create.clipboard" => self.create_from_clipboard(), "optimize.reduce" => self.reduce_file_size(), "page.duplicate" => { self.apply_edit(Edit::DuplicatePages { pages: targets }); diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 6efc714a..57f85d77 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -322,6 +322,9 @@ pub struct CommentView { pub hidden_types: Vec, pub hidden_authors: Vec, pub hidden_statuses: Vec, + /// Colours (`RRGGBB`, empty for none) and checkmark states ("Checked", "Unchecked") hidden. + pub hidden_colors: Vec, + pub hidden_checks: Vec, /// How the panel orders comments. pub sort: SortBy, /// Comment Properties was asked for from the panel: (page, index). diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index 069eea29..ba9ddfb2 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -69,6 +69,16 @@ fn initials(name: &str) -> String { if s.is_empty() { "?".into() } else { s.to_uppercase() } } +/// A comment's colour as `RRGGBB` (empty: none), the key the colour sort and filter use. +pub fn color_key(a: &Annotation) -> String { + a.color.map(|c| format!("{:02X}{:02X}{:02X}", (c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8)).unwrap_or_default() +} + +/// The filter's name for a checkmark state. +fn check_label(marked: bool) -> &'static str { + if marked { "Checked" } else { "Unchecked" } +} + /// The latest checkmark reply in a thread says "Marked". pub fn is_marked(thread: &[&Annotation]) -> bool { thread.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked")) @@ -159,11 +169,10 @@ pub(crate) fn show( .filter(|a| !cv.hidden_types.iter().any(|t| t == kind_label(a))) .filter(|a| !cv.hidden_authors.contains(&a.author.clone().unwrap_or_default())) .filter(|a| !cv.hidden_statuses.contains(&status_of(a))) + .filter(|a| !cv.hidden_colors.contains(&color_key(a))) + .filter(|a| !cv.hidden_checks.contains(&check_label(is_marked(&replies_of(a))).to_string())) .collect(); let sort = cv.sort; - let color_key = |a: &Annotation| { - a.color.map(|c| format!("{:02X}{:02X}{:02X}", (c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8)).unwrap_or_default() - }; match sort { SortBy::Page => {} SortBy::Author => roots.sort_by_key(|a| a.author.clone().unwrap_or_default().to_lowercase()), @@ -460,7 +469,15 @@ pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocV info.annotations.iter().filter(|a| a.in_reply_to.is_none()).map(|a| a.author.clone().unwrap_or_default()).collect(); authors.sort(); authors.dedup(); - let active = !(cv.hidden_types.is_empty() && cv.hidden_authors.is_empty() && cv.hidden_statuses.is_empty()); + let mut colors: Vec<(String, Option<[f32; 3]>)> = + info.annotations.iter().filter(|a| a.in_reply_to.is_none()).map(|a| (color_key(a), a.color)).collect(); + colors.sort_by(|a, b| a.0.cmp(&b.0)); + colors.dedup_by(|a, b| a.0 == b.0); + let active = !(cv.hidden_types.is_empty() + && cv.hidden_authors.is_empty() + && cv.hidden_statuses.is_empty() + && cv.hidden_colors.is_empty() + && cv.hidden_checks.is_empty()); let more = icons::button(ui, "ellipsis", 26.0, false, "More options"); egui::Popup::menu(&more).show(|ui| { ui.set_min_width(220.0); @@ -513,10 +530,37 @@ pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocV for s in ["None", "Accepted", "Rejected", "Cancelled", "Completed"] { toggle(ui, &mut cv.hidden_statuses, s, s); } + ui.separator(); + ui.label(egui::RichText::new("Colour").small()); + for (key, c) in &colors { + ui.horizontal(|ui| { + let name = crate::comments::SWATCHES + .iter() + .find(|(_, s)| c.is_some_and(|c| (0..3).all(|i| (s[i] as f32 - c[i]).abs() < 0.02))) + .map(|(n, _)| n.to_string()) + .unwrap_or_else(|| if key.is_empty() { "No colour".into() } else { format!("#{key}") }); + toggle(ui, &mut cv.hidden_colors, key, &name); + if let Some(c) = c { + let (r, _) = ui.allocate_exact_size(vec2(12.0, 12.0), Sense::hover()); + ui.painter().rect_filled( + r, + CornerRadius::same(2), + Color32::from_rgb((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8), + ); + } + }); + } + ui.separator(); + ui.label(egui::RichText::new("Checkmark").small()); + for s in ["Checked", "Unchecked"] { + toggle(ui, &mut cv.hidden_checks, s, s); + } if active && ui.button("Show all").clicked() { cv.hidden_types.clear(); cv.hidden_authors.clear(); cv.hidden_statuses.clear(); + cv.hidden_colors.clear(); + cv.hidden_checks.clear(); } }); command diff --git a/crates/ui-egui/src/create_ui.rs b/crates/ui-egui/src/create_ui.rs index adea973e..90f88daf 100644 --- a/crates/ui-egui/src/create_ui.rs +++ b/crates/ui-egui/src/create_ui.rs @@ -18,6 +18,43 @@ fn is_image(bytes: &[u8]) -> bool { || (bytes.starts_with(b"BM") && bytes.get(14..18).is_some_and(|h| matches!(u32::from_le_bytes([h[0], h[1], h[2], h[3]]), 12 | 40 | 52 | 56 | 108 | 124))) } +/// What Create ▸ Clipboard found on the clipboard. +#[derive(Clone, Debug, PartialEq)] +pub enum Clip { + /// RGBA pixels, row by row. + Image { + width: usize, + height: usize, + rgba: Vec, + }, + Text(String), +} + +/// An image on the system clipboard, or else its text. +#[cfg(not(target_arch = "wasm32"))] +fn read_clipboard() -> Option { + let mut cb = arboard::Clipboard::new().ok()?; + if let Ok(img) = cb.get_image() { + return Some(Clip::Image { width: img.width, height: img.height, rgba: img.bytes.into_owned() }); + } + cb.get_text().ok().filter(|t| !t.trim().is_empty()).map(Clip::Text) +} + +fn png_from_rgba(width: usize, height: usize, rgba: &[u8]) -> Result, String> { + let (w, h) = (u32::try_from(width).map_err(|e| e.to_string())?, u32::try_from(height).map_err(|e| e.to_string())?); + if w == 0 || h == 0 || rgba.len() != width * height * 4 { + return Err("the clipboard image is empty or malformed".into()); + } + let mut out = Vec::new(); + let mut enc = png::Encoder::new(&mut out, w, h); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut writer = enc.write_header().map_err(|e| e.to_string())?; + writer.write_image_data(rgba).map_err(|e| e.to_string())?; + writer.finish().map_err(|e| e.to_string())?; + Ok(out) +} + fn stem(name: &str) -> &str { name.rsplit_once('.').map_or(name, |(s, _)| s) } @@ -50,6 +87,35 @@ impl PrintCraftApp { Ok(()) } + /// Create ▸ Clipboard: a new document from the image (one page, its size) or the text on + /// the clipboard, as Acrobat does. + pub(crate) fn create_from_clipboard(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + let clip = read_clipboard(); + #[cfg(target_arch = "wasm32")] + let clip: Option = None; + match clip { + Some(c) => { + if let Err(e) = self.create_from_clip(c) { + self.notify(format!("Couldn't create a PDF: {e}")); + } + } + None => self.notify("The clipboard has no image or text"), + } + } + + /// Create a new document from clipboard contents. + pub fn create_from_clip(&mut self, clip: Clip) -> Result<(), String> { + let created = match clip { + Clip::Image { width, height, rgba } => { + let png = png_from_rgba(width, height, &rgba)?; + self.session.create_from_images(&[("Clipboard.png".into(), png)]) + } + Clip::Text(t) => self.session.create_from_text("Clipboard", &t), + }; + self.open_created_bytes("Clipboard.pdf", created.map_err(|e| e.to_string())) + } + /// Create ▸ Blank page: a new untitled US Letter document. pub(crate) fn create_blank(&mut self) { let created = self.session.create_blank(612.0, 792.0, 1).map_err(|e| e.to_string()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6c9cab32..14e33969 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -22,6 +22,7 @@ pub mod marks { } mod content_ui; mod link_ui; +pub use create_ui::Clip; pub use link_ui::LinkDraft; mod dialogs; mod editing; diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index 240d0642..070e63aa 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -43,7 +43,9 @@ fn harness() -> Harness<'static, PrintCraftApp> { } /// Commands that open a native file picker can't run headless. -const PICKERS: &[&str] = &["file.open", "page.combine", "page.insert", "file.save_as", "create.file", "create.images", "page.replace"]; +/// (Create ▸ Clipboard reads the system clipboard: tests use `create_from_clip`.) +const PICKERS: &[&str] = + &["file.open", "page.combine", "page.insert", "file.save_as", "create.file", "create.images", "page.replace", "create.clipboard"]; #[test] fn every_registered_command_is_implemented() { diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index d0bacb22..a046da03 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -388,3 +388,39 @@ fn polygons_clouds_connected_lines_callouts_and_inserted_text() { assert_eq!(caret.contents.as_deref(), Some("very")); assert_eq!(h.state().quick_tool, QuickTool::Select); } + +#[test] +fn the_panel_filters_by_colour_and_checkmark() { + let mut h = harness(|app| { + app.set_option("panel", "comments").unwrap(); + app.set_option("quick", "square").unwrap(); + }); + drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0)); + h.state_mut().set_option("quick", "circle").unwrap(); + drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0)); + h.state_mut().set_option("quick", "select").unwrap(); + h.state_mut().apply_edit(printcraft_engine::Edit::StyleAnnotation { + page: 0, + index: 1, + color: Some([0.0, 0.47, 0.84]), + opacity: None, + width: None, + }); + h.state_mut().apply_edit(printcraft_engine::Edit::MarkAnnotation { page: 0, index: 0, marked: true, author: "Tester".into() }); + h.run_steps(2); + let cards = |h: &Harness<'static, PrintCraftApp>| (h.query_all_by_label("Rectangle").count(), h.query_all_by_label("Oval").count()); + // The filter menu lists the colours by name (counted with it open: it names the types too). + h.get_by_label("Filter comments").click(); + h.run_steps(2); + let before = cards(&h); + h.get_by_label("Blue").click(); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.hidden_colors, vec!["0077D6".to_string()]); + assert_eq!(cards(&h).1, before.1 - 1, "the blue oval is filtered out"); + h.get_by_label("Checked").click(); + h.run_steps(2); + assert_eq!(cards(&h).0, before.0 - 1, "the checked rectangle is filtered out"); + h.get_by_label("Show all").click(); + h.run_steps(2); + assert_eq!(cards(&h), before); +} diff --git a/crates/ui-egui/tests/create.rs b/crates/ui-egui/tests/create.rs index bc4f11ad..99462af7 100644 --- a/crates/ui-egui/tests/create.rs +++ b/crates/ui-egui/tests/create.rs @@ -44,3 +44,18 @@ fn reduce_file_size_writes_a_compact_copy() { assert!(bytes.starts_with(b"%PDF-")); assert!(app.session.docs()[0].dirty, "the open document is unchanged"); } + +#[test] +fn clipboard_images_and_text_become_new_pdfs() { + use printcraft_ui_egui::Clip; + let mut app = PrintCraftApp::new(); + app.create_from_clip(Clip::Image { width: 40, height: 20, rgba: [10u8, 20, 30, 255].repeat(40 * 20) }).unwrap(); + app.create_from_clip(Clip::Text("Pasted\nlines".into())).unwrap(); + let docs = app.session.docs(); + assert_eq!(docs.len(), 2); + assert_eq!(docs[0].name, "Clipboard.pdf"); + // The image page takes the image's size (one pixel per point at 72 dpi). + let p = &docs[0].info.pages[0]; + assert!((p.width / p.height - 2.0).abs() < 0.01, "{} × {}", p.width, p.height); + assert!(app.create_from_clip(Clip::Image { width: 4, height: 4, rgba: vec![0; 3] }).is_err(), "malformed images are refused"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1d8422a4..530f4aad 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3875,11 +3875,11 @@ title = "Filter comments (type, author, status, colour, checkmark)" area = "E" tier = "P0" milestone = "M5" -status = "partial" +status = "shipped" commands = ["comment.list"] tools = ["comment_list"] -evidence = ["crates/ui-egui/tests/comments.rs::the_panel_filters_and_sorts"] -notes = "type, author and status; colour and checkmark filters not yet" +evidence = ["crates/ui-egui/tests/comments.rs::the_panel_filters_and_sorts", "crates/ui-egui/tests/comments.rs::the_panel_filters_by_colour_and_checkmark"] +notes = "type, author, status, colour and checkmark" [[feature]] id = "comment.sort-comments" @@ -6691,11 +6691,11 @@ title = "Create PDF from the clipboard" area = "J" tier = "P0" milestone = "M10" -status = "planned" -commands = [] +status = "shipped" +commands = ["create.clipboard"] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/create.rs::clipboard_images_and_text_become_new_pdfs"] +notes = "an image (one page at its size) or text; headless tools read files instead" [[feature]] id = "create.from-screenshot"