From 245c468bc7864fb9d1bd8a1d6fb2f5e75cc8ceff Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 23:35:30 -0400 Subject: [PATCH] M5.4: Comment Properties (appearance: colour, opacity, thickness, note icon; general: author, subject; review history) from the canvas and card menus, one undo step; Comments panel filter (type, author, status) and sort (page, author, date, type, colour) with group headers; annot set_info/props Co-Authored-By: Claude Opus 5.5 --- crates/annot/src/lib.rs | 76 +++++++++++++ crates/annot/src/tests.rs | 13 +++ crates/engine/src/lib.rs | 26 ++++- crates/ui-egui/src/canvas.rs | 5 + crates/ui-egui/src/comment_props.rs | 159 +++++++++++++++++++++++++++ crates/ui-egui/src/comments.rs | 25 +++++ crates/ui-egui/src/comments_panel.rs | 102 ++++++++++++++++- crates/ui-egui/src/dialogs.rs | 19 ++++ crates/ui-egui/src/editing.rs | 3 +- crates/ui-egui/src/lib.rs | 6 + crates/ui-egui/src/panels.rs | 7 ++ crates/ui-egui/tests/comments.rs | 50 +++++++++ parity/acrobat-features.toml | 40 +++---- 13 files changed, 503 insertions(+), 28 deletions(-) create mode 100644 crates/ui-egui/src/comment_props.rs diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 0a9550ff..f5288792 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -906,3 +906,79 @@ pub fn summaries(doc: &Document) -> Vec { out.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3]))); out } + +/// Change a comment's author (`/T`), subject (`/Subj`) and, for notes, icon (`/Name`, redrawn). +/// `None` leaves a value as it is. +pub fn set_info( + doc: &mut Document, + page: usize, + index: usize, + author: Option<&str>, + subject: Option<&str>, + icon: Option, + meta: &Meta, +) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + let is_note = annot_dict(doc, r).name(b"Subtype") == Some(b"Text"); + if icon.is_some() && !is_note { + return Err(AnnotError::Invalid("only sticky notes have an icon".into())); + } + doc.update_dict(r, |d| { + if let Some(a) = author { + d.set(b"T".to_vec(), PdfString::text(a)); + } + if let Some(s) = subject { + d.set(b"Subj".to_vec(), PdfString::text(s)); + } + if let Some(i) = icon { + d.set(b"Name".to_vec(), Object::name(i.name())); + } + touch(d, meta); + })?; + if icon.is_some() { + set_appearance(doc, r)?; + } + Ok(()) +} + +/// What Comment properties shows for one comment. +#[derive(Clone, Debug, PartialEq)] +pub struct Props { + pub subtype: String, + pub author: String, + pub subject: String, + pub color: Option, + pub opacity: f64, + /// Border / line width, for shapes, lines and drawings. + pub width: Option, + /// Notes only. + pub icon: Option, + /// `/M`, as written. + pub modified: Option, + /// The appearance can be redrawn (so colour, opacity and width can change). + pub restylable: bool, +} + +/// The current properties of the comment at `(page, index)`. +pub fn props(doc: &Document, page: usize, index: usize) -> Option { + let p = page_ref(doc, page).ok()?; + let entry = annots(doc, p).get(index).cloned()?; + let obj = doc.resolve(&entry); + let d = obj.as_dict()?; + let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned(); + let c: Vec = d.get(b"C").and_then(|o| o.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect())).unwrap_or_default(); + let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { (c.len() == 3).then(|| [c[0], c[1], c[2]]) }; + let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine") + .then(|| d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0)); + Some(Props { + author: text_value(doc, d, b"T").unwrap_or_default(), + subject: text_value(doc, d, b"Subj").unwrap_or_default(), + color, + opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0), + width, + icon: (subtype == "Text").then(|| d.name(b"Name").and_then(|n| NoteIcon::from_name(&String::from_utf8_lossy(n))).unwrap_or(NoteIcon::Note)), + modified: text_value(doc, d, b"M"), + restylable: appearance::build(d).is_some(), + subtype, + }) +} diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 48c42be3..4eeeecdd 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -332,3 +332,16 @@ fn fill_and_sign_items_are_drawn() { set_style(&mut doc, 1, marks[0], Some([0.0, 0.0, 1.0]), None, None, &meta("")).unwrap(); assert!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, &meta("")).is_err()); } + +#[test] +fn author_subject_and_note_icon_change() { + let mut doc = fixture(); + let n = add_annotation(&mut doc, &new(0, Shape::Note { at: [10.0, 100.0], icon: NoteIcon::Comment }), &meta("n")).unwrap(); + let before = ap_content(&doc, &list(&doc, 0)[n]); + set_info(&mut doc, 0, n, Some("Grace"), Some("Question"), Some(NoteIcon::Help), &meta("")).unwrap(); + let d = &list(&doc, 0)[n]; + assert_eq!((text(d, b"T").as_str(), text(d, b"Subj").as_str()), ("Grace", "Question")); + assert_eq!(d.name(b"Name"), Some(&b"Help"[..])); + assert_ne!(ap_content(&doc, d), before, "the icon was redrawn"); + assert!(matches!(set_info(&mut doc, 0, 0, None, None, Some(NoteIcon::Key), &meta("")), Err(AnnotError::Invalid(_)))); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b0d067aa..61bb90eb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -26,7 +26,7 @@ pub use printcraft_forms::{Field as FormField, FieldKind as FormFieldKind, Field /// Comment geometry helpers (text-box line breaking) for frontends. pub use printcraft_annot::appearance as annot_text; -pub use printcraft_annot::{FillMark, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, Style}; +pub use printcraft_annot::{FillMark, Markup, NewAnnotation, NoteIcon, Props as CommentProps, ReviewState, Rgb, Shape, Style}; pub type SplitPart = (usize, usize, Arc>); @@ -71,7 +71,8 @@ fn scope_of(edit: &Edit) -> Scope { | Edit::SetAnnotationStatus { .. } | Edit::MoveAnnotation { .. } | Edit::ResizeAnnotation { .. } - | Edit::StyleAnnotation { .. } => Scope::Comments, + | Edit::StyleAnnotation { .. } + | Edit::SetAnnotationInfo { .. } => Scope::Comments, Edit::SetFieldValue { .. } | Edit::ResetForm { .. } => Scope::Form, Edit::Batch { edits, .. } => { let mut scopes = edits.iter().map(scope_of); @@ -183,6 +184,11 @@ impl Document { Some(SecuritySummary { method: method.into(), owner: h.auth() == printcraft_cos::Auth::Owner && !pending, permissions, pending }) } + /// Comment properties of the comment at `(page, index)` (Comment Properties dialog). + pub fn comment_props(&self, page: usize, index: usize) -> Option { + self.editor.as_ref().and_then(|e| printcraft_annot::props(&e.cos, page, index)) + } + /// Every page's media, crop, bleed, trim and art boxes (user space), for Set Page Boxes. pub fn page_boxes(&self) -> Vec<[[f64; 4]; 5]> { self.editor.as_ref().and_then(|e| printcraft_organize::page_boxes(&e.cos).ok()).unwrap_or_default() @@ -467,6 +473,14 @@ pub enum Edit { opacity: Option, width: Option, }, + /// Comment properties ▸ General / note icon. + SetAnnotationInfo { + page: usize, + index: usize, + author: Option, + subject: Option, + icon: Option, + }, /// Fill in a form field. SetFieldValue { name: String, @@ -533,7 +547,7 @@ impl Edit { Edit::SetAnnotationStatus { state, .. } => format!("Set status {}", state.name()), Edit::MoveAnnotation { .. } => "Move comment".into(), Edit::ResizeAnnotation { .. } => "Resize comment".into(), - Edit::StyleAnnotation { .. } => "Change comment properties".into(), + Edit::StyleAnnotation { .. } | Edit::SetAnnotationInfo { .. } => "Change comment properties".into(), Edit::SetFieldValue { name, .. } => format!("Fill in {name}"), Edit::ResetForm { .. } => "Clear form".into(), Edit::AddHeaderFooter { replace: false, .. } => "Add header & footer".into(), @@ -612,7 +626,8 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), | Edit::SetAnnotationStatus { .. } | Edit::MoveAnnotation { .. } | Edit::ResizeAnnotation { .. } - | Edit::StyleAnnotation { .. } => { + | Edit::StyleAnnotation { .. } + | Edit::SetAnnotationInfo { .. } => { if p.annotate() { Ok(()) } else { @@ -750,6 +765,9 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) - Edit::StyleAnnotation { page, index, color, opacity, width } => { printcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, &cx.meta())?; } + Edit::SetAnnotationInfo { page, index, author, subject, icon } => { + printcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; + } Edit::SetFieldValue { name, value } => printcraft_forms::set_value(doc, name, value)?, Edit::ResetForm { names } => { printcraft_forms::reset(doc, names.as_deref())?; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e1e1de82..d391e2b6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -796,6 +796,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { let mut hover_text: Option<(Pos2, String)> = None; let mut clicked_link: Option = None; let mut canvas_action: Option = None; + let mut open_props: Option<(usize, usize)> = None; let out = scroll.show_viewport(ui, |ui, viewport| { let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag()); @@ -1144,6 +1145,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { match canvas_action { Some(comments::CanvasAction::Edit(e)) => view.pending_edit = Some(*e), Some(comments::CanvasAction::OpenComments) => app.right = Some(RightPanel::Comments), + Some(comments::CanvasAction::Properties(p, i)) => open_props = Some((p, i)), None => {} } match clicked_link { @@ -1156,6 +1158,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { tool = QuickTool::Select; } app.quick_tool = tool; + if let Some((p, i)) = open_props { + app.open_comment_props(p, i); + } if open_signature { app.signature_draft.clear(); app.dialog = Some(crate::Dialog::Signature); diff --git a/crates/ui-egui/src/comment_props.rs b/crates/ui-egui/src/comment_props.rs new file mode 100644 index 00000000..edbf84c0 --- /dev/null +++ b/crates/ui-egui/src/comment_props.rs @@ -0,0 +1,159 @@ +//! Comment Properties (Acrobat: right-click a comment ▸ Properties…; audit "Comment +//! properties"): Appearance (colour, opacity, line thickness, note icon), General (author, +//! subject, modified) and Review History (status changes). + +use egui::{Align, Layout}; +use printcraft_engine::{CommentProps, Edit, NoteIcon}; + +use crate::comments::swatch_grid; +use crate::theme::{self, Tokens}; +use crate::{PrintCraftApp, widgets}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PropsTab { + Appearance, + General, + ReviewHistory, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PropsDraft { + pub page: usize, + pub index: usize, + pub tab: PropsTab, + pub original: CommentProps, + pub edited: CommentProps, +} + +const ICONS: [NoteIcon; 7] = + [NoteIcon::Comment, NoteIcon::Note, NoteIcon::Help, NoteIcon::Insert, NoteIcon::Key, NoteIcon::NewParagraph, NoteIcon::Paragraph]; + +impl PrintCraftApp { + /// Open Comment Properties for the comment at `(page, index)` of the active document. + pub fn open_comment_props(&mut self, page: usize, index: usize) { + let Some((_, id)) = self.active_ids() else { return }; + let Some(p) = self.session.get(id).and_then(|d| d.comment_props(page, index)) else { return }; + self.comment_props = Some(PropsDraft { page, index, tab: PropsTab::Appearance, original: p.clone(), edited: p }); + self.dialog = Some(crate::Dialog::CommentProps); + } +} + +/// The edits that make the comment match the draft (empty when nothing changed). +pub fn edits(d: &PropsDraft) -> Vec { + let (o, e) = (&d.original, &d.edited); + let mut out = Vec::new(); + let color = (e.color != o.color).then_some(e.color).flatten(); + let opacity = ((e.opacity - o.opacity).abs() > 1e-6).then_some(e.opacity); + let width = (e.width != o.width).then_some(e.width).flatten(); + if e.restylable && (color.is_some() || opacity.is_some() || width.is_some()) { + out.push(Edit::StyleAnnotation { page: d.page, index: d.index, color, opacity, width }); + } + let author = (e.author != o.author).then(|| e.author.clone()); + let subject = (e.subject != o.subject).then(|| e.subject.clone()); + let icon = (e.icon != o.icon).then_some(e.icon).flatten(); + if author.is_some() || subject.is_some() || icon.is_some() { + out.push(Edit::SetAnnotationInfo { page: d.page, index: d.index, author, subject, icon }); + } + out +} + +/// Draw the dialog; returns (apply, cancel). +pub(crate) fn body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens) -> (bool, bool) { + let history: Vec<(String, String, String)> = app + .active_ids() + .and_then(|(_, id)| app.session.get(id)) + .zip(app.comment_props.as_ref()) + .map(|(doc, d)| { + let me = doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && a.in_reply_to.is_none()); + let nm = me.and_then(|a| a.name.clone()); + doc.info + .annotations + .iter() + .filter(|r| r.state.is_some() && nm.is_some() && r.in_reply_to == nm) + .map(|r| (r.state.clone().unwrap_or_default(), r.author.clone().unwrap_or_default(), r.modified.clone().unwrap_or_default())) + .collect() + }) + .unwrap_or_default(); + let Some(d) = app.comment_props.as_mut() else { return (false, true) }; + let kind = crate::comments_panel::subtype_label(&d.edited.subtype).to_string(); + ui.label(egui::RichText::new(format!("{kind} Properties")).font(theme::semibold(18.0))); + ui.add_space(6.0); + ui.horizontal(|ui| { + for (tab, label) in [(PropsTab::Appearance, "Appearance"), (PropsTab::General, "General"), (PropsTab::ReviewHistory, "Review History")] { + if widgets::mode_tab(ui, label, d.tab == tab).clicked() { + d.tab = tab; + } + } + }); + ui.separator(); + ui.add_space(6.0); + let e = &mut d.edited; + match d.tab { + PropsTab::Appearance => { + if !e.restylable { + ui.label(egui::RichText::new("This comment's appearance can't be changed yet.").color(t.text_muted)); + } + ui.add_enabled_ui(e.restylable, |ui| { + egui::Grid::new("comment-props").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { + if let Some(icon) = e.icon.as_mut() { + ui.label("Icon"); + egui::ComboBox::from_id_salt("note-icon").selected_text(icon.name()).show_ui(ui, |ui| { + for i in ICONS { + ui.selectable_value(icon, i, i.name()); + } + }); + ui.end_row(); + } + ui.label("Colour"); + if let Some(c) = swatch_grid(ui, e.color) { + e.color = Some(c); + } + ui.end_row(); + ui.label("Opacity"); + let mut pct = e.opacity * 100.0; + if ui.add(egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%")).changed() { + e.opacity = pct / 100.0; + } + ui.end_row(); + if let Some(w) = e.width.as_mut() { + ui.label("Thickness"); + ui.add(egui::Slider::new(w, 0.5..=12.0).step_by(0.5).suffix(" pt")); + ui.end_row(); + } + }); + }); + } + PropsTab::General => { + egui::Grid::new("comment-general").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { + let l = ui.label("Author"); + ui.add(egui::TextEdit::singleline(&mut e.author).desired_width(280.0)).labelled_by(l.id); + ui.end_row(); + let l = ui.label("Subject"); + ui.add(egui::TextEdit::singleline(&mut e.subject).desired_width(280.0)).labelled_by(l.id); + ui.end_row(); + ui.label("Modified"); + ui.label(e.modified.as_deref().map(printcraft_render::pretty_date).unwrap_or_else(|| "—".into())); + ui.end_row(); + }); + } + PropsTab::ReviewHistory => { + if history.is_empty() { + ui.label(egui::RichText::new("No status has been set.").color(t.text_muted)); + } + for (state, who, when) in &history { + ui.label(format!("{state} — {who} {when}")); + } + } + } + ui.add_space(12.0); + let (mut apply, mut cancel) = (false, false); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, "OK", true).clicked() { + apply = true; + } + if widgets::pill_button(ui, "Cancel", false).clicked() { + cancel = true; + } + }); + (apply, cancel) +} diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index c5132c9c..2c74e71e 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -267,6 +267,14 @@ pub struct CommentView { pub add_box: String, /// The Comments panel's search query (`None`: search closed). pub search: Option, + /// Comment types, authors and statuses hidden by the panel's filter. + pub hidden_types: Vec, + pub hidden_authors: Vec, + pub hidden_statuses: Vec, + /// How the panel orders comments. + pub sort: SortBy, + /// Comment Properties was asked for from the panel: (page, index). + pub props_request: Option<(usize, usize)>, pub search_focus: bool, /// Where the canvas context menu was opened: (page, user-space point). pub context_at: Option<(usize, [f64; 2])>, @@ -274,6 +282,17 @@ pub struct CommentView { pub tool_done: bool, } +/// Comments panel order (Acrobat: Sort by page, author, date, type, colour). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SortBy { + #[default] + Page, + Author, + Date, + Type, + Color, +} + /// What a page needs to know to handle comment input. pub(crate) struct PageCx<'a> { pub page: usize, @@ -833,6 +852,10 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo action = Some(CanvasAction::Edit(Box::new(Edit::DeleteAnnotation { page, index }))); ui.close(); } + if ui.add_enabled(allowed, egui::Button::new("Properties…")).clicked() { + action = Some(CanvasAction::Properties(page, index)); + ui.close(); + } } None => { if let Some((page, at)) = view.comments.context_at @@ -855,6 +878,8 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo pub enum CanvasAction { Edit(Box), OpenComments, + /// Comment Properties for (page, index). + Properties(usize, usize), } /// A grid of colour swatches; returns the one clicked. diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index d80c0a88..6f97157b 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -9,7 +9,7 @@ use printcraft_engine::{Edit, NoteIcon, ReviewState, Shape}; use printcraft_render::{Annotation, DocInfo}; use crate::canvas::DocView; -use crate::comments::{CommentPrefs, CommentTool, color32, status_badge}; +use crate::comments::{CommentPrefs, CommentTool, SortBy, color32, status_badge}; use crate::icons; use crate::panels::Nav; use crate::theme::{self, Tokens}; @@ -133,18 +133,52 @@ pub(crate) fn show( None => Vec::new(), } }; - let roots: Vec<&Annotation> = info + let cv = &view.comments; + let status_of = |a: &Annotation| replies_of(a).iter().rev().find_map(|r| r.state.clone()).unwrap_or_else(|| "None".into()); + let mut roots: Vec<&Annotation> = info .annotations .iter() .filter(|a| a.in_reply_to.is_none()) .filter(|a| query.as_deref().is_none_or(|q| matches(a, q) || replies_of(a).iter().any(|r| matches(r, q)))) + .filter(|a| !cv.hidden_types.iter().any(|t| t == subtype_label(&a.subtype))) + .filter(|a| !cv.hidden_authors.contains(&a.author.clone().unwrap_or_default())) + .filter(|a| !cv.hidden_statuses.contains(&status_of(a))) .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()), + SortBy::Date => roots.sort_by(|a, b| b.modified.cmp(&a.modified)), + SortBy::Type => roots.sort_by_key(|a| subtype_label(&a.subtype).to_string()), + SortBy::Color => roots.sort_by_key(|a| color_key(a)), + } if roots.is_empty() { ui.label(egui::RichText::new("No comments match.").color(t.text_muted)); } + // Group headers follow the sort key (no headers when sorted by date). + let group_of = |a: &Annotation| -> Option { + match sort { + SortBy::Page => Some(format!("Page {}", info.pages.get(a.page).map(|p| p.label.as_str()).unwrap_or("?"))), + SortBy::Author => Some(a.author.clone().unwrap_or_else(|| "Unknown author".into())), + SortBy::Type => Some(subtype_label(&a.subtype).to_string()), + SortBy::Color => Some(if a.color.is_some() { format!("#{}", color_key(a)) } else { "No colour".into() }), + SortBy::Date => None, + } + }; let mut page = usize::MAX; + let mut group: Option = None; for a in roots { - if a.page != page { + if sort != SortBy::Page { + let g = group_of(a); + if g.is_some() && g != group { + ui.add_space(6.0); + ui.label(egui::RichText::new(g.clone().unwrap_or_default()).font(theme::semibold(12.5)).color(t.text_muted)); + group = g; + } + } else if a.page != page { page = a.page; let n = info.annotations.iter().filter(|x| x.page == page && x.in_reply_to.is_none()).count(); ui.add_space(6.0); @@ -329,6 +363,10 @@ fn card( } } }); + if ui.button("Properties…").clicked() { + view.comments.props_request = Some((a.page, a.index)); + ui.close(); + } ui.separator(); if ui.button("Delete").clicked() { view.comments.selected = None; @@ -361,3 +399,61 @@ fn legible(c: Color32, t: &Tokens) -> Color32 { c } } + +/// The Comments panel header's filter and sort controls (Acrobat: filter funnel and "…"). +pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocView) { + let cv = &mut view.comments; + let mut types: Vec = info.annotations.iter().filter(|a| a.in_reply_to.is_none()).map(|a| subtype_label(&a.subtype).to_string()).collect(); + types.sort(); + types.dedup(); + let mut authors: Vec = + 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 more = icons::button(ui, "ellipsis", 26.0, false, "Sort comments"); + egui::Popup::menu(&more).show(|ui| { + ui.label(egui::RichText::new("Sort by").small()); + for (s, label) in + [(SortBy::Page, "Page"), (SortBy::Author, "Author"), (SortBy::Date, "Date"), (SortBy::Type, "Type"), (SortBy::Color, "Colour")] + { + if ui.radio(cv.sort == s, label).clicked() { + cv.sort = s; + ui.close(); + } + } + }); + let filter = icons::button(ui, "filter", 26.0, active, "Filter comments"); + egui::Popup::menu(&filter).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + ui.set_min_width(200.0); + let toggle = |ui: &mut egui::Ui, list: &mut Vec, value: &str, label: &str| { + let mut shown = !list.iter().any(|x| x == value); + if ui.checkbox(&mut shown, label).changed() { + if shown { + list.retain(|x| x != value); + } else { + list.push(value.to_string()); + } + } + }; + ui.label(egui::RichText::new("Type").small()); + for ty in &types { + toggle(ui, &mut cv.hidden_types, ty, ty); + } + ui.separator(); + ui.label(egui::RichText::new("Author").small()); + for a in &authors { + toggle(ui, &mut cv.hidden_authors, a, if a.is_empty() { "Unknown author" } else { a }); + } + ui.separator(); + ui.label(egui::RichText::new("Status").small()); + for s in ["None", "Accepted", "Rejected", "Cancelled", "Completed"] { + toggle(ui, &mut cv.hidden_statuses, s, s); + } + if active && ui.button("Show all").clicked() { + cv.hidden_types.clear(); + cv.hidden_authors.clear(); + cv.hidden_statuses.clear(); + } + }); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index dbfd4bae..76648ca9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -34,6 +34,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { let mut boxes_now = false; let mut marks_now = false; let mut export_now = false; + let mut props_now = false; let t = Tokens::get(ctx); let mut close = false; let mut next = dialog; @@ -225,6 +226,12 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { split_ready = Some(by); } } + Dialog::CommentProps => { + let (apply, cancel) = crate::comment_props::body(ui, app, &t); + props_now = apply; + close = apply || cancel; + return; + } Dialog::Signature => { let (apply, cancel) = crate::fill_sign::signature_pad(ui, &t, &mut app.signature_draft); if apply { @@ -458,6 +465,18 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { app.discard_recovered(&keys); } } + if props_now && let Some(d) = app.comment_props.take() { + let mut edits = crate::comment_props::edits(&d); + match edits.len() { + 0 => {} + 1 => { + app.apply_edit(edits.remove(0)); + } + _ => { + app.apply_edit(Edit::Batch { label: "Change comment properties".into(), edits }); + } + } + } if export_now && let Dialog::Export(kind) = dialog { app.start_export(kind); } diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 17b8505b..5290154b 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -255,7 +255,8 @@ fn comment_page(edit: &Edit) -> Option { | Edit::SetAnnotationStatus { page, .. } | Edit::MoveAnnotation { page, .. } | Edit::ResizeAnnotation { page, .. } - | Edit::StyleAnnotation { page, .. } => Some(*page), + | Edit::StyleAnnotation { page, .. } + | Edit::SetAnnotationInfo { page, .. } => Some(*page), _ => None, } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9608510e..f0655169 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,6 +8,7 @@ mod canvas; mod chrome; mod commands; +mod comment_props; pub mod comments; mod comments_panel; pub mod control; @@ -102,6 +103,8 @@ pub enum Dialog { Export(export_ui::ExportKind), /// Fill & Sign ▸ Create signature (the drawing pad). Signature, + /// Comment Properties. + CommentProps, /// Documents from a session that ended unexpectedly. Recovery, } @@ -203,6 +206,8 @@ pub struct PrintCraftApp { pub signature: Option>>, /// Strokes being drawn in the signature pad. pub signature_draft: Vec>, + /// The Comment Properties dialog's state. + pub comment_props: Option, /// The last web link the app asked the system to open (tests and automation). pub last_opened_url: Option, } @@ -272,6 +277,7 @@ impl PrintCraftApp { export_status: None, signature: None, signature_draft: Vec::new(), + comment_props: None, number_draft: NumberDraft { from: 1, to: 1, style: printcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 }, } } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index a42c2c89..6fa2b044 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -219,6 +219,10 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) { if icons::button(ui, "x", 26.0, false, "Close").clicked() { close = true; } + // Right to left: close, "…", filter, search (Acrobat's order left to right). + if panel == RightPanel::Comments { + crate::comments_panel::header_controls(ui, info, view); + } if panel == RightPanel::Comments && icons::button(ui, "search", 26.0, view.comments.search.is_some(), "Search comments").clicked() { @@ -327,6 +331,9 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) { if let Some(e) = panel_edit { app.apply_edit(e); } + if let Some((p, i)) = app.views.get_mut(index).and_then(|v| v.comments.props_request.take()) { + app.open_comment_props(p, i); + } app.bookmark_rename = bm_rename; if let Some(a) = bm_action { app.bookmark_action(a); diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index b1a74ba7..fed42cd4 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -215,3 +215,53 @@ fn the_panel_posts_comments_and_replies() { assert_eq!((reply.contents.as_deref(), reply.author.as_deref()), (Some("Agreed"), Some("Tester"))); h.get_by_label_contains("Agreed"); } + +#[test] +fn comment_properties_change_appearance_and_author() { + let mut h = harness(|app| app.set_option("quick", "square").unwrap()); + drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0)); + h.state_mut().open_comment_props(0, 0); + h.run_steps(2); + h.get_by_label("Rectangle Properties"); + { + let d = h.state_mut().comment_props.as_mut().expect("open"); + assert_eq!(d.original.width, Some(2.0)); + d.edited.color = Some([0.0, 0.0, 1.0]); + d.edited.width = Some(5.0); + d.edited.author = "Grace".into(); + } + h.get_by_label("General").click(); + h.run_steps(2); + h.get_by_label("Author"); + h.get_by_label("OK").click(); + h.run_steps(3); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let p = doc.comment_props(0, 0).unwrap(); + assert_eq!((p.color, p.width, p.author.as_str()), (Some([0.0, 0.0, 1.0]), Some(5.0), "Grace")); + assert_eq!(doc.can_undo(), Some("Change comment properties"), "one undo step"); +} + +#[test] +fn the_panel_filters_and_sorts() { + 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.run_steps(2); + let ovals = h.query_all_by_label("Oval").count(); + assert!(ovals >= 1); + h.state_mut().views[0].comments.hidden_types.push("Oval".into()); + h.run_steps(2); + assert_eq!(h.query_all_by_label("Oval").count(), ovals - 1, "the oval's card is filtered out"); + assert!(h.query_all_by_label("Rectangle").count() >= 1); + h.state_mut().views[0].comments.hidden_types.clear(); + h.state_mut().views[0].comments.sort = printcraft_ui_egui::comments::SortBy::Type; + h.run_steps(2); + // Grouped by type: a group header names each type. + assert_eq!(h.query_all_by_label("Oval").count(), ovals + 1, "a group header was added"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index d6004fd6..678a6547 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3743,11 +3743,11 @@ title = "Comment properties (appearance, author, subject)" area = "E" tier = "P0" milestone = "M5" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["comment.list"] +tools = ["comment_edit"] +evidence = ["crates/ui-egui/tests/comments.rs::comment_properties_change_appearance_and_author", "crates/annot/src/tests.rs::author_subject_and_note_icon_change"] +notes = "Appearance (colour, opacity, thickness, note icon), General (author, subject), Review History" [[feature]] id = "comment.properties-bar" @@ -3875,11 +3875,11 @@ title = "Filter comments (type, author, status, colour, checkmark)" area = "E" tier = "P0" milestone = "M5" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +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" [[feature]] id = "comment.sort-comments" @@ -3887,11 +3887,11 @@ title = "Sort comments (page, author, date, type, colour)" area = "E" tier = "P0" milestone = "M5" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["comment.list"] +tools = ["comment_list"] +evidence = ["crates/ui-egui/tests/comments.rs::the_panel_filters_and_sorts"] +notes = "page, author, date, type, colour, with group headers" [[feature]] id = "comment.search-comments" @@ -3947,11 +3947,11 @@ title = "Review history of state changes" area = "E" tier = "P2" milestone = "M5" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["comment.list"] +tools = ["comment_list"] +evidence = ["crates/ui-egui/tests/comments.rs::comment_properties_change_appearance_and_author"] +notes = "status changes listed in Comment Properties" [[feature]] id = "comment.stamps-standard"