mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 21:11:52 +00:00
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:
1 parent
1b9930e612
commit
b1c2c4cafe
6 files changed
+564
-94
No files matched your search
+1
-1
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user