M5: comment pop-up windows laid out as reply threads, fixed in size, scrolling and resizable (comment.popups)

The pop-up window now reads as the common readers' do:

- a one-line title bar with the comment's kind and author, its date and x,
  in the comment's colour; the body in a lighter tint, with fixed ink so it
  reads in either app theme;
- the comment's text, and each reply's, in a box typed in place;
- the replies as panels in thread order, each inside the panel of the reply
  it answers (/IRT), nesting at most eight deep;
- a Reply button under the comment and each reply, opening a box that
  answers that comment or reply (Enter starts a new line; Post or
  Ctrl/Cmd+Enter posts; Cancel closes it).

The window keeps the size of its pop-up's /Rect in points on screen as the
page zooms, instead of growing with the zoom; the text and replies scroll
inside it, with a scroll bar shown whenever there is more. Dragging its
lower right corner resizes it; the new size is saved as the pop-up's /Rect,
the window's upper left corner kept in place on screen (on a page turned a
quarter too), in one undo step. A /Rect with non-finite numbers gives the
least size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-01 09:16:30 +08:00
1 parent 1b9930e612
commit b1c2c4cafe
6 files changed
+564 -94

No files matched your search

+1 -1
View File
@@ -432,7 +432,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-15 (M3, #1248):** A fresh install follows the desktop's appearance: the theme preference defaults to System (dark on a dark desktop, Light where the desktop says nothing); saved settings and `--theme` still win. Dark page rendering is Reading colours, and the page reading themes are #1732.
- **2026-10-15 (M3, #1767):** Restore last view settings when reopening documents, as Acrobat's preference does: a file opened again shows the page, zoom and page display it was last shown with (remembered for the last 100 files when a tab closes or settings are saved), over the file's own initial view; on by default, Preferences ▸ Documents and view turns it off.
- **2026-10-14 (M5, comment pop-ups):** A double-click on a comment opens its pop-up window on the page, as in Acrobat: author, date, the text edited in place, the replies; the title bar drags it and × closes it, each an undo step saved in the file's `/Popup` so other viewers agree. A connector line in the comment's colour joins the comment to its open window on roll-over or selection. `comment_popup` does the same headlessly and `comment_list` reports the pop-up.
- **2026-10-14 (M5, comment pop-ups; reworked 2026-11-01):** A double-click on a comment opens its pop-up window on the page: a title bar with the comment's kind, author and date; its text and its replies' typed in place, the replies as panels each inside the one it answers, a Reply button under each; the window keeps its `/Rect` size on screen as the page zooms, scrolls its thread, drags by its title bar, resizes by its corner and closes with ×, each an undo step saved in the file's `/Popup` so other viewers agree. A faint wedge in the comment's colour joins the comment to its open window on roll-over or selection. `comment_popup` does the same headlessly and `comment_list` reports the pop-up.
- **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged.
- **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged.
- **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged.
+1 -1
View File
@@ -2732,7 +2732,7 @@ fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, prim
if let Some(e) = comments::composer(ui.ctx(), view, info, prefs) {
view.pending_edit = Some(e);
}
if let Some(e) = comments::popups(ui.ctx(), view, info, comments_hidden, allowed) {
if let Some(e) = comments::popups(ui.ctx(), view, info, prefs, comments_hidden, allowed) {
view.pending_edit = Some(e);
}
if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), view, info) {
+382 -86
View File
@@ -1,7 +1,5 @@
use super::state::PageCx;
use super::{
Annotation, Color32, CommentPrefs, CornerRadius, DocInfo, DocView, Edit, PagePointer, PageXform, Pos2, QuickTool, Rect, Tokens, pos2, theme,
};
use super::{Annotation, Color32, CommentPrefs, CornerRadius, DocInfo, DocView, Edit, PagePointer, PageXform, Pos2, QuickTool, Rect, pos2, theme};
/// A comment with a pop-up window of its own: not a text box, a Fill & Sign mark, a reply or a
/// status (replies are found by `/IRT` reference, [`Annotation::is_comment`]).
@@ -88,12 +86,257 @@ fn nearest_on(r: Rect, p: Pos2) -> Pos2 {
}
}
/// The open pop-up windows of the comments on screen: author, date and the comment's text,
/// editable in place; the title bar drags the window (its new place is saved with the comment);
/// × closes it. Returns the edits to apply (one, or a batch when a click both saves typed text
/// and closes). Hidden comments show none.
pub(crate) fn popups(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, hidden: bool, allowed: bool) -> Option<Edit> {
let t = Tokens::get(ctx);
/// A pop-up window's size on screen, in points, whatever the zoom (the readers keep theirs the
/// same as the page zooms), kept within these bounds whatever size the file gives it.
const MIN_WIDTH: f32 = 200.0;
const MAX_WIDTH: f32 = 800.0;
const MIN_HEIGHT: f32 = 120.0;
const MAX_HEIGHT: f32 = 800.0;
/// The side of the grip at a window's lower right corner that resizes it.
const RESIZE_GRIP: f32 = 10.0;
/// How far a reply's panel sits in from the panel around it.
const NEST: i8 = 6;
/// Past this depth a reply's panel stops nesting and sits under the one before it.
const MAX_NESTING: usize = 8;
/// `replies` (all filed under comment `top`) in thread order, each with its depth: a reply to
/// `top` is 1, and each reply comes right after the one it answers, followed by its own replies.
/// One whose parent isn't among them (a status reply, say) answers `top`. A cycle of `/IRT`
/// references shows each reply once.
fn thread<'a>(top: (usize, usize), replies: &[&'a Annotation]) -> Vec<(&'a Annotation, usize)> {
use std::collections::{HashMap, HashSet};
let known: HashSet<(usize, usize)> = replies.iter().map(|r| (r.page, r.index)).collect();
let mut answered: HashMap<(usize, usize), Vec<&'a Annotation>> = HashMap::new();
for r in replies {
let parent = r.parent.filter(|p| known.contains(p) && *p != (r.page, r.index)).unwrap_or(top);
answered.entry(parent).or_default().push(r);
}
let mut out = Vec::with_capacity(replies.len());
let mut seen = HashSet::new();
// Depth first, answers in document order: the stack holds them reversed.
let below = |key: (usize, usize), depth: usize| answered.get(&key).into_iter().flatten().rev().map(move |r| (*r, depth));
let mut stack: Vec<(&Annotation, usize)> = below(top, 1).collect();
let mut next = 0;
loop {
while let Some((r, depth)) = stack.pop() {
if !seen.insert((r.page, r.index)) {
continue;
}
out.push((r, depth));
stack.extend(below((r.page, r.index), depth.saturating_add(1)));
}
// Replies only a cycle reaches: shown from their first, as if they answered `top`. The
// search resumes where it stopped, so the whole of it is one pass.
while next < replies.len() && replies.get(next).is_some_and(|r| seen.contains(&(r.page, r.index))) {
next += 1;
}
match replies.get(next) {
Some(r) => stack.push((*r, 1)),
None => break,
}
}
out
}
/// A thread as nested panels: each reply ([`thread`] order) and the positions of the replies
/// drawn inside its panel; then the positions of the replies to the comment itself. Nesting stops
/// at [`MAX_NESTING`], so drawing it recurses no deeper than that.
fn nest<'a>(order: &[(&'a Annotation, usize)]) -> (Vec<(&'a Annotation, Vec<usize>)>, Vec<usize>) {
let mut nodes: Vec<(&Annotation, Vec<usize>)> = Vec::with_capacity(order.len());
let mut roots = Vec::new();
// The panels open at each depth above the reply being placed.
let mut open: Vec<usize> = Vec::new();
for (i, (r, depth)) in order.iter().enumerate() {
let depth = (*depth).clamp(1, MAX_NESTING);
open.truncate(depth - 1);
match open.last().and_then(|p| nodes.get_mut(*p)) {
Some((_, kids)) => kids.push(i),
None => roots.push(i),
}
nodes.push((r, Vec::new()));
open.push(i);
}
(nodes, roots)
}
/// A pop-up window's colours: the comment's colour, lightened for the body and the title bar,
/// and ink that reads on them. Fixed, not from the app's theme: the window is the comment's, as
/// it looks on paper.
#[derive(Clone, Copy)]
struct Palette {
edge: Color32,
title: Color32,
title_ink: Color32,
body: Color32,
panel_edge: Color32,
ink: Color32,
faint: Color32,
}
fn palette(color: Color32) -> Palette {
// `color` mixed into white, `f` of the way.
let mix = |f: f32| {
let c = |v: u8| (255.0 + (f32::from(v) - 255.0) * f).round().clamp(0.0, 255.0) as u8;
Color32::from_rgb(c(color.r()), c(color.g()), c(color.b()))
};
let title = mix(0.8);
let light = |c: Color32| 0.299 * f32::from(c.r()) + 0.587 * f32::from(c.g()) + 0.114 * f32::from(c.b()) > 150.0;
Palette {
edge: color,
title,
title_ink: if light(title) { Color32::from_gray(20) } else { Color32::WHITE },
body: mix(0.35),
panel_edge: mix(0.7),
ink: Color32::from_gray(20),
faint: Color32::from_gray(80),
}
}
/// What a pop-up window's parts share as they draw.
struct Parts<'v> {
allowed: bool,
author: &'v str,
pal: Palette,
cv: &'v mut super::state::CommentView,
edits: &'v mut Vec<Edit>,
}
/// One comment's or reply's text in its box, typed in place (saved when the box loses focus);
/// read-only when comments can't be changed or it is locked.
fn text_box(ui: &mut egui::Ui, parts: &mut Parts<'_>, a: &Annotation, name: &str) {
let key = (a.page, a.index);
if parts.allowed && !a.locked {
let typed = parts.cv.popup_text.as_ref().filter(|(p, i, _)| (*p, *i) == key).map(|(_, _, s)| s.clone());
let mut text = typed.unwrap_or_else(|| a.contents.clone().unwrap_or_default());
let field = ui
.add(
egui::TextEdit::multiline(&mut text)
.desired_rows(1)
.desired_width(f32::INFINITY)
.font(theme::regular(12.5))
.text_color(parts.pal.ink)
.background_color(Color32::WHITE)
.margin(egui::Margin::symmetric(4, 2))
.id_salt(("popup-text", a.page, a.index)),
)
.accessible_name(name);
if field.changed() {
parts.cv.popup_text = Some((a.page, a.index, text.clone()));
}
if field.lost_focus()
&& let Some((_, _, s)) = parts.cv.popup_text.take_if(|(p, i, _)| (*p, *i) == key)
&& s.trim_end() != a.contents.as_deref().unwrap_or_default()
{
parts.edits.push(Edit::SetAnnotationContents { page: a.page, index: a.index, text: s.trim_end().to_string() });
}
} else {
egui::Frame::NONE.fill(Color32::WHITE).inner_margin(egui::Margin::symmetric(4, 2)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.label(egui::RichText::new(a.contents.as_deref().unwrap_or_default()).font(theme::regular(12.5)).color(parts.pal.ink));
});
}
}
/// The row under a comment's or reply's text: a Reply button at its end (the start is kept for
/// more of a reply's controls), and the box to type that reply in once it is pressed (Post, or
/// Ctrl/⌘+Enter, posts it; Enter starts a new line, as in the readers' pop-ups).
fn footer(ui: &mut egui::Ui, parts: &mut Parts<'_>, a: &Annotation) {
let key = (a.page, a.index);
// Small buttons, as the readers' footers have.
ui.spacing_mut().button_padding = egui::vec2(6.0, 1.0);
let mut opened = false;
if parts.allowed {
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let label = egui::RichText::new(tl!("Reply")).font(theme::regular(11.0)).color(parts.pal.ink);
let button = egui::Button::new(label)
.fill(Color32::WHITE.gamma_multiply(0.6))
.stroke(egui::Stroke::new(1.0, parts.pal.panel_edge))
.min_size(egui::vec2(0.0, 16.0));
if ui.add(button).clicked() {
parts.cv.popup_reply = Some((a.page, a.index, String::new()));
opened = true;
}
});
});
}
let Some(mut reply) = parts.cv.popup_reply.as_ref().filter(|(p, i, _)| (*p, *i) == key).map(|(_, _, s)| s.clone()) else { return };
let field = ui.add(
egui::TextEdit::multiline(&mut reply)
.desired_rows(1)
.desired_width(f32::INFINITY)
.hint_text(tl!("Add a reply"))
.font(theme::regular(12.5))
.text_color(parts.pal.ink)
.background_color(Color32::WHITE)
.margin(egui::Margin::symmetric(4, 2))
.id_salt(("popup-reply", a.page, a.index)),
);
if opened {
field.request_focus();
}
if field.changed() {
parts.cv.popup_reply = Some((a.page, a.index, reply.clone()));
}
let enter = super::submit_key(ui, &field, false);
let ok = !reply.trim().is_empty();
let (post, cancel) = ui
.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let small = |s: &str| egui::RichText::new(s).font(theme::regular(11.0));
let post = ui.add_enabled(ok, egui::Button::new(small(tl!("Post"))).min_size(egui::vec2(0.0, 16.0))).clicked();
let cancel = ui.add(egui::Button::new(small(tl!("Cancel"))).min_size(egui::vec2(0.0, 16.0))).clicked();
(post, cancel)
})
.inner
})
.inner;
if (post || enter) && ok {
parts.cv.popup_reply = None;
parts.edits.push(Edit::ReplyToAnnotation { page: a.page, index: a.index, text: reply.trim().to_string(), author: parts.author.to_string() });
} else if cancel {
parts.cv.popup_reply = None;
}
}
/// A reply's panel: who wrote it and when, its text and footer, and the panels of its own
/// replies inside it.
fn reply_panel(ui: &mut egui::Ui, parts: &mut Parts<'_>, nodes: &[(&Annotation, Vec<usize>)], at: usize) {
let Some((r, kids)) = nodes.get(at) else { return };
let pal = parts.pal;
egui::Frame::NONE.stroke(egui::Stroke::new(1.0, pal.panel_edge)).corner_radius(CornerRadius::same(3)).inner_margin(egui::Margin::same(4)).show(
ui,
|ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 3.0;
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if let Some(m) = &r.modified {
ui.label(egui::RichText::new(m).font(theme::regular(10.5)).color(pal.faint));
}
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
let who = r.author.as_deref().unwrap_or("?");
ui.add(egui::Label::new(egui::RichText::new(who).font(theme::semibold(11.5)).color(pal.ink)).truncate());
});
});
});
text_box(ui, parts, r, r.author.as_deref().unwrap_or("?"));
footer(ui, parts, r);
for kid in kids {
egui::Frame::NONE.inner_margin(egui::Margin { left: NEST, ..Default::default() }).show(ui, |ui| reply_panel(ui, parts, nodes, *kid));
}
},
);
}
/// The open pop-up windows of the comments on screen, laid out as the readers' are: a title bar
/// with the comment's kind and author, its date and ×; the comment's text, editable in place; its
/// replies as panels, each inside the one it answers; and a Reply button under each, which opens
/// a box to answer it. The window keeps its size on screen as the page zooms, and its text and
/// replies scroll inside it. The title bar drags the window and its lower right corner resizes
/// it, both saved with the comment; × closes it. Returns the edits to apply (one, or a batch when
/// a click both saves typed text and closes). Hidden comments show none.
pub(crate) fn popups(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs, hidden: bool, allowed: bool) -> Option<Edit> {
let pointer = ctx.input(|i| i.pointer.hover_pos());
let mut edits: Vec<Edit> = Vec::new();
let mut rects = Vec::new();
@@ -108,40 +351,74 @@ pub(crate) fn popups(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, hi
let Some(p) = a.popup else { continue };
let key = (page, a.index);
let placed = xf.user_rect(info, page, p.rect);
// The window keeps a readable size whatever the zoom: its place follows the page,
// its width stays between what a note needs and what fits.
let width = placed.width().clamp(220.0, 420.0);
// Its place follows the page; its size is the pop-up's `/Rect` in points on screen,
// or the size its corner is being dragged to.
// A `/Rect` with numbers that aren't finite gives the least size.
let side = |a: f32, b: f32| Some((b - a).abs()).filter(|v| v.is_finite()).unwrap_or(0.0);
let saved = egui::vec2(side(p.rect[0], p.rect[2]), side(p.rect[1], p.rect[3]));
let clamp = |s: egui::Vec2| egui::vec2(s.x.clamp(MIN_WIDTH, MAX_WIDTH), s.y.clamp(MIN_HEIGHT, MAX_HEIGHT));
let resizing = view.comments.popup_resize.filter(|(rp, ri, _)| (*rp, *ri) == key).map(|(_, _, size)| size);
let size = clamp(resizing.unwrap_or(saved));
let dragging = view.comments.popup_drag.filter(|(dp, di, _)| (*dp, *di) == key).map(|(_, _, at)| at);
let pos = dragging.unwrap_or(placed.left_top());
let id = egui::Id::unique(("comment-popup", view.id.0, page, a.index));
let color = a
.color
.map_or(Color32::from_rgb(0xFF, 0xD6, 0x5C), |c| Color32::from_rgb((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8));
let pal = palette(color);
// A screen rectangle on this page in user space, to save as the pop-up's `/Rect`. Only
// converts: the pointer and the redaction look are unused.
let to_user = |r: Rect| {
let cx = PageCx {
page,
xf: &xf,
ptr: &PagePointer::default(),
info,
tool: QuickTool::Select,
prefs: &CommentPrefs::default(),
allowed,
hidden,
redact_mark: ([0.0; 3], 1.0),
};
cx.rect_to_user(r)
};
let area = egui::Area::new(id).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| {
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::ZERO).corner_radius(CornerRadius::same(6)).show(ui, |ui| {
ui.set_width(width);
// Title bar: the comment's colour, author and date; drag to move, × to close.
let bar = egui::Frame::NONE.fill(color.gamma_multiply(0.35)).inner_margin(egui::Margin::symmetric(10, 6)).show(ui, |ui| {
ui.set_width(width - 20.0);
let frame = egui::Frame::popup(ui.style()).inner_margin(egui::Margin::ZERO).corner_radius(CornerRadius::same(3)).fill(pal.body);
let window = frame.stroke(egui::Stroke::new(1.0, pal.edge)).show(ui, |ui| {
ui.set_width(size.x);
// The whole size, however short its thread.
ui.set_min_height(size.y);
ui.spacing_mut().item_spacing.y = 3.0;
// Title bar: the comment's kind and author, its date and ×, on one line; it
// drags the window.
let bar = egui::Frame::NONE.fill(pal.title).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {
ui.set_width(size.x - 12.0);
ui.horizontal(|ui| {
let who = a.author.clone().unwrap_or_else(|| crate::comments_panel::kind_label(&a).to_string());
ui.label(egui::RichText::new(who.as_str()).font(theme::semibold(13.0)).color(t.text));
if let Some(m) = &a.modified {
ui.label(egui::RichText::new(m).font(theme::regular(11.0)).color(t.text_faint));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// Closing changes the comment's /Open, which needs comment permission.
if ui.add_enabled_ui(allowed, |ui| crate::icons::button(ui, "x", 18.0, false, tl!("Close"))).inner.clicked() {
if ui.add_enabled_ui(allowed, |ui| crate::icons::button(ui, "x", 14.0, false, tl!("Close"))).inner.clicked() {
edits.push(Edit::SetAnnotationPopup { page, index: a.index, rect: None, open: Some(false) });
}
});
if let Some(m) = &a.modified {
ui.label(egui::RichText::new(m).font(theme::regular(10.5)).color(pal.title_ink));
}
// The kind and author take the rest of the line, cut short to fit.
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
let kind = tl!(crate::comments_panel::kind_label(&a));
let title = match a.author.as_deref() {
Some(who) => format!("{kind} – {who}"),
None => kind.to_string(),
};
ui.add(egui::Label::new(egui::RichText::new(title).font(theme::semibold(12.0)).color(pal.title_ink)).truncate());
});
})
});
});
// The bar drags the window, except where the × is: a region registered after
// the button would take its clicks.
let bar_rect = bar.response.rect;
let grip = ui.interact(
Rect::from_min_max(bar_rect.min, pos2(bar_rect.right() - 30.0, bar_rect.bottom())),
Rect::from_min_max(bar_rect.min, pos2(bar_rect.right() - 24.0, bar_rect.bottom())),
id.with("grip"),
egui::Sense::drag(),
);
@@ -159,72 +436,91 @@ pub(crate) fn popups(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, hi
if grip.drag_stopped()
&& let Some((_, _, at)) = view.comments.popup_drag.take()
&& allowed
&& at.distance(placed.left_top()) >= 2.0
{
// The same size on the page, at the new place.
let moved = Rect::from_min_size(at, placed.size());
// Only converts the rectangle to user space: the pointer and the
// redaction look are unused.
let cx = PageCx {
page,
xf: &xf,
ptr: &PagePointer::default(),
info,
tool: QuickTool::Select,
prefs: &CommentPrefs::default(),
allowed,
hidden,
redact_mark: ([0.0; 3], 1.0),
};
let rect = cx.rect_to_user(moved);
if at.distance(placed.left_top()) >= 2.0 {
edits.push(Edit::SetAnnotationPopup { page, index: a.index, rect: Some(rect), open: None });
}
// The same size on the page, at the new place. Only converts the
// rectangle to user space: the pointer and the redaction look are unused.
let rect = to_user(Rect::from_min_size(at, placed.size()));
edits.push(Edit::SetAnnotationPopup { page, index: a.index, rect: Some(rect), open: None });
}
// Body: the text, typed in place; saved when the field loses focus.
egui::Frame::NONE.inner_margin(egui::Margin::symmetric(10, 8)).show(ui, |ui| {
ui.set_width(width - 20.0);
if allowed && !a.locked {
let typed = view.comments.popup_text.as_ref().filter(|(p, i, _)| (*p, *i) == key).map(|(_, _, s)| s.clone());
let mut text = typed.unwrap_or_else(|| a.contents.clone().unwrap_or_default());
let field = ui.add(
egui::TextEdit::multiline(&mut text)
.hint_text(tl!("Add a comment"))
.desired_rows(3)
.desired_width(f32::INFINITY)
.id_salt(("popup-text", page, a.index)),
);
if field.changed() {
view.comments.popup_text = Some((page, a.index, text.clone()));
// Replies, as the thread reads: text replies only, filed under this
// comment by `/IRT` reference as in the Comments panel, each under the
// one it answers. A review status or a checkmark (`/State`) is a reply too,
// but not one to read here.
let replies: Vec<&Annotation> = info
.annotations
.iter()
.filter(|r| {
r.state.is_none() && r.parent.is_some_and(|parent| tops.get(&(r.page, r.index)).copied().unwrap_or(parent) == key)
})
.collect();
let order = thread(key, &replies);
let (nodes, roots) = nest(&order);
let mut parts = Parts { allowed, author: &prefs.author, pal, cv: &mut view.comments, edits: &mut edits };
// The body keeps the window's height: the text and replies scroll in what the
// title bar leaves, with a bar that shows whenever there is more (egui's show
// only near the pointer), in the ink's colour.
egui::Frame::NONE.inner_margin(egui::Margin::same(4)).show(ui, |ui| {
ui.set_width(size.x - 8.0);
ui.spacing_mut().scroll = egui::style::ScrollStyle {
dormant_handle_opacity: 0.4,
dormant_background_opacity: 0.1,
foreground_color: true,
..egui::style::ScrollStyle::solid()
};
ui.visuals_mut().override_text_color = Some(pal.ink);
let body_height = (size.y - bar_rect.height() - 8.0).max(24.0);
let scroll =
egui::ScrollArea::vertical().id_salt(("popup-body", page, a.index)).max_height(body_height).auto_shrink([false, false]);
scroll.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 3.0;
text_box(ui, &mut parts, &a, tl!("Add a comment"));
footer(ui, &mut parts, &a);
for root in &roots {
reply_panel(ui, &mut parts, &nodes, *root);
}
if field.lost_focus()
&& let Some((_, _, s)) = view.comments.popup_text.take_if(|(p, i, _)| (*p, *i) == key)
&& s.trim_end() != a.contents.as_deref().unwrap_or_default()
{
edits.push(Edit::SetAnnotationContents { page, index: a.index, text: s.trim_end().to_string() });
}
} else {
ui.label(a.contents.as_deref().unwrap_or_default());
}
// Replies, as the thread reads: text replies only, at any depth, filed
// under this comment by `/IRT` reference as in the Comments panel. A
// review status or a checkmark (`/State`) is a reply too, but not one to
// read here.
let replies: Vec<&Annotation> = info
.annotations
.iter()
.filter(|r| {
r.state.is_none() && r.parent.is_some_and(|parent| tops.get(&(r.page, r.index)).copied().unwrap_or(parent) == key)
})
.collect();
for r in replies {
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new(r.author.as_deref().unwrap_or("?")).font(theme::semibold(12.0)));
ui.label(egui::RichText::new(r.contents.as_deref().unwrap_or_default()).font(theme::regular(12.0)));
});
}
});
});
});
// The lower right corner resizes the window; its new size is saved with the
// comment, its upper left corner where it was, when the drag ends.
let corner = window.response.rect.right_bottom();
let grip =
ui.interact(Rect::from_min_max(corner - egui::vec2(RESIZE_GRIP, RESIZE_GRIP), corner), id.with("resize"), egui::Sense::drag());
if grip.hovered() || grip.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeNwSe);
}
// Two short diagonal strokes mark the grip.
for inset in [3.0, 7.0] {
let stroke = egui::Stroke::new(1.0, pal.faint);
ui.painter().line_segment([corner - egui::vec2(3.0, inset), corner - egui::vec2(inset, 3.0)], stroke);
}
if grip.drag_started() {
view.comments.popup_resize = Some((page, a.index, size));
}
if grip.dragged()
&& let Some((_, _, s)) = view.comments.popup_resize.as_mut()
{
*s += grip.drag_delta();
}
if grip.drag_stopped()
&& let Some((_, _, s)) = view.comments.popup_resize.take()
&& allowed
{
let s = clamp(s);
// A press on the grip that ends where it began changes nothing.
// The window's size in points is the `/Rect`'s, its upper left corner kept
// where it is on screen: the rectangle of that size on the page there. A page
// turned a quarter (its `/Rotate` and the view's) shows the `/Rect`'s width
// down the screen.
let zoom = xf.zoom();
if (s - clamp(saved)).length() >= 2.0 && zoom.is_finite() && zoom > 0.0 {
let turn = info.pages.get(page).map_or(0, |pg| u32::from(pg.rotation)) + u32::from(xf.rot);
let on_page = if turn % 180 == 90 { egui::vec2(s.y, s.x) } else { s } * zoom;
let rect = to_user(Rect::from_min_size(placed.left_top(), on_page));
edits.push(Edit::SetAnnotationPopup { page, index: a.index, rect: Some(rect), open: None });
}
}
});
let shown_at = area.response.rect;
if pointer.is_some_and(|p| shown_at.contains(p)) {
@@ -142,8 +142,12 @@ pub struct CommentView {
/// A pop-up window's title bar being dragged: the comment, and where the window's top-left
/// corner is now on screen.
pub popup_drag: Option<(usize, usize, Pos2)>,
/// A pop-up window's corner being dragged: the comment, and the window's size on screen now.
pub popup_resize: Option<(usize, usize, egui::Vec2)>,
/// Text being typed in a pop-up window: (page, index, text).
pub popup_text: Option<(usize, usize, String)>,
/// A reply being typed in a pop-up window: the comment or reply it answers (page, index), and its text.
pub popup_reply: Option<(usize, usize, String)>,
/// Where each open pop-up was drawn last frame, for the connector lines and hit-testing.
pub popup_rects: Vec<((usize, usize), Rect)>,
/// The pop-up the pointer was over last frame.
+174 -4
View File
@@ -3085,7 +3085,7 @@ trailer << /Root 1 0 R >>
});
h.run_steps(6);
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the open pop-up shows");
let in_window = |label: &str| h.query_all_by_label(label).any(|n| window.contains(n.rect().center()));
let in_window = |text: &str| h.query_all_by(|n| shows_text(n, text)).any(|n| window.contains(n.rect().center()));
assert!(in_window("Agreed with this"), "the text reply");
assert!(!in_window("Accepted set by Bob"), "not the review status");
assert!(!in_window("Marked set by Ada"), "not the checkmark");
@@ -3134,7 +3134,8 @@ fn a_double_click_opens_a_notes_pop_up_which_drags_and_closes() {
assert_eq!(note.popup.map(|p| p.open), Some(true), "the pop-up opened");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Open comment pop-up"));
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is on screen");
h.get_by_label("Tester");
// Its title names the kind of comment and its author.
h.get_by_label("Note – Tester");
// Drag the title bar 80 px right and 40 px down: the pop-up's place on the page moves.
let before = note.popup.unwrap().rect;
@@ -3165,7 +3166,7 @@ const POPUPS_SHARING_A_NAME: &[u8] = b"%PDF-1.7
4 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /NM (fitz-A0) /T (Alpha) /Contents (First note) /Popup 5 0 R /Open true >> endobj
5 0 obj << /Type /Annot /Subtype /Popup /Rect [60 110 160 190] /Parent 4 0 R /Open true >> endobj
6 0 obj << /Type /Annot /Subtype /Text /Rect [20 40 40 60] /NM (fitz-A0) /T (Beta) /Contents (Second note) /Popup 7 0 R /Open true >> endobj
7 0 obj << /Type /Annot /Subtype /Popup /Rect [180 10 290 90] /Parent 6 0 R /Open true >> endobj
7 0 obj << /Type /Annot /Subtype /Popup /Rect [180 -250 290 90] /Parent 6 0 R /Open true >> endobj
8 0 obj << /Type /Annot /Subtype /Text /Rect [20 40 40 60] /NM (fitz-A1) /IRT 6 0 R /T (Gamma) /Contents (Reply by reference) >> endobj
9 0 obj << /Type /Annot /Subtype /Text /Rect [20 40 40 60] /IRT 8 0 R /T (Delta) /Contents (Reply to the reply) >> endobj
trailer << /Root 1 0 R >>
@@ -3183,7 +3184,7 @@ fn a_pop_up_lists_its_replies_by_reference_when_names_repeat() {
for text in ["Reply by reference", "Reply to the reply"] {
// The Comments panel lists the thread too; count only what the windows show.
let shown: Vec<Pos2> =
h.query_all_by_label(text).map(|n| n.rect().center()).filter(|c| windows.iter().any(|(_, w)| w.contains(*c))).collect();
h.query_all_by(|n| shows_text(n, text)).map(|n| n.rect().center()).filter(|c| windows.iter().any(|(_, w)| w.contains(*c))).collect();
assert_eq!(shown.len(), 1, "{text:?} shows in one window: {shown:?}");
assert!(shown.iter().all(|c| second.contains(*c)), "{text:?} is in the second note's window");
}
@@ -3269,3 +3270,172 @@ fn a_connector_line_joins_a_comment_to_its_open_pop_up_while_the_pointer_is_on_e
h.run_steps(2);
assert_eq!(connectors(&h, blue).len(), 1, "a selected comment shows its connector");
}
/// A note with an open pop-up and a thread of replies: "Second" and "Third" answer the note,
/// "Second, first answer" answers "Second" (`/IRT` to a reply).
const NOTE_WITH_A_THREAD: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /C [1 0.85 0.2] /T (Alpha) /Contents (First) /Popup 5 0 R /Open true >> endobj
5 0 obj << /Type /Annot /Subtype /Popup /Rect [60 -250 280 150] /Parent 4 0 R /Open true >> endobj
6 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Beta) /Contents (Second) /IRT 4 0 R /F 30 >> endobj
7 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Beta) /Contents (Second, first answer) /IRT 6 0 R /F 30 >> endobj
8 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Beta) /Contents (Third) /IRT 4 0 R /F 30 >> endobj
trailer << /Root 1 0 R >>
%%EOF";
/// A pop-up window (comment.popup-windows) is laid out as the readers' are: a title bar with the
/// comment's kind and author; the comment's text; its replies as panels in thread order, an
/// answer to a reply inside that reply's panel; and a Reply button under each, which opens a box
/// that posts an answer to that comment or reply.
#[test]
fn a_pop_up_threads_its_replies_and_takes_a_new_one() {
let mut h = harness_with_bytes(NOTE_WITH_A_THREAD.to_vec(), |_| {});
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open");
h.get_by_label("Note – Alpha");
// Each reply's text box, inside the window.
let rect = |h: &Harness<'static, PdfCraftApp>, text: &str| {
let found: Vec<egui::Rect> = h.query_all_by(|n| shows_text(n, text)).map(|n| n.rect()).filter(|r| window.contains_rect(*r)).collect();
assert_eq!(found.len(), 1, "{text:?} once in the window: {found:?}");
found[0]
};
let (second, answer, third) = (rect(&h, "Second"), rect(&h, "Second, first answer"), rect(&h, "Third"));
assert!(second.bottom() < answer.top() && answer.bottom() < third.top(), "thread order: {second:?} {answer:?} {third:?}");
assert!(
answer.left() > second.left() + 4.0 && answer.right() < second.right() - 4.0,
"the answer sits inside its reply's panel: {second:?} {answer:?}"
);
assert!((third.left() - second.left()).abs() < 1.0, "replies to the note line up: {second:?} {third:?}");
// The Reply buttons, top to bottom: the note's, then each reply's.
let replies = |h: &Harness<'static, PdfCraftApp>| {
let mut b: Vec<egui::Rect> =
h.query_all_by(|n| n.role() == Role::Button && n.label().as_deref() == Some("Reply")).map(|n| n.rect()).collect();
b.sort_by(|x, y| x.top().total_cmp(&y.top()));
b
};
assert_eq!(replies(&h).len(), 4, "one under the note and each reply");
// The note's Reply: Enter starts a new line in the box, as in the readers' pop-ups; Post posts.
let before = comments(&h).len();
let at = replies(&h)[0].center();
click(&mut h, at);
field(&h, "Add a reply").type_text("Fourth");
h.run_steps(2);
h.key_press(egui::Key::Enter);
h.run_steps(2);
assert_eq!(comments(&h).len(), before, "Enter does not post");
h.event(egui::Event::Text("line two".into()));
h.run_steps(2);
h.get_by_label("Post").click();
h.run_steps(3);
let all = comments(&h);
let note = all.iter().find(|a| a.contents.as_deref() == Some("First")).expect("the note");
let reply = all.iter().find(|a| a.contents.as_deref() == Some("Fourth\nline two")).expect("the reply was added, on two lines");
assert_eq!(reply.parent, Some((note.page, note.index)));
assert_eq!(reply.author.as_deref(), Some("Tester"));
assert!(h.state().views[0].comments.popup_reply.is_none(), "the box closes");
// A reply's own Reply answers that reply; Ctrl+Enter posts too, as in the comment composer.
let second_key = all.iter().find(|a| a.contents.as_deref() == Some("Second")).map(|a| (a.page, a.index)).expect("the reply");
let at = replies(&h)[1].center();
click(&mut h, at);
field(&h, "Add a reply").type_text("Fifth");
h.run_steps(2);
h.key_press_modifiers(egui::Modifiers::CTRL, egui::Key::Enter);
h.run_steps(3);
let fifth = comments(&h).into_iter().find(|a| a.contents.as_deref() == Some("Fifth")).expect("Ctrl+Enter posts");
assert_eq!(fifth.parent, Some(second_key), "it answers the reply");
// Cancel closes the box without posting.
let before = comments(&h).len();
let at = replies(&h)[0].center();
click(&mut h, at);
field(&h, "Add a reply").type_text("Never mind");
h.run_steps(2);
h.get_by_label("Cancel").click();
h.run_steps(3);
assert_eq!(comments(&h).len(), before);
assert!(h.state().views[0].comments.popup_reply.is_none());
}
/// A note with an open pop-up and `replies` replies to it.
fn note_with_replies(replies: usize) -> Vec<u8> {
let annots: String = (0..replies + 2).map(|i| format!("{} 0 R ", i + 4)).collect();
let mut pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [{annots}] >> endobj
4 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Alpha) /Contents (First) /Popup 5 0 R /Open true >> endobj
5 0 obj << /Type /Annot /Subtype /Popup /Rect [60 10 280 150] /Parent 4 0 R /Open true >> endobj
"
);
for i in 0..replies {
pdf.push_str(&format!(
"{} 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Beta) /Contents (Reply {i}) /IRT 4 0 R /F 30 >> endobj\n",
i + 6
));
}
pdf.push_str("trailer << /Root 1 0 R >>\n%%EOF");
pdf.into_bytes()
}
/// A pop-up window keeps its height however long its thread grows: the text and replies scroll
/// inside it, as in the readers' pop-ups, rather than the window growing down the screen.
#[test]
fn a_long_thread_scrolls_inside_its_pop_up() {
let height = |replies: usize| {
let h = harness_with_bytes(note_with_replies(replies), |_| {});
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open");
window.height()
};
let (short, long) = (height(1), height(25));
assert!((short - long).abs() < 2.0, "the same height with 1 reply and with 25: {short} {long}");
assert!(long < 600.0, "within the screen: {long}");
}
/// Dragging a pop-up window's lower right corner resizes it, and the new size is saved with the
/// comment (the pop-up's `/Rect`) at the same place, in one undo step; the window shows it.
#[test]
fn a_pop_ups_corner_resizes_it() {
let mut h = harness_with_bytes(BLUE_NOTE_WITH_OPEN_POP_UP.to_vec(), |_| {});
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open");
let before = comments(&h).into_iter().find(|a| a.subtype == "Text").and_then(|a| a.popup).expect("its pop-up").rect;
let corner = window.right_bottom() - egui::vec2(3.0, 3.0);
drag(&mut h, corner, corner + egui::vec2(80.0, 60.0));
h.run_steps(3);
let after = comments(&h).into_iter().find(|a| a.subtype == "Text").and_then(|a| a.popup).expect("its pop-up").rect;
// Wider and taller on the page, its upper left corner where it was.
assert!(after[2] - after[0] > before[2] - before[0] + 5.0, "wider: {before:?} -> {after:?}");
assert!(after[3] - after[1] > before[3] - before[1] + 5.0, "taller: {before:?} -> {after:?}");
assert!((after[0] - before[0]).abs() < 0.5 && (after[3] - before[3]).abs() < 0.5, "the same place: {before:?} -> {after:?}");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move comment pop-up"));
let (_, resized) = h.state().views[0].comments.popup_rects.first().copied().expect("still open");
// The window shows the saved size, in points on screen whatever the zoom.
assert!((resized.width() - (after[2] - after[0])).abs() < 3.0, "as wide as saved: {after:?} {resized:?}");
assert!((resized.height() - (after[3] - after[1])).abs() < 3.0, "as tall as saved: {after:?} {resized:?}");
assert!(resized.width() > window.width() + 60.0 && resized.height() > window.height() + 40.0, "bigger on screen: {window:?} -> {resized:?}");
assert!((resized.left_top() - window.left_top()).length() < 1.0, "in the same place on screen");
}
/// A pop-up window's box holding `text` (a comment's or a reply's).
fn shows_text(n: &egui_kittest::kittest::AccessKitNode<'_>, text: &str) -> bool {
n.role() == Role::MultilineTextInput && n.value().as_deref() == Some(text)
}
/// On a page turned a quarter (View ▸ Rotate View), a resized pop-up window stays where it is on
/// screen and shows the size it was dragged to.
#[test]
fn a_pop_up_resized_on_a_turned_page_stays_in_place() {
let mut h = harness_with_bytes(BLUE_NOTE_WITH_OPEN_POP_UP.to_vec(), |app| app.views[0].rotation = 90);
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open");
let corner = window.right_bottom() - egui::vec2(3.0, 3.0);
drag(&mut h, corner, corner + egui::vec2(80.0, 60.0));
h.run_steps(3);
let (_, resized) = h.state().views[0].comments.popup_rects.first().copied().expect("still open");
assert!((resized.left_top() - window.left_top()).length() < 1.0, "in the same place on screen: {window:?} -> {resized:?}");
assert!((resized.width() - window.width() - 80.0).abs() < 3.0, "80 px wider: {window:?} -> {resized:?}");
// Taller by the drag, less what its text had already stretched it.
assert!(resized.height() > window.height() + 50.0, "taller: {window:?} -> {resized:?}");
}
+2 -2
View File
@@ -4135,8 +4135,8 @@ milestone = "M5"
status = "shipped"
commands = []
tools = ["comment_popup", "comment_list"]
evidence = ["crates/ui-egui/tests/it/comments.rs::a_double_click_opens_a_notes_pop_up_which_drags_and_closes", "crates/annot/src/tests.rs::pop_ups_open_close_and_move_and_are_reported", "crates/automation/tests/it/automation.rs::comment_popups_are_opened_moved_and_listed", "crates/render/src/inspect/tests.rs::a_comments_pop_up_is_read_with_its_place_and_state"]
notes = "double-click a comment to open its pop-up window on the page (its /Popup /Open and /Rect, so other viewers show it the same); author, date, the text typed in place and the replies; the title bar drags it, × closes it; each is an undo step. Not yet: Acrobat's pop-up options menu (reply, status, delete from the window), replying from the window, resizing the window, Preferences ▸ Commenting pop-up behaviours (open on roll-over, hide when the Comments list opens)"
evidence = ["crates/ui-egui/tests/it/comments.rs::a_double_click_opens_a_notes_pop_up_which_drags_and_closes", "crates/ui-egui/tests/it/comments.rs::a_pop_up_threads_its_replies_and_takes_a_new_one", "crates/ui-egui/tests/it/comments.rs::a_long_thread_scrolls_inside_its_pop_up", "crates/ui-egui/tests/it/comments.rs::a_pop_ups_corner_resizes_it", "crates/ui-egui/tests/it/comments.rs::a_pop_up_resized_on_a_turned_page_stays_in_place", "crates/annot/src/tests.rs::pop_ups_open_close_and_move_and_are_reported", "crates/automation/tests/it/automation.rs::comment_popups_are_opened_moved_and_listed", "crates/render/src/inspect/tests.rs::a_comments_pop_up_is_read_with_its_place_and_state"]
notes = "double-click a comment to open its pop-up window on the page (its /Popup /Open and /Rect, so other viewers show it the same), at the size its /Rect gives in points whatever the zoom; a title bar with the comment's kind, author and date; its text typed in place; its replies as panels, each inside the one it answers (/IRT), their text typed in place too; a Reply button under the comment and under each reply opens a box that answers it (Enter starts a new line, Post or Ctrl+Enter posts); the text and replies scroll inside the window; the title bar drags it, its lower right corner resizes it, × closes it; each is an undo step. Not yet: the window's options menu (status, mark, lock, delete from the window), collapsing a reply's answers, resizing from the other corners and sides, Preferences ▸ Commenting pop-up behaviours (open on roll-over, hide when the Comments list opens)"
[[feature]]
id = "comment.connector-lines"