From b1c2c4cafe37213d79c9357c1d08d3cd3154da96 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 09:15:42 +0800 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- ROADMAP.md | 2 +- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/tools/comments/popups.rs | 468 ++++++++++++++++---- crates/ui-egui/src/tools/comments/state.rs | 4 + crates/ui-egui/tests/it/comments.rs | 178 +++++++- parity/acrobat-features.toml | 4 +- 6 files changed, 564 insertions(+), 94 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0854b65f96..a7f274ea62 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 05a977d9cc..7d9187270b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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) { diff --git a/crates/ui-egui/src/tools/comments/popups.rs b/crates/ui-egui/src/tools/comments/popups.rs index db34e1098e..fc902708af 100644 --- a/crates/ui-egui/src/tools/comments/popups.rs +++ b/crates/ui-egui/src/tools/comments/popups.rs @@ -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 { - 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)>, Vec) { + let mut nodes: Vec<(&Annotation, Vec)> = 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 = 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, +} + +/// 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)], 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 { let pointer = ctx.input(|i| i.pointer.hover_pos()); let mut edits: Vec = 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)) { diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index bc1564aad9..32ef995f8f 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -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. diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index d6db68403b..e2f6b0412b 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -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 = - 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 = 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 = + 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 { + 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:?}"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3c856087fb..b87804573a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"