M5/M10: Comments panel filters by colour (named swatches) and checkmark; Create PDF from clipboard (image at its size, or text)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 02:14:50 -04:00
parent 7616535dd1
commit fb3eca5dcc
13 changed files with 185 additions and 13 deletions

1
Cargo.lock generated
View File

@ -3243,6 +3243,7 @@ dependencies = [
name = "printcraft-ui-egui"
version = "0.1.0"
dependencies = [
"arboard",
"base64",
"eframe",
"egui",

View File

@ -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),
],
}],

View File

@ -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"),

View File

@ -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"

View File

@ -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 });

View File

@ -322,6 +322,9 @@ pub struct CommentView {
pub hidden_types: Vec<String>,
pub hidden_authors: Vec<String>,
pub hidden_statuses: Vec<String>,
/// Colours (`RRGGBB`, empty for none) and checkmark states ("Checked", "Unchecked") hidden.
pub hidden_colors: Vec<String>,
pub hidden_checks: Vec<String>,
/// How the panel orders comments.
pub sort: SortBy,
/// Comment Properties was asked for from the panel: (page, index).

View File

@ -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

View File

@ -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<u8>,
},
Text(String),
}
/// An image on the system clipboard, or else its text.
#[cfg(not(target_arch = "wasm32"))]
fn read_clipboard() -> Option<Clip> {
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<Vec<u8>, 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<Clip> = 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());

View File

@ -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;

View File

@ -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() {

View File

@ -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);
}

View File

@ -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");
}

View File

@ -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"