M5.3/M5.4: commenting UI: quick-bar Comment/Highlight/Draw flyout groups with pin, colour and thickness; sticky note and text box composer; highlight/underline/strikethrough from text selection; freehand, line, arrow, rectangle and oval gestures with live previews; Select tool selects (per-line frames for markup), moves, resizes with 8 handles, edits and deletes comments; canvas context menu (edit, reply, set status, colour, delete); Comments panel rewrite (add-a-comment box, search, selectable cards with reply box, ... menu, inline edit, status badges); comment edits re-render only their page and keep stale rasters on screen; control channel ui.drag, secondary click, pages_on_screen and comment state; view options quick/author/comment; 7 Lucide icons; parity entries; README

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 21:38:07 -04:00
parent 4058c156f7
commit 54e288c5fe
26 changed files with 2226 additions and 291 deletions

View File

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

View File

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

View File

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

View File

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

17
assets/icons/circle-x.svg Normal file
View File

@ -0,0 +1,17 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<svg
class="lucide lucide-circle-x"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="m15 9-6 6" />
<path d="m9 9 6 6" />
</svg>

After

Width:  |  Height:  |  Size: 372 B

View File

@ -0,0 +1,17 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<svg
class="lucide lucide-message-square-plus"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z" />
<path d="M12 8v6" />
<path d="M9 11h6" />
</svg>

After

Width:  |  Height:  |  Size: 475 B

View File

@ -0,0 +1,16 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<svg
class="lucide lucide-move-right"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 8L22 12L18 16" />
<path d="M2 12H22" />
</svg>

After

Width:  |  Height:  |  Size: 346 B

16
assets/icons/pin.svg Normal file
View File

@ -0,0 +1,16 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<svg
class="lucide lucide-pin"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 17v5" />
<path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z" />
</svg>

After

Width:  |  Height:  |  Size: 524 B

16
assets/icons/reply.svg Normal file
View File

@ -0,0 +1,16 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<svg
class="lucide lucide-reply"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 18v-2a4 4 0 0 0-4-4H4" />
<path d="m9 17-5-5 5-5" />
</svg>

After

Width:  |  Height:  |  Size: 354 B

View File

@ -0,0 +1,16 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<svg
class="lucide lucide-thumbs-down"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z" />
<path d="M17 14V2" />
</svg>

After

Width:  |  Height:  |  Size: 481 B

View File

@ -0,0 +1,16 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<svg
class="lucide lucide-thumbs-up"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" />
<path d="M7 10v12" />
</svg>

After

Width:  |  Height:  |  Size: 480 B

View File

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

View File

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

View File

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

View File

@ -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<usize, f64>,
thumbs: HashMap<usize, TextureHandle>,
/// Thumbnails that are out of date (still shown until their replacement arrives).
stale_thumbs: HashSet<usize>,
/// 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<usize>,
/// An edit requested by the view (organize toolbar, keys), applied by the app this frame.
pub pending_edit: Option<Edit>,
/// 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<ViewAction>,
}
@ -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<PageXform> {
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<LinkTarget> = None;
let mut canvas_action: Option<comments::CanvasAction> = 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<RenderRequest> = (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 {

View File

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

View File

@ -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<Self> {
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<Markup> {
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<Gesture>,
pub composer: Option<Composer>,
/// 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<String>,
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<Item = &Annotation> {
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<Rect> {
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<Pos2>,
origin: Option<Pos2>,
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<Pos2> = 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<Shape> {
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<Edit> {
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<CanvasAction> {
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<Rgb>) -> Option<Rgb> {
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);
}
}

View File

@ -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<Nav>,
) -> Option<Edit> {
let mut edit = None;
// Search (the header's magnifier toggles it).
if let Some(q) = view.comments.search.as_mut() {
let r = ui.add(egui::TextEdit::singleline(q).hint_text("Search comments").desired_width(f32::INFINITY).id_salt("comment-search"));
if view.comments.search_focus {
r.request_focus();
view.comments.search_focus = false;
}
ui.add_space(6.0);
}
// "Add a comment": a sticky note on the current page, near its top-right corner.
if allowed {
let r = ui.add(
egui::TextEdit::singleline(&mut view.comments.add_box)
.hint_text("Add a comment")
.desired_width(f32::INFINITY)
.margin(egui::Margin::symmetric(8, 6))
.id_salt("comment-add-box"),
);
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !view.comments.add_box.trim().is_empty() {
let page = view.current.min(info.pages.len().saturating_sub(1));
if let Some(p) = info.pages.get(page) {
let at = p.view_to_user(p.width - 44.0, 24.0);
let text = std::mem::take(&mut view.comments.add_box);
edit = Some(Edit::AddAnnotation(printcraft_engine::NewAnnotation {
page,
shape: Shape::Note { at: [at[0] as f64, at[1] as f64], icon: NoteIcon::Comment },
style: prefs.style(CommentTool::Note),
contents: text.trim().to_string(),
author: prefs.author.clone(),
}));
}
}
ui.add_space(8.0);
}
if info.annotations.is_empty() {
ui.add_space(30.0);
ui.vertical_centered(|ui| {
ui.add(icons::image("message-square-text", 36.0, t.text_faint));
ui.add_space(8.0);
ui.label(egui::RichText::new("No comments yet.").color(t.text_muted));
if allowed {
ui.label(egui::RichText::new("Pick a tool in the quick bar, or type above.").color(t.text_faint).small());
}
});
return edit;
}
let query = view.comments.search.clone().filter(|q| !q.trim().is_empty());
let replies_of = |a: &Annotation| -> Vec<&Annotation> {
match &a.name {
Some(nm) => info.annotations.iter().filter(|r| r.in_reply_to.as_deref() == Some(nm.as_str())).collect(),
None => Vec::new(),
}
};
let 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))))
.collect();
if roots.is_empty() {
ui.label(egui::RichText::new("No comments match.").color(t.text_muted));
}
let mut page = usize::MAX;
for a in roots {
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);
ui.horizontal(|ui| {
ui.add(icons::image("chevron-down", 14.0, t.text_muted));
ui.label(egui::RichText::new(format!("Page {}", info.pages[page].label)).font(theme::semibold(12.5)).color(t.text_muted));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| ui.label(egui::RichText::new(n.to_string()).color(t.text_faint).small()));
});
ui.add_space(2.0);
}
let thread = replies_of(a);
if let Some(e) = card(ui, t, a, &thread, view, prefs, allowed, nav) {
edit = Some(e);
}
}
edit
}
#[allow(clippy::too_many_arguments)]
fn card(
ui: &mut egui::Ui,
t: &Tokens,
a: &Annotation,
thread: &[&Annotation],
view: &mut DocView,
prefs: &CommentPrefs,
allowed: bool,
nav: &mut Option<Nav>,
) -> Option<Edit> {
let mut edit = None;
let key = (a.page, a.index);
let selected = view.comments.selected == Some(key);
let color = a.color.map(|c| color32(c.map(f64::from))).unwrap_or(t.accent);
let status = thread.iter().rev().find_map(|r| r.state.as_deref());
let replies: Vec<&&Annotation> = thread.iter().filter(|r| r.state.is_none()).collect();
let editing = view.comments.editing.as_ref().is_some_and(|(p, i, _)| (*p, *i) == key);
let fill = if selected { t.hover.gamma_multiply(0.7) } else { Color32::TRANSPARENT };
let mut open_menu = false;
let frame = egui::Frame::NONE
.fill(fill)
.corner_radius(CornerRadius::same(6))
.inner_margin(egui::Margin { left: 10, right: 8, top: 10, bottom: 10 })
.show(ui, |ui| {
ui.set_width(ui.available_width());
let head = ui.horizontal(|ui| {
avatar(ui, t, Some(subtype_icon(&a.subtype)), None, color);
ui.label(egui::RichText::new(a.author.as_deref().unwrap_or("Unknown author")).font(theme::semibold(12.5)).color(t.text));
if let Some(m) = &a.modified {
ui.add(egui::Label::new(egui::RichText::new(m).font(theme::regular(11.0)).color(t.text_faint)).truncate());
}
if selected && allowed {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
open_menu = icons::button(ui, "ellipsis", 22.0, false, "More").clicked();
});
}
});
egui::Frame::NONE.inner_margin(egui::Margin { left: 34, right: 0, top: 4, bottom: 0 }).show(ui, |ui| {
ui.set_width(ui.available_width());
if editing {
let (_, _, text) = view.comments.editing.as_mut().expect("checked");
let r = ui.add(egui::TextEdit::multiline(text).desired_rows(2).desired_width(f32::INFINITY).id_salt(("comment-edit", key)));
if !r.has_focus() && !ui.memory(|m| m.focused().is_some()) {
r.request_focus();
}
ui.horizontal(|ui| {
if ui.add(egui::Button::new(egui::RichText::new("Save").color(Color32::WHITE)).fill(t.accent).corner_radius(12)).clicked() {
let (page, index, text) = view.comments.editing.take().expect("checked");
edit = Some(Edit::SetAnnotationContents { page, index, text });
}
if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
view.comments.editing = None;
}
});
} else {
let body = a.contents.as_deref().unwrap_or("");
let text = if body.is_empty() {
egui::RichText::new(subtype_label(&a.subtype)).italics().color(t.text_faint)
} else {
egui::RichText::new(body).color(t.text)
};
let mut label = egui::Label::new(text.font(theme::regular(13.0))).wrap();
if !selected {
label = label.truncate();
}
ui.add(label);
}
if let Some((icon, label, c)) = status.and_then(status_badge) {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.add(icons::image(icon, 13.0, c));
ui.label(egui::RichText::new(label).font(theme::medium(11.5)).color(c));
});
}
});
// Replies, joined to the parent by a thread line.
for r in &replies {
ui.add_space(8.0);
let resp = ui.horizontal(|ui| {
avatar(ui, t, None, Some(&initials(r.author.as_deref().unwrap_or("?"))), t.text_faint);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 2.0;
ui.horizontal(|ui| {
ui.label(egui::RichText::new(r.author.as_deref().unwrap_or("Reply")).font(theme::semibold(12.0)));
if let Some(m) = &r.modified {
ui.add(egui::Label::new(egui::RichText::new(m).font(theme::regular(11.0)).color(t.text_faint)).truncate());
}
});
ui.add(
egui::Label::new(egui::RichText::new(r.contents.as_deref().unwrap_or("")).font(theme::regular(12.5)).color(t.text))
.wrap(),
);
});
});
let x = resp.response.rect.left() + 12.0;
ui.painter().vline(x, (resp.response.rect.top() - 14.0)..=(resp.response.rect.top() - 2.0), Stroke::new(1.5, t.border));
}
if selected && allowed {
ui.add_space(8.0);
egui::Frame::NONE.inner_margin(egui::Margin { left: 34, right: 0, top: 0, bottom: 0 }).show(ui, |ui| {
ui.horizontal(|ui| {
let r = ui.add(
egui::TextEdit::singleline(&mut view.comments.reply)
.hint_text("Add a reply")
.desired_width(ui.available_width() - 56.0)
.id_salt(("comment-reply", key)),
);
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let ok = !view.comments.reply.trim().is_empty();
if (ui.add_enabled(ok, egui::Button::new("Post").corner_radius(12)).clicked() || enter) && ok {
let text = std::mem::take(&mut view.comments.reply).trim().to_string();
edit = Some(Edit::ReplyToAnnotation { page: a.page, index: a.index, text, author: prefs.author.clone() });
}
});
});
}
head.response.rect
});
let rect = frame.response.rect;
if selected {
let bar = egui::Rect::from_min_size(rect.min, vec2(4.0, rect.height()));
ui.painter().rect_filled(bar, CornerRadius { nw: 6, sw: 6, ne: 0, se: 0 }, CARD_ACCENT);
if std::mem::take(&mut view.comments.reveal) {
frame.response.scroll_to_me(Some(Align::Center));
}
}
ui.painter().hline(rect.x_range().shrink(10.0), rect.bottom() + 2.0, Stroke::new(1.0, t.divider));
ui.add_space(5.0);
// The header row selects the card (and shows the comment on the page); unselected cards are
// clickable everywhere.
let hit = if selected { frame.inner } else { rect };
let click = ui
.interact(hit, ui.id().with(("card", key)), Sense::click())
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(format!("{} — click to show it on the page", subtype_label(&a.subtype)));
if click.clicked() {
view.comments.selected = Some(key);
view.comments.reply.clear();
*nav = Some(Nav::Flash(a.page, a.rect));
}
if click.double_clicked() && allowed {
view.comments.editing = Some((a.page, a.index, a.contents.clone().unwrap_or_default()));
}
let menu_id = ui.id().with(("card-menu", key));
if open_menu {
egui::Popup::open_id(ui.ctx(), menu_id);
}
egui::Popup::new(menu_id, ui.ctx().clone(), egui::PopupAnchor::Pointer, ui.layer_id())
.open_memory(None)
.kind(egui::PopupKind::Menu)
.layout(Layout::top_down_justified(Align::Min))
.show(|ui| {
ui.set_min_width(160.0);
if ui.button("Edit").clicked() {
view.comments.editing = Some((a.page, a.index, a.contents.clone().unwrap_or_default()));
ui.close();
}
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() {
edit = Some(Edit::SetAnnotationStatus { page: a.page, index: a.index, state: s, author: prefs.author.clone() });
ui.close();
}
}
});
ui.separator();
if ui.button("Delete").clicked() {
view.comments.selected = None;
edit = Some(Edit::DeleteAnnotation { page: a.page, index: a.index });
ui.close();
}
});
edit
}
/// A 26 pt round badge: a type glyph (comments) or initials (replies).
fn avatar(ui: &mut egui::Ui, t: &Tokens, icon: Option<&str>, text: Option<&str>, color: Color32) {
let (r, _) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::hover());
let bg = if t.dark() { Color32::from_gray(0x55) } else { Color32::from_gray(0xD8) };
ui.painter().circle_filled(r.center(), 12.0, bg);
if let Some(i) = icon {
icons::paint(ui, r, i, 13.0, legible(color, t));
}
if let Some(s) = text {
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, s, theme::semibold(10.5), t.text);
}
}
/// Darken very light annotation colours (e.g. note yellow) so their glyph stays legible.
fn legible(c: Color32, t: &Tokens) -> Color32 {
let lum = 0.2126 * c.r() as f32 + 0.7152 * c.g() as f32 + 0.0722 * c.b() as f32;
if !t.dark() && lum > 150.0 {
Color32::from_rgb((c.r() as f32 * 0.55) as u8, (c.g() as f32 * 0.55) as u8, (c.b() as f32 * 0.55) as u8)
} else {
c
}
}

View File

@ -16,7 +16,10 @@
//! notice.
//! - `ui.inspect {query?, role?, limit?}`: widgets in tree order with `id` (a string), `role`, `label`,
//! `value`, `rect` (points), `enabled`, `toggled`, `selected`, `clickable`, `depth`.
//! - `ui.click {id}` | `{label}` | `{x, y}`: click a widget (by its AccessKit action) or a point.
//! - `ui.click {id}` | `{label}` | `{x, y, button?}`: click a widget (by its AccessKit action) or a
//! point (`button`: primary or secondary, for context menus).
//! - `ui.drag {from: [x, y], to: [x, y], steps?, modifiers?}`: press, move and release (drawing
//! comments, selecting text, moving comments). `ui.state` reports `pages_on_screen` to aim at.
//! - `ui.type {text}`, `ui.key {key, modifiers?}`: keyboard input to the focused widget / app.
//! - `ui.command {id}`: run a registry command (as the menu would). `ui.commands` lists them.
//! - `ui.set {key, value}`: the view options of the command line (`--page`, `--zoom`, …).
@ -271,6 +274,7 @@ impl Control {
"ui.open" => Ok(Handled::Now(host.open(str_param("path")?))),
"ui.inspect" => Ok(Handled::Now(Ok(self.inspect(p)))),
"ui.click" => self.click(p),
"ui.drag" => self.drag(p),
"ui.type" => {
let text = str_param("text")?.to_string();
Ok(Handled::AfterFrames(self.inject(vec![vec![egui::Event::Text(text)]]), json!({ "typed": true })))
@ -327,11 +331,36 @@ impl Control {
json!({ "widgets": out, "count": total, "truncated": total > out.len() })
}
/// Press at `from`, move to `to` in `steps` frames, release (drawing, selecting text, moving).
fn drag(&mut self, p: &Value) -> Result<Handled, String> {
let point = |k: &str| -> Result<egui::Pos2, String> {
match p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect::<Vec<_>>()).as_deref() {
Some([x, y]) => Ok(egui::pos2(*x as f32, *y as f32)),
_ => Err(format!("ui.drag: {k} must be [x, y] in points")),
}
};
let (from, to) = (point("from")?, point("to")?);
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200) as usize;
let modifiers = modifiers(p.get("modifiers"))?;
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers };
let mut frames = vec![vec![egui::Event::PointerMoved(from)], vec![button(from, true)]];
for k in 1..=steps {
frames.push(vec![egui::Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32))]);
}
frames.push(vec![button(to, false)]);
let n = self.inject(frames);
Ok(Handled::AfterFrames(n, json!({ "dragged": [[from.x, from.y], [to.x, to.y]] })))
}
fn click(&mut self, p: &Value) -> Result<Handled, String> {
if let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
let pos = egui::pos2(x as f32, y as f32);
let button =
|pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
let which = match p.get("button").and_then(Value::as_str).unwrap_or("primary") {
"primary" | "left" => egui::PointerButton::Primary,
"secondary" | "right" => egui::PointerButton::Secondary,
other => return Err(format!("ui.click: unknown button {other:?} (primary, secondary)")),
};
let button = |pressed| egui::Event::PointerButton { pos, button: which, pressed, modifiers: egui::Modifiers::NONE };
let frames = self.inject(vec![vec![egui::Event::PointerMoved(pos)], vec![button(true)], vec![button(false)]]);
return Ok(Handled::AfterFrames(frames, json!({ "clicked": [x, y] })));
}
@ -500,7 +529,16 @@ impl Host for crate::PrintCraftApp {
"organize": v.organize,
"find_open": v.find.is_some(),
"page_errors": v.page_errors().iter().map(|(p, e)| json!({ "page": p + 1, "error": e })).collect::<Vec<_>>(),
// Where pages are on screen (points), to aim ui.click / ui.drag at page content.
"pages_on_screen": v.visible_page_rects().iter().map(|(p, r)| json!({ "page": p + 1, "rect": [r.min.x, r.min.y, r.max.x, r.max.y] })).collect::<Vec<_>>(),
"selected_comment": v.comments.selected.map(|(p, i)| json!({ "page": p + 1, "index": i + 1 })),
"comment_composer_open": v.comments.composer.is_some(),
})),
"quick_tool": match self.quick_tool {
crate::QuickTool::Select => "select".to_string(),
crate::QuickTool::Hand => "hand".to_string(),
crate::QuickTool::Comment(t) => t.command().trim_start_matches("comment.").to_string(),
},
"home": self.active.is_none(),
"mode": format!("{:?}", self.mode),
"left_panel": if self.left_open { json!(format!("{:?}", self.left)) } else { Value::Null },

View File

@ -32,7 +32,11 @@ impl PrintCraftApp {
Ok(()) => {
let info = &self.session.get(id).expect("exists").info;
let view = &mut self.views[i];
view.document_changed(info);
match comment_page(&edit) {
// Comment edits change one page: keep every other raster.
Some(page) => view.page_changed(page),
None => view.document_changed(info),
}
// Keep the pages the user acted on selected, where they now are.
match edit {
Edit::MovePages { pages, to } => {
@ -42,8 +46,18 @@ impl PrintCraftApp {
}
Edit::InsertBlankPage { at, .. } => view.select_pages(&[at.min(info.pages.len() - 1)]),
Edit::DeletePages { .. } => view.select_pages(&[]),
Edit::AddAnnotation(a) => {
// Select the new comment (appended last among the page's comments).
let newest = info.annotations.iter().filter(|x| x.page == a.page && x.in_reply_to.is_none()).map(|x| x.index).max();
view.comments.selected = newest.map(|n| (a.page, n));
view.comments.reveal = true;
}
Edit::DeleteAnnotation { .. } => view.comments.selected = None,
_ => {}
}
if std::mem::take(&mut view.comments.tool_done) && !self.comment_prefs.pinned {
self.quick_tool = crate::QuickTool::Select;
}
true
}
Err(e) => {
@ -68,6 +82,7 @@ impl PrintCraftApp {
Ok(label) => {
let info = &self.session.get(id).expect("exists").info;
self.views[i].document_changed(info);
self.views[i].comments.selected = None;
self.notify(format!("{} {label}", if undo { "Undid" } else { "Redid" }));
}
Err(e) => self.notify(e.to_string()),
@ -230,6 +245,21 @@ impl PrintCraftApp {
}
}
/// The page a comment edit changes (`None` for other edits).
fn comment_page(edit: &Edit) -> Option<usize> {
match edit {
Edit::AddAnnotation(a) => Some(a.page),
Edit::DeleteAnnotation { page, .. }
| Edit::SetAnnotationContents { page, .. }
| Edit::ReplyToAnnotation { page, .. }
| Edit::SetAnnotationStatus { page, .. }
| Edit::MoveAnnotation { page, .. }
| Edit::ResizeAnnotation { page, .. }
| Edit::StyleAnnotation { page, .. } => Some(*page),
_ => None,
}
}
/// Write via a temporary file in the same directory and rename over the target, so a crash or
/// full disk never leaves a half-written PDF where the original was.
pub fn write_atomically(path: &str, bytes: &[u8]) -> std::io::Result<()> {

View File

@ -17,40 +17,41 @@ pub static ICONS: &[(&str, &[u8])] = &[
("bookmark", include_bytes!("../../../assets/icons/bookmark.svg")),
("bookmark-plus", include_bytes!("../../../assets/icons/bookmark-plus.svg")),
("braces", include_bytes!("../../../assets/icons/braces.svg")),
("brush-cleaning", include_bytes!("../../../assets/icons/brush-cleaning.svg")),
("brush", include_bytes!("../../../assets/icons/brush.svg")),
("brush-cleaning", include_bytes!("../../../assets/icons/brush-cleaning.svg")),
("camera", include_bytes!("../../../assets/icons/camera.svg")),
("check-circle-2", include_bytes!("../../../assets/icons/check-circle-2.svg")),
("check", include_bytes!("../../../assets/icons/check.svg")),
("check-circle-2", include_bytes!("../../../assets/icons/check-circle-2.svg")),
("chevron-down", include_bytes!("../../../assets/icons/chevron-down.svg")),
("chevron-left", include_bytes!("../../../assets/icons/chevron-left.svg")),
("chevron-right", include_bytes!("../../../assets/icons/chevron-right.svg")),
("chevron-up", include_bytes!("../../../assets/icons/chevron-up.svg")),
("chevrons-left", include_bytes!("../../../assets/icons/chevrons-left.svg")),
("chevrons-right", include_bytes!("../../../assets/icons/chevrons-right.svg")),
("circle", include_bytes!("../../../assets/icons/circle.svg")),
("circle-check", include_bytes!("../../../assets/icons/circle-check.svg")),
("circle-dashed", include_bytes!("../../../assets/icons/circle-dashed.svg")),
("circle-dot", include_bytes!("../../../assets/icons/circle-dot.svg")),
("circle-help", include_bytes!("../../../assets/icons/circle-help.svg")),
("circle", include_bytes!("../../../assets/icons/circle.svg")),
("clock-3", include_bytes!("../../../assets/icons/clock-3.svg")),
("circle-x", include_bytes!("../../../assets/icons/circle-x.svg")),
("clock", include_bytes!("../../../assets/icons/clock.svg")),
("cloud-upload", include_bytes!("../../../assets/icons/cloud-upload.svg")),
("clock-3", include_bytes!("../../../assets/icons/clock-3.svg")),
("cloud", include_bytes!("../../../assets/icons/cloud.svg")),
("cloud-upload", include_bytes!("../../../assets/icons/cloud-upload.svg")),
("code-xml", include_bytes!("../../../assets/icons/code-xml.svg")),
("columns-2", include_bytes!("../../../assets/icons/columns-2.svg")),
("compass", include_bytes!("../../../assets/icons/compass.svg")),
("contrast", include_bytes!("../../../assets/icons/contrast.svg")),
("copy-plus", include_bytes!("../../../assets/icons/copy-plus.svg")),
("copy", include_bytes!("../../../assets/icons/copy.svg")),
("copy-plus", include_bytes!("../../../assets/icons/copy-plus.svg")),
("crop", include_bytes!("../../../assets/icons/crop.svg")),
("droplet", include_bytes!("../../../assets/icons/droplet.svg")),
("ellipsis-vertical", include_bytes!("../../../assets/icons/ellipsis-vertical.svg")),
("ellipsis", include_bytes!("../../../assets/icons/ellipsis.svg")),
("ellipsis-vertical", include_bytes!("../../../assets/icons/ellipsis-vertical.svg")),
("eraser", include_bytes!("../../../assets/icons/eraser.svg")),
("external-link", include_bytes!("../../../assets/icons/external-link.svg")),
("eye-off", include_bytes!("../../../assets/icons/eye-off.svg")),
("eye", include_bytes!("../../../assets/icons/eye.svg")),
("eye-off", include_bytes!("../../../assets/icons/eye-off.svg")),
("file-check", include_bytes!("../../../assets/icons/file-check.svg")),
("file-down", include_bytes!("../../../assets/icons/file-down.svg")),
("file-input", include_bytes!("../../../assets/icons/file-input.svg")),
@ -58,8 +59,8 @@ pub static ICONS: &[(&str, &[u8])] = &[
("file-minus", include_bytes!("../../../assets/icons/file-minus.svg")),
("file-output", include_bytes!("../../../assets/icons/file-output.svg")),
("file-pen-line", include_bytes!("../../../assets/icons/file-pen-line.svg")),
("file-plus-2", include_bytes!("../../../assets/icons/file-plus-2.svg")),
("file-plus", include_bytes!("../../../assets/icons/file-plus.svg")),
("file-plus-2", include_bytes!("../../../assets/icons/file-plus-2.svg")),
("file-scan", include_bytes!("../../../assets/icons/file-scan.svg")),
("file-search", include_bytes!("../../../assets/icons/file-search.svg")),
("file-symlink", include_bytes!("../../../assets/icons/file-symlink.svg")),
@ -68,9 +69,9 @@ pub static ICONS: &[(&str, &[u8])] = &[
("files", include_bytes!("../../../assets/icons/files.svg")),
("film", include_bytes!("../../../assets/icons/film.svg")),
("filter", include_bytes!("../../../assets/icons/filter.svg")),
("folder", include_bytes!("../../../assets/icons/folder.svg")),
("folder-open", include_bytes!("../../../assets/icons/folder-open.svg")),
("folder-plus", include_bytes!("../../../assets/icons/folder-plus.svg")),
("folder", include_bytes!("../../../assets/icons/folder.svg")),
("gauge", include_bytes!("../../../assets/icons/gauge.svg")),
("git-compare", include_bytes!("../../../assets/icons/git-compare.svg")),
("globe", include_bytes!("../../../assets/icons/globe.svg")),
@ -80,26 +81,27 @@ pub static ICONS: &[(&str, &[u8])] = &[
("heading", include_bytes!("../../../assets/icons/heading.svg")),
("highlighter", include_bytes!("../../../assets/icons/highlighter.svg")),
("house", include_bytes!("../../../assets/icons/house.svg")),
("image-plus", include_bytes!("../../../assets/icons/image-plus.svg")),
("image", include_bytes!("../../../assets/icons/image.svg")),
("image-plus", include_bytes!("../../../assets/icons/image-plus.svg")),
("info", include_bytes!("../../../assets/icons/info.svg")),
("italic", include_bytes!("../../../assets/icons/italic.svg")),
("languages", include_bytes!("../../../assets/icons/languages.svg")),
("lasso", include_bytes!("../../../assets/icons/lasso.svg")),
("layers", include_bytes!("../../../assets/icons/layers.svg")),
("layout-grid", include_bytes!("../../../assets/icons/layout-grid.svg")),
("link-2", include_bytes!("../../../assets/icons/link-2.svg")),
("link", include_bytes!("../../../assets/icons/link.svg")),
("link-2", include_bytes!("../../../assets/icons/link-2.svg")),
("list", include_bytes!("../../../assets/icons/list.svg")),
("list-checks", include_bytes!("../../../assets/icons/list-checks.svg")),
("list-ordered", include_bytes!("../../../assets/icons/list-ordered.svg")),
("list", include_bytes!("../../../assets/icons/list.svg")),
("lock-open", include_bytes!("../../../assets/icons/lock-open.svg")),
("lock", include_bytes!("../../../assets/icons/lock.svg")),
("maximize-2", include_bytes!("../../../assets/icons/maximize-2.svg")),
("lock-open", include_bytes!("../../../assets/icons/lock-open.svg")),
("maximize", include_bytes!("../../../assets/icons/maximize.svg")),
("maximize-2", include_bytes!("../../../assets/icons/maximize-2.svg")),
("message-circle-reply", include_bytes!("../../../assets/icons/message-circle-reply.svg")),
("message-square-text", include_bytes!("../../../assets/icons/message-square-text.svg")),
("message-square", include_bytes!("../../../assets/icons/message-square.svg")),
("message-square-plus", include_bytes!("../../../assets/icons/message-square-plus.svg")),
("message-square-text", include_bytes!("../../../assets/icons/message-square-text.svg")),
("messages-square", include_bytes!("../../../assets/icons/messages-square.svg")),
("mic", include_bytes!("../../../assets/icons/mic.svg")),
("minimize", include_bytes!("../../../assets/icons/minimize.svg")),
@ -107,6 +109,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
("moon", include_bytes!("../../../assets/icons/moon.svg")),
("mouse-pointer-2", include_bytes!("../../../assets/icons/mouse-pointer-2.svg")),
("move", include_bytes!("../../../assets/icons/move.svg")),
("move-right", include_bytes!("../../../assets/icons/move-right.svg")),
("navigation", include_bytes!("../../../assets/icons/navigation.svg")),
("paint-bucket", include_bytes!("../../../assets/icons/paint-bucket.svg")),
("palette", include_bytes!("../../../assets/icons/palette.svg")),
@ -117,6 +120,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
("pen-line", include_bytes!("../../../assets/icons/pen-line.svg")),
("pen-tool", include_bytes!("../../../assets/icons/pen-tool.svg")),
("pencil", include_bytes!("../../../assets/icons/pencil.svg")),
("pin", include_bytes!("../../../assets/icons/pin.svg")),
("pipette", include_bytes!("../../../assets/icons/pipette.svg")),
("plus", include_bytes!("../../../assets/icons/plus.svg")),
("presentation", include_bytes!("../../../assets/icons/presentation.svg")),
@ -124,26 +128,27 @@ pub static ICONS: &[(&str, &[u8])] = &[
("rectangle-horizontal", include_bytes!("../../../assets/icons/rectangle-horizontal.svg")),
("redo-2", include_bytes!("../../../assets/icons/redo-2.svg")),
("replace", include_bytes!("../../../assets/icons/replace.svg")),
("reply", include_bytes!("../../../assets/icons/reply.svg")),
("rotate-ccw", include_bytes!("../../../assets/icons/rotate-ccw.svg")),
("rotate-cw", include_bytes!("../../../assets/icons/rotate-cw.svg")),
("ruler", include_bytes!("../../../assets/icons/ruler.svg")),
("save", include_bytes!("../../../assets/icons/save.svg")),
("scan-text", include_bytes!("../../../assets/icons/scan-text.svg")),
("scan", include_bytes!("../../../assets/icons/scan.svg")),
("scan-text", include_bytes!("../../../assets/icons/scan-text.svg")),
("scissors", include_bytes!("../../../assets/icons/scissors.svg")),
("search", include_bytes!("../../../assets/icons/search.svg")),
("send", include_bytes!("../../../assets/icons/send.svg")),
("settings-2", include_bytes!("../../../assets/icons/settings-2.svg")),
("settings", include_bytes!("../../../assets/icons/settings.svg")),
("settings-2", include_bytes!("../../../assets/icons/settings-2.svg")),
("share-2", include_bytes!("../../../assets/icons/share-2.svg")),
("shield-check", include_bytes!("../../../assets/icons/shield-check.svg")),
("signature", include_bytes!("../../../assets/icons/signature.svg")),
("sliders-horizontal", include_bytes!("../../../assets/icons/sliders-horizontal.svg")),
("sparkles", include_bytes!("../../../assets/icons/sparkles.svg")),
("spline", include_bytes!("../../../assets/icons/spline.svg")),
("square-dashed-mouse-pointer", include_bytes!("../../../assets/icons/square-dashed-mouse-pointer.svg")),
("square-dashed", include_bytes!("../../../assets/icons/square-dashed.svg")),
("square", include_bytes!("../../../assets/icons/square.svg")),
("square-dashed", include_bytes!("../../../assets/icons/square-dashed.svg")),
("square-dashed-mouse-pointer", include_bytes!("../../../assets/icons/square-dashed-mouse-pointer.svg")),
("squares-subtract", include_bytes!("../../../assets/icons/squares-subtract.svg")),
("stamp", include_bytes!("../../../assets/icons/stamp.svg")),
("star", include_bytes!("../../../assets/icons/star.svg")),
@ -154,8 +159,10 @@ pub static ICONS: &[(&str, &[u8])] = &[
("tag", include_bytes!("../../../assets/icons/tag.svg")),
("text-cursor-input", include_bytes!("../../../assets/icons/text-cursor-input.svg")),
("text-select", include_bytes!("../../../assets/icons/text-select.svg")),
("trash-2", include_bytes!("../../../assets/icons/trash-2.svg")),
("thumbs-down", include_bytes!("../../../assets/icons/thumbs-down.svg")),
("thumbs-up", include_bytes!("../../../assets/icons/thumbs-up.svg")),
("trash", include_bytes!("../../../assets/icons/trash.svg")),
("trash-2", include_bytes!("../../../assets/icons/trash-2.svg")),
("triangle-alert", include_bytes!("../../../assets/icons/triangle-alert.svg")),
("type", include_bytes!("../../../assets/icons/type.svg")),
("underline", include_bytes!("../../../assets/icons/underline.svg")),

View File

@ -8,6 +8,8 @@
mod canvas;
mod chrome;
mod commands;
pub mod comments;
mod comments_panel;
pub mod control;
mod dialogs;
mod editing;
@ -62,6 +64,8 @@ pub enum RightPanel {
pub enum QuickTool {
Select,
Hand,
/// A commenting tool (Add comments).
Comment(comments::CommentTool),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@ -113,6 +117,8 @@ pub struct PrintCraftApp {
pub left_open: bool,
pub right: Option<RightPanel>,
pub quick_tool: QuickTool,
/// Comment author, per-tool colours and widths, pin.
pub comment_prefs: comments::CommentPrefs,
pub theme: ThemeKind,
pub dialog: Option<Dialog>,
pub palette_open: bool,
@ -188,6 +194,7 @@ impl PrintCraftApp {
left_open: true,
right: None,
quick_tool: QuickTool::Select,
comment_prefs: Default::default(),
theme: ThemeKind::Light,
dialog: None,
palette_open: false,
@ -565,7 +572,28 @@ impl PrintCraftApp {
v.select_pages(&pages.map_err(|_| "select: comma-separated page numbers")?);
}
("notice", Some(v)) => v.notice_dismissed = value == "off",
(k, None) if ["page", "zoom", "layout", "organize", "fields", "find", "rotate", "select", "notice"].contains(&k) => {
("quick", _) => {
// `--quick select|hand|note|freetext|highlight|underline|strikeout|ink|line|arrow|square|circle`
self.quick_tool = match value {
"select" => QuickTool::Select,
"hand" => QuickTool::Hand,
other => {
let t = comments::CommentTool::from_command(&format!("comment.{other}")).ok_or_else(|| format!("unknown tool {other}"))?;
self.comment_prefs.group_tool[t.group()] = t;
QuickTool::Comment(t)
}
};
}
("author", _) => self.comment_prefs.author = value.to_string(),
("comment", Some(v)) => {
// `--comment 2:4` selects the 4th annotation of page 2 (1-based, as comment_list reports).
let (p, i) = value.split_once(':').ok_or("comment: PAGE:INDEX")?;
let (p, i): (usize, usize) =
(p.trim().parse().map_err(|_| "comment: PAGE:INDEX")?, i.trim().parse().map_err(|_| "comment: PAGE:INDEX")?);
v.comments.selected = Some((p.saturating_sub(1), i.saturating_sub(1)));
v.comments.reveal = true;
}
(k, None) if ["page", "zoom", "layout", "organize", "fields", "find", "rotate", "select", "notice", "comment"].contains(&k) => {
return Err(format!("`{k}` needs an open document"));
}
(other, _) => return Err(format!("unknown option {other}")),

View File

@ -3,7 +3,7 @@
use egui::{Align, Align2, Color32, CornerRadius, Layout, Rect, Sense, Stroke, pos2, vec2};
use printcraft_engine::catalog::{self, Availability, TOOL_GROUPS, ToolGroup};
use printcraft_render::{Annotation, DocInfo, FieldKind, OutlineItem};
use printcraft_render::{DocInfo, FieldKind, OutlineItem};
use crate::theme::{self, Tokens};
use crate::{LeftPanel, PrintCraftApp, RightPanel, icons, widgets};
@ -168,7 +168,7 @@ fn tool_detail(app: &mut PrintCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'stat
// ───────────────────────────────────────────────────────────────────────────── right panels
enum Nav {
pub(crate) enum Nav {
Page(usize),
Flash(usize, [f32; 4]),
}
@ -182,12 +182,15 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
let mut toggle_layer: Option<(usize, bool)> = None;
let mut attachment_action: Option<(usize, bool)> = None; // (index, open instead of save)
let mut bm_action: Option<BmAction> = None;
let mut panel_edit: Option<printcraft_engine::Edit> = None;
let mut bm_rename = app.bookmark_rename.clone();
let bm_editable = app.session.get(id).is_some_and(|d| d.allows_assembly() && d.read_only_reason.is_none());
{
let Some(doc) = app.session.get(id) else { return };
let info = &doc.info;
let view = &app.views[index];
let view = &mut app.views[index];
let prefs = &app.comment_prefs;
let comment_allowed = doc.allows_annotation();
egui::Panel::right("right_panel")
.resizable(true)
.default_size(330.0)
@ -200,7 +203,7 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
)
.show(ui, |ui| {
let (title, count) = match panel {
RightPanel::Comments => ("Comments", Some(info.annotations.len())),
RightPanel::Comments => ("Comments", Some(crate::comments_panel::count(info))),
RightPanel::Bookmarks => ("Bookmarks", None),
RightPanel::Pages => ("Pages", Some(info.pages.len())),
RightPanel::Fields => ("Fields", Some(info.fields.len())),
@ -216,6 +219,15 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
if icons::button(ui, "x", 26.0, false, "Close").clicked() {
close = true;
}
if panel == RightPanel::Comments
&& icons::button(ui, "search", 26.0, view.comments.search.is_some(), "Search comments").clicked()
{
view.comments.search = match view.comments.search {
Some(_) => None,
None => Some(String::new()),
};
view.comments.search_focus = true;
}
if panel == RightPanel::Bookmarks
&& bm_editable
&& icons::button(ui, "bookmark-plus", 26.0, false, "New bookmark (⌘B)").clicked()
@ -226,7 +238,11 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
});
ui.add_space(6.0);
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match panel {
RightPanel::Comments => comments(ui, &t, info, &mut nav),
RightPanel::Comments => {
if let Some(e) = crate::comments_panel::show(ui, &t, info, view, prefs, comment_allowed, &mut nav) {
panel_edit = Some(e);
}
}
RightPanel::Bookmarks => {
if info.outline.is_empty() {
empty(ui, &t, "bookmark", "This document has no bookmarks.");
@ -308,6 +324,9 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
if close {
app.right = None;
}
if let Some(e) = panel_edit {
app.apply_edit(e);
}
app.bookmark_rename = bm_rename;
if let Some(a) = bm_action {
app.bookmark_action(a);
@ -340,136 +359,6 @@ fn empty(ui: &mut egui::Ui, t: &Tokens, icon: &str, text: &str) {
});
}
fn subtype_icon(s: &str) -> &'static str {
match s {
"Text" => "sticky-note",
"Highlight" => "highlighter",
"Underline" => "underline",
"StrikeOut" => "strikethrough",
"Squiggly" => "spline",
"FreeText" => "type",
"Ink" => "pencil",
"Square" => "square",
"Circle" => "circle",
"Line" => "arrow-up-right",
"Polygon" | "PolyLine" => "pen-tool",
"Stamp" => "stamp",
"FileAttachment" => "paperclip",
"Caret" => "text-select",
"Redact" => "rectangle-horizontal",
_ => "message-square-text",
}
}
fn comments(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, nav: &mut Option<Nav>) {
if info.annotations.is_empty() {
empty(ui, t, "message-square-text", "No comments yet.");
return;
}
let mut page = usize::MAX;
let roots: Vec<&Annotation> = info.annotations.iter().filter(|a| a.in_reply_to.is_none()).collect();
for a in roots {
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);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(format!("Page {}", info.pages[page].label)).font(theme::semibold(12.5)).color(t.text_muted));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| ui.label(egui::RichText::new(n.to_string()).color(t.text_faint).small()));
});
}
let replies: Vec<&Annotation> = match &a.name {
Some(nm) => info.annotations.iter().filter(|r| r.in_reply_to.as_deref() == Some(nm.as_str())).collect(),
None => Vec::new(),
};
if comment_card(ui, t, a, &replies).clicked() {
*nav = Some(Nav::Flash(a.page, a.rect));
}
}
}
/// Reader-friendly names for annotation subtypes (the PDF names are shown in tooltips).
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,
}
}
/// Darken very light annotation colours (e.g. note yellow) so their icon stays legible.
fn legible(c: Color32, t: &Tokens) -> Color32 {
let lum = 0.2126 * c.r() as f32 + 0.7152 * c.g() as f32 + 0.0722 * c.b() as f32;
if !t.dark() && lum > 170.0 {
Color32::from_rgb((c.r() as f32 * 0.62) as u8, (c.g() as f32 * 0.62) as u8, (c.b() as f32 * 0.62) as u8)
} else {
c
}
}
fn comment_card(ui: &mut egui::Ui, t: &Tokens, a: &Annotation, replies: &[&Annotation]) -> egui::Response {
let color = a.color.map(|c| Color32::from_rgb((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8)).unwrap_or(t.accent);
let id = ui.id().with(("comment", a.page, a.rect[0].to_bits(), a.rect[1].to_bits()));
let hovered = ui.data(|d| d.get_temp::<bool>(id)).unwrap_or(false);
let resp = egui::Frame::NONE
.fill(if hovered { t.hover } else { Color32::TRANSPARENT })
.corner_radius(CornerRadius::same(8))
.inner_margin(egui::Margin { left: 8, right: 8, top: 10, bottom: 10 })
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::hover());
ui.painter().circle_filled(r.center(), 14.0, color.gamma_multiply(if t.dark() { 0.35 } else { 0.28 }));
icons::paint(ui, r, subtype_icon(&a.subtype), 14.0, legible(color, t));
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 1.0;
ui.add(
egui::Label::new(egui::RichText::new(a.author.as_deref().unwrap_or("Unknown author")).font(theme::semibold(13.0))).truncate(),
);
let meta = format!("{}{}", subtype_label(&a.subtype), a.modified.as_deref().map(|m| format!(" · {m}")).unwrap_or_default());
ui.add(egui::Label::new(egui::RichText::new(meta).color(t.text_faint).font(theme::regular(11.0))).truncate());
});
});
// Body and replies are indented under the avatar and must wrap to the panel width.
egui::Frame::NONE.inner_margin(egui::Margin { left: 36, right: 0, top: 4, bottom: 0 }).show(ui, |ui| {
ui.set_width(ui.available_width());
if let Some(c) = &a.contents {
ui.add(egui::Label::new(egui::RichText::new(c).color(t.text)).wrap());
}
for r in replies {
ui.add_space(6.0);
let resp = egui::Frame::NONE.inner_margin(egui::Margin { left: 10, right: 0, top: 2, bottom: 2 }).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 1.0;
ui.add(egui::Label::new(egui::RichText::new(r.author.as_deref().unwrap_or("Reply")).font(theme::semibold(12.0))).truncate());
if let Some(c) = &r.contents {
ui.add(egui::Label::new(egui::RichText::new(c).color(t.text_muted).font(theme::regular(12.0))).wrap());
}
});
let b = resp.response.rect;
ui.painter().vline(b.left() + 1.0, b.y_range(), Stroke::new(2.0, t.border));
}
});
})
.response;
let div_y = resp.rect.bottom() + 2.0;
ui.painter().hline(resp.rect.x_range().shrink(8.0), div_y, Stroke::new(1.0, t.divider));
ui.add_space(5.0);
let click = ui
.interact(resp.rect, id.with("click"), Sense::click())
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(format!("/{} annotation — click to show it on the page", a.subtype));
ui.data_mut(|d| d.insert_temp(id, click.hovered()));
click
}
/// What the user asked to do with a bookmark (applied after the panel is drawn).
#[derive(Clone, Debug, PartialEq)]
pub enum BmAction {

View File

@ -0,0 +1,217 @@
//! Commenting in the real shell (egui_kittest): tools, gestures, selection, the composer and
//! the Comments panel.
use egui::{Pos2, pos2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use printcraft_render::Annotation;
use printcraft_ui_egui::{PrintCraftApp, QuickTool};
/// Two 300×200 pages of Helvetica text.
const TEXT_FIXTURE: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 5 0 R /Resources << /Font << /F1 7 0 R >> >> >> endobj
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 6 0 R /Resources << /Font << /F1 7 0 R >> >> >> endobj
5 0 obj << /Length 55 >> stream
BT /F1 14 Tf 20 150 Td (The quick brown fox) Tj ET
endstream endobj
6 0 obj << /Length 58 >> stream
BT /F1 14 Tf 20 150 Td (A second brown animal) Tj ET
endstream endobj
7 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj
trailer << /Root 1 0 R >>
%%EOF";
fn harness(setup: impl FnOnce(&mut PrintCraftApp) + 'static) -> Harness<'static, PrintCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PrintCraftApp::new();
app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
app.set_option("left", "closed").unwrap();
app.set_option("author", "Tester").unwrap();
setup(&mut app);
app
});
h.run_steps(4);
settle(&mut h);
h
}
fn settle(h: &mut Harness<'static, PrintCraftApp>) {
for _ in 0..200 {
h.run_steps(2);
if !h.state().render_pending() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
/// A point on page 1 in PDF user space → screen (no rotation; MediaBox 300×200).
fn at(h: &Harness<'static, PrintCraftApp>, x: f32, y: f32) -> Pos2 {
let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen");
pos2(r.left() + x / 300.0 * r.width(), r.top() + (200.0 - y) / 200.0 * r.height())
}
fn drag(h: &mut Harness<'static, PrintCraftApp>, from: Pos2, to: Pos2) {
h.hover_at(from);
h.run_steps(1);
h.drag_at(from);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(from + (to - from) * (k as f32 / 4.0));
h.run_steps(1);
}
h.drop_at(to);
h.run_steps(3);
}
fn click(h: &mut Harness<'static, PrintCraftApp>, p: Pos2) {
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.drop_at(p);
h.run_steps(3);
}
/// Drag between two page points (user space).
fn drag_pt(h: &mut Harness<'static, PrintCraftApp>, a: (f32, f32), b: (f32, f32)) {
let (a, b) = (at(h, a.0, a.1), at(h, b.0, b.1));
drag(h, a, b);
}
/// Click a page point (user space).
fn click_pt(h: &mut Harness<'static, PrintCraftApp>, p: (f32, f32)) {
let p = at(h, p.0, p.1);
click(h, p);
}
/// A text field by its placeholder ("Add a comment", "Add a reply").
fn field<'a>(h: &'a Harness<'static, PrintCraftApp>, placeholder: &'a str) -> egui_kittest::Node<'a> {
h.query_all_by(|n| n.placeholder() == Some(placeholder)).next().unwrap_or_else(|| panic!("no field {placeholder:?}"))
}
fn comments(h: &Harness<'static, PrintCraftApp>) -> Vec<Annotation> {
let s = h.state();
s.session.get(s.views[0].id).unwrap().info.annotations.clone()
}
#[test]
fn dragging_over_text_with_the_highlighter_highlights_it() {
let mut h = harness(|app| app.set_option("quick", "highlight").unwrap());
let (a, b) = {
let v = &h.state().views[0];
(v.glyph_screen_pos(0, 4).expect("text layer"), v.glyph_screen_pos(0, 14).expect("glyph"))
};
drag(&mut h, a, b);
let c = comments(&h);
assert_eq!(c.len(), 1, "{c:?}");
assert_eq!((c[0].subtype.as_str(), c[0].author.as_deref(), c[0].quads.len()), ("Highlight", Some("Tester"), 1));
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the new comment is selected");
assert_eq!(h.state().quick_tool, QuickTool::Comment(printcraft_ui_egui::comments::CommentTool::Highlight), "the highlighter stays on");
// The Comments panel opened with the tool and lists it.
h.get_by_label_contains("Comments");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Add highlight"));
}
#[test]
fn a_selection_made_first_is_marked_when_a_tool_is_picked() {
let mut h = harness(|_| {});
h.state_mut().views[0].select_text(0, 4, 8);
assert!(h.state_mut().execute("comment.strikeout"));
h.run_steps(2);
let c = comments(&h);
assert_eq!(c.len(), 1);
assert_eq!(c[0].subtype, "StrikeOut");
assert!(h.state().views[0].selected_text().is_none(), "the selection is consumed");
}
#[test]
fn shapes_and_ink_are_drawn_by_dragging() {
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().set_option("quick", "ink").unwrap();
drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0));
h.state_mut().set_option("quick", "arrow").unwrap();
drag_pt(&mut h, (20.0, 20.0), (120.0, 30.0));
// A click with a drawing tool draws nothing.
h.state_mut().set_option("quick", "circle").unwrap();
click_pt(&mut h, (200.0, 180.0));
let c = comments(&h);
let mut c = c;
c.sort_by_key(|a| a.index);
let kinds: Vec<&str> = c.iter().map(|a| a.subtype.as_str()).collect();
assert_eq!(kinds, ["Square", "Ink", "Line"], "{c:?}");
let r = c[0].rect;
assert!((r[0] - 40.0).abs() < 3.0 && (r[1] - 40.0).abs() < 3.0 && (r[2] - 140.0).abs() < 3.0 && (r[3] - 100.0).abs() < 3.0, "{r:?}");
}
#[test]
fn sticky_note_composer_posts_and_returns_to_select() {
let mut h = harness(|app| app.set_option("quick", "note").unwrap());
click_pt(&mut h, (250.0, 180.0));
assert!(h.state().views[0].comments.composer.is_some(), "the composer opened");
field(&h, "Add a comment").type_text("Please check");
h.run_steps(2);
h.get_by_label("Post").click();
h.run_steps(3);
let c = comments(&h);
assert_eq!(c.len(), 1);
assert_eq!((c[0].subtype.as_str(), c[0].contents.as_deref()), ("Text", Some("Please check")));
assert!(h.state().views[0].comments.composer.is_none());
assert_eq!(h.state().quick_tool, QuickTool::Select, "one-shot tools return to Select");
// Cancelling another one creates nothing.
h.state_mut().set_option("quick", "freetext").unwrap();
click_pt(&mut h, (50.0, 60.0));
h.get_by_label("Cancel").click();
h.run_steps(2);
assert_eq!(comments(&h).len(), 1);
}
#[test]
fn comments_are_selected_moved_and_deleted_with_the_select_tool() {
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().set_option("quick", "select").unwrap();
// Click away, then on the rectangle's border area.
click_pt(&mut h, (250.0, 20.0));
assert_eq!(h.state().views[0].comments.selected, None);
click_pt(&mut h, (90.0, 70.0));
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)));
drag_pt(&mut h, (90.0, 70.0), (140.0, 70.0));
let r = comments(&h)[0].rect;
assert!((r[0] - 90.0).abs() < 3.0 && (r[2] - 190.0).abs() < 3.0, "moved right by 50 pt: {r:?}");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move comment"));
h.key_press(egui::Key::Delete);
h.run_steps(3);
assert!(comments(&h).is_empty());
h.state_mut().undo();
h.run_steps(2);
assert_eq!(comments(&h).len(), 1);
}
#[test]
fn the_panel_posts_comments_and_replies() {
let mut h = harness(|app| app.set_option("panel", "comments").unwrap());
field(&h, "Add a comment").click();
h.run_steps(2);
field(&h, "Add a comment").type_text("General remark");
h.run_steps(1);
h.key_press(egui::Key::Enter);
h.run_steps(3);
let c = comments(&h);
assert_eq!((c.len(), c[0].subtype.as_str(), c[0].contents.as_deref()), (1, "Text", Some("General remark")));
// The new comment is selected: reply to it.
field(&h, "Add a reply").click();
h.run_steps(2);
field(&h, "Add a reply").type_text("Agreed");
h.run_steps(1);
h.get_by_label("Post").click();
h.run_steps(3);
let c = comments(&h);
assert_eq!(c.len(), 2);
let reply = c.iter().find(|a| a.in_reply_to.is_some()).expect("a reply");
assert_eq!((reply.contents.as_deref(), reply.author.as_deref()), (Some("Agreed"), Some("Tester")));
h.get_by_label_contains("Agreed");
}

View File

@ -132,6 +132,27 @@ fn commands_keys_and_typing() {
assert!(call(&mut h, &c, "ui.frobnicate", json!({})).unwrap_err().contains("unknown method"));
}
#[test]
fn drawing_a_comment_by_drag_and_its_context_menu() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.command", json!({ "id": "comment.square" }));
let st = ok(&mut h, &c, "ui.state", json!({}));
assert_eq!(st["quick_tool"], "square");
let r = &st["active"]["pages_on_screen"][0]["rect"];
let (x0, y0, x1, y1) = (r[0].as_f64().unwrap(), r[1].as_f64().unwrap(), r[2].as_f64().unwrap(), r[3].as_f64().unwrap());
let (a, b) = ([x0 + (x1 - x0) * 0.2, y0 + (y1 - y0) * 0.2], [x0 + (x1 - x0) * 0.5, y0 + (y1 - y0) * 0.4]);
ok(&mut h, &c, "ui.drag", json!({ "from": a, "to": b }));
h.run_steps(2);
let st = ok(&mut h, &c, "ui.state", json!({}));
assert_eq!(st["active"]["selected_comment"], json!({ "page": 1, "index": 1 }), "{st}");
assert_eq!(st["documents"][0]["dirty"], true);
// A right-click on it offers the comment menu.
ok(&mut h, &c, "ui.click", json!({ "x": (a[0] + b[0]) / 2.0, "y": (a[1] + b[1]) / 2.0, "button": "secondary" }));
let menu = ok(&mut h, &c, "ui.inspect", json!({ "query": "Set status" }));
assert!(menu["count"].as_u64().unwrap() >= 1, "{menu}");
assert!(call(&mut h, &c, "ui.drag", json!({ "from": [1, 2] })).unwrap_err().contains("to must be"));
}
#[test]
fn screenshots_of_window_and_region() {
let (mut h, c) = harness();

View File

@ -3433,9 +3433,9 @@ tier = "P0"
milestone = "M3"
status = "shipped"
commands = ["comment.list"]
tools = ["doc_info"]
evidence = ["crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks"]
notes = "read-only; creating and managing comments is M5"
tools = ["comment_list"]
evidence = ["crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/comments.rs::the_panel_posts_comments_and_replies"]
notes = "cards grouped by page with replies, status badges, selection, reply box and an Add a comment box"
[[feature]]
id = "comment.appearance-generation"
@ -3443,11 +3443,11 @@ title = "Generate correct appearances for every markup type"
area = "E"
tier = "P0"
milestone = "M5"
status = "planned"
status = "partial"
commands = []
tools = []
evidence = []
notes = ""
tools = ["comment_add"]
evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/engine/src/tests.rs::highlight_multiplies_over_text"]
notes = "appearances for every type PrintCraft creates (highlight multiplies); clouds, callouts, stamps and rich text are not drawn yet"
[[feature]]
id = "comment.sticky-note"
@ -3455,11 +3455,11 @@ title = "Sticky note comments"
area = "E"
tier = "P0"
milestone = "M5"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["comment.note"]
tools = ["comment_add"]
evidence = ["crates/ui-egui/tests/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"]
notes = "our own Comment/Note icon drawings, with a pop-up"
[[feature]]
id = "comment.highlight"
@ -3467,11 +3467,11 @@ title = "Highlight text"
area = "E"
tier = "P0"
milestone = "M5"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["comment.highlight"]
tools = ["comment_add"]
evidence = ["crates/ui-egui/tests/comments.rs::dragging_over_text_with_the_highlighter_highlights_it", "crates/engine/src/tests.rs::highlight_multiplies_over_text"]
notes = "drag over text, or pick the tool with text selected; agents can highlight by text search"
[[feature]]
id = "comment.underline"
@ -3479,10 +3479,10 @@ title = "Underline text"
area = "E"
tier = "P0"
milestone = "M5"
status = "planned"
commands = []
tools = []
evidence = []
status = "shipped"
commands = ["comment.underline"]
tools = ["comment_add"]
evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/ui-egui/tests/comments.rs::a_selection_made_first_is_marked_when_a_tool_is_picked"]
notes = ""
[[feature]]
@ -3491,10 +3491,10 @@ title = "Strikethrough text"
area = "E"
tier = "P0"
milestone = "M5"
status = "planned"
commands = []
tools = []
evidence = []
status = "shipped"
commands = ["comment.strikeout"]
tools = ["comment_add"]
evidence = ["crates/ui-egui/tests/comments.rs::a_selection_made_first_is_marked_when_a_tool_is_picked", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"]
notes = ""
[[feature]]
@ -3503,11 +3503,11 @@ title = "Squiggly underline"
area = "E"
tier = "P2"
milestone = "M5"
status = "planned"
status = "partial"
commands = []
tools = []
evidence = []
notes = ""
tools = ["comment_add"]
evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"]
notes = "engine and agent tool; no toolbar entry yet"
[[feature]]
id = "comment.replace-text"
@ -3551,11 +3551,11 @@ title = "Text box comment with rich text"
area = "E"
tier = "P0"
milestone = "M5"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["comment.freetext"]
tools = ["comment_add"]
evidence = ["crates/ui-egui/tests/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn"]
notes = "plain text in Helvetica; rich text (/RC, /DS) is not written yet"
[[feature]]
id = "comment.callout"
@ -3575,11 +3575,11 @@ title = "Freehand drawing (ink)"
area = "E"
tier = "P0"
milestone = "M5"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["comment.ink"]
tools = ["comment_add"]
evidence = ["crates/ui-egui/tests/comments.rs::shapes_and_ink_are_drawn_by_dragging"]
notes = "one ink annotation per stroke"
[[feature]]
id = "comment.eraser"
@ -3599,10 +3599,10 @@ title = "Line"
area = "E"
tier = "P0"
milestone = "M5"
status = "planned"
commands = []
tools = []
evidence = []
status = "shipped"
commands = ["comment.line"]
tools = ["comment_add"]
evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"]
notes = ""
[[feature]]
@ -3611,10 +3611,10 @@ title = "Arrow"
area = "E"
tier = "P0"
milestone = "M5"
status = "planned"
commands = []
tools = []
evidence = []
status = "shipped"
commands = ["comment.arrow"]
tools = ["comment_add"]
evidence = ["crates/ui-egui/tests/comments.rs::shapes_and_ink_are_drawn_by_dragging"]
notes = ""
[[feature]]
@ -3623,11 +3623,11 @@ title = "Rectangle"
area = "E"
tier = "P0"
milestone = "M5"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["comment.square"]
tools = ["comment_add", "comment_edit"]
evidence = ["crates/ui-egui/tests/comments.rs::shapes_and_ink_are_drawn_by_dragging", "crates/annot/src/tests.rs::restyle_and_resize_redraw_the_appearance"]
notes = "resize with the 8 handles"
[[feature]]
id = "comment.oval"
@ -3635,10 +3635,10 @@ title = "Oval"
area = "E"
tier = "P0"
milestone = "M5"
status = "planned"
commands = []
tools = []
evidence = []
status = "shipped"
commands = ["comment.circle"]
tools = ["comment_add"]
evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"]
notes = ""
[[feature]]
@ -3731,11 +3731,11 @@ title = "All ten line-ending styles and dashed/cloudy borders"
area = "E"
tier = "P1"
milestone = "M5"
status = "planned"
status = "partial"
commands = []
tools = []
evidence = []
notes = ""
tools = ["comment_add"]
evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"]
notes = "None, OpenArrow and ClosedArrow are drawn; other endings are kept but not redrawn"
[[feature]]
id = "comment.properties-dialog"
@ -3755,11 +3755,11 @@ title = "Comment properties bar"
area = "E"
tier = "P1"
milestone = "M5"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["comment.note"]
tools = ["comment_edit"]
evidence = ["crates/annot/src/tests.rs::restyle_and_resize_redraw_the_appearance"]
notes = "quick-bar colour swatches and line thickness per tool, colour from the context menu; no opacity control in the UI yet"
[[feature]]
id = "comment.lock-comment"
@ -3791,11 +3791,11 @@ title = "Author name from identity preferences"
area = "E"
tier = "P1"
milestone = "M5"
status = "planned"
status = "partial"
commands = []
tools = []
evidence = []
notes = ""
tools = ["comment_add"]
evidence = ["crates/ui-egui/tests/comments.rs::dragging_over_text_with_the_highlighter_highlights_it"]
notes = "the login name, as Acrobat defaults to; no Identity preferences yet"
[[feature]]
id = "comment.reply"
@ -3803,10 +3803,10 @@ title = "Reply to comments"
area = "E"
tier = "P0"
milestone = "M5"
status = "planned"
commands = []
tools = []
evidence = []
status = "shipped"
commands = ["comment.list"]
tools = ["comment_reply"]
evidence = ["crates/ui-egui/tests/comments.rs::the_panel_posts_comments_and_replies", "crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent"]
notes = ""
[[feature]]
@ -3815,11 +3815,11 @@ title = "Set review status (accepted, rejected, cancelled, completed)"
area = "E"
tier = "P0"
milestone = "M5"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["comment.list"]
tools = ["comment_set_status"]
evidence = ["crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent", "crates/engine/src/tests.rs::comments_are_added_drawn_threaded_and_undone"]
notes = "status replies (/State, /StateModel Review), shown as a badge"
[[feature]]
id = "comment.checkmark"
@ -3839,11 +3839,11 @@ title = "Delete comments"
area = "E"
tier = "P0"
milestone = "M5"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["comment.list"]
tools = ["comment_delete"]
evidence = ["crates/ui-egui/tests/comments.rs::comments_are_selected_moved_and_deleted_with_the_select_tool", "crates/annot/src/tests.rs::delete_takes_popup_and_reply_chain"]
notes = "deletes the pop-up and the reply thread"
[[feature]]
id = "comment.edit-comment-text"
@ -3851,11 +3851,11 @@ title = "Edit comment text"
area = "E"
tier = "P0"
milestone = "M5"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["comment.list"]
tools = ["comment_edit"]
evidence = ["crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn", "crates/automation/tests/automation.rs::comments_through_tools"]
notes = "double-click a comment or card, or Edit in the menus"
[[feature]]
id = "comment.copy-comment-text"
@ -3899,11 +3899,11 @@ title = "Search comments"
area = "E"
tier = "P1"
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_posts_comments_and_replies"]
notes = "search by author, text and type in the panel; not yet in the Find bar"
[[feature]]
id = "comment.show-hide-comments"