Comments: an option to show only the selected text's comment (#1055) (#1066)

* test(engine): drop a needless drop() that clippy 1.97 rejects (drop_non_drop)

* Comments: a click inside a selected balloon moves the caret in its text (#1013)

- The canvas under the markup area also "contains" the pointer, so a press on the balloon counted as a click on the page and left it
- Presses over a balloon card now belong to the card, in both the deselect check and the document click in mouse()
- Test: press, drag and release inside the selected balloon; a click on the page still leaves it

* Comments: an option to show only the selected text's comment (#1055)

- Track Changes Options › Comments: "In the margin" (default, as in Word) or "Only for the selected text"
- The second mode takes comments out of the margin column; the comment under the caret, or the clicked one, floats beside the page, over its edge in a narrow window
- The floating card has its own foreground layer, so presses on it never reach the canvas
- review.trackingOptions takes commentDisplay: margin|selected
- comment_range_at_caret: the comment whose range holds the caret
- Strings in all catalogs; tests for the option, the helper and the card's clicks

* vi: comment display options

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
gossardla
2026-10-14 16:55:27 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 77da439d0b
commit 3e20488c15
26 changed files with 255 additions and 20 deletions
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: clicking in a comment's text moves its caret, #1013; previously trivial: progress log: a press on the ribbon no longer scrolls the document, #1019; previously trivial: title rename and account settings; previously trivial: progress log: Down and Up no longer skip a table, #1007; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: an option to show only the selected text's comment, #1055; previously trivial: progress log: clicking in a comment's text moves its caret, #1013; previously trivial: progress log: a press on the ribbon no longer scrolls the document, #1019; previously trivial: title rename and account settings; previously trivial: progress log: Down and Up no longer skip a table, #1007; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
@@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | What landed |
|---|---|
| 2026-10-12 | Comments can show only for the selected text (#1055): Track Changes Options › Comments › "Only for the selected text" drops the margin column, and the comment under the caret, or the clicked one, floats beside the page (over its edge in a narrow window). The default stays "In the margin", as in Word; `review.trackingOptions {"commentDisplay"}` for agents |
| 2026-10-12 | Clicking inside a selected comment's balloon moves the caret in its text (#1013); the click used to leave the balloon, so the caret could only be moved with the arrow keys. A press on any balloon now belongs to the balloon, not the page behind it |
| 2026-10-12 | Page background pictures and textures (Design › Page Color › Fill Effects) are drawn (tiled or stretched) and kept on save instead of being dropped from the package (#999); PDF export paints them too |
| 2026-10-12 | Holding a click on the ribbon or toolbar no longer scrolls the document up (#1019): a double-click in the header or footer area left a text drag running, so the next press anywhere drove the edge auto-scroll; a drag now ends with the button, wherever it's released |
@@ -336,6 +337,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | trivial | Progress log: an option to show only the selected text's comment (#1055) |
| 2026-10-12 | trivial | Progress log: clicking in a comment's text moves its caret (#1013) |
| 2026-10-12 | trivial | Progress log: a press on the ribbon no longer scrolls the document (#1019) |
| 2026-10-12 | trivial | Progress log: Ctrl+drag copies an object (#1014) |
+41 -5
View File
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("review.trackingOptions", "Track Changes Options", "Review › Tracking", tracking_options)
.params(
r#"{"comments"?: bool, "ink"?: bool, "insertionsDeletions"?: bool, "formatting"?: bool, "balloons"?: "revisions|inline|commentsAndFormatting", "insertMark"?: "underline|doubleUnderline|bold|italic|strikethrough|colorOnly|none", "insertColor"?: "byAuthor" | "RRGGBB", "deleteMark"?: "strikethrough|doubleStrikethrough|hidden|caret|hash|underline|colorOnly", "deleteColor"?: "byAuthor" | "RRGGBB", "changedLines"?: "outside|left|right|none", "changedLinesColor"?: "auto" | "RRGGBB", "trackFormatting"?: bool} → the options (omit all to read them)"#,
r#"{"comments"?: bool, "ink"?: bool, "insertionsDeletions"?: bool, "formatting"?: bool, "balloons"?: "revisions|inline|commentsAndFormatting", "commentDisplay"?: "margin|selected", "insertMark"?: "underline|doubleUnderline|bold|italic|strikethrough|colorOnly|none", "insertColor"?: "byAuthor" | "RRGGBB", "deleteMark"?: "strikethrough|doubleStrikethrough|hidden|caret|hash|underline|colorOnly", "deleteColor"?: "byAuthor" | "RRGGBB", "changedLines"?: "outside|left|right|none", "changedLinesColor"?: "auto" | "RRGGBB", "trackFormatting"?: bool} → the options (omit all to read them)"#,
)
.pure(),
CommandSpec::new("review.wordCount", "Word Count", "Review › Proofing", word_count)
@@ -166,8 +166,8 @@ fn edit_comment(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"id": id}))
}
/// Comment anchored around the caret.
fn comment_at_caret(s: &Session) -> Option<u32> {
/// The innermost comment whose range holds the caret.
pub fn comment_range_at_caret(s: &Session) -> Option<u32> {
let f = &s.sel.focus;
let p = s.doc.para_at(f)?;
let mut open: Vec<u32> = Vec::new();
@@ -181,7 +181,14 @@ fn comment_at_caret(s: &Session) -> Option<u32> {
_ => {}
}
}
open.last().copied().or_else(|| {
open.last().copied()
}
/// Comment anchored around the caret, else the first one in its paragraph.
fn comment_at_caret(s: &Session) -> Option<u32> {
let f = &s.sel.focus;
let p = s.doc.para_at(f)?;
comment_range_at_caret(s).or_else(|| {
p.object_offsets().into_iter().find_map(|o| match p.object_at(o) {
Some(InlineObject::CommentStart { id }) | Some(InlineObject::CommentEnd { id }) => Some(*id),
_ => None,
@@ -909,7 +916,7 @@ fn thesaurus(s: &mut Session, v: &Value) -> CmdResult {
/// Track Changes Options: change the fields given, return them all. A per-user preference.
fn tracking_options(s: &mut Session, v: &Value) -> CmdResult {
use wordcraft_doc::Rgb;
use wordcraft_layout::display::{BalloonMode, ChangeBar, DeleteMark, InsertMark};
use wordcraft_layout::display::{BalloonMode, ChangeBar, CommentDisplay, DeleteMark, InsertMark};
let mut m = s.prefs.markup.clone();
let color = |k: &str, auto: &str, cur: Option<Rgb>| -> Result<Option<Rgb>, CmdError> {
match p::str(v, k) {
@@ -933,6 +940,9 @@ fn tracking_options(s: &mut Session, v: &Value) -> CmdResult {
if let Some(x) = pick("balloons") {
m.balloons = BalloonMode::parse(x).ok_or_else(|| CmdError::Params("`balloons`: revisions|inline|commentsAndFormatting".into()))?;
}
if let Some(x) = pick("commentDisplay") {
m.comment_display = CommentDisplay::parse(x).ok_or_else(|| CmdError::Params("`commentDisplay`: margin|selected".into()))?;
}
if let Some(x) = pick("insertMark") {
m.insert_mark = InsertMark::parse(x).ok_or_else(|| CmdError::Params("unknown `insertMark`".into()))?;
}
@@ -962,6 +972,7 @@ pub fn tracking_json(m: &wordcraft_layout::display::MarkupOptions) -> Value {
"insertionsDeletions": m.insertions_deletions,
"formatting": m.formatting,
"balloons": m.balloons.name(),
"commentDisplay": m.comment_display.name(),
"insertMark": m.insert_mark.name(),
"insertColor": hex(m.insert_color, "byAuthor"),
"deleteMark": m.delete_mark.name(),
@@ -1039,6 +1050,31 @@ mod tests {
assert_eq!(s.run("review.trackingOptions", &json!({})).unwrap()["insertColor"], "00AA00");
}
/// #1055: comments show in the margin as in Word unless the option says otherwise.
#[test]
fn comment_display_defaults_to_margin_and_is_validated() {
let mut s = Session::new(wordcraft_doc::Document::new());
assert_eq!(s.run("review.trackingOptions", &json!({})).unwrap()["commentDisplay"], "margin");
assert_eq!(s.run("review.trackingOptions", &json!({"commentDisplay": "selected"})).unwrap()["commentDisplay"], "selected");
assert_eq!(s.run("review.trackingOptions", &json!({})).unwrap()["commentDisplay"], "selected");
assert!(s.run("review.trackingOptions", &json!({"commentDisplay": "never"})).is_err());
assert_eq!(s.run("review.trackingOptions", &json!({"commentDisplay": "margin"})).unwrap()["commentDisplay"], "margin");
}
/// #1055: the caret's own comment is the one whose range holds it, not any in its paragraph.
#[test]
fn comment_range_at_caret_needs_the_caret_inside_the_range() {
let mut s = Session::new(wordcraft_doc::Document::from_text("one two three"));
s.run("select.text", &json!({"text": "two"})).unwrap();
let id = s.run("review.newComment", &json!({"text": "note"})).unwrap()["id"].as_u64().unwrap() as u32;
s.run("select.text", &json!({"text": "two"})).unwrap();
s.run("caret.left", &json!({})).unwrap();
assert_eq!(super::comment_range_at_caret(&s), Some(id), "inside the commented text");
s.run("select.text", &json!({"text": "three"})).unwrap();
assert_eq!(super::comment_range_at_caret(&s), None, "elsewhere in the paragraph");
assert_eq!(super::comment_at_caret(&s), Some(id), "the older lookup still falls back to the paragraph's comment");
}
fn glyph_text(d: &[Draw]) -> String {
d.iter().filter_map(|x| if let Draw::Glyphs { text, .. } = x { Some(text.as_str()) } else { None }).collect()
}
+13
View File
@@ -259,6 +259,17 @@ named! {
}
}
named! {
/// How comments are shown beside the page.
CommentDisplay {
/// Every comment in the margin, as in Word.
#[default]
Margin = "margin",
/// Only the comment of the text under the caret (or the clicked one) floats beside the page.
Selected = "selected",
}
}
/// Track Changes Options: what markup shows and how revisions are drawn. A per-user preference,
/// as in Word (saved with the interface settings, not the document).
#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
@@ -269,6 +280,7 @@ pub struct MarkupOptions {
pub insertions_deletions: bool,
pub formatting: bool,
pub balloons: BalloonMode,
pub comment_display: CommentDisplay,
pub insert_mark: InsertMark,
/// `None` = by author.
pub insert_color: Option<Rgb>,
@@ -290,6 +302,7 @@ impl Default for MarkupOptions {
insertions_deletions: true,
formatting: true,
balloons: BalloonMode::default(),
comment_display: CommentDisplay::default(),
insert_mark: InsertMark::default(),
insert_color: None,
delete_mark: DeleteMark::default(),
+57 -13
View File
@@ -8,6 +8,7 @@ use std::hash::{Hash, Hasher};
use egui::{Color32, Pos2, Rect, Sense, Stroke, TextureHandle, Ui, pos2, vec2};
use serde_json::json;
use wordcraft_doc::{PartKind, Pos, StoryRef};
use wordcraft_layout::display::CommentDisplay;
use wordcraft_layout::{DocLayout, Page, Placed};
use crate::WordApp;
@@ -317,6 +318,23 @@ fn on_page_gap(rects: &[Rect], bands: &[(f32, f32)], p: Pos2) -> bool {
})
}
/// Width of the card that floats beside a page when comments show only for the selected text (points).
const FLOAT_WIDTH: f32 = 216.0;
/// Comments show as a floating card rather than in a margin column (#1055): the option is on,
/// balloons are shown at all, and no other markup needs the column.
fn floating_comments(app: &WordApp) -> bool {
let s = &app.session;
let m = &s.prefs.markup;
s.balloons_shown() && m.comments && m.comment_display == CommentDisplay::Selected && !s.doc.comments.is_empty() && markup_width(app) <= 0.0
}
/// The comment shown when only the selected one is: the clicked balloon, else the comment whose
/// range holds the caret.
fn shown_comment(app: &WordApp) -> Option<u32> {
app.canvas.balloon.or_else(|| wordcraft_engine::cmd::review::comment_range_at_caret(&app.session))
}
/// Width of the markup area beside each page for comment and revision balloons (points), or 0.
pub fn markup_width(app: &WordApp) -> f32 {
// Word shows comments either in balloons (contextual) or in the Comments pane (list); Track
@@ -326,7 +344,7 @@ pub fn markup_width(app: &WordApp) -> f32 {
return 0.0;
}
let m = &s.prefs.markup;
let comments = m.comments && !s.doc.comments.is_empty();
let comments = m.comments && !s.doc.comments.is_empty() && m.comment_display == CommentDisplay::Margin;
let [has_formats, has_moves, has_deletions] = match app.canvas.markup_facts.get() {
Some((rev, facts)) if rev == (s.document_id(), s.rev()) => facts,
_ => {
@@ -1078,7 +1096,21 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
let near = clip.expand2(vec2(clip.width(), clip.height() * TEXTURE_KEEP_SCREENS));
app.canvas.textures.retain(|k, _| *k < n && geo.rects.get(*k).is_some_and(|r| r.translate(content_origin.to_vec2()).intersects(near)));
app.canvas.page_rects = rects.clone();
balloons(app, ui, &painter, &rects, &layout, geo.scale);
let view_rect = ui.clip_rect();
if floating_comments(app) {
// Its own layer, so presses on the card never reach the canvas under it.
let ctx = ui.ctx().clone();
egui::Area::new(egui::Id::new("floating_comment")).order(egui::Order::Foreground).fixed_pos(Pos2::ZERO).sense(Sense::hover()).show(
&ctx,
|aui| {
aui.set_clip_rect(view_rect);
let ap = aui.painter().clone();
balloons(app, aui, &ap, &rects, &layout, geo.scale, view_rect);
},
);
} else {
balloons(app, ui, &painter, &rects, &layout, geo.scale, view_rect);
}
// Find › Reading Highlight.
let marks: Vec<(Pos, Pos)> = app.session.find_highlights().iter().take(5000).cloned().collect();
for (a, b) in &marks {
@@ -1435,17 +1467,20 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
/// Comment balloons in the markup area right of each page, joined to their anchors, with each
/// comment's replies under it. Clicking a balloon selects it, like Word: its text becomes editable
/// in place, with Reply, Resolve and Delete (the same commands as the Comments pane).
fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Rect], layout: &DocLayout, scale: f32) {
fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Rect], layout: &DocLayout, scale: f32, view: Rect) {
app.canvas.balloon_rects.clear();
let mw = markup_width(app);
if app.canvas.balloon.is_some_and(|id| mw <= 0.0 || !app.session.doc.comments.contains_key(&id)) {
let float = floating_comments(app);
if app.canvas.balloon.is_some_and(|id| (mw <= 0.0 && !float) || !app.session.doc.comments.contains_key(&id)) {
deselect_balloon(app, ui.ctx());
}
if mw <= 0.0 {
if mw <= 0.0 && !float {
return;
}
let t = Tokens::get(ui.ctx());
let clip = ui.clip_rect();
let clip = view;
let only_shown = app.session.prefs.markup.comment_display == CommentDisplay::Selected;
let shown = shown_comment(app);
// Replies hang under the comment they answer rather than getting balloons of their own.
let is_reply = |c: &wordcraft_doc::Comment| c.parent.is_some_and(|p| app.session.doc.comments.contains_key(&p));
let mut replies: HashMap<u32, Vec<u32>> = HashMap::new();
@@ -1462,7 +1497,7 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re
let comments = if m.comments { wordcraft_engine::cmd::review::comment_list(&app.session) } else { Vec::new() };
for (id, pos) in comments {
let Some(pos) = pos else { continue };
if app.session.doc.comments.get(&id).is_some_and(is_reply) {
if app.session.doc.comments.get(&id).is_some_and(is_reply) || (only_shown && shown != Some(id)) {
continue;
}
let Some(c) = layout.caret_on(&pos, app.session.page_hint) else { continue };
@@ -1496,7 +1531,7 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re
for (pi, pr) in rects.iter().enumerate() {
let page_scale = layout.pages.get(pi).map_or(scale, |page| page_screen_scale(*pr, page, scale));
let area = Rect::from_min_max(pos2(pr.max.x, pr.min.y), pos2(pr.max.x + mw * page_scale, pr.max.y));
if area.intersects(clip) {
if mw > 0.0 && area.intersects(clip) {
painter.rect_filled(area, 0.0, Color32::from_rgb(0xF3, 0xF3, 0xF3));
painter.line_segment([area.left_top(), area.left_bottom()], Stroke::new(1.0, Color32::from_rgb(0xE0, 0xE0, 0xE0)));
}
@@ -1511,8 +1546,14 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re
}
list.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.total_cmp(&b.0)));
let page_scale = layout.pages.get(pi).map_or(scale, |page| page_screen_scale(*pr, page, scale));
let x0 = pr.max.x + 10.0;
let w = (mw * page_scale - 20.0).max(60.0);
// Beside the page in the column; floating, beside it when the view has room, else over its edge.
let (x0, w, over_page) = if mw > 0.0 {
(pr.max.x + 10.0, (mw * page_scale - 20.0).max(60.0), false)
} else {
let w = (FLOAT_WIDTH * page_scale - 20.0).max(60.0);
let beside = pr.max.x + 10.0;
if beside + w <= view.max.x - 8.0 { (beside, w, false) } else { ((view.max.x - w - 8.0).max(view.min.x + 8.0), w, true) }
};
let mut next_y = pr.min.y;
for (ax, ay, item, pos) in list.iter() {
let id = match item {
@@ -1557,7 +1598,8 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re
let fs = (11.0 * page_scale / PX_PER_PT).clamp(8.0, 16.0);
let thread: Vec<u32> = replies.get(id).cloned().unwrap_or_default();
let anchor = pos2(pr.min.x + ax * page_scale, pr.min.y + ay * page_scale);
let top = (anchor.y - 12.0).max(next_y);
// Over the page the card goes under the commented line, not on it.
let top = (anchor.y + if over_page { 4.0 } else { -12.0 }).max(next_y);
let active = app.canvas.balloon == Some(*id);
// Lay out a read-only balloon's text: the comment, then each reply under a rule.
let mut lines: Vec<(std::sync::Arc<egui::Galley>, f32)> = Vec::new();
@@ -1584,8 +1626,10 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re
let selected = active || (app.session.sel.focus.path == pos.path && app.session.sel.focus.story == pos.story);
// Leader: from the anchor along the text to the page edge, then to the card.
let lead = Stroke::new(if selected { 1.5 } else { 1.0 }, color.linear_multiply(if selected { 1.0 } else { 0.7 }));
dashed(painter, anchor, pos2(pr.max.x, anchor.y), lead);
painter.line_segment([pos2(pr.max.x, anchor.y), pos2(x0, top + 10.0)], lead);
if !over_page {
dashed(painter, anchor, pos2(pr.max.x, anchor.y), lead);
painter.line_segment([pos2(pr.max.x, anchor.y), pos2(x0, top + 10.0)], lead);
}
let card = if active {
active_balloon(app, ui, painter, *id, &thread, card, fs, color)
} else {
+10
View File
@@ -351,6 +351,7 @@ pub struct TrackForm {
pub insertions_deletions: bool,
pub formatting: bool,
pub balloons: String,
pub comment_display: String,
pub insert_mark: String,
/// Hex, empty = by author.
pub insert_color: String,
@@ -374,6 +375,7 @@ impl TrackForm {
insertions_deletions: b("insertionsDeletions"),
formatting: b("formatting"),
balloons: s("balloons"),
comment_display: s("commentDisplay"),
insert_mark: s("insertMark"),
insert_color: c("insertColor"),
delete_mark: s("deleteMark"),
@@ -393,6 +395,7 @@ impl TrackForm {
"insertionsDeletions": self.insertions_deletions,
"formatting": self.formatting,
"balloons": self.balloons,
"commentDisplay": self.comment_display,
"insertMark": self.insert_mark,
"insertColor": or(&self.insert_color, "byAuthor"),
"deleteMark": self.delete_mark,
@@ -472,6 +475,10 @@ pub fn track_options(app: &mut WordApp, ui: &mut Ui, f: &mut TrackForm) -> bool
] {
ui.radio_value(&mut f.balloons, v.to_string(), tl!(l));
}
heading(ui, "Comments");
for (v, l) in [("margin", "In the margin"), ("selected", "Only for the selected text")] {
ui.radio_value(&mut f.comment_display, v.to_string(), tl!(l));
}
heading(ui, "Advanced");
egui::Grid::new("tco_advanced").num_columns(3).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(tl!("Insertions:"));
@@ -574,6 +581,8 @@ mod tests {
assert_eq!(a.run("review.trackingOptions", json!({})).unwrap(), json!({"pending": "trackChangesOptions"}));
let Some(Dialog::TrackOptions { form }) = &mut a.dialog else { panic!("dialog") };
assert_eq!((form.insert_mark.as_str(), form.balloons.as_str(), form.insert_color.as_str()), ("underline", "commentsAndFormatting", ""));
assert_eq!(form.comment_display, "margin", "comments stay in the margin, as in Word, unless chosen otherwise (#1055)");
form.comment_display = "selected".into();
form.insert_mark = "doubleUnderline".into();
form.changed_lines = "right".into();
form.changed_lines_color = "FF0000".into();
@@ -582,6 +591,7 @@ mod tests {
a.run("review.trackingOptions", params).unwrap();
let m = &a.session.prefs.markup;
assert_eq!(m.insert_mark, wordcraft_layout::display::InsertMark::DoubleUnderline);
assert_eq!(m.comment_display, wordcraft_layout::display::CommentDisplay::Selected);
assert_eq!(m.changed_lines_color, Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
// Saved and restored with the preferences.
let saved = a.prefs();
+2
View File
@@ -87,6 +87,7 @@
Enter formatting for number: Въведете форматиране за номера:
Follow number with: След номера:
Formatting Форматиране
In the margin На полето
Include level number from: Включване на номер на ниво от:
Ink Мастило
Insertions and Deletions Вмъквания и изтривания
@@ -100,6 +101,7 @@
Number alignment: Подравняване на номера:
Number format Формат на номера
Number style for this level: Стил на номериране за това ниво:
Only for the selected text Само за избрания текст
Outside border Външна граница
Restart list after: Рестартиране на списъка след:
Right border Дясна граница
Can't render this file because it contains an unexpected character in line 7 and column 25.
+2
View File
@@ -538,6 +538,7 @@
Immersive Asistivní čtení
Immersive Reader Asistivní čtečka
Immersive\nReader Asistivní\nčtečka
In the margin Na okraji
Include level number from: Zahrnout číslo úrovně z:
Increase Font Size Zvětšit velikost písma
Increase Indent Zvětšit odsazení
@@ -789,6 +790,7 @@
OK OK
One Jeden
One Page Jedna stránka
Only for the selected text Pouze pro vybraný text
Only web (http, https) and email (mailto) links can be followed Otevřít lze pouze webové (http, https) a e-mailové (mailto) odkazy
Open Otevřít
OpenDocument Text (*.odt) Text OpenDocument (*.odt)
Can't render this file because it contains an unexpected character in line 7 and column 25.
+2
View File
@@ -92,6 +92,7 @@
Enter formatting for number: Formatierung für die Nummer eingeben:
Follow number with: Der Nummer folgt:
Formatting Formatierung
In the margin Am Rand
Include level number from: Ebenennummer übernehmen aus:
Ink Freihand
Insertions and Deletions Einfügungen und Löschungen
@@ -110,6 +111,7 @@
Number alignment: Nummernausrichtung:
Number format Nummernformat
Number style for this level: Nummernstil für diese Ebene:
Only for the selected text Nur für den ausgewählten Text
Outside border Äußere Rahmenlinie
Reopen Erneut öffnen
Restart list after: Liste neu starten nach:
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -96,6 +96,7 @@
Ignore web and file addresses Omitir direcciones web y de archivo
Ignore words in capitals Omitir palabras en mayúsculas
Ignore words with numbers Omitir palabras con números
In the margin En el margen
Inches Pulgadas
Formatting exceptions Excepciones de formato
Include level number from: Incluir número de nivel de:
@@ -123,6 +124,7 @@
Number alignment: Alineación del número:
Number format Formato de número
Number style for this level: Estilo de número para este nivel:
Only for the selected text Solo para el texto seleccionado
Outside border Borde exterior
Overtype Sobrescribir
Overtype (typing replaces the characters after the caret) Sobrescribir (lo que escribes reemplaza los caracteres tras el cursor)
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -337,6 +337,7 @@
Immersive Süvenemine
Immersive Reader Süvaluger
Immersive\nReader Süva-\nluger
In the margin Veerisel
Inches Tollid
Increase Font Size Suurenda fondi suurust
Increase Indent Suurenda taanet
@@ -504,6 +505,7 @@
Office 2003 Default Office 2003 vaikevalik
One Üks
One Page Üks leht
Only for the selected text Ainult valitud teksti jaoks
Open Ava
OpenDocument Text (*.odt) OpenDocumenti tekst (*.odt)
Optional Hyphen Valikuline poolitusmärk
Can't render this file because it contains an unexpected character in line 460 and column 13.
+2
View File
@@ -86,6 +86,7 @@
Follow number with: Faire suivre le numéro de :
Formatting Mise en forme
Formatting exceptions Exceptions de mise en forme
In the margin Dans la marge
Include level number from: Inclure le numéro de niveau de :
Ink Encre
Insertions and Deletions Insertions et suppressions
@@ -101,6 +102,7 @@
Number alignment: Alignement des numéros :
Number format Format des numéros
Number style for this level: Style de numéros pour ce niveau :
Only for the selected text Uniquement pour le texte sélectionné
Outside border Bordure extérieure
Overtype Refrappe
Outside: Extérieur :
Can't render this file because it contains an unexpected character in line 7 and column 25.
+2
View File
@@ -328,6 +328,7 @@
Immersive Elmélyült
Immersive Reader Elmélyült olvasás
Immersive\nReader Elmélyült\nolvasás
In the margin A margón
Increase Font Size Betűméret növelése
Increase Indent Behúzás növelése
Indent Behúzás
@@ -482,6 +483,7 @@
OK OK
One Egy
One Page Egy oldal
Only for the selected text Csak a kijelölt szövegnél
Open Megnyitás
OpenDocument Text (*.odt) OpenDocument szöveg (*.odt)
Optional Hyphen Feltételes kötőjel
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -85,6 +85,7 @@
Enter formatting for number: Formato del numero:
Follow number with: Dopo il numero:
Formatting Formattazione
In the margin A margine
Include level number from: Includi numero di livello da:
Ink Penna
Insertions and Deletions Inserimenti ed eliminazioni
@@ -98,6 +99,7 @@
Number alignment: Allineamento numero:
Number format Formato numero
Number style for this level: Stile numero per questo livello:
Only for the selected text Solo per il testo selezionato
Outside border Bordo esterno
Restart list after: Riavvia elenco dopo:
Right border Bordo destro
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -94,6 +94,7 @@
Ignore web and file addresses Web アドレスとファイル パスを無視する
Ignore words in capitals すべて大文字の単語を無視する
Ignore words with numbers 数字を含む単語を無視する
In the margin 余白に表示
Inches インチ
Formatting exceptions 書式の例外
Include level number from: 含めるレベルの番号:
@@ -127,6 +128,7 @@
Number alignment: 番号の配置:
Number format 番号書式
Number style for this level: このレベルの番号の種類:
Only for the selected text 選択したテキストのみ
Outside border 外側の罫線
Make WordCraft the default for Word documents… WordCraft を Word 文書の既定のアプリにする…
Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. 今後、.docx、.doc、.rtf、.odt ファイルと Word テンプレートを WordCraft で開きます。
1 # Japanese (ja) interface catalog. Same format and clean-room rules as zh-hans.tsv.
94
95
96
97
98
99
100
128
129
130
131
132
133
134
+2
View File
@@ -322,6 +322,7 @@
Immersive Czytanie immersyjne
Immersive Reader Czytnik immersyjny
Immersive\nReader Czytnik\nimmersyjny
In the margin Na marginesie
Increase Font Size Zwiększ rozmiar czcionki
Increase Indent Zwiększ wcięcie
Indent Wcięcie
@@ -472,6 +473,7 @@
Office 2003 Default Domyślny pakietu Office 2003
One Jedna
One Page Jedna strona
Only for the selected text Tylko dla zaznaczonego tekstu
Open Otwórz
OpenDocument Text (*.odt) Tekst OpenDocument (*.odt)
Optional Hyphen Łącznik opcjonalny
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -97,6 +97,7 @@
Ignore web and file addresses Ignorar endereços da Web e de arquivos
Ignore words in capitals Ignorar palavras em maiúsculas
Ignore words with numbers Ignorar palavras com números
In the margin Na margem
Inches Polegadas
Formatting exceptions Exceções de formatação
Include level number from: Incluir número do nível:
@@ -124,6 +125,7 @@
Number alignment: Alinhamento do número:
Number format Formato do número
Number style for this level: Estilo de número deste nível:
Only for the selected text Somente para o texto selecionado
Outside border Borda externa
Overtype Sobrescrever
Overtype (typing replaces the characters after the caret) Sobrescrever (o que você digita substitui os caracteres após o cursor)
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -189,6 +189,8 @@
Delete Style Удалить стиль
Delete Table Удалить таблицу
Design Дизайн
In the margin На полях
Only for the selected text Только для выделенного текста
No document is open. Create a document or open one first. Нет открытого документа. Сначала создайте или откройте документ.
Search Поиск
Title Bar Строка заголовка
Can't render this file because it contains an unexpected character in line 7 and column 25.
+2
View File
@@ -121,6 +121,7 @@
Ignore web and file addresses Zanemari veb-adrese i putanje datoteka
Ignore words in capitals Zanemari reči pisane velikim slovima
Ignore words with numbers Zanemari reči sa brojevima
In the margin Na margini
Inches Inči
Formatting exceptions Izuzeci formatiranja
Include level number from: Uključi broj nivoa:
@@ -149,6 +150,7 @@
Number alignment: Poravnanje broja:
Number format Format broja
Number style for this level: Stil broja za ovaj nivo:
Only for the selected text Samo za izabrani tekst
Outside border Spoljna ivica
In Default apps, choose WordCraft for the document types you want it to open. U podrazumevanim aplikacijama izaberite WordCraft za vrste dokumenata koje želite da otvara.
Make WordCraft the default for Word documents… Postavi WordCraft kao podrazumevanu aplikaciju za Word dokumente…
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -125,6 +125,7 @@
Ignore web and file addresses Занемари веб-адресе и путање датотека
Ignore words in capitals Занемари речи писане великим словима
Ignore words with numbers Занемари речи са бројевима
In the margin На маргини
Inches Инчи
Formatting exceptions Изузеци форматирања
Include level number from: Укључи број нивоа:
@@ -153,6 +154,7 @@
Number alignment: Поравнање броја:
Number format Формат броја
Number style for this level: Стил броја за овај ниво:
Only for the selected text Само за изабрани текст
Outside border Спољна ивица
In Default apps, choose WordCraft for the document types you want it to open. У подразумеваним апликацијама изаберите WordCraft за врсте докумената које желите да отвара.
Make WordCraft the default for Word documents… Постави WordCraft као подразумевану апликацију за Word документе…
Can't render this file because it contains an unexpected character in line 11 and column 25.
+2
View File
@@ -326,6 +326,7 @@
Immersive Odaklı
Immersive Reader Odaklı Okuyucu
Immersive\nReader Odaklı\nOkuyucu
In the margin Kenarda
Increase Font Size Yazı Tipi Boyutunu Büyüt
Increase Indent Girintiyi Artır
Indent Girinti
@@ -482,6 +483,7 @@
OK Tamam
One Bir
One Page Bir Sayfa
Only for the selected text Yalnızca seçili metin için
Open Aç
OpenDocument Text (*.odt) OpenDocument Metni (*.odt)
Optional Hyphen İsteğe Bağlı Kısa Çizgi
Can't render this file because it contains an unexpected character in line 441 and column 13.
+2
View File
@@ -99,6 +99,7 @@
Ignore web and file addresses Пропускати веб-адреси та шляхи до файлів
Ignore words in capitals Пропускати слова з великих літер
Ignore words with numbers Пропускати слова з цифрами
In the margin На полях
Inches Дюйми
Formatting exceptions Винятки форматування
Include level number from: Додати номер рівня:
@@ -126,6 +127,7 @@
Number alignment: Вирівнювання номера:
Number format Формат номера
Number style for this level: Стиль номера для цього рівня:
Only for the selected text Лише для виділеного тексту
Outside border Зовнішня межа
Overtype Заміна
Overtype (typing replaces the characters after the caret) Заміна (введений текст замінює символи після курсора)
Can't render this file because it contains an unexpected character in line 6 and column 31.
+4
View File
@@ -2556,3 +2556,7 @@
Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Các tệp Hunspell .aff và .dic trong thư mục này (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) được dùng trước các tệp của hệ thống.
{language}: {file} {language}: {file}
{language}: no dictionary found {language}: không tìm thấy từ điển
# Show only the selected text's comment (#1066)
In the margin Trong lề
Only for the selected text Chỉ cho văn bản được chọn
Can't render this file because it contains an unexpected character in line 7 and column 25.
+2
View File
@@ -106,6 +106,7 @@
Ignore web and file addresses 忽略网址和文件路径
Ignore words in capitals 忽略全部大写的单词
Ignore words with numbers 忽略包含数字的单词
In the margin 在页边
Inches 英寸
Formatting exceptions 格式例外
Include level number from: 包含的级别编号来自:
@@ -139,6 +140,7 @@
Number alignment: 编号对齐方式:
Number format 编号格式
Number style for this level: 此级别的编号样式:
Only for the selected text 仅针对所选文字
Outside border 外侧框线
Make WordCraft the default for Word documents… 将 WordCraft 设为 Word 文档的默认应用…
Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. 今后用 WordCraft 打开 .docx、.doc、.rtf、.odt 文件和 Word 模板。
Can't render this file because it contains an unexpected character in line 7 and column 25.
+2
View File
@@ -95,6 +95,7 @@
Ignore web and file addresses 忽略網址和檔案路徑
Ignore words in capitals 忽略全部大寫的單字
Ignore words with numbers 忽略包含數字的單字
In the margin 在頁邊
Inches 英寸
Formatting exceptions 格式例外
Include level number from: 包含的層級編號來自:
@@ -128,6 +129,7 @@
Number alignment: 編號對齊方式:
Number format 編號格式
Number style for this level: 此層級的編號樣式:
Only for the selected text 僅針對所選文字
Outside border 外側框線
Make WordCraft the default for Word documents… 將 WordCraft 設為 Word 文件的預設應用程式…
Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. 今後以 WordCraft 開啟 .docx、.doc、.rtf、.odt 檔案和 Word 範本。
1 # Traditional Chinese (zh-hant, Taiwan vocabulary) interface catalog. Same format and clean-room
95
96
97
98
99
100
101
129
130
131
132
133
134
135
+89
View File
@@ -3214,6 +3214,95 @@ mod tests {
assert_eq!(a.canvas.balloon, None, "a click on the page leaves the balloon");
}
/// #1055: with "only for the selected text" the margin column is gone and just the comment
/// under the caret floats, beside the page in a wide window and over its edge in a narrow one.
#[test]
fn selected_comment_floats_and_keeps_its_clicks() {
for width in [1700.0_f32, 1000.0] {
let ctx = egui::Context::default();
let mut a = app();
a.run("text.insert", json!({"text": "one two three. ".repeat(60)})).unwrap();
a.run("select.text", json!({"text": "two"})).unwrap();
a.run("review.newComment", json!({"text": "A comment with a few words in it"})).unwrap();
a.session.view.comments_pane = false;
assert!(canvas::markup_width(&a) > 0.0, "the margin is the default, as in Word");
a.run("review.trackingOptions", json!({"commentDisplay": "selected"})).unwrap();
assert_eq!(canvas::markup_width(&a), 0.0, "no margin column any more");
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 700.0));
let mut t = 0.0;
let mut frame = |a: &mut WordApp, events: Vec<egui::Event>| {
t += 1.0 / 60.0;
let input = egui::RawInput { events, time: Some(t), screen_rect: Some(screen), ..Default::default() };
ctx.run_ui(input, |ui| {
a.logic(ui.ctx());
a.ui(ui);
})
.drop_without_applying_deltas();
};
let btn = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
for _ in 0..30 {
frame(&mut a, Vec::new());
}
// The caret is in the text after the first comment: not inside its range.
a.run("caret.docStart", json!({})).unwrap();
for _ in 0..5 {
frame(&mut a, Vec::new());
}
assert!(a.canvas.balloon_rects.is_empty(), "width {width}: no comment under the caret, no card");
// Inside the commented word.
a.run("select.text", json!({"text": "two"})).unwrap();
a.run("caret.left", json!({})).unwrap();
for _ in 0..5 {
frame(&mut a, Vec::new());
}
assert_eq!(a.canvas.balloon_rects.len(), 1, "width {width}: the comment under the caret shows");
let (id, card) = a.canvas.balloon_rects[0];
let page = a.canvas.page_rects.first().copied().unwrap();
assert_eq!(
card.left() < page.right(),
width < 1100.0,
"width {width}: beside the page when the window has room, else over its edge: {card:?} {page:?}"
);
if width < 1100.0 {
let line = a.session.layout().caret_on(&a.session.sel.focus, a.session.page_hint).expect("caret laid out");
let bottom = page.min.y + (line.top + line.height) * canvas::PX_PER_PT;
assert!(card.top() >= bottom, "width {width}: over the page the card sits under the commented line: {card:?} vs {bottom}");
}
// The #1013 flow on the floating card: click, press inside, drag, release.
let (caret, scroll) = (a.session.sel.clone(), a.canvas.scroll_offset);
frame(&mut a, vec![egui::Event::PointerMoved(card.center())]);
frame(&mut a, vec![btn(card.center(), true)]);
frame(&mut a, vec![btn(card.center(), false)]);
for _ in 0..5 {
frame(&mut a, Vec::new());
}
assert_eq!(a.canvas.balloon, Some(id), "width {width}: a click on the card selects it");
assert_eq!(a.session.sel.focus.path, caret.focus.path, "width {width}: the document's caret stays in its paragraph");
let (_, card) = *a.canvas.balloon_rects.iter().find(|(i, _)| *i == id).unwrap();
let (from, to) = (card.left_top() + egui::vec2(30.0, 30.0), card.left_top() + egui::vec2(80.0, 30.0));
let caret = a.session.sel.clone();
frame(&mut a, vec![egui::Event::PointerMoved(from)]);
frame(&mut a, vec![btn(from, true)]);
for _ in 0..3 {
frame(&mut a, vec![egui::Event::PointerMoved(to)]);
}
assert!(!a.canvas.drag_selecting(), "width {width}: the press didn't start a drag in the document");
frame(&mut a, vec![btn(to, false)]);
assert_eq!(a.canvas.balloon, Some(id), "width {width}: still selected");
assert_eq!(a.session.sel, caret, "width {width}: the document's caret didn't move");
assert_eq!(a.canvas.scroll_offset, scroll, "width {width}: nothing scrolled");
// A click on the page text leaves the card, and the comment goes away with the caret.
let body = egui::pos2(page.left() + 120.0, page.top() + 40.0 + 150.0);
frame(&mut a, vec![egui::Event::PointerMoved(body)]);
frame(&mut a, vec![btn(body, true)]);
frame(&mut a, vec![btn(body, false)]);
for _ in 0..5 {
frame(&mut a, Vec::new());
}
assert_eq!(a.canvas.balloon, None, "width {width}: a click on the page leaves the card");
}
}
/// #1019: a double-click in the header area returned before the release ended the drag, so
/// a later press held on the ribbon edge-scrolled the document up.
#[test]
+2 -1
View File
@@ -1,6 +1,6 @@
# UI parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (Ctrl+drag copies a picture, shape or text box, #1014; previously trivial: the mini toolbar scrolls under the ribbon, not over it; previously trivial: the mini toolbar steps aside for the ribbon; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (Track Changes Options › Comments: show only the selected text's comment, beyond Word, #1055; previously trivial: Ctrl+drag copies a picture, shape or text box, #1014; previously trivial: the mini toolbar scrolls under the ribbon, not over it; previously trivial: the mini toolbar steps aside for the ribbon; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch.
Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our
@@ -51,6 +51,7 @@ Document title opens Rename Document; the account badge opens Options › Genera
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | trivial | Track Changes Options › Comments: "In the margin" (default, as in Word) or "Only for the selected text", an option Word doesn't have (#1055). In the second mode the margin column is gone and only the comment under the caret, or the clicked one, floats beside the page (over its edge in a narrow window); comment ranges stay shaded in the text. `review.trackingOptions {"commentDisplay": "margin"\|"selected"}` |
| 2026-10-12 | trivial | On-canvas objects: Ctrl+drag copies a picture, shape or text box (`arrange.copyTo`, #1014) |
| 2026-10-12 | trivial | Cross-reference dialog: reference type (heading, bookmark, equation, figure, table), what to insert and the item; the ribbon button opened nothing before (#916); modal dialog count 55 |
| 2026-10-12 | trivial | Document title opens Rename Document; the account badge opens Options › General for user name and initials. |