diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index d1f2515d..897cf13b 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PrintCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (205) +## In this repository (212) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -213,6 +213,13 @@ Every asset PrintCraft includes, bundles or uses to build its published material | `docs/images/printcraft-tools.png` | PrintCraft screenshot: All tools | PrintCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: tool catalogue. Shows PrintCraft UI, Lucide icons, the showcase PDF built from the [[fetched]] OFL fonts, and the ArtCraft mark (Storyteller trademark from docs/brand/, not covered by this licence) | | `docs/images/printcraft-twoup.png` | PrintCraft screenshot: Two-up reading | PrintCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: layouts. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | | `docs/images/printcraft-viewer.png` | PrintCraft screenshot: Viewer with comments | PrintCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README hero. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | +| `assets/icons/pin.svg` | Lucide icon "pin" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pin.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/move-right.svg` | Lucide icon "move-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/reply.svg` | Lucide icon "reply" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/reply.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/thumbs-up.svg` | Lucide icon "thumbs-up" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-up.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/thumbs-down.svg` | Lucide icon "thumbs-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/message-square-plus.svg` | Lucide icon "message-square-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/circle-x.svg` | Lucide icon "circle-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | ## Compiled in through dependencies (6) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index d094a85e..ab350669 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -2596,3 +2596,80 @@ licence_file = "LICENSE-MIT" kind = "image" usage = "README hero. Shows only PrintCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" sha256 = "ecd261028d8837f7f7c013dd796d81ca7e89643bcfcbad3ab9fbfbe87ded04e3" + +[[asset]] +path = "assets/icons/pin.svg" +title = "Lucide icon \"pin\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pin.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "d28d42dd90e0ed601fb7120b6e0b98218289da8fc7bf41fac5ca83aeffd0ae32" + +[[asset]] +path = "assets/icons/move-right.svg" +title = "Lucide icon \"move-right\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-right.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "d4586a7c7aea911762604211e3624b3436b998d1343b0b29162b3eddf9e6ab81" + +[[asset]] +path = "assets/icons/reply.svg" +title = "Lucide icon \"reply\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/reply.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "f6161386877fb29e058c85ac76f1793fb43f6fcf5144b21ef3f36a367cb41e94" + +[[asset]] +path = "assets/icons/thumbs-up.svg" +title = "Lucide icon \"thumbs-up\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-up.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "e30b1dc9b4f1d56fa935ee55a4a2c9dad0eaeaffa6603085a1d3f8d5c22f60a3" + +[[asset]] +path = "assets/icons/thumbs-down.svg" +title = "Lucide icon \"thumbs-down\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-down.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "283a01b7798806a6b9ffd19f4c449e72a08ef860281fa0d33b584b52590e7240" + +[[asset]] +path = "assets/icons/message-square-plus.svg" +title = "Lucide icon \"message-square-plus\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-plus.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "5e9d80429d9b219c98402a0d4f3194d2a0cbfe0c41dd42f46771a4d7e1c563e8" + +[[asset]] +path = "assets/icons/circle-x.svg" +title = "Lucide icon \"circle-x\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "ef1fec17d88794ba6c34c5cf63bdadbd234b3d4dffbcc3d55468b47e3f398472" diff --git a/README.md b/README.md index 29d5a87b..072d045d 100644 --- a/README.md +++ b/README.md @@ -271,13 +271,14 @@ printcraft-cli edit in.pdf --rotate 1,2:90 --delete 5 --title "Q3" --out out.pd ## Built for agents, too -Every engine feature is reachable without the GUI, through one table of JSON-Schema-described tools: open, inspect, render pages to PNG, extract and find text, rotate, delete, move and insert pages, set metadata, undo and redo, save, combine, extract and split. Three front doors share it: +Every engine feature is reachable without the GUI, through one table of JSON-Schema-described tools: open, inspect, render pages to PNG, extract and find text, rotate, delete, move and insert pages, edit bookmarks and page labels, add, reply to, restyle and delete comments (highlight a phrase just by naming it), set metadata, undo and redo, save, combine, extract and split. Three front doors share it: - **`printcraft-cli run`**, for one-off calls and JSON scripts: ```sh printcraft-cli tools # every tool and its JSON Schema printcraft-cli run text_find doc=1 query=invoice # key=value; values parse as JSON + printcraft-cli run --script review.json # e.g. comment_add {"type": "highlight", "find": "total due"} printcraft-cli run --script steps.json --root ./work # several steps in one session ``` @@ -301,6 +302,8 @@ Start the desktop app with `printcraft --control /tmp/pc.json` and an agent can printcraft-cli ui --control /tmp/pc.json inspect query=rotate # find widgets printcraft-cli ui --control /tmp/pc.json click label="Organize pages" printcraft-cli ui --control /tmp/pc.json key key=K modifiers='["command"]' +printcraft-cli ui --control /tmp/pc.json command id=comment.square # pick a tool, then draw: +printcraft-cli ui --control /tmp/pc.json drag from='[400,300]' to='[600,420]' printcraft-cli ui --control /tmp/pc.json screenshot --out window.png ``` @@ -315,7 +318,8 @@ PrintCraft is a Cargo workspace of focused crates, layered so the core never dep | `printcraft-filters` | Every PDF stream filter (Flate, LZW, ASCII85, RunLength, predictors), encode and decode, property-tested | | `printcraft-crypt` | The standard security handler: RC4, AES-128/256, revisions 2–6, permissions | | `printcraft-cos` | The PDF object layer: tolerant parsing, repair, copy-on-write edits, incremental and full writing | -| `printcraft-organize` | Page operations, combine / extract / split, document information | +| `printcraft-organize` | Page operations, combine / extract / split, bookmarks, page labels, document information | +| `printcraft-annot` | Comments: builders and appearance streams for notes, text markup, shapes, ink and text boxes; replies, status, edits | | `printcraft-render` | Rendering, inspection and text extraction with reading order | | `printcraft-engine` | The façade every frontend uses: sessions, edits, undo, saving, the tool catalogue | | `printcraft-automation` | Agent control: the headless tool table, `printcraft-cli run`, and the opt-in MCP server | @@ -351,7 +355,8 @@ PrintCraft is young and moving fast. The aim is a workbench where you can view, **Available today:** - viewing, search and navigation; -- organizing pages, combining, extracting and splitting; +- organizing pages, combining, extracting and splitting; bookmarks and page labels; +- commenting: sticky notes, highlight / underline / strikethrough, text boxes, freehand drawing, lines, arrows, rectangles and ovals, with replies, status, colours, moving, resizing and a searchable Comments panel; - document information; - opening encrypted documents, honouring their permissions, and saving them encrypted; - undo and safe saving; @@ -363,8 +368,8 @@ PrintCraft is young and moving fast. The aim is a workbench where you can view, | Next up | Milestone | |---|---| -| Bookmark and page-label editing, page boxes | M4 | -| Creating and editing comments, stamps, FDF/XFDF | M5 | +| Page boxes | M4 | +| Callouts, clouds, stamps, FDF/XFDF, comment summaries | M5 | | Filling and authoring forms, JavaScript | M6 | | Editing text and images in place, headers, watermarks | M7 | | Adding passwords, redaction | M8 | diff --git a/apps/printcraft-cli/src/main.rs b/apps/printcraft-cli/src/main.rs index 45309fd5..81f7a8ca 100644 --- a/apps/printcraft-cli/src/main.rs +++ b/apps/printcraft-cli/src/main.rs @@ -472,7 +472,7 @@ fn ui(args: &[String]) -> Result<(), String> { let port = info["port"].as_u64().ok_or(format!("{file}: no port"))?; let token = info["token"].as_str().ok_or(format!("{file}: no token"))?; let pos = positional(args); - let method = *pos.first().ok_or("ui: missing method (state, inspect, click, type, key, command, commands, set, open, screenshot)")?; + let method = *pos.first().ok_or("ui: missing method (state, inspect, click, drag, type, key, command, commands, set, open, screenshot)")?; let method = if method.starts_with("ui.") { method.to_string() } else { format!("ui.{method}") }; let mut params = serde_json::Map::new(); for kv in pos.iter().skip(1) { diff --git a/assets/icons/circle-x.svg b/assets/icons/circle-x.svg new file mode 100644 index 00000000..8872303b --- /dev/null +++ b/assets/icons/circle-x.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/message-square-plus.svg b/assets/icons/message-square-plus.svg new file mode 100644 index 00000000..5aaa9ad5 --- /dev/null +++ b/assets/icons/message-square-plus.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/move-right.svg b/assets/icons/move-right.svg new file mode 100644 index 00000000..bf599d26 --- /dev/null +++ b/assets/icons/move-right.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/pin.svg b/assets/icons/pin.svg new file mode 100644 index 00000000..7c0602af --- /dev/null +++ b/assets/icons/pin.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/reply.svg b/assets/icons/reply.svg new file mode 100644 index 00000000..cd4ffd18 --- /dev/null +++ b/assets/icons/reply.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/thumbs-down.svg b/assets/icons/thumbs-down.svg new file mode 100644 index 00000000..8b081734 --- /dev/null +++ b/assets/icons/thumbs-down.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/thumbs-up.svg b/assets/icons/thumbs-up.svg new file mode 100644 index 00000000..5007fae8 --- /dev/null +++ b/assets/icons/thumbs-up.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/crates/automation/README.md b/crates/automation/README.md index 22a445b7..3e84c5e7 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -3,7 +3,7 @@ Agent control for PrintCraft: a headless tool table over the engine, and an opt-in MCP server. - **Layer:** L7 (architecture §13), but headless. It depends on `engine`, `render` and `organize`, never on a UI toolkit. -- **Status:** 20 tools covering inspection, rendering, text, page edits, metadata, undo/redo, save, combine, extract and split. Planned next: the UI control channel (`ui.*`, M3.9), tools for comments, forms and bookmarks as those features land, and MCP resources for page images. +- **Status:** 33 tools covering inspection, rendering, text, page edits, page labels, bookmarks, comments, metadata, undo/redo, save, combine, extract and split. Comment tools take geometry in the same top-left-origin points as everything else, and `comment_add` can mark text by searching for it (`find`). Planned next: tools for forms and protection as those features land, and MCP resources for page images. The running app's UI is driven separately, through the `ui.*` control channel in `printcraft-ui-egui`. ## API diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index b80f3118..bed9ac22 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -193,25 +193,26 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ icon: "message-square-text", hue: ORANGE, badge: None, - availability: Planned("M5"), + availability: Ready, sections: &[ ToolSection { title: "Markup", items: &[ - item("Sticky note", "sticky-note", "comment.note", Planned("M5")), - item("Highlight text", "highlighter", "comment.highlight", Planned("M5")), - item("Underline", "underline", "comment.underline", Planned("M5")), - item("Strikethrough", "strikethrough", "comment.strikeout", Planned("M5")), - item("Text box", "type", "comment.freetext", Planned("M5")), + item("Sticky note", "sticky-note", "comment.note", Ready), + item("Highlight text", "highlighter", "comment.highlight", Ready), + item("Underline", "underline", "comment.underline", Ready), + item("Strikethrough", "strikethrough", "comment.strikeout", Ready), + item("Text box", "type", "comment.freetext", Ready), ], }, ToolSection { title: "Drawing", items: &[ - item("Draw freehand", "pencil", "comment.ink", Planned("M5")), - item("Rectangle", "square", "comment.square", Planned("M5")), - item("Oval", "circle", "comment.circle", Planned("M5")), - item("Line / arrow", "arrow-up-right", "comment.line", Planned("M5")), + item("Draw freehand", "pencil", "comment.ink", Ready), + item("Rectangle", "square", "comment.square", Ready), + item("Oval", "circle", "comment.circle", Ready), + item("Line", "minus", "comment.line", Ready), + item("Arrow", "move-right", "comment.arrow", Ready), item("Cloud", "cloud", "comment.cloud", Planned("M5")), item("Stamp", "stamp", "comment.stamp", Planned("M5")), ], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 9c3821ff..e0f05c6c 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -22,6 +22,8 @@ pub enum Needs { Assembly, /// The open document's security allows content and metadata changes. Modification, + /// The open document's security allows adding and changing comments. + Annotate, /// There is something to undo. Undo, /// There is something to redo. @@ -143,6 +145,16 @@ pub const COMMANDS: &[CommandSpec] = &[ c("view.read_mode", "Read mode", VIEW, Some(Shortcut { command: true, shift: false, mac_ctrl: true, key: "H" }), Document, "book-open"), c("view.theme", "Switch light / dark theme", VIEW, None, Nothing, "moon"), c("comment.list", "Comments panel", VIEW, None, Document, "message-square-text"), + c("comment.note", "Add a sticky note", None, None, Annotate, "sticky-note"), + c("comment.freetext", "Add a text box", None, None, Annotate, "type"), + c("comment.highlight", "Highlight text", None, None, Annotate, "highlighter"), + c("comment.underline", "Underline text", None, None, Annotate, "underline"), + c("comment.strikeout", "Strikethrough text", None, None, Annotate, "strikethrough"), + c("comment.ink", "Draw freehand", None, None, Annotate, "pencil"), + c("comment.line", "Draw a line", None, None, Annotate, "minus"), + c("comment.arrow", "Draw an arrow", None, None, Annotate, "move-right"), + c("comment.square", "Draw a rectangle", None, None, Annotate, "square"), + c("comment.circle", "Draw an oval", None, None, Annotate, "circle"), c("form.fields", "Form fields panel", VIEW, None, Document, "list"), c("protect.properties", "Security properties…", FILE, None, Document, "shield-check"), c("page.organize", "Organize pages", PAGES, None, Document, "layout-grid"), @@ -180,6 +192,7 @@ pub fn is_enabled(spec: &CommandSpec, session: &Session, active: Option) Document => doc.is_some(), Assembly => doc.is_some_and(|d| d.allows_assembly()), Modification => doc.is_some_and(|d| d.allows_modification()), + Annotate => doc.is_some_and(|d| d.allows_annotation()), Undo => doc.is_some_and(|d| d.can_undo().is_some()), Redo => doc.is_some_and(|d| d.can_redo().is_some()), } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3c52b2fe..0d09f6ce 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -13,7 +13,7 @@ use printcraft_engine::{DocId, Edit}; use printcraft_render::{DocInfo, LinkTarget, PageText, RenderPool, RenderRequest, RequestKind, Tile}; use crate::theme::{self, Tokens}; -use crate::{PrintCraftApp, QuickTool, RightPanel, icons, widgets}; +use crate::{PrintCraftApp, QuickTool, RightPanel, comments, icons, widgets}; /// Logical pixels per PDF point at 100% (96 dpi, like browsers). pub const PT: f32 = 96.0 / 72.0; @@ -23,6 +23,8 @@ const MARGIN: f32 = 28.0; const SIDE: f32 = 70.0; const THUMB_TAG: u64 = 1 << 63; const TEXT_TAG: u64 = 1 << 62; +/// The tag of a raster that is out of date (shown until its replacement arrives). +const STALE_TAG: u64 = u64::MAX; /// Pages whose raster would exceed this many device pixels on a side are drawn in tiles. const TILE_THRESHOLD: f32 = 4096.0; const TILE: u32 = 1024; @@ -96,6 +98,8 @@ pub struct DocView { /// When each still-missing page was first shown, to flag unusually slow renders. waiting_since: HashMap, thumbs: HashMap, + /// Thumbnails that are out of date (still shown until their replacement arrives). + stale_thumbs: HashSet, /// Sharp tiles of large pages: (page, tile x, tile y) → (scale tag, texture). tiles: HashMap<(usize, u32, u32), (u64, TextureHandle)>, /// Text layers, extracted in the background on demand (selection, find, copy). @@ -122,6 +126,8 @@ pub struct DocView { select_anchor: Option, /// An edit requested by the view (organize toolbar, keys), applied by the app this frame. pub pending_edit: Option, + /// Commenting state: selected comment, gestures, composer. + pub comments: crate::comments::CommentView, /// A non-edit action requested by the organize toolbar, handled by the app. pub pending_action: Option, } @@ -170,6 +176,7 @@ impl DocView { errors: HashMap::new(), waiting_since: HashMap::new(), thumbs: HashMap::new(), + stale_thumbs: HashSet::new(), tiles: HashMap::new(), texts: HashMap::new(), text_failed: HashSet::new(), @@ -188,6 +195,7 @@ impl DocView { select_anchor: None, pending_edit: None, pending_action: None, + comments: Default::default(), } } @@ -226,11 +234,14 @@ impl DocView { !self.last_queue.is_empty() } - /// Drop every cached raster and text layer (the document's appearance changed, e.g. a layer - /// was toggled). Pages re-render on the next frame; the old textures are simply released. + /// Re-render every page and text layer (the document's appearance changed, e.g. a layer + /// was toggled). Out-of-date rasters stay on screen until their replacements arrive, so the + /// view never flashes blank. pub fn invalidate_content(&mut self) { - self.pages.clear(); - self.thumbs.clear(); + for p in self.pages.values_mut() { + p.tag = STALE_TAG; + } + self.stale_thumbs.extend(self.thumbs.keys().copied()); self.tiles.clear(); self.texts.clear(); self.text_failed.clear(); @@ -244,6 +255,57 @@ impl DocView { } } + /// Only `page` changed (a comment was added, edited or removed): re-render that page and + /// re-read its text, keep everything else. + pub fn page_changed(&mut self, page: usize) { + if let Some(p) = self.pages.get_mut(&page) { + p.tag = STALE_TAG; + } + if self.thumbs.contains_key(&page) { + self.stale_thumbs.insert(page); + } + self.tiles.retain(|(p, _, _), _| *p != page); + self.texts.remove(&page); + self.text_failed.remove(&page); + self.errors.remove(&page); + self.last_queue.clear(); + if self.selection.is_some_and(|s| s.page == page) { + self.selection = None; + } + if let Some(f) = self.find.as_mut() { + f.matches.retain(|(p, _)| *p != page); + f.current = None; + } + } + + /// Pages drawn last frame and their screen rectangles. + pub fn visible_page_rects(&self) -> Vec<(usize, Rect)> { + self.screen_xforms.iter().map(|(p, xf)| (*p, xf.rect)).collect() + } + + /// Where `page` is drawn this frame, with its coordinate mapping. + pub(crate) fn page_xform(&self, page: usize) -> Option { + self.screen_xforms.iter().find(|(p, _)| *p == page).map(|(_, xf)| *xf) + } + + /// The text selection as markup quadrilaterals in user space: (page, quads). + pub(crate) fn selection_quads(&self, info: &DocInfo) -> Option<(usize, Vec<[f64; 8]>)> { + let s = self.selection?; + let text = self.texts.get(&s.page)?; + let page = info.pages.get(s.page)?; + let quads: Vec<[f64; 8]> = text.line_rects(s.range()).into_iter().map(|r| page.view_rect_to_quad(r)).collect(); + (!quads.is_empty()).then_some((s.page, quads)) + } + + pub(crate) fn clear_selection(&mut self) { + self.selection = None; + } + + /// Select text on `page` from glyph `from` to glyph `to` (tests and automation). + pub fn select_text(&mut self, page: usize, from: usize, to: usize) { + self.selection = Some(Selection { page, anchor: from, head: to }); + } + /// Open the find bar (or focus it if already open). pub fn open_find(&mut self) { let f = self.find.get_or_insert_with(Find::default); @@ -419,6 +481,7 @@ impl DocView { if r.request.tag & THUMB_TAG != 0 { let tex = ctx.load_texture(format!("thumb-{:?}-{page}", self.id), img, TextureOptions::LINEAR); self.thumbs.insert(page, tex); + self.stale_thumbs.remove(&page); } else { let tex = ctx.load_texture(format!("page-{:?}-{page}", self.id), img, TextureOptions::LINEAR); self.pages.insert(page, PageTex { tag: r.request.tag, tex }); @@ -704,8 +767,18 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { let scale = view.render_scale(ppp); let tag = (scale * 1000.0) as u64; let hand = app.quick_tool == QuickTool::Hand; + let tool = app.quick_tool; + // Text selection runs for the Select tool and for the markup tools (highlight…). + let selects_text = match tool { + QuickTool::Select => true, + QuickTool::Comment(t) => t.markup().is_some(), + QuickTool::Hand => false, + }; + let prefs = &app.comment_prefs; + let allowed = doc.allows_annotation(); let mut hover_text: Option<(Pos2, String)> = None; let mut clicked_link: Option = None; + let mut canvas_action: Option = 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()); @@ -812,6 +885,10 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { } painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside); + // Comments: tools, selection, moving and resizing come before text selection. + let pcx = comments::PageCx { page: i, xf: &xf, info, tool, prefs, allowed }; + let consumed = comments::page_input(ui, &resp, &pcx, view); + // Text layer: find matches, selection, I-beam and drag-to-select. let to_screen = |g: [f32; 4]| xf.view_rect(g); if let Some(text) = view.texts.get(&i).cloned() { @@ -836,7 +913,10 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { painter.rect_filled(to_screen(lr), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); } } - if !hand && let Some(p) = pointer.filter(|p| r.contains(*p)) { + if selects_text + && !consumed + && let Some(p) = pointer.filter(|p| r.contains(*p)) + { let (vx, vy) = xf.screen_to_view(p); let over_text = text.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3]); let over_link = info.links.iter().any(|l| l.page == i && xf.user_rect(info, i, l.rect).contains(p)); @@ -876,6 +956,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { } } + comments::page_after_text(&resp, &pcx, view); + comments::paint_page(ui, painter, &pcx, view); + // Form-field highlight (Acrobat's "Highlight existing fields"). if view.highlight_fields { for f in info.fields.iter().filter(|f| f.page == Some(i)) { @@ -903,13 +986,14 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { LinkTarget::Other(s) => format!("{s} action"), }; hover_text = Some((p, label)); - if resp.clicked() && !hand { + if resp.clicked() && tool == QuickTool::Select && !consumed { clicked_link = Some(l.target.clone()); } } } // Annotation hover shows the comment, as Acrobat's popups do. - for a in info.annotations.iter().filter(|a| a.page == i) { + let gesturing = view.comments.gesture.is_some(); + for a in info.annotations.iter().filter(|a| a.page == i && a.in_reply_to.is_none() && !gesturing) { let sr = xf.user_rect(info, i, a.rect); if sr.contains(p) && hover_text.is_none() { let who = a.author.clone().unwrap_or_else(|| a.subtype.clone()); @@ -946,6 +1030,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { view.page_input = (current + 1).to_string(); } } + resp.context_menu(|ui| { + canvas_action = comments::context_menu(ui, view, info, prefs, allowed); + }); (wanted, visible_now) }); @@ -975,7 +1062,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { if want_thumbs { let s = THUMB_W * ppp / info.pages.iter().map(|p| p.width).fold(1.0, f32::max); for page in 0..info.pages.len() { - if !view.thumbs.contains_key(&page) && !view.errors.contains_key(&page) { + if (!view.thumbs.contains_key(&page) || view.stale_thumbs.contains(&page)) && !view.errors.contains_key(&page) { queue.push(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: s, tag: THUMB_TAG }); } } @@ -997,12 +1084,23 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { }); } find_bar(view, info.pages.len(), avail, ui, &t); + if let Some(e) = comments::composer(ui.ctx(), view, info, prefs) { + view.pending_edit = Some(e); + } + let mut tool = app.quick_tool; + comments::keys(ui.ctx(), view, &mut tool, allowed); + match canvas_action { + Some(comments::CanvasAction::Edit(e)) => view.pending_edit = Some(e), + Some(comments::CanvasAction::OpenComments) => app.right = Some(RightPanel::Comments), + None => {} + } match clicked_link { Some(LinkTarget::Page(p)) => view.go_to_page(p), Some(LinkTarget::Uri(u)) => ui.ctx().open_url(egui::OpenUrl::new_tab(u)), Some(LinkTarget::Other(s)) => app.notify(format!("{s} actions run in the JavaScript engine (M6)")), None => {} } + app.quick_tool = tool; quick_bar(app, avail, ui); } @@ -1135,15 +1233,64 @@ fn quick_bar(app: &mut PrintCraftApp, area: Rect, ui: &mut egui::Ui) { if icons::button(ui, "hand", 32.0, app.quick_tool == QuickTool::Hand, "Hand (H)").clicked() { app.quick_tool = QuickTool::Hand; } - for (icon, tip, cmd) in [ - ("message-square-text", "Add a comment (M5)", "comment.note"), - ("highlighter", "Highlight text (M5)", "comment.highlight"), - ("pencil", "Draw freehand (M5)", "comment.ink"), - ("pen-line", "Fill & Sign (M5)", "sign.fill.text"), - ] { - if icons::button(ui, icon, 32.0, false, tip).clicked() { - app.run_command(cmd); + // Comment ▸, Highlight ▸, Draw ▸ (Acrobat's comment toolbar groups). Clicking a + // group selects its last-used tool; clicking it again opens the flyout. + for g in 0..comments::GROUPS.len() { + let current = app.comment_prefs.group_tool[g]; + let active = matches!(app.quick_tool, QuickTool::Comment(t) if t.group() == g); + let resp = icons::button(ui, current.icon(), 32.0, active, current.label()); + // A small corner triangle marks the flyout. + let r = resp.rect; + let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)]; + ui.painter().add(egui::Shape::convex_polygon(tri.to_vec(), if active { Color32::WHITE } else { t.text_muted }, Stroke::NONE)); + let open = (resp.clicked() && active) || resp.secondary_clicked(); + if resp.clicked() && !active { + app.execute(current.command()); } + egui::Popup::menu(&resp) + .open_memory(open.then_some(egui::SetOpenCommand::Toggle)) + .align(egui::RectAlign::RIGHT_START) + .gap(6.0) + .show(|ui| { + for tool in comments::GROUPS[g] { + let on = app.quick_tool == QuickTool::Comment(*tool); + let (row, click) = ui.allocate_exact_size(vec2(180.0, 28.0), Sense::click()); + if click.hovered() { + ui.painter().rect_filled(row, CornerRadius::same(4), t.hover); + } + icons::paint(ui, Rect::from_min_size(row.min + vec2(8.0, 6.0), vec2(16.0, 16.0)), tool.icon(), 16.0, t.text); + ui.painter().text( + row.left_center() + vec2(34.0, 0.0), + Align2::LEFT_CENTER, + tool.label(), + theme::regular(13.0), + t.text, + ); + if on { + icons::paint( + ui, + Rect::from_min_size(row.right_top() + vec2(-24.0, 7.0), vec2(14.0, 14.0)), + "check", + 14.0, + t.accent, + ); + } + let click = click.on_hover_cursor(egui::CursorIcon::PointingHand); + click.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, tool.label())); + if click.clicked() { + app.execute(tool.command()); + ui.close(); + } + } + }); + } + if icons::button(ui, "pen-line", 32.0, false, "Fill & Sign (M5)").clicked() { + app.run_command("sign.fill.text"); + } + if let QuickTool::Comment(tool) = app.quick_tool { + let (r, _) = ui.allocate_exact_size(vec2(32.0, 9.0), Sense::hover()); + ui.painter().hline(r.x_range().shrink(6.0), r.center().y, Stroke::new(1.0, t.divider)); + comments::quick_bar_controls(ui, tool, &mut app.comment_prefs); } }); }); @@ -1290,7 +1437,7 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, pool: &RenderPool, editable }); let s = THUMB_W * ppp / info.pages.iter().map(|p| p.width).fold(1.0, f32::max); let queue: Vec = (0..info.pages.len()) - .filter(|p| !view.thumbs.contains_key(p) && !view.errors.contains_key(p)) + .filter(|p| (!view.thumbs.contains_key(p) || view.stale_thumbs.contains(p)) && !view.errors.contains_key(p)) .map(|page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: s, tag: THUMB_TAG }) .collect(); if queue != view.last_queue { diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 867c7da1..3a52ffa9 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -22,7 +22,7 @@ impl PrintCraftApp { let why = match spec.needs { commands::Needs::Undo => "Nothing to undo".to_string(), commands::Needs::Redo => "Nothing to redo".to_string(), - commands::Needs::Assembly | commands::Needs::Modification if self.active.is_some() => { + commands::Needs::Assembly | commands::Needs::Modification | commands::Needs::Annotate if self.active.is_some() => { "The document's security settings don't allow this change".to_string() } _ => "Open a document first".to_string(), @@ -86,6 +86,32 @@ impl PrintCraftApp { } } "comment.list" => self.right = Some(RightPanel::Comments), + tool if crate::comments::CommentTool::from_command(tool).is_some() => { + let tool = crate::comments::CommentTool::from_command(tool).expect("checked"); + self.comment_prefs.group_tool[tool.group()] = tool; + self.quick_tool = crate::QuickTool::Comment(tool); + // Acrobat opens the Comments panel with the commenting tools. + if self.right.is_none() { + self.right = Some(RightPanel::Comments); + } + // A text selection made before picking a markup tool is marked right away. + if let (Some(kind), Some(i)) = (tool.markup(), active) { + let info = &self.session.get(self.views[i].id).expect("active").info; + if let Some((page, quads)) = self.views[i].selection_quads(info) { + self.views[i].clear_selection(); + let style = self.comment_prefs.style(tool); + let author = self.comment_prefs.author.clone(); + let shape = printcraft_engine::Shape::TextMarkup { kind, quads }; + self.apply_edit(Edit::AddAnnotation(printcraft_engine::NewAnnotation { + page, + shape, + style, + contents: String::new(), + author, + })); + } + } + } "form.fields" => self.right = Some(RightPanel::Fields), "page.organize" => { if let Some(i) = active { diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs new file mode 100644 index 00000000..81efe5db --- /dev/null +++ b/crates/ui-egui/src/comments.rs @@ -0,0 +1,952 @@ +//! Commenting in the document view (execution plan M5.3–M5.4; Acrobat's "Add comments"). +//! +//! - **Tools** ([`CommentTool`]): sticky note, text box, highlight / underline / strikethrough +//! (driven by text selection), freehand, line, arrow, rectangle, oval. They live in the quick +//! bar in three flyout groups, as in Acrobat's comment toolbar (audit `a14-*`). +//! - **On the page:** drawing gestures with live previews; with the Select tool, comments can be +//! hovered, selected (blue frame with 8 round handles), moved, resized (rectangles, ovals and +//! text boxes), edited (double-click) and deleted (⌫ / Delete); a context menu offers Edit, +//! Reply, Set status, Colour and Delete. +//! - **Composer:** the floating "Add a comment" card used for new notes, text boxes and edits. +//! +//! Everything is turned into `printcraft_engine::Edit`s, queued on the view as `pending_edit` and +//! applied by the app, so each change is one undo step. + +use egui::{Color32, CornerRadius, Pos2, Rect, Sense, Stroke, pos2, vec2}; +use printcraft_engine::{Edit, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, Style}; +use printcraft_render::{Annotation, DocInfo}; + +use crate::canvas::{DocView, PageXform}; +use crate::theme::{self, Tokens}; +use crate::{QuickTool, icons}; + +/// Acrobat's selection blue for annotation frames and handles (11-visual-spec §5). +const SELECT_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CommentTool { + Note, + TextBox, + Highlight, + Underline, + StrikeOut, + Ink, + Line, + Arrow, + Rectangle, + Oval, +} + +/// The quick-bar flyout groups, in Acrobat's order: Comment ▸, Highlight ▸, Draw ▸. +pub const GROUPS: [&[CommentTool]; 3] = [ + &[CommentTool::Note, CommentTool::TextBox], + &[CommentTool::Highlight, CommentTool::Underline, CommentTool::StrikeOut], + &[CommentTool::Ink, CommentTool::Line, CommentTool::Arrow, CommentTool::Rectangle, CommentTool::Oval], +]; + +pub const ALL: [CommentTool; 10] = [ + CommentTool::Note, + CommentTool::TextBox, + CommentTool::Highlight, + CommentTool::Underline, + CommentTool::StrikeOut, + CommentTool::Ink, + CommentTool::Line, + CommentTool::Arrow, + CommentTool::Rectangle, + CommentTool::Oval, +]; + +impl CommentTool { + pub fn command(self) -> &'static str { + match self { + Self::Note => "comment.note", + Self::TextBox => "comment.freetext", + Self::Highlight => "comment.highlight", + Self::Underline => "comment.underline", + Self::StrikeOut => "comment.strikeout", + Self::Ink => "comment.ink", + Self::Line => "comment.line", + Self::Arrow => "comment.arrow", + Self::Rectangle => "comment.square", + Self::Oval => "comment.circle", + } + } + + pub fn from_command(id: &str) -> Option { + ALL.into_iter().find(|t| t.command() == id) + } + + pub fn label(self) -> &'static str { + match self { + Self::Note => "Add a sticky note", + Self::TextBox => "Add a text box", + Self::Highlight => "Highlight", + Self::Underline => "Underline", + Self::StrikeOut => "Strikethrough", + Self::Ink => "Draw", + Self::Line => "Line", + Self::Arrow => "Arrow", + Self::Rectangle => "Rectangle", + Self::Oval => "Oval", + } + } + + pub fn icon(self) -> &'static str { + match self { + Self::Note => "message-square-plus", + Self::TextBox => "type", + Self::Highlight => "highlighter", + Self::Underline => "underline", + Self::StrikeOut => "strikethrough", + Self::Ink => "pencil", + Self::Line => "minus", + Self::Arrow => "move-right", + Self::Rectangle => "square", + Self::Oval => "circle", + } + } + + pub fn group(self) -> usize { + GROUPS.iter().position(|g| g.contains(&self)).unwrap_or(0) + } + + pub fn markup(self) -> Option { + match self { + Self::Highlight => Some(Markup::Highlight), + Self::Underline => Some(Markup::Underline), + Self::StrikeOut => Some(Markup::StrikeOut), + _ => None, + } + } + + /// Tools that draw with a drag gesture. + pub fn draws(self) -> bool { + matches!(self, Self::Ink | Self::Line | Self::Arrow | Self::Rectangle | Self::Oval) + } + + /// Whether the line-thickness control applies. + pub fn has_width(self) -> bool { + self.draws() + } + + /// A placeholder shape of this kind (for per-tool default styles). + fn sample(self) -> Shape { + match self { + Self::Note => Shape::Note { at: [0.0; 2], icon: NoteIcon::Comment }, + Self::TextBox => Shape::TextBox { rect: [0.0; 4], font_size: 12.0 }, + Self::Highlight | Self::Underline | Self::StrikeOut => { + Shape::TextMarkup { kind: self.markup().unwrap_or(Markup::Highlight), quads: Vec::new() } + } + Self::Ink => Shape::Ink { strokes: Vec::new() }, + Self::Line => Shape::Line { from: [0.0; 2], to: [0.0; 2], arrow: false }, + Self::Arrow => Shape::Line { from: [0.0; 2], to: [0.0; 2], arrow: true }, + Self::Rectangle => Shape::Rectangle { rect: [0.0; 4] }, + Self::Oval => Shape::Oval { rect: [0.0; 4] }, + } + } +} + +/// The comment swatches (our palette, close to Acrobat's quick colours). +pub const SWATCHES: [(&str, Rgb); 10] = [ + ("Yellow", [1.0, 0.94, 0.0]), + ("Orange", [1.0, 0.54, 0.0]), + ("Red", [0.89, 0.13, 0.13]), + ("Pink", [1.0, 0.37, 0.64]), + ("Purple", [0.54, 0.25, 0.82]), + ("Blue", [0.0, 0.47, 0.84]), + ("Light blue", [0.36, 0.75, 0.98]), + ("Green", [0.18, 0.62, 0.36]), + ("Gray", [0.5, 0.5, 0.5]), + ("Black", [0.0, 0.0, 0.0]), +]; + +pub fn color32(c: Rgb) -> Color32 { + let b = |v: f64| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgb(b(c[0]), b(c[1]), b(c[2])) +} + +/// Commenting preferences shared by every document (author, per-tool styles, pin). +#[derive(Clone, Debug)] +pub struct CommentPrefs { + /// `/T` of new comments (Acrobat: Preferences ▸ Identity, default the login name). + pub author: String, + styles: Vec<(CommentTool, Style)>, + /// Keep the tool selected after use (the pin). + pub pinned: bool, + /// The tool each flyout group shows (the last one used). + pub group_tool: [CommentTool; 3], +} + +impl Default for CommentPrefs { + fn default() -> Self { + Self { + author: login_name(), + styles: ALL.iter().map(|t| (*t, Style::default_for(&t.sample()))).collect(), + pinned: false, + group_tool: [CommentTool::Note, CommentTool::Highlight, CommentTool::Ink], + } + } +} + +impl CommentPrefs { + pub fn style(&self, tool: CommentTool) -> Style { + self.styles.iter().find(|(t, _)| *t == tool).map(|(_, s)| s.clone()).unwrap_or_default() + } + + pub fn set_color(&mut self, tool: CommentTool, c: Rgb) { + if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) { + s.color = c; + } + } + + pub fn set_width(&mut self, tool: CommentTool, w: f64) { + if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) { + s.width = w.clamp(0.5, 12.0); + } + } +} + +/// The user's login name, which Acrobat uses as the default comment author. +fn login_name() -> String { + #[cfg(not(target_arch = "wasm32"))] + for var in ["USER", "USERNAME", "LOGNAME"] { + if let Ok(v) = std::env::var(var) + && !v.trim().is_empty() + { + return v; + } + } + "Guest".into() +} + +/// A drag in progress on a page. +#[derive(Clone, Debug, PartialEq)] +pub enum Gesture { + /// Drawing with a tool: points in user space (ink: the stroke; others: start and end). + Draw { page: usize, tool: CommentTool, points: Vec<[f64; 2]> }, + /// Moving a comment, from the press position on screen. + Move { page: usize, index: usize, from: Pos2 }, + /// Resizing a comment by one of its handles: (dx, dy) ∈ {-1, 0, 1}² says which sides move. + Resize { page: usize, index: usize, handle: (i8, i8), from: Pos2 }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ComposerKind { + Note, + TextBox, + /// Edit the text of the comment at this index on the composer's page. + Edit(usize), +} + +/// The floating "Add a comment" card. +#[derive(Clone, Debug, PartialEq)] +pub struct Composer { + pub page: usize, + /// Anchor in user space (the note's top-left, the text box's top-left, the comment's corner). + pub at: [f64; 2], + pub kind: ComposerKind, + pub text: String, + pub focus: bool, +} + +/// Per-document commenting state. +#[derive(Clone, Debug, Default)] +pub struct CommentView { + /// The selected comment: (page, index in `/Annots`). + pub selected: Option<(usize, usize)>, + pub gesture: Option, + pub composer: Option, + /// Reply being typed under the selected card. + pub reply: String, + /// Inline edit of a card's text in the Comments panel: (page, index, text). + pub editing: Option<(usize, usize, String)>, + /// The panel should scroll the selected card into view. + pub reveal: bool, + /// The "Add a comment" box at the top of the Comments panel. + pub add_box: String, + /// The Comments panel's search query (`None`: search closed). + pub search: Option, + pub search_focus: bool, + /// Where the canvas context menu was opened: (page, user-space point). + pub context_at: Option<(usize, [f64; 2])>, + /// A one-shot tool just finished; the app returns to the Select tool unless pinned. + pub tool_done: bool, +} + +/// What a page needs to know to handle comment input. +pub(crate) struct PageCx<'a> { + pub page: usize, + pub xf: &'a PageXform, + pub info: &'a DocInfo, + pub tool: QuickTool, + pub prefs: &'a CommentPrefs, + /// The document allows commenting. + pub allowed: bool, +} + +impl PageCx<'_> { + fn to_user(&self, p: Pos2) -> [f64; 2] { + let (vx, vy) = self.xf.screen_to_view(p); + let u = self.info.pages[self.page].view_to_user(vx, vy); + [u[0] as f64, u[1] as f64] + } + + fn to_screen(&self, p: [f64; 2]) -> Pos2 { + let v = self.info.pages[self.page].user_to_view(p[0] as f32, p[1] as f32); + self.xf.norm_to_screen(v[0] / self.xf.pw, v[1] / self.xf.ph) + } + + fn screen_rect(&self, a: &Annotation) -> Rect { + self.xf.user_rect(self.info, self.page, a.rect) + } + + /// Top-level comments on this page, in paint order. + fn comments(&self) -> impl Iterator { + self.info.annotations.iter().filter(move |a| a.page == self.page && a.in_reply_to.is_none() && a.subtype != "Popup") + } + + /// Where a comment is on screen: one rectangle, or one per marked line for text markup. + fn screen_rects(&self, a: &Annotation) -> Vec { + if is_markup(&a.subtype) && !a.quads.is_empty() { + a.quads + .iter() + .map(|q| { + let xs = [q[0], q[2], q[4], q[6]]; + let ys = [q[1], q[3], q[5], q[7]]; + let (x0, x1) = (xs.iter().copied().fold(f32::MAX, f32::min), xs.iter().copied().fold(f32::MIN, f32::max)); + let (y0, y1) = (ys.iter().copied().fold(f32::MAX, f32::min), ys.iter().copied().fold(f32::MIN, f32::max)); + self.xf.user_rect(self.info, self.page, [x0, y0, x1, y1]) + }) + .collect() + } else { + vec![self.screen_rect(a)] + } + } + + /// The topmost comment under `p`. + fn hit(&self, p: Pos2) -> Option<&Annotation> { + self.comments().filter(|a| self.screen_rects(a).iter().any(|r| r.expand(3.0).contains(p))).last() + } + + fn get(&self, index: usize) -> Option<&Annotation> { + self.comments().find(|a| a.index == index) + } +} + +fn is_markup(subtype: &str) -> bool { + matches!(subtype, "Highlight" | "Underline" | "StrikeOut" | "Squiggly") +} + +fn resizable(subtype: &str) -> bool { + matches!(subtype, "Square" | "Circle" | "FreeText") +} + +const HANDLES: [(i8, i8); 8] = [(-1, -1), (0, -1), (1, -1), (1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0)]; + +fn handle_pos(r: Rect, (hx, hy): (i8, i8)) -> Pos2 { + let x = match hx { + -1 => r.left(), + 0 => r.center().x, + _ => r.right(), + }; + let y = match hy { + -1 => r.top(), + 0 => r.center().y, + _ => r.bottom(), + }; + pos2(x, y) +} + +fn resized(r: Rect, (hx, hy): (i8, i8), d: egui::Vec2) -> Rect { + let mut r = r; + match hx { + -1 => r.min.x += d.x, + 1 => r.max.x += d.x, + _ => {} + } + match hy { + -1 => r.min.y += d.y, + 1 => r.max.y += d.y, + _ => {} + } + Rect::from_two_pos(r.min, r.max) +} + +/// Comment input on one page, before text selection runs. Returns `true` when the pointer +/// gesture belongs to commenting (text selection must ignore it). +pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView) -> bool { + let pointer = ui.input(|i| i.pointer.hover_pos()); + let origin = ui.input(|i| i.pointer.press_origin()); + let page_rect = cx.xf.rect; + let pressed_here = origin.is_some_and(|o| page_rect.contains(o)); + let over_page = pointer.is_some_and(|p| page_rect.contains(p)); + let cv = &mut view.comments; + if resp.secondary_clicked() + && let Some(p) = pointer.filter(|p| page_rect.contains(*p)) + { + cv.context_at = Some((cx.page, cx.to_user(p))); + cv.selected = cx.hit(p).map(|a| (cx.page, a.index)); + } + match cx.tool { + QuickTool::Comment(tool) if tool.draws() => { + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.drag_started() + && pressed_here + && let Some(o) = origin + { + cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![cx.to_user(o)] }); + cv.selected = None; + } + if let Some(Gesture::Draw { page, tool, points }) = cv.gesture.as_mut() + && *page == cx.page + && let Some(p) = pointer + { + let p = clamp_to(page_rect, p); + let u = cx.to_user(p); + if *tool == CommentTool::Ink { + let last = points.last().map(|l| cx.to_screen(*l)).unwrap_or(p); + if last.distance(p) >= 1.5 { + points.push(u); + } + } else { + points.truncate(1); + points.push(u); + } + } + if resp.drag_stopped() + && let Some(Gesture::Draw { page, tool, points }) = cv.gesture.clone() + && page == cx.page + { + cv.gesture = None; + if let Some(shape) = drawn_shape(tool, &points) { + view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); + } + } + true + } + QuickTool::Comment(tool @ (CommentTool::Note | CommentTool::TextBox)) => { + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.clicked() + && over_page + && let Some(p) = pointer + { + let kind = if tool == CommentTool::Note { ComposerKind::Note } else { ComposerKind::TextBox }; + cv.composer = Some(Composer { page: cx.page, at: cx.to_user(p), kind, text: String::new(), focus: true }); + cv.selected = None; + } + true + } + QuickTool::Select => select_input(ui, resp, cx, view, pointer, origin, pressed_here), + _ => false, + } +} + +fn clamp_to(r: Rect, p: Pos2) -> Pos2 { + pos2(p.x.clamp(r.left(), r.right()), p.y.clamp(r.top(), r.bottom())) +} + +/// The Select tool: hover, select, move, resize, edit, delete. +fn select_input( + ui: &egui::Ui, + resp: &egui::Response, + cx: &PageCx<'_>, + view: &mut DocView, + pointer: Option, + origin: Option, + pressed_here: bool, +) -> bool { + let cv = &mut view.comments; + let selected = cv.selected.filter(|(p, _)| *p == cx.page).and_then(|(_, i)| cx.get(i)); + // Handles of a selected, resizable comment. + let handle_at = |p: Pos2| -> Option<(i8, i8)> { + let a = selected.filter(|a| cx.allowed && resizable(&a.subtype))?; + let r = cx.screen_rect(a); + HANDLES.into_iter().find(|h| handle_pos(r, *h).distance(p) <= 7.0) + }; + let mut consumed = false; + if let Some(p) = pointer { + if let Some(h) = handle_at(p) { + ui.ctx().set_cursor_icon(match h { + (0, _) => egui::CursorIcon::ResizeVertical, + (_, 0) => egui::CursorIcon::ResizeHorizontal, + (-1, -1) | (1, 1) => egui::CursorIcon::ResizeNwSe, + _ => egui::CursorIcon::ResizeNeSw, + }); + } else if let Some(a) = cx.hit(p) { + let movable = cx.allowed && !is_markup(&a.subtype); + ui.ctx().set_cursor_icon(if movable { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand }); + } + } + if resp.drag_started() + && pressed_here + && let Some(o) = origin + { + if let (Some(h), Some(a)) = (handle_at(o), selected) { + cv.gesture = Some(Gesture::Resize { page: cx.page, index: a.index, handle: h, from: o }); + consumed = true; + } else if let Some(a) = cx.hit(o) + && !is_markup(&a.subtype) + { + cv.selected = Some((cx.page, a.index)); + cv.reveal = true; + if cx.allowed { + cv.gesture = Some(Gesture::Move { page: cx.page, index: a.index, from: o }); + } + consumed = true; + } + } + if matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. }) if page == cx.page) { + consumed = true; + } + if resp.drag_stopped() + && let Some(p) = pointer + { + match cv.gesture.clone() { + Some(Gesture::Move { page, index, from }) if page == cx.page => { + cv.gesture = None; + let (a, b) = (cx.to_user(from), cx.to_user(p)); + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + if from.distance(p) >= 2.0 { + view.pending_edit = Some(Edit::MoveAnnotation { page, index, dx, dy }); + } + } + Some(Gesture::Resize { page, index, handle, from }) if page == cx.page => { + cv.gesture = None; + if let Some(a) = cx.get(index) { + let r = resized(cx.screen_rect(a), handle, p - from); + let (u0, u1) = (cx.to_user(r.left_top()), cx.to_user(r.right_bottom())); + let rect = [u0[0].min(u1[0]), u0[1].min(u1[1]), u0[0].max(u1[0]), u0[1].max(u1[1])]; + if r.width() >= 4.0 && r.height() >= 4.0 { + view.pending_edit = Some(Edit::ResizeAnnotation { page, index, rect }); + } + } + } + _ => {} + } + } + // (egui clears the press origin on release, so clicks are located by the pointer.) + let on_page = pointer.is_some_and(|p| cx.xf.rect.contains(p)); + if resp.clicked() && on_page { + match pointer.and_then(|p| cx.hit(p)) { + Some(a) => { + cv.selected = Some((cx.page, a.index)); + cv.reveal = true; + consumed = true; + } + None => cv.selected = None, + } + } + if resp.double_clicked() + && on_page + && cx.allowed + && let Some(a) = pointer.and_then(|p| cx.hit(p)) + { + cv.selected = Some((cx.page, a.index)); + cv.composer = Some(Composer { + page: cx.page, + at: [a.rect[2] as f64, a.rect[3] as f64], + kind: ComposerKind::Edit(a.index), + text: a.contents.clone().unwrap_or_default(), + focus: true, + }); + consumed = true; + } + consumed +} + +/// After text selection ran: a markup tool turns a finished selection into a comment. +pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView) { + let QuickTool::Comment(tool) = cx.tool else { return }; + if tool.markup().is_none() || !cx.allowed || !(resp.drag_stopped() || resp.double_clicked()) { + return; + } + if let Some((page, quads)) = view.selection_quads(cx.info).filter(|(p, _)| *p == cx.page) { + view.clear_selection(); + let kind = tool.markup().expect("checked"); + view.pending_edit = Some(new_comment(cx, tool, Shape::TextMarkup { kind, quads }, String::new())); + let _ = page; + } +} + +/// Paint comment selection, hover and gesture previews on a page. +pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) { + let cv = &view.comments; + let pointer = ui.input(|i| i.pointer.hover_pos()); + if cx.tool == QuickTool::Select + && cv.gesture.is_none() + && let Some(a) = pointer.and_then(|p| cx.hit(p)) + && cv.selected != Some((cx.page, a.index)) + { + for r in cx.screen_rects(a) { + painter.rect_stroke(r.expand(2.0), CornerRadius::same(2), Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.7)), egui::StrokeKind::Outside); + } + } + if let Some((page, index)) = cv.selected + && page == cx.page + && let Some(a) = cx.get(index) + { + if is_markup(&a.subtype) && !a.quads.is_empty() { + for r in cx.screen_rects(a) { + painter.rect_stroke(r.expand(2.0), CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Middle); + } + return paint_gesture(painter, cx, view); + } + let mut r = cx.screen_rect(a).expand(2.0); + if let (Some(p), Some(g)) = (pointer, &cv.gesture) { + match g { + Gesture::Move { page, index: gi, from } if *page == cx.page && *gi == index => r = r.translate(p - *from), + Gesture::Resize { page, index: gi, handle, from } if *page == cx.page && *gi == index => r = resized(r, *handle, p - *from), + _ => {} + } + } + painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Middle); + if cx.allowed && resizable(&a.subtype) { + for h in HANDLES { + let c = handle_pos(r, h); + painter.circle(c, 4.5, Color32::WHITE, Stroke::new(1.0, SELECT_BLUE)); + } + } + } + paint_gesture(painter, cx, view); +} + +fn paint_gesture(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) { + if let Some(Gesture::Draw { page, tool, points }) = &view.comments.gesture + && *page == cx.page + { + let style = cx.prefs.style(*tool); + let zoom = cx.xf.rect.width() / cx.xf.pw.max(1.0); + let stroke = Stroke::new((style.width as f32 * zoom).max(1.0), color32(style.color)); + let pts: Vec = points.iter().map(|p| cx.to_screen(*p)).collect(); + match tool { + CommentTool::Ink => { + painter.add(egui::Shape::line(pts, stroke)); + } + CommentTool::Line | CommentTool::Arrow if pts.len() == 2 => { + painter.line_segment([pts[0], pts[1]], stroke); + if *tool == CommentTool::Arrow { + let d = (pts[0] - pts[1]).normalized(); + let s = (6.0 + 3.0 * style.width as f32) * zoom; + let rot = |v: egui::Vec2, a: f32| vec2(v.x * a.cos() - v.y * a.sin(), v.x * a.sin() + v.y * a.cos()); + for a in [0.52f32, -0.52] { + painter.line_segment([pts[1], pts[1] + rot(d, a) * s], stroke); + } + } + } + CommentTool::Rectangle if pts.len() == 2 => { + painter.rect_stroke(Rect::from_two_pos(pts[0], pts[1]), CornerRadius::ZERO, stroke, egui::StrokeKind::Inside); + } + CommentTool::Oval if pts.len() == 2 => { + let r = Rect::from_two_pos(pts[0], pts[1]); + painter.add(egui::Shape::ellipse_stroke(r.center(), r.size() / 2.0, stroke)); + } + _ => {} + } + } +} + +/// The shape a finished drawing gesture makes (`None` if it is too small to mean anything). +fn drawn_shape(tool: CommentTool, points: &[[f64; 2]]) -> Option { + let (first, last) = (*points.first()?, *points.last()?); + let far = (first[0] - last[0]).hypot(first[1] - last[1]) >= 2.0; + let rect = [first[0].min(last[0]), first[1].min(last[1]), first[0].max(last[0]), first[1].max(last[1])]; + let big = rect[2] - rect[0] >= 2.0 && rect[3] - rect[1] >= 2.0; + match tool { + CommentTool::Ink if points.len() >= 2 => Some(Shape::Ink { strokes: vec![points.to_vec()] }), + CommentTool::Line | CommentTool::Arrow if far => Some(Shape::Line { from: first, to: last, arrow: tool == CommentTool::Arrow }), + CommentTool::Rectangle if big => Some(Shape::Rectangle { rect }), + CommentTool::Oval if big => Some(Shape::Oval { rect }), + _ => None, + } +} + +fn new_comment(cx: &PageCx<'_>, tool: CommentTool, shape: Shape, contents: String) -> Edit { + Edit::AddAnnotation(NewAnnotation { page: cx.page, shape, style: cx.prefs.style(tool), contents, author: cx.prefs.author.clone() }) +} + +/// The floating composer card (new note, new text box, edit). Returns the edit to apply. +pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs) -> Option { + let c = view.comments.composer.as_ref()?; + let page = c.page; + let Some(xf) = view.page_xform(page) else { + // Scrolled away: keep the draft until the page is back. + return None; + }; + let cx = PageCx { page, xf: &xf, info, tool: QuickTool::Select, prefs, allowed: true }; + let anchor = cx.to_screen(c.at); + let t = Tokens::get(ctx); + let mut post = false; + let mut cancel = false; + let c = view.comments.composer.as_mut().expect("checked"); + let title = match c.kind { + ComposerKind::Note => "Sticky note", + ComposerKind::TextBox => "Text box", + ComposerKind::Edit(_) => "Edit comment", + }; + let pos = pos2(anchor.x + 12.0, anchor.y); + egui::Area::new(egui::Id::new(("comment-composer", view.id.0))).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { + egui::Frame::popup(ui.style()).inner_margin(egui::Margin::same(12)).corner_radius(CornerRadius::same(8)).show(ui, |ui| { + ui.set_width(260.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(&prefs.author).font(theme::semibold(13.0))); + ui.label(egui::RichText::new(title).font(theme::regular(11.5)).color(t.text_faint)); + }); + ui.add_space(6.0); + let hint = if c.kind == ComposerKind::TextBox { "Type text" } else { "Add a comment" }; + let edit = + ui.add(egui::TextEdit::multiline(&mut c.text).hint_text(hint).desired_rows(3).desired_width(f32::INFINITY).id_salt("composer-text")); + if c.focus { + edit.request_focus(); + c.focus = false; + } + let enter = ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command); + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + cancel = true; + } + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let can_post = !c.text.trim().is_empty() || matches!(c.kind, ComposerKind::Edit(_)); + if ui + .add_enabled(can_post, egui::Button::new(egui::RichText::new("Post").color(Color32::WHITE)).fill(t.accent).corner_radius(14)) + .clicked() + || (enter && can_post) + { + post = true; + } + if ui.add(egui::Button::new("Cancel").corner_radius(14)).clicked() { + cancel = true; + } + }); + }); + }); + }); + if cancel { + view.comments.composer = None; + return None; + } + if !post { + return None; + } + let c = view.comments.composer.take()?; + let text = c.text.trim_end().to_string(); + match c.kind { + ComposerKind::Edit(index) => Some(Edit::SetAnnotationContents { page, index, text }), + ComposerKind::Note => { + view.comments.tool_done = true; + Some(new_comment(&cx, CommentTool::Note, Shape::Note { at: c.at, icon: NoteIcon::Comment }, text)) + } + ComposerKind::TextBox => { + view.comments.tool_done = true; + Some(new_comment(&cx, CommentTool::TextBox, Shape::TextBox { rect: text_box_rect(c.at, &text, 12.0), font_size: 12.0 }, text)) + } + } +} + +/// A text box sized to its text (at most 300 pt wide), hanging from its top-left corner. +pub fn text_box_rect(at: [f64; 2], text: &str, size: f64) -> [f64; 4] { + use printcraft_engine::annot_text::{text_width, wrap}; + let pad = 2.0; + let longest = text.lines().map(|l| text_width(l, size)).fold(0.0, f64::max); + let w = (longest + 2.0 * pad + 4.0).clamp(40.0, 300.0); + let lines = wrap(text, size, w - 2.0 * pad).len().max(1); + let h = lines as f64 * size * 1.2 + 2.0 * pad + 2.0; + [at[0], at[1] - h, at[0] + w, at[1]] +} + +/// Delete / Escape handling for comments (only while no text field has focus). +pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool, allowed: bool) { + if ctx.egui_wants_keyboard_input() { + return; + } + let cv = &mut view.comments; + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + // One step back per press: cancel the gesture, then deselect, then drop the tool. + let cancelled = cv.gesture.take().is_some() || cv.selected.take().is_some(); + if !cancelled && matches!(tool, QuickTool::Comment(_)) { + *tool = QuickTool::Select; + } + } + if allowed + && let Some((page, index)) = cv.selected + && ctx.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) + { + cv.selected = None; + view.pending_edit = Some(Edit::DeleteAnnotation { page, index }); + } +} + +/// Items of the canvas context menu for the selected comment (or the page). +pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs, allowed: bool) -> Option { + let mut action = None; + let selected = view.comments.selected.and_then(|(p, i)| info.annotations.iter().find(|a| a.page == p && a.index == i && a.in_reply_to.is_none())); + match selected { + Some(a) => { + let (page, index) = (a.page, a.index); + if ui.add_enabled(allowed, egui::Button::new("Edit text…")).clicked() { + view.comments.composer = Some(Composer { + page, + at: [a.rect[2] as f64, a.rect[3] as f64], + kind: ComposerKind::Edit(index), + text: a.contents.clone().unwrap_or_default(), + focus: true, + }); + ui.close(); + } + if ui.add_enabled(allowed, egui::Button::new("Reply")).clicked() { + view.comments.reveal = true; + action = Some(CanvasAction::OpenComments); + ui.close(); + } + ui.add_enabled_ui(allowed, |ui| { + ui.menu_button("Set status", |ui| { + for s in [ReviewState::None, ReviewState::Accepted, ReviewState::Cancelled, ReviewState::Completed, ReviewState::Rejected] { + if ui.button(s.name()).clicked() { + action = Some(CanvasAction::Edit(Edit::SetAnnotationStatus { page, index, state: s, author: prefs.author.clone() })); + ui.close(); + } + } + }); + ui.menu_button("Colour", |ui| { + if let Some(c) = swatch_grid(ui, a.color.map(|c| c.map(f64::from))) { + action = Some(CanvasAction::Edit(Edit::StyleAnnotation { page, index, color: Some(c), opacity: None, width: None })); + ui.close(); + } + }); + }); + ui.separator(); + if ui.add_enabled(allowed, egui::Button::new("Delete")).clicked() { + view.comments.selected = None; + action = Some(CanvasAction::Edit(Edit::DeleteAnnotation { page, index })); + ui.close(); + } + } + None => { + if let Some((page, at)) = view.comments.context_at + && ui.add_enabled(allowed, egui::Button::new("Add a sticky note here")).clicked() + { + view.comments.composer = Some(Composer { page, at, kind: ComposerKind::Note, text: String::new(), focus: true }); + ui.close(); + } + if ui.button("Comments panel").clicked() { + action = Some(CanvasAction::OpenComments); + ui.close(); + } + } + } + action +} + +/// What the canvas asks the app to do. +#[derive(Clone, Debug, PartialEq)] +pub enum CanvasAction { + Edit(Edit), + OpenComments, +} + +/// A grid of colour swatches; returns the one clicked. +pub fn swatch_grid(ui: &mut egui::Ui, current: Option) -> Option { + let mut picked = None; + egui::Grid::new(ui.id().with("swatches")).spacing(vec2(6.0, 6.0)).show(ui, |ui| { + for (i, (name, c)) in SWATCHES.iter().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + let on = current.is_some_and(|cur| cur.iter().zip(c).all(|(a, b)| (a - b).abs() < 0.02)); + ui.painter().circle_filled(r.center(), 9.0, color32(*c)); + ui.painter().circle_stroke(r.center(), 9.0, Stroke::new(1.0, Color32::from_black_alpha(40))); + if on || resp.hovered() { + ui.painter().circle_stroke(r.center(), 11.0, Stroke::new(1.5, SELECT_BLUE)); + } + if resp.on_hover_text(*name).clicked() { + picked = Some(*c); + } + if i % 5 == 4 { + ui.end_row(); + } + } + }); + picked +} + +/// The comment tools' extra quick-bar controls: pin, colour and thickness. +pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &mut CommentPrefs) { + let style = prefs.style(tool); + if icons::button(ui, "pin", 32.0, prefs.pinned, if prefs.pinned { "Keep tool selected: on" } else { "Keep tool selected" }).clicked() { + prefs.pinned = !prefs.pinned; + } + let (r, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::click()); + ui.painter().circle_filled(r.center(), 9.0, color32(style.color)); + ui.painter().circle_stroke(r.center(), 9.0, Stroke::new(1.0, Color32::from_black_alpha(50))); + let resp = resp.on_hover_text("Colour"); + egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).show(|ui| { + if let Some(c) = swatch_grid(ui, Some(style.color)) { + prefs.set_color(tool, c); + ui.close(); + } + }); + if tool.has_width() { + let resp = icons::button(ui, "sliders-horizontal", 32.0, false, "Line thickness"); + egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + ui.set_min_width(180.0); + let mut w = style.width; + ui.label(egui::RichText::new("Line thickness").font(theme::semibold(12.0))); + if ui.add(egui::Slider::new(&mut w, 0.5..=12.0).step_by(0.5).suffix(" pt")).changed() { + prefs.set_width(tool, w); + } + }); + } +} + +/// Status badge icon and label for a review state name. +pub fn status_badge(state: &str) -> Option<(&'static str, &'static str, Color32)> { + match state { + "Accepted" => Some(("thumbs-up", "Accepted", Color32::from_rgb(0x2D, 0x9D, 0x5B))), + "Rejected" => Some(("thumbs-down", "Rejected", Color32::from_rgb(0xD3, 0x2F, 0x2F))), + "Cancelled" => Some(("circle-x", "Cancelled", Color32::from_rgb(0x8E, 0x8E, 0x8E))), + "Completed" => Some(("check", "Completed", Color32::from_rgb(0x2D, 0x9D, 0x5B))), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tools_round_trip_through_their_commands() { + for t in ALL { + assert_eq!(CommentTool::from_command(t.command()), Some(t)); + assert!(printcraft_engine::commands::command(t.command()).is_some(), "{} is registered", t.command()); + assert!(icons::exists(t.icon()), "icon {}", t.icon()); + assert!(GROUPS[t.group()].contains(&t)); + } + } + + #[test] + fn drawn_shapes_ignore_slips() { + assert_eq!(drawn_shape(CommentTool::Rectangle, &[[0.0, 0.0], [1.0, 1.0]]), None); + assert_eq!(drawn_shape(CommentTool::Rectangle, &[[10.0, 0.0], [0.0, 10.0]]), Some(Shape::Rectangle { rect: [0.0, 0.0, 10.0, 10.0] })); + assert_eq!(drawn_shape(CommentTool::Ink, &[[0.0, 0.0]]), None); + assert!(matches!(drawn_shape(CommentTool::Arrow, &[[0.0, 0.0], [5.0, 0.0]]), Some(Shape::Line { arrow: true, .. }))); + } + + #[test] + fn text_boxes_fit_their_text() { + let r = text_box_rect([100.0, 500.0], "Hi", 12.0); + assert_eq!((r[0], r[3]), (100.0, 500.0)); + assert!(r[2] - r[0] < 60.0); + let long = text_box_rect([0.0, 500.0], &"word ".repeat(80), 12.0); + assert_eq!(long[2], 300.0); + assert!(long[3] - long[1] > 50.0); + } +} diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs new file mode 100644 index 00000000..d80c0a88 --- /dev/null +++ b/crates/ui-egui/src/comments_panel.rs @@ -0,0 +1,363 @@ +//! The Comments panel (Acrobat's right-hand comment list; 11-visual-spec §3, audit `a15-*`). +//! +//! Header "Comments N" with search; an "Add a comment" box that posts a sticky note on the +//! current page; cards grouped by page. Selecting a card selects the comment on the page and +//! opens its reply box and "…" menu (Edit, Set status, Delete). Status replies show as a badge. + +use egui::{Align, Color32, CornerRadius, Layout, Sense, Stroke, vec2}; +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::icons; +use crate::panels::Nav; +use crate::theme::{self, Tokens}; + +/// Accent bar of the selected card (11-visual-spec §3: `#0165DD`). +const CARD_ACCENT: Color32 = Color32::from_rgb(0x01, 0x65, 0xDD); + +/// Reader-friendly names for annotation subtypes (the PDF names are shown in tooltips). +pub fn subtype_label(s: &str) -> &str { + match s { + "Text" => "Note", + "FreeText" => "Text box", + "StrikeOut" => "Strikethrough", + "Square" => "Rectangle", + "Circle" => "Oval", + "Ink" => "Drawing", + "PolyLine" => "Polyline", + "FileAttachment" => "Attachment", + "Caret" => "Insert text", + other => other, + } +} + +pub fn subtype_icon(s: &str) -> &'static str { + match s { + "Text" => "message-square-text", + "Highlight" => "highlighter", + "Underline" => "underline", + "StrikeOut" => "strikethrough", + "Squiggly" => "spline", + "FreeText" => "type", + "Ink" => "pencil", + "Square" => "square", + "Circle" => "circle", + "Line" => "move-right", + "Polygon" | "PolyLine" => "pen-tool", + "Stamp" => "stamp", + "FileAttachment" => "paperclip", + "Caret" => "text-select", + "Redact" => "rectangle-horizontal", + _ => "message-square-text", + } +} + +fn initials(name: &str) -> String { + let s: String = name.split_whitespace().filter_map(|w| w.chars().next()).take(2).collect(); + if s.is_empty() { "?".into() } else { s.to_uppercase() } +} + +/// The number shown in the header: comments and their replies (not status changes). +pub fn count(info: &DocInfo) -> usize { + info.annotations.iter().filter(|a| a.state.is_none()).count() +} + +fn matches(a: &Annotation, q: &str) -> bool { + let q = q.to_lowercase(); + [a.author.as_deref(), a.contents.as_deref(), Some(subtype_label(&a.subtype))].into_iter().flatten().any(|s| s.to_lowercase().contains(&q)) +} + +/// Draw the panel body. Returns an edit to apply (posting, replying, deleting…). +pub(crate) fn show( + ui: &mut egui::Ui, + t: &Tokens, + info: &DocInfo, + view: &mut DocView, + prefs: &CommentPrefs, + allowed: bool, + nav: &mut Option