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:
parent
7616535dd1
commit
fb3eca5dcc
|
|
@ -3243,6 +3243,7 @@ dependencies = [
|
|||
name = "printcraft-ui-egui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"base64",
|
||||
"eframe",
|
||||
"egui",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
}],
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue