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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 23:35:30 -04:00
parent adb644c5e1
commit 245c468bc7
13 changed files with 503 additions and 28 deletions

View File

@ -906,3 +906,79 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
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<NoteIcon>,
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<Rgb>,
pub opacity: f64,
/// Border / line width, for shapes, lines and drawings.
pub width: Option<f64>,
/// Notes only.
pub icon: Option<NoteIcon>,
/// `/M`, as written.
pub modified: Option<String>,
/// 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<Props> {
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<f64> = 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,
})
}

View File

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

View File

@ -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<Vec<u8>>);
@ -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<printcraft_annot::Props> {
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<f64>,
width: Option<f64>,
},
/// Comment properties ▸ General / note icon.
SetAnnotationInfo {
page: usize,
index: usize,
author: Option<String>,
subject: Option<String>,
icon: Option<NoteIcon>,
},
/// 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())?;

View File

@ -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<LinkTarget> = None;
let mut canvas_action: Option<comments::CanvasAction> = 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);

View File

@ -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<Edit> {
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)
}

View File

@ -267,6 +267,14 @@ pub struct CommentView {
pub add_box: String,
/// The Comments panel's search query (`None`: search closed).
pub search: Option<String>,
/// Comment types, authors and statuses hidden by the panel's filter.
pub hidden_types: Vec<String>,
pub hidden_authors: Vec<String>,
pub hidden_statuses: Vec<String>,
/// 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<Edit>),
OpenComments,
/// Comment Properties for (page, index).
Properties(usize, usize),
}
/// A grid of colour swatches; returns the one clicked.

View File

@ -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<String> {
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<String> = 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<String> = 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<String> =
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<String>, 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();
}
});
}

View File

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

View File

@ -255,7 +255,8 @@ fn comment_page(edit: &Edit) -> Option<usize> {
| Edit::SetAnnotationStatus { page, .. }
| Edit::MoveAnnotation { page, .. }
| Edit::ResizeAnnotation { page, .. }
| Edit::StyleAnnotation { page, .. } => Some(*page),
| Edit::StyleAnnotation { page, .. }
| Edit::SetAnnotationInfo { page, .. } => Some(*page),
_ => None,
}
}

View File

@ -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<Vec<Vec<[f32; 2]>>>,
/// Strokes being drawn in the signature pad.
pub signature_draft: Vec<Vec<[f32; 2]>>,
/// The Comment Properties dialog's state.
pub comment_props: Option<comment_props::PropsDraft>,
/// The last web link the app asked the system to open (tests and automation).
pub last_opened_url: Option<String>,
}
@ -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 },
}
}

View File

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

View File

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

View File

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